J3vbandClaude Fable 5 f92452124d test(b3-6): benchmarks and a recorded bench baseline — six Benchmark* on the B6 gate paths, bench-baseline.sh with a missing-name guard (#1459)
* test(b3-6): client connection model test — fc.commands over the real stack

B3-6 item 4 (Tier 3a of docs/plans/bug-detection-improvements.md). Property
tests find bad functions; this repo's recurring bugs are bad orderings, and
nothing generated orderings.

Client/tests/unit/connection.model.test.ts drives the real connection stack —
createWsClient() + wireDispatcher() + the real stores — through seven
fc.commands (Connect, Disconnect, RegisterNow, Receive(id, seq), Supersede,
Resync, Logout) against a minimal reference model, checking four invariants
after every command: no duplicate message ids, a monotonic seq watermark
(observed at the auth frame, reset only at the modelled epoch resets), a
verified peer that never flips to unverified, and a superseded attempt's
teardown that never kills the newer session.

Only the boundaries are mocked: the Tauri IPC wire (the shared ws-mocks
helper) and the LiveKit / notification / toast / identity leaves, as in
dispatcher.test.ts. Seeded (OWNCORD_MODEL_SEED, default fixed) so a failure
replays exactly; 150 runs of up to 30 commands, ~0.9 s for the file. A second
test asserts every invariant family was actually reached, so a family that
stops being reachable fails instead of silently passing.

Test only — no Client/src/ change, so B7's rule holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): evidence block for item 4 (client connection model test)

Records the branch and commit, the seven commands and four invariants, the
RED counterexample for each invariant family with its restored control, the
GREEN runs, and the numbers (seed 20260830, numRuns 150, maxCommands 30,
1083 invariant checks, 119 ms of test time).

Also notes the two spec details resolved against HEAD: RegisterNow has no
client-side symbol (it is the server's hub registration, observed here as the
ready-snapshot/queued-frame redelivery), and the design's aborted voice
attempt is reachable from the connection layer through the dispatcher's stale
voice_leave guard rather than through LiveKitSession's join generations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): connection model — drop the tautological seq check, make coverage counters count the non-trivial case, guard the seed

Review findings on B3-6 item 4.

The invariant-2 assertion in checkInvariants compared the model to itself and
could not fail, while reading as though the seq watermark were checked after
every command. Deleted; the header comment now says where the real assertion
lives (connectCmd, against that connect's own auth frame).

Both coverage counters were counting their no-op case: exercised.seq counted
the initial connect declaring last_seq 0, and exercised.verified counted the
check that runs immediately after Supersede seeded the verifications itself.
They now count only a resume (last_seq > 0) and a verification check that
survived some other command, so "reached every invariant family" fails if only
the trivial form remains. Both still hold at the default seed and at 99.

A malformed OWNCORD_MODEL_SEED now throws instead of handing fast-check the
NaN (or the 0 an empty variable coerces to) and running a different suite than
the one that was asked for.

The evidence block's "+0.4 s on the full client suite" was never measured —
both full-suite runs included this file. Replaced with the file's own measured
cost and the observed suite spread, which is larger than that cost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): connection model — a buffer resume replays events after auth_ok; ready only on the fresh/fallback path (Codex P2 on #1455)

Verified against the server before changing anything. reconnectWriteReplay
(Server/ws/serve.go:593) writes auth_ok with the replay tier and then the
missed events, and never a ready; only reconnectPrecheck falling through to
handleFreshConnect produces auth_ok(none) + ready. The epoch-1 fixtures record
exactly that split: fresh-connect.json is auth_ok(none) -> ready -> ...,
resume-replay.json is auth_ok(buffer) -> presence -> chat_message -> presence,
with no ready anywhere. Codex is right.

Connect now drives whichever shape the model's watermark implies: last_seq 0
takes the fresh path unchanged, last_seq > 0 takes the resume path — auth_ok
with the tier, then one replayed chat_message carrying the next seq, and no
ready. The replayed frame is a message that committed while we were away, or,
once the id pool is exhausted, a redelivery of one already held, which is the
other real replay shape. An assertion after the handshake requires that frame
to be in the store: on this path the replay burst is the only thing that
repairs client state, so nothing else can cover for it.

RegisterNow had the same defect one step smaller — a bare ready, which the
server never writes either. It now sends the full auth_ok(none) + ready
handshake before the queued redelivery, so every ready in the file follows the
auth_ok that precedes it on the wire, and the redelivered frame carries the
server's restarted counter (OC-0032).

exercised.resumeReplay joins the coverage counters, so the resume path cannot
quietly stop being generated. Reverting the resume branch to the pre-fix shape
fails on [Connect,Receive(id=1,seq=1),Disconnect,Connect] with
"expected [ 1 ] to include 2" and on the family counter. Merely adding a ready
alongside the replay still passes — recorded in the report as the honest
result: that shape does not break an invariant, it just lets a snapshot do the
repair the replay burst is supposed to do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* feat(b3-6): contract drift — generated route, table and config-key indexes

B3-6 item 9 (workstream 10). `check:server` already diffs the two
generators; this adds a third for the three server contracts that only
prose described until now.

`Server/cmd/gendocs` rewrites one marked block per document:

- `docs/api.md` "Route index (generated)" — 111 rows from `chi.Walk` over
  the production router built with uploads, voice and the GIF proxy on,
  the same scaffolding `api/absence_contract_test.go` uses. Carries that
  test's vacuity guards: fewer than 100 routes, or no `/admin/` route,
  fails the run.
- `docs/schema.md` "Table index (generated)" — 34 rows from `sqlite_master`
  and `pragma_table_info` on an in-memory database with the migrations
  applied. sqlc exposes no catalog, so the migrated schema is the catalog.
- `docs/server-configuration.md` "Key index (generated)" — 56 keys from the
  koanf struct tags, each mapped to the `###` section of the hand-written
  reference that names it. A key documented nowhere fails the run by name.

Output is padded exactly the way Prettier formats a table, so the drift
check and the hygiene gate agree instead of undoing each other.

Wiring, copied from protocol-verify: `make docs-generate` / `make
docs-verify`, a `DOCS_VERIFY` step in `check:server` and the generator in
`generate` (`scripts/run.mjs`), a CI step on the ubuntu leg of
`server-build-test`, and a `.githooks/pre-commit` block on router, handler,
migration, config and generator paths.

Everything hand-written in the three documents is untouched. The new
`cmd/gendocs` file imports `db` for the catalog, so it takes a boundary row
in the B3-0 inventory and `server-boundaries.md` is regenerated with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): evidence block for item 9 (machine-readable contract drift)

Records the three RED controls and their restore, the counts (111 routes,
34 tables, 56 config keys, 0 undocumented), and two corrections to the item's
spec: the configuration reference table lives in docs/server-configuration.md,
not docs/deployment.md, and sqlc exposes no catalog — the migrated in-memory
schema is the catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): gendocs — exclude ANALYZE artifacts, honest hook message, admin routes trigger the hook, generate order, width ceiling

