Files
OwnCord/docs/plans/discord-parity.md
T
J3vbandClaude Opus 5 2a37f386f9 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>
2026-08-26 18:00:26 +00:00

50 KiB
Raw Blame History

Discord feature parity — gap analysis and plan

Status (verified 2026-08-04): Shipped — phases 16 complete. Phase 1's table below was written as a gap list and never re-marked; all six rows have since shipped (block/unblock UI SidebarMemberSection.ts:177-186; topics in ready via channelPayloadFrom; role colors from the server list with seeded-name fallback formatting.ts:158-175; profile popup mounted from MemberList.ts; temp bans BanDurationHours in PATCH /admin/api/users/{id}; archived channels hidden by the unified predicate permissions/checker.go:116-121). Named leftovers remain open and are listed in-line: role hoist/mentionable flags + @RoleName mentions (Phase 5), categories as real entities (Phase 5), and the §"still-dead code" cleanup list (voice_speakers, voice_config.bitrate, macOS PTT stub — the sounds table was dropped by migration 029 on 2026-08-04, A-2026-07-13).

Status: phase 6 complete (2026-08-01)

This is a depth audit of features OwnCord already has, compared against what Discord's version of the same feature can do (free tier, with Nitro notes where relevant). Wholly absent features (threads, forums, polls, soundboard, …) are listed at the end for reference but are out of scope here — the focus is finishing the features we have.

Where OwnCord already meets or beats Nitro

  • Uploads: 100 MB default (free Discord: 10 MB, Nitro Basic: 50 MB, Nitro: 500 MB) — and max_upload_bytes is operator-configurable.
  • Message length: 4000 runes, equal to Nitro's limit (free Discord: 2000).
  • Streaming: source-quality preset and up to 120 fps, above Nitro's 1080p60/4K60.
  • Client themes: full custom CSS-var theming with import/export — a paid perk on Discord.
  • E2EE voice with TOFU identity verification — Discord has no equivalent at any tier.

Phase 1 — quick wins (this branch)

Features where one side is already built and the other was never finished.

# Item What exists What's missing
1 Block/unblock UI Full server enforcement (user_blocks, DM create + send checks), GET /blocks client call Client never calls PUT/DELETE /api/v1/blocks/{userId}; no menu items
2 Channel topics in client channels.topic column, admin-panel editing Not in WS ready payload; chat header never renders it; client edit modal is name-only
3 Role colors roles.color stored and shipped in ready Client hardcodes a switch on 4 role names (formatting.ts)
4 Profile popup UserProfilePopup.ts built and unit-tested Never mounted; member click opens only the admin context menu
5 Temp bans users.ban_expires, BanUser(..., expires), IsEffectivelyBanned all honor expiry Every caller passes nil; no API field, no UI
6 Archived channels channels.archived stored, settable in admin panel No read path filters on it — archived channels appear everywhere

Phase 2 — moderation depth

  • DONE (2026-07-31) — Honest kick semantics. There is no membership model, so DELETE /admin/api/users/{id}/sessions cannot remove anyone; it revokes the target's sessions and they can sign straight back in. Rather than invent a membership table, the user-facing action is renamed to what it does: the desktop member-list menu item is now Force Logout (confirm "Log them out?", pending "Logging out...", toast "Forced {name} to log out"), and the admin panel's row button, modal and toast say Force Logout too, with the modal spelling out that the user can sign back in. The endpoint, the KICK_MEMBERS bit and the onKick/adminKickMember call sites are unchanged.
  • DONE (2026-07-31) — Enforce the decorative permission bits. The /admin/api perimeter now admits any role holding a bit of permissions.AdminPerimeter instead of requiring ADMINISTRATOR, and each route group re-checks its own bit: channels + channel overrides → MANAGE_CHANNELS, audit log → VIEW_AUDIT_LOG, settings → MANAGE_SERVER, force-logout → KICK_MEMBERS; ban/unban (BAN_MEMBERS) and role assignment (MANAGE_ROLES) are authorized inside ModerationService. Stats/users/GET /me stay perimeter-level; backups, updates, API tokens, plugins and the log stream are unchanged. GET /admin/api/me reports the caller's mask so the admin panel hides tabs and row actions it cannot use, and the desktop member-list context menu gates Kick/Ban/Change Role on the bits from the ready role list instead of on the role name. MUTE_MEMBERS admits to the perimeter but still has no route behind it (see voice moderation below).
  • DONE (2026-07-31) — Hierarchy checks beyond ban/unban: ModerationService.ChangeUserRole requires the actor to strictly outrank the target and refuses to assign a role positioned at or above the actor's own, closing the "any admin can promote anyone to Owner" hole. ModerationService.ForceLogout enforces the same outranks rule.
  • DONE (2026-07-31) — Voice moderation on MUTE_MEMBERS (the bit is now live): voice_mod_mute, voice_mod_deafen, voice_mod_move and voice_mod_kick, each requiring the bit plus a strict role-position outrank of the target, rate limited 5/sec and audit-logged. voice_states gained server_muted / server_deafened, which the voice_state broadcast now carries; a server mute is also applied to the target's published audio track via the LiveKit RoomService, and the target's own voice_mute / voice_deafen unmute attempts are refused with SERVER_MUTED / SERVER_DEAFENED. Move and disconnect run the hub's voice-leave routine for the target and then send them voice_moved (client re-joins the destination through the ordinary join path) or voice_disconnected. The desktop client's voice-user context menu grows a moderation section gated on the bit, renders a distinct server-muted icon, and disables the widget's mute/deafen buttons with a reason while server muted.
  • DONE (2026-07-31) — Bulk message delete. POST /api/v1/channels/{id}/messages/purge takes {limit: 1-100, before?} and soft-deletes the newest matching messages, gated on READ_MESSAGES|MANAGE_MESSAGES for that channel (per-channel overrides apply, DMs rejected — a DM has no MANAGE_MESSAGES gate to answer to). Deletion is the same soft delete a single delete performs, so tombstones and reply_to targets survive; already-deleted rows are skipped and the select+update run in one writer transaction. One message_purge audit entry per call carries the count, and the fan-out is a single new chat_bulk_deleted {channel_id, ids} server->client message instead of N chat_deleted events. The desktop channel context menu grows a "Purge Messages…" item — gated on the actor's MANAGE_MESSAGES bit and hidden on voice channels — opening an inline 1-100 count prompt with a confirm step; the dispatcher marks every id in the broadcast as deleted.

