Files
b7d388a39c 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 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>
2026-08-28 06:54:32 +02:00

90 KiB
Raw Permalink Blame History

REST API Reference

OwnCord server REST API reference. All endpoints use the base URL https://{server}:{port}/api/v1.


Authentication

All authenticated endpoints require a session token delivered via the Authorization: Bearer {token} header. Tokens are obtained from POST /api/v1/auth/login, POST /api/v1/auth/register, or POST /api/v1/auth/verify-totp after a partial 2FA challenge.

Session Lifecycle

  • Sessions are created on login/register and stored with a SHA-256 hash of the raw token, the client IP, User-Agent, and an expiry timestamp.
  • Each authenticated request updates the session's last_active timestamp.
  • Banned users are rejected at the middleware level with 403 FORBIDDEN.

Middleware Stack (all routes)

In mount order (Server/api/router.go):

  1. boundRequestID -- drops an oversized (>128 bytes) or non-printable client-supplied X-Request-Id before chi adopts it.
  2. RequestID (chi) -- assigns the request ID used in logs.
  3. setRequestIDHeader -- echoes the request ID into the X-Request-Id response header.
  4. Recoverer -- catches panics, logs them through slog with a stack capture, returns 500.
  5. Request Logger -- structured logging of method, path, status, duration.
  6. Telemetry HTTP middleware -- OpenTelemetry tracing; a no-op unless the server was built with -tags otel and telemetry is enabled.
  7. SecurityHeadersWithTLS -- (adds Strict-Transport-Security when TLS is on) sets X-Content-Type-Options: nosniff, X-Frame-Options: DENY, X-XSS-Protection: 0, Referrer-Policy: strict-origin-when-cross-origin, Content-Security-Policy: default-src 'self', Permissions-Policy: camera=(), microphone=(), geolocation=(), Cache-Control: no-store.
  8. MaxBodySize -- 1 MiB default for all routes except /api/v1/uploads (100 MiB), /api/v1/admin/plugins/install (16 MiB envelope), and /api/v1/users/me/avatar (2 MiB envelope).
  9. Coraza WAF (optional) -- OWASP Core Rule Set request filtering, mounted only when server.waf_enabled: true (see docs/server-configuration.md).

Note: chi's middleware.RealIP is deliberately not used -- client IPs are resolved from X-Forwarded-For only when the peer is listed in server.trusted_proxies.


Standard Error Response

Error responses use this JSON envelope (one exception: the plugin admin endpoints return plain-text errors — see their section):

{
  "error": "ERROR_CODE",
  "message": "Human-readable detail"
}

Error Codes

Code HTTP Status When It Occurs
UNAUTHORIZED 401 Missing/invalid/expired session token
INVALID_CREDENTIALS 401 Login/register with bad username/password/invite (generic to prevent enumeration)
FORBIDDEN 403 Insufficient permissions, banned account, or admin IP restriction
NOT_FOUND 404 Resource (channel, message, user, invite, file, backup) not found
RATE_LIMITED 429 Too many requests; response includes Retry-After header (seconds)
INVALID_INPUT / BAD_REQUEST 400 Malformed body, missing required fields, invalid query params, or an upload exceeding the size limit (oversize uploads are rejected 400, not 413; the only 413 in the API is the plugin-install endpoint's plain-text "plugin upload too large")
CONFLICT 409 Duplicate username on register, or server already up-to-date on update
INTERNAL_ERROR 500 Internal server error
STORAGE_ERROR 507 Upload could not be persisted (storage backend write failure)
BAD_GATEWAY 502 Upstream failure (GitHub API, LiveKit, GIF provider, asset download)
GIF_DISABLED 503 GIF proxy is not configured on this server (no gif.api_key)

Auth Endpoints

POST /api/v1/auth/register

Create a new account using an invite code. The first user is created via /admin/api/setup instead.

Auth: None (public) Rate limit: 3 requests/minute per IP

Request

{
  "username": "alex",
  "password": "MyStr0ng!Pass",
  "invite_code": "abc123def"
}
Field Type Required Notes
username string Yes HTML-stripped, trimmed. Must be non-empty.
password string Yes Validated for strength (min length, complexity).
invite_code string Yes Must be a valid, non-expired, non-revoked invite with remaining uses.

Response 201 Created

{
  "token": "raw-session-token-64-chars",
  "user": {
    "id": 2,
    "username": "alex",
    "avatar": "",
    "display_name": null,
    "about": null,
    "custom_status": null,
    "status": "offline",
    "role_id": 4,
    "totp_enabled": false,
    "created_at": "2026-03-24T12:00:00Z"
  }
}

See GET /api/v1/auth/me for the full user-object field table.

Errors

Status Code Cause
400 INVALID_INPUT Missing username/password/invite_code, or weak password
400 INVALID_CREDENTIALS Bad invite code, expired/revoked invite, or duplicate username
403 FORBIDDEN Registration is closed or unavailable while server-wide 2FA is required
429 RATE_LIMITED Exceeded 3 registrations/minute from this IP
500 INTERNAL_ERROR Hashing failure, session creation failure, or DB error

POST /api/v1/auth/login

Authenticate with username and password.

Auth: None (public) Rate limit: 5 requests/minute per IP. After 10 failed attempts within 15 minutes from the same IP, the IP is locked out for 15 minutes. Independently, 10 failed attempts against the same username (from any IP) lock that account out for 15 minutes. Lockouts are persisted to the database and survive server restarts.

Request

{
  "username": "alex",
  "password": "MyStr0ng!Pass"
}

Response 200 OK

If the account does not have TOTP enabled:

{
  "token": "raw-session-token-64-chars",
  "requires_2fa": false,
  "user": {
    "id": 1,
    "username": "alex",
    "avatar": "/api/v1/files/uuid",
    "display_name": "Alex",
    "about": null,
    "custom_status": null,
    "status": "offline",
    "role_id": 4,
    "totp_enabled": false,
    "created_at": "2026-03-24T12:00:00Z"
  }
}

See GET /api/v1/auth/me for the full user-object field table.

If the account has TOTP enabled:

{
  "partial_token": "opaque-partial-token",
  "requires_2fa": true
}

Errors

Status Code Cause
400 INVALID_INPUT Missing username or password
401 UNAUTHORIZED Wrong username or password
403 FORBIDDEN Account is banned/suspended
429 RATE_LIMITED IP locked out after 10 consecutive failures (15 min cooldown)
500 INTERNAL_ERROR Session creation failure

POST /api/v1/auth/verify-totp

Complete a TOTP login challenge started by POST /api/v1/auth/login.

Auth: Required with the partial_token from the login response Rate limit: 10 requests/minute per IP, plus a 5-attempt budget per partial challenge

Request

{
  "code": "123456"
}

Response 200 OK

{
  "token": "raw-session-token-64-chars",
  "requires_2fa": false,
  "user": {
    "id": 1,
    "username": "alex",
    "avatar": "/api/v1/files/uuid",
    "display_name": "Alex",
    "about": null,
    "custom_status": null,
    "status": "offline",
    "role_id": 4,
    "totp_enabled": true,
    "created_at": "2026-03-24T12:00:00Z"
  }
}

See GET /api/v1/auth/me for the full user-object field table.

Errors

Status Code Cause
400 INVALID_INPUT Malformed request body
401 UNAUTHORIZED Missing/expired challenge, invalid TOTP code, or challenge consumed
500 INTERNAL_ERROR Session creation failure

GET /api/v1/auth/me

Get the current authenticated user's profile.

Auth: Required (Bearer token)

Response 200 OK

{
  "id": 1,
  "username": "alex",
  "avatar": "/api/v1/files/uuid",
  "display_name": "Alex",
  "about": "A short bio.",
  "custom_status": "building things",
  "status": "online",
  "role_id": 2,
  "totp_enabled": true,
  "created_at": "2026-03-24T12:00:00Z"
}

This is the canonical user object, also returned as user by register, login and the TOTP challenge.

Field Type Description
id int64 User ID
username string Unique handle; the name @mentions resolve against
avatar string Avatar URL (/api/v1/files/{id} after an upload, or an https:// URL), or empty string
display_name string|null Nickname rendered instead of username; null when unset
about string|null Profile bio, max 300 characters; null when unset
custom_status string|null Free-text status line, max 128 characters; null when unset. Set over WebSocket (presence_update), not over REST
status string One of: online, idle, dnd, invisible, offline. This is the caller's own true status, so invisible appears here; every payload describing this user to anyone else reports offline instead
role_id int64 Numeric role ID (1=Owner, 2=Admin, 3=Moderator, 4=Member)
totp_enabled bool Whether the user has a confirmed TOTP secret
created_at string ISO 8601 timestamp

POST /api/v1/auth/logout

Invalidate the current session token.

Auth: Required (Bearer token)

Response 204 No Content


DELETE /api/v1/auth/account

Permanently delete the authenticated user's account. Requires password confirmation.

Auth: Required (Bearer token) Rate limit: 5 requests/minute per IP. After 3 failed password attempts, the endpoint locks out for 15 minutes per user.

Request

{
  "password": "MyStr0ng!Pass"
}

Response 204 No Content

Account deleted successfully. All sessions, messages (soft-deleted), and associated data are cleaned up.

Errors

Status Code Cause
400 INVALID_INPUT Missing or incorrect password
403 FORBIDDEN Cannot delete the last admin account
429 RATE_LIMITED Locked out after 3 failed password attempts (15 min cooldown)
500 INTERNAL_ERROR Database error during deletion

POST /api/v1/users/me/totp/enable

Start TOTP enrollment for the authenticated user. The secret is not persisted until /api/v1/users/me/totp/confirm succeeds.

Auth: Required Rate limit: 5 requests/minute per IP

Request

{
  "password": "MyStr0ng!Pass"
}

Response 200 OK

{
  "qr_uri": "otpauth://totp/OwnCord:alex?...",
  "backup_codes": []
}

POST /api/v1/users/me/totp/confirm

Confirm a pending TOTP enrollment.

Auth: Required Rate limit: 5 requests/minute per IP

Request

{
  "password": "MyStr0ng!Pass",
  "code": "123456"
}

Response 204 No Content


DELETE /api/v1/users/me/totp

Disable TOTP for the authenticated user.

Auth: Required Rate limit: 5 requests/minute per IP

Request

{
  "password": "MyStr0ng!Pass"
}

Response 204 No Content


User Profile & Sessions

PATCH /api/v1/users/me

Update the authenticated user's profile. Broadcasts a user_update WebSocket message to all clients on success, carrying the full profile snapshot (the event replaces the client's copy rather than patching it).

Auth: Required Rate limit: 10 requests/minute

Request

{
  "username": "newname",
  "avatar": "https://example.com/pic.png",
  "display_name": "New Name",
  "about": "A short bio."
}
Field Rules
username Required. The unique handle; @mentions resolve against it.
avatar Optional. Must be an https:// URL (max 512 chars) or "" to clear. Upload a file instead with POST /api/v1/users/me/avatar.
display_name Optional, 132 characters. Shown instead of username everywhere; "" clears it and falls back to the username. Rejected if it contains control or invisible (bidi-override) characters.
about Optional, max 300 characters. "" clears it.
identity_public_key Optional, base64, max 128 characters. Publishes the client's long-term E2EE identity public key for voice TOFU pinning (see protocol.md, Voice End-to-End Encryption).

Omitting a field leaves it unchanged; sending "" clears the nullable ones. display_name and about are HTML-sanitized and trimmed server-side, and the length caps count characters, not bytes.

Response 200 OK

Returns the updated user object (same shape as GET /api/v1/auth/me).


POST /api/v1/users/me/avatar

Upload an avatar image and point the authenticated user's avatar at it. Broadcasts a user_update on success, exactly like the PATCH above.

The bytes are stored as an ordinary attachment with no channel, and users.avatar is set to /api/v1/files/{id}. That URL is what makes the picture readable: GET /api/v1/files/{id} normally serves an unlinked attachment only to its uploader, and additionally admits one that some user's avatar currently points at — so an avatar is readable by every authenticated user for exactly as long as it is in use, and stops being readable the moment it is replaced.

Not registered when the server has no working storage backend.

Auth: Required Rate limit: 5 uploads/minute per user

Request

multipart/form-data with a single file part.

Rule Value
Type image/png, image/jpeg or image/webp, sniffed from the file's own bytes (the client's Content-Type is ignored)
Size 1 MiB
Dimensions 1024x1024, measured from the sniffed image