Review findings on item 9.

1. The table index dropped `sqlite_stat1` / `sqlite_stat4`. `db.Migrate` runs
   ANALYZE after applying migrations, so those hold planner statistics, not
   schema — and `sqlite_stat4` exists only because the current
   modernc.org/sqlite build has STAT4, so a driver bump would have failed the
   docs drift check on an unrelated dependency PR. Filtered with GLOB (LIKE's
   `_` is a wildcard), block regenerated, header line's justification
   corrected: 34 -> 32 tables.
2. The pre-commit message now covers both failure modes — stale blocks are
   regenerated and staged, a key the tool named as undocumented is documented
   in docs/server-configuration.md.
3. `Server/admin/.*\.go` added to the hook's trigger: the 34 `/admin/api/*`
   routes are registered there, not in api/router.go, so a new admin route
   could commit stale docs locally.
4. `run.mjs` `generate` runs gendocs after `sqlc generate` — gendocs compiles
   the api package, which imports db/dbgen.
5. The vacuity guard now requires a traversed `/admin/api/` subroute rather
   than any `/admin/` path, which the per-method mount catch-alls satisfied on
   their own, so its message is true. `writeTable` gained a comment naming its
   ceiling: padding counts runes, Prettier counts display width, so a
   full-width cell would diverge — none exists in the generated content.