Phase 3 — mentions done right

The largest single messaging gap: @word was regex-highlighted with no resolution, no notification and no badge, and read_states.mention_count was a dead column. The server is now the authority on what a mention is — the client highlights and badges from the resolved fields rather than re-parsing content.

  • DONE (2026-07-31) — Server-side mention resolution and storage. MessageService.resolveMentions parses @tokens out of sanitized content with a word-boundary rule (mentionTokenRe) that refuses address-shaped text: mail@example and @@name never match, and @bob@example.com is rejected whole rather than half-matched. Tokens are lowercased, deduplicated, ordered by first appearance and resolved case-insensitively against users.username (UNIQUE COLLATE NOCASE), with a second spelling that drops trailing ./- so "@bob." resolves to bob when nobody is literally named "bob.". A token matching no username resolves to nothing and stays plain text. Two caps bound the work one send can cause: at most 60 distinct tokens are looked up (maxMentionCandidates) and at most 20 resolve (maxMentionsPerMessage). Resolved IDs land in the new message_mentions table (migration 022, PK (message_id, mentioned_user_id) plus idx_message_mentions_user for the per-user direction), written in the same writer transaction as the message row and rewritten wholesale on edit. Resolution failures are logged and degrade to "no mentions" — a message is never rejected because its mention lookup failed. mentions and mentions_everyone now ride on the chat_message and chat_edited broadcasts, on GET /channels/{id}/messages, on pinned-message responses and on FTS search results; mentions is always present and empty rather than null. buildChatMessage took a chatMessageArgs struct in the process — the positional list had outgrown a readable call site.
  • DONE (2026-07-31)read_states.mention_count is live. applyMentionCounts raises it on message insert for every mentioned user who can actually read the channel — the role walk applies channel overrides, and DMs skip it entirely since participation is membership, not permissions. The author is always excluded, and users who have blocked the author are dropped (fail-closed: a ListBlockersOf error skips the whole increment, because a badge from a blocked user is worse than no badge). Edits deliberately never increment: only the original send can raise a badge, which is the simplest rule that makes double-counting a re-added mention impossible. channel_focus resets the count to 0 via the UpdateReadState upsert, and the ready payload ships mention_count per channel. Because GetChannelUnreadCounts covers text/announcement channels, a DM mention badge is raised live by the dispatcher but starts at 0 on reconnect — DM unreads are surfaced separately in the DM sidebar.
  • DONE (2026-07-31) — Client rendering, badges and notifications. @lib/mentions is the single source of truth shared by the renderer, the badge path and the notification gate, so all three agree on what counts as a mention; its regex mirrors the server's, and the server's mentions/mentions_everyone decide the outcome whenever present (the local token parse only stands in for servers predating the fields). Resolved mentions render as a highlighted .mention span, with .mention-self when the mention is the current user; an unresolvable token renders as plain text. In the channel sidebar a red .mention-badge outranks the plain unread badge — only one shows, and it counts mentions rather than messages. Desktop notifications retitle to "{user} mentioned you in #{channel}", and "Suppress @everyone" now means exactly that: it drops only a notification the @everyone/@here alone caused, so a message that also names you still notifies, and an @everyone the sender lacked the bit for was never a mention to suppress. No OS dock/taskbar count badge was added — the existing taskbar flash is the only OS-level signal; a real badge count needs a Tauri-side API and is left for a later pass.
  • DONE (2026-07-31)@everyone/@here behind a permission, plus composer autocomplete. New MENTION_EVERYONE bit (21, 0x200000); migration 022 grants it to the seeded Owner/Admin/Moderator roles, moving the Moderator mask from 0x000FFFFF to 0x002FFFFF. Without the bit the token carries no mention semantics at all — no highlight, no badge, no notification — and DM channels have no @everyone semantics since there is no permission surface to answer to. @here narrows the fan-out to readers whose status is not offline; @everyone reaches every reader. The composer opens an inline member picker on @ (MentionAutocomplete, max 10 rows) whose active-token rule mirrors the server's, so it never offers a completion for text a send would not resolve; @everyone/@here appear as rows only for users who hold the bit.
  • DONE (2026-07-31) — Clickable #channel links. #name tokens in message content resolve case-insensitively against the channel store (DM channels excluded — they have no user-visible #name) and render as links; unresolvable tokens stay plain text. Navigation funnels through the new @lib/channel-navigation.navigateToChannel, now the single entry point shared by the sidebar item and #channel links, so every affordance clears the same unread and mention badges. Role mentions remain out of scope by design — they need role management, which is phase 5.