GIF is refused (an animated avatar renders in every message row), and so is SVG — it is markup with script and external-fetch capability, and an avatar is rendered inline by definition. The server does not re-encode or crop; the client is expected to downscale and square-crop before uploading.

Response 201 Created

{
  "id": "5f2c...",
  "filename": "me.png",
  "size": 20481,
  "mime": "image/png",
  "url": "/api/v1/files/5f2c...",
  "width": 256,
  "height": 256
}

Errors

Status Code Cause
400 BAD_REQUEST Missing file part, wrong type, too large, or too many pixels
429 RATE_LIMITED Too many uploads

PUT /api/v1/users/me/password

Change the authenticated user's password. Verifies the old password, enforces password strength, and revokes all other sessions on success.

Auth: Required Rate limit: 5 requests/minute, plus a failed-confirmation lockout on repeated wrong old passwords

Request

{
  "old_password": "OldPass!1",
  "new_password": "NewStr0ng!Pass"
}

Response 204 No Content

Password changed and other sessions revoked. If the password change committed but revoking other sessions failed, the endpoint returns 200 OK with a warning body instead (the new password is in effect — do not retry with the old one).

Errors

Status Code Cause
400 INVALID_INPUT Weak new password, or new password equals old
403 FORBIDDEN Incorrect old password
429 RATE_LIMITED Too many attempts / lockout

GET /api/v1/users/me/sessions

List the authenticated user's active sessions.

Auth: Required

Response 200 OK

{
  "sessions": [
    {
      "id": 12,
      "device": "Mozilla/5.0 ...",
      "ip": "192.168.1.100",
      "created_at": "2026-07-01T10:00:00Z",
      "last_used": "2026-07-19T09:00:00Z",
      "is_current": true
    }
  ]
}

DELETE /api/v1/users/me/sessions/{id}

Revoke one of the authenticated user's sessions by ID.

Auth: Required

Response 204 No Content


Channel Endpoints

GET /api/v1/channels

List all channels the authenticated user has READ_MESSAGES permission for. DM channels are NOT included (use GET /api/v1/dms instead).

Auth: Required

Response 200 OK

[
  {
    "id": 1,
    "name": "general",
    "type": "text",
    "topic": "Welcome to the server!",
    "category": "Text Channels",
    "position": 0,
    "slow_mode": 0,
    "archived": false,
    "nsfw": false,
    "voice_max_users": 0,
    "voice_max_video": 0
  }
]
Field Type Description
id int64 Channel ID
name string Channel name
type string text, voice, or announcement (announcement channels are read like text but only MANAGE_MESSAGES holders can post)
topic string Channel topic/description
category string Category grouping
position int Sort order within category
slow_mode int Slow-mode delay in seconds (0 = disabled)
archived bool Whether the channel is archived
nsfw bool Age-restriction label. Stored and shipped only — the server applies no content behaviour to a flagged channel (see below)
voice_max_users int Voice capacity, 0 = unlimited. Enforced on join (CHANNEL_FULL)
voice_max_video int Simultaneous cameras/screen shares, 0 = unlimited. Enforced on publish (VIDEO_LIMIT)

The nsfw flag

nsfw is metadata and nothing else. The server stores it, ships it in ready and in the channel_create / channel_update broadcasts, and audits an operator flipping it — and does not filter content, check anyone's age, or restrict who may read or post in a flagged channel. Every consequence is the client's: the desktop client shows a one-time-per-session "may contain sensitive content" gate before rendering a flagged channel's messages (remembered in sessionStorage, so a new session asks again) and marks the channel in its sidebar. A client that ignores the field behaves exactly as it did before the field existed.


GET /api/v1/channels/{id}/messages

Paginated message history for a channel.

Auth: Required Permission: READ_MESSAGES on the channel (or DM participant membership)

Query Parameters

Param Type Default Range Description
before int64 0 (latest) >= 0 Cursor: return messages with ID less than this value
limit int 50 1-100 Number of messages to return