Evidence block updated for the new table count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): gendocs — generate the route index from the full-tag build with telemetry on; the hook triggers on every api/ and admin/ Go file (Codex P2s on #1456)

1. `/metrics` was missing from the route index. It mounts only when
   `telemetry.PrometheusHandler()` returns non-nil (api/router.go:431-437),
   which needs the otel build tag AND telemetry enabled at runtime; the
   generator ran in the default build with telemetry unset, so the index
   omitted a production route.

   The route index is now the superset build. The scaffold config enables
   telemetry with the Prometheus exporter and the tool calls telemetry.Init
   the way main.go does, and every invocation passes -tags otel,wazero:
   Makefile docs-generate/docs-verify, scripts/run.mjs (DOCS_VERIFY and
   generate), .githooks/pre-commit, the regenCmd quoted into all three block
   header lines, and the CLAUDE.md row. ci.yml inherits it through
   `make docs-verify`. The route block's header line now says which build it
   came from and what is enabled.

   Rather than a build-tag constant, the tool checks the condition that
   actually gates the route: if telemetry.Init leaves no Prometheus handler
   it exits non-zero naming the tags, so the default build cannot quietly
   generate a short index.

   Nothing under Server/api or Server/admin carries a build constraint, so
   wazero adds and removes no route; it rides along so one build serves the
   whole repository. Route count 111 -> 121 (ten per-method rows for the
   /metrics mount, the same shape chi gives /admin and /livekit).

2. The pre-commit trigger named individual api/ files and missed
   client_update.go, whose MountClientUpdateRoute registers a route directly.
   It is now the whole of Server/api/ and Server/admin/ — naming files
   individually is how a trigger goes stale — plus the existing migrations/,
   config/config.go and cmd/gendocs/ patterns.

Evidence block updated: route count and the tagged-build decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): fuzz seeds — epoch-1 corpora for every target, protocol + predicate-parity fuzz targets

Workstream 3. Every Fuzz* target `make fuzz` loops over now has a committed
corpus, so a plain `go test ./...` replays the real wire and not only the
hand-written f.Add shapes. 17 -> 20 targets, 3 -> 20 with a corpus, 98 corpus
files added.

Two new targets:

- ws/protocol_fuzz_test.go — FuzzHandleMessageDecode drives the inbound
  envelope decoder (handlers.go) through a headless NewHubForTest +
  NewTestClient; FuzzCommandPayloads drives all 24 payload decoders in
  commandConstructors, which are pure funcs of (userID, reqID, raw) and so
  need no hub at all. Between them they pin: a rejected frame yields no log
  fields and one invalid-count tick, an accepted frame yields the 64-byte
  capped fields and re-encodes to an equal envelope, a rejected payload never
  returns a command alongside its error, and a decoded command always carries
  the authenticated sender rather than a user id lifted from the payload.
- permissions/predicates_fuzz_test.go — FuzzPredicateParity continues the
  B2-5 parity tables by machine: each predicate against the two-layer
  override formula written out longhand, sentinel included (so "an
  unauthorized caller never learns a channel is archived" is pinned), plus
  CanAdmitSession == CanViewChannel and CanType == CanSendMessage.

Corpus entries are generated from protocol/fixtures/epoch-1 — every distinct
c2s frame of the 11 journeys for the two ws targets, and the role permission
values, channel types, message bodies, usernames, avatar URL and channel ids
those journeys carry for the rest. Replay costs <= 0.02s per target.

Test-only: no production file changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): evidence block for item 5 (fuzz seeds)

Seed counts per target, the two RED negative controls with their failing
excerpts, the replay wall clock, and — as the shared rules require — what was
found stale at HEAD for each of the item's four pointers and what was done
instead: the inbound decoders live in handlers.go/command.go not messages.go,
permissions.Subject has no wire form so parity replaces "round-trips", there
is no pure upload-admission function to fuzz, and there is no recovery-token
parser at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): FuzzParseMentionTokens compares with db.LowerASCII, the OC-0131 rule — make fuzz green again

The target still asserted the Unicode fold (strings.ToLower) that OC-0131
removed from parseMentionTokens: usernames.username is COLLATE NOCASE, which
folds ASCII A-Z only, so the parser folds with db.LowerASCII to stay in step
with GetUserIDsByUsernames' equally ASCII-folded map key. Any mention of a
name starting with an uppercase non-ASCII letter (@Ǥ0, @Ł) therefore failed
the assertion, and `make fuzz` found one within four seconds.

The assertion now uses the same fold the code under test does. Nothing else
in the file changes, and no production behaviour is involved — the fold was
already correct; only the check disagreed with it.

30s of fuzzing on a cleared cache: PASS at 1,159,227 execs (it failed at
66,255 before).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): fuzz seeds — every command constructor seeded, the auth payload decoder gets its own target, evidence corrected

commandConstructors registers 26 decoders, not the 24 the evidence block
claimed (the count missed the two E2EE keys), and only 16 had any input: ten
commands appear in no epoch-1 journey, so presence_update, call_ring,
call_decline, voice_token_refresh, voice_mute, voice_deafen, voice_camera,
voice_screenshare, voice_mod_deafen and voice_mod_kick were reachable only if
the fuzzer guessed the type string. Each now has a corpus entry carrying a
minimal valid payload taken from its own decoder struct, with the fixture
channel and user ids where they apply.