Phase 4 — markdown and message polish

  • DONE (2026-08-01) — Full markdown rendering, client-side. The content parser grew a real tokenizer (message-list/markdown.ts): one left-to-right scan with recursive descent into matched delimiter pairs, which is what makes nesting (**bold *and italic***), backslash escaping and "markdown is dead inside code" fall out of a single rule set instead of a pile of regexes fighting over overlaps. Inline: bold, italic (*/_, with a word-boundary rule so snake_case_names stay literal), underline, strikethrough and spoilers; blocks (line-start only): > quotes that merge contiguous lines, >>> for the rest of the message, #### headings that require the space, -/*/1. lists with one level of nesting. Masked links accept absolute http(s) only — javascript:, data: and relatives render as their literal source — and are excluded from extractUrls, so hiding an address does not get it previewed back. Code fences take a language tag that renders as a label and drives a hand-rolled highlighter (syntax-highlight.ts: comments/strings/numbers/keywords for js/ts, go, python, rust, json, bash, css, html, plain fallback) — no highlighting dependency was added. Spoilers are per-span role="button" elements with aria-pressed, and the revealing click is swallowed so a link underneath cannot open with it. Rendering stays a strict DOM builder: no innerHTML anywhere, every href through isSafeUrl. Composer: Ctrl+B/I/U wrap (and unwrap) the selection, stopping propagation so Ctrl+U formats while typing and still uploads elsewhere.
  • DONE (2026-08-01) — Message navigation: fetch-around, reply jumps, permalinks. Server gained GET /api/v1/channels/{id}/messages/around/{messageId}?limit=50 — the same read gate as history (READ_MESSAGES / DM membership), the window split half-and-half around the centre and returned oldest-first, with has_more_before/has_more_after derived by over-fetching one row per side rather than two extra count queries. A centre that is soft-deleted is a 404, not an empty window: history omits deleted rows, so there is nothing to centre on. The three duplicated read-permission blocks in MessageService collapsed into one requireChannelRead, and the three copies of limit parsing in the handlers into one parseLimitParam. Client-side every jump affordance — search hit, pinned entry, the quoted reply bar, a permalink chip, an owncord://message/… link from the OS — now routes through a single jumper (lib/message-navigation.ts registry → main-page/MessageJump.ts): scroll + flash when the target is loaded, otherwise fetch the around-window, swap it in, scroll + flash. A window with newer messages below it is detached: the store refuses to append live broadcasts onto it (they belong below a gap) and the list shows a Jump to Present pill that reattaches and refetches the tail. Permalinks are owncord://message/{channelId}/{messageId} — copied from the hover bar, parsed by the same deep-link.ts that owns the invite scheme (whose bare-code form now refuses the message route), and rendered as a compact channel-name chip when pasted into chat; a link to a channel the reader cannot see stays plain text.
  • DONE (2026-08-01) — Who-reacted list. Server added GET /api/v1/channels/{id}/messages/{messageId}/reactions/{emoji}/users (emoji percent-encoded; chi routes on RawPath, so the handler unescapes it) behind the same requireChannelRead gate as history, returning up to 100 reactors oldest-first. A separate endpoint rather than user_ids inline on every reaction summary: a page of chat carries dozens of pills and almost none are hovered, so the payload stays small. A message that lives in another channel is a 404 — the channel in the URL is the one the permission check ran against. Client: hovering or focusing a pill for 300 ms (the lib/streamPreview.ts debounce, so a pointer crossing a row fires nothing) fetches and shows "alice, bob, carol and 4 others reacted with 👍". Lists are cached per message+emoji and evicted wholesale for a message on reaction_update, which names only the emoji that changed; a response that lands after an invalidation or after the pointer left is discarded rather than repopulating the cache or popping a tooltip nobody is hovering. Usernames go in as text nodes.
  • DONE (2026-08-01) — Inline video/audio players. video/mp4|webm|ogg render as <video controls preload="metadata"> inside the same max box as an image (download button on hover); the common audio containers render as an <audio controls preload="metadata"> row with filename, size and download. Both are allowlists, not video//audio/ prefix tests — an unknown container gets the download chip rather than a player that fails to decode — and image/svg+xml is now excluded from the image path too (it can carry script, and the data-URI allowlist already refused it, so inlining only ever produced a stuck placeholder). /api/v1/files/{id} is permission-checked, so the source is fetched through the same cert-pinned proxy with the session bearer token images use, then handed over as a blob: URL rather than the image path's base64 data URI, which would inflate a 50 MB video into a string and cache it in IndexedDB.
  • DONE (2026-08-01) — Read-state polish. A red NEW divider marks the first unread message when a channel is opened with unread; because opening clears the badge, setActiveChannel snapshots the count first (getUnreadOnOpen) and the list places the line above the last N loaded messages — suppressed while the window is detached (a slice around an old message is not the tail) and gone on the next visit. Explicit mark-as-read arrived as a new client→server WS message mark_read: channel_focus already advances read state but also rebinds the connection's focused channel, which is wrong when marking a channel the user is not looking at. It backs Mark as Read in the channel context menu (disabled when already read, absent for voice) and Mark All as Read on the sidebar's server header, which only appears while something is unread. DM sidebar rows now show real unread counts and a red mention count that outranks them; GetChannelUnreadCounts includes the caller's DM rows so ready ships a DM mention_count — previously absent, which silently reset every DM mention badge on reconnect.