Response 200 OK

{
  "messages": [
    {
      "id": 1042,
      "channel_id": 5,
      "user": {
        "id": 1,
        "username": "alex",
        "avatar": "uuid.png"
      },
      "content": "Hello!",
      "reply_to": null,
      "attachments": [
        {
          "id": "file-uuid",
          "filename": "photo.jpg",
          "size": 204800,
          "mime_type": "image/jpeg",
          "url": "/api/v1/files/file-uuid",
          "width": 1920,
          "height": 1080
        }
      ],
      "reactions": [
        {
          "emoji": "\ud83d\udc4d",
          "count": 2,
          "me": true
        }
      ],
      "pinned": false,
      "edited_at": null,
      "deleted": false,
      "timestamp": "2026-03-14T10:30:00Z",
      "mentions": [7],
      "mentions_everyone": false
    }
  ],
  "has_more": true
}

mentions is the server-resolved list of mentioned user IDs (always present, empty when the message mentions nobody) and mentions_everyone reports an @everyone/@here that cleared the MENTION_EVERYONE permission. Both are resolved at send time and re-resolved on edit; an @word that matches no username, or an @everyone from a user without the bit, carries no mention semantics and stays plain text. The same two fields appear on pinned-message responses and on the WebSocket chat_message/chat_edited payloads.

Pagination

Use cursor-based pagination by passing the id of the last message as the before parameter:

GET /api/v1/channels/5/messages?before=1042&limit=50

When has_more is false, you have reached the beginning of the channel history.


GET /api/v1/channels/{id}/messages/around/{messageId}

The window of channel history centred on one message, for jumping to a message that is not in the client's loaded page — a search hit, a pinned entry, a reply reference, or an owncord://message/{channelId}/{messageId} permalink.

Auth: Required Permission: READ_MESSAGES on the channel (or DM participant membership) — the same gate as GET /messages

Query Parameters

Param Type Default Range Description
limit int 50 1-100 Total window size, centre included

Half the window sits before the centre and the remainder after it: limit=50 returns up to 25 older messages, the centre, and up to 24 newer ones. Near the start or end of a channel the window is simply shorter — it is not re-balanced toward the other side.

Response 200 OK

{
  "messages": [],
  "has_more_before": true,
  "has_more_after": true
}

messages holds the same message objects as GET /messages (user, attachments, reactions with the me flag, mentions, mentions_everyone), but is ordered oldest-first, not newest-first like the paginated history endpoint.

has_more_before / has_more_after report whether the channel holds further live history on each side of the returned window. A client that renders an around-window is detached from the live tail while has_more_after is true: newly broadcast messages belong below the window and are not part of it, so the client should offer a "jump to present" affordance that refetches the normal GET /messages tail.

Errors

Status Code When
400 BAD_REQUEST id or messageId is not a positive integer, or limit is not a positive integer
403 FORBIDDEN The channel exists but READ_MESSAGES is denied
404 NOT_FOUND The channel does not exist, the caller is not a participant of the DM, or the message does not live in this channel

Soft-deleted messages are 404 here, not an empty window: history omits deleted rows, so there is no row to centre on. Deleted messages are also excluded from the window itself, exactly as in GET /messages.


POST /api/v1/channels/{id}/messages/purge

Bulk soft-delete the newest messages in a channel.

Auth: Required Permission: READ_MESSAGES and MANAGE_MESSAGES on the channel (per-channel overrides apply)

Not available in DM channels — a DM has no MANAGE_MESSAGES gate, so those requests are rejected with 403.

Request Body

{
  "limit": 50,
  "before": 1042
}
Field Type Required Description
limit integer Yes How many messages to delete, 1--100. Values above 100 are clamped; 0 or negative is a 400.
before integer No Only delete messages with an id below this one. Omit or 0 to start from the newest.

Response 200 OK

{
  "channel_id": 5,
  "ids": [1042, 1041, 1040],
  "count": 3
}

ids is newest-first and may hold fewer than limit entries when the channel has less history; already-deleted messages are skipped. Deletion is soft: the rows stay as tombstones, exactly as with a single delete. A single chat_bulk_deleted WebSocket event is broadcast to the channel (not one chat_deleted per message), and one message_purge audit entry is written.

Rate limited to 5/sec per user.


GET /api/v1/channels/{id}/messages/{messageId}/reactions/{emoji}/users

List the users who reacted to a message with a specific emoji — the "who reacted" tooltip behind a reaction pill.

Auth: Required Permission: READ_MESSAGES on the channel (DM: participant)

The reactor list is a separate endpoint rather than user_ids inline on every reaction summary, so message payloads stay small: a busy channel carries dozens of pills per page and almost none of them are ever hovered.

{emoji} is a path segment and must be percent-encoded (👍%F0%9F%91%8D). The message must belong to {id}; a message in another channel is a 404, so the channel in the URL is always the one the permission check ran against.

Response 200 OK

{
  "users": [
    { "id": 3, "username": "alice", "avatar": "" },
    { "id": 7, "username": "bob", "avatar": "/api/v1/files/abc123" }
  ]
}

Ordered oldest reaction first and capped at 100 reactors — the list is for a tooltip, not an audit. users is always an array ([] when nobody used that emoji, which is also the answer for an emoji that does not exist). avatar is "" when the user has none.

Status Error When
400 BAD_REQUEST Non-positive id/messageId, or an empty / over-32-rune / control-character emoji
403 FORBIDDEN No READ_MESSAGES on the channel
404 NOT_FOUND Channel or message not found, the message lives in another channel, or a DM the caller is not in

GET /api/v1/channels/{id}/pins

Get all pinned messages for a channel.

Auth: Required Permission: READ_MESSAGES on the channel

Response 200 OK

Returns { "messages": [...], "has_more": false }. has_more is always false for pins (all pinned messages are returned at once).


POST /api/v1/channels/{id}/pins/{messageId}

Pin a message in a channel.

Auth: Required Permission: MANAGE_MESSAGES on the channel

Response 204 No Content


DELETE /api/v1/channels/{id}/pins/{messageId}

Unpin a message from a channel.

Auth: Required Permission: MANAGE_MESSAGES on the channel

Response 204 No Content


GET /api/v1/search

Full-text search across messages in channels the user can read. Uses SQLite FTS5 for matching.

Auth: Required Rate limit: 30 requests/minute

Query Parameters

Param Type Default Range Description
q string (required) non-empty Search query (FTS5 syntax)
channel_id int64 (all channels) > 0 Restrict search to a single channel
limit int 50 1-100 Maximum results to return

Response 200 OK

{
  "results": [
    {
      "message_id": 1042,
      "channel_id": 5,
      "channel_name": "general",
      "user": {
        "id": 1,
        "username": "alex"
      },
      "content": "...matched text...",
      "timestamp": "2026-03-14T10:30:00Z",
      "mentions": [7],
      "mentions_everyone": false
    }
  ]
}

GIFs

The server proxies the Klipy GIF API so the provider API key stays server-side. Clients never contact api.klipy.com — a key shipped in the desktop bundle would be public by construction. The key is configured as gif.api_key (see Server Configuration).

Default-off contract: with no key configured, both endpoints return 503 with error code GIF_DISABLED. Clients must treat that as "this server does not have GIFs" and hide/disable the GIF affordance — not retry.

The media URLs in the response point at Klipy's CDN; the client still validates them against its klipy.com CDN allowlist before rendering.

GET /api/v1/gif/search

Auth: Required Rate limit: 30 requests/minute (dedicated per-IP bucket)

Query Parameters

Param Type Default Range Description
q string (required) 1-100 chars Search term
limit int 20 1-50 Maximum results to return

Response 200 OK

{
  "results": [
    {
      "id": "abc123",
      "title": "happy cat",
      "media_formats": {
        "tinygif": { "url": "https://media.klipy.com/abc123_tiny.gif" },
        "gif": { "url": "https://media.klipy.com/abc123.gif" }
      }
    }
  ]
}

Only id, title, and the two media_formats URLs are forwarded. Every other field the upstream returns is dropped, so an upstream that echoed the API key could not leak it to clients. Results missing either format are omitted.

Errors