TestCommandPayloadSeedsCoverEveryConstructor is the guardrail that keeps that
true: it unions the hand-written seed list with the committed corpus and fails
when a registered command has neither, or when a seed names a command nothing
registers. Removing one corpus entry fails it by name.

auth was decoded by neither target. It is not in the constructor table —
authenticateConn reads it before the hub knows the client — so its two corpus
entries were inert under FuzzCommandPayloads. They move to FuzzAuthPayload,
which pins the property that matters in a handshake a stranger controls: no
numeric field takes a value its Go type cannot hold, and the token that will
be hashed is the string the JSON carried. The production decode is inline
behind a live socket read and a session lookup, so the target mirrors the
struct and the comment says why rather than reshaping production to expose it.

Corpus entries now credit the journey that owns the frame: the ping frame to
ping.json, the auth frame to fresh-connect.json.

Test-only: no production file changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): fuzz seeds — auth target gates on the real epoch constants; corpus reader fails on a malformed entry

FuzzAuthPayload was proving encoding/json behaviour against a copy of the
handshake struct and nothing more. It now mirrors the two rejections
authenticateConn actually makes — the decode error and the empty token as one
(serve_auth.go:58), then the epoch window (:62) — using minClientEpoch and
ProtocolEpoch themselves, so moving either constant or that gate turns the
target red instead of leaving it quietly stale. The load-bearing case is the
absent epoch: every client up to v1.2.0-alpha.4 predates the field and relies
on the zero value being inside the window, so raising minClientEpoch above 0
now fails here rather than in the field. Setting it to 1 locally fails both
fixture-derived corpus entries and two seeds.

Deciding "absent" needed care, and fuzzing found that out in three seconds:
encoding/json falls back to a case-INSENSITIVE tag match, so "epoCh" populates
Epoch while an exact key lookup calls the field missing. The probe now decodes
into a *int, which is the same matching the server does, and three seeds pin
the rule.

corpusFirstString skipped a corpus file with no string(...) argument, which
would have let a malformed entry masquerade as a seeded command while the
coverage test still passed. It is now a failure naming the file.

The struct comment cited serve_auth.go:44; the struct starts at :45.

Test-only: no production file changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): fuzz seeds — token expectation uses struct decoding semantics; parity oracle states the zero-permission ordering (Codex P2s on #1457)

FuzzAuthPayload derived the expected token from an exact key lookup, so
{"token":"a","TOKEN":"b"} failed the target: encoding/json resolves both keys
to the tagged field and the last one wins, leaving the handshake holding "b"
while the lookup expected "a". The expectation now comes from a probe struct
carrying the same json:"token" tag, so it follows the decoder's field
resolution rather than the raw key set — the same correction the epoch probe
already needed. A corpus entry pins it; reverting the probe fails on that
entry by name.

rawHas mirrors Subject.Has, which applies the Administrator bypass before the
zero-permission refusal, so an administrator holds an empty mask where
HasPerm(_, 0) is false. Parity with production is this target's purpose, so
the ordering stays; what changes is that the oracle's contract comment now
states it instead of claiming the tidier rule, and
TestSubjectHasZeroPermIsAdminBypassed records the divergence as observed
behaviour with a message that says to move both together if it is ever
changed deliberately.

The evidence block gains the call-site survey behind that: every leaf caller
of Subject.Has names a permissions.* constant, the variable-forwarding
wrappers are all reached with named constants, and the one table-driven site
has two rows.

No production code changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* feat(b3-6): nightly docker smoke on dev — its own workflow, plus a timeout on ci.yml's verify job

dev is not a push trigger, so an image regression on dev is only found when a
dev -> main PR opens. A nightly at 03:00 UTC closes that window.

Not a schedule on ci.yml, which is what the plan proposed: a scheduled run
attaches its check runs to the default branch's tip, so the jobs skipped to
scope the nightly to the smoke would land on main's tip as `skipped` under
seven of the twelve required contexts. verify-gate-evidence.mjs:45-61 keeps
the latest attempt per name and does not count `skipped` as success, and
release.yml's gate-evidence job gates every build and publish job on it — so
a tag cut from a main tip that had sat through one nightly would be refused.
A separate file writes one check run, under a name that is no required
context, and leaves ci.yml's job selection untouched.

The nightly checks out dev explicitly, since a schedule always reads the
workflow from the default branch. Its build and smoke steps are the
server-docker-build ones verbatim — same pinned actions, same commands, same
Server/scripts/docker-smoke.sh that release.yml runs — with a keep-in-sync
comment on both jobs.

ci.yml's only change is `timeout-minutes: 20` on server-docker-build. The
plan asserted that B1-7's guard check already enforced a timeout there; it
does not (check-workflow-guards.mjs audits only the workflows in METERED,
which is claude.yml alone), and the job had none, so it inherited GitHub's
360-minute default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): evidence block for item 8 — the deviation, the gate conflict behind it, and the proof command

