ConcordReview
Keep the character. Fix the foundation.
Concord has a clear purpose: self-hosted, terminal-first chat and voice. The next release should make that promise dependable. This review read the source, ran the suite, and reproduced the worst findings with real tests.
Six perspectives.
Six review roles, each with what works, what breaks and a verdict. Specialist AI perspectives, checked against the code, not human expert endorsements.
Go & systems engineer
Skeptical strategist
Prospective user
Security specialist
Release engineer
What the six are saying.
The judge reads all six, compresses each to one line, then rules. Nothing below contradicts the panel above. It sequences it.
- Full-stackThe boundaries are sound. Authorization leaks across them because handlers act on whatever channel ID the client sends. Close those flows instead of restructuring.
- Go & systemsThe failure paths are where state is lost, and one of them is silent: the database has been running without foreign keys or WAL since day one. Turn the pragmas on, then fix what they expose.
- StrategistFeature breadth does not pay for setup burden. Narrow the next release to one small-group promise and prove it.
- UserJoining, hearing others, muting, fetching a file and recovering after a restart must work without a developer in the room. Today, mute is a label.
- SecurityOwnership, role hierarchy, enforced access checks and isolated credentials are prerequisites for trust. Two of the gaps were reproduced with a real client, not inferred.
- ReleaseSource fixes are not enough. Add CI, make the build fail loudly, name artifacts honestly, and make the README match the binary.
Stabilize the core. Then expand.
There is no factual disagreement among the six. Only emphasis differs, and sequencing resolves it: security and correctness first, then packaging and onboarding, then watch real users and let the evidence pick the next feature.
Concord’s terminal-first, self-hosted identity, its permission engine, its plugin platform and its integration tests are assets. Keep them. This snapshot does not yet support a public server with strangers on it.
What actually ran.
The first pass read source only. This pass built the project on a clean macOS machine, ran the suite, and wrote throwaway tests that exercise the top findings against a real server and real WebSockets.
- PassBuild, vet, 266 tests, race detectorEvery package with tests passes with the novoice tag, and passes again under the race detector. Real integration tests spawn a plugin binary over WebSockets and complete a WebRTC file transfer.
- PassVoice client builds on macOSAfter installing pkg-config, opus and opusfile. The README's macOS section lists none of them, so a new user following it cannot build voice.
- ReproducedNon-member read a private channelA second user with no server membership requested history by channel ID and received the seeded message. The handler goes from JSON decode straight to the database.
- ReproducedOld socket evicts the live sessionTwo connections for one user. Closing the older one removed the newer one from the hub's client map while its socket stayed open. It keeps running, unreachable.
- ReproducedDatabase pragmas are ignoredThe connection string uses another driver's syntax. The pinned driver reports foreign keys off, journal mode delete, busy timeout zero. Every cascade delete in the schema is decoration.
- ReproducedNo-voice build fails on macOS and LinuxThe Makefile disables CGO for it, but the client imports a native save dialog that needs CGO. The fallback only exists on Windows.
- DocumentedFirst registrant becomes ownerConfirmed through the real registration handler. The project has a test asserting this as intended, so this is a design argument, not a regression report.
Three flows worth fixing.
Switch between the current behavior and the recommended path. These diagrams summarize verified findings.
Twelve fixes. Clear finish lines.
Open a finding for the reason, the acceptance check and its source. Priorities are review judgments. Line numbers refer to main as downloaded on 2026-09-09.
P0Make owner setup explicitRemove first-user and email-match promotion.
Why: The first registrant on any fresh server becomes owner and admin. A later registrant matching the configured admin email takes ownership, and the first registrant keeps Admin. Nothing verifies email. Reproduced through the real registration handler.
Done when: A local setup command or single-use secret creates exactly one owner, even with simultaneous requests. Transfers revoke the previous holder. Restart cannot reopen setup.
P0Enforce access everywhereHistory, pins, whispers, edits, voice, signaling.
Why: Any logged-in user can read any channel's history by UUID (reproduced). Voice state, voice signaling and file-transfer signaling relay to any target user with no shared-channel check, which hands a stranger a peer connection to someone's microphone and IP. Whispers need no shared server. Edit and delete trust the client's channel ID for the permission check and the broadcast.
Done when: Every handler that takes a channel or target resolves it server-side and requires membership plus the relevant permission. Denied members cannot read, hear, signal or be signaled. Revocation reaches connected clients.
Watch: The permission helper uses any-bit semantics. No caller passes a combined mask today, so fix the contract before one does.
P0Turn the database onPragmas are silently dropped by the driver.
Why: The connection string passes _foreign_keys=on&_journal_mode=WAL, which is the mattn driver's syntax. The pinned driver is modernc, which only reads _pragma=. Probed live: foreign keys 0, journal mode delete, busy timeout 0. Cascade deletes orphan attachments, mentions, reactions and voice states. Plugins sharing the file hit SQLITE_BUSY with no wait. The skipped cascade test blames the test database. It is production.
Done when: The DSN uses _pragma=foreign_keys(1), journal_mode(WAL) and busy_timeout(5000), the cascade test is un-skipped and green, and system messages no longer write a nil author into a NOT NULL foreign key. Do the second part first or the server will not boot.
P0Stop the hub deadlocking on itselfDisconnect publishes to the queue it is draining.
Why: The hub loop is the only consumer of the broadcast channel. Unregister runs inside that loop and does a blocking send to broadcast a presence update. If the 256-slot queue is full when a client drops, the loop blocks forever, every read pump then blocks on unregister, and the typing manager blocks while holding its lock. The whole server freezes with no recovery. Separately, register overwrites by user ID and unregister never checks identity, so an old socket evicts the live one (reproduced).
Done when: Nothing inside the hub goroutine pushes to its own channels. Unregister compares the map entry to the client being removed. A saturated-queue disconnect completes under a test that fills the queue first.
P0Add a role hierarchyManagers can grant Administrator. Moderators can ban the owner.
Why: Anyone with Manage Roles can create a role carrying Administrator, assign Admin to themselves, or put Administrator on everyone. Kick, ban and timeout have no target checks, so a Ban Members holder can ban the owner, who is then rejected at identify and cannot log in to undo it. Bans and kicks are only enforced at identify: a live session keeps posting, and kick never removes membership, so the target reconnects with the same token.
Done when: Nobody can grant a permission they do not hold or act on a role at or above their own. Owner and self are untouchable. Ban checks run on send and whisper, revoke all tokens, and close every connection for that user.
P1Protect credentialsSeparate identity from reusable passwords.
Why: One plaintext password is saved and sent to every server. Config and the hub registration secret are written 0644. Plain ws and http are accepted, so a server operator, or anyone on the path, receives a reused credential. Any login error, including a timeout, falls through to registration. There is no logout, no revocation path in use, and 30-day tokens survive bans.
Done when: Each server has distinct credentials, tokens use OS-protected storage, raw passwords leave config files, secrets are 0600, remote auth requires TLS, timeouts never create accounts, and a logout endpoint plus revoke-on-ban exist.
P1Make voice actually workMute is cosmetic. Join race. Opus framing. ICE failure.
Why: The voice engine contains no mute or deafen logic; the keys only update state, and the server comments that clients suppress audio. The server sends the voice-state event before the voice-server event, so the lower-UUID peer offers before the joiner's engine exists and nothing retries. Encoding trusts whatever frame count the audio backend delivers, so backends with 512-frame callbacks fail every encode. A failed ICE connection is never torn down, TURN is never populated, and reconnect backoff has no jitter.
Done when: Muted stops sending and deafened stops mixing, verified by test. Signals queue until the engine exists. Frames are accumulated to exact 20 ms. Failed peers are removed and retried. Two-user and three-user calls are demonstrated on real networks.
P1Keep conversations consistentAtomic writes, working cursor, one timestamp format.
Why: Message, mention and attachment writes are three separate statements. The history handler ignores the cursor it is sent, so the client cannot page past the first batch. Timestamps are stored as Go's String output including the monotonic suffix, while other paths write UTC or CURRENT_TIMESTAMP, so ordering is lexical on mixed formats and breaks across DST. Full outgoing queues silently drop state-changing events. The client's event reader is re-armed on every reconnect, so dispatches can arrive out of order.
Done when: A failed write leaves no partial message. Users traverse more than 200 messages without gaps. All timestamps are UTC in one format. Slow clients resync or disconnect clearly. One reader forwards events in order.
P1Sanitize what reaches the terminalEscape sequences in messages run on every viewer's screen.
Why: Message content, usernames, nicknames, titles and role names are accepted as raw bytes and rendered untouched. A message carrying an OSC clipboard-write or title sequence executes in each recipient's terminal. This is the one class of bug unique to a terminal chat app, and it is unhandled.
Done when: Control characters and ESC, CSI and OSC sequences are stripped at validation and again at render, with a test that sends the clipboard sequence and asserts it is inert.
P1Ship a build people can useCI, honest artifacts, docs that match the binary.
Why: There is no CI. The dist target omits the hub, names a no-voice client as the real one, and its shell loop reports success when a build fails. The no-voice target disables CGO but the client imports a native save dialog, so it fails on macOS and Linux (reproduced). The version variables the Makefile stamps do not exist. The README documents --setup and --dashboard; the server accepts --reconfigure and --hybrid. The macOS prerequisites omit opus, opusfile and pkg-config. The Windows steps name gcc while the Makefile requires clang for a documented compiler bug.
Done when: CI runs build, vet, race and the voice build on every push. Failing builds fail the release. Artifacts match their names. A clean-install walkthrough on each platform works from the README alone.
P1Harden the Grapevine hubUnauthenticated registration, SSRF, spoofable rate limit.
Why: Server registration is unauthenticated and unlimited, and proves nothing about host ownership, so the listing can be filled with junk. Each join then makes the hub POST to whatever host was registered, which turns the hub into a request proxy into its own network. The join limiter keys on X-Forwarded-For, which any client can set. The admin token is compared with a plain string compare.
Done when: Registration is rate-limited and verified with a ping before listing, forwarded headers are ignored unless behind a trusted proxy, and secrets compare in constant time.
P1Validate every inputGhost channels, unbounded fields, attachment names.
Why: If the channel lookup fails, every permission, lock and mute check is skipped and the message is still stored, so any user can grow the database without limit. Reply targets are never validated. Attachment filenames and sizes are accepted as-is, and the client's no-dialog fallback joins the name onto the download directory, so a dotted path escapes it. Status text is unbounded and broadcast server-wide. Force-move and voice-mute write state for any UUID.
Done when: Unknown channels return an error and store nothing. Filenames are base names. Every free-text field has a length cap. Moderation targets must hold live state in that server.
Change the lens. Keep the decision clear.
Subtract before adding.
Question, delete, simplify, accelerate, automate. In that order.
Question
Does this help a small group chat and talk reliably, with strangers kept out?
Delete
Implicit ownership, any-bit permission checks, the fake no-voice target, docs for flags that do not exist.
Simplify
One access policy resolved server-side, one recovery flow, one timestamp format, one supported build path per platform.
Accelerate
Shorten installation and make each failure reproducible with a test, the way this review did.
Automate
CI on every push: build, vet, race, voice build, and the reproduction tests turned into regression tests.
Proposed cuts from the next release: more themes, federation depth, new plugin surface. Keep: chat, voice, the plugin platform and the existing extensions. No code was removed.
A stabilization release.
Use passing checks to decide readiness. Avoid a broad refactor or an arbitrary deadline.
Secure control
Explicit owner setup, role hierarchy, access checks on every handler, protected credentials, terminal sanitizing.
Prove recovery
Pragmas on, hub loop fixed, session identity, atomic writes, working cursor, one clock.
Verify delivery
CI, honest artifacts, voice builds on all three platforms from the README alone, backup and restore rehearsed.
Watch real users
Can a new member install, join, talk, mute, share a file and recover after a restart without help?
Review boundary: Source downloaded from main on 2026-09-09; GitHub lists 9300bff as the latest commit. Build, vet, 266 tests and the race detector were run on macOS. The voice client was built on macOS. Four findings were reproduced with throwaway tests against a real server and real WebSockets, then the tests were deleted. Not done: a multi-user voice session, a Windows or Linux build, a user study, or any exploit beyond those tests. User reactions remain hypotheses. This review supports a backlog, not a release certification.