Status Code When
400 INVALID_INPUT Missing/blank q, q over 100 chars, or limit outside 1-50
401 UNAUTHORIZED No valid session (checked before the disabled check)
429 RATE_LIMITED Over 30 requests/minute
502 BAD_GATEWAY Upstream error, timeout, or unparseable response
503 GIF_DISABLED gif.api_key is not configured

GET /api/v1/gif/trending

Same auth, rate limit, response shape, and error codes as /api/v1/gif/search, minus the q parameter.

Param Type Default Range Description
limit int 20 1-50 Maximum results to return

Direct Messages

DM channels use participant-based authorization rather than role-based permissions.

POST /api/v1/dms

Create or retrieve a 1-on-1 DM channel with another user. If a DM channel already exists, it is returned and re-opened.

Auth: Required

Request

{
  "recipient_id": 2
}

Response 200 OK (existing channel) or 201 Created (new channel)

{
  "channel_id": 100,
  "recipient": {
    "id": 2,
    "username": "jordan",
    "avatar": "uuid.png",
    "status": "online"
  },
  "created": false
}

On a newly created channel (201, "created": true) the recipient also receives a dm_channel_open. Re-opening an existing DM (200) emits nothing — it only touches the caller's own open state. The creator is not sent the event on either path; it learns the channel from the response body above.


GET /api/v1/dms

List all open DM channels for the authenticated user, ordered by most recent activity.

Auth: Required

Response 200 OK

{
  "dm_channels": [
    {
      "channel_id": 100,
      "name": "Lunch crew",
      "is_group": true,
      "recipient": {
        "id": 2,
        "username": "jordan",
        "display_name": "Jo",
        "avatar": "/api/v1/files/uuid",
        "status": "online"
      },
      "recipients": [
        {
          "id": 2,
          "username": "jordan",
          "display_name": "Jo",
          "avatar": "/api/v1/files/uuid",
          "status": "online"
        },
        {
          "id": 3,
          "username": "sam",
          "display_name": "",
          "avatar": "",
          "status": "idle"
        }
      ],
      "last_message_id": 5042,
      "last_message": "Hey, how's it going?",
      "last_message_at": "2026-03-28T14:30:00Z",
      "unread_count": 3
    }
  ]
}
Field Description
recipient The other participant of a 1:1 DM. Backward compatibility only — for a group it carries the first of recipients.
recipients Every participant except the caller. What group-aware clients read.
name Optional group name; "" for a 1:1 DM and for an unnamed group.
is_group True for a group DM. Stored, not derived from the live participant count.

status is viewer-adjusted: an invisible participant reads as offline.


POST /api/v1/dms/group

Create a group DM between the caller and 28 other users (310 total).

Unlike POST /api/v1/dms this always creates: the same set of people may reasonably want more than one group, so there is no "the group for these users" to look up.

Blocks are enforced in both directions, per recipient — a user may neither pull someone they have blocked into a room with them nor use a group to reach someone who has blocked them. The check is creation-time only; see docs/protocol.md § DM Authorization for why sending into a group is not block-checked.

Auth: Required

Request

{
  "recipient_ids": [2, 3],
  "name": "Lunch crew"
}
Field Type Required Description
recipient_ids int[] Yes 28 other users. De-duplicated; the caller is dropped if named.
name string No Group name, ≤ 100 characters. HTML-stripped. Omit or "" for an unnamed group.

Response 201 Created

The same DM summary shape GET /api/v1/dms returns, from the creator's seat. Every participant — the creator included — also receives a dm_channel_open.

Errors

Status Code Reason
400 BAD_REQUEST Fewer than 2 or more than 8 recipients, or a name over 100 characters
403 FORBIDDEN A recipient is blocked by, or has blocked, the caller
404 NOT_FOUND A recipient does not exist

PATCH /api/v1/dms/{channelId}

Set or clear a group DM's name.

Any participant may rename it. That is Discord's rule and the only one that works here: a group DM has no owner column and no roles, so "who may rename" has exactly one answer that does not require inventing an ownership model. A 1:1 DM refuses — its name is who is in it.

Auth: Required (participant)

Request

{ "name": "Lunch crew" }

An empty name clears it, and the group falls back to listing its members.

Response 200 OK

The DM summary shape, from the caller's seat. Every participant also receives a dm_channel_open carrying the new name.

Errors

Status Code Reason
400 BAD_REQUEST The channel is a 1:1 DM, or the name exceeds 100 characters
404 NOT_FOUND Not a participant of this DM

DELETE /api/v1/dms/{channelId}

Remove a DM from the caller's sidebar. What that means depends on the kind of DM, and the route is shared because the gesture is shared:

  • 1:1 DM — a hide. The channel and messages remain, the caller remains a participant, and a new message from either side re-opens it.
  • Group DM — a leave. The caller comes out of dm_participants, stops receiving the group's messages, and cannot return unaided. When the last participant leaves, the channel row is deleted (a DM nobody is in is reachable by nobody, and its messages cascade off the channel).

The caller receives dm_channel_close; after a group leave the remaining participants receive a fresh dm_channel_open with the new membership.

Auth: Required (participant)

Response 204 No Content

Errors

Status Code Reason
404 NOT_FOUND Not a participant of this DM

User Blocks

Blocking a user prevents DM creation and messaging in both directions (backed by the user_blocks table).

GET /api/v1/blocks

List the IDs of users the authenticated user has blocked.

Auth: Required

Response 200 OK

{ "blocked_user_ids": [2, 7] }

PUT /api/v1/blocks/{userId}

Block a user.

Auth: Required

Response 200 OK

{ "message": "user blocked" }

DELETE /api/v1/blocks/{userId}

Unblock a user.

Auth: Required


Invite Endpoints

All invite endpoints require authentication and the MANAGE_INVITES permission.

POST /api/v1/invites

Create a new invite code.

Auth: Required Permission: MANAGE_INVITES

Request

{
  "max_uses": 5,
  "expires_in_hours": 48
}

Both fields are optional. An empty body creates an invite with unlimited uses and no expiry.

Response 201 Created

{
  "id": 1,
  "code": "abc123def",
  "max_uses": 5,
  "uses": 0,
  "expires_at": "2026-03-30T10:30:00Z",
  "revoked": false,
  "created_at": "2026-03-28T10:30:00Z"
}

GET /api/v1/invites

List all invites (active, expired, and revoked).

Auth: Required Permission: MANAGE_INVITES

Response 200 OK

Returns a JSON array of invite objects.


DELETE /api/v1/invites/{code}

Revoke an invite by its code string.

Auth: Required Permission: MANAGE_INVITES

Response 204 No Content


File Upload and Serving

POST /api/v1/uploads

Upload a file as multipart form data.

Auth: Required Rate limit: 10 requests/minute Body size limit: 100 MiB Content-Type: multipart/form-data

Files are validated against blocked magic bytes (PE executables, ELF binaries, Mach-O binaries, shell scripts). Files are stored with UUID filenames.

Response 201 Created

{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "filename": "photo.jpg",
  "size": 204800,
  "mime": "image/jpeg",
  "url": "/api/v1/files/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "width": 1920,
  "height": 1080
}

width and height are only present for image files.


GET /api/v1/files/{id}

Serve a previously uploaded file by its UUID.

Auth: Required (Bearer token) — downloads are access-controlled Caching: Cache-Control: private, no-cache (never stored by shared/proxy caches; browsers must revalidate)

Supports HTTP range requests and conditional requests. MIME types that could execute under the app origin (HTML, SVG, XML, PDF) are served with Content-Disposition: attachment to force download.


Custom Emoji

Server-wide custom emoji, usable as :shortcode: in message content and as reaction strings.

Permission model. Reading the set is open to any authenticated member — an emoji nobody can render is not an emoji, and the set is server-wide with no per-channel scope to leak. Adding and removing require MANAGE_SERVER.

That is a deliberate reuse rather than a new permission bit: a bit is a schema-visible, forever decision, and "who may change server-wide branding" is exactly what MANAGE_SERVER already answers for the server name, icon and settings. There is no MANAGE_EMOJI.

GET /api/v1/emoji

List every custom emoji, ordered by shortcode.

Auth: Required

Response 200 OK