Records why the nightly is its own workflow when the item says it is not:
a skipped job still writes a check run (observed on main's tip, where Tauri
Full Build reports `skipped`), a scheduled run attaches to the default
branch's tip, and verify-gate-evidence.mjs:45-61 would then read seven of the
twelve required contexts as skipped on the commit a release is tagged from.

Also: that a schedule only runs from the default branch, so the nightly does
not start until this file reaches main at the next release merge; the
contents of the new workflow against the job it mirrors; the controller's
proof command with the observed-SHA placeholder; and the false premise in the
item's "B1-7's guard check enforces both", which is what the one-line ci.yml
timeout answers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): item 8 — proof recorded (run 33301623322), cache-scope note, comment count

Run 33301623322 fired from the temporary push trigger, now dropped: "Print
checked-out revision" logged event=push on the branch ref, and
git rev-parse HEAD printed 75d64dd412 — dev's
tip at the time, not the branch's, which is what `ref: dev` exists to do.
Build and boot-smoke green.

Two facts the evidence block was missing. A scheduled run has
github.ref = refs/heads/main, so the buildx type=gha cache is scoped to the
default branch while the layers come from dev's tree — the only behavioural
difference from the PR job, and harmless because the cache is
content-addressed. And a red nightly reaches the repository owner, by
GitHub's scheduled-workflow failure email.

ci.yml's release-gate comment said docker-smoke.sh is called "from both
workflows"; it is three now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): seeded hub simulation and fault-injected transport (items 2 and 3)

Server/ws/hub_sim_test.go drives a PCG-seeded interleaving of subscribe,
broadcast (global, channel, recipients-scoped, sequenced DM), ack, disconnect
and reconnect-transfer over a real Hub with eight headless clients, and a
model client checks the per-client FIFO/seq oracle from Server/CLAUDE.md
after every step: strictly increasing seq per connection, exact audience
delivery (nothing lost, extra or twice), a resume replayed exactly from the
watermark to the seq at which registerNow ran, h.seq advancing only for a
frame that reached the ring, an evicted watermark refused a replay, and a
replaced socket's late teardown reporting replaced=true. The resume step runs
reconnectRegister as-is (snapshot and registerNow under one seqMu section)
on a goroutine while up to three broadcasts race it; the model recovers the
snapshot point from the replay burst, so any interleaving is checkable.

OWNCORD_SIM_SEED replays one seed, OWNCORD_SIM_SEEDS (default 20) and
OWNCORD_SIM_STEPS (default 200) size a run, and a failure prints the seed,
the step, a ready-to-paste replay line and the last steps. The default runs
in about 2.3 s under -race; `make sim` runs 10,000 steps per seed.

Server/ws/faultconn_test.go is the seeded, deterministic frame transport the
simulation reads through: drop, tail cut, duplicate, bounded reorder and an
order-preserving lag from its own PCG stream, exported to ws_test through
export_test.go as NewFaultConnForTest. The simulation's default wire is a lag
plus tail cuts, the one fault a TCP-backed WebSocket really has; the silent
drop is the negative control that proves the oracle notices a lost replay.

BenchmarkReconnectStorm resumes 50 live clients per op through the same
path. newTestHub and its three seed helpers take testing.TB so the benchmark
can share them. No production code changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): evidence block for items 2 and 3 (hub simulation, fault transport)

Oracle, the RED/GREEN excerpts (inverted assertion, seed replay, drop-all
wire, unsynchronized registerNow), wall-clock and benchmark figures, gate
results and the epoch-harness decision, under B3-6 in the plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): hub sim — deterministic topic limiter for exact replay, a floor on the step mix, auth-frame-wins under transfer, wire-seed mixing

Review fixes for items 2 and 3.

