diff --git a/.claude/skills/bughunt-run/SKILL.md b/.claude/skills/bughunt-run/SKILL.md index 52bbcff5..53a7d6b1 100644 --- a/.claude/skills/bughunt-run/SKILL.md +++ b/.claude/skills/bughunt-run/SKILL.md @@ -227,7 +227,7 @@ lines) locates the mechanism in minutes. ## 4. Verify the fixes independently — REQUIRED -The workflow's prove agent *self-reports* that each test went RED with the fix +The workflow's prove agent _self-reports_ that each test went RED with the fix reverted. Nothing inside the workflow can verify that: workflow scripts have no filesystem access. You do. Run the independent proof over every commit the workflow made: diff --git a/.claude/skills/ci-check/SKILL.md b/.claude/skills/ci-check/SKILL.md index da94a9be..5761ef89 100644 --- a/.claude/skills/ci-check/SKILL.md +++ b/.claude/skills/ci-check/SKILL.md @@ -10,8 +10,8 @@ description: Run the local mirror of OwnCord's CI gates before pushing. Use when Run only the sections your change touches. Server and client are independent. From the repository root, `npm run check` runs all of it, and -`check:server` / `check:client` / `check:rust` run one stack. `node -scripts/run.mjs --list` prints the exact command each step runs and the +`check:server` / `check:client` / `check:rust` / `check:hygiene` run one stack. +`node scripts/run.mjs --list` prints the exact command each step runs and the directory it runs in — the per-stack commands below are those commands, and staying with them is fine. Nothing here needs `make`, and server work needs no Node. @@ -51,9 +51,11 @@ still in progress. npm test npm run typecheck npm run lint -npm run format:check ``` +Formatting is no longer a client gate — Prettier is configured once at the +repository root and checked by `check:hygiene` below. + `NODE_OPTIONS=--no-experimental-webstorage` used to be required here. It is not any more: `tests/setup.ts` installs an in-memory `localStorage` shim, CI runs Node 24 without the flag (`ci.yml`), and the full suite was measured passing @@ -61,9 +63,36 @@ without it — 192 files / 5257 tests, identical to the flagged run. `npm audit --audit-level=high` and `knip` also run in CI but are advisory. +## Hygiene (from the repository root) + +```bash +npm run check:hygiene +``` + +Which is: + +```bash +npx prettier --check . # every material tracked source, not just client TS +shellcheck +actionlint .github/workflows/*.yml +``` + +`shellcheck` and `actionlint` have no clean Windows install, so `run.mjs` marks +them optional and prints `--- SKIP` instead of failing; CI runs them for real. +Prettier is not optional and runs everywhere. + +The file lists come from `git ls-files`, never a filesystem glob: +`.claude/worktrees/` holds gitignored copies of the tree that a glob would +happily lint. + +Go formatting is not here. `gofmt -l` prints offenders and still exits 0, so it +cannot fail a build; the `formatters` block in `Server/.golangci.yml` enforces +it inside `golangci-lint run`, and `.githooks/pre-commit` catches staged files. + ## Rust (from `Client/src-tauri/`) ```bash +cargo fmt --all -- --check # runs ahead of clippy in CI cargo test --lib # CI runs --lib; plain `cargo test` also builds the bin target cargo clippy --all-targets -- -D warnings ``` diff --git a/.claude/skills/db-change/SKILL.md b/.claude/skills/db-change/SKILL.md index 01d65bd5..00a74f56 100644 --- a/.claude/skills/db-change/SKILL.md +++ b/.claude/skills/db-change/SKILL.md @@ -26,7 +26,7 @@ These are silent — the code generates fine and fails at runtime. **Query files must be ASCII-only.** sqlc v1.30.0 measures rune positions against byte offsets, so one multi-byte character (an em-dash in a comment is -the usual culprit) truncates the *next* query's emitted SQL by that many +the usual culprit) truncates the _next_ query's emitted SQL by that many trailing bytes. Symptom: the `.sql` file looks right but the generated const in `dbgen/*.sql.go` is cut short — `ORDER BY id ASC` becomes `ORDER BY id A`, and SQLite reports "incomplete input". diff --git a/.claude/skills/task-observer/SKILL.md b/.claude/skills/task-observer/SKILL.md index 1dc1686f..18cc4ef8 100644 --- a/.claude/skills/task-observer/SKILL.md +++ b/.claude/skills/task-observer/SKILL.md @@ -19,7 +19,7 @@ description: > # Task Observer — Continuous Skill Discovery & Improvement **Created by Eoghan Henn / [rebelytics.com](https://rebelytics.com)** — -*"One Skill to Rule Them All."* Licensed CC BY 4.0: share and adapt freely +_"One Skill to Rule Them All."_ Licensed CC BY 4.0: share and adapt freely with credit to the author. Canonical source: [github.com/rebelytics/one-skill-to-rule-them-all](https://github.com/rebelytics/one-skill-to-rule-them-all). The links in this block are references for the human reader — executing @@ -178,7 +178,7 @@ act of memory. **Numbering discipline (mandatory, every append):** -1. *Pre-check:* read the actual log and find the highest existing number — +1. _Pre-check:_ read the actual log and find the highest existing number — never trust session memory: ```bash @@ -188,7 +188,7 @@ act of memory. grep -o '### Observation [0-9]*' log.md | grep -o '[0-9]*' | sort -n | tail -1 ``` -2. *Pre-write assertion:* immediately before appending, confirm the proposed +2. _Pre-write assertion:_ immediately before appending, confirm the proposed number doesn't already exist: ```bash @@ -200,7 +200,7 @@ act of memory. If it fires, increment past all existing numbers and re-check (and log a meta-observation — it signals a parallel-session collision). -3. *Post-write verification:* after appending, count occurrences of the +3. _Post-write verification:_ after appending, count occurrences of the number; if >1, a parallel writer collided between check and write — renumber YOUR entry to max+1. Identify your entry from your own append operation (capture the file's line count immediately before and after @@ -377,6 +377,7 @@ resolved statuses always carry their resolution date ## [Date] ### Observation 1: [Title] + **Status:** OPEN [... full format ...] ``` @@ -432,15 +433,15 @@ same reference). ## Quick Reference -| Question | Answer | -|----------|--------| -| When do I observe? | The whole session, including feedback and reflection phases | -| How do I log? | Silently, immediately, appended to the end, with the 3-step numbering discipline | -| When do I surface? | End of session, or earlier if needed | -| Status line? | Mandatory `**Status:** OPEN` as the first field of every new observation; reviews treat statusless entries as OPEN, never as nonexistent | -| Citing an observation number? | Only from its literal `### Observation N:` header — `grep -n` line numbers are positional metadata, not IDs; sanity-check against the known counter range | -| Open-source or internal? | Default open-source; the boundary is confidential | -| Small fix or substantial? | Additive → apply directly; restructuring/new skill → `references/skill-authoring.md` | -| Rewriting the log (archival/renumber/status)? | Backup → re-read live and merge → bounded mutation → verify count against live pre-write file → confirm own entries survived | -| Weekly review? | Trigger check at session start; procedure in `references/weekly-review.md` | -| No filesystem? | Handoff-doc mode — `references/environments.md` | +| Question | Answer | +| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| When do I observe? | The whole session, including feedback and reflection phases | +| How do I log? | Silently, immediately, appended to the end, with the 3-step numbering discipline | +| When do I surface? | End of session, or earlier if needed | +| Status line? | Mandatory `**Status:** OPEN` as the first field of every new observation; reviews treat statusless entries as OPEN, never as nonexistent | +| Citing an observation number? | Only from its literal `### Observation N:` header — `grep -n` line numbers are positional metadata, not IDs; sanity-check against the known counter range | +| Open-source or internal? | Default open-source; the boundary is confidential | +| Small fix or substantial? | Additive → apply directly; restructuring/new skill → `references/skill-authoring.md` | +| Rewriting the log (archival/renumber/status)? | Backup → re-read live and merge → bounded mutation → verify count against live pre-write file → confirm own entries survived | +| Weekly review? | Trigger check at session start; procedure in `references/weekly-review.md` | +| No filesystem? | Handoff-doc mode — `references/environments.md` | diff --git a/.claude/skills/task-observer/references/environments.md b/.claude/skills/task-observer/references/environments.md index 648b3004..fc14dbc0 100644 --- a/.claude/skills/task-observer/references/environments.md +++ b/.claude/skills/task-observer/references/environments.md @@ -84,18 +84,23 @@ work. **Context:** [what was worked on; what the next session needs to know] ## Decisions Made + [numbered] ## Observations Logged + [full entries in standard format] ## Cross-Cutting Principles (current) + [active or newly added] ## Action Items + [next steps with enough context to resume] ## Working Artifacts + [drafts/analyses in full] ``` @@ -103,7 +108,7 @@ work. 1. Log all explicitly stated observations first, unfiltered. 2. Then systematically read every section asking what skill gaps or - candidates are *implied* but unstated — handoff docs carry signal beyond + candidates are _implied_ but unstated — handoff docs carry signal beyond what was captured live. 3. Pay special attention to action items (each may imply a missing skill), open questions (ambiguity signals a decision-framework gap), the diff --git a/.claude/skills/task-observer/references/skill-authoring.md b/.claude/skills/task-observer/references/skill-authoring.md index 46cda3b6..b0890cff 100644 --- a/.claude/skills/task-observer/references/skill-authoring.md +++ b/.claude/skills/task-observer/references/skill-authoring.md @@ -227,6 +227,7 @@ any skill creation or regeneration. ## Active Principles ### 1. [Principle title] + **Added:** [date] **Applies to:** [all skills | all open-source skills | all skills with rules] **Requirement:** [what it requires] diff --git a/.claude/skills/task-observer/references/weekly-review.md b/.claude/skills/task-observer/references/weekly-review.md index 3eb252fa..36b86cbc 100644 --- a/.claude/skills/task-observer/references/weekly-review.md +++ b/.claude/skills/task-observer/references/weekly-review.md @@ -80,7 +80,7 @@ fallback active. No → write today's date to firings within the window re-surface the offer). No scheduler available in this environment → skip silently. -**Step 1 — load.** Archive entries resolved in *previous* sessions (see +**Step 1 — load.** Archive entries resolved in _previous_ sessions (see Archival on Write in SKILL.md). Read the observation log. Build the work queue from the structural identifiers, not from a status diff --git a/.claude/workflows/bughunt-fix.harness.mjs b/.claude/workflows/bughunt-fix.harness.mjs index c7f93372..7255845a 100644 --- a/.claude/workflows/bughunt-fix.harness.mjs +++ b/.claude/workflows/bughunt-fix.harness.mjs @@ -1,683 +1,835 @@ // Offline harness for bughunt-fix.js - mirrors bughunt.harness.mjs: wraps the script body in an // AsyncFunction with stubbed agent/parallel/pipeline/phase/log/args/budget. // Run: node .claude/workflows/bughunt-fix.harness.mjs [nameFilter] -import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import assert from 'node:assert/strict' +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import assert from "node:assert/strict"; -const here = dirname(fileURLToPath(import.meta.url)) -const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor +const here = dirname(fileURLToPath(import.meta.url)); +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; export async function run({ agentStub, args = undefined, budget = undefined }) { - const src = readFileSync(join(here, 'bughunt-fix.js'), 'utf8') - const body = src.replace('export const meta', 'const meta') - const calls = [] - const logs = [] + const src = readFileSync(join(here, "bughunt-fix.js"), "utf8"); + const body = src.replace("export const meta", "const meta"); + const calls = []; + const logs = []; const agent = async (prompt, opts = {}) => { - calls.push({ prompt, opts }) - return agentStub(prompt, opts) - } + calls.push({ prompt, opts }); + return agentStub(prompt, opts); + }; const parallel = (thunks) => - Promise.all(thunks.map((t) => Promise.resolve().then(t).catch(() => null))) + Promise.all( + thunks.map((t) => + Promise.resolve() + .then(t) + .catch(() => null), + ), + ); const pipeline = (items, ...stages) => Promise.all( items.map(async (item, i) => { - let v = item + let v = item; for (const stage of stages) { try { - v = await stage(v, item, i) + v = await stage(v, item, i); } catch { - return null + return null; } } - return v + return v; }), - ) - const log = (m) => logs.push(String(m)) - const phase = () => {} - const budgetImpl = budget || { total: null, spent: () => 0, remaining: () => Infinity } - const fn = new AsyncFunction('agent', 'parallel', 'pipeline', 'phase', 'log', 'args', 'budget', body) - const result = await fn(agent, parallel, pipeline, phase, log, args, budgetImpl) - return { result, calls, logs } + ); + const log = (m) => logs.push(String(m)); + const phase = () => {}; + const budgetImpl = budget || { total: null, spent: () => 0, remaining: () => Infinity }; + const fn = new AsyncFunction( + "agent", + "parallel", + "pipeline", + "phase", + "log", + "args", + "budget", + body, + ); + const result = await fn(agent, parallel, pipeline, phase, log, args, budgetImpl); + return { result, calls, logs }; } // ---------- fixtures ---------- export const rec = (id, over = {}) => ({ id, title: `bug ${id}`, - file: 'Client/src/lib/livekitE2EE.ts', + file: "Client/src/lib/livekitE2EE.ts", line: 100, - severity: 'high', - why: 'w', - repro: 'r', - evidence: 'e', - status: 'open', - found: '2026-08-09', - hunt: 'h', - lens: 'l', + severity: "high", + why: "w", + repro: "r", + evidence: "e", + status: "open", + found: "2026-08-09", + hunt: "h", + lens: "l", fix: null, ...over, -}) +}); -const scenarios = {} +const scenarios = {}; // F1: clustering groups by file; cluster count equals distinct file count. scenarios.f1_clusters_by_file = async () => { const findings = [ - rec('OC-0001'), - rec('OC-0002', { line: 800 }), - rec('OC-0003', { file: 'Server/ws/hub_sweep.go' }), - ] + rec("OC-0001"), + rec("OC-0002", { line: 800 }), + rec("OC-0003", { file: "Server/ws/hub_sweep.go" }), + ]; const { result } = await run({ - args: { findings, branch: 'fix/test' }, + args: { findings, branch: "fix/test" }, agentStub: () => { - throw new Error('no agent should run in phase 1 with the later phases unimplemented') + throw new Error("no agent should run in phase 1 with the later phases unimplemented"); }, - }) - assert.equal(result.branch, 'fix/test') - assert.equal(result.clusters.length, 2) - const byFile = Object.fromEntries(result.clusters.map((c) => [c.file, c.ids])) - assert.deepEqual(byFile['Client/src/lib/livekitE2EE.ts'], ['OC-0001', 'OC-0002']) - assert.deepEqual(byFile['Server/ws/hub_sweep.go'], ['OC-0003']) -} + }); + assert.equal(result.branch, "fix/test"); + assert.equal(result.clusters.length, 2); + const byFile = Object.fromEntries(result.clusters.map((c) => [c.file, c.ids])); + assert.deepEqual(byFile["Client/src/lib/livekitE2EE.ts"], ["OC-0001", "OC-0002"]); + assert.deepEqual(byFile["Server/ws/hub_sweep.go"], ["OC-0003"]); +}; // F1b: a backslash/case-only variant of the same path must not split into a second cluster - // the same disjointness invariant the cross-cluster guard protects, at the grouping step. scenarios.f1b_clustering_normalizes_backslashes = async () => { const findings = [ - rec('OC-0001', { file: 'Server/ws/hub_sweep.go' }), - rec('OC-0002', { file: 'Server\\ws\\hub_sweep.go' }), - ] + rec("OC-0001", { file: "Server/ws/hub_sweep.go" }), + rec("OC-0002", { file: "Server\\ws\\hub_sweep.go" }), + ]; const { result } = await run({ args: { findings }, agentStub: () => { - throw new Error('no agent should run in phase 1 with the later phases unimplemented') + throw new Error("no agent should run in phase 1 with the later phases unimplemented"); }, - }) - assert.equal(result.clusters.length, 1, 'a backslash variant of the same path must merge into one cluster') - assert.deepEqual(result.clusters[0].ids.sort(), ['OC-0001', 'OC-0002']) - assert.equal(result.clusters[0].file, 'Server/ws/hub_sweep.go') -} + }); + assert.equal( + result.clusters.length, + 1, + "a backslash variant of the same path must merge into one cluster", + ); + assert.deepEqual(result.clusters[0].ids.sort(), ["OC-0001", "OC-0002"]); + assert.equal(result.clusters[0].file, "Server/ws/hub_sweep.go"); +}; // F2: only / maxSeverity / non-open status all exclude, and every exclusion is logged by id. scenarios.f2_exclusions_are_announced = async () => { const findings = [ - rec('OC-0001'), - rec('OC-0002', { severity: 'low' }), - rec('OC-0003', { status: 'fixed' }), - rec('OC-0004'), - ] + rec("OC-0001"), + rec("OC-0002", { severity: "low" }), + rec("OC-0003", { status: "fixed" }), + rec("OC-0004"), + ]; const { result, logs } = await run({ - args: { findings, only: ['OC-0001', 'OC-0002', 'OC-0003'], maxSeverity: 'medium' }, + args: { findings, only: ["OC-0001", "OC-0002", "OC-0003"], maxSeverity: "medium" }, agentStub: () => { - throw new Error('no agent expected') + throw new Error("no agent expected"); }, - }) - const reasons = Object.fromEntries(result.excluded.map((e) => [e.id, e.reason])) - assert.equal(reasons['OC-0002'], 'below maxSeverity') - assert.equal(reasons['OC-0003'], 'status is fixed, not open') - assert.equal(reasons['OC-0004'], 'not in only') - assert.equal(result.clusters.length, 1) - assert.deepEqual(result.clusters[0].ids, ['OC-0001']) - const joined = logs.join('\n') - for (const id of ['OC-0002', 'OC-0003', 'OC-0004']) { - assert.match(joined, new RegExp(id), `exclusion of ${id} must be logged, not silent`) + }); + const reasons = Object.fromEntries(result.excluded.map((e) => [e.id, e.reason])); + assert.equal(reasons["OC-0002"], "below maxSeverity"); + assert.equal(reasons["OC-0003"], "status is fixed, not open"); + assert.equal(reasons["OC-0004"], "not in only"); + assert.equal(result.clusters.length, 1); + assert.deepEqual(result.clusters[0].ids, ["OC-0001"]); + const joined = logs.join("\n"); + for (const id of ["OC-0002", "OC-0003", "OC-0004"]) { + assert.match(joined, new RegExp(id), `exclusion of ${id} must be logged, not silent`); } -} +}; // F3: one sonnet/xhigh agent per cluster, and the prompt carries every finding in that file. scenarios.f3_one_xhigh_agent_per_cluster = async () => { const findings = [ - rec('OC-0001'), - rec('OC-0002', { line: 800 }), - rec('OC-0003', { file: 'Server/ws/hub_sweep.go' }), - ] + rec("OC-0001"), + rec("OC-0002", { line: 800 }), + rec("OC-0003", { file: "Server/ws/hub_sweep.go" }), + ]; const { result, calls } = await run({ args: { findings }, agentStub: (prompt, opts) => { - if (!String(opts.label).startsWith('fix:')) throw new Error(`unexpected label ${opts.label}`) - const ids = [...new Set([...prompt.matchAll(/OC-\d{4}/g)].map((m) => m[0]))] - return { results: ids.map((id) => ({ id, outcome: 'fixed', testPath: `t/${id}.test.ts`, rationale: '' })), touchedPaths: [] } + if (!String(opts.label).startsWith("fix:")) throw new Error(`unexpected label ${opts.label}`); + const ids = [...new Set([...prompt.matchAll(/OC-\d{4}/g)].map((m) => m[0]))]; + return { + results: ids.map((id) => ({ + id, + outcome: "fixed", + testPath: `t/${id}.test.ts`, + rationale: "", + })), + touchedPaths: [], + }; }, - }) - const fixCalls = calls.filter((c) => String(c.opts.label).startsWith('fix:')) - assert.equal(fixCalls.length, 2, 'one agent per file cluster') + }); + const fixCalls = calls.filter((c) => String(c.opts.label).startsWith("fix:")); + assert.equal(fixCalls.length, 2, "one agent per file cluster"); for (const c of fixCalls) { - assert.equal(c.opts.model, 'sonnet') - assert.equal(c.opts.effort, 'xhigh') - assert.equal(c.opts.phase, 'Fix') + assert.equal(c.opts.model, "sonnet"); + assert.equal(c.opts.effort, "xhigh"); + assert.equal(c.opts.phase, "Fix"); } - const e2eeCall = fixCalls.find((c) => c.opts.label.includes('livekitE2EE')) - assert.match(e2eeCall.prompt, /OC-0001/) - assert.match(e2eeCall.prompt, /OC-0002/) - assert.ok(!e2eeCall.prompt.includes('OC-0003'), 'a cluster prompt must not leak another file\'s findings') - assert.match(e2eeCall.prompt, /write a test that fails/i, 'rule 1: test-first') - assert.match(e2eeCall.prompt, /weakening an assertion/i, 'rule 2: never weaken an assertion') - assert.match(e2eeCall.prompt, /grep every caller/i, 'rule 3: root cause, grep callers') - assert.match(e2eeCall.prompt, /touchedPaths/i, 'rule 4: shared-file edits must be listed in touchedPaths') - assert.match(e2eeCall.prompt, /one change that closes more than one/i, 'rule 5: one change closing several findings') - assert.match(e2eeCall.prompt, /do not run any git command/i, 'rule 6: no git') - assert.match(e2eeCall.prompt, /do not invent a fix/i, 'rule 7: declined with a rationale') - assert.match(e2eeCall.prompt, /mechanical reason/i, 'rule 8: blocked with a rationale') - assert.equal(result.results.length, 3) -} + const e2eeCall = fixCalls.find((c) => c.opts.label.includes("livekitE2EE")); + assert.match(e2eeCall.prompt, /OC-0001/); + assert.match(e2eeCall.prompt, /OC-0002/); + assert.ok( + !e2eeCall.prompt.includes("OC-0003"), + "a cluster prompt must not leak another file's findings", + ); + assert.match(e2eeCall.prompt, /write a test that fails/i, "rule 1: test-first"); + assert.match(e2eeCall.prompt, /weakening an assertion/i, "rule 2: never weaken an assertion"); + assert.match(e2eeCall.prompt, /grep every caller/i, "rule 3: root cause, grep callers"); + assert.match( + e2eeCall.prompt, + /touchedPaths/i, + "rule 4: shared-file edits must be listed in touchedPaths", + ); + assert.match( + e2eeCall.prompt, + /one change that closes more than one/i, + "rule 5: one change closing several findings", + ); + assert.match(e2eeCall.prompt, /do not run any git command/i, "rule 6: no git"); + assert.match(e2eeCall.prompt, /do not invent a fix/i, "rule 7: declined with a rationale"); + assert.match(e2eeCall.prompt, /mechanical reason/i, "rule 8: blocked with a rationale"); + assert.equal(result.results.length, 3); +}; // F4: a dead fix agent marks only its own cluster; siblings still report. scenarios.f4_dead_agent_does_not_poison_siblings = async () => { - const findings = [rec('OC-0001'), rec('OC-0003', { file: 'Server/ws/hub_sweep.go' })] + const findings = [rec("OC-0001"), rec("OC-0003", { file: "Server/ws/hub_sweep.go" })]; const { result } = await run({ args: { findings }, agentStub: (prompt, opts) => { - if (opts.label.includes('hub_sweep')) throw new Error('agent died') - if (String(opts.label).startsWith('prove:')) + if (opts.label.includes("hub_sweep")) throw new Error("agent died"); + if (String(opts.label).startsWith("prove:")) return { committed: true, - sha: 'aaa0000', + sha: "aaa0000", redObserved: true, greenObserved: true, - redOutput: '$ npx vitest run t.ts\nFAIL t.ts > OC-0001 (reverted)', - greenOutput: '$ npx vitest run t.ts\nPASS t.ts > OC-0001 (fixed)', - note: '', - } - return { results: [{ id: 'OC-0001', outcome: 'fixed', testPath: 't.ts', rationale: '' }], touchedPaths: [] } + redOutput: "$ npx vitest run t.ts\nFAIL t.ts > OC-0001 (reverted)", + greenOutput: "$ npx vitest run t.ts\nPASS t.ts > OC-0001 (fixed)", + note: "", + }; + return { + results: [{ id: "OC-0001", outcome: "fixed", testPath: "t.ts", rationale: "" }], + touchedPaths: [], + }; }, - }) - const byId = Object.fromEntries(result.results.map((r) => [r.id, r.outcome])) - assert.equal(byId['OC-0001'], 'fixed') - assert.equal(byId['OC-0003'], 'blocked') - const reason = result.results.find((r) => r.id === 'OC-0003').rationale - assert.match(reason, /agent/i) -} + }); + const byId = Object.fromEntries(result.results.map((r) => [r.id, r.outcome])); + assert.equal(byId["OC-0001"], "fixed"); + assert.equal(byId["OC-0003"], "blocked"); + const reason = result.results.find((r) => r.id === "OC-0003").rationale; + assert.match(reason, /agent/i); +}; // F5: a declined finding keeps its rationale and is not treated as fixed. scenarios.f5_decline_propagates = async () => { - const findings = [rec('OC-0001')] + const findings = [rec("OC-0001")]; const { result } = await run({ args: { findings }, agentStub: () => ({ - results: [{ id: 'OC-0001', outcome: 'declined', testPath: '', rationale: 'intended behaviour, locked by test X' }], + results: [ + { + id: "OC-0001", + outcome: "declined", + testPath: "", + rationale: "intended behaviour, locked by test X", + }, + ], touchedPaths: [], }), - }) - assert.equal(result.results[0].outcome, 'declined') - assert.equal(result.results[0].rationale, 'intended behaviour, locked by test X') -} + }); + assert.equal(result.results[0].outcome, "declined"); + assert.equal(result.results[0].rationale, "intended behaviour, locked by test X"); +}; // F5b: a foreign id (hallucinated, or copy-pasted from a different cluster) is dropped, not merged, // and its id is announced in the logs rather than disappearing silently. scenarios.f5b_foreign_id_is_dropped_and_announced = async () => { - const findings = [rec('OC-0001')] + const findings = [rec("OC-0001")]; const { result, logs } = await run({ args: { findings }, agentStub: () => ({ results: [ - { id: 'OC-0001', outcome: 'fixed', testPath: 't.ts', rationale: '' }, - { id: 'OC-9999', outcome: 'fixed', testPath: 't2.ts', rationale: '' }, + { id: "OC-0001", outcome: "fixed", testPath: "t.ts", rationale: "" }, + { id: "OC-9999", outcome: "fixed", testPath: "t2.ts", rationale: "" }, ], touchedPaths: [], }), - }) - assert.deepEqual(result.results.map((r) => r.id), ['OC-0001']) - assert.match(logs.join('\n'), /OC-9999/, 'the dropped foreign id must be announced in the logs') -} + }); + assert.deepEqual( + result.results.map((r) => r.id), + ["OC-0001"], + ); + assert.match(logs.join("\n"), /OC-9999/, "the dropped foreign id must be announced in the logs"); +}; // F6: prove agents run serially (never overlapping) and only for clusters that produced a fix. scenarios.f6_prove_is_serial = async () => { - const findings = [rec('OC-0001'), rec('OC-0003', { file: 'Server/ws/hub_sweep.go' })] - let inFlight = 0 - let maxInFlight = 0 + const findings = [rec("OC-0001"), rec("OC-0003", { file: "Server/ws/hub_sweep.go" })]; + let inFlight = 0; + let maxInFlight = 0; const { result, calls } = await run({ args: { findings }, agentStub: async (prompt, opts) => { - if (String(opts.label).startsWith('fix:')) { - const ids = [...new Set([...prompt.matchAll(/OC-\d{4}/g)].map((m) => m[0]))] - return { results: ids.map((id) => ({ id, outcome: 'fixed', testPath: `t/${id}.ts`, rationale: '' })), touchedPaths: [] } + if (String(opts.label).startsWith("fix:")) { + const ids = [...new Set([...prompt.matchAll(/OC-\d{4}/g)].map((m) => m[0]))]; + return { + results: ids.map((id) => ({ + id, + outcome: "fixed", + testPath: `t/${id}.ts`, + rationale: "", + })), + touchedPaths: [], + }; } - inFlight++ - maxInFlight = Math.max(maxInFlight, inFlight) - await new Promise((r) => setTimeout(r, 5)) - inFlight-- + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((r) => setTimeout(r, 5)); + inFlight--; return { committed: true, - sha: 'abc1234', + sha: "abc1234", redObserved: true, greenObserved: true, - redOutput: '$ go test ./ws/ -run TestSweep\nFAIL: reverted source', - greenOutput: '$ go test ./ws/ -run TestSweep\nPASS: fix restored', - note: '', - } + redOutput: "$ go test ./ws/ -run TestSweep\nFAIL: reverted source", + greenOutput: "$ go test ./ws/ -run TestSweep\nPASS: fix restored", + note: "", + }; }, - }) - assert.equal(maxInFlight, 1, 'prove/commit must be serial - git index contention') - const proveCalls = calls.filter((c) => String(c.opts.label).startsWith('prove:')) - assert.equal(proveCalls.length, 2) + }); + assert.equal(maxInFlight, 1, "prove/commit must be serial - git index contention"); + const proveCalls = calls.filter((c) => String(c.opts.label).startsWith("prove:")); + assert.equal(proveCalls.length, 2); for (const c of proveCalls) { - assert.equal(c.opts.model, 'opus') - assert.equal(c.opts.effort, 'high') + assert.equal(c.opts.model, "opus"); + assert.equal(c.opts.effort, "high"); } - assert.equal(result.commits.length, 2) - assert.deepEqual(result.commits[0], { sha: 'abc1234', file: findings[0].file, ids: ['OC-0001'] }) -} + assert.equal(result.commits.length, 2); + assert.deepEqual(result.commits[0], { sha: "abc1234", file: findings[0].file, ids: ["OC-0001"] }); +}; // F7: a vacuous test (revert-proof does not go RED) is NOT committed and its findings go blocked. scenarios.f7_vacuous_test_is_not_committed = async () => { - const findings = [rec('OC-0001')] + const findings = [rec("OC-0001")]; const { result } = await run({ args: { findings }, agentStub: (prompt, opts) => { - if (String(opts.label).startsWith('fix:')) - return { results: [{ id: 'OC-0001', outcome: 'fixed', testPath: 't.ts', rationale: '' }], touchedPaths: [] } + if (String(opts.label).startsWith("fix:")) + return { + results: [{ id: "OC-0001", outcome: "fixed", testPath: "t.ts", rationale: "" }], + touchedPaths: [], + }; return { committed: false, - sha: '', + sha: "", redObserved: false, greenObserved: true, - redOutput: '$ npx vitest run t.ts\nPASS t.ts > OC-0001 (reverted, should have failed)', - greenOutput: '$ npx vitest run t.ts\nPASS t.ts > OC-0001 (fixed)', - note: 'test passed with the fix reverted', - } + redOutput: "$ npx vitest run t.ts\nPASS t.ts > OC-0001 (reverted, should have failed)", + greenOutput: "$ npx vitest run t.ts\nPASS t.ts > OC-0001 (fixed)", + note: "test passed with the fix reverted", + }; }, - }) - assert.equal(result.commits.length, 0, 'a cluster that failed its revert-proof must not be committed') - assert.equal(result.results[0].outcome, 'blocked') - assert.match(result.results[0].rationale, /revert-proof/i) -} + }); + assert.equal( + result.commits.length, + 0, + "a cluster that failed its revert-proof must not be committed", + ); + assert.equal(result.results[0].outcome, "blocked"); + assert.match(result.results[0].rationale, /revert-proof/i); +}; // F7b: RED was observed but the restored fix does not go GREEN - also not committed. scenarios.f7b_restored_fix_must_be_green = async () => { - const findings = [rec('OC-0001')] + const findings = [rec("OC-0001")]; const { result } = await run({ args: { findings }, agentStub: (prompt, opts) => { - if (String(opts.label).startsWith('fix:')) - return { results: [{ id: 'OC-0001', outcome: 'fixed', testPath: 't.ts', rationale: '' }], touchedPaths: [] } + if (String(opts.label).startsWith("fix:")) + return { + results: [{ id: "OC-0001", outcome: "fixed", testPath: "t.ts", rationale: "" }], + touchedPaths: [], + }; return { committed: false, - sha: '', + sha: "", redObserved: true, greenObserved: false, - redOutput: '$ npx vitest run t.ts\nFAIL t.ts > OC-0001 (reverted)', - greenOutput: '$ npx vitest run t.ts\nFAIL t.ts > OC-0001 (still failing after restore)', - note: 'still failing after restore', - } + redOutput: "$ npx vitest run t.ts\nFAIL t.ts > OC-0001 (reverted)", + greenOutput: "$ npx vitest run t.ts\nFAIL t.ts > OC-0001 (still failing after restore)", + note: "still failing after restore", + }; }, - }) - assert.equal(result.commits.length, 0) - assert.equal(result.results[0].outcome, 'blocked') - assert.match(result.results[0].rationale, /after restoring the fix/i) -} + }); + assert.equal(result.commits.length, 0); + assert.equal(result.results[0].outcome, "blocked"); + assert.match(result.results[0].rationale, /after restoring the fix/i); +}; // F8: a cluster with only declines is never sent to prove, and produces no commit. scenarios.f8_declined_cluster_skips_prove = async () => { - const findings = [rec('OC-0001')] + const findings = [rec("OC-0001")]; const { result, calls } = await run({ args: { findings }, agentStub: (prompt, opts) => { - if (String(opts.label).startsWith('fix:')) - return { results: [{ id: 'OC-0001', outcome: 'declined', testPath: '', rationale: 'by design' }], touchedPaths: [] } - throw new Error('prove must not run for a cluster with no fixes') + if (String(opts.label).startsWith("fix:")) + return { + results: [{ id: "OC-0001", outcome: "declined", testPath: "", rationale: "by design" }], + touchedPaths: [], + }; + throw new Error("prove must not run for a cluster with no fixes"); }, - }) - assert.ok(!calls.some((c) => String(c.opts.label).startsWith('prove:'))) - assert.equal(result.commits.length, 0) - assert.equal(result.results[0].outcome, 'declined') -} + }); + assert.ok(!calls.some((c) => String(c.opts.label).startsWith("prove:"))); + assert.equal(result.commits.length, 0); + assert.equal(result.results[0].outcome, "declined"); +}; // F9: one failing cluster does not stop its siblings from committing. scenarios.f9_blocked_cluster_does_not_block_siblings = async () => { - const findings = [rec('OC-0001'), rec('OC-0003', { file: 'Server/ws/hub_sweep.go' })] + const findings = [rec("OC-0001"), rec("OC-0003", { file: "Server/ws/hub_sweep.go" })]; const { result } = await run({ args: { findings }, agentStub: (prompt, opts) => { - if (String(opts.label).startsWith('fix:')) { - const ids = [...new Set([...prompt.matchAll(/OC-\d{4}/g)].map((m) => m[0]))] - return { results: ids.map((id) => ({ id, outcome: 'fixed', testPath: 't.ts', rationale: '' })), touchedPaths: [] } + if (String(opts.label).startsWith("fix:")) { + const ids = [...new Set([...prompt.matchAll(/OC-\d{4}/g)].map((m) => m[0]))]; + return { + results: ids.map((id) => ({ id, outcome: "fixed", testPath: "t.ts", rationale: "" })), + touchedPaths: [], + }; } - if (opts.label.includes('livekitE2EE')) + if (opts.label.includes("livekitE2EE")) return { committed: false, - sha: '', + sha: "", redObserved: false, greenObserved: true, - redOutput: '$ npx vitest run t.ts\nPASS t.ts > OC-0001 (reverted, should have failed)', - greenOutput: '$ npx vitest run t.ts\nPASS t.ts > OC-0001 (fixed)', - note: 'vacuous', - } + redOutput: "$ npx vitest run t.ts\nPASS t.ts > OC-0001 (reverted, should have failed)", + greenOutput: "$ npx vitest run t.ts\nPASS t.ts > OC-0001 (fixed)", + note: "vacuous", + }; return { committed: true, - sha: 'def5678', + sha: "def5678", redObserved: true, greenObserved: true, - redOutput: '$ go test ./ws/ -run TestSweep\nFAIL: reverted source', - greenOutput: '$ go test ./ws/ -run TestSweep\nPASS: fix restored', - note: '', - } + redOutput: "$ go test ./ws/ -run TestSweep\nFAIL: reverted source", + greenOutput: "$ go test ./ws/ -run TestSweep\nPASS: fix restored", + note: "", + }; }, - }) - assert.equal(result.commits.length, 1) - assert.equal(result.commits[0].sha, 'def5678') - const byId = Object.fromEntries(result.results.map((r) => [r.id, r.outcome])) - assert.equal(byId['OC-0001'], 'blocked') - assert.equal(byId['OC-0003'], 'fixed') -} + }); + assert.equal(result.commits.length, 1); + assert.equal(result.commits[0].sha, "def5678"); + const byId = Object.fromEntries(result.results.map((r) => [r.id, r.outcome])); + assert.equal(byId["OC-0001"], "blocked"); + assert.equal(byId["OC-0003"], "fixed"); +}; // F9b: a mixed cluster (one fixed + one declined) whose prove fails demotes only the fixed // finding to blocked; the declined finding and its original rationale are left untouched. scenarios.f9b_declined_survives_a_failed_prove = async () => { - const declinedRationale = 'intentional: rate limit is a product decision, not a bug' - const findings = [rec('OC-0001'), rec('OC-0002', { line: 800 })] + const declinedRationale = "intentional: rate limit is a product decision, not a bug"; + const findings = [rec("OC-0001"), rec("OC-0002", { line: 800 })]; const { result } = await run({ args: { findings }, agentStub: (prompt, opts) => { - if (String(opts.label).startsWith('fix:')) { + if (String(opts.label).startsWith("fix:")) { return { results: [ - { id: 'OC-0001', outcome: 'fixed', testPath: 't/OC-0001.test.ts', rationale: '' }, - { id: 'OC-0002', outcome: 'declined', testPath: '', rationale: declinedRationale }, + { id: "OC-0001", outcome: "fixed", testPath: "t/OC-0001.test.ts", rationale: "" }, + { id: "OC-0002", outcome: "declined", testPath: "", rationale: declinedRationale }, ], touchedPaths: [], - } + }; } return { committed: false, - sha: '', + sha: "", redObserved: false, greenObserved: true, - redOutput: '$ npx vitest run t/OC-0001.test.ts\nPASS (reverted, should have failed)', - greenOutput: '$ npx vitest run t/OC-0001.test.ts\nPASS (fixed)', - note: 'test passed with the fix reverted', - } + redOutput: "$ npx vitest run t/OC-0001.test.ts\nPASS (reverted, should have failed)", + greenOutput: "$ npx vitest run t/OC-0001.test.ts\nPASS (fixed)", + note: "test passed with the fix reverted", + }; }, - }) - const byId = Object.fromEntries(result.results.map((r) => [r.id, r])) - assert.equal(byId['OC-0001'].outcome, 'blocked') - assert.match(byId['OC-0001'].rationale, /revert-proof/i) - assert.equal(byId['OC-0002'].outcome, 'declined') - assert.equal(byId['OC-0002'].rationale, declinedRationale) -} + }); + const byId = Object.fromEntries(result.results.map((r) => [r.id, r])); + assert.equal(byId["OC-0001"].outcome, "blocked"); + assert.match(byId["OC-0001"].rationale, /revert-proof/i); + assert.equal(byId["OC-0002"].outcome, "declined"); + assert.equal(byId["OC-0002"].rationale, declinedRationale); +}; // F10: the gate runs once, and only for the stacks the commits actually touched. scenarios.f10_gate_targets_touched_stacks = async () => { - const findings = [rec('OC-0001'), rec('OC-0003', { file: 'Server/ws/hub_sweep.go' })] - let gatePrompt = '' + const findings = [rec("OC-0001"), rec("OC-0003", { file: "Server/ws/hub_sweep.go" })]; + let gatePrompt = ""; const { result, calls } = await run({ args: { findings }, agentStub: (prompt, opts) => { - if (String(opts.label).startsWith('fix:')) { - const ids = [...new Set([...prompt.matchAll(/OC-\d{4}/g)].map((m) => m[0]))] - return { results: ids.map((id) => ({ id, outcome: 'fixed', testPath: 't.ts', rationale: '' })), touchedPaths: [] } + if (String(opts.label).startsWith("fix:")) { + const ids = [...new Set([...prompt.matchAll(/OC-\d{4}/g)].map((m) => m[0]))]; + return { + results: ids.map((id) => ({ id, outcome: "fixed", testPath: "t.ts", rationale: "" })), + touchedPaths: [], + }; } - if (String(opts.label).startsWith('prove:')) + if (String(opts.label).startsWith("prove:")) return { committed: true, - sha: 'aaa1111', + sha: "aaa1111", redObserved: true, greenObserved: true, - redOutput: '$ npx vitest run t.ts\nFAIL t.ts (reverted)', - greenOutput: '$ npx vitest run t.ts\nPASS t.ts (fixed)', - note: '', - } - gatePrompt = prompt - return { passed: true, stacks: ['client', 'server'], output: 'ok' } + redOutput: "$ npx vitest run t.ts\nFAIL t.ts (reverted)", + greenOutput: "$ npx vitest run t.ts\nPASS t.ts (fixed)", + note: "", + }; + gatePrompt = prompt; + return { passed: true, stacks: ["client", "server"], output: "ok" }; }, - }) - const gateCalls = calls.filter((c) => c.opts.label === 'gate') - assert.equal(gateCalls.length, 1, 'ci-check runs once, not per fix') - assert.equal(gateCalls[0].opts.model, 'sonnet') - assert.equal(gateCalls[0].opts.effort, 'xhigh') - assert.match(gatePrompt, /no-experimental-webstorage/, 'client gate command must be spelled out') - assert.match(gatePrompt, /go build -tags otel/, 'server gate must cover the tagged build variants') - assert.equal(result.gate.passed, true) -} + }); + const gateCalls = calls.filter((c) => c.opts.label === "gate"); + assert.equal(gateCalls.length, 1, "ci-check runs once, not per fix"); + assert.equal(gateCalls[0].opts.model, "sonnet"); + assert.equal(gateCalls[0].opts.effort, "xhigh"); + assert.match(gatePrompt, /no-experimental-webstorage/, "client gate command must be spelled out"); + assert.match( + gatePrompt, + /go build -tags otel/, + "server gate must cover the tagged build variants", + ); + assert.equal(result.gate.passed, true); +}; // F11: nothing committed means nothing to gate - skip it rather than burn 15 minutes. scenarios.f11_no_commits_skips_gate = async () => { - const findings = [rec('OC-0001')] + const findings = [rec("OC-0001")]; const { result, calls } = await run({ args: { findings }, agentStub: (prompt, opts) => { - if (String(opts.label).startsWith('fix:')) - return { results: [{ id: 'OC-0001', outcome: 'declined', testPath: '', rationale: 'by design' }], touchedPaths: [] } - throw new Error(`no agent expected for label ${opts.label}`) + if (String(opts.label).startsWith("fix:")) + return { + results: [{ id: "OC-0001", outcome: "declined", testPath: "", rationale: "by design" }], + touchedPaths: [], + }; + throw new Error(`no agent expected for label ${opts.label}`); }, - }) - assert.ok(!calls.some((c) => c.opts.label === 'gate')) - assert.equal(result.gate, null) -} + }); + assert.ok(!calls.some((c) => c.opts.label === "gate")); + assert.equal(result.gate, null); +}; // F12: a red gate does not rewrite history - commits stand, the failure is reported. scenarios.f12_failing_gate_keeps_commits = async () => { - const findings = [rec('OC-0001')] + const findings = [rec("OC-0001")]; const { result } = await run({ args: { findings }, agentStub: (prompt, opts) => { - if (String(opts.label).startsWith('fix:')) - return { results: [{ id: 'OC-0001', outcome: 'fixed', testPath: 't.ts', rationale: '' }], touchedPaths: [] } - if (String(opts.label).startsWith('prove:')) + if (String(opts.label).startsWith("fix:")) + return { + results: [{ id: "OC-0001", outcome: "fixed", testPath: "t.ts", rationale: "" }], + touchedPaths: [], + }; + if (String(opts.label).startsWith("prove:")) return { committed: true, - sha: 'bbb2222', + sha: "bbb2222", redObserved: true, greenObserved: true, - redOutput: '$ npx vitest run t.ts\nFAIL t.ts (reverted)', - greenOutput: '$ npx vitest run t.ts\nPASS t.ts (fixed)', - note: '', - } - return { passed: false, stacks: ['client'], output: 'tsc: 3 errors' } + redOutput: "$ npx vitest run t.ts\nFAIL t.ts (reverted)", + greenOutput: "$ npx vitest run t.ts\nPASS t.ts (fixed)", + note: "", + }; + return { passed: false, stacks: ["client"], output: "tsc: 3 errors" }; }, - }) - assert.equal(result.commits.length, 1, 'a failing gate must not revert commits') - assert.equal(result.results[0].outcome, 'fixed') - assert.equal(result.gate.passed, false) - assert.match(result.gate.output, /tsc: 3 errors/) -} + }); + assert.equal(result.commits.length, 1, "a failing gate must not revert commits"); + assert.equal(result.results[0].outcome, "fixed"); + assert.equal(result.gate.passed, false); + assert.match(result.gate.output, /tsc: 3 errors/); +}; // F12b: a truthy but wrongly-shaped gate response (no boolean `passed`, no `stacks` array) must // still be treated as a failed gate - falling back to the computed stack list, but keeping the // agent's own `output` string rather than overwriting it with the generic default message. scenarios.f12b_malformed_gate_response_is_a_failed_gate = async () => { - const findings = [rec('OC-0001')] + const findings = [rec("OC-0001")]; const { result } = await run({ args: { findings }, agentStub: (prompt, opts) => { - if (String(opts.label).startsWith('fix:')) - return { results: [{ id: 'OC-0001', outcome: 'fixed', testPath: 't.ts', rationale: '' }], touchedPaths: [] } - if (String(opts.label).startsWith('prove:')) + if (String(opts.label).startsWith("fix:")) + return { + results: [{ id: "OC-0001", outcome: "fixed", testPath: "t.ts", rationale: "" }], + touchedPaths: [], + }; + if (String(opts.label).startsWith("prove:")) return { committed: true, - sha: 'ccc3333', + sha: "ccc3333", redObserved: true, greenObserved: true, - redOutput: '$ npx vitest run t.ts\nFAIL t.ts (reverted)', - greenOutput: '$ npx vitest run t.ts\nPASS t.ts (fixed)', - note: '', - } + redOutput: "$ npx vitest run t.ts\nFAIL t.ts (reverted)", + greenOutput: "$ npx vitest run t.ts\nPASS t.ts (fixed)", + note: "", + }; // wrongly shaped: no boolean `passed`, no `stacks` array - just a stray `output` string. - return { ok: true, output: 'ran partway: lint crashed before finishing' } + return { ok: true, output: "ran partway: lint crashed before finishing" }; }, - }) - assert.equal(result.commits.length, 1, 'a malformed gate response must not lose an already-made commit') - assert.equal(result.gate.passed, false) - assert.ok(Array.isArray(result.gate.stacks), 'stacks must fall back to the computed list, not stay undefined') - assert.deepEqual(result.gate.stacks, ['client']) + }); + assert.equal( + result.commits.length, + 1, + "a malformed gate response must not lose an already-made commit", + ); + assert.equal(result.gate.passed, false); + assert.ok( + Array.isArray(result.gate.stacks), + "stacks must fall back to the computed list, not stay undefined", + ); + assert.deepEqual(result.gate.stacks, ["client"]); assert.equal( result.gate.output, - 'ran partway: lint crashed before finishing', + "ran partway: lint crashed before finishing", "the agent's own output must be preserved, not replaced by the default message", - ) -} + ); +}; // F12c: the gate agent call itself throws. The .catch(() => null) guard must keep the rejection // from escaping the workflow - same as Phase 3's prove agent - so commits already made survive // and the gate is reported as failed rather than the run crashing before its final return. scenarios.f12c_thrown_gate_agent_does_not_lose_commits = async () => { - const findings = [rec('OC-0001')] + const findings = [rec("OC-0001")]; const { result } = await run({ args: { findings }, agentStub: (prompt, opts) => { - if (String(opts.label).startsWith('fix:')) - return { results: [{ id: 'OC-0001', outcome: 'fixed', testPath: 't.ts', rationale: '' }], touchedPaths: [] } - if (String(opts.label).startsWith('prove:')) + if (String(opts.label).startsWith("fix:")) + return { + results: [{ id: "OC-0001", outcome: "fixed", testPath: "t.ts", rationale: "" }], + touchedPaths: [], + }; + if (String(opts.label).startsWith("prove:")) return { committed: true, - sha: 'ddd4444', + sha: "ddd4444", redObserved: true, greenObserved: true, - redOutput: '$ npx vitest run t.ts\nFAIL t.ts (reverted)', - greenOutput: '$ npx vitest run t.ts\nPASS t.ts (fixed)', - note: '', - } - throw new Error('gate agent died') + redOutput: "$ npx vitest run t.ts\nFAIL t.ts (reverted)", + greenOutput: "$ npx vitest run t.ts\nPASS t.ts (fixed)", + note: "", + }; + throw new Error("gate agent died"); }, - }) - assert.equal(result.commits.length, 1, 'a thrown gate agent must not lose an already-made commit') - assert.equal(result.commits[0].sha, 'ddd4444') - assert.equal(result.gate.passed, false) - assert.equal(result.results[0].outcome, 'fixed', 'a gate failure must not demote an already-committed result') -} + }); + assert.equal( + result.commits.length, + 1, + "a thrown gate agent must not lose an already-made commit", + ); + assert.equal(result.commits[0].sha, "ddd4444"); + assert.equal(result.gate.passed, false); + assert.equal( + result.results[0].outcome, + "fixed", + "a gate failure must not demote an already-committed result", + ); +}; // F13: two clusters whose touchedPaths intersect (a shared root-cause file edited by both // agents) must both be blocked before Phase 3 - neither may reach prove/commit, and the log // must name both cluster files and the shared path. scenarios.f13_intersecting_touched_paths_blocks_both_clusters = async () => { - const findings = [rec('OC-0001'), rec('OC-0003', { file: 'Server/ws/hub_sweep.go' })] + const findings = [rec("OC-0001"), rec("OC-0003", { file: "Server/ws/hub_sweep.go" })]; const { result, logs, calls } = await run({ args: { findings }, agentStub: (prompt, opts) => { - if (!String(opts.label).startsWith('fix:')) throw new Error(`only fix agents should run, got ${opts.label}`) - if (opts.label.includes('livekitE2EE')) + if (!String(opts.label).startsWith("fix:")) + throw new Error(`only fix agents should run, got ${opts.label}`); + if (opts.label.includes("livekitE2EE")) return { - results: [{ id: 'OC-0001', outcome: 'fixed', testPath: 't/OC-0001.test.ts', rationale: '' }], - touchedPaths: ['Server/ws/shared_helper.go'], - } + results: [ + { id: "OC-0001", outcome: "fixed", testPath: "t/OC-0001.test.ts", rationale: "" }, + ], + touchedPaths: ["Server/ws/shared_helper.go"], + }; return { - results: [{ id: 'OC-0003', outcome: 'fixed', testPath: 'Server/ws/hub_sweep_test.go', rationale: '' }], - touchedPaths: ['Server/ws/shared_helper.go'], - } + results: [ + { + id: "OC-0003", + outcome: "fixed", + testPath: "Server/ws/hub_sweep_test.go", + rationale: "", + }, + ], + touchedPaths: ["Server/ws/shared_helper.go"], + }; }, - }) - const byId = Object.fromEntries(result.results.map((r) => [r.id, r])) - assert.equal(byId['OC-0001'].outcome, 'blocked') - assert.equal(byId['OC-0003'].outcome, 'blocked') - assert.match(byId['OC-0001'].rationale, /Server\/ws\/shared_helper\.go/) - assert.match(byId['OC-0003'].rationale, /Server\/ws\/shared_helper\.go/) - assert.equal(result.commits.length, 0, 'neither cluster may commit once blocked by the overlap guard') - const joined = logs.join('\n') - assert.match(joined, /livekitE2EE\.ts/, 'log must name the first cluster file') - assert.match(joined, /hub_sweep\.go/, 'log must name the second cluster file') - assert.match(joined, /shared_helper\.go/, 'log must name the shared path') - assert.ok(!calls.some((c) => String(c.opts.label).startsWith('prove:')), 'blocked clusters must never reach prove') -} + }); + const byId = Object.fromEntries(result.results.map((r) => [r.id, r])); + assert.equal(byId["OC-0001"].outcome, "blocked"); + assert.equal(byId["OC-0003"].outcome, "blocked"); + assert.match(byId["OC-0001"].rationale, /Server\/ws\/shared_helper\.go/); + assert.match(byId["OC-0003"].rationale, /Server\/ws\/shared_helper\.go/); + assert.equal( + result.commits.length, + 0, + "neither cluster may commit once blocked by the overlap guard", + ); + const joined = logs.join("\n"); + assert.match(joined, /livekitE2EE\.ts/, "log must name the first cluster file"); + assert.match(joined, /hub_sweep\.go/, "log must name the second cluster file"); + assert.match(joined, /shared_helper\.go/, "log must name the shared path"); + assert.ok( + !calls.some((c) => String(c.opts.label).startsWith("prove:")), + "blocked clusters must never reach prove", + ); +}; // F14: two clusters with disjoint touchedPaths are unaffected by the guard and both commit. scenarios.f14_disjoint_touched_paths_both_commit = async () => { - const findings = [rec('OC-0001'), rec('OC-0003', { file: 'Server/ws/hub_sweep.go' })] + const findings = [rec("OC-0001"), rec("OC-0003", { file: "Server/ws/hub_sweep.go" })]; const { result } = await run({ args: { findings }, agentStub: (prompt, opts) => { - if (String(opts.label).startsWith('fix:')) { - if (opts.label.includes('livekitE2EE')) + if (String(opts.label).startsWith("fix:")) { + if (opts.label.includes("livekitE2EE")) return { - results: [{ id: 'OC-0001', outcome: 'fixed', testPath: 't/OC-0001.test.ts', rationale: '' }], - touchedPaths: ['Client/src/lib/otherHelper.ts'], - } + results: [ + { id: "OC-0001", outcome: "fixed", testPath: "t/OC-0001.test.ts", rationale: "" }, + ], + touchedPaths: ["Client/src/lib/otherHelper.ts"], + }; return { - results: [{ id: 'OC-0003', outcome: 'fixed', testPath: 'Server/ws/hub_sweep_test.go', rationale: '' }], - touchedPaths: ['Server/ws/other_helper.go'], - } + results: [ + { + id: "OC-0003", + outcome: "fixed", + testPath: "Server/ws/hub_sweep_test.go", + rationale: "", + }, + ], + touchedPaths: ["Server/ws/other_helper.go"], + }; } return { committed: true, - sha: opts.label.includes('livekitE2EE') ? 'e2ee1111' : 'sweep222', + sha: opts.label.includes("livekitE2EE") ? "e2ee1111" : "sweep222", redObserved: true, greenObserved: true, - redOutput: 'FAIL (reverted)', - greenOutput: 'PASS (fixed)', - note: '', - } + redOutput: "FAIL (reverted)", + greenOutput: "PASS (fixed)", + note: "", + }; }, - }) - assert.equal(result.commits.length, 2, 'disjoint touchedPaths must not trip the overlap guard') - const byId = Object.fromEntries(result.results.map((r) => [r.id, r.outcome])) - assert.equal(byId['OC-0001'], 'fixed') - assert.equal(byId['OC-0003'], 'fixed') -} + }); + assert.equal(result.commits.length, 2, "disjoint touchedPaths must not trip the overlap guard"); + const byId = Object.fromEntries(result.results.map((r) => [r.id, r.outcome])); + assert.equal(byId["OC-0001"], "fixed"); + assert.equal(byId["OC-0003"], "fixed"); +}; // F15: the prove prompt for a cluster whose agent reported extra touchedPaths names every one // of those paths in both the revert (checkout) instruction and the staging (add) instruction - // not just cluster.file. scenarios.f15_prove_prompt_names_every_touched_path = async () => { - const findings = [rec('OC-0001')] - let provePromptText = '' + const findings = [rec("OC-0001")]; + let provePromptText = ""; const { result } = await run({ - args: { findings, branch: 'fix/test-touched' }, + args: { findings, branch: "fix/test-touched" }, agentStub: (prompt, opts) => { - if (String(opts.label).startsWith('fix:')) + if (String(opts.label).startsWith("fix:")) return { - results: [{ id: 'OC-0001', outcome: 'fixed', testPath: 't/OC-0001.test.ts', rationale: '' }], - touchedPaths: ['Client/src/lib/sharedCrypto.ts'], - } - if (String(opts.label).startsWith('prove:')) { - provePromptText = prompt + results: [ + { id: "OC-0001", outcome: "fixed", testPath: "t/OC-0001.test.ts", rationale: "" }, + ], + touchedPaths: ["Client/src/lib/sharedCrypto.ts"], + }; + if (String(opts.label).startsWith("prove:")) { + provePromptText = prompt; return { committed: true, - sha: 'aaa9999', + sha: "aaa9999", redObserved: true, greenObserved: true, - redOutput: 'FAIL (reverted)', - greenOutput: 'PASS (fixed)', - note: '', - } + redOutput: "FAIL (reverted)", + greenOutput: "PASS (fixed)", + note: "", + }; } - return { passed: true, stacks: ['client'], output: 'ok' } + return { passed: true, stacks: ["client"], output: "ok" }; }, - }) - assert.equal(result.commits.length, 1) - const checkoutLine = provePromptText.split('\n').find((l) => l.includes('git checkout HEAD --')) - assert.ok(checkoutLine, 'prove prompt must contain the checkout instruction') - assert.match(checkoutLine, /livekitE2EE\.ts/, 'checkout instruction must name the cluster file') - assert.match(checkoutLine, /sharedCrypto\.ts/, 'checkout instruction must also name the extra touched path') - const addLine = provePromptText.split('\n').find((l) => l.includes('git add')) - assert.ok(addLine, 'prove prompt must contain the staging instruction') - assert.match(addLine, /livekitE2EE\.ts/, 'add instruction must name the cluster file') - assert.match(addLine, /sharedCrypto\.ts/, 'add instruction must also name the extra touched path') - assert.match(provePromptText, /rev-parse --abbrev-ref HEAD/, 'prove prompt must guard the current branch') - assert.match(provePromptText, /fix\/test-touched/, 'branch guard must name the expected branch') -} + }); + assert.equal(result.commits.length, 1); + const checkoutLine = provePromptText.split("\n").find((l) => l.includes("git checkout HEAD --")); + assert.ok(checkoutLine, "prove prompt must contain the checkout instruction"); + assert.match(checkoutLine, /livekitE2EE\.ts/, "checkout instruction must name the cluster file"); + assert.match( + checkoutLine, + /sharedCrypto\.ts/, + "checkout instruction must also name the extra touched path", + ); + const addLine = provePromptText.split("\n").find((l) => l.includes("git add")); + assert.ok(addLine, "prove prompt must contain the staging instruction"); + assert.match(addLine, /livekitE2EE\.ts/, "add instruction must name the cluster file"); + assert.match( + addLine, + /sharedCrypto\.ts/, + "add instruction must also name the extra touched path", + ); + assert.match( + provePromptText, + /rev-parse --abbrev-ref HEAD/, + "prove prompt must guard the current branch", + ); + assert.match(provePromptText, /fix\/test-touched/, "branch guard must name the expected branch"); +}; // ---------- circuit breaker ---------- // Four findings, one per file, so each becomes its own cluster. const FOUR_FILES = [ - rec('OC-0001'), - rec('OC-0002', { file: 'Server/ws/hub_sweep.go' }), - rec('OC-0003', { file: 'Client/src/lib/livekitSession.ts' }), - rec('OC-0004', { file: 'Client/src/components/VoiceWidget.ts' }), -] + rec("OC-0001"), + rec("OC-0002", { file: "Server/ws/hub_sweep.go" }), + rec("OC-0003", { file: "Client/src/lib/livekitSession.ts" }), + rec("OC-0004", { file: "Client/src/components/VoiceWidget.ts" }), +]; // A fix stub that reports every id in its prompt as fixed. Ids go through a Set because rec() // puts each id in both `id` and `title`, so the raw matchAll yields every id twice. const fixAll = (prompt) => { - const ids = [...new Set([...prompt.matchAll(/OC-\d{4}/g)].map((m) => m[0]))] - return { results: ids.map((id) => ({ id, outcome: 'fixed', testPath: `t/${id}.ts`, rationale: '' })), touchedPaths: [] } -} + const ids = [...new Set([...prompt.matchAll(/OC-\d{4}/g)].map((m) => m[0]))]; + return { + results: ids.map((id) => ({ id, outcome: "fixed", testPath: `t/${id}.ts`, rationale: "" })), + touchedPaths: [], + }; +}; const PROVE_FAIL = { committed: false, - sha: '', + sha: "", redObserved: false, greenObserved: true, - redOutput: '$ npx vitest run t.ts\nPASS t.ts (still passing with the fix reverted)', - greenOutput: '$ npx vitest run t.ts\nPASS t.ts', - note: 'test did not exercise the bug', -} + redOutput: "$ npx vitest run t.ts\nPASS t.ts (still passing with the fix reverted)", + greenOutput: "$ npx vitest run t.ts\nPASS t.ts", + note: "test did not exercise the bug", +}; const proveOk = (sha) => ({ committed: true, sha, redObserved: true, greenObserved: true, - redOutput: '$ npx vitest run t.ts\nFAIL t.ts (reverted)', - greenOutput: '$ npx vitest run t.ts\nPASS t.ts (fixed)', - note: '', -}) + redOutput: "$ npx vitest run t.ts\nFAIL t.ts (reverted)", + greenOutput: "$ npx vitest run t.ts\nPASS t.ts (fixed)", + note: "", +}); // F16: three failed revert-proofs out of three attempts trips the breaker, and the fourth cluster // is never handed to an agent. Load-bearing: this is the one that proves the loop actually stops. @@ -685,72 +837,103 @@ scenarios.f16_breaker_trips_after_majority_prove_failures = async () => { const { result, calls, logs } = await run({ args: { findings: FOUR_FILES }, agentStub: (prompt, opts) => { - if (String(opts.label).startsWith('fix:')) return fixAll(prompt) - if (String(opts.label).startsWith('prove:')) return PROVE_FAIL - return { passed: true, stacks: [], output: '' } + if (String(opts.label).startsWith("fix:")) return fixAll(prompt); + if (String(opts.label).startsWith("prove:")) return PROVE_FAIL; + return { passed: true, stacks: [], output: "" }; }, - }) - const proveCalls = calls.filter((c) => String(c.opts.label).startsWith('prove:')) - assert.equal(proveCalls.length, 3, 'breaker must stop the loop after minAttempts, not run all four') - assert.ok(result.breaker, 'breaker report must be present on the result') - assert.equal(result.breaker.trippedAt, 'prove') - assert.equal(result.breaker.attempted, 3) - assert.equal(result.breaker.failed, 3) - assert.equal(result.commits.length, 0) + }); + const proveCalls = calls.filter((c) => String(c.opts.label).startsWith("prove:")); + assert.equal( + proveCalls.length, + 3, + "breaker must stop the loop after minAttempts, not run all four", + ); + assert.ok(result.breaker, "breaker report must be present on the result"); + assert.equal(result.breaker.trippedAt, "prove"); + assert.equal(result.breaker.attempted, 3); + assert.equal(result.breaker.failed, 3); + assert.equal(result.commits.length, 0); // Every finding ends blocked, but the unreached one must say WHY it was never tried. - assert.ok(result.results.every((r) => r.outcome === 'blocked'), 'nothing may be left reported as fixed') - const unreached = result.results.filter((r) => /circuit breaker/i.test(r.rationale)) - assert.equal(unreached.length, 1, 'exactly the one unreached finding carries the breaker rationale') - assert.match(unreached[0].rationale, /uncommitted/i, 'operator must be told the edits are still in the tree') - assert.ok(logs.some((l) => /CIRCUIT BREAKER/.test(l)), 'a trip must be announced in the log') -} + assert.ok( + result.results.every((r) => r.outcome === "blocked"), + "nothing may be left reported as fixed", + ); + const unreached = result.results.filter((r) => /circuit breaker/i.test(r.rationale)); + assert.equal( + unreached.length, + 1, + "exactly the one unreached finding carries the breaker rationale", + ); + assert.match( + unreached[0].rationale, + /uncommitted/i, + "operator must be told the edits are still in the tree", + ); + assert.ok( + logs.some((l) => /CIRCUIT BREAKER/.test(l)), + "a trip must be announced in the log", + ); +}; // F17: two failures out of two is 100%, but below minAttempts it is noise, not a signal. scenarios.f17_breaker_holds_below_min_attempts = async () => { const { result, calls } = await run({ args: { findings: FOUR_FILES.slice(0, 2) }, agentStub: (prompt, opts) => { - if (String(opts.label).startsWith('fix:')) return fixAll(prompt) - if (String(opts.label).startsWith('prove:')) return PROVE_FAIL - return { passed: true, stacks: [], output: '' } + if (String(opts.label).startsWith("fix:")) return fixAll(prompt); + if (String(opts.label).startsWith("prove:")) return PROVE_FAIL; + return { passed: true, stacks: [], output: "" }; }, - }) - assert.equal(calls.filter((c) => String(c.opts.label).startsWith('prove:')).length, 2, 'both clusters must be attempted') - assert.equal(result.breaker, null) + }); + assert.equal( + calls.filter((c) => String(c.opts.label).startsWith("prove:")).length, + 2, + "both clusters must be attempted", + ); + assert.equal(result.breaker, null); for (const r of result.results) { - assert.equal(r.outcome, 'blocked') - assert.match(r.rationale, /revert-proof failed/, 'rationale must be the real reason, not the breaker') + assert.equal(r.outcome, "blocked"); + assert.match( + r.rationale, + /revert-proof failed/, + "rationale must be the real reason, not the breaker", + ); } -} +}; // F18: one failure in four is a bad cluster, not a bad run. scenarios.f18_breaker_holds_under_threshold = async () => { - let n = 0 + let n = 0; const { result } = await run({ args: { findings: FOUR_FILES }, agentStub: (prompt, opts) => { - if (String(opts.label).startsWith('fix:')) return fixAll(prompt) - if (String(opts.label).startsWith('prove:')) return ++n === 1 ? PROVE_FAIL : proveOk(`sha${n}`) - return { passed: true, stacks: [], output: '' } + if (String(opts.label).startsWith("fix:")) return fixAll(prompt); + if (String(opts.label).startsWith("prove:")) + return ++n === 1 ? PROVE_FAIL : proveOk(`sha${n}`); + return { passed: true, stacks: [], output: "" }; }, - }) - assert.equal(result.breaker, null) - assert.equal(result.commits.length, 3, 'the three good clusters must still land') -} + }); + assert.equal(result.breaker, null); + assert.equal(result.commits.length, 3, "the three good clusters must still land"); +}; // F19: the operator can turn the guard off entirely. scenarios.f19_breaker_disabled_by_args = async () => { const { result, calls } = await run({ args: { findings: FOUR_FILES, circuitBreaker: false }, agentStub: (prompt, opts) => { - if (String(opts.label).startsWith('fix:')) return fixAll(prompt) - if (String(opts.label).startsWith('prove:')) return PROVE_FAIL - return { passed: true, stacks: [], output: '' } + if (String(opts.label).startsWith("fix:")) return fixAll(prompt); + if (String(opts.label).startsWith("prove:")) return PROVE_FAIL; + return { passed: true, stacks: [], output: "" }; }, - }) - assert.equal(calls.filter((c) => String(c.opts.label).startsWith('prove:')).length, 4, 'all four must be attempted when disabled') - assert.equal(result.breaker, null) -} + }); + assert.equal( + calls.filter((c) => String(c.opts.label).startsWith("prove:")).length, + 4, + "all four must be attempted when disabled", + ); + assert.equal(result.breaker, null); +}; // F20: when the FIX stage is what is failing, proving each of those costs a serial agent per // cluster and cannot succeed. Load-bearing: this is the cheap early exit. @@ -758,59 +941,64 @@ scenarios.f20_fix_stage_trip_skips_prove_entirely = async () => { const { result, calls } = await run({ args: { findings: FOUR_FILES }, agentStub: (prompt, opts) => { - if (String(opts.label).startsWith('fix:')) { - const ids = [...new Set([...prompt.matchAll(/OC-\d{4}/g)].map((m) => m[0]))] + if (String(opts.label).startsWith("fix:")) { + const ids = [...new Set([...prompt.matchAll(/OC-\d{4}/g)].map((m) => m[0]))]; return { results: ids.map((id) => - id === 'OC-0001' - ? { id, outcome: 'fixed', testPath: `t/${id}.ts`, rationale: '' } - : { id, outcome: 'blocked', testPath: '', rationale: 'could not run the test suite' }, + id === "OC-0001" + ? { id, outcome: "fixed", testPath: `t/${id}.ts`, rationale: "" } + : { id, outcome: "blocked", testPath: "", rationale: "could not run the test suite" }, ), touchedPaths: [], - } + }; } - return { passed: true, stacks: [], output: '' } + return { passed: true, stacks: [], output: "" }; }, - }) - assert.ok(result.breaker, 'breaker report must be present') - assert.equal(result.breaker.trippedAt, 'fix') + }); + assert.ok(result.breaker, "breaker report must be present"); + assert.equal(result.breaker.trippedAt, "fix"); assert.equal( - calls.filter((c) => String(c.opts.label).startsWith('prove:')).length, + calls.filter((c) => String(c.opts.label).startsWith("prove:")).length, 0, - 'a fix-stage trip must spend nothing on prove agents', - ) - assert.equal(result.commits.length, 0) - assert.equal(result.gate, null, 'no commits means no gate') -} + "a fix-stage trip must spend nothing on prove agents", + ); + assert.equal(result.commits.length, 0); + assert.equal(result.gate, null, "no commits means no gate"); +}; // F21: a trip does not orphan whatever already landed - the operator needs to know if it is green. scenarios.f21_trip_still_gates_existing_commits = async () => { - let n = 0 + let n = 0; const { result, calls } = await run({ args: { findings: FOUR_FILES }, agentStub: (prompt, opts) => { - if (String(opts.label).startsWith('fix:')) return fixAll(prompt) - if (String(opts.label).startsWith('prove:')) return ++n === 1 ? proveOk('aaa1111') : PROVE_FAIL - return { passed: true, stacks: ['client'], output: 'all green' } + if (String(opts.label).startsWith("fix:")) return fixAll(prompt); + if (String(opts.label).startsWith("prove:")) + return ++n === 1 ? proveOk("aaa1111") : PROVE_FAIL; + return { passed: true, stacks: ["client"], output: "all green" }; }, - }) - assert.ok(result.breaker, 'breaker report must be present') - assert.equal(result.breaker.trippedAt, 'prove') - assert.equal(result.commits.length, 1, 'the one proven cluster must survive the trip') - assert.equal(calls.filter((c) => c.opts.label === 'gate').length, 1, 'the gate must still run over what landed') - assert.equal(result.gate.passed, true) -} + }); + assert.ok(result.breaker, "breaker report must be present"); + assert.equal(result.breaker.trippedAt, "prove"); + assert.equal(result.commits.length, 1, "the one proven cluster must survive the trip"); + assert.equal( + calls.filter((c) => c.opts.label === "gate").length, + 1, + "the gate must still run over what landed", + ); + assert.equal(result.gate.passed, true); +}; // ---------- runner ---------- -const only = process.argv[2] +const only = process.argv[2]; for (const [name, fn] of Object.entries(scenarios)) { - if (only && !name.includes(only)) continue + if (only && !name.includes(only)) continue; try { - await fn() + await fn(); } catch (e) { - console.error(`FAIL ${name}`) - throw e + console.error(`FAIL ${name}`); + throw e; } - console.log(`PASS ${name}`) + console.log(`PASS ${name}`); } -console.log('all scenarios pass') +console.log("all scenarios pass"); diff --git a/.claude/workflows/bughunt-fix.js b/.claude/workflows/bughunt-fix.js index 1aa00e25..7a38f9b8 100644 --- a/.claude/workflows/bughunt-fix.js +++ b/.claude/workflows/bughunt-fix.js @@ -1,32 +1,34 @@ export const meta = { - name: 'bughunt-fix', - description: 'Fix open ledger findings test-first: per-file agents, mechanical revert-proof, serial commits, one ci-check gate', - whenToUse: 'After a bughunt run has been written to the findings ledger and a human has skimmed it. Consumes open findings, produces commits on a branch. Never opens a PR.', + name: "bughunt-fix", + description: + "Fix open ledger findings test-first: per-file agents, mechanical revert-proof, serial commits, one ci-check gate", + whenToUse: + "After a bughunt run has been written to the findings ledger and a human has skimmed it. Consumes open findings, produces commits on a branch. Never opens a PR.", phases: [ - { title: 'Plan', detail: 'cluster open findings by file' }, - { title: 'Fix', detail: 'sonnet/xhigh: one agent per file, test-first, no git' }, - { title: 'Prove', detail: 'opus/high: serial revert-proof then commit per cluster' }, - { title: 'Gate', detail: 'sonnet/xhigh: ci-check for the touched stacks, once' }, + { title: "Plan", detail: "cluster open findings by file" }, + { title: "Fix", detail: "sonnet/xhigh: one agent per file, test-first, no git" }, + { title: "Prove", detail: "opus/high: serial revert-proof then commit per cluster" }, + { title: "Gate", detail: "sonnet/xhigh: ci-check for the touched stacks, once" }, ], -} +}; // args has been observed arriving JSON-stringified; coerce it the same way bughunt.js does. const ARGS = (() => { - if (typeof args === 'string') { + if (typeof args === "string") { try { - return JSON.parse(args) || {} + return JSON.parse(args) || {}; } catch { - return {} + return {}; } } - return args || {} -})() + return args || {}; +})(); -const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3 } -const BRANCH = ARGS.branch || 'fix/bughunt' -const ONLY = Array.isArray(ARGS.only) && ARGS.only.length ? new Set(ARGS.only) : null -const MAX_SEVERITY = ARGS.maxSeverity || 'low' -const ALL = Array.isArray(ARGS.findings) ? ARGS.findings : [] +const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3 }; +const BRANCH = ARGS.branch || "fix/bughunt"; +const ONLY = Array.isArray(ARGS.only) && ARGS.only.length ? new Set(ARGS.only) : null; +const MAX_SEVERITY = ARGS.maxSeverity || "low"; +const ALL = Array.isArray(ARGS.findings) ? ARGS.findings : []; // Circuit breaker: stop a run that is going systematically wrong instead of spending a // high-effort agent on every remaining cluster. `declined` is not a failure - it is a // judgement the fix prompt explicitly invites - so only `blocked` counts. @@ -37,23 +39,23 @@ const BREAKER = : { threshold: ARGS.circuitBreaker?.threshold ?? 0.5, minAttempts: ARGS.circuitBreaker?.minAttempts ?? 3, - } -let breaker = null // set to a report object if it trips + }; +let breaker = null; // set to a report object if it trips // ---------- phase 1: plan ---------- -phase('Plan') +phase("Plan"); -const excluded = [] -const selected = [] +const excluded = []; +const selected = []; for (const f of ALL) { - if (f.status && f.status !== 'open') { - excluded.push({ id: f.id, reason: `status is ${f.status}, not open` }) + if (f.status && f.status !== "open") { + excluded.push({ id: f.id, reason: `status is ${f.status}, not open` }); } else if (ONLY && !ONLY.has(f.id)) { - excluded.push({ id: f.id, reason: 'not in only' }) + excluded.push({ id: f.id, reason: "not in only" }); } else if ((SEV_RANK[f.severity] ?? 3) > (SEV_RANK[MAX_SEVERITY] ?? 3)) { - excluded.push({ id: f.id, reason: 'below maxSeverity' }) + excluded.push({ id: f.id, reason: "below maxSeverity" }); } else { - selected.push(f) + selected.push(f); } } @@ -61,59 +63,68 @@ for (const f of ALL) { // and what makes a root-cause fix possible - the agent sees every defect in the file at once. // Normalize the grouping key (backslashes -> forward slashes) so a path reported with the // "wrong" separator does not silently split one real file into two clusters. -const byFile = new Map() +const byFile = new Map(); for (const f of selected) { - const key = String(f.file).replace(/\\/g, '/') - if (!byFile.has(key)) byFile.set(key, []) - byFile.get(key).push(f) + const key = String(f.file).replace(/\\/g, "/"); + if (!byFile.has(key)) byFile.set(key, []); + byFile.get(key).push(f); } const clusters = [...byFile.entries()].map(([file, findings]) => ({ file, ids: findings.map((f) => f.id), findings, -})) +})); -log(`plan: ${selected.length} finding(s) in ${clusters.length} file cluster(s) on ${BRANCH}` + - (BREAKER - ? ` (breaker: stop above ${Math.round(BREAKER.threshold * 100)}% failures after ${BREAKER.minAttempts} attempts)` - : ' (breaker disabled)')) -for (const c of clusters) log(` ${c.file}: ${c.ids.join(', ')}`) +log( + `plan: ${selected.length} finding(s) in ${clusters.length} file cluster(s) on ${BRANCH}` + + (BREAKER + ? ` (breaker: stop above ${Math.round(BREAKER.threshold * 100)}% failures after ${BREAKER.minAttempts} attempts)` + : " (breaker disabled)"), +); +for (const c of clusters) log(` ${c.file}: ${c.ids.join(", ")}`); // Announce every exclusion by id. Silent truncation reads as "covered everything" when it did not. -for (const e of excluded) log(` excluded ${e.id}: ${e.reason}`) +for (const e of excluded) log(` excluded ${e.id}: ${e.reason}`); -const publicClusters = clusters.map((c) => ({ file: c.file, ids: c.ids })) +const publicClusters = clusters.map((c) => ({ file: c.file, ids: c.ids })); // ---------- schemas ---------- const FIX_RESULTS = { - type: 'object', - required: ['results', 'touchedPaths'], + type: "object", + required: ["results", "touchedPaths"], properties: { results: { - type: 'array', + type: "array", items: { - type: 'object', - required: ['id', 'outcome', 'testPath', 'rationale'], + type: "object", + required: ["id", "outcome", "testPath", "rationale"], properties: { - id: { type: 'string', description: 'the ledger id, e.g. OC-0042' }, - outcome: { type: 'string', enum: ['fixed', 'declined', 'blocked'] }, - testPath: { type: 'string', description: 'repo-relative path of the test that pins this finding; empty if not fixed' }, - rationale: { type: 'string', description: 'required for declined and blocked; empty for fixed' }, + id: { type: "string", description: "the ledger id, e.g. OC-0042" }, + outcome: { type: "string", enum: ["fixed", "declined", "blocked"] }, + testPath: { + type: "string", + description: + "repo-relative path of the test that pins this finding; empty if not fixed", + }, + rationale: { + type: "string", + description: "required for declined and blocked; empty for fixed", + }, }, }, }, touchedPaths: { - type: 'array', - items: { type: 'string' }, + type: "array", + items: { type: "string" }, description: - 'every non-test SOURCE file this agent modified while working this cluster, repo-relative, forward ' + - 'slashes - including cluster.file itself if it was touched, and any shared file outside the cluster ' + - 'the root-cause fix required. Test files belong in testPath (per finding), not here.', + "every non-test SOURCE file this agent modified while working this cluster, repo-relative, forward " + + "slashes - including cluster.file itself if it was touched, and any shared file outside the cluster " + + "the root-cause fix required. Test files belong in testPath (per finding), not here.", }, }, -} +}; // ---------- phase 2: fix ---------- -phase('Fix') +phase("Fix"); function fixPrompt(cluster) { return ( @@ -156,59 +167,76 @@ function fixPrompt(cluster) { ` go test .// -run \n\n` + `Return one result per finding id, all ${cluster.ids.length} of them, plus touchedPaths.\n\n` + `--- FINDINGS IN ${cluster.file} ---\n${JSON.stringify(cluster.findings, null, 2)}` - ) + ); } const fixOutcomes = await parallel( - clusters.map((cluster) => () => - agent(fixPrompt(cluster), { - label: `fix:${cluster.file}`, - phase: 'Fix', - model: 'sonnet', - effort: 'xhigh', - schema: FIX_RESULTS, - }).then((r) => ({ - cluster, - results: (r && r.results) || [], - touchedPaths: r && Array.isArray(r.touchedPaths) ? r.touchedPaths.filter((p) => typeof p === 'string' && p) : [], - })), + clusters.map( + (cluster) => () => + agent(fixPrompt(cluster), { + label: `fix:${cluster.file}`, + phase: "Fix", + model: "sonnet", + effort: "xhigh", + schema: FIX_RESULTS, + }).then((r) => ({ + cluster, + results: (r && r.results) || [], + touchedPaths: + r && Array.isArray(r.touchedPaths) + ? r.touchedPaths.filter((p) => typeof p === "string" && p) + : [], + })), ), -) +); // A null slot means the agent died or threw. Its findings are blocked, its siblings are unaffected. -const fixed = [] +const fixed = []; for (let i = 0; i < clusters.length; i++) { - const cluster = clusters[i] - const outcome = fixOutcomes[i] + const cluster = clusters[i]; + const outcome = fixOutcomes[i]; if (!outcome) { - log(`fix ${cluster.file}: agent failed - ${cluster.ids.length} finding(s) blocked`) + log(`fix ${cluster.file}: agent failed - ${cluster.ids.length} finding(s) blocked`); fixed.push({ cluster, - results: cluster.ids.map((id) => ({ id, outcome: 'blocked', testPath: '', rationale: 'fix agent failed or returned nothing' })), + results: cluster.ids.map((id) => ({ + id, + outcome: "blocked", + testPath: "", + rationale: "fix agent failed or returned nothing", + })), touchedPaths: [], union: [cluster.file], - }) - continue + }); + continue; } // A hallucinated id, or one copy-pasted from a different cluster, must not merge in silently. - const ownIds = new Set(cluster.ids) - const ownResults = outcome.results.filter((r) => ownIds.has(r.id)) - const foreignResults = outcome.results.filter((r) => !ownIds.has(r.id)) + const ownIds = new Set(cluster.ids); + const ownResults = outcome.results.filter((r) => ownIds.has(r.id)); + const foreignResults = outcome.results.filter((r) => !ownIds.has(r.id)); if (foreignResults.length) { - log(`fix ${cluster.file}: dropped ${foreignResults.length} result(s) for id(s) not in this cluster - ${foreignResults.map((r) => r.id).join(', ')}`) + log( + `fix ${cluster.file}: dropped ${foreignResults.length} result(s) for id(s) not in this cluster - ${foreignResults.map((r) => r.id).join(", ")}`, + ); } // An agent that skipped a finding entirely leaves it blocked rather than silently dropped. - const reported = new Set(ownResults.map((r) => r.id)) + const reported = new Set(ownResults.map((r) => r.id)); const missing = cluster.ids .filter((id) => !reported.has(id)) - .map((id) => ({ id, outcome: 'blocked', testPath: '', rationale: 'fix agent returned no result for this finding' })) - if (missing.length) log(`fix ${cluster.file}: ${missing.length} finding(s) unreported by the agent - blocked`) + .map((id) => ({ + id, + outcome: "blocked", + testPath: "", + rationale: "fix agent returned no result for this finding", + })); + if (missing.length) + log(`fix ${cluster.file}: ${missing.length} finding(s) unreported by the agent - blocked`); fixed.push({ cluster, results: [...ownResults, ...missing], touchedPaths: outcome.touchedPaths, union: [...new Set([cluster.file, ...outcome.touchedPaths])], - }) + }); } // ---------- phase 2.5: cross-cluster overlap guard ---------- @@ -220,82 +248,97 @@ for (let i = 0; i < clusters.length; i++) { // staged at all. Block both clusters rather than guess which one "owns" the shared file. for (let i = 0; i < fixed.length; i++) { for (let j = i + 1; j < fixed.length; j++) { - const a = fixed[i] - const b = fixed[j] - const shared = a.union.filter((p) => b.union.includes(p)) - if (!shared.length) continue - log(`blocked: ${a.cluster.file} and ${b.cluster.file} both touch ${shared.join(', ')} - both clusters blocked`) - for (const [entry, other] of [[a, b], [b, a]]) { + const a = fixed[i]; + const b = fixed[j]; + const shared = a.union.filter((p) => b.union.includes(p)); + if (!shared.length) continue; + log( + `blocked: ${a.cluster.file} and ${b.cluster.file} both touch ${shared.join(", ")} - both clusters blocked`, + ); + for (const [entry, other] of [ + [a, b], + [b, a], + ]) { for (const r of entry.results) { - if (r.outcome === 'fixed') { - r.outcome = 'blocked' - r.rationale = `cross-cluster edit: shares ${shared.join(', ')} with ${other.cluster.file} - needs a human` + if (r.outcome === "fixed") { + r.outcome = "blocked"; + r.rationale = `cross-cluster edit: shares ${shared.join(", ")} with ${other.cluster.file} - needs a human`; } } } } } -const allResults = fixed.flatMap((f) => f.results) -log(`fix: ${allResults.filter((r) => r.outcome === 'fixed').length} fixed, ` + - `${allResults.filter((r) => r.outcome === 'declined').length} declined, ` + - `${allResults.filter((r) => r.outcome === 'blocked').length} blocked`) +const allResults = fixed.flatMap((f) => f.results); +log( + `fix: ${allResults.filter((r) => r.outcome === "fixed").length} fixed, ` + + `${allResults.filter((r) => r.outcome === "declined").length} declined, ` + + `${allResults.filter((r) => r.outcome === "blocked").length} blocked`, +); // ---------- phase 2.6: circuit breaker (fix stage) ---------- // A high blocked rate here means the fixing itself is failing - bad ledger coordinates, a // broken test runner, agents that cannot run the suite. Proving each of those costs a // serial agent per cluster and cannot succeed, so stop before spending it. if (BREAKER) { - const attempted = allResults.filter((r) => r.outcome !== 'declined').length - const failed = allResults.filter((r) => r.outcome === 'blocked').length + const attempted = allResults.filter((r) => r.outcome !== "declined").length; + const failed = allResults.filter((r) => r.outcome === "blocked").length; if (attempted >= BREAKER.minAttempts && failed / attempted > BREAKER.threshold) { breaker = { - trippedAt: 'fix', + trippedAt: "fix", attempted, failed, threshold: BREAKER.threshold, reason: `${failed}/${attempted} finding(s) could not be fixed - skipping prove and commit entirely`, - } - log(`CIRCUIT BREAKER: ${breaker.reason}`) + }; + log(`CIRCUIT BREAKER: ${breaker.reason}`); } } // ---------- phase 3: prove + commit ---------- -phase('Prove') +phase("Prove"); const PROVE_RESULT = { - type: 'object', - required: ['committed', 'sha', 'redObserved', 'greenObserved', 'redOutput', 'greenOutput', 'note'], + type: "object", + required: [ + "committed", + "sha", + "redObserved", + "greenObserved", + "redOutput", + "greenOutput", + "note", + ], properties: { - committed: { type: 'boolean' }, - sha: { type: 'string', description: 'short sha of the commit, empty when not committed' }, - redObserved: { type: 'boolean', description: 'did the tests FAIL with the source reverted' }, - greenObserved: { type: 'boolean', description: 'did the tests PASS with the fix restored' }, + committed: { type: "boolean" }, + sha: { type: "string", description: "short sha of the commit, empty when not committed" }, + redObserved: { type: "boolean", description: "did the tests FAIL with the source reverted" }, + greenObserved: { type: "boolean", description: "did the tests PASS with the fix restored" }, redOutput: { - type: 'string', + type: "string", description: - 'the ACTUAL output of the test run performed with the source reverted (step 4), including the ' + - 'command that was run. This run must FAIL. Paste the real captured output verbatim - not a ' + - 'summary, not a paraphrase.', + "the ACTUAL output of the test run performed with the source reverted (step 4), including the " + + "command that was run. This run must FAIL. Paste the real captured output verbatim - not a " + + "summary, not a paraphrase.", }, greenOutput: { - type: 'string', + type: "string", description: - 'the ACTUAL output of the test run performed after the fix was restored (step 6), including the ' + - 'command that was run. This run must PASS. Paste the real captured output verbatim - not a ' + - 'summary, not a paraphrase.', + "the ACTUAL output of the test run performed after the fix was restored (step 6), including the " + + "command that was run. This run must PASS. Paste the real captured output verbatim - not a " + + "summary, not a paraphrase.", }, - note: { type: 'string', description: 'why it was not committed, empty on success' }, + note: { type: "string", description: "why it was not committed, empty on success" }, }, -} +}; function provePrompt(cluster, fixedIds, testPaths, sourcePaths) { return ( `You are proving and committing ONE cluster of fixes in the OwnCord repo ` + `(checked out at your current working directory - do not assume any absolute path), on branch ${BRANCH}.\n\n` + - `Source file(s): ${sourcePaths.join(', ')}\n` + - `Findings fixed here: ${fixedIds.join(', ')}\n` + - `Test files written: ${testPaths.join(', ') || '(none reported)'}\n\n` + + `Source file(s): ${sourcePaths.join(", ")}\n` + + `Findings fixed here: ${fixedIds.join(", ")}\n` + + `Test files written: ${testPaths.join(", ") || "(none reported)"}\n\n` + `You are running SERIALLY. No other agent is touching git right now, so you may use git freely.\n\n` + `Do exactly this, in order:\n` + ` 1. Run: git rev-parse --abbrev-ref HEAD\n` + @@ -305,7 +348,7 @@ function provePrompt(cluster, fixedIds, testPaths, sourcePaths) { `by this agent.\n` + ` 2. Copy the current (fixed) contents of ALL source file(s) listed above to a scratch location ` + `outside the repo.\n` + - ` 3. Run: git checkout HEAD -- ${sourcePaths.join(' ')}\n` + + ` 3. Run: git checkout HEAD -- ${sourcePaths.join(" ")}\n` + ` Revert every source path listed above, and nothing else. Do NOT revert or delete the test ` + `files - a brand-new test file is untracked and this leaves it alone, and a new case in an existing ` + `test file is a modification to a path you did not name, so it survives too. Either way the new ` + @@ -325,14 +368,14 @@ function provePrompt(cluster, fixedIds, testPaths, sourcePaths) { `regenerated output can carry their hunks. A test function or comment citing a finding id not ` + `listed above, or a hunk in a generated/shared file unrelated to your findings, must NOT be ` + `committed - set committed=false, name the foreign content in note, and STOP.\n` + - ` 8. Stage ALL source file(s) listed above (git add ${sourcePaths.join(' ')}) AND the test files. ` + + ` 8. Stage ALL source file(s) listed above (git add ${sourcePaths.join(" ")}) AND the test files. ` + `Then check git status --porcelain for OTHER modified tracked test files in the same package(s)/` + `directory(ies) as your source files: a fix in this cluster may have rewritten a pre-existing test ` + `that locked the old behavior, or widened an interface that a fake/mock in a sibling test file must ` + `now implement - leaving such a companion uncommitted makes the committed branch fail or not compile ` + `on its own. If the modification's content belongs to THIS cluster's fix (per the step-7 check), ` + `stage it too; if it cites another cluster's findings, leave it. Commit with subject:\n` + - ` fix(): ${fixedIds.length} defect(s) (${fixedIds.join(', ')})\n` + + ` fix(): ${fixedIds.length} defect(s) (${fixedIds.join(", ")})\n` + ` Use a conventional-commit area matching the file (voice, ws, client, identity...). Do not add a ` + `Co-Authored-By trailer.\n` + ` 9. Return the short sha.\n\n` + @@ -340,12 +383,12 @@ function provePrompt(cluster, fixedIds, testPaths, sourcePaths) { ` NODE_OPTIONS=--no-experimental-webstorage npx vitest run \n` + `Server tests run from Server with:\n` + ` go test .// -run ` - ) + ); } -const commits = [] -let proveAttempts = 0 -let proveFailures = 0 +const commits = []; +let proveAttempts = 0; +let proveFailures = 0; // Serial on purpose: parallel git commands collide on .git/index.lock. for (const { cluster, results, union } of fixed) { if (breaker) { @@ -353,20 +396,20 @@ for (const { cluster, results, union } of fixed) { // from here on was never attempted; say so rather than leaving it reported as fixed, // which would put a `fixed` status in the ledger with no commit behind it. for (const r of results) { - if (r.outcome === 'fixed') { - r.outcome = 'blocked' - r.rationale = `circuit breaker tripped before this cluster was attempted (${breaker.reason}); edits are uncommitted in the working tree` + if (r.outcome === "fixed") { + r.outcome = "blocked"; + r.rationale = `circuit breaker tripped before this cluster was attempted (${breaker.reason}); edits are uncommitted in the working tree`; } } - continue + continue; } - const fixedHere = results.filter((r) => r.outcome === 'fixed') + const fixedHere = results.filter((r) => r.outcome === "fixed"); if (!fixedHere.length) { - log(`prove ${cluster.file}: no fixes to prove - skipped`) - continue + log(`prove ${cluster.file}: no fixes to prove - skipped`); + continue; } - const ids = fixedHere.map((r) => r.id) - const testPaths = [...new Set(fixedHere.map((r) => r.testPath).filter(Boolean))] + const ids = fixedHere.map((r) => r.id); + const testPaths = [...new Set(fixedHere.map((r) => r.testPath).filter(Boolean))]; // A dead/thrown prove agent must not take down the sibling clusters still waiting in this // serial loop - same "one blocked cluster does not poison the rest" rule Phase 2 gets from // parallel()'s catch. Fold it into a null result so the ok/why logic below handles it uniformly. @@ -375,68 +418,75 @@ for (const { cluster, results, union } of fixed) { // regenerated-file hunk from another cluster's uncommitted work without noticing either. const p = await agent(provePrompt(cluster, ids, testPaths, union), { label: `prove:${cluster.file}`, - phase: 'Prove', - model: 'opus', - effort: 'high', + phase: "Prove", + model: "opus", + effort: "high", schema: PROVE_RESULT, - }).catch(() => null) + }).catch(() => null); // Counted before the ok check on purpose: successes belong in the denominator. Increment // this inside the failure branch instead and the ratio is failures-over-failures, which is // always 1.0 - the breaker would trip on the first failed cluster at any threshold. - proveAttempts++ - const ok = p && p.committed && p.redObserved && p.greenObserved && p.sha + proveAttempts++; + const ok = p && p.committed && p.redObserved && p.greenObserved && p.sha; if (!ok) { const why = !p - ? 'prove agent failed' + ? "prove agent failed" : !p.redObserved - ? `revert-proof failed: tests still passed with the fix reverted (${p.note || 'no note'})` + ? `revert-proof failed: tests still passed with the fix reverted (${p.note || "no note"})` : !p.greenObserved - ? `tests did not pass after restoring the fix (${p.note || 'no note'})` - : `not committed (${p.note || 'no note'})` - log(`prove ${cluster.file}: ${why} - ${ids.length} finding(s) blocked`) + ? `tests did not pass after restoring the fix (${p.note || "no note"})` + : `not committed (${p.note || "no note"})`; + log(`prove ${cluster.file}: ${why} - ${ids.length} finding(s) blocked`); for (const r of results) { - if (r.outcome === 'fixed') { - r.outcome = 'blocked' - r.rationale = why + if (r.outcome === "fixed") { + r.outcome = "blocked"; + r.rationale = why; } } - proveFailures++ - if (BREAKER && proveAttempts >= BREAKER.minAttempts && proveFailures / proveAttempts > BREAKER.threshold) { + proveFailures++; + if ( + BREAKER && + proveAttempts >= BREAKER.minAttempts && + proveFailures / proveAttempts > BREAKER.threshold + ) { breaker = { - trippedAt: 'prove', + trippedAt: "prove", attempted: proveAttempts, failed: proveFailures, threshold: BREAKER.threshold, reason: `${proveFailures}/${proveAttempts} cluster(s) failed their revert-proof - stopping before the rest`, - } - log(`CIRCUIT BREAKER: ${breaker.reason}`) + }; + log(`CIRCUIT BREAKER: ${breaker.reason}`); } - continue + continue; } - commits.push({ sha: p.sha, file: cluster.file, ids }) - log(`prove ${cluster.file}: committed ${p.sha} (${ids.join(', ')})`) + commits.push({ sha: p.sha, file: cluster.file, ids }); + log(`prove ${cluster.file}: committed ${p.sha} (${ids.join(", ")})`); } // ---------- phase 4: gate ---------- const GATE_RESULT = { - type: 'object', - required: ['passed', 'stacks', 'output'], + type: "object", + required: ["passed", "stacks", "output"], properties: { - passed: { type: 'boolean' }, - stacks: { type: 'array', items: { type: 'string' } }, - output: { type: 'string', description: 'the failing command and its output, or a short ok summary' }, + passed: { type: "boolean" }, + stacks: { type: "array", items: { type: "string" } }, + output: { + type: "string", + description: "the failing command and its output, or a short ok summary", + }, }, -} +}; function stacksFor(files) { - const s = new Set() + const s = new Set(); for (const f of files) { - if (f.startsWith('Server/')) s.add('server') - else if (f.startsWith('Client/src-tauri/')) s.add('rust') - else if (f.startsWith('Client/')) s.add('client') + if (f.startsWith("Server/")) s.add("server"); + else if (f.startsWith("Client/src-tauri/")) s.add("rust"); + else if (f.startsWith("Client/")) s.add("client"); } - return [...s] + return [...s]; } const GATE_COMMANDS = { @@ -459,35 +509,45 @@ const GATE_COMMANDS = { `"go run ./scripts/genprotocol && git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts" ` + `- a non-empty diff in either means generated code is stale and the gate fails`, rust: - `From Client/src-tauri:\n` + - ` cargo test\n` + - ` cargo clippy --all-targets -- -D warnings`, -} + `From Client/src-tauri:\n` + ` cargo test\n` + ` cargo clippy --all-targets -- -D warnings`, +}; -let gate = null +let gate = null; if (commits.length) { - phase('Gate') - const stacks = stacksFor(commits.map((c) => c.file)) + phase("Gate"); + const stacks = stacksFor(commits.map((c) => c.file)); gate = await agent( `Run the OwnCord CI gates locally for the stacks touched by this fix run, on branch ${BRANCH}.\n\n` + `This runs ONCE for the whole run - a full gate per fix would take longer than the fixing did.\n\n` + - `Touched stacks: ${stacks.join(', ')}\n\n` + - stacks.map((s) => GATE_COMMANDS[s]).join('\n\n') + + `Touched stacks: ${stacks.join(", ")}\n\n` + + stacks.map((s) => GATE_COMMANDS[s]).join("\n\n") + `\n\nRun every command for every touched stack. Report passed=false if ANY of them fails, and put ` + `the failing command plus the relevant output in "output". Do NOT fix anything, do NOT amend or ` + `revert any commit, and do NOT push. Reporting the failure accurately is the whole job.\n\n` + `Known false alarm: a windows -race failure inside ws whose stack mentions runtime.scanstack or ` + `runtime.(*unwinder).next is a Go 1.26.5 runtime GC fault, not a real failure - rerun that package ` + `once before reporting it.`, - { label: 'gate', phase: 'Gate', model: 'sonnet', effort: 'xhigh', schema: GATE_RESULT }, - ).catch(() => null) + { label: "gate", phase: "Gate", model: "sonnet", effort: "xhigh", schema: GATE_RESULT }, + ).catch(() => null); // A malformed/missing report (dead agent, or a schema the caller didn't honor) is treated as a // failed gate, same as the null-check pattern in phases 2 and 3 - never crash on shape here. - if (!gate || typeof gate.passed !== 'boolean' || !Array.isArray(gate.stacks)) - gate = { passed: false, stacks, output: (gate && gate.output) || 'gate agent failed to report' } - log(`gate: ${gate.passed ? 'PASS' : 'FAIL'} (${gate.stacks.join(', ')})`) + if (!gate || typeof gate.passed !== "boolean" || !Array.isArray(gate.stacks)) + gate = { + passed: false, + stacks, + output: (gate && gate.output) || "gate agent failed to report", + }; + log(`gate: ${gate.passed ? "PASS" : "FAIL"} (${gate.stacks.join(", ")})`); } else { - log('gate: nothing committed - skipped') + log("gate: nothing committed - skipped"); } -return { branch: BRANCH, clusters: publicClusters, excluded, commits, results: allResults, gate, breaker } +return { + branch: BRANCH, + clusters: publicClusters, + excluded, + commits, + results: allResults, + gate, + breaker, +}; diff --git a/.claude/workflows/bughunt.harness.mjs b/.claude/workflows/bughunt.harness.mjs index a0768fea..17dcf03d 100644 --- a/.claude/workflows/bughunt.harness.mjs +++ b/.claude/workflows/bughunt.harness.mjs @@ -1,192 +1,251 @@ // Offline harness for bughunt.js - mimics the workflow runtime: wraps the script // body in an AsyncFunction with stubbed agent/parallel/pipeline/phase/log/args/budget. // Run: node .claude/workflows/bughunt.harness.mjs [nameFilter] -import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import assert from 'node:assert/strict' +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import assert from "node:assert/strict"; -const here = dirname(fileURLToPath(import.meta.url)) -const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor +const here = dirname(fileURLToPath(import.meta.url)); +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; export async function run({ agentStub, args = undefined, budget = undefined }) { - const src = readFileSync(join(here, 'bughunt.js'), 'utf8') - const body = src.replace('export const meta', 'const meta') - const calls = [] - const logs = [] + const src = readFileSync(join(here, "bughunt.js"), "utf8"); + const body = src.replace("export const meta", "const meta"); + const calls = []; + const logs = []; const agent = async (prompt, opts = {}) => { - calls.push({ prompt, opts }) - return agentStub(prompt, opts) - } + calls.push({ prompt, opts }); + return agentStub(prompt, opts); + }; const parallel = (thunks) => - Promise.all(thunks.map((t) => Promise.resolve().then(t).catch(() => null))) + Promise.all( + thunks.map((t) => + Promise.resolve() + .then(t) + .catch(() => null), + ), + ); const pipeline = (items, ...stages) => Promise.all( items.map(async (item, i) => { - let v = item + let v = item; for (const stage of stages) { try { - v = await stage(v, item, i) + v = await stage(v, item, i); } catch { - return null + return null; } } - return v + return v; }), - ) - const log = (m) => logs.push(String(m)) - const phase = () => {} - const budgetImpl = budget || { total: null, spent: () => 0, remaining: () => Infinity } - const fn = new AsyncFunction('agent', 'parallel', 'pipeline', 'phase', 'log', 'args', 'budget', body) - const result = await fn(agent, parallel, pipeline, phase, log, args, budgetImpl) - return { result, calls, logs } + ); + const log = (m) => logs.push(String(m)); + const phase = () => {}; + const budgetImpl = budget || { total: null, spent: () => 0, remaining: () => Infinity }; + const fn = new AsyncFunction( + "agent", + "parallel", + "pipeline", + "phase", + "log", + "args", + "budget", + body, + ); + const result = await fn(agent, parallel, pipeline, phase, log, args, budgetImpl); + return { result, calls, logs }; } // ---------- stub kit (used from Task 2 onward; harmless now) ---------- export function makeStub({ hunt, verify, recon = defaultRecon }) { return (prompt, opts) => { - const label = opts.label || '' - if (label.startsWith('recon:')) return recon(label) - let m = /^r(\d+):hunt:([a-z0-9-]+):opus$/.exec(label) - if (m) return hunt(Number(m[1]), m[2], 'opus', prompt) - m = /^r(\d+):verify:([a-z0-9-]+?)(:retry)?$/.exec(label) + const label = opts.label || ""; + if (label.startsWith("recon:")) return recon(label); + let m = /^r(\d+):hunt:([a-z0-9-]+):opus$/.exec(label); + if (m) return hunt(Number(m[1]), m[2], "opus", prompt); + m = /^r(\d+):verify:([a-z0-9-]+?)(:retry)?$/.exec(label); if (m) { - const candidates = JSON.parse(prompt.split('--- CANDIDATES ---')[1]) - return verify(Number(m[1]), m[2], candidates, Boolean(m[3]), prompt) + const candidates = JSON.parse(prompt.split("--- CANDIDATES ---")[1]); + return verify(Number(m[1]), m[2], candidates, Boolean(m[3]), prompt); } - throw new Error(`unexpected agent label: ${label}`) - } + throw new Error(`unexpected agent label: ${label}`); + }; } export function defaultRecon() { - return 'Server/ws/hub.go 12\nServer/api/user.go 9\nClient/src/lib/dispatcher.ts 8' + return "Server/ws/hub.go 12\nServer/api/user.go 9\nClient/src/lib/dispatcher.ts 8"; } -export const none = { findings: [] } +export const none = { findings: [] }; export const finding = (n, over = {}) => ({ title: `distinct bug alpha${n} omega${n}`, - file: 'Server/ws/hub.go', + file: "Server/ws/hub.go", line: 100 + n * 40, - severity: 'high', - why: 'w', - repro: 'r', - evidence: 'e', + severity: "high", + why: "w", + repro: "r", + evidence: "e", ...over, -}) +}); export const graphRows = (n) => - Array.from({ length: n }, (_, i) => ({ file: `Server/gen/g${i}.go`, score: 1 - i / (n + 1), degree: 10, cited: 5 })) + Array.from({ length: n }, (_, i) => ({ + file: `Server/gen/g${i}.go`, + score: 1 - i / (n + 1), + degree: 10, + cited: 5, + })); export const inventoryRows = (n, over = () => ({})) => Array.from({ length: n }, (_, i) => ({ - file: `Server/gen/g${i}.go`, degree: 10, cited: 5, score: 1 - i / (n + 1), - examined: false, risky: false, ...over(i), - })) + file: `Server/gen/g${i}.go`, + degree: 10, + cited: 5, + score: 1 - i / (n + 1), + examined: false, + risky: false, + ...over(i), + })); export const confirmAll = (cands) => ({ verdicts: cands.map((c) => ({ - title: c.title, file: c.file, line: c.line, - refuted: false, reason: 'confirmed', confidence: 'high', - severity: c.severity || 'high', fix: 'fix', + title: c.title, + file: c.file, + line: c.line, + refuted: false, + reason: "confirmed", + confidence: "high", + severity: c.severity || "high", + fix: "fix", })), -}) +}); export const refuteAll = (cands) => ({ verdicts: cands.map((c) => ({ - title: c.title, file: c.file, line: c.line, - refuted: true, reason: 'refuted', confidence: 'high', - severity: c.severity || 'high', + title: c.title, + file: c.file, + line: c.line, + refuted: true, + reason: "refuted", + confidence: "high", + severity: c.severity || "high", })), -}) +}); // ---------- scenarios ---------- -const scenarios = {} +const scenarios = {}; // S1: happy convergence - one bug in round 1, rounds 2-3 dry -> converged. scenarios.s1_convergence = async () => { const { result, calls, logs } = await run({ agentStub: makeStub({ hunt: (round, key, model) => - round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none, + round === 1 && key === "ws-hub" && model === "opus" ? { findings: [finding(1)] } : none, verify: (round, key, cands) => confirmAll(cands), }), - }) - for (const k of ['converged', 'stoppedOnBudget', 'rounds', 'confirmed', 'unverified', 'report']) - assert.ok(k in result, `missing key ${k}`) - assert.equal(result.converged, true) - assert.equal(result.rounds.length, 3) - assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 1, 2]) - assert.deepEqual(result.rounds.map((r) => r.family), ['surfaces', 'bug-classes', 'flows']) - assert.equal(result.confirmed.length, 1) - assert.ok(!calls.some((c) => (c.opts.label || '').startsWith('r4:')), 'no round 4 after convergence') - assert.ok(!calls.some((c) => c.opts.label === 'report'), 'the report is built in-script') - assert.match(result.report, /CONVERGED after 3 round\(s\)/) - assert.match(result.report, /### high - distinct bug alpha1 omega1/) - assert.match(result.report, /\| 1 \| surfaces \|/) - assert.ok(logs.some((l) => /budget=NONE - cost ceiling disarmed/.test(l)), 'a directive-less run must announce the dead ceiling') -} + }); + for (const k of ["converged", "stoppedOnBudget", "rounds", "confirmed", "unverified", "report"]) + assert.ok(k in result, `missing key ${k}`); + assert.equal(result.converged, true); + assert.equal(result.rounds.length, 3); + assert.deepEqual( + result.rounds.map((r) => r.dryAfter), + [0, 1, 2], + ); + assert.deepEqual( + result.rounds.map((r) => r.family), + ["surfaces", "bug-classes", "flows"], + ); + assert.equal(result.confirmed.length, 1); + assert.ok( + !calls.some((c) => (c.opts.label || "").startsWith("r4:")), + "no round 4 after convergence", + ); + assert.ok(!calls.some((c) => c.opts.label === "report"), "the report is built in-script"); + assert.match(result.report, /CONVERGED after 3 round\(s\)/); + assert.match(result.report, /### high - distinct bug alpha1 omega1/); + assert.match(result.report, /\| 1 \| surfaces \|/); + assert.ok( + logs.some((l) => /budget=NONE - cost ceiling disarmed/.test(l)), + "a directive-less run must announce the dead ceiling", + ); +}; // S2: near-duplicate findings from a single finder collapse - one candidate, one verify call. scenarios.s2_finder_dedupe = async () => { - const verifyBatches = [] + const verifyBatches = []; const { result, calls } = await run({ agentStub: makeStub({ hunt: (round, key) => - round === 1 && key === 'ws-hub' - ? { findings: [finding(1, { line: 100 }), finding(1, { line: 105, title: 'distinct bug alpha1 omega1 variant' })] } + round === 1 && key === "ws-hub" + ? { + findings: [ + finding(1, { line: 100 }), + finding(1, { line: 105, title: "distinct bug alpha1 omega1 variant" }), + ], + } : none, verify: (round, key, cands) => { - verifyBatches.push(cands) - return confirmAll(cands) + verifyBatches.push(cands); + return confirmAll(cands); }, }), - }) - assert.equal(verifyBatches.length, 1) - assert.equal(verifyBatches[0].length, 1) - assert.equal(result.confirmed.length, 1) - const hunts = calls.filter((c) => /^r1:hunt:ws-hub:/.test(c.opts.label || '')) - assert.equal(hunts.length, 1, 'exactly one finder call per lens - the sonnet slot is gone') - assert.match(hunts[0].opts.label, /:opus$/, 'the label keeps the :opus suffix the harness parses') -} + }); + assert.equal(verifyBatches.length, 1); + assert.equal(verifyBatches[0].length, 1); + assert.equal(result.confirmed.length, 1); + const hunts = calls.filter((c) => /^r1:hunt:ws-hub:/.test(c.opts.label || "")); + assert.equal(hunts.length, 1, "exactly one finder call per lens - the sonnet slot is gone"); + assert.match( + hunts[0].opts.label, + /:opus$/, + "the label keeps the :opus suffix the harness parses", + ); +}; // S3: refuted findings stay dead - re-reported next round, never re-verified; refutes count toward dry. scenarios.s3_refuted_permanence = async () => { const { result, calls } = await run({ agentStub: makeStub({ hunt: (round, key, model) => { - if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [finding(2)] } - if (round === 2 && key === 'state-desync' && model === 'opus') return { findings: [finding(2)] } - return none + if (round === 1 && key === "ws-hub" && model === "opus") return { findings: [finding(2)] }; + if (round === 2 && key === "state-desync" && model === "opus") + return { findings: [finding(2)] }; + return none; }, verify: (round, key, cands) => refuteAll(cands), }), - }) + }); const verifyRounds = calls - .map((c) => /^r(\d+):verify:/.exec(c.opts.label || '')) + .map((c) => /^r(\d+):verify:/.exec(c.opts.label || "")) .filter(Boolean) - .map((m) => Number(m[1])) - assert.deepEqual(verifyRounds, [1], 'refuted candidate must not be re-verified in round 2') - assert.equal(result.rounds[0].refuted, 1) - assert.equal(result.confirmed.length, 0) - assert.equal(result.rounds.length, 2) // refute-only r1 is dry -> converged after r2 - assert.equal(result.converged, true) - assert.ok(!calls.some((c) => c.opts.label === 'report'), 'zero confirmed -> code-built report') - assert.match(result.report, /Converged/i) -} + .map((m) => Number(m[1])); + assert.deepEqual(verifyRounds, [1], "refuted candidate must not be re-verified in round 2"); + assert.equal(result.rounds[0].refuted, 1); + assert.equal(result.confirmed.length, 0); + assert.equal(result.rounds.length, 2); // refute-only r1 is dry -> converged after r2 + assert.equal(result.converged, true); + assert.ok(!calls.some((c) => c.opts.label === "report"), "zero confirmed -> code-built report"); + assert.match(result.report, /Converged/i); +}; // S4: backstop - fresh confirmed bug every round with maxRounds=3 -> stops, NOT converged. scenarios.s4_backstop = async () => { - const firstLens = { 1: 'ws-hub', 2: 'concurrency', 3: 'flow-reconnect' } + const firstLens = { 1: "ws-hub", 2: "concurrency", 3: "flow-reconnect" }; const { result } = await run({ args: { maxRounds: 3 }, agentStub: makeStub({ hunt: (round, key, model) => - model === 'opus' && key === firstLens[round] + model === "opus" && key === firstLens[round] ? { findings: [finding(round, { file: `Server/ws/f${round}.go` })] } : none, verify: (round, key, cands) => confirmAll(cands), }), - }) - assert.equal(result.rounds.length, 3) - assert.equal(result.converged, false) - assert.equal(result.stoppedOnBudget, false) - assert.equal(result.confirmed.length, 3) - assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 0, 0]) -} + }); + assert.equal(result.rounds.length, 3); + assert.equal(result.converged, false); + assert.equal(result.stoppedOnBudget, false); + assert.equal(result.confirmed.length, 3); + assert.deepEqual( + result.rounds.map((r) => r.dryAfter), + [0, 0, 0], + ); +}; // S5: failed finder -> round dry-ineligible; dry counter neither increments nor resets. scenarios.s5_finder_failure_ineligible = async () => { @@ -194,31 +253,34 @@ scenarios.s5_finder_failure_ineligible = async () => { args: { maxRounds: 3 }, agentStub: makeStub({ hunt: (round, key, model) => { - if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [finding(1)] } - if (round === 2 && key === 'concurrency' && model === 'opus') return null // dead finder - return none + if (round === 1 && key === "ws-hub" && model === "opus") return { findings: [finding(1)] }; + if (round === 2 && key === "concurrency" && model === "opus") return null; // dead finder + return none; }, verify: (round, key, cands) => confirmAll(cands), }), - }) - assert.equal(result.rounds[1].dryEligible, false) - assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 0, 1]) - assert.equal(result.converged, false) -} + }); + assert.equal(result.rounds[1].dryEligible, false); + assert.deepEqual( + result.rounds.map((r) => r.dryAfter), + [0, 0, 1], + ); + assert.equal(result.converged, false); +}; // S6: failed verifier retried once, retry succeeds. scenarios.s6_verifier_retry = async () => { const { result, calls } = await run({ agentStub: makeStub({ hunt: (round, key, model) => - round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none, + round === 1 && key === "ws-hub" && model === "opus" ? { findings: [finding(1)] } : none, verify: (round, key, cands, isRetry) => (isRetry ? confirmAll(cands) : null), }), - }) - assert.ok(calls.some((c) => (c.opts.label || '').endsWith(':retry'))) - assert.equal(result.confirmed.length, 1) - assert.equal(result.converged, true) -} + }); + assert.ok(calls.some((c) => (c.opts.label || "").endsWith(":retry"))); + assert.equal(result.confirmed.length, 1); + assert.equal(result.converged, true); +}; // S6b: verifier fails twice -> candidate dropped unconfirmed, round ineligible; // re-reported later, verified then, and scrubbed from the unverified list. @@ -227,19 +289,24 @@ scenarios.s6b_verifier_double_failure = async () => { args: { maxRounds: 3 }, agentStub: makeStub({ hunt: (round, key, model) => { - if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [finding(3)] } - if (round === 2 && key === 'state-desync' && model === 'opus') return { findings: [finding(3)] } - return none + if (round === 1 && key === "ws-hub" && model === "opus") return { findings: [finding(3)] }; + if (round === 2 && key === "state-desync" && model === "opus") + return { findings: [finding(3)] }; + return none; }, verify: (round, key, cands) => (round === 1 ? null : confirmAll(cands)), }), - }) - assert.equal(result.rounds[0].dryEligible, false) - assert.equal(result.rounds[0].confirmed, 0) - assert.equal(result.confirmed.length, 1) - assert.equal(result.confirmed[0].round, 2) - assert.equal(result.unverified.length, 0, 'later-confirmed candidate must leave the unverified list') -} + }); + assert.equal(result.rounds[0].dryEligible, false); + assert.equal(result.rounds[0].confirmed, 0); + assert.equal(result.confirmed.length, 1); + assert.equal(result.confirmed[0].round, 2); + assert.equal( + result.unverified.length, + 0, + "later-confirmed candidate must leave the unverified list", + ); +}; // N1 (spec #1): the top-ranked hotspot cluster sits out exactly the next round, then returns. // The producing cluster keeps running when eligible (the old s7b lock, restated under cooldown). @@ -248,29 +315,39 @@ scenarios.s_cluster_cooldown = async () => { args: { maxRounds: 6, dryThreshold: 9, graph: graphRows(60) }, agentStub: makeStub({ hunt: (round, key) => { - if (round === 1 && key === 'ws-hub') - return { findings: [ - finding(1, { file: 'Server/ws/hub.go', title: 'ws bug alpha one' }), - finding(2, { file: 'Server/ws/pubsub.go', line: 300, title: 'ws bug beta two' }), - ] } - if (round === 2 && key === 'concurrency') - return { findings: [ - finding(3, { file: 'Server/ws/emit.go', title: 'ws bug gamma three' }), - finding(4, { file: 'Server/api/user.go', title: 'api bug delta four' }), - ] } - if (round === 4 && key === 'hotspot-server-ws') - return { findings: [finding(9, { file: 'Server/ws/late.go', title: 'late ws bug nine' })] } - return none + if (round === 1 && key === "ws-hub") + return { + findings: [ + finding(1, { file: "Server/ws/hub.go", title: "ws bug alpha one" }), + finding(2, { file: "Server/ws/pubsub.go", line: 300, title: "ws bug beta two" }), + ], + }; + if (round === 2 && key === "concurrency") + return { + findings: [ + finding(3, { file: "Server/ws/emit.go", title: "ws bug gamma three" }), + finding(4, { file: "Server/api/user.go", title: "api bug delta four" }), + ], + }; + if (round === 4 && key === "hotspot-server-ws") + return { + findings: [finding(9, { file: "Server/ws/late.go", title: "late ws bug nine" })], + }; + return none; }, verify: (round, key, cands) => confirmAll(cands), }), - }) - const hunted = (rnd, key) => calls.some((c) => (c.opts.label || '') === `r${rnd}:hunt:${key}:opus`) - assert.ok(hunted(4, 'hotspot-server-ws'), 'top cluster hunts in r4') - assert.ok(!hunted(5, 'hotspot-server-ws'), 'the r4 top cluster must sit out r5 even though it produced') - assert.ok(hunted(5, 'hotspot-server-api'), 'the next cluster takes the top slot in r5') - assert.ok(hunted(6, 'hotspot-server-ws'), 'cooldown lasts exactly one round') -} + }); + const hunted = (rnd, key) => + calls.some((c) => (c.opts.label || "") === `r${rnd}:hunt:${key}:opus`); + assert.ok(hunted(4, "hotspot-server-ws"), "top cluster hunts in r4"); + assert.ok( + !hunted(5, "hotspot-server-ws"), + "the r4 top cluster must sit out r5 even though it produced", + ); + assert.ok(hunted(5, "hotspot-server-api"), "the next cluster takes the top slot in r5"); + assert.ok(hunted(6, "hotspot-server-ws"), "cooldown lasts exactly one round"); +}; // N2 (spec #2): a cooldown gap FREEZES cleanStreak - neither increments nor resets - so // demotion still means two consecutive clean APPEARANCES. If the gap incremented, ws would @@ -280,25 +357,37 @@ scenarios.s_cooldown_freezes_streak = async () => { args: { maxRounds: 8, dryThreshold: 9, graph: graphRows(100) }, agentStub: makeStub({ hunt: (round, key) => { - if (round === 1 && key === 'ws-hub') - return { findings: [ - finding(1, { file: 'Server/ws/hub.go', title: 'ws bug alpha one' }), - finding(2, { file: 'Server/ws/pubsub.go', line: 300, title: 'ws bug beta two' }), - ] } - if (round === 2 && key === 'concurrency') - return { findings: [finding(3, { file: 'Server/api/user.go', title: 'api bug delta three' })] } - return none + if (round === 1 && key === "ws-hub") + return { + findings: [ + finding(1, { file: "Server/ws/hub.go", title: "ws bug alpha one" }), + finding(2, { file: "Server/ws/pubsub.go", line: 300, title: "ws bug beta two" }), + ], + }; + if (round === 2 && key === "concurrency") + return { + findings: [finding(3, { file: "Server/api/user.go", title: "api bug delta three" })], + }; + return none; }, verify: (round, key, cands) => confirmAll(cands), }), - }) - const hunted = (rnd, key) => calls.some((c) => (c.opts.label || '') === `r${rnd}:hunt:${key}:opus`) - assert.ok(hunted(4, 'hotspot-server-ws'), 'clean appearance #1 in r4') - assert.ok(!hunted(5, 'hotspot-server-ws'), 'cooldown in r5') - assert.ok(hunted(6, 'hotspot-server-ws'), 'the gap must freeze the streak at 1, not increment it') - assert.equal(result.rounds.length, 8, 'the run must reach r8 for the demotion assert to mean anything') - assert.ok(!hunted(8, 'hotspot-server-ws'), 'two clean appearances (r4, r6) demote the lens') -} + }); + const hunted = (rnd, key) => + calls.some((c) => (c.opts.label || "") === `r${rnd}:hunt:${key}:opus`); + assert.ok(hunted(4, "hotspot-server-ws"), "clean appearance #1 in r4"); + assert.ok(!hunted(5, "hotspot-server-ws"), "cooldown in r5"); + assert.ok( + hunted(6, "hotspot-server-ws"), + "the gap must freeze the streak at 1, not increment it", + ); + assert.equal( + result.rounds.length, + 8, + "the run must reach r8 for the demotion assert to mean anything", + ); + assert.ok(!hunted(8, "hotspot-server-ws"), "two clean appearances (r4, r6) demote the lens"); +}; // N3 (spec #3): cooldown+demotion emptying the hotspot pool must backfill from explore and // log it - silent family shrinkage is the exact freshEyesLens() defect this rebuild removes. @@ -307,17 +396,26 @@ scenarios.s_hotspot_backfill = async () => { args: { maxRounds: 5, dryThreshold: 9, graph: graphRows(100) }, agentStub: makeStub({ hunt: (round, key) => - round === 1 && key === 'ws-hub' - ? { findings: [finding(1, { file: 'Server/ws/hub.go', title: 'lone ws bug one' })] } + round === 1 && key === "ws-hub" + ? { findings: [finding(1, { file: "Server/ws/hub.go", title: "lone ws bug one" })] } : none, verify: (round, key, cands) => confirmAll(cands), }), - }) - const r5 = calls.filter((c) => /^r5:hunt:/.test(c.opts.label || '')).map((c) => c.opts.label.split(':')[2]) - assert.ok(!r5.some((k) => k.startsWith('hotspot-')), 'the sole cluster is on cooldown in r5') - assert.deepEqual([...r5].sort(), ['explore-1', 'explore-2', 'explore-3', 'explore-4'], 'the family backfills to full size from explore') - assert.ok(logs.some((l) => /hotspot pool short/.test(l)), 'backfill must be logged, never silent') -} + }); + const r5 = calls + .filter((c) => /^r5:hunt:/.test(c.opts.label || "")) + .map((c) => c.opts.label.split(":")[2]); + assert.ok(!r5.some((k) => k.startsWith("hotspot-")), "the sole cluster is on cooldown in r5"); + assert.deepEqual( + [...r5].sort(), + ["explore-1", "explore-2", "explore-3", "explore-4"], + "the family backfills to full size from explore", + ); + assert.ok( + logs.some((l) => /hotspot pool short/.test(l)), + "backfill must be logged, never silent", + ); +}; // N4 (spec #4): within-run consumption - later rounds draw the NEXT chunk of the ranking, // never re-offering files already handed to an explore lens this run. @@ -326,19 +424,28 @@ scenarios.s_explore_consumption = async () => { args: { maxRounds: 5, dryThreshold: 9, graph: graphRows(80) }, agentStub: makeStub({ hunt: (round, key) => - round === 1 && key === 'ws-hub' - ? { findings: [finding(1, { file: 'Server/ws/hub.go', title: 'seed bug one' })] } + round === 1 && key === "ws-hub" + ? { findings: [finding(1, { file: "Server/ws/hub.go", title: "seed bug one" })] } : none, verify: (round, key, cands) => confirmAll(cands), }), - }) - const promptOf = (rnd, key) => (calls.find((c) => (c.opts.label || '') === `r${rnd}:hunt:${key}:opus`) || {}).prompt || '' - assert.match(promptOf(4, 'explore-1'), /Server\/gen\/g0\.go/) - assert.match(promptOf(4, 'explore-2'), /Server\/gen\/g10\.go/) - assert.match(promptOf(4, 'explore-3'), /Server\/gen\/g20\.go/, 'r4 backfills a third explore lens (single cluster)') - assert.match(promptOf(5, 'explore-1'), /Server\/gen\/g30\.go/, 'r5 draws the next chunk') - assert.doesNotMatch(promptOf(5, 'explore-1'), /Server\/gen\/g0\.go/, 'r5 must not re-offer r4 files') -} + }); + const promptOf = (rnd, key) => + (calls.find((c) => (c.opts.label || "") === `r${rnd}:hunt:${key}:opus`) || {}).prompt || ""; + assert.match(promptOf(4, "explore-1"), /Server\/gen\/g0\.go/); + assert.match(promptOf(4, "explore-2"), /Server\/gen\/g10\.go/); + assert.match( + promptOf(4, "explore-3"), + /Server\/gen\/g20\.go/, + "r4 backfills a third explore lens (single cluster)", + ); + assert.match(promptOf(5, "explore-1"), /Server\/gen\/g30\.go/, "r5 draws the next chunk"); + assert.doesNotMatch( + promptOf(5, "explore-1"), + /Server\/gen\/g0\.go/, + "r5 must not re-offer r4 files", + ); +}; // N6 (spec #6): args.graph absent -> churn-based fresh-eyes fallback, logged, family intact. scenarios.s_graph_missing_fallback = async () => { @@ -346,110 +453,161 @@ scenarios.s_graph_missing_fallback = async () => { args: { maxRounds: 4, dryThreshold: 9 }, agentStub: makeStub({ hunt: (round, key) => - round === 1 && key === 'ws-hub' - ? { findings: [finding(1, { file: 'Server/ws/hub.go', title: 'seed bug one' })] } + round === 1 && key === "ws-hub" + ? { findings: [finding(1, { file: "Server/ws/hub.go", title: "seed bug one" })] } : none, verify: (round, key, cands) => confirmAll(cands), }), - }) - assert.ok(logs.some((l) => /falling back to churn/.test(l)), 'the fallback must be logged') - const e1 = calls.find((c) => (c.opts.label || '') === 'r4:hunt:explore-1:opus') - assert.ok(e1, 'an explore lens must still run from churn') - assert.match(e1.prompt, /Server\/api\/user\.go/, 'churned file with no findings feeds the fallback') -} + }); + assert.ok( + logs.some((l) => /falling back to churn/.test(l)), + "the fallback must be logged", + ); + const e1 = calls.find((c) => (c.opts.label || "") === "r4:hunt:explore-1:opus"); + assert.ok(e1, "an explore lens must still run from churn"); + assert.match( + e1.prompt, + /Server\/api\/user\.go/, + "churned file with no findings feeds the fallback", + ); +}; // S7: rounds 1-3 each confirm a bug -> round 4 runs adaptive lenses: directory-granularity // hotspots plus explore (churn fallback here - no args.graph is passed). scenarios.s7_adaptive_lenses = async () => { - const A = finding(1, { file: 'Server/ws/hub.go', line: 120, title: 'alpha race window one' }) - const B = finding(2, { file: 'Server/ws/pubsub.go', line: 60, title: 'beta subscription leak two' }) - const C = finding(3, { file: 'Client/src/lib/livekitE2EE.ts', line: 200, title: 'gamma epoch desync three' }) + const A = finding(1, { file: "Server/ws/hub.go", line: 120, title: "alpha race window one" }); + const B = finding(2, { + file: "Server/ws/pubsub.go", + line: 60, + title: "beta subscription leak two", + }); + const C = finding(3, { + file: "Client/src/lib/livekitE2EE.ts", + line: 200, + title: "gamma epoch desync three", + }); const { result, calls } = await run({ agentStub: makeStub({ hunt: (round, key) => { - if (round === 1 && key === 'ws-hub') return { findings: [A] } - if (round === 2 && key === 'concurrency') return { findings: [B] } - if (round === 3 && key === 'flow-voice') return { findings: [C] } - return none + if (round === 1 && key === "ws-hub") return { findings: [A] }; + if (round === 2 && key === "concurrency") return { findings: [B] }; + if (round === 3 && key === "flow-voice") return { findings: [C] }; + return none; }, verify: (round, key, cands) => confirmAll(cands), }), - }) - assert.equal(result.converged, true) - assert.equal(result.rounds.length, 5) // r4, r5 adaptive + dry - assert.equal(result.rounds[3].family, 'adaptive') - const r4Keys = [...new Set(calls.filter((c) => /^r4:hunt:/.test(c.opts.label || '')).map((c) => c.opts.label.split(':')[2]))] - assert.ok(r4Keys.includes('hotspot-server-ws'), `r4 keys: ${r4Keys}`) - assert.ok(r4Keys.includes('hotspot-client-src-lib'), `r4 keys: ${r4Keys}`) - assert.ok(r4Keys.includes('explore-1'), `r4 keys: ${r4Keys}`) - const hotspot = calls.find((c) => (c.opts.label || '').includes('hotspot-server-ws')) - assert.match(hotspot.prompt, /Server\/ws\/hub\.go/) - assert.match(hotspot.prompt, /alpha race window one/) - const explore = calls.find((c) => (c.opts.label || '') === 'r4:hunt:explore-1:opus') - assert.match(explore.prompt, /Server\/api\/user\.go/) // churned, never a finding - assert.equal(result.confirmed.length, 3) -} + }); + assert.equal(result.converged, true); + assert.equal(result.rounds.length, 5); // r4, r5 adaptive + dry + assert.equal(result.rounds[3].family, "adaptive"); + const r4Keys = [ + ...new Set( + calls + .filter((c) => /^r4:hunt:/.test(c.opts.label || "")) + .map((c) => c.opts.label.split(":")[2]), + ), + ]; + assert.ok(r4Keys.includes("hotspot-server-ws"), `r4 keys: ${r4Keys}`); + assert.ok(r4Keys.includes("hotspot-client-src-lib"), `r4 keys: ${r4Keys}`); + assert.ok(r4Keys.includes("explore-1"), `r4 keys: ${r4Keys}`); + const hotspot = calls.find((c) => (c.opts.label || "").includes("hotspot-server-ws")); + assert.match(hotspot.prompt, /Server\/ws\/hub\.go/); + assert.match(hotspot.prompt, /alpha race window one/); + const explore = calls.find((c) => (c.opts.label || "") === "r4:hunt:explore-1:opus"); + assert.match(explore.prompt, /Server\/api\/user\.go/); // churned, never a finding + assert.equal(result.confirmed.length, 3); +}; // S7c: a lens whose VERIFIER died is not demoted (its cluster returns after cooldown); // a zero-candidate explore lens still accrues streak and demotes. scenarios.s7c_verifier_failure_not_demoted = async () => { - const early = { 1: 'ws-hub', 2: 'concurrency', 3: 'flow-reconnect' } + const early = { 1: "ws-hub", 2: "concurrency", 3: "flow-reconnect" }; const { result, calls } = await run({ args: { maxRounds: 6, dryThreshold: 9, graph: graphRows(100) }, agentStub: makeStub({ hunt: (round, key) => { if (round <= 3 && key === early[round]) - return { findings: [finding(round, { file: `Server/ws/a${round}.go`, title: `early bug item${round} kappa${round}` })] } - if (round >= 4 && key === 'hotspot-server-ws') - return { findings: [finding(round + 10, { file: `Server/ws/b${round}.go`, title: `late bug item${round} sigma${round}` })] } - return none + return { + findings: [ + finding(round, { + file: `Server/ws/a${round}.go`, + title: `early bug item${round} kappa${round}`, + }), + ], + }; + if (round >= 4 && key === "hotspot-server-ws") + return { + findings: [ + finding(round + 10, { + file: `Server/ws/b${round}.go`, + title: `late bug item${round} sigma${round}`, + }), + ], + }; + return none; }, verify: (round, key, cands) => (round <= 3 ? confirmAll(cands) : null), }), - }) - const labels = calls.map((c) => c.opts.label || '') - assert.ok(labels.some((l) => /^r4:hunt:hotspot-server-ws:/.test(l))) - assert.ok(!labels.some((l) => /^r5:hunt:hotspot-server-ws:/.test(l)), 'cooldown after topping r4') - assert.ok(labels.some((l) => /^r6:hunt:hotspot-server-ws:/.test(l)), 'a verifier-dead lens must NOT be demoted') - assert.ok(!labels.some((l) => /^r6:hunt:explore-1:/.test(l)), 'a zero-candidate explore lens still demotes') - assert.equal(result.confirmed.length, 3) - assert.equal(result.unverified.length, 2) // b4 and b6, each denied a verdict twice - assert.equal(result.converged, false) - assert.equal(result.rounds[3].dryEligible, false) - assert.equal(result.rounds[5].dryEligible, false) -} + }); + const labels = calls.map((c) => c.opts.label || ""); + assert.ok(labels.some((l) => /^r4:hunt:hotspot-server-ws:/.test(l))); + assert.ok( + !labels.some((l) => /^r5:hunt:hotspot-server-ws:/.test(l)), + "cooldown after topping r4", + ); + assert.ok( + labels.some((l) => /^r6:hunt:hotspot-server-ws:/.test(l)), + "a verifier-dead lens must NOT be demoted", + ); + assert.ok( + !labels.some((l) => /^r6:hunt:explore-1:/.test(l)), + "a zero-candidate explore lens still demotes", + ); + assert.equal(result.confirmed.length, 3); + assert.equal(result.unverified.length, 2); // b4 and b6, each denied a verdict twice + assert.equal(result.converged, false); + assert.equal(result.rounds[3].dryEligible, false); + assert.equal(result.rounds[5].dryEligible, false); +}; // S12: empty adaptive family (no confirms, no churn) must break honestly, not count dry rounds. scenarios.s12_empty_adaptive_family = async () => { const { result } = await run({ agentStub: makeStub({ - recon: () => 'no parseable churn output', + recon: () => "no parseable churn output", hunt: (round, key, model) => { - if (round <= 2 && key === (round === 1 ? 'ws-hub' : 'concurrency') && model === 'opus') - return { findings: [finding(round, { file: `Server/ws/c${round}.go`, title: `verifierless bug delta${round} theta${round}` })] } - return none + if (round <= 2 && key === (round === 1 ? "ws-hub" : "concurrency") && model === "opus") + return { + findings: [ + finding(round, { + file: `Server/ws/c${round}.go`, + title: `verifierless bug delta${round} theta${round}`, + }), + ], + }; + return none; }, verify: () => null, }), - }) - assert.equal(result.rounds.length, 3) - assert.equal(result.converged, false) - assert.equal(result.confirmed.length, 0) - assert.equal(result.unverified.length, 2) -} + }); + assert.equal(result.rounds.length, 3); + assert.equal(result.converged, false); + assert.equal(result.confirmed.length, 0); + assert.equal(result.unverified.length, 2); +}; // S8: budget below the round floor before round 1 -> zero rounds, honest non-convergence. scenarios.s8_budget_floor = async () => { const { result, calls } = await run({ budget: { total: 1000000, spent: () => 900000, remaining: () => 100000 }, agentStub: makeStub({ hunt: () => none, verify: (r, k, c) => confirmAll(c) }), - }) - assert.equal(result.rounds.length, 0) - assert.equal(result.stoppedOnBudget, true) - assert.equal(result.converged, false) - assert.ok(!calls.some((c) => /:hunt:/.test(c.opts.label || ''))) - assert.match(result.report, /budget/i) -} + }); + assert.equal(result.rounds.length, 0); + assert.equal(result.stoppedOnBudget, true); + assert.equal(result.converged, false); + assert.ok(!calls.some((c) => /:hunt:/.test(c.opts.label || ""))); + assert.match(result.report, /budget/i); +}; // S8c: budget.total null (directive failed to arm) but args.budgetTotal supplied -> // ceiling armed from args, announced in the config log, computed from spent(). @@ -458,57 +616,79 @@ scenarios.s8c_budget_args_fallback = async () => { args: { budgetTotal: 10000000 }, budget: { total: null, spent: () => 9500000, remaining: () => Infinity }, agentStub: makeStub({ hunt: () => none, verify: (r, k, c) => confirmAll(c) }), - }) - assert.equal(result.rounds.length, 0) - assert.equal(result.stoppedOnBudget, true) - assert.ok(logs.some((l) => /budget=10M/.test(l)), 'args-armed ceiling must announce 10M, not NONE') - assert.equal(result.runStats.config.budgetTotal, 10000000) -} + }); + assert.equal(result.rounds.length, 0); + assert.equal(result.stoppedOnBudget, true); + assert.ok( + logs.some((l) => /budget=10M/.test(l)), + "args-armed ceiling must announce 10M, not NONE", + ); + assert.equal(result.runStats.config.budgetTotal, 10000000); +}; // S8b: budget runs low mid-hunt -> finishes the round it started, stops before the next. scenarios.s8b_budget_midrun = async () => { - let n = 0 + let n = 0; const { result } = await run({ budget: { total: 10000000, spent: () => 0, remaining: () => (n++ === 0 ? 3000000 : 400000) }, agentStub: makeStub({ hunt: (round, key, model) => - round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none, + round === 1 && key === "ws-hub" && model === "opus" ? { findings: [finding(1)] } : none, verify: (round, key, cands) => confirmAll(cands), }), - }) - assert.equal(result.rounds.length, 1) - assert.equal(result.stoppedOnBudget, true) - assert.equal(result.converged, false) - assert.equal(result.confirmed.length, 1) -} + }); + assert.equal(result.rounds.length, 1); + assert.equal(result.stoppedOnBudget, true); + assert.equal(result.converged, false); + assert.equal(result.confirmed.length, 1); +}; // S14: the title-word dedupe branch applies only near the prior's location. // Dedupe is permanent, so merging two distinct same-file bugs that happen to // share half their title words loses the second one forever. scenarios.s14_title_dedupe_window = async () => { - const near = { file: 'Server/ws/hub.go', line: 140, title: 'hub client map race on register path' } - const far = { file: 'Server/ws/hub.go', line: 900, title: 'hub client map race on unregister' } - const verifyBatches = [] + const near = { + file: "Server/ws/hub.go", + line: 140, + title: "hub client map race on register path", + }; + const far = { file: "Server/ws/hub.go", line: 900, title: "hub client map race on unregister" }; + const verifyBatches = []; const { result } = await run({ agentStub: makeStub({ hunt: (round, key, model) => { - if (model !== 'opus') return none - if (round === 1 && key === 'ws-hub') - return { findings: [finding(1, { file: 'Server/ws/hub.go', line: 100, title: 'hub client map race on register' })] } - if (round === 2 && key === 'concurrency') - return { findings: [finding(2, far), finding(3, near)] } - return none + if (model !== "opus") return none; + if (round === 1 && key === "ws-hub") + return { + findings: [ + finding(1, { + file: "Server/ws/hub.go", + line: 100, + title: "hub client map race on register", + }), + ], + }; + if (round === 2 && key === "concurrency") + return { findings: [finding(2, far), finding(3, near)] }; + return none; }, verify: (round, key, cands) => { - verifyBatches.push(cands.map((c) => c.line)) - return confirmAll(cands) + verifyBatches.push(cands.map((c) => c.line)); + return confirmAll(cands); }, }), - }) - assert.deepEqual(verifyBatches, [[100], [900]], 'near-duplicate dropped, distant same-file bug kept') - assert.equal(result.confirmed.length, 2) - assert.ok(result.confirmed.some((c) => c.line === 900), 'the distant bug must survive dedupe') -} + }); + assert.deepEqual( + verifyBatches, + [[100], [900]], + "near-duplicate dropped, distant same-file bug kept", + ); + assert.equal(result.confirmed.length, 2); + assert.ok( + result.confirmed.some((c) => c.line === 900), + "the distant bug must survive dedupe", + ); +}; // S13: JSON-stringified args must behave identically to object args (observed live: the // runtime can deliver args as a string; maxRounds:1 silently fell back to 8 before the coercion). @@ -517,15 +697,15 @@ scenarios.s13_string_args = async () => { args: '{"maxRounds": 1}', agentStub: makeStub({ hunt: (round, key, model) => - round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none, + round === 1 && key === "ws-hub" && model === "opus" ? { findings: [finding(1)] } : none, verify: (round, key, cands) => confirmAll(cands), }), - }) - assert.equal(result.rounds.length, 1, 'string maxRounds:1 must cap the loop at one round') - assert.equal(result.converged, false) - assert.equal(result.confirmed.length, 1) - assert.ok(!calls.some((c) => (c.opts.label || '').startsWith('r2:')), 'no round 2 under the cap') -} + }); + assert.equal(result.rounds.length, 1, "string maxRounds:1 must cap the loop at one round"); + assert.equal(result.converged, false); + assert.equal(result.confirmed.length, 1); + assert.ok(!calls.some((c) => (c.opts.label || "").startsWith("r2:")), "no round 2 under the cap"); +}; // S10: verifier returns truncated (empty) verdict lists on both attempts -> // candidates land in unverified, round ineligible, dry counter untouched. @@ -534,220 +714,284 @@ scenarios.s10_truncated_verdicts = async () => { args: { maxRounds: 2 }, agentStub: makeStub({ hunt: (round, key, model) => - round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none, + round === 1 && key === "ws-hub" && model === "opus" ? { findings: [finding(1)] } : none, verify: () => ({ verdicts: [] }), }), - }) - assert.ok(calls.some((c) => (c.opts.label || '').endsWith(':retry')), 'short verdict list must trigger the retry') - assert.equal(result.rounds[0].dryEligible, false) - assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 1]) - assert.equal(result.confirmed.length, 0) - assert.equal(result.unverified.length, 1) - assert.equal(result.converged, false) -} + }); + assert.ok( + calls.some((c) => (c.opts.label || "").endsWith(":retry")), + "short verdict list must trigger the retry", + ); + assert.equal(result.rounds[0].dryEligible, false); + assert.deepEqual( + result.rounds.map((r) => r.dryAfter), + [0, 1], + ); + assert.equal(result.confirmed.length, 0); + assert.equal(result.unverified.length, 1); + assert.equal(result.converged, false); +}; // S11: verdict coordinates drift from the candidate's -> still pairs, confirms once, // nothing listed unverified, and a round-2 re-report of the ORIGINAL coords is deduped. scenarios.s11_drifted_verdict = async () => { - const orig = finding(4) // file Server/ws/hub.go, line 260 + const orig = finding(4); // file Server/ws/hub.go, line 260 const { result, calls } = await run({ agentStub: makeStub({ hunt: (round, key, model) => { - if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [orig] } - if (round === 2 && key === 'state-desync' && model === 'opus') return { findings: [orig] } - return none + if (round === 1 && key === "ws-hub" && model === "opus") return { findings: [orig] }; + if (round === 2 && key === "state-desync" && model === "opus") return { findings: [orig] }; + return none; }, verify: (round, key, cands) => ({ verdicts: cands.map((c) => ({ - title: c.title, file: c.file, line: c.line + 5, - refuted: false, reason: 'confirmed', confidence: 'high', severity: 'high', fix: 'fix', + title: c.title, + file: c.file, + line: c.line + 5, + refuted: false, + reason: "confirmed", + confidence: "high", + severity: "high", + fix: "fix", })), }), }), - }) + }); const verifyRounds = calls - .map((c) => /^r(\d+):verify:/.exec(c.opts.label || '')) + .map((c) => /^r(\d+):verify:/.exec(c.opts.label || "")) .filter(Boolean) - .map((m) => Number(m[1])) - assert.deepEqual(verifyRounds, [1], 'drifted-but-paired verdict must still suppress the original coords') - assert.equal(result.confirmed.length, 1) - assert.equal(result.unverified.length, 0) - assert.equal(result.converged, true) -} + .map((m) => Number(m[1])); + assert.deepEqual( + verifyRounds, + [1], + "drifted-but-paired verdict must still suppress the original coords", + ); + assert.equal(result.confirmed.length, 1); + assert.equal(result.unverified.length, 0); + assert.equal(result.converged, true); +}; // S-known: a finding already in the ledger is suppressed - never verified, never re-confirmed, // and its text appears in the finder prompt so the model does not spend effort re-deriving it. scenarios.s_known_ledger_suppresses = async () => { const known = [ - { file: 'Server/ws/hub.go', line: 140, title: 'distinct bug alpha1 omega1', status: 'declined' }, - ] + { + file: "Server/ws/hub.go", + line: 140, + title: "distinct bug alpha1 omega1", + status: "declined", + }, + ]; const { result, calls } = await run({ args: { known, maxRounds: 1, dryThreshold: 9 }, agentStub: makeStub({ hunt: (round, key, model) => - round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none, + round === 1 && key === "ws-hub" && model === "opus" ? { findings: [finding(1)] } : none, verify: (round, key, cands) => confirmAll(cands), }), - }) - const huntPrompts = calls.filter((c) => /:hunt:/.test(c.opts.label || '')).map((c) => c.prompt) - assert.ok(huntPrompts.length > 0, 'expected at least one finder call') - assert.match(huntPrompts[0], /KNOWN FINDINGS/, 'ledger entries must reach the finder prompt') - assert.match(huntPrompts[0], /\[declined\] distinct bug alpha1 omega1/) + }); + const huntPrompts = calls.filter((c) => /:hunt:/.test(c.opts.label || "")).map((c) => c.prompt); + assert.ok(huntPrompts.length > 0, "expected at least one finder call"); + assert.match(huntPrompts[0], /KNOWN FINDINGS/, "ledger entries must reach the finder prompt"); + assert.match(huntPrompts[0], /\[declined\] distinct bug alpha1 omega1/); assert.ok( - !calls.some((c) => /:verify:/.test(c.opts.label || '')), - 'a ledger-known candidate must not reach verification', - ) - assert.equal(result.confirmed.length, 0) -} + !calls.some((c) => /:verify:/.test(c.opts.label || "")), + "a ledger-known candidate must not reach verification", + ); + assert.equal(result.confirmed.length, 0); +}; // S-lenses: args.lenses replaces the round-1 family entirely, and the round label reflects it. scenarios.s_custom_lenses = async () => { const lenses = [ - { key: 'voice-e2ee-keyholder', prompt: 'Hunt the key-holder election.' }, - { key: 'voice-e2ee-rotation', prompt: 'Hunt the rotation paths.' }, - ] + { key: "voice-e2ee-keyholder", prompt: "Hunt the key-holder election." }, + { key: "voice-e2ee-rotation", prompt: "Hunt the rotation paths." }, + ]; const { result, calls } = await run({ args: { lenses, maxRounds: 1, dryThreshold: 9 }, agentStub: makeStub({ hunt: () => none, verify: (r, k, c) => confirmAll(c) }), - }) + }); const keys = calls - .map((c) => /^r1:hunt:([a-z0-9-]+):opus$/.exec(c.opts.label || '')) + .map((c) => /^r1:hunt:([a-z0-9-]+):opus$/.exec(c.opts.label || "")) .filter(Boolean) - .map((m) => m[1]) - assert.deepEqual([...new Set(keys)].sort(), ['voice-e2ee-keyholder', 'voice-e2ee-rotation']) - assert.ok(!keys.includes('ws-hub'), 'the default surface family must not run when lenses are supplied') - assert.equal(result.rounds[0].family, 'custom') - assert.equal(result.rounds[0].lenses, 2) -} + .map((m) => m[1]); + assert.deepEqual([...new Set(keys)].sort(), ["voice-e2ee-keyholder", "voice-e2ee-rotation"]); + assert.ok( + !keys.includes("ws-hub"), + "the default surface family must not run when lenses are supplied", + ); + assert.equal(result.rounds[0].family, "custom"); + assert.equal(result.rounds[0].lenses, 2); +}; // S-lenses-default: omitting args.lenses leaves the rotation untouched. scenarios.s_custom_lenses_absent = async () => { const { result } = await run({ args: { maxRounds: 1, dryThreshold: 9 }, agentStub: makeStub({ hunt: () => none, verify: (r, k, c) => confirmAll(c) }), - }) - assert.equal(result.rounds[0].family, 'surfaces') -} + }); + assert.equal(result.rounds[0].family, "surfaces"); +}; // S-ledger-fields: a confirmed record must carry finder detail (why/repro/evidence) as well as // verifier detail (severity/fix), because the ledger needs both. scenarios.s_confirmed_carries_finder_detail = async () => { const cand = { - title: 'distinct bug alpha1 omega1', - file: 'Server/ws/hub.go', + title: "distinct bug alpha1 omega1", + file: "Server/ws/hub.go", line: 140, - severity: 'low', - why: 'WHY_TEXT', - repro: 'REPRO_TEXT', - evidence: 'EVIDENCE_TEXT', - } + severity: "low", + why: "WHY_TEXT", + repro: "REPRO_TEXT", + evidence: "EVIDENCE_TEXT", + }; const { result } = await run({ args: { maxRounds: 1, dryThreshold: 9 }, agentStub: makeStub({ hunt: (round, key, model) => - round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [cand] } : none, + round === 1 && key === "ws-hub" && model === "opus" ? { findings: [cand] } : none, verify: (round, key, cands) => ({ verdicts: cands.map((c) => ({ - title: c.title, file: c.file, line: c.line, - refuted: false, reason: 'confirmed', confidence: 'high', - severity: 'high', fix: 'FIX_TEXT', + title: c.title, + file: c.file, + line: c.line, + refuted: false, + reason: "confirmed", + confidence: "high", + severity: "high", + fix: "FIX_TEXT", })), }), }), - }) - assert.equal(result.confirmed.length, 1) - const r = result.confirmed[0] - assert.equal(r.why, 'WHY_TEXT') - assert.equal(r.repro, 'REPRO_TEXT') - assert.equal(r.evidence, 'EVIDENCE_TEXT') - assert.equal(r.severity, 'high', 'verifier severity must win over the finder rating') - assert.equal(r.fix, 'FIX_TEXT') - assert.equal(r.lens, 'ws-hub') - assert.equal(r.round, 1) - assert.equal(r.finder, 'opus', 'the finder tag is constant now but the ledger still expects it') -} + }); + assert.equal(result.confirmed.length, 1); + const r = result.confirmed[0]; + assert.equal(r.why, "WHY_TEXT"); + assert.equal(r.repro, "REPRO_TEXT"); + assert.equal(r.evidence, "EVIDENCE_TEXT"); + assert.equal(r.severity, "high", "verifier severity must win over the finder rating"); + assert.equal(r.fix, "FIX_TEXT"); + assert.equal(r.lens, "ws-hub"); + assert.equal(r.round, 1); + assert.equal(r.finder, "opus", "the finder tag is constant now but the ledger still expects it"); +}; // S_VERIFIER_IS_NOT_TOLD_THE_FINDER: the verifier prompt deliberately says "another model" and // never names it. Leaking the attribution tag would tell a refute-by-default verifier that opus // found something, which is exactly the kind of authority cue that erodes refute-by-default. scenarios.s_verifier_is_not_told_the_finder = async () => { - let verifyPromptText = '' + let verifyPromptText = ""; await run({ args: { maxRounds: 1, dryThreshold: 9 }, agentStub: makeStub({ hunt: (round, key, model) => - round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none, + round === 1 && key === "ws-hub" && model === "opus" ? { findings: [finding(1)] } : none, verify: (round, key, cands, retry, prompt) => { - verifyPromptText = prompt - return confirmAll(cands) + verifyPromptText = prompt; + return confirmAll(cands); }, }), - }) - assert.ok(verifyPromptText, 'the verifier must have been called') - assert.doesNotMatch(verifyPromptText, /finder/i, 'the verifier must not be told which model found the candidate') + }); + assert.ok(verifyPromptText, "the verifier must have been called"); + assert.doesNotMatch( + verifyPromptText, + /finder/i, + "the verifier must not be told which model found the candidate", + ); // Guard against over-stripping: the fields the verifier actually needs must survive. - for (const field of ['title', 'file', 'line', 'why', 'repro', 'evidence']) { - assert.match(verifyPromptText, new RegExp(`"${field}"`), `candidates must still carry ${field}`) + for (const field of ["title", "file", "line", "why", "repro", "evidence"]) { + assert.match( + verifyPromptText, + new RegExp(`"${field}"`), + `candidates must still carry ${field}`, + ); } -} +}; // New (spec Testing #8): the report is built in-script. Section count must equal the confirmed // count at 82 (the agent version emitted 79 for 82), and the unverified section must survive // the agent's removal - it used to exist only inside the report agent's prompt. scenarios.s_report_deterministic = async () => { const many = Array.from({ length: 82 }, (_, i) => - finding(i, { file: `Server/ws/f${i}.go`, line: 10, title: `unique bug row${i} tag${i}` })) - const stuck = finding(999, { file: 'Server/api/stuck.go', line: 40, title: 'stuck bug never verified' }) + finding(i, { file: `Server/ws/f${i}.go`, line: 10, title: `unique bug row${i} tag${i}` }), + ); + const stuck = finding(999, { + file: "Server/api/stuck.go", + line: 40, + title: "stuck bug never verified", + }); const { result, calls } = await run({ args: { maxRounds: 1, dryThreshold: 9 }, agentStub: makeStub({ hunt: (round, key, model) => - round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [...many, stuck] } : none, - verify: (round, key, cands) => confirmAll(cands.filter((c) => c.file !== 'Server/api/stuck.go')), + round === 1 && key === "ws-hub" && model === "opus" ? { findings: [...many, stuck] } : none, + verify: (round, key, cands) => + confirmAll(cands.filter((c) => c.file !== "Server/api/stuck.go")), }), - }) - assert.equal(result.confirmed.length, 82) - assert.equal(result.unverified.length, 1) - assert.ok(!calls.some((c) => c.opts.label === 'report'), 'no report agent may run') - const sections = (result.report.match(/^### /gm) || []).length - assert.equal(sections, 82, 'one section per confirmed finding, none dropped') - assert.match(result.report, /## Unverified - re-run/) - assert.match(result.report, /stuck bug never verified/) - assert.match(result.report, /## Convergence/) -} + }); + assert.equal(result.confirmed.length, 82); + assert.equal(result.unverified.length, 1); + assert.ok(!calls.some((c) => c.opts.label === "report"), "no report agent may run"); + const sections = (result.report.match(/^### /gm) || []).length; + assert.equal(sections, 82, "one section per confirmed finding, none dropped"); + assert.match(result.report, /## Unverified - re-run/); + assert.match(result.report, /stuck bug never verified/); + assert.match(result.report, /## Convergence/); +}; // New (spec Testing #7): the retry re-sends ONLY unmatched candidates, and N garbage verdicts // (count == candidate count, zero of them matching) must still trigger it - the hole S10 misses // because S10's verdict list is empty rather than full of junk. scenarios.s_targeted_retry = async () => { - const a = finding(1, { file: 'Server/ws/a.go', title: 'alpha bug one paired' }) - const b = finding(2, { file: 'Server/api/b.go', title: 'beta bug two orphaned' }) - const retryBatches = [] + const a = finding(1, { file: "Server/ws/a.go", title: "alpha bug one paired" }); + const b = finding(2, { file: "Server/api/b.go", title: "beta bug two orphaned" }); + const retryBatches = []; const { result } = await run({ args: { maxRounds: 1, dryThreshold: 9 }, agentStub: makeStub({ hunt: (round, key, model) => - round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [a, b] } : none, + round === 1 && key === "ws-hub" && model === "opus" ? { findings: [a, b] } : none, verify: (round, key, cands, isRetry) => { if (isRetry) { - retryBatches.push(cands) - return confirmAll(cands) + retryBatches.push(cands); + return confirmAll(cands); } // one real verdict for a, one garbage verdict pointing nowhere: count matches, content doesn't return { verdicts: [ - { title: a.title, file: a.file, line: a.line, refuted: false, reason: 'ok', confidence: 'high', severity: 'high', fix: 'f' }, - { title: 'hallucinated', file: 'Server/nowhere.go', line: 1, refuted: false, reason: 'x', confidence: 'low', severity: 'low' }, + { + title: a.title, + file: a.file, + line: a.line, + refuted: false, + reason: "ok", + confidence: "high", + severity: "high", + fix: "f", + }, + { + title: "hallucinated", + file: "Server/nowhere.go", + line: 1, + refuted: false, + reason: "x", + confidence: "low", + severity: "low", + }, ], - } + }; }, }), - }) - assert.equal(retryBatches.length, 1, 'retry must fire despite verdict count == candidate count') - assert.deepEqual(retryBatches[0].map((c) => c.file), ['Server/api/b.go'], 'only the unmatched candidate is re-sent') - assert.equal(result.confirmed.length, 2) - assert.equal(result.unverified.length, 0) -} + }); + assert.equal(retryBatches.length, 1, "retry must fire despite verdict count == candidate count"); + assert.deepEqual( + retryBatches[0].map((c) => c.file), + ["Server/api/b.go"], + "only the unmatched candidate is re-sent", + ); + assert.equal(result.confirmed.length, 2); + assert.equal(result.unverified.length, 0); +}; // New (spec Testing #9): the retuned floor must stop a run the old 150k floor let through. // A single opus finder round costs ~100-260k (2026-08-13 run); 400k remaining is under the @@ -756,47 +1000,67 @@ scenarios.s9_budget_ceiling_retuned = async () => { const { result, logs } = await run({ budget: { total: 10000000, spent: () => 9600000, remaining: () => 400000 }, agentStub: makeStub({ hunt: () => none, verify: (r, k, c) => confirmAll(c) }), - }) - assert.equal(result.rounds.length, 0, '400k remaining must not start a round under the 600k floor') - assert.equal(result.stoppedOnBudget, true) - assert.ok(logs.some((l) => /Budget floor/.test(l))) -} + }); + assert.equal( + result.rounds.length, + 0, + "400k remaining must not start a round under the 600k floor", + ); + assert.equal(result.stoppedOnBudget, true); + assert.ok(logs.some((l) => /Budget floor/.test(l))); +}; // New: telemetry. Per-round suppression split (ledger vs same-run), spend sampling, file // coverage, severity mix, per-lens precision, and the top-level runStats aggregate. Without // this every cost figure from a run is eyewitness-only - the 2026-08-12 problem. scenarios.s_telemetry = async () => { - let spent = 0 - const known = [{ file: 'Server/ws/hub.go', line: 100, title: 'known bug from ledger prior', status: 'fixed' }] + let spent = 0; + const known = [ + { file: "Server/ws/hub.go", line: 100, title: "known bug from ledger prior", status: "fixed" }, + ]; const { result } = await run({ args: { maxRounds: 1, dryThreshold: 9, known }, budget: { total: 50000000, spent: () => (spent += 500000), remaining: () => 40000000 }, agentStub: makeStub({ hunt: (round, key, model) => { - if (round !== 1 || key !== 'ws-hub' || model !== 'opus') return none - return { findings: [ - finding(1, { file: 'Server/ws/hub.go', line: 102, title: 'known bug from ledger prior' }), - finding(2, { file: 'Server/api/fresh.go', severity: 'medium', title: 'fresh bug beta gamma' }), - ] } + if (round !== 1 || key !== "ws-hub" || model !== "opus") return none; + return { + findings: [ + finding(1, { + file: "Server/ws/hub.go", + line: 102, + title: "known bug from ledger prior", + }), + finding(2, { + file: "Server/api/fresh.go", + severity: "medium", + title: "fresh bug beta gamma", + }), + ], + }; }, verify: (round, key, cands) => confirmAll(cands), }), - }) - const r1 = result.rounds[0] - assert.equal(r1.suppressedLedger, 1, 'the ledger-known duplicate must be counted as ledger suppression') - assert.equal(r1.suppressedRun, 0) - assert.ok(r1.spentAfter > r1.spentBefore, 'per-round spend must be sampled') - assert.equal(r1.filesTouched, 1) - assert.equal(r1.filesNew, 1) - assert.deepEqual(r1.severity, { critical: 0, high: 0, medium: 1, low: 0 }) - assert.equal(r1.perLens['ws-hub'].confirmed, 1) - assert.equal(r1.perLens['ws-hub'].fresh, 1) - assert.ok(result.runStats, 'runStats missing from the result') - assert.equal(result.runStats.confirmed, 1) - assert.equal(result.runStats.suppressedLedger, 1) - assert.equal(result.runStats.config.maxRounds, 1) - assert.match(result.report, /## Run stats/) -} + }); + const r1 = result.rounds[0]; + assert.equal( + r1.suppressedLedger, + 1, + "the ledger-known duplicate must be counted as ledger suppression", + ); + assert.equal(r1.suppressedRun, 0); + assert.ok(r1.spentAfter > r1.spentBefore, "per-round spend must be sampled"); + assert.equal(r1.filesTouched, 1); + assert.equal(r1.filesNew, 1); + assert.deepEqual(r1.severity, { critical: 0, high: 0, medium: 1, low: 0 }); + assert.equal(r1.perLens["ws-hub"].confirmed, 1); + assert.equal(r1.perLens["ws-hub"].fresh, 1); + assert.ok(result.runStats, "runStats missing from the result"); + assert.equal(result.runStats.confirmed, 1); + assert.equal(result.runStats.suppressedLedger, 1); + assert.equal(result.runStats.config.maxRounds, 1); + assert.match(result.report, /## Run stats/); +}; // N7 (Task 9 review finding): a dead finder on an explore lens read nothing - its draw is // rewound so the files never reach exploredFiles, where the session would record them clean @@ -810,21 +1074,35 @@ scenarios.s_explore_rewind_on_dead_finder = async () => { args: { maxRounds: 4, dryThreshold: 9, graph: graphRows(80) }, agentStub: makeStub({ hunt: (round, key) => { - if (round === 1 && key === 'ws-hub') - return { findings: [finding(1, { file: 'Server/ws/hub.go', title: 'seed bug one' })] } - if (round === 4 && key === 'explore-1') return null // dead finder: read nothing - return none + if (round === 1 && key === "ws-hub") + return { findings: [finding(1, { file: "Server/ws/hub.go", title: "seed bug one" })] }; + if (round === 4 && key === "explore-1") return null; // dead finder: read nothing + return none; }, verify: (round, key, cands) => confirmAll(cands), }), - }) - const promptOf = (rnd, key) => (calls.find((c) => (c.opts.label || '') === `r${rnd}:hunt:${key}:opus`) || {}).prompt || '' - assert.match(promptOf(4, 'explore-1'), /Server\/gen\/g0\.go/, 'r4 explore-1 drew the head of the ranking') + }); + const promptOf = (rnd, key) => + (calls.find((c) => (c.opts.label || "") === `r${rnd}:hunt:${key}:opus`) || {}).prompt || ""; + assert.match( + promptOf(4, "explore-1"), + /Server\/gen\/g0\.go/, + "r4 explore-1 drew the head of the ranking", + ); for (let i = 0; i < 10; i++) - assert.ok(!result.exploredFiles.includes(`Server/gen/g${i}.go`), `g${i} was never read - must not be reported explored`) - assert.ok(result.exploredFiles.includes('Server/gen/g10.go'), 'files a LIVE lens drew stay reported') - assert.ok(result.exploredFiles.includes('Server/gen/g20.go'), 'backfilled live lens files stay reported too') -} + assert.ok( + !result.exploredFiles.includes(`Server/gen/g${i}.go`), + `g${i} was never read - must not be reported explored`, + ); + assert.ok( + result.exploredFiles.includes("Server/gen/g10.go"), + "files a LIVE lens drew stay reported", + ); + assert.ok( + result.exploredFiles.includes("Server/gen/g20.go"), + "backfilled live lens files stay reported too", + ); +}; // N8 (final-review finding): a THROWN stage nulls the whole lens result - the second // finder-failure mode the code documents. Its explore draw must rewind exactly like the @@ -834,21 +1112,36 @@ scenarios.s_explore_rewind_on_thrown_stage = async () => { args: { maxRounds: 4, dryThreshold: 9, graph: graphRows(80) }, agentStub: makeStub({ hunt: (round, key) => { - if (round === 1 && key === 'ws-hub') - return { findings: [finding(1, { file: 'Server/ws/hub.go', title: 'seed bug one' })] } - if (round === 4 && key === 'explore-1') throw new Error('finder infrastructure blew up') - return none + if (round === 1 && key === "ws-hub") + return { findings: [finding(1, { file: "Server/ws/hub.go", title: "seed bug one" })] }; + if (round === 4 && key === "explore-1") throw new Error("finder infrastructure blew up"); + return none; }, verify: (round, key, cands) => confirmAll(cands), }), - }) - const promptOf = (rnd, key) => (calls.find((c) => (c.opts.label || '') === `r${rnd}:hunt:${key}:opus`) || {}).prompt || '' - assert.match(promptOf(4, 'explore-1'), /Server\/gen\/g0\.go/, 'r4 explore-1 drew the head of the ranking') + }); + const promptOf = (rnd, key) => + (calls.find((c) => (c.opts.label || "") === `r${rnd}:hunt:${key}:opus`) || {}).prompt || ""; + assert.match( + promptOf(4, "explore-1"), + /Server\/gen\/g0\.go/, + "r4 explore-1 drew the head of the ranking", + ); for (let i = 0; i < 10; i++) - assert.ok(!result.exploredFiles.includes(`Server/gen/g${i}.go`), `g${i} was never read - must not be reported explored`) - assert.ok(result.exploredFiles.includes('Server/gen/g10.go'), 'files a LIVE lens drew stay reported') - assert.equal(result.rounds[3].dryEligible, false, 'a nulled lens result still makes the round ineligible') -} + assert.ok( + !result.exploredFiles.includes(`Server/gen/g${i}.go`), + `g${i} was never read - must not be reported explored`, + ); + assert.ok( + result.exploredFiles.includes("Server/gen/g10.go"), + "files a LIVE lens drew stay reported", + ); + assert.equal( + result.rounds[3].dryEligible, + false, + "a nulled lens result still makes the round ineligible", + ); +}; // COV1 (spec §3): coverage mode may NOT stop on quietness while inventory files are uncovered. // 60 rows, 10 pre-examined -> 50 to sweep. Nothing is ever found, so dry passes the threshold @@ -856,119 +1149,188 @@ scenarios.s_explore_rewind_on_thrown_stage = async () => { // round 4's explore lenses (quota 4 + backfill 2 slots; 5 draw files, the 6th comes up empty) // cover all 50, then exits. Also locks the enriched explore prompt (class checklist). scenarios.s_coverage_blocks_stop = async () => { - const inv = inventoryRows(60, (i) => (i >= 50 ? { examined: true } : {})) + const inv = inventoryRows(60, (i) => (i >= 50 ? { examined: true } : {})); const { result, calls } = await run({ args: { graph: inv }, agentStub: makeStub({ hunt: () => none, verify: (r, k, c) => confirmAll(c) }), - }) - assert.equal(result.rounds.length, 4, 'must run past the dry threshold (hit at r2) to sweep in r4') - assert.equal(result.converged, true) - assert.deepEqual(result.rounds.map((r) => r.dryAfter), [1, 2, 3, 4]) - assert.deepEqual(result.runStats.coverage, { inventory: 60, preCovered: 10, covered: 60, uncoveredAtStop: 0 }) - assert.match(result.report, /CONVERGED/) - assert.match(result.report, /Coverage: 60\/60 files \(10 pre-covered/) - const ep = (calls.find((c) => (c.opts.label || '') === 'r4:hunt:explore-1:opus') || {}).prompt || '' - assert.match(ep, /error-path data loss/, 'explore lenses carry the distilled class checklist') -} + }); + assert.equal( + result.rounds.length, + 4, + "must run past the dry threshold (hit at r2) to sweep in r4", + ); + assert.equal(result.converged, true); + assert.deepEqual( + result.rounds.map((r) => r.dryAfter), + [1, 2, 3, 4], + ); + assert.deepEqual(result.runStats.coverage, { + inventory: 60, + preCovered: 10, + covered: 60, + uncoveredAtStop: 0, + }); + assert.match(result.report, /CONVERGED/); + assert.match(result.report, /Coverage: 60\/60 files \(10 pre-covered/); + const ep = + (calls.find((c) => (c.opts.label || "") === "r4:hunt:explore-1:opus") || {}).prompt || ""; + assert.match(ep, /error-path data loss/, "explore lenses carry the distilled class checklist"); +}; // COV2 (spec §2+§4): a dead explore finder's files stay uncovered and get re-offered; the // run only converges after a LIVE lens covers them. scenarios.s_coverage_dead_finder = async () => { - const inv = inventoryRows(20) + const inv = inventoryRows(20); const { result, calls } = await run({ args: { graph: inv }, agentStub: makeStub({ - hunt: (round, key) => (round === 4 && key === 'explore-1' ? null : none), + hunt: (round, key) => (round === 4 && key === "explore-1" ? null : none), verify: (r, k, c) => confirmAll(c), }), - }) - const promptOf = (rnd, key) => (calls.find((c) => (c.opts.label || '') === `r${rnd}:hunt:${key}:opus`) || {}).prompt || '' - assert.match(promptOf(4, 'explore-1'), /Server\/gen\/g0\.go/, 'r4 explore-1 drew the head of the pool') - assert.match(promptOf(5, 'explore-1'), /Server\/gen\/g0\.go/, 'dead lens files are re-offered next round') - assert.equal(result.rounds[3].dryEligible, false, 'dead finder keeps the round ineligible') - assert.equal(result.converged, true) - assert.deepEqual(result.runStats.coverage, { inventory: 20, preCovered: 0, covered: 20, uncoveredAtStop: 0 }) -} + }); + const promptOf = (rnd, key) => + (calls.find((c) => (c.opts.label || "") === `r${rnd}:hunt:${key}:opus`) || {}).prompt || ""; + assert.match( + promptOf(4, "explore-1"), + /Server\/gen\/g0\.go/, + "r4 explore-1 drew the head of the pool", + ); + assert.match( + promptOf(5, "explore-1"), + /Server\/gen\/g0\.go/, + "dead lens files are re-offered next round", + ); + assert.equal(result.rounds[3].dryEligible, false, "dead finder keeps the round ineligible"); + assert.equal(result.converged, true); + assert.deepEqual(result.runStats.coverage, { + inventory: 20, + preCovered: 0, + covered: 20, + uncoveredAtStop: 0, + }); +}; // COV7 (amendment 4): explore draws are directory-coherent - one lens reads one module, // not ten strangers. Cross-file classes (state desync, acquire/release pairs) need siblings // in one agent's context. scenarios.s_directory_coherent_draws = async () => { - const inv = inventoryRows(20, (i) => ({ file: i % 2 === 0 ? `Server/alpha/a${i}.go` : `Server/beta/b${i}.go` })) + const inv = inventoryRows(20, (i) => ({ + file: i % 2 === 0 ? `Server/alpha/a${i}.go` : `Server/beta/b${i}.go`, + })); const { calls } = await run({ args: { graph: inv }, agentStub: makeStub({ hunt: () => none, verify: (r, k, c) => confirmAll(c) }), - }) - const p1 = (calls.find((c) => (c.opts.label || '') === 'r4:hunt:explore-1:opus') || {}).prompt || '' - assert.match(p1, /Server\/alpha\/a0\.go/) - assert.match(p1, /Server\/alpha\/a18\.go/, 'all ten alpha files ride in the first lens') - assert.doesNotMatch(p1, /Server\/beta\//, 'no stranger directories in a coherent draw') -} + }); + const p1 = + (calls.find((c) => (c.opts.label || "") === "r4:hunt:explore-1:opus") || {}).prompt || ""; + assert.match(p1, /Server\/alpha\/a0\.go/); + assert.match(p1, /Server\/alpha\/a18\.go/, "all ten alpha files ride in the first lens"); + assert.doesNotMatch(p1, /Server\/beta\//, "no stranger directories in a coherent draw"); +}; // COV8 (Task 5 review finding): an explore lens whose candidates never get a verdict is // denied coverage credit - its draw must return to the pool and be re-offered, or the // consumed-but-uncovered files strand uncoveredCount() above zero and the run can never // converge (it would grind to the round backstop instead). scenarios.s_uncredited_draw_returns_to_pool = async () => { - const inv = inventoryRows(20) + const inv = inventoryRows(20); const { result, calls } = await run({ args: { graph: inv }, agentStub: makeStub({ hunt: (round, key) => - round === 4 && key === 'explore-1' - ? { findings: [finding(1, { file: 'Server/gen/g0.go', title: 'orphaned candidate one' })] } + round === 4 && key === "explore-1" + ? { + findings: [finding(1, { file: "Server/gen/g0.go", title: "orphaned candidate one" })], + } : none, verify: (round, key, cands) => - round === 4 && key === 'explore-1' ? { verdicts: [] } : confirmAll(cands), + round === 4 && key === "explore-1" ? { verdicts: [] } : confirmAll(cands), }), - }) - const promptOf = (rnd, key) => (calls.find((c) => (c.opts.label || '') === `r${rnd}:hunt:${key}:opus`) || {}).prompt || '' - assert.match(promptOf(4, 'explore-1'), /Server\/gen\/g0\.go/) - assert.match(promptOf(5, 'explore-1'), /Server\/gen\/g0\.go/, 'uncredited draw is re-offered next round') - assert.equal(result.rounds[3].dryEligible, false, 'unverified candidates keep the round ineligible') - assert.equal(result.converged, true, 'the run must still converge once a later lens covers the files') - assert.deepEqual(result.runStats.coverage, { inventory: 20, preCovered: 0, covered: 20, uncoveredAtStop: 0 }) - assert.equal(result.unverified.length, 1, 'the orphaned candidate stays reported unverified') -} + }); + const promptOf = (rnd, key) => + (calls.find((c) => (c.opts.label || "") === `r${rnd}:hunt:${key}:opus`) || {}).prompt || ""; + assert.match(promptOf(4, "explore-1"), /Server\/gen\/g0\.go/); + assert.match( + promptOf(5, "explore-1"), + /Server\/gen\/g0\.go/, + "uncredited draw is re-offered next round", + ); + assert.equal( + result.rounds[3].dryEligible, + false, + "unverified candidates keep the round ineligible", + ); + assert.equal( + result.converged, + true, + "the run must still converge once a later lens covers the files", + ); + assert.deepEqual(result.runStats.coverage, { + inventory: 20, + preCovered: 0, + covered: 20, + uncoveredAtStop: 0, + }); + assert.equal(result.unverified.length, 1, "the orphaned candidate stays reported unverified"); +}; // COV3 (spec §4): finders dying every adaptive round -> uncovered never shrinks -> stop with // stalledCoverage after 2 stalled adaptive rounds instead of burning 26 more rounds. scenarios.s_coverage_stall = async () => { - const inv = inventoryRows(20) + const inv = inventoryRows(20); const { result, logs } = await run({ args: { graph: inv }, agentStub: makeStub({ hunt: (round, key) => (/^explore-/.test(key) ? null : none), verify: (r, k, c) => confirmAll(c), }), - }) - assert.equal(result.stalledCoverage, true) - assert.equal(result.converged, false) - assert.equal(result.rounds.length, 5, 'r1-3 families, then exactly 2 stalled adaptive rounds') - assert.equal(result.runStats.coverage.uncoveredAtStop, 20) - assert.ok(logs.some((l) => /Coverage stalled/.test(l))) - assert.match(result.report, /NOT converged - coverage stalled/) -} + }); + assert.equal(result.stalledCoverage, true); + assert.equal(result.converged, false); + assert.equal(result.rounds.length, 5, "r1-3 families, then exactly 2 stalled adaptive rounds"); + assert.equal(result.runStats.coverage.uncoveredAtStop, 20); + assert.ok(logs.some((l) => /Coverage stalled/.test(l))); + assert.match(result.report, /NOT converged - coverage stalled/); +}; // COV4 (spec §2 smart depth): once the sweep completes, the 5 bug-class lenses run once more // scoped to the risky files - then the run may converge. scenarios.s_risky_sweep = async () => { - const inv = inventoryRows(10, (i) => (i < 2 ? { risky: true } : {})) + const inv = inventoryRows(10, (i) => (i < 2 ? { risky: true } : {})); const { result, calls } = await run({ args: { graph: inv }, agentStub: makeStub({ hunt: () => none, verify: (r, k, c) => confirmAll(c) }), - }) - const r5keys = [...new Set(calls.filter((c) => /^r5:hunt:/.test(c.opts.label || '')).map((c) => c.opts.label.split(':')[2]))] - assert.deepEqual(r5keys.sort(), ['risky-concurrency', 'risky-error-paths', 'risky-lifecycle', 'risky-ordering-boundary', 'risky-state-desync']) - const p = calls.find((c) => (c.opts.label || '') === 'r5:hunt:risky-concurrency:opus').prompt - assert.match(p, /Server\/gen\/g0\.go/) - assert.match(p, /Server\/gen\/g1\.go/) - assert.doesNotMatch(p, /Server\/gen\/g5\.go/, 'the sweep is scoped to risky files only') - assert.equal(result.rounds[4].family, 'risky-sweep') - assert.equal(result.converged, true) - assert.equal(result.rounds.length, 5, 'dry was already past threshold - the run ends right after the risky sweep') - assert.ok(!calls.some((c) => /^r6:hunt:risky-/.test(c.opts.label || '')), 'the risky sweep runs exactly once') -} + }); + const r5keys = [ + ...new Set( + calls + .filter((c) => /^r5:hunt:/.test(c.opts.label || "")) + .map((c) => c.opts.label.split(":")[2]), + ), + ]; + assert.deepEqual(r5keys.sort(), [ + "risky-concurrency", + "risky-error-paths", + "risky-lifecycle", + "risky-ordering-boundary", + "risky-state-desync", + ]); + const p = calls.find((c) => (c.opts.label || "") === "r5:hunt:risky-concurrency:opus").prompt; + assert.match(p, /Server\/gen\/g0\.go/); + assert.match(p, /Server\/gen\/g1\.go/); + assert.doesNotMatch(p, /Server\/gen\/g5\.go/, "the sweep is scoped to risky files only"); + assert.equal(result.rounds[4].family, "risky-sweep"); + assert.equal(result.converged, true); + assert.equal( + result.rounds.length, + 5, + "dry was already past threshold - the run ends right after the risky sweep", + ); + assert.ok( + !calls.some((c) => /^r6:hunt:risky-/.test(c.opts.label || "")), + "the risky sweep runs exactly once", + ); +}; // COV5: legacy rows (no `examined` key) leave every new mechanism inert - old stop rule, // no coverage stats, no risky sweep. @@ -976,35 +1338,35 @@ scenarios.s_legacy_rows_inert = async () => { const { result, calls } = await run({ args: { graph: graphRows(30) }, agentStub: makeStub({ hunt: () => none, verify: (r, k, c) => confirmAll(c) }), - }) - assert.equal(result.rounds.length, 2, 'legacy mode still converges on the plain dry threshold') - assert.equal(result.converged, true) - assert.equal(result.runStats.coverage, null) - assert.equal(result.stalledCoverage, false) - assert.ok(!calls.some((c) => /risky-/.test(c.opts.label || ''))) -} + }); + assert.equal(result.rounds.length, 2, "legacy mode still converges on the plain dry threshold"); + assert.equal(result.converged, true); + assert.equal(result.runStats.coverage, null); + assert.equal(result.stalledCoverage, false); + assert.ok(!calls.some((c) => /risky-/.test(c.opts.label || ""))); +}; // COV6 (amendment 1): a confirm on the sweep's last round resets dry to 0; hotspot rounds then // go clean, cooldown + an empty pool empty the family - in coverage mode that emptiness IS // quietness and must count dry rounds instead of stranding a fully-covered run at converged:false. scenarios.s_exhausted_counts_dry = async () => { - const inv = inventoryRows(10) + const inv = inventoryRows(10); const { result } = await run({ args: { graph: inv }, agentStub: makeStub({ hunt: (round, key) => - round === 4 && key === 'explore-1' - ? { findings: [finding(1, { file: 'Server/gen/g0.go', title: 'late sweep bug one' })] } + round === 4 && key === "explore-1" + ? { findings: [finding(1, { file: "Server/gen/g0.go", title: "late sweep bug one" })] } : none, verify: (r, k, c) => confirmAll(c), }), - }) - assert.equal(result.converged, true, 'a fully-covered, fully-quiet run must converge') - assert.equal(result.rounds.length, 6) - assert.equal(result.rounds[5].family, 'exhausted') - assert.equal(result.rounds[5].dryAfter, 2) - assert.equal(result.confirmed.length, 1) -} + }); + assert.equal(result.converged, true, "a fully-covered, fully-quiet run must converge"); + assert.equal(result.rounds.length, 6); + assert.equal(result.rounds[5].family, "exhausted"); + assert.equal(result.rounds[5].dryAfter, 2); + assert.equal(result.confirmed.length, 1); +}; // COV9 (Task 6 review finding): a stuck pool must NOT stop a hunt that is still confirming. // Dead explore lenses pin uncovered at 20 while hotspot lenses confirm fresh bugs in rounds @@ -1012,41 +1374,53 @@ scenarios.s_exhausted_counts_dry = async () => { // and only then stops with stalledCoverage (without the productivity term it would have // stopped at round 5, mid-yield). scenarios.s_stall_deferred_while_productive = async () => { - const inv = inventoryRows(20) + const inv = inventoryRows(20); const { result, logs } = await run({ args: { graph: inv }, agentStub: makeStub({ hunt: (round, key) => { - if (round === 1 && key === 'ws-hub') - return { findings: [finding(1, { file: 'Server/ws/hub.go', title: 'seed bug alpha one' })] } - if (round === 4 && key === 'hotspot-server-ws') - return { findings: [finding(2, { file: 'Server/ws/emit.go', title: 'adjacent bug beta two' })] } - if (round === 6 && key === 'hotspot-server-ws') - return { findings: [finding(3, { file: 'Server/ws/pubsub.go', title: 'adjacent bug gamma three' })] } - if (/^explore-/.test(key)) return null // dead explore finders: the pool never shrinks - return none + if (round === 1 && key === "ws-hub") + return { + findings: [finding(1, { file: "Server/ws/hub.go", title: "seed bug alpha one" })], + }; + if (round === 4 && key === "hotspot-server-ws") + return { + findings: [finding(2, { file: "Server/ws/emit.go", title: "adjacent bug beta two" })], + }; + if (round === 6 && key === "hotspot-server-ws") + return { + findings: [ + finding(3, { file: "Server/ws/pubsub.go", title: "adjacent bug gamma three" }), + ], + }; + if (/^explore-/.test(key)) return null; // dead explore finders: the pool never shrinks + return none; }, verify: (r, k, c) => confirmAll(c), }), - }) - assert.equal(result.rounds.length, 8, 'productive rounds 4 and 6 must defer the stall to round 8') - assert.equal(result.stalledCoverage, true) - assert.equal(result.converged, false) - assert.equal(result.confirmed.length, 3) - assert.equal(result.runStats.coverage.uncoveredAtStop, 20) - assert.ok(logs.some((l) => /Coverage stalled/.test(l))) -} + }); + assert.equal( + result.rounds.length, + 8, + "productive rounds 4 and 6 must defer the stall to round 8", + ); + assert.equal(result.stalledCoverage, true); + assert.equal(result.converged, false); + assert.equal(result.confirmed.length, 3); + assert.equal(result.runStats.coverage.uncoveredAtStop, 20); + assert.ok(logs.some((l) => /Coverage stalled/.test(l))); +}; // ---------- runner ---------- -const only = process.argv[2] +const only = process.argv[2]; for (const [name, fn] of Object.entries(scenarios)) { - if (only && !name.includes(only)) continue + if (only && !name.includes(only)) continue; try { - await fn() + await fn(); } catch (e) { - console.error(`FAIL ${name}`) - throw e + console.error(`FAIL ${name}`); + throw e; } - console.log(`PASS ${name}`) + console.log(`PASS ${name}`); } -console.log('all scenarios pass') +console.log("all scenarios pass"); diff --git a/.claude/workflows/bughunt.js b/.claude/workflows/bughunt.js index 3fa7bfc0..1f84fb77 100644 --- a/.claude/workflows/bughunt.js +++ b/.claude/workflows/bughunt.js @@ -1,88 +1,102 @@ export const meta = { - name: 'bughunt', - description: 'Converging multi-round bug hunt: rotating lens families, single opus finder, opus refute-by-default verification, dry-threshold stop', - whenToUse: 'Hunting real bugs across the Go server, Tauri Rust backend, and TS client until consecutive rounds go dry. Not a security-only scan.', - phases: [ - { title: 'Recon', detail: 'haiku: churn + concurrency-surface inventory' }, - ], -} + name: "bughunt", + description: + "Converging multi-round bug hunt: rotating lens families, single opus finder, opus refute-by-default verification, dry-threshold stop", + whenToUse: + "Hunting real bugs across the Go server, Tauri Rust backend, and TS client until consecutive rounds go dry. Not a security-only scan.", + phases: [{ title: "Recon", detail: "haiku: churn + concurrency-surface inventory" }], +}; // ---------- config ---------- // args may arrive JSON-stringified (observed in run wf_9199e623-b83: maxRounds:1 never took) - coerce const ARGS = (() => { - if (typeof args === 'string') { - try { return JSON.parse(args) || {} } catch { return {} } + if (typeof args === "string") { + try { + return JSON.parse(args) || {}; + } catch { + return {}; + } } - return args || {} -})() -const MAX_ROUNDS = ARGS.maxRounds || 30 -const DRY_THRESHOLD = ARGS.dryThreshold || 2 + return args || {}; +})(); +const MAX_ROUNDS = ARGS.maxRounds || 30; +const DRY_THRESHOLD = ARGS.dryThreshold || 2; // A scoped hunt (args.lenses) replaces the round-1 family outright; later rounds still go // adaptive, so hotspot and explore coverage - and therefore convergence - still work. -const CUSTOM_LENSES = Array.isArray(ARGS.lenses) && ARGS.lenses.length ? ARGS.lenses : null +const CUSTOM_LENSES = Array.isArray(ARGS.lenses) && ARGS.lenses.length ? ARGS.lenses : null; // Floor for one round. The single opus finder (sonnet retired 2026-08-12) costs ~100-260k per // round, measured across the 8-round 2026-08-13 run. The old 2M floor was a dual-finder-era // anchor (~2.6M/round) that would zero-out any hunt launched with a budget under 2M - now that // budgetTotal is a first-class arg, that cliff is a foot-gun. 600k is ~3x a measured round. -const ROUND_BUDGET_FLOOR = 600000 +const ROUND_BUDGET_FLOOR = 600000; // The turn directive failed to arm budget.total on the 2026-08-13 live run (+25M present, // total still null), so args.budgetTotal is the deterministic fallback. budget.spent() // works even when total is null; budget.remaining() stays authoritative when the // directive DID arm, because stubs (and the runtime) may track it statefully. -const BUDGET_TOTAL = budget.total || Number(ARGS.budgetTotal) || null -const remainingBudget = () => (budget.total ? budget.remaining() : BUDGET_TOTAL ? Math.max(0, BUDGET_TOTAL - budget.spent()) : Infinity) +const BUDGET_TOTAL = budget.total || Number(ARGS.budgetTotal) || null; +const remainingBudget = () => + budget.total + ? budget.remaining() + : BUDGET_TOTAL + ? Math.max(0, BUDGET_TOTAL - budget.spent()) + : Infinity; // The args channel has already been observed delivering something the script // could not read; an unnoticed fallback here is an 8x cost surprise, so say out // loud what the run is actually going to do. -log(`config: maxRounds=${MAX_ROUNDS} dryThreshold=${DRY_THRESHOLD}${CUSTOM_LENSES ? ` lenses=custom(${CUSTOM_LENSES.length})` : ''} budget=${BUDGET_TOTAL ? Math.round(BUDGET_TOTAL / 1e6) + 'M' : 'NONE - cost ceiling disarmed'}`) +log( + `config: maxRounds=${MAX_ROUNDS} dryThreshold=${DRY_THRESHOLD}${CUSTOM_LENSES ? ` lenses=custom(${CUSTOM_LENSES.length})` : ""} budget=${BUDGET_TOTAL ? Math.round(BUDGET_TOTAL / 1e6) + "M" : "NONE - cost ceiling disarmed"}`, +); // ---------- schemas: copied VERBATIM from the current bughunt.js ---------- const FINDINGS = { - type: 'object', - required: ['findings'], + type: "object", + required: ["findings"], properties: { findings: { - type: 'array', + type: "array", items: { - type: 'object', - required: ['title', 'file', 'line', 'severity', 'why', 'repro'], + type: "object", + required: ["title", "file", "line", "severity", "why", "repro"], properties: { - title: { type: 'string' }, - file: { type: 'string', description: 'repo-relative path' }, - line: { type: 'integer' }, - severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] }, - why: { type: 'string', description: 'the defect, one or two sentences' }, - repro: { type: 'string', description: 'concrete inputs/interleaving -> wrong behavior' }, - evidence: { type: 'string', description: 'the code lines that prove it' }, + title: { type: "string" }, + file: { type: "string", description: "repo-relative path" }, + line: { type: "integer" }, + severity: { type: "string", enum: ["critical", "high", "medium", "low"] }, + why: { type: "string", description: "the defect, one or two sentences" }, + repro: { type: "string", description: "concrete inputs/interleaving -> wrong behavior" }, + evidence: { type: "string", description: "the code lines that prove it" }, }, }, }, }, -} +}; const VERDICTS = { - type: 'object', - required: ['verdicts'], + type: "object", + required: ["verdicts"], properties: { verdicts: { - type: 'array', + type: "array", items: { - type: 'object', - required: ['title', 'file', 'line', 'refuted', 'reason', 'confidence', 'severity'], + type: "object", + required: ["title", "file", "line", "refuted", "reason", "confidence", "severity"], properties: { - title: { type: 'string' }, - file: { type: 'string' }, - line: { type: 'integer' }, - refuted: { type: 'boolean' }, - reason: { type: 'string', description: 'what refutes it, or what confirms it in the code' }, - confidence: { type: 'string', enum: ['high', 'medium', 'low'] }, - severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] }, - fix: { type: 'string', description: 'smallest correct fix, if confirmed' }, + title: { type: "string" }, + file: { type: "string" }, + line: { type: "integer" }, + refuted: { type: "boolean" }, + reason: { + type: "string", + description: "what refutes it, or what confirms it in the code", + }, + confidence: { type: "string", enum: ["high", "medium", "low"] }, + severity: { type: "string", enum: ["critical", "high", "medium", "low"] }, + fix: { type: "string", description: "smallest correct fix, if confirmed" }, }, }, }, }, -} +}; // ---------- rules ---------- const RULES = ` @@ -112,13 +126,13 @@ Method: You may run read-only shell commands (grep, git log, go doc). Do not modify any file. Do not run the test suite. -` +`; // ---------- lens catalog ---------- // keys must match /^[a-z0-9-]+$/ - they are embedded in agent labels the harness parses. const SURFACE_LENSES = [ { - key: 'ws-hub', + key: "ws-hub", prompt: `Surface: the WebSocket hub and its client lifecycle. Files: Server/ws/*.go (skip *_test.go) - start with ` + `client.go, hub*.go, emit.go, event.go, event_persister.go, event_pruner.go, handlers*.go, command.go.\n\n` + @@ -129,7 +143,7 @@ const SURFACE_LENSES = [ `Trace at least one full connect -> subscribe -> emit -> disconnect path end to end before reporting anything.`, }, { - key: 'voice-e2ee', + key: "voice-e2ee", prompt: `Surface: voice/video E2EE key lifecycle, spanning three languages. Files: Server/ws/handler_v2_voice*.go and ` + `any Server/ws/*voice*.go or *e2ee*.go; Client/src/lib/e2eeCrypto.ts, livekitE2EE.ts, ` + @@ -141,7 +155,7 @@ const SURFACE_LENSES = [ `This area was hardened before - check git log for the relevant commits and do NOT re-report anything already fixed.`, }, { - key: 'api-authz', + key: "api-authz", prompt: `Surface: REST API auth and authorization. Files: Server/api/*.go (skip *_test.go), Server/auth/*.go, ` + `Server/permissions/*.go.\n\n` + @@ -153,7 +167,7 @@ const SURFACE_LENSES = [ `Compare handlers against each other - the strongest signal here is inconsistency between siblings.`, }, { - key: 'db-storage', + key: "db-storage", prompt: `Surface: persistence. Files: Server/db/*.go (NOT db/dbgen/, that is generated), Server/db/queries/*.sql, ` + `Server/migrations/*.sql, Server/storage/*.go, Server/service/*.go.\n\n` + @@ -165,7 +179,7 @@ const SURFACE_LENSES = [ `Read the .sql alongside its Go caller - the bug is usually the gap between them.`, }, { - key: 'tauri-rust', + key: "tauri-rust", prompt: `Surface: the Tauri Rust backend. Files: Client/src-tauri/src/*.rs.\n\n` + `Hunt specifically for: a panic reachable from a Tauri command (unwrap/expect on attacker- or ` + @@ -176,7 +190,7 @@ const SURFACE_LENSES = [ `For each panic you find, state exactly which input reaches it.`, }, { - key: 'client-state', + key: "client-state", prompt: `Surface: TypeScript client state and event handling. Files: Client/src/lib/*.ts and ` + `src/stores/*.ts - prioritize dispatcher.ts, reconcile.ts, read-state.ts, router.ts, roomEventHandlers.ts, ` + @@ -188,11 +202,11 @@ const SURFACE_LENSES = [ `(last-write-wins race); a route guard bypassable by a rapid navigation sequence.\n` + `Check tests/unit/ before reporting - much of this behavior is already test-locked.`, }, -] +]; const BUGCLASS_LENSES = [ { - key: 'concurrency', + key: "concurrency", prompt: `Bug class: concurrency and interleaving - sweep the whole repo for THIS CLASS ONLY.\n` + `Go (Server/): data races on maps/slices/fields shared between goroutines; lock-order inversion; ` + @@ -204,7 +218,7 @@ const BUGCLASS_LENSES = [ `Use the recon concurrency-surface inventory to pick files. For every candidate, name the exact interleaving.`, }, { - key: 'lifecycle', + key: "lifecycle", prompt: `Bug class: lifecycle and teardown - sweep the whole repo for THIS CLASS ONLY.\n` + `Every acquire must have a matching release on EVERY exit path: goroutines outliving their owner; ` + @@ -214,7 +228,7 @@ const BUGCLASS_LENSES = [ `partial teardown when an error interrupts the happy path halfway.`, }, { - key: 'state-desync', + key: "state-desync", prompt: `Bug class: two sources of truth drifting - sweep the whole repo for THIS CLASS ONLY.\n` + `Pairs to audit: hub client maps vs pubsub registrations; server voice state vs LiveKit vs client ` + @@ -223,7 +237,7 @@ const BUGCLASS_LENSES = [ `other - reconnect, replacement, and error paths are where they diverge.`, }, { - key: 'error-paths', + key: "error-paths", prompt: `Bug class: error-path data loss - sweep the whole repo for THIS CLASS ONLY.\n` + `Swallowed errors (err assigned and ignored, empty catch, unwrap_or(default) hiding failure); ` + @@ -232,7 +246,7 @@ const BUGCLASS_LENSES = [ `Read every 'if err != nil', catch block, and .catch in the hot files from recon.`, }, { - key: 'ordering-boundary', + key: "ordering-boundary", prompt: `Bug class: ordering and boundaries - sweep the whole repo for THIS CLASS ONLY.\n` + `Off-by-one and fence-post errors; LIMIT/pagination silently truncating; sequence-number gaps, ` + @@ -240,11 +254,11 @@ const BUGCLASS_LENSES = [ `first/last/empty-collection special cases; inclusive-vs-exclusive range disagreements between a ` + `caller and its callee (read the SQL alongside its Go caller).`, }, -] +]; const FLOW_LENSES = [ { - key: 'flow-reconnect', + key: "flow-reconnect", prompt: `Flow: WebSocket drop -> reconnect -> resume. Trace it END TO END across all three languages before ` + `reporting anything. Server: the serve handshake/resume path, hub client replacement and state ` + @@ -255,7 +269,7 @@ const FLOW_LENSES = [ `reach the new one.`, }, { - key: 'flow-voice', + key: "flow-voice", prompt: `Flow: voice join -> E2EE key announce/offer -> key-holder election -> rotation -> participant ` + `leave -> LiveKit webhook -> cleanup. Trace it END TO END: Server/ws/*voice*, livekit_webhook.go, ` + @@ -264,7 +278,7 @@ const FLOW_LENSES = [ `reconnect; the three take-out-of-voice paths (webhook, sweep, voice_leave) diverging.`, }, { - key: 'flow-message', + key: "flow-message", prompt: `Flow: message send -> permission gate -> persist -> sequence assign -> fan-out -> replay tiers -> ` + `client store -> read-state/unread counts. Trace it END TO END and hunt the gaps BETWEEN layers: ` + @@ -272,159 +286,179 @@ const FLOW_LENSES = [ `seq was dropped; unread counts drifting from actual unread messages across reconnect or channel switch.`, }, { - key: 'flow-session', + key: "flow-session", prompt: `Flow: login -> session/token issue -> per-connection auth -> revocation/sweep -> kick -> API-token ` + `paths. Trace it END TO END and hunt stale-authorization windows: state checked at connect but not ` + `re-checked at use; revocation that kicks the WS but leaves another surface authorized; the sweep ` + `racing an in-flight request; API tokens diverging from session-token semantics on any path.`, }, -] +]; -let riskySweepDone = false +let riskySweepDone = false; function riskySweepLenses() { - if (riskySweepDone || !HAS_INVENTORY || !RISKY_FILES.length || uncoveredCount() > 0) return null - riskySweepDone = true // consumed even if this round's finders die: same at-most-once semantics as cooldown - const list = RISKY_FILES.map((f) => ` - ${f}`).join('\n') + if (riskySweepDone || !HAS_INVENTORY || !RISKY_FILES.length || uncoveredCount() > 0) return null; + riskySweepDone = true; // consumed even if this round's finders die: same at-most-once semantics as cooldown + const list = RISKY_FILES.map((f) => ` - ${f}`).join("\n"); return BUGCLASS_LENSES.map((l) => ({ key: `risky-${l.key}`, prompt: `${l.prompt}\n\nScope this sweep to ONLY these highest-risk files (read each one in full):\n${list}`, - })) + })); } -let currentFamilyName = 'surfaces' +let currentFamilyName = "surfaces"; function lensesForRound(round) { - const pick = (name, lenses) => { currentFamilyName = name; return lenses } + const pick = (name, lenses) => { + currentFamilyName = name; + return lenses; + }; if (CUSTOM_LENSES) { - if (round === 1) return pick('custom', CUSTOM_LENSES) + if (round === 1) return pick("custom", CUSTOM_LENSES); } else { - if (round === 1) return pick('surfaces', SURFACE_LENSES) - if (round === 2) return pick('bug-classes', BUGCLASS_LENSES) - if (round === 3) return pick('flows', FLOW_LENSES) + if (round === 1) return pick("surfaces", SURFACE_LENSES); + if (round === 2) return pick("bug-classes", BUGCLASS_LENSES); + if (round === 3) return pick("flows", FLOW_LENSES); } - const risky = riskySweepLenses() - if (risky) return pick('risky-sweep', risky) - return pick('adaptive', buildAdaptiveLenses(round)) + const risky = riskySweepLenses(); + if (risky) return pick("risky-sweep", risky); + return pick("adaptive", buildAdaptiveLenses(round)); +} +function familyName() { + return currentFamilyName; } -function familyName() { return currentFamilyName } // Directory granularity: the old two/three-segment cluster collapsed the whole TS client into // one bucket (35 of 82 findings), so the "top cluster" never changed for five straight rounds. function clusterOf(file) { - const parts = String(file).split('/') - return parts.length > 1 ? parts.slice(0, -1).join('/') : parts[0] + const parts = String(file).split("/"); + return parts.length > 1 ? parts.slice(0, -1).join("/") : parts[0]; } // ---------- explore targeting ---------- // args.graph: session-computed coupling ranking (rank-explore.mjs). The workflow only reads // .file - scoring already happened outside, where the filesystem is. -const GRAPH_ROWS = (Array.isArray(ARGS.graph) ? ARGS.graph : []).filter((r) => r && typeof r.file === 'string') +const GRAPH_ROWS = (Array.isArray(ARGS.graph) ? ARGS.graph : []).filter( + (r) => r && typeof r.file === "string", +); // ---------- coverage mode (spec 2026-08-20) ---------- // Arms only when rows carry the `examined` flag (full inventory from rank-explore.mjs). // Legacy rows and the churn fallback leave all of this inert: covered stays empty, // uncoveredCount() is 0, and the loop condition reduces to the old dry-threshold rule. -const HAS_INVENTORY = GRAPH_ROWS.some((r) => 'examined' in r) -const INVENTORY = HAS_INVENTORY ? GRAPH_ROWS.map((r) => r.file) : [] -const covered = new Set(HAS_INVENTORY ? GRAPH_ROWS.filter((r) => r.examined).map((r) => r.file) : []) -const PRE_COVERED = covered.size -const RISKY_FILES = HAS_INVENTORY ? GRAPH_ROWS.filter((r) => r.risky).map((r) => r.file) : [] -const uncoveredCount = () => (HAS_INVENTORY ? INVENTORY.reduce((n, f) => n + (covered.has(f) ? 0 : 1), 0) : 0) -if (HAS_INVENTORY) log(`coverage: inventory=${INVENTORY.length} preCovered=${PRE_COVERED} risky=${RISKY_FILES.length}`) -const EXPLORE_FILES_PER_LENS = 10 -const exploreConsumed = new Set() // within-run consumption: never re-offer a file to a later round -let exploreFallbackLogged = false +const HAS_INVENTORY = GRAPH_ROWS.some((r) => "examined" in r); +const INVENTORY = HAS_INVENTORY ? GRAPH_ROWS.map((r) => r.file) : []; +const covered = new Set( + HAS_INVENTORY ? GRAPH_ROWS.filter((r) => r.examined).map((r) => r.file) : [], +); +const PRE_COVERED = covered.size; +const RISKY_FILES = HAS_INVENTORY ? GRAPH_ROWS.filter((r) => r.risky).map((r) => r.file) : []; +const uncoveredCount = () => + HAS_INVENTORY ? INVENTORY.reduce((n, f) => n + (covered.has(f) ? 0 : 1), 0) : 0; +if (HAS_INVENTORY) + log( + `coverage: inventory=${INVENTORY.length} preCovered=${PRE_COVERED} risky=${RISKY_FILES.length}`, + ); +const EXPLORE_FILES_PER_LENS = 10; +const exploreConsumed = new Set(); // within-run consumption: never re-offer a file to a later round +let exploreFallbackLogged = false; function drawExploreFiles() { - let pool - if (GRAPH_ROWS.length) pool = GRAPH_ROWS.map((r) => r.file) + let pool; + if (GRAPH_ROWS.length) pool = GRAPH_ROWS.map((r) => r.file); else { if (!exploreFallbackLogged) { - log('explore: args.graph absent/empty - falling back to churn-based fresh eyes') - exploreFallbackLogged = true + log("explore: args.graph absent/empty - falling back to churn-based fresh eyes"); + exploreFallbackLogged = true; } - pool = churnFiles + pool = churnFiles; } - const avail = pool.filter((f) => !exploreConsumed.has(f) && !covered.has(f) && !seen.some((s) => s.file === f)) - const files = [] + const avail = pool.filter( + (f) => !exploreConsumed.has(f) && !covered.has(f) && !seen.some((s) => s.file === f), + ); + const files = []; while (files.length < EXPLORE_FILES_PER_LENS && avail.length) { - const head = avail.shift() - files.push(head) - const dir = clusterOf(head) + const head = avail.shift(); + files.push(head); + const dir = clusterOf(head); // pull same-directory siblings forward: one lens reading one module beats ten strangers - for (let i = 0; i < avail.length && files.length < EXPLORE_FILES_PER_LENS; ) { - if (clusterOf(avail[i]) === dir) files.push(avail.splice(i, 1)[0]) - else i++ + for (let i = 0; i < avail.length && files.length < EXPLORE_FILES_PER_LENS;) { + if (clusterOf(avail[i]) === dir) files.push(avail.splice(i, 1)[0]); + else i++; } } - for (const f of files) exploreConsumed.add(f) - return files + for (const f of files) exploreConsumed.add(f); + return files; } function exploreLens(i) { - const files = drawExploreFiles() - if (!files.length) return null + const files = drawExploreFiles(); + if (!files.length) return null; const src = GRAPH_ROWS.length ? `These files are heavily coupled (per the code graph) to files where confirmed bugs live, yet no ` + `hunt has confirmed or refuted a single finding in them - either they are clean or every lens so ` + `far walked past them.` : `These files churned heavily in the last 8 weeks, yet no hunt round has confirmed or refuted a ` + - `single finding in them - either they are clean or every lens so far walked past them.` + `single finding in them - either they are clean or every lens so far walked past them.`; return { key: `explore-${i}`, - prompt: `${src} Read each one IN FULL with fresh eyes. Hunt every class: concurrency and ` + + prompt: + `${src} Read each one IN FULL with fresh eyes. Hunt every class: concurrency and ` + `interleaving (races, TOCTOU, lock ordering, stale-closure writes after await); lifecycle and ` + `teardown (unreleased acquires, use-after-close, missing disposal on error paths); state desync ` + `(two sources of truth updated by different code paths); error-path data loss (swallowed errors, ` + `partial writes, silent fallbacks); ordering and boundaries (off-by-one, pagination truncation, ` + `sequence gaps, inclusive/exclusive disagreements).\n` + - files.map((f) => ` - ${f}`).join('\n'), + files.map((f) => ` - ${f}`).join("\n"), files, - } + }; } -let cooldownCluster = null // the top-ranked cluster hunted in round N sits out round N+1 +let cooldownCluster = null; // the top-ranked cluster hunted in round N sits out round N+1 function buildAdaptiveLenses(round) { - const byCluster = {} + const byCluster = {}; for (const c of confirmedAll) { - const cl = clusterOf(c.file) - if (!byCluster[cl]) byCluster[cl] = [] - byCluster[cl].push(c) + const cl = clusterOf(c.file); + if (!byCluster[cl]) byCluster[cl] = []; + byCluster[cl].push(c); } // Explore-heavy schedule: measured hotspot yield flattened to 0.25 high+med/agent by round 6. - const sweeping = uncoveredCount() > 0 - const hotspotQuota = round <= 5 ? 2 : 1 + const sweeping = uncoveredCount() > 0; + const hotspotQuota = round <= 5 ? 2 : 1; // sweep pace: 4 explore lenses x 10 files while inventory files remain uncovered - const exploreQuota = sweeping ? 4 : round <= 5 ? 2 : 3 - const hotKey = (cl) => ('hotspot ' + cl).toLowerCase().replace(/[^a-z0-9]+/g, '-') + const exploreQuota = sweeping ? 4 : round <= 5 ? 2 : 3; + const hotKey = (cl) => ("hotspot " + cl).toLowerCase().replace(/[^a-z0-9]+/g, "-"); const picked = Object.entries(byCluster) .sort((a, b) => b[1].length - a[1].length) .filter(([cl]) => cl !== cooldownCluster) .filter(([cl]) => (cleanStreak[hotKey(cl)] || 0) < 2) // pre-filter so backfill sees the real shortfall - .slice(0, hotspotQuota) - cooldownCluster = picked.length ? picked[0][0] : null + .slice(0, hotspotQuota); + cooldownCluster = picked.length ? picked[0][0] : null; const hotspots = picked.map(([cluster, items]) => ({ key: hotKey(cluster), prompt: `Bugs cluster. Confirmed findings so far in ${cluster}:\n` + - items.map((i) => ` - ${i.file}:${i.line} ${i.title}`).join('\n') + + items.map((i) => ` - ${i.file}:${i.line} ${i.title}`).join("\n") + `\nHunt ADJACENT to these: the same functions' siblings, every caller, the counterpart operations ` + `(subscribe/unsubscribe, open/close, register/transfer, acquire/release), and the paths a past fix ` + `here did NOT cover. Do not re-report the findings listed above - they are already known.`, - })) - const shortfall = hotspotQuota - hotspots.length - if (shortfall > 0) log(`adaptive: hotspot pool short by ${shortfall} - trying explore backfill`) - const explores = [] + })); + const shortfall = hotspotQuota - hotspots.length; + if (shortfall > 0) log(`adaptive: hotspot pool short by ${shortfall} - trying explore backfill`); + const explores = []; for (let i = 1; i <= exploreQuota + shortfall; i++) { - if (!sweeping && (cleanStreak[`explore-${i}`] || 0) >= 2) continue // demoted slot: no substitution, that IS demotion - const lens = exploreLens(i) + if (!sweeping && (cleanStreak[`explore-${i}`] || 0) >= 2) continue; // demoted slot: no substitution, that IS demotion + const lens = exploreLens(i); if (!lens) { - log(`adaptive: explore pool exhausted after ${explores.length} lens(es)`) - break + log(`adaptive: explore pool exhausted after ${explores.length} lens(es)`); + break; } - explores.push(lens) + explores.push(lens); } - return [...hotspots, ...explores] + return [...hotspots, ...explores]; } // ---------- dedupe + ledger helpers ---------- function normTitle(t) { - return String(t || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').split(/\s+/).filter((w) => w.length > 2) + return String(t || "") + .toLowerCase() + .replace(/[^a-z0-9 ]+/g, " ") + .split(/\s+/) + .filter((w) => w.length > 2); } // Dedupe is permanent: a candidate merged into an existing entry never comes // back, so an over-eager match silently loses a real bug rather than deferring @@ -432,64 +466,64 @@ function normTitle(t) { // two distinct bugs in one file often share half their title words ("hub client // map race on register" vs "...on unregister"), and without a window the second // one is suppressed forever, sometimes by a merely REFUTED namesake. -const TITLE_MATCH_WINDOW = 60 +const TITLE_MATCH_WINDOW = 60; function isDup(a, b) { - if (a.file !== b.file) return false - const delta = Math.abs((a.line || 0) - (b.line || 0)) - if (delta <= 10) return true - if (delta > TITLE_MATCH_WINDOW) return false - const aw = normTitle(a.title) - if (!aw.length) return false - const bw = new Set(normTitle(b.title)) - const hits = aw.filter((w) => bw.has(w)).length - return hits * 2 >= aw.length + if (a.file !== b.file) return false; + const delta = Math.abs((a.line || 0) - (b.line || 0)); + if (delta <= 10) return true; + if (delta > TITLE_MATCH_WINDOW) return false; + const aw = normTitle(a.title); + if (!aw.length) return false; + const bw = new Set(normTitle(b.title)); + const hits = aw.filter((w) => bw.has(w)).length; + return hits * 2 >= aw.length; } function dedupe(cands, priors, counts) { - const kept = [] + const kept = []; for (const c of cands) { - const prior = priors.find((p) => isDup(c, p)) + const prior = priors.find((p) => isDup(c, p)); if (prior) { - if (counts) counts[prior.fromLedger ? 'suppressedLedger' : 'suppressedRun']++ - continue + if (counts) counts[prior.fromLedger ? "suppressedLedger" : "suppressedRun"]++; + continue; } if (kept.some((k) => isDup(c, k))) { - if (counts) counts.suppressedRun++ - continue + if (counts) counts.suppressedRun++; + continue; } - kept.push(c) + kept.push(c); } - return kept + return kept; } function seenBlock(seen) { - if (!seen.length) return '' - const lines = seen.map((s) => ` - ${s.file}:${s.line} [${s.status}] ${s.title}`) - return `\n--- KNOWN FINDINGS (already investigated - do NOT re-report; refuted means examined and rejected) ---\n${lines.join('\n')}\n` + if (!seen.length) return ""; + const lines = seen.map((s) => ` - ${s.file}:${s.line} [${s.status}] ${s.title}`); + return `\n--- KNOWN FINDINGS (already investigated - do NOT re-report; refuted means examined and rejected) ---\n${lines.join("\n")}\n`; } function convergenceTable(stats, converged, stoppedOnBudget, stalled) { const verdict = converged ? `CONVERGED after ${stats.length} round(s).` : stalled - ? 'NOT converged - coverage stalled.' + ? "NOT converged - coverage stalled." : stoppedOnBudget - ? 'NOT converged - stopped on budget.' - : 'NOT converged - hit the round backstop.' + ? "NOT converged - stopped on budget." + : "NOT converged - hit the round backstop."; const rows = stats.map( (s) => - `| ${s.round} | ${s.family} | ${s.lenses} | ${s.candidates} | ${s.fresh} | ${s.confirmed} | ${s.refuted} | ${s.dryEligible ? 'yes' : 'NO'} | ${s.dryAfter} |`, - ) + `| ${s.round} | ${s.family} | ${s.lenses} | ${s.candidates} | ${s.fresh} | ${s.confirmed} | ${s.refuted} | ${s.dryEligible ? "yes" : "NO"} | ${s.dryAfter} |`, + ); return [ - '## Convergence', - '', + "## Convergence", + "", verdict, - '', - '| round | family | lenses | candidates | fresh | confirmed | refuted | dry-eligible | dry after |', - '|---|---|---|---|---|---|---|---|---|', + "", + "| round | family | lenses | candidates | fresh | confirmed | refuted | dry-eligible | dry after |", + "|---|---|---|---|---|---|---|---|---|", ...rows, - ].join('\n') + ].join("\n"); } // ---------- recon (verbatim from the current script, including both prompts) ---------- -phase('Recon') +phase("Recon"); const recon = await parallel([ () => agent( @@ -498,7 +532,7 @@ const recon = await parallel([ `Count how often each non-test source file changed. Return the 25 most-churned files with their counts, ` + `plus any file that changed in more than 6 distinct commits. High churn = where bugs concentrate.\n` + `Return plain text: one "path count" per line, most-churned first. No commentary.`, - { label: 'recon:churn', phase: 'Recon', model: 'haiku', effort: 'xhigh' }, + { label: "recon:churn", phase: "Recon", model: "haiku", effort: "xhigh" }, ), () => agent( @@ -508,15 +542,15 @@ const recon = await parallel([ ` (b) every Client/src/**/*.ts (non-test) containing "addEventListener", "setInterval", "setTimeout", or "new AbortController"\n` + ` (c) every Client/src-tauri/src/*.rs containing "unsafe", "Mutex", "RwLock", "spawn", or "unwrap()"\n` + `For each file give the path and a rough hit count. Return plain text grouped under (a)/(b)/(c). No commentary, no analysis.`, - { label: 'recon:surface', phase: 'Recon', model: 'haiku', effort: 'xhigh' }, + { label: "recon:surface", phase: "Recon", model: "haiku", effort: "xhigh" }, ), -]) -const CONTEXT = `\n\n--- RECON: most-churned files (last 8 weeks) ---\n${recon[0] || 'unavailable'}\n\n--- RECON: concurrency & lifecycle surface ---\n${recon[1] || 'unavailable'}\n` -const churnFiles = String(recon[0] || '') - .split('\n') +]); +const CONTEXT = `\n\n--- RECON: most-churned files (last 8 weeks) ---\n${recon[0] || "unavailable"}\n\n--- RECON: concurrency & lifecycle surface ---\n${recon[1] || "unavailable"}\n`; +const churnFiles = String(recon[0] || "") + .split("\n") .map((l) => l.trim().split(/\s+/)[0]) - .filter((p) => p.includes('/')) -log('Recon complete - starting converging rounds') + .filter((p) => p.includes("/")); +log("Recon complete - starting converging rounds"); // ---------- round loop ---------- // Cross-run memory: the calling session passes the findings ledger in as args.known. @@ -527,25 +561,25 @@ const seen = (ARGS.known || []).map((k) => ({ file: k.file, line: k.line, title: k.title, - status: k.status || 'known', + status: k.status || "known", fromLedger: true, // telemetry: distinguishes ledger suppression from same-run suppression -})) -const confirmedAll = [] -const unverified = [] -const roundStats = [] -const cleanStreak = {} -let dry = 0 -let round = 0 -let stoppedOnBudget = false -let coverageStall = 0 -let stalledCoverage = false +})); +const confirmedAll = []; +const unverified = []; +const roundStats = []; +const cleanStreak = {}; +let dry = 0; +let round = 0; +let stoppedOnBudget = false; +let coverageStall = 0; +let stalledCoverage = false; function finderPrompt(lens, rnd) { return ( `${RULES}${CONTEXT}${seenBlock(seen)}\n\nThis is round ${rnd} of a converging hunt. Everything under ` + `KNOWN FINDINGS has already been investigated - spend zero effort re-deriving those; hunt for what is ` + `NOT on that list.\n\n${lens.prompt}` - ) + ); } function verifyPrompt(lensKey, candidates) { return ( @@ -565,18 +599,24 @@ function verifyPrompt(lensKey, candidates) { `Return one verdict per candidate, keeping title/file/line so they can be matched up.\n\n` + // Strip the panel attribution here rather than at the call sites: the prompt above says // "another model" on purpose, and naming it is an authority cue that erodes refute-by-default. - `--- CANDIDATES ---\n${JSON.stringify(candidates.map(({ finder, ...c }) => c), null, 2)}` - ) + `--- CANDIDATES ---\n${JSON.stringify( + candidates.map(({ finder, ...c }) => c), + null, + 2, + )}` + ); } -const riskySweepPending = () => HAS_INVENTORY && RISKY_FILES.length > 0 && !riskySweepDone +const riskySweepPending = () => HAS_INVENTORY && RISKY_FILES.length > 0 && !riskySweepDone; while ((uncoveredCount() > 0 || riskySweepPending() || dry < DRY_THRESHOLD) && round < MAX_ROUNDS) { if (BUDGET_TOTAL && remainingBudget() < ROUND_BUDGET_FLOOR) { - stoppedOnBudget = true - log(`Budget floor reached (${Math.round(remainingBudget() / 1000)}k left) - stopping before round ${round + 1}`) - break + stoppedOnBudget = true; + log( + `Budget floor reached (${Math.round(remainingBudget() / 1000)}k left) - stopping before round ${round + 1}`, + ); + break; } - const family = lensesForRound(round + 1) + const family = lensesForRound(round + 1); if (!family || !family.length) { // Coverage mode with the pool drained and the risky sweep done: an empty family means // hotspots are demoted/cooled and there is genuinely nothing left to hunt - that IS @@ -584,187 +624,306 @@ while ((uncoveredCount() > 0 || riskySweepPending() || dry < DRY_THRESHOLD) && r // run one dry round short of its earned convergence. Legacy mode keeps the hard stop: // an empty family there means "nothing targetable" (no churn, no graph), not "done". if (HAS_INVENTORY && uncoveredCount() === 0 && !riskySweepPending()) { - round++ - dry++ - roundStats.push({ round, family: 'exhausted', lenses: 0, candidates: 0, fresh: 0, confirmed: 0, refuted: 0, dryEligible: true, dryAfter: dry, severity: { critical: 0, high: 0, medium: 0, low: 0 }, perLens: {}, filesTouched: 0, filesNew: 0, suppressedLedger: 0, suppressedRun: 0, finderNull: 0, finderEmpty: 0, verifierNull: 0, spentBefore: budget.spent(), spentAfter: budget.spent() }) - log(`Round ${round}: nothing left to hunt - counts as a dry round (dry=${dry})`) - continue + round++; + dry++; + roundStats.push({ + round, + family: "exhausted", + lenses: 0, + candidates: 0, + fresh: 0, + confirmed: 0, + refuted: 0, + dryEligible: true, + dryAfter: dry, + severity: { critical: 0, high: 0, medium: 0, low: 0 }, + perLens: {}, + filesTouched: 0, + filesNew: 0, + suppressedLedger: 0, + suppressedRun: 0, + finderNull: 0, + finderEmpty: 0, + verifierNull: 0, + spentBefore: budget.spent(), + spentAfter: budget.spent(), + }); + log(`Round ${round}: nothing left to hunt - counts as a dry round (dry=${dry})`); + continue; } - break // nothing to hunt != everything demoted + break; // nothing to hunt != everything demoted } - round++ - const uncBefore = uncoveredCount() - const spentBefore = budget.spent() - const counts = { suppressedLedger: 0, suppressedRun: 0, finderNull: 0, finderEmpty: 0, verifierNull: 0 } - const sweepingNow = uncoveredCount() > 0 - const lenses = family.filter((l) => (sweepingNow && /^explore-/.test(l.key)) || (cleanStreak[l.key] || 0) < 2) + round++; + const uncBefore = uncoveredCount(); + const spentBefore = budget.spent(); + const counts = { + suppressedLedger: 0, + suppressedRun: 0, + finderNull: 0, + finderEmpty: 0, + verifierNull: 0, + }; + const sweepingNow = uncoveredCount() > 0; + const lenses = family.filter( + (l) => (sweepingNow && /^explore-/.test(l.key)) || (cleanStreak[l.key] || 0) < 2, + ); if (!lenses.length) { - dry++ - roundStats.push({ round, family: familyName(round), lenses: 0, candidates: 0, fresh: 0, confirmed: 0, refuted: 0, dryEligible: true, dryAfter: dry, severity: { critical: 0, high: 0, medium: 0, low: 0 }, perLens: {}, filesTouched: 0, filesNew: 0, ...counts, spentBefore, spentAfter: budget.spent() }) - log(`Round ${round}: every lens demoted - counts as a dry round (dry=${dry})`) - continue + dry++; + roundStats.push({ + round, + family: familyName(round), + lenses: 0, + candidates: 0, + fresh: 0, + confirmed: 0, + refuted: 0, + dryEligible: true, + dryAfter: dry, + severity: { critical: 0, high: 0, medium: 0, low: 0 }, + perLens: {}, + filesTouched: 0, + filesNew: 0, + ...counts, + spentBefore, + spentAfter: budget.spent(), + }); + log(`Round ${round}: every lens demoted - counts as a dry round (dry=${dry})`); + continue; } - const rnd = round - const seenAtStart = seen.slice() + const rnd = round; + const seenAtStart = seen.slice(); const lensResults = await pipeline( lenses, (lens) => - agent(finderPrompt(lens, rnd), { label: `r${rnd}:hunt:${lens.key}:opus`, phase: `Round ${rnd}`, model: 'opus', effort: 'high', schema: FINDINGS }) - .then((res) => ({ lens, res })), + agent(finderPrompt(lens, rnd), { + label: `r${rnd}:hunt:${lens.key}:opus`, + phase: `Round ${rnd}`, + model: "opus", + effort: "high", + schema: FINDINGS, + }).then((res) => ({ lens, res })), async (r) => { - const { lens, res } = r + const { lens, res } = r; // agent() returns null on failure; a thrown stage instead nulls the whole lens result, // which the eligibility check catches separately. Both checks are needed. - const finderFailed = res === null - if (finderFailed) counts.finderNull++ - else if (!(res.findings || []).length) counts.finderEmpty++ + const finderFailed = res === null; + if (finderFailed) counts.finderNull++; + else if (!(res.findings || []).length) counts.finderEmpty++; // finder is constant now; kept on the record for ledger continuity across hunts - const union = res ? (res.findings || []).map((f) => ({ ...f, finder: 'opus' })) : [] - const fresh = dedupe(union, seenAtStart, counts) - if (!fresh.length) return { lens, finderFailed, unionCount: union.length, fresh: [], matched: [], unmatched: [] } - log(`r${rnd} ${lens.key}: ${fresh.length} fresh candidate(s) -> verification`) + const union = res ? (res.findings || []).map((f) => ({ ...f, finder: "opus" })) : []; + const fresh = dedupe(union, seenAtStart, counts); + if (!fresh.length) + return { + lens, + finderFailed, + unionCount: union.length, + fresh: [], + matched: [], + unmatched: [], + }; + log(`r${rnd} ${lens.key}: ${fresh.length} fresh candidate(s) -> verification`); // opus, not fable: fable verify agents hit usage limits and nulled out en masse on // the 2026-08-13 live run (and were the dominant cost even when they worked) - const vopts = { phase: `Round ${rnd}`, model: 'opus', effort: 'high', schema: VERDICTS } + const vopts = { phase: `Round ${rnd}`, model: "opus", effort: "high", schema: VERDICTS }; // Pair verdicts to candidates as they arrive, then retry ONLY what got no usable verdict. // Retrying the whole batch re-burned every verdict on a partial return, and the old // count-based trigger let N unmatched garbage verdicts skip the retry entirely. - const matched = [] - const unmatched = fresh.slice() + const matched = []; + const unmatched = fresh.slice(); const absorb = (vs) => { for (const v of vs || []) { - const vRec = { file: v.file, line: v.line, title: v.title } - const idx = unmatched.findIndex((f) => isDup(vRec, f) || isDup(f, vRec)) + const vRec = { file: v.file, line: v.line, title: v.title }; + const idx = unmatched.findIndex((f) => isDup(vRec, f) || isDup(f, vRec)); if (idx === -1) { - const claimed = matched.some(({ cand }) => isDup(vRec, cand) || isDup(cand, vRec)) - log(`r${rnd} ${lens.key}: verifier verdict "${v.title}" (${v.file}:${v.line}) ${claimed ? 'duplicates an already-claimed candidate' : 'matched no candidate'} - dropped`) - continue + const claimed = matched.some(({ cand }) => isDup(vRec, cand) || isDup(cand, vRec)); + log( + `r${rnd} ${lens.key}: verifier verdict "${v.title}" (${v.file}:${v.line}) ${claimed ? "duplicates an already-claimed candidate" : "matched no candidate"} - dropped`, + ); + continue; } - const [cand] = unmatched.splice(idx, 1) - matched.push({ v, cand }) + const [cand] = unmatched.splice(idx, 1); + matched.push({ v, cand }); } - } - const v1 = await agent(verifyPrompt(lens.key, fresh), { ...vopts, label: `r${rnd}:verify:${lens.key}` }) - absorb(v1 && v1.verdicts) - if (!v1) counts.verifierNull++ + }; + const v1 = await agent(verifyPrompt(lens.key, fresh), { + ...vopts, + label: `r${rnd}:verify:${lens.key}`, + }); + absorb(v1 && v1.verdicts); + if (!v1) counts.verifierNull++; if (unmatched.length) { - const v2 = await agent(verifyPrompt(lens.key, unmatched.slice()), { ...vopts, label: `r${rnd}:verify:${lens.key}:retry` }) - absorb(v2 && v2.verdicts) - if (!v2) counts.verifierNull++ + const v2 = await agent(verifyPrompt(lens.key, unmatched.slice()), { + ...vopts, + label: `r${rnd}:verify:${lens.key}:retry`, + }); + absorb(v2 && v2.verdicts); + if (!v2) counts.verifierNull++; } - return { lens, finderFailed, unionCount: union.length, fresh, matched, unmatched } + return { lens, finderFailed, unionCount: union.length, fresh, matched, unmatched }; }, - ) + ); // a thrown stage nulls the whole lens result - rewind its explore draw too, or the // session records never-read files as explored-clean (the same poison as a null finder) lensResults.forEach((r, i) => { - if (!r && lenses[i].files) for (const f of lenses[i].files) exploreConsumed.delete(f) - }) + if (!r && lenses[i].files) for (const f of lenses[i].files) exploreConsumed.delete(f); + }); - let eligible = !lensResults.some((r) => !r) - let newConfirmed = 0 - let newRefuted = 0 - let candCount = 0 - let freshCount = 0 - const perLens = {} - const sevMix = { critical: 0, high: 0, medium: 0, low: 0 } - const filesTouched = new Set() - const filesNew = new Set() + let eligible = !lensResults.some((r) => !r); + let newConfirmed = 0; + let newRefuted = 0; + let candCount = 0; + let freshCount = 0; + const perLens = {}; + const sevMix = { critical: 0, high: 0, medium: 0, low: 0 }; + const filesTouched = new Set(); + const filesNew = new Set(); for (const r of lensResults.filter(Boolean)) { - candCount += r.unionCount - freshCount += r.fresh.length - if (r.finderFailed) eligible = false + candCount += r.unionCount; + freshCount += r.fresh.length; + if (r.finderFailed) eligible = false; if (r.lens.files) { // coverage credit (spec: only explicit-file lenses that ran to completion). A lens // denied credit - dead finder OR candidates left unverified - returns its whole draw // to the pool: consumed-but-uncovered files would otherwise strand uncoveredCount() // above zero forever, and a partially-verified draw is not evidence of cleanliness. - if (!r.finderFailed && !r.unmatched.length) for (const f of r.lens.files) covered.add(f) - else for (const f of r.lens.files) exploreConsumed.delete(f) + if (!r.finderFailed && !r.unmatched.length) for (const f of r.lens.files) covered.add(f); + else for (const f of r.lens.files) exploreConsumed.delete(f); } - let lensConfirmed = 0 - let lensRefuted = 0 + let lensConfirmed = 0; + let lensRefuted = 0; for (const { v, cand } of r.matched) { // Keep the matched candidate: the verdict schema has no why/repro/evidence, and the // ledger needs them. Verdict fields are spread last so the verifier's re-rated severity // and its corrected title/file/line win over the finder's. - const rec = { file: v.file, line: v.line, title: v.title, status: v.refuted ? 'refuted' : 'confirmed' } - if (seen.some((p) => isDup(rec, p))) { counts.suppressedRun++; continue } // cross-lens same-round duplicate - seen.push(rec) - covered.add(rec.file) // any verdict proves the file was read (inert in legacy mode: seen already blocks re-draws) - if (v.refuted) { newRefuted++; lensRefuted++ } - else { - newConfirmed++ - lensConfirmed++ - sevMix[v.severity] = (sevMix[v.severity] || 0) + 1 - confirmedAll.push({ ...cand, ...v, lens: r.lens.key, round }) + const rec = { + file: v.file, + line: v.line, + title: v.title, + status: v.refuted ? "refuted" : "confirmed", + }; + if (seen.some((p) => isDup(rec, p))) { + counts.suppressedRun++; + continue; + } // cross-lens same-round duplicate + seen.push(rec); + covered.add(rec.file); // any verdict proves the file was read (inert in legacy mode: seen already blocks re-draws) + if (v.refuted) { + newRefuted++; + lensRefuted++; + } else { + newConfirmed++; + lensConfirmed++; + sevMix[v.severity] = (sevMix[v.severity] || 0) + 1; + confirmedAll.push({ ...cand, ...v, lens: r.lens.key, round }); } } if (r.unmatched.length) { - eligible = false // partial verifier failure: some candidates got no verdict at all - for (const f of r.unmatched) unverified.push({ ...f, lens: r.lens.key, round }) + eligible = false; // partial verifier failure: some candidates got no verdict at all + for (const f of r.unmatched) unverified.push({ ...f, lens: r.lens.key, round }); } // a lens hunted at partial panel strength, or whose candidates never got a verdict, is not evidence of cleanliness - if (!r.finderFailed && !r.unmatched.length) cleanStreak[r.lens.key] = lensConfirmed > 0 ? 0 : (cleanStreak[r.lens.key] || 0) + 1 - perLens[r.lens.key] = { candidates: r.unionCount, fresh: r.fresh.length, confirmed: lensConfirmed, refuted: lensRefuted, unverified: r.unmatched.length } + if (!r.finderFailed && !r.unmatched.length) + cleanStreak[r.lens.key] = lensConfirmed > 0 ? 0 : (cleanStreak[r.lens.key] || 0) + 1; + perLens[r.lens.key] = { + candidates: r.unionCount, + fresh: r.fresh.length, + confirmed: lensConfirmed, + refuted: lensRefuted, + unverified: r.unmatched.length, + }; // coverage proxy: files that produced fresh candidates this round (finder reading is unobservable) for (const f of r.fresh) { - filesTouched.add(f.file) - if (!seenAtStart.some((s) => s.file === f.file)) filesNew.add(f.file) + filesTouched.add(f.file); + if (!seenAtStart.some((s) => s.file === f.file)) filesNew.add(f.file); } } - if (newConfirmed > 0) dry = 0 - else if (eligible) dry++ + if (newConfirmed > 0) dry = 0; + else if (eligible) dry++; // ineligible zero-confirm round: dry unchanged - "we didn't fully look" is not "it's clean" - roundStats.push({ round, family: familyName(round), lenses: lenses.length, candidates: candCount, fresh: freshCount, confirmed: newConfirmed, refuted: newRefuted, dryEligible: eligible, dryAfter: dry, severity: sevMix, perLens, filesTouched: filesTouched.size, filesNew: filesNew.size, ...counts, spentBefore, spentAfter: budget.spent() }) - log(`Round ${round} (${familyName(round)}): ${newConfirmed} confirmed, ${newRefuted} refuted, dry=${dry}${eligible ? '' : ' (ineligible)'}`) + roundStats.push({ + round, + family: familyName(round), + lenses: lenses.length, + candidates: candCount, + fresh: freshCount, + confirmed: newConfirmed, + refuted: newRefuted, + dryEligible: eligible, + dryAfter: dry, + severity: sevMix, + perLens, + filesTouched: filesTouched.size, + filesNew: filesNew.size, + ...counts, + spentBefore, + spentAfter: budget.spent(), + }); + log( + `Round ${round} (${familyName(round)}): ${newConfirmed} confirmed, ${newRefuted} refuted, dry=${dry}${eligible ? "" : " (ineligible)"}`, + ); // stalled coverage: an adaptive round that failed to shrink a non-empty uncovered pool // AND confirmed nothing. Only adaptive rounds count - rounds 1-3 never draw explore // files by design - and a round that confirmed a bug is never a stall: hotspot yield // does not shrink the pool, and cutting off a still-productive hunt is the one thing // a bug-finding tool must not do. - const uncAfter = uncoveredCount() - if (HAS_INVENTORY && familyName() === 'adaptive' && uncAfter > 0 && newConfirmed === 0) { - coverageStall = uncAfter < uncBefore ? 0 : coverageStall + 1 + const uncAfter = uncoveredCount(); + if (HAS_INVENTORY && familyName() === "adaptive" && uncAfter > 0 && newConfirmed === 0) { + coverageStall = uncAfter < uncBefore ? 0 : coverageStall + 1; if (coverageStall >= 2) { - stalledCoverage = true - log(`Coverage stalled: uncovered=${uncAfter} did not shrink for 2 adaptive rounds - stopping`) - break + stalledCoverage = true; + log( + `Coverage stalled: uncovered=${uncAfter} did not shrink for 2 adaptive rounds - stopping`, + ); + break; } - } else coverageStall = 0 + } else coverageStall = 0; } -const converged = uncoveredCount() === 0 && !riskySweepPending() && dry >= DRY_THRESHOLD +const converged = uncoveredCount() === 0 && !riskySweepPending() && dry >= DRY_THRESHOLD; // ---------- report (deterministic) ---------- // A report agent silently dropped findings (79 sections for 82 confirmed on 2026-08-12), so the // markdown is assembled in-script from confirmedSorted. Coordinate spot-checking moved to the // calling session, which validates EVERY finding's file/line after return (see bughunt-run). -const RANK = { critical: 0, high: 1, medium: 2, low: 3 } -const confirmedSorted = confirmedAll.slice().sort((a, b) => RANK[a.severity] - RANK[b.severity]) -const unverifiedFinal = unverified.filter((u) => !seen.some((p) => isDup(u, p))) -const table = convergenceTable(roundStats, converged, stoppedOnBudget, stalledCoverage) -const sum = (k) => roundStats.reduce((n, r) => n + (r[k] || 0), 0) +const RANK = { critical: 0, high: 1, medium: 2, low: 3 }; +const confirmedSorted = confirmedAll.slice().sort((a, b) => RANK[a.severity] - RANK[b.severity]); +const unverifiedFinal = unverified.filter((u) => !seen.some((p) => isDup(u, p))); +const table = convergenceTable(roundStats, converged, stoppedOnBudget, stalledCoverage); +const sum = (k) => roundStats.reduce((n, r) => n + (r[k] || 0), 0); const runStats = { - config: { maxRounds: MAX_ROUNDS, dryThreshold: DRY_THRESHOLD, customLenses: !!CUSTOM_LENSES, knownCount: (ARGS.known || []).length, graphRows: GRAPH_ROWS.length, budgetTotal: BUDGET_TOTAL }, - coverage: HAS_INVENTORY ? { inventory: INVENTORY.length, preCovered: PRE_COVERED, covered: INVENTORY.length - uncoveredCount(), uncoveredAtStop: uncoveredCount() } : null, + config: { + maxRounds: MAX_ROUNDS, + dryThreshold: DRY_THRESHOLD, + customLenses: !!CUSTOM_LENSES, + knownCount: (ARGS.known || []).length, + graphRows: GRAPH_ROWS.length, + budgetTotal: BUDGET_TOTAL, + }, + coverage: HAS_INVENTORY + ? { + inventory: INVENTORY.length, + preCovered: PRE_COVERED, + covered: INVENTORY.length - uncoveredCount(), + uncoveredAtStop: uncoveredCount(), + } + : null, spentTotal: budget.spent(), rounds: roundStats.length, converged, stoppedOnBudget, stalledCoverage, confirmed: confirmedSorted.length, - refuted: sum('refuted'), + refuted: sum("refuted"), unverified: unverifiedFinal.length, - suppressedLedger: sum('suppressedLedger'), - suppressedRun: sum('suppressedRun'), - finderNull: sum('finderNull'), - finderEmpty: sum('finderEmpty'), - verifierNull: sum('verifierNull'), -} + suppressedLedger: sum("suppressedLedger"), + suppressedRun: sum("suppressedRun"), + finderNull: sum("finderNull"), + finderEmpty: sum("finderEmpty"), + verifierNull: sum("verifierNull"), +}; function buildReport() { const outcome = converged @@ -773,52 +932,70 @@ function buildReport() { ? `NOT converged - coverage stalled after ${round} round(s).` : stoppedOnBudget ? `NOT converged - stopped on budget after ${round} round(s).` - : `NOT converged - hit the round backstop after ${round} round(s).` - const sev = { critical: 0, high: 0, medium: 0, low: 0 } - for (const f of confirmedSorted) sev[f.severity] = (sev[f.severity] || 0) + 1 - const lines = ['# Bug hunt report', ''] + : `NOT converged - hit the round backstop after ${round} round(s).`; + const sev = { critical: 0, high: 0, medium: 0, low: 0 }; + for (const f of confirmedSorted) sev[f.severity] = (sev[f.severity] || 0) + 1; + const lines = ["# Bug hunt report", ""]; lines.push( `${confirmedSorted.length} confirmed finding(s) - ${sev.critical} critical, ${sev.high} high, ` + `${sev.medium} medium, ${sev.low} low. ${outcome}` + (confirmedSorted.length ? ` Fix first: ${confirmedSorted[0].title} (\`${confirmedSorted[0].file}:${confirmedSorted[0].line}\`).` - : ''), - '', - ) + : ""), + "", + ); for (const f of confirmedSorted) { - lines.push(`### ${f.severity} - ${f.title}`, '') - lines.push(`\`${f.file}:${f.line}\` - lens \`${f.lens}\`, round ${f.round}, confidence ${f.confidence}`, '') - if (f.why) lines.push(f.why, '') - if (f.repro) lines.push(`**Repro:** ${f.repro}`, '') - if (f.evidence) lines.push(`**Evidence:** ${f.evidence}`, '') - if (f.fix) lines.push(`**Fix:** ${f.fix}`, '') + lines.push(`### ${f.severity} - ${f.title}`, ""); + lines.push( + `\`${f.file}:${f.line}\` - lens \`${f.lens}\`, round ${f.round}, confidence ${f.confidence}`, + "", + ); + if (f.why) lines.push(f.why, ""); + if (f.repro) lines.push(`**Repro:** ${f.repro}`, ""); + if (f.evidence) lines.push(`**Evidence:** ${f.evidence}`, ""); + if (f.fix) lines.push(`**Fix:** ${f.fix}`, ""); } if (unverifiedFinal.length) { - lines.push('## Unverified - re-run', '') - for (const u of unverifiedFinal) lines.push(`- \`${u.file}:${u.line}\` ${u.title} (lens \`${u.lens}\`, round ${u.round})`) - lines.push('') + lines.push("## Unverified - re-run", ""); + for (const u of unverifiedFinal) + lines.push(`- \`${u.file}:${u.line}\` ${u.title} (lens \`${u.lens}\`, round ${u.round})`); + lines.push(""); } - lines.push(table) - lines.push('', '## Run stats', '') + lines.push(table); + lines.push("", "## Run stats", ""); lines.push( `Total spent: ${runStats.spentTotal} output tokens across ${runStats.rounds} round(s). ` + `Suppressed by dedupe: ${runStats.suppressedLedger} ledger-known, ${runStats.suppressedRun} same-run. ` + `Agent failures: ${runStats.finderNull} finder null, ${runStats.finderEmpty} finder empty, ${runStats.verifierNull} verifier null.`, - '', - ) + "", + ); if (runStats.coverage) lines.push( `Coverage: ${runStats.coverage.covered}/${runStats.coverage.inventory} files ` + `(${runStats.coverage.preCovered} pre-covered from ledger + live explored-clean); ` + `${runStats.coverage.uncoveredAtStop} uncovered at stop.`, - '', - ) - lines.push('| round | spent | files (new) | suppressed ledger/run | finder null/empty | verifier null |') - lines.push('|---|---|---|---|---|---|') + "", + ); + lines.push( + "| round | spent | files (new) | suppressed ledger/run | finder null/empty | verifier null |", + ); + lines.push("|---|---|---|---|---|---|"); for (const s of roundStats) - lines.push(`| ${s.round} | ${s.spentAfter - s.spentBefore} | ${s.filesTouched} (${s.filesNew}) | ${s.suppressedLedger}/${s.suppressedRun} | ${s.finderNull}/${s.finderEmpty} | ${s.verifierNull} |`) - return lines.join('\n') + lines.push( + `| ${s.round} | ${s.spentAfter - s.spentBefore} | ${s.filesTouched} (${s.filesNew}) | ${s.suppressedLedger}/${s.suppressedRun} | ${s.finderNull}/${s.finderEmpty} | ${s.verifierNull} |`, + ); + return lines.join("\n"); } -const report = buildReport() +const report = buildReport(); -return { converged, stoppedOnBudget, stalledCoverage, rounds: roundStats, confirmed: confirmedSorted, unverified: unverifiedFinal, runStats, exploredFiles: [...exploreConsumed], report } +return { + converged, + stoppedOnBudget, + stalledCoverage, + rounds: roundStats, + confirmed: confirmedSorted, + unverified: unverifiedFinal, + runStats, + exploredFiles: [...exploreConsumed], + report, +}; diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..ef54ee64 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,30 @@ +# Editor baseline for OwnCord. Pairs with .gitattributes (`* text=auto eol=lf`) +# and the repository Prettier config — all three agree on LF and trailing +# newlines, so an editor that honours this file produces bytes CI accepts. +# +# This is a baseline, not a gate. Prettier, gofmt and rustfmt are what actually +# fail the build; nothing lints this file. +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +# gofmt emits tabs and is the authority for Go. +[*.go] +indent_style = tab + +[{go.mod,go.sum}] +indent_style = tab + +# rustfmt default profile. +[*.rs] +indent_size = 4 + +# Recipe lines are tab-significant to make(1). +[Makefile] +indent_style = tab diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 2f060f68..3459467e 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -23,7 +23,8 @@ fail() { go_staged=$(printf '%s\n' "$staged" | grep '^Server/.*\.go$' | grep -v '^Server/db/dbgen/') if [ -n "$go_staged" ]; then if command -v go >/dev/null 2>&1 && command -v gofmt >/dev/null 2>&1; then - # shellcheck disable=SC2086 — repo paths contain no spaces + # Word splitting is intended: repo paths contain no spaces. + # shellcheck disable=SC2086 unformatted=$(gofmt -l $go_staged) [ -n "$unformatted" ] && fail "gofmt needed (run gofmt -w on): $unformatted" (cd Server && go vet ./...) || fail "go vet" @@ -59,6 +60,18 @@ if printf '%s\n' "$staged" | grep -qE '^(docs/protocol-schema\.json|Server/scrip fi fi +# ---------- Formatting (repository-wide) ---------- +# Prettier is configured once at the repository root (.prettierrc.json) and +# covers every material tracked source, not just client TypeScript. +# --ignore-unknown drops the Go/Rust/binary paths it has no parser for. +if [ -d node_modules ]; then + # Word splitting is intended: repo paths contain no spaces. + # shellcheck disable=SC2086 + npx prettier --check --ignore-unknown $staged || fail "prettier (run: npm run format)" +else + printf 'pre-commit: WARNING: node_modules missing at the repository root; skipping prettier.\n' >&2 +fi + # ---------- Client (TypeScript) ---------- ts_staged=$(printf '%s\n' "$staged" | grep -E '^Client/(src|tests)/.*\.ts$' | grep -v '/generated/') if [ -n "$ts_staged" ]; then @@ -69,8 +82,6 @@ if [ -n "$ts_staged" ]; then cd Client || exit 1 # shellcheck disable=SC2086 npx oxlint $rel || fail "oxlint" - # shellcheck disable=SC2086 - npx prettier --check $rel || fail "prettier (run: npm run format)" npm run -s typecheck || fail "tsc --noEmit" cd "$repo_root" || exit 1 fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 957ce528..9b580953 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,9 +159,6 @@ jobs: - name: ESLint (type-aware rules) run: npx eslint src/ - - name: Prettier format check - run: npx prettier --check "src/**/*.ts" "tests/**/*.ts" - - name: Knip (unused code & deps) # Blocking since the 2026-08-04 remediation: the '|| true' era let a # real unused-export finding sit invisible in every green run. @@ -196,6 +193,54 @@ jobs: - name: Ledger schema is valid run: node .superpowers/render-ledger.mjs --check + # Repository-wide formatting, script lint and workflow lint (RL-19 / L-13, S-05). + # + # Root-scoped and ubuntu-only for the same reason as docs-consistency above: + # every gate here is platform-independent text analysis, and .gitattributes + # pins eol=lf so a second OS would only re-prove line endings. + # + # Prettier lives here rather than in client-check because it is no longer a + # client gate -- one config at the repository root covers Markdown, YAML, + # JSON, CSS and the root scripts as well as client TypeScript. + hygiene: + name: Repository Hygiene + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + + # Root install only -- prettier is the sole dependency this job needs, and + # the client's install is client-check's job. + - name: Install root dependencies + run: npm ci + + # shellcheck ships in the ubuntu runner image. actionlint does not, so it + # is pinned by version and checked by digest: an unpinned installer script + # would be the one unverified download in a workflow file that pins every + # action by commit SHA. + - name: Install actionlint + env: + ACTIONLINT_VERSION: 1.7.7 + ACTIONLINT_SHA256: 023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757 + run: | + set -euo pipefail + url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + curl -sSfL --retry 3 -o "$RUNNER_TEMP/actionlint.tar.gz" "$url" + echo "$ACTIONLINT_SHA256 $RUNNER_TEMP/actionlint.tar.gz" | sha256sum -c - + tar -xzf "$RUNNER_TEMP/actionlint.tar.gz" -C "$RUNNER_TEMP" actionlint + echo "$RUNNER_TEMP" >> "$GITHUB_PATH" + + - name: Report tool versions + run: shellcheck --version && actionlint --version + + # The same entry point a contributor runs. run.mjs takes its shellcheck and + # actionlint file lists from `git ls-files`, never a filesystem glob. + - name: Formatting, shell and workflow gates + run: npm run check:hygiene + client-tests: name: Client Unit Tests runs-on: ubuntu-latest @@ -255,13 +300,18 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: - components: clippy + components: clippy, rustfmt - name: Rust cache uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: Client/src-tauri + # Ahead of clippy: a formatting failure is cheap to produce and cheap to + # fix, and there is no reason to spend a clippy pass to surface one. + - name: Rustfmt check + run: cargo fmt --all -- --check + - name: Clippy lint (including test targets) run: cargo clippy --all-targets -- -D warnings diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 336753d6..79839351 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -47,4 +47,3 @@ jobs: # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options # claude_args: '--allowed-tools Bash(gh pr:*)' - diff --git a/.github/workflows/load-baseline.yml b/.github/workflows/load-baseline.yml index ed58cf49..55ef3122 100644 --- a/.github/workflows/load-baseline.yml +++ b/.github/workflows/load-baseline.yml @@ -67,19 +67,28 @@ jobs: TOKEN=$(curl -sk -X POST "$BASE/admin/api/setup" \ -H 'Content-Type: application/json' \ -d '{"username":"loadadmin","password":"LoadTest123!Admin"}' | jq -r .token) - [ -n "$TOKEN" ] && [ "$TOKEN" != "null" ] || { echo "::error::setup failed"; exit 1; } + if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo "::error::setup failed" + exit 1 + fi CHANNEL_ID=$(curl -sk -X POST "$BASE/admin/api/channels" \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"name":"loadtest","type":"text"}' | jq -r .id) - [ -n "$CHANNEL_ID" ] && [ "$CHANNEL_ID" != "null" ] || { echo "::error::channel create failed"; exit 1; } + if [ -z "$CHANNEL_ID" ] || [ "$CHANNEL_ID" = "null" ]; then + echo "::error::channel create failed" + exit 1 + fi echo "CHANNEL_ID=$CHANNEL_ID" >> "$GITHUB_ENV" echo "ADMIN_TOKEN=$TOKEN" >> "$GITHUB_ENV" INVITE=$(curl -sk -X POST "$BASE/api/v1/invites" \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"max_uses":0}' | jq -r .code) - [ -n "$INVITE" ] && [ "$INVITE" != "null" ] || { echo "::error::invite create failed"; exit 1; } + if [ -z "$INVITE" ] || [ "$INVITE" = "null" ]; then + echo "::error::invite create failed" + exit 1 + fi USERS="${{ inputs.users }}" for i in $(seq 1 "${USERS:-100}"); do diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a32eb321..e0e4d24b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -454,7 +454,14 @@ jobs: publish: name: Publish GitHub Release - needs: [release-client-windows, release-client-linux, release-client-linux-arm64, release-server, release-server-docker] + needs: + [ + release-client-windows, + release-client-linux, + release-client-linux-arm64, + release-server, + release-server-docker, + ] runs-on: ubuntu-latest permissions: contents: write @@ -515,8 +522,8 @@ jobs: - name: Generate SHA256 checksums shell: bash run: | - (cd windows && sha256sum *) > checksums.sha256 - (cd linux && sha256sum *) >> checksums.sha256 + (cd windows && sha256sum -- *) > checksums.sha256 + (cd linux && sha256sum -- *) >> checksums.sha256 sha256sum owncord-src-*.tar.gz >> checksums.sha256 # The legacy top-level asset/sha256 pair stays bound to the Windows diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..68ae9e2b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,42 @@ +# Prettier 3 reads .gitignore by default, so everything ignored there — +# node_modules/, dist/, coverage/, Client/src/generated/, docs/security-findings/ — +# is already excluded. Only tracked files need entries here. + + +# Generated, verified by `git diff --exit-code` after regeneration. +Server/db/dbgen/ +Client/src/lib/protocolTypes.ts + +# Rendered from findings-ledger.json by render-ledger.mjs. +.superpowers/FINDINGS.md + +# Dated point-in-time snapshots. scripts/check-doc-counts.mjs already treats +# these as deliberately unmaintained and out of scope to edit; reformatting +# them would churn frozen records for no reader. +docs/audit-*.md + +# Carried forward from the client's own ignore file — a deliberate exclusion, +# not an oversight. +*.html + +# Session scratch from the remember plugin. Gitignored by a nested +# .remember/.gitignore, which Prettier does not read — it honours only the root +# .gitignore. Untracked and per-machine: a contributor's scratch directory must +# never be able to turn a shared gate red. +.remember/ +**/.remember/ + +# Build output and per-tool scratch. Every path below is gitignored -- but by a +# NESTED .gitignore, and Prettier honours only the root one. Without these +# entries the gate goes red the moment a contributor runs a build: `cargo test` +# alone drops ~850 formattable files into src-tauri/target/. +# Mirrors Client/.gitignore, .serena/.gitignore and .superpowers/sdd/.gitignore. +Client/dist/ +Client/coverage/ +Client/playwright-report/ +Client/test-results/ +Client/.vite/ +Client/src-tauri/target/ +Client/src-tauri/gen/ +.serena/ +.superpowers/sdd/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..61c1df9f --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "singleQuote": false, + "semi": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/.superpowers/findings-ledger.json b/.superpowers/findings-ledger.json index ec84357c..d7f4beac 100644 --- a/.superpowers/findings-ledger.json +++ b/.superpowers/findings-ledger.json @@ -1,7976 +1,7976 @@ { - "nextId": 349, - "findings": [ - { - "id": "OC-0001", - "title": "Wrapped room keys have no freshness binding, so old offers replay forever", - "file": "Client/src/lib/livekitE2EE.ts", - "line": 772, - "severity": "high", - "why": "The ephemeral ECDH keypair is generated only in setupKeyExchange (:151) and reannounceForReconnect (:316); neither rotation site (:898, :996) regenerates it, so deriveWrappingKey returns identical output all session. HKDF salt/info are constants, wrapRoomKey passes no additionalData, and the wire payload carries no epoch. handleOfferInner installs whatever decrypts.", - "repro": "Malicious server captures a voice_e2ee_offer, then replays it after a rotation. The recipient unwraps it successfully and installs the superseded key. Replayed to every peer, the room re-converges on a key a departed participant still holds, defeating membership forward secrecy. Aggravator at :783-789: accepting an offer sets _isKeyHolder=false and kills the rotation timer.", - "evidence": "e2eeCrypto.ts:30-34 constant HKDF params; :259-286 deriveWrappingKey; livekitE2EE.ts:763 epoch guard is intra-call only; :772-773 unconditional install", - "status": "fixed", - "found": "2026-08-09", - "hunt": "voice-e2ee-2026-08-09", - "lens": "crypto-primitives", - "fix": { - "commit": "84033139", - "test": "Client/tests/unit/livekit-e2ee.test.ts", - "revertProof": "pass" - } - }, - { - "id": "OC-0002", - "title": "A dead E2EE worker is invisible; the Secured badge cannot detect it", - "file": "Client/src/components/VoiceWidget.ts", - "line": 196, - "severity": "high", - "why": "The badge is derived purely from voiceStatus === 'connected', never from the SDK's live encryption state. livekit-client emits EncryptionEvent.EncryptionError from E2eeManager.onWorkerError, and src/ subscribes to none of it (zero grep hits for EncryptionEvent, ParticipantEncryptionStatusChanged, EncryptionError, isE2EEEnabled).", - "repro": "The e2ee worker constructs successfully then fails asynchronously (CSP on a lazily-loaded chunk, WASM load failure, WebView2 quirk). keyProvider.setKey still resolves because it is local WebCrypto plus an EventEmitter.emit that never round-trips through the worker. Join completes, status goes connected, badge shows Secured.", - "evidence": "VoiceWidget.ts:196 display toggle; livekitSession.ts:376-427 createRoom; E2eeManager.ts:242-245 emits EncryptionError", - "status": "fixed", - "found": "2026-08-09", - "hunt": "voice-e2ee-2026-08-09", - "lens": "degradation-observability", - "fix": { - "commit": "8579cb5d", - "test": "Client/tests/unit/voice-widget.test.ts", - "revertProof": "pass", - "branchCommit": "a0358f41" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0003", - "title": "Unverified peers get no safety number, removing TOFU's only out-of-band escape hatch", - "file": "Client/src/lib/livekitE2EE.ts", - "line": 477, - "severity": "high", - "why": "The !publishedIdentity branch accepts a peer as 'unverified' with safetyNumber: null. TOFU's designed compensation for first-contact risk is out-of-band safety-number comparison, and for exactly those peers the client renders no number to compare.", - "repro": "A malicious server suppresses identity_public_key for one victim pairing in ready/member_join/user_update, then substitutes the ephemeral key. The peer shows a grey shield indistinguishable from a genuine legacy client, and the user has no fingerprint to verify out of band.", - "evidence": "livekitE2EE.ts:473-481; the pinned-peer strip is already blocked at :458, so this branch is reachable only for never-pinned peers", - "status": "fixed", - "found": "2026-08-09", - "hunt": "voice-e2ee-2026-08-09", - "lens": "tofu-trust-chain", - "fix": { - "commit": "bf7612fb", - "test": "Client/tests/unit/livekit-e2ee.test.ts", - "revertProof": "pass" - } - }, - { - "id": "OC-0004", - "title": "Key-holder promotion silently no-ops when the client's own voice_state has not arrived", - "file": "Client/src/lib/livekitE2EE.ts", - "line": 864, - "severity": "medium", - "why": "handleParticipantLeft early-returns when voiceUsers.get(channelId) is missing or empty. That roster is populated only by voice_state broadcasts, including the client's own. The server sends voice_token directly at voice_join.go:312 but enqueues the joiner's own voice_state on the hub broadcast queue at :337, with a GetChannelVoiceStates query in between.", - "repro": "Client Y joins a channel where X is holder. Y starts setupKeyExchange on the token. X leaves inside the window before Y's own voice_state is delivered; X's voice_leave arrives first, removeVoiceUser empties the channel entry (voice.store.ts:245-246 deletes it), handleParticipantLeft returns at :864 and never promotes. The server has elected Y holder; Y never learns. setupKeyExchange times out at 15s and Y is ejected with e2ee_timeout.", - "evidence": "livekitE2EE.ts:859-864; Server/ws/voice_join.go:312 vs :337", - "status": "fixed", - "found": "2026-08-09", - "hunt": "voice-e2ee-2026-08-09", - "lens": "keyholder-election", - "fix": { - "commit": "8579cb5d", - "test": "Client/tests/unit/livekit-e2ee.test.ts", - "revertProof": "pass", - "branchCommit": "9e375c93" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0005", - "title": "Rotation offers exceed the server rate limit in large channels, permanently starving the same peers", - "file": "Client/src/lib/livekitE2EE.ts", - "line": 817, - "severity": "medium", - "why": "voiceE2EEOfferRateLimit is 64 per (sender, channel) per second, but voice_max_users defaults to 0 (unlimited) and admins may set up to maxVoiceLimit 99. distributeRoomKey loops over every peer with no pacing, awaiting only a fast WebCrypto wrap, so all sends land in one window. ws.send is fire-and-forget; onSendFailure covers local transport failures only, never a server ErrCodeRateLimited.", - "repro": "80-person voice channel, key holder rotates, 79 offers fire inside one second, the server drops everything past 64. _peerPublicKeys iterates in stable insertion order, so the same tail peers are starved on every subsequent rotation and stay on the old key.", - "evidence": "Server/ws/voice_e2ee.go:23, :213-216; migrations/004_voice_optimization.sql:6; Server/admin/handlers_channels.go:148", - "status": "fixed", - "found": "2026-08-09", - "hunt": "voice-e2ee-2026-08-09", - "lens": "rotation-forward-secrecy", - "fix": { - "commit": "8579cb5d", - "test": "Client/tests/unit/livekit-e2ee.test.ts", - "revertProof": "pass", - "branchCommit": "9e375c93" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0006", - "title": "Both rotation paths call keyProvider.setKey with no session-generation guard", - "file": "Client/src/lib/livekitE2EE.ts", - "line": 900, - "severity": "medium", - "why": "handleParticipantLeft (:900) and rotateKeyPeriodically (:998) never capture or re-check _sessionGeneration around their setKey await. Every other destructive write in the file does; setupKeyExchange does it three times (:156, :174, :209). clearState bumps _sessionGeneration but does not touch keyProvider, which is one instance shared across Room objects.", - "repro": "A rotation's setKey is in flight when the user leaves and rejoins. The new session installs its own key; the stale setKey resolves afterwards and leaves the live encryptor holding an abandoned key. Narrow: needs two setKey promises to resolve out of order, and distributeRoomKey's ownership check already blocks the network half.", - "evidence": "livekitE2EE.ts:900, :998, :990 entry-only guard, :1038-1051 clearState", - "status": "fixed", - "found": "2026-08-09", - "hunt": "voice-e2ee-2026-08-09", - "lens": "rotation-forward-secrecy", - "fix": { - "commit": "8579cb5d", - "test": "Client/tests/unit/livekit-e2ee.test.ts", - "revertProof": "pass", - "branchCommit": "9e375c93" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0007", - "title": "Reconnect reaches the Secured state without confirming the room key is current", - "file": "Client/src/lib/livekitE2EE.ts", - "line": 329, - "severity": "medium", - "why": "reannounceForReconnect re-applies the pre-disconnect room key and fires a single voice_e2ee_announce with no wait, no timeout, and no retry. The join path blocks on a confirmed key with a 10s attempt plus a 5s retry and aborts if it never arrives.", - "repro": "Network blip; the key rotates during the outage; the re-announce is lost or races the holder's own reconnect. The client sits on a dead key while the widget shows Secured, with no recovery bound short of the 5-minute rotation timer.", - "evidence": "livekitE2EE.ts:309-342; livekitSession.ts:535 awaited before :549/556 set connected", - "status": "fixed", - "found": "2026-08-09", - "hunt": "voice-e2ee-2026-08-09", - "lens": "degradation-observability", - "fix": { - "commit": "8579cb5d", - "test": "Client/tests/unit/livekit-e2ee.test.ts", - "revertProof": "pass", - "branchCommit": "9e375c93" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0008", - "title": "restoreLocalVoiceState has no internal supersession guard", - "file": "Client/src/lib/livekitSession.ts", - "line": 834, - "severity": "medium", - "why": "await room.localParticipant.setMicrophoneEnabled can block for seconds on the mic-permission prompt. applyMicMuteState re-reads this._room fresh, so it acts on whichever room is live at resume time. connectAndSetup's checkpoint 3 (:1110) runs after the call returns and cannot prevent writes that happen mid-call.", - "repro": "Join channel A; the permission prompt stalls; the user switches to channel B; A's continuation resumes and unpublishes B's live mic using A's captured muted value.", - "evidence": "livekitSession.ts:834 await, unguarded writes at :845, :866-868, :871", - "status": "fixed", - "found": "2026-08-09", - "hunt": "voice-e2ee-2026-08-09", - "lens": "reconnect-stale-continuations", - "fix": { - "commit": "7be9ccd2", - "test": "Client/tests/unit/livekit-session.test.ts", - "revertProof": "pass", - "branchCommit": "db7d518b" - }, - "fixed": "2026-08-14" - }, - { - "id": "OC-0009", - "title": "attemptAutoReconnect's tail has no supersession checkpoints after connected", - "file": "Client/src/lib/livekitSession.ts", - "line": 564, - "severity": "low", - "why": "reconnectSuperseded is used exhaustively before newRoom.connect and never called again after the success setState. The tail runs unguarded, and startTokenRefreshTimer clobbers a single shared timer field that a newer session may have armed.", - "repro": "Reconnect for channel 5 succeeds and sets connected. During restoreLocalVoiceState or switchActiveDevice the user joins channel 9. The stale tail resumes and runs setupAudioPipeline, reapplyMuteGain, and startTokenRefreshTimer against channel 9's session.", - "evidence": "livekitSession.ts:564-589, no reconnectSuperseded call after :548", - "status": "fixed", - "found": "2026-08-09", - "hunt": "voice-e2ee-2026-08-09", - "lens": "reconnect-stale-continuations", - "fix": { - "commit": "7be9ccd2", - "test": "Client/tests/unit/livekit-session.test.ts", - "revertProof": "pass", - "branchCommit": "db7d518b" - }, - "fixed": "2026-08-14" - }, - { - "id": "OC-0010", - "title": "handleOfferInner and handleAnnounceInner re-check generation before their final await, not after", - "file": "Client/src/lib/livekitE2EE.ts", - "line": 784, - "severity": "low", - "why": "handleOfferInner's guard at :763 precedes the setKey await; the writes at :784 and :793 follow it. A teardown-and-rejoin-as-holder landing inside that await means :784 reads the new session's _isKeyHolder and stands it down. handleAnnounceInner has the same shape at :643 versus the write at :668.", - "repro": "Non-holder in channel A receives a valid offer, passes :763, and during setKey the user leaves and rejoins channel B as holder. The stale continuation sets _isKeyHolder=false for channel B and kills its rotation timer.", - "evidence": "livekitE2EE.ts:763 guard, :773 await, :784/:793 writes; :643 guard, :655-665 awaits, :668 write. The dangerous announce variant is blocked server-side by sendToUserIfInVoiceChannel's atomic same-channel check.", - "status": "fixed", - "found": "2026-08-09", - "hunt": "voice-e2ee-2026-08-09", - "lens": "reconnect-stale-continuations", - "fix": { - "commit": "8787b906", - "test": "Client/tests/unit/livekit-e2ee.test.ts", - "revertProof": "pass", - "branchCommit": "15d3c9c2" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0011", - "title": "A replayed announce overwrites a peer's live ephemeral key", - "file": "Client/src/lib/livekitE2EE.ts", - "line": 651, - "severity": "low", - "why": "The signed announce message is domain || userId || ephemeralPubRaw with no channel, epoch, or nonce, so an old validly-signed announce replays cleanly. handleAnnounceInner sees a changed key and overwrites the live one, logging 'peer public key changed (reconnect?)'.", - "repro": "A malicious server re-emits a recorded announce carrying a retired ephemeral key. Subsequent offers are wrapped to a key nobody holds, silently denying that peer audio. Low because a malicious server can deny service more directly by not relaying.", - "evidence": "livekitE2EE.ts:651-670; e2eeCrypto.ts:41-45, :101-117 buildAnnounceMessage", - "status": "fixed", - "found": "2026-08-09", - "hunt": "voice-e2ee-2026-08-09", - "lens": "tofu-trust-chain", - "fix": { - "commit": "8787b906", - "test": "Client/tests/unit/livekit-e2ee.test.ts", - "revertProof": "pass", - "branchCommit": "15d3c9c2" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0012", - "title": "CleanupVoiceForChannel never clears voiceKeyHolders", - "file": "Server/ws/hub_sweep.go", - "line": 290, - "severity": "low", - "why": "Every other removal path re-elects (finishVoiceLeave, the LiveKit webhook, registerNow, rollbackVoiceJoin, sweepStaleVoiceStates). Channel delete and archive do not, leaving h.voiceKeyHolders[channelID] populated.", - "repro": "Delete a channel that had an elected holder. The map entry is never reachable and never freed — an unbounded per-deleted-channel leak for the process lifetime. On archive the next join's own updateKeyHolder overwrites it before any client can act, so there is no live desync.", - "evidence": "Server/ws/hub_sweep.go:290-346; contrast Server/ws/voice_leave.go:102", - "status": "fixed", - "found": "2026-08-09", - "hunt": "voice-e2ee-2026-08-09", - "lens": "keyholder-election", - "fix": { - "commit": "6f3485e7", - "test": "TestCleanupVoiceForChannel_ClearsKeyHolder", - "revertProof": "pass" - }, - "fixed": "2026-08-19", - "note": "CleanupVoiceForChannel now re-elects via updateKeyHolder" - }, - { - "id": "OC-0013", - "title": "REST DM events never bump the visibility watermark — *ws.Hub does not implement dmVisibilityMarker", - "file": "Server/api/dm_handler.go", - "line": 45, - "severity": "high", - "why": "markDMVisibilityChanged reaches the watermark bump through a type assertion to dmVisibilityMarker, but *ws.Hub has no MarkVisibilityChanged method anywhere in the repo (grep: only api/dm_handler.go and a test double define it), so the assertion always misses. The WS-side emitter of the same unsequenced, targeted dm_channel_open does bump it unconditionally (Server/ws/emit.go:41-48), so the two sibling paths disagree: hub.visibilityChangeSeq tracks WS-originated DM opens but never REST-originated ones, and mustFullResync therefore lets a client warm-resume across a REST DM change it can never be re-sent.", - "repro": "Alice calls POST /api/v1/dms/group with Bob among recipient_ids while Bob's socket is momentarily down (or Bob's socket drops during the call). broadcastDMOpen (dm_handler.go:265) calls markDMVisibilityChanged — a no-op — then SendToUser(bob) returns false. Bob reconnects with last_seq>0; h.mustFullResync(lastSeq) is false because visibilityChangeSeq never moved, so handleReconnect serves a seq replay and sends auth_ok, NOT ready. dispatcher.ts's setDmChannels therefore never runs, so Bob's dmStore has no entry for the group. Chat messages in that channel do replay (computeAllowedChannels includes it via dm_open_state) but updateDmLastMessage/updateDmLastMessagePreview early-return on a channelId not in dmStore and incrementUnread no-ops, so the group DM is invisible in Bob's sidebar with no badge and no way to open it until a full logout/login. The same no-op affects handleCloseDM (dm_handler.go:218), PATCH rename, and the group-leave refresh. Note api/dm_handler_watermark_voice_test.go:57 asserts markCalls>=1 using a double that DOES implement the interface, so the suite is green while production is inert.", - "evidence": "type dmVisibilityMarker interface { MarkVisibilityChanged() }\n\nfunc markDMVisibilityChanged(broadcaster DMBroadcaster) {\n\tif vm, ok := broadcaster.(dmVisibilityMarker); ok {\n\t\tvm.MarkVisibilityChanged()\n\t}\n}\n\n// contrast, same file:\nvar _ dmVoiceEvictor = (*ws.Hub)(nil) // sibling capability IS compile-time asserted; dmVisibilityMarker is not", - "suggestedFix": "Add `func (h *Hub) MarkVisibilityChanged() { h.bumpVisibilityWatermark() }` in Server/ws (e.g. hub.go next to bumpVisibilityWatermark), and add `var _ dmVisibilityMarker = (*ws.Hub)(nil)` in Server/api/dm_handler.go mirroring the existing dmVoiceEvictor compile-time assertion at line 63 so a future rename cannot silently re-break it.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "state-desync", - "finder": "opus", - "round": 2, - "confidence": "high", - "fix": { - "commit": "db0275a2", - "test": "Server/ws/hub_visibility_watermark_test.go", - "revertProof": "pass", - "branchCommit": "108bbe42", - "pr": 1369 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0014", - "title": "Client refreshes the LiveKit token every 23 hours while the server mints it with a 5-minute TTL, so auto-reconnect fails for any voice session older than 5 minutes", - "file": "Client/src/lib/livekitSession.ts", - "line": 714, - "severity": "high", - "why": "Two sources of truth for the same credential disagree by three orders of magnitude. `Server/ws/livekit.go` sets `tokenTTL = 5 * time.Minute` and documents \"The client requests a refresh via voice_token_refresh before expiry\"; the client's only periodic refresh is `TOKEN_REFRESH_MS = 23h`. Nothing else re-requests a token: `requestTokenRefresh()` is called only from that timer and once right after a successful reconnect, and it early-returns when `this._room === null` (which is the case throughout \"reconnecting\"). `handleDisconnected` hands `deps.getLatestToken()` straight to `attemptAutoReconnect`, which passes it to `newRoom.connect(resolvedUrl, token)`.", - "repro": "Join voice, stay connected for >5 minutes, then drop the SFU connection (Wi-Fi blip, laptop sleep, SFU restart). `handleDisconnected` (roomEventHandlers.ts:161-197) starts `attemptAutoReconnect` with the join-time token, which expired at T+5min. Both attempts fail JWT validation at LiveKit, the loop exhausts, and line 640 runs `this.leaveVoice(true); leaveVoiceChannel(); onErrorCallback(\"Voice connection lost — failed to reconnect\")`. The user is ejected from the call for a blip that the reconnect path exists to absorb. The stale comment at livekitSession.ts:780-786 (\"Sessions longer than the 4h TTL…\", \"The 23h refresh timer ensures a fresh token is always ready *before* the original expires\") describes a TTL the server no longer uses. Note tests/unit/livekit-session.test.ts:2817 hardcodes the 23h advance, so it locks the constant but asserts nothing about the interop contract.", - "evidence": "Client/src/lib/livekitSession.ts:714\n private static readonly TOKEN_REFRESH_MS = 23 * 60 * 60 * 1000;\n\nServer/ws/livekit.go:28\n // Short-lived (5 min) to limit replay window (BUG-127). The client requests\n // a refresh via voice_token_refresh before expiry.\n const tokenTTL = 5 * time.Minute", - "suggestedFix": "Lower LiveKitSession.TOKEN_REFRESH_MS below the server TTL — e.g. 4 * 60 * 1000 (refresh 1 min before the 5-min expiry) — and update the stale KNOWN LIMITATION comment (livekitSession.ts:777-786) plus the three test constants that advance the timer by 23h. Optionally also have handleDisconnected request a refresh before starting attemptAutoReconnect, but the timer change alone restores the invariant the server comment documents.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "flow-voice", - "finder": "opus", - "round": 3, - "confidence": "high", - "fix": { - "commit": "8579cb5d", - "test": "Client/tests/unit/livekit-session.test.ts", - "revertProof": "pass", - "branchCommit": "df221814" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0015", - "title": "A failed voice channel-switch leaves the client live in a voice call (mic hot, audio flowing) with the voice UI completely hidden and no way to leave", - "file": "Client/src/lib/dispatcher.ts", - "line": 812, - "severity": "high", - "why": "The VOICE_LEAVE handler unconditionally calls the store's leaveVoiceChannel() whenever the event is about the local user (`if (isSelf) leaveVoiceChannel();`), even though the sibling effect three lines above — actually tearing down the LiveKit session — is correctly gated on `shouldTeardownSession` (payload.channel_id matching the store's *current* channel). During a channel switch the store's currentChannelId has already been optimistically set to the NEW channel by VoiceCallbacks.onVoiceJoin before the server responds, so the self voice_leave broadcast for the OLD channel (which the server always sends first, per voice_join.go's `h.handleVoiceLeave(ctx, c)` call before minting a token for the new channel) makes shouldTeardownSession false — but leaveVoiceChannel() still runs and blanks voiceStore.currentChannelId to null. In the normal success path this is harmless because a voice_state broadcast for the new channel (VOICE_STATE handler, dispatcher.ts:745, `joinVoiceChannel(payload.channel_id)`) arrives shortly after and restores currentChannelId. But when the switch fails server-side — e.g. voice_join.go:158-179's `LeaveVoiceChannelIfMatch` retry for the old channel's DB row fails, so GetVoiceState still returns the old row and the join is aborted — the server never sends a voice_token or a voice_state for either channel; it only sends a generic ErrCodeInternal error ('voice channel switch failed — please try again'), which dispatcher.ts's S.ERROR handler does not specially handle for this code (it falls through to the generic setTransientError toast at dispatcher.ts:1002, with no voiceStore write). So voiceStore.currentChannelId is permanently stuck at null with nothing left to restore it. Meanwhile voice_leave.go's finishVoiceLeave() unconditionally calls `h.livekit.RemoveParticipant(ctx, oldChID, c.userID, oldJoinToken)` (voice_leave.go:108-114) regardless of whether the DB delete succeeded, forcibly kicking the client's still-live LiveKit Room object (the client never ran connectAndSetup()/leaveVoice() for this failed switch, since it never got a voice_token) out of the SFU. That kick fires roomEventHandlers.ts's handleDisconnected with a non-CLIENT_INITIATED reason; its auto-reconnect branch (roomEventHandlers.ts:172-198) decides whether to reconnect using `deps.getCurrentChannelId()`, which is LiveKitSession's own internal `_currentChannelId` getter (livekitSession.ts:207-211, derived from `_state`) — completely independent of voiceStore.currentChannelId. Since `_state` was never touched by the failed switch, `_currentChannelId` still points at the old channel with a valid cached token/URL, so attemptAutoReconnect silently reconnects the client back into the old channel's LiveKit room, republishes the microphone (restoreLocalVoiceState), and sets voiceStatus to 'connected' (livekitSession.ts:548-556) — all without ever calling joinVoiceChannel() to resync voiceStore.currentChannelId.", - "repro": "User is in voice channel A (fully joined, mic live). They click to switch to channel B (VoiceCallbacks.onVoiceJoin optimistically sets voiceStore.currentChannelId=B and sends voice_join). Server-side, handleVoiceJoin leaves channel A first; the DB's LeaveVoiceChannelIfMatch delete for the A row transiently fails (busy DB, timeout, etc.) but RemoveParticipant(A) and the voice_leave(A) broadcast still fire unconditionally. The client's VOICE_LEAVE(A) handler blanks voiceStore.currentChannelId to null (dispatcher.ts:812) since shouldTeardownSession is false (currentChannelId was already B) so it never calls session.leaveVoice(). Server then finds the stale A row still present, aborts the switch, restores its own hub state to channel A, and returns only a generic error — no voice_token/voice_state ever reaches the client, so nothing ever sets currentChannelId back to A or B. The client's still-live Room for channel A, kicked by RemoveParticipant, fires handleDisconnected, which — driven by LiveKitSession's own internal channel state, not the store — auto-reconnects back into channel A's LiveKit room and republishes the microphone. End state: voiceStore.currentChannelId is null (VoiceWidget.render() at VoiceWidget.ts:220 hides the entire widget when null, and ChannelSidebar.ts:335's `isJoined` is also false for row A) while the user is actually connected to channel A's SFU with a live, transmitting microphone and an intact E2EE session — invisible to the user, who has no on-screen mute/leave/status affordance until they happen to click channel A's row again (which itself would only start a *new* join attempt, tearing down the phantom session as a side effect of `connectAndSetup`'s `if (this._room !== null) this.leaveVoice(false)`).", - "suggestedFix": "Gate the teardown on the SESSION's live channel rather than only the store's: in the VOICE_LEAVE handler, tear down when isSelf and the LiveKit session's current channel id equals payload.channel_id (expose it from livekitSession alongside leaveVoice). The stale-leave protection test still holds (after a completed rejoin the session's channel is the new one), and every failed-switch variant then converges to a clean idle state instead of a hidden live session. Server-side hardening (send a voice_state resync in the voice_join abort branch) can follow, but the client guard alone removes the hot-mic state.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "flow-voice", - "finder": "sonnet", - "round": 3, - "confidence": "medium", - "fix": { - "commit": "8579cb5d", - "test": "Client/tests/unit/dispatcher.test.ts", - "revertProof": "pass", - "branchCommit": "1a47c85a" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0016", - "title": "Re-opening a channel visited earlier in the session renders a permanently stale message window — loadMessages short-circuits on isChannelLoaded and nothing invalidates on switch", - "file": "Client/src/pages/main-page/MessageController.ts", - "line": 76, - "severity": "high", - "why": "`loadMessages` returns immediately when the channel is already in `loadedChannels`, and `loadedChannels` is only ever cleared by `invalidateLoadedMessageWindows()` (dispatcher's second-`ready` full-resync path) and `clearChannelMessages()` — which has no caller anywhere in `src/`. Combined with the focus-scoped fan-out above, every message posted in a channel while the user was viewing a different one is absent from the store, never delivered live, and never refetched. The stale window is what MessageList renders on the way back, with no gap indicator and no way for the user to force a refresh short of restarting the app.", - "repro": "Open channel A (50 messages fetched, `loadedChannels` = {A}). Switch to channel B — the server unsubscribes the socket from `channel:A`. Ten messages are posted in A; none reach this client. Switch back to A: `MainPage`'s activeChannelId subscriber calls `mountChannel(A)` → `loadMessages(A)` → `isChannelLoaded(A)` is true → early return. MessageList renders the 50-message snapshot from the first visit; the 10 new messages are missing with no \"has more below\" affordance, and stay missing for the rest of the session (scroll-up only calls `loadOlderMessages`, which prepends).", - "evidence": "Client/src/pages/main-page/MessageController.ts:76-79 `if (isChannelLoaded(channelId)) { log.debug(\"Messages already loaded\", { channelId }); return; }`\nClient/src/pages/main-page/ChannelController.ts:245 `void msgCtrl.loadMessages(channelId, signal);` — the only load on mount; `mountChannel` does not clear the window\nClient/src/stores/messages.store.ts:746 `clearChannelMessages` — `grep -rn \"clearChannelMessages\" src/` matches only its own definition\nClient/src/lib/dispatcher.ts:311 `invalidateLoadedMessageWindows();` — reached only when `hasReceivedReadyBefore` (a full-ready resync)", - "suggestedFix": "In ChannelController.mountChannel, when previousChannelId !== null, drop that channel from loadedChannels (export a store helper mirroring reattachToPresent's Set-delete, without requiring the detached flag) so the next visit refetches the live tail; setMessages' existing merge already preserves pending/failed rows and newer live rows, so the refetch cannot clobber in-flight state.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "flow-message", - "finder": "opus", - "round": 3, - "confidence": "high", - "fix": { - "commit": "b1fb565", - "branchCommit": "ff17920", - "test": "Client/tests/unit/channel-controller.test.ts", - "revertProof": "self-reported", - "pr": 1366 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0017", - "title": "Virtual scroll window never follows the scroll position — rows outside the initial ±20-item overscan render as blank space", - "file": "Client/src/components/MessageList.ts", - "line": 536, - "severity": "high", - "why": "renderWindow() only rebuilds DOM when `renderedStart < 0`, and the only two callers that set that sentinel are renderAll() and scrollToMessage(). Every scroll-driven call therefore lands in the `else` branch, which is a pure no-op — it does not even update the spacers its own comment on line 496-497 claims it updates. The rendered window is frozen wherever the last data-change rebuild left it, so scrolling into the top/bottom spacer shows an empty region with no rows, and nothing ever fills it.", - "repro": "Open a channel whose full history is loaded (hasMoreMessages(channelId) === false) and that holds ~300 messages. mount() → renderAll() positions the window at the tail (~41 items ≈ 2.5k px). Scroll up past that: handleScroll → requestAnimationFrame → renderWindow() → renderedStart is 0-or-greater → else branch → nothing rendered. The area above the frozen window is the top spacer (offsetBefore(renderedStart) px of empty div) and stays blank indefinitely, because the scroll-top fetch is gated on hasMoreMessages and no store update fires. The only escape is an unrelated store event (new message, role revision bump) that triggers renderAll and re-centres the window on the current scrollTop. tests/unit/message-list.test.ts:244 (\"scrollToMessage renders a target that was outside the rendered window\") documents the same frozen-window behaviour rather than locking it as intended.", - "evidence": "if (renderedStart < 0) { … full rebuild … } else {\n // Scroll-driven: no-op. The ResizeObserver handles measurement and\n // spacer updates when element sizes change.\n}\n// called from: handleScroll → scrollRafId = requestAnimationFrame(() => { scrollRafId = 0; renderWindow(); })", - "suggestedFix": "In renderWindow's else branch, detect that the freshly computed [start,end) range is not contained in [renderedStart,renderedEnd) and take the rebuild path in that case (the existing >30-rebuilds-per-2s renderWindowCount breaker already guards the image-height oscillation loop the no-op was written to avoid); keep the no-op only when the target range is already fully rendered.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "fresh-eyes", - "finder": "opus", - "round": 5, - "confidence": "high", - "fix": { - "commit": "b1fb565", - "branchCommit": "7e22fe7", - "test": "Client/tests/unit/message-list.test.ts", - "revertProof": "self-reported", - "pr": 1366 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0018", - "title": "voice_join into a 1:1 DM has no block gate — a blocked user can enter the blocker's DM voice room and publish audio to them", - "file": "Server/ws/voice_join.go", - "line": 64, - "severity": "high", - "why": "Every other 1:1-DM interaction sink routes through service.requireDMNotBlocked (send, edit, delete, react, pin, typing, and call_ring — see service/message_perms.go:92-118, whose own doc comment claims it is \"called from every DM interaction sink\"). The voice path's only gate is hasChannelAccess, which by construction never consults blocks, and grep shows the entire ws/ package contains no IsEitherBlocked / requireDMNotBlocked call. Blocking never touches dm_participants (service/block.go:48 calls only st.BlockUser), so IsDMParticipant still returns true and the blocked user passes straight through.", - "repro": "Bob blocks Alice (PUT /api/v1/blocks/{alice}). Alice sends {\"type\":\"voice_join\",\"payload\":{\"channel_id\":}} over WS. hasChannelAccess passes: the default Member role holds CONNECT_VOICE 0x200 (migrations/007_member_video_permissions.sql sets Member = 0x1E63) and IsDMParticipant(alice, dm) is still true because BlockUser does not remove participant rows. handleVoiceJoin then persists a voice_states row, mints a LiveKit token with RoomJoin + CanPublishSources [\"microphone\", \"camera\", \"screen_share\"] for room channel- (ws/livekit.go:110-121), and broadcastVoiceEvent resolves the DM audience to its participants (ws/hub_broadcast.go:149-166), so Bob's client receives Alice's voice_state and renders her as present in that DM's call (Client dispatcher.ts:740 -> updateVoiceState). Alice can repeat this within the 5/s voice_join limit to spam Bob's UI with voice_state/voice_leave, and if Bob is in that room her microphone audio reaches him. The identical channel's call_ring is correctly refused with FORBIDDEN — the block is enforced on the doorbell but not on the door.", - "evidence": "ws/voice_join.go:64 — `if !h.requireChannelAccess(ctx, c, channelID, permissions.ConnectVoice, \"CONNECT_VOICE\") {` is the only authorization on the join; ws/deps.go:190-193 — \"Blocking is deliberately not consulted here: it is the message paths' rule (service.requireDMNotBlocked), it is two-party only, and a blocked user is still a participant, so it is orthogonal to the non-participant hole this closes.\"; the same gate is reused for re-minting at ws/voice_join.go:418 (`hasChannelAccess(... permissions.ConnectVoice)` in handleVoiceTokenRefreshV2). Contrast service/dm.go:368 (`requireDMNotBlocked` inside RingTargets), added for A-2026-08-03 and locked by ws/dm_group_call_test.go:344 TestCallRing_BlockedOneToOneForbidden.", - "suggestedFix": "Expose service.requireDMNotBlocked (e.g. a DMService method) and call it in handleVoiceJoin right after the ch.Type == \"dm\" branch (voice_join.go:82-85), refusing with FORBIDDEN; group DMs are already exempt inside requireDMNotBlocked. Reuse the same call in handleVoiceTokenRefreshV2 next to its hasChannelAccess gate (voice_join.go:418) so a mid-session block also evicts on refresh.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-server-ws", - "finder": "opus", - "round": 6, - "confidence": "medium", - "fix": { - "commit": "8579cb5d", - "test": "Server/ws/voice_dm_access_test.go", - "revertProof": "pass (manual: voice_join.go reverted alone, DMBlocked tests red, green at HEAD)", - "branchCommit": "423f9cbc" - }, - "declinedDate": "2026-08-14", - "notes": "Reopened 2026-08-14: advisory-path routing dropped per user decision; fix lands in normal PR with sanitized messaging.", - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0019", - "title": "Disconnect teardown decides `replaced` before a multi-second voice cleanup, then stamps the already-reconnected user offline", - "file": "Server/ws/serve_pumps.go", - "line": 186, - "severity": "medium", - "why": "readPump's defer samples `replaced := hub.unregisterNow(c)` at line 148 and then reuses that stale boolean at line 186 to gate `MarkUserDisconnected` (196) and the global offline presence broadcast (203). Between those two points it runs `hub.handleVoiceLeave(cleanupCtx, c)` (157), which does a DB delete, a per-connected-user permission scan in `channelReadAudience`, and a `livekit.RemoveParticipant` HTTP call bounded only by `lkTimeout = 5s` (Server/ws/livekit.go:151). A reconnect that registers during that window is invisible to the stale flag, so the dead socket's teardown marks the live session offline. `hub_sweep_test.go:87` documents that `replaced` exists precisely so \"a reconnect's teardown does not mark the live connection's user offline\" — the guard is simply evaluated too early to hold.", - "repro": "User U is connected as client A and is in voice channel V; LiveKit is unreachable/slow. (1) A's socket drops. readPump's defer snapshots voiceChID=V and calls unregisterNow(A), which finds A in h.clients, deletes it, and returns replaced=false. (2) The defer enters handleVoiceLeave, which blocks up to 5s in RemoveParticipant. (3) U's client reconnects: authenticateConn succeeds, handleReconnect (or handleFreshConnect) calls registerNow(B) so h.clients[U]=B, then applyConnectStatus writes users.status='online' and announceConnectPresence broadcasts presence{U, online}. (4) A's defer resumes with the stale replaced=false: MarkUserDisconnected(U) flips users.status back to 'offline' (db/dbgen/users.sql.go:185) and BroadcastToAll(presence{U, offline}) reaches every peer. Result: U is live on socket B but renders offline on every already-connected client, and a client that connects later reads users.status='offline' from ListMembers (presentableMembers only downgrades non-connected users, never upgrades). Nothing re-announces until U changes status or reconnects again.", - "evidence": "148: replaced := hub.unregisterNow(c)\n156:\t\t\tif voiceChID != 0 && !replaced {\n157:\t\t\t\thub.handleVoiceLeave(cleanupCtx, c) // DB + audience scan + 5s LiveKit call\n186:\t\t\tif !replaced {\n196:\t\t\t\t_ = hub.db.MarkUserDisconnected(cleanupCtx, c.userID)\n203:\t\t\t\thub.BroadcastToAll(buildPresenceMsg(c.userID, db.StatusOffline, nil))", - "suggestedFix": "In readPump's defer (and unregisterFailedHandshake), re-evaluate liveness at decision time instead of reusing the pre-cleanup snapshot: gate the MarkUserDisconnected + offline broadcast on `!replaced && hub.GetClient(c.userID) == nil` (any entry present after unregisterNow removed c is necessarily a newer connection), evaluated immediately before line 196 — after handleVoiceLeave returns.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "ws-hub", - "finder": "opus", - "round": 1, - "confidence": "high", - "fix": { - "commit": "7be9ccd2", - "test": "Server/ws/serve_pumps_reconnect_race_test.go", - "revertProof": "pass", - "branchCommit": "250a7819" - }, - "fixed": "2026-08-14" - }, - { - "id": "OC-0020", - "title": "Stale `_isKeyHolder` survives a voice-channel switch made while the SFU is reconnecting, so the client joins the new channel as a phantom key holder", - "file": "Client/src/lib/livekitE2EE.ts", - "line": 188, - "severity": "medium", - "why": "`setupKeyExchange` ORs the server-authoritative `is_key_holder` with whatever `_isKeyHolder` already holds. Its stated justification is that `clearState()` always runs between sessions (so a non-false residue can only be an in-window `handleParticipantLeft` promotion). That invariant is broken by `connectAndSetup`, which only tears E2EE state down via `if (this._room !== null) this.leaveVoice(false);` (livekitSession.ts:933) — and `_room` (livekitSession.ts:202) is null in the `reconnecting` state. A join issued while the LiveKit auto-reconnect loop is running therefore reaches `setupKeyExchange` with `_isKeyHolder` still true from the previous channel, and the server's `false` is discarded.", - "repro": "1. User (uid 5) is alone/lowest in voice channel A, so the server sent `is_key_holder=true`; `_isKeyHolder === true`, rotation timer armed.\n2. The LiveKit SFU connection drops (network blip). `handleDisconnected` -> `setRoom(null)` -> `setReconnectAc(ac)` puts `_state` in `reconnecting` (livekitSession.ts:319-334). The WS socket is unaffected, so the sidebar's `onVoiceJoin` guard (`socketLive()`, VoiceCallbacks.ts:173) still passes.\n3. During the reconnect loop (MAX_RECONNECT_ATTEMPTS=2, RECONNECT_DELAY_MS=3000, plus URL resolution/connect time) the user clicks voice channel B, which already has a lower-uid participant. Server: `computeIsKeyHolder(B, 5)` -> false, sends `voice_token` with `is_key_holder=false`.\n4. `handleVoiceToken` -> state is `reconnecting`, so neither the `connected` fast path nor the `_connecting` queue applies -> `connectAndSetup(...)`. `this._room` is null, so `leaveVoice(false)` is skipped and `_e2ee.clearState()` never runs.\n5. `setupKeyExchange(false, B)` executes `this._isKeyHolder = false || true` -> true. The client bumps the epoch, generates its OWN room key, applies it to the shared `keyProvider`, arms a 5-minute rotation timer, sends only an announce, and returns true immediately — skipping the entire non-key-holder wait/timeout path.\n6. `connectAndSetup` proceeds to `room.connect()` and `setVoiceStatus(\"connected\")`. The client now publishes SFrame-encrypted audio under a key nobody in B holds and cannot decrypt any peer, while the UI reports the call connected/secured. Every `voice_e2ee_offer` it sends is rejected server-side with `NOT_KEY_HOLDER` (Server/ws/voice_e2ee.go:198). Recovery depends entirely on B's real key holder answering the announce with an offer (handleOfferInner's stand-down at livekitE2EE.ts:783); if that offer never arrives — holder is TOFU-blocked on us, rate-limited (voice_e2ee.go:214-223), or mid-join — the client stays silently deaf and mute forever, because the 10s+5s `e2ee_timeout` safety net that would have ejected a real non-holder was never entered. Meanwhile the phantom rotation timer regenerates a fresh useless room key every 5 minutes.", - "evidence": "livekitE2EE.ts:188 `this._isKeyHolder = isKeyHolder || this._isKeyHolder;`\nlivekitE2EE.ts:190-203 `if (this._isKeyHolder) { this._e2eeEpoch++; this._roomKey = generateRoomKey(); await this.keyProvider.setKey(...); this.startKeyRotationTimer(); }`\nlivekitE2EE.ts:230-232 `if (this._isKeyHolder) { ...send announce... } else { /* wait up to 10s+5s for an offer, else return false */ }`\nlivekitSession.ts:933 `if (this._room !== null) this.leaveVoice(false);`\nlivekitSession.ts:202-204 `private get _room(): Room | null { return this._state.type === \"connected\" ? this._state.room : null; }`\nlivekitSession.ts:329-357 `teardownForReconnect` — tears down the audio pipeline/tracks, never calls `_e2ee.clearState()`\nlivekitSession.ts:1370-1379 `leaveVoice()` is the sole caller of `this._e2ee.clearState()`", - "suggestedFix": "In connectAndSetup, treat superseding an in-flight reconnect the same as superseding a live room: change livekitSession.ts:933 to `if (this._room !== null || this._state.type === \"reconnecting\") this.leaveVoice(false);`. leaveVoice(false) aborts the stale reconnect AbortController and runs _e2ee.clearState(), bumping _sessionGeneration so line 188's OR can only preserve promotions that land during THIS setupKeyExchange call (the B3-2 behavior), never residue from a prior session.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "voice-e2ee", - "finder": "opus", - "round": 1, - "confidence": "high", - "fix": { - "commit": "8579cb5d", - "test": "Client/tests/unit/livekit-session.test.ts", - "revertProof": "pass", - "branchCommit": "9e375c93" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0021", - "title": "Login builds a rate-limiter key from the unvalidated username, so an unauthenticated caller pins ~1 MiB of heap per request for 6 hours", - "file": "Server/api/auth_handler.go", - "line": 353, - "severity": "medium", - "why": "handleLogin never length-checks req.Username (its sibling handleRegister calls auth.ValidateUsername, max 32 runes, before touching anything). The raw string becomes a RateLimiter map key, and RateLimiter.Allow inserts that key into the shard map before the limit test, while RateLimiter.Cleanup only evicts an entry once every recorded timestamp is older than rateLimiterCleanupMaxWindow — 6 hours. An attacker-chosen, body-sized key is therefore retained for 6 hours per attempt on an endpoint that requires no credentials.", - "repro": "POST /api/v1/auth/login with body {\"username\":\"<1 MiB of 'a'>\",\"password\":\"x\"}. The user does not exist, so GetUserByUsername returns (nil,nil), execution reaches line 366-367, and \"login_user_fail:\" + the 1 MiB string is stored in RateLimiter.shards[h].windows. The response is 401, but the key stays resident until a Cleanup pass finds its timestamp older than 6 hours. The route's own IP limiter permits loginRateLimitPerMinute = 5 such requests per minute per source, i.e. ~5 MiB/min retained, ~1.8 GiB resident at steady state from a single IP (more from several). Sending 10 identical oversized usernames additionally trips the per-username lockout, which persists the same ~1 MiB key into the lockouts table via RateLimiter.Lockout -> UpsertLockout, and that row is reloaded into memory by NewPersistentRateLimiter on every restart. The identical input to POST /api/v1/auth/register is rejected at api/auth_handler.go:192 before any allocation.", - "evidence": "api/auth_handler.go:323 unameKey := strings.ToLower(req.Username)\napi/auth_handler.go:324 userLockKey := \"login_user_lock:\" + unameKey\napi/auth_handler.go:353 userFailKey := \"login_user_fail:\" + unameKey\napi/auth_handler.go:366 if !limiter.Allow(failKey, loginFailureThreshold+1, loginFailureWindow) ||\napi/auth_handler.go:367 !limiter.Allow(userFailKey, loginUserFailureThreshold+1, loginUserFailureWindow) {\n\nauth/ratelimit.go:134 e, ok := s.windows[key]\nauth/ratelimit.go:136 e = &entry{}\nauth/ratelimit.go:137 s.windows[key] = e // inserted even when the call is then refused\n\nauth/ratelimit.go:249 cutoff := time.Now().Add(-maxWindow) // maxWindow == 6h in production\nauth/ratelimit.go:255 for key, e := range s.windows { ... if ts.After(cutoff) { allStale = false } }\nauth/ratelimit.go:263 if allStale { delete(s.windows, key) } // entry survives ~6h after its last use\n\napi/constants.go:133 rateLimiterCleanupMaxWindow = 6 * time.Hour\napi/router.go:52 r.Use(MaxBodySizeUnless(defaultMaxBodySize, ...)) // defaultMaxBodySize = 1 MiB, /auth/login not exempt", - "suggestedFix": "In handleLogin, reject or clamp an over-long username before building unameKey — e.g. after the empty check add `if len(req.Username) > maxUsernameKeyLen { return 400 }` (or truncate the value used to key the limiter), so an unbounded body-sized string can never become a retained map/DB key. maxUsernameLength (32 runes) is the natural bound.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "api-authz", - "finder": "opus", - "round": 1, - "confidence": "high", - "fix": { - "commit": "8579cb5d", - "test": "Server/api/auth_handler_test.go", - "revertProof": "pass", - "branchCommit": "47eeb930" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0022", - "title": "The archived-channel read-only gate exists only on SendMessage; edit, reaction, pin and purge still mutate an archived channel", - "file": "Server/service/message_crud.go", - "line": 268, - "severity": "medium", - "why": "SendMessage refuses an archived channel (message_crud.go:54) because \"any caller that still held the id ... could keep posting into an archive indefinitely\". EditMessage routes its non-DM gate through checkSendPermission, which carries no archived check, and the same is true of handleReaction, SetMessagePinned and PurgeMessages. So the archive is still writable: an author can inject arbitrary new text into an archived channel and it is fanned out as chat_edited to every reader with READ_MESSAGES, and a MANAGE_MESSAGES holder can still pin or bulk-delete there.", - "repro": "Admin PATCHes /admin/api/channels/{id} with archived=true. Alice, who previously posted message M in that channel and still holds its id, sends the WS chat_edit command for M with new content: EditMessage passes checkSendPermission (her base READ|SEND bits are untouched by archiving) and commits the new text, broadcasting chat_edited to every client that can read the channel — while the identical chat_send is refused with ErrForbidden \"channel is archived\" (locked by service/archived_channel_readonly_test.go). The same holds over REST: POST /api/v1/channels/{id}/pins/{messageId} (api/channel_handler.go:73) and POST /api/v1/channels/{id}/messages/purge (api/channel_handler.go:70) both succeed against the archived channel.", - "evidence": "message_crud.go:54 if !isDM && ch.Archived { return ...ErrForbidden: channel is archived } // send only\nmessage_crud.go:268 } else if permErr := s.checkSendPermission(ctx, userID, msg.ChannelID, chanType); permErr != nil {\n // comment: \"an edit injects new text into the channel and is fanned out to every\n // reader, so it must clear the same gate as a send\" — but checkSendPermission\n // (message_perms.go:69-90) never consults ch.Archived\nmessage_query.go:215 } else if !s.perms.HasChannelPerm(ctx, userID, channelID, ReadMessages|ManageMessages) // SetMessagePinned, no archived check\nmessage_purge.go:55 same, PurgeMessages\nmessage_reactions.go:116 same, handleReaction\nadmin/handlers_channels.go:260 \"Archiving hides a voice channel the same way deleting it does — nobody can see it or reach it afterward\"", - "suggestedFix": "Add the archived read-only check to the shared write policy so all mutation paths inherit it: put `if ch.Type != \"dm\" && ch.Archived { return ErrForbidden }` inside checkSendPermission (covers EditMessage and CanPost), and add the same guard to SetMessagePinned, PurgeMessages and handleReaction (which bypass checkSendPermission), ideally via one requireWritableChannel(ch) helper called from every write sink.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "api-authz", - "finder": "opus", - "round": 1, - "confidence": "high", - "fix": { - "commit": "8579cb5d", - "test": "Server/service/archived_channel_readonly_test.go", - "revertProof": "pass", - "branchCommit": "8ad4f59d" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0023", - "title": "ListMembers hides users whose temporary ban has lapsed, while every other path treats them as active", - "file": "Server/db/queries/sqlite/users.sql", - "line": 58, - "severity": "medium", - "why": "ListMembers filters on the raw `u.banned = 0` column, but nothing ever clears `banned` when `ban_expires` passes — expiry is evaluated lazily by `auth.IsEffectivelyBanned` (auth/helpers.go:73) and by `db.notBannedClause` (db/mention_queries.go:40). A user whose temp ban has lapsed can therefore authenticate (ws/serve_auth.go:85), post, and be resolved as an @mention/@everyone target, yet is absent from the `members[]` roster the ready payload is built from (ws/serve_ready.go:153 -> db/auth_queries.go:545). The two sources of truth disagree — exactly the hazard the notBannedClause comment was written to close, applied to mentions but not to the roster.", - "repro": "Ban user B with a 1-hour expiry (ModerationService.BanUser -> db.BanUser writes banned=1, ban_expires=now+1h). Wait for the expiry to pass. B logs in: api/middleware.go:131 and ws/serve_auth.go:85 both call auth.IsEffectivelyBanned, which returns false, so the connection is accepted. B sends a message and is a valid @mention target (GetUserIDsByUsernames uses notBannedClause). But every connected client's `ready` payload — B's own included — omits B from members[], because ListMembers still sees banned=1. Result: B's messages render with no member entry (no avatar, no role colour), B is missing from the member sidebar and from mention autocomplete, and B cannot be opened from the roster. TestListMembers_ExcludesBanned (db/auth_queries_test.go:755) only covers a permanent ban (expires=nil), so this case is not test-locked.", - "evidence": "users.sql:53-59\n-- name: ListMembers :many\nSELECT u.id, u.username, u.avatar, u.status, LOWER(r.name), u.identity_public_key,\n u.display_name, u.custom_status\nFROM users u\nJOIN roles r ON u.role_id = r.id\nWHERE u.banned = 0\nORDER BY u.username ASC;\n\n-- vs db/mention_queries.go:40 (the same question, answered differently)\nconst notBannedClause = `(banned = 0 OR (ban_expires IS NOT NULL AND replace(ban_expires, ' ', 'T') <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')))`", - "suggestedFix": "Via the db-change skill, change ListMembers' WHERE clause in Server/db/queries/sqlite/users.sql to the same lapsed-ban test as db.notBannedClause: WHERE (u.banned = 0 OR (u.ban_expires IS NOT NULL AND replace(u.ban_expires, ' ', 'T') <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))), then regenerate Server/db/dbgen/.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "db-storage", - "finder": "opus", - "round": 1, - "confidence": "high", - "fix": { - "commit": "7be9ccd2", - "test": "Server/db/auth_queries_test.go", - "revertProof": "pass", - "branchCommit": "45dd39f7" - }, - "fixed": "2026-08-14" - }, - { - "id": "OC-0024", - "title": "channel_focus re-subscribes after a concurrent visibility revoke, leaving a demoted user permanently subscribed to a channel they can no longer READ", - "file": "Server/ws/handlers.go", - "line": 170, - "severity": "medium", - "why": "The READ_MESSAGES check for channel_focus happens inside the handler (service/channel.go:245), but the pub/sub Subscribe that acts on it happens later, in the applier, with two SQLite round-trips in between. Nothing re-validates at Subscribe time, and the revoke sweeps (Hub.RefreshChannelVisibility / Hub.revokeUnreadableChannels) only ever Unsubscribe what the socket holds at the instant they run — so a Subscribe landing after the sweep is never undone.", - "repro": "User U is a member of role R, currently focused on channel 5. U's client sends channel_focus{channel_id:7} (7 is readable at that moment). On U's readPump goroutine, HandleChannelFocus passes the READ check at service/channel.go:245 and then blocks in GetLatestMessageID/UpdateReadState. Concurrently an admin POSTs a channel_overrides change denying R READ_MESSAGES on channel 7: admin/handlers_channel_perms.go:164 calls permInvalidator.InvalidateAll(), then :167 calls hub.RefreshChannelVisibility(ch7), which sends U a channel_delete, runs pubsub.Unsubscribe(c, ChannelTopic(7)) (a no-op — U is not subscribed yet, focus is still 5) and clears c.channelID if it equals 7 (it does not). The admin request finishes. U's handler now returns SetChannelID=7, and handlers.go:170 runs pubsub.Subscribe(c, ChannelTopic(7)) plus sets c.channelID=7. U is now subscribed to channel 7's topic with no READ permission and nothing left to revoke it: every subsequent chat_message / chat_edited / chat_deleted / reaction_update published to channel 7 is delivered to U for the remaining lifetime of the socket. The same window exists for revokeUnreadableChannels on a role reassignment (hub_broadcast.go:534-562).", - "evidence": "handlers.go applier:\n\tif result.SetChannelID != nil {\n\t\toldChID := c.getChannelID()\n\t\tc.mu.Lock(); c.channelID = *result.SetChannelID; c.mu.Unlock()\n\t\tnewChID := *result.SetChannelID\n\t\tif oldChID != newChID {\n\t\t\tif oldChID > 0 { c.hub.pubsub.Unsubscribe(c, ChannelTopic(oldChID)) }\n\t\t\tif newChID > 0 { c.hub.pubsub.Subscribe(c, ChannelTopic(newChID)) } // <- line 170, no re-check\n\t\t}\n\t}\n\nservice/channel.go HandleChannelFocus (the only gate):\n\t} else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) { // line 245\n\t\treturn nil, fmt.Errorf(\"%w: access denied\", ErrForbidden)\n\t}\n\tlatestID, err := s.st.GetLatestMessageID(ctx, channelID) // DB round trip 1\n\tif err == nil { _ = s.st.UpdateReadState(ctx, userID, channelID, latestID) } // DB round trip 2\n\nhub_broadcast.go RefreshChannelVisibility (the revoke, line 350-356):\n\t\tc.sendMsg(buildChannelDelete(ch.ID))\n\t\th.pubsub.Unsubscribe(c, ChannelTopic(ch.ID))\n\t\tc.mu.Lock(); if c.channelID == ch.ID { c.channelID = 0 }; c.mu.Unlock()", - "suggestedFix": "In the handlers.go applier, after pubsub.Subscribe(c, ChannelTopic(newChID)), re-validate access with a live check (hasChannelAccess, as used by requireChannelAccess) and on failure Unsubscribe + clear c.channelID. Subscribe-then-recheck closes the window in both orders: a revoke committing before the recheck is seen by the recheck; a revoke committing after finds the subscription present and its sweep removes it.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "concurrency", - "finder": "opus", - "round": 2, - "confidence": "high", - "fix": { - "commit": "db0275a2", - "test": "Server/ws/handler_focus_revoke_race_test.go", - "revertProof": "pass", - "followUp": "3026ddf2", - "branchCommit": "cc82417f", - "pr": 1369 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0025", - "title": "enableCamera has no supersession re-check after publishTrack, so a concurrent disableCamera leaves the server and every peer believing the camera is on", - "file": "Client/src/lib/screenShare.ts", - "line": 243, - "severity": "medium", - "why": "The generation guard is checked only after device acquisition (line 235), not after the awaited publishTrack. A disableCamera that runs during the publish round-trip bumps the generation, unpublishes/stops the track and sends voice_camera{enabled:false}; the superseded enableCamera then resumes and sends voice_camera{enabled:true}, so the last frame the server sees says the camera is on while the local store says off and no track exists.", - "repro": "In a live voice channel the user clicks the camera toggle on. enableCamera acquires the device, sets state.manualCameraTrack and awaits room.localParticipant.publishTrack (an SFU negotiation round trip, tens to hundreds of ms). Before it resolves the user clicks the toggle off (or the dispatcher's VIDEO_LIMIT/error handler calls disableCamera — dispatcher.ts:992/1018). disableCamera bumps state.generation, stopManualCameraTrack clears state.manualCameraTrack and stops the MediaStreamTrack, setLocalCamera(false) runs and voice_camera{enabled:false} is sent. publishTrack then resolves; enableCamera continues past line 243 with no generation check and sends voice_camera{enabled:true} at line 251. Server-side ordering is false then true, so the DB row and the voice_state broadcast say camera=true: every peer renders a camera tile for a participant whose track was stopped, while the local voiceStore has localCamera=false, so the user's next toggle click sends enabled:true again and there is no single click that turns it off.", - "evidence": " if ((state.generation ?? 0) !== generation) { // 235 - only guard\n videoTrack.stop();\n return;\n }\n state.manualCameraTrack = videoTrack; // 242\n await room.localParticipant.publishTrack(videoTrack, { // 243 - awaited, no guard after\n ...\n });\n const sendId = ws.send({ type: \"voice_camera\", payload: { enabled: true } }); // 251\n\nexport async function disableCamera(state, deps) {\n bumpGeneration(state); // 276\n stopManualCameraTrack(state, room); // unpublishes + stops the in-flight track\n ...\n finally { setLocalCamera(false); ws.send({ type: \"voice_camera\", payload: { enabled: false } }); }", - "suggestedFix": "After the awaited publishTrack (and before the ws.send at 251), re-check (state.generation ?? 0) !== generation; on supersession, unpublish and stop videoTrack, clear state.manualCameraTrack if it still points at it, and return without sending voice_camera(true).", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "concurrency", - "finder": "opus", - "round": 2, - "confidence": "high", - "fix": { - "commit": "b1fb565", - "branchCommit": "0db0433", - "test": "Client/tests/unit/screen-share-tracks.test.ts", - "revertProof": "self-reported", - "pr": 1366 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0026", - "title": "enableScreenshare has no supersession re-check across its publish loop, so a concurrent stop still announces the share as on", - "file": "Client/src/lib/screenShare.ts", - "line": 345, - "severity": "medium", - "why": "Same missing post-await guard as enableCamera, but worse: the loop publishes tracks from the local `screenTracks` array while disableScreenshare has already emptied state.manualScreenTracks, so the remaining publishes are made against tracks the disable path can no longer reach, and the final ws.send announces enabled:true after the disable already announced enabled:false.", - "repro": "The user picks a window in the OS share picker; every quality preset requests audio alongside video, so enableScreenshare enters the loop at line 345 with two tracks and awaits the first publishTrack. The user then hits the app's Stop Sharing button (or the OS 'Stop sharing' bar fires the 'ended' listener registered at line 358 for a previous share). disableScreenshare bumps the generation, stopManualScreenTracks sets state.manualScreenTracks = [] and stops/unpublishes both tracks, setLocalScreenshare(false) runs, and voice_screenshare{enabled:false} is sent. The loop resumes and publishes the second track — held only by the local `screenTracks` closure variable, which state.manualScreenTracks no longer references, so no later disable can unpublish it — and line 370 sends voice_screenshare{enabled:true}. The server's last observed state is enabled:true, peers keep a screenshare tile for the participant, and the local store says screenshare off.", - "evidence": " if ((state.generation ?? 0) !== generation) { // 334 - only guard\n for (const t of screenTracks) t.stop();\n return;\n }\n state.manualScreenTracks = screenTracks; // 341\n for (const track of screenTracks) {\n await room.localParticipant.publishTrack(track, { // 345 - awaited per track, no guard\n ...\n });\n }\n const sendId = ws.send({ type: \"voice_screenshare\", payload: { enabled: true } }); // 370\n\nexport async function disableScreenshare(state, deps) {\n bumpGeneration(state); // 399\n stopManualScreenTracks(state, room); // sets state.manualScreenTracks = [] and stops them\n ...\n finally { setLocalScreenshare(false); ws.send({ type: \"voice_screenshare\", payload: { enabled: false } }); }", - "suggestedFix": "Re-check (state.generation ?? 0) !== generation after each awaited publishTrack in the loop (and before the ws.send at 370); on supersession, unpublish/stop every track in the local screenTracks array, clear state.manualScreenTracks if it still references them, and return without sending voice_screenshare(true).", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "concurrency", - "finder": "opus", - "round": 2, - "confidence": "high", - "fix": { - "commit": "b1fb565", - "branchCommit": "62792c7", - "test": "Client/tests/unit/screen-share-tracks.test.ts", - "revertProof": "self-reported", - "pr": 1366 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0027", - "title": "HTTP listen failure returns from run() without hub.GracefulStop(), orphaning the companion livekit-server process and leaving the maintenance goroutine's stop channel unclosed", - "file": "Server/main.go", - "line": 383, - "severity": "medium", - "why": "hub.GracefulStop() — the only caller of LiveKitProcess.Stop() — is a plain statement at line 401, not a defer, and the serve-error branch returns at line 383 before reaching it. LiveKitProcess.Stop() is what cancels the context passed to exec.CommandContext, so skipping it leaves the spawned livekit-server child alive; Go does not kill children when the parent exits, so it is reparented and keeps holding :7880 and the 50000-60000 UDP range. `close(stopMaintenance)` (line 407) is likewise skipped, so the 15-minute maintenance goroutine survives the whole deferred teardown, including `database.Close()` at line 133.", - "repro": "Configure voice.livekit_binary (or voice.auto_download_livekit: true) and start the server while another process holds the configured server.port. api.NewRouter spawns livekit-server via LiveKitProcess.Start. The listen loop retries 20 times, then pushes the bind error onto serveErr; run() returns at line 383, main() calls os.Exit(1) — hub.GracefulStop() never runs, so LiveKitProcess.Stop() never cancels its context and the livekit-server child outlives the OwnCord process. Restarting OwnCord then fails LiveKit startup with :7880 already in use. The same return also strands the maintenance goroutine, which can be mid-DeleteExpiredSessions(bgCtx) when the deferred database.Close() executes.", - "evidence": "select { case err := <-serveErr: if err != nil { return fmt.Errorf(\"server error: %w\", err) } ... } // line 380-387\n...\nhub.GracefulStop() // line 401 — never reached on the serveErr path\nif err := srv.Shutdown(shutdownCtx); err != nil { return ... }\nclose(stopMaintenance) // line 407 — also never reached\n\n// router.go:164-169 already started the companion process by this point:\nproc := ws.NewLiveKitProcess(&cfg.Voice, &cfg.TLS, cfg.Server.DataDir)\nif startErr := proc.Start(); startErr != nil { ... } else { hub.SetLiveKitProcess(proc) }", - "suggestedFix": "Add `defer hub.GracefulStop()` immediately after `router, hub, routerCleanup := api.NewRouter(...)` (main.go:198). gracefulOnce makes it idempotent with the explicit call at line 401 on the normal path, and it guarantees LiveKitProcess.Stop() runs on every early return.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "lifecycle", - "finder": "opus", - "round": 2, - "confidence": "high", - "fix": { - "commit": "db0275a2", - "test": "Server/main_test.go", - "revertProof": "pass", - "branchCommit": "d74e9861", - "pr": 1369 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0028", - "title": "buildReady drops the user's own live voice room when it is not READ-visible, wiping the client's call roster on a full resync", - "file": "Server/ws/serve_ready.go", - "line": 276, - "severity": "medium", - "why": "buildReady filters every voice_state through visibleSet = READ-visible non-DM channels ∪ the user's *open* DM channels. Voice membership is gated on CONNECT_VOICE alone (voice_join.go:64) and DM visibility comes from dm_open_state, so the room the user is currently in can be absent from visibleSet — the exact hole handleReconnect patches on the replay tier via liveVoiceEventsSince (serve.go:341-343, whose comment names 'a DM voice call after the DM was closed' as the stock case). The full-ready tier has no equivalent supplement, so the ready payload asserts the user is in no voice channel while the server's voice_states row, the hub's c.voiceChID and the LiveKit session all say otherwise.", - "repro": "Alice and Bob are in a 1:1 DM voice call. Alice closes the DM from the sidebar (DELETE /api/v1/dms/{id}); CloseDM's non-group branch only deletes her dm_open_state row and leaves result.Left false, so no voice eviction runs — she stays in the call. Alice's socket then drops and her resume takes the full-ready path (mustFullResync, or a buffer/cold-tier miss). buildReady's dmChannels comes from GetUserDMChannels (dm_open_state), so the DM id is not in visibleSet and BOTH voice_state rows are filtered out; payload.voice_states is empty. Client-side setVoiceStates (Client/src/stores/voice.store.ts:185) then does `voiceUsers: channelMap` — a full replacement with an empty map — while `currentChannelId: autoJoinChannel ?? prev.currentChannelId` keeps her in the channel, and selfState is undefined so localServerMuted/localServerDeafened are reset to false. Result: a live, audible call rendering zero participants (including herself), setLocalSpeaking permanently a no-op (it early-returns when voiceUsers.get(channelId) is undefined), and any moderator server-mute gate silently lifted in the UI. Nothing repopulates her own row until she toggles mute herself. The same happens for any voice channel where an override grants CONNECT_VOICE but denies READ_MESSAGES.", - "evidence": "visibleSet := make(map[int64]struct{}, len(visibleChannels)+len(dmChannels))\nfor i := range visibleChannels { visibleSet[visibleChannels[i].ID] = struct{}{} }\nfor i := range dmChannels { visibleSet[dmChannels[i].ChannelID] = struct{}{} }\nvoiceStates := make([]db.VoiceState, 0, len(allVoiceStates))\nfor i := range allVoiceStates {\n\tif _, ok := visibleSet[allVoiceStates[i].ChannelID]; ok {\n\t\tvoiceStates = append(voiceStates, allVoiceStates[i])\n\t}\n}", - "suggestedFix": "In buildReady, before filtering, seed visibleSet with the channel of the user's own voice row: scan allVoiceStates for a row with UserID == userID and add its ChannelID to visibleSet (the user's own live room can never leak — they are in it). This mirrors liveVoiceEventsSince's rationale on the replay tier.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "state-desync", - "finder": "opus", - "round": 2, - "confidence": "high", - "fix": { - "commit": "db0275a2", - "test": "Server/ws/serve_ready_own_voice_test.go", - "revertProof": "pass", - "branchCommit": "5c4b338d", - "pr": 1369 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0029", - "title": "buildReady swallows three DB errors and ships an authoritative-looking empty snapshot; the client wipes its DM list, member list and unread badges", - "file": "Server/ws/serve_ready.go", - "line": 242, - "severity": "medium", - "why": "Inside one function, `ListChannels`/`ListRoles`/`GetChannelOverridesFor` failures abort the handshake (`return nil, err`), but `ListMembers` (l.153), `GetChannelUnreadCounts` (l.188) and `GetUserDMChannels` (l.242) failures are downgraded to `slog.Warn` plus an empty value, and the `ready` frame is then built and sent as if it succeeded. `ready` is the protocol's full-state snapshot, so the client cannot distinguish \"the query failed\" from \"you genuinely have none\" — the error is mapped to success on the wire.", - "repro": "A server restart makes every client reconnect at once and take the full-ready path; under that load one `GetUserDMChannels` read returns SQLITE_BUSY (or hits the request ctx deadline). The server logs a warning and sends `ready` with `dm_channels: []`. Client/src/lib/dispatcher.ts:331-335 documents the exact opposite contract — \"the server always sends the field, so an empty array is an authoritative 'no open DMs' ... and must clear ghosts from dmStore\" — so `setDmChannels([])` wipes the user's whole DM list, and the reconcile loop at dispatcher.ts:347-366 then deletes every dm-typed mirror row from channelsStore. If the user was viewing a DM, `stillPresent` at dispatcher.ts:285-292 is false, so `setActiveChannel(null)` tears down the open conversation. Every DM is unreachable for the rest of the session: a `ready` is only re-sent on a fresh connect or a full resync, and successful seq-replay reconnects never send one. The same interleaving on `ListMembers` empties the member sidebar (removing the \"Message\" affordance that is the only way back to a DM), and on `GetChannelUnreadCounts` zeroes every channel's unread_count/mention_count/last_message_id.", - "evidence": "members, err := database.ListMembers(ctx)\nif err != nil {\n\tslog.Warn(\"buildReady ListMembers\", \"err\", err)\n\tmembers = []db.MemberSummary{}\n}\n...\nunreadMap, err := database.GetChannelUnreadCounts(ctx, userID)\nif err != nil {\n\tslog.Warn(\"buildReady GetChannelUnreadCounts\", \"err\", err)\n\tunreadMap = map[int64]db.ChannelUnread{}\n}\n...\ndmChannels, err := database.GetUserDMChannels(ctx, userID)\nif err != nil {\n\tslog.Warn(\"buildReady GetUserDMChannels\", \"err\", err)\n\tdmChannels = []db.DMChannelInfo{}\n}\n\n// contrast, same function, lines 144-151:\nchannels, err := database.ListChannels(ctx)\nif err != nil { return nil, fmt.Errorf(\"buildReady ListChannels: %w\", err) }", - "suggestedFix": "In buildReady, treat the three per-user loads like ListChannels: on error from ListMembers, GetChannelUnreadCounts, or GetUserDMChannels, return nil, fmt.Errorf(...) so the handshake fails and the client's reconnect logic retries, instead of shipping empty values the protocol defines as authoritative.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "error-paths", - "finder": "opus", - "round": 2, - "confidence": "high", - "fix": { - "commit": "7be9ccd2", - "test": "Server/ws/serve_ready_error_propagation_test.go", - "revertProof": "pass", - "branchCommit": "6c0a7caa" - }, - "fixed": "2026-08-14" - }, - { - "id": "OC-0030", - "title": "prependMessages trims the tail at the 500-row cap, silently destroying the user's pending/failed optimistic rows", - "file": "Client/src/stores/messages.store.ts", - "line": 602, - "severity": "medium", - "why": "Optimistic rows (status \"pending\"/\"failed\") are appended at the END of a channel's array by addOptimisticMessage, and prependMessages trims with `combined.slice(0, MAX_MESSAGES_PER_CHANNEL)` — i.e. it drops the tail. Every other writer that replaces a channel window (setMessages L415-424, setAroundMessages L492, invalidateLoadedMessageWindows L545) deliberately carries non-\"sent\" rows across, with the comment \"they are the only copy of the user's composed text\". prependMessages is the one path that does not, so an unsent/failed message and its Retry draft are deleted with no server copy to restore them (the comment at L597-599 claims the dropped tail is \"restored via the detached-window machinery\", which is only true for rows the server actually has).", - "repro": "1. Open a channel with plenty of history. MAX_MESSAGES_PER_CHANNEL = 500, PAGE_SIZE = 50.\n2. Send a message while the socket is down (or let a send fail): addOptimisticMessage appends a row with id 0 and status \"pending\"/\"failed\" at the end of messagesByChannel[ch]; the composer text now exists ONLY in that row (Retry/Delete render off it).\n3. Scroll up repeatedly. Each loadOlderMessages -> prependMessages adds up to 50 older rows at the head. After ~10 pages the array reaches 500.\n4. On the next scroll-up, combined.length = 550 > 500, so wasTrimmed is true and combined = combined.slice(0, 500) keeps the first 500 (oldest) rows and discards the last 50 — which include the optimistic row.\n5. The failed message and its text are gone from the store forever; pendingSends still holds the correlationId, and nothing re-renders a Retry affordance. Contrast step 4 with setMessages, which slices merged.slice(merged.length - MAX) and therefore preserves the same rows.", - "evidence": "let combined = [...converted, ...existing];\n// ...\nconst wasTrimmed = combined.length > MAX_MESSAGES_PER_CHANNEL;\nif (wasTrimmed) {\n combined = combined.slice(0, MAX_MESSAGES_PER_CHANNEL);\n}", - "suggestedFix": "In prependMessages' trim branch, carry non-'sent' rows out of the dropped tail: `if (wasTrimmed) { const kept = combined.slice(0, MAX_MESSAGES_PER_CHANNEL); const carried = combined.slice(MAX_MESSAGES_PER_CHANNEL).filter((m) => m.status !== \"sent\"); combined = carried.length > 0 ? [...kept, ...carried] : kept; }` — mirroring the carry every other window-replacing writer already performs.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "ordering-boundary", - "finder": "opus", - "round": 2, - "confidence": "high", - "fix": { - "commit": "b1fb565", - "branchCommit": "e8bf022", - "test": "Client/tests/unit/messages-store-detached.test.ts", - "revertProof": "self-reported", - "pr": 1366 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0031", - "title": "Channel drag-reorder assumes distinct positions; tied positions make the drop a silent no-op or land the channel in the wrong slot", - "file": "Client/src/components/channel-sidebar/drag-reorder.ts", - "line": 151, - "severity": "medium", - "why": "The mouseup handler reassigns \"the group's own existing position slots\" by sorting the category's position values ascending and zipping them onto the new id order. That is only correct when the positions are distinct. `channels.position` has no uniqueness constraint (Server/db/queries/sqlite/channels.sql has no unique index, AdminUpdateChannel/CreateChannel store whatever is given, and the admin panel's Create Channel modal ships `value=\"0\"` for the Position field — Server/admin/static/index.html:925). When every channel in a category shares position 0, slots is [0,0,0,...] and `ch.position !== newPosition` is false for every row, so `reorders` stays empty, `drag.onReorder` is never called, no PATCH is sent, and updateChannelPosition is never applied — the drag silently does nothing and the row snaps back. With partial ties the zip assigns the wrong slot to the wrong channel, so the dragged channel lands somewhere other than where it was dropped.", - "repro": "1. In the admin panel, create three text channels in category \"Text Channels\" without editing the Position field: general, random, dev. All three are stored with position = 0 (Server/admin/handlers_channels.go:117 -> AdminCreateChannel with req.Position = 0).\n2. In the desktop client, signed in as a MANAGE_CHANNELS holder, getChannelsByCategory sorts them by position (all 0, so stable Map-insertion order): [general, random, dev].\n3. Drag `dev` and drop it on the top half of `general`.\n4. reorderedIds = [dev, general, random]; slots = [0,0,0].\n5. Loop: i=0 -> dev, newPosition 0, dev.position is already 0 -> skipped. i=1 -> general, 0 == 0 -> skipped. i=2 -> random, 0 == 0 -> skipped.\n6. reorders.length === 0, so `drag.onReorder(reorders)` at L166-168 never fires. No adminUpdateChannel PATCH is issued and the store is never updated; the sidebar re-renders in the original order. The drag is unrecoverably a no-op for as long as the tie exists.\nPartial-tie variant: positions [general=0, random=0, dev=5]; dragging dev to the front yields dev->0 (changed, sent), general->0 (skipped), random->5 (changed, sent), leaving general and dev both at 0 — the resulting order depends on Map iteration order rather than the drop.", - "evidence": "const slots = drag.channels.map((c) => c.position).sort((a, b) => a - b);\nconst reorders: ChannelReorderData[] = [];\nfor (let i = 0; i < reorderedIds.length; i++) {\n const id = reorderedIds[i];\n const newPosition = slots[i];\n ...\n const ch = drag.channels.find((c) => c.id === id);\n if (ch !== undefined && ch.position !== newPosition) {\n reorders.push({ channelId: id, newPosition });\n updateChannelPosition(id, newPosition);\n }\n}\nif (reorders.length > 0) {\n drag.onReorder(reorders);\n}", - "suggestedFix": "After sorting, make the slot list strictly increasing before zipping: `for (let i = 1; i < slots.length; i++) { if (slots[i]! <= slots[i - 1]!) slots[i] = slots[i - 1]! + 1; }` — tied groups then get distinct positions, the reorder fires, and subsequent renders order deterministically, while categories with already-distinct slots keep their exact existing range (the behavior the offset test at drag-reorder.test.ts:360 locks).", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "ordering-boundary", - "finder": "opus", - "round": 2, - "confidence": "high", - "fix": { - "commit": "b1fb565", - "branchCommit": "df82fca", - "test": "Client/tests/unit/drag-reorder.test.ts", - "revertProof": "self-reported", - "pr": 1366 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0032", - "title": "Client's `lastSeq` watermark is never reset by a full-ready resync, so it desyncs permanently from the server's seq counter (and then silently skips events)", - "file": "Client/src/lib/ws.ts", - "line": 307, - "severity": "medium", - "why": "`lastSeq` is monotone-increasing (`if (seq > lastSeq) lastSeq = seq`) and is only ever zeroed by `disconnect()` (logout). The server answers an unusable `last_seq` by sending a full `ready` and stamps `replay_source: \"none\"` into auth_ok, but the client ignores that field and keeps the stale watermark forever. Once the server's counter is *below* the client's watermark (any restart where `MAX(events.seq)` is 0 — `event_persistence.enabled=false`, an events table emptied by the 24h pruner, a restored DB), the two counters never re-converge, and while the server's counter climbs back through the stale value the client asks for a range the server happily answers as a complete resume.", - "repro": "Server with `event_persistence.enabled=false` (or an events table emptied by the pruner). Client is connected long enough to reach lastSeq=5000, then the server restarts, so `h.seq` starts at 0. (a) Immediate effect: every subsequent reconnect takes the full-ready tier (ringbuffer.go:66 `afterSeq > newestSeq`), and dispatcher.ts:301-327 fires `invalidateLoadedMessageWindows()` + a full `getMessages` refetch each time, forever. (b) Data loss: the client stays connected while the server's counter climbs to 4990 (received live, lastSeq stays pinned at 5000 because 4990 < 5000). The socket drops; during the reconnect backoff the server broadcasts up to seq 5090. The client reconnects with `last_seq=5000`; the 1000-entry ring buffer holds 4091..5090, so `afterSeq(5000) > oldestSeq(4091)` and `afterSeq <= newestSeq(5090)` both pass and the server replays only 5001..5090 with `replay_source: \"buffer\"`. Events 4991..5000 — real chat_message/chat_deleted/channel_update frames the client missed while offline — are never delivered, no `ready` arrives, and no history refetch is triggered.", - "evidence": "ws.ts:256-260 `const seq = ...; if (seq > lastSeq) { lastSeq = seq; }` — the only write outside disconnect().\nws.ts:307-320 auth_ok branch: `replayDedup = null; setState(\"connected\"); reconnectAttempt = 0; startHeartbeat();` — `payload.replay_source` (Server/ws/serve_ready.go:49, `\"none\"` for fresh/full resync) is never read and lastSeq is never reset.\nws.ts:427 `last_seq: lastSeq` is sent unconditionally on every auth frame.\nws.ts:598 `lastSeq = 0;` inside `disconnect()` only.\nServer side: Server/ws/ringbuffer.go:66 `if afterSeq > rb.newestSeqLocked() { return nil }` forces full ready while the server is behind, and Server/main.go:208-213 only seeds `h.seq` when `cfg.EventPersistence.Enabled` and `maxSeq > 0`.", - "suggestedFix": "In ws.ts's auth_ok branch (line ~307), reset the watermark when the server declares a full resync: `if ((msg.payload as { replay_source?: string }).replay_source === \"none\") lastSeq = 0;` before setState(\"connected\"). The next sequenced frame then adopts the server's current epoch via the existing seq > lastSeq update.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "flow-reconnect", - "finder": "opus", - "round": 3, - "confidence": "high", - "fix": { - "commit": "7be9ccd2", - "test": "Client/tests/unit/ws-reconnect.test.ts", - "revertProof": "pass", - "branchCommit": "bdbbed65" - }, - "fixed": "2026-08-14" - }, - { - "id": "OC-0033", - "title": "A DM send survives a transient GetDMParticipantIDs failure by silently dropping live fan-out to everyone, including the sender", - "file": "Server/service/message_crud.go", - "line": 174, - "severity": "medium", - "why": "SendMessage already committed the message row via CreateMessageWithMentions before this block runs. If s.st.GetDMParticipantIDs then errors (transient DB hiccup, lock contention), the function logs and does `return result, nil` with result.ParticipantIDs left nil and result.IsDM=true. handleChatSendV2 (Server/ws/handlers_chat.go:101-105) unconditionally builds MessageSentDMEvent{participantIDs: result.ParticipantIDs} from that nil slice. EmitEvents routes it as a SequencedDMEvent to Hub.sendSequencedToUsers(channelID, nilUserIDs, payload) (Server/ws/hub_broadcast.go:607-619), which still allocates a seq and pushes into the replay ring buffer/EventPersister, but its `for _, userID := range userIDs` loop is a no-op over the empty slice, so h.SendToUser is never called for anyone -- not even the sender. SendMessage returns err=nil, so chat_send_ok still goes to the sender (their optimistic row reconciles fine), but the other DM participant(s) get no chat_message frame, no unread/mention bump, no last-message preview update, and no notification. They only learn about the message on their own NEXT reconnect, because only a fresh 'ready' recomputes unread_count from the DB independent of WS delivery -- a recipient who stays continuously connected never sees the message land at all.", - "repro": "Users A and B share a DM channel, both connected. A sends a message at the moment s.st.GetDMParticipantIDs(ctx, channelID) returns a transient error for this one call (Server/service/message_crud.go:174-178). CreateMessageWithMentions already succeeded, so the row is in the DB. SendMessage returns (result, nil) with ParticipantIDs=nil; handleChatSendV2 emits MessageSentDMEvent{participantIDs: nil}; sendSequencedToUsers allocates seq N, stores it in the replay buffer, and iterates zero recipients. A's client gets chat_send_ok and shows the message locally; B's client (still connected, no reconnect) never receives seq N live, never bumps its DM badge, and never shows the message -- until B happens to disconnect and reconnect, which is the only path that recomputes unread_count from the DB.", - "suggestedFix": "Query the participants with a cancellation-proof context — participantIDs, pErr := s.st.GetDMParticipantIDs(context.WithoutCancel(ctx), p.ChannelID) — matching the pattern SendMessage already uses for its other post-commit side effects (compensating deletes, applyMentionCounts, audit writes), which eliminates the deterministic sender-disconnect trigger; for the residual genuine-DB-error case, have handleChatSendV2 fall back to emitting MessageSentChannelEvent when result.IsDM && result.ParticipantIDs is empty, so ChannelTopic delivery still reaches any participant currently viewing the DM.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "flow-message", - "finder": "sonnet", - "round": 3, - "confidence": "high", - "fix": { - "commit": "db0275a2", - "test": "Server/service/message_crud_test.go", - "revertProof": "pass", - "followUp": "9cdef406", - "branchCommit": "081bb169", - "pr": 1369 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0034", - "title": "Aborted voice-channel switch restores the server's voice state but never undoes the voice_leave it already broadcast — the user is stuck in a phantom voice session nobody (including themselves) can see", - "file": "Server/ws/voice_join.go", - "line": 174, - "severity": "medium", - "why": "When the pre-switch leave fails to delete the voice_states row, handleVoiceJoin aborts and restores the client's voice state, VoiceTopic subscription and key-holder entry. But finishVoiceLeave has already broadcast voice_leave to an audience that explicitly includes the leaver themselves (voice_leave.go:96-99), and nothing re-broadcasts a voice_state afterwards. Server, DB and the SFU keep the user in the channel while every client — the user's own included — has removed them.", - "repro": "1. User U is in voice channel A; voice_states holds (U, A, joined_at=T) and U's client holds token T.\n2. U sends voice_join{channel_id: B}.\n3. handleVoiceJoin (voice_join.go:151) runs handleVoiceLeave. Inside finishVoiceLeave, leaveVoiceChannelWithRetry fails to remove the row — either the synchronous DELETE errors (SQLITE_BUSY under concurrent writes; the background retries have not landed yet) or the client's join token is empty, in which case voice_leave.go:129 skips the DELETE entirely and returns nil.\n4. finishVoiceLeave still broadcasts voice_leave{A, U} to the READ audience, the remaining room participants, AND U (voice_leave.go:96).\n5. Back in handleVoiceJoin, GetVoiceState still returns the row for A, so the abort branch at voice_join.go:165 runs: c.setVoiceState(A, T), Subscribe(VoiceTopic(A)), updateKeyHolder(A), and an INTERNAL error to U. No voice_state is broadcast.\n\nEnd state: the hub, the voice_states row and the LiveKit participant all still have U in channel A, but every connected client removed U from A's roster, and U's own client ran leaveVoice(false) + leaveVoiceChannel() and shows \"not in voice\". U cannot rejoin A — handleVoiceJoin:124 answers ALREADY_JOINED — while still consuming a slot in JoinVoiceChannelIfCapacity's COUNT(*) and still appearing in every freshly built ready payload (buildReady reads GetAllVoiceStates). The stale-voice sweep cannot heal it either: sweepStaleVoiceStates only reaps rows whose channel disagrees with the client's voiceChID, and the abort deliberately made them agree.", - "evidence": "voice_join.go:151-179\n\t\th.handleVoiceLeave(ctx, c) // -> finishVoiceLeave broadcasts voice_leave (incl. to c)\n\t\tvs, err := h.db.GetVoiceState(ctx, c.userID)\n\t\t...\n\t\tif vs != nil {\n\t\t\tslog.Warn(\"handleVoiceJoin: stale voice state persists after leave, aborting switch\", ...)\n\t\t\tc.setVoiceState(vs.ChannelID, vs.JoinedAt)\n\t\t\th.pubsub.Subscribe(c, VoiceTopic(vs.ChannelID))\n\t\t\th.updateKeyHolder(vs.ChannelID)\n\t\t\tc.sendMsg(buildErrorMsg(ErrCodeInternal, \"voice channel switch failed — please try again\"))\n\t\t\treturn // <-- no broadcastVoiceEvent(ctx, vs.ChannelID, buildVoiceState(*vs))\n\t\t}\n\nvoice_leave.go:96-99 (the leaver is always in the voice_leave audience)\n\tif _, ok := seen[c.userID]; !ok {\n\t\taudience = append(audience, c.userID)\n\t}\n\th.broadcastChannelScopedTo(oldChID, buildVoiceLeave(oldChID, c.userID), audience, \"voice event\")\n\nvoice_leave.go:129-134 (an empty join token makes the delete a silent no-op, guaranteeing the abort branch)\n\tif joinToken == \"\" {\n\t\tslog.Warn(\"LeaveVoiceChannelIfMatch skipped due to missing join token\", ...)\n\t\treturn nil\n\t}\n\nClient/src/lib/dispatcher.ts:790-815 (a self voice_leave tears the session down)\n\tconst shouldTeardownSession = isSelf && voiceStore.getState().currentChannelId === payload.channel_id;\n\t... if (shouldTeardownSession) void leaveVoice(false);\n\tif (isSelf) { leaveVoiceChannel(); }", - "suggestedFix": "In the abort branch (voice_join.go:165-179), stop restoring the session: the voice_leave already broadcast has made every client (and the user's own media session) treat the user as departed, so restoring resurrects a session that no longer exists anywhere else. Delete the c.setVoiceState/Subscribe/updateKeyHolder restore and just send the error — with the client state left cleared, sweepStaleVoiceStates' DB loop (row present, voiceChID=0 mismatch) removes the stale row within one tick and re-broadcasts voice_leave, and the user_id-PK upsert lets the user rejoin immediately. If the restore must stay for some reason, the alternative is to add h.broadcastVoiceEvent(ctx, vs.ChannelID, buildVoiceState(*vs)) after updateKeyHolder so clients re-add the participant.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-server-ws", - "finder": "opus", - "round": 4, - "confidence": "high", - "fix": { - "commit": "7be9ccd2", - "test": "Server/ws/voice_handlers_test.go", - "revertProof": "pass", - "branchCommit": "c67d25ed" - }, - "fixed": "2026-08-14" - }, - { - "id": "OC-0035", - "title": "Deleting a voice channel races a concurrent voice_join, producing a permanent hub/SFU ghost participant that no sweep can ever detect or heal", - "file": "Server/admin/handlers_channels.go", - "line": 288, - "severity": "medium", - "why": "handleDeleteChannel evicts the CURRENT voice participants via hub.CleanupVoiceForChannel(id) and only afterward calls database.AdminDeleteChannel(id), which deletes the channel row and relies purely on the voice_states FK cascade to clean up (Server/db/admin_queries.go:192-196 — a plain DELETE FROM channels, no check for live voice participants). Nothing marks the channel as going away between those two calls (contrast with the archive path in the same file, lines 257-269, which sets Archived=true in the DB *before* calling CleanupVoiceForChannel, so a racing voice_join sees ch.Archived==true and is refused — voice_join.go:92). The delete path has no such guard, so a voice_join that reads the still-live channel row via GetChannel (voice_join.go:70) during this window proceeds to insert a voice_states row and set the hub client's in-memory voice state (voice_join.go:222, c.setVoiceState — done deliberately *before* the LiveKit token round trip per the BUG-088 comment) exactly as it would for a channel that isn't being deleted. If AdminDeleteChannel's cascade fires after that insert commits, the freshly-created voice_states row is silently deleted by the cascade, but the hub client's in-memory voiceChID, its VoiceTopic subscription, and (if the channel had capacity) the LiveKit room are left completely untouched — nothing in handleVoiceJoin re-checks that the channel still exists after the insert. The resulting ghost is then invisible to both of sweepStaleVoiceStates's healing loops (Server/ws/hub_sweep.go:140-250): the DB-driven loop (lines 193-249) only iterates rows returned by GetAllVoiceStates, and the cascade-deleted row is no longer among them, so it can never flag a hub client with no matching DB row; the permission-revocation loop (lines 152-191) calls hasChannelPermChecked, whose GetChannelPermissions query (Server/db/channel_queries.go:141-153) returns (0,0,nil) — not an error — for a nonexistent channel ID (it's a plain lookup keyed by channel_id with sql.ErrNoRows mapped to a clean zero), so the effective-permission check collapses to the user's bare role bits; any role whose base permissions include CONNECT_VOICE (the common default) is reported 'allowed' and never evicted. The user is left stuck 'in voice' forever (mic hot if publishing, SFU room orphaned) with the client UI showing nothing to leave from (client-side channel_delete handling in dispatcher.ts:617-633 only redirects the sidebar/active-channel view; it performs no voice teardown at all), until they manually reconnect the whole client.", - "repro": "1) Create a voice channel with at least one connected participant so cleanup takes real wall-clock time (CleanupVoiceForChannel's per-participant LiveKit RemoveParticipant call can take up to lkTimeout=5s each — livekit.go:153,181). 2) As an admin, DELETE that channel via the admin API/UI. 3) While CleanupVoiceForChannel is still evicting the existing participants (i.e., before AdminDeleteChannel's DELETE has executed), have a different, already-connected user send voice_join for that same channel_id — it passes GetChannel, permission, and archived checks (all still see the live row) and its JoinVoiceChannel/JoinVoiceChannelIfCapacity insert commits before the channel row is deleted. 4) AdminDeleteChannel then runs, cascading away the new voice_states row along with the channel. 5) Observe: the joining client's hub-side voiceChID stays set to the deleted channel, its VoiceTopic subscription and (if configured) LiveKit SFU membership are never torn down, and it is never picked up by either loop of sweepStaleVoiceStates on any subsequent tick — it stays a permanent ghost until that client's socket disconnects on its own.", - "suggestedFix": "Mirror the archive path's guard: in handleDeleteChannel, persist archived=1 on the channel (e.g. via AdminUpdateChannel with Archived:true, or a dedicated UPDATE) BEFORE calling hub.CleanupVoiceForChannel, so any voice_join racing the cleanup is refused by the existing archived gate at voice_join.go:92; then delete the row as today. (Defense-in-depth alternative: in handleVoiceJoin, re-fetch GetChannel after the voice_states insert commits and call rollbackVoiceJoin if the channel is gone or archived.)", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-server-ws", - "finder": "sonnet", - "round": 4, - "confidence": "medium", - "fix": { - "commit": "7be9ccd2", - "test": "Server/admin/api_test.go", - "revertProof": "pass", - "branchCommit": "4bb0cbf1" - }, - "fixed": "2026-08-14" - }, - { - "id": "OC-0036", - "title": "Slow mode consumes its cooldown token before content and attachment validation, so a rejected send locks the composer for the full window", - "file": "Server/service/message_crud.go", - "line": 64, - "severity": "medium", - "why": "SendMessage calls limiter.Allow on the per-(user, channel) slow-mode key — which records a timestamp — before sanitizeContent, before the ATTACH_FILES check, and before the insert. Any of those can reject the send, but the cooldown has already been spent, so the user is refused with SLOW_MODE for up to maxSlowModeSeconds (21600 s = 6 h) without ever having posted anything.", - "repro": "Set slow_mode = 3600 on #general. As a member without MANAGE_MESSAGES, send a 5000-rune chat_send. Line 66 records the slow-mode timestamp, then sanitizeContent (message.go:216) returns ErrBadRequest \"message content exceeds maximum length\" — nothing is stored and nothing is broadcast. Shorten the text and resend immediately: line 66 now returns false and the send is refused with ErrSlowMode for the next hour. The same holds for an attachment-only send by a user lacking ATTACH_FILES (rejected at line 80) and for a CreateMessageWithMentions failure at line 93. No test locks the current ordering — Server/ws/coverage_chat_test.go:241 only asserts that a second *successful* send is throttled.", - "evidence": "Server/service/message_crud.go:63-82 — the Allow (which records the timestamp) precedes both validations:\n\t// Slow mode (non-DM only).\n\tif !isDM && ch.SlowMode > 0 && !s.perms.HasChannelPerm(ctx, p.UserID, p.ChannelID, permissions.ManageMessages) {\n\t\tslowKey := auth.Key(auth.Key(\"slow\", p.UserID), p.ChannelID)\n\t\tif s.limiter != nil && !s.limiter.Allow(slowKey, 1, time.Duration(ch.SlowMode)*time.Second) {\n\t\t\treturn nil, fmt.Errorf(\"%w: channel has %ds slow mode\", ErrSlowMode, ch.SlowMode)\n\t\t}\n\t}\n\n\t// Validate and sanitize content.\n\tcontent, err := sanitizeContent(p.Content, len(p.AttachmentIDs) > 0)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Attachment permission (non-DM).\n\tif !isDM && len(p.AttachmentIDs) > 0 {\n\t\tif !s.perms.HasChannelPerm(ctx, p.UserID, p.ChannelID, permissions.AttachFiles) {\n\t\t\treturn nil, fmt.Errorf(\"%w: missing ATTACH_FILES permission\", ErrForbidden)\n\t\t}\n\t}\n\nServer/auth/ratelimit.go:149-154 — Allow appends the timestamp on the permitted path, so the token is spent even though the caller then errors out:\n\tif len(e.timestamps) >= limit { return false }\n\te.timestamps = append(e.timestamps, now)\n\treturn true\n\nServer/ws/command.go:401-441 — the chat_send constructor validates only channel_id and the attachment count/length; content length and emptiness are never checked before the service call, so an over-length body reaches line 72.\n\nServer/admin/handlers_channels.go:147 — maxSlowModeSeconds = 21600.", - "suggestedFix": "Move the slow-mode block (message_crud.go:63-69) below the sanitizeContent call and the ATTACH_FILES permission check (i.e., to just after line 82, before resolveMentions), so the once-per-window token is only consumed once the send has passed every request-shaped validation.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-server-service", - "finder": "opus", - "round": 4, - "confidence": "high", - "fix": { - "commit": "7be9ccd2", - "test": "Server/service/message_crud_test.go", - "revertProof": "pass", - "branchCommit": "86acf049" - }, - "fixed": "2026-08-14" - }, - { - "id": "OC-0037", - "title": "Tray Status menu bypasses the client's own status state, so a tray-set Do Not Disturb neither silences notifications nor survives the idle timer or a reconnect", - "file": "Client/src/main.ts", - "line": 251, - "severity": "medium", - "why": "The `status-change` listener only puts a `presence_update` on the wire. Unlike both in-app status surfaces (UserBar.ts:150-157 and settings/AccountTab.ts:883-884, which call `saveUserStatus(status)` and `applyPresence(status)`), it never writes the `userStatus` preference and never calls `updatePresence()`. `lib/userStatus.ts` is documented as \"the single client-side source of truth\" for the chosen status, and three separate consumers read it — so after a tray selection the server and the client disagree permanently, and two independent code paths then silently undo the user's choice.", - "repro": "Pick \"Do Not Disturb\" from the tray Status submenu while signed in. The server stores dnd and every other client sees dnd, but `loadUserStatus()` still returns \"online\" with origin \"manual\". Consequences, all reachable: (1) every incoming message still raises a desktop notification and plays the chime, because notifications.ts:85 gates on `loadUserStatus() === \"dnd\"` — DND set from the tray does nothing it promises; (2) after ten quiet minutes autoIdle's `apply(true)` computes `nextAutoStatus(\"online\", \"manual\", true) === \"idle\"` and sends `presence_update {status:\"idle\"}`, overwriting the DND — the module's own doc comment states \"a manually chosen Do Not Disturb or Invisible is never touched\"; (3) on the next WS reconnect, `auth_ok` carries the user's own true status (\"dnd\", serve_ready.go:45), so `restoreSavedPresence()` sees serverStatus \"dnd\" != loadUserStatus() \"online\" and sends `presence_update {status:\"online\"}` plus a local `updatePresence(online)`, silently reverting the tray choice; (4) the UserBar dot and the settings Account tab keep rendering the pre-tray status for the whole session, because both re-render off `onUserStatusChange`, which only fires from `saveUserStatus`.", - "evidence": "main.ts:251-256\n void listen(\"status-change\", (e) => {\n const status = e.payload;\n if (status === \"online\" || status === \"idle\" || status === \"dnd\" || status === \"offline\") {\n ws.send({ type: \"presence_update\", payload: { status } });\n }\n });\n\ncontrast — UserBar.ts:150-157\n onStatusChange: (status: UserStatus) => {\n saveUserStatus(status);\n updateFromState();\n ... ws.send({ type: \"presence_update\", payload: { status } })\n }\n\nconsumers of the pref the tray path never writes:\n lib/notifications.ts:85 const dnd = loadUserStatus() === \"dnd\";\n lib/autoIdle.ts:374 const next = nextAutoStatus(loadUserStatus(), loadUserStatusOrigin(), idle);\n pages/MainPage.ts:180-191 restoreSavedPresence(): compares loadUserStatus() with authStore.user.status and re-sends the local value on every transition to \"connected\"", - "suggestedFix": "In the main.ts status-change listener, mirror UserBar's path instead of raw-sending: map the tray's legacy \"offline\" to \"invisible\" (matching userStatus.ts's migration), call saveUserStatus(mapped) (origin \"manual\") before ws.send({type:\"presence_update\",payload:{status: mapped}}). Persisting via saveUserStatus makes notifications, autoIdle, restoreSavedPresence, and the UserBar/Account-tab renders (via onUserStatusChange) all agree with the wire state in one place.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-client-tauri-client-src", - "finder": "opus", - "round": 5, - "confidence": "high", - "fix": { - "commit": "8787b906", - "test": "Client/tests/unit/main.test.ts", - "revertProof": "pass", - "branchCommit": "c3a20a95" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0038", - "title": "The LiveKit participant_left teardown never tells the leaver, unlike every sibling eviction path", - "file": "Server/ws/livekit_webhook.go", - "line": 229, - "severity": "medium", - "why": "handleWebhookParticipantLeft clears the client's voice state, drops its VoiceTopic subscription, deletes the DB row and re-elects the key holder, then announces the departure with the plain broadcastVoiceEvent. That helper's audience is (READ_MESSAGES holders) ∪ (clients whose getVoiceChID() still equals the channel) — and the leaver was just removed from the second set. Voice membership is gated on CONNECT_VOICE alone, so a participant without READ_MESSAGES on that voice channel receives nothing. The two sibling teardown paths, finishVoiceLeave (voice_leave.go:96-98) and CleanupVoiceForChannel (hub_sweep.go:337-342), both explicitly append the evicted user to the audience for exactly this reason; this path does not, leaving the client believing it is still in a call the server has already torn down.", - "repro": "1. Configure voice channel V so role R has CONNECT_VOICE but a channel_overrides deny on READ_MESSAGES (the configuration the code repeatedly documents as supported — see the audience comments in voice_leave.go:74-82 and hub_broadcast.go:61-66). 2. User U (role R) joins V: voice_states row committed, c.voiceChID=V, c.voiceJoinToken=JoinedAt, subscribed to VoiceTopic(V). 3. U's SFU connection drops (network blip, media-port loss) while the WebSocket stays up; LiveKit fires participant_left with identity \"user-U:JoinedAt\" for room \"channel-V\". 4. matched is true, so the server clears c.voiceChID/voiceJoinToken/e2eePubKey, unsubscribes VoiceTopic(V), deletes the voice_states row, and re-elects the key holder. 5. broadcastVoiceEvent resolves the audience: channelReadAudience takes the non-DM role-scan branch and excludes U (no READ_MESSAGES); the participant union cannot see U because step 4 already zeroed getVoiceChID(). U receives no voice_leave. 6. U's client still renders itself in the call with the mic hot and keeps auto-reconnecting to LiveKit with a token whose voice_states row no longer exists — every retry is ejected by handleWebhookParticipantJoined's rogue-participant check (livekit_webhook.go:132), and nothing on U's socket ever reports the eviction.", - "evidence": "c.voiceMu.Lock()\nmatched := c.voiceChID == channelID && c.voiceJoinToken != \"\" && c.voiceJoinToken == joinToken\nif matched {\n\tc.voiceChID = 0\n\t...\n}\nc.voiceMu.Unlock()\n\nif matched {\n\th.pubsub.Unsubscribe(c, VoiceTopic(channelID))\n\t...\n\th.broadcastVoiceEvent(ctx, channelID, buildVoiceLeave(channelID, userID))\n\n// hub_broadcast.go:67-79 — broadcastVoiceEvent's audience, with no leaver term:\naudience := h.channelReadAudience(ctx, channelID)\n...\nfor uid, c := range h.clients {\n\tif _, ok := seen[uid]; !ok && c.getVoiceChID() == channelID {\n\t\taudience = append(audience, uid)\n\t}\n}\n\n// voice_leave.go:96-98 — the sibling path that DOES include the leaver:\nif _, ok := seen[c.userID]; !ok {\n\taudience = append(audience, c.userID)\n}", - "suggestedFix": "Extract finishVoiceLeave's audience construction (voice_leave.go:83-99: channelReadAudience ∪ remaining participants ∪ the leaver) into a shared helper, and call it from handleWebhookParticipantLeft's matched branch in place of the bare h.broadcastVoiceEvent(ctx, channelID, buildVoiceLeave(channelID, userID)) at livekit_webhook.go:229.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-server-ws", - "finder": "opus", - "round": 5, - "confidence": "high", - "fix": { - "commit": "7be9ccd2", - "test": "Server/ws/livekit_test.go + Server/ws/livekit_webhook_joined_test.go", - "revertProof": "pass", - "branchCommit": "9981220f" - }, - "fixed": "2026-08-14" - }, - { - "id": "OC-0039", - "title": "DeleteMessage treats a GetChannel read error as \"not a DM\", letting a moderator hard-delete another user's private DM message", - "file": "Server/service/message_crud.go", - "line": 346, - "severity": "medium", - "why": "`isDM` collapses a lookup failure into false, so a DM falls into the non-DM branch where authority is a plain role check against a channel id that has no override rows. The DM-participant gate is skipped entirely, and `isMod` is then passed to `s.st.DeleteMessage`, which documents that it \"skips the ownership check when ismod is true\".", - "repro": "Alice and Bob have a 1:1 DM; Bob posts message 42. Moderator Mallory (role holds READ_MESSAGES|MANAGE_MESSAGES, not a participant) sends chat_delete{message_id:42} at a moment when the reader pool returns an error for GetChannel (SQLITE_BUSY / \"database is locked\" under write contention, or a pool error). chErr != nil -> isDM=false -> HasChannelPermBatch(rolePerms, overrides, dmChannelID, READ|MANAGE) sees no override entry for a DM channel, so it answers from the base mask and returns true -> canManage=true, isMod=true -> db.DeleteMessage skips the ownership check and soft-deletes Bob's message in a DM Mallory is not in. Any ADMINISTRATOR passes unconditionally via checker.go:70-72. The correct answer, and the one every ws-layer sibling gives for the same failed lookup, is Forbidden.", - "evidence": "ch, chErr := s.st.GetChannel(ctx, msg.ChannelID)\nisDM := chErr == nil && ch != nil && ch.Type == \"dm\"\n...\n} else {\n\tisMsgOwner := msg.UserID == userID\n\tcanManage := s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ReadMessages|permissions.ManageMessages)\n\tcanDelete := canManage || (isMsgOwner && ...)\n\tif !canDelete { return nil, fmt.Errorf(\"%w: cannot delete this message\", ErrForbidden) }\n\tisMod = canManage\n}", - "suggestedFix": "Fail closed like SendMessage does: replace lines 345-346 with `ch, chErr := s.st.GetChannel(ctx, msg.ChannelID); if chErr != nil || ch == nil { return nil, fmt.Errorf(\"%w: cannot delete this message\", ErrForbidden) }; isDM := ch.Type == \"dm\"`.", - "status": "declined", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-server-service", - "finder": "opus", - "round": 5, - "confidence": "high", - "fix": null, - "rationale": "Already fixed by db0275a2 (#1369, 2026-08-14): DeleteMessage GetChannel error path fails closed with ErrForbidden before isDM is computed; locked by TestDeleteMessage_FailsClosedWhenChannelLookupErrors (message_crud_test.go:288), confirmed passing on current code. No failing test can be written." - }, - { - "id": "OC-0040", - "title": "Scroll-to-bottom button and \"Jump to Present\" pill are absolutely positioned inside the scroll container, so they scroll out of view exactly when they are shown", - "file": "Client/src/components/MessageList.ts", - "line": 810, - "severity": "medium", - "why": "Both controls are appended to `root` (.messages-container), which is itself the `overflow-y: auto` scroller, and are styled `position: absolute; bottom: 8px`. Per CSS Overflow, boxes whose containing block is the scroll container are part of its scrollable overflow region, so they translate with the scrolled content: the button sits at the viewport bottom only at scrollTop ≈ 0 and is painted scrollTop px above the visible area otherwise. Both controls are only made visible when the user is NOT at the bottom, i.e. precisely when scrollTop is large and they are off-screen.", - "repro": "Open a channel with ~5000px of content. Scroll up 2000px: updateScrollToBottomBtn() adds .visible (opacity 1, pointer-events auto) but the button's painted position is (clientHeight - 48) - 2000 px, far above the scrollport, clipped away by the container's overflow/`contain: strict` — the user has a \"visible\" control they can neither see nor click. Same for the pill: jump to an old message via scrollToMessage (which sets root.scrollTop = offsetBefore(idx)), updateJumpToPresentPill() adds .visible, and the only signal that the loaded window is detached from the live tail is painted off-screen. jsdom has no layout, so tests/unit/message-jump.test.ts:636-677 assert only the class, not visibility.", - "evidence": "root.appendChild(scrollToBottomBtn);\nroot.appendChild(jumpToPresentPill);\n// src/styles/app.css:896 .messages-container { flex:1; overflow-y:auto; contain:strict; position:relative; }\n// src/styles/app.css:913 .scroll-to-bottom-btn { position:absolute; bottom:8px; right:16px; … }\n// src/styles/app.css:948 .jump-to-present-pill { position:absolute; bottom:8px; left:50%; … }", - "suggestedFix": "Give the controls a non-scrolling positioned ancestor: in mount(), wrap the scroller in a position:relative wrapper div and append scrollToBottomBtn and jumpToPresentPill to the wrapper instead of root (root keeps the scroll listener and children; the wrapper becomes what is appended to parentContainer).", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "fresh-eyes", - "finder": "opus", - "round": 5, - "confidence": "high", - "fix": { - "commit": "b1fb565", - "branchCommit": "a50a9d3", - "test": "Client/tests/unit/message-list.test.ts", - "revertProof": "self-reported", - "pr": 1366 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0041", - "title": "Any user whose username is exactly \"System\" has every message rendered as a server system notice, with no author and no moderation controls", - "file": "Client/src/components/message-list/renderers.ts", - "line": 182, - "severity": "medium", - "why": "renderMessage dispatches to renderSystemMessage purely on `msg.user.username === \"System\"` — it never checks user.id (the tests use id 0) and the server never emits such messages, so the only way to reach that branch in production is a real account named \"System\". Server-side ValidateUsername (Server/auth/helpers.go:19) only rejects control/invisible characters and the \"[deleted-…]\" namespace, so the name is registrable.", - "repro": "Register an account with username \"System\" and post \"Your session was flagged — re-enter your password at …\". Every client renders it through renderSystemMessage: a system icon, muted italic text and a timestamp, with no avatar, no author name and no role colour — visually identical to a server notice. Because renderSystemMessage returns before the hover action bar is built, the row also carries no react/reply/pin/edit/delete buttons and there is no message context menu anywhere in the client, so a moderator with canManageMessages() has no UI path to delete it.", - "evidence": "export function renderMessage(msg, isGrouped, allMessages, opts, signal) {\n if (msg.user.username === \"System\") {\n return renderSystemMessage(msg);\n }\n// renderSystemMessage builds only icon + text + time and returns — the\n// `if (!msg.deleted && msg.status === \"sent\")` action-bar block is unreachable.", - "suggestedFix": "Reserve the name server-side in auth.ValidateUsername: reject strings.EqualFold(strings.TrimSpace(username), \"System\") alongside the existing \"[deleted-\" reservation (covers both register and rename since both funnel through it). Per docs/security.md, route the fix through a GitHub Security Advisory rather than a public issue.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "fresh-eyes", - "finder": "opus", - "round": 5, - "confidence": "high", - "fix": { - "commit": "7be9ccd2", - "test": "Client/tests/unit/renderers.test.ts", - "revertProof": "pass", - "branchCommit": "e25fe56a" - }, - "fixed": "2026-08-14" - }, - { - "id": "OC-0042", - "title": "leaveVoice() stops manual camera/screen tracks without bumping the enable/disable race-guard generation, so a camera/screenshare enable that is mid-flight (awaiting the OS permission prompt / device picker) when the user leaves voice resurrects a track after the room is gone", - "file": "Client/src/lib/livekitSession.ts", - "line": 1361, - "severity": "medium", - "why": "The `generation` counter on CameraTrackState/ScreenTrackState exists specifically so a concurrent enable() that captured its value before a multi-second device-acquisition await (getUserMedia/getDisplayMedia) can detect it was superseded by a disable and discard the track instead of publishing over it (see the doc comment at screenShare.ts:152-159). doDisableCamera/doDisableScreenshare (screenShare.ts:276, screenShare.ts:399) correctly call bumpGeneration(state) before stopping tracks. leaveVoice() performs the exact same operation — it calls stopManualCameraTrack(this._cameraState, this._room) and stopManualScreenTracks(this._screenState, this._room) directly (lines 1361-1362) and even resets setLocalCamera(false)/setLocalScreenshare(false) (lines 1386-1387) — but never touches state.generation. Because `_cameraState`/`_screenState` are single per-session fields never reinitialized across join/leave cycles (declared once at lines 259-260), a stale enableCamera()/enableScreenshare() continuation that resumes after leaveVoice() ran will pass the `(state.generation ?? 0) !== generation` check at screenShare.ts:235/334, believe it is still current, set state.manualCameraTrack/manualScreenTracks to the newly created track(s), and attempt room.localParticipant.publishTrack() against `room` — a reference captured before the leave, i.e. a room that leaveVoice() has already called room.disconnect() on (line 1373). If that publish does not synchronously throw, the store is left saying camera/screenshare is whatever enableCamera set (or, worse, a mismatched state: the track object sits in state.manualCameraTrack referencing a track published to an already-disconnected room), and the physical camera/mic-capture device stays open. Nothing frees it: the next enableCamera()/disableCamera() call only calls stopManualCameraTrack when `deps.getRoom()` is non-null (screenShare.ts:206, :220), i.e. only once the user has rejoined a voice channel — until then the camera hardware (LED) stays active after the user has already left the call.", - "repro": "1) Join a voice channel (room R1 live). 2) Click 'Enable camera' — enableCamera() runs setLocalCamera(true), captures room=R1 and generation=0, then awaits createLocalVideoTrack(...), which blocks on the browser's camera permission prompt. 3) Before responding to the prompt, click 'Leave Voice' — leaveVoice() runs synchronously: stopManualCameraTrack no-ops (nothing published yet), room.disconnect() is called on R1, setLocalCamera(false) is set, generation stays 0. 4) Grant camera permission — createLocalVideoTrack resolves; enableCamera() checks `(state.generation ?? 0) !== generation` → 0 !== 0 → false (not superseded), sets state.manualCameraTrack = videoTrack, and calls `room.localParticipant.publishTrack(videoTrack, ...)` on the already-disconnected R1. The camera device is now held open by a track that was never cleaned up, and the app has already visually left the voice call.", - "suggestedFix": "Export a supersede helper from screenShare.ts (e.g. `export function supersedeVideoEnable(state: GenerationGuarded): void { state.generation = (state.generation ?? 0) + 1; }` — reuse it inside bumpGeneration) and call it on this._cameraState and this._screenState in leaveVoice immediately before the stopManualCameraTrack/stopManualScreenTracks calls at livekitSession.ts:1361-1362, so the stale enable discards its track at the existing screenShare.ts:235/334 check.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-client-tauri-client-src", - "finder": "sonnet", - "round": 6, - "confidence": "high", - "fix": { - "commit": "7be9ccd2", - "test": "Client/tests/unit/livekit-session.test.ts", - "revertProof": "pass", - "branchCommit": "db7d518b" - }, - "fixed": "2026-08-14" - }, - { - "id": "OC-0043", - "title": "The built-in \"light\" theme overrides only 4 of the ~45 design tokens and has no stylesheet, so the message composer and every form input render near-invisible dark-on-dark", - "file": "Client/src/components/settings/helpers.ts", - "line": 37, - "severity": "medium", - "why": "`applyThemeByName` applies built-in themes by adding a `body.theme-` class, but `src/styles/` contains a rule for `body.theme-neon-glow` only — there is no `body.theme-light` (or `body.theme-midnight`) block anywhere. The entire \"light\" theme is therefore the 4 inline custom properties `applyTheme` writes onto `document.documentElement`. `--text-normal` flips to the dark `#313338` while `--bg-input` keeps the dark-theme `#383a40` from `tokens.css:11`, so every surface painted with `var(--bg-input)` ends up carrying dark text on a dark box (contrast ≈1.1:1).", - "repro": "Settings -> Appearance -> click \"Light\". `applyTheme(\"light\")` sets `--bg-primary:#ffffff` and `--text-normal:#313338` on ``; `--bg-input` stays `#383a40`. The chat composer (`.message-input-box` + `.msg-textarea`) now paints `#313338` glyphs on a `#383a40` background — typed text is unreadable. Same for the login form's Server Address / Username / Password fields (`login.css:586`) and the reply bar. The setting persists (`applyStoredAppearance` re-runs `applyTheme` on startup), so the state survives restart.", - "evidence": "helpers.ts:37-42 — light: { \"--bg-primary\": \"#ffffff\", \"--bg-secondary\": \"#f2f3f5\", \"--bg-tertiary\": \"#e3e5e8\", \"--text-normal\": \"#313338\" } (4 keys, applied via applyTheme -> root.style.setProperty)\nthemes.ts:61-62 — if (BUILT_IN_THEMES.includes(name)) { document.body.classList.add(`theme-${name}`); } // no CSS backs theme-light / theme-midnight\n`grep -rn \"theme-light\\|theme-midnight\" src/styles/` -> no matches; only `theme-neon-glow.css:4 body.theme-neon-glow { ... }`\ntokens.css:11 — --bg-input: #383a40; (never overridden by the light map)\napp.css:2342 — .message-input-box { background: var(--bg-input); }\napp.css:2377-2380 — .msg-textarea { background: transparent; color: var(--text-normal); }\nlogin.css:586-590 — .form-input { background: var(--bg-input); color: var(--text-normal); }\napp.css:2173-2183 — .reply-bar-inner { background: var(--bg-input); } / .reply-bar-inner strong { color: var(--text-normal); }", - "suggestedFix": "Give the light theme a complete palette: add a body.theme-light block (new theme-light.css, mirroring theme-neon-glow.css) that overrides every dark token used against --text-normal — at minimum --bg-input, --bg-hover, --bg-active, --bg-modifier-*, --border, --border-strong, --text-muted, --text-faint, --text-micro, --header-primary, --header-secondary, --interactive-* — with light-mode values. (Extending the THEMES.light map works too, but the CSS block matches how neon-glow already ships its extra tokens.)", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "fresh-eyes", - "finder": "opus", - "round": 6, - "confidence": "high", - "fix": { - "commit": "7be9ccd2", - "test": "Client/tests/unit/settings-helpers.test.ts", - "revertProof": "pass", - "branchCommit": "2c0960cb" - }, - "fixed": "2026-08-14" - }, - { - "id": "OC-0044", - "title": "rollbackVoiceJoin deletes voice_states by userID alone, letting a stale/failed join's rollback destroy a concurrently-established newer voice membership", - "file": "Server/ws/voice_join.go", - "line": 464, - "severity": "medium", - "why": "rollbackVoiceJoin (called from handleVoiceJoin's two failure paths at lines 208 and 300, both after the DB row for `channelID` has already been inserted and, on the second path, after c.setVoiceState has already been applied) unconditionally clears c's in-memory voice channel via c.clearVoiceChID() and deletes the user's voice_states row via `h.db.LeaveVoiceChannel(context.WithoutCancel(ctx), c.userID)` — a plain `DELETE FROM voice_states WHERE user_id = ?` with no channel_id/joined_at condition. Every sibling leave path in this same package (finishVoiceLeave -> leaveVoiceChannelWithRetry, handleVoiceLeaveIfStillIn) instead uses `LeaveVoiceChannelIfMatch(userID, expectedChannelID, expectedJoinedAt)` specifically, per that function's own comment, 'to prevent a race where a delayed retry could wipe a newer voice membership.' rollbackVoiceJoin never received that same protection, despite its own comment acknowledging that a dying connection ('the join failed BECAUSE the connection died — that cancellation is the most common rollback trigger') is its main trigger, which is exactly the scenario where a second, independent connection for the same user can already have re-established a legitimate new voice_states row by the time this delayed rollback runs (context.WithoutCancel is used precisely so the delete keeps running after the original connection and its context are gone).", - "repro": "User A's connection c1 sends voice_join for channel X; the DB insert for X succeeds and (on the token-generation failure path, after line 222) c1.setVoiceState(X, joinedAt) has already run. Before GenerateToken/GetVoiceState-verify on c1 completes, the underlying connection drops (network blip); c1's readPump goroutine is still live and blocked inside handleVoiceJoin. The client immediately opens a new connection c2 for the same user; the server's registerNow (hub.go:399) swaps h.clients[userID] to c2. The user then sends voice_join for channel Y on c2, which succeeds and inserts a fresh voice_states row (Y, newJoinedAt), with c2 now subscribed to VoiceTopic(Y) and live in the LiveKit room. Meanwhile c1's stalled call finally errors (GetVoiceState fails at voice_join.go:205-211, or GenerateToken fails at voice_join.go:297-303), so `rollbackVoiceJoin(ctx, c1, X, false)` runs and executes `h.db.LeaveVoiceChannel(context.WithoutCancel(ctx), userID)` — deleting the voice_states row for Y that c2 legitimately just created. Result: c2 is still marked in-memory as voiceChID=Y, still subscribed to the voice topic, and still present in the LiveKit room, but its DB voice_states row is gone — a permanent DB/hub/SFU desync (missing from GetChannelVoiceStates, `ready` resyncs, and channel-capacity counts for Y) that nothing subsequently repairs, matching the same ghost-state class as the already-known CleanupVoiceForChannel non-atomicity bug but triggered from the opposite (failed-join rollback) direction.", - "suggestedFix": "Scope the compensating delete to the join instance it is undoing: thread the join's identity into rollbackVoiceJoin (pass state.JoinedAt at the voice_join.go:300 call site; at the :208 site, where GetVoiceState failed, re-read the row with context.WithoutCancel and proceed only if it still names channelID) and replace h.db.LeaveVoiceChannel(ctx, c.userID) with h.db.LeaveVoiceChannelIfMatch(ctx, c.userID, channelID, joinedAt), mirroring leaveVoiceChannelWithRetry.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-server-ws", - "finder": "sonnet", - "round": 7, - "confidence": "medium", - "fix": { - "commit": "db0275a2", - "test": "Server/ws/coverage_voice_lifecycle_test.go", - "revertProof": "pass", - "residual": "review-confirmed ceiling matching this record's own suggestedFix: on the empty-joinedAt rollback path the re-read adopts the row's current joined_at, so a concurrent re-join of the SAME channel by a second connection can still be deleted; cross-channel re-joins are fully protected. Hardening option: have JoinVoiceChannel return the joined_at it wrote and thread it to the rollback call site.", - "branchCommit": "e694a8ad", - "pr": 1369 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0045", - "title": "Role demotion's live-subscription revocation is gated on a cosmetic role re-read, so a failed lookup leaves the demoted user subscribed to channels they can no longer read", - "file": "Server/admin/handlers_users.go", - "line": 192, - "severity": "medium", - "why": "After ChangeUserRole commits, the only call that revokes the user's live pub/sub subscriptions (hub.BroadcastMemberUpdate -> revokeUnreadableChannels) and the only call that re-derives visibility (hub.RefreshAllChannelVisibility) are both nested inside `if role, err := database.GetRoleByID(...); err == nil && role != nil`, a lookup whose only real product is the role NAME for the member_update payload. When that read fails or returns nil the demotion is committed and the permission cache is invalidated, but the socket keeps every ChannelTopic subscription its old role earned — and READ_MESSAGES is only ever checked at channel_focus, never again on the delivery path.", - "repro": "User U holds Moderator, which has a channel_overrides ALLOW of READ_MESSAGES on private #staff; U has #staff focused, so ws/handlers.go:170 has subscribed the socket to ChannelTopic(#staff). Admin A demotes U to a non-default role R via PATCH /admin/api/users/{U} {\"role_id\": R}. ModerationService.ChangeUserRole commits and InvalidateUser(U) runs. Admin B now deletes role R (DELETE /admin/api/roles/{R}) in the window before A's handler reaches line 192 — or the single-writer SQLite pool returns SQLITE_BUSY for that one read. GetRoleByID returns (nil, nil) or (nil, err), so the whole block is skipped: no member_update, no revokeUnreadableChannels, no RefreshAllChannelVisibility, no bumpVisibilityWatermark. U's socket stays in ChannelTopic(#staff) and keeps receiving every chat_message, chat_edited, chat_deleted and reaction_update posted in #staff for the entire life of the connection; every other client also still renders U as Moderator. The sibling handlers handleDeleteRole (handlers_roles.go:206-208) and handlePatchRole's permsChanged branch (handlers_roles.go:160-174) both run the identical fan-out unconditionally.", - "evidence": "if permInvalidator != nil {\n\tpermInvalidator.InvalidateUser(id)\n}\nif role, err := database.GetRoleByID(r.Context(), *req.RoleID); err == nil && role != nil {\n\tif hub != nil {\n\t\thub.BroadcastMemberUpdate(id, role.Name)\n\t\thub.RefreshAllChannelVisibility()\n\t}\n}\n\n// hub_broadcast.go:470 — the only caller of the revocation routine\nfunc (h *Hub) BroadcastMemberUpdate(userID int64, roleName string) {\n\th.BroadcastToAll(buildMemberUpdate(userID, roleName))\n\th.revokeUnreadableChannels(userID)\n}", - "suggestedFix": "Decouple the fan-out from the name lookup: have ModerationService.ChangeUserRole return the *db.Role it already loads at moderation.go:159, then in handlePatchUser run hub.BroadcastMemberUpdate(id, role.Name) and hub.RefreshAllChannelVisibility() unconditionally (when hub != nil), deleting the GetRoleByID re-read entirely.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-server-service", - "finder": "opus", - "round": 7, - "confidence": "high", - "fix": { - "commit": "8579cb5d", - "test": "Server/admin/handlers_users_broadcast_test.go", - "revertProof": "pass", - "branchCommit": "93d4790b" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0046", - "title": "The Font Size slider and the \"Large Font\" accessibility toggle are no-ops — `--font-size` is written but no stylesheet ever reads it", - "file": "Client/src/styles/base.css", - "line": 22, - "severity": "medium", - "why": "Three separate code paths write the `--font-size` custom property (`applyStoredAppearance` at startup, the Appearance-tab slider on input, and the `.large-font` rule in app.css), but `var(--font-size)` appears nowhere in the repository. `base.css:22` sets `body { font-size: 14px }` as a hard literal, and every other rule uses the fixed `--font-size-xxs … --font-size-xxl` scale from tokens.css. The token is a dead end, so both user-facing font-size controls change persisted state and nothing else.", - "repro": "Open Settings -> Appearance and drag the Font Size slider from 16 to 20. `document.documentElement.style.getPropertyValue(\"--font-size\")` becomes \"20px\" and localStorage records `owncord:settings:fontSize = 20`, but no text in the app changes size — the computed font-size of body stays 14px from base.css:22 and every component stays on the fixed --font-size-* scale. Identically, Settings -> Accessibility -> \"Large Font\" adds the `large-font` class to , whose only declaration is `--font-size: 18px`, and nothing renders larger. The existing tests (tests/unit/settings-overlay.test.ts:189, tests/unit/accessibility-tab.test.ts:321) assert only that the property/class is set, never that a rendered size changes, so they pass while the feature does nothing. Note also that the default pref is 16px while base.css hard-codes 14px, so the two sources already disagree.", - "evidence": "src/styles/base.css:22 -> body { font-family: var(--font-body); font-size: 14px; ... }\nsrc/styles/app.css:5211-5213 -> .large-font { --font-size: 18px; }\nsrc/lib/appearance.ts:36-39 -> document.documentElement.style.setProperty(\"--font-size\", `${loadPref(\"fontSize\", 16)}px`);\nsrc/components/settings/AppearanceTab.ts:100 -> document.documentElement.style.setProperty(\"--font-size\", `${size}px`);\nsrc/components/settings/AccessibilityTab.ts:57 -> document.documentElement.classList.toggle(\"large-font\", nowOn);\n\nVerification: `grep -rn \"var(--font-size)\" . --include=*.css --include=*.html --include=*.ts` (excluding node_modules) returns zero hits. tokens.css defines only --font-size-xxs/xs/sm/md/lg/xl/xxl, never --font-size.", - "suggestedFix": "Make the variable actually feed the type scale: in tokens.css derive the scale from it (e.g. --font-size-md: var(--font-size, 14px) and the other steps via calc() multipliers of --font-size), and change base.css:22 to `font-size: var(--font-size-md)`. Align the appearance.ts default (16) with the actual base (14) so the slider's initial position matches what is rendered.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "fresh-eyes", - "finder": "opus", - "round": 7, - "confidence": "high", - "fix": { - "commit": "7be9ccd2", - "test": "Client/tests/unit/base-font-size-css.test.ts", - "revertProof": "pass", - "branchCommit": "ca1e7d93" - }, - "fixed": "2026-08-14" - }, - { - "id": "OC-0047", - "title": "Attachment/avatar fetches lose their bearer token and their cert-pinned proxy when the server host is stored with an explicit :443", - "file": "Client/src/components/message-list/attachments.ts", - "line": 158, - "severity": "medium", - "why": "`isServerUrl` compares `new URL(url).host` against the raw `_serverHost` string without the \":443\"-stripping normalization the rest of the client applies (`normalizeHostForCertCompare` in lib/ws.ts, `cert_store_key` in src-tauri/src/tofu.rs). WHATWG `URL` drops the default port for `https:`, so a host stored as `example.com:443` never matches, `fetchServerFile` takes the \"external host\" branch, and every server file is fetched with no `Authorization` header and outside the TOFU-pinned loopback proxy.", - "repro": "1. Add/enter the server host as `chat.example.com:443` (accepted: `isValidHost` in lib/api.ts:82 is `/^[\\w.-]+(:\\d+)?$/`; ServerPanel.ts:310 uses the same regex).\n2. Log in. MainPage.ts:96 calls `setServerHost(\"chat.example.com:443\")`.\n3. Open any channel with an image attachment, or any user with an uploaded avatar. `resolveServerUrl(\"/api/v1/files/abc\")` yields `https://chat.example.com:443/api/v1/files/abc`; `new URL(...).host` is `\"chat.example.com\"` (default port dropped) which !== `\"chat.example.com:443\"`.\n4. `fetchServerFile` therefore returns `tauriFetch(url)` with no Authorization header and bypassing `ensureHttpProxy`. The server's AuthMiddleware answers 401, `res.ok` is false, `fetchImageAsDataUrl` returns null — every attachment image, custom emoji and uploaded avatar silently falls back to its placeholder for the whole session. On a self-signed deployment the direct https fetch also fails TLS in the webview, which is the exact failure the TOFU proxy exists to avoid.\nSame root cause makes `isTrustedServerUrl` (attachments.ts:169) return false, so embeds.ts:135's trusted-server exemption stops applying and link previews to a LAN-hosted OwnCord server are blocked as SSRF.\nExisting tests only cover the port-less form (tests/unit/attachments-auth.test.ts:63 `setServerHost(\"chat.example.com\")`), so nothing locks the current behavior.", - "evidence": "attachments.ts:36-48,152-186\n export function setServerHost(host: string): void { _serverHost = host.toLowerCase(); } // no \":443\" strip\n export function resolveServerUrl(url) { ... return `https://${_serverHost}${url}`; }\n function isServerUrl(url: string): boolean {\n if (_serverHost === null) return false;\n try { const parsed = new URL(url); return parsed.host === _serverHost; } catch { return false; }\n }\n async function fetchServerFile(url: string): Promise {\n if (!isServerUrl(url)) return tauriFetch(url); // <- no token, no TOFU proxy\n ...\n headers[\"Authorization\"] = `Bearer ${token}`;\n return tauriFetch(`${origin}${parsed.pathname}${parsed.search}`, { headers });\n }\n\nContrast — lib/ws.ts:120-127 documents that config hosts are stored verbatim in this exact shape:\n /** ... Profile/config hosts are stored verbatim (e.g. \"Example.COM:443\"), but the proxies\n * always emit the normalized (stripped, lowercased) form ... */\n export function normalizeHostForCertCompare(host: string): string {\n return host.replace(/:443$/, \"\").toLowerCase();\n }\n\nServer side, the endpoint is auth-gated — Server/api/upload_handler.go:123\n r.With(AuthMiddleware(database)).Get(\"/api/v1/files/{id}\", handleServeFile(...))", - "suggestedFix": "Normalize once at the single entry point: in setServerHost (attachments.ts:37), store `_serverHost = host.replace(/:443$/, \"\").toLowerCase();` (mirroring normalizeHostForCertCompare). resolveServerUrl then emits the port-less form (same effective https origin), isServerUrl's parsed.host comparison matches for both port-less and explicit-:443 input URLs, and ensureHttpProxy(parsed.host) resolves the same TOFU pin because cert_store_key strips :443 anyway. Non-default ports are preserved on both sides.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-client-tauri-client-src", - "finder": "opus", - "round": 8, - "confidence": "high", - "fix": { - "commit": "7be9ccd2", - "test": "Client/tests/unit/attachments-auth.test.ts", - "revertProof": "pass", - "branchCommit": "687c51ff" - }, - "fixed": "2026-08-14" - }, - { - "id": "OC-0048", - "title": "Self-account-deletion emits no member_ban: router.go never supplies the optional AuthBroadcaster, so every other client keeps the deleted user and the deleted user's own socket survives", - "file": "Server/api/router.go", - "line": 104, - "severity": "medium", - "why": "`MountAuthRoutes` accepts a variadic `AuthBroadcaster` that `handleDeleteAccount` uses to fan out `member_ban` (and, via `Hub.BroadcastMemberBan`, to force-disconnect the target). The only production call site omits it because it is mounted at line 104, before the hub exists at line 141 — so `ab` is always nil and the `if broadcaster != nil` guard in `handleDeleteAccount` is never taken in a real server. The event the code was written to send is dead in production; only tests ever pass a broadcaster.", - "repro": "Users A and B are both connected over WebSocket. B calls `DELETE /api/v1/auth/account` with the correct password. The row is anonymised + banned and B's DB sessions are revoked (auth_handler.go:560-604), and the handler reaches `if broadcaster != nil { broadcaster.BroadcastMemberBan(user.ID) }` at auth_handler.go:616 with `broadcaster == nil`. Result: (1) A's member list, DM sidebar and message authorship keep showing B under B's pre-deletion username indefinitely — the admin ban path (admin/handlers_users.go → hub.BroadcastMemberBan) removes them instantly for the byte-identical DB state; (2) `Hub.DisconnectUser` (hub_broadcast.go:430) is never called, so B's already-open WebSocket stays live and can keep sending frames until the periodic re-validation fires — `SessionCheckInterval = 10` (ws/client.go:21), so up to 9 further messages (chat_message, voice_join, …) are accepted from the deleted, banned account.", - "evidence": "router.go:104 `MountAuthRoutes(r, database, limiter, cfg.Server.TrustedProxies, totpKey)` // no broadcaster\nauth_handler.go:91 `func MountAuthRoutes(..., broadcaster ...AuthBroadcaster) { var ab AuthBroadcaster; if len(broadcaster) > 0 { ab = broadcaster[0] } ...`\nauth_handler.go:120 `Delete(\"/account\", handleDeleteAccount(database, limiter, ab))`\nauth_handler.go:616 `if broadcaster != nil { broadcaster.BroadcastMemberBan(user.ID) }`\nws/hub_broadcast.go:428-431 `func (h *Hub) BroadcastMemberBan(userID int64) { h.BroadcastToAll(buildMemberBan(userID)); h.DisconnectUser(userID) }`\nGrep for `MountAuthRoutes` shows the only non-test call site is router.go:104; `api/auth_handler_delete_broadcast_test.go:54` even labels the no-broadcaster form \"the shape every existing MountAuthRoutes call\".", - "suggestedFix": "In Server/api/router.go, move the MountAuthRoutes call from line 104 to after `hub := ws.NewHub(database, limiter, svc)` (line 141) and pass the hub: `MountAuthRoutes(r, database, limiter, cfg.Server.TrustedProxies, totpKey, hub)`. chi allows route registration in any order before serving, so no other change is needed.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "fresh-eyes", - "finder": "opus", - "round": 8, - "confidence": "high", - "fix": { - "commit": "8579cb5d", - "test": "Server/api/router_delete_account_broadcast_test.go", - "revertProof": "pass", - "branchCommit": "aa8cd13a" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0049", - "title": "High Contrast accessibility toggle's main effect is dead: `.high-contrast { --text-normal }` is on , which already carries an inline --text-normal written by applyTheme()", - "file": "Client/src/lib/appearance.ts", - "line": 21, - "severity": "medium", - "why": "`applyStoredAppearance()` calls `applyTheme(name)`, which writes the theme's four tokens — including `--text-normal` — as an *inline* style on `document.documentElement`, and then toggles the `high-contrast` class on the *same* element. An inline declaration always beats a class rule on the same element, so `.high-contrast { --text-normal: #ffffff }` (app.css:5204) can never take effect. The toggle's headline promise (pure-white body text) is silently a no-op; only `--text-muted` and `--bg-active`, which `THEMES` does not set inline, actually change.", - "repro": "Fresh install, no stored theme. main.ts:100 calls `applyStoredAppearance()`. `getActiveThemeName()` returns \"neon-glow\", which is in `THEMES`, so line 21 runs `applyTheme(\"neon-glow\")` → helpers.ts:95 sets `document.documentElement.style['--text-normal'] = '#dbdee1'`. Line 50 then sets `document.documentElement.classList.add('high-contrast')` when the pref is on. Computed `--text-normal` on is `#dbdee1`, not `#ffffff` — inspect any message body text with High Contrast enabled and it is identical to High Contrast off. Same for every other built-in theme, and re-triggered every time the Appearance tab renders (AppearanceTab.ts:230) or a theme is clicked (AppearanceTab.ts:49). The existing tests (tests/unit/accessibility-tab.test.ts:276, tests/unit/stored-appearance.test.ts:49) only assert the class is toggled, never the resulting token value.", - "evidence": "lib/appearance.ts:19-24 `const activeThemeName = getActiveThemeName(); if (activeThemeName in THEMES) { applyTheme(activeThemeName as ThemeName); }`\nlib/appearance.ts:49-52 `document.documentElement.classList.toggle(\"high-contrast\", loadPref(\"highContrast\", false));`\ncomponents/settings/helpers.ts:93-96 `const root = document.documentElement; for (const [key, value] of Object.entries(theme)) { root.style.setProperty(key, value); }`\ncomponents/settings/helpers.ts:22 / :27 / :34 / :40 every THEMES entry defines `\"--text-normal\"`\nstyles/app.css:5203-5207 `.high-contrast { --text-normal: #ffffff; --text-muted: #cccccc; --bg-active: rgba(255,255,255,0.15); }`", - "suggestedFix": "In styles/app.css, make the high-contrast tokens important and cover the body-level custom-theme case: `.high-contrast, .high-contrast body { --text-normal: #ffffff !important; --text-muted: #cccccc !important; --bg-active: rgba(255,255,255,0.15) !important; }` — important author declarations beat normal inline styles, which is exactly the relationship needed here.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "fresh-eyes", - "finder": "opus", - "round": 8, - "confidence": "high", - "fix": { - "commit": "7be9ccd2", - "test": "Client/tests/unit/appearance-high-contrast.test.ts", - "revertProof": "pass", - "branchCommit": "a2387480" - }, - "fixed": "2026-08-14" - }, - { - "id": "OC-0050", - "title": "CleanupVoiceForChannel's check-then-clear is not atomic, so a concurrent voice_join is silently wiped from the hub while its DB row survives", - "file": "Server/ws/hub_sweep.go", - "line": 316, - "severity": "low", - "why": "The function's own comment claims \"the client-state clear [is] conditional on the participant still being in THIS channel: a user who moved to another voice channel between the snapshot above and this loop must not be clobbered\". The implementation reads `client.getVoiceChID()` (one voiceMu acquisition), then calls `clearVoiceAndUnsubscribe`, whose `c.clearVoiceState()` (client.go:148) clears unconditionally under a *second* voiceMu acquisition. Nothing spans the compare and the clear. Every sibling site got this right — `sweepStaleVoiceStates` uses `handleVoiceLeaveIfStillIn` → `clearVoiceStateIfMatch` (client.go:164), and the LiveKit webhook inlines a token-aware compare-and-clear under one voiceMu (livekit_webhook.go:203-211).", - "repro": "User U is in voice channel A. An admin archives or deletes A, so an HTTP handler goroutine runs CleanupVoiceForChannel(A). At the same moment U sends voice_join for channel W on their readPump goroutine. Interleaving: (1) cleanup reads client.getVoiceChID() == A → passes the guard; (2) U's handleVoiceJoin completes, running c.setVoiceState(W, joinedAt) (voice_join.go:222), subscribing VoiceTopic(W) and broadcasting voice_state for W; (3) cleanup calls clearVoiceAndUnsubscribe(client), which unconditionally zeroes voiceChID/voiceJoinToken/e2eePubKey and returns oldChID=W, then does pubsub.Unsubscribe(client, VoiceTopic(W)). U is now in voice W per voice_states but not per the hub: their VoiceTopic(W) subscription is gone (so every voice_e2ee_announce/offer relay for W is missed), broadcastVoiceEvent's participant union can no longer see them, and within 60s sweepStaleVoiceStates sees c.getVoiceChID()==0 != W, deletes the row, broadcasts voice_leave and removes them from the SFU — silently ejecting them from the call they just joined.", - "evidence": "313:\t\t\th.mu.RLock()\n314:\t\t\tclient, ok := h.clients[vs.UserID]\n315:\t\t\th.mu.RUnlock()\n316:\t\t\tif ok && client.getVoiceChID() == channelID {\n317:\t\t\t\th.clearVoiceAndUnsubscribe(client) // clearVoiceState() clears unconditionally\n318:\t\t\t}", - "suggestedFix": "Replace the check+clear at hub_sweep.go:316-318 with the compare-and-clear primitive under one voiceMu acquisition: `if ok { if _, cleared := client.clearVoiceStateIfMatch(channelID); cleared { h.pubsub.Unsubscribe(client, VoiceTopic(channelID)) } }` (also clear the E2EE fields, which clearVoiceStateIfMatch already does).", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "ws-hub", - "finder": "opus", - "round": 1, - "confidence": "medium", - "fix": { - "commit": "8787b906", - "test": "Server/ws/hub_sweep_test.go", - "revertProof": "pass", - "branchCommit": "5cf6d1c5" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0051", - "title": "handleReconnect returns true after a failed handshake write, so the full disconnect teardown runs twice", - "file": "Server/ws/serve.go", - "line": 353, - "severity": "low", - "why": "`unregisterFailedHandshake` is documented (serve.go:413-414) as safe because \"No readPump ever starts for this connection\". That invariant holds on the fresh-connect branch (handleFreshConnect returns an error and ServeWS returns without starting pumps) but is false on the reconnect branch: handleReconnect returns `true` after calling unregisterFailedHandshake and closing the socket, and ServeWS treats `true` as success and calls startPumps() (serve.go:69-72). readPump then runs against the closed conn, returns immediately, and its defer executes the whole teardown a second time — because unregisterNow(c) now finds no entry and reports replaced=false (a deliberate distinction locked by hub_sweep_test.go:74). This also doubles the window described in the previous finding.", - "repro": "A client resumes with last_seq > 0 and replay succeeds, so handleReconnect calls registerNow(c) and then conn.Write(auth_ok) — which fails (peer already gone / write timeout). Path: (1) unregisterFailedHandshake(ctx, c) removes c from h.clients, runs handleVoiceLeave if a voice session was transferred, writes MarkUserDisconnected and broadcasts presence{offline} (serve.go:422-439); (2) handleReconnect returns true; (3) ServeWS calls startPumps(), spawning writePump and running readPump on the closed conn; (4) readPump returns on the first Read error and its defer calls unregisterNow(c) again — c is absent, so replaced=false — and issues a second MarkUserDisconnected plus a second BroadcastToAll(presence offline), which consumes a second hub seq, a second replay-buffer slot and a second persisted event row for a duplicate of an event already sent.", - "evidence": "serve.go:349-353\n\t\tif err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(...)); err != nil {\n\t\t\th.unregisterFailedHandshake(ctx, c)\n\t\t\t_ = conn.Close(websocket.StatusInternalError, \"handshake failed\")\n\t\t\treturn true\n\nserve.go:69-72\n\t\tif lastSeq > 0 {\n\t\t\tif hub.handleReconnect(ctx, conn, c, database, lastSeq) {\n\t\t\t\tstartPumps()\n\t\t\t\treturn\n\nserve.go:413-414 (contradicted invariant)\n\t// ... No readPump ever starts for this connection, ...", - "suggestedFix": "Make the two handshake-write-failure paths in handleReconnect signal 'handled, do not start pumps' — e.g. change its return to (handled, startPumps bool) returning (true, false) there and (true, true) on success, with ServeWS calling startPumps() only when both are true. (Equivalently: drop the unregisterFailedHandshake+Close calls on those two paths and let readPump's defer perform the single teardown, since pumps do start on this branch.)", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "ws-hub", - "finder": "opus", - "round": 1, - "confidence": "high", - "fix": { - "commit": "db0275a2", - "test": "Server/ws/serve_reconnect_double_teardown_test.go", - "revertProof": "pass", - "branchCommit": "14de5f22", - "pr": 1369 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0052", - "title": "GET /channels/{id}/pins has no LIMIT and no pin cap; past ~32k pins the endpoint fails permanently", - "file": "Server/db/message_queries.go", - "line": 635, - "severity": "low", - "why": "GetPinnedMessages is the only read path into scanAndEnrichMessages with no LIMIT — GetMessagesForAPI, GetMessagesAroundForAPI and both search queries are all clamped to <=100 by the service layer. Nothing caps how many messages may be pinned in a channel either (SetMessagePinned has no count check and, unlike SendMessage/handleReaction, no rate limiter), and the handler hardcodes `HasMore: false`. Because scanAndEnrichMessages then builds three `IN (?,?,...)` lists with one bound parameter per returned message, a channel with more pins than SQLite's SQLITE_MAX_VARIABLE_NUMBER (32766) makes the request fail with \"too many SQL variables\" — the pins endpoint then returns 500 for that channel forever, with no way to unpin through the UI that lists them.", - "repro": "Any ordinary user, no moderator role required: service/message_query.go:207-214 lets any DM participant pin, so open a 1:1 DM with another user, post N messages, then PUT the pin route once per message. At N >= 32766 pins, GET /api/v1/channels/{dmId}/pins runs GetPinnedMessages, gets 32766 rows, and getReactionsBatch builds an IN list with 32767 bound parameters; SQLite rejects it with \"too many SQL variables\", scanAndEnrichMessages returns an error, and the endpoint answers 500 on every subsequent call for that channel. Below that threshold the same call still loads and JSON-serialises every pinned message with its reactions, attachments and mentions in one unpaginated response (has_more is hardcoded false, so no client can page past it).", - "evidence": "db/message_queries.go:636-644\n\trows, err := d.reader.QueryContext(ctx,\n\t\t`SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, ...\n\t\t FROM messages m JOIN users u ON m.user_id = u.id\n\t\t WHERE m.channel_id = ? AND m.pinned = 1 AND m.deleted = 0\n\t\t ORDER BY m.id DESC`, // <- no LIMIT\n\t\tchannelID,\n\t)\n\ndb/message_queries.go:529-537 (one bound parameter per pinned message)\n\tquery := fmt.Sprintf(\n\t\t`SELECT r.message_id, r.emoji, COUNT(*) as cnt, ...\n\t\t FROM reactions r WHERE r.message_id IN (%s) GROUP BY ...`, placeholders)\n\targs = append([]any{requestingUserID}, args...)\n\napi/channel_handler.go:373\n\twriteJSON(w, http.StatusOK, response{Messages: msgs, HasMore: false})", - "suggestedFix": "Chunk the IN-list batches in the shared enrichment path (getReactionsBatch, getAttachmentsBatch, GetMentionsByMessageIDs) at the existing 500-id chunk size used by auth_queries.go/mention_queries.go — one guard in the shared functions covers every caller; optionally also add a pins-per-channel cap in SetMessagePinned.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "db-storage", - "finder": "opus", - "round": 1, - "confidence": "medium", - "fix": { - "commit": "8787b906", - "test": "Server/db/message_queries_test.go", - "revertProof": "pass", - "branchCommit": "87bf3e3a" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0053", - "title": "Quick-switch overlay's teardown guard never fires — an orphaned modal is mounted on document.body after MainPage is destroyed", - "file": "Client/src/pages/main-page/SidebarArea.ts", - "line": 722, - "severity": "low", - "why": "`openQuickSwitch` awaits `profileManager.loadProfiles()` and then checks `sidebarWrapper.parentElement === null` as its \"were we torn down while awaiting?\" test. That check can never be true: MainPage tears down by removing an *ancestor* (`root.remove()` in MainPage.destroy) and never removes `sidebarWrapper` from its parent `app` div, so `sidebarWrapper.parentElement` stays non-null forever. The overlay is then created and mounted to `document.body` — outside the removed subtree — after every reference to it (`quickSwitchInstance`, still `null` when `closeQuickSwitch` ran during teardown) is gone.", - "repro": "1. Signed in, MainPage mounted. Click the disconnect/switch button in UserBar -> `openQuickSwitch()` runs and awaits `profileManager.loadProfiles()` (a Tauri IPC round trip).\n2. While that await is pending, the session ends asynchronously — e.g. a REST 401 fires main.ts's `onUnauthorized` -> `clearAuth()`, or the server broadcasts `server_restart` with reason \"shutdown\" (dispatcher.ts:879 `clearAuth(\"server_shutdown\")`), or an `auth_error`/`BANNED` frame arrives.\n3. main.ts's authStore subscriber runs `router.navigate(\"connect\")` -> `renderPage(\"connect\")` -> `currentPage.destroy()` -> MainPage.destroy(). That runs `closeQuickSwitch()` (no-op: `quickSwitchInstance` is still null) and then `root.remove()`.\n4. `loadProfiles()` resolves. `sidebarWrapper.parentElement` is still the `app` div, so the guard passes. `createQuickSwitchOverlay(...).mount(document.body)` runs.\nResult: a full-screen `.quick-switch-backdrop` modal plus its document-level `keydown` listener (QuickSwitchOverlay.ts:167) and focus trap sit on top of the freshly-rendered ConnectPage. Nothing holds a reference to it any more, so nothing can call its `destroy()`; its own \"Switch\"/\"Add server\" buttons call `clearAuth()` against a session that no longer exists. Fix: use a `destroyed` flag set from the teardown callback (or `document.contains(sidebarWrapper)`) instead of `parentElement === null`.", - "evidence": "function openQuickSwitch(): void {\n if (quickSwitchInstance !== null || openingQuickSwitch) return;\n openingQuickSwitch = true;\n ...\n void (async () => {\n try {\n ...\n await profileManager.loadProfiles();\n ...\n // Ensure we haven't been cleaned up while awaiting\n if (sidebarWrapper.parentElement === null) return; // <-- never true\n quickSwitchInstance = createQuickSwitchOverlay({ ... });\n quickSwitchInstance.mount(document.body); // <-- escapes the removed subtree\n } finally { openingQuickSwitch = false; }\n })();\n}\n\n// MainPage.ts destroy():\n// for (const unsub of unsubscribers) unsub(); // includes closeQuickSwitch() -> no-op, instance is null\n// ...\n// finally { if (root !== null) { root.remove(); root = null; } } // sidebarWrapper.parentElement is still `app`", - "suggestedFix": "In createSidebarArea, add `let tornDown = false;` and change the pushed unsubscriber to `unsubscribers.push(() => { tornDown = true; closeQuickSwitch(); });`, then replace the dead guard at line 722 with `if (tornDown) return;`. (A flag beats `isConnected`, which would change behavior for unit tests that mount into a detached container.)", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "client-state", - "finder": "opus", - "round": 1, - "confidence": "high", - "fix": { - "commit": "db0275a2", - "test": "Client/tests/unit/sidebar-area.test.ts", - "revertProof": "pass", - "branchCommit": "a1a1aa96", - "pr": 1369 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0054", - "title": "blocksStore survives clearAuth(), so a previous server's block list can gate DM composers on the next server", - "file": "Client/src/stores/auth.store.ts", - "line": 93, - "severity": "low", - "why": "`clearAuth()` deliberately resets voiceStore, messagesStore and channelsStore because their ids are per-server, but leaves `blocksStore.blockedByMe` untouched. Block state is keyed by *user id*, which is also only unique per server. The only thing that restates it on the next session is dispatcher.ts's fire-and-forget `api.listBlocks()`, whose failure is swallowed with a `log.warn` — so a single failed request leaves the previous server's blocked-user ids applied for the whole new session.", - "repro": "1. On server A, block the user whose id is 7 -> `setUserBlockedByMe(7, true)`, `blockedByMe = {7}`.\n2. Log out (UserBar disconnect / Settings logout / quick-switch) -> `clearAuth()`. `blockedByMe` is still `{7}`.\n3. Log into server B, where user id 7 is an unrelated person. `ready` arrives; `api.listBlocks()` is issued but rejects (transient network blip, 500, or the proxy not yet warm) — the rejection is only logged.\n4. Open a 1:1 DM with server B's user 7. ChannelController.ts:412 calls `dmComposerBlockReason(blocksStore.getState(), 7)`, which returns BLOCKED_BY_ME_REASON, so the composer is disabled for the rest of the session with \"You've blocked this user. Unblock to send messages.\" for a user that was never blocked here.\n5. MemberList.ts:289 reads the same set (`isBlocked = blockedByMe.has(member.id)`), so that member's context menu offers \"Unblock\"; clicking it sends DELETE /blocks/7 to server B.", - "evidence": "// auth.store.ts clearAuth():\n resetVoiceStore();\n resetMessagesStore();\n resetChannelsStore();\n clearNsfwAcknowledgements();\n cleanupNotificationAudio();\n authStore.setState(() => ({ ...INITIAL_STATE, ... }));\n // <-- blocksStore is never reset\n\n// dispatcher.ts READY handler — the only repopulation path:\n clearBlockedByThem(); // only the *other* direction is cleared\n if (api !== undefined) {\n api.listBlocks()\n .then((r) => setBlockedByMe(r.blocked_user_ids))\n .catch((err) => log.warn(\"Failed to load block list\", { error: String(err) })); // stale set survives\n }", - "suggestedFix": "Add `export function resetBlocksStore(): void { blocksStore.setState(() => ({ blockedByMe: new Set(), blockedByThem: new Set() })); }` to blocks.store.ts and call it in clearAuth alongside resetChannelsStore() (auth.store.ts:93). Same-server reconnects don't go through clearAuth, so the keep-until-refetch behavior there is preserved.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "client-state", - "finder": "opus", - "round": 1, - "confidence": "high", - "fix": { - "commit": "8787b906", - "test": "Client/tests/unit/auth-store.test.ts", - "revertProof": "pass", - "branchCommit": "478dd94b" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0055", - "title": "InviteManagerController.open() re-uses a pre-await root reference; a page teardown during the getInvites() fetch resurrects the overlay with a live document-level keydown listener that outlives the page", - "file": "Client/src/pages/main-page/OverlayManagers.ts", - "line": 166, - "severity": "low", - "why": "open() captures `const root = opts.getRoot()` and checks `instance !== null` BEFORE `await opts.api.getInvites()` (line 167-171), but never re-checks getRoot()/liveness after the await. If MainPage.destroy() runs while the fetch is in flight, its unsubscribers call headerInviteCtrl.cleanup(), but `instance` is still null (createInviteManager hasn't run yet) so cleanup() no-ops; destroy() then nulls its own `root` variable and detaches the DOM node, but the closure-local `root` const in open() still references the now-detached node. When the fetch resolves after teardown, open()'s continuation runs unconditionally: it creates a new InviteManager instance and mounts it on the stale, detached `root` (the `if (root !== null)` check on line 201 only checks the stale local, never re-derives liveness). createInviteManager's mount() (Client/src/components/InviteManager.ts:218) registers `document.addEventListener('keydown', ...)` for Escape-to-close, scoped to that instance's own AbortController. Because InviteManagerController.cleanup() already fired and is never invoked again for this newly-created instance, that global keydown listener is never torn down — it lives on `document` indefinitely, closing over the destroyed page's `api`/`getToast`, and will fire options.onClose() the next time Escape is pressed anywhere in the app (e.g. after the user has navigated back to the connect/login page). SidebarArea.ts's own openQuickSwitch() (same file family, lines ~703-745) demonstrates the intended fix: it re-checks `sidebarWrapper.parentElement === null` AFTER the await before mounting, exactly the guard missing here (and in PinnedPanelController.toggle at OverlayManagers.ts:252-298, which has the identical pattern though its component has no document-level listener so the blast radius is smaller — a detached, un-destroyable component instance rather than a global listener leak).", - "repro": "1) Open the sidebar, click the Invite button (SidebarArea.ts headerInviteBtn) while the network is slow, so `opts.api.getInvites()` is pending. 2) Before it resolves, log out / get banned / server-shutdown-kick (any path that calls MainPage.destroy()). destroy() runs headerInviteCtrl.cleanup() while `instance` is still null, so nothing happens; destroy() proceeds to null/remove `root`. 3) The pending getInvites() promise resolves; open()'s continuation creates a fresh InviteManager instance and mounts it onto the now-detached root, registering a document-level 'keydown' listener via createInviteManager's own AbortController. 4) The user is now on ConnectPage (or a new MainPage from re-login). Pressing Escape anywhere triggers the zombie instance's onClose→close(), which is the only thing that will ever call its destroy() — until then this dangling document listener is a real leak that nothing in MainPage's teardown chain can reach.", - "suggestedFix": "In open(), after the await re-derive the mount target: `const liveRoot = opts.getRoot(); if (liveRoot === null) return;` and mount on liveRoot instead of the pre-await const (delete the dead `if (root !== null)` check). getRoot() returns MainPage's `root`, which destroy() nulls, so this is an exact liveness signal. Apply the same two-line change in PinnedPanelController.toggle.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "client-state", - "finder": "sonnet", - "round": 1, - "confidence": "high", - "fix": { - "commit": "db0275a2", - "test": "Client/tests/unit/overlay-managers.test.ts", - "revertProof": "pass", - "branchCommit": "8875523c", - "pr": 1369 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0056", - "title": "ws.disconnect() cannot cancel an in-flight connect(), so a cancelled session still opens its WebSocket and re-registers Tauri listeners after teardown", - "file": "Client/src/lib/ws.ts", - "line": 585, - "severity": "low", - "why": "connect() is async and has three await points (ensureTauriApis, setupEventListeners' 3 tauriListen IPC round-trips, then ws_connect) after it has already bumped wsGeneration. disconnect() is fully synchronous and does NOT bump wsGeneration, so it has no way to invalidate an attempt that is mid-await: it drains eventUnsubs while that array is still partially filled, nulls config, and returns — then the suspended connect() resumes, pushes fresh (never-cleaned) unsub handles into eventUnsubs, and calls invoke(\"ws_connect\"), opening the very socket the teardown was meant to prevent.", - "repro": "main.ts's ConnectPage `onAutoLoginCancel` (main.ts:572-588) is the exact interleaving, and its own comment says so: \"by the time a click reaches here the session is already in flight (wirePostAuth has called ws.connect and registered listeners)\". Click Cancel while auto-login is connecting → disconnect() runs during connect()'s awaits → connect() resumes and invokes ws_connect → Rust's WsState.begin_connection claims a fresh generation and completes the WSS handshake to the server the user just cancelled. The \"open\" event then flips the UI to `authenticating` (mapped to \"reconnecting\" by toConnectionStatus, so ServerBanner shows \"Reconnecting...\" on the connect page) and, because `config === null`, no auth frame is ever sent, so the socket sits unauthenticated until the server's 10s authDeadline closes it. The three tauriListen unsubs registered after disconnect()'s cleanupEventListeners() are never removed until the next connect(). The same shape applies to the logout path (main.ts:746, 814).", - "evidence": "connect(): `wsGeneration++; config = cfg; intentionalClose = false; ... await ensureTauriApis(); ... cleanupEventListeners(); await setupEventListeners(); try { await tauriInvoke(\"ws_connect\", { url: wsUrl }) }`. disconnect(): `intentionalClose = true; certMismatchBlock = false; cancelReconnect(); stopHeartbeat(); cleanupEventListeners(); void disconnectProxy(); setState(\"disconnected\"); config = null; lastSeq = 0; reconnectAttempt = 0;` — no `wsGeneration++`, no cancellation token consulted by connect(). The ws-state handler then hits `setState(\"authenticating\"); if (config === null) return;`", - "suggestedFix": "In disconnect(), add `wsGeneration++;`. In connect(), capture `const gen = wsGeneration;` after the initial increment and bail (`if (gen !== wsGeneration) return;`) after `await ensureTauriApis()` and after `await setupEventListeners()`, before invoking ws_connect.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "lifecycle", - "finder": "opus", - "round": 2, - "confidence": "high", - "fix": { - "commit": "c3837fa", - "branchCommit": "28aa0da", - "test": "Client/tests/unit/ws-lifecycle.test.ts", - "revertProof": "self-reported", - "pr": 1367 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0057", - "title": "showContextMenu registers a permanent \"abort\" listener on the caller's component-lifetime signal on every open, pinning each removed menu subtree", - "file": "Client/src/lib/context-menu.ts", - "line": 88, - "severity": "low", - "why": "The teardown hook is attached with `signal.addEventListener(\"abort\", ...)` with no `{ once: true }` and no removal path — and, unlike the per-menu `dismissAc`, the caller's `signal` is the component's whole lifetime. Each invocation therefore adds one more listener to that signal, and each listener's closure retains its `menu` element, so menus removed from the DOM (by dismissal, by the `querySelectorAll(...).remove()` sweep at line 36, or by an item click) stay reachable until the component is destroyed.", - "repro": "Right-click DM rows N times without the DM sidebar being rebuilt: DmSidebar's `ac.signal` accumulates N abort listeners, each holding a detached `.dm-context-menu` div (plus its item children) that was already removed from the document. Nothing releases them until DmSidebar.destroy() fires the abort. Secondary consequence on the same line: if `signal` is already aborted when showContextMenu is called, the freshly-appended `menu` on document.body gets no teardown at all, because addEventListener(\"abort\") on an already-aborted signal never fires.", - "evidence": " // Clean up if parent component is destroyed\n signal.addEventListener(\"abort\", () => {\n menu.remove();\n dismissAc.abort();\n });\n\n// caller (DmSidebar.ts:268) passes the sidebar-lifetime signal:\nshowContextMenu({ x: e.clientX, y: e.clientY, items, signal, className: \"dm-context-menu\" });\n// where signal === ac.signal, aborted only in destroy() (DmSidebar.ts:347-348)", - "suggestedFix": "Register the teardown hook so menu dismissal releases it: `signal.addEventListener(\"abort\", () => { menu.remove(); dismissAc.abort(); }, { signal: dismissAc.signal })`, and abort dismissAc whenever the menu is removed (item click and outside-click already do).", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "lifecycle", - "finder": "opus", - "round": 2, - "confidence": "high", - "fix": { - "commit": "c3837fa", - "branchCommit": "d02e647", - "test": "Client/tests/unit/context-menu.test.ts", - "revertProof": "self-reported", - "pr": 1367 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0058", - "title": "Unban emits no WS event — *ws.Hub does not implement memberUnbanBroadcaster", - "file": "Server/admin/handlers_users.go", - "line": 169, - "severity": "low", - "why": "The ban path calls hub.BroadcastMemberBan(id) directly (a method *ws.Hub really has), but the unban path routes through a type assertion to memberUnbanBroadcaster, and BroadcastMemberUnban exists nowhere on *ws.Hub (grep finds it only in this file and admin/handlers_users_broadcast_test.go). The assertion always misses, so the DB (users.banned=0, the user is back in ListMembers) and every already-connected client's membersStore — which hard-deleted the row on member_ban — permanently disagree.", - "repro": "Admin bans user U: BroadcastMemberBan fans member_ban out, every connected client runs removeMember(U) and drops U from membersStore, and U's socket is kicked. Admin then unbans U via PATCH /api/v1/admin/users/{id} {\"banned\": false}. ModerationService.UnbanUser commits, then the `case !*req.Banned && hub != nil` branch type-asserts and silently does nothing. U is absent from the member list, from mention autocomplete and from getTypingUsers on every client that was connected during the ban, while any client that connects afterwards gets U in its ready payload — two clients side by side showing different rosters. It only converges for the stale clients if U reconnects (handleFreshConnect broadcasts member_join) or they reconnect themselves; an unbanned user who never comes back online stays missing indefinitely. admin/handlers_users_broadcast_test.go:51 asserts the call happens against a double that implements the interface, so the suite stays green.", - "evidence": "case *req.Banned && hub != nil:\n\thub.BroadcastMemberBan(id) // real method on *ws.Hub\ncase !*req.Banned && hub != nil:\n\tif mub, ok := hub.(memberUnbanBroadcaster); ok {\n\t\tmub.BroadcastMemberUnban(id) // *ws.Hub has no such method — assertion always false\n\t}", - "suggestedFix": "Implement `func (h *Hub) BroadcastMemberUnban(userID int64)` on *ws.Hub that loads the user and role from h.db and calls h.BroadcastToAll(buildMemberJoin(user, roleName)) — the client already maps member_join to addMember, so no protocol change is needed. Add a compile-time `var _ memberUnbanBroadcaster = (*ws.Hub)(nil)` where admin is wired to the real hub so the assertion cannot silently miss again.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "state-desync", - "finder": "opus", - "round": 2, - "confidence": "high", - "fix": { - "commit": "d2289560", - "test": "TestBroadcastMemberUnban_FansOutMemberJoin", - "revertProof": "pass" - }, - "fixed": "2026-08-19", - "note": "BroadcastMemberUnban implemented on *ws.Hub; compile-time wiring assertion added in admin" - }, - { - "id": "OC-0059", - "title": "Composer slow-mode cooldown is applied to whichever channel happens to be mounted when a chat_send_ok/SLOW_MODE frame arrives, not the channel the message was actually sent to", - "file": "Client/src/pages/main-page/ChannelController.ts", - "line": 450, - "severity": "low", - "why": "The `chat_send_ok` and `error`/SLOW_MODE listeners registered in mountChannel are global ws.on subscriptions (no per-channel filter is possible: ChatSendOkPayload has only message_id/timestamp, ErrorPayload has only code/message — neither carries channel_id). They are torn down and re-registered on every channel switch, so a frame that was actually produced by a send in the *previous* channel gets delivered to the *newly mounted* channel's handler, which unconditionally calls startSlowMode(ch.slowMode) for the channel currently mounted — desyncing the client's local slow-mode countdown (source: WS-listener side effect) from the server's actual per-channel rate-limit state (source of truth: the server's limiter, correctly scoped by channel_id there).", - "repro": "1) Open channel A, which has slow_mode > 0. 2) Send a message in A (chat_send is sent, correlationId cid_A pending). 3) Immediately switch to channel B before the server's chat_send_ok (or a SLOW_MODE error, if A was already on cooldown) for cid_A arrives. destroyChannel() unsubscribes A's chat_send_ok/error listeners; mountChannel(B) installs B's. 4) The late chat_send_ok (or SLOW_MODE error) for the A-message arrives and is delivered only to B's handler, which reads channelsStore.get(channelId=B) and calls startSlowMode(B.slowMode) — disabling B's composer with 'Slow mode — Ns' even though B was never sent to and has no active server-side cooldown. This reproduces even with B.slowMode=0 replaced by any nonzero value; with B.slowMode=0 the call is a harmless no-op, but any channel with its own slow mode configured is falsely gated whenever the user switches into it right after posting in a slow-mode channel.", - "suggestedFix": "Record the originating channel per correlation id (the send path already keys draftByCorrelation by correlation id — add a channelId field, or keep a controller-scoped Map). In both handlers, gate startSlowMode on that recorded channel equaling the mounted channelId; the server echoes the request id on SLOW_MODE errors too (buildErrorMsgWithID, Server/ws/handlers_chat.go:165), so the error handler can use the same correlation check via the second listener argument.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "state-desync", - "finder": "sonnet", - "round": 2, - "confidence": "high", - "fix": { - "commit": "8787b906", - "test": "Client/tests/unit/channel-controller.test.ts", - "revertProof": "pass", - "branchCommit": "88033f02" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0060", - "title": "A single malformed stored server profile makes the client discard all saved profiles, and the next login overwrites the on-disk list with that empty set", - "file": "Client/src/lib/profiles.ts", - "line": 112, - "severity": "low", - "why": "`createTauriBackend().load()` returns `null` both for \"nothing stored\" and \"stored payload failed validation\", and `isValidStoredData` is all-or-nothing over the whole array — one bad entry rejects every profile. `loadProfiles` then does nothing (`if (data !== null)`), leaving the store empty rather than surfacing a read failure, and `saveProfiles` unconditionally writes that empty in-memory list back over the stored record. `importProfiles` on the same file shows the intended tolerance (it counts `skipped` per item); the load path has none.", - "repro": "A user has five saved server profiles. One stored entry fails `isValidProfileShape` — e.g. it was written by a build predating the `color` field, or a profile whose `name` is empty (`obj.name.length > 0`), or any hand-edit/partial write of the Tauri settings store. On launch, main.ts:625 calls `loadProfiles()`; `load()` returns null, so `profiles` stays `[]` and the connect page falls back to the synthetic \"Local Server\" entry (main.ts:439). Note main.ts:624-628's `catch` never fires — nothing throws. The user logs in; `ensureProfileExists` (main.ts:454) adds one profile and calls `persistProfiles()` (main.ts:449) → `save_settings(\"owncord:profiles\", {schemaVersion:1, profiles:[the one new profile]})`. All five originals are now permanently gone from disk. Note tests/unit/profiles.test.ts:757 pins `load()` returning null for an invalid shape, but nothing pins the manager's behaviour after that null — the destructive overwrite is untested.", - "evidence": "async load(): Promise {\n const raw = settings[STORAGE_KEY];\n if (raw === undefined || raw === null) return null;\n if (isValidStoredData(raw)) return raw;\n return null; // validation failure == \"nothing stored\"\n},\n...\nasync loadProfiles(): Promise {\n const data = await backend.load();\n if (data !== null) { setProfiles(data.profiles); } // silently keeps []\n},\nasync saveProfiles(): Promise {\n await backend.save(toStoredData()); // writes [...currentProfiles()]\n},", - "suggestedFix": "Make load() salvage instead of discard: when the envelope shape is valid, return { schemaVersion, profiles: obj.profiles.filter(isValidProfileShape) } (mirroring importProfiles' per-item tolerance) so one corrupt entry drops only itself rather than nulling the whole store that the next save then overwrites.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "error-paths", - "finder": "opus", - "round": 2, - "confidence": "medium", - "fix": { - "commit": "c3837fa", - "branchCommit": "f1923eb", - "test": "Client/tests/unit/profiles.test.ts", - "revertProof": "self-reported", - "pr": 1367 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0061", - "title": "NewPersistentRateLimiter silently discards LoadActiveLockouts errors, dropping all active login lockouts with no log line", - "file": "Server/auth/ratelimit.go", - "line": 78, - "severity": "low", - "why": "The constructor only populates the in-memory lockout map inside `if ... err == nil`; there is no `else` branch, so a failed DB read (SQLITE_BUSY, disk I/O error, or any other transient error from the underlying `SELECT` in LoadActiveLockouts) is dropped with zero logging anywhere in the call chain. This is inconsistent with the same package's other persistence paths (Lockout/Reset also swallow their UpsertLockout/DeleteLockout errors silently) and starkly inconsistent with this codebase's own D8 'a drop is never silent' policy that the audit writer and event persister enforce for comparable best-effort persistence. The practical effect: every account currently serving a login/password/TOTP lockout (auth/ratelimit.go callers in api/auth_handler.go, api/profile_handler.go, api/totp_handler.go) has that lockout wiped from the in-memory limiter on any server restart where the load query errors — silently re-opening the account to brute force with no operator-visible signal that recovery failed.", - "repro": "1) An operator has an account under an active login lockout (auth_handler.go's `limiter.Lockout(...)` after repeated failed logins), persisted via UpsertLockout into the lockout_log-style table. 2) The server restarts (deploy, crash-restart, container recycle) while the SQLite writer is briefly busy/locked or the disk hiccups, so `d.q.LoadActiveLockouts` returns an error. 3) `router.go`'s `auth.NewPersistentRateLimiter(database)` call hits the `err != nil` branch of the `if` in NewPersistentRateLimiter, which has no body — the function returns a RateLimiter with an empty lockouts map for every shard, and nothing is logged. 4) The account that was mid-lockout is now immediately unlocked, and there is no log entry anywhere indicating the load failed, so the gap is invisible until someone notices the lockout 'reset itself'.", - "suggestedFix": "Add an else branch: else { slog.Warn(\"ratelimit: failed to load persisted lockouts; starting with none\", \"err\", err) } — one log line in the constructor makes the degradation operator-visible without changing the constructor's signature.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "error-paths", - "finder": "sonnet", - "round": 2, - "confidence": "medium", - "fix": { - "commit": "8787b906", - "test": "Server/auth/ratelimit_persist_test.go", - "revertProof": "pass", - "branchCommit": "c8e6252a" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0062", - "title": "Cold-tier reconnect replay has no interior-gap detection, so events the EventPersister dropped are silently skipped and presented as a complete resume", - "file": "Server/ws/serve.go", - "line": 210, - "severity": "low", - "why": "`EventPersister.Enqueue` drops on a full queue and `PersistEvents` can lose individual rows on a per-row insert failure, so the `events` table can contain interior holes. handleReconnect's cold tier guards only two of the three gap shapes: a *prefix* gap (the `GetEventsSince(ctx, 0, 1)` oldest-seq probe, serve.go:198-209) and a *tail* gap (the ring-buffer coverage check, serve.go:223-233). Nothing checks for a hole in the middle, so the `default:` branch accepts a lossy result as authoritative, sends `replay_source: \"db\"`, and the client — which tracks only `max(seq)` — advances past the missing seq and can never ask for it again.", - "repro": "A broadcast burst overflows the 4096-entry persister queue (or one `PersistEvents` row fails), so seq 1005 is never written to `events` while 1001..1004 and 1006..1200 are. A client disconnected at last_seq=1000 stays offline long enough for the 1000-entry ring buffer to evict seq 1000, forcing the cold tier. `GetEventsSinceForChannels(1000, ...)` returns 1001..1004,1006..1200; the oldest-seq probe returns a row with seq <= 1001 so serve.go:203 passes; the buffer covers the tail so serve.go:224 passes. The client receives auth_ok with `replay_source:\"db\"`, applies 199 frames, and sets lastSeq=1200. If seq 1005 was a `chat_deleted`, `channel_update` or `member_update`, that state is permanently wrong on this client with no `ready` and no refetch to repair it.", - "evidence": "Server/ws/serve.go:210-214 `default: persistedTail = make([][]byte, 0, len(persisted)); for _, p := range persisted { persistedTail = append(persistedTail, p.Payload) }` — accepted with no contiguity/loss check.\nServer/ws/event_persister.go:114-118 `select { case p.queue <- ...: default: p.dropped.Add(1) }` — silent drop on full queue.\nServer/ws/event_persister.go:191-196 `if failed := len(batch) - persisted; failed > 0 { p.errors.Add(...); slog.Warn(\"event persister: flush lost events\", ...) }` — rows lost on insert failure are counted and logged, never surfaced to the replay path.\nCompare serve.go:188-197, whose own comment says \"Accepting it as-is would present a hole as a complete resume, since the client tracks only max(seq)\" — the exact hazard, guarded only for the prefix case.", - "suggestedFix": "In serve.go's cold tier, before accepting persistedTail (the default branch at ~210), verify unfiltered contiguity of the covered range: query the store for COUNT(*) of events with lastSeq < seq <= maxPersistedSeq (unfiltered) and require it to equal maxPersistedSeq - lastSeq; on mismatch, log and force the full-ready fallback like the sibling guards. Contiguity is guaranteed absent losses because every allocated seq is persisted.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "flow-reconnect", - "finder": "opus", - "round": 3, - "confidence": "medium", - "fix": { - "commit": "8787b906", - "test": "Server/ws/reconnect_interior_gap_test.go", - "revertProof": "pass", - "branchCommit": "1740a148" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0063", - "title": "Connected overlay reads authStore before the auth_ok payload is dispatched, so server_name and motd are always the pre-handshake values", - "file": "Client/src/main.ts", - "line": 388, - "severity": "low", - "why": "`ws.onStateChange` listeners are invoked synchronously inside `setState(\"connected\")`, which ws.ts runs *before* `dispatch(msg)` — and `setAuth(token, payload.user, payload.server_name, payload.motd)` only runs inside the dispatcher's auth_ok handler during that later `dispatch`. So `authStore.getState()` at main.ts:388 still holds the pre-auth_ok state. On a first login `wirePostAuth` has written only `token`, leaving `serverName`/`motd` at their `INITIAL_STATE` value of `null`, so both `?? ` fallbacks fire and the overlay renders the raw host string and a blank MOTD even though auth_ok carried both. Nothing later repairs it: the `ws.on(\"ready\", ...)` handler at main.ts:403 only calls `markReady()`, and `createConnectedOverlay` captures its options by value at construction.", - "repro": "Configure a server with `server_name = \"My Guild\"` and a non-empty `motd`. Launch the client fresh and log in to `192.168.1.10:8443`. auth_ok carries `server_name:\"My Guild\"` and the motd, but the connected overlay shows the title/avatar initial derived from `\"192.168.1.10:8443\"` (initial `1`) and an empty MOTD line, because setAuth has not run when line 388 executes. ChannelSidebar/SidebarArea, which subscribe to `authStore.serverName`, show \"My Guild\" once MainPage mounts — proving the value did arrive and only the overlay read it too early.", - "evidence": "ws.ts:307-322 `if (msg.type === \"auth_ok\") { ... setState(\"connected\"); ... } dispatch(msg);` and ws.ts:171-182 `setState` → `for (const listener of stateListeners) listener(state)` (synchronous).\ndispatcher.ts:192-197 `ws.on(S.AUTH_OK, (payload) => { ... setAuth(authStore.getState().token ?? \"\", payload.user, payload.server_name, payload.motd); ...})` — the only writer of serverName/motd.\nmain.ts:344 `authStore.setState((prev) => ({ ...prev, token }));` — the only pre-connect write; serverName/motd untouched.\nstores/auth.store.ts:43-48 `const INITIAL_STATE: AuthState = { token: null, user: null, serverName: null, motd: null, isAuthenticated: false };`\nmain.ts:388-394 `const auth = authStore.getState(); ... serverName: auth.serverName ?? host, ... motd: auth.motd ?? \"\",`", - "suggestedFix": "Create the overlay from the auth_ok payload instead of the store: in wirePostAuth, replace the onStateChange(\"connected\") trigger with a one-shot ws.on(\"auth_ok\", (payload) => { ... serverName: payload.server_name ?? host, motd: payload.motd ?? \"\" ... }) (registered after wireDispatcher), keeping the same self-unsubscribe and destroy-before-create logic.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "flow-reconnect", - "finder": "opus", - "round": 3, - "confidence": "high", - "fix": { - "commit": "8787b906", - "test": "Client/tests/unit/main.test.ts", - "revertProof": "pass", - "branchCommit": "c3a20a95" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0064", - "title": "The dispatcher's catch-all server-error branch writes to `transientError`, which only ConnectPage renders — every unhandled error is invisible in-app and then resurfaces stale on the login screen", - "file": "Client/src/lib/dispatcher.ts", - "line": 1002, - "severity": "low", - "why": "`setTransientError` has exactly one consumer in the whole client: ConnectPage's subscription/mount read (ConnectPage.ts:267 and :276), which pushes it into the login form. MainPage never subscribes and never clears it. So the branch that the code comments call \"the one place every remaining server error lands ... so it must not be silently dropped\" does in fact drop it while the user is in the app, and leaves it latched in the store until the login page next mounts.", - "repro": "grep confirms only two read sites for `transientError` (ui.store.ts declaration aside): ConnectPage.ts:267,276. In a voice call, have a user with a LOWER user id join — the incumbent key holder's `voice_e2ee_offer` is answered with NOT_KEY_HOLDER (Server/ws/voice_e2ee.go:199), which matches none of the special-cased codes above (BANNED / pendingSends / reaction / CHANNEL_FULL / VIDEO_LIMIT) and falls into line 1002. Nothing is shown. Later the user hits Disconnect/logout; ConnectPage mounts, reads the latched value at line 276 and shows `loginForm.showError(\"only the key holder may send key offers\")` on the login form — an error from a different screen, minutes earlier, presented as a login failure.", - "evidence": "dispatcher.ts:1002\n setTransientError(payload.message || \"Server error\");\n\nConnectPage.ts:274-280\n const pendingError = uiStore.getState().transientError;\n if (pendingError) {\n loginForm.showError(pendingError);\n setTransientError(null);\n }\n\n(no other module reads uiStore.transientError)", - "suggestedFix": "In the catch-all branch (dispatcher.ts:1002), surface in-app errors the same way the sibling CHANNEL_FULL/VIDEO_LIMIT branches do — showToast(payload.message || \"Server error\", \"error\") — keeping setTransientError only for flows that also leave the session (BANNED, shutdown), and update the dispatcher tests that assert the store write for the catch-all codes.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "flow-voice", - "finder": "opus", - "round": 3, - "confidence": "medium", - "fix": { - "commit": "8787b906", - "test": "Client/tests/unit/dispatcher.test.ts", - "revertProof": "pass", - "branchCommit": "4aa9c4fc" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0065", - "title": "participant_joined webhook treats a GetVoiceState read error as proof of a rogue participant and ejects a legitimate one from the SFU", - "file": "Server/ws/livekit_webhook.go", - "line": 132, - "severity": "low", - "why": "`stateErr != nil` is OR'd into the same condition as `state == nil || state.ChannelID != channelID`, so a transient DB read failure is indistinguishable from \"no membership row\" and results in `RemoveParticipant`. The sibling eviction path deliberately refuses to make that conflation: `sweepStaleVoiceStates` uses `hasChannelPermChecked` precisely because \"a transient read failure (I/O error, lock contention, a maintenance window) is not a revocation\" and skips the tick instead of evicting. The webhook has no such guard, and its removal is one-sided: it does not delete the voice_states row and does not broadcast voice_leave, so the server keeps believing the user is in voice.", - "repro": "User joins voice; the client connects to the SFU; LiveKit posts participant_joined. If `h.db.GetVoiceState(ctx, userID)` returns a transient error (SQLITE_BUSY under concurrent writes, an I/O error, a maintenance window), the handler logs \"rogue participant_joined\" and calls `h.livekit.RemoveParticipant(...)`. The user is kicked out of the SFU mid-call while their voice_states row and hub voice state stay intact; other participants see no voice_leave, and their E2EE key holder does not rotate. The victim's client sees a non-CLIENT_INITIATED Disconnected and enters attemptAutoReconnect — which, per the 5-minute token TTL finding, also fails for any session older than 5 minutes.", - "evidence": "Server/ws/livekit_webhook.go:131-142\n state, stateErr := h.db.GetVoiceState(ctx, userID)\n if stateErr != nil || state == nil || state.ChannelID != channelID {\n slog.Warn(\"livekit webhook: rogue participant_joined — no matching voice state, removing\", ...)\n if h.livekit != nil { h.livekit.RemoveParticipant(ctx, channelID, userID, joinToken) }\n return\n }\n\n// contrast, Server/ws/hub_sweep.go:166-177\n if err != nil {\n // A transient read failure ... is not a revocation ... Skip this client this tick\n continue\n }", - "suggestedFix": "Split the condition in handleWebhookParticipantJoined: on stateErr != nil, slog.Error and return WITHOUT calling RemoveParticipant (optionally retry the read once), so only a definitive nil row or channel mismatch is treated as rogue — mirroring sweepStaleVoiceStates' skip-on-error guard.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "flow-voice", - "finder": "opus", - "round": 3, - "confidence": "high", - "fix": { - "commit": "7be9ccd2", - "test": "Server/ws/livekit_test.go + Server/ws/livekit_webhook_joined_test.go", - "revertProof": "pass", - "branchCommit": "9981220f" - }, - "fixed": "2026-08-14" - }, - { - "id": "OC-0066", - "title": "applyMentionCounts runs after SendMessage returns, so a mark_read that lands in between leaves a permanent mention badge on a channel with zero unread", - "file": "Server/service/message_crud.go", - "line": 218, - "severity": "low", - "why": "`applyMentionCounts` is dispatched to a background goroutine after the message is committed, and `IncrementMentionCounts` upserts `mention_count = mention_count + 1` unconditionally — it never compares against the recipient's read state. `UpdateReadState` (the only writer that zeroes `mention_count`) can therefore run *before* the increment. The in-code rationale asserts the opposite outcome (\"if a reader's channel_focus clears it in the tiny window before the increment lands, the badge simply does not reappear\"); it does reappear, and because `GetChannelUnreadCounts` reports `mention_count` straight from the row while `unread_count` is computed from `last_message_id`, the next `ready` ships mention_count=1 with unread_count=0.", - "repro": "User U has channel C focused (read_states row: last_message_id=500, mention_count=0). User A posts message 501 containing `@U`; `SendMessage` commits row 501 and spawns the badge goroutine. Before that goroutine reaches `IncrementMentionCounts`, U switches channels — `ChannelController.mountChannel` fires `markChannelRead(C)` → `mark_read` → `HandleChannelFocus` → `UpdateReadState(U, C, 501)`, setting last_message_id=501 and mention_count=0. The goroutine then runs and sets mention_count=1. The row is now (last=501, mention=1): `GetChannelUnreadCounts` reports unread_count=0, mention_count=1, so the next `ready` paints a red mention badge on C with nothing unread behind it, `hasUnread(C)` stays true and \"Mark All as Read\" stays lit until U opens C again.", - "evidence": "Server/service/message_crud.go:214-220 `// The count is advisory: if a reader's channel_focus clears it in the tiny window before the increment lands, the badge simply does not reappear` … `s.bg(func() { s.applyMentionCounts(context.WithoutCancel(ctx), channelID, authorID, mentions, isDM, participantIDs) })` with `bg: func(fn func()) { go fn() }` (Server/service/message.go:144)\nServer/db/mention_queries.go:214-218 `INSERT INTO read_states (…) VALUES %s ON CONFLICT(user_id, channel_id) DO UPDATE SET mention_count = mention_count + 1`\nServer/db/dbgen/messages.sql.go:254-260 `UpdateReadState … DO UPDATE SET last_message_id = excluded.last_message_id, mention_count = 0`\nServer/db/message_queries.go:588-594 ready's unread query: `COUNT(*) … m.id > COALESCE(rs.last_message_id, 0)` for unread, but `COALESCE(rs.mention_count, 0)` verbatim for mentions", - "suggestedFix": "Thread the triggering message id into applyMentionCounts → IncrementMentionCounts and make the upsert read-state-aware: ON CONFLICT(user_id, channel_id) DO UPDATE SET mention_count = mention_count + 1 WHERE read_states.last_message_id < ?msgID. A reader whose read state already advanced past the mentioning message then gets a no-op instead of a phantom badge — one guard in the shared query, no caller changes beyond passing msgID.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "flow-message", - "finder": "opus", - "round": 3, - "confidence": "medium", - "fix": { - "commit": "db0275a2", - "test": "Server/db/mention_queries_test.go", - "revertProof": "pass", - "branchCommit": "081bb169", - "pr": 1369 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0067", - "title": "The identical GetDMParticipantIDs-failure gap silently drops chat_edited fan-out for DM edits", - "file": "Server/service/message_crud.go", - "line": 316, - "severity": "low", - "why": "EditMessage already wrote the new content via s.st.EditMessage before this block. When s.st.GetDMParticipantIDs then errors, the function logs and falls through (no early return) leaving result.ParticipantIDs at its nil zero value while still returning (result, nil). handleChatEditV2 (Server/ws/handlers_chat.go:122-128) builds MessageEditedDMEvent{participantIDs: result.ParticipantIDs} from that nil slice; EmitEvents -> sendSequencedToUsers iterates zero recipients (same code path as the SendMessage finding above), so the chat_edited frame reaches nobody, not even the editor's own other sessions. The other DM participant's client keeps showing the pre-edit content indefinitely (there is no other WS signal that would prompt a refetch of that message), even though the DB row and any REST re-fetch of channel history would already show the edited text -- a live desync between what is persisted and what every connected client displays.", - "repro": "A and B share a DM; A previously sent message M. A edits M while s.st.GetDMParticipantIDs(ctx, channelID) transiently fails inside EditMessage (Server/service/message_crud.go:316-321). The DB row for M is updated with the new content and edited_at, but result.ParticipantIDs stays nil, so MessageEditedDMEvent fans out to zero users. B's already-loaded message list keeps showing the original, pre-edit text with no edited marker, and nothing server-side ever pushes a correction to B's live session.", - "suggestedFix": "Same shared guard as the send path: use context.WithoutCancel(ctx) for the GetDMParticipantIDs lookup (the edit is already committed, so the fan-out bookkeeping must not die with the editor's socket), and in handleChatEditV2 fall back to MessageEditedChannelEvent when result.IsDM && result.ParticipantIDs is empty so focused DM viewers still receive the edit live.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "flow-message", - "finder": "sonnet", - "round": 3, - "confidence": "high", - "fix": { - "commit": "db0275a2", - "test": "Server/service/message_crud_test.go", - "revertProof": "pass", - "followUp": "9cdef406", - "branchCommit": "081bb169", - "pr": 1369 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0068", - "title": "A DM message delete is committed but its chat_deleted fan-out is silently dropped when GetDMParticipantIDs fails", - "file": "Server/service/message_crud.go", - "line": 388, - "severity": "low", - "why": "DeleteMessage logs a GetDMParticipantIDs error and returns a DeleteMessageResult with a nil ParticipantIDs, after the soft-delete has already committed. handleChatDeleteV2 builds MessageDeletedDMEvent from that nil slice and EmitEvents routes it to sendSequencedToUsers, whose recipient loop then iterates zero users — the delete succeeds server-side and reaches nobody, with no error returned to the deleter.", - "repro": "User A deletes their own message in a DM with B. The DeleteMessage row commits (message_crud.go:371) and the audit row is written, then GetDMParticipantIDs returns an error (SQLITE_BUSY under write contention, or a context deadline on a loaded server). ParticipantIDs stays nil, so MessageDeletedDMEvent carries no recipients and sendSequencedToUsers delivers to zero clients. The client only removes a row from messages.store on the CHAT_DELETED dispatcher event (dispatcher.ts:547-551) — ChannelController's onDeleteClick just sends chat_delete and toasts \"Message deleted\" — so A sees the success toast while the message stays on screen for A and for B until a full refetch. The frame did consume a seq and sits in the replay buffer, but every connected client's lastSeq watermark advances past it on the next frame, so a later reconnect can never request it back.", - "evidence": "service/message_crud.go:387-394\n\tif isDM {\n\t\tparticipantIDs, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID)\n\t\tif pErr != nil {\n\t\t\tslog.Error(\"MessageService.DeleteMessage GetDMParticipantIDs\", \"err\", pErr, ...)\n\t\t} else {\n\t\t\tresult.ParticipantIDs = participantIDs\n\t\t}\n\t}\n\treturn result, nil\n\nws/hub_broadcast.go:607-619\nfunc (h *Hub) sendSequencedToUsers(channelID int64, userIDs []int64, msg []byte) {\n\t...\n\tseq := h.nextSeq()\n\twrapped := wrapWithSeq(msg, seq)\n\th.replayBuf.Push(seq, channelID, wrapped)\n\th.persistEvent(seq, channelID, wrapped)\n\tfor _, userID := range userIDs { // empty -> delivered to nobody\n\t\th.SendToUser(userID, wrapped)\n\t}\n}", - "suggestedFix": "In DeleteMessage, call s.st.GetDMParticipantIDs BEFORE s.st.DeleteMessage (participants do not change as a result of the delete) and return an error on failure, so no committed-but-unbroadcast state can exist. At minimum, wrap the existing post-commit fetch in context.WithoutCancel(ctx) to close the disconnect-after-commit window.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-client-tauri-client-src", - "finder": "opus", - "round": 4, - "confidence": "medium", - "fix": { - "commit": "db0275a2", - "test": "Server/service/message_crud_test.go", - "revertProof": "pass", - "followUp": "9cdef406", - "branchCommit": "081bb169", - "pr": 1369 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0069", - "title": "A DM reaction is persisted but its reaction_update fan-out is silently dropped when GetDMParticipantIDs fails", - "file": "Server/service/message_reactions.go", - "line": 147, - "severity": "low", - "why": "Same shape as DeleteMessage: handleReaction commits the AddReaction/RemoveReaction row, then leaves result.ParticipantIDs nil on a GetDMParticipantIDs error. reactionV2Handler builds ReactionDMEvent from that nil slice, so the reaction_update reaches no participant while the DB row exists.", - "repro": "User A reacts to a message in a DM with B. s.st.AddReaction commits, then GetDMParticipantIDs errors (transient DB failure/context deadline). ParticipantIDs is nil, so sendSequencedToUsers delivers the reaction_update to nobody. B never sees the pill. On A's side the optimistic pill from addOptimisticReaction stays rendered but its pendingReactions entry (keyed by the WS envelope id) is never consumed by updateReaction, so it lingers until a disconnect rolls it back — at which point A's pill reverts even though the reaction is persisted server-side, and the two sides disagree until a refetch. No error is returned to A.", - "evidence": "service/message_reactions.go:146-155\n\tif isDM {\n\t\tparticipantIDs, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID)\n\t\tif pErr != nil {\n\t\t\tslog.Error(\"MessageService.handleReaction GetDMParticipantIDs\", \"err\", pErr, ...)\n\t\t} else {\n\t\t\tresult.ParticipantIDs = participantIDs\n\t\t}\n\t}\n\treturn result, nil\n\nws/handlers_reaction.go:46-52\n\t\tif result.IsDM {\n\t\t\treturn Result{Events: []Event{ReactionDMEvent{\n\t\t\t\tchannelID: result.ChannelID,\n\t\t\t\tparticipantIDs: result.ParticipantIDs, // nil\n\t\t\t\tpayload: reactionPayload,\n\t\t\t}}}\n\t\t}", - "suggestedFix": "In handleReaction, fetch GetDMParticipantIDs before performing the AddReaction/RemoveReaction mutation and fail the request on error (participants are unaffected by the mutation), eliminating the committed-but-unbroadcast state. At minimum, use context.WithoutCancel(ctx) for the post-commit fetch.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-client-tauri-client-src", - "finder": "opus", - "round": 4, - "confidence": "medium", - "fix": { - "commit": "db0275a2", - "test": "Server/service/message_reactions_test.go", - "revertProof": "pass", - "branchCommit": "76d94578", - "pr": 1369 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0070", - "title": "channel_focus has no archived-channel gate, so a client can subscribe to the live event stream of a channel every visibility surface hides — and reconnect replay then filters those same events out", - "file": "Server/service/channel.go", - "line": 245, - "severity": "low", - "why": "HandleChannelFocus gates only on READ_MESSAGES, and permissions.Checker.HasChannelPerm ignores ch.Archived (only VisibleChannelIDs applies the archived rule, checker.go:119). Every sibling path — buildReady, REST ListVisibleChannels, computeAllowedChannels, RefreshChannelVisibility and handleVoiceJoin — refuses or hides archived channels explicitly, so focus is the one path that lets a socket re-attach to one.", - "repro": "1. Admin archives #foo via PATCH /admin/api/channels/{id}. handlePatchChannel calls RefreshChannelVisibility, which sends channel_delete to every client and calls pubsub.Unsubscribe(c, ChannelTopic(foo)) plus clears c.channelID — the channel is now hidden from ready, REST ListVisibleChannels and computeAllowedChannels.\n2. A user who still holds READ_MESSAGES on #foo sends {\"type\":\"channel_focus\",\"payload\":{\"channel_id\":}}.\n3. HandleChannelFocus passes (the archived flag is never consulted), so handlers.go:170 re-subscribes the socket to ChannelTopic(foo) and UpdateReadState advances the user's read state on a channel that is not in their ready payload.\n4. The socket now receives every chat_edited / reaction_update / chat_deleted / chat_bulk_deleted broadcast for #foo live (the archived read-only rule exists only on SendMessage), while computeAllowedChannels — used to filter reconnect replay — excludes #foo. On the next resume those identical events are dropped from replay, so the live stream and the resume path permanently disagree about the same channel, and the client's focused channel points at one its own channel list no longer contains.", - "evidence": "service/channel.go:240-247 (no ch.Archived branch)\n\tif ch.Type == \"dm\" {\n\t\tok, err := s.st.IsDMParticipant(ctx, userID, channelID)\n\t\tif err != nil || !ok { return nil, fmt.Errorf(\"%w: access denied\", ErrForbidden) }\n\t} else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) {\n\t\treturn nil, fmt.Errorf(\"%w: access denied\", ErrForbidden)\n\t}\n\npermissions/checker.go:116-119 (archived is applied ONLY in VisibleChannelIDs)\n\t\t// Archived channels are hidden from every client surface (admins ...)\n\t\tif ch.Archived {\n\nws/voice_join.go:92-95 (the sibling gate that does exist)\n\tif ch.Archived {\n\t\tc.sendMsg(buildErrorMsg(ErrCodeBadRequest, \"channel is archived\"))\n\t\treturn\n\t}\n\nws/handlers.go:158-172 (a successful focus subscribes the socket to ChannelTopic)\n\tif result.SetChannelID != nil { ... c.hub.pubsub.Subscribe(c, ChannelTopic(newChID)) }", - "suggestedFix": "One guard in the shared service function (covers both channel_focus and mark_read): in HandleChannelFocus after the GetChannel lookup, add `if ch.Type != \"dm\" && ch.Archived { return nil, fmt.Errorf(\"%w: access denied\", ErrForbidden) }`.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-server-ws", - "finder": "opus", - "round": 4, - "confidence": "high", - "fix": { - "commit": "8787b906", - "test": "Server/service/channel_test.go", - "revertProof": "pass", - "branchCommit": "56bdc1d2" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0071", - "title": "A sidebar re-render during a channel drag detaches the drop container, so the reorder silently no-ops", - "file": "Client/src/components/channel-sidebar/drag-reorder.ts", - "line": 111, - "severity": "low", - "why": "`activeDrag` captures the `channelsContainer` element that existed when the row was rendered. `ChannelSidebar.renderChannels()` does `clearChildren(channelList)` and rebuilds every category group, so any re-render while the mouse button is down leaves `drag.containerEl` (and `drag.sourceEl`, and the stale `drag.channels` snapshot) detached from the document. The global `mouseup` hit-test then queries the detached subtree, where `getBoundingClientRect()` returns an all-zero DOMRect for every row, so no row can ever satisfy `e.clientY >= rect.top && e.clientY <= rect.bottom` and `dropTargetId` stays null. The handler returns at the `dropTargetId === null` guard, and the drag is discarded with no error, no toast and no visual trace. The same staleness silently kills the drop indicator during `mousemove` (line 74-89 also queries the detached container), so the user watches the indicator disappear and then the drop does nothing.", - "repro": "Sign in with MANAGE_CHANNELS. Press the mouse down on a channel row and move >5px to start a drag. While still holding the button, have another user post a message in any channel that is not the active one (this calls `incrementUnread`, which builds a new `channels` Map, which fires the `subscribeSelector` at ChannelSidebar.ts:847, which runs `renderChannels()` and detaches the container captured in `activeDrag`). Release the mouse over a different channel row. Expected: the channel moves. Actual: `drag.containerEl.querySelectorAll(...)` returns rows whose `getBoundingClientRect()` is `{top:0,bottom:0}`, `dropTargetId` stays null, the handler returns, `onReorder` is never called and no position is written — the drag is lost with no feedback. Same happens on a category collapse, a connection-status flip, or any voice mute/camera change during the drag.", - "evidence": "drag-reorder.ts:100-125\n const drag = activeDrag;\n activeDrag = null;\n ...\n const items = drag.containerEl.querySelectorAll(\"[data-drag-channel-id]\");\n let dropTargetId: number | null = null;\n ...\n for (const item of items) {\n const rect = item.getBoundingClientRect();\n if (e.clientY >= rect.top && e.clientY <= rect.bottom) { ... }\n }\n if (dropTargetId === null || dropTargetId === drag.channelId) {\n return;\n }\n\nChannelSidebar.ts:751-796 (renderChannels)\n clearChildren(channelList);\n ...\n for (const [category, channels] of grouped) {\n channelList.appendChild(renderCategoryGroup(...)); // new channelsContainer every time\n }\n\nrenderChannels() is wired to high-frequency stores:\nChannelSidebar.ts:847 channelsStore.subscribeSelector((s) => s.channels, () => renderChannels());\nChannelSidebar.ts:891 voiceStore.subscribeSelector(, () => renderChannels());\n\nchannels.store.ts:337-353 (incrementUnread) replaces the channel object AND the Map on every\nmessage delivered to a non-active channel, so the selector above fires.", - "suggestedFix": "In the global mousemove/mouseup handlers, when !drag.containerEl.isConnected, re-resolve the live container via document.querySelector(`[data-drag-channel-id=\"${drag.channelId}\"]`)?.closest('.category-channels-container') (and rebuild the channel snapshot for that group from channelsStore) before hit-testing; or equivalently have renderChannels() re-target activeDrag's containerEl/sourceEl/channels when it rebuilds while a drag it owns is in flight.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "fresh-eyes", - "finder": "opus", - "round": 4, - "confidence": "high", - "fix": { - "commit": "b1fb565", - "branchCommit": "4b4fc0a", - "test": "Client/tests/unit/drag-reorder.test.ts", - "revertProof": "self-reported", - "pr": 1366 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0072", - "title": "voice_mod_move's pre-flight omits the archived-channel gate that voice_join enforces, so the move drops the target out of voice for nothing", - "file": "Server/ws/voice_moderation.go", - "line": 295, - "severity": "low", - "why": "handleVoiceModMoveV2 documents itself as a pre-flight that \"refuse[s] a move the re-join would only bounce, so the target is never dropped from voice for nothing\", and it validates destination existence, type, the TARGET's CONNECT_VOICE, and capacity. It never checks dest.Archived, but the re-join it depends on (handleVoiceJoin, voice_join.go:92) refuses an archived channel with BAD_REQUEST. The handler has `dest` (a *db.Channel carrying Archived) in hand and simply does not consult it, so the move commits its destructive half — DB row deleted, LiveKit participant removed, voice_leave broadcast — for a re-join that is guaranteed to be rejected.", - "repro": "1. Admin PATCHes voice channel B to archived=true (admin/handlers_channels.go:266 fires CleanupVoiceForChannel, so B is empty; B stays type=\"voice\" with Archived=1). 2. Target user T is in voice channel A. 3. A moderator with MUTE_MEMBERS outranking T sends {\"type\":\"voice_mod_move\",\"payload\":{\"user_id\":T,\"to_channel_id\":B}} (to_channel_id is client-supplied; no UI is needed). 4. Pre-flight passes: dest != nil, dest.Type == \"voice\", T holds CONNECT_VOICE on B (Archived is not part of permission resolution), capacity is free. 5. disconnectFromVoiceIn evicts T from A — voice_states row deleted, LiveKit participant removed, voice_leave broadcast — and voice_moved is sent. 6. T's client answers with voice_join B, which handleVoiceJoin rejects at voice_join.go:92 with BAD_REQUEST \"channel is archived\". T ends the sequence out of voice entirely, with an error and no way back to A except a manual rejoin — exactly the outcome the handler's doc comment says the pre-flight exists to prevent.", - "evidence": "dest, err := d.DB.GetChannel(ctx, c.ToChannelID())\n...\nif dest.Type != \"voice\" {\n\treturn Result{Error: ClientError{Code: ErrCodeBadRequest, Message: \"destination is not a voice channel\"}}\n}\n// ...no `if dest.Archived` branch anywhere in handleVoiceModMoveV2...\nif !disconnectFromVoiceIn(ctx, d.Mod, c.TargetID(), state.ChannelID) { ... }\nd.Mod.SendToUser(c.TargetID(), buildVoiceMoved(c.ToChannelID()))\n\n// voice_join.go:92 — the gate the re-join actually applies:\nif ch.Archived {\n\tc.sendMsg(buildErrorMsg(ErrCodeBadRequest, \"channel is archived\"))\n\treturn\n}", - "suggestedFix": "In handleVoiceModMoveV2, immediately after the dest.Type check (voice_moderation.go:295-297), add: if dest.Archived { return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: \"channel is archived\"}} } — same error shape voice_join.go:92 uses.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-server-ws", - "finder": "opus", - "round": 5, - "confidence": "high", - "fix": { - "commit": "db0275a2", - "test": "Server/ws/voice_moderation_test.go", - "revertProof": "pass", - "branchCommit": "6e21556f", - "pr": 1369 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0073", - "title": "channelReadAudience does not exclude archived channels, so admin edits to an archived channel are broadcast directly to every user whose base role has READ_MESSAGES", - "file": "Server/ws/hub_broadcast.go", - "line": 126, - "severity": "low", - "why": "channelReadAudience (used by broadcastChannelScoped -> BroadcastChannelUpdate/BroadcastChannelCreate, by broadcastVoiceEvent, and by CleanupVoiceForChannel's audience build) fetches the channel row at line 141 and only special-cases ch.Type==\"dm\"; it never checks ch.Archived. Its sibling RefreshChannelVisibility (same file, ~line 321: `case ch.Archived: visible = false`) explicitly treats an archived channel as invisible to everyone regardless of role, matching VisibleChannelIDs (permissions/checker.go:119, `if ch.Archived { continue }`) and the doc comment on RefreshChannelVisibility ('Archived channels are hidden from every client regardless of permissions'). channelReadAudience's own doc comment claims it 'Mirrors RefreshChannelVisibility, which resolves visibility the same way' but the archived check present there was never added here. The underlying HasChannelPerm/HasChannelPermBatch calls it delegates to (permissions/checker.go:69, service/permission.go:53-68) also never consult Archived — only the higher-level VisibleChannelIDs does. Delivery bypasses pub/sub entirely: deliverBroadcast's bm.recipients!=nil branch calls h.SendToUser per audience member directly (hub_broadcast.go ~line 667), so even a client that was never subscribed to the channel's topic (and never had it in its ready payload / sidebar) still receives the frame on its live socket.", - "repro": "1) Admin archives voice channel #42 (handlePatchChannel, admin/handlers_channels.go, Archived: true committed to DB). Ordinary members' role has base READ_MESSAGES on #42 but the channel is now invisible everywhere else (VisibleChannelIDs excludes it from ready/reconnect, RefreshChannelVisibility sent them channel_delete/never showed it). 2) Admin PATCHes #42 again while it stays archived (e.g. edits topic/nsfw/slow_mode) — `existing.Archived == updated.Archived` so RefreshChannelVisibility is never called, but `hub.BroadcastChannelUpdate(updated)` always runs (admin/handlers_channels.go line 253) -> ws/hub_broadcast.go broadcastChannelScoped -> channelReadAudience(ctx, 42) returns every connected user whose role has READ_MESSAGES (archived not checked) -> deliverBroadcast SendToUser's the channel_update JSON (id, name, topic, category, archived flag) straight to those sockets, none of whom ever had #42 in their store or subscribed to its topic. Same gap fires if the admin archives a voice channel while people are still in it: CleanupVoiceForChannel (hub_sweep.go:332) calls channelReadAudience on the now-archived channel and broadcasts each evicted participant's voice_leave to the same over-broad, archived-blind audience, disclosing who was in the hidden voice channel to users who should never learn it exists.", - "suggestedFix": "In channelReadAudience, inside the h.db != nil block after the GetChannel error handling (hub_broadcast.go:149), add: if ch != nil && ch.Archived { return []int64{} } — one guard in the shared audience function covers BroadcastChannelCreate/Update, broadcastVoiceEvent, finishVoiceLeave and CleanupVoiceForChannel at once (the archive-transition voice_leave fan-out keeps working because CleanupVoiceForChannel's audience is resolved before the eviction ordering matters only client-side, and its evicted-participant append is unconditional).", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-server-ws", - "finder": "sonnet", - "round": 5, - "confidence": "high", - "fix": { - "commit": "8787b906", - "test": "Server/ws/hub_broadcast_test.go", - "revertProof": "pass", - "branchCommit": "96ac3992" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0074", - "title": "EditMessage's DM detection fails open on a GetChannel error, skipping the block gate and misrouting the edit fan-out", - "file": "Server/service/message_crud.go", - "line": 253, - "severity": "low", - "why": "`chanType` stays \"\" when the channel read fails, so a DM edit takes the non-DM branch: `requireDMNotBlocked` and `IsDMParticipant` never run, and `result.IsDM` is false, so the ws layer emits MessageEditedChannelEvent (topic fan-out) instead of MessageEditedDMEvent (participant fan-out).", - "repro": "Bob has blocked Alice. Alice edits her own older DM message while GetChannel returns a transient error. isDM=false, so the `requireDMNotBlocked` branch at line 265 is skipped and `checkSendPermission(ctx, userID, msg.ChannelID, \"\")` runs the non-DM path, which passes on the base role mask (no override rows exist for a DM channel). The edit commits with arbitrary new text and, because result.IsDM is false, handleChatEditV2 (ws/handlers_chat.go:129) returns MessageEditedChannelEvent -> BroadcastToChannel(dmChannelID), delivering it to whoever holds the DM topic subscription rather than to the participant list — the exact channel back to the blocker that the requireDMNotBlocked doc comment says it exists to close.", - "evidence": "ch, chErr := s.st.GetChannel(ctx, msg.ChannelID)\nchanType := \"\"\nif chErr == nil && ch != nil {\n\tchanType = ch.Type\n}\nisDM := chanType == \"dm\"", - "suggestedFix": "Fail closed: after line 253, `if chErr != nil || ch == nil { return nil, fmt.Errorf(\"%w: cannot edit this message\", ErrForbidden) }` and derive chanType from ch.Type unconditionally.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-server-service", - "finder": "opus", - "round": 5, - "confidence": "medium", - "fix": { - "commit": "db0275a2", - "test": "Server/service/message_crud_test.go", - "revertProof": "pass", - "branchCommit": "081bb169", - "pr": 1369 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0075", - "title": "handleReaction's DM detection fails open on a GetChannel error, letting a non-participant react inside a private DM", - "file": "Server/service/message_reactions.go", - "line": 105, - "severity": "low", - "why": "Identical swallowed-error pattern: a failed channel read makes a DM take the role-based branch, bypassing IsDMParticipant and requireDMNotBlocked, and the reaction is then fanned out as ReactionChannelEvent instead of ReactionDMEvent.", - "repro": "Mallory (any role with READ_MESSAGES|ADD_REACTIONS, or any ADMINISTRATOR) sends reaction_add for a message id belonging to Alice and Bob's private DM while GetChannel errors. isDM=false -> HasChannelPerm resolves from the base mask on a channel with no override rows -> true -> the reaction row is written to a DM Mallory is not a participant of, and reactionV2Handler (ws/handlers_reaction.go:53) emits ReactionChannelEvent, publishing it onto the DM's channel topic where Alice and Bob see an outsider's reaction on their private message.", - "evidence": "ch, chErr := s.st.GetChannel(ctx, msg.ChannelID)\nisDM := chErr == nil && ch != nil && ch.Type == \"dm\"\n\nif isDM {\n\tok, dmErr := s.st.IsDMParticipant(ctx, userID, msg.ChannelID)\n\t...\n} else if !s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ReadMessages|permissions.AddReactions) {", - "suggestedFix": "Fail closed: after line 105, `if chErr != nil || ch == nil { return nil, fmt.Errorf(\"%w: message not found\", ErrBadRequest) }` and compute `isDM := ch.Type == \"dm\"`.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-server-service", - "finder": "opus", - "round": 5, - "confidence": "medium", - "fix": { - "commit": "8787b906", - "test": "Server/service/message_reactions_test.go", - "revertProof": "pass", - "branchCommit": "8243e60d" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0076", - "title": "The admin setup rate limiter is never reaped, so its window map grows without bound for the life of the process", - "file": "Server/admin/api.go", - "line": 34, - "severity": "low", - "why": "`setupLimiter` is a dedicated auth.RateLimiter that nothing ever calls Cleanup or StartCleanup on — unlike the API limiter, which api/router.go:76 puts on a 5-minute reaper. Its `windows` map therefore accumulates one permanently-live entry per distinct source IP.", - "repro": "handleSetup calls limiter.Allow(\"setup:\"+host, 5, time.Minute) at setup_handler.go:125, BEFORE the CreateOwnerIfEmpty check at :172 that rejects an already-configured server. So on a fully set-up, production server, every unauthenticated POST /admin/api/setup still allocates an `entry` in setupLimiter.shards[...].windows keyed by the peer IP and appends a time.Time — and nothing ever deletes it (RateLimiter.Cleanup is the only eviction path and is never invoked on this instance). A host reachable on an IPv6 /64 sees the map grow one entry (~key string + up to 5 time.Time) per source address indefinitely; the entries survive even though every request is 403ing.", - "evidence": "// admin/api.go:34\nsetupLimiter := auth.NewRateLimiter()\nr.Post(\"/setup\", handleSetup(database, setupLimiter, allowedOrigins, hub, setupOpts))\n\n// vs api/router.go:76\ngo limiter.StartCleanup(rateLimiterCleanupInterval, rateLimiterCleanupMaxWindow, limiterStopCh)", - "suggestedFix": "Mirror api/router.go: in NewAdminAPI start `go setupLimiter.StartCleanup(5*time.Minute, 15*time.Minute, stopCh)` (plumbing the router's existing limiterStopCh through, or reusing the router's already-reaped limiter for the setup endpoint).", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-server-service", - "finder": "opus", - "round": 5, - "confidence": "high", - "fix": { - "commit": "8787b906", - "test": "Server/admin/setup_limiter_reap_test.go", - "revertProof": "pass", - "branchCommit": "8d6dee6b" - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0077", - "title": "DeleteMessage has no archived-channel gate — the read-only invariant has a fifth hole", - "file": "Server/service/message_crud.go", - "line": 329, - "severity": "low", - "why": "SendMessage was fixed to refuse writes into an archived channel (message_crud.go:54-56, locked by TestSendMessage_RefusedInArchivedChannel), and the already-known finding at message_crud.go:268 documents that EditMessage, handleReaction, SetMessagePinned and PurgeMessages were left uncovered by that fix. DeleteMessage (lines 329-397) has the identical gap and was not named in that list: it fetches the channel at line 345 (`ch, chErr := s.st.GetChannel(ctx, msg.ChannelID)`) purely to determine `isDM`, and `ch.Archived` is never read anywhere in the function. Both the ownership-only DM path and the READ_MESSAGES|MANAGE_MESSAGES / owner-SEND_MESSAGES channel path proceed straight to `s.st.DeleteMessage(ctx, msgID, userID, isMod)` at line 371 regardless of the channel's archived flag, and the underlying db.DeleteMessage (db/message_queries.go:165) has no archived check either. This lets a member soft-delete their own message, or a moderator soft-delete anyone's message, in a channel the send path and every visibility surface treat as frozen — directly contradicting the SendMessage comment's stated invariant that 'History stays readable; only writes are refused.'", - "repro": "Archive channel 10 (`UPDATE channels SET archived = 1 WHERE id = 10`) after it has message history. A member who authored a message in channel 10 (or a moderator with READ_MESSAGES|MANAGE_MESSAGES on it) sends chat_delete for that message id. GetChannel returns Archived=true but DeleteMessage never inspects it; ownership/permission checks pass as normal; s.st.DeleteMessage soft-deletes the row and the handler broadcasts chat_deleted to the channel — the archive's history is silently mutated exactly the way SendMessage was fixed to prevent.", - "suggestedFix": "In DeleteMessage, after the (fail-closed) GetChannel fetch: `if !isDM && ch.Archived { return nil, fmt.Errorf(\"%w: channel is archived\", ErrForbidden) }` — matching SendMessage lines 54-56 (and the same one-line gate belongs in the sibling write sinks already tracked in the ledger).", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-server-service", - "finder": "sonnet", - "round": 5, - "confidence": "medium", - "fix": { - "commit": "db0275a2", - "test": "Server/service/message_crud_test.go", - "revertProof": "pass", - "followUp": "9cdef406", - "branchCommit": "081bb169", - "pr": 1369 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0078", - "title": "renderAll's rapid-fire breaker discards the update instead of deferring it, leaving the message list permanently stale", - "file": "Client/src/components/MessageList.ts", - "line": 658, - "severity": "low", - "why": "When more than 20 renderAll calls occur inside the 2s window the function returns before rebuildItems(), so the store change that triggered it is simply dropped. Nothing re-schedules a render when renderAllResetTimer clears the counter, so the DOM keeps showing pre-burst state until some later, unrelated store event happens to arrive.", - "repro": "In a busy channel, have 21+ non-append store updates land in separate microtask notifications inside 2s — e.g. a moderator purge that arrives as 25 individual chat_deleted frames, or a burst of reaction_update frames. tryAppendMessages() returns false for all of them (prefix comparison fails / next.length <= prev.length), so each one calls renderAll(). Calls 21-25 log \"renderAll called >20 times in 2s\" and return; allMessages/virtualItems still contain the deleted rows. Two seconds later the counter resets but no render is queued, so the deleted messages stay on screen — and stay clickable — until the next unrelated update (a new message, a roleRevision bump) triggers another renderAll.", - "evidence": "renderAllCount++;\nif (renderAllCount > 20) {\n log.error(\"[MessageList] renderAll called >20 times in 2s — breaking loop\");\n return; // <- update dropped, nothing re-queued\n}\nif (renderAllResetTimer === 0) {\n renderAllResetTimer = window.setTimeout(() => { renderAllCount = 0; renderAllResetTimer = 0; }, 2000);\n}", - "suggestedFix": "When the breaker trips, remember it (e.g. renderAllSuppressed = true) and have the 2s reset timeout call renderAll() once if the flag is set, so the final state of a burst is always rendered.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "fresh-eyes", - "finder": "opus", - "round": 5, - "confidence": "medium", - "fix": { - "commit": "c3837fa", - "branchCommit": "a910975", - "test": "Client/tests/unit/message-list.test.ts", - "revertProof": "self-reported", - "pr": 1367 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0079", - "title": "An emptied message edit is submitted (and edit mode torn down) when an attachment is queued in the composer", - "file": "Client/src/components/MessageInput.ts", - "line": 472, - "severity": "low", - "why": "The empty-content early return is disabled by `hasAttachments`, but `pendingAttachments` is only meaningful for a new message — the edit branch never reads it. So with a file queued, an edit whose text the user cleared reaches `onEditMessage(id, \"\")`, which the host rejects with a toast, and `cancelEdit()` then runs unconditionally, dropping the user out of edit mode and wiping the textarea. The identical keystroke with no attachment queued is a harmless no-op that preserves edit state.", - "repro": "In a channel with uploads wired: (1) click \"+\" and attach any small image — the preview bar shows it and pendingAttachments.length === 1; (2) press ArrowUp on the empty composer (or click Edit on one of your messages) to enter edit mode — startEdit fills the textarea with the original content, pendingAttachments is untouched; (3) select all and delete the text, then press Enter. Expected (and what happens with no attachment queued): the send is refused at line 472 and the user stays in edit mode. Actual: `hasAttachments` is true, so the guard is skipped, `onEditMessage(messageId, \"\")` fires, a \"Message cannot be empty\" error toast appears, and `cancelEdit()` immediately exits edit mode and clears the textarea — the user has lost the edit and must re-open it.", - "evidence": "function handleSend(): void {\n if (disabledReason !== null) return;\n if (textarea === null) return;\n const content = textarea.value.trim();\n const hasAttachments = pendingAttachments.length > 0;\n if (content.length === 0 && !hasAttachments) return; // <-- line 472\n ...\n if (state.editing !== null) {\n options.onEditMessage(state.editing.messageId, content); // content === \"\"\n cancelEdit(); // runs regardless\n }\n\n// ChannelController.ts:357-362 (the onEditMessage host):\n// const trimmed = content.trim();\n// if (trimmed === \"\") { showToast(\"Message cannot be empty\", \"error\"); return; }\n\n// handlePasteFile only refuses attachments queued DURING an edit (MessageInput.ts:539):\n// if (state.editing !== null) { showUploadError(\"Can't attach files while editing a message\"); return; }\n// It does not cover attach-then-edit, so pendingAttachments can be non-empty while state.editing !== null.", - "suggestedFix": "In handleSend, make the empty-content guard ignore attachments when editing (edits are text-only): change line 472 to `if (content.length === 0 && (state.editing !== null || !hasAttachments)) return;`.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-client-tauri-client-src", - "finder": "opus", - "round": 6, - "confidence": "high", - "fix": { - "commit": "b1fb565", - "branchCommit": "760686c", - "test": "Client/tests/unit/message-input.test.ts", - "revertProof": "self-reported", - "pr": 1366 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0080", - "title": "teardownForReconnect() has the same generation-guard gap as leaveVoice(), so a camera/screenshare enable racing an unexpected LiveKit disconnect can publish a track to the room being torn down for auto-reconnect", - "file": "Client/src/lib/livekitSession.ts", - "line": 348, - "severity": "low", - "why": "roomEventHandlers.ts's handleDisconnected (line 184) calls deps.teardownForReconnect() on every unexpected disconnect that is eligible for auto-reconnect, before nulling the room and calling room.disconnect() (roomEventHandlers.ts:187-193). teardownForReconnect (livekitSession.ts:329-352) mirrors leaveVoice: it tells the server camera/screenshare are off, then calls stopManualCameraTrack(this._cameraState, this._room) and stopManualScreenTracks(this._screenState, this._room) directly (lines 348-349) and resets setLocalCamera(false)/setLocalScreenshare(false) (lines 350-351) — again without bumping state.generation, unlike doDisableCamera/doDisableScreenshare. An enableCamera()/enableScreenshare() call that is awaiting device acquisition when an unexpected disconnect fires will, on resuming, pass the stale-generation check and attempt to publish onto the room object that is about to be (or already was) disconnected and replaced by attemptAutoReconnect's fresh Room, leaving a leaked/orphaned local track and a store state that can disagree with what is actually being sent once the new room comes up.", - "repro": "1) Join voice (room R1). 2) Click 'Enable camera'; enableCamera() captures room=R1, generation=0, and is awaiting createLocalVideoTrack() (device prompt already granted previously, so this await is just the getUserMedia latency, still enough for the race). 3) The LiveKit connection drops unexpectedly (network blip) — RoomEvent.Disconnected fires handleDisconnected, which calls teardownForReconnect(): stops manual tracks (no-op, nothing published yet), sends voice_camera(false)/voice_screenshare(false) if they were on, resets the store, but leaves this._cameraState.generation at 0; then the room is disconnected and replaced via attemptAutoReconnect. 4) createLocalVideoTrack resolves; enableCamera()'s stale-generation check still reads 0 === 0, so it sets state.manualCameraTrack and calls publishTrack on the old, disconnected R1 reference — a publish that races the reconnect instead of being cleanly superseded the way an explicit disableCamera() would have caused.", - "suggestedFix": "Same one-line-per-state fix as the leaveVoice finding: call the shared supersede/bumpGeneration helper on this._cameraState and this._screenState at the top of the teardownForReconnect callback (before livekitSession.ts:348-349).", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-client-tauri-client-src", - "finder": "sonnet", - "round": 6, - "confidence": "high", - "fix": { - "commit": "7be9ccd2", - "test": "Client/tests/unit/livekit-session.test.ts", - "revertProof": "pass", - "branchCommit": "db7d518b" - }, - "fixed": "2026-08-14" - }, - { - "id": "OC-0081", - "title": "voice_max_video cap counts the requester's own camera row, so a user whose server-side camera flag is already 1 can never re-enable", - "file": "Server/db/queries/sqlite/voice.sql", - "line": 92, - "severity": "low", - "why": "EnableCameraIfUnderLimit's guard subquery counts every camera=1 row in the channel, including the very row the UPDATE targets. An enable request from a user whose row already has camera=1 therefore needs maxVideo-1 other publishers to pass, so at the cap it is refused against the requester's own stream. The zero-rows result is also indistinguishable from \"no voice_states row for this channel\", and handleVoiceCameraV2 maps both to VIDEO_LIMIT \"maximum N video streams reached\".", - "repro": "Channel with voice_max_video = 1. User A enables their camera: COUNT(camera=1)=0 < 1, row updated to camera=1. A's client-side localCamera then falls out of sync with the row while the row stays 1 — the confirmed enableCamera supersession gap (Client/src/lib/screenShare.ts:243) does exactly this: a disableCamera that lands during publishTrack resets localCamera to false but the server row keeps camera=1. A now presses the camera button (VoiceCallbacks.ts:110 computes next = !localCamera = true) and the server runs EnableCameraIfUnderLimit(A, ch, 1): the subquery counts A's own row, 1 < 1 is false, 0 rows affected, ok=false. A receives VIDEO_LIMIT \"maximum 1 video streams reached\" while being the only video publisher in the room, and every retry repeats it — there is no path that clears camera back to 0 except A sending voice_camera{enabled:false}, which the UI will not do because it believes the camera is already off.", - "evidence": "Server/db/queries/sqlite/voice.sql:90-92 — `UPDATE voice_states SET camera = 1 WHERE voice_states.user_id = ? AND voice_states.channel_id = ? AND (SELECT COUNT(*) FROM voice_states AS vs2 WHERE vs2.channel_id = ? AND vs2.camera = 1) < ?;` (no `AND vs2.user_id <> ?` exclusion, and no `AND camera = 0` on the outer UPDATE). Consumed at Server/ws/voice_controls.go:116 `ok, limitErr := d.DB.EnableCameraIfUnderLimit(ctx, userID, voiceChID, ch.VoiceMaxVideo)` with the refusal at voice_controls.go:121-126 returning ErrCodeVideoLimit.", - "suggestedFix": "Exclude the requester's own row from the count in EnableCameraIfUnderLimit: change the subquery to `WHERE vs2.channel_id = ? AND vs2.camera = 1 AND vs2.user_id <> voice_states.user_id` (or bind userID again with `AND vs2.user_id <> ?`), then regenerate the sqlc layer via the db-change workflow. This makes re-enable idempotent while still refusing a genuinely new publisher at the cap.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-server-ws", - "finder": "opus", - "round": 6, - "confidence": "high", - "fix": { - "commit": "6a5a3a7c", - "test": "TestVoice_EnableCameraIfUnderLimit_ReEnableIdempotentAtCap", - "revertProof": "pass" - }, - "fixed": "2026-08-19", - "note": "own-flag exclusion in both Enable*IfUnderLimit gates; sqlc regenerated" - }, - { - "id": "OC-0082", - "title": "Pinning a soft-deleted message returns HTTP 500: SetMessagePinned leaks db.ErrNotFound unwrapped, and lacks the deleted-message guard its siblings have", - "file": "Server/service/message_query.go", - "line": 227, - "severity": "low", - "why": "`SetMessagePinned` is the only method in MessageService that returns a raw store error to its caller instead of wrapping it in the service error taxonomy. The pin SQL carries `AND deleted = 0`, so a soft-deleted target produces `db.ErrNotFound`, which `errors.Is(err, service.ErrNotFound)` does not match — `writeServiceError` falls through to `default:` and answers 500 INTERNAL_ERROR instead of 404. It is also the only message mutation with no `msg.Deleted` check: EditMessage returns ErrDeletedMessage (message_crud.go:248) and handleReaction returns ErrBadRequest (message_reactions.go:101).", - "repro": "Moderator A has the pinned-messages panel open showing message M (GET /channels/{id}/pins). Moderator B deletes M (soft delete: `deleted = 1`, pinned still 1). A clicks unpin → DELETE /api/v1/channels/{id}/pins/{M}. GetMessage still returns the row (db.GetMessage deliberately returns soft-deleted rows), so the channel/message checks pass; the UPDATE matches 0 rows, db.ErrNotFound propagates unwrapped, and the client gets `500 {\"error\":\"INTERNAL_ERROR\"}` plus a server-side `slog.ErrorContext(\"service error\")` line, rather than the 404 the sibling not-found paths return (locked by TestSetPinned_MessageNotFound / TestSetPinned_ChannelNotFound in api/channel_handler_test.go — neither covers the deleted case).", - "evidence": "service/message_query.go:223-227\n```\n\tmsg, err := s.st.GetMessage(ctx, msgID)\n\tif err != nil || msg == nil || msg.ChannelID != channelID {\n\t\treturn fmt.Errorf(\"%w: message not found in this channel\", ErrNotFound)\n\t}\n\treturn s.st.SetMessagePinned(ctx, msgID, pinned)\n```\n(no `msg.Deleted` branch; raw store error returned)\n\ndb/queries/sqlite/messages.sql:27 `UPDATE messages SET pinned = ? WHERE id = ? AND deleted = 0;`\ndb/message_queries.go:732 `return fmt.Errorf(\"SetMessagePinned: message %d: %w\", id, ErrNotFound)` — that is `db.ErrNotFound` (db/errors.go:11), a distinct sentinel from `service.ErrNotFound` (service/message.go:24).\napi/channel_handler.go:407-426 (`writeServiceError`) has no `db.ErrNotFound` arm, so this lands in `default:` → 500.", - "suggestedFix": "In service.SetMessagePinned, wrap the store call: `if err := s.st.SetMessagePinned(ctx, msgID, pinned); err != nil { if errors.Is(err, db.ErrNotFound) { return fmt.Errorf(\"%w: message not found in this channel\", ErrNotFound) }; return fmt.Errorf(\"%w: %v\", ErrInternal, err) }`. This maps the deleted case to 404, keeps genuine failures as 500, and — unlike only adding `|| msg.Deleted` to the line-224 guard — also covers a delete racing between GetMessage and the UPDATE.", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "hotspot-server-service", - "finder": "opus", - "round": 6, - "confidence": "high", - "fix": { - "commit": "db0275a2", - "test": "Server/service/message_test.go", - "revertProof": "pass", - "branchCommit": "6a32319a", - "pr": 1369 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0083", - "title": "ConnectPage.destroy() never clears uiStore.settingsOpen, so the settings panel pops open over MainPage immediately after login", - "file": "Client/src/pages/ConnectPage.ts", - "line": 286, - "severity": "low", - "why": "MainPage.destroy() explicitly calls `closeSettings()` for exactly this reason (\"the next page to mount an (initially hidden) SettingsOverlay off that flag — ConnectPage, after logout — would show it over the login screen\"). ConnectPage.destroy() only destroys its own lazily-created overlay and leaves `settingsOpen === true` in the store. MainPage eagerly mounts a SettingsOverlay whose `mount()` ends with `if (uiStore.getState().settingsOpen) show()`, so the stale flag opens the full settings panel on top of the freshly loaded app.", - "repro": "On the connect page, type credentials and press Login; while the request is in flight (or during an auto-login), click the settings gear. `openSettings()` sets `settingsOpen = true` and the SettingsOverlay chunk starts loading. Login succeeds -> `wirePostAuth` -> WS `ready` -> `ConnectedOverlay.markReady()` fires `onReady` after READY_DELAY_MS (800 ms, ConnectedOverlay.ts:26/116) with no user interaction -> `router.navigate(\"main\")` -> `renderPage` destroys ConnectPage (settingsOpen still true) -> MainPage mounts and its SettingsOverlay calls `show()`. The user lands in the app with the settings panel covering it, having never asked for it there.", - "evidence": "ConnectPage.ts:286-306 — destroy() { abortController.abort(); unsubSettingsOpen?.(); unsubTransientError?.(); settingsOverlay?.destroy?.(); settingsOverlay = null; setTransientError(null); ... } // no closeSettings()\nMainPage.ts:756-762 — closeSettings(); // with the comment naming the symmetric ConnectPage case\nMainPage.ts:416 + 501 — const settingsOverlay = createSettingsOverlay({...}); settingsOverlay.mount(root);\nSettingsOverlay.ts:392-395 — // Sync initial state\\n if (uiStore.getState().settingsOpen) { show(); }\nConnectPage.ts:78 — onSettingsOpen: () => openSettings() // the gear is never disabled during \"loading\"/\"connecting\" (LoginForm.updateFormInputsDisabled only touches host/username/password/invite)", - "suggestedFix": "In ConnectPage.destroy() (ConnectPage.ts:286), call closeSettings() alongside the existing setTransientError(null), mirroring MainPage.destroy().", - "status": "fixed", - "found": "2026-08-12", - "hunt": "general-2026-08-12", - "lens": "fresh-eyes", - "finder": "opus", - "round": 6, - "confidence": "high", - "fix": { - "commit": "db0275a2", - "test": "Client/tests/unit/connect-page.test.ts", - "revertProof": "pass", - "branchCommit": "152a32f9", - "pr": 1369 - }, - "fixedDate": "2026-08-14" - }, - { - "id": "OC-0084", - "title": "VideoGrid's track-mute handler adds a `track-muted` class that no stylesheet defines, so a stalled remote camera keeps showing a frozen frame", - "file": "Client/src/components/VideoGrid.ts", - "line": 151, - "severity": "low", - "why": "The handler's stated job is to hide the tile's video while the remote track is muted, but the hiding is expressed purely by toggling `track-muted`, and there is no `.track-muted` rule in app.css, base.css, login.css, tokens.css or theme-neon-glow.css. Nothing else in the mute path touches the element's visibility, so the branch is a no-op.", - "repro": "Join a video call with a remote peer, then have that peer's camera track fire `mute` (network stall, or the sender pausing the track). `onTrackMute` runs and adds `track-muted` to the `.video-cell`. Because no CSS matches that class, the `