[
  { "id": 3, "shortcode": "wave", "url": "/api/v1/emoji/3/image" },
  { "id": 7, "shortcode": "party_blob", "url": "/api/v1/emoji/7/image" }
]

url is server-relative and authenticated — see GET /api/v1/emoji/{id}/image.


POST /api/v1/emoji

Upload one custom emoji as multipart form data.

Auth: Required — MANAGE_SERVER Rate limit: 10 requests/minute per user Body size limit: 1 MiB (the image itself is capped at 512 KiB) Content-Type: multipart/form-data

Field Type Notes
shortcode string [a-z0-9_]{2,32}; surrounding colons are stripped and the value is lowercased before validation, so :WAVE: and wave are the same shortcode
file file PNG, JPEG, GIF or WebP

Validation, in the order it is applied — the permission check runs before the multipart body is read, so a member without the bit never causes a spool to disk:

  1. MANAGE_SERVER, then the rate limit, then the shortcode format.
  2. At most 512 KiB of image bytes.
  3. The MIME type is sniffed from the file's own bytes, never taken from the client's part header. Only image/png, image/jpeg, image/gif and image/webp are accepted. SVG is refused outright: it is markup with script and external-fetch capability, and an emoji is by definition rendered inline.
  4. Dimensions are re-read from the sniffed image (WebP headers are parsed directly, since the standard library has no WebP decoder) and must be at most 128 x 128.
  5. Shortcodes are unique case-insensitively; a collision is 409 CONFLICT.
  6. A server holds at most 200 emoji.

On success the full set is broadcast as emoji_update (see protocol.md), so every connected client converges without a reconnect.

Response 201 Created

{ "id": 3, "shortcode": "wave", "url": "/api/v1/emoji/3/image" }

Errors

Status Code Cause
400 BAD_REQUEST bad shortcode, wrong format, too large, too big
403 FORBIDDEN caller lacks MANAGE_SERVER
409 CONFLICT an emoji with that shortcode already exists
429 RATE_LIMITED upload rate limit exceeded

GET /api/v1/emoji/{id}/image

Serve one emoji's image bytes.

Auth: Required (Bearer token) Caching: Cache-Control: private, max-age=86400, immutable

Authenticated rather than public so an emoji cannot be used as an unauthenticated tracking pixel hosted on someone else's server. There is no per-channel ACL to apply — emoji are server-wide by construction, so authentication is the whole check. An emoji's bytes never change for a given id (a replacement is a new row), which is what lets the response be cached hard. Unknown ids answer 404.


DELETE /api/v1/emoji/{id}

Delete one custom emoji and unlink its stored file.

Auth: Required — MANAGE_SERVER

Messages and reactions that used the shortcode fall back to rendering the literal :shortcode: text. Broadcasts emoji_update on success.

Response 204 No Content

Errors

Status Code Cause
400 BAD_REQUEST id is not a positive integer
403 FORBIDDEN caller lacks MANAGE_SERVER
404 NOT_FOUND no emoji with that id

Health Check

GET /health

GET /api/v1/health

Public health check endpoint, no authentication required. The server version is deliberately not exposed here (anti-fingerprinting hardening, C-2).

{
  "status": "ok",
  "uptime": 86400,
  "online_users": 3
}

When a health probe fails, the endpoint answers 503 with "status": "degraded" and a reason field naming the failing subsystem — "hub" (WS dispatch loop dead), "database", or "disk":

{ "status": "degraded", "reason": "database", "uptime": 86400, "online_users": 3 }

Server Info

GET /api/v1/info

Returns the server name. The version field was removed from this unauthenticated endpoint (anti-fingerprinting hardening, C-2).

Auth: None

{
  "name": "My OwnCord Server"
}

Metrics

GET /api/v1/metrics

Runtime server metrics. IP-restricted (not token-based): allowed CIDRs come from server.metrics_allowed_cidrs, falling back to server.admin_allowed_cidrs when unset — set the dedicated key to admit a scraper without widening the admin perimeter.

Auth: IP restriction (metrics CIDRs, admin fallback)

{
  "uptime": "2h30m15s",
  "uptime_seconds": 9015.0,
  "goroutines": 42,
  "heap_alloc_mb": 12.5,
  "heap_sys_mb": 24.0,
  "num_gc": 156,
  "connected_users": 8,
  "voice_sessions": 2,
  "broadcast_drops": 0,
  "livekit_healthy": true,
  "reconnect_tier_buffer": 120,
  "reconnect_tier_db": 4,
  "reconnect_tier_full": 1,
  "backpressure_queue_disconnects": 0,
  "backpressure_high_fallbacks": 0,
  "backpressure_low_drops": 17,
  "ws_conn_rejects": 0,
  "disk_free_mb": 51200.5,
  "db_writer_wait_count": 3,
  "db_writer_wait_seconds": 0.021,
  "perm_cache_hits": 5120,
  "perm_cache_misses": 84,
  "event_persister": {
    "persisted": 4021,
    "dropped": 0,
    "flushes": 311,
    "errors": 0
  }
}

voice_sessions is the number of active voice connections. broadcast_drops is the cumulative count of events dropped because the hub-wide broadcast queue was full — sequenced events lost before delivery, worth alerting on if it ever grows. Per-client send-queue pressure is reported separately: backpressure_queue_disconnects (clients disconnected to force a replay-recovering reconnect), backpressure_high_fallbacks (high-priority sends that fell back to the normal queue), and backpressure_low_drops (typing/presence messages silently dropped — safe to lose, but a growth trend means clients are draining too slowly). reconnect_tier_* counts resume attempts served from the in-memory ring buffer, the persisted event log, and full-resync fallback; a rising full share means the replay budget is too small for observed disconnect gaps. db_writer_wait_count/_seconds accumulate time requests spent queueing for SQLite's single write connection — the most direct saturation signal for the write path. perm_cache_* report permission-cache effectiveness (a miss is any lookup that repopulated from the database). ws_conn_rejects counts upgrades refused by the server.max_ws_connections cap, and disk_free_mb is free space on the data volume (omitted when the platform can't report it). livekit_healthy is omitted when no LiveKit health check is wired; event_persister is omitted when event persistence is disabled.

GET /metrics (Prometheus)

A Prometheus text-format exporter is mounted at /metrics only when the server was built with -tags otel and telemetry.exporter is set to prometheus. It is admin-IP-restricted like the JSON endpoint. In the default build the route does not exist (404).


Admin API Authorization

The admin panel API lives under /admin/api (not /api/v1) and takes the same Authorization: Bearer {token} header — a login session or an API token, which inherits its owning user's role.

Authorization is two-layered:

  1. Perimeter. The request is rejected with 403 FORBIDDEN unless the principal's role holds at least one bit of permissions.AdminPerimeter (ADMINISTRATOR, MANAGE_CHANNELS, MANAGE_ROLES, MANAGE_SERVER, VIEW_AUDIT_LOG, KICK_MEMBERS, BAN_MEMBERS, MUTE_MEMBERS). Banned users are rejected here even while their session is still valid.
  2. Per-route bit. Route groups then require the specific permission below. ADMINISTRATOR bypasses every one of them; owner-only routes gate on role position (>= 100) instead of on a bit, so not even ADMINISTRATOR substitutes for being the owner.
Route Requires
GET /admin/api/me perimeter only
GET /admin/api/stats perimeter only
GET /admin/api/users perimeter only
PATCH /admin/api/users/{id} perimeter; BAN_MEMBERS for banned, MANAGE_ROLES for role_id (checked in the service)
DELETE /admin/api/users/{id}/sessions KICK_MEMBERS
GET/POST/PATCH/DELETE /admin/api/channels… (incl. /permissions and /user-permissions) MANAGE_CHANNELS
GET/POST/PATCH/DELETE /admin/api/roles… (incl. /roles/reorder) MANAGE_ROLES
GET /admin/api/audit-log VIEW_AUDIT_LOG
GET/PATCH /admin/api/settings MANAGE_SERVER
POST /admin/api/logs/ticket, GET /admin/api/logs/stream ADMINISTRATOR
/api/v1/admin/plugins… ADMINISTRATOR
/admin/api/tokens…, /admin/api/backup(s)…, /admin/api/updates… Owner role (position 100)