Exact replay. TopicRateLimiter keys its window on time.Now(), so at 10,000
steps the shed boundary was a timing-dependent step and every later seq
differed between runs; the printed OWNCORD_SIM_SEED line could not reproduce
a failure. FreezeTopicLimiterForTest (export_test.go) swaps the hub's limiter
for one whose window never rolls over inside a run, so the shed is a
per-channel count. Three more leaks of the scheduler's interleaving into the
trajectory surfaced once that was fixed, and are closed the same way — by
taking the decision away from the race or making both outcomes read the
same: racing frames are pulled into the wire at attach time (queue fill no
longer depends on which side of the snapshot they fell), the racing burst is
aimed at the resuming client's own audience (a replay-superset frame was
read iff it landed before the snapshot), and a resume within the burst's
reach of the ring's eviction boundary is not raced (the allocations could
evict the watermark before or after the snapshot and pick replay or
fallback). Three runs of one seed now print byte-identical stats; what still
varies — how many racing seqs land in the replay burst — is printed on its
own line and stated in the doc comment.

Floor. TestHubSimulation aggregates the per-seed stats and requires every
load-bearing transition (the four broadcast kinds, resume, fallback, fresh,
cut, kicked, racing-in-replay) at least once across the default run, so a
constant change cannot turn the simulation into no-ops with CI green. Its
first run found that the overflow kick had become unreachable at 200 steps;
the sim's queue is 12 now (production stays 256).

Also: the resume step draws active_channel_id as none / the open channel /
another channel whether or not the old socket is registered, so registerNow's
auth-frame-wins branch runs under the transfer; the wire's PCG takes the seed
and (idx<<32|conns) as its two words instead of an arithmetic mix that
collided past 131 connections; seedTestUser takes testing.TB like its
siblings; the evidence block lists what the simulation does not cover and the
-timeout 60m the ten-pass deadlock gate needs, with the same line under
Traps carried forward.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): hub sim — floor on raced resumes instead of the scheduler-decided key; log when the floor is skipped

racing-in-replay was the one floor key the scheduler decides, so a correct hub could in principle fail the floor on a run where no racing seq landed inside a burst. The floor now keys on raced resumes — a resume that got a replay while a burst ran (burst > 0 && ok), which the seed determines — and racing-in-replay stays a printed count. The floor also says so when it is skipped for OWNCORD_SIM_SEED or a shorter seed list instead of returning silently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): hub sim — raced counts a broadcast allocated while registration was in progress, not the requested burst

Codex P2 on #1458: raced incremented on burst > 0, which counted a resume whose goroutine had returned before the first broadcast ran and one whose every channel broadcast the limiter shed, so the floor could pass with no broadcast overlapping a registration. raced now counts a resume where a racing broadcast allocated a seq while the reconnect goroutine had not yet been observed to return (the driver's done handshake, checked after each allocation). That is the scheduler's call, so raced moves off the deterministic stats line and is floored only in aggregate across the 20 default seeds — 179 bursts per run, 178–179 observed overlapping in three measured runs, odds named in the comment and the evidence block. The requested burst stays a printed, seed-determined count (bursts) and is floored as before; the floor logs its totals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): benchmarks and the bench-baseline script (item 6)

Five new Benchmark* beside the reconnect storm the hub-simulation item
delivered, one file per package touched:

- BenchmarkPermissionInvalidation (ws) — RefreshChannelVisibility over 50
  registered clients, the fan-out an override or role edit triggers.
- BenchmarkBroadcastFanout (ws) — one sequenced global broadcast through
  deliverBroadcast to 100 headless clients, queues drained per iteration.
- BenchmarkReplaySelection (ws) — EventsSince over a ring filled to the
  hub's own capacity, from a watermark halfway back.
- BenchmarkReadStateWrite (service) — one mark_read/channel_focus through
  ChannelService against an in-memory SQLite; a distinct user per
  iteration so the no-op write-skip never short-circuits the write.
- BenchmarkUploadAdmission (api) — sanitizeUploadFilename then
  storage.ValidateFileType on a fixed fixture.

scripts/bench-baseline.sh runs the six through benchstat (pinned, run with
go run, not a go.mod dependency) into docs/plans/b3-bench-baseline-<date>.md.
It fails when an expected benchmark name is absent from the run, so a rename
cannot silently shorten the baseline; `make bench-baseline` wires it in, and
nothing else does. Baselines are recorded, not gated.

quietLogs points the default logger at io.Discard while a hub benchmark
runs: go test prints a benchmark's name before running it, so the hub's
per-registration INFO lines land inside the result line and benchstat drops
the benchmark from the table. Three service seed helpers now take
testing.TB, as the ws helpers already do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): recorded bench baseline 2026-08-30 and the item 6 evidence block

