B3-6 item 8 (roadmap workstream 16) merged the workflow to dev in #1452,
but a schedule only ever runs from the default branch, so it has been
registered and dormant: zero scheduled runs, and no dispatch button. The
file itself already says "this file does nothing until it reaches main"
and checks out ref: dev — dev is the branch the nightly exists to smoke.
Its job name matches no required context, so release gating on main is
unaffected. Verbatim copy of dev's file; owner decision 2026-08-31 to
activate now rather than wait for the next release merge. The item is
operationally closed by the first observed green scheduled run, recorded
in the B3 plan's evidence block.
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4
Co-authored-by: Claude <noreply@anthropic.com>
* ci(deps): bump anthropics/claude-code-action (#1404)
Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).
Updates `anthropics/claude-code-action` from 1.0.193 to 1.0.199
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/9d7150bc8a3dae8149739a88019d192b579ad90c...dcb57747bfceeaa1fa72638cae52295d1d853d4a)
---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
dependency-version: 1.0.199
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: actions-dependencies
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* build(deps): pin rfd to tauri-plugin-dialog's major to unblock the cargo group (#1406)
The cargo-dependencies group PR (#1405) fails Rust Unit Tests on Linux:
error: failed to run custom build command for `rfd v0.17.2`
You need to choose at least one backend: `gtk3` or `xdg-portal`
features for x86_64-linux
rfd is not really ours. It arrives in the tree via tauri-plugin-dialog,
which pins ^0.16; we declare it directly only for the fatal-startup
message box in lib.rs, where the Tauri app never finished building and
the plugin has no AppHandle to run a dialog through.
Cargo unifies features only within a semver-compatible version group, so
while both wanted ^0.16 there was a single rfd in the graph and the
plugin's backend features covered our `default-features = false`
declaration too. Bumping our direct dep to 0.17 forks rfd into two
crates: the plugin keeps 0.16.0 with its features, ours resolves to
0.17.2 with none, and rfd 0.17 added a build.rs assertion that aborts
the Linux build when no backend feature is set. Confirmed in the PR's
lockfile, which carries both 0.16.0 and 0.17.2.
Adding a Linux backend feature would be the wrong fix: it would paper
over the fork and still build rfd twice on every platform for one error
dialog. Our version has to track the plugin's instead, so ignore
semver-minor rfd updates (0.16 -> 0.17 for a 0.x crate) until
tauri-plugin-dialog moves. Patch updates inside 0.16.x still flow.
The remaining five crates in the group are unaffected; `windows` in fact
consolidates 3 versions down to 2.
Cargo.toml is comment-only here - no dependency, feature, or lockfile
change - so the build is untouched.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(deps): bump log (#1407)
Bumps the cargo-dependencies group with 1 update in the /Client/tauri-client/src-tauri directory: [log](https://github.com/rust-lang/log).
Updates `log` from 0.4.33 to 0.4.34
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.33...0.4.34)
---
updated-dependencies:
- dependency-name: log
dependency-version: 0.4.34
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: cargo-dependencies
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* ci(deps): bump anthropics/claude-code-action (#1408)
Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).
Updates `anthropics/claude-code-action` from 1.0.199 to 1.0.200
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/dcb57747bfceeaa1fa72638cae52295d1d853d4a...24dcd50c0568f0fc9e9211213a4fd2d9eb15c4e0)
---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
dependency-version: 1.0.200
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: actions-dependencies
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* release: v1.2.0-alpha.4 — 62 fixes plus the B0/B1 repository foundation (#1426)
* Fix 27 findings from 2026-08-21 bug hunt (#1400)
* chore(findings): record 2026-08-21 bug hunt (38 findings)
* fix(api): 1 defect(s) (OC-0240)
* fix(client): 1 defect(s) (OC-0241)
* fix(plugin): 2 defect(s) (OC-0243, OC-0265)
* fix(client): 3 defect(s) (OC-0244, OC-0256, OC-0259)
* fix(client): 1 defect(s) (OC-0247)
* fix(client): 2 defect(s) (OC-0248, OC-0258)
* fix(identity): 1 defect(s) (OC-0250)
* fix(ws): 3 defect(s) (OC-0252, OC-0269, OC-0272)
* fix(admin): 1 defect(s) (OC-0253)
* fix(client): 1 defect(s) (OC-0254)
* fix(voice): 1 defect(s) (OC-0255)
* fix(ws): 1 defect(s) (OC-0260)
* fix(client): 1 defect(s) (OC-0261)
* fix(client): 1 defect(s) (OC-0262)
* fix(client): 1 defect(s) (OC-0263)
* fix(client): 1 defect(s) (OC-0264)
* fix(client): 1 defect(s) (OC-0268)
* fix(ws): 1 defect(s) (OC-0273)
* fix(service): 1 defect(s) (OC-0275)
* style: satisfy golangci-lint and prettier on 2026-08-21 fix commits
- drop ineffectual backupDir reset before return (registry.go, OC-0265)
- reflow long boolean expression (attachments.ts, OC-0241)
* fix(client): 4 defect(s) (OC-0242, OC-0246, OC-0249, OC-0251)
* fix(voice): 1 defect(s) (OC-0267)
* fix(admin): 1 defect(s) (OC-0274)
* fix(voice): 1 defect(s) (OC-0245)
* fix(ws): 1 defect(s) (OC-0271)
* fix(voice): 2 defect(s) (OC-0239, OC-0257)
* fix(ws): 1 defect(s) (OC-0266)
* fix(voice): 1 defect(s) (OC-0270)
* style: clear golangci-lint modernize and prettier nits from 2026-08-21 fixes
- range-over-int and slices.Contains modernizations in new Go test files
- prettier reflow in dispatcher.ts
* chore(findings): mark 2026-08-21 hunt findings fixed/declined
37 fixed across the fix waves, OC-0238 declined (LiveKit webhook TLS
requires a product decision, not a mechanical patch).
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix: 35 findings from the 2026-08-22 bug hunt (#1402)
* fix(voice): 1 defect(s) (OC-0277)
* fix(voice): 1 defect(s) (OC-0278)
* fix(client): 1 defect(s) (OC-0280)
refreshDmSidebar() rebuilds the entire DM sidebar subtree on every
dmStore.channels change - which includes presence flips and new
messages, not just DM list changes. The "Find a conversation" filter
text and input focus live only in that destroyed subtree, so they were
silently wiped mid-typing. Capture and restore both across the
destroy+recreate cycle.
* fix(ws): 1 defect(s) (OC-0285)
* fix(client): 1 defect(s) (OC-0286)
* fix(client): 1 defect(s) (OC-0288)
Consume the legacy unscoped mute key after migrating it onto the first
host, so a brand-new host with no scoped key of its own no longer reads
through to the same legacy list and inherits another server's mutes.
* fix(voice): 1 defect(s) (OC-0290)
* fix(db): 1 defect(s) (OC-0293)
DecrementMentionCounts reversed mention_count bumps that were never
applied: message_mentions stores every resolved mention id including the
author's blockers, while applyMentionCounts excludes blockers before
incrementing. Deleting a blocked author's message therefore wiped an
unrelated, genuine mention badge on the same read_states row. Mirror the
block exclusion in the decrement UPDATE.
* fix(db): 1 defect(s) (OC-0294)
DeleteAccount soft-deletes the departing user's messages but never reversed the read_states.mention_count bumps those messages made, leaving phantom mention badges. Reverse them inline in the existing transaction, mirroring DecrementMentionCounts' guards.
* fix(client): 1 defect(s) (OC-0295)
MemberList rebuilt every row on any non-presence-only membersStore change
and on every roles_update, but registered each row's click/contextmenu
listeners on the component-lifetime disposable.signal, which only aborts
at destroy(). Discarded rows therefore stayed reachable (and their
listeners live) for the component's whole lifetime. Route per-row
listeners through a per-render AbortController that is aborted and
replaced at the top of every render, and aborted again in destroy().
* fix(identity): 1 defect(s) (OC-0297)
UpdateProfile's post-commit re-read of the user row could fail for reasons
unrelated to context cancellation (SQLITE_BUSY, I/O error, pool exhaustion)
and was reported as ErrInternal even though UpdateUserProfile had already
committed. Callers that treat any UpdateProfile error as proof the write
never landed — handleUploadAvatar deletes the file it just stored — would
delete a file the committed avatar column now points at, permanently
breaking the avatar with no user_update broadcast.
Since UpdateUserProfile only writes username/avatar/display_name/about,
merge those four onto the pre-write snapshot to reconstruct the committed
row without needing the re-read to succeed, and log the read failure.
* fix(ws): 2 defect(s) (OC-0298, OC-0299)
- OC-0298: applyConnectStatus stamped c.user.Status even when the
UpdateUserStatus write failed, so auth_ok and the presence broadcast
claimed a status users.status disagreed with, and buildReady's
ListMembers read never self-corrected for the session.
- OC-0299: refreshUserSnapshot silently fell back to roleName "member"
when the new role lookup failed, pinning the session to a fabricated
role on the wire. It now fails closed like the sibling lookups in
upgradeAndAuth and handleFreshConnect.
* fix(client): 1 defect(s) (OC-0300)
* fix(client): 1 defect(s) (OC-0301)
* fix(ws): 1 defect(s) (OC-0302)
* fix(api): 1 defect(s) (OC-0305)
handleDiagnosticsConnectivity used clientIP(r), ignoring cfg.Server.TrustedProxies, so behind a configured trusted reverse proxy the endpoint reported the proxy hop instead of the real client address. Use clientIPWithProxies with the parsed trusted-proxy nets, matching RateLimitMiddleware on the same route.
* fix(client): 2 defect(s) (OC-0306, OC-0308)
* fix(client): 1 defect(s) (OC-0307)
QuickSwitcher registered a per-row click listener against the
overlay-lifetime AbortSignal, but renderResults() rebuilds every row on
each keystroke, arrow key, and store refresh. Discarded rows kept their
listeners alive until the overlay closed. Replaced with one delegated
click listener on the stable results container, keyed off the
data-channelid each row already carries.
* fix(client): 1 defect(s) (OC-0310)
* fix(server): 3 defect(s) (OC-0279, OC-0291, OC-0292)
Reap a soft-deleted message's attachment files, count lapsed temporary
bans as active users in the require_2fa enrollment gate, and only apply
the 2FA-enrollment precondition when require_2fa itself is being enabled.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
* test(api): sync apiTestSchema with the user_blocks migration
DeleteAccount's mention-count reversal joins user_blocks; the api
package's hand-rolled schema fixture predates migration 012.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
* fix(client): 3 defect(s) (OC-0281, OC-0282, OC-0296)
Decouple the E2EE identity-mismatch modal and right-click popovers from
the sidebar's per-render abort signal, and let global drag listeners
survive a mid-drag re-render.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
* fix(voice): 2 defect(s) (OC-0283, OC-0287)
Retire a departed peer's E2EE key unconditionally on leave, and surface
a failed microphone unmute instead of reporting an unmuted state the
room never saw.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
* fix(client): 3 defect(s) (OC-0289, OC-0303, OC-0309)
Guard the DM call button against redialing the channel already joined,
resolve the incoming-call banner's caller through the nickname-aware
display name, and keep the DM profile sidebar subscribed to live
member/status updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
* style(client): prettier-format the dm-store test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
* fix(server): 1 defect(s) (OC-0284)
Make message soft-delete a compare-and-set so a repeated chat_delete
cannot reverse mention counts twice.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
* fix(server): 2 defect(s) (OC-0276, OC-0304)
Re-sync a resumed connection's voice E2EE peer keys in registerNow
(announce frames are unsequenced and cannot be replayed), and apply the
live-connection presence rule to every DM payload DMService builds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
* chore(ledger): record the 2026-08-21 hunt findings as fixed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
* chore(ledger): independent revert-proof pass for OC-0276..OC-0310
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
* refactor(service): extract DeleteMessage authorization into a helper
Keeps DeleteMessage under the cyclop complexity ceiling after the
OC-0284 guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(ledger): record the 38 open findings from the 2026-08-22 hunt (#1403)
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
Co-authored-by: Claude <noreply@anthropic.com>
* chore(graphify): refresh knowledge graph
* fix: close the three B0 P0 gates and record a measured baseline (#1409)
* chore(security): stop tracking the private security-finding reports
docs/security-findings/ holds detailed reports for defects that are not yet
fixed. The directory was untracked but not ignored, so any 'git add .' would
have published seven unfixed vulnerability traces to a public repository.
Findings are coordinated through private GitHub Security Advisories
(docs/security.md); only opaque identifiers and safe status belong in tracked
plans.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): repair the two red P0 unit contracts (G-01, G-02)
G-02: noise-suppression-restart stubbed MediaStream with
vi.fn().mockImplementation(arrow), which is not constructible. Vitest 4 threw
'is not a constructor' at the new MediaStream([inputTrack]) call in
noise-suppression.ts before reaching any assertion. Replaced with a real
class; the OC-0277 assertions are unchanged.
G-01: message-list's OC-0217 guard was inverted, not merely stale. It spied on
AbortSignal.prototype.addEventListener and asserted zero abort registrations,
but the leak it names registered row listeners via
element.addEventListener(..., { signal }) — a path that never calls that
prototype method. Measured: the leak produces 0 registrations (test passes),
while the OC-0286 fix rotates a per-window AbortSignal.any and produces 5
across 5 distinct signals (test fails). The guard passed on the bug and failed
on the fix.
It now captures the signal each window's row listeners register against and
asserts the invariant its name always claimed: one signal per rendered window,
a fresh signal per jump, and every superseded window already aborted with
exactly one live. Verified both directions — green on the fix, and
'expected 1 to be 5' with beginRowRender() reverted to rowSignal = ac.signal.
Client suite: 5257 passed, 0 failed (was 5255 passed, 2 failed).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): make the Playwright suite terminate
The runner finished every test and then never exited, printing no summary — so
the failure read as 'tests never finish' when it was 'process never exits'.
getActiveResourcesInfo() at hang time showed a live ProcessWrap plus several
PipeWrap: the Vite dev server was still running. Playwright's webServer
teardown does not kill it here.
Measured, full suite each time:
npm run dev hangs, tests pass
node node_modules/vite/bin/vite.js hangs, tests pass
reuseExistingServer: false hangs, tests pass
gracefulShutdown SIGTERM/3s hangs, tests pass
npx vite exits, 290 of 293 FAIL
no webServer (pre-started) exits, 293 pass in 33s
npx only appears to fix it: npx exits once Vite is up, Playwright reads that as
the server dying and tears the group down mid-run, so later tests get
ERR_CONNECTION_REFUSED.
globalTeardown now kills the process listening on the dev port, releasing the
runner's handle. The webServer command spawns Vite's entry point directly so
the listening process is Playwright's own child — via 'npm run dev' the npm
process would still hold the handle open. It also reaps servers orphaned by an
interrupted run, which reuseExistingServer would otherwise silently adopt.
An earlier revision used netstat, which is not on PATH in every shell here; the
swallowed ENOENT made the fix look applied while the hang persisted. It now
uses PowerShell on Windows and lsof elsewhere, and warns on failure rather than
failing silently.
npm run test:e2e: exit 0, 293 passed, 37s, reproducible, no orphan listener.
playwright.config.prod.ts carried the same npm-wrapper shape.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(client): align .nvmrc with the Node version CI uses
Three versions were in play, not two: .nvmrc said 20, CI pins 24, and the
machine the audit was measured on runs 26. A baseline measured against .nvmrc
is not the baseline CI produces, which defeats the point of B0.
Scoped to .nvmrc only. The full single-source-of-truth work — package engines,
contributor docs, release — stays in B1 (RL-17 / C-01).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(plans): add the beta audit set and the B0 baseline
The 2026-08-23 audit set has been sitting untracked: repository-health and
repository-layout audits, beta product requirements, requirement traceability,
the issue register, and the B0-B10 roadmap. They are the plan of record for
beta and belong in the repository.
Adds b0-baseline-2026-08-25.md, which supersedes the roadmap's 'current
evidence snapshot'. Every row is marked measured or carried, so nothing is
inherited silently. It also records three audit claims that did not survive
verification:
- G-01 was an inverted guard, not a stale assertion — it passed on the bug
and failed on the fix.
- The Playwright hang matched none of the three hypotheses; the runner could
not kill its own dev server.
- The golangci-lint toolchain failure is refuted: 19 linters run, 0 issues,
verified with -v to rule out the known zero-linters false-green.
Adds b0-dev-branch-protection.sh, which records the applied dev branch
protection and the reasoning behind each setting.
Security detail stays private: the register carries only opaque SEC-* families
and safe closure criteria, per the roadmap's public/private handling policy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(graphify): refresh the knowledge graph
Own commit, per CLAUDE.md — the graph payload does not belong in the diff of
the changes that triggered it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(plans): add the active-plan index and fix a stale status header (G-04)
Planning documents had no recorded state, so a reader could not tell current
guidance from shipped history. docs/plans/README.md now indexes every plan as
active, partially implemented, design-only, or shipped, and names the source of
truth for each concern so a defect count is never read out of a plan.
Status is recorded in the index rather than by moving or rewriting the
historical plans, so links from audits and commit messages keep resolving.
One real stale claim found and fixed: audit-2026-08-19-remediation.md still
read 'in progress 2026-08-19' while its own phase table showed phases 1-6 done
2026-08-20 (merged 03fcb7d5, PR #1396) with only phase 7 pending. The header
had drifted because the table was updated in place and the header was not.
No plan was found claiming '0 open findings'.
Also records the Step 8 staleness pass in the B0 baseline: all 38 open OC
records still resolve to a live file:line at this commit, so none is superseded
by later work. Adjudicating them individually is bughunt-fix work, not B0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Update graph output files and manifest with new metadata
- Updated graph.html and graph.json with new binary data.
- Modified manifest.json to reflect changes in file modification times and AST hashes for several documents.
- Added new entry for README.md in the manifest with its corresponding metadata.
* docs(plans): close the Docker and coverage leftovers in the B0 baseline
Docker smoke: measured and passing. Image builds at 50.1 MB and boots on :8443
with TLS; docker-smoke.sh exits 0.
Server coverage: re-measured at 74.6% aggregate, confirming the figure carried
from the audit rather than continuing to inherit it.
Two findings from doing it:
ENV-03 — docker-smoke.sh cannot be run from Git Bash on Windows. MSYS path
conversion rewrites the container-internal /chatserver into
'C:/Program Files/Git/chatserver', so docker exec fails 127 and the script
reports 'container never reported healthy within 30s' — indistinguishable from
a real boot regression. MSYS_NO_PATHCONV=1 makes the same script pass. CI is
Linux and unaffected, but Windows is an official contributor platform (RL-20).
The CI Docker job is gated on main, so it is skipped for any PR targeting dev
— a dev-targeted change cannot get Docker evidence from CI at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(graphify): refresh the knowledge graph
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(plans): B1 execution plan, and accept HP-0 (#1410)
* docs(plans): add the B1 repository-foundation execution plan
B1 is the isolated layout/contributor phase. This records the execution
order, the proof for each step, and what is out of scope.
Two findings worth surfacing before any B1 work starts:
- HP-0 was never formally accepted. The roadmap's B1 entry gate requires
it; no scorecard artifact exists, no commit or document records an
acceptance, and the B0 baseline still lists "Step 10: HP-0 sign-off"
under "Not yet done in B0". The plan lists the five gaps that closing
it requires, including pinning required status checks on dev -- which
are still unset, so a dev PR can currently merge red.
- Several layout-audit claims do not survive verification against HEAD,
matching the B0 pattern. RL-09's "no single command verifies both
protocol consumers" is false (make protocol-verify does, and is
enforced in CI, the pre-commit hook, and a contract test). RL-10's
test-discovery side effect never fires (no _test.go in Server/scripts).
RL-06's regeneration concern is refuted locally. RL-08 grows a
toolchain constraint instead. RL-05, RL-07, RL-20 and RL-21 are each
worse than written -- RL-20 includes a live bug where a missing `make`
is reported as stale protocol constants.
The riskiest item, RL-01 (flatten Client/tauri-client into Client), gets
a full reference inventory and a mechanical proof for both commits: tree-
object equality for the pure move, and scripted-substitution replay for
the path rewrite. Release asset names and updater contracts are verified
independent of the directory name, so the move cannot rename an artifact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(plans): correct the B1 status-check pin list from a live dev PR
The list was derived from ci.yml. Observing PR #1410's actual checks
found three that exist in no workflow file -- Analyze (go),
Analyze (javascript-typescript), Analyze (actions) -- because CodeQL
runs from GitHub default setup, configured in repository settings.
Reading .github/ alone misses them.
Also confirms the two negative predictions against a real dev-targeted
PR: Server Docker Build (verify) reports as "skipping", and Tauri Full
Build never appears in the check list at all. Neither may be pinned.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(plans): accept HP-0 and pin the dev required status checks
Closes B1's entry gate. All five B1-0 items are done.
The scorecard is the artifact the hold point asks for: one place that
answers its four questions, records what was accepted as a stated
limitation rather than claimed green, and part-closes R-08.
Required status checks are now pinned on dev -- ten of them. That was
B0's one outstanding step. Two things came out of doing it:
- The names cannot be inferred from ci.yml. Three of the ten (the
Analyze jobs) exist in no workflow file, because CodeQL runs from
GitHub default setup configured in repository settings. They were read
off a live dev-targeted PR with `gh pr checks`.
- Server Docker Build, Tauri Full Build and the CodeQL aggregate are
deliberately excluded. The first two report "skipping" on a dev PR --
Tauri Full Build under its unexpanded matrix name, since the job is
skipped before matrix expansion. Admin Panel E2E is excluded because
continue-on-error makes it report success unconditionally.
Two prior claims are corrected rather than left to propagate:
- b0-dev-branch-protection.sh was written assuming repository-settings
writes are blocked from the agent sandbox. They are not; the PUT
succeeded. The script stays as the record of intent and the way to
re-apply or undo.
- An earlier revision of the B1 plan said Tauri Full Build does not
appear in a dev PR's check list at all. It does, as skipping.
Evidence closed out:
- Rust is no longer a carried row. Re-measured: 115 passed, cargo clippy
--all-targets -- -D warnings at exit 0, confirming the carried figure.
- The 38 open ledger records are accepted as counted, non-stale and
assigned: 11 medium / 27 low, zero high or critical, zero dead paths
across all 348 re-verified at this commit, and none assigned to B1.
- The private security review is reconciled: 7 findings, 7 of 7 mapped
to existing public rows, 0 unmapped. Summary is content-free; the
detail stays in the untracked private reports.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor: flatten Client/tauri-client into Client (B1-1) (#1411)
* refactor: move Client/tauri-client to Client (pure move, no content change)
* refactor: re-point paths after the Client flatten (mechanical, no behaviour change)
---------
Co-authored-by: Claude <noreply@anthropic.com>
* B1-2: truth, entry points, and contributor path (#1412)
* fix(hooks): guard on the command the hook actually runs
pre-commit probed one binary and invoked another. The protocol block
guarded on `command -v go` and then ran `make protocol-verify`; the sqlc
block guarded on `command -v sqlc` and ran `make sqlc-verify`. `make` is
not on PATH on a stock Windows box, so a contributor with Go installed
but no make had their commit rejected with
pre-commit: FAIL: protocol constants are stale — run 'make
protocol-generate' in Server/ and stage the result
when nothing had been generated and nothing compared. The real cause was
`make: not found`, and the advice the message gives fails the same way.
Rather than add a `command -v make` guard, inline what the two Makefile
targets reduce to — `sqlc generate` / `go run ./scripts/genprotocol`
followed by `git diff --exit-code`. Same semantics, one less prerequisite,
and it doubles as the make-free equivalent B1-2 asks for. The Makefile
targets stay for anyone who prefers them.
Also: the protocol block was the only one with no `else`, so a
contributor without Go got no check and no notice. It now warns like its
two siblings. And gofmt is a separate binary from go, so it is probed
separately.
Verified both directions with the hook body replayed verbatim:
- Go present, make absent -> passes, no staleness claimed.
- schema edited without regenerating -> fails, as it must.
Refs RL-20 / L-14.
* docs: state one branch and PR model
Active documents contradicted each other head-on. README.md and
docs/contributing.md said branch from `dev` and target `dev`; CLAUDE.md
said branch from `main`, PR to `main`. That is R-02, and the 2026-08-19
audit had already recorded it as D-06 without it being resolved.
`dev` is the answer, and the repository already behaves that way: B0 made
`dev` PR-only with ten required checks enforced on admins, and #1409,
#1410 and #1411 all landed there. `main` carries releases.
docs/contributing.md becomes the single source of truth. It now states the
model, what protection is actually applied, and the two consequences a
contributor meets on their first PR — that a self-mergeable PR still cannot
merge red, and that Docker and Tauri Full Build report as skipped against
`dev` rather than failing. Everywhere else summarises and links here.
- CLAUDE.md: corrected, with a link rather than a second copy.
- CONTRIBUTING.md: new. GitHub's contributing-guidelines affordance only
resolves the root, .github/ or docs/ — `docs/contributing.md` is not a
path it finds, so the link never appeared on issues or PRs.
- PULL_REQUEST_TEMPLATE.md: names the base branch, which it did not.
- bughunt-run skill: reviewed the branch against `origin/main`, which is
the wrong base once every PR targets `dev`.
README.md already said `dev` and is left as the short summary it should be.
Dated audits and the historical remediation plan keep their `main`-era
wording — they are records.
Refs R-02.
* fix(hooks): pick the pre-push base from the nearest integration branch
pre-push decided which side's gates to run from
`git diff --name-only origin/main...HEAD`. That was right when everything
targeted `main`. Once `dev` became the integration branch it stopped being
right: a branch cut from `dev` diffed against `main` counts everything on
`dev` and not yet on `main` as "changed".
Measured on this branch: the old base reported 609 changed files, the new
one reports 6. So in practice the hook was running the full server build
matrix and the client typecheck plus eslint on every push, whatever the
change touched — the file-based narrowing it exists for never engaged.
Now it picks whichever of origin/dev, origin/main is nearest, by commits
between merge-base and HEAD, skipping a candidate that scores 0. Verified:
a branch cut from dev picks origin/dev (2 ahead); dev itself scores 0
against dev and picks origin/main (8 ahead), which is what a dev -> main
release PR wants. With no candidate resolvable it falls back to the
existing `__all__`, so an unfetched or shallow clone still runs everything.
Refs RL-20 / L-14, R-02.
* chore(node): one Node source of truth
`.nvmrc` and all ten `actions/setup-node` pins said 24; five active
documents and the repo's only `engines` block still said 20. A contributor
following the docs installed a version CI does not run.
Node 24 wins — it is what CI already runs. Every manifest now declares
`engines`, and `engine-strict=true` turns a wrong major into a failed
install rather than an `EBADENGINE` warning nobody reads. `>=24` rather
than `^24` so a Node 26 box keeps working; `Client/.nvmrc` stays the
human-facing pin and the docs point at it instead of restating a number.
The `.npmrc` is per package root, not one at the top. npm reads the
project `.npmrc` from the package directory and does not walk parents —
verified with a throwaway package requiring node >=99: with only a parent
`.npmrc` npm warned and exited 0; with one in the package directory it
failed `notsup`. A single root file would have left `Client/`, the package
that matters most, on warnings.
Five docs, not the four previously identified — `docs/mcp-introspect.md`
also said 20. And `docs/contributing.md` claimed "`.nvmrc` + CI both say
Node 20", which was wrong about both.
Verified both directions in all three package roots: Node 22 fails
`notsup`; Node 24 installs clean and `npm ci` passes in Client/.
Refs RL-17 / C-01, ENV-01.
* docs: add the documentation landing page
`docs/` had 24 top-level files and no index. The root README carried a
flat list of 22 links that had drifted: six documents were reachable from
nowhere at all — including both 2026-08-23 audits and the test audit — and
two entries were labelled "latest" while newer unlinked audits existed.
docs/README.md is the index RL-12 asked for. It groups by what a document
*is*, because that is what decides whether to trust it: guidance tells you
how to do something, reference describes a contract the code implements,
audits are dated snapshots nobody updates, plans record intent. Every
tracked file under docs/ now appears exactly once, and the audit table says
plainly that audit-2026-08-19.md still claims "0 open findings" when the
ledger has 38.
The root README keeps a short curated list and defers to the index, rather
than maintaining a second copy that drifts again. Two fixes while there:
`docs/plans/` was linked as a bare directory, unlike its two sibling
directory entries, and was annotated "each carries a verified status
header" — which docs/plans/README.md:7-9 explicitly contradicts, since a
plan's header is exactly the thing that drifts and the index is the
authority.
Verified: 78 relative links across the new and edited files resolve, and
no tracked docs/ file is unreachable from the index.
Refs RL-12 / R-06.
* feat(scripts): root command facade
Entry points existed only inside Server/ (a Makefile) and Client/ (npm
scripts). Nothing at the root told a new contributor where to start, and
the root package.json had three scripts, none of which built or tested
anything.
`npm run check` from the root now runs what CI gates on, and
check:server / check:client / check:rust run one stack. scripts/run.mjs
is dependency-free Node — the shape render-ledger.mjs already uses — so
`npm run check` works before `npm install` has.
Cross-platform by construction: every step is spawned with an explicit cwd
and no shell, so there is nothing to quote and no `cd &&` to behave
differently on Windows. npm and npx get their .cmd suffix there. No step
shells out to make.
The facade orchestrates; it is not a new required path. Each step prints
the command and the directory before running it, and those are exactly the
commands documented per-stack — so a server contributor can read the output
and type them instead, and still never needs Node. Tools CI installs but a
contributor may not have (golangci-lint, which has no wrapper in this repo
at all; sqlc, pinned by Server/sqlc.version) are skipped with a printed
reason rather than failing.
Three corrections to the ci-check skill while aligning it:
- `make sqlc-verify protocol-verify` replaced by what those targets reduce
to, so the documented path does not require make either.
- `cargo test` -> `cargo test --lib`, which is what ci.yml actually runs.
- "NODE_OPTIONS=--no-experimental-webstorage is mandatory on Node 22+" was
false. tests/setup.ts installs the shim, CI runs Node 24 without the
flag, and the suite was measured passing without it — 192 files / 5257
tests, identical to the flagged run.
Also documents the third RL-20 problem, which needed no code: core.hooksPath
is exclusive, so `npm run hooks:install` silently disables any
.git/hooks/post-commit — including the one `graphify hook install` writes,
which CLAUDE.md tells agents to install. Nothing warned about that.
Verified: check:client 5257/192 green, check:rust 123 tests + clippy green,
--list prints every command, and the optional-tool skip path reports rather
than fails.
Refs RL-04 / L-04, RL-20 / L-14.
* feat(ci): fail on a document that contradicts the findings ledger
G-04's remaining half. The ledger is the source of truth for defect counts,
but nothing stopped a planning document from stating a different number and
nothing noticed when one did. `render-ledger.mjs --check` cannot help: it
validates the JSON schema and returns before rendering, so it never reads
FINDINGS.md and cannot see drift at all — and no workflow ran it anyway.
scripts/check-doc-counts.mjs counts ledger statuses and compares them to
what an allow-list of active documents claims, failing with file, line,
claimed value and actual. Wired into ci.yml as a job with no npm ci, since
the script imports nothing outside node:, and into the facade as
`npm run check:docs` — first in `check`, so a contradicted count does not
wait behind ten minutes of -race.
The patterns are narrow on purpose. A first attempt matched any
"<number> <status>" and flagged nineteen things, all false: "the 45 open P1
rows" (issue-register rows, not ledger findings), "All 8 findings F1-F8"
(a different register), "G-05 **refuted**" (an identifier), `">=20"` and
`CGO_ENABLED=0` (not counts at all). A check that cries wolf gets ignored,
which is the failure G-04 already describes. So a number is only read as a
claim in three shapes that cannot mean anything else: an enumeration of two
or more "<n> <status>" pairs, a status table row in a table that totals
itself, and "<n> records/findings" where the ledger is named within three
lines. Fifteen selftest assertions pin both directions, and the job runs
them before it runs the check.
It reads findings-ledger.json directly rather than importing
render-ledger.mjs for `validate`/`render`: that module ends in a bare
top-level `await main()` with no import.meta.main guard, so importing it
rewrites FINDINGS.md as a side effect.
Dated docs/audit-*.md are reported, never failed — they are snapshots
nobody maintains. audit-2026-08-19.md does claim zero open findings against
38 open, so b0-baseline's "No plan was found claiming '0 open findings'"
holds for docs/plans/ but not for docs/.
Not included: a real FINDINGS.md render-drift check. That is RL-07 and
belongs with the generated-artifact work, not here.
Verified: 27 claims across 9 documents agree; corrupting one count in
docs/plans/README.md fails the check naming that line, for both the status
and the total.
Refs G-04.
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore: remove graphify knowledge graph tooling (#1413)
The committed knowledge graph and its PreToolUse hooks were steering every
codebase question through `graphify query` before any other tool could run.
Serena (gopls + rust-analyzer + tsserver over MCP) answers the same questions
from real language servers rather than a generated snapshot that goes stale
between rebuilds, so the graph no longer earns the ~20 MB it costs the tree.
Removed:
- `graphify-out/` untracked (7 files, ~20 MB) and now gitignored
- both `graphify hook-guard` PreToolUse hooks from `.claude/settings.json`
- the "Knowledge graph (graphify)" section of `CLAUDE.md`
- the `graphify-out/**` block from `.gitattributes` and `.gitignore`
- the graph-rebuild step from the `bughunt-run` skill, and the graph-edge
guidance from the bughunt workflow prompt
- the graphify-specific `core.hooksPath` example in `ci-check` and
`docs/contributing.md`, keeping the underlying warning in generic form
Also deletes the locally installed `post-commit` / `post-checkout` rebuild
hooks (untracked, not part of this diff).
This does not shrink clone size: the graph blobs stay in published history,
which `docs/plans/b1-repository-foundation-2026-08-25.md` explicitly rules out
rewriting. It does stop future refreshes from adding more.
Dated audit and plan documents keep their graphify references as a historical
record of the state they described.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* B1-3: repository hygiene gates (RL-19 / L-13, S-05) (#1414)
* chore(format): one Prettier config at the repository root
Every formatting rule in this repository lived under Client/ and covered
exactly two globs: Client/src/**/*.ts and Client/tests/**/*.ts. Root Markdown,
all of docs/, every YAML and JSON, all CSS, the root scripts and
tools/mcp-introspect were formatted by nothing. There was no .editorconfig.
The obvious fix -- a second Prettier config at the root for "everything else"
-- gives two configs and two ignore files that can silently disagree about the
same file. So the root takes ownership instead: config, ignore file and gate
move up, and Client/ folds in. Client's inline "prettier" block, its
.prettierignore, its format/format:check scripts and its now-unused prettier
devDependency are all deleted; knip would have failed client-check on that last
one.
The .prettierrc.json values are lifted byte-for-byte from Client/package.json,
which is what keeps the reformat commit free of client TypeScript churn: 87
tracked files need reformatting and not one of them is under Client/src or
Client/tests.
.prettierignore carries only what .gitignore does not. Prettier 3 reads the
root .gitignore by default, so node_modules/, dist/, coverage/,
Client/src/generated/ and docs/security-findings/ need no entry. It does NOT
read nested .gitignore files, which is why .remember/ is listed explicitly --
38 untracked per-machine scratch files were otherwise able to turn a shared
gate red. graphify-out/ is listed because its seven files are tracked and
.graphify_labels.json is signed byte-for-byte by its .sig, so formatting it
would silently invalidate the signature.
check:hygiene is registered in scripts/run.mjs and folded into check and
release:preflight. It deliberately contains no `gofmt -l` step: gofmt -l prints
offenders and still exits 0, so it cannot fail a build. Go formatting is
enforced separately.
shellcheck and actionlint take their file lists from `git ls-files`, never a
filesystem glob -- .claude/worktrees/ holds a gitignored pre-flatten copy of
the tree with three .sh files a glob would happily lint.
This commit leaves the tree non-conformant on purpose. The reformat is the next
commit, so the 87-file diff is reviewable separately from the rule that caused
it.
Not included: editorconfig-checker. .editorconfig is the editor baseline the
audit asked for; Prettier, gofmt and rustfmt already fail CI on the same
indentation and newline rules, so a fourth tool checking them again is a gate
with no failure mode of its own.
Verified: `npx prettier --check .` names 87 tracked files and zero untracked
ones; the same command listed 38 .remember/ scratch files before the ignore
entry and none after. `node scripts/run.mjs --list` resolves check:hygiene to 8
shell targets and 4 workflow targets. Both package.json files parse.
Refs RL-19 / L-13, S-05.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(format): reformat the tree to the repository Prettier rules
Mechanical. This commit is `npx prettier --write .` and nothing else -- the
rule that caused it landed in the previous commit so this diff can be reviewed
as a transformation rather than as 84 files of hunks.
84 tracked files: 54 Markdown, 7 .mjs, 7 JSON, 6 YAML, 4 .js, 3 CSS, 2
TypeScript (the two Playwright configs at Client's root, which the old
Client/src + Client/tests globs never covered). No file under Client/src or
Client/tests moves, because .prettierrc.json carries Client's former inline
values byte-for-byte.
Prettier rewrote 87 files, not 84. The three in .github/ISSUE_TEMPLATE/ had
CRLF on disk and differ only in line endings, which .gitattributes
(`* text=auto eol=lf`) already normalises, so their committed blobs are
unchanged. Worth knowing before someone reconciles the two numbers.
The largest single diff is .superpowers/findings-ledger.json at 7976 lines
rewritten. That is safe to format: nothing writes the ledger programmatically
-- render-ledger.mjs reads it and writes only FINDINGS.md -- so no tool will
fight Prettier over its style on the next hunt. FINDINGS.md itself is ignored
as generated.
Verified: `npx prettier --check .` reports "All matched files use Prettier code
style", so the pass is both complete and idempotent. All 7 reformatted JSON
files were parsed before and after and compared as values: semantically
identical, zero content changes. `node .superpowers/render-ledger.mjs --check`
still reports 348 valid findings and leaves FINDINGS.md untouched.
`node scripts/check-doc-counts.mjs` still passes its selftest and still agrees
on 27 claims across 9 watched documents -- table realignment did not break the
patterns it matches on. `node scripts/run.mjs --list` still parses.
Refs RL-19 / L-13, S-05.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(lint): enforce Go formatting in the Server linter
S-05: repository-wide Go formatting was not a required gate. The only gofmt
enforcement anywhere was .githooks/pre-commit, which is opt-in per clone
(`npm run hooks:install`), only sees staged files, and warns-and-skips when
gofmt is off PATH.
The obvious fix -- a `gofmt -l` step in CI -- does not work: `gofmt -l` prints
its offenders and still exits 0, so the step passes no matter what it finds.
scripts/run.mjs has the same problem, which is why check:hygiene has no Go step
either.
So gofmt goes where it can actually fail something: Server/.golangci.yml. The
file was already `version: "2"` but had no `formatters:` block at all, so the
19 enabled linters ran with zero formatters. In v2 gofmt/gofumpt/goimports
moved out of `linters.enable` into their own section with its own exclusions.
Adding it there means the gate reports through the Lint step of "Server Build &
Test", which is already pinned as required on dev -- no new job and no new pin.
Every tracked .go file is under Server/ (551 of them, one go.mod), so
Server-scoped is repository-wide here.
One file was genuinely misformatted: a one-space struct field alignment in
Server/admin/handlers_users_broadcast_test.go, fixed in the same commit because
a single line does not need its own reformat commit.
Trap worth recording: `gofmt -l .` on a Windows working tree lists every file
that has CRLF on disk, because gofmt normalises line endings. That reported 18
offenders here, 17 of them ghosts. The blobs are all LF -- .gitattributes
forces `eol=lf` -- so CI never saw them, and the honest test is to run gofmt
over `git show HEAD:<file>` rather than the working copy. Doing that across all
551 tracked Go files found exactly the one real offender above.
Verified both directions with golangci-lint v2 locally: `golangci-lint run
./...` reports 0 issues on the formatted tree; appending a misformatted
function to Server/auth/constants.go produces 2 gofmt findings; appending the
same misformatted function to Server/db/dbgen/admin.sql.go produces 0, so the
exclusion holds. Both files restored and verified clean afterwards.
Refs RL-19 / L-13, S-05.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(scripts): escape the NUL separator instead of embedding one
The `tracked()` helper added earlier in this branch splits `git ls-files -z`
output on NUL. The separator was written as a literal NUL byte rather than the
two-character JavaScript escape, so scripts/run.mjs became a binary file: `git
diff` refused to show it, `grep` reported "Binary file matches" instead of the
line, and `* text=auto` in .gitattributes stops normalising line endings for a
blob it detects as binary.
The code worked -- splitting on a raw NUL and splitting on "\0" are the same
operation -- which is exactly why this is worth fixing before it is inherited.
A source file that tooling classifies as binary is a file nobody can review.
Verified: zero NUL bytes remain, `grep -n "split("` now prints line 50 instead
of "Binary file matches", `node scripts/run.mjs --list` still resolves the same
8 shell and 4 workflow targets, and prettier still reports the file clean.
* chore(lint): enforce Rust formatting
Rust had no formatting gate of any kind: no rustfmt.toml, no `cargo fmt`
anywhere in CI, in scripts/run.mjs, in the Makefile or in the git hooks. Clippy
was the only Rust gate, and clippy does not check layout.
`cargo fmt --all -- --check` now runs in the rust-tests job, 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. The stable toolchain in that job
requested `components: clippy` only, so rustfmt is added there.
Only that job. ci.yml has a second, byte-identical `Install Rust` block in
tauri-build; it stays clippy-only, because a full desktop build is the wrong
place to discover a misplaced brace.
No rustfmt.toml. The default profile is the point of a baseline -- a config
file here would be a second opinion about style with nothing to say.
Client/src-tauri is a single `[package]`, not a workspace, so `--all` is a
safeguard against a future member rather than a fan-out today.
Verified: `node scripts/run.mjs --list` resolves check:rust to three steps with
`cargo fmt --all -- --check` first, `npm run format` now also runs `cargo fmt
--all`, and prettier reports ci.yml, run.mjs and the ci-check skill clean.
`cargo fmt --all -- --check` currently fails on 13 files -- that is the
reformat, and it is the next commit.
Refs RL-19 / L-13.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(format): reformat the Rust crate to rustfmt defaults
Mechanical. This commit is `cargo fmt --all` and nothing else; the gate that
demands it landed in the previous commit so this diff is reviewable on its own.
13 of the 17 tracked .rs files, +343/-164. The crate had never been formatted,
so the changes are the usual first-run set: aligned trailing comments collapsed
to single spaces, single-element slice literals folded onto one line, long
method chains broken across lines, closure bodies expanded into blocks.
Verified: `cargo fmt --all -- --check` is clean, so the pass is complete and
idempotent. `cargo clippy --all-targets -- -D warnings` finishes with no
warnings, and `cargo test --lib` reports 115 passed / 0 failed -- identical to
before the reformat, which is what "mechanical" has to mean for a commit that
touches this much of the crate.
Refs RL-19 / L-13.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(scripts): make the root facade actually run on Windows
Adding the first gate that a contributor would run from the repository root
exposed two bugs in the facade, both of which made it silently wrong on the
platform this project is developed on.
1. Every npm and npx step failed. bin() appends `.cmd` on Windows, but Node
refuses to spawn a .cmd or .bat with shell:false -- the CVE-2024-27980
mitigation -- and fails with EINVAL and a *null* exit status. run.mjs only
special-cased ENOENT, so the result was `FAILED: npx prettier --check .
exited null` with nothing to explain it. check:client has three npm steps and
has never been able to run here.
Fixed by spawning only the npm shims through a shell. They are concatenated
into a single command string rather than passed as an args array, because
shell:true plus a separate array is deprecated (DEP0190) and prints a warning
on every invocation; no argument in this file contains a space.
2. Every optional() step was skipped, always. onPath() shelled out to
`where` on Windows, but where.exe lives in C:\WINDOWS\System32, which a Git
Bash PATH does not necessarily contain -- on this machine PATH carries
System32\Wbem, System32\WindowsPowerShell\v1.0 and System32\OpenSSH but not
System32 itself. The probe could not start, `probe.status === 0` was false,
and golangci-lint and sqlc reported as "not installed" while installed.
Fixed by resolving against PATH and PATHEXT directly. No subprocess, and no
dependency on which directories happen to be on PATH.
A spawn error other than ENOENT now reports its code instead of surfacing as a
null exit status.
Verified: before, `node scripts/run.mjs check:hygiene` died with "exited null"
and both optional steps printed SKIP with the tools present on PATH. After, the
same command runs prettier, shellcheck and actionlint and prints
"check:hygiene: passed", with no deprecation warning. `golangci-lint` is
detected by the new onPath where the old one missed it.
Refs RL-20 / L-14.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(format): ignore build output that nested gitignores hide
Prettier honours the root .gitignore and no other. Every build and scratch
directory in this repository is ignored by a *nested* one -- Client/.gitignore,
.serena/.gitignore, .superpowers/sdd/.gitignore -- so none of them were
excluded from the new repository-wide gate.
The effect is not subtle. Running `cargo test` once drops roughly 850
formattable files into Client/src-tauri/target/, and the hygiene gate goes from
clean to "Code style issues found in 939 files". CI never sees it, because a
fresh checkout has no build output; every contributor sees it on their second
command.
Mirrors the three nested files rather than inventing a list: dist, coverage,
playwright-report, test-results, .vite, src-tauri/target and src-tauri/gen from
Client/.gitignore, plus .serena/ and .superpowers/sdd/. node_modules needs no
entry -- Prettier ignores it by default.
Verified: `npx prettier --check .` reports "All matched files use Prettier code
style" with a fully populated Client/src-tauri/target/ present on disk, and
still names README.md when a misformatted table is appended to it.
Refs RL-19 / L-13.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(ci): shellcheck, actionlint, and a repository hygiene job
The last two gates RL-19 asks for. Neither existed: the shell scripts were
never linted, the workflows were never syntax-checked, and .githooks/pre-commit
carried hand-written `# shellcheck disable=` directives that nothing had ever
read.
New `hygiene` job, ubuntu-only and root-scoped, modelled on docs-consistency
for the same reason: every gate in it is platform-independent text analysis,
and .gitattributes pins eol=lf so a second OS would only re-prove line endings.
It runs `npm run check:hygiene` -- the same entry point a contributor runs, not
a parallel copy of the commands.
shellcheck ships in the runner image. actionlint does not, so it is pinned by
version and verified by sha256: an installer script piped from a branch would
be the one unverified download in a workflow file that pins every action by
commit SHA.
Prettier's step moves here from client-check, where it no longer belongs.
Both linters found real defects.
shellcheck, 3 findings in 8 scripts. Two are SC1125 errors in
.githooks/pre-commit: `# shellcheck disable=SC2086 - repo paths contain no
spaces` is not a valid directive. Trailing prose makes shellcheck discard the
rest of the line, so neither suppression was ever in effect -- and one of the
two was written earlier in this same branch, which is a fair demonstration of
why the gate is worth having. The prose moves to its own line above. The third
is SC2015 in start-server.sh, rewritten as an explicit if.
actionlint, 5 findings, all inside `run:` blocks it shellchecks once shellcheck
is on PATH. Three SC2015 in load-baseline.yml, rewritten as explicit ifs. Two
SC2035 in release.yml, where `sha256sum *` should not become `sha256sum ./*`:
the comment four lines above records that ParseChecksumFile exact-matches the
last field, so a "./" prefix would strand every deployed server exactly as a
"windows/" prefix would. `sha256sum -- *` satisfies the linter and leaves the
output bytes identical.
Verified all three gates in both directions with shellcheck 0.10.0 and
actionlint 1.7.7 on PATH. Passing: `node scripts/run.mjs check:hygiene` prints
"check:hygiene: passed" with all three steps run, not skipped. Failing:
appending `bait_fn() { cat $1; }` to Server/scripts/voice-test.sh fails on
SC2086; changing a runs-on to `ubunt-latest` fails on runner-label; appending a
misformatted table to README.md fails prettier. All three files restored and
confirmed clean afterwards. actionlint validates the new job in ci.yml itself.
Refs RL-19 / L-13, S-05.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(plans): record B1 progress through B1-3
The header still read "B1-0 is complete; B1-1 is the next step" three merged
phases later. A plan that misstates where it is costs a reader the same
confusion whether it is stale by one phase or three.
B1-0 (#1410), B1-1 (#1411), B1-2 (#1412) and B1-3 (this branch) are done; B1-4,
dependency automation, is next.
Verified: `node scripts/check-doc-counts.mjs` still agrees on 27 claims across
9 watched documents -- this file is one of them -- and prettier reports it
clean.
* chore(ci): pin Repository Hygiene as a required check on dev
The second half of S-05. Its acceptance criterion is "tree is formatted AND a
fast required gate fails future drift" -- a check that runs but is not pinned
lets a formatting regression merge, so the gate is not a gate until this lands.
The name was read off PR #1414 with `gh pr checks` after the job reported
`pass` in 26s, not copied out of ci.yml. That order matters: the B0 script
records that three pinned names exist in no workflow file at all, and that a
required check which never reports blocks every PR forever.
Extends the existing script rather than adding a second one, per the B1 plan.
Also records, in the "deliberately NOT pinned" list, that Docs & Ledger
Consistency reports and passes on a dev PR yet is unpinned. That reads as an
oversight from the 2026-08-25 pass rather than a decision, but it belongs to
G-04, so it is documented here and not changed.
NOT APPLIED YET. Running this script now would pin a check that PR #1413 cannot
report -- its branch predates the hygiene job, so the job does not exist in its
workflow file and the check would never arrive. Run it after #1414 merges;
#1413 needs a rebase onto dev regardless.
Verified: shellcheck clean, the embedded JSON parses, and `check:hygiene`
passes with prettier, shellcheck and actionlint all running.
Refs S-05, RL-14 / G-03.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* B1-4: dependency automation (RL-05 / L-05, RL-18) (#1415)
* chore(deps): cover the root and mcp-introspect npm roots
The repository has three npm package roots — `/` (changelogen, prettier),
`/Client`, and `/tools/mcp-introspect` (@modelcontextprotocol/sdk, zod) —
each with its own package-lock.json, and `npm run bootstrap` runs `npm ci`
in all three. Dependabot watched exactly one of them. The root's prettier is
what the Repository Hygiene gate runs, so the formatting gate's own toolchain
was drifting unwatched.
The obvious fix is to collapse the three roots into an npm workspace and
watch one lockfile. Measured on npm 11.17 / Node 26 rather than assumed, that
trade is bad, and it is bad for different reasons than expected. Workspaces do
not break the things you would predict: `npm ci` inside `Client/` still exits
0, `npm run <script>` still resolves the hoisted binaries because npm prepends
every ancestor `node_modules/.bin` to PATH, and `engine-strict` still fails
the install on a wrong Node major. What they cost is ten CI steps keyed on
`cache-dependency-path: Client/package-lock.json` (six in ci.yml, four in the
tag-only, CI-ungated release.yml) pointing at a file that stops existing; the
Repository Hygiene job's deliberate root-only install growing 970 ms to
6172 ms and 39 to 318 packages unless every call site remembers
`--workspaces=false`; and one shared lockfile putting all three npm Dependabot
groups back into the same file, which is precisely the rebase storm the
grouping comment at the top of dependabot.yml exists to prevent. The measured
benefit is one 298 KB lockfile instead of three (17 KB / 253 KB / 42 KB) and
614 resolved packages deduped to 582 — 32 packages, 5.2% — with client install
time unchanged at 5642 ms against 5667 ms.
So the roots stay separate and each gets its own block, matching the four that
already exist: grouped to one PR, majors ignored, weekly on Monday. A single
block with `directories:` was rejected for the same reason as workspaces —
grouping only works while a group rewrites exactly one lockfile. The decision
and its numbers are recorded in docs/contributing.md under Dependency Policy,
so the next person to propose workspaces reads the measurement instead of
repeating it.
Verified: a coverage checker cross-references every `package-ecosystem` /
`directory` pair in dependabot.yml against every manifest in `git ls-files`,
in both directions. Against dev at 2a37f386 it reports `UNWATCHED npm
package.json` and `UNWATCHED npm tools/mcp-introspect/package.json` (plus
Server/Dockerfile, which the next commit covers). Against this commit both npm
rows read `ok`, and the forward direction confirms each newly declared
directory really holds a package.json. `npx prettier --check` reports both
edited files unchanged, so the B1-3 formatting gate stays green.
Not included: the docker ecosystem (next commit, RL-18); immutable image
digests for release/runtime containers, which is R-04's remaining half and
belongs to B6; and turning the coverage checker into a permanent
`check:hygiene` gate — a new manifest root can still drift unwatched, which is
how this gap arose, but that is new gate machinery rather than the coverage
this item asks for.
Refs RL-05 / L-05
* chore(deps): watch the server container base images
Server/Dockerfile pulls `golang:1.26-bookworm` to build and
`gcr.io/distroless/static-debian12` to run, and nothing watched either. Every
other dependency root in the repository is on a weekly Dependabot schedule, so
the one artefact that ships to users as a whole filesystem was the only one
whose upstream moved silently — including its CA certificates, which the
Dockerfile comment specifically calls out as the reason distroless was chosen
over scratch.
The obvious fix is to pin both images by digest and be done. That is the wrong
move here for two reasons. A digest pin with no automation behind it is worse
than a tag: it freezes the base image at whatever was current the day someone
typed it, and a frozen distroless base is a frozen CA bundle. And digest
refresh for release and runtime images is R-04's other half, scoped to B6
alongside the smoke tests that have to gate it — landing half of it here would
leave the digests pinned and the refresh unowned.
So this adds the `docker` ecosystem for /Server on the same terms as the five
blocks around it: grouped to one PR, majors ignored, weekly on Monday. Be
precise about what that actually buys, because it is less than the block
implies. Of the two images only `golang:1.26-bookworm` carries a comparable
version, so it is the only one Dependabot can act on today;
`gcr.io/distroless/static-debian12` has no version tag, and an untagged image
is not something a version update can move — it needs the digest pinning that
B6 owns. The comment above the block records that the Go builder tag tracks
Server/go.mod and every `actions/setup-go` in CI, so a `1.27-bookworm` PR is a
prompt to move all three together rather than a standalone merge.
Verified: the coverage checker cross-references every `package-ecosystem` /
`directory` pair against every manifest in `git ls-files`, in both directions.
Against dev at 2a37f386 the reverse direction reports `UNWATCHED docker
Server/Dockerfile`; against this commit every row reads `ok` and it exits 0 —
`ALL ROOTS WATCHED`, six blocks covering six manifest roots, with the forward
direction confirming /Server really holds a Dockerfile. `npx prettier --check`
passes on the edited file.
Not included: Server/docker-compose.yml and Server/docker-compose.otel.yml.
Their four images are `ghcr.io/j3vb/owncord-server:latest` (this repository's
own published image), `livekit/livekit-server:v1` (a floating major tag, and
majors are ignored everywhere), `jaegertracing/all-in-one:latest` and
`prom/prometheus:latest` — none of which a version update can move, so a
compose block would be configuration that provably produces nothing. Also not
included: immutable digests plus digest-refresh PRs with smoke tests for the
release and runtime images, which is the remainder of R-04 and belongs to B6.
Refs RL-18
* docs: apply skill-review findings to ci-check and the project skills (#1416)
The observation log had accumulated 46 open entries against a last review of
2026-08-14. Seven of them target skills tracked in this repository and were
verified still-unapplied against the current files.
`ci-check` gains four things it was missing. It never mentioned `cargo audit`,
which CI runs pinned at 0.22.1 in `tauri-build` — the one gate that turns red
with zero local changes, because an upstream advisory breaks a branch that was
clean yesterday, and the one a hand-written mirror silently drops because no
edit provokes it. It never mentioned that `release.yml` is tag-triggered and
PR-ungated, so a smoke/sign/strip step added only there first executes on the
release; #1376 shipped a smoke harness whose own bug then blocked a release,
and #1378 fixed it structurally by extracting `Server/scripts/docker-smoke.sh`
for both workflows. And it had no guidance for reading a red check at all: a
new section adds causality-before-forensics triage (diff the changed-file set
against the failing job's input surface before opening a log — a workflow-only
diff cannot cause a Go goroutine leak), the lockfile-fork diagnosis for
dependency bumps (a 1 → 2 entry-count transition means the update forked the
dependency and revoked the features it was borrowing, so aligning versions is
the fix, not setting the feature the new copy demands), and the known-flake
table promoted to a signature-to-recovery index, now including the apt-mirror
hang that cancels `tauri-build` by timeout.
The baseline rule that came with the triage section needed adjusting rather
than transcribing. Its source observation recorded `golangci-lint`'s known-red
complexity baseline as 23 cyclop / 6 dupl / 21 funlen / 12 nestif; #1389
cleared that to zero, so quoting those numbers would have taught the reader to
excuse a failure that is now genuinely theirs. The rule is recorded without
them, stating that the repo currently carries no known-red gate and what to do
if one is ever reintroduced.
`protocol-change` claimed the schema is the source of truth without saying what
it covers. It holds message-type names only, so a payload-field change touches
the Go command/message files, the client types and `docs/protocol.md` and never
the schema — routing one through the regenerate cycle is wasted work. A table
splits the three cases, with the relay-handler caveat: a server that
re-serialises drops unknown fields, so a forwarded field is not backward
compatible with older servers.
`task-observer`'s numbering discipline treated collisions as a parallel-human
accident. They are structural in fan-out workflows, because a dispatched
subagent has the skill active in its own context and writes to the same log.
`bughunt-run` covered findings blocked by a circuit breaker but not findings
that went stale: a later hunt routinely fixes a blocked finding as a side
effect of an overlapping sibling, and a saved debris patch stops applying once
a refactor rewrites its files. Of 6 findings blocked on 2026-08-14, 2 were
already fixed 5 days later.
`docs/contributing.md` gains the commit-body convention that was being followed
without being written down anywhere — reasoning over diff-restatement, a
`Verified:` paragraph proving both directions, and an explicit `Not included:`
line. That last one is what keeps adjacent scope from becoming either silent
drift or an unnecessary blocking question.
Verified: each edit was checked against the live file before applying, which
changed two outcomes. Observation 50 (make the hunt's stop rule measure
coverage, not just quietness) is already implemented — `bughunt-run` documents
`coverage + dry is the real stop`, `stalledCoverage` and
`coverage.uncoveredAtStop`, landed by #1399 — so it is marked actioned rather
than re-applied. Observation 42 looked covered by the same grep and was not:
the existing text handles breaker-blocked findings, a different case from a
finding a sibling fix already closed. Confirmed absent before editing:
`cargo audit` and `release.yml` in ci-check, `payload` in protocol-change,
`subagent` in task-observer. `npm run check:hygiene` passes (prettier clean on
all five files); `npm run check:docs` passes.
Not included: the 21 open observations targeting `superpowers:*` plugin skills,
which live in a versioned plugin cache and…
* chore(deps): bump the npm-dependencies group across 1 directory with 3 updates (#1428)
Bumps the npm-dependencies group with 3 updates in the /Client directory: [eslint](https://github.com/eslint/eslint), [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).
Updates `eslint` from 10.9.0 to 10.9.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.9.0...v10.9.1)
Updates `oxlint` from 1.79.0 to 1.80.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxlint_v1.80.0/npm/oxlint)
Updates `typescript-eslint` from 8.67.0 to 8.68.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.68.0/packages/typescript-eslint)
---
updated-dependencies:
- dependency-name: eslint
dependency-version: 10.9.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: npm-dependencies
- dependency-name: oxlint
dependency-version: 1.80.0
dependency-type: direct:development
update-type: version-update:semver-minor
dependency-group: npm-dependencies
- dependency-name: typescript-eslint
dependency-version: 8.68.0
dependency-type: direct:development
update-type: version-update:semver-minor
dependency-group: npm-dependencies
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* ci(deps): bump anthropics/claude-code-action (#1429)
Bumps the actions-dependencies group with 1 update in the / directory: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).
Updates `anthropics/claude-code-action` from 1.0.200 to 1.0.206
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/24dcd50c0568f0fc9e9211213a4fd2d9eb15c4e0...1f291e1cfe0f5fc21db2aef19af844591600ade7)
---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
dependency-version: 1.0.206
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: actions-dependencies
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump golang
Bumps the docker-dependencies group with 1 update in the /Server directory: golang.
Updates `golang` from 1.26-bookworm to 1.27-bookworm
---
updated-dependencies:
- dependency-name: golang
dependency-version: 1.27-bookworm
dependency-type: direct:production
dependency-group: docker-dependencies
...
Signed-off-by: dependabot[bot] <support@github.com>
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: J3vb <192430104+J3vb@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Fix 27 findings from 2026-08-21 bug hunt (#1400)
* chore(findings): record 2026-08-21 bug hunt (38 findings)
* fix(api): 1 defect(s) (OC-0240)
* fix(client): 1 defect(s) (OC-0241)
* fix(plugin): 2 defect(s) (OC-0243, OC-0265)
* fix(client): 3 defect(s) (OC-0244, OC-0256, OC-0259)
* fix(client): 1 defect(s) (OC-0247)
* fix(client): 2 defect(s) (OC-0248, OC-0258)
* fix(identity): 1 defect(s) (OC-0250)
* fix(ws): 3 defect(s) (OC-0252, OC-0269, OC-0272)
* fix(admin): 1 defect(s) (OC-0253)
* fix(client): 1 defect(s) (OC-0254)
* fix(voice): 1 defect(s) (OC-0255)
* fix(ws): 1 defect(s) (OC-0260)
* fix(client): 1 defect(s) (OC-0261)
* fix(client): 1 defect(s) (OC-0262)
* fix(client): 1 defect(s) (OC-0263)
* fix(client): 1 defect(s) (OC-0264)
* fix(client): 1 defect(s) (OC-0268)
* fix(ws): 1 defect(s) (OC-0273)
* fix(service): 1 defect(s) (OC-0275)
* style: satisfy golangci-lint and prettier on 2026-08-21 fix commits
- drop ineffectual backupDir reset before return (registry.go, OC-0265)
- reflow long boolean expression (attachments.ts, OC-0241)
* fix(client): 4 defect(s) (OC-0242, OC-0246, OC-0249, OC-0251)
* fix(voice): 1 defect(s) (OC-0267)
* fix(admin): 1 defect(s) (OC-0274)
* fix(voice): 1 defect(s) (OC-0245)
* fix(ws): 1 defect(s) (OC-0271)
* fix(voice): 2 defect(s) (OC-0239, OC-0257)
* fix(ws): 1 defect(s) (OC-0266)
* fix(voice): 1 defect(s) (OC-0270)
* style: clear golangci-lint modernize and prettier nits from 2026-08-21 fixes
- range-over-int and slices.Contains modernizations in new Go test files
- prettier reflow in dispatcher.ts
* chore(findings): mark 2026-08-21 hunt findings fixed/declined
37 fixed across the fix waves, OC-0238 declined (LiveKit webhook TLS
requires a product decision, not a mechanical patch).
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix: 35 findings from the 2026-08-22 bug hunt (#1402)
* fix(voice): 1 defect(s) (OC-0277)
* fix(voice): 1 defect(s) (OC-0278)
* fix(client): 1 defect(s) (OC-0280)
refreshDmSidebar() rebuilds the entire DM sidebar subtree on every
dmStore.channels change - which includes presence flips and new
messages, not just DM list changes. The "Find a conversation" filter
text and input focus live only in that destroyed subtree, so they were
silently wiped mid-typing. Capture and restore both across the
destroy+recreate cycle.
* fix(ws): 1 defect(s) (OC-0285)
* fix(client): 1 defect(s) (OC-0286)
* fix(client): 1 defect(s) (OC-0288)
Consume the legacy unscoped mute key after migrating it onto the first
host, so a brand-new host with no scoped key of its own no longer reads
through to the same legacy list and inherits another server's mutes.
* fix(voice): 1 defect(s) (OC-0290)
* fix(db): 1 defect(s) (OC-0293)
DecrementMentionCounts reversed mention_count bumps that were never
applied: message_mentions stores every resolved mention id including the
author's blockers, while applyMentionCounts excludes blockers before
incrementing. Deleting a blocked author's message therefore wiped an
unrelated, genuine mention badge on the same read_states row. Mirror the
block exclusion in the decrement UPDATE.
* fix(db): 1 defect(s) (OC-0294)
DeleteAccount soft-deletes the departing user's messages but never reversed the read_states.mention_count bumps those messages made, leaving phantom mention badges. Reverse them inline in the existing transaction, mirroring DecrementMentionCounts' guards.
* fix(client): 1 defect(s) (OC-0295)
MemberList rebuilt every row on any non-presence-only membersStore change
and on every roles_update, but registered each row's click/contextmenu
listeners on the component-lifetime disposable.signal, which only aborts
at destroy(). Discarded rows therefore stayed reachable (and their
listeners live) for the component's whole lifetime. Route per-row
listeners through a per-render AbortController that is aborted and
replaced at the top of every render, and aborted again in destroy().
* fix(identity): 1 defect(s) (OC-0297)
UpdateProfile's post-commit re-read of the user row could fail for reasons
unrelated to context cancellation (SQLITE_BUSY, I/O error, pool exhaustion)
and was reported as ErrInternal even though UpdateUserProfile had already
committed. Callers that treat any UpdateProfile error as proof the write
never landed — handleUploadAvatar deletes the file it just stored — would
delete a file the committed avatar column now points at, permanently
breaking the avatar with no user_update broadcast.
Since UpdateUserProfile only writes username/avatar/display_name/about,
merge those four onto the pre-write snapshot to reconstruct the committed
row without needing the re-read to succeed, and log the read failure.
* fix(ws): 2 defect(s) (OC-0298, OC-0299)
- OC-0298: applyConnectStatus stamped c.user.Status even when the
UpdateUserStatus write failed, so auth_ok and the presence broadcast
claimed a status users.status disagreed with, and buildReady's
ListMembers read never self-corrected for the session.
- OC-0299: refreshUserSnapshot silently fell back to roleName "member"
when the new role lookup failed, pinning the session to a fabricated
role on the wire. It now fails closed like the sibling lookups in
upgradeAndAuth and handleFreshConnect.
* fix(client): 1 defect(s) (OC-0300)
* fix(client): 1 defect(s) (OC-0301)
* fix(ws): 1 defect(s) (OC-0302)
* fix(api): 1 defect(s) (OC-0305)
handleDiagnosticsConnectivity used clientIP(r), ignoring cfg.Server.TrustedProxies, so behind a configured trusted reverse proxy the endpoint reported the proxy hop instead of the real client address. Use clientIPWithProxies with the parsed trusted-proxy nets, matching RateLimitMiddleware on the same route.
* fix(client): 2 defect(s) (OC-0306, OC-0308)
* fix(client): 1 defect(s) (OC-0307)
QuickSwitcher registered a per-row click listener against the
overlay-lifetime AbortSignal, but renderResults() rebuilds every row on
each keystroke, arrow key, and store refresh. Discarded rows kept their
listeners alive until the overlay closed. Replaced with one delegated
click listener on the stable results container, keyed off the
data-channelid each row already carries.
* fix(client): 1 defect(s) (OC-0310)
* fix(server): 3 defect(s) (OC-0279, OC-0291, OC-0292)
Reap a soft-deleted message's attachment files, count lapsed temporary
bans as active users in the require_2fa enrollment gate, and only apply
the 2FA-enrollment precondition when require_2fa itself is being enabled.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
* test(api): sync apiTestSchema with the user_blocks migration
DeleteAccount's mention-count reversal joins user_blocks; the api
package's hand-rolled schema fixture predates migration 012.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
* fix(client): 3 defect(s) (OC-0281, OC-0282, OC-0296)
Decouple the E2EE identity-mismatch modal and right-click popovers from
the sidebar's per-render abort signal, and let global drag listeners
survive a mid-drag re-render.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
* fix(voice): 2 defect(s) (OC-0283, OC-0287)
Retire a departed peer's E2EE key unconditionally on leave, and surface
a failed microphone unmute instead of reporting an unmuted state the
room never saw.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
* fix(client): 3 defect(s) (OC-0289, OC-0303, OC-0309)
Guard the DM call button against redialing the channel already joined,
resolve the incoming-call banner's caller through the nickname-aware
display name, and keep the DM profile sidebar subscribed to live
member/status updates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
* style(client): prettier-format the dm-store test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
* fix(server): 1 defect(s) (OC-0284)
Make message soft-delete a compare-and-set so a repeated chat_delete
cannot reverse mention counts twice.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
* fix(server): 2 defect(s) (OC-0276, OC-0304)
Re-sync a resumed connection's voice E2EE peer keys in registerNow
(announce frames are unsequenced and cannot be replayed), and apply the
live-connection presence rule to every DM payload DMService builds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
* chore(ledger): record the 2026-08-21 hunt findings as fixed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
* chore(ledger): independent revert-proof pass for OC-0276..OC-0310
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
* refactor(service): extract DeleteMessage authorization into a helper
Keeps DeleteMessage under the cyclop complexity ceiling after the
OC-0284 guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore(ledger): record the 38 open findings from the 2026-08-22 hunt (#1403)
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo
Co-authored-by: Claude <noreply@anthropic.com>
* chore(graphify): refresh knowledge graph
* fix: close the three B0 P0 gates and record a measured baseline (#1409)
* chore(security): stop tracking the private security-finding reports
docs/security-findings/ holds detailed reports for defects that are not yet
fixed. The directory was untracked but not ignored, so any 'git add .' would
have published seven unfixed vulnerability traces to a public repository.
Findings are coordinated through private GitHub Security Advisories
(docs/security.md); only opaque identifiers and safe status belong in tracked
plans.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): repair the two red P0 unit contracts (G-01, G-02)
G-02: noise-suppression-restart stubbed MediaStream with
vi.fn().mockImplementation(arrow), which is not constructible. Vitest 4 threw
'is not a constructor' at the new MediaStream([inputTrack]) call in
noise-suppression.ts before reaching any assertion. Replaced with a real
class; the OC-0277 assertions are unchanged.
G-01: message-list's OC-0217 guard was inverted, not merely stale. It spied on
AbortSignal.prototype.addEventListener and asserted zero abort registrations,
but the leak it names registered row listeners via
element.addEventListener(..., { signal }) — a path that never calls that
prototype method. Measured: the leak produces 0 registrations (test passes),
while the OC-0286 fix rotates a per-window AbortSignal.any and produces 5
across 5 distinct signals (test fails). The guard passed on the bug and failed
on the fix.
It now captures the signal each window's row listeners register against and
asserts the invariant its name always claimed: one signal per rendered window,
a fresh signal per jump, and every superseded window already aborted with
exactly one live. Verified both directions — green on the fix, and
'expected 1 to be 5' with beginRowRender() reverted to rowSignal = ac.signal.
Client suite: 5257 passed, 0 failed (was 5255 passed, 2 failed).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): make the Playwright suite terminate
The runner finished every test and then never exited, printing no summary — so
the failure read as 'tests never finish' when it was 'process never exits'.
getActiveResourcesInfo() at hang time showed a live ProcessWrap plus several
PipeWrap: the Vite dev server was still running. Playwright's webServer
teardown does not kill it here.
Measured, full suite each time:
npm run dev hangs, tests pass
node node_modules/vite/bin/vite.js hangs, tests pass
reuseExistingServer: false hangs, tests pass
gracefulShutdown SIGTERM/3s hangs, tests pass
npx vite exits, 290 of 293 FAIL
no webServer (pre-started) exits, 293 pass in 33s
npx only appears to fix it: npx exits once Vite is up, Playwright reads that as
the server dying and tears the group down mid-run, so later tests get
ERR_CONNECTION_REFUSED.
globalTeardown now kills the process listening on the dev port, releasing the
runner's handle. The webServer command spawns Vite's entry point directly so
the listening process is Playwright's own child — via 'npm run dev' the npm
process would still hold the handle open. It also reaps servers orphaned by an
interrupted run, which reuseExistingServer would otherwise silently adopt.
An earlier revision used netstat, which is not on PATH in every shell here; the
swallowed ENOENT made the fix look applied while the hang persisted. It now
uses PowerShell on Windows and lsof elsewhere, and warns on failure rather than
failing silently.
npm run test:e2e: exit 0, 293 passed, 37s, reproducible, no orphan listener.
playwright.config.prod.ts carried the same npm-wrapper shape.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(client): align .nvmrc with the Node version CI uses
Three versions were in play, not two: .nvmrc said 20, CI pins 24, and the
machine the audit was measured on runs 26. A baseline measured against .nvmrc
is not the baseline CI produces, which defeats the point of B0.
Scoped to .nvmrc only. The full single-source-of-truth work — package engines,
contributor docs, release — stays in B1 (RL-17 / C-01).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(plans): add the beta audit set and the B0 baseline
The 2026-08-23 audit set has been sitting untracked: repository-health and
repository-layout audits, beta product requirements, requirement traceability,
the issue register, and the B0-B10 roadmap. They are the plan of record for
beta and belong in the repository.
Adds b0-baseline-2026-08-25.md, which supersedes the roadmap's 'current
evidence snapshot'. Every row is marked measured or carried, so nothing is
inherited silently. It also records three audit claims that did not survive
verification:
- G-01 was an inverted guard, not a stale assertion — it passed on the bug
and failed on the fix.
- The Playwright hang matched none of the three hypotheses; the runner could
not kill its own dev server.
- The golangci-lint toolchain failure is refuted: 19 linters run, 0 issues,
verified with -v to rule out the known zero-linters false-green.
Adds b0-dev-branch-protection.sh, which records the applied dev branch
protection and the reasoning behind each setting.
Security detail stays private: the register carries only opaque SEC-* families
and safe closure criteria, per the roadmap's public/private handling policy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(graphify): refresh the knowledge graph
Own commit, per CLAUDE.md — the graph payload does not belong in the diff of
the changes that triggered it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(plans): add the active-plan index and fix a stale status header (G-04)
Planning documents had no recorded state, so a reader could not tell current
guidance from shipped history. docs/plans/README.md now indexes every plan as
active, partially implemented, design-only, or shipped, and names the source of
truth for each concern so a defect count is never read out of a plan.
Status is recorded in the index rather than by moving or rewriting the
historical plans, so links from audits and commit messages keep resolving.
One real stale claim found and fixed: audit-2026-08-19-remediation.md still
read 'in progress 2026-08-19' while its own phase table showed phases 1-6 done
2026-08-20 (merged 03fcb7d5, PR #1396) with only phase 7 pending. The header
had drifted because the table was updated in place and the header was not.
No plan was found claiming '0 open findings'.
Also records the Step 8 staleness pass in the B0 baseline: all 38 open OC
records still resolve to a live file:line at this commit, so none is superseded
by later work. Adjudicating them individually is bughunt-fix work, not B0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Update graph output files and manifest with new metadata
- Updated graph.html and graph.json with new binary data.
- Modified manifest.json to reflect changes in file modification times and AST hashes for several documents.
- Added new entry for README.md in the manifest with its corresponding metadata.
* docs(plans): close the Docker and coverage leftovers in the B0 baseline
Docker smoke: measured and passing. Image builds at 50.1 MB and boots on :8443
with TLS; docker-smoke.sh exits 0.
Server coverage: re-measured at 74.6% aggregate, confirming the figure carried
from the audit rather than continuing to inherit it.
Two findings from doing it:
ENV-03 — docker-smoke.sh cannot be run from Git Bash on Windows. MSYS path
conversion rewrites the container-internal /chatserver into
'C:/Program Files/Git/chatserver', so docker exec fails 127 and the script
reports 'container never reported healthy within 30s' — indistinguishable from
a real boot regression. MSYS_NO_PATHCONV=1 makes the same script pass. CI is
Linux and unaffected, but Windows is an official contributor platform (RL-20).
The CI Docker job is gated on main, so it is skipped for any PR targeting dev
— a dev-targeted change cannot get Docker evidence from CI at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(graphify): refresh the knowledge graph
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(plans): B1 execution plan, and accept HP-0 (#1410)
* docs(plans): add the B1 repository-foundation execution plan
B1 is the isolated layout/contributor phase. This records the execution
order, the proof for each step, and what is out of scope.
Two findings worth surfacing before any B1 work starts:
- HP-0 was never formally accepted. The roadmap's B1 entry gate requires
it; no scorecard artifact exists, no commit or document records an
acceptance, and the B0 baseline still lists "Step 10: HP-0 sign-off"
under "Not yet done in B0". The plan lists the five gaps that closing
it requires, including pinning required status checks on dev -- which
are still unset, so a dev PR can currently merge red.
- Several layout-audit claims do not survive verification against HEAD,
matching the B0 pattern. RL-09's "no single command verifies both
protocol consumers" is false (make protocol-verify does, and is
enforced in CI, the pre-commit hook, and a contract test). RL-10's
test-discovery side effect never fires (no _test.go in Server/scripts).
RL-06's regeneration concern is refuted locally. RL-08 grows a
toolchain constraint instead. RL-05, RL-07, RL-20 and RL-21 are each
worse than written -- RL-20 includes a live bug where a missing `make`
is reported as stale protocol constants.
The riskiest item, RL-01 (flatten Client/tauri-client into Client), gets
a full reference inventory and a mechanical proof for both commits: tree-
object equality for the pure move, and scripted-substitution replay for
the path rewrite. Release asset names and updater contracts are verified
independent of the directory name, so the move cannot rename an artifact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(plans): correct the B1 status-check pin list from a live dev PR
The list was derived from ci.yml. Observing PR #1410's actual checks
found three that exist in no workflow file -- Analyze (go),
Analyze (javascript-typescript), Analyze (actions) -- because CodeQL
runs from GitHub default setup, configured in repository settings.
Reading .github/ alone misses them.
Also confirms the two negative predictions against a real dev-targeted
PR: Server Docker Build (verify) reports as "skipping", and Tauri Full
Build never appears in the check list at all. Neither may be pinned.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(plans): accept HP-0 and pin the dev required status checks
Closes B1's entry gate. All five B1-0 items are done.
The scorecard is the artifact the hold point asks for: one place that
answers its four questions, records what was accepted as a stated
limitation rather than claimed green, and part-closes R-08.
Required status checks are now pinned on dev -- ten of them. That was
B0's one outstanding step. Two things came out of doing it:
- The names cannot be inferred from ci.yml. Three of the ten (the
Analyze jobs) exist in no workflow file, because CodeQL runs from
GitHub default setup configured in repository settings. They were read
off a live dev-targeted PR with `gh pr checks`.
- Server Docker Build, Tauri Full Build and the CodeQL aggregate are
deliberately excluded. The first two report "skipping" on a dev PR --
Tauri Full Build under its unexpanded matrix name, since the job is
skipped before matrix expansion. Admin Panel E2E is excluded because
continue-on-error makes it report success unconditionally.
Two prior claims are corrected rather than left to propagate:
- b0-dev-branch-protection.sh was written assuming repository-settings
writes are blocked from the agent sandbox. They are not; the PUT
succeeded. The script stays as the record of intent and the way to
re-apply or undo.
- An earlier revision of the B1 plan said Tauri Full Build does not
appear in a dev PR's check list at all. It does, as skipping.
Evidence closed out:
- Rust is no longer a carried row. Re-measured: 115 passed, cargo clippy
--all-targets -- -D warnings at exit 0, confirming the carried figure.
- The 38 open ledger records are accepted as counted, non-stale and
assigned: 11 medium / 27 low, zero high or critical, zero dead paths
across all 348 re-verified at this commit, and none assigned to B1.
- The private security review is reconciled: 7 findings, 7 of 7 mapped
to existing public rows, 0 unmapped. Summary is content-free; the
detail stays in the untracked private reports.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor: flatten Client/tauri-client into Client (B1-1) (#1411)
* refactor: move Client/tauri-client to Client (pure move, no content change)
* refactor: re-point paths after the Client flatten (mechanical, no behaviour change)
---------
Co-authored-by: Claude <noreply@anthropic.com>
* B1-2: truth, entry points, and contributor path (#1412)
* fix(hooks): guard on the command the hook actually runs
pre-commit probed one binary and invoked another. The protocol block
guarded on `command -v go` and then ran `make protocol-verify`; the sqlc
block guarded on `command -v sqlc` and ran `make sqlc-verify`. `make` is
not on PATH on a stock Windows box, so a contributor with Go installed
but no make had their commit rejected with
pre-commit: FAIL: protocol constants are stale — run 'make
protocol-generate' in Server/ and stage the result
when nothing had been generated and nothing compared. The real cause was
`make: not found`, and the advice the message gives fails the same way.
Rather than add a `command -v make` guard, inline what the two Makefile
targets reduce to — `sqlc generate` / `go run ./scripts/genprotocol`
followed by `git diff --exit-code`. Same semantics, one less prerequisite,
and it doubles as the make-free equivalent B1-2 asks for. The Makefile
targets stay for anyone who prefers them.
Also: the protocol block was the only one with no `else`, so a
contributor without Go got no check and no notice. It now warns like its
two siblings. And gofmt is a separate binary from go, so it is probed
separately.
Verified both directions with the hook body replayed verbatim:
- Go present, make absent -> passes, no staleness claimed.
- schema edited without regenerating -> fails, as it must.
Refs RL-20 / L-14.
* docs: state one branch and PR model
Active documents contradicted each other head-on. README.md and
docs/contributing.md said branch from `dev` and target `dev`; CLAUDE.md
said branch from `main`, PR to `main`. That is R-02, and the 2026-08-19
audit had already recorded it as D-06 without it being resolved.
`dev` is the answer, and the repository already behaves that way: B0 made
`dev` PR-only with ten required checks enforced on admins, and #1409,
#1410 and #1411 all landed there. `main` carries releases.
docs/contributing.md becomes the single source of truth. It now states the
model, what protection is actually applied, and the two consequences a
contributor meets on their first PR — that a self-mergeable PR still cannot
merge red, and that Docker and Tauri Full Build report as skipped against
`dev` rather than failing. Everywhere else summarises and links here.
- CLAUDE.md: corrected, with a link rather than a second copy.
- CONTRIBUTING.md: new. GitHub's contributing-guidelines affordance only
resolves the root, .github/ or docs/ — `docs/contributing.md` is not a
path it finds, so the link never appeared on issues or PRs.
- PULL_REQUEST_TEMPLATE.md: names the base branch, which it did not.
- bughunt-run skill: reviewed the branch against `origin/main`, which is
the wrong base once every PR targets `dev`.
README.md already said `dev` and is left as the short summary it should be.
Dated audits and the historical remediation plan keep their `main`-era
wording — they are records.
Refs R-02.
* fix(hooks): pick the pre-push base from the nearest integration branch
pre-push decided which side's gates to run from
`git diff --name-only origin/main...HEAD`. That was right when everything
targeted `main`. Once `dev` became the integration branch it stopped being
right: a branch cut from `dev` diffed against `main` counts everything on
`dev` and not yet on `main` as "changed".
Measured on this branch: the old base reported 609 changed files, the new
one reports 6. So in practice the hook was running the full server build
matrix and the client typecheck plus eslint on every push, whatever the
change touched — the file-based narrowing it exists for never engaged.
Now it picks whichever of origin/dev, origin/main is nearest, by commits
between merge-base and HEAD, skipping a candidate that scores 0. Verified:
a branch cut from dev picks origin/dev (2 ahead); dev itself scores 0
against dev and picks origin/main (8 ahead), which is what a dev -> main
release PR wants. With no candidate resolvable it falls back to the
existing `__all__`, so an unfetched or shallow clone still runs everything.
Refs RL-20 / L-14, R-02.
* chore(node): one Node source of truth
`.nvmrc` and all ten `actions/setup-node` pins said 24; five active
documents and the repo's only `engines` block still said 20. A contributor
following the docs installed a version CI does not run.
Node 24 wins — it is what CI already runs. Every manifest now declares
`engines`, and `engine-strict=true` turns a wrong major into a failed
install rather than an `EBADENGINE` warning nobody reads. `>=24` rather
than `^24` so a Node 26 box keeps working; `Client/.nvmrc` stays the
human-facing pin and the docs point at it instead of restating a number.
The `.npmrc` is per package root, not one at the top. npm reads the
project `.npmrc` from the package directory and does not walk parents —
verified with a throwaway package requiring node >=99: with only a parent
`.npmrc` npm warned and exited 0; with one in the package directory it
failed `notsup`. A single root file would have left `Client/`, the package
that matters most, on warnings.
Five docs, not the four previously identified — `docs/mcp-introspect.md`
also said 20. And `docs/contributing.md` claimed "`.nvmrc` + CI both say
Node 20", which was wrong about both.
Verified both directions in all three package roots: Node 22 fails
`notsup`; Node 24 installs clean and `npm ci` passes in Client/.
Refs RL-17 / C-01, ENV-01.
* docs: add the documentation landing page
`docs/` had 24 top-level files and no index. The root README carried a
flat list of 22 links that had drifted: six documents were reachable from
nowhere at all — including both 2026-08-23 audits and the test audit — and
two entries were labelled "latest" while newer unlinked audits existed.
docs/README.md is the index RL-12 asked for. It groups by what a document
*is*, because that is what decides whether to trust it: guidance tells you
how to do something, reference describes a contract the code implements,
audits are dated snapshots nobody updates, plans record intent. Every
tracked file under docs/ now appears exactly once, and the audit table says
plainly that audit-2026-08-19.md still claims "0 open findings" when the
ledger has 38.
The root README keeps a short curated list and defers to the index, rather
than maintaining a second copy that drifts again. Two fixes while there:
`docs/plans/` was linked as a bare directory, unlike its two sibling
directory entries, and was annotated "each carries a verified status
header" — which docs/plans/README.md:7-9 explicitly contradicts, since a
plan's header is exactly the thing that drifts and the index is the
authority.
Verified: 78 relative links across the new and edited files resolve, and
no tracked docs/ file is unreachable from the index.
Refs RL-12 / R-06.
* feat(scripts): root command facade
Entry points existed only inside Server/ (a Makefile) and Client/ (npm
scripts). Nothing at the root told a new contributor where to start, and
the root package.json had three scripts, none of which built or tested
anything.
`npm run check` from the root now runs what CI gates on, and
check:server / check:client / check:rust run one stack. scripts/run.mjs
is dependency-free Node — the shape render-ledger.mjs already uses — so
`npm run check` works before `npm install` has.
Cross-platform by construction: every step is spawned with an explicit cwd
and no shell, so there is nothing to quote and no `cd &&` to behave
differently on Windows. npm and npx get their .cmd suffix there. No step
shells out to make.
The facade orchestrates; it is not a new required path. Each step prints
the command and the directory before running it, and those are exactly the
commands documented per-stack — so a server contributor can read the output
and type them instead, and still never needs Node. Tools CI installs but a
contributor may not have (golangci-lint, which has no wrapper in this repo
at all; sqlc, pinned by Server/sqlc.version) are skipped with a printed
reason rather than failing.
Three corrections to the ci-check skill while aligning it:
- `make sqlc-verify protocol-verify` replaced by what those targets reduce
to, so the documented path does not require make either.
- `cargo test` -> `cargo test --lib`, which is what ci.yml actually runs.
- "NODE_OPTIONS=--no-experimental-webstorage is mandatory on Node 22+" was
false. tests/setup.ts installs the shim, CI runs Node 24 without the
flag, and the suite was measured passing without it — 192 files / 5257
tests, identical to the flagged run.
Also documents the third RL-20 problem, which needed no code: core.hooksPath
is exclusive, so `npm run hooks:install` silently disables any
.git/hooks/post-commit — including the one `graphify hook install` writes,
which CLAUDE.md tells agents to install. Nothing warned about that.
Verified: check:client 5257/192 green, check:rust 123 tests + clippy green,
--list prints every command, and the optional-tool skip path reports rather
than fails.
Refs RL-04 / L-04, RL-20 / L-14.
* feat(ci): fail on a document that contradicts the findings ledger
G-04's remaining half. The ledger is the source of truth for defect counts,
but nothing stopped a planning document from stating a different number and
nothing noticed when one did. `render-ledger.mjs --check` cannot help: it
validates the JSON schema and returns before rendering, so it never reads
FINDINGS.md and cannot see drift at all — and no workflow ran it anyway.
scripts/check-doc-counts.mjs counts ledger statuses and compares them to
what an allow-list of active documents claims, failing with file, line,
claimed value and actual. Wired into ci.yml as a job with no npm ci, since
the script imports nothing outside node:, and into the facade as
`npm run check:docs` — first in `check`, so a contradicted count does not
wait behind ten minutes of -race.
The patterns are narrow on purpose. A first attempt matched any
"<number> <status>" and flagged nineteen things, all false: "the 45 open P1
rows" (issue-register rows, not ledger findings), "All 8 findings F1-F8"
(a different register), "G-05 **refuted**" (an identifier), `">=20"` and
`CGO_ENABLED=0` (not counts at all). A check that cries wolf gets ignored,
which is the failure G-04 already describes. So a number is only read as a
claim in three shapes that cannot mean anything else: an enumeration of two
or more "<n> <status>" pairs, a status table row in a table that totals
itself, and "<n> records/findings" where the ledger is named within three
lines. Fifteen selftest assertions pin both directions, and the job runs
them before it runs the check.
It reads findings-ledger.json directly rather than importing
render-ledger.mjs for `validate`/`render`: that module ends in a bare
top-level `await main()` with no import.meta.main guard, so importing it
rewrites FINDINGS.md as a side effect.
Dated docs/audit-*.md are reported, never failed — they are snapshots
nobody maintains. audit-2026-08-19.md does claim zero open findings against
38 open, so b0-baseline's "No plan was found claiming '0 open findings'"
holds for docs/plans/ but not for docs/.
Not included: a real FINDINGS.md render-drift check. That is RL-07 and
belongs with the generated-artifact work, not here.
Verified: 27 claims across 9 documents agree; corrupting one count in
docs/plans/README.md fails the check naming that line, for both the status
and the total.
Refs G-04.
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore: remove graphify knowledge graph tooling (#1413)
The committed knowledge graph and its PreToolUse hooks were steering every
codebase question through `graphify query` before any other tool could run.
Serena (gopls + rust-analyzer + tsserver over MCP) answers the same questions
from real language servers rather than a generated snapshot that goes stale
between rebuilds, so the graph no longer earns the ~20 MB it costs the tree.
Removed:
- `graphify-out/` untracked (7 files, ~20 MB) and now gitignored
- both `graphify hook-guard` PreToolUse hooks from `.claude/settings.json`
- the "Knowledge graph (graphify)" section of `CLAUDE.md`
- the `graphify-out/**` block from `.gitattributes` and `.gitignore`
- the graph-rebuild step from the `bughunt-run` skill, and the graph-edge
guidance from the bughunt workflow prompt
- the graphify-specific `core.hooksPath` example in `ci-check` and
`docs/contributing.md`, keeping the underlying warning in generic form
Also deletes the locally installed `post-commit` / `post-checkout` rebuild
hooks (untracked, not part of this diff).
This does not shrink clone size: the graph blobs stay in published history,
which `docs/plans/b1-repository-foundation-2026-08-25.md` explicitly rules out
rewriting. It does stop future refreshes from adding more.
Dated audit and plan documents keep their graphify references as a historical
record of the state they described.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* B1-3: repository hygiene gates (RL-19 / L-13, S-05) (#1414)
* chore(format): one Prettier config at the repository root
Every formatting rule in this repository lived under Client/ and covered
exactly two globs: Client/src/**/*.ts and Client/tests/**/*.ts. Root Markdown,
all of docs/, every YAML and JSON, all CSS, the root scripts and
tools/mcp-introspect were formatted by nothing. There was no .editorconfig.
The obvious fix -- a second Prettier config at the root for "everything else"
-- gives two configs and two ignore files that can silently disagree about the
same file. So the root takes ownership instead: config, ignore file and gate
move up, and Client/ folds in. Client's inline "prettier" block, its
.prettierignore, its format/format:check scripts and its now-unused prettier
devDependency are all deleted; knip would have failed client-check on that last
one.
The .prettierrc.json values are lifted byte-for-byte from Client/package.json,
which is what keeps the reformat commit free of client TypeScript churn: 87
tracked files need reformatting and not one of them is under Client/src or
Client/tests.
.prettierignore carries only what .gitignore does not. Prettier 3 reads the
root .gitignore by default, so node_modules/, dist/, coverage/,
Client/src/generated/ and docs/security-findings/ need no entry. It does NOT
read nested .gitignore files, which is why .remember/ is listed explicitly --
38 untracked per-machine scratch files were otherwise able to turn a shared
gate red. graphify-out/ is listed because its seven files are tracked and
.graphify_labels.json is signed byte-for-byte by its .sig, so formatting it
would silently invalidate the signature.
check:hygiene is registered in scripts/run.mjs and folded into check and
release:preflight. It deliberately contains no `gofmt -l` step: gofmt -l prints
offenders and still exits 0, so it cannot fail a build. Go formatting is
enforced separately.
shellcheck and actionlint take their file lists from `git ls-files`, never a
filesystem glob -- .claude/worktrees/ holds a gitignored pre-flatten copy of
the tree with three .sh files a glob would happily lint.
This commit leaves the tree non-conformant on purpose. The reformat is the next
commit, so the 87-file diff is reviewable separately from the rule that caused
it.
Not included: editorconfig-checker. .editorconfig is the editor baseline the
audit asked for; Prettier, gofmt and rustfmt already fail CI on the same
indentation and newline rules, so a fourth tool checking them again is a gate
with no failure mode of its own.
Verified: `npx prettier --check .` names 87 tracked files and zero untracked
ones; the same command listed 38 .remember/ scratch files before the ignore
entry and none after. `node scripts/run.mjs --list` resolves check:hygiene to 8
shell targets and 4 workflow targets. Both package.json files parse.
Refs RL-19 / L-13, S-05.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(format): reformat the tree to the repository Prettier rules
Mechanical. This commit is `npx prettier --write .` and nothing else -- the
rule that caused it landed in the previous commit so this diff can be reviewed
as a transformation rather than as 84 files of hunks.
84 tracked files: 54 Markdown, 7 .mjs, 7 JSON, 6 YAML, 4 .js, 3 CSS, 2
TypeScript (the two Playwright configs at Client's root, which the old
Client/src + Client/tests globs never covered). No file under Client/src or
Client/tests moves, because .prettierrc.json carries Client's former inline
values byte-for-byte.
Prettier rewrote 87 files, not 84. The three in .github/ISSUE_TEMPLATE/ had
CRLF on disk and differ only in line endings, which .gitattributes
(`* text=auto eol=lf`) already normalises, so their committed blobs are
unchanged. Worth knowing before someone reconciles the two numbers.
The largest single diff is .superpowers/findings-ledger.json at 7976 lines
rewritten. That is safe to format: nothing writes the ledger programmatically
-- render-ledger.mjs reads it and writes only FINDINGS.md -- so no tool will
fight Prettier over its style on the next hunt. FINDINGS.md itself is ignored
as generated.
Verified: `npx prettier --check .` reports "All matched files use Prettier code
style", so the pass is both complete and idempotent. All 7 reformatted JSON
files were parsed before and after and compared as values: semantically
identical, zero content changes. `node .superpowers/render-ledger.mjs --check`
still reports 348 valid findings and leaves FINDINGS.md untouched.
`node scripts/check-doc-counts.mjs` still passes its selftest and still agrees
on 27 claims across 9 watched documents -- table realignment did not break the
patterns it matches on. `node scripts/run.mjs --list` still parses.
Refs RL-19 / L-13, S-05.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(lint): enforce Go formatting in the Server linter
S-05: repository-wide Go formatting was not a required gate. The only gofmt
enforcement anywhere was .githooks/pre-commit, which is opt-in per clone
(`npm run hooks:install`), only sees staged files, and warns-and-skips when
gofmt is off PATH.
The obvious fix -- a `gofmt -l` step in CI -- does not work: `gofmt -l` prints
its offenders and still exits 0, so the step passes no matter what it finds.
scripts/run.mjs has the same problem, which is why check:hygiene has no Go step
either.
So gofmt goes where it can actually fail something: Server/.golangci.yml. The
file was already `version: "2"` but had no `formatters:` block at all, so the
19 enabled linters ran with zero formatters. In v2 gofmt/gofumpt/goimports
moved out of `linters.enable` into their own section with its own exclusions.
Adding it there means the gate reports through the Lint step of "Server Build &
Test", which is already pinned as required on dev -- no new job and no new pin.
Every tracked .go file is under Server/ (551 of them, one go.mod), so
Server-scoped is repository-wide here.
One file was genuinely misformatted: a one-space struct field alignment in
Server/admin/handlers_users_broadcast_test.go, fixed in the same commit because
a single line does not need its own reformat commit.
Trap worth recording: `gofmt -l .` on a Windows working tree lists every file
that has CRLF on disk, because gofmt normalises line endings. That reported 18
offenders here, 17 of them ghosts. The blobs are all LF -- .gitattributes
forces `eol=lf` -- so CI never saw them, and the honest test is to run gofmt
over `git show HEAD:<file>` rather than the working copy. Doing that across all
551 tracked Go files found exactly the one real offender above.
Verified both directions with golangci-lint v2 locally: `golangci-lint run
./...` reports 0 issues on the formatted tree; appending a misformatted
function to Server/auth/constants.go produces 2 gofmt findings; appending the
same misformatted function to Server/db/dbgen/admin.sql.go produces 0, so the
exclusion holds. Both files restored and verified clean afterwards.
Refs RL-19 / L-13, S-05.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(scripts): escape the NUL separator instead of embedding one
The `tracked()` helper added earlier in this branch splits `git ls-files -z`
output on NUL. The separator was written as a literal NUL byte rather than the
two-character JavaScript escape, so scripts/run.mjs became a binary file: `git
diff` refused to show it, `grep` reported "Binary file matches" instead of the
line, and `* text=auto` in .gitattributes stops normalising line endings for a
blob it detects as binary.
The code worked -- splitting on a raw NUL and splitting on "\0" are the same
operation -- which is exactly why this is worth fixing before it is inherited.
A source file that tooling classifies as binary is a file nobody can review.
Verified: zero NUL bytes remain, `grep -n "split("` now prints line 50 instead
of "Binary file matches", `node scripts/run.mjs --list` still resolves the same
8 shell and 4 workflow targets, and prettier still reports the file clean.
* chore(lint): enforce Rust formatting
Rust had no formatting gate of any kind: no rustfmt.toml, no `cargo fmt`
anywhere in CI, in scripts/run.mjs, in the Makefile or in the git hooks. Clippy
was the only Rust gate, and clippy does not check layout.
`cargo fmt --all -- --check` now runs in the rust-tests job, 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. The stable toolchain in that job
requested `components: clippy` only, so rustfmt is added there.
Only that job. ci.yml has a second, byte-identical `Install Rust` block in
tauri-build; it stays clippy-only, because a full desktop build is the wrong
place to discover a misplaced brace.
No rustfmt.toml. The default profile is the point of a baseline -- a config
file here would be a second opinion about style with nothing to say.
Client/src-tauri is a single `[package]`, not a workspace, so `--all` is a
safeguard against a future member rather than a fan-out today.
Verified: `node scripts/run.mjs --list` resolves check:rust to three steps with
`cargo fmt --all -- --check` first, `npm run format` now also runs `cargo fmt
--all`, and prettier reports ci.yml, run.mjs and the ci-check skill clean.
`cargo fmt --all -- --check` currently fails on 13 files -- that is the
reformat, and it is the next commit.
Refs RL-19 / L-13.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(format): reformat the Rust crate to rustfmt defaults
Mechanical. This commit is `cargo fmt --all` and nothing else; the gate that
demands it landed in the previous commit so this diff is reviewable on its own.
13 of the 17 tracked .rs files, +343/-164. The crate had never been formatted,
so the changes are the usual first-run set: aligned trailing comments collapsed
to single spaces, single-element slice literals folded onto one line, long
method chains broken across lines, closure bodies expanded into blocks.
Verified: `cargo fmt --all -- --check` is clean, so the pass is complete and
idempotent. `cargo clippy --all-targets -- -D warnings` finishes with no
warnings, and `cargo test --lib` reports 115 passed / 0 failed -- identical to
before the reformat, which is what "mechanical" has to mean for a commit that
touches this much of the crate.
Refs RL-19 / L-13.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(scripts): make the root facade actually run on Windows
Adding the first gate that a contributor would run from the repository root
exposed two bugs in the facade, both of which made it silently wrong on the
platform this project is developed on.
1. Every npm and npx step failed. bin() appends `.cmd` on Windows, but Node
refuses to spawn a .cmd or .bat with shell:false -- the CVE-2024-27980
mitigation -- and fails with EINVAL and a *null* exit status. run.mjs only
special-cased ENOENT, so the result was `FAILED: npx prettier --check .
exited null` with nothing to explain it. check:client has three npm steps and
has never been able to run here.
Fixed by spawning only the npm shims through a shell. They are concatenated
into a single command string rather than passed as an args array, because
shell:true plus a separate array is deprecated (DEP0190) and prints a warning
on every invocation; no argument in this file contains a space.
2. Every optional() step was skipped, always. onPath() shelled out to
`where` on Windows, but where.exe lives in C:\WINDOWS\System32, which a Git
Bash PATH does not necessarily contain -- on this machine PATH carries
System32\Wbem, System32\WindowsPowerShell\v1.0 and System32\OpenSSH but not
System32 itself. The probe could not start, `probe.status === 0` was false,
and golangci-lint and sqlc reported as "not installed" while installed.
Fixed by resolving against PATH and PATHEXT directly. No subprocess, and no
dependency on which directories happen to be on PATH.
A spawn error other than ENOENT now reports its code instead of surfacing as a
null exit status.
Verified: before, `node scripts/run.mjs check:hygiene` died with "exited null"
and both optional steps printed SKIP with the tools present on PATH. After, the
same command runs prettier, shellcheck and actionlint and prints
"check:hygiene: passed", with no deprecation warning. `golangci-lint` is
detected by the new onPath where the old one missed it.
Refs RL-20 / L-14.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(format): ignore build output that nested gitignores hide
Prettier honours the root .gitignore and no other. Every build and scratch
directory in this repository is ignored by a *nested* one -- Client/.gitignore,
.serena/.gitignore, .superpowers/sdd/.gitignore -- so none of them were
excluded from the new repository-wide gate.
The effect is not subtle. Running `cargo test` once drops roughly 850
formattable files into Client/src-tauri/target/, and the hygiene gate goes from
clean to "Code style issues found in 939 files". CI never sees it, because a
fresh checkout has no build output; every contributor sees it on their second
command.
Mirrors the three nested files rather than inventing a list: dist, coverage,
playwright-report, test-results, .vite, src-tauri/target and src-tauri/gen from
Client/.gitignore, plus .serena/ and .superpowers/sdd/. node_modules needs no
entry -- Prettier ignores it by default.
Verified: `npx prettier --check .` reports "All matched files use Prettier code
style" with a fully populated Client/src-tauri/target/ present on disk, and
still names README.md when a misformatted table is appended to it.
Refs RL-19 / L-13.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(ci): shellcheck, actionlint, and a repository hygiene job
The last two gates RL-19 asks for. Neither existed: the shell scripts were
never linted, the workflows were never syntax-checked, and .githooks/pre-commit
carried hand-written `# shellcheck disable=` directives that nothing had ever
read.
New `hygiene` job, ubuntu-only and root-scoped, modelled on docs-consistency
for the same reason: every gate in it is platform-independent text analysis,
and .gitattributes pins eol=lf so a second OS would only re-prove line endings.
It runs `npm run check:hygiene` -- the same entry point a contributor runs, not
a parallel copy of the commands.
shellcheck ships in the runner image. actionlint does not, so it is pinned by
version and verified by sha256: an installer script piped from a branch would
be the one unverified download in a workflow file that pins every action by
commit SHA.
Prettier's step moves here from client-check, where it no longer belongs.
Both linters found real defects.
shellcheck, 3 findings in 8 scripts. Two are SC1125 errors in
.githooks/pre-commit: `# shellcheck disable=SC2086 - repo paths contain no
spaces` is not a valid directive. Trailing prose makes shellcheck discard the
rest of the line, so neither suppression was ever in effect -- and one of the
two was written earlier in this same branch, which is a fair demonstration of
why the gate is worth having. The prose moves to its own line above. The third
is SC2015 in start-server.sh, rewritten as an explicit if.
actionlint, 5 findings, all inside `run:` blocks it shellchecks once shellcheck
is on PATH. Three SC2015 in load-baseline.yml, rewritten as explicit ifs. Two
SC2035 in release.yml, where `sha256sum *` should not become `sha256sum ./*`:
the comment four lines above records that ParseChecksumFile exact-matches the
last field, so a "./" prefix would strand every deployed server exactly as a
"windows/" prefix would. `sha256sum -- *` satisfies the linter and leaves the
output bytes identical.
Verified all three gates in both directions with shellcheck 0.10.0 and
actionlint 1.7.7 on PATH. Passing: `node scripts/run.mjs check:hygiene` prints
"check:hygiene: passed" with all three steps run, not skipped. Failing:
appending `bait_fn() { cat $1; }` to Server/scripts/voice-test.sh fails on
SC2086; changing a runs-on to `ubunt-latest` fails on runner-label; appending a
misformatted table to README.md fails prettier. All three files restored and
confirmed clean afterwards. actionlint validates the new job in ci.yml itself.
Refs RL-19 / L-13, S-05.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(plans): record B1 progress through B1-3
The header still read "B1-0 is complete; B1-1 is the next step" three merged
phases later. A plan that misstates where it is costs a reader the same
confusion whether it is stale by one phase or three.
B1-0 (#1410), B1-1 (#1411), B1-2 (#1412) and B1-3 (this branch) are done; B1-4,
dependency automation, is next.
Verified: `node scripts/check-doc-counts.mjs` still agrees on 27 claims across
9 watched documents -- this file is one of them -- and prettier reports it
clean.
* chore(ci): pin Repository Hygiene as a required check on dev
The second half of S-05. Its acceptance criterion is "tree is formatted AND a
fast required gate fails future drift" -- a check that runs but is not pinned
lets a formatting regression merge, so the gate is not a gate until this lands.
The name was read off PR #1414 with `gh pr checks` after the job reported
`pass` in 26s, not copied out of ci.yml. That order matters: the B0 script
records that three pinned names exist in no workflow file at all, and that a
required check which never reports blocks every PR forever.
Extends the existing script rather than adding a second one, per the B1 plan.
Also records, in the "deliberately NOT pinned" list, that Docs & Ledger
Consistency reports and passes on a dev PR yet is unpinned. That reads as an
oversight from the 2026-08-25 pass rather than a decision, but it belongs to
G-04, so it is documented here and not changed.
NOT APPLIED YET. Running this script now would pin a check that PR #1413 cannot
report -- its branch predates the hygiene job, so the job does not exist in its
workflow file and the check would never arrive. Run it after #1414 merges;
#1413 needs a rebase onto dev regardless.
Verified: shellcheck clean, the embedded JSON parses, and `check:hygiene`
passes with prettier, shellcheck and actionlint all running.
Refs S-05, RL-14 / G-03.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* B1-4: dependency automation (RL-05 / L-05, RL-18) (#1415)
* chore(deps): cover the root and mcp-introspect npm roots
The repository has three npm package roots — `/` (changelogen, prettier),
`/Client`, and `/tools/mcp-introspect` (@modelcontextprotocol/sdk, zod) —
each with its own package-lock.json, and `npm run bootstrap` runs `npm ci`
in all three. Dependabot watched exactly one of them. The root's prettier is
what the Repository Hygiene gate runs, so the formatting gate's own toolchain
was drifting unwatched.
The obvious fix is to collapse the three roots into an npm workspace and
watch one lockfile. Measured on npm 11.17 / Node 26 rather than assumed, that
trade is bad, and it is bad for different reasons than expected. Workspaces do
not break the things you would predict: `npm ci` inside `Client/` still exits
0, `npm run <script>` still resolves the hoisted binaries because npm prepends
every ancestor `node_modules/.bin` to PATH, and `engine-strict` still fails
the install on a wrong Node major. What they cost is ten CI steps keyed on
`cache-dependency-path: Client/package-lock.json` (six in ci.yml, four in the
tag-only, CI-ungated release.yml) pointing at a file that stops existing; the
Repository Hygiene job's deliberate root-only install growing 970 ms to
6172 ms and 39 to 318 packages unless every call site remembers
`--workspaces=false`; and one shared lockfile putting all three npm Dependabot
groups back into the same file, which is precisely the rebase storm the
grouping comment at the top of dependabot.yml exists to prevent. The measured
benefit is one 298 KB lockfile instead of three (17 KB / 253 KB / 42 KB) and
614 resolved packages deduped to 582 — 32 packages, 5.2% — with client install
time unchanged at 5642 ms against 5667 ms.
So the roots stay separate and each gets its own block, matching the four that
already exist: grouped to one PR, majors ignored, weekly on Monday. A single
block with `directories:` was rejected for the same reason as workspaces —
grouping only works while a group rewrites exactly one lockfile. The decision
and its numbers are recorded in docs/contributing.md under Dependency Policy,
so the next person to propose workspaces reads the measurement instead of
repeating it.
Verified: a coverage checker cross-references every `package-ecosystem` /
`directory` pair in dependabot.yml against every manifest in `git ls-files`,
in both directions. Against dev at 2a37f386 it reports `UNWATCHED npm
package.json` and `UNWATCHED npm tools/mcp-introspect/package.json` (plus
Server/Dockerfile, which the next commit covers). Against this commit both npm
rows read `ok`, and the forward direction confirms each newly declared
directory really holds a package.json. `npx prettier --check` reports both
edited files unchanged, so the B1-3 formatting gate stays green.
Not included: the docker ecosystem (next commit, RL-18); immutable image
digests for release/runtime containers, which is R-04's remaining half and
belongs to B6; and turning the coverage checker into a permanent
`check:hygiene` gate — a new manifest root can still drift unwatched, which is
how this gap arose, but that is new gate machinery rather than the coverage
this item asks for.
Refs RL-05 / L-05
* chore(deps): watch the server container base images
Server/Dockerfile pulls `golang:1.26-bookworm` to build and
`gcr.io/distroless/static-debian12` to run, and nothing watched either. Every
other dependency root in the repository is on a weekly Dependabot schedule, so
the one artefact that ships to users as a whole filesystem was the only one
whose upstream moved silently — including its CA certificates, which the
Dockerfile comment specifically calls out as the reason distroless was chosen
over scratch.
The obvious fix is to pin both images by digest and be done. That is the wrong
move here for two reasons. A digest pin with no automation behind it is worse
than a tag: it freezes the base image at whatever was current the day someone
typed it, and a frozen distroless base is a frozen CA bundle. And digest
refresh for release and runtime images is R-04's other half, scoped to B6
alongside the smoke tests that have to gate it — landing half of it here would
leave the digests pinned and the refresh unowned.
So this adds the `docker` ecosystem for /Server on the same terms as the five
blocks around it: grouped to one PR, majors ignored, weekly on Monday. Be
precise about what that actually buys, because it is less than the block
implies. Of the two images only `golang:1.26-bookworm` carries a comparable
version, so it is the only one Dependabot can act on today;
`gcr.io/distroless/static-debian12` has no version tag, and an untagged image
is not something a version update can move — it needs the digest pinning that
B6 owns. The comment above the block records that the Go builder tag tracks
Server/go.mod and every `actions/setup-go` in CI, so a `1.27-bookworm` PR is a
prompt to move all three together rather than a standalone merge.
Verified: the coverage checker cross-references every `package-ecosystem` /
`directory` pair against every manifest in `git ls-files`, in both directions.
Against dev at 2a37f386 the reverse direction reports `UNWATCHED docker
Server/Dockerfile`; against this commit every row reads `ok` and it exits 0 —
`ALL ROOTS WATCHED`, six blocks covering six manifest roots, with the forward
direction confirming /Server really holds a Dockerfile. `npx prettier --check`
passes on the edited file.
Not included: Server/docker-compose.yml and Server/docker-compose.otel.yml.
Their four images are `ghcr.io/j3vb/owncord-server:latest` (this repository's
own published image), `livekit/livekit-server:v1` (a floating major tag, and
majors are ignored everywhere), `jaegertracing/all-in-one:latest` and
`prom/prometheus:latest` — none of which a version update can move, so a
compose block would be configuration that provably produces nothing. Also not
included: immutable digests plus digest-refresh PRs with smoke tests for the
release and runtime images, which is the remainder of R-04 and belongs to B6.
Refs RL-18
* docs: apply skill-review findings to ci-check and the project skills (#1416)
The observation log had accumulated 46 open entries against a last review of
2026-08-14. Seven of them target skills tracked in this repository and were
verified still-unapplied against the current files.
`ci-check` gains four things it was missing. It never mentioned `cargo audit`,
which CI runs pinned at 0.22.1 in `tauri-build` — the one gate that turns red
with zero local changes, because an upstream advisory breaks a branch that was
clean yesterday, and the one a hand-written mirror silently drops because no
edit provokes it. It never mentioned that `release.yml` is tag-triggered and
PR-ungated, so a smoke/sign/strip step added only there first executes on the
release; #1376 shipped a smoke harness whose own bug then blocked a release,
and #1378 fixed it structurally by extracting `Server/scripts/docker-smoke.sh`
for both workflows. And it had no guidance for reading a red check at all: a
new section adds causality-before-forensics triage (diff the changed-file set
against the failing job's input surface before opening a log — a workflow-only
diff cannot cause a Go goroutine leak), the lockfile-fork diagnosis for
dependency bumps (a 1 → 2 entry-count transition means the update forked the
dependency and revoked the features it was borrowing, so aligning versions is
the fix, not setting the feature the new copy demands), and the known-flake
table promoted to a signature-to-recovery index, now including the apt-mirror
hang that cancels `tauri-build` by timeout.
The baseline rule that came with the triage section needed adjusting rather
than transcribing. Its source observation recorded `golangci-lint`'s known-red
complexity baseline as 23 cyclop / 6 dupl / 21 funlen / 12 nestif; #1389
cleared that to zero, so quoting those numbers would have taught the reader to
excuse a failure that is now genuinely theirs. The rule is recorded without
them, stating that the repo currently carries no known-red gate and what to do
if one is ever reintroduced.
`protocol-change` claimed the schema is the source of truth without saying what
it covers. It holds message-type names only, so a payload-field change touches
the Go command/message files, the client types and `docs/protocol.md` and never
the schema — routing one through the regenerate cycle is wasted work. A table
splits the three cases, with the relay-handler caveat: a server that
re-serialises drops unknown fields, so a forwarded field is not backward
compatible with older servers.
`task-observer`'s numbering discipline treated collisions as a parallel-human
accident. They are structural in fan-out workflows, because a dispatched
subagent has the skill active in its own context and writes to the same log.
`bughunt-run` covered findings blocked by a circuit breaker but not findings
that went stale: a later hunt routinely fixes a blocked finding as a side
effect of an overlapping sibling, and a saved debris patch stops applying once
a refactor rewrites its files. Of 6 findings blocked on 2026-08-14, 2 were
already fixed 5 days later.
`docs/contributing.md` gains the commit-body convention that was being followed
without being written down anywhere — reasoning over diff-restatement, a
`Verified:` paragraph proving both directions, and an explicit `Not included:`
line. That last one is what keeps adjacent scope from becoming either silent
drift or an unnecessary blocking question.
Verified: each edit was checked against the live file before applying, which
changed two outcomes. Observation 50 (make the hunt's stop rule measure
coverage, not just quietness) is already implemented — `bughunt-run` documents
`coverage + dry is the real stop`, `stalledCoverage` and
`coverage.uncoveredAtStop`, landed by #1399 — so it is marked actioned rather
than re-applied. Observation 42 looked covered by the same grep and was not:
the existing text handles breaker-blocked findings, a different case from a
finding a sibling fix already closed. Confirmed absent before editing:
`cargo audit` and `release.yml` in ci-check, `payload` in protocol-change,
`subagent` in task-observer. `npm run check:hygiene` passes (prettier clean on
all five files); `npm run check:docs` passes.
Not included: the 21 open observations targeting `superpowers:*` plugin skills,
which live in a versioned plugin cache and are overwritten on update — they are
being routed to a separate user-owned extras skill outside this repository. The
6 targeting `graphify` are deferred pending a decision on whether that skill is
still in use here now that #1413 removed its repository integration. The 5
new-skill candidates are noted only; a review is not permitted to create skills.
Refs skill-observations #25, #35, #39, #41, #42, #43, #45, #58, #59, #63
* B1-5: ownership moves (RL-09 / L-09, RL-10 / L-10, RL-11 / L-11, RL-13 / L-12) (#1417)
* refactor: move the protocol schema to protocol/schema.json (RL-09)
The WebSocket message-type schema is the one artifact in this repository that
neither component owns: `Server/ws/message_types.go` and
`Client/src/lib/protocolTypes.ts` are both generated from it, and neither may
be hand-edited. It nonetheless lived at `docs/protocol-schema.json` — filed
under the directory for prose, whose own README calls it "Reference" material
— and its generator lived at `Server/scripts/genprotocol/`, i.e. inside one of
the two consumers. Ownership was legible from neither location.
The obvious fix — move the generator to the repository root alongside the
schema, so the whole tool is at the cross-component boundary — is wrong here.
The generator is a Go `package main`, and Go modules are directory-rooted:
`Server/go.mod` roots at `Server/`, so a root-level Go program needs a second
module or a `go.work`. That second module would sit outside every path filter
this repository already has — `golangci-lint` runs with `working-directory:
Server/` (ci.yml), `go vet ./...` runs from `Server/` (scripts/run.mjs,
.githooks/pre-commit), `.githooks/pre-commit` selects Go files with
`^Server/.*\.go$`, `.githooks/pre-push` sets `server_changed` on `^Server/`,
setup-go caches on `Server/go.sum`, and dependabot has one gomod block for
`/Server`. Six gates would silently stop covering the generator, each failing
open. The schema is data and moves freely; the generator is Go and stays where
the Go toolchain already runs.
Done instead:
- `docs/protocol-schema.json` -> `protocol/schema.json`. A new top-level
`protocol/` is the cross-component boundary, with a `README.md` naming the
two generated consumers, the one command, and the four gates.
- `Server/scripts/genprotocol/` -> `Server/cmd/genprotocol/`, the module's
conventional home for an executable. This also empties `Server/scripts/` of
Go entry points except `seed.go`, which RL-10 moves next.
- `Server/cmd/` added to `Server/.dockerignore` and `Server/.air.toml`, which
both already excluded `Server/scripts/`. Without this the move would have
silently widened the Docker build context and the air watch set.
27 files, 115 insertions, 76 deletions. Two runtime path resolvers re-pointed
(`cmd/genprotocol/main.go:41` `-schema` default, `ws/protocol_contract_test.go:67`
`filepath.Join`); two git-hook grep patterns (`pre-commit:53`, `pre-push:57`);
eight generator call sites across five files (Makefile x2, scripts/run.mjs x2,
pre-commit x2, ci-check skill, bughunt-fix.js); two broken relative markdown
links (docs/README.md:47, docs/protocol.md:1497); two generated files
regenerated, header lines only, zero constants changed; two ledger prose hits
plus a `render-ledger.mjs` re-render. No new verify was written: the
regenerate-and-diff check is already enforced three times (CI `make
protocol-verify`, `.githooks/pre-commit`, `npm run check:server`) and
`ws/protocol_contract_test.go` independently checks the schema against the
constants a fourth time.
Verified: both directions, for both resolvers. With `protocol/schema.json`
removed, `go test ./ws/ -run TestProtocol` fails with `reading protocol schema
at /home/user/OwnCord/protocol/schema.json: no such file or directory` (two
tests) and `go run ./cmd/genprotocol` exits 1 with `read schema: open
../protocol/schema.json: no such file or directory`; with the file restored
both pass. So the new path is genuinely resolved, not merely spelled in a
comment. The hook patterns were exercised directly: the pre-commit pattern
matches `protocol/schema.json` and `Server/cmd/genprotocol/main.go` and no
longer matches `docs/protocol-schema.json`; the pre-push pattern matches
`protocol/schema.json`. `go run ./cmd/genprotocol` twice in a row leaves
`git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts`
clean, so the committed outputs are exactly what the generator emits.
`go build ./...` and `go vet ./...` pass; `npx prettier --check .`,
`npm run typecheck` and `npm run lint` pass; `node .superpowers/render-ledger.mjs
--check` reports 348 findings valid.
Not included: the four dated `docs/audit-*.md` files, the older
`docs/plans/*`, and `CHANGELOG.md` keep the old path — they are point-in-time
records, and `.prettierignore` and `scripts/check-doc-counts.mjs` already
treat them as deliberately unmaintained. The B1 plan itself keeps its own
wording, since it states intent rather than current state. `Server/scripts/`
is not deleted: it still holds `seed.go` (RL-10), `k6/`, `toxiproxy/` and two
shell scripts. `Server/telemetry/metrics.go:19` declares a scope for a
`Server/voice` package that does not exist — spotted here, unrelated to this
move, left for RL-13's sweep to carry forward verbatim rather than fixed
inside a relocation. No `seed:` Make target was added.
Refs RL-09, L-09
* refactor: move the seed tool under Server/cmd/seed (RL-10)
`Server/scripts/seed.go` was a `package main` sitting directly in
`Server/scripts/`, which made `Server/scripts` itself one of the module's
three main packages — a developer tool in the module's build graph under a
directory name that says "loose scripts". It also did filesystem work in
`func init()`: `os.MkdirAll("data", 0o750)` ran before `flag.Parse()`, so the
directory appeared even when the tool immediately refused to run.
The audit row (RL-10) claims that `init()` fires "during test discovery". It
does not, and the obvious fix aimed at that claim would be aimed at nothing:
`Server/scripts/` contains zero `_test.go` files, so Go never builds a test
binary there and `go test ./...` never runs the `init()`. The residual defect
is narrower and real — an untagged `package main` in the build graph, plus a
side effect on a path (`go run ./cmd/seed -h`) that has nothing to do with
tests.
Done:
- `Server/scripts/seed.go` -> `Server/cmd/seed/main.go`, joining
`cmd/genprotocol/` from RL-09. `Server/scripts/` now holds shell and JS
tooling only (docker-smoke.sh, k6/, toxiproxy/, voice-test.sh) and no Go
entry point at all.
- The `os.MkdirAll` moved out of `init()` to immediately before `db.Open` in
`main()` — the one call that needs the directory, since `db.Open` ->
`OpenWithMaxReaders` -> `openFile` creates no intermediate directories.
- The package doc comment's usage lines were wrong in two ways, not one: they
named `go run scripts/seed.go`, which no longer exists, and they omitted
the mandatory `-confirm-dev`, so neither documented command could ever have
run. Both corrected, and `seed.go is a standalone tool` became the
conventional `Command seed populates ...`.
- `Server/CLAUDE.md`'s Layout list now names `cmd/` and states that no Go
entry point lives in `scripts/`.
Two files, 20 insertions, 17 deletions. `go list` main packages go from
`{server, server/cmd/genprotocol, server/scripts}` to `{server,
server/cmd/genprotocol, server/cmd/seed}` — the count is unchanged at three,
which is the honest framing: this relocates a main package to a conventional
path, it does not remove one from the build graph.
Verified: both directions, by building the pre-change file and the
post-change file and running each in a fresh empty directory. Before, `seed`
with no flags exits 1 *and leaves a `data/` directory behind*; `seed -h`
exits 0 and also leaves `data/` behind. After, both exit the same way and
create nothing — `data/ exists=NO` in each case. The happy path is unchanged:
`seed -confirm-dev` in an empty directory creates `data/` at mode 0750,
writes `data/chatserver.db`, and reports 4 users / 5 channels / 31 messages;
a second run reports 0 new rows, so idempotence survives. The old documented
invocation now fails loudly (`go run scripts/seed.go` -> `stat
scripts/seed.go: no such file or directory`) and the new one is what the
comment says. All four build-tag variants compile, `go vet ./...` passes,
`gofmt -l` is clean outside `db/dbgen`, and `npx prettier --check .` passes.
Behaviour delta, called out rather than left silent: the two cases above
(`-h`, and a missing `-confirm-dev`) no longer create `./data`. That is a
change, not a pure relocation. It is the change RL-10 asks for — the remedy
text is "remove import/test-time filesystem side effects" — and the
alternative that preserves the old behaviour exactly, making the `MkdirAll`
the first statement of `main()` before `flag.Parse()`, would keep precisely
the side effect the item exists to remove.
Not included: `Server/scripts/genprotocol` was moved to `Server/cmd/` by the
RL-09 commit rather than here, so the "executable tooling under conventional
command ownership" class is closed across the two commits, not this one
alone. `filepath.Dir(*dbPath)` was evaluated for the `MkdirAll` and rejected:
it would fix a real gap (`-db /elsewhere/x.db` still creates a useless
`./data` and does not create `/elsewhere`) but it means creating an arbitrary
directory from CLI input, and that is a behaviour change past "shift it out
of `init()`" — worth its own item. No `make seed` target was added, and the
dated `docs/audit-*.md` rows naming `Server/scripts/seed.go` keep the old
path. The findings ledger has zero references to this file, so no re-render
was needed.
Refs RL-10, L-10
* test: give the cross-stack contracts a named tier (RL-11)
`Client/tests/unit/admin-static-channel-perms.test.ts` reads and executes
`Server/admin/static/index.html`. Filed under `tests/unit`, nothing about its
location or name said it locks a server-owned artifact, so a Go developer
editing the admin SPA got a red check called "Client Unit Tests" with no clue
why.
The register describes this as one file. It is not, and the measured set does
not match the description in either direction:
- Client -> Server: exactly ONE test crosses by filesystem read, not two.
`main-page.test.ts` was named in the plan but only carries a prose comment
citing `Server/admin/update_handlers.go:181` at line 1046 — no read, no
import, nothing to move.
- Server -> Client: the four tests the plan named do not cross.
`waf_test.go`/`waf_crs_test.go` set a `User-Agent: OwnCordClient/1.0`
literal that appears nowhere under `Client/`; `ws_integration_test.go:289`
and `sanitize_content_fuzz_test.go:46` are comments. The real crossing is
one the register never named: `Server/updater/updater_test.go:630` does
`os.ReadFile` on `Client/src-tauri/tauri.conf.json`.
The obvious fixes are both wrong. Moving the invariant "to the owning server
test" cannot work: `Server/go.mod` carries no JavaScript engine (no goja,
otto, v8go, quickjs, rogchap, duktape), so a Go port could only assert at the
text level like `admin/perm_grid_test.go` does — and that is not a
substitute. Flipping the guard at `admin/static/index.html:1182` to
`targetIsTouchedRole=false` reintroduces OC-0154 in full while leaving every
greppable identifier intact, so a text-level test passes on a broken file.
Relocating it to the e2e admin journey is worse: that job is
`continue-on-error: true` and deliberately unpinned ("requiring it is
theatre" — `docs/plans/b0-dev-branch-protection.sh`), so it would convert a
blocking, pinned gate into one that is green regardless. And the journey does
not cover the invariant today: `grep -Eic "perm|access|role|override|matrix"`
over its 142 lines returns 0, so the "if e2e already covers it, delete"
branch never fires.
Done — one tier, applied to the whole set, defined by artifact coupling and
placed by runtime capability:
- New `Client/tests/contract/`, holding
`server-admin-static-channel-perms.test.ts`. Same directory depth, so
`../../../Server/...` still resolves; the body is byte-identical apart from
a header naming the owner and the runner.
- `Server/updater/tauri_key_contract_test.go` splits the one cross-component
Go test out of `updater_test.go` verbatim, same `package updater`. It stays
in Go — placement follows capability, and Go parses JSON fine — so only the
file name has to declare the crossing. Without this the item would have
been "moved one file and declared the class closed".
- `npm run test:contract`, and the tier, the membership rule and a
blocking/non-blocking table in `docs/contributing.md#testing`, which
previously described no tiers at all.
- `Client/CLAUDE.md`'s tier list was missing `tests/e2e/admin` and
`tests/e2e/native` before this; it now lists all seven and states the rule.
`Server/CLAUDE.md` records why the SPA's execution-level invariant is
locked from the client tree, so nobody "fixes" it into a regex.
- Ledger `OC-0154.fix.test` re-pointed and `FINDINGS.md` re-rendered;
`.claude/workflows/bughunt.js` — the workflow that produced OC-0154 — no
longer describes the TS test surface as `tests/unit/*.test.ts` only.
- Three stale cross-stack pointers of exactly the class this item is about:
`tests/e2e/helpers.ts:348,351` and `tests/unit/types.test.ts:13` named
`docs/brain/06-Specs/PROTOCOL.md`, which does not exist (`docs/brain/` is a
gitignored path); all now name `docs/protocol.md`.
15 files, 125 insertions, 33 deletions. No CI job, workflow, vitest,
tsconfig, eslint, knip or stryker change, and no new pinned check —
`ci.yml`'s `npx vitest run --coverage` has no path filter and
`vitest.config.ts` includes `tests/**/*.test.ts`, so enforcement after the
move is bit-identical to enforcement before it. That is deliberate: `dev`
pins 11 contexts and a 12th is a branch-protection API write, not something a
PR can do, so any new job would be advisory until someone separately changed
repository settings — strictly less protection than today.
Verified: both directions, and the assertion was not weakened. Flipping
`admin/static/index.html:1182` to `const targetIsTouchedRole=false;` makes
the moved test fail (`AssertionError: expected 'DELETE' not to be 'DELETE'`);
`git checkout` of that file makes it pass again — so the invariant survived
the move intact rather than becoming a test that passes anywhere. The split
Go test's cross-boundary read is live too: with
`Client/src-tauri/tauri.conf.json` moved away, `go test ./updater/` fails
with `ReadFile(../../Client/src-tauri/tauri.conf.json): no such file or
directory` from `tauri_key_contract_test.go:20`, and passes once restored.
The full client suite is 192 files / 5257 tests passing, identical to the
count before the move; `npm run typecheck` passes, which proves
`tests/contract/` is inside the tsconfig graph and that `tests/types/jsdom.d.ts`
still resolves the moved test's `import { JSDOM }`. `npm run lint`,
`npx prettier --check .`, `go vet ./...` and `go test ./updater/` all pass.
`git grep "tests/unit/admin-static-channel-perms"` finds no survivor outside
the B1 plan itself.
Not included: nothing was deleted, because no e2e sibling covers OC-0154.
`Client/tests/types/jsdom.d.ts` was neither moved nor deleted — it is still
the only type source for the moved test's `jsdom` import. `capabilities-scope.test.ts`
and `tauri-conf-webview2-args.test.ts` read `src-tauri/` and stay in
`tests/unit`: `src-tauri` is inside the `Client` component, so they are not
contract tests, and the rule earns that rather than hand-waving it — moving
them would have forced repoints of ledger entry OC-0089 and
`docs/security.md:64` for no gain. Each gained a one-line header saying why.
`Server/admin/perm_grid_test.go` and `emoji_section_test.go` read their own
package's embedded asset and are unchanged; they are the text-level
complement to the execution-level test, not duplicates. No JS engine was
added to `go.mod`, no npm root was created under `Server/`, and no root-level
`tests/` tier was created — there is no runner for one and no way to make it
blocking from a PR. Separately noticed and NOT fixed here:
`docs/contributing.md:221` still says "All ten required checks" while
`docs/plans/b0-dev-branch-protection.sh` pins eleven since B1-3 added
`Repository Hygiene`, and `docs/plans/hp-0-scorecard-2026-08-25.md:109` is
stale the same way — that is the branch-protection item's to fix, not this
one's, and one register item per commit.
Refs RL-11, L-11
* refactor: rename the Go module to github.com/J3vb/OwnCord/Server (RL-13)
`Server/go.mod` declared `github.com/owncord/server` while the public
repository is `github.com/J3vb/OwnCord`. Nothing resolves that path — there is
no `owncord` GitHub org and no vanity-import host serving go-import metadata
for it — so every import line in the tree named a location that does not
exist. It compiles because a main module's own path is never fetched, which is
exactly why it went unnoticed.
The obvious fix — an AST-aware import rewriter (`gomvpkg`, `go mod edit`) —
is wrong here, and provably so. Six of the 722 occurrences are not imports at
all: `api/main_test.go:20` (a goleak `IgnoreTopFunction` pattern),
`telemetry/metrics.go:17-19` (three OTel instrumentation-scope names),
`invariants/syncutil_locks.go:73` (a diagnostic message), and
`invariants/syncutil_locks_test.go:56` (an import line inside a raw-string Go
fixture). An import rewriter touches none of them, and the compiler cannot
see any of them either.
Done as one scripted substitution over `git ls-files`, anchored on the full
`github.com/owncord/server` string. The anchor matters: `owncord-server` is a
different identifier — the OTel `service.name` (`config/config.go`,
`telemetry/telemetry_otel.go`) and the GHCR image name
(`.github/workflows/release.yml`, `docker-compose.yml`) — and a looser pattern
would have moved it. It is untouched: 10 occurrences across 9 files, before
and after.
350 files, 728 insertions, 728 deletions. 722 occurrences in 344 Go files,
plus `go.mod:1`, the `sed` at `Makefile:67`, `Server/CLAUDE.md:3`,
`docs/architecture/server.md:5`, and the ledger pair
(`findings-ledger.json:3758` plus a `render-ledger.mjs` re-render of
`FINDINGS.md`). Zero in any workflow, zero in the Dockerfile, zero in
`Server/.golangci.yml` (no `local-prefixes`, `gci`, `importas` or `depguard`
rule keys on the module path, so import grouping is not configured anywhere).
The plan's blast-radius estimate missed one thing, and it is the one that
would have gone red: **gofmt**. `J` (0x4A) sorts before every lowercase
letter, so in the 36 files where a module-local import shares a contiguous
group with a third-party one, the module's imports must move above
`github.com/go-chi/...`. `gofmt -l` was clean before the substitution and
listed exactly 36 files after it; `gofmt -w` on those 36 restores it to
clean. `gofmt` is an enforced gate — the `formatters` block in
`Server/.golangci.yml`, which is S-05 — so a substitution-only commit fails
Lint.
Verified: both directions, and the line accounting is exact. Every added line
in this diff contains the new module path (728) and every removed line
contains the old one (728); the count of changed lines containing neither is
**zero**, so the gofmt re-sort moved module-path lines only and touched no
third-party import. The residual check
(`git ls-files -z | xargs -0 grep -n 'github\.com/owncord/server'`) returns
exactly two hits, both deliberately out of scope: the RL-13 row in
`docs/audit-2026-08-23-repository-layout.md` and the measurement row in this
phase's own plan. The compiler-invisible half was proven by reverting *only*
`api/main_test.go:20` to the old path on the otherwise-renamed tree:
`go build ./...` and `go vet ./api/` both still pass — they see nothing wrong
— while `go test ./api/` FAILS, because the runtime function name now carries
the new path and goleak stops ignoring `ws.(*Hub).Run.func1`. Restoring the
line makes it pass. `go.sum` is byte-identical (no `go mod tidy` was run and
none was needed). All four build-tag variants compile; `go vet ./...`,
`go vet -tags otel,wazero ./...` and `go vet -tags deadlock ./...` pass;
`go test -race ./...` is 16/16 packages green; `go test -tags deadlock ./...`
passes; the tag-gated `./plugin/...` (wazero) and `./telemetry/...` (otel)
runs pass. `golangci-lint` v2.11.3 — the pinned CI version, rebuilt locally
against Go 1.26 because the packaged binary cannot load a 1.26 config —
reports **0 issues**. `go run ./cmd/genprotocol` leaves
`git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts`
clean, so the rename does not reach the generated protocol constants.
`npx prettier --check .` and `node .superpowers/render-ledger.mjs --check`
pass.
Not included: `docs/audit-2026-08-23-repository-layout.md` and
`docs/plans/b1-repository-foundation-2026-08-25.md` keep the old path — they
are the audit row and the measurement that motivated this change, and
rewriting them would erase the record of what was measured. They are why the
residual check needs a two-path allowance rather than being empty; that
allowance is stated above rather than hidden in a pathspec.
`telemetry/metrics.go:19` declares `scopeVoice` for a `Server/voice` package
that does not exist; the substitution carried the dead path forward verbatim
as `github.com/J3vb/OwnCord/Server/voice` rather than fixing it, because
correcting a real observability bug inside a mechanical rename would hide it
in a 350-file diff. It needs its own item. No `go.work`, no second module,
and no vanity-import host was set up — the new path resolves against the real
repository, but nothing imports this module as a library, so `go get`
reachability was not exercised either way.
Refs RL-13, L-12
---------
Co-authored-by: Claude <noreply@anthropic.com>
* B1-6: generated artifacts (RL-06 / L-06, RL-07 / L-07, RL-08 / L-08) (#1418)
* ci: verify FINDINGS.md against the ledger it renders from (RL-07)
`.superpowers/FINDINGS.md` is generated from `findings-ledger.json`, and
`CLAUDE.md` forbids hand-editing it — but nothing checked. The one automated
consumer, `render-ledger.mjs --check`, validates the ledger's JSON schema and
`return`s at line 116, *before* the only `render()` call at line 118, and never
opens `FINDINGS.md` at all. A stale 1.09 MB rendering passed it cleanly.
The audit says "no workflow runs it". That was true when it was written and is
not now: B1-2 (#1412) wired `--check` into the `Docs & Ledger Consistency` job.
So the gate exists, reports, and is blind to the thing its name suggests it
watches — which is worse than absent, because it reads as covered.
The obvious fix — render to a temp file and diff, as the B1 plan suggests — is
not what this repository does. It has three implementations of one idea
(`Server/Makefile` sqlc-verify and protocol-verify, `.githooks/pre-commit`,
`scripts/run.mjs`), and all three regenerate **in place** and let `git diff
--exit-code` be the differ. That needs no temp path, no cleanup, and inherits
`.gitattributes`' line-ending normalisation for free. A fourth shape would cost
a reader something for nothing.
Done:
- The gate, in all three places the existing two gates live: the
`docs-consistency` CI job, `scripts/run.mjs`'s `CHECK_DOCS`, and a new
`.githooks/pre-commit` block gated on the ledger, the rendering, or the
renderer being staged. `npm run check` never ran the renderer at all before
this, which contradicted `run.mjs`'s own stated purpose.
- `validate()` now requires `severity`. This is not a nice-to-have riding
along: `render()` sorts the open section by `SEV_RANK`, and an unranked
severity makes the comparator return `NaN`, which leaves the sort order
implementation-defined. A gate whose expected output is implementation-defined
can go red across a Node upgrade for a reason that is not drift. The
validation is what makes the gate's premise — that the rendering is a pure
function of the ledger — true rather than merely true today.
- `--stat` on the diff. Deliberate deviation from the three precedents: a fully
drifted rendering is a ~40,000-line CI log, and the exit code is what gates.
Eight files, 119 insertions, 24 deletions. The gate is one render (67-170 ms)
plus one `git diff`. Rendering subsumes `--check`, because `main()` validates
and exits 1 before it writes — so the CI job keeps both steps only so the checks
UI names which fix is needed.
Verified: both directions, and the naive test would have lied. Appending to
`FINDINGS.md` proves nothing — the renderer overwrites it, so the perturbation
vanishes and the diff comes back clean. `git diff <path>` compares the worktree
against the **index**, so the drift has to live in the index. Changing one
finding's title in the ledger and staging it *without* re-rendering — exactly
the mistake the gate exists to catch — makes `git diff --exit-code --stat` exit
1 with a one-line stat, and `.githooks/pre-commit` fail with `FINDINGS.md is
stale`. Restoring the ledger and re-rendering returns both to exit 0, and `git
status --porcelain` is clean afterwards. Severity validation both ways: setting
one finding to `moderate` makes `--check` print `INVALID OC-0001: bad severity
moderate` and exit 1; `git checkout` of the ledger makes it valid again. The
hook's grep pattern was exercised against five paths — the three
`.superpowers/` targets match, `.superpowers/sdd/notes.md` and
`scripts/check-doc-counts.mjs` do not. `node scripts/run.mjs --list` resolves
`check:docs` to three steps rather than one; `npm run check:docs` and
`npm run check:hygiene` pass, the latter with prettier, shellcheck (the new hook
block) and actionlint (the new CI step) all running for real.
Not included: untracking `FINDINGS.md` — that is the next commit, and the order
matters. L-07 requires the drift check to exist *before* the removal, because
the check is what proves the tracked copy was current at the moment it was
deleted. No `import.meta.main` guard on the renderer: no caller imports it, and
`import.meta.main` landed in Node 24.2 against an `engines` floor of `>=24`, so
it would silently no-op on 24.0/24.1 — `scripts/check-doc-counts.mjs` documents
the workaround and stays accurate. No `existsSync` guard for a missing ledger:
the unhandled rejection already exits non-zero, so CI already rejects it and
only the message is ugly, which is not drift. The `docs-consistency` job is not
converted to `npm run check:docs`; it is deliberately `npm ci`-free with direct
`node` calls in every step, and half-converting it would be worse than being
internally consistent. No `Server/Makefile` target — the ledger is
root-scoped, and `make` is not on PATH on a stock Windows box (RL-20).
Refs RL-07, L-07
* chore: stop tracking the rendered FINDINGS.md (RL-07)
The previous commit built the drift check RL-07 asked for. This is the second
half: with the check in place proving the committed rendering was current, the
rendering itself comes out of the index.
Untracking is strictly stronger than checking. A drift check watches for a
rendering that has fallen behind its source; not tracking it removes the
possibility. `findings-ledger.json` stays the only tracked copy and remains
canonical — `CLAUDE.md` tells contributors to open a PR against it — and the
1.09 MB view of it is regenerated in 67-170 ms by a command that was already
documented.
Why the drift check still had to land first, in its own commit: it is what
proved the tracked copy was current at the moment it was deleted. Deleting a
generated file you have never verified against its source is how you discover,
later, that the source was wrong. L-07 sequences it the same way — "remove the
tracked duplicate human rendering *after* deterministic on-demand/CI rendering
and a drift check exist" — and this commit is the "after".
The gate transforms rather than disappears. `git diff --exit-code` cannot watch
an untracked file, so what remains of L-07's "CI rejects generation failure or
drift" is the generation half, plus its separate "a downloadable rendering is
reproducible" clause. CI now renders **twice and compares** — which tests both:
the render must succeed (it validates and exits 1 before writing) and it must be
a pure function of the ledger. The severity rule added in the previous commit is
what makes that second property true rather than merely true today. The
rendering is then uploaded as the `findings-ledger-rendering` artifact with
`if: always()`, so a reviewer reads it without a Node run — and can read it
precisely when the job failed.
Six coordinated edits, and the fourth is not optional:
- `.gitignore` — drop the `!` negation; the `.superpowers/*` blanket takes over.
- `.gitattributes` — drop `linguist-generated=true`, now dead.
- `.prettierignore` — drop the entry; Prettier 3 reads the root `.gitignore`.
- `scripts/check-doc-counts.mjs` — drop it from `WATCHED`. A missing watched
file is pushed to `failures` and exits 1 by design, with a message telling you
to fix the list. Forgetting this line reds `Docs & Ledger Consistency` and
`npm run check` on every subsequent run.
- `CLAUDE.md` — the command stays, the "tracked artifact" framing goes.
- `.claude/skills/bughunt-run/SKILL.md` — the human gate between hunt and fix
reads this file, so it now says to generate it first. That reader is already
at a terminal that ran the renderer seconds earlier.
13 files, 103 insertions, 9,278 deletions. The check-doc-counts gate goes from
27 claims across 9 documents to 21 across 8; the six it loses were rendered
*from* the ledger they were checked against, so they were self-consistent by
construction and could only ever have failed on a stale rendering — which is the
thing that can no longer exist.
Verified: both directions. `git ls-files .superpowers/` returns exactly two
files; `git check-ignore -v .superpowers/FINDINGS.md` names `.gitignore:87`
while the ledger itself is not ignored (exit 1), so the blanket rule did not
overreach. Deleting the rendering outright and running
`node scripts/check-doc-counts.mjs` prints `21 claim(s) across 8 watched
document(s)` and exits **0** — the proof that the `WATCHED` line was dropped,
because leaving it would have failed here. `npm run check:docs` then regenerates
the file (1,087,051 bytes) and passes. Rendering twice and `cmp`-ing the results
reports byte-identical output. The pre-commit hook was exercised both ways with
the ledger staged: a severity of `moderate` fails with `findings-ledger.json is
invalid`, and a valid tree passes with exit 0. `npm run check:hygiene` passes
with prettier, shellcheck and actionlint all running for real.
Not included: `findings-ledger.json` is untouched by this commit — it is the
canonical copy and it stays tracked, at 1,205,085 bytes, which is *larger* than
the rendering just removed. Anyone reaching for the size argument should know
that untracking the rendering removes 47% of the pair and leaves the bigger,
less readable half; the reason to do it is that the rendering is 100% derived
and would otherwise write a fresh ~1.06 MB blob into permanent history on every
hunt, not that it is the heavy one. No history rewrite — the blobs already
committed stay where they are, per the B1 non-goal. `Server/Makefile` gains no
ledger target: root-scoped, and `make` is not on PATH on a stock Windows box.
Refs RL-07, L-07
* chore: stop tracking the prebuilt hello.wasm plugin example (RL-08)
`Server/plugin/examples/hello/hello.wasm` was 946,410 bytes of committed build
output — 84% of that directory — for a plugin subsystem that is disabled twice
over: it compiles only under `-tags wazero`, and `plugins.enabled` defaults to
`false`. Nothing verified it matched the `main.go` beside it.
The remedy the audit names is a compile-and-compare gate. It cannot be built,
and not for cost reasons: TinyGo embeds absolute host paths from the building
machine's Go SDK and module cache into its output and offers no `-trimpath`
equivalent, so two machines compiling identical source produce different bytes.
A byte-identity gate cannot pass in principle. What is left is a compile-only
check, and that needs three pinned downloads — TinyGo, a *second* Go SDK at
1.25.x because TinyGo 0.40.1 rejects the Go 1.26 this module pins, and Binaryen
129 — on every PR, to prove something weaker than advertised about a subsystem
that ships in zero release artifacts.
So the artifact goes and its provenance is written down instead. BPR-080 asks
that the example WASM be "reproducible **or** provenance-verified" — disjunctive
— and the second branch is the one that is actually reachable here.
The repository had already made this call for itself. `sandbox_wazero_test.go`
uses a 41-byte inline WASM literal, with the comment "Using a literal here
avoids dragging a binary asset into the repo." This extends that from the tests
to the example.
Done:
- `git rm --cached` the artifact; a narrow `.gitignore` entry naming the exact
path. Deliberately **not** a blanket `*.wasm`: `Client/public/rnnoise.wasm` is
a vendored npm artifact this repository does not build and the client fetches
at runtime, so ignoring it would break noise suppression. The rule that
separates them — untrack build output whose source we own and whose absence
breaks nothing; keep vendored third-party artifacts required at runtime — is
written into the ignore comment.
- `Server/.dockerignore` gains `plugin/examples/`. `Dockerfile` does `COPY . .`
and the file already excluded `scripts/` and `cmd/` but not this, so a
developer who still has the untracked artifact on disk was shipping it into
the build context. Same omission B1-5 fixed for `cmd/`.
- The README carried two false statements, both now removed: it claimed the
plugin is "used by `Server/plugin/plugin_test.go`" and that that test
"exercises the manifest parser and the loader against this directory".
Neither is true — `plugin_test.go` builds every fixture in `t.TempDir()`.
- A Provenance section: TinyGo 0.40.1 + Go 1.25.3 + Binaryen 129, why the output
is not byte-reproducible, and why the compile gate is deferred rather than
merely absent.
- The ABI-stability sentence L-08 requires, which existed nowhere in the
repository: the ABI is experimental with no compatibility promise, and both
halves of "disabled" are named with the files that prove them. Verbatim
identical in the example README and `docs/contributing.md`.
- The TinyGo/Go/Binaryen table existed in two hand-maintained copies that had
already drifted in wording. It now lives in the example README only;
`docs/contributing.md` links to it, which is the pattern that page already
used two lines above for the ABI itself.
Five files, 87 insertions, 20 deletions, plus the 946,410-byte deletion.
Verified: both directions. The inertness proof is the load-bearing one, and it
is the inverse of B1-5's remove-and-watch-it-fail, because here passing is the
point: with `hello.wasm` moved out of the tree entirely, `go build ./...`,
`go build -tags wazero ./...`, `go vet ./...`, `go test ./plugin/...`,
`go test -tags wazero -count=1 ./plugin/...` and `go test ./api/...` all pass.
`go list ./plugin/...` returns a single package with and without the tag, so
`//go:build tinygo` keeps the example out of the module's build graph. The
narrowness proof is one pair: `git check-ignore -v` matches
`Server/plugin/examples/hello/hello.wasm` at `.gitignore:59` and exits 0, and
exits 1 on `Client/public/rnnoise.wasm`, which `git ls-files` confirms is still
tracked. `git ls-files Server/plugin/examples/` now returns exactly the three
source files. `npm run check:hygiene` and `npm run check:docs` pass.
Not included: no CI compile-and-compare job, per the reasoning above — deferred
to B2, which the issue register already names as L-08's second phase. **L-08 is
not claimed closed**: its closure evidence reads "Deterministic source build
passes", and that is precisely what TinyGo cannot deliver here; the register's
B1/B2 span is what makes deferring it in-scope rather than a slip. No
`tinygo.version` pin file — `Server/sqlc.version` earns its existence through
four mechanical consumers, and nothing would read this one; the gap in
`docs/contributing.md`'s toolchain-pinning policy is closed by recording TinyGo
and Binaryen as a documented exception instead. `main.go`, `plugin.json` and the
README stay tracked — L-08 says keep the source, and this commit keeps all of
it. `.gitattributes` keeps `*.wasm binary`, which still covers the client's
vendored module. No history rewrite: the artifact's existing blobs stay where
they are, per the B1 non-goal.
Refs RL-08, L-08
* docs(plans): retire the removed graphify tooling from the B1 plan (RL-06)
RL-06 asked for a 20.41 MB tracked `graphify-out/` payload to stop being
tracked, after a portable regeneration command and a CI artifact existed.
None of that happened. Instead `a5f7d95` (#1413) deleted the tool outright,
taking all 7 tracked files with it — 20,408,656 bytes, `graph.json` at
19,463,420 — before B1-6 opened. `git ls-files` matches nothing graphify-related
today.
So the outcome RL-06 wanted holds (no large tracked payload, history intact) and
the method it prescribed was bypassed. There is nothing left to do in the
repository. What was left is a documentation problem, and a live one: this plan
is an active document, and it still told a reader to run a tool that does not
exist.
The obvious response — delete every graphify mention — is wrong twice over.
The `.gitignore` rule has to stay: the local directory reached ~208 MB with
cache and dated snapshots on the machine that ran the tool, and dropping the
rule would flood that contributor's `git status` with untracked noise. And the
"do not rewrite history to shrink graphify-out" non-goal has to stay too: the
files are gone from the tree but four `graph.json` revisions remain in the pack
(~71 MiB logical, ~3.2 MiB packed of 13.28 MiB), so the line is still operative.
It is what keeps "closed" honest rather than overclaiming.
Done — nine edits, each a dead instruction rather than a stale mention:
- **B1-2 Step 7, the worst of them.** It told a human to `unset
GRAPHIFY_SKIP_HOOK`, run `graphify update .`, and `git commit -am` a refresh.
The tool is gone, and `git commit -am` with nothing to commit exits non-zero
while reading like a no-op success. Replaced with a retirement note; Step 7 is
the last step, so nothing renumbers.
- **The "Traps carried forward" entry.** A live instruction, in a list of traps,
aimed at exactly the multi-commit sequence this phase is. Deleted.
- B1-1 Step 1's `export GRAPHIFY_SKIP_HOOK=1` and its four-line hook rationale,
collapsed to one sentence of history. The "close any editor, cargo, vite"
paragraph beside it is still true and stays.
- The RL-06 verdict row, the B1-6 bullet, the flatten's "leave alone" list, the
`post-commit` parenthetical, B1-3's exclusion list, and the non-goal line.
- `.gitignore`'s stale "delete the dir when convenient" TODO becomes a recorded
decision citing the commit that caused it.
Verified: `git grep -i graphify` outside the dated audit and the issue register
returns exactly five hits, and every one is intended — the `.gitignore` rule and
four plan lines that are explicitly retirement or history notes ("once began",
"Retired", "closed by deletion", and the non-goal). `git grep
GRAPHIFY_SKIP_HOOK` returns one hit, the sentence recording that it used to be
required. `node scripts/check-doc-counts.mjs` still passes — this file is one of
the documents it watches — and `npx prettier --check` is clean after the
verdict-row rewrite reflowed the table.
Not included: `docs/audit-2026-08-23-repository-layout.md` keeps its RL-06 row —
dated point-in-time snapshot, and `check-doc-counts.mjs` already classifies
`docs/audit-*` as report-only. `docs/plans/repo-health-issue-register-2026-08-23.md`
keeps L-06 and the R-03 row that routes to it, and the reason is *not* that it
is dated: it is in the watched set, i.e. this repository treats it as active. It
is that no B1 phase has updated its closure column, so L-01, L-04, L-05 and
L-09 through L-13 are all closed in fact and open on paper. Changing that
convention in the phase with the least to say about it would leave the register
half-updated, which is worse than uniformly stale. That sweep belongs to `R-06`,
or to one pass at B1's end. No history rewrite, per the non-goal this commit
deliberately keeps.
Refs RL-06, L-06
* docs(plans): record B1 progress through B1-6
The header still read "B1-3 are complete; B1-4 is the next step" three merged
phases later — B1-3 (#1414), B1-4 (#1415) and B1-5 (#1417) have all landed, and
B1-6 is this branch.
B1-3 set this convention with its own `docs(plans): record B1 progress through
B1-3` commit, and then B1-4 and B1-5 both skipped it. A plan that misstates
where it is costs a reader the same confusion whether it is one phase stale or
three; three is just harder to notice, because the header looks deliberate.
Verified: `node scripts/check-doc-counts.mjs` still agrees on 21 claims across 8
watched documents — this file is one of them — and prettier reports it clean.
Refs RL-06 (the phase this records), R-08
* chore(ci): pin Docs & Ledger Consistency as a required check on dev
The previous commits gave `Docs & Ledger Consistency` a gate that can actually
fail: it now rejects a ledger that will not render, on top of the schema check
it already ran. But the job is not among `dev`'s required contexts, so it
reports and cannot block. L-07's closure evidence reads "CI **rejects**
generation failure or drift" — reporting is not rejecting, and the item is not
closed until this lands.
The script's own header already diagnosed the omission: it listed
`Docs & Ledger Consistency` under "deliberately NOT pinned" with the note that
it "looks like an oversight from the 2026-08-25 pass rather than a decision".
That entry is now wrong in the other direction, so it moves out of the
not-pinned list and into a dated note beside `Repository Hygiene`'s.
The name was read off **PR #1418's live check runs** after the job reported
`success` — not copied out of `ci.yml`. That order is B1-3's rule and it is not
pedantry: the B0 script records that three of the pinned names exist in no
workflow file at all, because CodeQL runs from GitHub default setup.
Two count claims move with it. `docs/contributing.md` said "All ten required
checks" and the HP-0 scorecard's table said **10**, both stale since B1-3 added
`Repository Hygiene` and now doubly so. B1-5 spotted the first and deferred it
to "the branch-protection item's to fix"; this is that item, and it is also the
commit that changes the number, so leaving them stale here would make this
commit the proximate cause of a documented inconsistency. The scorecard is in
`check-doc-counts.mjs`'s watched set — the repository classifies it as active,
not as a frozen snapshot — so the don't-edit-dated-docs rule does not shield it.
Its pinned block gains both names and a line recording when each was added.
NOT APPLIED YET. Running this script is `gh api -X PUT
repos/J3vb/OwnCord/branches/dev/protection`, a repository-settings write this
session cannot perform. Run `bash docs/plans/b0-dev-branch-protection.sh` after
this PR merges.
The pre-flight is clear, stated positively rather than assumed: a required check
that never reports blocks every PR forever, which is the hazard B1-3's own
NOT-APPLIED note was about. It does not apply here. `Docs & Ledger Consistency`
has existed in `dev`'s `ci.yml` since #1412, so no in-flight branch predates the
job, and it reported `success` on this PR in 11 seconds.
Verified: `bash -n` and `shellcheck` are clean. Extracting the heredoc and
parsing it with `node` reports **12** contexts including
`Docs & Ledger Consistency`, spelled exactly as the live check reports it — the
JSON is machine-checked rather than eyeballed, because a typo here is a branch
that cannot merge. `node scripts/check-doc-counts.mjs` still agrees on 21 claims
across 8 watched documents, the scorecard among them, and
`npm run check:hygiene` passes with prettier, shellcheck and actionlint all
running.
Not included: the script is not run — that is the owner's step, above. No other
context is added or removed; the four remaining "deliberately NOT pinned"
entries keep their recorded reasons, including `Admin Panel E2E`, whose
`continue-on-error: true` still makes requiring it theatre until `R-01`
graduates it.
Refs RL-07, L-07, RL-14, G-03
---------
Co-authored-by: Claude <noreply@anthropic.com>
* B1-7: community intake and automation authorization (RL-21 / L-15, RL-22 / L-16, RL-16 / R-09) (#1419)
* ci(claude): constrain automation triggers and bound run cost (L-16)
The Claude Code workflow consumes a metered credential, and the repository
stated nothing about who may spend it or for how long. Whatever downstream
behaviour happens to hold, an invariant this repository depends on should be
asserted and tested here, not inherited from a pinned dependency that a routine
version bump can re-derive.
Three controls, in the one workflow that spends:
- **Authorization.** The job condition now requires the actor to be on an
explicit maintainer allowlist as well as the trigger text to mention the bot.
An allowlist rather than an association check: this repository has exactly one
collaborator, the term is unambiguous to read and to review, and it matches
the actor-term pattern `ci.yml` already uses to exclude Dependabot. Adding a
login is a one-line edit, which is the honest cost.
- **Duration.** `timeout-minutes: 30`, in the band every other long-running job
here uses. Without it the job inherits GitHub's 360-minute default — the wrong
ceiling for metered work, and the only job in the repository that lacked one.
- **Fan-out.** A `concurrency` group keyed on the issue or pull request number
with `cancel-in-progress: true`, so repeated triggers on one thread collapse
into a single run instead of running in parallel. Exactly one of
`github.event.issue.number` and `github.event.pull_request.number` is present
per triggering event, so the key is stable across all four.
The `permissions:` block and the checkout are deliberately untouched. The
permissions are already minimal and the checkout takes no `ref:`, so it reads
the base branch rather than proposed code — both correct, and rewriting either
would be churn.
`scripts/check-workflow-guards.mjs` keeps all three from silently regressing.
Modelled on `scripts/check-doc-counts.mjs`: same `--selftest`-then-assert shape,
same dependency-free approach. It is text-level rather than YAML-parsed on
purpose — the root has no YAML parser, and adding a dependency to assert that a
file contains a `timeout-minutes` key would be a poor trade. That limit is
stated in the file: these are presence-and-shape checks, not semantics.
It runs from `CHECK_HYGIENE` in `scripts/run.mjs`, so it is reachable as
`npm run check:hygiene` locally and executes inside `Repository Hygiene`, which
is already a pinned required check on `dev`. No new CI job and no new pin — the
guard is blocking from the moment it lands.
`actionlint` cannot do this job. It validates expression syntax, action inputs
and runner labels; a job condition is valid input to it whatever the condition
admits, and it has no notion of cost at all. The two tools are complementary
and both now run.
Also corrects the record: `docs/plans/b1-repository-foundation-2026-08-25.md`
claimed impact was bounded by read-only content permissions. The workflow's own
token block is least-privilege, but that is not the only identity a run can
hold, so the claim was narrower than the truth and is now stated accurately.
And `docs/security.md` gains the private-coordination section that two planning
documents already cite it for. The citation pointed at a policy that was not
written down; it now says what stays private, that the rule covers the
repository's own automation and settings rather than only product code, and
that a commit message on a public repository is a disclosure channel.
Verified: both directions, per guard. `node scripts/check-workflow-guards.mjs`
exits 0 on the current tree and reports four guards present. Deleting the
`timeout-minutes` line makes it exit 1 naming that guard and the invariant to
restore; replacing the actor term with `true` makes it exit 1 naming that one;
restoring each returns exit 0. `--selftest` passes eight assertions covering
every guard's absence, a commented-out guard (which must not count), and the two
shapes that must not trip it — any positive timeout value, and any concurrency
key. `npm run check:hygiene` passes with prettier, shellcheck, actionlint and
both new steps running for real; actionlint accepts the edited workflow.
Not included: the workflow's `permissions:` block and checkout step, per above.
No change to the action version or its inputs. `ci.yml`, `release.yml` and
`load-baseline.yml` are outside this item — none is reachable the same way, and
each already carries per-job least-privilege permissions, and where relevant a
timeout and a concurrency group. `METERED` in the new script lists one workflow
because one workflow spends; a second entry is a one-line change when that
changes.
Refs RL-22, L-16
* feat(intake): structured bug form, and route ideas to Discussions (RL-21)
Both issue templates were Markdown with front matter, so nothing they collected
was structured, required, or validated. A reporter could submit the form
untouched. The Environment block was three bullets with `Windows 11` prefilled
as the OS — the single most common answer, pre-filled, on a project that ships
Windows and Linux builds and an ARM64 client.
And `feature_request.md` existed at all, which is the direct violation: BPR-100
says Issues is the bug tracker and Discussions hosts support, ideas and
community feedback. A feature-request template routes ideas into Issues by
construction.
Done:
- `bug_report.md` → `bug_report.yml`, a real issue form. Six fields are
`validations: required` — what happened, steps to reproduce, component, OS,
architecture, deployment mode — because those six are what turns a report into
something reproducible. The rest are optional on purpose; a form that demands
everything gets abandoned.
- `feature_request.md` deleted. Nothing in the tree referenced either template
by filename, so this breaks no link, script, or workflow.
- `config.yml` gains three routed destinations and keeps `blank_issues_enabled:
false` — which is what makes the routing hold, since a blank issue bypasses
every form and every warning on one.
The new environment fields are drawn from what this project actually ships, not
from a generic template:
- **Architecture** x64 / ARM64, with the note that ARM64 is the Linux desktop
client today and there is no ARM64 server release.
- **Deployment mode** covering the six paths `docs/deployment.md` documents —
prebuilt binary on either OS, from source, Docker/Compose, systemd, Windows
service.
- **TLS mode** matching `tls.mode`'s four values exactly, `off` quoted so YAML
does not read it as boolean false.
- **Network topology** — direct, port forward, reverse proxy, Tailscale — because
voice bugs in particular bifurcate hard on this, and the reverse-proxy path
cannot carry the WebRTC UDP range at all.
- **Separate client and server versions.** They are obtained differently and can
legitimately differ. The server field says where to look — admin panel or the
startup banner — and explicitly tolerates "unknown", because the version is
deliberately absent from the unauthenticated `/health` endpoint as
anti-fingerprinting hardening, so a non-admin reporter genuinely cannot get it.
- **Client webview**, WebView2 or WebKitGTK. No "PWA" option: no PWA exists, B1
excludes browser and PWA work, and BPR-092 forbids presenting unavailable
behaviour as functional. The field is diagnostic today regardless — the desktop
client renders through the OS webview, and that already drives real bug classes.
Every public template now carries the disclosure warning BPR-101 asks for, and
the security contact link is first in the chooser, above the Discussions links.
Four files, 189 insertions, 58 deletions.
Verified: both files parse as YAML, and the form was checked against the issue
form schema rather than only for parseability — 13 body elements, 12 unique ids
with no collisions, every non-markdown element carrying an id and a label, every
dropdown carrying options, and the markdown block carrying neither an id nor
validations (both of which GitHub rejects). `config.yml` has
`blank_issues_enabled: false` and four contact links each with exactly
name/url/about. `npm run check:hygiene` passes.
The gap that verification leaves, stated plainly: nothing in this repository
validates issue-form schema. Prettier confirms the YAML parses and actionlint
does not read `.github/ISSUE_TEMPLATE/` at all, so a file that is valid YAML but
an invalid form disappears from the "New issue" chooser silently. The checks
above are a local stand-in, not the real gate. The live chooser needs a look
after merge — which BPR-100's closure evidence ("dry-run submissions reach the
intended destination") requires in any case.
Not included: the Discussions `?category=` slugs are written as `q-a` and
`ideas`, GitHub's defaults. If this repository's categories were renamed, a
wrong slug drops the user on the category picker rather than erroring — confirm
against the live Discussions tab before relying on them. No PR-template or
documentation changes here; those are the next commit. L-15 is not closed by
this commit alone: BPR-100 names six surfaces and three of them are docs.
Refs RL-21, L-15
* docs(intake): route contributors, and state the security path (RL-21)
The previous commit fixed the forms. This is the half BPR-100 and BPR-102
actually ask for and the B1 plan's bullet does not mention: their closure
evidence names repository navigation, support links and contribution docs
alongside the issue forms, so a `.github/`-only change cannot satisfy either.
Three gaps, each verified rather than assumed:
**Discussions was invisible.** The only link to it anywhere in the tree was
inside `.github/ISSUE_TEMPLATE/config.yml` — the new-issue chooser. So "route
ideas and feedback to Discussions" worked for exactly one audience: people who
had already decided to file an issue. `README.md` and `docs/README.md` now each
carry the routing, so it is reachable from the two pages a newcomer actually
lands on.
**`docs/contributing.md` never mentioned security reporting.** Five files
carry the "never a public issue" rule — the root `README.md`, `CONTRIBUTING.md`,
`SECURITY.md`, `docs/security.md`, `CLAUDE.md` — and every one of them delegates
the full process to `docs/contributing.md`, which is also the document BPR-102's
evidence row sends a fresh contributor to. It said nothing about it. It now has
a routing table and a security section that says the thing that actually matters
on a public repository: the PR description, the commits and the branch name are
disclosure channels, so a fix for a vulnerability describes the control it adds
and nothing else.
**The README contradicted the issue chooser.** The banner said "there's no
support" while the chooser offered a link named "Community Support". Both were
defensible in isolation and together they told a user two different things
before they had read anything else. The banner now says the honest version — no
support *commitment* — and a "Getting Help and Reporting Problems" table names
the right destination for each kind of message without promising a response.
Also in the PR template, which the audit's remedy names as "PR guidance":
- The Test Plan asked for `npm test` / `go test ./...` / `npx tsc --noEmit`.
Those predate B1-4's root facade; `npm run check` is the entry point CI gates
on and the one `CONTRIBUTING.md` and `README.md` now tell people to run.
- A generated-files checkbox naming all five, since CI fails on drift and a
hand-edited generated file is the failure that wastes a cycle.
- A `Not included:` prompt, because `docs/contributing.md` makes a written
deferral a required commit element and the template asked for it nowhere.
- The disclosure warning BPR-101 wants on public templates.
Two stale claims fixed while in these files: `docs/contributing.md` said "ten
status checks are required" three lines from a section that says twelve, and
`docs/plans/README.md` still read "B1-0 done, B1-1 next" six phases later — in
the index that declares itself the authority over plan headers.
Five files, 70 insertions, 12 deletions.
Verified: `git grep "ten status checks"` returns nothing.
`node scripts/check-doc-counts.mjs` still agrees on 21 claims across 8 watched
documents — `docs/plans/README.md` and `README.md` are both watched, so a
count claim broken by these edits would have failed here.
`npm run check:hygiene` passes with prettier, shellcheck, actionlint and the
workflow-guard check all running.
One nearby claim checked and deliberately left: `docs/contributing.md` also says
"four of the ten" a hundred lines later. That is four of ten *CI steps keying on
a cache-dependency-path*, not required checks — correct in context, and changing
it would have been a wrong fix to a right-looking grep hit.
Not included: L-15 is **not** closed. BPR-100's closure evidence requires
dry-run submissions that reach the intended destination, and BPR-102's requires
a fresh Windows and Linux contributor to follow these docs and land a passing
sample change. Neither is a file edit. BPR-101 additionally wants a tabletop
report proving private receipt, triage, advisory and coordinated disclosure —
no such artifact exists in the tree, and this commit does not create one.
`CODE_OF_CONDUCT.md` and `GOVERNANCE.md` do not exist in this repository; adding
them is community-health scope, not RL-21's, and neither is named by the audit
row or the register row.
Refs RL-21, L-15
* ci(release): require exact-SHA gate evidence before publishing (RL-16)
A tag push starts `release.yml` and nothing else — `ci.yml` has no `tags:`
trigger. And `release.yml` re-runs none of the required checks: it verifies the
version, builds, boot-smokes and signs, which is a different question from
"did the gate pass on this commit". So a tag could publish from a commit whose
CI was red, and nothing would notice.
It already has. `v1.2.0-alpha.3` published from `fb04a579`, whose CI run
concluded **failure** — `Server Build & Test (windows-latest)`, the race and
coverage step. The Release run on the same commit went green and shipped. That
is R-09 demonstrated rather than hypothesised, and it is the fixture this commit
is verified against.
The obvious fix — re-run the test suite inside `release.yml` — is the wrong one.
It would double the tag-time cost, still not cover the checks that run in other
workflows (CodeQL's three `Analyze` jobs exist in no workflow file at all), and
answer a weaker question: "does it pass now" rather than "did the gate pass on
this commit". The evidence already exists; nothing was reading it.
Done:
- `scripts/verify-gate-evidence.mjs` resolves the tagged SHA's check runs and
asserts every required context is present and `success`. `skipped` and
`neutral` are not success — a required check that skipped on the tagged commit
proves nothing about it — and a still-`in_progress` check is called out as
unfinished rather than treated as absent. Where a context reported more than
once, the latest attempt decides, in both directions.
- The required set is **parsed out of `b0-dev-branch-protection.sh`**, not
restated. Pinning a thirteenth check cannot leave this gate behind, and a
change to that file's shape fails the self-test rather than silently
weakening the gate.
- A `gate-evidence` job in `release.yml` that `verify-versions` needs. Every
build job already needs `verify-versions` and both publishers need those, so
one edge gates the whole graph — including the GHCR push, which today can
mutate `:latest` before `publish` has run at all.
- `permissions: checks: read` and nothing else.
It is a script rather than a `run:` block because of the rule in the `ci-check`
skill: a step that exists only in `release.yml` first executes at tag time, so
its own bugs surface on the release. `Server/scripts/docker-smoke.sh` is the
worked example — one script, two call sites. Here the second call site is
`--selftest`, run by `ci.yml`'s docs-consistency job on every pull request.
`docs/plans/b1-release-tag-protection.sh` covers the half a workflow file
cannot express: a ruleset on `refs/tags/v*` blocking update and deletion, and a
`release` environment with a required reviewer. **NOT APPLIED** — both are
repository-settings writes this session cannot make. Run
`bash docs/plans/b1-release-tag-protection.sh` when you want them.
Deliberately **no `environment: release` key** in `release.yml` yet. The key is
PR-landable, but naming an environment that does not exist stalls the next
release; the script says to add it after creating the environment, and says why.
Verified: both directions, on real data rather than only fixtures. Feeding the
actual check runs from `fb04a579` — the commit alpha.3 shipped from — through
`evaluate` returns **NOT RELEASABLE**, naming `Server Build & Test
(windows-latest): failure` first. Feeding PR #1418's real check runs on
`8875238` returns **RELEASABLE**, and correctly ignores the red
`github-advanced-security` result because it is not a pinned context — the gate
tracks the required set, not "everything is green". `--selftest` passes 12
assertions covering a missing check, a failure, an unfinished run, `skipped`,
`neutral`, both re-run orderings, an unrequired extra, and a commit with no
checks at all. `bash -n` and `shellcheck` are clean on the new script and both
its heredocs parse as JSON. `npm run check:hygiene` passes with actionlint over
both edited workflows.
The module gained a direct-invocation guard so it can be imported and tested
without reaching the network — compared against `argv[1]` rather than
`import.meta.main`, which needs Node 24.2 against an engines floor of `>=24`
and would silently no-op on 24.0.
Not included: the network path itself is exercised only at tag time. The
self-test covers the decision logic and the required-set parsing, which is where
the bugs live; a live API call needs a token this environment does not have.
R-09's "protected release approval" limb stays open until the settings script is
run — the register phases R-09 **B1/B10**, so that half is B10's. `release.yml`'s
version stamping, both signing keys, the fail-closed minisign verify,
`checksums.sha256`'s bare filenames, both cold-boot smokes and the `git archive`
source snapshot are untouched; the remedy says to retain them and this commit
only adds an edge in front of them.
Refs RL-16, R-09
* docs(plans): record B1 progress through B1-7
B1-6 (#1418) merged and B1-7 is this branch, so the header and the plan index
both move on. B1-8 — the platform contract map — is next, and it is documentation
only: it records the browser-neutral contract folders and their owners, and moves
no native behaviour. Adapter extraction stays B7.
Verified: `node scripts/check-doc-counts.mjs` still agrees on 21 claims across 8
watched documents, both edited files among them; prettier clean.
Refs R-08
---------
Co-authored-by: Claude <noreply@anthropic.com>
* B1-8: platform contract map, HP-1 structural review, and the B1 exit gate (RL-02 / L-02) (#1420)
* docs: record the desktop/browser platform contract map (B1-8, RL-02/L-02)
Client/src/platform/ does not exist — no commits, no files, zero importers.
RL-02 asked for the boundary to be *recorded* in B1 so that B7 executes a
decided plan rather than rediscovering the surface. This is that record, and
nothing more: no directory, no interface, no code.
Measured against dev @ eb873fe7, not estimated: 20 files under Client/src/
import @tauri-apps, using 26 distinct invoke command names against 30
#[tauri::command] handlers, with zero dangling calls and zero uses of the
window.__TAURI__ global. Every native dependency is an import, so a static
check can find all of them — which is what BPR-025 will eventually enforce.
The count is 26 and not 22 because Client/src/lib/ws.ts binds core.invoke to a
local tauriInvoke before calling it; a regex matching only invoke("…") misses
ws_connect, ws_send, ws_disconnect and accept_cert_fingerprint. Any future
lint rule enforcing the seam has to match the binding, not the call site.
The 20 files collapse into 13 capability clusters, three of which have no
browser equivalent and are flagged as product decisions rather than shims:
certificate TOFU in ws.ts, the OS keychain behind credentials.ts/identity.ts,
and out-of-focus push-to-talk in ptt.ts.
Ownership is recorded by phase (B7/B8/B2). No human owners exist for these
folders anywhere in the repository; the document says so rather than leaving
the absence to read as an oversight.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: perform the HP-1 structural review and measure the B1 exit gate
HP-1 asks whether B1's migrations were mechanical. It had never been run, and
it cannot be run against dev: dev is squash-merge only, so #1411 landed as one
commit and the pure-move/path-rewrite separation the hold point exists to
review survives only on refs/pull/1411/head. The scorecard records the
pre-squash SHAs so the review is reproducible.
Four proofs, all passing:
- Pure move (4befe699): 473 renames, all R100, zero non-rename entries, zero
line changes, and every renamed blob byte-identical. The blob-OID comparison
is what actually covers the six binaries — --numstat prints "-" for them, so
the obvious line-count filter reports false positives.
- Path rewrite (38ddca73): 983 added / 983 removed, and after normalising the
substitution, six unpaired pairs remain — all relative-path depth arithmetic
from losing one directory level. Each was resolved against HEAD. The release
signer is among them and runs only on a tag, so no CI run on any branch
executes it; it is correct (working-directory: Client, artifacts at the root)
and guarded by a downstream verify step that fails closed.
- Go module rename (7a4e5dc3): 350 files, 728/728, zero unpaired lines. The
largest change in B1 is provably a pure substitution.
- Active path inventory: 11 files still name tauri-client, all historical —
ledger lens labels, dated audits, and plans that describe the move. Zero in
code, workflows, scripts, hooks or the Dockerfile.
The seed move (93ee14d5) does change behaviour — init() deleted, os.MkdirAll
moved into main(). That was authorised by the plan and is isolated in its own
commit, which is what HP-1 asks for.
Exit gate: seven of eight conditions evidenced. Condition 6 is recorded as
PARTIALLY MET and is a real gap — dev has 11 required checks pinned but
strict:false, so when dev advances after a PR goes green that PR can still
merge without re-testing, and the squash commit that lands was never itself
tested. Deliberately not changed here: flipping strict forces a rebase on every
open PR whenever another lands, and enforce_admins is on. Owner's call.
ENV-01 is closed. Every B0 number was measured on Node 26 while CI pins 24. The
client suite now re-runs on Node 24 from a fresh clone in a node:24 container:
192 files, 5257 tests — identical to B0, and the clone doubles as the exit
gate's Linux setup smoke. ENV-02 also reproduces at 50.1 MB booting on :8443.
Corrects the plan's stale Docker command along the way: the script moved to
Server/scripts/ and now takes the image as an argument, and the build context
is Server/ rather than the repository root — building from the root streams the
whole working tree and then fails on the missing go.mod.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: record the applied repository settings in the HP-1 scorecard
Both checked-in settings scripts were run on 2026-08-27 — they had landed in
#1418 and #1419 but were deliberately never executed, because repo-settings
writes need a person.
b0-dev-branch-protection.sh pinned the twelfth required check on dev,
"Docs & Ledger Consistency". Until that run the FINDINGS.md drift gate reported
but could not block a merge. Condition 6 now reads 12 pinned checks; it stays
PARTIALLY MET because strict is still false, which the script itself encodes as
a deliberate choice.
b1-release-tag-protection.sh created the "Release tags" ruleset (active, target
tag, refs/tags/v*, blocks update and deletion, zero bypass actors) and the
release environment with one required reviewer. Checked for a pre-existing
ruleset of that name first — the POST half is not idempotent and a second run
would have created a duplicate. Three rulesets existed, all targeting branches,
none named "Release tags".
Condition 7 closes: B1-7 merged, and the Discussions slugs its issue-template
config hardcodes — q-a and ideas — both exist, so the contact links resolve
rather than silently dropping the user on the category picker.
Two things the read-back surfaced, both recorded as open, neither blocking:
- The release environment has can_admins_bypass: true, GitHub's default. The
ruleset has zero bypass actors, but the reviewer gate does not. Moot while
the sole admin is also the sole reviewer.
- claude.yml passes secrets.CLAUDE_CODE_OAUTH_TOKEN and the repository has no
such secret. Nothing is failing, because all five issue_comment runs are
skipped at the B1-7 guard before the missing secret would matter — but the
paid-automation surface RL-22 hardens is inert today.
environment: release is still absent from release.yml, deliberately. The
environment now exists, so that is a separate two-line change.
Gate re-run after rebasing onto c0c87366 so condition 8 is measured over the
final tree, B1-7 included: green, 5257 client tests, exit 0. B1-7's
check-workflow-guards.mjs runs locally; its sibling verify-gate-evidence.mjs
does not — CI runs the selftest, and the assert form needs a token and a real
SHA.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: accept HP-1 — B1 is complete (#1421)
HP-1 accepted 2026-08-27 by J3vb (repository owner). Recorded the same way HP-0
was: a decision line on the scorecard, and a dated acceptance section appended
to the baseline document.
Condition 6 is accepted as a STATED LIMITATION, not as met. dev carries
strict:false, so a PR whose checks went green before dev advanced can still
merge without re-testing, and the squash commit that lands was never itself
tested as it stands. Closing it forces a rebase on every open PR whenever
another lands, and enforce_admins:true leaves no exemption. Taken knowingly;
not a B2 blocker. Recording it as accepted-with-limitation rather than met is
the point — a scorecard that rounds a partial up to a pass is worth nothing.
Also corrects a stale claim the plan index itself is supposed to police: it
still read "No phase complete" for the roadmap, which stopped being true when
HP-0 was accepted on 2026-08-25. That is the G-04 drift class this index exists
to close, so it should not be the document carrying it.
B2's entry gate condition "B1 is complete and protocol source has one owner" is
now met. Its other two conditions remain B2 entry work, not B1 debt.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: make the changelog a scannable list, and write down the rule (#1422)
The changelog had drifted into walls of text — v1.2.0-alpha.3's entry is a
handful of paragraphs where a single bullet runs eleven lines and names the
function that owned the bug. An operator cannot tell in ten seconds whether any
of it bit them, which is the only job this file has.
Adds a "How to write an entry" section to CHANGELOG.md as the rule: lead with
what is user-visible and what is not, group by an area a user recognises rather
than by subsystem or PR, one line per fix, say what was broken then what it does
now, plain language over symbol names, no OC-* ids or file paths, counts in a
summary line rather than on every bullet. Repository work that changes nothing
observable gets at most a short block at the end. Shipped entries are left
alone as history; the rule starts from the next release.
Rewrites Unreleased to follow it, which also closes a real gap: that section
documented B0/B1 repository plumbing and omitted all 62 operator-visible bug
fixes from #1400 and #1402. Exactly backwards — the invisible half was written
up and the half users would notice was not. A release cut from dev today would
have shipped a changelog that mentioned a directory rename and not "banned users
could still connect".
docs/contributing.md's PR process now points at the rule, since that is where a
contributor decides whether their change needs an entry.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* release: v1.2.0-alpha.4 (#1423)
Bumps the client version across every pin verify-versions enforces
(package.json, tauri.conf.json, Cargo.toml) plus the two lockfiles that carry
it, and the user-facing build examples in README.md, docs/deployment.md,
docs/quick-start.md, docs/api.md and the issue-form placeholders.
Deliberately NOT bumped: the v1.2.0-alpha.3 references in ci.yml,
release.yml and docker-smoke.sh, which record the release that published from
a red commit and are the reason the gate-evidence job exists; and the string in
scripts/check-doc-counts.mjs, which is a selftest fixture asserting a version
number is not read as a ledger claim. Rewriting either would falsify a record.
CHANGELOG's Unreleased section becomes v1.2.0-alpha.4.
Verified rather than assumed:
- npm ci exits 0, so package-lock.json still matches package.json.
- cargo metadata --locked exits 0, so Cargo.lock needs no regeneration.
- The verify-versions comparison was run locally against tag v1.2.0-alpha.4:
all three sources agree, so the tag will not be rejected.
- npm run check passes end to end, exit 0.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: merge main into dev to unblock the alpha.4 release PR (#1425)
* ci(deps): bump anthropics/claude-code-action (#1404)
Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).
Updates `anthropics/claude-code-action` from 1.0.193 to 1.0.199
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/9d7150bc8a3dae8149739a88019d192b579ad90c...dcb57747bfceeaa1fa72638cae52295d1d853d4a)
---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
dependency-version: 1.0.199
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: actions-dependencies
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* build(deps): pin rfd to tauri-plugin-dialog's major to unblock the cargo group (#1406)
The cargo-dependencies group PR (#1405) fails Rust Unit Tests on Linux:
error: failed to run custom build command for `rfd v0.17.2`
You need to choose at least one backend: `gtk3` or `xdg-portal`
features for x86_64-linux
rfd is not really ours. It arrives in the tree via tauri-plugin-dialog,
which pins ^0.16; we declare it directly only for the fatal-startup
message box in lib.rs, where the Tauri app never finished building and
the plugin has no AppHandle to run a dialog through.
Cargo unifies features only within a semver-compatible version group, so
while both wanted ^0.16 there was a single rfd in the graph and the
plugin's backend features covered our `default-features = false`
declaration too. Bumping our direct dep to 0.17 forks rfd into two
crates: the plugin keeps 0.16.0 with its features, ours resolves to
0.17.2 with none, and rfd 0.17 added a build.rs assertion that aborts
the Linux build when no backend feature is set. Confirmed in the PR's
lockfile, which carries both 0.16.0 and 0.17.2.
Adding a Linux backend feature would be the wrong fix: it would paper
over the fork and still build rfd twice on every platform for one error
dialog. Our version has to track the plugin's instead, so ignore
semver-minor rfd updates (0.16 -> 0.17 for a 0.x crate) until
tauri-plugin-dialog moves. Patch updates inside 0.16.x still flow.
The remaining five crates in the group are unaffected; `windows` in fact
consolidates 3 versions down to 2.
Cargo.toml is comment-only here - no dependency, feature, or lockfile
change - so the build is untouched.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(deps): bump log (#1407)
Bumps the cargo-dependencies group with 1 update in the /Client/tauri-client/src-tauri directory: [log](https://github.com/rust-lang/log).
Updates `log` from 0.4.33 to 0.4.34
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.33...0.4.34)
---
updated-dependencies:
- dependency-name: log
dependency-version: 0.4.34
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: cargo-dependencies
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* ci(deps): bump anthropics/claude-code-action (#1408)
Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).
Updates `anthropics/claude-code-action` from 1.0.199 to 1.0.200
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/dcb57747bfceeaa1fa72638cae52295d1d853d4a...24dcd50c0568f0fc9e9211213a4fd2d9eb15c4e0)
---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
dependency-version: 1.0.200
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: actions-dependencies
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* fix(client): strip tags to a fixpoint inside sanitizePassApprox
CodeQL alert 17 (js/incomplete-multi-character-sanitization, high) fires on
the single-pass `input.replace(/<[^>]*>/g, "")`: a lone replace can in
principle splice a fresh `<...>` out of the text either side of what it
removed. echoNormalize already loops sanitizePassApprox to a fixpoint, so
that was absorbed one level up and the output is unchanged -- but the
repetition is now where a reader (and the query) can see it.
sanitizePassApprox is a comparison normalizer, never rendered output: its
only consumer is the `===` echo match in isUnreconciledEcho. Not a
sanitization boundary, so this is a legibility fix, not a security one.
Client suite 5257/5257, tsc, lint, hygiene all green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): put the strip-tags replace inside the loop body
The previous form hoisted the `replace` into the `for` header's init
expression, so CodeQL still reported it (alert 18, line 217 col 21) --
js/incomplete-multi-character-sanitization only credits a repeated
replacement when the call sits in the loop *body*, which is also the shape
the rule's own guidance shows.
Same fixpoint, same output; `while (out.includes("<"))` gives the loop a
real condition instead of `for (;;)`.
Client suite 5257/5257, tsc, lint, prettier green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
The cargo-dependencies group PR (#1405) fails Rust Unit Tests on Linux:
error: failed to run custom build command for `rfd v0.17.2`
You need to choose at least one backend: `gtk3` or `xdg-portal`
features for x86_64-linux
rfd is not really ours. It arrives in the tree via tauri-plugin-dialog,
which pins ^0.16; we declare it directly only for the fatal-startup
message box in lib.rs, where the Tauri app never finished building and
the plugin has no AppHandle to run a dialog through.
Cargo unifies features only within a semver-compatible version group, so
while both wanted ^0.16 there was a single rfd in the graph and the
plugin's backend features covered our `default-features = false`
declaration too. Bumping our direct dep to 0.17 forks rfd into two
crates: the plugin keeps 0.16.0 with its features, ours resolves to
0.17.2 with none, and rfd 0.17 added a build.rs assertion that aborts
the Linux build when no backend feature is set. Confirmed in the PR's
lockfile, which carries both 0.16.0 and 0.17.2.
Adding a Linux backend feature would be the wrong fix: it would paper
over the fork and still build rfd twice on every platform for one error
dialog. Our version has to track the plugin's instead, so ignore
semver-minor rfd updates (0.16 -> 0.17 for a 0.x crate) until
tauri-plugin-dialog moves. Patch updates inside 0.16.x still flow.
The remaining five crates in the group are unaffected; `windows` in fact
consolidates 3 versions down to 2.
Cargo.toml is comment-only here - no dependency, feature, or lockfile
change - so the build is untouched.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* build(client): upgrade TypeScript to 6.0.3
Staging step toward TypeScript 7 (the native compiler), which needs its
7.1 stable API before typescript-eslint and Stryker's typescript-checker
can run on it. TS 6 is the JS-based bridge release that aligns config
defaults with 7.
Two fallout fixes:
- tsconfig.e2e.json: TS 6 defaults "types" to [] instead of every
installed @types package, so the Playwright layer's Node globals
(process, Buffer) need an explicit "types": ["node"].
- media-visibility.test.ts: TS 6's DOM lib adds scrollMargin to
IntersectionObserver, so the mock grows the property.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lw5KEz6gdD816Wxmm4A3Bn
* build(server): bump chi to 5.3.2, modernc.org/sqlite to 1.57.0, toolchain to go1.26.7
Go 1.27 deliberately deferred until 1.27.1 lands.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lw5KEz6gdD816Wxmm4A3Bn
* build(tauri): bump tokio-tungstenite to 0.30, refresh Cargo.lock
In-range lockfile refresh via cargo update; tungstenite 0.29/0.30 changes
are client-API-neutral (header handling, server-side handshake hardening).
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lw5KEz6gdD816Wxmm4A3Bn
* build(client): upgrade vite 8, vitest 4, jsdom 30, stryker 10 + minors
- vite 6 -> 8: Rolldown requires the function form of manualChunks;
__dirname -> import.meta.dirname in configs
- vitest 3 -> 4: browser provider moved to @vitest/browser-playwright;
vi.fn() mocks now need explicit signatures (typed throughout tests);
constructor mocks use function impls; restoreAllMocks no longer resets
vi.fn state; matchMedia spies replaced with vi.stubGlobal
- jsdom 29 -> 30: one internal bookkeeping abort listener per signal,
listener-count regression tests adjusted (leak detection retained)
- stryker 9 -> 10, @types/node 20 -> 24, eslint/oxlint/livekit-client minors
- tsconfigs: explicit "types" now that TS6/vitest4 stop injecting
@types/node ambiently; build config keeps Node globals out of src/
Validated: tsc (main/build/e2e), eslint, oxlint, knip, prettier,
unit+integration (5196 tests), browser suite, vite build, stryker dry run.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lw5KEz6gdD816Wxmm4A3Bn
* ci: move Node 20 (EOL 2026-04-30) to Node 24 LTS
The jsdom suite runs on modern Node without --no-experimental-webstorage:
tests/setup.ts already replaces the shadowed localStorage with an
in-memory shim. Client CLAUDE.md gotcha updated accordingly.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lw5KEz6gdD816Wxmm4A3Bn
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat(bughunt): coverage-driven stop rule and directory-coherent sweep
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(bughunt): return uncredited explore draws to the pool
An explore lens denied coverage credit (dead finder or unverified
candidates) now un-consumes its draw so later rounds re-offer the files;
consumed-but-uncovered files could otherwise pin uncoveredCount above
zero and block convergence. Directory grouping reuses clusterOf().
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(bughunt): stalled-coverage guard, risky-file class sweep, exhausted-dry convergence
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(bughunt): stall guard never stops a still-confirming hunt
A round with newConfirmed > 0 resets the coverage-stall counter instead
of counting toward it; hotspot yield does not shrink the uncovered pool,
and a stuck sweep must not cut off a hunt that is still finding bugs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(bughunt): coverage telemetry in report and operator docs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(bughunt): scoped-hunt coverage trap and current cost estimate
Final-review fixes: warn that args.lenses plus an examined-armed inventory
still sweeps the whole pool (pass a filtered inventory or legacy rows to
truly scope), and align the budget note with the coverage-run estimate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(identity): 2 defect(s) (OC-0192, OC-0197)
OC-0192: bound raw display_name/about/avatar bytes before the quadratic
fixpoint sanitizer runs, in both the REST handler and UserService.UpdateProfile.
OC-0197: sanitize display_name before validateDisplayName so an
HTML-entity-encoded bidi override (e.g. "‮") can no longer pass
validation as ASCII and be decoded into the real character on the way to
storage.
* fix(ws): 1 defect(s) (OC-0196)
A transient DB error during WebSocket auth (session or user lookup) was
collapsed into the terminal auth_error frame, which the client treats as
non-recoverable: it stops reconnecting and clears stored credentials. A
sub-second SQLite hiccup therefore force-logged-out every reconnecting
client with a perfectly valid session. Send a non-terminal INTERNAL error
frame instead so normal backoff/reconnect retries.
* fix(api): 1 defect(s) (OC-0198)
* fix(ws): 1 defect(s) (OC-0200)
normalizeHostForCertCompare now unwraps a bracketed IPv6 literal after the
trailing-":443" strip and before lowercasing, matching tofu::cert_store_key's
normalization order. Without the unwrap, every cert-tofu host equality guard
took the "unrelated host" branch for bracketed-IPv6 servers.
* fix(api): 1 defect(s) (OC-0202)
* fix(admin): 1 defect(s) (OC-0203)
Channel permission override handlers applied requireGrantableOverride only
to the bits being written, so an all-zero PUT or a DELETE could clear a
deny bit the actor's own role does not hold — EffectivePerms =
(rolePerm &^ deny) | allow makes removing a deny an escalation. Both the
role-layer and per-user handlers now check the guard against the bits
already on the row.
* fix(client): 1 defect(s) (OC-0205)
* fix(client): 3 defect(s) (OC-0207, OC-0227, OC-0235)
* fix(client): 1 defect(s) (OC-0208)
* fix(voice): 3 defect(s) (OC-0209, OC-0212, OC-0213)
OC-0209: reject a replayed retired-key announce before verifyPeerAnnounce
runs, so the replay cannot overwrite the peer's displayed verification
status/session fingerprint with the retired key's before being rejected.
OC-0212: buffer an announce blocked as a TOFU pin mismatch and replay it
after a successful rePinPeerIdentity, so re-pinning actually restores the
peer for the live call instead of clearing the badge and leaving them
un-keyed (a mid-call peer never re-announces on its own).
OC-0213: skip retiring a departing peer's key when the local voice roster
still lists them as present — a rejoin announce published straight into
the send queue can overtake the buffered, stale voice_leave, and retiring
a still-live key would reject every later genuine re-announce as a replay.
* fix(ws): 1 defect(s) (OC-0211)
* fix(identity): 1 defect(s) (OC-0214)
The delete-account admin guard counted remaining admins with a raw
`banned = 0` filter, so an admin whose temporary ban had already lapsed
was treated as unusable. Use the shared notBannedClause, appended outside
the Sprintf format string because its strftime verbs (%Y, %H) would
otherwise be parsed as fmt directives.
* fix(client): 1 defect(s) (OC-0215)
* fix(voice): 1 defect(s) (OC-0216)
* fix(client): 1 defect(s) (OC-0217)
* fix(voice): 1 defect(s) (OC-0219)
rollbackVoiceJoin cleared the client's in-memory voiceChID but left its
VoiceTopic subscription in place, so a socket whose join failed after
voiceJoinComplete's Subscribe kept receiving that room's E2EE relays for
the rest of the connection. Use clearVoiceAndUnsubscribe instead, matching
every other path that takes a client out of voice while its WS stays up.
* fix(client): 2 defect(s) (OC-0220, OC-0224)
dmDisplayName: a group DM whose other members have all left keeps a live
is_group row, but the server leaves `recipient` zero-valued, so the empty
username fell through as a blank label. Fall back to a non-empty placeholder.
updateDmLastMessage: a queued chat_message redelivered for an id already
reflected in the `ready` snapshot double-counted the unread badge. Only
increment when the message id advances past lastMessageId.
* fix(client): 1 defect(s) (OC-0221)
Cap queued attachments at the server's 10-attachment limit in the message
composer. Past that the server rejects the whole chat_send frame as a
generic parse error, orphaning already-uploaded attachments; refusing
before the upload starts keeps composer state and the send in sync.
* fix(ws): 1 defect(s) (OC-0222)
handleReconnect built the resume auth_ok before applyConnectStatus settled
c.user.Status, so a resumed client was told its disconnect-time status
(routinely "offline") instead of the status it was coming online as.
Move applyConnectStatus ahead of reconnectWriteReplay, matching
handleFreshConnect's ordering.
* fix(mentions): 1 defect(s) (OC-0223)
* fix(admin): 1 defect(s) (OC-0225)
* fix(client): 1 defect(s) (OC-0226)
* fix(client): 1 defect(s) (OC-0228)
* fix(client): 1 defect(s) (OC-0230)
Route the Logs tab entry counter through renderLogEntries so every render path (filter change, Clear, Refresh, live entry) keeps the count in sync with the list.
* fix(voice): 1 defect(s) (OC-0231)
* fix(client): 1 defect(s) (OC-0232)
Reduce Motion toggle wrote the reduced-motion class directly, fighting the
OS-sync media-query listener that owns it when Sync with OS is on. Route the
side effect through syncOsMotionListener so whichever source owns the class
re-derives it.
* fix(client): 1 defect(s) (OC-0233)
notifyIncomingMessage titled the desktop notification with the raw
payload username, so the popup named the sender differently from the
message row it points at. Resolve the author the same way the message
list does (resolveAuthor over the live membersStore, then
resolveDisplayName).
* fix(client): 1 defect(s) (OC-0234)
* fix(client): 1 defect(s) (OC-0236)
* fix(ws): 1 defect(s) (OC-0237)
* fix(client): 4 defect(s) (OC-0193, OC-0201, OC-0204, OC-0218)
* fix(identity): 1 defect(s) (OC-0195)
Bound free-text profile fields by raw byte length before cleanText's
quadratic sanitizeToFixpoint pass runs, generalizing OC-0192's guard into
cleanTextBounded and applying it to HandlePresenceUpdate's custom_status,
SetCustomStatus, and group DM names.
* fix(dm): 1 defect(s) (OC-0199)
handleCreateDM now broadcasts dm_channel_open to the recipient when a 1:1 DM is newly created, matching handleCreateGroupDM. GetOrCreateDMChannel pre-seeds dm_open_state for both users, so the recipient's later OpenDM reported opened=false and nothing ever told them the DM existed.
* fix(voice): 1 defect(s) (OC-0206)
vad-worklet.js gate timing constants were copied from the setTimeout
fallback's ~16ms poll cadence, but AudioWorkletProcessor.process() runs
once per 128-sample render quantum (~2.667ms at the 48kHz AudioContext).
The mic gate therefore closed ~6x faster than intended (~32ms of silence
instead of ~200ms), with the startup grace and RMS post interval off by
the same factor. Scale the frame counts to render quanta.
* fix(client): 1 defect(s) (OC-0229)
* test(client): assert the real TOFU re-pin outcome and make the pin mock faithful
The e2e journey test asserted that "Trust New Key" makes the peer's verify
badge disappear. That is the behaviour OC-0212 identifies as the defect: a
mid-call peer never re-announces, so clearing the badge left the peer
un-keyed for the rest of the call with nothing on screen. Re-pinning now
replays the announce that was blocked as a mismatch and re-verifies it
against the pin just stored, so assert the peer actually lands verified.
The mock's store_identity_pin was a no-op recorder while get_identity_pin
served a static seed map, so the replayed announce re-read the stale pin and
re-failed — a mismatch the real keyring never produces. Back the pins with a
mutable map so a write is visible to the next read. The unreadable-store
(DC-08) and reject-keeps-blocked paths are unchanged and still pass.
* fix(dm): 1 defect(s) (OC-0194)
Add regression tests pinning the raw-byte bound on group DM names, for
both CreateGroupDM and RenameGroupDM.
The Server/service/dm.go source fix for OC-0194 already landed in
bdbd5ac (fix(identity): 1 defect(s) (OC-0195)), which generalized the
guard into cleanTextBounded and applied it to the group DM name paths
alongside the profile fields. This commit therefore carries the OC-0194
tests only; dm.go is unchanged.
Revert-proof: with dm.go restored to bdbd5ac^ (cleanText before the
rune-count check) both new tests fail — CreateGroupDM returns "recipient
not found" after 222ms and RenameGroupDM accepts the name after 251ms,
against a 150ms budget. With the fix in place both pass in 0.03s.
* fix(ws): 1 defect(s) (OC-0210)
* chore(findings): record the 2026-08-20 hunt's 46 findings as fixed
Appends OC-0192..OC-0237 from the 2026-08-20 converging hunt and marks each
fixed with its commit and the test that pins it. Pre-existing records are
byte-identical; nextId moves 192 -> 238 so the next hunt cannot collide with
these ids.
Every fix was independently revert-proofed: the commit's own source diff is
reverse-applied, its test must go red, and must return green once restored.
43 of 46 carry revertProof "pass" from that mechanical run. Three could not be
checked at file level and were proved by hand at hunk level instead, recorded
as "pass (hand-proved)": OC-0200, whose ws.ts edit no longer reverse-applies
because the merge kept main's equivalent implementation; OC-0215, whose Rust
tests live in-file under #[cfg(test)]; and OC-0194, which stacks on a helper
introduced by an earlier commit. No fix was found to rest on a vacuous test.
OC-0200 additionally carries a note: main fixed that same normalizer
independently while this branch was in flight, so the branch is no longer the
only thing closing it.
* docs: record the dm_channel_open emission on 1:1 DM creation
POST /api/v1/dms now emits dm_channel_open to the recipient when it creates a
channel (it previously emitted nothing on that path), so api.md states it the
way the sibling DM endpoints already state theirs.
The channels/members/DMs UX spec claimed the server broadcast the event "to
both parties" on this flow. That was never true — nothing was broadcast before,
and now only the recipient is sent it; the creator learns the channel from the
response body. This doc lists dispatcher.ts, dm.store.ts, ChannelSidebar.ts,
service/channel.go and dm.go among its sources of truth, all touched here, so
it is corrected in the same change per its maintenance rule.
---------
Co-authored-by: Claude <noreply@anthropic.com>
* docs(plans): phased remediation plan for the 2026-08-19 audit
Executes the audit's §8 MUST-fix verdict and §9.1 fix order: one phase per
finding group, statuses updated in place as phases land.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ
* test(client): give the renderWindow-breaker test its own timeout (audit F-5)
30 synchronous 100-row jsdom rebuilds can exceed vitest's default 5s on a
loaded runner; the test timed out once under CI-like load and passes in
isolation, so it now carries an explicit 20s budget.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ
* docs: fix the ten wrong reference-doc statements from audit 2026-08-19 (B-01..B-10)
schema.md: migrations 030/031 documented, attachments ON DELETE SET NULL
(matching 030's rebuild), index inventory rewritten from cumulative migration
state, writer/reader pool split described, default-roles table made a
consistent post-migration snapshot, dbgen preamble updated.
protocol.md: DM chat events documented as sequenced/ring-buffered/replayable
(they are), plugin_broadcast seq flipped to Yes, retry_after claim removed
(no WS error carries it), the five enforced-but-documented-as-None rate
limits added (channel_focus, mark_read, call_decline, chat_command, ping),
E2EE announce/offer budgets corrected incl. the per-target inner cap,
BAD_PAYLOAD and NOT_KEY_HOLDER added to the error table, ready voice_states/
roles field lists completed, member_join top-level status documented.
api.md: diagnostics endpoint is ADMINISTRATOR-only (H-8) with a per-IP
limiter and host:port livekit_url, error-code table now matches emitted codes
(INTERNAL_ERROR, STORAGE_ERROR 507; oversize upload is 400), body-cap
exemptions listed, identity_public_key documented on PATCH /users/me, plugin
endpoints' plain-text errors + X-Plugin-Runtime header documented, /health
503 degraded state documented, metrics/LiveKit CIDR keys named, updates/apply
restart-conflict 409s added.
Also folds in the audit's D-04/D-05 comment and plan-header staleness fixes
(buildReady comment, e2e spec-count comments, logctx stray word, three plan
status headers).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ
* fix(server): log the five silently-discarded persistence errors (audit F-3/F-4/D-16)
Lockout Upsert/Delete/Cleanup failures (auth/ratelimit.go), the H-6
session-cap eviction failure in CreateSession (db/auth_queries.go), and the
channel_focus read-state write failure (service/channel.go) all discarded
their errors with no trace — a brute-force lockout could silently fail to
survive a restart. In-memory behavior is unchanged (warn-and-continue); the
lockout write paths are pinned by tests mirroring OC-0061's load-path test.
The session-cap and read-state sites are log-only additions on seams the
existing suites already exercise on the success path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ
* fix(dm): blocking a user evicts them from the pair's live 1:1 DM voice call (audit F-1)
The block gate ran only at voice_join and voluntary voice_token_refresh, so
a blocked user already in the shared 1:1 DM call kept their session
indefinitely — the same guard-asymmetry family as A-2026-08-03. handleBlockUser
now severs the call through the dmVoiceEvictor capability handleCloseDM
already exercises, using a new find-only FindDMChannelIDBetween lookup
(sqlc-generated; mirrors GetOrCreateDMChannel's is_group=0 clause so group
DM calls stay exempt, matching requireDMNotBlocked). Pinned by three handler
tests: shared-DM eviction, no-DM no-op, group-only no-op.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ
* fix(ws): close the role-reassignment/handshake race (audit F-2)
A role reassignment landing mid-handshake was invisible for the socket's
whole life: both handshake paths resolved permissions from the auth-time
c.user snapshot, revokeUnreadableChannels early-returns for a user not yet
in h.clients, and its Unsubscribe no-ops on the pubsub identity guard once a
reconnect replaced the client.
Three coordinated fixes: (1) refreshUserSnapshot re-reads the user row (and
role name) in reconnectPrecheck and handleFreshConnect, fail-closed; (2) the
resume-fallback path re-reads the role once more after registerNow and runs
the revocation pass when it moved, so the reassignment-vs-registration
orderings meet in the middle; (3) revokeUnreadableChannels re-resolves the
live client immediately before acting, mirroring RefreshChannelVisibility.
Pinned by four tests driving real WS handshakes through the existing race
hooks plus a new pre-register/pre-act hook pair; ws suite green under the
default and deadlock builds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ
* refactor(client): remove the inert replay-dedup machinery (audit F-6)
The server writes auth_ok before the replay burst, so replayDedup — created
on socket-open and cleared when auth_ok is processed — could never be active
for a real replayed frame, and the dispatcher's isReplaying() unread gates
never fired. Their no-op behavior is the correct behavior (a buffer/db
resume has no ready payload, so replayed frames must count as unread), so
the machinery, the gates, and the misleading comments are removed rather
than repaired. The pinning tests injected replay frames in an order a
spec-compliant server never produces; they are replaced by a test pinning
the real contract (frames after auth_ok are dispatched verbatim; duplicate
handling belongs to the stores). Client suite green: 5036/5036.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ
* docs(plans): mark remediation phases 1-6 done
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ
* fix(ws): nolint the context-less revoke call golangci-lint flags
revokeUnreadableChannels takes no context by design (admin HubBroadcaster
interface); annotate the one call site inside a ctx-taking function, matching
the RefreshChannelVisibility precedent. golangci-lint v2.11.3: 0 issues.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ
---------
Co-authored-by: Claude <noreply@anthropic.com>
Full-repo health check at eacba10: prior-finding closure verification
across all five dated audits, dynamic checks (all suites green, server
boots clean), doc/code drift for the three reference specs, and a static
sweep of the ws hub, reconnect/sync, and the REST/WS boundary. Findings
ranked BROKEN/FRAGILE/DEBT with a fix-order and alpha-exit roadmap.
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ
Co-authored-by: Claude <noreply@anthropic.com>
* fix(e2ee): bind the key epoch into wrapped room-key offers
The holder's rotation counter now rides inside encrypted_key as a
versioned header and is bound as AES-GCM additional data, so a receiver
can tell a current room key from a superseded one. Receivers keep a
per-sender high-water mark and apply an offer only at or above it; the
mark resets when that sender announces a fresh ephemeral key. Blobs in
the pre-epoch layout are still accepted for holders on the older build
(compat path, scheduled for removal next release). No server or schema
change: the relay treats encrypted_key as opaque.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(e2ee): show a per-call session fingerprint for every voice peer
A peer with no published identity key has no safety number, so the TOFU
badge gave the user nothing to compare out of band. Every accepted
announce now also carries a fingerprint of the peer's ephemeral session
key, shown on the unverified badge and labelled as changing every call
and not an identity; the local user's own session fingerprint is shown
on their row so it can be read back. safetyNumber is unchanged and stays
null for unverified peers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(ledger): mark OC-0001 and OC-0003 fixed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): resolve an empty READ audience for a channel whose row is gone
channelReadAudience already failed closed on a GetChannel error; a
deleted channel returns (nil, nil) and fell through to the role scan.
Return nobody for a missing row too — voice teardown callers union the
room's participants and the leaver back in, so their signals still land.
Test locks both halves: the non-participant hears nothing, the leaver
still gets voice_leave. (OC-0090)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): re-elect the key holder in CleanupVoiceForChannel
Every other voice-removal path re-elects (finishVoiceLeave, the LiveKit
webhook, registerNow, rollbackVoiceJoin, sweepStaleVoiceStates); the
channel delete/archive path did not, so a torn-down channel's
voiceKeyHolders entry lived for the process lifetime. One updateKeyHolder
call at the end of the teardown deletes it. (OC-0012)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): implement BroadcastMemberUnban so unban reaches connected clients
The admin unban path reaches the hub through an optional-capability type
assertion that *ws.Hub never satisfied, so it always missed silently and
clients connected during a ban kept the user missing from their member
store. Implement the mirror of BroadcastMemberBan: fan out the same
member_join a fresh connect sends (clients already map it to addMember),
reporting offline since the unbanned user cannot be connected. A
compile-time assertion in admin pins the wiring so the assertion can
never silently miss again. (OC-0058)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(db): exclude the requester's own flag from the video-cap stream count
EnableCameraIfUnderLimit and EnableScreenshareIfUnderLimit counted every
stream in the channel including the very flag the UPDATE sets, so a user
whose server-side flag was already 1 (client lost track and retried) was
refused at the cap against their own stream, with no path out. Subtract
the outer row's own bit from the correlated count: re-enable becomes
idempotent while the requester's other stream and everyone else's still
count. sqlc layer regenerated. (OC-0081)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(ledger): resolve the six blocked batch-4 findings
Four fixed in this branch (OC-0012, OC-0058, OC-0081, OC-0090), each
with an independent revert-proof pass. Two were already fixed on main by
later sibling fixes and are recorded as such: OC-0086 by the OC-0017
pre-delete re-check (#1374), OC-0101 by the OC-0206 early watermark bump
(#1375). The ledger holds zero open and zero blocked findings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(identity): 1 defect(s) (OC-0151)
* fix(ws): 1 defect(s) (OC-0152)
* fix(admin): 1 defect(s) (OC-0153)
* fix(admin): 1 defect(s) (OC-0154)
* fix(voice): 2 defect(s) (OC-0155, OC-0167)
Replace distributeRoomKey's per-call offer counter with an instance-level
sliding-window budget shared by every voice_e2ee_offer send path.
- OC-0155: back-to-back rotations (the second run immediately by
drainPendingRotationOrArmTimer) each got a fresh pacing budget, so their
combined sends could exceed the server's single per-second cap.
- OC-0167: handleAnnounceInner's drain-time offer send bypassed pacing
entirely, letting a key holder joining a large ongoing call burst every
queued announce's offer unpaced.
The shared budget is reset in clearState() since the server's limit is
scoped per (sender, channel).
* fix(client): 1 defect(s) (OC-0156)
createPresenceSender dropped a queued custom_status when a later plain
status change superseded the pending retry. The retry now carries the
last committed custom_status forward.
* fix(client): 2 defect(s) (OC-0160, OC-0163)
OC-0160: exempt the handshake frames (ready, auth_ok) from the ws message
size limit and run the guard after parsing. A 'ready' frame grows unbounded
with member/channel/DM counts and carries no seq, so dropping it left the
client on empty stores with no error and no recovery path.
OC-0163: bracket a bare IPv6 host when building the wss:// URL so the
authority parses, and collapse bracketed/bare IPv6 literals to the same
cert_store_key so one server is not pinned (and user-confirmed) twice.
* fix(voice): 1 defect(s) (OC-0162)
updatePttKey armed the Rust poller when a PTT key was bound mid-call but
never applied the gate. The poller only emits 'ptt-state' on a press/release
transition, so an idle key produced no event and the already-published mic
stayed hot until the user's first physical press+release. Mirror the join-time
gate computation in updatePttKey, guarded on being in a call, polling actually
being live, and the mic not already being gated.
* fix(client): 1 defect(s) (OC-0164)
* fix(plugin): 1 defect(s) (OC-0165)
scanPluginDirectory now skips a malformed plugin subdirectory and joins its
error instead of aborting the whole scan, and LoadAll logs-and-continues so
one bad plugin directory cannot disable every other plugin.
* fix(ws): 1 defect(s) (OC-0166)
Route PresenceSelfEvent onto the owner's normal-priority queue instead of
letting it fall through to the UserTargetedEvent high-priority case, so a
user's own presence frames all share one FIFO and cannot be delivered out
of order relative to the visible presence_update path.
* fix(db): 1 defect(s) (OC-0168)
* fix(client): 1 defect(s) (OC-0169)
* fix(client): 1 defect(s) (OC-0171)
addMessage appended a broadcast at the tail even when trailing optimistic
rows were still unreconciled, so a message that committed while our own
send was in flight ended up ordered behind the row confirmSend later
stamped with a higher server id/timestamp. Insert before the trailing
unreconciled run instead.
* fix(voice): 1 defect(s) (OC-0172)
* fix(client): 1 defect(s) (OC-0174)
* fix(ws): 1 defect(s) (OC-0175)
* fix(client): 1 defect(s) (OC-0177)
* fix(client): 1 defect(s) (OC-0178)
* fix(voice): 1 defect(s) (OC-0179)
Undeafening no longer sends a voice_mute{muted:false} the server will
refuse while a moderator-imposed mute stands, matching the localServerMuted
guard already present in onMuteToggle.
* fix(client): 1 defect(s) (OC-0182)
* fix(plugin): 1 defect(s) (OC-0183)
* fix(client): 1 defect(s) (OC-0184)
Treat a trailing underscore as an emphasis delimiter, not part of the URL,
when scanning for the end of an autolinked URL.
* fix(client): 1 defect(s) (OC-0185)
Reveal .msg-actions-bar on .message:focus-within, not only on hover, so
keyboard users can see the per-message action buttons they Tab into
instead of activating them at opacity: 0.
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* fix(client): 1 defect(s) (OC-0186)
* fix(client): 1 defect(s) (OC-0187)
The Add Server modal validated addresses with its own narrower regex that
never gained IPv6 support when api.ts's validator did, so an IPv6 server
could be logged into but never saved as a profile. Extract the validator
into src/lib/hostValidation.ts and use it from both call sites.
* fix(client): 1 defect(s) (OC-0189)
DM sidebar rows dropped mention counts entirely and the header total
excluded muted conversations outright, so a direct mention in a muted DM
was invisible. Render a mention badge that outranks the plain unread
badge, and count a muted channel's mentionCount toward the header total.
* fix(client): 1 defect(s) (OC-0190)
* fix(client): 1 defect(s) (OC-0191)
* fix(client): 2 defect(s) (OC-0157, OC-0176)
* fix(client): 1 defect(s) (OC-0161)
confirmTotp answers 401 for a wrong enrollment code while the session is still valid; firing the global onUnauthorized sink signed the user out and deleted their stored credential. Opt that one call out via a skipUnauthorized flag on doFetch.
* fix(admin): 1 defect(s) (OC-0173)
* fix(identity): 1 defect(s) (OC-0180)
* fix(admin): archived channel PATCH skips voice eviction and fan-out (OC-0158)
handlePatchChannel commits the AdminUpdateChannel write, then re-reads the
channel to drive voice eviction and the visibility fan-out. When that
post-commit re-read failed, the handler returned early: the archive was
durable but connected clients were never told and voice members were never
evicted, leaving users talking in a channel that no longer exists for them.
Drive the post-commit work off the values already in hand rather than
abandoning it when the re-read fails.
Adds SetPatchChannelPostCommitHook so the test can land a cancellation in
that exact window deterministically instead of racing wall-clock timing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* fix(admin): role changes commit with no client ever notified (OC-0170)
broadcastRoles derived its context from the inbound *http.Request, so the
roles_update fan-out was tied to the request lifetime. A role create,
update, or delete could commit to the database and then broadcast nothing
once that request context was done, leaving every connected client on a
stale role list until the next full resync.
Decouple the fan-out from the request context so the broadcast follows the
commit rather than the caller.
Adds BroadcastRolesForTest to reach broadcastRoles from the external test
package.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* fix(client): username rename stomps the profile card header (OC-0188)
The account profile card's header is a resolveDisplayName() slot, but the
username-rename save path wrote the raw username straight into it. A user
with a display name set would see the header switch from their display
name to their new username after a rename, disagreeing with every other
surface that renders the same identity.
Resolve the header through the same display-name path the initial render
uses, so a rename updates the username field without touching the header.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* fix(client): settings overlay never focuses when mounted already-open (OC-0181)
mount() synced initial state — including the show() that calls
focusDialog() — before appending root to the container. .focus() on a
still-detached subtree is a silent no-op, so a caller that mounts while
uiStore.settingsOpen is already true (ConnectPage's lazy first-open path)
got a visible overlay whose focus trap never captured focus: keyboard
users landed outside the dialog with Tab escaping to the page behind it.
Attach root before syncing initial state so focusDialog() runs against a
connected subtree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* chore: satisfy the CI gates for this fix batch
The fix batch's own commits left three CI gates red. Nothing here changes
behaviour; every edit is a lint, type, or formatting correction to code
this batch introduced.
golangci-lint:
- OC-0153 and OC-0173 replaced the last two uses of admin's setupSanitizer,
and OC-0151 the last use of api's sanitizer, leaving both package-level
bluemonday vars unused. Remove them along with the now-unused imports,
and reword the comments that named them so they still explain why the
fixpoint sanitizer is the right one without pointing at deleted symbols.
- Modernize the new handshake-deadline test's loop to range-over-int.
tsc --noEmit:
- jsdom ships no types and @types/jsdom is not a dependency, so declare the
surface the new admin-panel test uses, following src/types/jitsi-rnnoise.d.ts.
- Narrow the last-call lookup instead of indexing under
noUncheckedIndexedAccess, with an explicit failure message.
- membersStore.setState replaces whole state, so the presence-sender mocks
must supply typingUsers.
prettier: reformat the five files this batch touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* chore(ledger): record the 2026-08-19 hunt and its fixes
Adds the 41 findings confirmed by the 2026-08-19 hunt and marks the 40
fixed on this branch, each with its commit, the test that pins it, and
revertProof "pass".
"pass" means an independent check, not the fixing agent's self-report:
every commit had its source diff reverted against the working tree, its
own test re-run and required to FAIL, then the source restored and the
test required to PASS. Commits whose tests live inline in Rust
#[cfg(test)] blocks were proven the same way at hunk level, splicing the
pre-fix source onto the post-fix test module.
OC-0159 is recorded as a duplicate of OC-0152: the flow-reconnect and
flow-message lenses independently found the same unbounded handshake
write and proposed the same helper over the same call sites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* test(e2e): make the voice-roster join fixture self-consistent
The voice-widget join test emitted a voice_state for user_id 4 claiming
username "newvoiceuser", but id 4 is "member2" in MOCK_MEMBERS_MULTI_ROLE.
A real server never sends a voice_state whose username disagrees with the
member record for that id, and the same file's VOICE_STATE_EVENT already
pairs id 1 with "testuser" correctly — this one event was the outlier.
The contradiction was invisible while the roster rendered the payload's
raw username. OC-0177 makes it resolve identity through membersStore so a
nickname shows the same in voice as everywhere else, at which point the
fixture's own inconsistency surfaced as a failure.
Send id 4's real username and assert on it. The test still covers what it
did before — a genuine join by a user not previously in voice, asserted by
name and by roster count.
Verified against the app unchanged: with the old fixture the spec fails
1/5 (matching CI), with this one it passes 5/5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(deps): bump h2 to 0.4.16 to clear RUSTSEC-2026-0258
The Rust dependency audit step in Tauri Full Build fails on h2 0.4.13,
which RustSec patches at >=0.4.16. h2 is transitive (hyper -> reqwest),
so this is a lockfile-only bump.
Edited the h2 stanza directly rather than running
`cargo update -p h2 --precise`: that command also re-unified ten
unrelated windows-sys references down a minor, churn this change has no
reason to carry. `cargo metadata --locked` accepts the edited lockfile,
which is the resolver confirming it is a valid resolution.
reqwest (0.12.28, 0.13.2), hyper 1.8.1, hyper-rustls 0.27.7 and rustls
0.23.43 are all unchanged, so the preconfigured-ClientConfig seam that
tauri-plugin-updater's minor pin protects is untouched.
cargo audit now exits 0; the 19 remaining entries are unmaintained/yanked
warnings (atk and the rest of the GTK3 tree under wry), which the audit
does not fail on and which only Tauri upstream can retire.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(ws): join the load-soak drain goroutines instead of racing goleak
TestTheLoadTest closed each anchor's stopDrain channel and then relied on
a 300ms sleep for the drain goroutines to actually exit before the
deferred goleak.VerifyNone ran. Closing the channel only makes those
goroutines runnable — it does not wait for the scheduler to run them.
On windows-latest the whole test takes ~126s under -race with 20 churn
workers and 6 broadcasters saturating the runner, and goleak's bounded
retry window can expire while all 8 drains are still sitting in state
"runnable". CI then fails with "found unexpected goroutines" pointing at
load_soak_test.go:127 even though nothing actually leaks.
Track the drains on a WaitGroup and join them right after the stopDrain
channels close. The wait happens in the test body, and goleak.VerifyNone
is deferred, so the check can no longer observe a drain that has been
signalled but not yet scheduled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(ws): split handleVoiceJoin into cohesive join-stage helpers
handleVoiceJoin was 130 statements / cyclomatic 59 / nestif 11, breaking all
three complexity budgets at once. Split along the stage boundaries the doc
comment already described: precheck, leave-current, persist, restore
moderator flags, grant token, complete. The publish-permission derivation
becomes its own helper because it is the one branch-heavy block inside the
token grant.
Pure move: every statement is preserved verbatim. The only edits are bare
`return`s becoming the typed returns of their new helper, `c.userID` becoming
the `userID` parameter inside voiceJoinPublishPerms, and voiceJoinComplete
re-reading `ch.VoiceMaxUsers` instead of receiving it — `ch` is never mutated,
so the value is identical.
Verified by normalising both revisions of the region to sorted, comment- and
whitespace-stripped statements and diffing: the only deltas are the ones
listed above.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor: collapse the three duplicated sibling pairs
dupl flagged three pairs of adjacent near-identical functions. Each pair is
now one parameterised implementation plus two thin, still-greppable wrappers.
- ws/voice_controls.go: handleVoiceMuteV2 / handleVoiceDeafenV2 share
voiceSelfToggleV2; handleVoiceCameraV2 / handleVoiceScreenshareV2 share
voiceStreamToggleV2. Camera and screenshare drawing from one
voice_max_video budget (OC-0023) was a bug caused by exactly this
duplication drifting, so one body is the point, not a side effect.
- db/mention_queries.go: ListMentionTargetsByRoles / ListMentionTargetsByUserIDs
share listMentionTargets. The matched column is a closed named type
(mentionTargetColumn) rather than a bare string, so the value interpolated
into the SELECT cannot become caller-supplied.
Behaviour is unchanged: every rate-limit key, error code, error string, slog
message and slog key is preserved verbatim, including the two "failed to
update <kind> state" messages, which are now assembled the same way
enableVideoSlot already assembled them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(api): extract readEmojiUpload from handleCreateEmoji
handleCreateEmoji was 101 lines against a 100-line budget. The upload-bytes
stage — pull the file out of the parsed form, cap its size, sniff its MIME
type and sniff its dimensions — is the one self-contained block in it, and it
already wrote its own refusals, so it moves out whole as readEmojiUpload.
The permission-before-parse ordering the doc comment calls out is unchanged;
so is every error string. file.Close() now runs when the helper returns
rather than when the handler does, which is strictly earlier and unobservable:
the bytes are already copied into raw and nothing else touches the handle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor: extract one cohesive block from three single-budget offenders
Each of these was over exactly one budget, so each gets exactly one extraction
rather than a restructure:
- api/totp_handler.go handleVerifyTOTP (102 lines / 100): the block that
resolves the user behind the partial-auth challenge and decrypts their TOTP
secret becomes totpChallengeSecret. The ban-inside-the-partial-window check
moves with it.
- service/message_reactions.go handleReaction (cyclop 21 / 20): the whole
authorisation chain — channel lookup, archived gate, DM participant and
block checks, non-DM permission check — becomes reactionAudience, which
also returns the DM fan-out audience it already resolved. Check order is
unchanged and load-bearing.
- db/admin_queries.go BackupToSafe (cyclop 21 / 20): the character allowlist
loop and the SQL-comment rejection become validateBackupPathChars. That
loop alone was most of the branch count.
No error string, no check and no ordering changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(plugin): split InstallFromZip into staged install helpers
104 statements / cyclomatic 44 / nestif 12. Split along the stages the code
already had: installZipExtract (the per-entry write loop, with
installZipEntryDest holding the mode/symlink/zip-slip guard chain and
installZipWriteEntry the size-capped copy), installZipStagedManifest,
installZipPromote, and installZipReactivate for the :399 nested block.
Every zip-slip, symlink, entry-mode and uncompressed-size check is preserved
in the same order relative to the writes it guards. The 19 inline
`cleanup(); return` sites collapse to 4 in the orchestrator, one per stage,
because each helper now returns an error instead of unwinding itself — the
staging directory is still removed on exactly the same set of failures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(api): split newWAFMiddleware into engine build and per-phase helpers
184 lines / cyclomatic 38, and the request-body block at :382 was the worst
nested site in the tree at nestif 17.
Engine construction moves out of the closure (wafInlineEngine, wafCRSEngine —
the Coraza directive string is lifted verbatim), and each request phase
becomes its own helper: wafInlineRequestHeaders, wafCRSRequestHeaders
(including the Host/Transfer-Encoding re-add for CRS 920280), wafFeedCRSBody
and wafInspectRequestBody, which is the old :382 block.
The three `handleWAFInterruption(w, it); return` sites inside the body block
become one: the helper now returns the interruption and the orchestrator
handles it. No statement runs between the two points on either side, so the
verdict is honoured identically — in particular a CRS body interruption still
returns without replacing r.Body.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(service): split SendMessage and lift EditMessage's access check
SendMessage was 79 statements / cyclomatic 35 with an 11-deep nested
attachment block at :101; EditMessage was one point over cyclop.
SendMessage becomes sendMessagePrecheck (permission and DM-block gates,
content sanitisation), sendMessageLinkAttachments (the :101 block: attachment
ownership, claim and link) and sendMessageDMSideEffects. EditMessage gets
editMessageCheckAccess and nothing else — one budget over earns one
extraction.
The sanitizeContent fixpoint and the attachment ownership check are unchanged,
as is the order of every gate. The DM side effects run behind
`isDM && !s.sendMessageDMSideEffects(...)`, so a non-DM never enters them;
inside, only the GetDMParticipantIDs failure returns false, matching the one
error the original early-returned on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(admin): split handlePatchUser into per-field apply helpers
106 lines / cyclomatic 29, with the ban block at :154 nested 9 deep.
Each optional field of the partial edit becomes its own helper —
patchUserPrecheck, patchUserAuthorizeRole, patchUserApplyBan (the :154 block,
including the session disconnect and the broadcast) and patchUserApplyRole.
Each returns a bool meaning "keep going"; none of them writes a success
response, so the single response site in the orchestrator is unchanged.
Field application order, the permission-cache invalidation on a role change
and the disconnect-and-broadcast on a ban are all preserved, as are the three
fail-closed `mod == nil` guards, which now sit at the top of their own helper
and still fire on exactly the same conditions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(admin): split handleSetup into first-run setup stages
143 lines / cyclomatic 30, with the optional-wizard block at :219 sitting
exactly on the nestif threshold.
Split into the stages the endpoint already had: request gating (rate limit and
origin check, which run before any auth exists on a fresh server), owner
account creation, and the wizard application that was the :219 block.
Every gate in front of the handler is a security control on an unauthenticated
endpoint; none moved relative to the work it protects. setup_wizard.go is
untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor: split run() into named bootstrap and shutdown steps
131 statements / cyclomatic 57, with the executable-path fallback at :126
nested 9 deep.
The five anonymous `defer func(){...}()` blocks become named functions —
telemetryStop, runClosePlugins, runStopEventPersistence, runStopAuditWriter,
maintenanceStop — and the bootstrap stages move out likewise.
Every defer is still registered in run() itself, at the same point in the
sequence, so the LIFO teardown order is unchanged; that order is documented
in the surrounding comments and is load-bearing (the audit-writer stop must
follow database.Close's registration, the event-persistence stop must precede
it). runStopEventPersistence is now registered unconditionally with a nil
persister meaning "disabled", where the old code registered its defer inside
the enabled branch — a no-op occupying that slot cannot change the relative
order of the others.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(ws): split handleReconnect into resume stages
77 statements / cyclomatic 41, plus the replay block at :199 and, in
handleFreshConnect, the voice-state restore at :622.
handleReconnect becomes reconnectPrecheck, reconnectSelectReplay (with
reconnectVetColdTail for the cold-tier gap check), reconnectRegister and
reconnectWriteReplay. handleFreshConnect's stale-voice cleanup moves to its
own helper, where the `if h.livekit != nil` wrapper becomes a guard clause —
that block was the tail of its scope, so returning early and falling off the
end are the same.
The parts that carry the invariants are moved verbatim: reconnectRegister
still takes h.seqMu, still calls registerNow inside that same critical
section (BUG-123 / OC-0206), still unlocks on every exit, and still emits the
"full" tier counter and telemetry on each of its three re-check failures.
handleReconnect's two-boolean contract is unchanged — the collapsed
`return false, false` sites are all fall-through-to-full-ready, and the
single `return true, false` is still the handshake-write-failure path whose
teardown already ran (OC-0051).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(server): fold in the adversarial review of the complexity refactors
Eleven skeptic passes over the refactor commits on this branch found no
blocker and no major — behaviour is preserved throughout. They did find
comment and accuracy defects worth correcting:
- db/mention_queries.go: the mentionTargetColumn rationale claimed the named
type made the interpolated column "only ever one of the two constants". A
Go named type is not closed, so that is a convention the type makes visible,
not one it enforces. Reworded, gosec justification included.
- ws/voice_controls.go: the dupl collapse generalised away three specifics —
that a server deafen is the moderator's to lift (now on the serverDeafen
field), the concrete voice_states.camera / voice_states.screenshare column
names, and the half of the OC-0023 rationale about neither stream kind
hiding from the other's count. All three restored.
- ws/voice_join.go: `maxUsers := ch.VoiceMaxUsers` had been hoisted to the top
of voiceJoinComplete, moving a read across the tail supersession guard. The
read is inert, but it was the one statement in that commit whose position
relative to a security guard changed; it now sits at its use, as before.
- ws/*_test.go: three test comments cited voice_join.go line numbers that the
split invalidated. They now cite the helper by name instead.
- service/message_reactions.go: reactionAudience's doc claimed to enforce
"every gate on reacting"; it enforces the channel-scoped ones, and the doc
now says which gates stay with the caller.
- api/emoji_handler.go: the readEmojiUpload call reused the outer `ok` from
the auth check by assignment; it gets its own readOK.
- admin/setup_handler.go: a moved comment kept a "the response above" deictic
that no longer had a response above it.
No behaviour change. Build, vet, full tests and -race on five packages green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(ws): clear the remaining complexity budgets across the hub
Eight files, thirteen findings. Each function is split at the stages it
already had; no branch is reordered, merged or inverted.
- handlers.go handleMessage (cyclop 28, 88 stmts): session re-check, frame
decode and result application become handleMessageSessionRecheck,
handleMessageDecode and handleMessageApply. The V2 constructor lookup ->
DispatchV2 -> Result resolution order is untouched.
- serve_ready.go buildReady (cyclop 26, 61 stmts): the per-section fetches
split out, readyChannelPayloads among them. Every visibility predicate is
preserved verbatim — this is the payload that decides what a client may see.
- serve_pumps.go writePump (cyclop 31): writePumpWrite, writePumpDeliver,
writePumpDrainChannel and writePumpDrainAndClose. Every channel receive
stays in the same select statement, so scheduling is unchanged.
- hub_sweep.go sweepStaleVoiceStates (cyclop 22, 56 stmts): the staleness
predicate, the hub-lock ordering and the position of the race hook are all
as they were — handleVoiceJoin's BUG-088 ordering depends on them.
- hub_broadcast.go channelReadAudienceImpl and RefreshChannelVisibility
(cyclop 22 each, 57 stmts): channelReadAudienceDM and
refreshChannelVisibilityCanSend. The audience predicate is the OC-0090
group-DM leak surface, so it is extracted, never simplified.
- livekit_webhook.go (nestif 13 and 14): webhookJoinedEnforceVoiceState,
webhookLeftCleanupClient and webhookLeftFinishLeave. DB delete still
precedes broadcast on every path.
- livekit_download.go EnsureLiveKitBinary (52 stmts): one extraction,
ensureLiveKitStageBinary, keeping every archive path check intact.
- voice_moderation.go (nestif 8): voiceModDeafenRollback. The persisted
server_muted flag remains the authority.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(api): clear the remaining complexity budgets across the HTTP layer
- router.go NewRouter (cyclop 28, 84 stmts): split by wiring concern into
routerTOTPKey, routerHealthDeps, routerMiddleware, routerUploadRoutes,
routerPluginWiring, routerVoiceRoutes and routerMetricsRoutes. Middleware
ORDER is a security property (auth before handler, WAF before body parse,
rate limit before work) and is unchanged; the returned cleanup func still
closes over and releases everything it did before.
- auth_handler.go handleRegister (133 lines) and handleLogin (cyclop 21,
152 lines): registerPolicyGate, registerReadRequest, loginReadRequest and
loginAuthenticate. The always-compare posture, every rate-limit key, every
counter reset and the ban-check-versus-password-compare order are all
preserved — including loginUserFailureThreshold staying unscaled by
scaledAuthLimit, which is deliberate and commented.
- upload_handler.go handleServeFile (cyclop 31, 128 lines): serveFileResolve
and serveFileAuthorize. Every header this sets — Content-Disposition
included, which is what stops a stored file being served as active content —
is still set with the same value in the same circumstances.
- profile_handler.go handleUploadAvatar (120 lines): avatarUploadReadImage,
mirroring readEmojiUpload in shape but with the avatar caps and MIME set.
The two deliberately do not share a helper.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor: clear the last complexity budgets in db and admin
- db/account.go DeleteAccount (cyclop 28, 55 stmts): grouped by subsystem into
deleteAccountAdminGuard, deleteAccountDMChannels and
deleteAccountCloseDMChannels, each taking the same transaction. The
transaction boundary, the delete ORDER (which foreign keys depend on) and
the rollback path are unchanged.
- admin/logstream.go handleLogStream (cyclop 24): logStreamAuthorize. Flush
cadence, heartbeat and disconnect detection untouched.
- admin/setup_wizard.go validateWizard (cyclop 23): grouped by section into
wizardValidateIdentity, wizardValidateNetwork and wizardValidateMedia. Every
message and bound is unchanged — this is the first input-validation boundary
on a fresh server, before any auth exists.
With this the tree is at zero: golangci-lint run reports 0 issues against the
budgets set in #1384 (funlen 100/50, cyclop 20, nestif 8, dupl 150), with no
//nolint and no exclusion added anywhere.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(lint): add ratcheted complexity budgets
Enables funlen, cyclop, nestif and dupl. Each threshold sits just above today's
worst offender, so the tree is green now and the budgets only block regression
past the current extreme:
funlen 320 lines / 135 statements (worst: main.go run, 311/131)
cyclop 60 (worst: ws handleVoiceJoin, 59)
nestif 18 (worst: 17)
dupl 250 tokens (green boundary; 150 flags 3 real pairs)
Measured over 1446 production functions with tests excluded. Verified tight
rather than slack: 320/135 is green and 310/130 is not.
The budgets apply to production code only. Table-driven tests are legitimately
long, and duplicated setup between cases is clearer than a helper that hides
what each case does.
These are a ratchet, not a standard. 94 functions exceed 60 lines and 22 exceed
120; none of them are touched. The settings block records what each budget is
waiting on, including the three duplicate pairs that must be collapsed before
dupl can drop to the conventional 150.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(lint): set complexity budgets to targets, not to what passes
Replaces the ratchet (thresholds parked just above today's worst) with real
targets. Existing offenders are left failing rather than excluded: an exclusion
list goes stale and quietly becomes permanent, whereas a failing check is a
backlog you can see and work off.
funlen 100 lines / 50 statements (was 320/135)
cyclop 20 (was 60)
nestif 8 (was 18)
dupl 150 tokens (was 250)
These are not the tool defaults (60/40, 10, 4). Those descend from 1976-era
cyclomatic-complexity work predating Go's explicit error handling, where every
`if err != nil` costs a branch and idiomatic code scores high for no real
complexity — which is why golangci-lint's other cyclomatic linter, gocyclo,
defaults to 30 rather than 10. The values above are chosen for a Go server.
Also disables three output limits that hide work. uniq-by-line is the sharp one:
it keeps one issue per line, and because cyclop and funlen both anchor at the
function declaration, enabling cyclop silently swallowed 16 of funlen's 21
findings. The visible backlog was 46; the real one is 62.
This leaves the lint gate RED by design. No other linter regressed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ws): 1 defect(s) (OC-0001)
* fix(db): 1 defect(s) (OC-0002)
sanitizeFTSQuery filtered only characters, so FTS5's bareword boolean
keywords (AND, OR, NOT) reached MATCH as operators; a query in an
invalid operator position raised "fts5: syntax error" instead of
returning results. Drop those bareword tokens after sanitizing.
* fix(dm): 1 defect(s) (OC-0004)
* fix(ws): 1 defect(s) (OC-0005)
* fix(voice): 1 defect(s) (OC-0006)
Count the shared voice_max_video budget in streams rather than rows: a
single user publishing both camera and screenshare consumed one slot while
producing two live streams, letting a channel over-admit up to 2N streams
against an N-stream cap.
* fix(client): 2 defect(s) (OC-0007, OC-0009)
OC-0007: mark the active channel loading before invalidating its message
window on a full-ready resync, so MessageList shows the spinner instead
of the empty-channel state for the duration of the refetch.
OC-0009: fan USER_UPDATE renames out to voiceStore.voiceUsers, which
keeps its own frozen username copy, so the voice roster no longer shows
a stale name for the rest of the call.
* fix(admin): 1 defect(s) (OC-0010)
* fix(identity): 1 defect(s) (OC-0011)
* fix(ws): 1 defect(s) (OC-0003)
The public half of an invisible user's presence (PresenceOthersEvent, and
BroadcastPresence's own mapped payload) went out via broadcastExcludeLow on
the low-priority queue - the ephemeral, unsequenced, drop-on-overflow
transport built for typing indicators - while every other source of the same
user's presence shares the normal-priority queue. That split one user's
presence across two per-client FIFOs with different durability and different
drain order (writePump drains normal strictly before low), so a frame could
land out of order against a later connect/disconnect presence frame, or be
silently dropped with no replay recovery.
Adds Hub.BroadcastToAllExcept, which routes through the same h.broadcast
channel and seqMu-serialized deliverBroadcast as BroadcastToAll, carrying an
excludeUserID that deliverBroadcast applies via pubsub.Publish(TopicGlobal,
msg, excludeUserID).
* fix(ws): 1 defect(s) (OC-0008)
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat(invariants): add the invariant-rule harness and the syncutil-locks rule
* fix(ws,service): route the last five raw mutexes through syncutil
The -tags deadlock CI pass only observes locks declared via syncutil, whose
Mutex/RWMutex are build-tag aliases. These five were declared as raw sync
types and were invisible to it, including the hub voice key-holder lock and
the permission and role caches.
TestServerInvariants now gates the tree against regressions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(invariants): walk the tree through os.Root to close a symlink TOCTOU
gosec G122: reading a filepath.WalkDir-supplied path is race-prone, since a
symlink swapped between the walk and the read escapes the intended tree.
os.Root confines every read to the root and cannot be traversed out of.
Walking the root's fs.FS also yields slash-separated paths already relative
to it, so the filepath.Rel and ToSlash conversion is no longer needed.
* fix(invariants): close syncutil-locks evasions, isolate per-rule tests, harden the gate
- I1: TestServerInvariants now asserts every registered Rule.Scope
directory exists and holds at least one non-test .go file, so the
gate cannot pass by scanning nothing.
- I2: split CheckSource into a thin wrapper over an unexported
checkSourceWith(rules, ...), so TestSyncutilLocks tests the
syncutil-locks rule in isolation instead of the whole registry.
- I3: broaden checkSyncutilLocks to a single SelectorExpr match (any
sync.Mutex/sync.RWMutex reference bound via f.Imports, aliases
included) instead of only *ast.Field/*ast.ValueSpec. Catches :=
composite literals, untyped var specs, type aliases, and
[]sync.Mutex/map[K]sync.Mutex, none of which the old rule saw. A
dot-import of "sync" is now its own violation, since it would
otherwise let a bare Mutex evade the selector match entirely.
- M2: suppression now keys off the violation's own Rule id
(allowed[v.Line][v.Rule]) rather than the running rule's ID, so a
rule that ever emits a sub-id isn't silently unsuppressible.
- M3: Run sorts with sort.SliceStable, since an unreasoned allow
comment and the violation it fails to suppress can share a
file:line.
- M4/M5/M1-partial: add a build-tag-gated fixture test, document that
allow comments must be same-line, and correct the skipDirs comment
to describe both the generated-code and gitignored-runtime-dir
cases it actually covers.
All ten original TestSyncutilLocks subtests pass unchanged; six new
subtests cover the evasions above.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(server): point at the syncutil-locks invariant gate
Server/CLAUDE.md told developers not to hand-roll around syncutil but
never said it's enforced. Note that Server/invariants/ checks it at
go test time and that exceptions are greppable via
grep -rn "invariant:allow" Server/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: track the graphify knowledge graph and the bug-hunt ledger
Both were local-only, so a clone — including a cloud session, which sees
only tracked files — started with no graph and no findings history.
graphify-out/: the top-level built graph is now tracked so a fresh clone can
query it without a rebuild. Subdirectories stay ignored: cache/ is a
per-machine AST cache, and graphify parks the previous graph in a dated
YYYY-MM-DD/ backup on every rebuild (18 MB of stale duplicate, a local
rollback aid rather than shared state).
.gitattributes marks the tree -text: the repo-wide `* text=auto eol=lf` rule
would otherwise rewrite line endings inside .graphify_labels.json.sig, which
signs the labels byte-for-byte, and invalidate the signature on checkout.
graph.json/graph.html also get -diff, and the tree is linguist-generated so
it stays out of language stats and collapses in review.
.superpowers/: findings-ledger.json, its FINDINGS.md render and
render-ledger.mjs are tracked so contributors can add findings by PR. Hunt
transcripts, .bak snapshots and debris patches remain per-session scratch.
Tradeoff accepted deliberately: the post-commit rebuild hook rewrites
graph.json, so each refresh writes a fresh ~18 MB blob into history. Refresh
it in its own commit rather than folding it into an unrelated diff.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(graphify): refresh the graph over the newly-tracked files
The first commit added findings-ledger.json, FINDINGS.md and render-ledger.mjs
to the tracked tree, so the post-commit rebuild picked them up and rewrote the
graph. Also ignores .pending_changes, the transient rebuild-state file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(graphify): share the PreToolUse graph-first nudge hooks
The two hook-guard hooks lived in the gitignored settings.local.json with an
absolute C:/Users path, so no other clone got them. Portable form: bare
`graphify` off PATH, and `|| exit 0` so a contributor without graphify
installed is never blocked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Update graph output files and manifest
- Updated binary files: graph.html and graph.json with new content.
- Added new entry for findings-ledger.json in manifest.json with updated metadata.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(server): supervisor detection and server.restart_mode config key
RunningUnderSupervisor detects systemd (INVOCATION_ID) and, best-effort,
NSSM (NSSM_SERVICE_NAME — 2.24 does not set it, so NSSM deployments set
the mode explicitly). server.restart_mode (auto|spawn|supervised, default
auto, env OWNCORD_SERVER_RESTART_MODE) selects how a self-restart hands
off after the server drains: exit for the supervisor to relaunch, or
spawn the replacement directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ngzj2Rx9UGC35uLHAfErMp
* fix(server): make the self-restart handoff drain fully before starting the successor
The update/restore/wizard restart previously spawned the replacement
while the old server was still serving, then SIGTERMed itself and
hard-exited after 10s. That design failed in every documented deployment
mode: under the shipped systemd unit the spawned child (same cgroup) was
killed when the old main process exited and Restart=on-failure never
relaunched a clean exit; on Windows the self-SIGTERM is unsupported and
silently dropped, so graceful shutdown never ran — hub.GracefulStop (the
only caller of LiveKitProcess.Stop) was skipped, orphaning livekit-server
on TCP 7880/UDP 50000-60000 and dropping queued event/audit batches; and
NSSM's relaunch raced the self-spawned replacement for the database lock.
Admin handlers now perform only the on-disk swap and request a restart
through an injected hook (admin.SetRestartHandoff). The main package's
restart coordinator cancels the parent of run()'s signal.NotifyContext —
the exact drain a SIGTERM triggers, on every platform — and after run()
has fully torn down (listeners closed, hub and LiveKit stopped, queues
flushed, DB closed and its lock released) main() performs the handoff:
spawn the replacement in spawn mode, or exit 0 for the supervisor in
supervised mode. A 90s backstop force-exits a wedged teardown; the
DB-lock and bind retries demote to safety nets.
A three-state guard (idle/busy/restart-pending) serializes update apply,
backup restore, and setup-wizard restarts against each other: concurrent
applies no longer race the same staged .new file or broadcast a spurious
update_aborted, and conflicting requests get 409 UPDATE_IN_PROGRESS /
RESTART_PENDING. The swap being free of process side effects also makes
the apply success path unit-testable for the first time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ngzj2Rx9UGC35uLHAfErMp
* fix(server): errno-based bind-conflict detection, ACME bind retry, LiveKit Pdeathsig
isAddrInUse now unwraps to the platform errno (EADDRINUSE; WSAEADDRINUSE
10048 on Windows) with the English strings kept only as fallback — the
string-only match never fired on localized Windows, silently disabling
the bind retry. The retry loop is extracted into serveWithBindRetry and
now also covers the ACME :80 challenge server, which previously gave up
on first conflict and stayed dead (breaking HTTP-01 renewals) until the
next restart. The .old-binary boot cleanup retries briefly for the
window where a spawn-mode predecessor has not fully exited. The
companion livekit-server gets Pdeathsig SIGKILL on Linux so a parent
killed without teardown (kill -9, OOM, backstop exit) cannot orphan it
with the voice ports held.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ngzj2Rx9UGC35uLHAfErMp
* docs(deploy): Restart=always unit and per-supervisor restart-mode guidance
Restart=always is what lets the deliberate clean exit after a
self-update/restore relaunch under systemd (systemctl stop is never
auto-restarted; failure exits behave as before). Deployment docs gain
the required NSSM AppEnvironmentExtra line, the Task Scheduler and
Docker restart-policy notes, and the new drain-then-handoff update flow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ngzj2Rx9UGC35uLHAfErMp
---------
Co-authored-by: Claude <noreply@anthropic.com>
TestTheLoadTest failed on the post-merge main run's windows-latest -race leg
(job 95067154071) with "timed out after 5s waiting for churned clients to
fully unregister" — every worker had finished, the hub was still running,
and the goleak dump that followed was only the anchor drainers the t.Fatal
skipped stopping. The runner was simply slow: the ws package took 276s
against 159s on the identical tree an hour earlier, db and service ran
13-24% slower too, and Unregister drains asynchronously behind the hub
loop's remaining broadcast work.
Bound the settle wait by the test's own overallTimeout (90s), the same
"only a genuine hang takes this long" limit the workers use. waitFor
returns as soon as ClientCount matches, so a healthy run pays nothing —
locally under -race the whole test still finishes in ~9s.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(release): give the Docker boot-smoke a writable /app, and run it in CI
The v1.2.0-alpha.3 release run died at "Boot-smoke Docker image": a bare
`docker run` of the distroless image has nowhere the uid-65532 server can
write — /app is root-owned, and the VOLUME /app/data anonymous volume is
created root-owned too — so config.Load failed on "writing default config:
open config.yaml: permission denied" and the container exited. Real
deployments bind-mount config.yaml and data/, which is why the image itself
is fine.
Move the smoke into Server/scripts/docker-smoke.sh, run the container with
`--tmpfs /app --tmpfs /app/data` (Docker's tmpfs default mode is 1777, so
the non-root server can write both), and call the same script from ci.yml's
docker-build job — loading the image it already builds — so the smoke is
exercised on every PR to main instead of for the first time at tag time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(docker): ship /app and /app/data owned by the runtime uid so a bare run boots
The tmpfs approach did not survive CI: runc re-applies the underlying
directory's mode to a tmpfs mounted over an existing path, so /app stayed
root:755 and the write still failed. Fix the image instead of the harness:
stage /app/data in the builder, chown it to 65532, COPY --chown it into the
distroless stage before WORKDIR. Docker seeds the VOLUME's anonymous volume
from that image dir, ownership included, so `docker run <image>` with no
mounts now boots and answers /health — which is also the contract the smoke
should be testing, so it goes back to a bare `docker run`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Bump the client version in package.json (+lock), tauri.conf.json and
Cargo.toml (+lock) so release.yml's verify-versions gate passes and
deployed clients see the update; refresh the literal version in the
README and docs build examples; add the curated CHANGELOG entry covering
the 199 verified defects fixed since v1.2.0-alpha.2 (#1366-#1375), the
observability/backup/deployment hardening in #1376, migration 031, and
the new config keys.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* docs: add infrastructure roadmap plan
Records the verified recommendations from an infrastructure review in three
tracks: raising the single-instance ceiling, cheap seams for a possible
multi-instance future, and ops hygiene. Includes explicit anti-recommendations
and sequencing. Security-sensitive detail is intentionally excluded per
docs/security.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj
* feat(server): real health checks and saturation metrics
/api/v1/metrics now exposes signals that were already computed in memory but
never surfaced: reconnect replay tier hits, event-persister counters, SQLite
writer-pool wait stats, aggregate per-client backpressure counters (including
previously invisible low-priority drops), and permission-cache hit/miss.
/health now returns a real verdict: hub dispatch-loop liveness, a bounded
database ping, and a free-disk check, returning 503 with a subsystem reason
when degraded. Checks are cached so the unauthenticated endpoint cannot
amplify load. The hub's panic breaker now exits the process so a supervisor
can restart it, instead of leaving broadcast delivery silently dead while
clients still appear online.
OTel instruments that were declared but never recorded are now wired
(ws_active_connections, ws_broadcast_latency_seconds, ws_messages_total,
ws_events_dropped_total, voice gauges) or removed (db_query_duration_seconds).
Also corrects the docs/api.md description of broadcast_drops, which counts
hub-queue overflow, not client send-queue overflow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj
* feat(server): implement scheduled backups, retention, and backup verification
The backup_schedule and backup_retention settings have existed in the admin
panel and API since the initial schema but were never read by any code. The
15-minute maintenance loop now enforces them: a scheduled backup is taken
when the newest backup on disk is older than the schedule interval (manual
backups reset the clock), and retention prunes backups older than the
configured days while always keeping the newest one.
Backups are now verified with PRAGMA integrity_check immediately after
VACUUM INTO (a failed backup is removed rather than listed as restorable)
and again before a restore may overwrite the live database. A failed VACUUM
INTO also cleans up its partial output file — but never a pre-existing one.
The backup directory is configurable via a new backup.dir key (default
data/backups) so operators can point backups at another disk or an off-host
mount, mirroring the SetDatabasePath plumb.
Restore-handler tests now use real SQLite fixtures (the integrity gate
correctly refuses text files) with the mid-copy failure injected through a
test-only copy hook. Also adds audited gosec suppressions to the Windows
disk-free syscall added in the previous commit, which the Windows lint leg
flagged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj
* feat(server): capacity and failure-mode guardrails
- server.max_ws_connections: optional cap on concurrent WebSocket clients,
checked before the upgrade with a 503 + Retry-After; rejections are counted
and exposed as ws_conn_rejects in /api/v1/metrics.
- Single-process database lock: an OS-level advisory lock (flock / exclusive
handle) beside the SQLite file makes a second server process fail fast with
a clear message instead of silently fighting the first over process-local
state. A bounded retry covers the self-update/restore restart handoff, and
the lock mechanism failing (e.g. network filesystems) only warns.
- Disk-space awareness: boot-time warnings for the data and backup volumes,
plus a disk_free_mb metrics field, via a small cross-platform diskutil
package (already used by /health).
- Upload storage failures: storage.Save now marks server-side filesystem
failures with a sentinel (storage.ErrIO); handlers return 507 for those
instead of blaming the client with a 400, and the emoji route stops echoing
raw storage errors (which embed absolute paths) into responses.
- Unknown config keys now warn at startup — a typo like admin_alowed_cidrs
previously kept the default silently while the operator believed the
setting changed. Never fatal: newer servers tolerate older configs.
- Admin settings honesty: the three stored-but-inert settings (server_icon,
max_upload_bytes, voice_quality) are shown read-only with a note pointing
at the real config.yaml keys, instead of pretending to apply.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj
* perf(db): write-path efficiency and capacity knobs
- channel_focus/mark_read now skip the read-state UPSERT when the stored row
already matches (same last_message_id, no mentions) — refocus events fire
at up to 10/s/user and every no-op write still occupied the single SQLite
writer connection. The extra existence check runs on the reader pool, which
doesn't serialize. Same shape as the session-touch throttle.
- DeleteExpiredSessions is now sargable: migration 031 normalizes legacy
expiry formats to the RFC3339-Z layout the server writes and indexes
expires_at, replacing the strftime full-table scan that ran on the writer
every 15 minutes.
- Boot-time ANALYZE runs only when a migration actually applied; unchanged
schemas get the cheap PRAGMA optimize instead (which also covers
crash-restarts that never reached the shutdown optimize).
- The read/write SQL router gets a table-driven test with explicit expected
values (INSERT ... RETURNING must hit the writer despite being :one).
- New knobs, all defaulting to current behavior: database.max_readers,
security.auth_rate_limit_multiplier (for shared-NAT communities),
event_persistence.replay_ring_size and replay_cold_limit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj
* fix(server): shutdown lifecycle ordering
- The event pruner and maintenance loop are now joined (bounded) before the
database closes: bgCtx cancellation used to run AFTER database.Close via
LIFO defers, contradicting its own comment, and neither goroutine was ever
waited on — a mid-tick scheduled backup or prune could still hold the
writer while the pool tore down. StartEventPruner returns a done channel
with the same join contract EventPersister.Stop already had.
- srv.Shutdown now runs before hub.GracefulStop, so in-flight HTTP handlers'
broadcasts still reach a live hub and the event persister instead of
vanishing from the replay/event store across a restart. Shutdown does not
wait on hijacked WebSocket connections, so the swap adds no delay.
- GracefulStopContext threads the 30s shutdown budget into the hub: the 5s
client-notice window (matching the countdown clients are shown) ends early
when the budget expires, and is skipped entirely when nobody is connected —
early-return startup paths and idle servers no longer sleep 5s for an
audience of zero.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj
* build(deploy): systemd unit, compose hardening, boot-smoked releases, CI polish
- deploy/owncord.service: hardened systemd unit template with the two
verified caveats encoded (install dir stays writable for self-update under
ProtectSystem=strict; CAP_NET_BIND_SERVICE for ACME's :80), plus a
'Linux (systemd)' deployment docs section — the Linux service story was
previously 'Docker or nothing'.
- New 'Reverse Proxy Topology' docs section with a working nginx snippet and
the correct signaling-vs-media distinction: /livekit/* is already proxied
by the server, only WebRTC media ports must be directly reachable.
- docker-compose: log rotation, commented resource limits, and a healthcheck
backed by a new 'chatserver healthcheck' subcommand (the distroless image
has no shell) that probes /health without config side effects.
- release.yml: a concurrency group (queue, never cancel), and boot-smoke
gates — the freshly built server binaries and the Docker image are cold
booted and probed healthy BEFORE anything is signed or pushed. The release
feed drives signed self-updates, so a binary that compiles but dies on
boot previously would have shipped itself to every auto-updating instance.
- ci.yml: client-check/client-tests move to ubuntu with the reasoning
recorded (no win32 code paths, LF enforced repo-wide); admin-e2e gets a
written graduation criterion instead of an open-ended non-blocking status.
- docs: Tailscale guide notes the CGNAT range vs the default admin CIDRs;
architecture overview records presence/voice state as the fifth
single-instance blocker and the macOS client scope decision.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj
* perf(server): measured load tooling, narrowed invalidation, presence coalescing, storage and CIDR seams
- Fix scripts/k6/ws-load.js against the real wire protocol: envelope-wrapped
frames, correct message types (typing_start, presence_update), the correct
/api/v1/ws path, and thresholds that fail a run where nobody authenticated
or went ready — the script had drifted to pre-envelope framing and reported
100% green while every auth failed on the first frame. A new
workflow_dispatch-only load-baseline workflow boots a real server, seeds
users through the setup/invite APIs, runs the script, and uploads the k6
summary plus a metrics snapshot for before/after comparison.
- Role-scoped channel-override changes now evict only the affected role's
members from the permission cache (fail-safe: unreadable member list still
flushes everything). InvalidateAll here repopulated every connected user —
two reads each — synchronously inside the admin request via
RefreshChannelVisibility, a stampede that scaled with total population
rather than the role's size. Same pattern the per-user override endpoints
already used.
- Connect/disconnect presence broadcasts now pass through a 300ms latest-wins
coalescer (QueuePresence): each un-coalesced presence change is a sequenced
global broadcast (an O(clients) fan-out under seqMu), so a reconnect storm
fired O(users) of them from the connect critical path. A flap inside the
window collapses to its final state; the wire format, seq ordering, and
replay behaviour are unchanged, and the delivery path (BroadcastPresence)
is untouched.
- Storage seam: api handlers now consume a FileStore interface (consumer-side,
same pattern as service.Store) with Open returning a seekable storage.File —
writing down the contract (range-request seeks included) an alternative
backend would have to meet, without building one.
- The metrics surfaces and the LiveKit webhook/health endpoints get their own
allowlist keys (metrics_allowed_cidrs, livekit_webhook_allowed_cidrs, both
defaulting to admin_allowed_cidrs), so a central Prometheus scraper or an
externally-hosted LiveKit no longer requires widening the admin panel's
perimeter. Startup now also warns when admin_allowed_cidrs is customized
while trusted_proxies is empty — behind a proxy or container network the
check would otherwise compare the proxy's private address, not the client's.
- The container healthcheck probe now PINS the server's own certificate from
disk (VerifyConnection, exact-match) instead of skipping TLS verification,
addressing the CodeQL finding on the previous commit; WebPKI verification
is used when no local cert exists (ACME).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj
* fix(server): address self-review findings on the hardening branch
Seven fixes from a high-effort review of the full branch diff:
- healthcheck CLI now works under tls.mode acme: it overrides ServerName
with the configured domain for WebPKI verification instead of pinning a
cert that doesn't exist (or is stale) in that mode. Previously an ACME
deployment's container healthcheck failed forever.
- /health pings the READER pool (new db.PingRead): the writer ping queued
behind a scheduled backup's VACUUM INTO and reported the server degraded
for the whole backup — which an autoheal watchdog would turn into a
nightly mid-backup restart.
- /health runs its cached checks under context.WithoutCancel so a probe
that disconnects mid-request cannot poison the shared cache with a false
degraded verdict for the next 5 seconds.
- The token CLI uses a new db.OpenShared that skips the single-process
lock: minting a token against a running server is safe under WAL and was
a documented workflow the lock had broken.
- The per-user TOTP failure cap is no longer scaled by
security.auth_rate_limit_multiplier — that knob exists for per-IP limits;
scaling the only cross-IP brute-force defence multiplied an attacker's
distributed guess budget. Mirrors the unscaled per-user login threshold.
- A direct presence_update now drops the user's queued entry in the
connect/disconnect coalescer, so a stale connect-time presence can no
longer flush 300ms later over the user's fresher chosen status.
- The scheduled-backup filename collision loop breaks on any stat error
and bounds its suffix probing, instead of spinning the maintenance
goroutine forever on a persistent EACCES.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj
* test(admin): real SQLite fixture for the merged Close-failure restore test
TestHandleRestoreBackup_RestartsWhenCloseFails arrived from main (#1375)
with a plain-text backup fixture; this branch's restore handler verifies
backups with integrity_check before touching the live database, so the text
fixture was (correctly) refused with 400 before the Close-failure branch
under test was reached. Use a real backup via BackupToSafe, matching the
other restore tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(client): 1 defect(s) (OC-0201)
* fix(service): 1 defect(s) (OC-0202)
HandleTyping built the per-user-per-channel rate-limit key before resolving the channel or checking read permission, so forged channel ids could pin unbounded dead entries in the shared process-wide RateLimiter.
* fix(client): 2 defect(s) (OC-0203, OC-0224)
* fix(server): 1 defect(s) (OC-0204)
* fix(ws): 2 defect(s) (OC-0205, OC-0211)
* fix(admin): 2 defect(s) (OC-0209, OC-0212)
* fix(client): 1 defect(s) (OC-0210)
* fix(db): 1 defect(s) (OC-0213)
* fix(ws): 1 defect(s) (OC-0214)
Route handler-driven PresenceEvent through BroadcastToAll instead of BroadcastToAllLow so every source of a user's presence shares one ordered per-client FIFO.
* fix(admin): 1 defect(s) (OC-0215)
PATCH /users/{id} combining banned + role_id committed and broadcast the ban before authorizing the role change, so a refused role change returned an error while leaving the target banned. Authorize the role change up front via the new ModerationService.AuthorizeRoleChange.
* fix(db): 1 defect(s) (OC-0216)
LinkAttachmentsToMessage no longer claims an attachment that is a user's live avatar (users.avatar points at it). Once message_id is set, handleServeFile's avatar branch (gated on ChannelID == nil) is unreachable and the file falls under the message's channel ACL / soft-delete state, permanently disagreeing with users.avatar about who may read it.
* fix(emoji): 1 defect(s) (OC-0217)
* fix(client): 1 defect(s) (OC-0218)
The data-copy phase of an HTTP proxy tunnel was unbounded. Steps 1-2 of
handle_connection (header read, TCP connect, TLS handshake) each run under
a 10s guard, but step 3 called io::copy_bidirectional with no deadline. A
remote that completes the TLS handshake and then neither responds nor
closes parks the spawned connection task, the loopback socket and the
remote TLS session indefinitely: copy_bidirectional only resolves once
BOTH directions finish, so closing the local side alone does not free it.
Wrap the copy in copy_with_deadline, a generic helper bounded by
DATA_PHASE_TIMEOUT (600s). The bound is deliberately far looser than the
10s setup guards because this phase carries the REST body, including
attachment and avatar uploads, so it must reclaim only genuinely stuck
connections rather than merely slow ones. The helper is generic over the
stream types so it can be exercised without a live TLS connection.
Regression test drives two in-memory duplex pairs whose far ends stay
alive, so neither half ever observes EOF and raw copy_bidirectional would
block forever; the test asserts the call resolves on its own deadline with
ErrorKind::TimedOut.
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL
* fix(ws): 1 defect(s) (OC-0219)
* fix(client): 1 defect(s) (OC-0221)
UpdateNotifier scheduled its deferred update check with a setTimeout whose
handle was never retained, so destroy() could not cancel it. A component torn
down inside the 3s window (page swap / logout) still fired performCheck() and
issued a network update check against the old server URL. Retain the timer
handle and clear it in destroy().
* fix(dm): 1 defect(s) (OC-0222)
* fix(client): 1 defect(s) (OC-0223)
* fix(voice): 1 defect(s) (OC-0225)
The Grant-Microphone retry's .finally hardcoded grantMicBtn.disabled = false, undoing updateFrozen()'s socket-down freeze when the WS socket dropped while the mic permission request was in flight. Delegate the state back to render().
* fix(admin): 1 defect(s) (OC-0226)
handleApplyUpdate broadcasts a 'restarting in 5s' notice before the on-disk
swap. Every failure path in the swap returned silently, leaving clients
counting down to a restart that never happened. Extract the swap into
applyStagedUpdate and send a corrective 'update_aborted' broadcast from a
deferred guard on every path that does not reach the respawn.
* fix(admin): 1 defect(s) (OC-0227)
PATCH /channels/{id} accepted a blank or whitespace-only name, leaving the
channel unidentifiable in clients. updateChannelRequest.validate() now
rejects it the way handleCreateChannel already did.
* fix(identity): 1 defect(s) (OC-0228)
* fix(admin): run deferred cleanup before the update restart exits
The fix batch left three golangci-lint findings and two prettier findings
that CI gates on.
applyStagedUpdate called os.Exit(0) in the same function that defers both
staged.Close() and the corrective "update_aborted" broadcast, so neither
ran (gocritic exitAfterDefer). Return a bool instead and let the caller
exit once those defers have run — on Windows, releasing the staged binary's
file handle is the reason the restart exists at all, so this is a real fix
rather than a lint appeasement. The exported test hook calls the function as
a statement, so the added result does not affect it.
Also modernize a bulk-insert loop to range-over-int, compare backup bytes
with bytes.Equal, and reflow two test files to prettier's output.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL
* test(ws): pin the live presence path against the invisible custom-status leak
OC-0207 and OC-0211 are the same defect at two emitters: hub_broadcast.go's
BroadcastPresence (connect/reconnect) and event.go's presenceEvents (live
presence_update). The fix for OC-0211 closed both sites in one change, but
only the hub_broadcast side got a regression test.
This pins the event.go sibling: an invisible user's real custom status must
be blanked on the PresenceOthersEvent frame while the owner's own
PresenceSelfEvent still carries it. Without it, a later change could reopen
the live path while the committed test kept passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL
* fix(ws): 1 defect(s) (OC-0206)
* test(ws): silence a contextcheck false positive in the reconnect race test
RefreshChannelVisibility takes no context by design — it is reached through
the admin HubBroadcaster interface, which carries none, so it builds its own
internally. contextcheck flags the call only because the test closure around
it holds a ctx for its override write, so there is nothing to propagate.
Suppress at the call site rather than widen a production interface (and its
mocks) to satisfy a lint in a test.
golangci-lint v2.11.3 (the version ci.yml pins) now reports 0 issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix: enhance bugfix workflow documentation with detailed clustering and staging instructions
* fix(voice): 6 defect(s) (OC-0001, OC-0006, OC-0009, OC-0010, OC-0015, OC-0029)
* fix(voice): 1 defect(s) (OC-0005)
* fix(client): 1 defect(s) (OC-0007)
* fix(client): 1 defect(s) (OC-0011)
* fix(client): 1 defect(s) (OC-0012)
* fix(admin): 1 defect(s) (OC-0013)
* fix(client): 3 defect(s) (OC-0014, OC-0024, OC-0031)
* fix(voice): 1 defect(s) (OC-0018)
* fix(voice): 1 defect(s) (OC-0019)
* fix(client): 1 defect(s) (OC-0021)
* fix(client): 1 defect(s) (OC-0025)
* fix(ws): 1 defect(s) (OC-0026)
* fix(client): 1 defect(s) (OC-0027)
* fix(client): 1 defect(s) (OC-0028)
* fix(identity): 1 defect(s) (OC-0030)
* fix(voice): 1 defect(s) (OC-0016)
* fix(client): 2 defect(s) (OC-0002, OC-0020)
OC-0002: chain offer handling behind the announce chain so an offer that
arrives immediately behind its sender's announce is not dropped as an
unknown peer.
OC-0020: retire a departing peer's ECDH key on participant-left so a
replayed pre-leave announce cannot overwrite the fresh key they rejoined
with.
* fix(voice): 1 defect(s) (OC-0008)
handleVoiceJoin handed the client its LiveKit token before checking whether
the join had been superseded by a concurrent eviction (moderator kick/move,
the CONNECT_VOICE revocation sweep, CleanupVoiceForChannel). Those evictors
delete the voice_states row, clear the client's in-memory state, and call
RemoveParticipant — which no-ops because the join has not reached the SFU
yet. The client was left holding a live 5-minute RoomJoin credential for a
membership the server had just torn down.
Re-check the client's voice state immediately after GenerateToken and
withhold the credential if the join was superseded, with a best-effort
RemoveParticipant to match every other eviction path.
* fix(ws): 2 defect(s) (OC-0017, OC-0022)
OC-0017: sweepStaleVoiceStates re-checks the live client immediately before
deleting a snapshotted-stale voice_states row. voice_join commits the row
before calling c.setVoiceState, so a join that lands inside that window was
snapshotted as a ghost and had its just-committed row deleted, leaving the
client in voice in memory with no DB row.
OC-0022: CleanupVoiceForChannel resolves its voice_leave audience with a
variant of channelReadAudience that skips the archived short-circuit. Both
production callers archive the channel before evicting, so the plain
resolver always returned an empty audience and only the evicted
participants learned the call ended.
* fix(voice): 1 defect(s) (OC-0023)
Camera and screenshare now draw from the same per-channel voice_max_video
budget. handleVoiceScreenshareV2 performed no cap check at all, and the
camera gate's slot-count subquery counted only `camera = 1` rows, so a
screensharing occupant was invisible to it. Both gates now count
`camera = 1 OR screenshare = 1` via a shared enableVideoSlot helper.
* fix(client): 2 defect(s) (OC-0032, OC-0033)
OC-0033: voice_disconnected staleness guard swallowed the kick toast when
the sibling voice_leave had already cleared currentChannelId. Treat a
cleared store as not-stale.
OC-0032: VIDEO_LIMIT rollback assumed the camera, tearing down a working
camera and leaving refused screen tracks published. Correlate by envelope
id and roll back the kind that was actually refused.
* fix(voice): 1 defect(s) (OC-0034)
* fix(client): 1 defect(s) (OC-0035)
A superseded video-enable id makes rollbackPendingVideo return undefined.
The dispatcher's ternary treated undefined as "not screen" and called
disableCamera(), tearing down a working camera the user never touched.
Return early instead: undefined means there is nothing to roll back.
* fix(voice): 1 defect(s) (OC-0036)
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix: enhance bugfix workflow documentation with detailed clustering and staging instructions
* fix(workflows): drop hardcoded absolute repo path from bughunt prompts
The bughunt and bughunt-fix agent prompts told every finder, verifier, fix
and prove agent that the repo lives at a specific absolute path from one
contributor's machine. Anywhere else - a cloud session, CI, another
checkout - that path does not exist, and the churn recon agent ran
`git -C <that path> log ...` outright, so the most-churned-files inventory
came back empty and every finder prompt lost its churn context.
Point the prompts at the agent's working directory instead, which is the
repo root on every platform.
Both harnesses pass (bughunt.harness.mjs, bughunt-fix.harness.mjs).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(voice): 4 defect(s) (OC-0008, OC-0009, OC-0042, OC-0080)
Guard LiveKit session state against supersession: bump the camera/screen
generation in leaveVoice and teardownForReconnect so an in-flight enable
discards its track, bail out of restoreLocalVoiceState when a newer room
claimed _room mid-await, and recheck isStateConnected in the auto-reconnect
tail.
* fix(ws): 1 defect(s) (OC-0019)
* fix(db): 1 defect(s) (OC-0023)
* fix(ws): 1 defect(s) (OC-0029)
* fix(ws): 1 defect(s) (OC-0032)
* fix(voice): 1 defect(s) (OC-0034)
* fix(admin): 1 defect(s) (OC-0035)
* fix(service): 2 defect(s) (OC-0036, OC-0128)
* fix(voice): 2 defect(s) (OC-0038, OC-0065)
OC-0038: the LiveKit participant_left webhook cleared the leaver's own
client voice state before broadcasting voice_leave, so the broadcast
audience (READ_MESSAGES holders union still-in-the-room participants)
could no longer see them. Voice membership is gated on CONNECT_VOICE
alone, so a participant without READ_MESSAGES never learned the server
had torn down their call. Extracted finishVoiceLeave's audience logic
into broadcastVoiceEventWithLeaver and used it on the webhook path.
OC-0065: handleWebhookParticipantJoined OR'd a GetVoiceState read error
into the same branch as "no matching row", so a transient DB failure
ejected a legitimate participant from the SFU mid-call. Now the read
error is logged and the check skipped, matching sweepStaleVoiceStates.
* fix(client): 1 defect(s) (OC-0041)
* fix(client): 1 defect(s) (OC-0043)
* fix(client): 1 defect(s) (OC-0046)
* fix(client): 1 defect(s) (OC-0047)
* fix(client): 1 defect(s) (OC-0049)
* fix(client): 1 defect(s) (OC-0108)
* fix(client): 2 defect(s) (OC-0111, OC-0143)
OC-0111: retry a presence_update dropped by the 1-per-10s limiter once the
window reopens, so auto-idle's return-to-online does not leave the server
and every other client stuck on idle.
OC-0143: pass apiConfig.host to the DM profile sidebar so per-user notes
are scoped per server, matching channel mutes, the NSFW gate and volume.
* test(ws): align aborted-switch test with OC-0034 no-resurrect behavior
The fix agent rewrote this pre-existing test (it locked the buggy restore
path) but the prove agent left it out of c67d25ed; committed state alone
failed go test ./ws/ without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(workflows): raise subagent effort tiers (sonnet/haiku to xhigh, prove opus to high)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(voice): 6 defect(s) (OC-0098, OC-0004, OC-0005, OC-0006, OC-0007, OC-0020)
* fix(db): 1 defect(s) (OC-0096)
* fix(admin): 1 defect(s) (OC-0097)
* fix(auth): 2 defect(s) (OC-0099, OC-0021)
* fix(voice): 1 defect(s) (OC-0018)
* fix(admin): 1 defect(s) (OC-0045)
* fix(api): 1 defect(s) (OC-0103)
* fix(client): 1 defect(s) (OC-0105)
* fix(client): 1 defect(s) (OC-0107)
* fix(api): 1 defect(s) (OC-0109)
* fix(api): 1 defect(s) (OC-0112)
* test(admin): compare restore bytes with bytes.Equal
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(voice): 2 defect(s) (OC-0095, OC-0014)
OC-0095: createRoom never called setE2EEEnabled(true), so the full ECDH/HKDF/AES-GCM key exchange completed but frames still reached the SFU in plaintext.
OC-0014: token refresh timer was 23h while the server mints LiveKit tokens with a 5-minute TTL, so any reconnect after minute 5 presented an expired token.
* fix(profile): 2 defect(s) (OC-0100, OC-0102)
* fix(service): 1 defect(s) (OC-0022)
Archived channels were only read-only for SendMessage/DeleteMessage. Edit, reaction, pin and purge sinks bypassed the check. Route every write sink through a shared requireChannelWritable gate.
* fix(api): 1 defect(s) (OC-0048)
* chore(workflows): correct stale model labels in bughunt-fix phase details
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(client): 1 defect(s) (OC-0015)
* fix(voice): 1 defect(s) (OC-0002)
* test: fix two CI-only failures in the batch-4 test suite
The delete-account broadcast test now observes member_ban on a second
client's socket: the hub broadcasts and then force-disconnects the target,
so on a slow runner the close could beat the target's own copy of the
frame. The observer is also the party the event exists for.
The voice e2e mock now echoes the real joined channel id on voice_leave
(it hardcoded channel_id 0, which the dispatcher's channel-matched
self-leave teardown correctly ignores), and the rejoin test waits for the
mock's delayed echoes to settle before clicking the row again — clicking
inside the echo window toggled a leave instead of a join.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(message-list): rebuild virtual window when scroll leaves the rendered range
Scroll-driven renderWindow calls previously never rebuilt the DOM, so
scrolling past the overscan showed only spacer blank space until an
unrelated data change forced a full re-render. The window now rebuilds
whenever the computed visible range is not fully contained in the
rendered one, keeping the no-op (and the existing rebuild rate limiter)
for ranges that are already rendered.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU
* fix(messages): refetch channel tail when revisiting a previously loaded channel
The server only delivers live message broadcasts for the focused channel,
so a channel's loaded window stops updating once the user switches away.
Switching channels now drops the left channel's loaded flag so the next
visit refetches the live tail, while keeping the old rows rendered until
the refetch merges in (pending/failed rows are preserved).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU
* fix(channel-sidebar): assign distinct slots when reordering channels with tied positions
Categories whose channels share a position value (the server does not
enforce uniqueness, and new channels default to position 0) previously
produced an empty or partial reorder on drop, leaving the final order
ambiguous. Tied slots are now nudged into a strictly increasing sequence
before being reassigned, while already-distinct groups keep their range.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU
* fix(embeds): keep the link-preview abort timer armed until the body is read
The link-preview fetch previously cleared its 5 s abort timer as soon as
response headers arrived, so reading the response body was unbounded in
time and size. The timer is now cleared in a finally after the body read,
so the timeout covers the whole request and the 50 KB parse cap applies
as documented.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU
* fix(message-list): anchor scroll-to-bottom and jump-to-present controls outside the scroller
The two floating controls were appended inside the overflow scroller, so
they were part of its scrollable overflow and translated away with the
content whenever the user scrolled up — precisely when they become
visible. They now anchor to a position:relative frame that wraps the
scroller, keeping them pinned to the viewport edge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU
* fix(voice): reset the pinned input device before cycling the mic to default
Selecting the default microphone (or losing the selected one to a hot
unplug) only muted and unmuted the existing track, which kept capturing
from the previously pinned device. The shared cycle now resets the
capture device to the system default first so both paths actually reach
it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU
* fix(voice): re-check supersession after camera publish before announcing camera on
A camera disable that completes while the enable's publishTrack call is
still in flight now causes the enable to unpublish and stop its track and
skip the enabled announcement, so the server's last word matches the local
state instead of reporting a stopped camera as on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU
* fix(voice): re-check supersession across the screenshare publish loop
A screenshare disable that completes while a publish in the enable loop is
still in flight now stops the loop before the remaining tracks are
published; the enable attempt unpublishes and stops all of its tracks and
skips the enabled announcement, so tracks held only by that attempt are
released and the server's last word matches the local state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU
* fix(sidebar): rethrow channel modal API failures so modals can recover
The create/edit/delete channel callbacks caught API errors and only
showed a toast, so the awaiting modal never saw the failure and left its
submit button disabled with the in-flight label. The callbacks now
rethrow after toasting, letting each modal re-enable its button and
render its inline error so the user can retry without losing the form.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU
* fix(messages): carry pending and failed rows across the prepend trim
When a scroll-up page pushed a channel past the per-channel cap,
prependMessages trimmed the tail wholesale, deleting pending/failed
optimistic rows that hold the only copy of the user's composed text.
The trim now carries those rows across, matching the other
window-replacing writers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU
* fix(channel-sidebar): re-resolve the drop container when the sidebar re-renders mid-drag
A store-driven sidebar re-render while a drag is in flight rebuilds the
channel rows, detaching the container captured at mousedown; detached
rows report all-zero rects, so the drop and the hover indicator could
never resolve. The global handlers now re-target the live row, its
container, and the store's current group snapshot before hit-testing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU
* fix(delete-channel-modal): re-arm the confirm button when a delete fails
The confirm button was only restored from the catch block, so a caller
that handled the failure itself and resolved left the button disabled on
'Deleting...' with no way to retry. Restoration now runs in a finally
block whenever the modal is still open, regardless of how the callback
settled.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU
* fix(dm-sidebar): keep the presence dot when an avatar image loads
The avatar swap cleared the whole circle before inserting the fetched
image, which also removed the online/idle/dnd/offline dot on 1:1 rows.
The initial now lives in its own node and only that node is replaced,
so the presence dot survives the image arriving.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU
* fix(formatting): compute the yesterday boundary from the calendar date
The relative-day fence post was derived by subtracting a fixed 24 hours
from local midnight, which lands inside the wrong calendar day when a
DST transition makes the local day 23 or 25 hours long. It is now built
from the calendar date directly, so hover and expanded message
timestamps keep the correct Yesterday label around transitions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU
* fix(message-input): keep the empty-edit guard active while attachments are queued
Edits are text-only, so a queued attachment no longer bypasses the
empty-content guard while editing. Submitting an edit whose text was
cleared is now refused with edit mode intact, matching the behavior
when nothing is attached.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU
---------
Co-authored-by: Claude <noreply@anthropic.com>
First-live-run fallout (2026-08-13, base 34f2e41):
- Verify moves fable -> opus. An Anthropic outage (Mythos/Fable/Sonnet
elevated errors) mass-nulled the fable verify agents mid-run; opus
verifiers then carried the whole 8-round hunt with 0 nulls / 0 unverified.
- args.budgetTotal fallback. The +25M turn directive left budget.total null
in every probe this session, silently disarming the cost ceiling. The
ceiling now arms from args.budgetTotal when the directive doesn't, computed
from budget.spent(); budget.remaining() stays authoritative when it does.
- ROUND_BUDGET_FLOOR 2M -> 600k. The 2M value was a dual-finder-era anchor
(~2.6M/round); the single opus finder costs ~100-260k/round, so 2M would
zero-out any hunt launched with a budget under 2M - a foot-gun now that
budgetTotal is a first-class arg.
Harness: +s8c (args-armed ceiling announces budget=10M and floor-stops);
s8b/s9 retuned to the 600k floor. 33/33 offline scenarios pass.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore: ignore graphify-out
* fix(bughunt): assemble the report in-script - the report agent dropped findings
* fix(bughunt): retry only unverified candidates and catch garbage-verdict batches
* fix(bughunt): retune the round budget floor and require a budget directive
* feat(bughunt): per-round telemetry and a runStats aggregate
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(bughunt-run): record run telemetry and validate coordinates after each hunt
* feat(bughunt): drop the sonnet finder slot
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(bughunt): rebuild adaptive targeting - directory clusters, cooldown, graph-fed explore lenses
* fix(bughunt): rewind a dead finder's explore draw so unread files are never marked clean
* docs(bughunt-run): pre-hunt graph ranking checklist and offline test roster
* fix(bughunt): rewind thrown-stage explore draws and correct log/doc wording
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(bughunt): disambiguate absorb() drop log and retire dead sonnet label alternation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(bughunt-fix): add a circuit breaker for systematically failing runs
A fix run had no abort condition. If something was systematically wrong - the
operator on the wrong branch, a broken test runner, ledger coordinates gone
stale after a rebase - it worked through every cluster, spending a high-effort
agent on each, and only reported the wreckage at the end.
Two trip points, because there are two distinct failure signals:
- after the fix stage, a high blocked rate means the fixing itself is failing.
Proving each of those costs a serial agent per cluster and cannot succeed, so
phase 3 is skipped entirely.
- inside the prove loop, a high revert-proof failure rate means the proving is
failing. Break rather than attempt the rest.
`declined` never counts as a failure - it is a judgement the fix prompt
explicitly invites, and a run where several findings are correctly declined is a
good run. Both points require a minimum number of attempts first, because "50%
of two" is noise. Clusters never reached are marked blocked with a rationale
naming the breaker, so nothing is left reported as fixed with no commit behind
it, and the gate still runs over whatever committed before the trip.
proveAttempts is incremented before the ok check so successes land in the
denominator; inside the failure branch the ratio would be failures-over-failures
and trip on the first failed cluster at any threshold.
Verified with 6 new harness scenarios (21 -> 27, all green, bughunt.harness.mjs
untouched at 21). The guard was also proved load-bearing: with the threshold
temporarily raised to an unreachable 1.1, f16 runs all four clusters instead of
stopping at three and f20 produces no breaker report - both fail for the reason
the guard exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(bughunt): attribute confirmed findings to the finder that produced them
The dual-model panel unions its two finders rather than voting between them, so
the second model's entire value is what it finds alone - and the union threw
that away, leaving no way to tell whether sonnet earns its cost.
Tag each finding with its panel slot. Because dedupe keeps the first occurrence
and opus is slot 0, a confirmed finding tagged sonnet is one opus missed, which
is exactly the number that decides the question. The run logs the split.
Three details worth naming:
- the tag is taken from the panel slot, not from the position in the surviving
list. Filtering the nulls out before reading the index shifts sonnet into slot
0 whenever opus dies and mislabels its finds as opus - precisely when the
attribution matters most.
- the tag is stripped in verifyPrompt, not at its two call sites, so every
caller routes through the guard. The verifier prompt says "another model" on
purpose; naming it is an authority cue that erodes refute-by-default.
- dropping to a single finder would also weaken convergence, since a round only
counts as dry when the full panel reported. The skill records this next to the
count so the decision is made with both halves in view.
Verified with 4 new harness scenarios (21 -> 25). The dead-opus case is the
load-bearing one: it fails against the naive filter-then-index form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(bughunt): seed dedupe from the findings ledger via args.known
* feat(bughunt): allow a scoped hunt via args.lenses
* feat(bughunt): carry finder why/repro/evidence into confirmed records
* feat(bughunt-fix): add workflow skeleton with per-file clustering
* feat(bughunt-fix): add parallel per-file fix agents
* fix(bughunt-fix): dedupe ids in the test stub, not in the merge loop
* fix(bughunt-fix): drop foreign result ids loudly and assert the fix-prompt rules
* feat(bughunt-fix): add serial revert-proof and per-cluster commits
* fix(bughunt-fix): require real test output in the prove report
* feat(bughunt-fix): add the ci-check gate and finalise the return shape
* fix(bughunt-fix): harden the gate call and cover generated-code drift
* docs(bughunt): add the bughunt-run operator skill
* fix(bughunt-fix): guard cross-cluster edits, branch, and ledger handoff
The 2026-08-10 refresh opened 17 PRs: ten gomod, four npm, three actions.
Each one rewrites its ecosystem's lockfile, so merging any single PR
invalidates every sibling, which then rebases and re-runs the full ~15
minute CI matrix. Clearing the batch sequentially costs 17 CI cycles for
one weekly dependency refresh.
A catch-all group per ecosystem makes that 4 PRs at most. It also keeps
release trains intact -- the seven OpenTelemetry modules in that batch are
one coordinated release and belong in one PR.
The stryker and vitest groups are removed because the npm catch-all
subsumes them; their reason for existing (exact peer pins across a family
break under a partial merge) is now the rationale for the whole scheme and
is recorded at the top of the file.
Majors are already ignored for every ecosystem, so each group only ever
carries patch and minor updates. A bad member goes on the ignore list
rather than ungrouping the rest.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
NewLiveKitProcess built its health-check http.Client without a Transport,
so it fell back to the process-wide http.DefaultTransport.
httptest.Server.Close calls CloseIdleConnections on http.DefaultTransport
by design ("assume most users of httptest.Server will be using the standard
transport, so help them out"), and ws is full of t.Parallel tests that each
defer srv.Close(). Any one of them finishing while a health check held a
pooled connection severed that request:
livekit_test.go:978: HealthCheck: livekit health check failed:
Get "http://127.0.0.1:41343": net/http: HTTP/1.x transport connection
broken: http: CloseIdleConnections called
That surfaced as an unrelated-looking CI failure on a TypeScript lint bump
(#1341). It is not purely a test artifact: in production the health check
also shared one connection pool with every other DefaultTransport user in
the server process.
Cloning DefaultTransport keeps its tuned defaults (proxy, dial and TLS
timeouts, HTTP/2) while giving the client a private pool.
Locked by TestHealthCheckClientOwnsItsTransport, which fails on the
unfixed constructor.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Batches the four open Dependabot npm PRs into one change so
package-lock.json is rewritten once instead of four times:
eslint 10.8.0 -> 10.8.1
oxlint 1.76.0 -> 1.77.0
knip 6.31.0 -> 6.32.0
typescript-eslint 8.65.0 -> 8.66.0
All four are devDependencies; no runtime dependency moves.
Supersedes #1341, #1345, #1349, and #1352.
Verified per the ci-check skill: 4822 unit tests across 171 files, tsc
--noEmit, npm run lint, and prettier --check all pass. The
no-underscore-dangle warnings oxlint prints on livekitSession.ts are
pre-existing -- oxlint 1.76.0 emits the identical set -- and are warnings,
not errors.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Batches the ten open Dependabot gomod PRs into one change so the OTel
release train lands together and go.sum is rewritten once instead of ten
times:
go.opentelemetry.io/otel 1.44.0 -> 1.45.0
go.opentelemetry.io/otel/sdk 1.44.0 -> 1.45.0
go.opentelemetry.io/otel/metric 1.44.0 -> 1.45.0
go.opentelemetry.io/otel/trace 1.44.0 -> 1.45.0
go.opentelemetry.io/otel/sdk/metric 1.44.0 -> 1.45.0
go.opentelemetry.io/otel/exporters/prometheus 0.66.0 -> 0.67.0
contrib/instrumentation/net/http/otelhttp 0.69.0 -> 0.70.0
github.com/knadh/koanf/v2 2.3.5 -> 2.3.6
github.com/knadh/koanf/parsers/yaml 1.1.0 -> 1.1.1
modernc.org/sqlite 1.55.0 -> 1.56.0
go mod tidy also carried the transitive bumps each of those PRs would have
pulled on its own (httpsnoop, logr, go-isatty, libc).
Supersedes #1338, #1340, #1342, #1343, #1344, #1346, #1347, #1348, #1350,
and #1351.
Verified per the ci-check skill: all four build-tag variants, go vet,
go test -race ./... , the -tags deadlock pass over ws, and golangci-lint
(0 issues).
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
golangci-lint-action verifies .golangci.yml against a JSONSchema it pulls
from https://golangci-lint.run before it lints anything. On d352696 that
fetch hit its client timeout and took the required ubuntu leg of Server
Build & Test red -- with zero linters run and nothing wrong with the code.
Turn the pass off. `golangci-lint run` already rejects a malformed config
on its own, so the only thing lost is a nicer error message for a config
typo, and the thing gained is a required gate that no longer depends on a
third-party website being reachable.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: add bug-detection improvements plan
Plan for mechanical bug detection alongside the agentic hunt: activate the 14
unused Go fuzz harnesses, the configured-but-never-run Stryker setup, and
browser-mode vitest; encode recurring bug classes as semgrep rules; add
model-based and fault-injected ordering tests; add a persistent seen-ledger
and sibling-sweep lens to the hunt.
All local-only and on demand - fuzz crashers are working reproducers, and this
repo is public.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* build: add make fuzz target and ignore mutation-test output
`go test ./...` runs each Fuzz* function against its committed seed corpus
only - one pass per seed, zero generated inputs - so the 17 fuzz harnesses in
Server/ have never actually fuzzed. `make fuzz` enumerates every target and
runs each with a time budget (Go fuzzes one target per package per
invocation, hence the loop). Local-only by design: a crasher is a working
reproducer and this repo is public.
Also gitignore Client/tauri-client/.stryker-tmp/ and reports/ - a Stryker run
left 200+ untracked files, and a surviving-mutant report maps exactly which
behaviour nothing tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(client): pin reconnect auth-frame and replay-dedup arming
Stryker found 14 surviving mutants across ws.ts:413/422/428 - the auth frame
built on reconnect. Every condition there could be flipped with all 4777
tests still green: the replay-dedup arming guard, the resume-vs-fresh-connect
ternary, and the conditional active_channel_id spread.
Seven tests through the public send/isReplaying surface, no new exports. Two
isolate each half of the `reconnectAttempt > 0 && lastSeq > 0` AND condition -
the combination no existing test reached, and the one an && -> || mutant
walked straight through.
Verified by flipping the line 413 guard to `if (true)`: 3 of 7 fail, revert
restores green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: record two fuzz corpus traps
Interrupting a fuzz run manufactures a false crasher: Go cannot distinguish a
worker that crashed on an input from one killed externally, so it saves the
in-flight input to testdata/fuzz/ as a suspect. It looks exactly like a real
security finding. Replay before believing it.
And committed seed corpus shares the testdata/fuzz/<Target>/ directory with
any false crasher, so clearing one by removing the directory deletes the
seeds too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(client): enforce three prose invariants as ESLint rules
CLAUDE.md documents the voice-supersession, E2EE staleness and dispatcher
invariants in English. English fails no build, and bug hunts keep rediscovering
the same classes. Five rules encode them as an inline flat-config plugin - no
new dependency, and `npx eslint src/` is already a blocking CI gate.
- no-leave-voice-when-superseded: a global leaveVoice() inside a branch that
already confirmed supersession tears down the newer live session
- e2ee-epoch-needs-keypair-check: a non-key-holder never bumps the epoch, so
an epoch-only staleness guard cannot see a restarted session
- e2ee-verified-status-literal: keeps "verified" tied to a hand-written call
site that earned it, never a computed status
- no-identity-scope-fallback: a `?? 0` placeholder scope mints a keypair under
the wrong account
- no-store-write-in-ws-on: page-local ws.on handlers may read stores, not
write them
Each rule proven to fire by reintroducing the historical bug shape and
reverting; RuleTester cases cover both the real shapes that must stay clean
and the bug shapes that must not.
A fourth candidate - await-then-stale-snapshot - was declined as not
AST-expressible: whether an await needs a guard, and whether the guard is
sufficient, is intent rather than shape, and the rule would flag most of the
already-correct guard code in livekitSession.ts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: correct dispatcher invariant, record Tier 2 as shipped
The client CLAUDE.md claimed ws.on(...) appears only in dispatcher.ts. Eight
handlers across main.ts, MainPage.ts and ChannelController.ts say otherwise -
page-local UI (ringing, overlays, slow-mode timers) legitimately subscribes.
The real invariant is narrower: dispatcher is the single path by which server
events WRITE to domain stores. That is what local/no-store-write-in-ws-on
enforces, and the doc now matches the code.
Also record that Tier 2 shipped as ESLint rules rather than semgrep, and why
the fourth candidate was declined.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): move the status-picker dot onto the avatar corner
The corner dot on the user bar avatar was a static hardcoded-green div —
never reflected real status and did nothing on click. Removed it and
relocated the actual StatusPicker trigger dot (real color, opens the
status dropdown) to that same corner instead of its own row. The
"Online"/"Idle"/... text label under the username is unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(client): return the saved password over IPC again
The remember-password box saved a password the client could never read
back. Hardening had put #[serde(skip)] on CredentialData::password, so
load_credential returned a record whose password was always absent and
the login form could not prefill it — the box appeared to work and
silently did nothing.
Drop the skip and carry the field through the TS wrapper, which now maps
a non-string password to undefined rather than trusting the payload.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(client): add an auto-connect checkbox to the login form
Auto-connect already existed end to end — ServerProfile.autoConnect,
setAutoLogin(), and the boot auto-login block with its cancel overlay —
but was only reachable through the zap button on a server card. This
surfaces the same state as a checkbox under Remember password, where
users look for it.
Ticking it forces Remember password on and disables it: boot auto-login
replays the stored token, which saveCredential only writes when the
password is remembered, so the two cannot be set independently without
producing a setting that silently does nothing.
Unticking is guarded. setAutoLogin(null) clears autoConnect on every
profile, so a bare toggle-off would wipe another server's setting; the
clear now only fires when this profile is the current holder. The guard
lives in ensureProfileExists, which all four auth paths already route
through.
Also consume the password restored in the previous commit, so selecting
a saved server prefills it instead of leaving the field blank.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* chore(release): bump client to 1.2.0-alpha.2
The client version is not derived from the tag — release.yml's
verify-versions job compares the tag against package.json and
tauri.conf.json and fails the release if they drift, so all five
manifests (both lockfiles included) move together.
Also refreshes the literal version in the README and docs build
examples, and closes the Unreleased changelog section as v1.2.0-alpha.2.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* docs(changelog): record the three bug-hunt sweeps in v1.2.0-alpha.2
PRs #1328, #1331 and #1332 merged to main after v1.2.0-alpha.1 was tagged
and closed 233 verified defects between them, but none of the three left
an entry in the curated changelog — the generated list covers commits,
this file covers behaviour, and nothing bridged the two.
Verified unreleased by ancestry rather than by date (none of the three
merge commits is an ancestor of v1.2.0-alpha.1), so all of it ships for
the first time in alpha.2.
Nine entries grouped by subsystem, leading with the changes an operator
or user would actually notice: the 24h-retention desync, the avatar-
deleting orphan sweep, the zero-byte restore truncation, the six hot-mic
paths, and the TOFU re-pin that would have warned every install at once.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* test(client): drop the e2e assertion for the removed user-bar status dot (#1334)
26b46cc removed the hardcoded-green `.status-dot` div from the user bar
avatar and relocated the real StatusPicker trigger dot into that corner,
adding "status picker dot sits on the avatar" to cover the new element.
The old "user bar has status dot" test was left behind and now fails on
an element that no longer exists by design.
The replacement test already asserts the corner dot is present and
visible, so removing the stale one loses no coverage.
Claude-Session: https://claude.ai/code/session_01Rkv9dVo5YEYArqrDRfW41w
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): gate every mic re-enable path on the user's mute state
Six separate paths republished the microphone without consulting whether
the user had muted themselves: the audio-device fallback, selecting the
"Default" input, un-deafening, retryMicPermission, a stale PTT ownership
latch, and auto-reconnect's restoreLocalVoiceState. Each one produced a
hot mic while every remote UI still showed the user as muted.
These were six findings but one missing guard. Adds isMicPolicyGated()
(localMuted || localDeafened || localServerMuted || pttGated) and routes
the device-switch cycle, applyMicMuteState's unmute branch and
retryMicPermission through it, which also covers setDeafened(false) --
a call site no finding named.
Also extracts reconnectSuperseded() so all five supersession checkpoints
in the auto-reconnect loop carry the state-type check that only the
give-up path had, and clears the PTT gate on stopPtt and on ptt-error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): stop camera/screenshare publishing after the user turns it off
enableCamera and enableScreenshare set the store flag before awaiting
getUserMedia/getDisplayMedia, so clicking off during the OS picker left
the track publishing to the SFU while the UI showed it off, with no stop
affordance. Adds one shared generation guard: disable bumps, enable
captures before the await and discards the track if it changed.
Also in this area:
- a server refusal of voice_screenshare (or a non-VIDEO_LIMIT refusal of
voice_camera) never rolled back the published track; the dispatcher now
correlates the error by envelope id rather than blanket-rolling-back.
- a full-ready resync left every loaded channel with a permanent hole in
its history, because that tier never replays chat_message frames.
Loaded windows are now invalidated on a resync (pending and failed rows
carry through) and the active channel refetched.
- CHANNEL_FULL while joining left voiceStatus stuck; DM mirror rows kept
phantom entries and stale unread counts across a resync; addMessage and
setAroundMessages dropped offline/failed optimistic rows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): preserve a mid-setup key-holder promotion, and route the
audio graph through the noise suppressor
setupKeyExchange unconditionally wrote the server's key-holder value
captured at join, clobbering a handleParticipantLeft promotion that
landed during its pre-publish awaits. The joiner then waited for an offer
only it could send, timed out, and was ejected from voice. The write now
preserves an existing promotion; it sits after the existing
session-generation check, and clearState bumps that generation and resets
the flag synchronously, so stale state cannot survive a teardown.
Enhanced Noise Suppression silently disabled the input-volume slider and
the VAD gate: livekit-client's setProcessor() does its own internal
replaceTrack(processedTrack) after awaiting addModule and a fetch, so it
landed after ours and wired the sender straight to the raw mic. The
pipeline now sources from the processed track and re-runs after
attaching, so our replaceTrack wins.
Also scopes the voice identity keypair by host AND user id so two
accounts sharing one OS profile stop sharing an identity keypair, guards
peer-key and TOFU writes against a clearState during their IPC awaits,
and seeds VideoGrid tiles from the persisted per-user volume.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): drop the previous server's bearer token on a host switch
api.setConfig spread the new config over the old, so switching hosts
carried the previous server's session token forward and the login request
to the next server went out holding a live credential for the first one.
The token is now dropped in the shared setConfig when host changes
without an accompanying token, covering login, register and auto-connect
at once.
Also fixes a packaged-build-only failure: the CSP omitted blob: from
img-src, so avatar upload validation (which measures the image via
URL.createObjectURL) always failed in release and never in dev.
Smaller connection and IPC fixes: ws_disconnect now bumps the connection
generation instead of nulling the sender slot, so an in-flight handshake
cannot install after a disconnect; a dead LiveKit proxy listener
deregisters itself instead of being reused forever; httpProxy no longer
caches an origin the Rust side may have torn down; logPersistence stopped
looping on its own flush-failure logs; ConnectPage subscribes to
transientError instead of reading it once; cert-mismatch accept/reject
only act when the event host matches the live session.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): guard the quick-switcher against a double-open
openQuickSwitch assigned its instance only after awaiting the profile
load, so a second click during that window mounted a second overlay and
orphaned the first. Every close affordance destroys only the tracked
instance, leaving a body-mounted position:fixed backdrop that blocks all
input until the app is reloaded. Adds the same `opening` flag the sibling
overlay controllers already use; audited every other opener in these
files and found no second instance of the race.
Also: loadOlderMessages and loadMessages now discard a response whose
window was replaced mid-fetch by a same-channel jump; the ArrowUp
edit-last-message scan skips unsent rows, matching the visual affordance;
unpinning from the pinned panel writes the store row; the pinned panel
forwards the channel it captured at open time rather than reading the
active one at click time; the reaction picker closes on channel teardown;
a non-voice channel switch dismisses the video grid; and destroy() closes
the settings overlay.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): repair the status-picker stylesheet and a dozen UI defects
The .status-picker rules targeted a root element the component never
toggles, leaving the popup's own chrome unstyled and the root
display:none. Repointed at .status-picker-dropdown and dropped the dead
rules.
Component and store fixes, all test-first: the upload preview bar never
became visible so upload errors were invisible; replying while editing
left the edit text in the textarea; MessageList's load-older latch keyed
off a raw count so a live tail append refired the fetch; drag-reorder
renumbered channels into a 0..n-1 range instead of reusing the group's
own position slots; DM avatars bypassed the authenticated fetch path;
the member-list moderation gate read a mount-time role snapshot; mention
autocomplete offered usernames the mention grammar cannot express;
notifications titled DMs as "#channel"; the update-notifier catch
dereferenced a null banner; and the channel context menu leaked its node
on teardown.
Also resets authStore in member-list.test.ts's shared reset helper: one
test was leaving role="admin" set for every test after it, unnoticed
because no gate read authStore for role until now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): adopt the legacy identity key instead of re-minting one
Scoping the identity keypair by host and user id changed the keyring
account name, so every existing install would have found nothing at the
new account and generated a fresh identity key. Every peer who had
already pinned the old one would then see a TOFU mismatch, which raises
the re-pin modal telling the user to verify the safety number
out-of-band -- a MITM alarm fired at the whole alpha population at once,
which teaches people to click through the one warning meant to matter.
When the scoped account is empty, the legacy host-only account is now
adopted: saved under the scoped name, then the legacy account deleted.
Save happens before delete so a partial failure leaves the legacy key in
place for the next launch rather than stranding the user with neither.
A corrupt legacy blob falls through to fresh generation without throwing.
A second account on the same host still mints its own distinct keypair,
which was the point of the scoping fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): surface server errors that had no dedicated handler
The WebSocket error handler bannered only RATE_LIMITED and FORBIDDEN, so
every other code that reached the fallthrough was dropped in silence --
a rejected chat_edit reported nothing at all while the optimistic
"Message edited" toast still fired. Every specific branch above already
returns, so the fallthrough sees only genuinely unhandled codes; it now
banners all of them.
Also:
- reattachToPresent cleared the detached flag eagerly, so a failed tail
refetch let a live broadcast splice onto the stale around-window with a
silent gap. The flag now survives until setMessages lands the tail.
- a mixed-case host and its lowercase-normalized URL form resolved to
different cert-store pin keys; tofu::cert_store_key and ws.ts's
normalizeHostForCertCompare both lowercase now. attachments.ts already
did the right thing and is unchanged.
- clearAuth left the channels store populated for the next login.
- capabilities/default.json was missing
core:window:allow-request-user-attention.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): tear down video tiles, focus and the lightbox on leave
Four defects an earlier pass could not finish because each spanned two
files:
- closeVideoGrid only hid the grid, so remote video tiles survived a
channel leave and reappeared on the next join. VideoGrid grew a
clearStreams(), called from the real-leave branch of checkVideoMode
(not the reconnect branch).
- the grid kept its focused-tile state across a close; setFocusedTile now
accepts null and closeVideoGrid clears it.
- the per-user volume preference key had no host component, so volumes
set on one server applied to a different user with the same id on
another. Scoped via setAudioVolumeHost, mirroring channel-mutes.
- the media lightbox stayed mounted after MainPage.destroy().
Also repairs tests/unit/audio-elements.test.ts, which was missing an
afterEach import and failing to compile.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): close eight defects a review found in this batch
Three of these are incomplete fixes from earlier commits on this branch --
the diagnosis landed, the cure stopped short.
- main.ts kept a hand-copied normalizeHostForCertCompare that never got the
.toLowerCase() its ws.ts original and tofu::cert_store_key both have. Since
the Rust side always emits the lowercased host and a profile stores it
verbatim, any uppercase in the hostname broke all three guards -- worst of
them the mismatch modal's onReject, which then skipped disconnect/clearAuth
and left the user connected to the server whose certificate they had just
refused. ws.ts now exports the one implementation and the copy is gone.
- the status-picker stylesheet repair repointed the root and deleted the old
.status-option rules without adding replacements under the names the
component emits, so the trigger dot -- a bare div whose only style is an
inline background -- stayed 0x0, invisible and unclickable. The picker still
could not be opened.
- ungateMic's re-open branch was unreachable in the one scenario its comment
described: a PTT release routes through setMuted(true), so localMuted is
always true there. It now takes the pttOwnsMute latch read *before* each
call site resets it; reading the module flag from inside would always see
false and move the bug rather than fix it.
The rest:
- dispatcher.ts statically imported @lib/screenShare, which has value imports
from livekit-client -- dragging ~1.3 MB into the entry chunk that the file's
own comment says is deliberately kept out of it. Now lazy, like every other
voice call site here.
- replay detection compared payload.timestamp (server clock) against
Date.now() (client clock). A self-hosted server without NTP made every live
message after a reconnect look like a replay, silently killing notifications
for the whole drift window. Both sides are now in server time via an
observed skew estimate; latency biases it toward treat-as-live, which is the
side that costs a duplicate rather than a dropped notification.
- identity.ts and livekitE2EE.ts each derived the keyring scope with `?? 0`.
A missing user id would have adopted-and-deleted the real legacy key into a
bogus host:0 account, then minted a second keypair under host:<realId> --
published key and signing key permanently disagreeing, which is a false MITM
warning for every peer. Unreachable today, irreversible if reached.
- per-user volumes were scoped by host with a legacy fallback that only fired
when currentHost was null, which MainPage never leaves it as -- so every
saved volume silently read as the default on upgrade. Reads now fall through
to the unscoped key once and persist under the scoped one.
- a post-resync invalidate ran unconditionally while its refetch was guarded,
so a missing getMessages left every window dropped with nothing to reload it.
A ninth finding -- that the DM reconcile could strand activeChannelId -- was
checked and rejected: the block 40 lines above already clears it whenever the
id is absent from both channels and dm_channels.
Two test-suite notes: livekit-session's announce-signing test was joining
voice with no authenticated user, which production does not permit, so it now
sets one (below PEER_ID, leaving key-holder election unchanged) and clears it
after. status-picker-userbar reads app.css from disk rather than `?raw`, which
vitest stubs to an empty string for stylesheets.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): repair the e2e cert test and four defects found verifying it
The e2e suite caught one behavioural divergence from this branch, and
hand-verifying the hunt's flagged-but-unchecked items turned up four more
defects.
E2E:
- cert-tofu's "disconnect on mismatch returns to the connect page" emitted the
mismatch for myserver.example:8443 while the session was authenticated
against localhost:8443, so it asserted the pre-fix behaviour: a certificate
rotating on ANY unrelated saved profile logs you out of the server you are
using. That is the bug 8917c28 deliberately fixed. The test now emits for the
live host, and a new sibling pins the guard itself -- a mismatch for another
host must leave the session alone. Verified by defeating the guard: only the
new test goes red, which is why the old one never noticed the change.
Defects found verifying the ledger's open items:
- logging out fired delete_credential fire-and-forget and then navigated to the
connect page, whose auto-login immediately read the same account back. Since
B4-3 moved the credential commands to #[tauri::command(async)] they no longer
serialize on the IPC thread, so a read that wins that race signs the user
straight back into the server they just left. Two fixes, because the race and
the intent are separate problems: a CREDENTIAL_LOCK mutex restores the
one-operation-at-a-time property that also keeps secret_store::set's
read-modify-write atomic, and the connect page now skips auto-login once
after a logout that removed the credential -- mirroring the quick-switch
sessionStorage idiom already in that file. A server_shutdown logout keeps its
credential and deliberately does not set the flag, so restart auto-login
still works. e2e-pinned: with the suppression defeated, the user is visibly
back in the app after clicking Log Out.
- a post-resync history refetch that REJECTED left the active channel's window
already invalidated but never marked errored, so MessageList fell into its
"no messages yet" welcome branch -- rendering a failed reload as a genuinely
empty channel, with no Retry, until the user navigated away and back. Now
calls setChannelLoadError, reusing MessageController's existing plumbing.
- an invite deep link arriving during the connected overlay's 800ms ready
countdown hit a gate that assumed isAuthenticated implies the router is on
"main". It is not: clearAuth() ran without the teardown that only the
authStore subscriber performs (and only while on "main"), so the overlay's
timer then mounted MainPage over a nulled-out auth state, and the invite was
dropped. Gated on the real invariant and the in-flight session is now torn
down explicitly.
- channel mutes carried the same dead legacy-preference fallback that per-user
volumes had -- guarded on currentHost === null, which MainPage never leaves
it as -- so every saved mute was silently discarded on upgrade. Mutes are a
list, where an empty saved value is real data, so this needed a presence
probe rather than the volume fix's sentinel.
Also extends the e2e Tauri mock with storedSettings/storedCredential seeds so
auto-login paths are exercisable at all.
Verified clean: 4800 vitest, 293 Playwright, 97 cargo, tsc, tsc -p e2e, eslint,
prettier, clippy -D warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): stop Tab escaping every modal, and a duplicate row after a resync
Two bugs left open by the previous round.
The "flaky" a11y focus-trap test was not flaky -- it was a real accessibility
defect surfacing nondeterministically. FOCUSABLE_SELECTOR is structural and
says nothing about visibility, but this codebase hides controls with inline
`style.display = "none"` (MemberPickerModal's group-name field and confirm
button both start hidden). So focusDialog() picked a display:none input as the
dialog's first focusable and called .focus() on it -- which browsers silently
refuse -- and focus never entered the dialog at all. trapFocus() then computed
first/last as those same hidden elements, so neither Tab branch ever matched
document.activeElement, preventDefault() never fired, and Tab fell through to
the browser's native order and walked straight out of the dialog. Whether the
test noticed depended on how much async sidebar content happened to be
focusable at that moment, which is what made it look intermittent.
Fixed in the shared helper rather than in the one modal that exposed it: about
forty call sites hide controls the same way, so every factory modal had the
same hole. trapFocus and focusDialog now filter out inline-hidden elements.
Reproduced first at 3/10 failures under --repeat-each; 10/10 after, and 20/20
at --workers=4. Note the check reads inline styles only -- an element hidden by
a CSS class would still slip through, which no current call site does.
Second: a message the server persisted but whose chat_send_ok ack was lost to
the same disconnect that forced a resync was displayed twice. The optimistic
row keeps id 0 until confirmSend stamps it, so setMessages' id-based carry-over
could never collide it with the real row, while addMessage had solved exactly
this for the live path by matching on content. Extracted that predicate as
isUnreconciledEcho and used it in both, so the two cannot drift apart.
The dangerous direction here is over-merging, not under-merging: collapsing two
genuinely distinct sends of the same text loses a real message. Three things
bound it -- only rows still awaiting reconciliation qualify (pending, or failed
for OFFLINE specifically, since a SLOW_MODE rejection is never broadcast and
eating that row would kill a live retry draft), author and content must both
match, and each snapshot row is consumed at most once, so N identical pending
sends pair off against N identical real rows instead of collapsing onto one.
Both directions are tested.
Verified: 4804 vitest, 293 Playwright with zero flaky, tsc, tsc -p e2e, eslint,
prettier.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): style the user profile popup
The popup rendered unstyled: it appeared at the bottom of the page and
pushed the rest of the app up, with the avatar drawn as a full-width bar.
app.css carried a complete Discord-shaped card under `.user-popup` /
`.up-*`, but nothing in the codebase renders those classes — the
component emits `.upp-*`. The component had been rewritten with a new
prefix and the stylesheet was left pointing at a DOM that no longer
existed. With no rule matching, the card stayed `position: static`, so
the left/top it computes were discarded and both it and its overlay laid
out as ordinary blocks at the end of <body>.
Replace the orphaned block with rules for the classes actually rendered,
following the same anatomy: banner strip, avatar straddling the
banner/body seam inside a ring punched from the card background, panel
sections, action row. Everything routes through existing tokens, so the
card follows the theme contract.
Two latent bugs fixed while there:
- Placement guessed a 300px card height and clamped only the top edge,
so a member clicked low in the list opened a card that ran off the
bottom of the window. Measure the card and clamp both edges.
- The avatar has to hang off the body's top edge, but the body scrolls,
and `overflow-y: auto` clips horizontally too. Make it a child of the
card rather than the body.
The fade+scale moves from inline styles into CSS so a
`prefers-reduced-motion` override can drop it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): stop vite watching src-tauri
`npm run tauri dev` died on Windows partway through the cargo build:
Error: EBUSY: resource busy or locked, watch
'src-tauri\target\debug\deps\owncord_client_lib.dll'
Error The "beforeDevCommand" terminated with a non-zero status code.
Vite's watcher recursed into `src-tauri/target/`, and the moment cargo
wrote the output DLL, node's FSWatcher raised EBUSY as an unhandled
error event and killed the vite process. Vite is tauri's
`beforeDevCommand`, so its death aborted the whole dev session.
The config matched the upstream Tauri vite template in every respect
except the `server.watch.ignored` block that template ships with. Add
it. Tauri already watches `src-tauri` itself for rebuilds, so nothing
is lost.
Windows-specific — EBUSY on an open handle is a Windows filesystem
semantic, and CI only ever runs `tauri build`, never `tauri dev`, so
neither caught it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(security): add 2026-08-04 whole-codebase security review (#1326)
Read-only security review of the full tree (Go server, admin panel, WASM
plugin host, LiveKit voice, Tauri client). No code changes.
Three findings, all the same defect class — a security predicate enforced
at some members of a handler family but not all:
- A-2026-08-01 (HIGH) handleDeleteChannelPermission omits the hierarchy and
grantability guards its PUT twin carries, so a MANAGE_CHANNELS holder can
clear their own role's channel deny and read private channels.
- A-2026-08-02 (HIGH) the admin channel list/patch/delete handlers omit the
type == "dm" guard their sibling getPermChannel carries, so the same role
can enumerate and irreversibly cascade-delete arbitrary DMs and group DMs.
- A-2026-08-03 (MEDIUM) DMService.RingTargets omits the block check the five
other DM interaction sinks perform, so a blocked user can ring the person
who blocked them.
Also records one non-vulnerability observation (backup restore writes to a
hardcoded database path, silently no-opping when database.path is
customised), the candidates rejected during verification, the areas verified
clean, and the areas not examined.
Claude-Session: https://claude.ai/code/session_01Q7GUJtdsHHHGs4pSiLn6LJ
Co-authored-by: Claude <noreply@anthropic.com>
* Full audit: docs/spec refresh + remediation (security fixes, dead-code removal, test & CI gaps) (#1327)
* docs: fix server reference docs (api, protocol, server-configuration, deployment)
api.md:
- Correct the login rate limit: 5/min per IP (was documented as 60/min);
document the per-username lockout and lockout persistence
(Server/api/constants.go, Server/api/auth_handler.go)
- Complete the middleware list to the real 9-entry chain incl. the
opt-in Coraza WAF (Server/api/router.go)
- Add voice_sessions and broadcast_drops to the metrics sample
(Server/api/metrics_handler.go) and document the otel-only
Prometheus /metrics mount
- Add reference sections for the previously undocumented /admin/api
endpoints: setup, stats, users, audit-log, settings, tokens,
backups, updates, and the SSE log stream (Server/admin/api.go)
protocol.md:
- Fix type counts (client->server 26, server->client 37) and add the
missing rows: call_ring, call_decline, emoji_update, call_incoming,
call_declined
- Correct rate limits: voice join/leave 5/1s (was "None"), E2EE offer
64/1s (was 5/1s), and add the call-ring limit (1/3s)
- Document the plugin command wire types (chat_command, command_reply,
plugin_broadcast) and flag that they sit outside protocol-schema.json
server-configuration.md:
- Add missing keys: server.waf_* (3), database.type,
telemetry.otlp_insecure, and the whole logging section +
OWNCORD_LOGGING_LEVEL
- Correct plugin-disabled status code to 503 (was 501)
deployment.md:
- Drop the removed "version" field from the /health sample; add
broadcast_drops to the metrics sample; note the distroless non-root
image; refresh build version strings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(architecture): rewrite stale architecture pages against 5630aa1
All six pages carried "Verified against ddc49f0 (2026-07-19)" stamps and had
drifted:
- websocket.md: delete the false claim that docs/protocol-schema.json does
not exist — it is the codegen source of truth (Server/scripts/genprotocol,
CI-gated by make protocol-verify); note the hand-declared plugin command
family as the one exception; refresh LOC
- server.md: fix the websocket dependency (github.com/coder/websocket, not
nhooyr.io), refresh LOC (42k/71k), migrations 016 -> 028
- data-model.md: migrations 001-028, 23 -> 26 tables, add api_tokens and
channel_user_overrides to the ER diagram, channels.type now includes
announcement, note 017/024/027/028 columns; drop the claim that schema.md
is 6 migrations behind (it is current)
- voice-e2ee.md: drop the stale claim that the E2EE flow is absent from
protocol.md (it has a full section); document livekitE2EE.ts/identity.ts
and identity-key pinning
- client.md: rewrite — Solid beachhead is gone; the HTTP path is now
TOFU-pinned through http_proxy.rs (the doc claimed the opposite); shared
tofu.rs core with explicit-consent pinning; 9 stores (roles store deleted,
blocks + emoji added); refreshed LOC and tooling figures
- README.md: 26 tables/001-028; client-architecture.md described as the
redirect stub it is; companion-audit links refreshed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(architecture/ux): align UI flow specs with current client behavior
- Cert first-use is a blocking trust modal on status "first_use" — the Rust
proxy rejects the first connection until the user confirms (main.ts:146-176,
tofu.rs); the specs described the pre-F4/F8 behavior (an 8s banner on
"trusted_first_use"). Fixed in README.md and connection-and-auth.md.
- Dispatcher event table: add the five missing types (chat_bulk_deleted,
roles_update, emoji_update, voice_moved, voice_disconnected) and note that
call_incoming/call_declined are page-scoped listeners in MainPage.ts.
- channels-members-dms.md: the "no in-client block button" gap is closed
(AdminActions.ts context-menu item -> SidebarMemberSection.ts:177-186);
document group DMs (MemberPickerModal, 10-participant cap, rename/leave)
and per-channel notification mutes (lib/channel-mutes.ts); refresh stale
line anchors.
- voice-and-e2ee.md: document the actual E2EE verification surface (roster
shield badge -> identity-mismatch modal -> rePinPeerIdentity with TOCTOU-
safe key capture), noise suppression + fallback, device hot-swap, stream
preview, and DM ring/incoming-call flow; drop the nonexistent
VoiceChannel.ts reference.
- settings-and-admin.md: the "ban should collect a reason" gap is closed
(appendBanFlow with reason + duration); document the admin-panel deep-link
(lib/admin-panel.ts) and the tray status menu.
- messaging.md: correct the pinned-messages empty-state copy and drop the
nonexistent components/message-input/ directory reference.
- Re-stamp all six specs "Verified against 5630aa1 (2026-08-04)".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs: refresh client-facing and top-level docs
- security.md: drop the stale "hardcoded Tenor API key" limitation (the GIF
provider is Klipy, proxied server-side with an operator-supplied key —
nothing ships in the client bundle); describe credential storage accurately
(OS keyring primary, verified writes, DPAPI/ChaCha20 file fallback);
complete the audit-log action list against the actual WriteAudit call sites
and note that backup restore is not audit-logged; fix the firewall
checklist to include the LiveKit media ports (7880-7881/TCP,
50000-60000/UDP) and ACME port 80
- credential-storage.md: probe_credential_store sample now shows the real
serialized backend value ("Keyring") and the full variant union
- quick-start.md + README.md: refresh build version strings to
1.2.0-alpha.1; README "audits" section now points at the current audit
documents
- contributing.md: sqlc rows no longer claim a PostgreSQL engine/pgdbgen
(removed with the store layer); add the protocol-generate/verify targets
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(plans): add verified status headers and fix stale references
Every plan under docs/plans/ now carries a dated status verified against
5630aa1:
- Shipped: audit-2026-07-19-decisions (all 13 rows), channel-visibility-
unification, http-tofu-proxy, permission-middleware-consolidation (the
disclosed ws.channelCanSend copy is still open, now at serve_ready.go:119),
security-hardening-remediation, sqlc-adoption, v2-dispatch-migration,
tauri-capability-narrowing (DNS-rebinding follow-up still open)
- Shipped with corrections: discord-parity — Phase 1's gap table was never
re-marked; all six rows have since shipped, including archived channels,
which are filtered by permissions.VisibleChannelIDs (checker.go:116-121);
named leftovers (role hoist/mentionable, @RoleName mentions, categories as
entities, dead-code list) stay open. security-scan-2026-07-22 — all 8
findings closed; two of the four F3 follow-ups have since shipped (safety
number rendered in the roster badge; rePinPeerIdentity wired to the
identity-mismatch modal), getIdentityPin fail-open remains open; noted the
scan artifact directory is not in the repo
- Design-only: slash-commands — added staleness notes (migration number 016
now taken, Server/store/ deleted, src/state/ never existed)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(audits): reconcile prior audit statuses with current CI and code
- audit-test-coverage-2026-07-25: T-2026-07-25-21 (HIGH, 229/255 web e2e
failing) was fixed by the mock repair but the audit was never updated —
now RESOLVED, re-verified by a local 270/270 run at 5630aa1; the CI gate
table row updated to match
- audit-2026-07-19: carried-over item 11 ("no Playwright job in ci.yml") is
resolved — client-e2e (non-blocking, every PR) and the blocking
client-e2e-parity job both exist; backlog item 10 marked DONE
(client-tests is blocking, Playwright wired)
Only status/closure cells were edited; original finding text is untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(changelog): add Unreleased section for post-v1.2.0-alpha.1 fixes
Three fixes landed after the release with no changelog home (the file had no
Unreleased section at all): the profile-popup styling fix (a308f81), the
vite/src-tauri watch fix (cdcfc03), and the AppImage env-key signing fix
(9d75890). Also corrects the Deferred-work note that still described the
Solid.js removal in the present progressive — it completed 2026-07-19.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(tests): rewrite the e2e issues log against a real suite run
The old file was dated 2026-03-18, claimed 209/209 passing (the suite is now
270 tests), and pointed at a plan document that does not exist in the repo.
Rewritten from an actual run at 5630aa1: 270/270 web tests green (8.6 min),
15/15 @parity subset green (the blocking CI job), with the suite inventory,
CI wiring, the two real open issues (three native specs matched by no
playwright.config.native.ts project; client-e2e still non-blocking), and
dispositions for every claim the old file carried.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(audit): add 2026-08-04 docs-and-coverage audit report
Companion to the same-day security review (disjoint scope). Contains: the
verified architecture summary; real test-run results for every runnable
suite at 5630aa1 (Go race+deadlock, 4394 client unit tests at 94.66% stmt
coverage, 83 Rust tests + clippy, 270/270 web e2e, 15/15 parity, browser
smoke — with env-blocked suites named and their compensating CI evidence
cited); a 52-row UI/UX flow coverage matrix (30 covered / 21 partial /
1 untested / 0 broken, headline gaps: TOFU flow, E2EE verification, admin
panel, updater — all unit-only); per-doc drift findings with the commit that
fixed each; reconciliation of all four prior audits and eleven plans
(including the orphaned 2026-04-07 #8 resurfaced as DC-11); the dead-code
and TODO inventories; and a prioritized DC-01..DC-15 gap list with ordered
next steps.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(admin): add hierarchy guard to channel role-override delete (A-2026-08-01)
Deleting an override is a permission mutation: removing a deny row restores
exactly the access the PUT path refuses to grant, so a MANAGE_CHANNELS holder
could unlock a private channel their own role was locked out of. Gate DELETE
identically to handlePutChannelPermission: resolve the role (404 when
missing), fail closed without an actor role, and refuse targets at or above
the actor's position.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(admin): exclude DM channels from the admin channel surface (A-2026-08-02)
DMs and group DMs share the channels table and id space with guild channels,
but they belong to their participants, not to MANAGE_CHANNELS holders:
listing exposed ids and group names of every private conversation, PATCH
could silently rename one, and DELETE cascade-destroyed one irreversibly.
List now filters type=dm; PATCH and DELETE resolve through getAdminChannel,
which answers 404 for DM ids so the surface does not confirm which ids are
private conversations.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(service): enforce blocks on DM call rings (A-2026-08-03)
RingTargets checked participation but not blocks, so a blocked user could
still make the blocker's client ring. Route rings through
requireDMNotBlocked like every other DM sink; group DMs stay exempt inside
it, matching the send path (blocks are enforced at group creation instead).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* chore(client): delete dead modules (DC audit remediation)
All verified unreferenced by any import before deletion:
- ServerStrip.ts: removed from the layout when the unified sidebar header
landed (SidebarArea.ts); only its own orphaned unit test still used it.
The e2e spec already asserted .unified-sidebar-header, so it is renamed
to sidebar-header.spec.ts and retitled honestly.
- FileUpload.ts: uploads go through api.uploadFile from MessageInput.
- lib/reconcile.ts: nothing imports it; the messages store carries its own
pending-send reconciliation.
- public/rnnoise-worklet.ts: unreferenced duplicate of the .js worklet the
runtime actually loads, and public/ ships verbatim into the bundle.
- api.getSounds/deleteSound + SoundResponse: the server has no /sounds
routes; these called endpoints that do not exist (pairs with the
sounds-table drop on the server side).
- dm.store incrementDmMention: zero callers; DM mention counts flow from
the server mention_count via the dispatcher. This was the one live knip
error the CI '|| true' was masking.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* chore(client): retire the tauri-typegen ritual (DC-05)
src/generated/ was tauri-typegen output frozen on 2026-04-03: it covered 21
of the 29 IPC commands lib.rs registers, nothing ever imported it (0%
coverage), and CI carried a bespoke patch step solely to keep the unused
file lint-clean. Delete the directory and every part of the pipeline that
existed to feed it: the client-check patch step, the tauri-build
generate/patch steps, the tauri.conf.json plugin block, and the inert
Cargo.toml build-dependency (build.rs is bare tauri_build::build(); no Rust
source references the crate). Cargo.lock shrinks by exactly the typegen
subtree — no other resolution changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* ci: make knip blocking (DC-06 follow-through)
Pre-verified green locally after the dead-module deletions; the config
hints knip still prints do not affect its exit code.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* chore(server): drop the dead sounds table (A-2026-07-13)
The table shipped in 001 for a soundboard that was never built: no query,
model, sqlc definition, route or handler ever referenced it. Migration 029
drops it; the sqlc model regenerates without the Sound struct (sqlc emits a
struct per schema table even with zero queries). schema.md, the data-model
blueprint, and the 2026-07-19 audit closure table are updated in the same
change per the docs maintenance rule.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* chore(server): remove dead WAF wrapper, use protocol constants, fix stale comments
- NewWAFMiddleware had no production caller (the router mounts the CRS
variant); its doc text folds into NewWAFMiddlewareCRS and the tests call
the survivor directly.
- serve_auth compares against MsgTypeAuth and the DM-close REST path builds
its WS notification from MsgTypeDMChannelClose instead of restating the
wire strings, so the generated constants are load-bearing again.
- Comment fixes: DatabaseConfig no longer claims Postgres scaffolding that
main.go removed; host_ui.go no longer advertises a route that is not
mounted (DC-09's sibling); buildReady cites docs/protocol.md, the file
PROTOCOL.md was renamed to (DC-09).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(protocol): add the plugin command family to protocol-schema.json (DC-01)
chat_command, command_reply and plugin_broadcast were the only wire types
outside the schema: the first declared by hand in handlers_command.go, the
other two raw string literals, all bypassing the protocol-verify codegen
gate. Add the three schema entries (27 c2s / 39 s2c), regenerate both
constant files, and swap the hand-rolled declarations for the generated
constants. The ws protocol-contract test's exception list is empty now —
and stays that way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* test(client): wire orphaned native specs and typecheck the Playwright layer
- dm-system, reconnection and theme-persistence (14 tests) matched no
project's testMatch in playwright.config.native.ts, so they had never
executed (E2E-ISSUES open issue #1 / DC-03). All three use the persistent
fixture + ensureLoggedIn, so they join native-authenticated.
- tests/e2e was excluded from tsconfig, leaving 47 spec files with no
typechecking anywhere. New tsconfig.e2e.json project (+@types/node for
the node-API fixtures), a typecheck:e2e script, and a CI step. The one
real error it surfaced is fixed: mockTotpFailure omitted the required
simulateWsFlow flag.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* test(client): cover createPromptModal and the external-abort close path
modalFactory.ts was the least-covered file in the repo (57.6%):
createPromptModal had no tests at all and createModal's external-abort
branch never ran with an onClose. Now 100% statements/branches/functions,
including the trimmed-submit, legitimate-empty-submit, Enter-preventDefault
and no-double-close contracts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* test(client): e2e-cover the TOFU certificate ceremony (DC-04 slice)
The first-use confirmation and mismatch warning are the client's core
security ceremony and had no e2e coverage. Six tests drive them through
the mocked Tauri event layer: first-use modal content, trust, cancel,
modal non-stacking, mismatch fingerprint rows, and disconnect-to-connect-
page. The mock now exposes its listener registry so tests can wait for
the async cert-tofu registration instead of racing it (validated with
--repeat-each=3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs: fix the inaccuracies the 2026-08-04 refresh missed
- contributing.md: drop the '-tags postgres' build row (no such tag exists
anywhere in Server/), add the four Make targets the 07-25 audit created
(test/test-deadlock/cover/cover-all), align the coverage statement with
the real gates (client 70%, no Go floor by T-2026-07-25-19), point TS
style at architecture/client.md instead of the tombstone, and describe
the real dev-branch PR flow.
- docs/security.md: reporting section now defers to root SECURITY.md as
the canonical policy (it said 48h where SECURITY.md promises 7 days, and
described the maintainer's advisory path rather than the reporter's);
fixed the updater-key link that resolved to docs/Server/... on GitHub.
- audit-2026-04-07.md closure table: #10 and #11 were long-resolved (#10
verified in db/audit.go, #11 exceeded by per-PR e2e jobs), #6 written in
future tense for work done 2026-07-19, #7 citing a 113-file count from
months ago.
- README: Contributing section matched neither ci.yml nor contributing.md
(branch from dev, not main); Docs Index gains the six missing live docs;
the plugin system joins the feature list; the security row no longer
anchors to an aging version string.
- server-configuration.md: the env-var table is explicitly a subset — the
OWNCORD_<SECTION>_<KEY> scheme covers every key.
- mcp-introspect.md: index.mjs is 266 lines, not ~230.
- types.ts header cited PROTOCOL.md/API.md/SCHEMA.md, filenames that no
longer exist.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* ci: pin claude.yml actions by SHA; add docs checkbox to the PR template
claude.yml was the only workflow with unpinned third-party actions —
checkout now uses the same v4.2.2 SHA the other workflows pin, and
claude-code-action pins the commit the v1 tag resolves to (Dependabot's
github-actions ecosystem keeps both fresh).
The PR template gains the docs checkbox A-2026-07-03 recommended: the
architecture/UX maintenance rule ('a PR changing a diagram's source-of-
truth files updates the diagram in the same PR') existed only as prose no
process step ever surfaced.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* style(client): prettier-format the cert-tofu spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(audit): record the remediation pass and close finding statuses
- Security review: A-2026-08-01/02/03 -> RESOLVED with their pinning tests
named.
- Docs-and-coverage audit: DC statuses updated in place (01/02/03/05
resolved, 04/09 partial with the remainder named, 14's keep-decision
recorded) and a remediation addendum added: what shipped, the decisions
taken (plugin host API kept, reserved protocol entries kept, e2e soak not
shortcut, the 404-on-missing-role semantics note), and the full
verification table from real runs — Go race + deadlock suites green,
4 tag builds, client 4360/4360 units at 95.35% coverage, Playwright
276/276 in 8.9 min, parity 15/15.
- CHANGELOG Unreleased: security fixes, migration 029, protocol additions,
dead-code retirement, CI gates.
- E2E-ISSUES: rewritten against the remediation HEAD (276/276), native
orphan issue moved to resolved, mock listener-registry note.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(admin): index the channel slice in the DM filter (gocritic rangeValCopy)
golangci-lint (CI-only gate) flags the range-value copy of the 152-byte
db.Channel struct in the admin list filter.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* chore(client): add .nvmrc pinning Node 20 to match CI (DC-10)
Also re-triggers CI: the previous run's windows server job died to a Go
runtime unwinder fatal ('traceback did not unwind completely') with no
test failure — toolchain flake, and the integration lacks permission to
rerun failed jobs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix: 26 bug-hunt findings across WS hub, voice/E2EE, admin, roles, and client (runs 1-3) (#1328)
* fix(ws): keep pubsub subscriptions when a replaced client is unsubscribed
Both pubsub indexes are keyed by userID, but a reconnect registers a new
*Client under that same userID. UnsubscribeAll and Unsubscribe deleted by
userID alone, so a kick of the already-replaced connection stripped the live
one's topics. The live client stays in h.clients and keeps answering
ping/pong, so it never reconnects -- it just silently stops receiving every
global, user, and channel broadcast.
Guard the forward-index delete in unsubscribeLocked with an identity check and
route UnsubscribeAll through it, so the four Unsubscribe call sites
(voice_leave, hub_broadcast x2, handlers) and the three UnsubscribeAll ones
(kickClient, unregisterNow, registerNow) all share one rule.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): mark kicked clients offline instead of reporting them replaced
Every kick path deletes the hub entry via kickClient, so the readPump defer's
unregisterNow finds nothing and fell through to "return true", conflating
absent with replaced. serve_pumps.go then skipped MarkUserDisconnected, the
offline presence broadcast, and handleVoiceLeave -- already-connected peers
rendered every kicked user as online until that user reconnected and
disconnected cleanly.
Return exists instead: a different client in the slot is a genuine
replacement, an absent entry is a real disconnect. Only serve_pumps.go reads
the return value; the five serve.go/hub.go call sites discard it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): force a full ready when cold-tier replay hits the row cap
GetEventsSinceForChannels is "ORDER BY seq ASC LIMIT n", so a reconnect gap
larger than maxColdReplay returned the oldest 5000 rows and dropped the
newest. handleReconnect accepted any non-empty result as a successful resume,
and the client only tracks max(seq) with no gap detection -- so it accepted
the next live event and silently lost the range in between, including state
events (channel/role/member changes) that REST history fetches never repair.
Treat a result at the cap as overflow and fall through to the full ready
re-sync. An exactly-cap-remaining gap pays one unnecessary full ready.
maxColdReplay is hoisted to the package const block so the test can seed
exactly enough events to hit it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): re-elect the voice E2EE key holder on the two paths that skipped it
updateKeyHolder had only two callers (voice_join, voice_leave), so two paths
that remove a participant from voice left voiceKeyHolders naming someone who
is gone. IsVoiceKeyHolder then rejects the real lowest-uid participant's rekey
offers with NOT_KEY_HOLDER -- which the client does not handle -- after it has
already applied its rotated key locally, splitting keys across the room.
1. The LiveKit participant_left webhook (media-only loss, WS stays up) cleared
voice state and broadcast voice_leave with no re-election.
2. registerNow's fresh-connect replacement (F5 reload) drops the old
connection's voice state without transferring it. handleVoiceLeave never
runs there: readPump skips it when replaced, and it early-returns on
already-cleared state.
Both call updateKeyHolder outside h.mu, since it takes keyHolderMu then
h.mu.RLock. The recompute reads live client voice state, so it is idempotent
and stays correct when a network reconnect transfers voice state -- locked by
TestRegisterNow_KeepsKeyHolderWhenVoiceStateTransfers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(client): stand down as E2EE key holder on offer, keep peer keys on reconnect
Two independent key-holder desync bugs in E2EEManager:
1. _isKeyHolder had no demotion path -- set at join, promoted on participant
leave, cleared only on voice leave. The server re-elects the lowest userID
on every join, so a lower-ID joiner left the incumbent still believing it
held the key with an armed 5-minute timer. Its rotations applied the new
key locally before the server rejected the offers with NOT_KEY_HOLDER (which
the client does not handle), so it went deaf and mute every rotation cycle.
Accepting an offer proves the sender is the server-authoritative holder, so
treat it as the demotion signal and clear the timer.
2. reannounceForReconnect cleared _peerPublicKeys and peer verifications with
nothing able to refill them: handleAnnounce replies with an offer rather
than a counter-announce, and the server relays stored peer keys only on
voice_join, which an SFU-level reconnect never runs. handleOffer's
unknown-peer guard then dropped every later rotation, stranding the
reconnector on the pre-reconnect key. The clear was also unnecessary --
peers' keys stay valid when we regenerate our own pair.
vitest 4396/4396; typecheck and prettier clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tauri): give the ws sender slot connection ownership, bound livekit TLS
ws_proxy: state.tx is one slot shared by every ws_connect, and both the
install and the teardown clear were unconditional while the mutex was only
held in short scoped blocks. A handshake pends up to CONNECT_TIMEOUT and a
profile switch starts a second ws_connect without awaiting or cancelling the
first, so a stale connect could complete after a newer one was live, emit an
untagged "open", and install its sender over the live one -- routing the next
auth send to the previously-trusted host, then tearing down the live socket
and emitting "closed" while JS believed it was connected.
Add a generation counter claimed at ws_connect entry and checked under the
slot lock before install, plus same_channel ownership on the teardown clear,
mirroring the Arc::ptr_eq guard ptt.rs already uses for ATOMICRACE-001.
livekit_proxy: the outbound TcpStream::connect and TLS handshake were bare
awaits, while the sibling http_proxy.rs bounds both at 10s. TCP connect is
OS-bounded, but a peer that accepts TCP and never answers the ClientHello
blocked the task forever. The task holds `local` without polling it, so the
SDK closing its side never cancels it, and the detached per-connection tasks
survive stop_livekit_proxy -- so they leaked on every SDK retry.
cargo test 80 passed; clippy --all-targets -D warnings clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): keep voice E2EE alive across WS resume and unify voice teardown
A network reconnect transferred voiceChID/joinToken to the new connection
but left it unsubscribed from voice:<id> (the only transport for
voice_e2ee_announce relays) and wiped the announced ECDH key, so a
resumed key holder could never offer the room key to later joiners and
voice_join replayed nothing for the resumed user. registerNow now
transfers the announced key with the voice state and re-subscribes
VoiceTopic unconditionally (it is CONNECT_VOICE-gated at join; only the
message-stream ChannelTopic needs the READ gate).
The LiveKit participant_left webhook and CleanupVoiceForChannel cleared
voice state without dropping the voice-topic subscription, leaving the
socket receiving another room's announces (which carry no channel_id to
filter on) for its lifetime. All take-out-of-voice paths now go through
one clearVoiceAndUnsubscribe helper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): route sequenced DMs through the normal FIFO
writePump drains sendHigh to exhaustion before send, so a seq-stamped DM
on the high queue reached the socket ahead of lower-seq events still
queued behind a slow write. The client acks max(seq) and replay is
strictly seq > last_seq, so a disconnect in that window silently and
permanently lost the overtaken events while auth_ok reported a clean
resume. Sequenced frames now share the one per-client FIFO; the high
queue remains for unsequenced targeted messages (DM opens, voice tokens).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(storage): remove the partial file when Save fails after create
The io.Copy and f.Sync error paths returned without deleting the file
created for the upload, and the orphan sweep is DB-row-driven, so a
write-side failure (ENOSPC, disk I/O error) permanently leaked a partial
storage/<uuid> with no DB row. One success-flag deferred cleanup now
covers every failure path (the oversize branch folds into it), fixing
all three callers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(client): close three voice-E2EE ordering gaps and wire the DM mention badge
- setupKeyExchange generated the room key AFTER draining queued
announces, so a key holder joining an ongoing call sent drained peers
no offer — they waited on the 5-minute rotation timer. Keygen now
precedes the drain and every drained peer gets its offer immediately.
- A key-holder re-election arriving while the elected client was still
connecting was dropped (getCurrentChannelId is null for the whole
key-exchange wait), stranding the client until timeout ejection. The
manager now remembers its channel from setupKeyExchange, and the
become-holder rotation resolves a pending room-key wait.
- Offers applied concurrently could finish out of order (no epoch on the
receiver side), leaving the older key active. handleOffer now chains
applications so offers apply strictly in WS delivery order.
- incrementDmMention had zero callers: the DM @mention badge (dmStore's
mentionCount, the mute-immune signal DmSidebar renders) never fired
live, only after a reconnect restored the server count. The dispatcher
now bumps it under the same guards as the DM unread count.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tauri): restart the LiveKit proxy when the TOFU pin changes
After the user accepted a rotated cert, two stale caches kept every
voice rejoin tunneling into the old pin until logout: the Rust reuse
branch returned the running listener (which bakes its fingerprint in at
spawn) without re-reading certs.json, and ensureLiveKitProxy's port
cache never invoked Rust again at all. start_livekit_proxy now loads the
stored fingerprint before the reuse check and tears down on host OR pin
change, and the TS side invokes it on every join — the reuse branch
dedups the unchanged case.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(tauri): ignore two unreachable cargo-audit advisories
RUSTSEC-2024-0429 (glib 0.18, Linux-only, Variant::array_iter_str never
called; no semver-compatible fix exists) and RUSTSEC-2026-0097 (rand 0.7
as a phf_generator build-dep with a fixed seed and no log feature; the
pre-release kuchikiki pin blocks the upgrade path). Both entries document
their drop condition inline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): transfer the focused channel on WS resume so the message stream survives
registerNow's replaced-client branch moved voice state and the E2EE key to
the resumed connection but not the focused channel; newClient always starts
with channelID == 0 and the client never re-sends channel_focus on a resume,
so the ChannelTopic re-subscribe was a no-op and the user silently stopped
receiving chat_message until manually switching channels. Transfer the old
connection's focused channel, READ-gated and fail-closed like every other
ChannelTopic subscription.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): always include a voice room's participants in its voice-event audience
broadcastVoiceEvent filtered recipients on READ_MESSAGES while voice
membership is gated on CONNECT_VOICE alone, so a participant in the gap
(e.g. READ revoked mid-call by a channel override) never received the
room's voice_state/voice_leave. The client's E2EE key-holder election and
forward-secrecy rotation run only off the voice_leave WS event, so a
departing key holder was never replaced and new joiners hung until the
e2ee_timeout eject. Union the READ audience with the room's current
participants; what outsiders may observe is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(client): tear down the failed reconnect attempt's room instead of leaking it
The catch block read this._room, whose typed accessor returns null in the
"reconnecting" state — so the failed attempt's freshly created Room was
never disconnected and kept all its listeners. livekit-client emits
Disconnected synchronously on a failed connect, and in "reconnecting"
state the token/channel/url getters all return values, so each leaked room
spawned an additional concurrent reconnect loop whose AbortController was
discarded and unreachable from leaveVoice. Alias the attempt's room outside
the try and clean it up in the catch, mirroring cleanupAbortedReconnect.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(admin): evict voice participants before deleting a channel
CleanupVoiceForChannel was doc-commented 'Called when a channel is
deleted' but had zero production callers, and the voice_states FK cascade
wipes the rows it reads — so deleting a voice channel stranded its
participants with live client voice state, a voice-topic subscription, and
a LiveKit session, and the stale sweeper could never recover them (a
nonexistent channel resolves base-role permission bits). Wire the cleanup
into handleDeleteChannel BEFORE the row delete, via HubBroadcaster.
Also harden the cleanup itself: the row delete and client-state clear are
now conditional on the participant still being in the deleted channel, so
a user who moved rooms mid-cleanup is untouched, and the evicted
participants are always included in their own voice_leave audience (their
client state is already cleared, so the participant union in
broadcastVoiceEvent cannot see them).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(client): stop leaking an E2EE worker and SetKey listener per voice join
createRoom spun up a fresh E2EE Worker per Room while the key provider
lives for the whole process; livekit's per-room E2EEManager registers a
SetKey listener on the provider with no matching removal and never
terminates the worker. Every join, channel switch, or failed reconnect
attempt therefore permanently added one running worker plus one listener,
and every later setKey posted the new room key into every orphaned worker
— key material outliving its session. Track the worker on the session:
clear provider listeners and terminate the stale worker before each Room,
and terminate it in leaveVoice so the last key does not stay resident.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(service): reject role position collisions on update, matching create
UpdateRole's position branch ran only validatePosition and let an explicit
position land on a slot another role holds — while CreateRole refuses
exactly that, with a comment explaining why: every hierarchy comparison
uses >=/<=, so tied positions read as equal rank and the two roles can no
longer manage each other's members. Refuse a position held by a different
role with the same ErrBadRequest; re-stating the role's own position stays
allowed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): close the window where a dying connection re-takes a pubsub topic
Subscribe had no counterpart to unsubscribeLocked's identity guard: an old
connection's in-flight handler (a channel_focus mid DB round-trip shares no
lock with registerNow) could Subscribe after UnsubscribeAll(old) had run,
stealing the topic from its replacement — whose own unsubscribes then skip
the entry while publishes go to the closed connection. Subscribe now
refuses a client whose send is closed (checked under ps.mu), and
registerNow closes the old client's send BEFORE stripping it, so a late
Subscribe either sees the closed send and is refused or slipped in earlier
and is removed by the subsequent UnsubscribeAll.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(ws): rewrite cold-replay if-else chain as switch (gocritic)
Fixes the ifElseChain lint failure on CI for both platforms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(client): restore incrementDmMention deleted as dead code on dev
The audit PR (#1327) removed it from dm.store.ts because its only caller
lives on this branch (the DM mention badge wiring), which was not merged
yet. The rebase was textually clean but left dispatcher.ts calling a
function that no longer existed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Audit closure pass: DC-04/06/08/09/12/13/15 (E2EE + updater e2e, fail-closed pin lookup, a11y pass, UX polish) (#1329)
* ci(server): run the tag-gated wazero/otel Go tests (DC-06)
The build-tag matrix only compiled the otel/wazero variants; the tests
behind those tags (plugin/sandbox_wazero_test.go 462 lines,
telemetry/telemetry_otel_test.go 214 lines) ran nowhere since they were
written (T-2026-07-25-16). Scoped to the two packages that carry tagged
files; verified green locally before wiring:
go test -tags wazero ./plugin/... and -tags otel ./telemetry/... both pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(client): fail closed when the identity pin store is unreadable (DC-08)
getIdentityPin collapsed a keyring read error into "no pin stored", so a
transient failure sent a pinned peer down the TOFU first-sight path:
verifyPeerAnnounce verified against the server-delivered key and then
RE-PINNED it — a fail-open a malicious server could exploit by inducing
store errors (F3 follow-up 3, plans/security-scan-2026-07-22).
getIdentityPin now returns a three-state IdentityPinLookup
(pinned/unpinned/unavailable), mirroring how tofu.rs keeps Err distinct
from Ok(None) first-use. verifyPeerAnnounce rejects the announce on
"unavailable" without any pin write, records the new "unknown"
PeerVerification status, and the roster badge renders it as an amber
shield-question ("could not check") distinct from the legacy
"unverified" state.
Pinned by unit tests: pin present, no pin, store error (identity.ts),
the fail-closed rejection path (livekit-session), and the badge
presentation (channel-sidebar).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* feat(admin): write a backup_restore audit row that survives the restore (DC-09)
Backup restore was the one admin mutation with no audit_log row —
docs/security.md documented the gap as inherent ("the database is closed
as part of the restore"). The row IS writable durably: written
synchronously (LogAudit, deliberately not the async WriteAudit fast path)
before BackupTo takes the pre-restore safety copy, it is captured inside
pre_restore_*.db and survives the file swap forensically.
The extended restore test opens the pre-restore backup as a database and
asserts the backup_restore row is inside it — proving both the write and
its ordering. docs/security.md now documents where the row lives instead
of the gap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* feat(client): channel-delete toast + optimistic reaction toggle (DC-12)
Two messaging-surface gaps the UX specs carried as open:
- channel_delete on the active channel now toasts "This channel was
deleted" alongside the existing redirect (ux/channels-members-dms
§1.2) — the redirect alone read as the app spontaneously changing
channels. Non-active deletions stay silent.
- Reactions toggle optimistically (ux/messaging §5): the pill flips on
the click, registered under the send's WS envelope id — the same
correlation scheme as the optimistic message rows. updateReaction
consumes the matching self-echo instead of re-applying it (the
delta-based arithmetic would double-count), other users' echoes apply
normally, and an error reply or local transport failure rolls back
exactly that toggle via rollbackReaction in the dispatcher's error and
send-failure handlers. The pill reverting is the failure feedback.
Both spec gap notes flipped to implemented; the stale §2 note claiming
the ready payload lacks slow_mode fell in the same edit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(client): guard the role-change submenu against double-fire (DC-12)
Every other destructive admin action already carried an in-flight guard
(withConfirmation, unblockRunning, banRunning, purge) — the role-change
submenu was the residual: currentRole only updates when the
member_update echoes, so a double-click (or a second option clicked
while the first PATCH was in flight) fired onChangeRole twice. One
shared guard now inerts the whole submenu while a change is running,
with the pending class on the clicked option.
The settings-and-admin spec's in-flight gap note flips to implemented,
and the stale messaging §8 slow-mode note is corrected in the same
docs sweep (the countdown shipped with the ready payload's slow_mode —
verified against ChannelController.startSlowMode and its tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(client): drag-reorder document listeners leak — own them per sidebar signal (DC-12)
The shared document mousemove/mouseup pair was reference-counted per
attached channel row: a sidebar with N channels took N refs (and N more
per re-render) while its single destroy returned exactly one, so the
count never reached zero and the listeners plus their activeDrag
closure lived for the rest of the process — the KNOWN BUG the
drag-reorder test pinned since the 2026-07-25 audit.
Ownership is now a Set of AbortSignals (the sidebar's lifetime
controller — the @lib/disposable teardown idiom): acquisition is
idempotent per signal no matter how many rows attach, release is the
signal's abort, and an owner aborted mid-drag clears the in-flight
visual state (which the old containerEl comparison never actually
matched in production — it compared the category container against
channelList). releaseGlobalDragListeners is gone; ChannelSidebar's
destroy releases via its existing ac.abort().
The pinning test changes with the fix, as its own comment instructed;
the lifecycle block now pins the fixed contract, including
re-registration after full teardown.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* test(e2e): E2EE identity-verification and updater journeys (DC-04)
The two remaining client-side headline coverage gaps from the 2026-08-04
audit's flow matrix (rows 38 and 49), both driven through the web mock
harness:
voice-e2ee-verify.spec.ts (6 tests, ux/voice-and-e2ee §7): the peer's
announce is real crypto — an ECDSA P-256 identity key signing an ECDH
ephemeral key exactly as e2eeCrypto does — so the badge states come out
of the production verification path. Covers the verified badge with
safety number (+ TOFU pin on first sight), the legacy unverified badge,
the mismatch block, the mismatch modal's reject path (peer stays
blocked, nothing pinned) and Trust New Key (re-pins the displayed key),
and the DC-08 fail-closed 'could not check' badge when the pin store is
unreadable. The harness gap that kept this untestable is closed by a
voice_join handler that grants a key-holder voice_token plus a WebSocket
shim that parks LiveKit's room.connect forever, holding the session
stably in 'securing'.
updater.spec.ts (4 tests, ux/settings-and-admin §5): no-update silence,
banner + Later dismissal, the full banner → download progress (% and MB
fallback via real update-progress events) → automatic relaunch journey,
and the failure state with Dismiss. There is no restart prompt by design
— the applied state IS the relaunch, asserted via the recorded
plugin:process|restart invoke.
Harness: buildTauriMockScript gains per-test identity-pin config
(identityPins / identityPinError) and a window.__invokeLog recording
every IPC call so tests can assert side effects with no DOM footprint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* style(client): prettier-format the drag-reorder module and new e2e specs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* feat(client): accessibility pass over the modal/overlay stack (DC-13)
The repo had exactly one focus-trapped dialog (UserProfilePopup), one
aria-live region, and no role="dialog" anywhere else. This pass
generalizes that one good implementation into lib/a11y.ts
(applyDialogSemantics, trapFocus, focusDialog, setRovingTabindex,
enableRovingNavigation) and applies it across the stack — all additive:
no DOM classes, testids, structure, or visible text changed.
- modalFactory: every factory modal now carries role=dialog + aria-modal,
moves focus in on open, restores it on every close path, and Tab-cycles
inside; createPromptModal is labelled by its title.
- Hand-rolled modals (CertMismatch/CertFirstUse/IdentityMismatch,
Create/Edit/DeleteChannel, InviteManager): dialog semantics labelled by
their existing headings, focus trap + restore, aria-label on icon-only
close buttons, and Escape mapped to each modal's SAFE action (reject on
the trust prompts — dismissal must never grant trust; cancel on the
channel modals — never the destructive/submit callback).
- SettingsOverlay: dialog on the panel, focus in on open/restore on
close; the sidebar is a vertical role=tablist with roving tabindex and
ArrowUp/Down/Home/End activate-on-focus; the content pane is a
tabpanel labelled by the active tab.
- QuickSwitcher: dialog + combobox/listbox/option wiring with
aria-activedescendant tracking the active row. QuickSwitchOverlay:
dialog + keyboard-operable rows (the inert current-server row stays
unfocusable on purpose).
- EmojiPicker/GifPicker: listbox/option cells with a roving tabindex
(Arrow/Home/End move the single Tab stop, Enter/Space activate through
the click path). inline-autocomplete: option ids + combobox attrs and
aria-activedescendant on the composer textarea — deliberately NOT
roving tabindex, since moving DOM focus out of the textarea would
break typing (the combobox pattern).
- Toast and TypingIndicator are polite live regions (role=status).
Tests: +71 unit cases across 18 files (4474 total, all green) pinning
roles, traps, restores, Escape safety, and roving behavior; plus an
axe-style e2e smoke (a11y-smoke.spec.ts, 5 tests) proving the wiring in
the running app — settings tablist + focus restore to the opener, quick
switcher combobox, member-picker Tab containment, live regions, and the
cert first-use dialog where Escape rejects without trusting.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(ux): replace file:line anchors with symbol references (DC-15)
The UX specs cited code as file:line anchors, and a three-week-old
snapshot already had 15 of 58 pointing at entirely wrong code (the audit
measured 200-700 lines of drift). All 55 remaining anchors across the
six spec files now reference the owning symbol instead
("validateForm() in pages/connect-page/LoginForm.ts"), each target
verified to exist before rewriting; the stale ones were re-aimed at the
correct symbol, not just de-numbered. Zero file:line references remain
under docs/architecture/ux/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(audit): record the 2026-08-05 closure pass; stamp DC statuses in place
- audit-2026-08-04-docs-and-coverage.md: DC-06/08/12/13/15 marked
resolved, DC-04 and DC-09 further-resolved (admin-panel journey and the
handleApplyUpdate TODO are the remainders), matrix rows 38/49 flipped
from headline gaps to covered, the §4 UX-problem bullets closed, and a
§12 closure addendum records what shipped and the verification runs.
- CHANGELOG Unreleased: operator-facing entries for the DC-08 fail-closed
fix, the a11y pass, the UX polish, the backup_restore audit row, the
tag-gated CI tests, and the new e2e journeys.
- E2E-ISSUES.md: fresh full-suite run recorded at this HEAD — 291/291
passed in 9.2 min with zero flaky retries (276 baseline + 6 E2EE + 4
updater + 5 a11y smoke), @parity 15/15; suite inventory now 40 files.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* test(client): fix install-settle race in the updater e2e spec
The install settle handles are created only when the app's
download_and_install_update invoke reaches the mock wrapper, but the spec
called them right after asserting the banner text — which flips
synchronously on click, before the invoke's microtask runs. Local runners
won that race; CI lost it three attempts in a row
(window.__rejectInstall is not a function). Both settle sites now wait
for the handles, same pattern as the listener waits the file already uses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Final audit closure: admin-panel e2e, container-safe updates, blocking e2e gate, dependency policy (#1330)
* ci(server): run the tag-gated wazero/otel Go tests (DC-06)
The build-tag matrix only compiled the otel/wazero variants; the tests
behind those tags (plugin/sandbox_wazero_test.go 462 lines,
telemetry/telemetry_otel_test.go 214 lines) ran nowhere since they were
written (T-2026-07-25-16). Scoped to the two packages that carry tagged
files; verified green locally before wiring:
go test -tags wazero ./plugin/... and -tags otel ./telemetry/... both pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(client): fail closed when the identity pin store is unreadable (DC-08)
getIdentityPin collapsed a keyring read error into "no pin stored", so a
transient failure sent a pinned peer down the TOFU first-sight path:
verifyPeerAnnounce verified against the server-delivered key and then
RE-PINNED it — a fail-open a malicious server could exploit by inducing
store errors (F3 follow-up 3, plans/security-scan-2026-07-22).
getIdentityPin now returns a three-state IdentityPinLookup
(pinned/unpinned/unavailable), mirroring how tofu.rs keeps Err distinct
from Ok(None) first-use. verifyPeerAnnounce rejects the announce on
"unavailable" without any pin write, records the new "unknown"
PeerVerification status, and the roster badge renders it as an amber
shield-question ("could not check") distinct from the legacy
"unverified" state.
Pinned by unit tests: pin present, no pin, store error (identity.ts),
the fail-closed rejection path (livekit-session), and the badge
presentation (channel-sidebar).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* feat(admin): write a backup_restore audit row that survives the restore (DC-09)
Backup restore was the one admin mutation with no audit_log row —
docs/security.md documented the gap as inherent ("the database is closed
as part of the restore"). The row IS writable durably: written
synchronously (LogAudit, deliberately not the async WriteAudit fast path)
before BackupTo takes the pre-restore safety copy, it is captured inside
pre_restore_*.db and survives the file swap forensically.
The extended restore test opens the pre-restore backup as a database and
asserts the backup_restore row is inside it — proving both the write and
its ordering. docs/security.md now documents where the row lives instead
of the gap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* feat(client): channel-delete toast + optimistic reaction toggle (DC-12)
Two messaging-surface gaps the UX specs carried as open:
- channel_delete on the active channel now toasts "This channel was
deleted" alongside the existing redirect (ux/channels-members-dms
§1.2) — the redirect alone read as the app spontaneously changing
channels. Non-active deletions stay silent.
- Reactions toggle optimistically (ux/messaging §5): the pill flips on
the click, registered under the send's WS envelope id — the same
correlation scheme as the optimistic message rows. updateReaction
consumes the matching self-echo instead of re-applying it (the
delta-based arithmetic would double-count), other users' echoes apply
normally, and an error reply or local transport failure rolls back
exactly that toggle via rollbackReaction in the dispatcher's error and
send-failure handlers. The pill reverting is the failure feedback.
Both spec gap notes flipped to implemented; the stale §2 note claiming
the ready payload lacks slow_mode fell in the same edit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(client): guard the role-change submenu against double-fire (DC-12)
Every other destructive admin action already carried an in-flight guard
(withConfirmation, unblockRunning, banRunning, purge) — the role-change
submenu was the residual: currentRole only updates when the
member_update echoes, so a double-click (or a second option clicked
while the first PATCH was in flight) fired onChangeRole twice. One
shared guard now inerts the whole submenu while a change is running,
with the pending class on the clicked option.
The settings-and-admin spec's in-flight gap note flips to implemented,
and the stale messaging §8 slow-mode note is corrected in the same
docs sweep (the countdown shipped with the ready payload's slow_mode —
verified against ChannelController.startSlowMode and its tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* fix(client): drag-reorder document listeners leak — own them per sidebar signal (DC-12)
The shared document mousemove/mouseup pair was reference-counted per
attached channel row: a sidebar with N channels took N refs (and N more
per re-render) while its single destroy returned exactly one, so the
count never reached zero and the listeners plus their activeDrag
closure lived for the rest of the process — the KNOWN BUG the
drag-reorder test pinned since the 2026-07-25 audit.
Ownership is now a Set of AbortSignals (the sidebar's lifetime
controller — the @lib/disposable teardown idiom): acquisition is
idempotent per signal no matter how many rows attach, release is the
signal's abort, and an owner aborted mid-drag clears the in-flight
visual state (which the old containerEl comparison never actually
matched in production — it compared the category container against
channelList). releaseGlobalDragListeners is gone; ChannelSidebar's
destroy releases via its existing ac.abort().
The pinning test changes with the fix, as its own comment instructed;
the lifecycle block now pins the fixed contract, including
re-registration after full teardown.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* test(e2e): E2EE identity-verification and updater journeys (DC-04)
The two remaining client-side headline coverage gaps from the 2026-08-04
audit's flow matrix (rows 38 and 49), both driven through the web mock
harness:
voice-e2ee-verify.spec.ts (6 tests, ux/voice-and-e2ee §7): the peer's
announce is real crypto — an ECDSA P-256 identity key signing an ECDH
ephemeral key exactly as e2eeCrypto does — so the badge states come out
of the production verification path. Covers the verified badge with
safety number (+ TOFU pin on first sight), the legacy unverified badge,
the mismatch block, the mismatch modal's reject path (peer stays
blocked, nothing pinned) and Trust New Key (re-pins the displayed key),
and the DC-08 fail-closed 'could not check' badge when the pin store is
unreadable. The harness gap that kept this untestable is closed by a
voice_join handler that grants a key-holder voice_token plus a WebSocket
shim that parks LiveKit's room.connect forever, holding the session
stably in 'securing'.
updater.spec.ts (4 tests, ux/settings-and-admin §5): no-update silence,
banner + Later dismissal, the full banner → download progress (% and MB
fallback via real update-progress events) → automatic relaunch journey,
and the failure state with Dismiss. There is no restart prompt by design
— the applied state IS the relaunch, asserted via the recorded
plugin:process|restart invoke.
Harness: buildTauriMockScript gains per-test identity-pin config
(identityPins / identityPinError) and a window.__invokeLog recording
every IPC call so tests can assert side effects with no DOM footprint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* style(client): prettier-format the drag-reorder module and new e2e specs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* feat(client): accessibility pass over the modal/overlay stack (DC-13)
The repo had exactly one focus-trapped dialog (UserProfilePopup), one
aria-live region, and no role="dialog" anywhere else. This pass
generalizes that one good implementation into lib/a11y.ts
(applyDialogSemantics, trapFocus, focusDialog, setRovingTabindex,
enableRovingNavigation) and applies it across the stack — all additive:
no DOM classes, testids, structure, or visible text changed.
- modalFactory: every factory modal now carries role=dialog + aria-modal,
moves focus in on open, restores it on every close path, and Tab-cycles
inside; createPromptModal is labelled by its title.
- Hand-rolled modals (CertMismatch/CertFirstUse/IdentityMismatch,
Create/Edit/DeleteChannel, InviteManager): dialog semantics labelled by
their existing headings, focus trap + restore, aria-label on icon-only
close buttons, and Escape mapped to each modal's SAFE action (reject on
the trust prompts — dismissal must never grant trust; cancel on the
channel modals — never the destructive/submit callback).
- SettingsOverlay: dialog on the panel, focus in on open/restore on
close; the sidebar is a vertical role=tablist with roving tabindex and
ArrowUp/Down/Home/End activate-on-focus; the content pane is a
tabpanel labelled by the active tab.
- QuickSwitcher: dialog + combobox/listbox/option wiring with
aria-activedescendant tracking the active row. QuickSwitchOverlay:
dialog + keyboard-operable rows (the inert current-server row stays
unfocusable on purpose).
- EmojiPicker/GifPicker: listbox/option cells with a roving tabindex
(Arrow/Home/End move the single Tab stop, Enter/Space activate through
the click path). inline-autocomplete: option ids + combobox attrs and
aria-activedescendant on the composer textarea — deliberately NOT
roving tabindex, since moving DOM focus out of the textarea would
break typing (the combobox pattern).
- Toast and TypingIndicator are polite live regions (role=status).
Tests: +71 unit cases across 18 files (4474 total, all green) pinning
roles, traps, restores, Escape safety, and roving behavior; plus an
axe-style e2e smoke (a11y-smoke.spec.ts, 5 tests) proving the wiring in
the running app — settings tablist + focus restore to the opener, quick
switcher combobox, member-picker Tab containment, live regions, and the
cert first-use dialog where Escape rejects without trusting.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(ux): replace file:line anchors with symbol references (DC-15)
The UX specs cited code as file:line anchors, and a three-week-old
snapshot already had 15 of 58 pointing at entirely wrong code (the audit
measured 200-700 lines of drift). All 55 remaining anchors across the
six spec files now reference the owning symbol instead
("validateForm() in pages/connect-page/LoginForm.ts"), each target
verified to exist before rewriting; the stale ones were re-aimed at the
correct symbol, not just de-numbered. Zero file:line references remain
under docs/architecture/ux/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(audit): record the 2026-08-05 closure pass; stamp DC statuses in place
- audit-2026-08-04-docs-and-coverage.md: DC-06/08/12/13/15 marked
resolved, DC-04 and DC-09 further-resolved (admin-panel journey and the
handleApplyUpdate TODO are the remainders), matrix rows 38/49 flipped
from headline gaps to covered, the §4 UX-problem bullets closed, and a
§12 closure addendum records what shipped and the verification runs.
- CHANGELOG Unreleased: operator-facing entries for the DC-08 fail-closed
fix, the a11y pass, the UX polish, the backup_restore audit row, the
tag-gated CI tests, and the new e2e journeys.
- E2E-ISSUES.md: fresh full-suite run recorded at this HEAD — 291/291
passed in 9.2 min with zero flaky retries (276 baseline + 6 E2EE + 4
updater + 5 a11y smoke), @parity 15/15; suite inventory now 40 files.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* test(client): fix install-settle race in the updater e2e spec
The install settle handles are created only when the app's
download_and_install_update invoke reaches the mock wrapper, but the spec
called them right after asserting the banner text — which flips
synchronously on click, before the invoke's microtask runs. Local runners
won that race; CI lost it three attempts in a row
(window.__rejectInstall is not a function). Both settle sites now wait
for the handles, same pattern as the listener waits the file already uses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* ci: promote client-e2e to blocking (DC-07)
The soak is decided: green full-suite runs at 270, 276 and 291 tests across
the audit branches, and the one hard failure in the window was a real spec
bug a non-blocking job would have let rot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* feat(server): refuse in-place self-update in container deployments
Resolves the long-standing handleApplyUpdate TODO. In a container the
running binary is image content: the staged replacement dies with the
container and the restart comes back as the old image. RunningInContainer
(OWNCORD_CONTAINER authoritative both ways — the shipped Dockerfile sets 1,
bind-mount operators can set 0 — with /.dockerenv//run/.containerenv as
fallback) now gates POST /admin/api/updates/apply with 503
CONTAINER_DEPLOYMENT before any updater logic, GET /admin/api/updates gains
can_apply, and the admin SPA swaps the apply button for an image-upgrade
note when it is false.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs: adopt the dependency pinning/review policy (DC-11, 2026-04-07 #8)
Writes down the policy the lockfiles already enforce: lockfiles
authoritative with npm ci-only installs, weekly Dependabot with majors
adopted deliberately, per-PR security gates (npm audit on shipped deps,
govulncheck, cargo audit, knip), and toolchain-level version pins.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* test(admin): add the admin-panel e2e journey against a real server (DC-04)
The admin SPA was the one surface no suite could reach: it is served by the
Go server and mocked nowhere. start-server.sh builds and boots a real
server (fresh temp data dir, TLS off, loopback) and the journey drives the
SPA end to end — first-run wizard creating the owner, dashboard stats,
channel create/rename, audit-log rows for both mutations, and sign-out/
sign-in. One shared page keeps the localStorage session across the serial
steps, mirroring the native suite's persistent fixture and staying under
the 5-logins/min limiter; on a Playwright retry the wizard branch downgrades
to login since setup is one-shot server-side. New non-blocking admin-e2e CI
job on the same graduation convention client-e2e followed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(audit): final closure — every DC finding resolved or deliberately reserved
Records the owner-directed closure pass (§13): DC-04 fully (admin journey
was the last row), DC-07 (client-e2e blocking), DC-09 fully (container-
aware update refusal), DC-11 + 2026-04-07 #8 (dependency policy).
Remaining open items are all deliberate: DC-14 reserved protocol entries,
the admin-e2e soak graduation, and the accepted/tracked 2026-04 carryovers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(attachments): stop the orphan sweep destroying every avatar and the grace period
The 15-minute maintenance sweep deleted attachment rows and their files for
any attachment with message_id IS NULL. Avatars are exactly that by design:
users.avatar points at the attachment by URL and nothing ever links it to a
message (migration 027). Every avatar in the instance was therefore destroyed
on the first tick past the grace period, permanently 404ing every profile
picture. The query now excludes attachments a user's avatar still points at.
Independently, the cutoff was formatted RFC3339 while uploaded_at is written
by SQLite as 'YYYY-MM-DD HH:MM:SS'. TEXT comparison is bytewise and ' ' sorts
before 'T', so every unlinked upload sharing the cutoff's UTC date was swept
regardless of time -- the one-hour grace collapsed to 'immediately'. Rather
than fix the format at the one call site, DeleteOrphanedAttachments now takes
a time.Time and formats it internally, so no caller can reintroduce the class.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(client): send old_password so changing a password can succeed
The client posted {current_password, new_password} while the server decodes
json:"old_password" (Server/api/profile_handler.go:43). Go's encoding/json
does no alias matching, so OldPassword was always empty and every password
change returned 400 INVALID_INPUT -- the feature could never work for anyone.
docs/api.md and every server test already document old_password.
The existing unit test asserted the client's own broken payload, so it passed
while the feature was dead; it now asserts the documented server contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(admin): roll back and restart when a backup restore fails mid-copy
copyFile truncates the destination with os.Create before it can know whether
the read will succeed. On the restore path the destination is the live
database, already closed, so a failure in io.Copy or Sync left a zero-byte
chatserver.db, no rollback, and -- because the old code returned before the
restart -- a process still answering requests against a closed DB while the
response and the server_restart broadcast both claimed a restart was underway.
The failure branch now puts the pre-restore safety copy back (saying so
honestly in the error, including when the rollback itself fails) and requests
the restart the success path already did.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(voice): refuse client-side unmute while server-muted by a moderator
Push-to-talk called LiveKitSession.setMuted directly, which had no
server-mute guard -- only the voice widget's own handler checked. Unmuting
re-publishes a fresh mic track, and since MuteParticipantAudio only mutes the
track SIDs that exist at mute time while the LiveKit grant still carries the
microphone publish source, the SFU accepted it: holding PTT lifted a
moderator's mute and never told the server.
The guard now lives in setMuted itself, the one entry point every caller
shares, so PTT and any future caller are covered. Muting stays allowed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): force a full ready when a client's seq is ahead of the ring buffer
EventsSince/EventsSinceFiltered guarded only the lower bound, so a client
asking for events newer than anything the buffer ever held got a non-nil empty
slice -- which handleReconnect reads as a successful, complete replay. It then
registers the client, sends auth_ok with replay_source=buffer and skips ready
entirely, leaving stale members, channels and read state until the counter
climbs back past the client's remembered value.
That disagreement is reachable in normal operation: the hub seeds its counter
from GetMaxEventSeq, which is 0 once the 24h pruner has emptied the table, so
a restart can reseed seq below a lastSeq clients preserve across reconnects.
Both functions now return nil (the existing 'cannot guarantee coverage'
signal) when afterSeq exceeds the newest buffered seq, so the caller falls
through to the cold tier and the intended full ready. afterSeq == newestSeq
remains the legitimate caught-up case and still replays empty.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(client): re-send channel_focus on auth_ok so reconnects keep receiving messages
channel_focus was sent only by mountChannel, which early-returns when the
channel id is unchanged, so a reconnect into the same channel never re-sent it.
The server transfers the focused channel from the old connection, but only
while that connection is still registered -- readPump's defer unregisters it
and drops every topic subscription the moment the server observes the close,
about a second before the client's first retry. Any server-observed close
(restart, proxy close, network reset) therefore resumed with no ChannelTopic
subscription: server channel messages, edits and reactions are delivered
exclusively over that topic, so the message stream went silently dead while
global events kept arriving and made the connection look healthy.
auth_ok fires on every connection including resumes and the full-ready
fallback, and it also covers the server-restart case where there is no old
state to transfer from. The server's handler is idempotent, so the extra focus
on a fresh connect is harmless.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(messages): stop persisting HTML-escaped text, safely
bluemonday writes text tokens through html.EscapeString, so sanitizeContent
persisted and broadcast the escaped form: every apostrophe, quote, ampersand
and angle bracket reached other users as a literal entity, and because stored
quote lines began with '>' the client's blockquote regex could never fire.
cleanText (display names, about, custom status, DM names) had the same bug.
Unescaping bluemonday's output alone would be a sanitizer bypass: surviving
text tokens can recombine into live markup -- '<<script>script>alert(1)<'
+ '</script>/script>' reassembles a real end tag. Instead the whole
unescape -> Sanitize -> unescape cycle now runs to a fixpoint, so the stored
result is by construction stable under re-sanitizing: any '<' that the
tokenizer would read as a tag start is stripped rather than re-encoded, and
only inert punctuation survives. The loop is bounded by the input length and
each pass is non-increasing; measured worst case over pathological tag/entity
soup at the 16 KiB input ceiling is under a millisecond.
The fuzz sinks are tightened to match the new contract rather than loosened:
they now require a tag-like start ('<' + letter or '/') because a bare '<'
followed by punctuation is inert plain text under every client render path.
The <script substring and idempotency checks are unchanged. Verified with
4.2M fuzz executions, zero crashers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(client): close the WS task and TLS socket on disconnect via generation-owned teardown
ws_disconnect dropped the slot's sender expecting the write task to end, but
the monitor task held my_tx — a Sender clone kept only to prove teardown
ownership — so rx.recv() could never yield None: the monitor waits on the
writer via join_next() while the writer waits on the monitor's clone
dropping. Every intentional disconnect or profile-switch reconnect leaked
the writer, the reader, and the TLS socket, and with no server-side read
deadline the connection stayed registered — the user remained presence-online
after logout, and the stale Rust reader kept injecting the old server's
events into the new session's stores.
Ownership is now proven by the connection generation that already guards
install: the monitor captures my_generation plus the generation Arc and
clears/announces only if the generation is still current, checked under the
slot lock (generation only advances inside begin_connection while that lock
is held, so check-and-clear is atomic against new attempts). install_sender
receives the only Sender, so dropping the slot's sender really closes the
channel: writer exits, join_next returns, abort_all reaps the reader, and
the socket drops.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(voice): send voice_leave when the E2EE key exchange times out
connectAndSetup's key-exchange failure branch called leaveVoice(false) — no
voice_leave frame, no leaveVoiceChannel(). The timeout fires BEFORE
room.connect(), so no SFU participant ever exists and no LiveKit webhook can
clean up, while the server already registered the join when it sent
voice_token. The orphaned voice_states row matches the connected client's
channel, so sweepStaleVoiceStates never reaps it; once the ghost has the
lowest uid it wins key-holder election with a cleared E2EE state, every
later joiner's exchange times out and ghosts too, and rejoining the same
channel bounces off ALREADY_JOINED.
Mirror the reconnect-exhausted give-up path: leaveVoice(true) +
leaveVoiceChannel(), so the server drops the row and the local store
converges. The supersession checkpoints keep leaveVoice(false) — there a
newer attempt owns the server-side state and a voice_leave would destroy it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): keep voice state when the replay-failure fallback will transfer it
handleFreshConnect's stale-voice cleanup ran unconditionally, but the
replay-failure fallback (lastSeq > 0, e.g. after a restart reset the seq
counter) reaches it while the old connection is still registered — and
registerNow then transfers that connection's live voice state into the new
client. The cleanup had already deleted the DB row, broadcast voice_leave,
and removed the live LiveKit participant (using the very JoinedAt token
being transferred), so the user ended up "in voice" on the hub only:
voice_join bounced off ALREADY_JOINED and sweepStaleVoiceStates never
reaps in-memory state without a row.
Skip the cleanup when lastSeq > 0 and the still-registered old client's
voiceChID matches the row — exactly the case registerNow transfers. All
other cases (F5 fresh connects, no old client, mismatched channel) keep
the existing cleanup, and if the old client unregisters in the window
before registerNow, the untransferred row is reaped by the next sweep.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): stop the mic meter stream when it resolves after teardown
The mic-meter IIFE had no post-await guard: a getUserMedia resolving after
SettingsOverlay.hide() ran cleanup() (or after the tab's signal aborted)
opened the microphone anyway, started the rAF meter loop, and registerMic
re-armed state that cleanupMic() had already cleared — the mic stayed hot
for the rest of the session with nobody left to stop it.
Mirror the camera preview's request-id guard: cleanupMic()'s invalidation
callback now bumps a micRequestId alongside cameraRequestId, the IIFE
captures the id before the await, and a stale or aborted request stops the
just-acquired tracks and bails before touching the AudioContext.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api,db): close the API-token and stale-ban holes in 2FA and account deletion
Four related gaps around sessionless (API-token) principals and account
teardown, all found by the bughunt harvest:
- 2FA enable/disable skipped the BUG-108 "revoke other sessions" step
entirely when the caller authenticated with an API token (nil session).
Both handlers now use the change-password pattern: keep=0 matches no
row, so every login session is revoked.
- verify-totp issued a session to a user banned after the password step;
it now runs the same IsEffectivelyBanned refusal as login.
- DeleteAccount left API tokens active (they authenticate independently
of the purged sessions) and left a stale lapsed ban_expires in place,
which makes banned=1 read as NOT banned — together a previously
temp-banned self-deleted account stayed fully usable through any
owner-minted token. Tokens are now revoked in the purge and
anonymiseUser sets ban_expires = NULL.
- The last-admin guard resolved admin-class roles by display name
('Owner','Admin'), so renaming the seeded Admin role silently disabled
self-deletion protection for its holders. It now keys on the canonical
OwnerRoleID/AdminRoleID plus any role holding the Administrator bit.
(The harvest's suggested criterion — Owner ID or Administrator bit
alone — would have DROPPED seeded Admins, whose 0x3FFFFFFF permissions
lack bit 30; the ID-based form preserves existing guard semantics.)
- DeleteAccount also now applies LeaveGroupDM's invariant: DM channels
left with zero participants are removed instead of becoming
unreachable, undeletable rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(api,ws): rate-limit gaps — bucket isolation, focus/mark_read, call_decline, reaper horizon
- Every empty-prefix RateLimitMiddleware mount shared one bare-IP bucket,
and the limiter records a timestamp per call regardless of the limit
passed — so unrelated endpoints capped each other at the minimum limit
(five ordinary profile edits 429'd the password endpoint; NAT'd logins
blocked register). The prefix is now a required parameter and every
mount names its own bucket, mirroring the existing client_update:/
livekit_proxy:/gif: pattern. The sessions-list handler also stops
401ing API-token principals (nil session only ever fed IsCurrent).
- channel_focus and mark_read were the only user-facing V2 handlers with
no rate limit, and each drives an unmetered SQLite write plus pubsub
churn; they now share a 5/s per-user budget (same underlying service
call), silently dropping over-budget frames like their siblings.
- call_decline gets the same limiter as its sibling call_ring — the
identical participant-lookup-plus-fan-out cost shape.
- The rate-limiter reaper pruned any entry idle past 15 minutes, but slow
mode passes windows up to the 6 h admin cap, so long slow modes were
silently reset; the cleanup horizon now covers the largest real window.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(server): unreachable route envelopes, dead env override, admin paging, plugin/updater defects
Seven harvest findings across router, config, admin, plugin and updater:
- The global 1 MiB body cap shadowed every route with a larger documented
envelope: the 16 MiB plugin install 400'd at ~1 MiB and an at-limit
avatar could never fit its multipart framing. The exemption list is now
a named var covering uploads, plugin install, and avatar — each of
which enforces its own cap at the route/handler level.
- queryInt clamped offset with the limit's 500 cap, so the admin audit
log and user list could never page past row 550; the cap is now an
explicit per-call bound (offset callers pass MaxInt32).
- OWNCORD_EVENT_PERSISTENCE_* env overrides were documented but dead:
envKeyToKoanf cut at the first underscore, producing the unknown path
event.persistence_* that koanf silently drops.
- InstallPlugin trusted LastInsertId, which SQLite does not update on the
upsert's DO UPDATE branch — on the shared writer connection a reinstall
returned the rowid of some unrelated prior INSERT, so EnablePlugin
no-opped and plugin_kv wrote to a nonexistent plugin id. RETURNING id
is correct on both branches.
- Every wazero plugin re-activation compiled the module again and leaked
the previous CompiledModule; the handle is now retained on the instance
and closed in deactivate, the lost-activation race, and the
closed-module release path.
- Linux server self-update was gated on the Windows-only
chatserver.exe.sig asset it never uses; the required-asset check and
the signature fetch are now GOOS-aware.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ws): batch S4 — seq-gap shed, replay tail merge, drain-on-close, handshake teardown
Eight harvest findings in the hub/replay/pump paths, each locked by a
watched-red test in harvest_s4_internal_test.go / reconnect_db_test.go:
- kickClient closes the send channel BEFORE UnsubscribeAll so a racing
Subscribe can never leave a dead client holding a topic.
- deliverBroadcast consults the topic limiter BEFORE allocating a seq: a
shed frame no longer burns a sequence number that sits in the replay
buffer forever unpublished.
- onStaleTick prunes idle topic-limiter buckets (Cleanup had no caller).
- dm_channel_open bumps the visibility watermark so a client resuming
from an older seq takes the full-ready path instead of silently losing
the targeted, unsequenced open.
- computeAllowedChannels treats a DM-lookup failure as fatal (full ready)
instead of replaying with every DM event silently stripped.
- Cold-tier replay merges the ring-buffer tail past the newest persisted
row; if the buffer cannot vouch for the flush gap it forces full ready.
- writePump drains queued frames (e.g. the BANNED kick reason) after
closeSend instead of dropping them on the first closed channel.
- A failed post-registerNow handshake runs the offline teardown when no
replacement connection holds the slot — no more users stuck online.
Declined by design: hoisting registerNow above the replay snapshot
(report L390) — every fallback path would re-register the same client
and registerNow self-kicks the slot holder; the µs dedup window does not
justify that risk in the hottest path.
The kickClient ordering test is a 300-iteration stress whose race window
is too narrow to hit reliably; it documents the invariant rather than
having been watched red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(client): batch C1 — store merges, ready-badge resync, pending-send teardown
Ten harvest findings in the stores/dispatcher layer, each locked by a
watched-red vitest test:
- setMessages merges instead of clobbering: live broadcasts and
pending/failed optimistic rows that landed while the history GET was
in flight survive the snapshot.
- addChannel is idempotent — the re-sent channel_create on role edits no
longer wipes unread/mention counts, lastMessageId, or canSend.
- setChannels carries client-synthesized DM rows across the rebuild.
- READY marks the focused channel read after the store repopulation so
stale server read_states cannot resurrect badges on the channel the
user is actively reading (skipped on first connect).
- setVoiceStates maps the ready payload's camera/screenshare flags
instead of blanking live streams on a mid-call resync.
- The dm_channels length guard is gone: an empty array is authoritative
and clears ghost DMs.
- addMessage's defensive pending-row reconcile requires content equality
so another session's replayed message cannot consume the pending row.
- performSend into a detached history window reattaches to present
first, mirroring onJumpToPresent.
- prependMessages at the cap keeps the fetched older page and detaches
the window instead of silently discarding the fetch (which refetched
the same page forever); hasMore is the server's value again.
- A connection leaving "connected" fails every pending optimistic send
(retry affordance) instead of letting rows spin forever.
One existing assertion updated to the corrected semantics: trimming on
prepend now drops rows below the window, so hasMore stays the server's
value and the test asserts the detach instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(server): batch S5 — voice eviction scoping, fail-closed camera cap, role-service races
Seven harvest findings in the voice/service layer, each locked by a
watched-red test:
- The CONNECT_VOICE revocation sweep evicts via a channel-conditional
clear (the in-memory analogue of LeaveVoiceChannelIfMatch): a
voice_join to a permitted channel that commits while the DB-backed
permission check runs can no longer be torn down. The report's
suggested pre-check guard was rejected — it leaves the same race open
between guard and clear, proven by the interleaving test.
- A failed channel switch's abort branch re-subscribes the restored
session to its VoiceTopic and re-elects the key holder; without them
the session silently missed every voice_e2ee relay.
- voice_camera fails closed when the VoiceMaxVideo lookup errors instead
of skipping the cap check and enabling unconditionally.
- LiveKitProcess starts the child inside the p.mu critical section that
publishes p.cmd (Wait stays outside), removing the data race between
Start's cmd.Process write and IsRunning/Stop reads.
- AffectedUserIDs reports lookup success; handlePatchRole falls back to
a blanket permission-cache invalidation when the member list was
unreadable, instead of evicting nobody and leaving revoked grants live.
- RoleService serializes its read-check-write mutations (position
uniqueness and the role cap are snapshot-enforced, not DB-enforced);
concurrent creates can no longer land on the same position and tie
every hierarchy comparison.
- channel_focus writes the read state even when the channel has no
undeleted messages — the upsert is what zeroes mention_count, so
emptied channels finally clear their badge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(client): batch C2 — voice eviction teardown, supersession scoping, VAD generation
Seven harvest findings in the voice/session layer, each locked by a
watched-red vitest test:
- A server-initiated voice_leave for self tears down the LiveKit session
(mic publish + E2EE key material), guarded on channel match so a
late-arriving leave cannot kill a newer join.
- VIDEO_LIMIT refusal rolls back with disableCamera() — max_video has no
SFU-level enforcement, so the already-published track kept streaming.
- teardownForReconnect sends voice_camera/voice_screenshare OFF frames
before stopping local tracks, freeing the server-side max_video slot a
reconnect otherwise occupies forever.
- Supersession checkpoints 3/4/5 disconnect only their own local room
(mirroring checkpoint 2) instead of calling the global leaveVoice,
which by then tears down the newer attempt's live session.
- retryMicPermission honors a moderator's server-mute like it honors
deafen — granting mic while listen-only no longer hands the channel an
unmuted track.
- handleDisconnected defers to the active reconnect loop (livekit-client
fires Disconnected synchronously inside the loop's own connect call),
preventing a second uncancellable retry loop.
- stopVadPolling invalidates an in-flight startVadPolling addModule via
a VAD-scoped generation counter, so VAD cannot resurrect itself with a
stale threshold.
Deliberately skipped: the report's optional RATE_LIMITED camera rollback
— that error code is shared by unrelated actions and the payload cannot
attribute it to a camera toggle, so a blind rollback would be wrong.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(client): batch C3 — E2EE rotation races, pin-write tri-state, stale-offer guards
Six harvest findings in the voice-E2EE key-exchange layer, each locked
by a watched-red vitest test; the security-invariant sweep (re-pin
TOCTOU, forward-secrecy rekey, concurrent-rotation, blind-repin — 239
E2EE-adjacent tests) stays green:
- Re-election as key holder during an in-flight rotation defers (sets
_isKeyHolder + _rotationPending, mirroring the sibling branch) instead
of dropping the election and stranding the room without a holder.
- storeIdentityPin returns tri-state stored/no-store/failed; a FAILED
pin write now marks the peer unverified instead of displaying
"verified" with no pin persisted — an unpinned peer could never trip
mismatch detection, the exact MITM window the pin exists to close.
- handleOfferInner discards a stale offer when the session keypair
changed, not just the epoch — a non-key-holder never bumps epoch, so
an offer surviving clearState() into the next session passed the
epoch-only check.
- handleAnnounce's wrap-and-offer path gets the same epoch guard as the
receive path, so a rotation landing mid-wrap cannot ship a dead key.
- The key-exchange retry races a FRESH promise (the first rejection had
permanently settled the old one, making the retry window zero), and
aborts cleanly when clearState() tore the session down mid-exchange.
- setupKeyExchange publishes _ecdhKeyPair only after _isKeyHolder and
_roomKey are ready, so a concurrent announce is queued and drained
through the offer-sending path instead of being consumed offer-less.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(client): batch R1 — secret store must not report a broken keyring as empty
Three harvest findings in the Tauri credential store, each locked by a
watched-red test:
- secret_store::get treated a keyring read error as "nothing stored"
whenever the fallback file was also empty, which is indistinguishable
from first login. loadOrGenerateIdentityKeyPair reads exactly that
signal, so an unreadable keychain made the client mint and publish a
fresh identity key over the existing one, invalidating every peer's
TOFU pin. It now prefers a fallback copy and otherwise propagates the
error; loadIdentityKey rethrows instead of swallowing to null.
- A failed keyring write left any older entry in place while the fresh
secret went to the fallback file — and get() reads the keyring first,
so the stale value shadowed the new one forever. The write-failure arm
now purges the entry, mirroring the read-back-mismatch arm beside it.
- fallback_crypto deleted nothing when the key file's write or sync
failed, leaving a short file that every later load rejects; since the
key file is never rewritten once it exists, one ENOSPC poisoned the
fallback store permanently.
Both Rust fixes needed a small injectable seam (get_with/set_with,
finish_new_key_file) because the keyring error branches are otherwise
unreachable without a live OS credential store.
The saved-login path is unaffected in behavior: loadCredential still
catches and degrades to "no saved credential" rather than surfacing the
new error. The persistence re-read in loadOrGenerateIdentityKeyPair
deliberately does not rethrow — the keypair already exists in memory by
then, so a transient failure keeps the existing "did not persist" warning.
fallback_crypto is cfg(not(windows)), so its test ran only under a
temporary local gate lift (reverted, verified no residual diff); it
executes for real on the Linux and macOS CI runners.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: rewrite CLAUDE.md per Claude 5 context guidance; commit skills + hunt workflow
The CLAUDE.md files were a manual: build commands, code style, layout Claude
can read off the filesystem. Rewritten so they are short and spend their
tokens on gotchas instead — the things that are invisible until they cost an
afternoon.
Moved out of CLAUDE.md into skills (progressive disclosure), which also fixes
three references to skills that never existed:
- ci-check: the full local CI mirror, including the four Go build-tag variants
and the deadlock pass a plain build/test misses, and the windows-latest
runtime.scanstack GC fault that should be rerun rather than investigated.
- db-change: the sqlc workflow plus three silent traps — non-ASCII query files
truncating the NEXT query's emitted SQL, semicolons in migration comments
orphaning statements, and LIMIT 1 mis-emitting on a :one query.
- protocol-change: regenerate both constant files and commit the pair.
Dropped: command lists duplicated from the Makefile and package.json, prettier
style rules the formatter already enforces, and layout facts a directory
listing answers. Added the subsystem invariants that keep getting rediscovered
the hard way — the ws seq/FIFO contract, voice-session supersession scoping,
E2EE staleness guards, and the Node 22 webstorage failure mode.
.claude/ is no longer ignored wholesale: skills and workflows are tracked so a
cloud session, which sees only tracked files, starts with instructions rather
than nothing. Machine-local settings and locks stay ignored. Deleted
bughunting.js, a superseded copy declaring the same workflow name as
bughunt.js, which left the registry ambiguous.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(client): batch C4 — cert-latch scoping, stale active channel, credential opt-out
Thirteen harvest findings across the client UI and wiring, each locked by a
watched-red vitest test:
- The TLS cert-mismatch latch fired on any host's event, so an unrelated
saved profile's rotated certificate permanently killed this socket's
reconnect loop. It now latches only for the connected host, cancels any
pending reconnect (a timer armed before the mismatch would otherwise fire
connect(), clearing the latch and resuming against that host), and resets
on a fresh connect.
- ready never cleared activeChannelId when the channel vanished from the
snapshot, and MainPage's subscriber had no else branch — the message list
and composer stayed mounted and enabled against a channel the server no
longer recognizes. Both sides fixed; the mark-read from batch C1 is
suppressed when the clear happens.
- user_update re-saved the session token unconditionally, bypassing the
remember-password opt-out, and dropped the stored password while doing it.
- A failed older-page fetch latched loadingOlder, permanently killing
infinite scroll for that view; it now clears in a finally.
- Concurrent message jumps raced, letting the older response overwrite the
newer window. Guarded by a generation counter.
- A FORBIDDEN send in a group DM flagged participants[0] as blocking, which
disabled the unrelated 1:1 composer with that person; block gating is
1:1-only.
- streamPreview added an abort listener per call instead of per signal.
- dm_channel_close had no fallback when the closed DM was being viewed;
both call sites now share one closeDmLocally helper.
- The GIF picker routed through the textarea and discarded the draft.
- QuickSwitcher listed DM rows that the DM section already shows.
- Accepting a rotated certificate reconnected into a page with nothing left
listening, stranding the user on the connect screen.
- Logout read voiceStore after clearAuth had already reset it, so the
voice_leave was never sent; clearAuth now snapshots logoutWasInVoice.
- disconnect() left reconnectAttempt set, carrying a stale backoff ceiling
into the next login.
Also fixes two lint errors this branch introduced earlier and that only a
full `npm run lint` catches: a useless spread in the C1 pending-send sweep
(now Array.from, which states the snapshot intent), and two floating
promises in C2's voice_leave handler, where converting an implicit-return
arrow to a block body stopped chaining them.
main.ts and MainPage.ts have no unit-test seam, so three focused pieces were
extracted to make the fixes testable: createUserUpdateCredentialSaver,
reconnectAfterCertAccept, and the logoutWasInVoice snapshot.
One existing assertion corrected: a dispatcher test claimed ready must keep
an active channel that was absent from the payload, which locked the bug.
It now keeps a channel that is present, with a sibling test for the absent
case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: resolve 107 verified defects across ws hub, voice/E2EE, db, and client
Findings came from a multi-round hunt, each verified against the tree by an
independent adversarial pass before any code changed, then fixed and re-reviewed.
Every behavioural fix carries a regression test that was confirmed to fail
against the pre-fix code.
Server (Go)
- Reconnect/replay: force a full ready when retention pruning has removed the
events after a client's last_seq, rather than accepting the surviving suffix
as a complete resume; close the snapshot-to-registration window under seqMu;
restore the focused-channel subscription during the handshake via a new
READ-gated active_channel_id auth field; supplement replay with the client's
own voice room; tear down transferred voice sessions on a failed handshake.
- Hub: ratchet visibilityChangeSeq upward only (all three writers); make the
stale-voice sweep error-aware so a transient DB failure no longer evicts
every participant; re-elect the E2EE key holder on sweep and cleanup paths.
- Voice: preserve moderator mute/deafen across channel switches; deliver
voice_leave to evicted users; gate camera/screenshare permission checks on
the enabling direction only; reject joins to non-voice and archived channels.
- Permissions: archived channels are now read-only and unjoinable, and
can_send is recomputed per client on role/override changes.
- Data: stop cascaded message deletes from stranding uploaded files
(migration 030 unlinks instead); clear personal data on account deletion;
exclude banned users from owner lookup; drop the silent 1000-member cap;
advance the author's own read state on send.
Client (TypeScript / Rust)
- Voice: make joinGeneration monotonic so a superseded attempt can no longer
pass supersession checks; scope aborted-path cleanup to the attempt's own
room; send voice_leave on connect failure; stop push-to-talk from writing the
user's explicit mute flag; gate join-time PTT muting on a new backend
capability probe so platforms that cannot report key state are unaffected.
- E2EE: act on the tri-state pin-write result instead of reporting an
unverified peer as verified; use keypair ownership rather than null checks.
- State: reset the message cache on logout; clear NSFW acknowledgements on
logout; scope channel mutes, NSFW acks and DM notes per server host.
- UI: make the attachment remove button and the failed-send Retry/Discard
buttons work; fix drag-reorder's phantom-drag latch and its permission gate.
Docs: protocol.md now documents can_send, active_channel_id, the archive
read-only contract, and the sequenced/unsequenced presence split.
Verified: all four Go build tag variants, go vet, go test -race, the ws
deadlock detector, sqlc and protocol generation, tsc, eslint, prettier, and
the full client suite (169 files, 4664 tests).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWUzUmsjCKNAzAfQzT4fz7
* fix(ci): satisfy golangci-lint, knip, and the host-scoped mute key in e2e
Three CI failures on the previous commit, all genuine fallout from it.
golangci-lint (v2.11.3) — 6 issues in tests added by that commit:
- contextcheck: the temp-ban subtest captured an outer ctx while calling
seedTokenUser, which builds its own; declare ctx inside the subtest instead.
- modernize: use WaitGroup.Go and range-over-int in three tests.
knip — SessionResponse in lib/types.ts became unused. The getSessions fix
replaced it with SessionInfo in lib/api.ts, which documents why the old
declaration was wrong (it named ip_address/expires_at, which the server never
sends, and omitted ip/is_current, which it always does). Delete the dead type
rather than re-export it, and fold that reasoning into the surviving comment.
Client E2E — the per-channel-mute parity test asserted the pre-scoping
localStorage key. Channel mutes are now keyed mutedChannels:<host>, because
channel ids are per-server autoincrement integers sharing one webview origin;
verified in a browser that the app writes
owncord:settings:mutedChannels:localhost:8443. The test now resolves whichever
scoped key exists instead of pinning the test server's host, so it still
asserts the same thing: the id persists on mute and is gone on unmute.
Verified with the CI linter version built against Go 1.26 (0 issues), all four
build tag variants, go vet, go test -race, the ws deadlock detector, knip,
tsc for both tsconfigs, prettier, the full client unit suite, and the
previously-failing parity specs run in a real browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWUzUmsjCKNAzAfQzT4fz7
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dependabot PRs run under the separate `dependabot` secrets scope, so
TAURI_SIGNING_PRIVATE_KEY arrives empty and `npm run tauri build` always
aborted with "failed to decode secret key" while signing the updater
artifact -- after the compile and the NSIS/AppImage/deb bundle had both
already succeeded. Every dependency PR therefore burned ~50 min of runner
time across three platforms to produce a red check carrying no signal,
and the permanent red masked whether the job would have caught a real
break.
Granting Dependabot the signing secret would clear the symptom but hands
a release signing key to workflows triggered by third-party dependency
updates, so the job is skipped for that actor instead.
Coverage is preserved where it matters: `rust-tests` is a required check,
runs on every event, and compiles the crate via `cargo clippy
--all-targets` and `cargo test --lib`, so a dependency bump that breaks
the Rust build is still caught. Given up on Dependabot PRs only:
bundling, Windows/ARM-specific compilation, and the `cargo audit` step --
which overlaps with Dependabot's own cargo scanning.
`Tauri Full Build` is not among the required status checks on main
(Server Build & Test x2, Client Static Checks, Client Unit Tests, Rust
Unit Tests), so skipping it cannot leave a PR waiting on a status.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
description:Run a bug hunt and turn its findings into committed fixes. Use when starting a hunt, resuming one, or fixing findings already in the ledger. Covers the ledger handoff between the bughunt and bughunt-fix workflows.
---
# Running the bughunt pipeline
Two workflows with a human gate between them. The ledger at
`.superpowers/findings-ledger.json` is the interface. Both workflows are pure
functions of their `args` — **the session does all file I/O**, because workflow
scripts have no filesystem access.
`.superpowers/` is gitignored. This repo is public and unfixed defects must never
reach a commit, an issue, or a PR body.
## 1. Hunt
**Launch the hunt from a turn that carries a token-budget directive** (recommended:
`+25M`, comfortably above a full coverage run's ~8-12M). The workflow's cost ceiling is gated
on `budget.total`, which is null without a directive — a directive-less run has **no
ceiling at all**. The workflow's first log line echoes the state: `budget=25M` means
armed; `budget=NONE - cost ceiling disarmed` means stop the run and relaunch with a
directive.
Before launching, in order:
1.**Build the inventory**: `node .superpowers/rank-explore.mjs` — writes
`.superpowers/explore-ranking.json`: EVERY non-test source file (~419 rows), each with
`examined` (already carries a ledger finding or a LIVE explored-clean record → the hunt
pre-seeds its covered set), `risky` (top coupling ∪ past-bug clusters ∪ top churn,
capped at 40 → they get an extra pass through all 5 bug-class lenses), and `churn`.
Explored-clean records carry content hashes: editing a file expires its clean record,
so re-runs automatically re-hunt what changed. The hunt cannot stop while any inventory
file is uncovered, so a full run now takes ~10-20 rounds and ~8-12M tokens — the `+25M`
directive still covers it. Regenerate the inventory and read `known` from the ledger in
the SAME session step: both derive from `findings-ledger.json`, and every `known` file
must be `examined` in the inventory — a `known` file the inventory does not mark
examined can never be drawn (the seen-filter blocks it) nor covered, which would
strand `uncoveredCount()` above zero and block convergence.
2. Read the ledger and pass every record in as `known`, so the hunt does not
re-derive anything already found, fixed, declined, or refuted.
```
Workflow({
name: "bughunt",
args: {
known: <every record from findings-ledger.json, as {file, line, title, status}>,
graph: <the rows of .superpowers/explore-ranking.json>,
lenses: [ {key, prompt}, ... ], // optional: scope the hunt to one subsystem
maxRounds: 30, // safety backstop only - coverage + dry is the real stop
dryThreshold: 2,
},
})
```
If `graph` is omitted or empty the hunt logs
`explore: args.graph absent/empty - falling back to churn-based fresh eyes` and
still runs — degraded targeting, never a smaller lens family.
`converged: true` now means: every inventory file was covered by a completed
explicit-file lens (or carries a verdict), the risky class sweep ran, and then
`dryThreshold` consecutive eligible rounds confirmed nothing (a round where the
lens family comes up empty with the pool drained counts as dry — family
`exhausted`). Rows without the `examined` field fall back to the old
quietness-only stop. Two new run outcomes: `stalledCoverage: true` means adaptive
rounds stopped shrinking the uncovered pool (usually mass finder failures —
investigate before re-running); a budget stop now reports
`coverage.uncoveredAtStop` so the next run knows exactly what remains (re-run
with the ledger as `known`; live explored-clean records pre-cover what was
finished, so the sweep naturally continues where it stopped).
Omit `lenses` for a general hunt across the rotating families.
**Scoping a hunt while coverage mode is armed is a budget trap:**`lenses` only
replaces round 1, and inventory rows with `examined` force the coverage stop
rule — from round 2 the run sweeps the ENTIRE uncovered pool and the risky
sweep before it may converge, at general-hunt cost. For a true scoped hunt,
pass a subsystem-filtered inventory as `graph` (only the rows you want swept),
or rows without the `examined` field to fall back to the legacy quietness-only
stop.
Each lens object is `{key, prompt}`. `key` must match `^[a-z0-9-]+$` —
lowercase letters, digits, and hyphens only. Keys get interpolated into agent
labels of the form `r<N>:hunt:<key>:<model>`, and a key containing uppercase,
dots, or spaces breaks label parsing. A lens missing `key` or `prompt` is not
validated — it reaches the finder prompt as the literal string `undefined`,
silently degrading that lens instead of failing loudly. Check your lens
objects before passing them.
When it returns, first save the raw result verbatim to
description:Run the local mirror of OwnCord's CI gates before pushing. Use when finishing a change, before a commit or push, or when asked to verify work — CI takes ~15 min and catches things a plain build/test does not.
---
# ci-check
`.github/workflows/ci.yml` is the source of truth. This mirrors it locally.
Run only the sections your change touches. Server and client are independent.
**A step added only to `release.yml` first runs at tag time.**`release.yml` is
tag-triggered and never gated by a PR, so a smoke/sign/strip step added there is
untested code on the critical path — its own bugs surface on the release, not on
a PR. Extract it to a script `ci.yml` also runs (`Server/scripts/docker-smoke.sh`
is the worked example) or duplicate it into `ci.yml` before merge.
From the repository root, `npm run check` runs all of it, and
`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.
## Server (from `Server/`)
All four build-tag variants must compile — the tags gate whole files, so a
default-build pass proves nothing about the others:
```bash
go build ./... && go build -tags otel ./... && go build -tags wazero ./... && go build -tags otel,wazero ./...
go vet ./...
go test -race ./...
go test -tags deadlock -count=1 ./ws/ # deadlock detector; ws is where lock order actually varies
golangci-lint run # CI pins v2.11.3
# Generated output must not be stale. These are what `make sqlc-verify` and
# `make protocol-verify` reduce to — make is not on PATH on a stock Windows box.
sqlc generate && git diff --exit-code db/dbgen
go run ./cmd/genprotocol && git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts
```
Add `-tags wazero` to `go vet`/`go test` when you touched `plugin/`.
A `windows-latest``-race` failure inside `ws` that matches `runtime.scanstack`
or `runtime.(*unwinder).next` is a Go 1.26.5 runtime GC fault, not your change.
The Go 1.26.6 toolchain shows a variant signature: `unexpected fault address
| `windows-latest``-race` fault in `ws`: `runtime.scanstack`, `runtime.(*unwinder).next`, or `unexpected fault address 0xffffffffffffffff` / `fatal error: fault` inside ordinary stdlib frames | Go runtime GC fault, not your code — see the Server section. `gh run rerun --job <id>` |
| `##[error]The operation was canceled.` + `Terminate orphan process: ... playwright install --with-deps` + a wall of `Ign:N http://azure.archive.ubuntu.com/...` and no Playwright summary line | Runner apt-mirror outage during "Install Linux system dependencies". The job was **canceled by timeout**, not failed. `gh run cancel` then `gh run rerun --failed` |
| Red `Lint` step with zero linters actually run | `golangci-lint`'s network schema fetch failed. Re-run |
`gh run view --log` refuses while a run is in progress; `gh api
repos/<owner>/<repo>/actions/jobs/<id>/logs` works. A job cannot be rerun while
its parent run is still in progress. `tauri-build` has no `timeout-minutes`, so a
hung apt step can hold a run open for the 6 h default — cancel it rather than wait.
## Hooks
`npm run hooks:install` (once per clone) points `core.hooksPath` at
`.githooks/`: `pre-commit` runs fast staged-file checks, `pre-push` runs the
server build variants plus tsc and eslint. `OWNCORD_PREPUSH_TESTS=1` adds
server tests. Bypass with `--no-verify` or `OWNCORD_SKIP_HOOKS=1` — CI still
enforces everything.
**`core.hooksPath` is exclusive, not additive.** Once set, Git resolves every
hook against `.githooks/` and stops consulting `.git/hooks/` entirely.
`.githooks/` holds only `pre-commit` and `pre-push`, so running
`hooks:install`**silently disables any locally installed hook** of any other
name (`post-commit`, `post-checkout`, ...). Nothing warns you. If you need one,
re-install it under `.githooks/` (untracked, and it stays yours), or skip
`hooks:install` and run the checks through `npm run check` instead.
description:Change OwnCord's SQLite schema or queries — add a migration, edit Server/db/queries/*.sql, and regenerate the sqlc layer. Use before touching anything under Server/db/ or Server/migrations/.
---
# db-change
`Server/db/dbgen/` is generated. Edit the inputs, regenerate, commit both.
1. Add the migration to `Server/migrations/` and/or edit
`Server/db/queries/sqlite/*.sql`.
2. Regenerate: `make sqlc-generate` from `Server/`.
3. Commit the regenerated `Server/db/dbgen/` alongside your inputs. CI runs
`make sqlc-verify` and fails on drift.
`sqlc.version` pins the binary (currently v1.30.0). If `make` is not on PATH:
```bash
go install github.com/sqlc-dev/sqlc/cmd/sqlc@$(cat sqlc.version)
$(go env GOPATH)/bin/sqlc generate
```
## Traps
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
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".
**No semicolons inside migration `--` comments.**`splitStatements` in
`Server/db/migrate.go` splits on `;` before stripping comments, so a semicolon
in comment prose orphans the rest of that comment as a bogus statement
("near <word>: syntax error").
**Regenerate from a tree where the query files carry only YOUR change.**
sqlc regenerates every `dbgen/` file from every query file on each run, so
unrelated working-tree edits to any `queries/*.sql` — a parallel agent's
half-finished work, leftover debris — are silently baked into generated
output you then commit. Check `git status` on `Server/db/` before
`sqlc generate`, and diff the regen for hunks that are not yours.
**Do not put `LIMIT 1` on a `:one` query.** It is emitted as a bare `LIMIT`.
A `:one` uses `QueryRow` and reads a single row regardless — use `ORDER BY` to
choose which one.
After regenerating, gopls diagnostics against `dbgen` go stale. Trust
description:Add or change a WebSocket message type in OwnCord. Use before editing protocol/schema.json, Server/ws/message_types.go, or Client/src/lib/protocolTypes.ts.
---
# protocol-change
`protocol/schema.json` is the source of truth. Both constant files are
generated from it by `Server/cmd/genprotocol/`.
**The schema holds message-type NAMES only.** Route by what you are changing —
most payload work never touches it, and sending a field change through the
| New message type | schema + regenerate (steps below) |
| New or changed payload **field** on an existing type | `Server/ws/command.go`/`messages.go`, `Client/src/lib/protocolTypes.ts`, `docs/protocol.md` — no schema, no regenerate |
| Content inside an opaque blob the server relays verbatim | `docs/protocol.md` only; often zero Go change |
Before assuming a field needs server work, read the relay handler: if the server
forwards the message raw, there is nothing to add. If it **re-serialises**, an
older server drops unknown JSON fields — so a field the server must forward is
NOT backward compatible with older servers.
1. Edit `protocol/schema.json`.
2. Run `make protocol-generate` from `Server/`.
3. Commit **both** outputs — `Server/ws/message_types.go` and
`Client/src/lib/protocolTypes.ts`. One run regenerates the
pair; committing only the Go side is the usual mistake, and CI's
`make protocol-verify` fails on either being stale.
Document the semantics in `docs/protocol.md` — the schema carries names and
shapes, not behaviour.
Adding a message type is not enough to make it work: a server handler must be
registered in the `ws` V1/V2 dispatch tables, and the client needs a
`ws.on(...)` subscription in `Client/src/lib/dispatcher.ts`.
| 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` |
"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"},
],
};
// args has been observed arriving JSON-stringified; coerce it the same way bughunt.js does.
|| fail "db/dbgen is stale — run 'sqlcgenerate' in Server/ and stage the result"
else
printf 'pre-commit: WARNING: sqlc not installed (make sqlc-install); CI will run sqlc-verify.\n' >&2
printf 'pre-commit: WARNING: sqlc not installed; skipping the db/dbgen check. Install the version pinned in Server/sqlc.version. CI will run it.\n' >&2
fi
fi
# Protocol schema changed -> regenerated Go + TS constants must be in the same commit.
if printf '%s\n' "$staged" | grep -qE '^(docs/protocol-schema\.json|Server/scripts/genprotocol/)'; then
if printf '%s\n' "$staged" | grep -qE '^(protocol/schema\.json|Server/cmd/genprotocol/)'; then
if command -v go >/dev/null 2>&1; then
(cd Server && make protocol-verify) \
|| fail "protocol constants are stale — run 'make protocol-generate' in Server/ and stage the result"
"description":"Default capability granting core permissions to the main window. NOTE: http:allow-fetch is the ONLY URL-scoped HTTP identifier — tauri-plugin-http validates the URL once, in the `fetch` command; `fetch_send` and `fetch_read_body` take an already-validated ResourceId and never consult a scope, so allow/deny blocks on those identifiers are inert. Do not re-add them.",
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.