Moderation routes additionally enforce the role hierarchy: the actor must strictly outrank the target (actor.position > target.position), and a role assignment may only grant a role positioned strictly below the actor's own — so an admin cannot promote anyone to Owner, and a moderator cannot demote an admin. Violations return 403 FORBIDDEN.

GET /admin/api/me

Describes the calling principal so a panel can hide what the role cannot use. Every route still re-checks its bit server-side.

Response 200 OK

{
  "id": 7,
  "username": "mod",
  "role_id": 3,
  "role_name": "Moderator",
  "role_position": 60,
  "permissions": 1048575,
  "is_owner": false
}

First-Run Setup

GET /admin/api/setup/status

Reports whether initial setup is needed (no users exist yet).

Auth: None (public). After the first user exists, the response reveals nothing about the configuration.

Response 200 OK

{
  "needs_setup": true,
  "defaults": {
    "server_name": "OwnCord",
    "motd": "Welcome!",
    "registration_open": false,
    "port": 8443,
    "tls_mode": "self-signed",
    "tls_domain": "",
    "upload_max_size_mb": 100,
    "voice_quality": "medium",
    "voice_auto_download": true
  }
}

defaults (wizard prefill from the running config and settings table) is present only while needs_setup is true.


POST /admin/api/setup

Create the first (Owner) account, optionally applying first-run wizard configuration. Only functional while no users exist; afterwards it returns an error.

Auth: None (public) Rate limit: 5 requests/minute per IP

Request

{
  "username": "owner",
  "password": "MyStr0ng!Pass",
  "wizard": {
    "server_name": "My Server",
    "motd": "Welcome!",
    "registration_open": false,
    "port": 8443,
    "tls_mode": "self-signed",
    "tls_domain": "",
    "upload_max_size_mb": 100,
    "voice_quality": "medium",
    "voice_auto_download": true
  }
}

All wizard fields are optional; server_name, motd and registration_open are stored in the settings table (live), the rest are written back to config.yaml (consumed at startup).

Response 200 OK

{
  "token": "raw-session-token",
  "user_id": 1,
  "username": "owner",
  "invite_code": "abc123def",
  "restart_required": false,
  "restart_url": "",
  "warnings": []
}

restart_required is true when wizard values that are only read at startup (port, TLS) differ from the running config; the server restarts itself right after responding, and restart_url is where the admin panel will be reachable afterwards. warnings lists non-fatal problems (e.g. config.yaml not writable) — the account exists whenever this response is returned.


Server Stats & User Administration

GET /admin/api/stats

Aggregate counts for the admin dashboard.

Auth: Admin perimeter

Response 200 OK

{
  "user_count": 12,
  "message_count": 4821,
  "channel_count": 9,
  "invite_count": 2,
  "db_size_bytes": 1048576,
  "online_count": 3
}

GET /admin/api/users

List all users with role and ban state.

Auth: Admin perimeter Query params: limit (default 50, min 1), offset (default 0)

Response 200 OK

Array of:

Field Type Notes
id int
username string
avatar string? omitted when unset
role_id int
role_name string
status string presence status
created_at string
last_seen string? omitted when never seen
banned bool
ban_reason string? omitted when unset
ban_expires string? omitted for permanent bans

Password hashes and TOTP secrets are never included.


PATCH /admin/api/users/{id}

Change a user's role and/or ban state. Both actions route through the moderation service, which enforces the required bit (MANAGE_ROLES for role_id, BAN_MEMBERS for banned), the role hierarchy, and writes the audit row.

Auth: Admin perimeter + per-action bit (see above)

Request

{
  "role_id": 3,
  "banned": true,
  "ban_reason": "spam",
  "ban_duration_hours": 24
}

All fields optional. ban_duration_hours makes the ban temporary (18760; omitted or 0 = permanent) and is only meaningful with banned: true.

Response 200 OK -- the updated user (same shape as the list entry).

Errors

Status Code Cause
400 BAD_REQUEST Invalid id/body, ban_duration_hours out of range, or attempting to modify your own account
403 FORBIDDEN Missing bit, or the actor does not outrank the target
404 NOT_FOUND User not found

DELETE /admin/api/users/{id}/sessions

Force-logout: revoke every session of the target user. The hierarchy rule (actor outranks target) is enforced in the moderation service and the action is audited.

Auth: KICK_MEMBERS

Response 204 No Content


Audit Log

GET /admin/api/audit-log

Read the audit trail, newest first.

Auth: VIEW_AUDIT_LOG Query params: limit (default 50, min 1), offset (default 0)

Response 200 OK

Array of:

{
  "id": 991,
  "actor_id": 1,
  "actor_name": "owner",
  "action": "user_ban",
  "target_type": "user",
  "target_id": 7,
  "detail": "spam",
  "created_at": "2026-08-04T12:00:00Z"
}

Server Settings

GET /admin/api/settings

Auth: MANAGE_SERVER

Returns the settings table as a flat string map, e.g.:

{
  "server_name": "My Server",
  "motd": "Welcome!",
  "registration_open": "1",
  "require_2fa": "0"
}

PATCH /admin/api/settings

Update settings. Keys are validated against a whitelist before anything is written, and all updates are applied in one transaction; each change is audited as setting_change.

Auth: MANAGE_SERVER

Request

A flat map of key → string value. Allowed keys: server_name, server_icon, motd, max_upload_bytes, voice_quality, require_2fa, registration_open, backup_schedule, backup_retention. Boolean settings accept 1/0/true/false and are normalized to 1/0.

backup_schedule (off/daily/weekly) and backup_retention (days) are enforced by the server's maintenance loop — see the Backup Strategy section of docs/deployment.md for the exact semantics.

Three keys are accepted and stored but have no runtime effect: server_icon (reserved for a future release), max_upload_bytes (the real limit is upload.max_size_mb in config.yaml, applied at startup), and voice_quality (the real setting is voice.quality in config.yaml). The admin panel shows them read-only for this reason.

Enabling require_2fa is refused unless registration is closed and every user has TOTP enabled.

Response 200 OK -- the full settings map after the update.

Errors

Status Code Cause
400 BAD_REQUEST Unknown key, invalid boolean, or require_2fa preconditions not met

API Tokens

Owner-only: minting a long-lived bearer credential over the network is the one admin action that, via a hijacked session, would outlive a password change and bulk logout (API tokens deliberately live outside the session table). These routes are the HTTP equivalent of the server token create|list|revoke CLI.

GET /admin/api/tokens

Auth: Owner role

Response 200 OK

Array of:

{
  "id": 1,
  "user_id": 1,
  "username": "owner",
  "label": "ci-bot",
  "created_at": "2026-08-01T10:00:00Z",
  "last_used": null,
  "expires_at": null,
  "revoked_at": null
}

Token hashes are never returned.


POST /admin/api/tokens

Auth: Owner role

Request

{
  "label": "ci-bot",
  "username": "",
  "expires_hours": 0
}

label is required. Empty username binds the token to the owner account; expires_hours: 0 means never expires.

Response 201 Created

{
  "id": 2,
  "token": "raw-api-token",
  "label": "ci-bot",
  "user": "owner"
}

The raw token is shown exactly once and is never recoverable.


DELETE /admin/api/tokens/{id}

Auth: Owner role

Response 204 No Content

404 NOT_FOUND if there is no active token with that id.


Backups

All backup routes are Owner-only. Backups are SQLite snapshots (VACUUM INTO) stored under data/backups/.

POST /admin/api/backup

Create a backup named chatserver_<UTC timestamp>.db.

Auth: Owner role

Response 200 OK

{
  "path": "chatserver_20260804_120000.db",
  "created": "20260804_120000"
}

GET /admin/api/backups

List backups, newest first.

Auth: Owner role

Response 200 OK

[{ "name": "chatserver_20260804_120000.db", "size": 1048576, "date": "2026-08-04T12:00:00Z" }]

DELETE /admin/api/backups/{name}

Auth: Owner role

name is validated against path traversal. Returns 204 No Content, or 404 NOT_FOUND if the file does not exist.


POST /admin/api/backups/{name}/restore