The baseline `make bench-baseline` produced at c7917fcc (go1.26.7
windows/amd64, Ryzen 9 7950X3D, -count=6, 65 s): benchstat medians for the
six B3-6 benchmarks, with a provenance block and a "reading these numbers"
note. Recorded, not gated — no CI step reads it and no workflow runs the
script; the performance gate is B6's.

Plus the plan index row and the evidence block under B3-6, carrying the RED
excerpt (a renamed benchmark makes the script exit 1 naming it, writing no
baseline), the -benchtime=1x smoke showing all six ran, and the headline
figures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): bench-baseline — render to a temp file and move on success, guard the benchstat table too, dump unfiltered output on failure

The `{ … } >"$out"` group truncated the committed baseline at group start, so
under `set -euo pipefail` a benchstat failure — first-run module fetch, proxy
outage, a bad pin — left a 13-byte stub where the baseline had been. The
document now renders into the temp directory and is moved onto its path only
once everything has succeeded, making "no baseline written on failure" true
for every path, not just the early ones.

The expected-name guard grepped the raw output, and a result line corrupted by
output written from inside the benchmark still starts with the benchmark's
name — so benchstat drops that row, exits 0, and the guard sees nothing wrong.
The same loop now runs a second time over the rendered table, where benchstat
prints the name without its Benchmark prefix; a name missing there fails the
run naming it.

Also: tee the unfiltered stream so a failure dump shows toolchain and module
errors rather than the grep-filtered view; reject a BENCH_COUNT that is not a
positive integer; drop the redundant `|| exit 1` after `cd` under `set -e`.

The baseline document gains three lines under "Reading these numbers" — that
`go test ./...` runs the three packages' benchmarks concurrently, that
PermissionInvalidation measures the uncached bare-hub path, and how
regeneration works — and the plan index row says only the newest baseline is
kept. Regenerated on the rebased tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): bench baseline regenerated on the rebased tree

The document's header names the commit it was measured on; the rebase onto
the chained hub-sim tip replaced that SHA, so the six benchmarks were re-run
(-count=6) on the rebased tree and the table re-recorded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 15:24:53 +00:00
2026-08-27 21:37:40 +02:00

CI Release Status Go Tauri Platforms License: AGPL-3.0

OwnCord

A self-hosted chat app I build for me and my friends — text channels, voice and video, and a server you actually own.

Alpha, and a hobby project. This is something I build for fun and run for a small group of friends. It isn't a product, it comes with no support commitment, and it isn't production-ready. Expect rough edges, rapid changes, and the occasional breaking change.

Don't use it for anything sensitive.

It's a Go server plus a Tauri desktop client: real-time messaging, voice/video via LiveKit, file sharing, and a web admin panel. Run the server on a spare box or a VPS, hand your friends an invite code, and that's the whole thing.

OwnCord Client

Login Page Admin Panel

How it's built

Most of the implementation is generated with AI tooling, with quality held up by automated checks — CI, tests, linting — and by me and my friends actually using it. That keeps iteration fast, and it also means behaviour can change quickly between releases.

What works right now

Area Status
Core chat flow Working in alpha
Voice/video Working in alpha
Admin panel Working in alpha
Security hardening Ongoing review passes; findings and their statuses are tracked in the dated audits in docs/ (see the Docs Index below)

Platform Support (Current Releases)

Component Windows x64 Linux x64 Linux ARM64
Server binary Yes Yes Not yet
Desktop client Yes (NSIS installer) Yes (AppImage, .deb) Yes (AppImage, .deb)
Docker server N/A Build from source (compose) Not yet

Start Here

Quick Start

Option A: Prebuilt binaries

  1. Download assets from Releases (binaries, checksums, signatures, and a full source snapshot per release).
  2. Run the server binary:
    • Windows: chatserver.exe
    • Linux: ./chatserver
  3. Open https://localhost:8443/admin and complete the setup wizard — it creates your Owner account and configures the server for you (settings are saved to config.yaml automatically).
  4. Generate invite codes in the admin panel and share them with friends.

Option B: Docker (Linux server)

cd Server
cp .env.example .env
cp livekit.yaml.example livekit.yaml
# Edit both files before starting
docker compose up -d

See the full setup guide in docs/deployment.md.

The client uses TOFU (Trust On First Use) for self-signed certificates: it prompts once, then pins the certificate for future connections.