Phase 5 — roles & channels management

  • DONE (2026-08-01) — Role CRUD. Roles were four seeded rows whose permission masks were frozen at migration time; they are now real entities behind /admin/api/roles (GET, POST, PATCH /{id}, DELETE /{id}, PATCH /roles/reorder), gated on MANAGE_ROLES with the whole rule set in a new service.RoleService rather than in the handlers. Every rule is measured against the actor's role position: you may only create/edit/delete/reorder roles strictly below your own (equality is refused too, so a role cannot rewrite itself, and nothing outranks position 100 — which is what makes the seeded Owner role immutable and undeletable for everyone including the owner), and you may never grant a bit your own role lacks, though removing one is allowed because de-escalation is always safe (ADMINISTRATOR bypasses). The default role is undeletable — every member falls back to it — and deleting a role moves its members onto that fallback in one UPDATE, drops the role's channel_overrides rows and deletes the role in a single writer transaction, then invalidates exactly the moved members' cached permissions. Names are unique case-insensitively (migration 023 adds idx_roles_name_nocase; the column's own UNIQUE is BINARY, so "Moderator" and "moderator" used to be two roles the client's case-insensitive lookup could not tell apart), colors are #rgb/#rrggbb normalized to uppercase, and unknown permission bits are masked off rather than rejected. Reorder takes an ordered id list that must name exactly the roles below the actor — a partial list is refused rather than leaving the omitted ones at positions that now collide — and normalizes them to N…1. Every mutation audits (role_create/role_update/role_delete/role_reorder). Cache and client sync follow the existing patterns rather than inventing one: the permission cache is invalidated before the hub calls (as the channel-override handlers do), a permission change runs the new Hub.RefreshAllChannelVisibilityRefreshChannelVisibility across every non-DM channel, because a role's mask is the base every channel's effective permission derives from, where an override edit touches exactly one — and a delete additionally sends one member_update per reassigned member. A new roles_update server→client message (schema + make protocol-generate + docs/protocol.md) carries the full new list, so clients refresh channelsStore.roles without reconnecting; replacing rather than patching means a dropped intermediate event can never leave a deleted role on screen. The member list now subscribes to that list too — grouping, labels and name colors all derive from it, and before this they only re-rendered when some unrelated member change happened along. The admin panel grows a Roles section (nav gated on MANAGE_ROLES) listing roles by position with a color swatch and member count, up/down reorder arrows scoped to the manageable slice, a create/edit modal with a permission checkbox grid grouped as docs/schema.md groups the bitfield — bits the caller's own role lacks are rendered disabled — and a delete confirmation that names how many members move and where. Hoist and mentionable are still out of scope: neither has a column, and role mentions need the mention resolver to learn about roles.
  • Role CRUD leftovers: hoist and mentionable flags (no columns yet), role mentions (@RoleName).
  • DONE (2026-08-01) — Per-user channel overrides + the full override matrix UI. New table channel_user_overrides (migration 024, PK (channel_id, user_id) plus idx_channel_user_overrides_user for the per-user direction) makes the resolution order Discord's: base role permissions → role override → user override, with the narrower layer last, so a user deny beats a role allow and a user allow beats a user deny; ADMINISTRATOR still bypasses both. The formula has exactly one implementation, permissions.EffectiveChannelPerms, which Checker.HasChannelPerm, Checker.HasChannelPermBatch and through it VisibleChannelIDs all route through — so extending the order was a change to one function plus the fetch, not to the dozens of HasChannelPerm call sites. HasChannelPerm grew a userID parameter (0 = "no member in hand", skip the user layer), and both layers are loaded together by db.GetChannelOverridesFor(roleID, userID) — two batch queries, never per channel — which is now the single fetch behind buildReady, computeAllowedChannels, REST ListVisibleChannels, MessageService.GetAccessibleChannelIDs, the voice-join publish grants and the cached service.PermissionService. channelCanSend resolves both layers too, and the @everyone fan-out (mentionReaders) folds the user layer in both directions: a user deny drops a reader the role walk admitted (unless they hold ADMINISTRATOR), a user allow adds one it excluded. Hub.RefreshChannelVisibility and channelReadAudience stopped memoising visibility per role — two members of one role can now legitimately disagree about a channel, which is exactly what a per-user override edit creates. Server/ws/channel_visibility_agreement_test.go grew a second case proving REST, ready and replay filtering still agree for three members of the same role carrying different overrides. API: PUT/DELETE /admin/api/channels/{id}/user-permissions/{userId} with {allow, deny} masks, gated MANAGE_CHANNELS like the role layer, unknown bits masked off, audited as channel_user_perms_update/channel_user_perms_clear. They invalidate only the target's cache (InvalidateUser) rather than the whole cache the role layer must drop — a per-user override cannot change anyone else's verdict — before the hub re-sync. GET .../permissions now returns users alongside roles: every role (zero masks when unset) but only the members who actually carry an override row. The admin panel's single "Can access" checkbox survives as the quick private-channel shortcut, writing exactly the mask it always did, and gained a real matrix editor beneath it: pick a role or a member, then set allow / inherit / deny per channel-scoped bit (READ, SEND, ATTACH_FILES, ADD_REACTIONS, MANAGE_MESSAGES, MENTION_EVERYONE, CONNECT, SPEAK, VIDEO, SHARE_SCREEN). An all-inherit row is sent as a DELETE, because storing (0,0) would leave a row that resolves to nothing. perm_grid_test.go ties the matrix's bit list to permissions the same way it already tied the role grid.
  • DONE (2026-08-01) — Categories stopped being magic strings. The server refused any non-voice channel under a category literally named "Voice Channels" and any voice channel outside it (validateCategoryType), and the client mirrored the rule with a substring test on the category name. Both are gone: POST /admin/api/channels validates the type alone, categories are free text, and any type lives under any name. PATCH /admin/api/channels/{id} accepts category, so moving a channel between categories is an edit rather than a delete-and-recreate. The desktop CreateChannelModal's read-only category display became an editable text input with a <datalist> of the categories in use (channelsStore.getKnownCategories), offering all three types; EditChannelModal gained the same field; the admin panel's create and edit forms got the same input plus datalist. The sidebar groups voice channels under whatever category they carry — sharing a group with text channels is fine — and falls back to a synthetic "Voice" group only for voice channels with no category at all (displayCategoryOf). Collapse persistence stays client-side, unchanged.
  • Categories as real entities (own permissions, ordering).
  • DONE (2026-08-01) — Channel management moved into the desktop client. EditChannelModal offered name, topic and category; it now also carries slow mode (a preset <select> from Off to the server's 6-hour ceiling — a free number field mostly produces typos like "300" meant as minutes, and a stored off-preset value set through the admin panel is kept as its own option rather than silently rounded), an NSFW toggle, and a voice section (User Limit / Video Limit, 099, 0 = unlimited) rendered for voice channels alone — the columns exist on every row, but on a text channel they are values nothing reads, so a text-channel edit omits the keys entirely rather than sending 0 and wiping limits the row happens to hold. Every control pre-fills from channelsStore (which channel_update writes into), not from the sidebar row, so the modal opens on current values. PATCH /admin/api/channels/{id} and db.AdminUpdateChannel grew nsfw, voice_max_users and voice_max_video; the positional argument list became db.ChannelUpdate once it reached nine fields, four of them ints. Bounds (slow_mode 0…21600, both voice limits 0…99) are validated before the write and refused with 400 INVALID_INPUT rather than clamped — a caller that sent -1 meant something — so a rejected body writes nothing at all. The whole UI is gated on MANAGE_CHANNELS, not on role names: permissions.canManageChannels() is now the single derivation behind the category "+" and the context menu's Edit/Delete, which were still asking whether the role was literally called "owner" or "admin" (a custom role the server would happily let edit a channel saw no way to, and a role merely called "admin" with no channel bit saw items every click would be refused for). channel_create/channel_update and ready all carry slow_mode, nsfw and both voice limits — always present with their zero values, never omitted, so "absent" never means two things — built by one channelPayloadFrom constructor so the two events cannot drift. The store applies a partial channel_update field by field (an absent key is left alone, not cleared) and finally handles category, so a category move regroups the sidebar without a reconnect.
  • DONE (2026-08-01) — NSFW flag end-to-end, and the voice limits surfaced. Migration 025 adds channels.nsfw (0/1, like archived). The server does nothing with it and says so in schema.md, api.md, protocol.md and the migration itself: it stores, broadcasts and audits the flag (updated #foo (marked NSFW) / (unmarked NSFW), plain when it did not move) and applies no filtering, no age check and no restriction on who may read or post — a client ignoring the field behaves exactly as before it existed. Every consequence is the desktop client's: @lib/nsfw-gate remembers acknowledgement per channel in sessionStorage (the promise is "once per session", so localStorage would quietly make it "once ever"; a throwing storage reads as not acknowledged, erring toward asking again), and NsfwGate mounts over the messages slot — not as a modal, since the channel is live underneath and the sidebar stays usable — with "This channel may contain sensitive content — Continue?", a note stating plainly that nothing is filtered, and a Go Back that leaves the channel rather than stranding the reader. The sidebar marks flagged channels with a shield beside the name (not a recolour: unread/mention/active already own the row's colour). Voice limits: the row shows "3/5" when a user limit is set and nothing when unlimited ("3/0" would read as a bug), and the client still never pre-blocks a join — its participant list can lag and an invented refusal would be uncorrectable, so the server answers CHANNEL_FULL, which the dispatcher now surfaces as a toast (it was logged and otherwise silent, as was VIDEO_LIMIT).
  • DONE (2026-08-01) — The audit log stays admin-panel-only — it is a paginated, filterable table over an endpoint the desktop client otherwise never calls, and a second implementation would be a second thing to keep correct — but it stopped being unreachable. The sidebar's server header grows an "Audit Log" entry gated on VIEW_AUDIT_LOG (kept in sync with authStore and the role list, because ready can land after the header is built), opening https://{host}/admin#audit in the user's browser via the opener plugin. Deliberately not through the loopback TOFU proxy the REST client uses: that origin means nothing to an external browser, so a self-signed deployment shows the browser's certificate warning, which is the honest outcome. The admin panel learned to honour a #section fragment on load (falling back to the dashboard when the principal may not open it, exactly as a stale stored section does), so the entry lands on the log rather than on the dashboard with a tab still to find.

Phase 6 — social & profiles

  • DONE (2026-08-01) — Custom emoji end-to-end. The emoji table had shipped in migration 001 with zero server code, and the client carried getEmoji/deleteEmoji methods aimed at routes nobody had registered plus an EmojiPicker option nothing ever passed; all three are now real. Server: GET /api/v1/emoji (open to any member — an emoji nobody can render is not an emoji, and the set is server-wide with no per-channel scope to leak), POST /api/v1/emoji and DELETE /api/v1/emoji/{id} gated on MANAGE_SERVER. No new permission bit was added, and that is the decision rather than an omission: a bit is a schema-visible, forever choice, and "who may change server-wide branding" is exactly what MANAGE_SERVER already answers for the server name, icon and settings. The gate runs before the multipart body is read, so a member without it never causes a spool to disk. Uploads are capped at 512 KiB, sniffed from their own bytes (image/png|jpeg|gif|webp only — SVG is refused outright: it is markup with script and external-fetch capability, and an emoji is by definition rendered inline), and re-measured from the sniffed image against a 128×128 ceiling; WebP headers are parsed by hand (webpDimensions, all three of VP8/VP8L/VP8X) because the standard library has no WebP decoder and none was vendored for a dimension read. Shortcodes are [a-z0-9_]{2,32}, lowercased on the way in — which is what makes the table's plain UNIQUE index a case-insensitive one without a COLLATE change — with a collision answering 409 CONFLICT and a 200-emoji cap per server. Bytes go through the existing storage layer under a UUID; migration 026 adds the one column the table lacked, mime_type, so GET /api/v1/emoji/{id}/image can set a Content-Type without re-sniffing the file on every request. That route is authenticated (an emoji must not be usable as an unauthenticated tracking pixel) but has no per-channel ACL to apply, and is immutable-cacheable because an emoji's bytes never change for a given id. A failed insert unlinks the orphaned file; a failed unlink after a successful delete is logged rather than failing the delete. New emoji_update server→client message (schema + make protocol-generate + docs/protocol.md) carries the whole set after every mutation, for the same reason roles_update does: replacing rather than patching means a dropped event can never leave a deleted emoji rendering in the messages that name it. It is deliberately not in the ready payload — the set belongs to the server, not the session, so clients load it once over REST on ready and keep it fresh from the event. Client: a new emojiStore whose resolveEmoji is the single answer to "is :name: a real emoji here", consulted by message rendering, the picker, the composer autocomplete and reaction pills so none of them can disagree. :shortcode: renders as a 22px inline image — jumbo 48px when the message is nothing but emoji (unicode included, capped at Discord's 27, and an unresolved shortcode is plain text so it never earns jumbo) — via a .msg-text-jumbo class that sizes glyphs and images together rather than threading a flag through four render functions. It is added in the same token pass as @mentions and #channels, so code spans and fenced blocks are excluded for free: inline code never reaches the token pass, and fences are split off before it. Images are fetched through the same cert-pinned, bearer-token path attachments use and swapped in as a data URI — assigning the server URL to img.src would 401 — with the shortcode as alt, so a message reads correctly before (and if) the bytes arrive. Reactions are free-form strings already, so a custom reaction is stored as the literal :shortcode: and the pill renders the image when it resolves and the text when it does not (a deleted emoji leaves a working, if plain, reaction). The reaction length cap stopped being a bare 32 and is now derived as MaxShortcodeLen + 2: a 31- or 32-character shortcode was a legal emoji that rendered in messages and was silently refused as a reaction, which is exactly the kind of gap a hardcoded constant on each side produces. The composer's picker finally gets its customEmoji option, showing a Server category that inserts :shortcode:, and gained a :-autocomplete mirroring the @-mention one — colon plus 2+ characters, custom emoji ranked above the built-in unicode set, only one popup open at a time. The admin panel grows an Emoji section (nav gated on MANAGE_SERVER) with upload, list and delete; it calls the ordinary member API rather than a duplicate /admin/api handler set, and loads thumbnails as blob URLs because <img src> cannot send an Authorization header (the panel's CSP gained img-src 'self' blob: for exactly that).
  • DONE (2026-08-01) — Profiles & presence depth: avatar upload, display names, about-me, custom status, real invisible, auto-idle. Migration 027 adds users.display_name (32), about (300) and custom_status (128) — all nullable, all bounded and HTML-sanitized in UserService/ChannelService rather than in a handler, so every transport gets the same rules and "omitted = unchanged, empty string = cleared" is one decision rather than four. display_name is display-only on purpose: @mentions keep resolving against username, because it is the unique case-insensitive key and a non-unique nickname would make @alice ambiguous the moment two people pick the same one. POST /api/v1/users/me/avatar takes a multipart PNG/JPEG/WebP (1 MiB, 1024×1024, both re-measured from the sniffed bytes; GIF is refused because an animated avatar renders in every message row, SVG for the reason emoji refuse it). The bytes land in the ordinary attachments table with no channel and users.avatar is pointed at /api/v1/files/{id} — which is what makes the picture readable: an unlinked attachment is uploader-only, and the file route now also admits one that some user's avatar column currently equals (covered by a partial index added in the same migration). An avatar is public exactly while it is somebody's avatar and stops being readable the instant it is replaced; the previous file's bytes are deliberately left on disk, since a blind delete would race any request already in flight for a message rendered with it. PATCH /users/me still takes an https URL, and both paths end at the same column. Real invisible is the load-bearing change. users.status stores the status the user chose, invisible included; the collapse to offline happens at read time in exactly two functions (db.BroadcastStatus, db.StatusForViewer) that every payload builder delegates to, so a new payload cannot leak it by forgetting. A presence change to invisible splits into two events — a global broadcast excluding the owner that says offline, and a targeted one carrying their true state — because a client told it was offline would render its own picker wrong and re-announce online on the next reconnect. That reconnect flash is gone at the source too: ws serve no longer stamps online on connect, it reads the saved status (db.ConnectStatus: idle/dnd/invisible survive, anything else becomes online) and announces that, before buildReady runs so the member list and the broadcast cannot disagree. A chosen status now survives a disconnect (MarkUserDisconnected clears only online) and a restart, and the stale-choice problem that would otherwise create is handled at read time: a member with no live connection renders offline whatever the column says. The client's restoreSavedPresence shrank to a no-op safeguard that only speaks up when the server genuinely disagrees. The one place that read the column as a value rather than through the two collapse functions was the @here fan-out, which tested status == "offline" literally and so would have pinged exactly the people who had asked not to be seen; it now collapses through db.BroadcastStatus first, so @here skips invisible readers and @everyone still reaches them. Custom status rides the presence payload rather than getting its own message: presence_update takes an optional custom_status where omitted means "leave it alone" and "" clears — a distinction that exists because the auto-idle timer sends a bare status flip several times an hour and must not blank the text the user typed. It persists across reconnects and is cleared on logout (a "what I am doing right now" note that outlives the session states something no longer true, unlike the status itself, which is a preference). Auto-idle is client-side (@lib/autoIdle): ten quiet minutes → idle, any input → online, input listening throttled to 1 Hz because mousemove fires hundreds of times a second against a timer measured in minutes. Its whole safety property is one function, nextAutoStatus: only a manual Online becomes an automatic Idle, and only an automatic Idle goes back to Online — a manually chosen Idle is a statement, and dnd/invisible are never touched in either direction. That needed userStatus to record who chose the status ("auto" vs "manual"), which is also what lets a stored pre-phase-6 "offline" be migrated to invisible on read. Client: a shared @lib/avatar helper is now the single answer to "how do I draw this user" — message rows, the reply preview, the member list, the user bar, the profile popup and the account card all went through it, and it fetches the authenticated file through the same cert-pinned bearer-token path attachments and emoji use (an <img src> cannot carry an Authorization header, so the URL would 401) while keeping the letter as the fallback until and unless the bytes arrive. Display names render everywhere with a username fallback, resolved from the member store first so a rename patches messages already on screen; the popup shows the @handle underneath so the thing you would actually type is still one glance away. The about section the popup has rendered since the quick-wins phase finally has real data behind it. The Account tab grew an avatar uploader (client-side type/size/dimension check mirroring the server's, so a refusal costs no upload) plus display-name and about fields, and the StatusPicker gained a custom-status input and sends invisible as its own value.
  • DONE (2026-08-01) — Group DMs. dm_participants always held N rows per channel; what was missing was a create path, a way to tell a group from a two-person DM, and a client that did not assume one recipient. Server: POST /api/v1/dms/group (28 others, 310 total), PATCH /api/v1/dms/{id} to set or clear the name (any participant may — a group DM has no owner column and no roles, so that is the only rule that does not require inventing an ownership model; a 1:1 refuses, since its name is who is in it), and DELETE /api/v1/dms/{id} which is now two operations behind one gesture: a hide for a 1:1 (unchanged) and a leave for a group, deleting the channel when the last participant goes. Migration 028 adds channels.is_group, and that column rather than a participant count is the load-bearing decision. A group of three that two people leave has two participants, and the 1:1 lookup — "the dm channel both of these users are in" — would then match it, so "message Bob" would silently deliver into the remnants of a group in front of whoever was still there. The same count would also make leaving destructive for the third-from-last leaver and non-destructive for the second-from-last. Group-ness is therefore decided once at creation and never recomputed. db.DMChannelInfo grew recipients, name and is_group, and recipient stayed as the first of recipients so a pre-group client still renders somebody; db.NewDMChannelInfo is the single place that answers "which of these is the recipient", so GET /dms, the ready payload and dm_channel_open cannot disagree about a channel. dm_channel_open is now built per viewerrecipient and recipients are defined relative to who is reading, so one shared payload would list a group member as their own DM partner. GetUserDMChannels became two queries (channel rows + every participant of every open DM) rather than one, because a single joined query returns one row per (channel, participant) pair and the caller has to de-duplicate anyway. Blocks are a 1:1 rule, which is Discord's semantics and the only coherent one for a shared room: requireDMNotBlocked exempts groups, because dropping one member's messages for one other member would leave the two of them reading different conversations under the same name. They are enforced at creation instead — a user may neither add someone they have blocked nor add someone who has blocked them — where "may these two be in a room together" still has one answer. The client's composer gate follows the same line and applies to 1:1 DMs alone. Message/typing/read fan-out already went through dm_participants and needed no change; the tests pin that it genuinely reaches the third member. Voice in a group DM works through the existing participant check (hasChannelAccessIsDMParticipant), also unchanged. Client: DM rows are keyed on the channel, not the recipient — a group has no single recipient, and the same person can be in both a 1:1 and a group with you — with stacked avatars, a participant count, and dmDisplayName as the one answer to "what is this conversation called" (a group's name, else its members joined and capped at three plus a count, else the other person). The New DM picker became multi-select with one button relabelled by the selection size, because "new conversation" is one intent and making the user pick "DM" or "group" before choosing who is in it asks them to declare it twice.
  • DONE (2026-08-01) — DM calls with ringing. New call_ring/call_decline (client→server) and call_incoming/call_declined (server→client) via the schema + make protocol-generate. No new DB state: a call in a DM is presence in that DM's voice channel, which voice_state already broadcasts, and ringing is transient signalling on top. A persisted call row would be one more thing a crashed client can leave dangling in exchange for information the presence already carries. call_ring is participant-gated and rate limited to one every 3 seconds per user (not per channel — the abuse is spamming somebody with banners); the fan-out reaches whichever participants are connected, since a targeted event to an offline user is a no-op by construction and a ring that arrives after the fact is worse than no ring. call_decline is addressed to all other participants rather than "the ringer", because with no call state the server does not know who that was, and in a group more than one person may be ringing. Client: the DM header gained a Call button that joins the voice channel before ringing — the ring is only truthful once the caller is actually there. Incoming calls draw a banner (not a modal: a ring is an offer, and blocking the app until a 30s timer expires is not one) with Accept/Decline and a repeating chime. The whole lifetime is a statechart in @lib/call-ring with no DOM in it — accept, decline, 30s timeout, call_declined, and the ringer's voice_leave all exit through one stopRinging, so there is exactly one place that can leave the chime playing. A timeout deliberately sends no decline: it means "nobody was there", and claiming a refusal that did not happen would be a lie to the ringer.
  • DONE (2026-08-01) — Friends list: the dead nav item is removed, which is the option this plan already listed. It was a row in the DM sidebar whose onFriendsClick no call site ever passed and whose friendsActive no call site ever set; building a friends list behind it would have meant a follow/request model, a table, and a second notion of "who can DM whom" alongside blocks. The item, both dead props and its CSS are gone, and a test pins the deletion.
  • DONE (2026-08-01) — Per-channel notification mutes. Client-side prefs in localStorage (@lib/channel-mutes), because the server has no per-user channel settings table and "which of my devices bothers me" is a property of the device, not the account — the same reason desktopNotifications and notificationSounds live next to it. Discord's semantics exactly: a muted channel fires no desktop notification, no chime and no taskbar flash (a flashing taskbar is precisely the interruption the mute was asked for); its unread badge still counts but renders dimmed, because the channel has not stopped existing, it has stopped shouting; and a message that mentions you still notifies and still shows the red mention badge. That last rule is what makes a mute safe to use, and it lives in one function (notificationAllowed) so the popup, the chime and the flash cannot end up applying three slightly different copies of it. Channel and DM context menus gained Mute/Unmute (until turned off — a timed mute needs a stored expiry the client would have to sweep, to buy an affordance the user can reproduce by unmuting), and the Notifications tab lists what is muted with unmute buttons, including mutes that outlived their channel, since otherwise there is no way to clear them.

Absent wholesale (not planned here)

Threads, forum/stage channels, webhooks, bot accounts, stickers, polls, message forwarding, TTS, soundboard (dead sounds table), priority speaker, streamer mode, video backgrounds, multi-guild, email/account recovery, slash commands (separate plan: slash-commands.md).

Known dead code to reconcile as phases land

Still dead after phase 6 (nothing in this plan reconciles them):

  • sounds table — the soundboard is absent wholesale, so the table has no feature to belong to; it and the client's getSounds/deleteSound, which still call unregistered routes, are the largest remaining piece.
  • voice_speakers reserved WS type (never sent).
  • voice_config.bitrate — sent to clients, never applied client-side.
  • PTT stub on macOS.

Came off the list:

  • read_states.mention_count (phase 3) — now written, shipped in ready and cleared by channel_focus.
  • The emoji table and the client's getEmoji/deleteEmoji (phase 6) — the table is written by /api/v1/emoji, and the two dead methods were replaced by listEmoji/uploadEmoji/deleteEmoji against the real routes.
  • UserProfilePopup's about section (phase 6) — built, styled and tested while every call site passed a hardcoded null; users.about now feeds it.
  • The DM sidebar's Friends nav item (phase 6) — deleted rather than implemented. Its onFriendsClick was never passed by any call site and its friendsActive was never set; giving it a destination would have meant a follow/request model, a table and a second notion of "who may DM whom" alongside blocks. The item, both dead props and its CSS are gone, and a test pins the deletion.