Restore the database from a backup. The server first writes a pre_restore_<timestamp>.db safety backup (the restore is aborted if that fails), broadcasts a server_restart to connected clients, checkpoints and closes the database, copies the backup over it, responds, and then restarts itself.

Auth: Owner role

Response 200 OK

{
  "message": "database restored — server restarting",
  "backup": "chatserver_20260804_120000.db"
}

Server Updates

Owner-only self-update from GitHub Releases (minisign/Ed25519-verified; see docs/security.md).

GET /admin/api/updates

Auth: Owner role

Response 200 OK

{
  "current": "v1.2.0-alpha.4",
  "latest": "v1.2.0",
  "update_available": true,
  "required_assets_present": true,
  "release_url": "…",
  "download_url": "…",
  "checksum_url": "…",
  "signature_url": "…",
  "manifest_url": "…",
  "manifest_signature_url": "…",
  "release_notes": "…",
  "can_apply": true
}

can_apply is false in container deployments (detected via OWNCORD_CONTAINER, which the shipped Dockerfile sets, or the engine marker files): checking still works, but POST /updates/apply will refuse — the admin SPA replaces the apply button with an image-upgrade note.

Errors

Status Code Cause
503 UPDATE_UNAVAILABLE Update checking is not configured
502 UPDATE_CHECK_FAILED GitHub API failure

POST /admin/api/updates/apply

Download, verify and apply the latest release. On success the server responds first, then broadcasts a restart notice, swaps the binary (with staged-hash re-verification against TOCTOU swaps), spawns the new process and shuts down.

Auth: Owner role

Response 200 OK

{ "status": "applying", "version": "v1.2.0" }

Errors

Status Code Cause
503 CONTAINER_DEPLOYMENT Container deployment — the binary is image content; upgrade by pulling the new image (opt back in with OWNCORD_CONTAINER=0 if the binary is bind-mounted)
503 UPDATE_UNAVAILABLE Update checking is not configured
409 RESTART_PENDING A restart from an earlier apply/restore is already pending
409 UPDATE_IN_PROGRESS Another restart-sensitive operation (update apply or backup restore) is running
409 NO_UPDATE Already up to date
502 UPDATE_CHECK_FAILED / MISSING_ASSETS / DOWNLOAD_FAILED Check, asset or download/verification failure

Server Logs (SSE)

Streaming the server log requires two steps because EventSource cannot send an Authorization header.

POST /admin/api/logs/ticket

Issue a single-use ticket (30 s TTL) bound to the calling bearer credential.

Auth: ADMINISTRATOR

Response 200 OK

{ "ticket": "64-hex-chars" }

GET /admin/api/logs/stream?ticket={ticket}

Server-Sent Events stream of structured log records: on connect the in-memory ring buffer (capacity 2000) is replayed as backfill, then new entries stream live, with a keepalive every 15 s. The ticket is consumed on connect; the ADMINISTRATOR bit is re-checked throughout the stream, and revoking the underlying session or API token (or banning the user) mid-stream cuts it.

Auth: single-use ticket (from POST /admin/api/logs/ticket)

Each event's data is one JSON record:

{ "ts": "2026-08-04T12:00:00Z", "level": "INFO", "msg": "…", "source": "…", "attrs": "…" }

Role Management

Create, edit, delete and reorder roles. The whole group requires MANAGE_ROLES; RoleService then enforces the hierarchy rules below, so a principal that clears the bit still cannot escalate through it.

Rules, all measured against the actor's role position:

  • You may only create, edit, delete or reorder roles positioned strictly below your own. Equal rank is refused too, so a role cannot rewrite itself. Nothing sits above position 100, which makes the seeded Owner role immutable and undeletable for everyone, owner included.
  • You may never grant a permission bit your own role lacks. Removing one is allowed — de-escalation is always safe. ADMINISTRATOR bypasses this check entirely (it is what lets the owner hand out anything).
  • The default role (is_default = 1) cannot be deleted: every member falls back to it.
  • Deleting a role moves its members onto the default role in one UPDATE, drops the role's channel_overrides rows, invalidates the moved members' cached permissions, and broadcasts a member_update per member.
  • Names are unique case-insensitively (migration 023), matching the case-insensitive lookup the desktop client does. Max 32 characters.
  • Colors are #rgb or #rrggbb, normalized to uppercase. "" clears the color. Anything else is 400.
  • Unknown permission bits are masked off rather than rejected.
  • Every mutation writes an audit row (role_create, role_update, role_delete, role_reorder) and broadcasts roles_update (see docs/protocol.md) carrying the full new list.

GET /admin/api/roles

Roles ordered by position descending, each with its member count.

Response 200 OK

[
  {
    "id": 1,
    "name": "Owner",
    "color": "#E74C3C",
    "permissions": 2147483647,
    "position": 100,
    "is_default": false,
    "member_count": 1
  },
  {
    "id": 4,
    "name": "Member",
    "color": null,
    "permissions": 1635,
    "position": 40,
    "is_default": true,
    "member_count": 12
  }
]

POST /admin/api/roles

Request

{
  "name": "Helper",
  "color": "#5865F2",
  "permissions": 3,
  "position": 50
}
Field Type Required Description
name string Yes 132 characters, unique case-insensitively
color string No #rgb/#rrggbb, or "" for none
permissions integer No Bitfield; defaults to 0
position integer No Defaults to one below the actor's own position

Response 201 Created

The created role (id, name, color, permissions, position, is_default — always false; the default role is seeded, never created).

Errors

Status Code When
400 BAD_REQUEST Missing/blank/over-long name, duplicate name, bad color, negative position
403 FORBIDDEN Missing MANAGE_ROLES, position at or above your own, or a permission bit you lack

PATCH /admin/api/roles/{id}

Partial update — every field is optional and an omitted one is left alone. Same body and same errors as POST, plus 404 NOT_FOUND for a missing role. Editing a role at or above your own position is 403.

A permission change additionally invalidates the cached permissions of that role's members and re-syncs their channel visibility (the server sends targeted channel_create/channel_delete), because a role's mask is the base every channel's effective permission derives from.

Response 200 OK

The updated role.

DELETE /admin/api/roles/{id}

Response 204 No Content

Errors

Status Code When
400 BAD_REQUEST The role is the default role, or is the seeded Owner role
403 FORBIDDEN Missing MANAGE_ROLES, or the role is at or above your own position
404 NOT_FOUND No such role

PATCH /admin/api/roles/reorder

Request

{ "role_ids": [2, 9, 3, 4] }

role_ids is highest-rank-first and must name exactly the set of roles strictly below your own position — a partial list is refused rather than silently leaving the omitted roles at positions that now collide. Positions are normalized to N…1, so they stay unique, stay below the actor, and never collide with the untouched roles above.

Response 200 OK

The full role list after the reorder, position descending.

Errors

Status Code When
400 BAD_REQUEST Wrong number of ids, or a duplicate id
403 FORBIDDEN Missing MANAGE_ROLES, or an id that is unknown or not below your rank

Channel Management (admin)

POST /admin/api/channels takes {name, type, category, topic, position}; PATCH /admin/api/channels/{id} takes {name, topic, category, slow_mode, position, archived, nsfw, voice_max_users, voice_max_video} and seeds every omitted field from the current row, so a partial body is safe.

The numeric fields are bounds-checked before anything is written, and an out-of-range value is refused with 400 INVALID_INPUT rather than clamped — a caller that sent -1 meant something, and storing 0 would hide it. A refused body writes nothing at all:

Field Range Meaning
slow_mode 0…21600 Cooldown in seconds; 0 = off (6-hour ceiling, as Discord)
voice_max_users 0…99 Voice capacity; 0 = unlimited
voice_max_video 0…99 Simultaneous cameras/screen shares; 0 = unlimited

nsfw is a bool and is stored, broadcast and audited only — the server applies no content behaviour to a flagged channel (see GET /api/v1/channels). The audit detail names the transition: updated #foo (marked NSFW) / (unmarked NSFW), and plain updated #foo when the flag did not move.

The voice limits are stored on a channel of any type but are only meaningful on a voice one; the desktop client offers them for voice channels alone and omits the keys entirely elsewhere, so a text-channel edit cannot wipe limits the row happens to hold.