What OwnCord Already Has

  • Real-time channels and direct messages over WebSocket
  • Voice/video channels via LiveKit — the LiveKit server binary is downloaded and managed for you
  • Invite-only registration and role-based permissions
  • Web admin panel with logs, backups, and update tooling
  • File uploads and inline media rendering
  • TOTP 2FA support and API rate limiting
  • Desktop client auto-update with signature verification
  • WASM plugin system (slash commands; sandboxed, default-disabled — enable via plugins.enabled and build with -tags wazero)
  • GIF picker — off by default; each server supplies its own Klipy key via gif.api_key (setup)

See deeper feature and architecture docs in docs/architecture/ and docs/protocol.md.

Architecture

Two main components:

  • Go server (REST API, WebSocket hub, SQLite, admin panel)
  • Tauri v2 desktop client (Rust backend + TypeScript frontend)
+---------------------+         +---------------------+
|   OwnCord Client    |         |   OwnCord Server    |
|   (Tauri v2)        |         |       (Go)          |
|                     |         |                     |
|  +---------------+  |  WSS    |  +---------------+  |
|  |  Chat UI      |--+------->|  |  WebSocket Hub|  |
|  +---------------+  |         |  +---------------+  |
|  +---------------+  |  HTTPS  |  +---------------+  |
|  |  REST Client  |--+------->|  |  REST API     |  |
|  +---------------+  |         |  +---------------+  |
|  +---------------+  | LiveKit |  +---------------+  |
|  |  Voice/Video  |--+------->|  |  LiveKit SFU  |  |
|  +---------------+  |         |  +---------------+  |
+---------------------+         |  +---------------+  |
                                |  |  SQLite DB    |  |
                                |  +---------------+  |
                                +---------------------+

Build and Test

Prerequisites

  • Go 1.26+
  • Node.js 24+ (see Client/.nvmrc)
  • Rust stable (client builds)

Build from source

# Server (Windows)
cd Server
go build -o chatserver.exe -ldflags "-s -w -X main.version=1.2.0-alpha.4" .

# Server (Linux)
cd Server
CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.2.0-alpha.4" .

# Client
cd Client
npm install
npm run tauri build

Core verification commands

Everything CI gates on, from the repository root:

npm run check                  # server + client + Rust
npm run check:server           # or one stack at a time
node scripts/run.mjs --list    # exactly what each task runs, and where

Or run the stacks directly — the facade is a convenience, not the only path, and server work needs no Node at all:

# Server
cd Server
go test ./...

# Client
cd Client
npm run typecheck
npm run lint
npm test

For the full command set, use docs/contributing.md.

Configuration

On first run, the server generates config.yaml and a local data/ directory:

data/
├── chatserver.db
├── certs/
├── uploads/
└── backups/

Key options include TLS mode, upload limits, LiveKit settings, and admin CIDR restrictions. See docs/server-configuration.md.

Security and Vulnerability Reporting

  • For vulnerabilities, use GitHub Security Advisories (private disclosure flow).
  • Do not open public issues for security bugs.
  • Read full policy and hardening notes in docs/security.md.

Update Signing Notes (Maintainers)

Client and server update signing keys are intentionally separate.

Required Actions secrets for release signing:

  • TAURI_SIGNING_PRIVATE_KEY
  • TAURI_SIGNING_PRIVATE_KEY_PASSWORD
  • SERVER_UPDATE_SIGNING_PRIVATE_KEY
  • SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD

When rotating the server updater key, update Server/updater/server_update_public_key.txt and use staged rollover for live fleets.

Docs Index

docs/README.md is the complete index — every document in docs/, grouped by whether it is guidance, a reference contract, a dated audit, or a plan. The most-used entries:

Audits are dated snapshots and are not maintained after the fact — read them as history. docs/README.md lists all nine, newest first.

Contributing

  1. Create a branch from dev (the active development branch).
  2. Keep changes focused and tested.
  3. Open a PR targeting devdev is merged to main for releases.

See docs/contributing.md for the full process.

Getting Help and Reporting Problems

Nothing here is a support promise — see the note at the top — but there is a right place for each kind of message:

Kind Where
A reproducible bug Issues
A question about setup or usage Discussions → Q&A
An idea or feature suggestion Discussions → Ideas
A security vulnerability Private advisory — never an issue

License

AGPL-3.0

S
Description
OwnCord is a self-hosted, open-source chat platform with text channels, voice/video chat, direct messages, and a desktop client — built for communities that want full control over their data.
Readme AGPL-3.0
111 MiB
Languages
TypeScript 48.7%
Go 44%
JavaScript 2.2%
Rust 2.1%
CSS 1.4%
Other 1.5%