type must be text, voice or announcement (400 INVALID_INPUT otherwise). category constrains nothing. Categories are free text and a channel of any type may live under any of them — a voice channel under "Gaming", a text channel under "Voice Channels". Grouping is a display concern: the desktop client groups by whatever category a channel carries and falls back to a synthetic "Voice" group only for voice channels with no category at all. (Before phase 5 the server refused any non-voice channel under a category literally named "Voice Channels", and any voice channel outside it.)

PATCH accepts category, so moving a channel between categories is an edit rather than a delete-and-recreate. An empty string makes it uncategorized.


Channel Permission Overrides

Two override layers per channel, both gated on MANAGE_CHANNELS and both audit-logged. They resolve in Discord's order:

base role permissions -> role override -> user override

The later, narrower layer wins: a user deny beats a role allow, a user allow beats a role deny, and within one layer allow beats deny. ADMINISTRATOR bypasses both layers entirely. See docs/schema.md ("Permission Checking Logic") for the formula and permissions.EffectiveChannelPerms for the single implementation.

Denying READ_MESSAGES hides the channel outright — from the WS ready payload, from GET /api/v1/channels, from reconnect replay and from live broadcasts. Every write below invalidates the affected permission cache entries and then re-syncs connected clients with targeted channel_create / channel_delete messages, so sidebars converge without a reconnect.

DM channels have no override surface: 400 INVALID_INPUT.

GET /admin/api/channels/{id}/permissions

Both layers for one channel. roles lists every role (zero masks when it carries no override) so the panel can render a complete grid; users lists only members who actually have an override row.

Response 200 OK

{
  "channel_id": 4,
  "roles": [
    {
      "role_id": 1,
      "role_name": "Owner",
      "position": 100,
      "permissions": 2147483647,
      "allow": 0,
      "deny": 0
    },
    {
      "role_id": 4,
      "role_name": "Member",
      "position": 40,
      "permissions": 1635,
      "allow": 0,
      "deny": 514
    }
  ],
  "users": [{ "user_id": 12, "username": "alice", "role_id": 4, "allow": 2, "deny": 0 }]
}

PUT /admin/api/channels/{id}/permissions/{roleId}

PUT /admin/api/channels/{id}/user-permissions/{userId}

Write one override row. Same body for both layers:

{ "allow": 2, "deny": 1 }
Field Type Description
allow integer Bits granted in this channel
deny integer Bits refused in this channel

Bits outside permissions.AllPerms are masked off rather than rejected, so an unknown bit can never be persisted. A row with both masks 0 is meaningless — the admin panel sends DELETE for that case instead.

Response 200 OK

The stored row: {role_id, role_name, position, permissions, allow, deny} for the role layer, {user_id, username, role_id, allow, deny} for the user layer.

Cache and fan-out

  • Role layer: InvalidateAll (any member of that role is affected), then RefreshChannelVisibility.
  • User layer: InvalidateUser(userId) only — a per-user override cannot change anyone else's verdict, and dropping the whole cache for one member would cost every connected client a repopulate — then RefreshChannelVisibility, which resolves visibility per user through the full order.

Audit

channel_perms_update / channel_user_perms_update, target channel.

Errors

Status Code When
400 BAD_REQUEST Unparseable id or body
400 INVALID_INPUT The channel is a DM
403 FORBIDDEN Missing MANAGE_CHANNELS
404 NOT_FOUND Unknown channel, role or user

DELETE /admin/api/channels/{id}/permissions/{roleId}

DELETE /admin/api/channels/{id}/user-permissions/{userId}

Clear the override row, returning the target to the layer above it. 204 No Content; deleting a row that does not exist is a no-op, not a 404. Same cache/fan-out behavior as the writes; audits as channel_perms_clear / channel_user_perms_clear.


Plugin Administration

Manage WASM plugins. These endpoints sit behind both the admin IP restriction (allowed CIDRs) and admin bearer-token authentication, and require the ADMINISTRATOR bit specifically (the widened admin perimeter does not open them). Plugin execution additionally requires a server built with -tags wazero and plugins.enabled: true in config.

Unlike the rest of the API, these endpoints answer errors as plain text (http.Error), not the standard JSON envelope — the one envelope exception is install's 400 INSTALL_FAILED. When the runtime is unavailable, mutating endpoints answer 503 plugin runtime disabled; other plain-text statuses are 400 (bad multipart/zip), 415 (not a .zip), 413 (plugin upload too large — the API's only 413), and 500. GET /api/v1/admin/plugins always answers 200 and reports the runtime state in an X-Plugin-Runtime response header instead.

GET /api/v1/admin/plugins

List installed plugins.

Response 200 OK

Array of plugin rows: ID, Name, Version, Enabled, ManifestJSON, InstalledAt.

POST /api/v1/admin/plugins/install

Install a plugin from an uploaded zip (multipart form). The archive is size-capped (16 MiB compressed / 64 MiB uncompressed) and hardened against zip-slip and symlinks; installation is staged and atomically renamed.

Response 201 Created

{ "name": "plugin-name" }

POST /api/v1/admin/plugins/{id}/enable

POST /api/v1/admin/plugins/{id}/disable

Enable or disable an installed plugin.

DELETE /api/v1/admin/plugins/{id}

Uninstall a plugin.


LiveKit Endpoints

These endpoints are only registered when LiveKit voice is configured.

POST /api/v1/livekit/webhook

LiveKit webhook receiver. Uses LiveKit JWT verification. IP-restricted via server.livekit_webhook_allowed_cidrs (falls back to server.admin_allowed_cidrs when unset). Called by the LiveKit server, not by clients.

GET /api/v1/livekit/health

Check whether the LiveKit server is reachable.

Auth: IP restriction (server.livekit_webhook_allowed_cidrs, admin fallback)

Response 200 OK

{
  "status": "ok",
  "livekit_reachable": true
}

Response 503 Service Unavailable

{
  "status": "degraded",
  "livekit_reachable": false,
  "error": "connection refused"
}

/livekit/* (Reverse Proxy)

All requests to /livekit/* are reverse-proxied to the LiveKit server URL. The /livekit prefix is stripped before forwarding. This allows the client to connect to LiveKit through OwnCord's HTTPS server, avoiding mixed-content blocks.

Auth: None (LiveKit handles its own JWT-based auth) Rate limit: 30 requests/minute per IP


Diagnostics

GET /api/v1/diagnostics/connectivity

Returns connectivity diagnostics for debugging voice/network issues.

Auth: Required — ADMINISTRATOR only (H-8 hardening: the response reveals network topology) Rate limit: 5 requests/minute per IP

{
  "server": {
    "version": "1.0.0",
    "uptime_s": 3600,
    "go_version": "go1.23.0",
    "online_users": 5
  },
  "voice": {
    "enabled": true,
    "livekit_url": "localhost:7880",
    "livekit_health": true,
    "node_ip": "203.0.113.1",
    "proxy_path": "/livekit"
  },
  "client": {
    "remote_addr": "192.168.1.100",
    "is_private_network": true
  }
}

Client Auto-Update

GET /api/v1/client-update/{target}/{current_version}

Tauri-compatible update endpoint. The desktop client checks this to see if a newer version is available.

Auth: None

Path Parameters

Param Type Description
target string Tauri updater target {os}-{arch}-{installer} (e.g., windows-x86_64-nsis, linux-x86_64-appimage, linux-aarch64-appimage). Selects the platform's updater artifact and is echoed back as the platforms key. Targets without a published updater artifact (e.g., linux-x86_64-deb) get 204.
current_version string Client's current semver version (e.g., 1.0.0)

Response 200 OK (update available)

{
  "version": "1.2.0",
  "notes": "## What's Changed\n...",
  "pub_date": "2026-03-28T00:00:00Z",
  "platforms": {
    "windows-x86_64-nsis": {
      "signature": "base64-encoded-signature",
      "url": "https://github.com/J3vb/OwnCord/releases/download/v1.2.0/OwnCord_1.2.0_x64-setup.nsis.zip"
    }
  }
}

Response 204 No Content

Client is already up-to-date, or no client build is published for target.


WebSocket

GET /api/v1/ws

WebSocket upgrade endpoint. Authentication is performed in-band (first message must be an auth frame with the session token). See protocol.md for the full WebSocket message protocol.