918 Commits
Author SHA1 Message Date
J3vbandClaude Fable 5 63c87df487 refactor(b3-8): settings/audit family behind SettingsService (S-09, family 1) (#1477)
* feat(service): settings family — SettingsService over the Store seam

The B3-8 settings/audit family's service: List, Patch (whitelist,
boolean normalization, the require_2fa preconditions incl. the TOTP
census and the unrelated-key guard, atomic apply, one audit row per
changed key) and Setting (the read the hub and the backup scheduler
consume; wraps db.ErrNotFound as the store reports it). db gains
ApplySettings — the handler's raw upsert loop as one hand-written
transactional wrapper where raw SQL belongs — and Store carries it.

parseSettingsPatchBool duplicates auth.go's parseBooleanSettingValue
with the admin surface's own pinned error wording; both messages are
test-pinned, so the twins stay separate.

Service-level characterization in settings_test.go mirrors the
admin/api_test.go PATCH rows and adds the service-only contracts
(ErrNotFound wrap, audit rows, multi-key apply).

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

* refactor(admin): settings handlers thin over SettingsService; scheduler reads via it

handleGetSettings/handlePatchSettings become adapters (decode, delegate,
map ErrBadRequest to 400 with the service's prefix-free message); the
whitelist and every precondition now live only in the service, so
admin/types.go's copy is gone. MaintainBackups reads backup_schedule and
backup_retention through the service — its backup mechanics keep the
handle — and the maintenance chain threads Settings from the runtime the
hub stage built. NewHandler/NewAdminAPI gain the settings parameter;
all 207 construction sites wired via the newTestSettingsService helper.

Behavior parity pinned by the existing TestAdminAPI_*Settings* rows
(all green); the only unpinned change is the PATCH 500 path collapsing
its four stage-specific internal messages into one.

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

* refactor(ws): hub settings cache reads through a SettingsReader

The hub's server_name/motd cache consumes a consumer-side SettingsReader
interface (service.SettingsService satisfies it; HubOptions.Settings is
required and validated like DB and Limiter — the RequiredCollaborators
pin gains the refusal case). hub_settings.go no longer touches db at
all, so the import pin from the B3-5 finisher goes, and its allowlist
row goes with it; the thinned admin settings handler's row is deleted
too — two allowlist rows down, the settings family's persistence now
lives only in db/ and service/.

Test helpers (both ws package namespaces) default the reader over the
test database; newBareHub wires it explicitly; production passes
Services.Settings from StartRuntime.

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

* docs(boundaries,b3): settings/audit family re-measure and evidence

The backup pair takes its forecast boundary disposition; the family's
two deleted rows and the disposition counts (28/18/15 -> 24/18/17)
re-derived from the tool. Family evidence block appended to the B3-8
section; README B3 row records B3-5 complete and the family opened.

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

* fix(service): prefix-free ErrBadRequest wraps for the pinned admin bodies

The %.0w rework was meant to ride the service commit but was left
unstaged: with the plain %w wrap the PATCH error bodies carry a
'bad request: ' prefix the admin pins reject. Zero-width wrapping keeps
errors.Is(ErrBadRequest) while err.Error() stays exactly the pinned
message.

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

* test(app): lifecycle hub fixtures wire the required Settings reader

The two direct ws.NewHub sites in lifecycle_test predate Settings
becoming required; race across internal/app is green again.

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

* test(db): cover ApplySettings — the db coverage floor caught the gap

CI's coverage floor failed db at 78.9% against 79.3%: ApplySettings was
exercised only from service tests, which do not count toward db's own
figure. Four db-side rows cover the apply, the empty no-op, the
in-transaction failure rollback and the begin failure, using the
package's full-migration opener.

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

* chore(coverage): raise the service floor to the branch's measured 69.2

The settings family's tested service code raised the Linux figure from
the 67.8 floor to 69.2; the ratchet raises the floor in the same PR
(service is not in the run-varying set). db stays at 79.3 — this PR
restores its figure (79.5 with the ApplySettings tests), it did not set
out to raise it.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 15:13:38 +00:00
J3vbandClaude Fable 5 528ae260ee refactor(b3-5): hub.go under 400 — construction, settings cache and replay accessor move out (S-08, finisher) (#1476)
* refactor(ws): move construction, settings cache and replay accessor out of hub.go

Pure moves closing hub.go's B3-5 size target: HubOptions and NewHub ->
new hub_options.go; getCachedSettings and refreshSettingsLocked -> new
hub_settings.go; maxColdReplayLimit -> replay.go beside the family that
reads it. Normalized-diff residue: the new files' scaffolding plus
duplicates of five imports hub.go retains (auth, db, permissions,
plugin, service); errors, fmt and os moved outright. No identifier
changed.

hub.go now holds the Hub state, lifecycle (Run/Stop/graceful) and stats
accessors: 361 lines, under the 400-line exit target.

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

* docs(boundaries,b3): re-measure after the hub.go finishers; record the B3-5 exit

hub.go and hub_options.go become type-only boundary rows;
hub_settings.go reads through the h.db field and imports nothing, so it
carries no row (the header records that wrinkle). Disposition counts
re-derived from the tool's summary — boundary had been stale at 12 since
the seed-profile row. Finisher evidence block and the B3-5 exit summary
(five squash merges, all seven responsibilities, exit size table with
all three targets met) appended to the plan; README B3 row advanced.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 14:09:11 +00:00
J3vbandClaude Fable 5 df717e431c refactor(b3-5): voice leftovers join voice_broadcast.go; presence coalescer to hub_presence.go (S-08, PR 4) (#1475)
* refactor(ws): move voice broadcast leftovers into voice_broadcast.go

Pure move of broadcastVoiceEvent and broadcastVoiceEventWithLeaver from
hub_broadcast.go and VoiceSessionCount from hub.go into the existing
voice_broadcast.go, beside the voice rate-limit and quality tables.
Normalized-diff residue: one context import added to the destination;
the sources keep theirs. No identifier changed.

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

* refactor(ws): move the presence coalescer into hub_presence.go

Pure move of pendingPresence, QueuePresence,
dropQueuedPresenceAndBroadcast, presenceCoalesceWindow,
presenceFlushRaceHook, flushPresenceQueue and BroadcastPresence from
hub_broadcast.go into the new hub_presence.go, in source order.
Normalized-diff residue: the new file's scaffolding plus duplicates of
two imports the source keeps (time, db). No identifier changed.

hub_broadcast.go now holds only broadcast delivery and backpressure —
424 lines, under its 500-line B3-5 exit target.

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

* chore(invariants): hub_presence.go adapter row; hub_broadcast reason trimmed

The presence coalescer makes no persistence call — db.BroadcastStatus is
the doc's own pure-helper example — so the new file is an adapter row;
hub_broadcast.go's reason drops the presence half it no longer does.

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

* docs(boundaries,b3): re-measure after the voice and presence moves

Table regenerated (hub_presence.go adapter row, 17 -> 18; hub_broadcast
reason trimmed), header dated, split-PR-4 evidence block appended,
README B3 row advanced. hub_broadcast.go records its exit figure met
(424 < 500).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 15:25:29 +02:00
J3vbandClaude Fable 5 70875a36f6 refactor(b3-5): gather ws visibility and permission refresh into hub_visibility.go (S-08, PR 3) (#1474)
* refactor(ws): gather visibility and permission refresh into hub_visibility.go

Pure move of the visibility responsibility from the three files it was
spread across: the channelReadAudience family, RefreshChannelVisibility,
refreshChannelVisibilityCanSend, RefreshAllChannelVisibility and
revokeUnreadableChannels from hub_broadcast.go; computeAllowedChannels
from serve.go; bumpVisibilityWatermark and MarkVisibilityChanged from
hub.go. Normalized-diff residue: the new file's scaffolding plus
duplicates of five imports the sources keep (context, fmt, db, log/slog,
sync/atomic); the permissions import moved outright (out of
hub_broadcast.go and serve.go, into hub_visibility.go). No identifier
changed.

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

* docs(boundaries,b3): re-measure after the visibility gather

hub_visibility.go gains its DBImportAllow row; hub_broadcast.go's row
shrinks to the member/presence payload reads. Table regenerated, counts
and ratchet wording brought current (27 -> 28 move rows, zero new
calls), re-measurement header dated. Split-PR-3 evidence block appended
to the B3 plan; README B3 row advanced.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 13:59:33 +02:00
J3vbandClaude Fable 5 f9258efc6e refactor(b3-5): split ws — replay family and connection registry move to their files (S-08, PR 2) (#1473)
* refactor(ws): move reconnect replay selection and delivery into replay.go

Pure move of handleReconnect, reconnectPrecheck, reconnectSelectReplay,
reconnectVetColdTail, reconnectRegister, reconnectWriteReplay and
liveVoiceEventsSince — plus the replay-only maxColdReplay const, cut from
serve.go's shared const block — into the new replay.go. Normalized-diff
residue: the new file's scaffolding (package, import and const openers)
and duplicates of four imports serve.go retains (context, log/slog,
db, websocket); sync/atomic and telemetry moved outright. No identifier
changed.

serve.go keeps ServeWS, the shared connect-time helpers
(refreshUserSnapshot, applyConnectStatus, announceConnectPresence) and
computeAllowedChannels (visibility, moves with that responsibility).

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

* refactor(ws): move the connection registry and supersession out of hub.go

Pure move of Register, Unregister, registerNow, unregisterNow and
shouldMarkOffline into the new hub_registry.go. Normalized-diff residue:
the new file's package line and its log/slog import (hub.go keeps its
copy for the remaining code). No identifier changed.

The plan named registry.go as the destination, but that file holds the
message-type HandlerRegistry — a different registry; growing it would
conflate the two. hub_registry.go keeps them apart; deviation recorded
in the plan's evidence block.

clientEvent stays in hub.go: Run's dispatch loop owns the channel the
queued events flow through.

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

* docs(boundaries,b3): re-measure after the replay and registry moves

replay.go gains its DBImportAllow row (a split of serve.go's row,
type-only — the family's calls stayed with the shared helpers);
hub_registry.go needs none (no db use). Table regenerated, the
connection-rows note updated, the ratchet sentence now names the db
surface rather than the row count (26 -> 27 move rows, zero new calls),
disposition count and re-measurement header brought current. Split-PR-2
evidence block appended to the B3 plan; README B3 row advanced.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 13:19:28 +02:00
J3vb 0518e689d0 docs(register): enumerate OC-0349–0375 with owner-approved phases; identical-tree G-03; slim BG-07 (#1471)
28 register rows with phase ownership, the G-03/BG-07 rewords, and the
B1-exit strict-pin wording made precise (what tree identity detects; the
owner reapply step at hold points, from Codex's P1).
2026-08-31 12:59:57 +02:00
J3vb 1d1804ce5b refactor(b3-5): move handshake auth and fresh-connect out of ws serve.go (S-08, split 1) (#1472)
Pure moves into serve_auth.go and serve_ready.go with the boundaries
inventory re-measured; evidence in the B3 plan's split-PR-1 block.
2026-08-31 12:35:28 +02:00
J3vbandClaude e13adaf8b1 refactor(b3-4): hub constructor options — required collaborators validated at construction (S-11) (#1470)
ws.NewHub(opts HubOptions) (*Hub, error). The dispositions came from what
each setter's own implementation said, not the plan's guesses:

- The four rejectIfRunning-guarded knobs were construction wiring
  pretending to be mutable state and became validated options with their
  setters deleted: SetLiveKit, SetLiveKitProcess, SetPluginRegistry,
  ConfigureReplay. rejectIfRunning died with its last caller. The plan
  left SetLiveKitProcess "depends on whether the supervised process can
  restart" — it cannot: the restart path relaunches the whole app, so a
  process without a client is now refused at construction.
- The genuinely runtime-mutable stay setters, each with its why:
  SetEventPersister / SetEventStore / SetPluginEventSink are atomic
  hot-swaps internal/app wires one lifecycle stage after Run (the
  persister cannot exist before the hub; the sink consumes the built
  hub's broadcaster), and SetPendingVoiceModFlags is per-user state.
- DB and Limiter are required — before this change ws.NewHub(nil, nil,
  nil) succeeded (api's LiveKit-proxy tests built exactly that hub) and
  a missing collaborator surfaced as a later panic. Services stays
  optional: nil is the degraded fixture half the ws suite builds, and
  forcing a real service layer would change which handler paths the
  frozen tests take.

internal/app.StartRuntime builds the LiveKit client/process first
(buildVoice), passes everything through HubOptions, and starts the
supervised process only once the hub holds it (OC-0019 ordering kept);
construction failure is a startHub boot error now. Test migration:
newTestHub / newTestHubDeps / newTestHubWith keep the old call shape at
~76 sites across both ws package namespaces; the eighteen former setter
sites construct with options; TestNewHub_RequiredCollaborators and
TestHub_LiveKitProcessRequiresClient pin the refusals.

server-boundaries.md gains the after-B3-4 setter table; the plan's B3-4
section gains the dated evidence block.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 08:47:24 +00:00
J3vbandClaude Fable 5 ed69bb56c7 fix(admin): owner gate consumes the context role — no second lookup (OC-0379) (#1467)
* fix(admin): owner gate consumes the context role — no second lookup (OC-0379)

OC-0345's fix kept the redundant GetRoleByID its own title named (its
suggestedFix says why: reading adminRoleKey broke two tests that injected
only the user). Finish the job: ownerOnlyMiddleware now consumes the
*db.Role adminAuthMiddleware stored, exactly like requirePerm — missing
role fails closed as 401, position below Owner stays 403, and the query
plus its 503 branch are gone (a role read fault surfaces once, at the
perimeter). The signature drops *db.DB at all nine call sites, so a
reintroduced lookup is a compile-visible change.

Test-first: TestOwnerOnlyMiddleware_NoSecondRoleLookup renames the roles
table away with the role in context and demands 200 — red 503 against the
old code, green now. The old RoleLookupFailureIs503 test guarded a branch
that no longer exists in any form; a tombstone comment records where its
contract lives on (the perimeter's default branch). Added below-owner 403
and user-without-role 401 pins; the blackbox owner-route tests pass
unchanged.

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

* chore(ledger): record OC-0379 fixed; counts to 321/379 everywhere watched

New record for the residue bcdc0ef3 fixes, citing OC-0345's deliberate
half-fix; the register's truth table, its enumeration paragraph, and the
watched count lines in plans README, HP-0 and b0-baseline all move to
321 fixed / 379 total (check-doc-counts green, 21 claims agree).

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

* docs(hp-0): date the 379-record provenance honestly (Codex P2 on #1467)

The path-resolution row and the count caption attributed OC-0379 to sweeps
that predate it; both now say what actually happened — 378 re-verified
2026-08-29, OC-0379 path-verified at its own 2026-08-31 fix.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 10:34:45 +02:00
J3vbandClaude bd397d2bbc feat(b3-7): alpha-shaped dataset — deterministic seed profile and the v1.2.0-alpha.4 snapshot (#1469)
go run ./cmd/seed -confirm-dev -profile alpha fills an empty database with
the plan's dataset: 100 users (1/2/5/92 across the four roles), 12 channels
(10 text + 2 voice; 3 role-override, 2 user-override, 1 archived), 20,000
messages over 30 simulated days on a diurnal curve (exactly 15% in DMs
across 40 pairs), 300 attachment rows (60/10/10/20%, 10KB-5MB), 500
reactions, 30 invites (10 revoked), one disabled plugin row. Deterministic
by construction — fixed seed, fixed clock, constant bcrypt hash, explicit
ids and timestamps, VACUUM INTO as the canonical bytes — and
TestAlphaProfileByteIdentical holds the property (two full runs compared
byte for byte; a schema_versions wall-clock leak was the one leak found,
now pinned by the scrub). Two constants deliberately leave no rows and say
why in the package comment: voice sessions are LiveKit-ephemeral, and the
replay log is empty exactly as on a server restarted for an upgrade.

The committed snapshot (3.2MB, under the 5MB LFS line) is the scrubbed
VACUUM of that profile at the alpha.4 migration set - the schema has not
moved since the tag, so it is a true alpha.4 artifact. scrub.sql beside it
also anonymises a real donated database. db/alpha_snapshot_test.go is the
standing canary: provenance (31 applied migrations), HEAD migrations apply
cleanly, and every promised row count checks out, FTS included. Consumers
(B4 HP-4, B6 upgrade rehearsal, B10 in-place upgrade) are named in the
snapshot README and docs/deployment.md.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 06:12:09 +00:00
J3vbandClaude e524f28521 docs: amend BPR-032 to the slim epoch policy; identical-tree B1 evidence (#1466)
Owner decisions 2026-08-31, formalizing what B2-2 (2026-08-29, "shipped
slim") and HP-2 (condition 1 accepted at that scope) already record:

- BPR-032 states the single-current-epoch beta policy with a dated
  amendment; the N/N-1/N-2 window returns by decision when a real epoch
  bump needs one. Traceability row matched, with the slim-scope evidence
  (TestAuth_ProtocolEpoch, TestEpoch1Fixtures, TestReleaseProtocolEpoch).
- Roadmap B6 ws16 / B8 ws12+14 / B10 item 5 no longer imply B2-2 shipped
  GET /api/v1/server-info or a three-epoch matrix: B6 adds the endpoint,
  B10 re-runs the accepted epochs. The B2 exit bullet gets a dated note
  instead of a rewrite (closed-phase history stays legible).
- B1 exit gate reworded: integration evidence is the required matrix on
  the PR head plus strict:true tree identity with the squash commit
  (scripts/verify-integration-tree.sh; the strict selftest pin rides the
  companion CI PR).
- BPR-051's non-developer comprehension read becomes B10 qualification
  item 15 (an R-08 scorecard row); the traceability row points at it.
- plans README: BPR/roadmap rows carry the amendment notes; the stale
  sweep sentence is dated and scoped to the records then open; the
  roadmap row no longer claims B3 has not started.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 05:24:22 +00:00
J3vbandClaude 59518c4767 ci: run otel-tagged api tests; pin strict for identical-tree evidence (#1465)
The tag-gated step ran -tags otel only for ./telemetry/..., so
api/recoverer_otel_test.go — B3-9's OC-0346 panic-log test — executed
nowhere in CI. Widen the scope to ./api/... and correct the comment that
lists the tagged files. Both packages pass locally under the tag.

G-03 as amended: integration evidence for a dev squash commit is the full
required matrix on its PR head plus tree identity between the two, which
required_status_checks.strict guarantees by construction. Make that
checkable: verify-gate-evidence.mjs --selftest now fails if the protection
script ever loses "strict": true, and scripts/verify-integration-tree.sh
asserts squash-tree == PR-head-tree for any squash SHA (the three newest
dev commits PASS), for phase-exit and hold-point evidence blocks.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 04:58:39 +00:00
7abdd941fd refactor(b3-3): lifecycle extraction into Server/internal/app with one composite close (#1464)
* docs(b3-3): mark B3-3 in progress and record HP-3's merge SHA

HP-3 (#1461) merged as `52601114`; B3-3 (lifecycle extraction into
`Server/internal/app/`) starts on `feat/b3-3-lifecycle`. Status line only —
no step-table or scorecard edits.

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

* refactor(b3-3): move the process lifecycle into Server/internal/app (pure move)

Every run* block, the healthcheck CLI, the banner and disk helpers, the seq
seeding, the bind-retry listener and the restart coordinator move out of
`Server/main.go` into a new `Server/internal/app` package, verbatim. `main.go`
keeps the CLI dispatch, the log sinks, the `version` symbol `-ldflags` names
and the restart handoff, and calls `app.Run`.

Behaviour-neutral. The only substitutions are the package clause, `run` ->
`Run` and `runHealthcheckCLI` -> `RunHealthcheckCLI` (the two entry points
main() calls), the five restart-coordinator identifiers main() still names
(`RestartCoordinator`, `NewRestartCoordinator`, `RestartBackstopDelay`,
`PerformRestartHandoff`, `Disarm`), and `version` becoming a parameter of
`Run`/`runServeAndWait` instead of a package-level var — it has to stay in
package main because `-X main.version` is what the Makefile, `release.yml`
and the Dockerfile inject.

Normalised-diff proof (HP-1's shape): undoing those substitutions over the
whole Server diff and running `sort | uniq -u` leaves 45 unpaired lines, all
of them comment prose or the new import — no code line is unpaired.

`DBImportAllow` swaps its `main.go` row for the four `internal/app` files that
now own the handle (all `boundary`); `docs/architecture/server-boundaries.md`
is regenerated from it (50 -> 53 importers, boundary 7 -> 10; the summary
table's stale 6 is corrected to match the generated line).

Full gate green: four tag variants, vet, `go test -race ./...` with coverage
(aggregate 80.1%, unchanged), coverage floor, `-tags deadlock ./ws/`,
golangci-lint v2.11.3 (0 issues), genprotocol/sqlc/gendocs drift, check:docs,
check:hygiene. `TestAuthCharacterization` green.

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

* refactor(b3-3): one App with one composite close, replacing run()'s defer stack

`type App` threads the dependencies through fields instead of run() locals.
Every stage registers exactly one close step as it comes up, in start order,
and `App.Close(ctx)` walks them backwards — so there is a single teardown
path, taken on a failed start, a serve error and a clean shutdown alike,
where run() had a LIFO `defer` stack and an early return that skipped
whatever it had not reached.

`main.go` is 1,019 -> 99 lines: the CLI dispatch, the log sinks, the restart
handoff, and `cfg := app.LoadConfig(...); a := app.New(cfg, ...); a.Run(ctx)`.

The three ordering facts the inventory records are preserved, and are now
what the reverse walk is FOR rather than emergent from where a `defer`
happened to sit: the audit writer and event persistence both stop before
`database.Close`, and the hub's GracefulStop runs on every return from Run so
a supervised LiveKit process is never orphaned (OC-0027).

Test-first. Three RED rows, each with a negative control on this branch:

| Property                                              | Mutation that must fail it            | Result |
| ----------------------------------------------------- | -------------------------------------- | ------ |
| close order is the reverse of start order             | walk the closers forward               | FAIL   |
| first error returned, every later close still runs    | return on the first error              | FAIL   |
| hub stops when a stage after the router fails         | skip teardown on a failed start        | FAIL   |

Deliberate, documented changes that come with the contract:

* `Run(ctx)` is real: bgCtx and the serve context both descend from it, so
  cancelling the caller's context stops the server the way a signal or a
  restart request does (`context.AfterFunc` joins the coordinator's context
  to it).
* the four stop steps that used to build a fresh `context.Background()` with
  their own 5s cap now take Close's budget as their parent, so a wedged step
  cannot push teardown past the 30s the operator was told about;
* `database.Close`'s error is reported instead of discarded;
* the ACME start moves one stage later, after the maintenance loop, which is
  what makes "reverse of start" equal the order run()'s explicit shutdown
  call used to impose by hand (drain in-flight HTTP handlers first);
* `internal/app/app.go` gains a `DBImportAllow` row; the inventory doc is
  regenerated (54 importers, boundary 11).

Full gate green, aggregate coverage 80.2% (floor 79.8%).
`TestAuthCharacterization` green.

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

* refactor(b3-3): the hub has one owner — construction moves to internal/app

`api.NewRouter` gains an `api.Runtime` parameter and stops returning a hub.
`app.StartRuntime` (`Server/internal/app/hub.go`) now builds the rate limiter,
the service layer and the hub, applies every pre-Run setter and starts the
dispatch goroutine — the `ws.NewHub` call that was at `router.go:106` and the
plugin and LiveKit setters that were at `:325-360`.

Before this, the hub had two owners: the router built and wired it, and
`main.go` set the event persister and the event store after `NewRouter`
returned. Both now sit inside `internal/app`, which is what gives B3-4 one
place to turn the required setters into validated `HubOptions`.

The limiter and the service layer move with the hub because it needs the SAME
instances — the limiter persists auth lockouts and the services hold the
permission cache the hub invalidates, so a second copy of either would
silently split that state. `Runtime` carries them plus `VoiceEnabled`, which
is the `lkErr == nil` guard the voice routes were already mounted behind;
`routerVoiceRoutes` keeps only the mounting half, `routerPluginWiring` becomes
`app.wirePlugins`, and the LiveKit client and companion process are built by
`app.startVoice` with its OC-0019 fail-closed ordering unchanged.

The hub is its own lifecycle stage now, started before the router, so
`App.Close` stops it through the "hub" step exactly as before.

Call sites updated at the call site only, wiring with no assertion changes:
six `api_test` files and `cmd/gendocs`. `gendocs` produces a byte-identical
route index (its drift check is part of the gate).

Full gate green: four tag variants, vet, `go test -race ./...` with coverage
(aggregate 80.2%), coverage floor, `-tags deadlock ./ws/`, golangci-lint
v2.11.3 (0 issues), genprotocol/sqlc/gendocs drift, check:docs, check:hygiene.
`TestAuthCharacterization` green. `internal/app/hub.go` gains a
`DBImportAllow` row; the inventory doc is regenerated (55 importers).

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

* test(b3-3): fail every lifecycle stage in turn and assert what teardown releases

`Server/internal/app/lifecycle_failure_test.go` is the failure-injection
report the B3 exit gate asks for. Each of the fourteen stages `App.start`
brings up is made to fail in turn, and every row asserts the same four
properties: the returned error names the stage, no goroutine is left running
(`goleak`), the database handle is closed so the SQLite process lock is
released for the successor a restart handoff is about to start, and the
listener is not left bound so that successor can take the port.

The table is generated from `App.stages()` rather than written out, so a
stage added later is covered the day it is added.

Two rows are not injected. A real out-of-range port drives the genuine
listener-bind failure (the OC-0027 path). And a run that is cancelled while
actually serving is the control: the same four properties on the path where
nothing fails, with a nil error — so the injected rows are not passing merely
because something went wrong.

Negative controls on this branch:

| Assertion under test              | Mutation applied                | Result         |
| --------------------------------- | ------------------------------- | -------------- |
| the database handle is closed     | drop the `database` close step  | FAIL, 11 rows  |
| the hub's dispatch loop is stopped| drop the `hub` close step       | FAIL, 12 rows  |

Green under `go test -race ./internal/app/`. Full gate green, aggregate
coverage 80.2% (floor 79.8%), `internal/app` 66.1%.
`TestAuthCharacterization` green.

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

* docs(b3-3): evidence block, after-state lifecycle rows, status line and step row

Plan §B3-3 gains its evidence block: pre-squash SHA per numbered item with
the gate and `TestAuthCharacterization` result for each, `main.go`'s 1,019 →
99 lines, the normalised-diff proof for the pure move (the exact command and
its 45 unpaired lines, all comment prose or the new import, broken down per
file), the composite-close negative controls, the failure-injection table
(sixteen rows × four assertions), the hub-ownership before/after, the
build-and-packaging check, the gate list and the coverage figures.

`docs/architecture/server-boundaries.md`'s hub lifecycle inventory keeps its
before-state tables and gains the after-state: where each of the seven
setters is called now, and the fifteen-stage start list with the close step
each registers, plus why the resulting close order is what makes the three
ordering facts hold by rule rather than by `defer` placement.

One correction the check turned up and the evidence records: a plain `go
build .` from `Server/` produces a binary named `Server`, not `chatserver` —
that comes from the module path and is unchanged by B3-3. Every packaging
path (`Makefile`, `Dockerfile`, `release.yml`, all untouched) passes `-o`
explicitly, and `-X main.version` still resolves.

Status line, step-table row and the `docs/plans/README.md` B3 row point at
PR #1464; the squash SHA lands here at merge.

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

* fix(b3-3): keep the background workers alive through the HTTP drain

Codex (P2) caught a defect in B3-3's own new `Run(ctx)` contract: `bgCtx` was
derived from the caller's context, so cancelling that context stopped the
event persister, the audit writer and the maintenance loop immediately —
before `Close` ran its HTTP-first drain. That drain exists precisely so
in-flight handlers' broadcasts still reach a live hub and event persister and
their audit records still reach a live writer; with the consumers already
gone, both are dropped. It also made caller-context shutdown behave unlike
the SIGTERM and restart paths, which cancel only the serve context.

`run()` had this right for free by rooting `bgCtx` at `context.Background()`.
`main.go` passes `context.Background()`, so no released build was affected —
the defect is in the new contract this PR introduces.

`context.WithoutCancel(ctx)`: `bgCtx` inherits the caller's values but not its
cancellation. Cancelling ctx still stops serving, because the serve context
descends from it in `startSignals`; when the background work stops stays
`Close`'s decision, which is what the ordering rule promises.

Test-first. `TestAppRun_CallerCancel_KeepsBackgroundWorkersAliveThroughTheDrain`
records `bgCtx.Err()` as each close step runs — a new test-only `onCloseStep`
seam makes the teardown walk observable — and requires bgCtx still live at
`signals`, `http`, `maintenance` and `audit-writer`, and already cancelled by
`database` (the `event-persistence` step is what cancels it and joins the
pruner). RED on all four rows before the fix; the negative control, restoring
`context.WithCancel(ctx)`, fails it again.

Full gate green on the merged tree, including `dev`'s new `errorlint`,
`exhaustive` and `durationcheck` linters: golangci-lint v2.11.3, 0 issues.
Aggregate coverage 80.2% (floor 79.8%). `TestAuthCharacterization` green.

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

---------

Co-authored-by: J3vb <dragon613gaming@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 05:47:33 +02:00
J3vbandClaude Fable 5 6432e65c56 chore(claude): session-start + pre-bash hooks, deny .env reads; durationcheck + noImplicitOverride (#1463)
* chore(claude): session-start and pre-bash hooks, deny .env reads

scripts/claude-hook.mjs, wired in .claude/settings.json:
- SessionStart warns when core.hooksPath is not .githooks, so a clone or a
  new machine cannot silently run without the repo git hooks.
- PreToolUse on Bash refuses a top-level cd: the tool's shell is persistent,
  so a cd leaks into every later command and a gate can report green from
  the wrong directory. Subshells, git -C and root-relative paths pass.

permissions.deny gains Read(**/.env): the gitignored env files never enter
the model's context. Server/.env.example stays readable.

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

* chore(lint): durationcheck on the server, noImplicitOverride on the client

Both measured at zero hits on dev, so they cost nothing today and only
block regressions: a Duration multiplied by a Duration-typed value, and an
override left behind when its base method is renamed.

rowserrcheck and sqlclosecheck were measured too and rejected: their six
production hits are all correct code (rows.Err is checked inside
scanEventRows behind the rowsScanner interface; the three Close sites close
on every path by hand).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:50:12 +00:00
J3vbandClaude Fable 5 ead64cdc20 chore(lint): errorlint + exhaustive + switch-exhaustiveness-check, and permissions.deny for generated files (#1462)
* chore(claude): deny hand-edits to generated files via permissions.deny

CLAUDE.md already says the sqlc, protocol and tauri-typegen outputs are
never hand-edited; this turns the sentence into a permission rule so the
Edit/Write tools refuse those paths outright.

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

* chore(lint): switch-exhaustiveness-check on the client, default branch counts as exhaustive

A switch over a string union that misses a member is a silent drop, not a
type error. Every existing default-less switch already covers its union, so
this adds no exceptions; the four switches with a default keep it as the
deliberate catch-all.

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

* chore(lint): enable errorlint and exhaustive in golangci and fix the 110 hits

errorlint: 68 fmt.Errorf sites wrapped the inner error with %v, which hid it
from errors.Is/As upstream — now %w; 6 == / != comparisons on sentinel
errors become errors.Is (the recover() branch in the router asserts the
recovered value is an error first); 36 ClientError type assertions become
errors.As, so a wrapped ClientError still reaches the client with its code.
Three test assertions the autofixer inverted (!ok || code mismatch) are
restored by hand.

exhaustive (default-signifies-exhaustive): one hit, the hub simulation's
FaultStatus switch — FaultOK moves from an if-guard into the switch.

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

* chore(claude): path-scoped rules for the three generated-code workflows

.claude/rules/{db-change,protocol-change,gendocs}.md load only when Claude
reads a matching source-of-truth file, so the db-change / protocol-change
skills and the gendocs regeneration step surface at the moment they apply
instead of relying on the CLAUDE.md table being remembered. .gitignore
whitelists .claude/rules/ next to skills/, workflows/ and settings.json.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:22:55 +02:00
J3vbandClaude Fable 5 526011141f docs(hp-3): accepted 2026-08-30 by the owner; B3-9 squash SHA recorded (#1461)
hp-3-scorecard: decision line and signature filled as drafted (B3-9 closed
the three pinned defects after the measurement; nothing else changes).
Plan: status line, HP-3 step-table row, B3-9 evidence line carry
PR #1454 = 123c0899; docs/plans/README.md rows for the plan and the
scorecard updated. B3-3 is next.


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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 17:07:26 +00:00
J3vbandClaude Fable 5 e01061208d test(api): logbounds — a marker the random request id cannot contain (#1460)
TestBoundRequestID_ControlBytesRejected asserted that "abc" never reaches
the log record, but the server-generated fallback id is a short random base64
run and contained "abc" by chance in CI run 33308823281
(req_id=runnervmgx7h7/qj9LabcvlI-000002), failing an unrelated PR. The marker
is now a long distinctive token, and the test also asserts a request id was
logged at all, as its sibling does.


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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:06:55 +00:00
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
J3vbandClaude Fable 5 fb5b058ee4 test(b3-6): seeded hub simulation with a six-part FIFO/seq oracle, fault-injected transport, exact seed replay (Tier 3b/3c) (#1458)
* 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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:57:34 +00:00
J3vbandClaude Fable 5 746f60f789 feat(b3-6): nightly Docker smoke of dev in its own workflow, plus a timeout on the CI verify job (#1452)
* 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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:43:03 +00:00
J3vbandClaude Fable 5 1e9ac9a842 test(b3-6): fuzz seeds — epoch-1 corpora for every target, protocol/auth/predicate-parity fuzz targets, make fuzz green (#1457)
* 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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:30:53 +00:00
J3vbandClaude Fable 5 8cb0ec9e35 feat(b3-6): contract drift — generated route, table and config-key indexes with a CI drift check (#1456)
* 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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:18:54 +00:00
J3vbandClaude Fable 5 e0f9848aed test(b3-6): client connection model test — fc.commands over the real ws client, dispatcher and stores (Tier 3a) (#1455)
* 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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 12:13:17 +00:00
J3vbandClaude Fable 5 7da2a2b9ad feat(b3-6): coverage floor — script, floors and CI gate on the ubuntu leg (S-06) (#1453)
* feat(b3-6): coverage floor — script, floors and CI step (S-06)

scripts/coverage-floor.sh reads a Go coverage profile and fails when the
aggregate, or one of the core packages (ws, service, permissions, auth, db),
is below the figure recorded in coverage-floor.json. Statement counts come
from the profile's block lines, not `go tool cover -func`, so the per-package
figures are exact; percentages are truncated to one decimal and compared in
tenths. Exclusions (generated db/dbgen, cmd/) live in the floor file.

Measured with the CI command on this branch's base (origin/dev 75d64dd4):
aggregate 79.1 (11241/14194), auth 90.8, db 79.4, permissions 100.0,
service 67.8, ws 84.5. The plan's starting aggregate was 74.6 (B0 baseline at
an older SHA); the ratchet applies to this PR too, so the floor lands at the
measured figure.

Wired into ci.yml right after the race/coverage test step, on the Linux leg
only: OS-tagged files swap in and out of the build and several tests skip on
Windows, so one leg keeps the figure deterministic. The ratchet rule is in
Server/CLAUDE.md.

--floor <file> and OWNCORD_COVERAGE_FLOOR override the committed floors, so
the negative control needs no edit of the tracked JSON.

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

* docs(b3-6): evidence block for item 1 (coverage floor)

Numbers, RED/GREEN commands and the Linux-leg decision, appended to the
B3-6 section per the item's exit criteria.

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

* fix(b3-6): coverage floor — fail closed on JSON shape, honour --floor anywhere, LC_ALL=C, gate after the last test step

Review findings on item 1.

The floor-file parser now fails closed instead of silently reading less than
the file says. A Prettier-wrapped "exclude" array used to yield zero
exclusions and misreport as a coverage drop; an "exclude" key with no parsed
entries is now exit 2 naming the one-line-array rule. A package entry sharing
a line with the closing brace ("ws": 99.0 }) used to be dropped, so that floor
stopped being enforced; the block now closes after the line is parsed, and an
entry on the "packages": { line is parsed too. Two entries on one line are
exit 2 rather than one silently ignored. All three rules are in the header
comment.

--floor is parsed in any argument position, so `coverage-floor.sh coverage.out
--floor red.json` no longer silently uses the committed floors; an unknown
flag or a second positional argument is a usage message and exit 2.

LC_ALL=C is exported for mawk's locale-dependent decimal handling.

The ci.yml step moves after the deadlock and tag-gated test steps — it still
reads the profile the race step wrote, and still runs only on the ubuntu leg,
but a floor miss no longer hides those steps' results.

Server/CLAUDE.md gains the no-trailing-slash rule for exclusions.

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

* fix(b3-6): coverage floor — every package entry must parse, and the five core packages must all be present (Codex P2 on #1453)

A package entry whose value did not match the numeric regex — valid JSON such
as "auth": "90.8" — was silently ignored, and the only completeness check was
"at least one package parsed", so a malformed value quietly removed a floor.
That contradicts the script's own fail-closed contract.

Inside the "packages" block, any line that is not a well-formed
"name": <number> entry (or the block's closing brace) is now exit 2, naming the
line number, the line, and the shape rule. The entry match is anchored, so it
also subsumes the previous "two entries on one line" heuristic.

The five core packages — ws, service, permissions, auth, db — must each have a
parsed floor; a missing one is exit 2 naming it. Extra packages beyond the core
set stay allowed.

Header comment and the plan's evidence block record both rules.

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

* fix(b3-6): coverage floor — floors set from the Linux leg (CI run 33302062524)

The floors were first measured locally on Windows; the gate runs on the ubuntu
leg, and the first CI run of #1453 showed the two legs differ. Each floor is
now the Linux value truncated to one decimal, measured by the gate itself on
the leg that enforces it: aggregate 79.9 (11344/14191), auth 90.8 (418/460),
db 79.3 (1738/2189), permissions 100.0 (94/94), service 67.8 (1204/1775),
ws 86.9 (3271/3763).

The deltas are the ones the pre-merge analysis predicted. ws is higher on Linux
because three EnsureLiveKitBinary tests and one harvest_s5 case skip on Windows,
and that lifts the aggregate with it; db is one statement larger on Linux
(lockfile_unix.go has 11 statements where lockfile_windows.go has 10), which
costs it a tenth. auth, permissions and service carry no OS-conditional code and
are identical on both legs.

The evidence block now carries both columns, cites the run id, and keeps the
74.6 note. Running the new floors against the local Windows profile fails on
aggregate and ws by exactly those deltas, which is expected and is why the gate
is Linux-only.

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

* fix(b3-6): coverage floor — a tenth of headroom where the Linux leg varied run-to-run (ws, aggregate; runs 33302062524 and 33302732286)

Run 33302062524 measured ws at 3271/3763 (86.9); run 33302732286, same commit,
measured 3267/3763 (86.8) and failed the floor set from the first. Four
statements of run-to-run variance in ws under -race — timing-dependent branches
— which moves the aggregate with it (11344 -> 11340). The reviewer's
zero-headroom concern is now evidence rather than a prediction.

Floors are therefore the lowest observed Linux figure, truncated to 0.1, minus
0.1 where the package varied between runs: ws 86.9 -> 86.7, aggregate
79.9 -> 79.8. auth 90.8, db 79.3, permissions 100.0 and service 67.8 are
unchanged — their statement counts are identical in both runs, so they take no
headroom.

Server/CLAUDE.md states that rule next to the ratchet, and notes that a Windows
run reports aggregate and ws under floor by design. The evidence block carries
both runs' covered/total per package, which varied, and the resulting floors.

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 11:48:02 +00:00
J3vbandClaude Fable 5 0eb603cefc feat(b3-6): authz-chokepoint invariant rule — raw permission checks route through the B2-5 predicates (#1451)
* feat(b3-6): authz-chokepoint rule — raw permission checks route through the B2-5 predicates

B2-5 gave every channel-scoped security property exactly one predicate. This
is the guardrail that keeps the next call site from re-deriving one by hand,
which is how the thirteen hand-rolled decision sites B2-5 collapsed came to
exist.

The rule fails any production file outside Server/permissions that names one
of the six raw bit helpers (HasPerm, HasAnyPerm, HasServerPerm, HasAdmin,
EffectivePerms, EffectiveChannelPerms) — the whole exported surface of
permissions.go except Name — unless the enclosing symbol has a residue row.
It matches the selector rather than the call, so taking a helper as a value
does not evade it, and reports a dot-import of the package separately, since
that would let the helpers be spelled bare.

AuthzResidueAllow is HP-2 question 5's residue table: 19 symbols, 21 call
sites, re-measured at dev 75d64dd4 and unchanged in count. Rows are keyed by
directory plus enclosing function or method, never file:line — the table's
line numbers had already moved under B3-2. Each row carries one of question
5's five classes and a reason, so B3-8 can retire a class at a time.
TestAuthzResidueAllowIsLive fails any row whose symbol stopped calling a raw
helper, so the list can only shrink honestly.

importNames and the walker's rule-set parameter move to invariants.go: the
syncutil rule already needed the first, and the liveness test needs the
second. No production behaviour changes.

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

* docs(b3-6): authz-chokepoint evidence block — allowlist size, RED and GREEN runs

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

* fix(b3-6): authz-chokepoint — tighten the exclusion guard, reject file-scope rows, document the class set

Four review Minors, none behaviour-changing for the 19 real rows.

The permissions/ early return is gone rather than tested. It could never fire
for the package itself — a file there cannot import itself, so it binds no
"permissions" identifier and matches nothing — while its HasPrefix arm would
have silently exempted a future permissions/<sub>, which is a different
package that does import permissions. Dropping it is the strict choice. A new
fixture at permissions/policy/x.go pins that: re-adding the exclusion fails it.

An allowlist row keyed <dir>.<file-scope> would have blanket-exempted every
package-scope raw call and every dot-import in that directory at once.
TestAuthzResidueAllowIsLive now rejects such a row, and TestFileScopeRowsAreRejected
covers the predicate directly.

The class set is documented as closed: a row cannot invent a class, including
the "unclassified" escape valve the brief sketched, so new residue needs a
constant added as a deliberate edit.

The violation message said the call "decides authorization", which is wrong for
EffectivePerms and EffectiveChannelPerms — they compute the mask a decision
reads. It now says "resolves permission bits", distinguishes the two groups,
and names the five legal classes from authzClassList, a constant folded from
the class constants so it cannot drift.

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

* fix(b3-6): authz-chokepoint — bind residue rows to helper and multiplicity (Codex P2 on #1451)

The symbol-only lookup exempted the whole function. A second raw call added
inside any of the 19 allowlisted symbols, or a switch to a different helper
there, passed silently — and the liveness test only asked for at least one hit,
so the 21-call residue could grow without review. Correct by construction.

Each row now carries Calls, a helper-name to count multiset filled from what
the tree actually contains: 18 rows bind one call, mentionReaders binds three
(EffectivePerms 1, HasAdmin 2). By helper: HasAdmin 13, HasServerPerm 6,
HasAnyPerm 1, EffectivePerms 1 — 21 in all, unchanged.

The rule counts hits per symbol as it walks a file and flags the call that
takes a helper past its bound count, so an extra call, one more of the same
helper, and a helper the row never listed (bound count 0) all fail at the
offending line, with the helper and the expected-versus-found counts in the
message. A dot-import is now flagged inside an allowlisted symbol too — it
binds no call to count, and a row is no excuse for one. Fewer calls than the
row binds is left to TestAuthzResidueAllowIsLive, which compares the multiset
exactly (maps.Equal) instead of asking for at least one hit, so an over-counted
row cannot leave headroom either.

Symbol keying is unchanged: the plan mandates file:line independence.

RED, then restored: a second permissions.HasAdmin in api.serveFileAuthorize
fails naming "binds 1 call(s) of HasAdmin here, found 2"; swapping it to
HasPerm fails naming "binds 0 call(s) of HasPerm here, found 1"; setting that
row to HasAdmin: 2 fails the liveness test with the multiset diff. All three
have unit fixtures, alongside one that a shrinking residue is not the rule's
business.

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 11:07:37 +00:00
J3vbandClaude Fable 5 123c0899e5 fix(b3-9): close the B3-tagged findings — OC-0345, OC-0346, OC-0376, OC-0377, OC-0378 (#1454)
* docs(b3-9): record B3-2's merge (#1450 = 75d64dd4); B3-9 in progress

Plan status line, B3-2 step-table row (DONE) and evidence block carry the
squash SHA; docs/plans/README.md B3 row updated.

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

* fix(api): panic log carries trace_id — tracing ahead of recoverer (OC-0346)

recoverer snapshots telemetry.TraceIDFromContext before dispatch, so it
needs the otelhttp span to exist already; it was mounted two slots ahead
of telemetry.HTTPMiddleware and the trace_id attribute was always dropped.
Move the tracing middleware above it; request-id binding, security headers
and the body cap keep their relative positions.

Test (otel build only — the default build hard-wires TraceIDFromContext to
""): go test -tags otel -run TestRecoverer_PanicLogCarriesTraceID ./api/

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

* fix(admin): owner gate answers 503 on a role read fault, not 403 (OC-0345)

ownerOnlyMiddleware collapsed `err != nil || role == nil` into 403 "role not
found", so a transient GetRoleByID failure told the Owner they lack the
Owner role. Split the outcomes: a store error logs and answers 503
SERVICE_UNAVAILABLE (the perimeter's contract); a genuinely missing role
still answers 403. The existing whitebox tests, which inject only the user
into the context, are unchanged.

Test: TestOwnerOnlyMiddleware_RoleLookupFailureIs503 (roles table renamed,
whitebox — through the full stack the perimeter would answer its own 503).

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

* fix(service): verify-totp reports a store fault as 500, uncounted (OC-0377)

challengeSecret folded a GetUserByID error into the same 401 "invalid or
expired two-factor challenge" an expired challenge earns, and the attempt
had already been charged to the per-user totp_fail cap. Split the outcome:
a store error logs and returns the new service.ErrTOTPUnavailable
(ErrInternal, "two-factor verification temporarily unavailable"); an unknown
user or a missing secret still answers 401. The limiter reservation moves
after the store read — the rule authenticate already applies — and still
precedes the code compare, so the check-then-act it closes stays closed.

Characterization row flipped in the same commit: `VerifyTOTPFailurePaths/
user lookup fails -> 500, challenge kept, attempt not counted` — after the
fault ten wrong codes still answer 401 (the tenth would be 429 had the
fault counted), then the eleventh is refused. `per-user failure cap spans
challenges` unchanged.

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

* fix(service): verify-totp keeps the verified second factor when the session insert fails (OC-0378)

VerifyTOTP consumed the partial challenge before issueSession, so a store
fault on the session insert discarded a verified second factor and sent the
user back to the password step; the code was also marked used, so an
immediate retry would have been refused as a replay.

The claim stays atomic and first (two concurrent verifies can never both
reach issueSession). On issueSession failure the challenge is restored under
the same partial token — the client still holds it — and the accepted code
is released, so the retry completes the login without another password
step. auth gains PartialAuthStore.Restore and UsedTOTPCodeStore.Unmark, each
tested in the leaf package.

Characterization row flipped in the same commit: `VerifyTOTPFailurePaths/
session insert fails -> 500, the challenge and the code survive` — once the
trigger is dropped the same token and the same code answer 200 with a token.

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

* fix(service): registration commits the account, the invite use and the first session together (OC-0376)

CreateUserWithInvite committed the user and burned the invite; the session
insert ran outside that transaction, so a store fault there answered 500
with a half-registered account — a retry got "invalid invite or
credentials" while a login with the same password worked. Option B from
the ledger: the session token is generated first and the session row is
inserted inside the same transaction (db.insertSession through
dbgen.Queries.WithTx; no query or migration change, so no sqlc regen). A
fault at any step rolls the whole registration back and the caller simply
retries. The H-6 cap needs no eviction for a user with no sessions.

Characterization row flipped in the same commit: `RegisterPolicyAndFailurePaths/
session insert fails -> 500, nothing committed` — user row absent, invite
use_count 0, message "registration failed — please try again". db tests
pass the three new arguments; the happy-path test asserts the session row.

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

* refactor(service): one session-token path for Register and issueSession

OC-0376 gave Register its own auth.GenerateToken failure branch — a
duplicate of the unreachable one issueSession already carried — and the
auth slice's statement coverage dipped from 91.8% to 91.7% on that one
statement. newSessionToken generates the token and hands its hash to a
persist callback: CreateSession for login and verify-totp, the
CreateUserWithInvite transaction for registration. Behaviour identical
(the characterization file is green before and after); slice coverage
402/437 = 92.0%, service/auth.go 250/263 = 95.1%.

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

* chore(ledger): close the five B3-tagged findings; counts re-derived (PR #1454)

OC-0346 → 775eba50, OC-0345 → fb1afb8a, OC-0377 → f7015809,
OC-0378 → be37d7ee, OC-0376 → 85d86dc7: status fixed, fix.test,
revertProof pass (hand reverse-apply per commit + verify-fixes.mjs).
Ledger 315 fixed / 59 open → 320 / 54 (3 declined, 1 duplicate, 378).

The four count-carrying documents are re-derived around every number,
not just the totals (obs #100): docs/plans/README.md, hp-0-scorecard
(54 open = 1 high / 12 medium / 41 low; three hunts; 53 of 54 resolve;
Client 33 / Server 21), repo-health-issue-register (table, "eleven of
which", OC-0345/OC-0346 rows marked fixed with this PR), b0-baseline.
OC-0323 stays open — it rides B3-8's message/read-state family.

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

* fix(service): verify-totp — cap check before the store read; release the code on a lost claim (Codex P2s, #1454)

Two P2s from Codex on PR #1454, both verified against the code and fixed
test-first:

1. An exhausted totp_fail window is refused by the read-only limiter.Check
   before challengeSecret, so rotating source IPs cannot drive user reads
   and secret decryptions past the per-user cap. The atomic Allow that
   records the attempt still runs after the store read (OC-0377: an outage
   charges nothing); the cap boundary is unchanged.
   Test: api/totp_cap_before_store_test.go — budget filled through the
   limiter, users table hidden, expects 429 (RED: 500 "temporarily
   unavailable", the store was read first).

2. A verify whose claim loses at Consume releases the code it marked, so a
   winner mid-recovery (Consume → issueSession failed → Restore) is not
   left with a live token behind a dead code until the authenticator rolls
   over.
   Test: service/auth_lost_claim_test.go — forces the interleaving through
   the store's GetUserByID (RED: "the losing claim left its code marked as
   used").

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

* docs(b3-9): evidence block, status line, step-table row; README B3 row (PR #1454)

Per finding: pre-squash SHA, RED and GREEN lines, revert-proof, the two
negative controls, what changed; the ledger diff and the re-derived count
paragraphs; auth-slice coverage 402/437 = 92.0% (floor 392/427 = 91.8%),
service/auth.go 250/263 = 95.1%; the otel-tagged run. OC-0323 recorded as
riding B3-8. hp-3-scorecard untouched — the owner signs it as drafted.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 12:56:04 +02:00
J3vbandClaude Fable 5 75d64dd412 refactor(b3-2): auth vertical slice — service.AuthService behind a consumer-owned interface (S-10) + HP-3 draft (#1450)
* docs(b3-1): record PR #1449 = 71d867cb in the status line, step table and evidence block

Pre-squash SHAs completed with the coverage commit a0356ee1 and the three
Codex rounds (head 8614603b).

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

* refactor(b3-2): auth_deps.go — the consumer-owned AuthService interface

Eight methods beside the handlers that need them: Register, Login,
VerifyTOTP, Logout, DeleteAccount, EnableTOTP, ConfirmTOTP, DisableTOTP —
fewer than the ten *db.DB methods the two handlers call today. The input
and result types they name (Principal, RegisterInput, LoginInput,
AuthResult, TOTPChangeResult) and the AuthBroadcaster the delete path needs
live in service/auth.go. Nothing implements or calls the interface yet.

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

* refactor(b3-2): service.AuthService — the auth orchestration, moved verbatim

Register, Login, VerifyTOTP, Logout, DeleteAccount, EnableTOTP, ConfirmTOTP,
DisableTOTP and the RegistrationPolicy gate two characterization rows pin
ahead of the body read. The enumeration guard, the F3 reserve-before-compare,
the audit writes, the best-effort custom-status clear and the 200+warning
partial-success contract move line for line; persistence stays in db behind
Store. Each refusal is a named service.Err* whose Error() is the exact
public message the handler wrote and whose category (ErrUnauthorized and
ErrInvalidInput join the message.go set) the transport maps to a status.
The auth rate multiplier moves to auth/ratescale.go so the route mounts
and the login failure accounting read one value; api keeps its wrappers.
Nothing calls the service yet — the handlers still own their copies.

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

* refactor(b3-2): thin auth handlers — decode, call AuthService, encode

*db.DB leaves every handler signature in auth_handler.go and
totp_handler.go; MountAuthRoutes takes the interface and the
AuthMiddleware the caller builds, and router.go constructs the service
after the hub. Each refusal is encoded by one writeAuthError switch on the
service's error categories. The principal helper in middleware.go hands
the handlers the caller as service.Principal, and userResponse moves next
to the profile handler, so neither auth file names db any more: their two
DBImportAllow rows go in this commit (TestDBImportAllowIsLive proves the
rows could not outlive the import) and the boundary fixture points at
middleware.go instead. The auth-slice limits leave api/constants.go with
the code that reads them; profile_handler.go reads the shared pw_confirm
budget from the service. Test files change only where they mount the
routes (four helper lines + two direct mounts); no assertion or row moves.

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

* docs(b3-2): after-state boundary inventory — api db importers 12 → 10

Regenerated table (49 files; move 28 → 26), the auth slice's after-state
dependency rows, and the honest reading of the plan's "neither db nor
service" target: met for db, not for service — the handlers import service
for the interface's types and Err* categories.

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

* docs(b3-2): evidence block — pre-squash SHAs, graph deltas, gates, coverage

Characterization green at each SHA in a detached worktree with the frozen
files byte-identical to 71d867cb; nine-method interface vs ten db methods;
api db importers 12 → 10; slice coverage 392/433 = 90.5% → 392/427 = 91.8%;
the five behaviour notes (decode-before-gate corner cases, shared
AuthMiddleware, folded confirmation block, moved limits, moved converter).

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

* docs(hp-3): scorecard draft and the D4 vertical-slice pattern in server.md

Five questions answered with commands and outputs at fe1d11b8/3f0d24ec;
owner sign-off line left blank. server.md gains D4 — the eight-step
interface/service/handler rule for B3-8 with the awkward step
(gate-before-decode) named — and its D3 deviation note drops the auth
routes. Plans README indexes the scorecard.

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

* docs(b3-2): record PR #1450 in the evidence block and the HP-3 fetch line

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 09:49:05 +02:00
J3vbandClaude Fable 5 71d867cbdb test(b3-1): auth characterization tests — freeze the slice before B3-2 (#1449)
* docs(b3-0): record PR #1448 = d383d8c7 in the evidence block

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

* docs(b3-1): inventory of what the 85 auth tests already pin, per route and property

Route x property table in the B3-1 evidence block: the existing test for
each row, or GAP and the characterization row that fills it (next commit).

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

* test(b3-1): auth characterization — fill the inventory gaps against today's behaviour

12 tests / 44 table rows over the mounted auth router, no mocks: read
faults via ALTER TABLE RENAME, write faults via RAISE(FAIL) triggers.
Three rows pin defects as-is with `// known:` and ledger entries
OC-0376 (register 500 after the account commit), OC-0377 (verify-totp
maps a DB error to 401), OC-0378 (challenge consumed before the session
insert); fixed in B3-9, not here. Mutation spot-check: 401->500 in
totpChallengeSecret, 500->401 in loginAuthenticate, 503->401 in
AuthMiddleware each turned the rows that name them RED.

The nine watched ledger-count claims move 56 open / 375 -> 59 / 378.

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

* docs(b3-1): coverage before/after, row count and pre-squash SHAs in the evidence block

auth_handler.go 78.0% -> 90.2%, totp_handler.go 78.8% -> 91.1% (statements).

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

* fix(b3-1): Codex P2s — guaranteed-invalid TOTP code, B3-9 scope, hp-0 breakdown

- wrongTOTPCode derives a code outside the three accepted steps instead of
  assuming "000000" is invalid (1-in-333k flake in the per-user cap row)
- OC-0376/0377/0378 added to B3-9 in the step table and its section
- hp-0 open-record breakdown recomputed for the 59-open ledger

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

* docs(b3-1): Codex round 2 — OC-0378 fix must keep the challenge claim atomic; coverage command syntax

- OC-0378 suggestedFix: Consume first, restore/re-issue on session failure;
  issuing before Consume lets two concurrent verifies both create sessions
- evidence block: go test -coverprofile=cover.out ./api/ (flag needs a file)

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

* fix(b3-1): Codex round 3 — exclude the +2 TOTP step, sequence auth B3-9 after B3-2, keep the replay claim in OC-0378's remedy

- wrongTOTPCode excludes {-1,0,+1,+2}: the verifier samples the clock after
  the helper, so a step boundary in between shifts its window to {0,+1,+2}
- B3-9 "Parallel with": OC-0345/0346 any; OC-0323 with B3-8; OC-0376..0378
  after B3-2 (matches the safe-parallelism rule and the section text)
- OC-0378 suggestedFix: a restored challenge must carry the accepted
  verification or roll back the MarkUsed claim, or the retry is a replay

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 22:33:05 +02:00
J3vb d383d8c7e8 feat(b3-0): boundary inventory — dbinventory tool, db-import-boundary rule, server-boundaries.md (#1448)
* docs(b3): Codex round 1 — keep the main-PR Docker term, checkout dev on schedule, profile numbers, hub built in api.NewRouter

P1: the Docker verify condition keeps ref_name/base_ref main and adds the
schedule term. P2: scheduled runs check out dev explicitly (the workflow file
comes from main). P2: the alpha profile's dimensions are defined in the plan,
not borrowed from load-baseline.yml (which has only users=100). P2: ws.NewHub
is called in api/router.go:106 with setters split across router.go and
main.go, so B3-4 follows B3-3, which moves construction into internal/app.
Also: plan-index row and roadmap slice line for B3.

* feat(b3-0): boundary inventory — dbinventory tool, db-import-boundary rule, server-boundaries.md

51 production files outside db/ and service/ import db (ws 17, admin 16,
api 12, auth 2, root 2, cmd/seed 1, plugin 1); 14 are type-only. Each has a
disposition (move 28 / adapter 17 / boundary 6) and, for moves, a target
family, held in invariants.DBImportAllow so the generated document and the
gate cannot drift. The rule fails any new importer without a row; the live
test fails any stale row. Hub lifecycle (setters, locks, defer stack) and the
auth before-graph are inventoried for B3-2/B3-3/B3-4. Closes the B3 entry
gate's third item.

* fix(b3-0): dbinventory exempts only top-level db/ and service/ (Codex P2)

Skipping by directory name let a nested api/service/ escape the inventory
while the rule would still catch it; the walker now exempts by root-relative
path, with a test over a synthetic tree.
2026-08-29 20:07:15 +02:00
J3vb ad4defc27b docs(b3): execution plan — server architecture and permanent guardrails (#1447)
Ten steps (B3-0 inventory through B3-9 findings) with HP-3 mid-phase, mapping
all 17 roadmap workstreams and every B3-tagged register row. Entry gate 2 of 3
met; every roadmap and layout-refactor claim re-verified at bf7b886d (api has
12 db importers, not 11; workstream 7 already done by B2-5; the
seq-enqueue-paired rule never merged).
2026-08-29 14:46:00 +00:00
J3vb 972064f91b docs(hp-2): accepted 2026-08-29 by the owner; B2 complete, B3 next (#1446) 2026-08-29 16:32:11 +02:00
J3vb bf7b886df8 fix(hp-2): anchor checker drops the extension allowlist (Codex P2 on #1444) (#1445)
* fix(hp-2): anchor checker drops the extension allowlist (Codex P2)

The list skipped the .sh anchor and the extensionless Server/Dockerfile:13.
Any path with a slash, or a basename with an alphabetic extension, now counts:
117 -> 119 checked, 0 unresolvable. Scorecard and plan counts updated.

* docs(b2-9,hp-2): record the #1444 squash SHA; Codex fix lands in the follow-up
2026-08-29 16:06:14 +02:00
J3vb 2bfc5e30d6 docs(b2-9,hp-2): security owners closed, HP-2 sign-off scorecard (#1444)
* docs(b2-7): record the #1443 squash SHA in the evidence block

* docs(b2-9): SEC-03 sized and re-tagged to B5, verdict recorded

* test(e2ee): HP-2 adversarial membership and key-change cases

Three cases from docs/trust-model.md that had no dedicated test: a modified
server adding an unknown member at first contact (pinned as today's behaviour,
a known gap with its RED recorded in the HP-2 scorecard), a second device's key
overwriting the one-per-account pin so the first device mismatches, and a peer
resumed across a rotation being re-keyed with the rotated key (OC-0316, holder
side). The last two were proven able to fail by temporary code mutation.

* docs(b2-9): close the owner table; advisory placeholders for SEC-01/SEC-04

* docs(hp-2): protocol and threat-model sign-off scorecard

Seven questions answered with commands and their output; B2 exit gate walked
(nine conditions). Adds the trust-model anchor checker beside the scorecard,
the HP-2 evidence block, and the plan index / roadmap slice updates. Owner
lines (reader, review date, decision, signature) are left blank on purpose.
2026-08-29 12:54:30 +00:00
J3vbandClaude Fable 5 88c7a8249a docs(b2-7): trust model, absence proofs, plugin boundary (#1443)
* docs(b2-7): trust model — who can read what (BPR-050/051, C-09 contract)

One document states the operator trust model in plain language and traces
every claim to a code line or test: server-readable text and files and why,
E2EE voice/video/screen with the key-holder and TOFU rules, transport per TLS
mode with desktop pinning and the browser rule, the C-09 preview destination
contract B7 implements, at-rest storage, operator can/cannot, multi-device
sessions, and what beta does not claim. Linked from security.md,
deployment.md, quick-start.md and docs/README.md.

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

* test(b2-7): absence proofs — no federation/directory routes, outbound host table

TestAbsenceContract_NoFederationDirectoryOrListingRoutes builds the production
router with uploads, voice and GIF on, walks the whole mounted tree with
chi.Walk and fails on any route matching federat|directory|discover|listing.
A floor on the route count and a check that the admin subtree was traversed
keep it from passing vacuously. trust-model.md gains "What OwnCord does not
have" (BPR-040/082/083) and the outbound-host table B6's network capture
checks against.

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

* docs(b2-7): plugin boundary — off twice, compiled out of releases, no API promise

docs/architecture/plugins.md records the experimental WASM boundary (BPR-080/
081, BG-17): disabled by build tag and by config, absent from release.yml and
Dockerfile builds, the HP-2 configuration audit (fresh, upgraded, Docker,
standalone), the beta release-notes wording, what exists today with its limits
and tests, the post-beta plugin candidates that stay in core during beta, and
the core concerns that never move. Linked from architecture/README.md,
architecture/server.md and docs/README.md.

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

* docs(b2-7): L-08 re-tagged to B10 with the reason; B2-7 evidence block

L-08's remaining gate ("deterministic source build passes") cannot pass in
principle — TinyGo embeds host paths and has no -trimpath — and a compile-only
drift check would need a second Go SDK, TinyGo and Binaryen on every PR for a
subsystem release builds compile out. Re-tagged to B10, which runs the compile
once against the release candidate or closes on the provenance record; the
"no API promise" half is closed by docs/architecture/plugins.md. The plan's
B2-7 evidence block records the four pre-squash SHAs, the absence test's RED
output, the release-build finding, the decision, and the BPR-051 reader
placeholder. CHANGELOG gains a Documentation block.

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

* fix(b2-7): Codex review — TOFU windows disclosed, legacy TOTP, absence test at three boundaries

Two P1 and three P2 from the Codex review of 56f23a36, all verified against
the code. trust-model.md now states the desktop's first-connection TLS TOFU
window and the out-of-band fingerprint check; scopes identity-key pinning to
changes after the first pin, not first contact; discloses that databases from
before TOTP encryption may still hold plaintext secrets and how re-enrolment
fixes that. The absence contract gains two sibling tests — WebSocket wire
types from protocol/schema.json and every koanf key of config.Config (one
allowlisted on-disk path whose presence the test asserts) — and the document
states what the three tests bound. plugins.md corrected: unknown config keys
are warned about and ignored, not rejected. Evidence block records PR #1443
and the review outcome.

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

* fix(b2-7): Codex re-review — desktop first-use window in every TLS mode; tls off is plaintext

The desktop pins the fingerprint it sees on first connection in every
tls.mode (CaptureVerifier, no web-PKI validation on the server connection),
so a public-CA certificate closes the first-use window only for a browser;
the short answer and the pinning list now say so. tls.mode off served
directly is plaintext HTTP with nothing enforcing a proxy; the transport
table row states it. Evidence block records the round-2 outcome.

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

* fix(b2-7): Codex round 3 — E2EE does not survive a hostile operator on first contact; memory cap is server-wide

Identity keys are trusted on first use, so a modified server can deliver an
unpinned peer's first announce with keys the operator holds and the key
holder wraps the room key to it. trust-model.md now scopes E2EE to an
operator who reads, and to a modified server only for peers pinned and
compared out of band beforehand, in every place the stronger claim stood.
plugins.md: the wazero runtime is sized from plugins.max_memory_mb alone;
a manifest's memory value is validated but not applied. Evidence block
records the round.

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

* fix(b2-7): Codex round 4 — transport intro excludes tls off; ipAllowed is not the full C-09 deny-set

The transport section opened with "everything is TLS"; it now excludes
tls.mode off. The C-09 contract cited the server's ipAllowed as the complete
address deny-set; it rejects loopback, private, link-local, unspecified,
multicast and CGN only, so the clause now lists the documentation and
benchmarking ranges the native broker must add and records that widening
ipAllowed is a separate server change.

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

* fix(b2-7): Codex round 5 — backups exclude uploads; release-time appimagetool fetch listed

The built-in backup is VACUUM INTO of the SQLite file only; the trust model
and deployment.md now say uploaded files are not in it and upload.storage_dir
needs its own backup. The outbound-host section scopes "no script fetches an
external host" to scripts the server runs and lists the release workflow's
build-time appimagetool download.

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

* fix(b2-7): Codex round 6 — one pin per account, LiveKit media in the host table, quick-start wording

Identity pins are keyed by host and user id, so a peer's second device
overwrites the pin and the first device then mismatches; the document no
longer claims per-device pinning. The outbound-host table gains the
supervised LiveKit subprocess's WebRTC media and scopes the capture contract
to traffic the server initiates. The quick-start cross-link no longer says
voice and video are unreadable by the operator.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 11:49:24 +00:00
J3vbandClaude Opus 5 3159f976c9 chore(bughunt): record the 2026-08-29 hunt in the findings ledger (#1442)
* chore(bughunt): record the 2026-08-29 hunt in the findings ledger

Appends OC-0350..OC-0375 as `open` and bumps nextId to 376.

Run shape: 12 rounds, 72 agents, single opus finder with opus
refute-by-default verification. 61 candidates reached verification;
26 were confirmed and 35 refuted. A further 24 candidates were
suppressed as already-known from the ledger and 5 as same-run
duplicates.

The run did NOT converge: it stopped on the maxRounds=12 backstop
rather than on consecutive dry rounds, and round 12 still confirmed
new findings, so the sweep is incomplete and a follow-up hunt has
more to find. Cost ceiling was not the constraint (2.11M of a 5M
budget).

No code changes: this commit only records findings. Nothing is fixed.

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

* docs: follow the ledger's count claims after the 2026-08-29 hunt

check-doc-counts.mjs gates the watched planning documents against the
ledger, and appending OC-0350..OC-0375 moved open 30 -> 56 and total
349 -> 375. Updates the four count claims it flagged, the same
numbers-only edit the previous ledger commit made.

The path-resolution row is re-measured, not merely renumbered: all 375
records still resolve to a live file:line (0 dead paths, 0 lines past
end of file).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-29 10:27:36 +00:00
J3vbandClaude Fable 5 2b2d58abc1 feat(b2-6): safe audit coverage (S-02) (#1441)
* docs(b2-6): enumerate the security-sensitive mutations and their audit coverage

Step 1 of B2-6: the mutation inventory crossed with the 43 non-test
Audit( call sites at 67fdd18d, recorded in the plan's evidence block.
Invite create/revoke (S-02) and plugin install/uninstall have no audit
row; no timeout mutation exists (kick is force_logout / voice_mod_kick).

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

* added new doc

* docs(audit): update changelog and security documentation to include admin panel actions in audit log

* feat(b2-6): audit every security-sensitive mutation, invite and plugin rows first (S-02)

Step 2 of B2-6. TestAuditCoverage_* in service, api and admin drive each
mutation from the plan's inventory against a fake db.AuditStore
(Server/db/audittest) and assert the expected action arrives. Red before
this commit on exactly four rows: invite_create, invite_revoke,
plugin_install, plugin_uninstall.

- InviteService writes invite_create / invite_revoke naming the invite by
  id, never by code; RevokeInvite now takes the actor, threaded from the
  handler. A failed revoke writes nothing (test).
- The plugin admin handler takes a db.Auditor and writes plugin_install /
  plugin_uninstall against the RequireAdminAuth principal
  (admin.ActorIDFromContext, exported for that).
- docs/security.md lists the four new actions; CHANGELOG under Unreleased.

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

* test(b2-6): denylist over the recorded audit detail corpus

Step 3 of B2-6. Each TestAuditCoverage_* table now ends with a subtest
that runs audittest.AssertSafeDetails over every entry its rows recorded:
a shape denylist (bcrypt/argon2 hashes, password=/token=/secret=/
recovery-code key-value leaks, otpauth URIs, Bearer credentials) plus the
fixture's own secrets (raw tokens, passwords and hashes, TOTP secrets and
codes, invite codes, message bodies). audittest_test.go proves each class
bites and that ordinary details pass. Zero hits on the corpus at HEAD, so
no call site needed changing.

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

* docs(b2-6): record pre-squash SHAs, red/green and denylist evidence

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

* fix(b2-6): audit committed invites on a canceled request; 404 unknown plugin uninstall

Codex P2s on #1441, both test-first:
- CreateInvite read the invite back on the request context, so a cancel
  after the insert committed returned an error and skipped invite_create.
  The read-back and audit now run on context.WithoutCancel, like the
  password-change tail. TestCreateInvite_AuditSurvivesCanceledLookup.
- Registry.UninstallPlugin is idempotent on an unknown id, so the handler
  wrote plugin_uninstall for plugins that never existed. It now checks the
  row first: 404 and no audit. TestPluginsHandlerUninstallUnknownID.

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

* docs(b2-6): record the Codex review outcome on #1441

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 11:11:37 +02:00
J3vbandClaude Fable 5 67fdd18d7e feat(b2-5): one permission predicate per security property (#1440)
* feat(b2-5): canonical permission predicates

One value-taking predicate per security property in Server/permissions:
CanViewChannel, CanAdmitSession (= view), CanSendMessage, CanType (= send),
CanJoinVoice, CanModerateVoice, all over a Subject the caller resolves
(role bits, both override layers, channel flags, DM state). Checker now
resolves a Subject and asks it, so HasChannelPerm, HasChannelPermBatch and
VisibleChannelIDs are the same rule rather than three copies.

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

* fix(b2-5): send sites delegate to CanSendMessage (S-01)

checkSendPermission, HandleTyping, the ready payload's can_send and the
composer refresh all ask permissions.CanSendMessage over a resolved Subject
(PermissionService.Subject / ws subjectFor). Typing now follows the post
policy: a read-only member, an announcement reader without MANAGE_MESSAGES,
an archived channel, a blocked or non-participant DM user emit nothing.
Parity tables run each site against the predicate over the same fixture, in
both the cached-service and bare-hub branches.

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

* refactor(b2-5): view sites delegate to CanViewChannel/CanAdmitSession (S-12)

HandleChannelFocus and the post-Subscribe revalidation (applySetChannelID)
ask permissions.CanAdmitSession; channelReadAudience and
RefreshChannelVisibility ask CanViewChannel — all over a Subject resolved by
subjectFor in either the cached-service or bare-hub branch, so no ws path
mirrors the visibility rule by hand any more. hasPermChecked is gone with
its last caller. Parity tables per site, both branches, every override layer
plus an archived channel.

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

* refactor(b2-5): voice join sites delegate to CanJoinVoice

voice_join, voice_token_refresh, the destination of a moderator move and the
stale-voice sweep all ask permissions.CanJoinVoice over the subject the new
ws channelSubject resolves (role bits, both override layers, channel flags,
DM membership and block state); joinDenial maps a refusal to the frame each
reason always produced. hasChannelAccess, hasChannelAccessLive and
Hub.requireChannelAccess are gone with their last callers. The sweep now
re-runs the whole join rule (a deleted or archived channel, a lost DM
membership or a new block evict too, not only a lost CONNECT_VOICE bit), and
the token refresh refuses a deleted channel. Parity tables cover the shared
resolver, the join gate and the sweep in both branches.

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

* fix(b2-5): voice moderation delegates to CanModerateVoice (SEC-02 server half)

voiceModTarget decides with permissions.CanModerateVoice over the actor's
subject in the target's channel: effective MUTE_MEMBERS there (a role-layer
or user-layer deny now holds), READ_MESSAGES so a hidden room cannot be
moderated, and DM membership for a DM call. The base-bit check stays as an
early rejection only, keeping FORBIDDEN ahead of the voice-state lookup.
Locked by a table over both override layers, a hidden channel and the
Administrator bypass, through the real voice_mod_mute path; the deafen-race
fixtures gain the Checker the gate now needs.

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

* docs(b2-5): evidence block, inventory and closed rows

Record the B2-5 evidence (pre-squash SHAs, before/after inventory, the
SEC-02 READ decision, the residue that leaves the authz-chokepoint rule with
B3 item 15) in the plan, mark the step done, and flip S-01, S-12 and the
server half of SEC-02 to resolved/superseded in the issue register.

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

* fix(b2-5): CanJoinVoice refuses an archived DM call too

The old voice_join gate refused every archived channel regardless of type,
and the admin PATCH accepts archived for a DM; the predicate's DM branch
returned before consulting the flag, so join, token refresh and the sweep
would have let an evicted participant back into an archived call. Archive
is now checked after membership and block for both channel kinds (Codex P2
on #1440), pinned in the predicate table.

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

* docs(b2-5): record the Codex P2 fix in the evidence block

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 07:07:04 +00:00
J3vbandClaude Fable 5 2a21a22ecb docs(plans): bind the layout-refactor supplement into B3/B7/B9 and record B2-2 done (#1439)
Adds docs/plans/developer-experience-layout-refactor-2026-08-29.md and wires
it into the roadmap as dated workstream lines (B3 #17, B7 #16, B9 #11) rather
than a new phase. Updates the current implementation slice and README rows
for B2-2 (PR #1438, B2-3/B2-4 folded in; B2-5 next) and fixes the new plan's
stale pending-merge header.


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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 05:51:24 +00:00
J3vb 9c9b8be669 feat(b2-2): protocol epoch and negotiation (slim) (#1438)
* feat(b2-2): declare protocol_epoch in the schema and generate both constants

protocol/schema.json gains protocol_epoch (1). genprotocol emits
ws.ProtocolEpoch and PROTOCOL_EPOCH from it; the contract test pins the Go
constant to the schema so a stale regeneration fails the required check.

* feat(b2-2): check the client's protocol epoch in the auth handshake

The auth payload gains epoch (absent = 0). Outside [minClientEpoch,
ProtocolEpoch] the server answers one auth_error with code
protocol_epoch_unsupported, the client/server/min epochs, and a message
naming which side to update, then closes 1008 like every other handshake
failure. minClientEpoch is 0 for epoch 1 only so alpha.4 clients keep
connecting; the epoch-1 fixtures are unchanged.

* feat(b2-2): send the protocol epoch and offer the update on a refused connect

ws.ts sends epoch: PROTOCOL_EPOCH in the auth frame (contract test extended
on purpose). On auth_error code protocol_epoch_unsupported with a newer
server the dispatcher records the host in ui.store.updateRequiredHost and
main.ts mounts the UpdateNotifier on the connect page, so a refused client
gets the same Update Now banner it would have had on the main page.

* feat(b2-2): withhold client releases newer than the server's protocol epoch

The signed server-update manifest gains protocol_epoch (release.yml reads it
from protocol/schema.json). Updater.ReleaseProtocolEpoch verifies the
manifest and reads it; the client-update endpoint answers 204 when the
release's epoch is newer than ws.ProtocolEpoch or the manifest does not
verify. Releases without a manifest are epoch 0 and advertised as before.
Docs: protocol.md Compatibility section, api.md, deployment.md, protocol
README, CHANGELOG Unreleased.

* docs(b2-2): record the slim B2-2 decision and evidence; fold B2-3/B2-4 into it

* ci: prove the protocol_epoch manifest read on every PR, not only at tag time

* fix(b2-2): offer the update on an already-mounted connect page and keep the credential on a protocol refusal

Codex P1: on a first login or startup auto-login no overlay exists before
auth_ok, so a refusal never re-rendered the connect page and the one-time
read of updateRequiredHost missed it. The connect page now subscribes to
it, and a later refusal replaces the banner.

Codex P2: a refusal on reconnect went through the generic logout and
deleted the stored credential although the token is still valid.
clearAuth gets a protocol_epoch reason; main.ts keeps the credential on it
(the skip-auto-login flag is still set and, being sessionStorage, does not
survive the relaunch the update triggers).
2026-08-29 07:23:06 +02:00
e6c6bf12bf chore: sync dev with main after v1.2.0-alpha.4 (#1437)
* ci(deps): bump anthropics/claude-code-action (#1404)

Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.193 to 1.0.199
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/9d7150bc8a3dae8149739a88019d192b579ad90c...dcb57747bfceeaa1fa72638cae52295d1d853d4a)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.199
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): pin rfd to tauri-plugin-dialog's major to unblock the cargo group (#1406)

The cargo-dependencies group PR (#1405) fails Rust Unit Tests on Linux:

    error: failed to run custom build command for `rfd v0.17.2`
    You need to choose at least one backend: `gtk3` or `xdg-portal`
    features for x86_64-linux

rfd is not really ours. It arrives in the tree via tauri-plugin-dialog,
which pins ^0.16; we declare it directly only for the fatal-startup
message box in lib.rs, where the Tauri app never finished building and
the plugin has no AppHandle to run a dialog through.

Cargo unifies features only within a semver-compatible version group, so
while both wanted ^0.16 there was a single rfd in the graph and the
plugin's backend features covered our `default-features = false`
declaration too. Bumping our direct dep to 0.17 forks rfd into two
crates: the plugin keeps 0.16.0 with its features, ours resolves to
0.17.2 with none, and rfd 0.17 added a build.rs assertion that aborts
the Linux build when no backend feature is set. Confirmed in the PR's
lockfile, which carries both 0.16.0 and 0.17.2.

Adding a Linux backend feature would be the wrong fix: it would paper
over the fork and still build rfd twice on every platform for one error
dialog. Our version has to track the plugin's instead, so ignore
semver-minor rfd updates (0.16 -> 0.17 for a 0.x crate) until
tauri-plugin-dialog moves. Patch updates inside 0.16.x still flow.

The remaining five crates in the group are unaffected; `windows` in fact
consolidates 3 versions down to 2.

Cargo.toml is comment-only here - no dependency, feature, or lockfile
change - so the build is untouched.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): bump log (#1407)

Bumps the cargo-dependencies group with 1 update in the /Client/tauri-client/src-tauri directory: [log](https://github.com/rust-lang/log).


Updates `log` from 0.4.33 to 0.4.34
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.33...0.4.34)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump anthropics/claude-code-action (#1408)

Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.199 to 1.0.200
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/dcb57747bfceeaa1fa72638cae52295d1d853d4a...24dcd50c0568f0fc9e9211213a4fd2d9eb15c4e0)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.200
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* release: v1.2.0-alpha.4 — 62 fixes plus the B0/B1 repository foundation (#1426)

* Fix 27 findings from 2026-08-21 bug hunt (#1400)

* chore(findings): record 2026-08-21 bug hunt (38 findings)

* fix(api): 1 defect(s) (OC-0240)

* fix(client): 1 defect(s) (OC-0241)

* fix(plugin): 2 defect(s) (OC-0243, OC-0265)

* fix(client): 3 defect(s) (OC-0244, OC-0256, OC-0259)

* fix(client): 1 defect(s) (OC-0247)

* fix(client): 2 defect(s) (OC-0248, OC-0258)

* fix(identity): 1 defect(s) (OC-0250)

* fix(ws): 3 defect(s) (OC-0252, OC-0269, OC-0272)

* fix(admin): 1 defect(s) (OC-0253)

* fix(client): 1 defect(s) (OC-0254)

* fix(voice): 1 defect(s) (OC-0255)

* fix(ws): 1 defect(s) (OC-0260)

* fix(client): 1 defect(s) (OC-0261)

* fix(client): 1 defect(s) (OC-0262)

* fix(client): 1 defect(s) (OC-0263)

* fix(client): 1 defect(s) (OC-0264)

* fix(client): 1 defect(s) (OC-0268)

* fix(ws): 1 defect(s) (OC-0273)

* fix(service): 1 defect(s) (OC-0275)

* style: satisfy golangci-lint and prettier on 2026-08-21 fix commits

- drop ineffectual backupDir reset before return (registry.go, OC-0265)
- reflow long boolean expression (attachments.ts, OC-0241)

* fix(client): 4 defect(s) (OC-0242, OC-0246, OC-0249, OC-0251)

* fix(voice): 1 defect(s) (OC-0267)

* fix(admin): 1 defect(s) (OC-0274)

* fix(voice): 1 defect(s) (OC-0245)

* fix(ws): 1 defect(s) (OC-0271)

* fix(voice): 2 defect(s) (OC-0239, OC-0257)

* fix(ws): 1 defect(s) (OC-0266)

* fix(voice): 1 defect(s) (OC-0270)

* style: clear golangci-lint modernize and prettier nits from 2026-08-21 fixes

- range-over-int and slices.Contains modernizations in new Go test files
- prettier reflow in dispatcher.ts

* chore(findings): mark 2026-08-21 hunt findings fixed/declined

37 fixed across the fix waves, OC-0238 declined (LiveKit webhook TLS
requires a product decision, not a mechanical patch).

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix: 35 findings from the 2026-08-22 bug hunt (#1402)

* fix(voice): 1 defect(s) (OC-0277)

* fix(voice): 1 defect(s) (OC-0278)

* fix(client): 1 defect(s) (OC-0280)

refreshDmSidebar() rebuilds the entire DM sidebar subtree on every
dmStore.channels change - which includes presence flips and new
messages, not just DM list changes. The "Find a conversation" filter
text and input focus live only in that destroyed subtree, so they were
silently wiped mid-typing. Capture and restore both across the
destroy+recreate cycle.

* fix(ws): 1 defect(s) (OC-0285)

* fix(client): 1 defect(s) (OC-0286)

* fix(client): 1 defect(s) (OC-0288)

Consume the legacy unscoped mute key after migrating it onto the first
host, so a brand-new host with no scoped key of its own no longer reads
through to the same legacy list and inherits another server's mutes.

* fix(voice): 1 defect(s) (OC-0290)

* fix(db): 1 defect(s) (OC-0293)

DecrementMentionCounts reversed mention_count bumps that were never
applied: message_mentions stores every resolved mention id including the
author's blockers, while applyMentionCounts excludes blockers before
incrementing. Deleting a blocked author's message therefore wiped an
unrelated, genuine mention badge on the same read_states row. Mirror the
block exclusion in the decrement UPDATE.

* fix(db): 1 defect(s) (OC-0294)

DeleteAccount soft-deletes the departing user's messages but never reversed the read_states.mention_count bumps those messages made, leaving phantom mention badges. Reverse them inline in the existing transaction, mirroring DecrementMentionCounts' guards.

* fix(client): 1 defect(s) (OC-0295)

MemberList rebuilt every row on any non-presence-only membersStore change
and on every roles_update, but registered each row's click/contextmenu
listeners on the component-lifetime disposable.signal, which only aborts
at destroy(). Discarded rows therefore stayed reachable (and their
listeners live) for the component's whole lifetime. Route per-row
listeners through a per-render AbortController that is aborted and
replaced at the top of every render, and aborted again in destroy().

* fix(identity): 1 defect(s) (OC-0297)

UpdateProfile's post-commit re-read of the user row could fail for reasons
unrelated to context cancellation (SQLITE_BUSY, I/O error, pool exhaustion)
and was reported as ErrInternal even though UpdateUserProfile had already
committed. Callers that treat any UpdateProfile error as proof the write
never landed — handleUploadAvatar deletes the file it just stored — would
delete a file the committed avatar column now points at, permanently
breaking the avatar with no user_update broadcast.

Since UpdateUserProfile only writes username/avatar/display_name/about,
merge those four onto the pre-write snapshot to reconstruct the committed
row without needing the re-read to succeed, and log the read failure.

* fix(ws): 2 defect(s) (OC-0298, OC-0299)

- OC-0298: applyConnectStatus stamped c.user.Status even when the
  UpdateUserStatus write failed, so auth_ok and the presence broadcast
  claimed a status users.status disagreed with, and buildReady's
  ListMembers read never self-corrected for the session.
- OC-0299: refreshUserSnapshot silently fell back to roleName "member"
  when the new role lookup failed, pinning the session to a fabricated
  role on the wire. It now fails closed like the sibling lookups in
  upgradeAndAuth and handleFreshConnect.

* fix(client): 1 defect(s) (OC-0300)

* fix(client): 1 defect(s) (OC-0301)

* fix(ws): 1 defect(s) (OC-0302)

* fix(api): 1 defect(s) (OC-0305)

handleDiagnosticsConnectivity used clientIP(r), ignoring cfg.Server.TrustedProxies, so behind a configured trusted reverse proxy the endpoint reported the proxy hop instead of the real client address. Use clientIPWithProxies with the parsed trusted-proxy nets, matching RateLimitMiddleware on the same route.

* fix(client): 2 defect(s) (OC-0306, OC-0308)

* fix(client): 1 defect(s) (OC-0307)

QuickSwitcher registered a per-row click listener against the
overlay-lifetime AbortSignal, but renderResults() rebuilds every row on
each keystroke, arrow key, and store refresh. Discarded rows kept their
listeners alive until the overlay closed. Replaced with one delegated
click listener on the stable results container, keyed off the
data-channelid each row already carries.

* fix(client): 1 defect(s) (OC-0310)

* fix(server): 3 defect(s) (OC-0279, OC-0291, OC-0292)

Reap a soft-deleted message's attachment files, count lapsed temporary
bans as active users in the require_2fa enrollment gate, and only apply
the 2FA-enrollment precondition when require_2fa itself is being enabled.

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

* test(api): sync apiTestSchema with the user_blocks migration

DeleteAccount's mention-count reversal joins user_blocks; the api
package's hand-rolled schema fixture predates migration 012.

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

* fix(client): 3 defect(s) (OC-0281, OC-0282, OC-0296)

Decouple the E2EE identity-mismatch modal and right-click popovers from
the sidebar's per-render abort signal, and let global drag listeners
survive a mid-drag re-render.

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

* fix(voice): 2 defect(s) (OC-0283, OC-0287)

Retire a departed peer's E2EE key unconditionally on leave, and surface
a failed microphone unmute instead of reporting an unmuted state the
room never saw.

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

* fix(client): 3 defect(s) (OC-0289, OC-0303, OC-0309)

Guard the DM call button against redialing the channel already joined,
resolve the incoming-call banner's caller through the nickname-aware
display name, and keep the DM profile sidebar subscribed to live
member/status updates.

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

* style(client): prettier-format the dm-store test

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

* fix(server): 1 defect(s) (OC-0284)

Make message soft-delete a compare-and-set so a repeated chat_delete
cannot reverse mention counts twice.

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

* fix(server): 2 defect(s) (OC-0276, OC-0304)

Re-sync a resumed connection's voice E2EE peer keys in registerNow
(announce frames are unsequenced and cannot be replayed), and apply the
live-connection presence rule to every DM payload DMService builds.

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

* chore(ledger): record the 2026-08-21 hunt findings as fixed

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

* chore(ledger): independent revert-proof pass for OC-0276..OC-0310

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

* refactor(service): extract DeleteMessage authorization into a helper

Keeps DeleteMessage under the cyclop complexity ceiling after the
OC-0284 guard.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore(ledger): record the 38 open findings from the 2026-08-22 hunt (#1403)

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

Co-authored-by: Claude <noreply@anthropic.com>

* chore(graphify): refresh knowledge graph

* fix: close the three B0 P0 gates and record a measured baseline (#1409)

* chore(security): stop tracking the private security-finding reports

docs/security-findings/ holds detailed reports for defects that are not yet
fixed. The directory was untracked but not ignored, so any 'git add .' would
have published seven unfixed vulnerability traces to a public repository.

Findings are coordinated through private GitHub Security Advisories
(docs/security.md); only opaque identifiers and safe status belong in tracked
plans.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): repair the two red P0 unit contracts (G-01, G-02)

G-02: noise-suppression-restart stubbed MediaStream with
vi.fn().mockImplementation(arrow), which is not constructible. Vitest 4 threw
'is not a constructor' at the new MediaStream([inputTrack]) call in
noise-suppression.ts before reaching any assertion. Replaced with a real
class; the OC-0277 assertions are unchanged.

G-01: message-list's OC-0217 guard was inverted, not merely stale. It spied on
AbortSignal.prototype.addEventListener and asserted zero abort registrations,
but the leak it names registered row listeners via
element.addEventListener(..., { signal }) — a path that never calls that
prototype method. Measured: the leak produces 0 registrations (test passes),
while the OC-0286 fix rotates a per-window AbortSignal.any and produces 5
across 5 distinct signals (test fails). The guard passed on the bug and failed
on the fix.

It now captures the signal each window's row listeners register against and
asserts the invariant its name always claimed: one signal per rendered window,
a fresh signal per jump, and every superseded window already aborted with
exactly one live. Verified both directions — green on the fix, and
'expected 1 to be 5' with beginRowRender() reverted to rowSignal = ac.signal.

Client suite: 5257 passed, 0 failed (was 5255 passed, 2 failed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): make the Playwright suite terminate

The runner finished every test and then never exited, printing no summary — so
the failure read as 'tests never finish' when it was 'process never exits'.
getActiveResourcesInfo() at hang time showed a live ProcessWrap plus several
PipeWrap: the Vite dev server was still running. Playwright's webServer
teardown does not kill it here.

Measured, full suite each time:

  npm run dev                        hangs, tests pass
  node node_modules/vite/bin/vite.js hangs, tests pass
  reuseExistingServer: false         hangs, tests pass
  gracefulShutdown SIGTERM/3s        hangs, tests pass
  npx vite                           exits, 290 of 293 FAIL
  no webServer (pre-started)         exits, 293 pass in 33s

npx only appears to fix it: npx exits once Vite is up, Playwright reads that as
the server dying and tears the group down mid-run, so later tests get
ERR_CONNECTION_REFUSED.

globalTeardown now kills the process listening on the dev port, releasing the
runner's handle. The webServer command spawns Vite's entry point directly so
the listening process is Playwright's own child — via 'npm run dev' the npm
process would still hold the handle open. It also reaps servers orphaned by an
interrupted run, which reuseExistingServer would otherwise silently adopt.

An earlier revision used netstat, which is not on PATH in every shell here; the
swallowed ENOENT made the fix look applied while the hang persisted. It now
uses PowerShell on Windows and lsof elsewhere, and warns on failure rather than
failing silently.

npm run test:e2e: exit 0, 293 passed, 37s, reproducible, no orphan listener.
playwright.config.prod.ts carried the same npm-wrapper shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(client): align .nvmrc with the Node version CI uses

Three versions were in play, not two: .nvmrc said 20, CI pins 24, and the
machine the audit was measured on runs 26. A baseline measured against .nvmrc
is not the baseline CI produces, which defeats the point of B0.

Scoped to .nvmrc only. The full single-source-of-truth work — package engines,
contributor docs, release — stays in B1 (RL-17 / C-01).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): add the beta audit set and the B0 baseline

The 2026-08-23 audit set has been sitting untracked: repository-health and
repository-layout audits, beta product requirements, requirement traceability,
the issue register, and the B0-B10 roadmap. They are the plan of record for
beta and belong in the repository.

Adds b0-baseline-2026-08-25.md, which supersedes the roadmap's 'current
evidence snapshot'. Every row is marked measured or carried, so nothing is
inherited silently. It also records three audit claims that did not survive
verification:

  - G-01 was an inverted guard, not a stale assertion — it passed on the bug
    and failed on the fix.
  - The Playwright hang matched none of the three hypotheses; the runner could
    not kill its own dev server.
  - The golangci-lint toolchain failure is refuted: 19 linters run, 0 issues,
    verified with -v to rule out the known zero-linters false-green.

Adds b0-dev-branch-protection.sh, which records the applied dev branch
protection and the reasoning behind each setting.

Security detail stays private: the register carries only opaque SEC-* families
and safe closure criteria, per the roadmap's public/private handling policy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(graphify): refresh the knowledge graph

Own commit, per CLAUDE.md — the graph payload does not belong in the diff of
the changes that triggered it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): add the active-plan index and fix a stale status header (G-04)

Planning documents had no recorded state, so a reader could not tell current
guidance from shipped history. docs/plans/README.md now indexes every plan as
active, partially implemented, design-only, or shipped, and names the source of
truth for each concern so a defect count is never read out of a plan.

Status is recorded in the index rather than by moving or rewriting the
historical plans, so links from audits and commit messages keep resolving.

One real stale claim found and fixed: audit-2026-08-19-remediation.md still
read 'in progress 2026-08-19' while its own phase table showed phases 1-6 done
2026-08-20 (merged 03fcb7d5, PR #1396) with only phase 7 pending. The header
had drifted because the table was updated in place and the header was not.
No plan was found claiming '0 open findings'.

Also records the Step 8 staleness pass in the B0 baseline: all 38 open OC
records still resolve to a live file:line at this commit, so none is superseded
by later work. Adjudicating them individually is bughunt-fix work, not B0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Update graph output files and manifest with new metadata

- Updated graph.html and graph.json with new binary data.
- Modified manifest.json to reflect changes in file modification times and AST hashes for several documents.
- Added new entry for README.md in the manifest with its corresponding metadata.

* docs(plans): close the Docker and coverage leftovers in the B0 baseline

Docker smoke: measured and passing. Image builds at 50.1 MB and boots on :8443
with TLS; docker-smoke.sh exits 0.

Server coverage: re-measured at 74.6% aggregate, confirming the figure carried
from the audit rather than continuing to inherit it.

Two findings from doing it:

ENV-03 — docker-smoke.sh cannot be run from Git Bash on Windows. MSYS path
conversion rewrites the container-internal /chatserver into
'C:/Program Files/Git/chatserver', so docker exec fails 127 and the script
reports 'container never reported healthy within 30s' — indistinguishable from
a real boot regression. MSYS_NO_PATHCONV=1 makes the same script pass. CI is
Linux and unaffected, but Windows is an official contributor platform (RL-20).

The CI Docker job is gated on main, so it is skipped for any PR targeting dev
— a dev-targeted change cannot get Docker evidence from CI at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(graphify): refresh the knowledge graph

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): B1 execution plan, and accept HP-0 (#1410)

* docs(plans): add the B1 repository-foundation execution plan

B1 is the isolated layout/contributor phase. This records the execution
order, the proof for each step, and what is out of scope.

Two findings worth surfacing before any B1 work starts:

- HP-0 was never formally accepted. The roadmap's B1 entry gate requires
  it; no scorecard artifact exists, no commit or document records an
  acceptance, and the B0 baseline still lists "Step 10: HP-0 sign-off"
  under "Not yet done in B0". The plan lists the five gaps that closing
  it requires, including pinning required status checks on dev -- which
  are still unset, so a dev PR can currently merge red.

- Several layout-audit claims do not survive verification against HEAD,
  matching the B0 pattern. RL-09's "no single command verifies both
  protocol consumers" is false (make protocol-verify does, and is
  enforced in CI, the pre-commit hook, and a contract test). RL-10's
  test-discovery side effect never fires (no _test.go in Server/scripts).
  RL-06's regeneration concern is refuted locally. RL-08 grows a
  toolchain constraint instead. RL-05, RL-07, RL-20 and RL-21 are each
  worse than written -- RL-20 includes a live bug where a missing `make`
  is reported as stale protocol constants.

The riskiest item, RL-01 (flatten Client/tauri-client into Client), gets
a full reference inventory and a mechanical proof for both commits: tree-
object equality for the pure move, and scripted-substitution replay for
the path rewrite. Release asset names and updater contracts are verified
independent of the directory name, so the move cannot rename an artifact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): correct the B1 status-check pin list from a live dev PR

The list was derived from ci.yml. Observing PR #1410's actual checks
found three that exist in no workflow file -- Analyze (go),
Analyze (javascript-typescript), Analyze (actions) -- because CodeQL
runs from GitHub default setup, configured in repository settings.
Reading .github/ alone misses them.

Also confirms the two negative predictions against a real dev-targeted
PR: Server Docker Build (verify) reports as "skipping", and Tauri Full
Build never appears in the check list at all. Neither may be pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): accept HP-0 and pin the dev required status checks

Closes B1's entry gate. All five B1-0 items are done.

The scorecard is the artifact the hold point asks for: one place that
answers its four questions, records what was accepted as a stated
limitation rather than claimed green, and part-closes R-08.

Required status checks are now pinned on dev -- ten of them. That was
B0's one outstanding step. Two things came out of doing it:

- The names cannot be inferred from ci.yml. Three of the ten (the
  Analyze jobs) exist in no workflow file, because CodeQL runs from
  GitHub default setup configured in repository settings. They were read
  off a live dev-targeted PR with `gh pr checks`.
- Server Docker Build, Tauri Full Build and the CodeQL aggregate are
  deliberately excluded. The first two report "skipping" on a dev PR --
  Tauri Full Build under its unexpanded matrix name, since the job is
  skipped before matrix expansion. Admin Panel E2E is excluded because
  continue-on-error makes it report success unconditionally.

Two prior claims are corrected rather than left to propagate:

- b0-dev-branch-protection.sh was written assuming repository-settings
  writes are blocked from the agent sandbox. They are not; the PUT
  succeeded. The script stays as the record of intent and the way to
  re-apply or undo.
- An earlier revision of the B1 plan said Tauri Full Build does not
  appear in a dev PR's check list at all. It does, as skipping.

Evidence closed out:

- Rust is no longer a carried row. Re-measured: 115 passed, cargo clippy
  --all-targets -- -D warnings at exit 0, confirming the carried figure.
- The 38 open ledger records are accepted as counted, non-stale and
  assigned: 11 medium / 27 low, zero high or critical, zero dead paths
  across all 348 re-verified at this commit, and none assigned to B1.
- The private security review is reconciled: 7 findings, 7 of 7 mapped
  to existing public rows, 0 unmapped. Summary is content-free; the
  detail stays in the untracked private reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: flatten Client/tauri-client into Client (B1-1) (#1411)

* refactor: move Client/tauri-client to Client (pure move, no content change)

* refactor: re-point paths after the Client flatten (mechanical, no behaviour change)

---------

Co-authored-by: Claude <noreply@anthropic.com>

* B1-2: truth, entry points, and contributor path (#1412)

* fix(hooks): guard on the command the hook actually runs

pre-commit probed one binary and invoked another. The protocol block
guarded on `command -v go` and then ran `make protocol-verify`; the sqlc
block guarded on `command -v sqlc` and ran `make sqlc-verify`. `make` is
not on PATH on a stock Windows box, so a contributor with Go installed
but no make had their commit rejected with

    pre-commit: FAIL: protocol constants are stale — run 'make
    protocol-generate' in Server/ and stage the result

when nothing had been generated and nothing compared. The real cause was
`make: not found`, and the advice the message gives fails the same way.

Rather than add a `command -v make` guard, inline what the two Makefile
targets reduce to — `sqlc generate` / `go run ./scripts/genprotocol`
followed by `git diff --exit-code`. Same semantics, one less prerequisite,
and it doubles as the make-free equivalent B1-2 asks for. The Makefile
targets stay for anyone who prefers them.

Also: the protocol block was the only one with no `else`, so a
contributor without Go got no check and no notice. It now warns like its
two siblings. And gofmt is a separate binary from go, so it is probed
separately.

Verified both directions with the hook body replayed verbatim:
- Go present, make absent -> passes, no staleness claimed.
- schema edited without regenerating -> fails, as it must.

Refs RL-20 / L-14.

* docs: state one branch and PR model

Active documents contradicted each other head-on. README.md and
docs/contributing.md said branch from `dev` and target `dev`; CLAUDE.md
said branch from `main`, PR to `main`. That is R-02, and the 2026-08-19
audit had already recorded it as D-06 without it being resolved.

`dev` is the answer, and the repository already behaves that way: B0 made
`dev` PR-only with ten required checks enforced on admins, and #1409,
#1410 and #1411 all landed there. `main` carries releases.

docs/contributing.md becomes the single source of truth. It now states the
model, what protection is actually applied, and the two consequences a
contributor meets on their first PR — that a self-mergeable PR still cannot
merge red, and that Docker and Tauri Full Build report as skipped against
`dev` rather than failing. Everywhere else summarises and links here.

- CLAUDE.md: corrected, with a link rather than a second copy.
- CONTRIBUTING.md: new. GitHub's contributing-guidelines affordance only
  resolves the root, .github/ or docs/ — `docs/contributing.md` is not a
  path it finds, so the link never appeared on issues or PRs.
- PULL_REQUEST_TEMPLATE.md: names the base branch, which it did not.
- bughunt-run skill: reviewed the branch against `origin/main`, which is
  the wrong base once every PR targets `dev`.

README.md already said `dev` and is left as the short summary it should be.
Dated audits and the historical remediation plan keep their `main`-era
wording — they are records.

Refs R-02.

* fix(hooks): pick the pre-push base from the nearest integration branch

pre-push decided which side's gates to run from
`git diff --name-only origin/main...HEAD`. That was right when everything
targeted `main`. Once `dev` became the integration branch it stopped being
right: a branch cut from `dev` diffed against `main` counts everything on
`dev` and not yet on `main` as "changed".

Measured on this branch: the old base reported 609 changed files, the new
one reports 6. So in practice the hook was running the full server build
matrix and the client typecheck plus eslint on every push, whatever the
change touched — the file-based narrowing it exists for never engaged.

Now it picks whichever of origin/dev, origin/main is nearest, by commits
between merge-base and HEAD, skipping a candidate that scores 0. Verified:
a branch cut from dev picks origin/dev (2 ahead); dev itself scores 0
against dev and picks origin/main (8 ahead), which is what a dev -> main
release PR wants. With no candidate resolvable it falls back to the
existing `__all__`, so an unfetched or shallow clone still runs everything.

Refs RL-20 / L-14, R-02.

* chore(node): one Node source of truth

`.nvmrc` and all ten `actions/setup-node` pins said 24; five active
documents and the repo's only `engines` block still said 20. A contributor
following the docs installed a version CI does not run.

Node 24 wins — it is what CI already runs. Every manifest now declares
`engines`, and `engine-strict=true` turns a wrong major into a failed
install rather than an `EBADENGINE` warning nobody reads. `>=24` rather
than `^24` so a Node 26 box keeps working; `Client/.nvmrc` stays the
human-facing pin and the docs point at it instead of restating a number.

The `.npmrc` is per package root, not one at the top. npm reads the
project `.npmrc` from the package directory and does not walk parents —
verified with a throwaway package requiring node >=99: with only a parent
`.npmrc` npm warned and exited 0; with one in the package directory it
failed `notsup`. A single root file would have left `Client/`, the package
that matters most, on warnings.

Five docs, not the four previously identified — `docs/mcp-introspect.md`
also said 20. And `docs/contributing.md` claimed "`.nvmrc` + CI both say
Node 20", which was wrong about both.

Verified both directions in all three package roots: Node 22 fails
`notsup`; Node 24 installs clean and `npm ci` passes in Client/.

Refs RL-17 / C-01, ENV-01.

* docs: add the documentation landing page

`docs/` had 24 top-level files and no index. The root README carried a
flat list of 22 links that had drifted: six documents were reachable from
nowhere at all — including both 2026-08-23 audits and the test audit — and
two entries were labelled "latest" while newer unlinked audits existed.

docs/README.md is the index RL-12 asked for. It groups by what a document
*is*, because that is what decides whether to trust it: guidance tells you
how to do something, reference describes a contract the code implements,
audits are dated snapshots nobody updates, plans record intent. Every
tracked file under docs/ now appears exactly once, and the audit table says
plainly that audit-2026-08-19.md still claims "0 open findings" when the
ledger has 38.

The root README keeps a short curated list and defers to the index, rather
than maintaining a second copy that drifts again. Two fixes while there:
`docs/plans/` was linked as a bare directory, unlike its two sibling
directory entries, and was annotated "each carries a verified status
header" — which docs/plans/README.md:7-9 explicitly contradicts, since a
plan's header is exactly the thing that drifts and the index is the
authority.

Verified: 78 relative links across the new and edited files resolve, and
no tracked docs/ file is unreachable from the index.

Refs RL-12 / R-06.

* feat(scripts): root command facade

Entry points existed only inside Server/ (a Makefile) and Client/ (npm
scripts). Nothing at the root told a new contributor where to start, and
the root package.json had three scripts, none of which built or tested
anything.

`npm run check` from the root now runs what CI gates on, and
check:server / check:client / check:rust run one stack. scripts/run.mjs
is dependency-free Node — the shape render-ledger.mjs already uses — so
`npm run check` works before `npm install` has.

Cross-platform by construction: every step is spawned with an explicit cwd
and no shell, so there is nothing to quote and no `cd &&` to behave
differently on Windows. npm and npx get their .cmd suffix there. No step
shells out to make.

The facade orchestrates; it is not a new required path. Each step prints
the command and the directory before running it, and those are exactly the
commands documented per-stack — so a server contributor can read the output
and type them instead, and still never needs Node. Tools CI installs but a
contributor may not have (golangci-lint, which has no wrapper in this repo
at all; sqlc, pinned by Server/sqlc.version) are skipped with a printed
reason rather than failing.

Three corrections to the ci-check skill while aligning it:

- `make sqlc-verify protocol-verify` replaced by what those targets reduce
  to, so the documented path does not require make either.
- `cargo test` -> `cargo test --lib`, which is what ci.yml actually runs.
- "NODE_OPTIONS=--no-experimental-webstorage is mandatory on Node 22+" was
  false. tests/setup.ts installs the shim, CI runs Node 24 without the
  flag, and the suite was measured passing without it — 192 files / 5257
  tests, identical to the flagged run.

Also documents the third RL-20 problem, which needed no code: core.hooksPath
is exclusive, so `npm run hooks:install` silently disables any
.git/hooks/post-commit — including the one `graphify hook install` writes,
which CLAUDE.md tells agents to install. Nothing warned about that.

Verified: check:client 5257/192 green, check:rust 123 tests + clippy green,
--list prints every command, and the optional-tool skip path reports rather
than fails.

Refs RL-04 / L-04, RL-20 / L-14.

* feat(ci): fail on a document that contradicts the findings ledger

G-04's remaining half. The ledger is the source of truth for defect counts,
but nothing stopped a planning document from stating a different number and
nothing noticed when one did. `render-ledger.mjs --check` cannot help: it
validates the JSON schema and returns before rendering, so it never reads
FINDINGS.md and cannot see drift at all — and no workflow ran it anyway.

scripts/check-doc-counts.mjs counts ledger statuses and compares them to
what an allow-list of active documents claims, failing with file, line,
claimed value and actual. Wired into ci.yml as a job with no npm ci, since
the script imports nothing outside node:, and into the facade as
`npm run check:docs` — first in `check`, so a contradicted count does not
wait behind ten minutes of -race.

The patterns are narrow on purpose. A first attempt matched any
"<number> <status>" and flagged nineteen things, all false: "the 45 open P1
rows" (issue-register rows, not ledger findings), "All 8 findings F1-F8"
(a different register), "G-05 **refuted**" (an identifier), `">=20"` and
`CGO_ENABLED=0` (not counts at all). A check that cries wolf gets ignored,
which is the failure G-04 already describes. So a number is only read as a
claim in three shapes that cannot mean anything else: an enumeration of two
or more "<n> <status>" pairs, a status table row in a table that totals
itself, and "<n> records/findings" where the ledger is named within three
lines. Fifteen selftest assertions pin both directions, and the job runs
them before it runs the check.

It reads findings-ledger.json directly rather than importing
render-ledger.mjs for `validate`/`render`: that module ends in a bare
top-level `await main()` with no import.meta.main guard, so importing it
rewrites FINDINGS.md as a side effect.

Dated docs/audit-*.md are reported, never failed — they are snapshots
nobody maintains. audit-2026-08-19.md does claim zero open findings against
38 open, so b0-baseline's "No plan was found claiming '0 open findings'"
holds for docs/plans/ but not for docs/.

Not included: a real FINDINGS.md render-drift check. That is RL-07 and
belongs with the generated-artifact work, not here.

Verified: 27 claims across 9 documents agree; corrupting one count in
docs/plans/README.md fails the check naming that line, for both the status
and the total.

Refs G-04.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore: remove graphify knowledge graph tooling (#1413)

The committed knowledge graph and its PreToolUse hooks were steering every
codebase question through `graphify query` before any other tool could run.
Serena (gopls + rust-analyzer + tsserver over MCP) answers the same questions
from real language servers rather than a generated snapshot that goes stale
between rebuilds, so the graph no longer earns the ~20 MB it costs the tree.

Removed:
- `graphify-out/` untracked (7 files, ~20 MB) and now gitignored
- both `graphify hook-guard` PreToolUse hooks from `.claude/settings.json`
- the "Knowledge graph (graphify)" section of `CLAUDE.md`
- the `graphify-out/**` block from `.gitattributes` and `.gitignore`
- the graph-rebuild step from the `bughunt-run` skill, and the graph-edge
  guidance from the bughunt workflow prompt
- the graphify-specific `core.hooksPath` example in `ci-check` and
  `docs/contributing.md`, keeping the underlying warning in generic form

Also deletes the locally installed `post-commit` / `post-checkout` rebuild
hooks (untracked, not part of this diff).

This does not shrink clone size: the graph blobs stay in published history,
which `docs/plans/b1-repository-foundation-2026-08-25.md` explicitly rules out
rewriting. It does stop future refreshes from adding more.

Dated audit and plan documents keep their graphify references as a historical
record of the state they described.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* B1-3: repository hygiene gates (RL-19 / L-13, S-05) (#1414)

* chore(format): one Prettier config at the repository root

Every formatting rule in this repository lived under Client/ and covered
exactly two globs: Client/src/**/*.ts and Client/tests/**/*.ts. Root Markdown,
all of docs/, every YAML and JSON, all CSS, the root scripts and
tools/mcp-introspect were formatted by nothing. There was no .editorconfig.

The obvious fix -- a second Prettier config at the root for "everything else"
-- gives two configs and two ignore files that can silently disagree about the
same file. So the root takes ownership instead: config, ignore file and gate
move up, and Client/ folds in. Client's inline "prettier" block, its
.prettierignore, its format/format:check scripts and its now-unused prettier
devDependency are all deleted; knip would have failed client-check on that last
one.

The .prettierrc.json values are lifted byte-for-byte from Client/package.json,
which is what keeps the reformat commit free of client TypeScript churn: 87
tracked files need reformatting and not one of them is under Client/src or
Client/tests.

.prettierignore carries only what .gitignore does not. Prettier 3 reads the
root .gitignore by default, so node_modules/, dist/, coverage/,
Client/src/generated/ and docs/security-findings/ need no entry. It does NOT
read nested .gitignore files, which is why .remember/ is listed explicitly --
38 untracked per-machine scratch files were otherwise able to turn a shared
gate red. graphify-out/ is listed because its seven files are tracked and
.graphify_labels.json is signed byte-for-byte by its .sig, so formatting it
would silently invalidate the signature.

check:hygiene is registered in scripts/run.mjs and folded into check and
release:preflight. It deliberately contains no `gofmt -l` step: gofmt -l prints
offenders and still exits 0, so it cannot fail a build. Go formatting is
enforced separately.

shellcheck and actionlint take their file lists from `git ls-files`, never a
filesystem glob -- .claude/worktrees/ holds a gitignored pre-flatten copy of
the tree with three .sh files a glob would happily lint.

This commit leaves the tree non-conformant on purpose. The reformat is the next
commit, so the 87-file diff is reviewable separately from the rule that caused
it.

Not included: editorconfig-checker. .editorconfig is the editor baseline the
audit asked for; Prettier, gofmt and rustfmt already fail CI on the same
indentation and newline rules, so a fourth tool checking them again is a gate
with no failure mode of its own.

Verified: `npx prettier --check .` names 87 tracked files and zero untracked
ones; the same command listed 38 .remember/ scratch files before the ignore
entry and none after. `node scripts/run.mjs --list` resolves check:hygiene to 8
shell targets and 4 workflow targets. Both package.json files parse.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): reformat the tree to the repository Prettier rules

Mechanical. This commit is `npx prettier --write .` and nothing else -- the
rule that caused it landed in the previous commit so this diff can be reviewed
as a transformation rather than as 84 files of hunks.

84 tracked files: 54 Markdown, 7 .mjs, 7 JSON, 6 YAML, 4 .js, 3 CSS, 2
TypeScript (the two Playwright configs at Client's root, which the old
Client/src + Client/tests globs never covered). No file under Client/src or
Client/tests moves, because .prettierrc.json carries Client's former inline
values byte-for-byte.

Prettier rewrote 87 files, not 84. The three in .github/ISSUE_TEMPLATE/ had
CRLF on disk and differ only in line endings, which .gitattributes
(`* text=auto eol=lf`) already normalises, so their committed blobs are
unchanged. Worth knowing before someone reconciles the two numbers.

The largest single diff is .superpowers/findings-ledger.json at 7976 lines
rewritten. That is safe to format: nothing writes the ledger programmatically
-- render-ledger.mjs reads it and writes only FINDINGS.md -- so no tool will
fight Prettier over its style on the next hunt. FINDINGS.md itself is ignored
as generated.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style", so the pass is both complete and idempotent. All 7 reformatted JSON
files were parsed before and after and compared as values: semantically
identical, zero content changes. `node .superpowers/render-ledger.mjs --check`
still reports 348 valid findings and leaves FINDINGS.md untouched.
`node scripts/check-doc-counts.mjs` still passes its selftest and still agrees
on 27 claims across 9 watched documents -- table realignment did not break the
patterns it matches on. `node scripts/run.mjs --list` still parses.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(lint): enforce Go formatting in the Server linter

S-05: repository-wide Go formatting was not a required gate. The only gofmt
enforcement anywhere was .githooks/pre-commit, which is opt-in per clone
(`npm run hooks:install`), only sees staged files, and warns-and-skips when
gofmt is off PATH.

The obvious fix -- a `gofmt -l` step in CI -- does not work: `gofmt -l` prints
its offenders and still exits 0, so the step passes no matter what it finds.
scripts/run.mjs has the same problem, which is why check:hygiene has no Go step
either.

So gofmt goes where it can actually fail something: Server/.golangci.yml. The
file was already `version: "2"` but had no `formatters:` block at all, so the
19 enabled linters ran with zero formatters. In v2 gofmt/gofumpt/goimports
moved out of `linters.enable` into their own section with its own exclusions.
Adding it there means the gate reports through the Lint step of "Server Build &
Test", which is already pinned as required on dev -- no new job and no new pin.
Every tracked .go file is under Server/ (551 of them, one go.mod), so
Server-scoped is repository-wide here.

One file was genuinely misformatted: a one-space struct field alignment in
Server/admin/handlers_users_broadcast_test.go, fixed in the same commit because
a single line does not need its own reformat commit.

Trap worth recording: `gofmt -l .` on a Windows working tree lists every file
that has CRLF on disk, because gofmt normalises line endings. That reported 18
offenders here, 17 of them ghosts. The blobs are all LF -- .gitattributes
forces `eol=lf` -- so CI never saw them, and the honest test is to run gofmt
over `git show HEAD:<file>` rather than the working copy. Doing that across all
551 tracked Go files found exactly the one real offender above.

Verified both directions with golangci-lint v2 locally: `golangci-lint run
./...` reports 0 issues on the formatted tree; appending a misformatted
function to Server/auth/constants.go produces 2 gofmt findings; appending the
same misformatted function to Server/db/dbgen/admin.sql.go produces 0, so the
exclusion holds. Both files restored and verified clean afterwards.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): escape the NUL separator instead of embedding one

The `tracked()` helper added earlier in this branch splits `git ls-files -z`
output on NUL. The separator was written as a literal NUL byte rather than the
two-character JavaScript escape, so scripts/run.mjs became a binary file: `git
diff` refused to show it, `grep` reported "Binary file matches" instead of the
line, and `* text=auto` in .gitattributes stops normalising line endings for a
blob it detects as binary.

The code worked -- splitting on a raw NUL and splitting on "\0" are the same
operation -- which is exactly why this is worth fixing before it is inherited.
A source file that tooling classifies as binary is a file nobody can review.

Verified: zero NUL bytes remain, `grep -n "split("` now prints line 50 instead
of "Binary file matches", `node scripts/run.mjs --list` still resolves the same
8 shell and 4 workflow targets, and prettier still reports the file clean.

* chore(lint): enforce Rust formatting

Rust had no formatting gate of any kind: no rustfmt.toml, no `cargo fmt`
anywhere in CI, in scripts/run.mjs, in the Makefile or in the git hooks. Clippy
was the only Rust gate, and clippy does not check layout.

`cargo fmt --all -- --check` now runs in the rust-tests job, ahead of clippy: a
formatting failure is cheap to produce and cheap to fix, and there is no reason
to spend a clippy pass to surface one. The stable toolchain in that job
requested `components: clippy` only, so rustfmt is added there.

Only that job. ci.yml has a second, byte-identical `Install Rust` block in
tauri-build; it stays clippy-only, because a full desktop build is the wrong
place to discover a misplaced brace.

No rustfmt.toml. The default profile is the point of a baseline -- a config
file here would be a second opinion about style with nothing to say.
Client/src-tauri is a single `[package]`, not a workspace, so `--all` is a
safeguard against a future member rather than a fan-out today.

Verified: `node scripts/run.mjs --list` resolves check:rust to three steps with
`cargo fmt --all -- --check` first, `npm run format` now also runs `cargo fmt
--all`, and prettier reports ci.yml, run.mjs and the ci-check skill clean.
`cargo fmt --all -- --check` currently fails on 13 files -- that is the
reformat, and it is the next commit.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): reformat the Rust crate to rustfmt defaults

Mechanical. This commit is `cargo fmt --all` and nothing else; the gate that
demands it landed in the previous commit so this diff is reviewable on its own.

13 of the 17 tracked .rs files, +343/-164. The crate had never been formatted,
so the changes are the usual first-run set: aligned trailing comments collapsed
to single spaces, single-element slice literals folded onto one line, long
method chains broken across lines, closure bodies expanded into blocks.

Verified: `cargo fmt --all -- --check` is clean, so the pass is complete and
idempotent. `cargo clippy --all-targets -- -D warnings` finishes with no
warnings, and `cargo test --lib` reports 115 passed / 0 failed -- identical to
before the reformat, which is what "mechanical" has to mean for a commit that
touches this much of the crate.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): make the root facade actually run on Windows

Adding the first gate that a contributor would run from the repository root
exposed two bugs in the facade, both of which made it silently wrong on the
platform this project is developed on.

1. Every npm and npx step failed. bin() appends `.cmd` on Windows, but Node
   refuses to spawn a .cmd or .bat with shell:false -- the CVE-2024-27980
   mitigation -- and fails with EINVAL and a *null* exit status. run.mjs only
   special-cased ENOENT, so the result was `FAILED: npx prettier --check .
   exited null` with nothing to explain it. check:client has three npm steps and
   has never been able to run here.

   Fixed by spawning only the npm shims through a shell. They are concatenated
   into a single command string rather than passed as an args array, because
   shell:true plus a separate array is deprecated (DEP0190) and prints a warning
   on every invocation; no argument in this file contains a space.

2. Every optional() step was skipped, always. onPath() shelled out to
   `where` on Windows, but where.exe lives in C:\WINDOWS\System32, which a Git
   Bash PATH does not necessarily contain -- on this machine PATH carries
   System32\Wbem, System32\WindowsPowerShell\v1.0 and System32\OpenSSH but not
   System32 itself. The probe could not start, `probe.status === 0` was false,
   and golangci-lint and sqlc reported as "not installed" while installed.

   Fixed by resolving against PATH and PATHEXT directly. No subprocess, and no
   dependency on which directories happen to be on PATH.

A spawn error other than ENOENT now reports its code instead of surfacing as a
null exit status.

Verified: before, `node scripts/run.mjs check:hygiene` died with "exited null"
and both optional steps printed SKIP with the tools present on PATH. After, the
same command runs prettier, shellcheck and actionlint and prints
"check:hygiene: passed", with no deprecation warning. `golangci-lint` is
detected by the new onPath where the old one missed it.

Refs RL-20 / L-14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): ignore build output that nested gitignores hide

Prettier honours the root .gitignore and no other. Every build and scratch
directory in this repository is ignored by a *nested* one -- Client/.gitignore,
.serena/.gitignore, .superpowers/sdd/.gitignore -- so none of them were
excluded from the new repository-wide gate.

The effect is not subtle. Running `cargo test` once drops roughly 850
formattable files into Client/src-tauri/target/, and the hygiene gate goes from
clean to "Code style issues found in 939 files". CI never sees it, because a
fresh checkout has no build output; every contributor sees it on their second
command.

Mirrors the three nested files rather than inventing a list: dist, coverage,
playwright-report, test-results, .vite, src-tauri/target and src-tauri/gen from
Client/.gitignore, plus .serena/ and .superpowers/sdd/. node_modules needs no
entry -- Prettier ignores it by default.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style" with a fully populated Client/src-tauri/target/ present on disk, and
still names README.md when a misformatted table is appended to it.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(ci): shellcheck, actionlint, and a repository hygiene job

The last two gates RL-19 asks for. Neither existed: the shell scripts were
never linted, the workflows were never syntax-checked, and .githooks/pre-commit
carried hand-written `# shellcheck disable=` directives that nothing had ever
read.

New `hygiene` job, ubuntu-only and root-scoped, modelled on docs-consistency
for the same reason: every gate in it is platform-independent text analysis,
and .gitattributes pins eol=lf so a second OS would only re-prove line endings.
It runs `npm run check:hygiene` -- the same entry point a contributor runs, not
a parallel copy of the commands.

shellcheck ships in the runner image. actionlint does not, so it is pinned by
version and verified by sha256: an installer script piped from a branch would
be the one unverified download in a workflow file that pins every action by
commit SHA.

Prettier's step moves here from client-check, where it no longer belongs.

Both linters found real defects.

shellcheck, 3 findings in 8 scripts. Two are SC1125 errors in
.githooks/pre-commit: `# shellcheck disable=SC2086 - repo paths contain no
spaces` is not a valid directive. Trailing prose makes shellcheck discard the
rest of the line, so neither suppression was ever in effect -- and one of the
two was written earlier in this same branch, which is a fair demonstration of
why the gate is worth having. The prose moves to its own line above. The third
is SC2015 in start-server.sh, rewritten as an explicit if.

actionlint, 5 findings, all inside `run:` blocks it shellchecks once shellcheck
is on PATH. Three SC2015 in load-baseline.yml, rewritten as explicit ifs. Two
SC2035 in release.yml, where `sha256sum *` should not become `sha256sum ./*`:
the comment four lines above records that ParseChecksumFile exact-matches the
last field, so a "./" prefix would strand every deployed server exactly as a
"windows/" prefix would. `sha256sum -- *` satisfies the linter and leaves the
output bytes identical.

Verified all three gates in both directions with shellcheck 0.10.0 and
actionlint 1.7.7 on PATH. Passing: `node scripts/run.mjs check:hygiene` prints
"check:hygiene: passed" with all three steps run, not skipped. Failing:
appending `bait_fn() { cat $1; }` to Server/scripts/voice-test.sh fails on
SC2086; changing a runs-on to `ubunt-latest` fails on runner-label; appending a
misformatted table to README.md fails prettier. All three files restored and
confirmed clean afterwards. actionlint validates the new job in ci.yml itself.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): record B1 progress through B1-3

The header still read "B1-0 is complete; B1-1 is the next step" three merged
phases later. A plan that misstates where it is costs a reader the same
confusion whether it is stale by one phase or three.

B1-0 (#1410), B1-1 (#1411), B1-2 (#1412) and B1-3 (this branch) are done; B1-4,
dependency automation, is next.

Verified: `node scripts/check-doc-counts.mjs` still agrees on 27 claims across
9 watched documents -- this file is one of them -- and prettier reports it
clean.

* chore(ci): pin Repository Hygiene as a required check on dev

The second half of S-05. Its acceptance criterion is "tree is formatted AND a
fast required gate fails future drift" -- a check that runs but is not pinned
lets a formatting regression merge, so the gate is not a gate until this lands.

The name was read off PR #1414 with `gh pr checks` after the job reported
`pass` in 26s, not copied out of ci.yml. That order matters: the B0 script
records that three pinned names exist in no workflow file at all, and that a
required check which never reports blocks every PR forever.

Extends the existing script rather than adding a second one, per the B1 plan.

Also records, in the "deliberately NOT pinned" list, that Docs & Ledger
Consistency reports and passes on a dev PR yet is unpinned. That reads as an
oversight from the 2026-08-25 pass rather than a decision, but it belongs to
G-04, so it is documented here and not changed.

NOT APPLIED YET. Running this script now would pin a check that PR #1413 cannot
report -- its branch predates the hygiene job, so the job does not exist in its
workflow file and the check would never arrive. Run it after #1414 merges;
#1413 needs a rebase onto dev regardless.

Verified: shellcheck clean, the embedded JSON parses, and `check:hygiene`
passes with prettier, shellcheck and actionlint all running.

Refs S-05, RL-14 / G-03.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* B1-4: dependency automation (RL-05 / L-05, RL-18) (#1415)

* chore(deps): cover the root and mcp-introspect npm roots

The repository has three npm package roots — `/` (changelogen, prettier),
`/Client`, and `/tools/mcp-introspect` (@modelcontextprotocol/sdk, zod) —
each with its own package-lock.json, and `npm run bootstrap` runs `npm ci`
in all three. Dependabot watched exactly one of them. The root's prettier is
what the Repository Hygiene gate runs, so the formatting gate's own toolchain
was drifting unwatched.

The obvious fix is to collapse the three roots into an npm workspace and
watch one lockfile. Measured on npm 11.17 / Node 26 rather than assumed, that
trade is bad, and it is bad for different reasons than expected. Workspaces do
not break the things you would predict: `npm ci` inside `Client/` still exits
0, `npm run <script>` still resolves the hoisted binaries because npm prepends
every ancestor `node_modules/.bin` to PATH, and `engine-strict` still fails
the install on a wrong Node major. What they cost is ten CI steps keyed on
`cache-dependency-path: Client/package-lock.json` (six in ci.yml, four in the
tag-only, CI-ungated release.yml) pointing at a file that stops existing; the
Repository Hygiene job's deliberate root-only install growing 970 ms to
6172 ms and 39 to 318 packages unless every call site remembers
`--workspaces=false`; and one shared lockfile putting all three npm Dependabot
groups back into the same file, which is precisely the rebase storm the
grouping comment at the top of dependabot.yml exists to prevent. The measured
benefit is one 298 KB lockfile instead of three (17 KB / 253 KB / 42 KB) and
614 resolved packages deduped to 582 — 32 packages, 5.2% — with client install
time unchanged at 5642 ms against 5667 ms.

So the roots stay separate and each gets its own block, matching the four that
already exist: grouped to one PR, majors ignored, weekly on Monday. A single
block with `directories:` was rejected for the same reason as workspaces —
grouping only works while a group rewrites exactly one lockfile. The decision
and its numbers are recorded in docs/contributing.md under Dependency Policy,
so the next person to propose workspaces reads the measurement instead of
repeating it.

Verified: a coverage checker cross-references every `package-ecosystem` /
`directory` pair in dependabot.yml against every manifest in `git ls-files`,
in both directions. Against dev at 2a37f386 it reports `UNWATCHED npm
package.json` and `UNWATCHED npm tools/mcp-introspect/package.json` (plus
Server/Dockerfile, which the next commit covers). Against this commit both npm
rows read `ok`, and the forward direction confirms each newly declared
directory really holds a package.json. `npx prettier --check` reports both
edited files unchanged, so the B1-3 formatting gate stays green.

Not included: the docker ecosystem (next commit, RL-18); immutable image
digests for release/runtime containers, which is R-04's remaining half and
belongs to B6; and turning the coverage checker into a permanent
`check:hygiene` gate — a new manifest root can still drift unwatched, which is
how this gap arose, but that is new gate machinery rather than the coverage
this item asks for.

Refs RL-05 / L-05

* chore(deps): watch the server container base images

Server/Dockerfile pulls `golang:1.26-bookworm` to build and
`gcr.io/distroless/static-debian12` to run, and nothing watched either. Every
other dependency root in the repository is on a weekly Dependabot schedule, so
the one artefact that ships to users as a whole filesystem was the only one
whose upstream moved silently — including its CA certificates, which the
Dockerfile comment specifically calls out as the reason distroless was chosen
over scratch.

The obvious fix is to pin both images by digest and be done. That is the wrong
move here for two reasons. A digest pin with no automation behind it is worse
than a tag: it freezes the base image at whatever was current the day someone
typed it, and a frozen distroless base is a frozen CA bundle. And digest
refresh for release and runtime images is R-04's other half, scoped to B6
alongside the smoke tests that have to gate it — landing half of it here would
leave the digests pinned and the refresh unowned.

So this adds the `docker` ecosystem for /Server on the same terms as the five
blocks around it: grouped to one PR, majors ignored, weekly on Monday. Be
precise about what that actually buys, because it is less than the block
implies. Of the two images only `golang:1.26-bookworm` carries a comparable
version, so it is the only one Dependabot can act on today;
`gcr.io/distroless/static-debian12` has no version tag, and an untagged image
is not something a version update can move — it needs the digest pinning that
B6 owns. The comment above the block records that the Go builder tag tracks
Server/go.mod and every `actions/setup-go` in CI, so a `1.27-bookworm` PR is a
prompt to move all three together rather than a standalone merge.

Verified: the coverage checker cross-references every `package-ecosystem` /
`directory` pair against every manifest in `git ls-files`, in both directions.
Against dev at 2a37f386 the reverse direction reports `UNWATCHED docker
Server/Dockerfile`; against this commit every row reads `ok` and it exits 0 —
`ALL ROOTS WATCHED`, six blocks covering six manifest roots, with the forward
direction confirming /Server really holds a Dockerfile. `npx prettier --check`
passes on the edited file.

Not included: Server/docker-compose.yml and Server/docker-compose.otel.yml.
Their four images are `ghcr.io/j3vb/owncord-server:latest` (this repository's
own published image), `livekit/livekit-server:v1` (a floating major tag, and
majors are ignored everywhere), `jaegertracing/all-in-one:latest` and
`prom/prometheus:latest` — none of which a version update can move, so a
compose block would be configuration that provably produces nothing. Also not
included: immutable digests plus digest-refresh PRs with smoke tests for the
release and runtime images, which is the remainder of R-04 and belongs to B6.

Refs RL-18

* docs: apply skill-review findings to ci-check and the project skills (#1416)

The observation log had accumulated 46 open entries against a last review of
2026-08-14. Seven of them target skills tracked in this repository and were
verified still-unapplied against the current files.

`ci-check` gains four things it was missing. It never mentioned `cargo audit`,
which CI runs pinned at 0.22.1 in `tauri-build` — the one gate that turns red
with zero local changes, because an upstream advisory breaks a branch that was
clean yesterday, and the one a hand-written mirror silently drops because no
edit provokes it. It never mentioned that `release.yml` is tag-triggered and
PR-ungated, so a smoke/sign/strip step added only there first executes on the
release; #1376 shipped a smoke harness whose own bug then blocked a release,
and #1378 fixed it structurally by extracting `Server/scripts/docker-smoke.sh`
for both workflows. And it had no guidance for reading a red check at all: a
new section adds causality-before-forensics triage (diff the changed-file set
against the failing job's input surface before opening a log — a workflow-only
diff cannot cause a Go goroutine leak), the lockfile-fork diagnosis for
dependency bumps (a 1 → 2 entry-count transition means the update forked the
dependency and revoked the features it was borrowing, so aligning versions is
the fix, not setting the feature the new copy demands), and the known-flake
table promoted to a signature-to-recovery index, now including the apt-mirror
hang that cancels `tauri-build` by timeout.

The baseline rule that came with the triage section needed adjusting rather
than transcribing. Its source observation recorded `golangci-lint`'s known-red
complexity baseline as 23 cyclop / 6 dupl / 21 funlen / 12 nestif; #1389
cleared that to zero, so quoting those numbers would have taught the reader to
excuse a failure that is now genuinely theirs. The rule is recorded without
them, stating that the repo currently carries no known-red gate and what to do
if one is ever reintroduced.

`protocol-change` claimed the schema is the source of truth without saying what
it covers. It holds message-type names only, so a payload-field change touches
the Go command/message files, the client types and `docs/protocol.md` and never
the schema — routing one through the regenerate cycle is wasted work. A table
splits the three cases, with the relay-handler caveat: a server that
re-serialises drops unknown fields, so a forwarded field is not backward
compatible with older servers.

`task-observer`'s numbering discipline treated collisions as a parallel-human
accident. They are structural in fan-out workflows, because a dispatched
subagent has the skill active in its own context and writes to the same log.

`bughunt-run` covered findings blocked by a circuit breaker but not findings
that went stale: a later hunt routinely fixes a blocked finding as a side
effect of an overlapping sibling, and a saved debris patch stops applying once
a refactor rewrites its files. Of 6 findings blocked on 2026-08-14, 2 were
already fixed 5 days later.

`docs/contributing.md` gains the commit-body convention that was being followed
without being written down anywhere — reasoning over diff-restatement, a
`Verified:` paragraph proving both directions, and an explicit `Not included:`
line. That last one is what keeps adjacent scope from becoming either silent
drift or an unnecessary blocking question.

Verified: each edit was checked against the live file before applying, which
changed two outcomes. Observation 50 (make the hunt's stop rule measure
coverage, not just quietness) is already implemented — `bughunt-run` documents
`coverage + dry is the real stop`, `stalledCoverage` and
`coverage.uncoveredAtStop`, landed by #1399 — so it is marked actioned rather
than re-applied. Observation 42 looked covered by the same grep and was not:
the existing text handles breaker-blocked findings, a different case from a
finding a sibling fix already closed. Confirmed absent before editing:
`cargo audit` and `release.yml` in ci-check, `payload` in protocol-change,
`subagent` in task-observer. `npm run check:hygiene` passes (prettier clean on
all five files); `npm run check:docs` passes.

Not included: the 21 open observations targeting `superpowers:*` plugin skills,
which live in a versioned plugin cache and…

* chore(deps): bump the npm-dependencies group across 1 directory with 3 updates (#1428)

Bumps the npm-dependencies group with 3 updates in the /Client directory: [eslint](https://github.com/eslint/eslint), [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `eslint` from 10.9.0 to 10.9.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.9.0...v10.9.1)

Updates `oxlint` from 1.79.0 to 1.80.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxlint_v1.80.0/npm/oxlint)

Updates `typescript-eslint` from 8.67.0 to 8.68.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.68.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.9.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: oxlint
  dependency-version: 1.80.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.68.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump anthropics/claude-code-action (#1429)

Bumps the actions-dependencies group with 1 update in the / directory: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.200 to 1.0.206
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/24dcd50c0568f0fc9e9211213a4fd2d9eb15c4e0...1f291e1cfe0f5fc21db2aef19af844591600ade7)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.206
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump golang from 1.26-bookworm to 1.27-bookworm in /Server in the docker-dependencies group across 1 directory (#1427)

* ci(deps): bump anthropics/claude-code-action (#1404)

Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.193 to 1.0.199
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/9d7150bc8a3dae8149739a88019d192b579ad90c...dcb57747bfceeaa1fa72638cae52295d1d853d4a)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.199
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): pin rfd to tauri-plugin-dialog's major to unblock the cargo group (#1406)

The cargo-dependencies group PR (#1405) fails Rust Unit Tests on Linux:

    error: failed to run custom build command for `rfd v0.17.2`
    You need to choose at least one backend: `gtk3` or `xdg-portal`
    features for x86_64-linux

rfd is not really ours. It arrives in the tree via tauri-plugin-dialog,
which pins ^0.16; we declare it directly only for the fatal-startup
message box in lib.rs, where the Tauri app never finished building and
the plugin has no AppHandle to run a dialog through.

Cargo unifies features only within a semver-compatible version group, so
while both wanted ^0.16 there was a single rfd in the graph and the
plugin's backend features covered our `default-features = false`
declaration too. Bumping our direct dep to 0.17 forks rfd into two
crates: the plugin keeps 0.16.0 with its features, ours resolves to
0.17.2 with none, and rfd 0.17 added a build.rs assertion that aborts
the Linux build when no backend feature is set. Confirmed in the PR's
lockfile, which carries both 0.16.0 and 0.17.2.

Adding a Linux backend feature would be the wrong fix: it would paper
over the fork and still build rfd twice on every platform for one error
dialog. Our version has to track the plugin's instead, so ignore
semver-minor rfd updates (0.16 -> 0.17 for a 0.x crate) until
tauri-plugin-dialog moves. Patch updates inside 0.16.x still flow.

The remaining five crates in the group are unaffected; `windows` in fact
consolidates 3 versions down to 2.

Cargo.toml is comment-only here - no dependency, feature, or lockfile
change - so the build is untouched.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): bump log (#1407)

Bumps the cargo-dependencies group with 1 update in the /Client/tauri-client/src-tauri directory: [log](https://github.com/rust-lang/log).


Updates `log` from 0.4.33 to 0.4.34
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.33...0.4.34)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump anthropics/claude-code-action (#1408)

Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.199 to 1.0.200
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/dcb57747bfceeaa1fa72638cae52295d1d853d4a...24dcd50c0568f0fc9e9211213a4fd2d9eb15c4e0)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.200
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* release: v1.2.0-alpha.4 — 62 fixes plus the B0/B1 repository foundation (#1426)

* Fix 27 findings from 2026-08-21 bug hunt (#1400)

* chore(findings): record 2026-08-21 bug hunt (38 findings)

* fix(api): 1 defect(s) (OC-0240)

* fix(client): 1 defect(s) (OC-0241)

* fix(plugin): 2 defect(s) (OC-0243, OC-0265)

* fix(client): 3 defect(s) (OC-0244, OC-0256, OC-0259)

* fix(client): 1 defect(s) (OC-0247)

* fix(client): 2 defect(s) (OC-0248, OC-0258)

* fix(identity): 1 defect(s) (OC-0250)

* fix(ws): 3 defect(s) (OC-0252, OC-0269, OC-0272)

* fix(admin): 1 defect(s) (OC-0253)

* fix(client): 1 defect(s) (OC-0254)

* fix(voice): 1 defect(s) (OC-0255)

* fix(ws): 1 defect(s) (OC-0260)

* fix(client): 1 defect(s) (OC-0261)

* fix(client): 1 defect(s) (OC-0262)

* fix(client): 1 defect(s) (OC-0263)

* fix(client): 1 defect(s) (OC-0264)

* fix(client): 1 defect(s) (OC-0268)

* fix(ws): 1 defect(s) (OC-0273)

* fix(service): 1 defect(s) (OC-0275)

* style: satisfy golangci-lint and prettier on 2026-08-21 fix commits

- drop ineffectual backupDir reset before return (registry.go, OC-0265)
- reflow long boolean expression (attachments.ts, OC-0241)

* fix(client): 4 defect(s) (OC-0242, OC-0246, OC-0249, OC-0251)

* fix(voice): 1 defect(s) (OC-0267)

* fix(admin): 1 defect(s) (OC-0274)

* fix(voice): 1 defect(s) (OC-0245)

* fix(ws): 1 defect(s) (OC-0271)

* fix(voice): 2 defect(s) (OC-0239, OC-0257)

* fix(ws): 1 defect(s) (OC-0266)

* fix(voice): 1 defect(s) (OC-0270)

* style: clear golangci-lint modernize and prettier nits from 2026-08-21 fixes

- range-over-int and slices.Contains modernizations in new Go test files
- prettier reflow in dispatcher.ts

* chore(findings): mark 2026-08-21 hunt findings fixed/declined

37 fixed across the fix waves, OC-0238 declined (LiveKit webhook TLS
requires a product decision, not a mechanical patch).

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix: 35 findings from the 2026-08-22 bug hunt (#1402)

* fix(voice): 1 defect(s) (OC-0277)

* fix(voice): 1 defect(s) (OC-0278)

* fix(client): 1 defect(s) (OC-0280)

refreshDmSidebar() rebuilds the entire DM sidebar subtree on every
dmStore.channels change - which includes presence flips and new
messages, not just DM list changes. The "Find a conversation" filter
text and input focus live only in that destroyed subtree, so they were
silently wiped mid-typing. Capture and restore both across the
destroy+recreate cycle.

* fix(ws): 1 defect(s) (OC-0285)

* fix(client): 1 defect(s) (OC-0286)

* fix(client): 1 defect(s) (OC-0288)

Consume the legacy unscoped mute key after migrating it onto the first
host, so a brand-new host with no scoped key of its own no longer reads
through to the same legacy list and inherits another server's mutes.

* fix(voice): 1 defect(s) (OC-0290)

* fix(db): 1 defect(s) (OC-0293)

DecrementMentionCounts reversed mention_count bumps that were never
applied: message_mentions stores every resolved mention id including the
author's blockers, while applyMentionCounts excludes blockers before
incrementing. Deleting a blocked author's message therefore wiped an
unrelated, genuine mention badge on the same read_states row. Mirror the
block exclusion in the decrement UPDATE.

* fix(db): 1 defect(s) (OC-0294)

DeleteAccount soft-deletes the departing user's messages but never reversed the read_states.mention_count bumps those messages made, leaving phantom mention badges. Reverse them inline in the existing transaction, mirroring DecrementMentionCounts' guards.

* fix(client): 1 defect(s) (OC-0295)

MemberList rebuilt every row on any non-presence-only membersStore change
and on every roles_update, but registered each row's click/contextmenu
listeners on the component-lifetime disposable.signal, which only aborts
at destroy(). Discarded rows therefore stayed reachable (and their
listeners live) for the component's whole lifetime. Route per-row
listeners through a per-render AbortController that is aborted and
replaced at the top of every render, and aborted again in destroy().

* fix(identity): 1 defect(s) (OC-0297)

UpdateProfile's post-commit re-read of the user row could fail for reasons
unrelated to context cancellation (SQLITE_BUSY, I/O error, pool exhaustion)
and was reported as ErrInternal even though UpdateUserProfile had already
committed. Callers that treat any UpdateProfile error as proof the write
never landed — handleUploadAvatar deletes the file it just stored — would
delete a file the committed avatar column now points at, permanently
breaking the avatar with no user_update broadcast.

Since UpdateUserProfile only writes username/avatar/display_name/about,
merge those four onto the pre-write snapshot to reconstruct the committed
row without needing the re-read to succeed, and log the read failure.

* fix(ws): 2 defect(s) (OC-0298, OC-0299)

- OC-0298: applyConnectStatus stamped c.user.Status even when the
  UpdateUserStatus write failed, so auth_ok and the presence broadcast
  claimed a status users.status disagreed with, and buildReady's
  ListMembers read never self-corrected for the session.
- OC-0299: refreshUserSnapshot silently fell back to roleName "member"
  when the new role lookup failed, pinning the session to a fabricated
  role on the wire. It now fails closed like the sibling lookups in
  upgradeAndAuth and handleFreshConnect.

* fix(client): 1 defect(s) (OC-0300)

* fix(client): 1 defect(s) (OC-0301)

* fix(ws): 1 defect(s) (OC-0302)

* fix(api): 1 defect(s) (OC-0305)

handleDiagnosticsConnectivity used clientIP(r), ignoring cfg.Server.TrustedProxies, so behind a configured trusted reverse proxy the endpoint reported the proxy hop instead of the real client address. Use clientIPWithProxies with the parsed trusted-proxy nets, matching RateLimitMiddleware on the same route.

* fix(client): 2 defect(s) (OC-0306, OC-0308)

* fix(client): 1 defect(s) (OC-0307)

QuickSwitcher registered a per-row click listener against the
overlay-lifetime AbortSignal, but renderResults() rebuilds every row on
each keystroke, arrow key, and store refresh. Discarded rows kept their
listeners alive until the overlay closed. Replaced with one delegated
click listener on the stable results container, keyed off the
data-channelid each row already carries.

* fix(client): 1 defect(s) (OC-0310)

* fix(server): 3 defect(s) (OC-0279, OC-0291, OC-0292)

Reap a soft-deleted message's attachment files, count lapsed temporary
bans as active users in the require_2fa enrollment gate, and only apply
the 2FA-enrollment precondition when require_2fa itself is being enabled.

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

* test(api): sync apiTestSchema with the user_blocks migration

DeleteAccount's mention-count reversal joins user_blocks; the api
package's hand-rolled schema fixture predates migration 012.

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

* fix(client): 3 defect(s) (OC-0281, OC-0282, OC-0296)

Decouple the E2EE identity-mismatch modal and right-click popovers from
the sidebar's per-render abort signal, and let global drag listeners
survive a mid-drag re-render.

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

* fix(voice): 2 defect(s) (OC-0283, OC-0287)

Retire a departed peer's E2EE key unconditionally on leave, and surface
a failed microphone unmute instead of reporting an unmuted state the
room never saw.

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

* fix(client): 3 defect(s) (OC-0289, OC-0303, OC-0309)

Guard the DM call button against redialing the channel already joined,
resolve the incoming-call banner's caller through the nickname-aware
display name, and keep the DM profile sidebar subscribed to live
member/status updates.

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

* style(client): prettier-format the dm-store test

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

* fix(server): 1 defect(s) (OC-0284)

Make message soft-delete a compare-and-set so a repeated chat_delete
cannot reverse mention counts twice.

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

* fix(server): 2 defect(s) (OC-0276, OC-0304)

Re-sync a resumed connection's voice E2EE peer keys in registerNow
(announce frames are unsequenced and cannot be replayed), and apply the
live-connection presence rule to every DM payload DMService builds.

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

* chore(ledger): record the 2026-08-21 hunt findings as fixed

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

* chore(ledger): independent revert-proof pass for OC-0276..OC-0310

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

* refactor(service): extract DeleteMessage authorization into a helper

Keeps DeleteMessage under the cyclop complexity ceiling after the
OC-0284 guard.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore(ledger): record the 38 open findings from the 2026-08-22 hunt (#1403)

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

Co-authored-by: Claude <noreply@anthropic.com>

* chore(graphify): refresh knowledge graph

* fix: close the three B0 P0 gates and record a measured baseline (#1409)

* chore(security): stop tracking the private security-finding reports

docs/security-findings/ holds detailed reports for defects that are not yet
fixed. The directory was untracked but not ignored, so any 'git add .' would
have published seven unfixed vulnerability traces to a public repository.

Findings are coordinated through private GitHub Security Advisories
(docs/security.md); only opaque identifiers and safe status belong in tracked
plans.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): repair the two red P0 unit contracts (G-01, G-02)

G-02: noise-suppression-restart stubbed MediaStream with
vi.fn().mockImplementation(arrow), which is not constructible. Vitest 4 threw
'is not a constructor' at the new MediaStream([inputTrack]) call in
noise-suppression.ts before reaching any assertion. Replaced with a real
class; the OC-0277 assertions are unchanged.

G-01: message-list's OC-0217 guard was inverted, not merely stale. It spied on
AbortSignal.prototype.addEventListener and asserted zero abort registrations,
but the leak it names registered row listeners via
element.addEventListener(..., { signal }) — a path that never calls that
prototype method. Measured: the leak produces 0 registrations (test passes),
while the OC-0286 fix rotates a per-window AbortSignal.any and produces 5
across 5 distinct signals (test fails). The guard passed on the bug and failed
on the fix.

It now captures the signal each window's row listeners register against and
asserts the invariant its name always claimed: one signal per rendered window,
a fresh signal per jump, and every superseded window already aborted with
exactly one live. Verified both directions — green on the fix, and
'expected 1 to be 5' with beginRowRender() reverted to rowSignal = ac.signal.

Client suite: 5257 passed, 0 failed (was 5255 passed, 2 failed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): make the Playwright suite terminate

The runner finished every test and then never exited, printing no summary — so
the failure read as 'tests never finish' when it was 'process never exits'.
getActiveResourcesInfo() at hang time showed a live ProcessWrap plus several
PipeWrap: the Vite dev server was still running. Playwright's webServer
teardown does not kill it here.

Measured, full suite each time:

  npm run dev                        hangs, tests pass
  node node_modules/vite/bin/vite.js hangs, tests pass
  reuseExistingServer: false         hangs, tests pass
  gracefulShutdown SIGTERM/3s        hangs, tests pass
  npx vite                           exits, 290 of 293 FAIL
  no webServer (pre-started)         exits, 293 pass in 33s

npx only appears to fix it: npx exits once Vite is up, Playwright reads that as
the server dying and tears the group down mid-run, so later tests get
ERR_CONNECTION_REFUSED.

globalTeardown now kills the process listening on the dev port, releasing the
runner's handle. The webServer command spawns Vite's entry point directly so
the listening process is Playwright's own child — via 'npm run dev' the npm
process would still hold the handle open. It also reaps servers orphaned by an
interrupted run, which reuseExistingServer would otherwise silently adopt.

An earlier revision used netstat, which is not on PATH in every shell here; the
swallowed ENOENT made the fix look applied while the hang persisted. It now
uses PowerShell on Windows and lsof elsewhere, and warns on failure rather than
failing silently.

npm run test:e2e: exit 0, 293 passed, 37s, reproducible, no orphan listener.
playwright.config.prod.ts carried the same npm-wrapper shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(client): align .nvmrc with the Node version CI uses

Three versions were in play, not two: .nvmrc said 20, CI pins 24, and the
machine the audit was measured on runs 26. A baseline measured against .nvmrc
is not the baseline CI produces, which defeats the point of B0.

Scoped to .nvmrc only. The full single-source-of-truth work — package engines,
contributor docs, release — stays in B1 (RL-17 / C-01).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): add the beta audit set and the B0 baseline

The 2026-08-23 audit set has been sitting untracked: repository-health and
repository-layout audits, beta product requirements, requirement traceability,
the issue register, and the B0-B10 roadmap. They are the plan of record for
beta and belong in the repository.

Adds b0-baseline-2026-08-25.md, which supersedes the roadmap's 'current
evidence snapshot'. Every row is marked measured or carried, so nothing is
inherited silently. It also records three audit claims that did not survive
verification:

  - G-01 was an inverted guard, not a stale assertion — it passed on the bug
    and failed on the fix.
  - The Playwright hang matched none of the three hypotheses; the runner could
    not kill its own dev server.
  - The golangci-lint toolchain failure is refuted: 19 linters run, 0 issues,
    verified with -v to rule out the known zero-linters false-green.

Adds b0-dev-branch-protection.sh, which records the applied dev branch
protection and the reasoning behind each setting.

Security detail stays private: the register carries only opaque SEC-* families
and safe closure criteria, per the roadmap's public/private handling policy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(graphify): refresh the knowledge graph

Own commit, per CLAUDE.md — the graph payload does not belong in the diff of
the changes that triggered it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): add the active-plan index and fix a stale status header (G-04)

Planning documents had no recorded state, so a reader could not tell current
guidance from shipped history. docs/plans/README.md now indexes every plan as
active, partially implemented, design-only, or shipped, and names the source of
truth for each concern so a defect count is never read out of a plan.

Status is recorded in the index rather than by moving or rewriting the
historical plans, so links from audits and commit messages keep resolving.

One real stale claim found and fixed: audit-2026-08-19-remediation.md still
read 'in progress 2026-08-19' while its own phase table showed phases 1-6 done
2026-08-20 (merged 03fcb7d5, PR #1396) with only phase 7 pending. The header
had drifted because the table was updated in place and the header was not.
No plan was found claiming '0 open findings'.

Also records the Step 8 staleness pass in the B0 baseline: all 38 open OC
records still resolve to a live file:line at this commit, so none is superseded
by later work. Adjudicating them individually is bughunt-fix work, not B0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Update graph output files and manifest with new metadata

- Updated graph.html and graph.json with new binary data.
- Modified manifest.json to reflect changes in file modification times and AST hashes for several documents.
- Added new entry for README.md in the manifest with its corresponding metadata.

* docs(plans): close the Docker and coverage leftovers in the B0 baseline

Docker smoke: measured and passing. Image builds at 50.1 MB and boots on :8443
with TLS; docker-smoke.sh exits 0.

Server coverage: re-measured at 74.6% aggregate, confirming the figure carried
from the audit rather than continuing to inherit it.

Two findings from doing it:

ENV-03 — docker-smoke.sh cannot be run from Git Bash on Windows. MSYS path
conversion rewrites the container-internal /chatserver into
'C:/Program Files/Git/chatserver', so docker exec fails 127 and the script
reports 'container never reported healthy within 30s' — indistinguishable from
a real boot regression. MSYS_NO_PATHCONV=1 makes the same script pass. CI is
Linux and unaffected, but Windows is an official contributor platform (RL-20).

The CI Docker job is gated on main, so it is skipped for any PR targeting dev
— a dev-targeted change cannot get Docker evidence from CI at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(graphify): refresh the knowledge graph

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): B1 execution plan, and accept HP-0 (#1410)

* docs(plans): add the B1 repository-foundation execution plan

B1 is the isolated layout/contributor phase. This records the execution
order, the proof for each step, and what is out of scope.

Two findings worth surfacing before any B1 work starts:

- HP-0 was never formally accepted. The roadmap's B1 entry gate requires
  it; no scorecard artifact exists, no commit or document records an
  acceptance, and the B0 baseline still lists "Step 10: HP-0 sign-off"
  under "Not yet done in B0". The plan lists the five gaps that closing
  it requires, including pinning required status checks on dev -- which
  are still unset, so a dev PR can currently merge red.

- Several layout-audit claims do not survive verification against HEAD,
  matching the B0 pattern. RL-09's "no single command verifies both
  protocol consumers" is false (make protocol-verify does, and is
  enforced in CI, the pre-commit hook, and a contract test). RL-10's
  test-discovery side effect never fires (no _test.go in Server/scripts).
  RL-06's regeneration concern is refuted locally. RL-08 grows a
  toolchain constraint instead. RL-05, RL-07, RL-20 and RL-21 are each
  worse than written -- RL-20 includes a live bug where a missing `make`
  is reported as stale protocol constants.

The riskiest item, RL-01 (flatten Client/tauri-client into Client), gets
a full reference inventory and a mechanical proof for both commits: tree-
object equality for the pure move, and scripted-substitution replay for
the path rewrite. Release asset names and updater contracts are verified
independent of the directory name, so the move cannot rename an artifact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): correct the B1 status-check pin list from a live dev PR

The list was derived from ci.yml. Observing PR #1410's actual checks
found three that exist in no workflow file -- Analyze (go),
Analyze (javascript-typescript), Analyze (actions) -- because CodeQL
runs from GitHub default setup, configured in repository settings.
Reading .github/ alone misses them.

Also confirms the two negative predictions against a real dev-targeted
PR: Server Docker Build (verify) reports as "skipping", and Tauri Full
Build never appears in the check list at all. Neither may be pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): accept HP-0 and pin the dev required status checks

Closes B1's entry gate. All five B1-0 items are done.

The scorecard is the artifact the hold point asks for: one place that
answers its four questions, records what was accepted as a stated
limitation rather than claimed green, and part-closes R-08.

Required status checks are now pinned on dev -- ten of them. That was
B0's one outstanding step. Two things came out of doing it:

- The names cannot be inferred from ci.yml. Three of the ten (the
  Analyze jobs) exist in no workflow file, because CodeQL runs from
  GitHub default setup configured in repository settings. They were read
  off a live dev-targeted PR with `gh pr checks`.
- Server Docker Build, Tauri Full Build and the CodeQL aggregate are
  deliberately excluded. The first two report "skipping" on a dev PR --
  Tauri Full Build under its unexpanded matrix name, since the job is
  skipped before matrix expansion. Admin Panel E2E is excluded because
  continue-on-error makes it report success unconditionally.

Two prior claims are corrected rather than left to propagate:

- b0-dev-branch-protection.sh was written assuming repository-settings
  writes are blocked from the agent sandbox. They are not; the PUT
  succeeded. The script stays as the record of intent and the way to
  re-apply or undo.
- An earlier revision of the B1 plan said Tauri Full Build does not
  appear in a dev PR's check list at all. It does, as skipping.

Evidence closed out:

- Rust is no longer a carried row. Re-measured: 115 passed, cargo clippy
  --all-targets -- -D warnings at exit 0, confirming the carried figure.
- The 38 open ledger records are accepted as counted, non-stale and
  assigned: 11 medium / 27 low, zero high or critical, zero dead paths
  across all 348 re-verified at this commit, and none assigned to B1.
- The private security review is reconciled: 7 findings, 7 of 7 mapped
  to existing public rows, 0 unmapped. Summary is content-free; the
  detail stays in the untracked private reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: flatten Client/tauri-client into Client (B1-1) (#1411)

* refactor: move Client/tauri-client to Client (pure move, no content change)

* refactor: re-point paths after the Client flatten (mechanical, no behaviour change)

---------

Co-authored-by: Claude <noreply@anthropic.com>

* B1-2: truth, entry points, and contributor path (#1412)

* fix(hooks): guard on the command the hook actually runs

pre-commit probed one binary and invoked another. The protocol block
guarded on `command -v go` and then ran `make protocol-verify`; the sqlc
block guarded on `command -v sqlc` and ran `make sqlc-verify`. `make` is
not on PATH on a stock Windows box, so a contributor with Go installed
but no make had their commit rejected with

    pre-commit: FAIL: protocol constants are stale — run 'make
    protocol-generate' in Server/ and stage the result

when nothing had been generated and nothing compared. The real cause was
`make: not found`, and the advice the message gives fails the same way.

Rather than add a `command -v make` guard, inline what the two Makefile
targets reduce to — `sqlc generate` / `go run ./scripts/genprotocol`
followed by `git diff --exit-code`. Same semantics, one less prerequisite,
and it doubles as the make-free equivalent B1-2 asks for. The Makefile
targets stay for anyone who prefers them.

Also: the protocol block was the only one with no `else`, so a
contributor without Go got no check and no notice. It now warns like its
two siblings. And gofmt is a separate binary from go, so it is probed
separately.

Verified both directions with the hook body replayed verbatim:
- Go present, make absent -> passes, no staleness claimed.
- schema edited without regenerating -> fails, as it must.

Refs RL-20 / L-14.

* docs: state one branch and PR model

Active documents contradicted each other head-on. README.md and
docs/contributing.md said branch from `dev` and target `dev`; CLAUDE.md
said branch from `main`, PR to `main`. That is R-02, and the 2026-08-19
audit had already recorded it as D-06 without it being resolved.

`dev` is the answer, and the repository already behaves that way: B0 made
`dev` PR-only with ten required checks enforced on admins, and #1409,
#1410 and #1411 all landed there. `main` carries releases.

docs/contributing.md becomes the single source of truth. It now states the
model, what protection is actually applied, and the two consequences a
contributor meets on their first PR — that a self-mergeable PR still cannot
merge red, and that Docker and Tauri Full Build report as skipped against
`dev` rather than failing. Everywhere else summarises and links here.

- CLAUDE.md: corrected, with a link rather than a second copy.
- CONTRIBUTING.md: new. GitHub's contributing-guidelines affordance only
  resolves the root, .github/ or docs/ — `docs/contributing.md` is not a
  path it finds, so the link never appeared on issues or PRs.
- PULL_REQUEST_TEMPLATE.md: names the base branch, which it did not.
- bughunt-run skill: reviewed the branch against `origin/main`, which is
  the wrong base once every PR targets `dev`.

README.md already said `dev` and is left as the short summary it should be.
Dated audits and the historical remediation plan keep their `main`-era
wording — they are records.

Refs R-02.

* fix(hooks): pick the pre-push base from the nearest integration branch

pre-push decided which side's gates to run from
`git diff --name-only origin/main...HEAD`. That was right when everything
targeted `main`. Once `dev` became the integration branch it stopped being
right: a branch cut from `dev` diffed against `main` counts everything on
`dev` and not yet on `main` as "changed".

Measured on this branch: the old base reported 609 changed files, the new
one reports 6. So in practice the hook was running the full server build
matrix and the client typecheck plus eslint on every push, whatever the
change touched — the file-based narrowing it exists for never engaged.

Now it picks whichever of origin/dev, origin/main is nearest, by commits
between merge-base and HEAD, skipping a candidate that scores 0. Verified:
a branch cut from dev picks origin/dev (2 ahead); dev itself scores 0
against dev and picks origin/main (8 ahead), which is what a dev -> main
release PR wants. With no candidate resolvable it falls back to the
existing `__all__`, so an unfetched or shallow clone still runs everything.

Refs RL-20 / L-14, R-02.

* chore(node): one Node source of truth

`.nvmrc` and all ten `actions/setup-node` pins said 24; five active
documents and the repo's only `engines` block still said 20. A contributor
following the docs installed a version CI does not run.

Node 24 wins — it is what CI already runs. Every manifest now declares
`engines`, and `engine-strict=true` turns a wrong major into a failed
install rather than an `EBADENGINE` warning nobody reads. `>=24` rather
than `^24` so a Node 26 box keeps working; `Client/.nvmrc` stays the
human-facing pin and the docs point at it instead of restating a number.

The `.npmrc` is per package root, not one at the top. npm reads the
project `.npmrc` from the package directory and does not walk parents —
verified with a throwaway package requiring node >=99: with only a parent
`.npmrc` npm warned and exited 0; with one in the package directory it
failed `notsup`. A single root file would have left `Client/`, the package
that matters most, on warnings.

Five docs, not the four previously identified — `docs/mcp-introspect.md`
also said 20. And `docs/contributing.md` claimed "`.nvmrc` + CI both say
Node 20", which was wrong about both.

Verified both directions in all three package roots: Node 22 fails
`notsup`; Node 24 installs clean and `npm ci` passes in Client/.

Refs RL-17 / C-01, ENV-01.

* docs: add the documentation landing page

`docs/` had 24 top-level files and no index. The root README carried a
flat list of 22 links that had drifted: six documents were reachable from
nowhere at all — including both 2026-08-23 audits and the test audit — and
two entries were labelled "latest" while newer unlinked audits existed.

docs/README.md is the index RL-12 asked for. It groups by what a document
*is*, because that is what decides whether to trust it: guidance tells you
how to do something, reference describes a contract the code implements,
audits are dated snapshots nobody updates, plans record intent. Every
tracked file under docs/ now appears exactly once, and the audit table says
plainly that audit-2026-08-19.md still claims "0 open findings" when the
ledger has 38.

The root README keeps a short curated list and defers to the index, rather
than maintaining a second copy that drifts again. Two fixes while there:
`docs/plans/` was linked as a bare directory, unlike its two sibling
directory entries, and was annotated "each carries a verified status
header" — which docs/plans/README.md:7-9 explicitly contradicts, since a
plan's header is exactly the thing that drifts and the index is the
authority.

Verified: 78 relative links across the new and edited files resolve, and
no tracked docs/ file is unreachable from the index.

Refs RL-12 / R-06.

* feat(scripts): root command facade

Entry points existed only inside Server/ (a Makefile) and Client/ (npm
scripts). Nothing at the root told a new contributor where to start, and
the root package.json had three scripts, none of which built or tested
anything.

`npm run check` from the root now runs what CI gates on, and
check:server / check:client / check:rust run one stack. scripts/run.mjs
is dependency-free Node — the shape render-ledger.mjs already uses — so
`npm run check` works before `npm install` has.

Cross-platform by construction: every step is spawned with an explicit cwd
and no shell, so there is nothing to quote and no `cd &&` to behave
differently on Windows. npm and npx get their .cmd suffix there. No step
shells out to make.

The facade orchestrates; it is not a new required path. Each step prints
the command and the directory before running it, and those are exactly the
commands documented per-stack — so a server contributor can read the output
and type them instead, and still never needs Node. Tools CI installs but a
contributor may not have (golangci-lint, which has no wrapper in this repo
at all; sqlc, pinned by Server/sqlc.version) are skipped with a printed
reason rather than failing.

Three corrections to the ci-check skill while aligning it:

- `make sqlc-verify protocol-verify` replaced by what those targets reduce
  to, so the documented path does not require make either.
- `cargo test` -> `cargo test --lib`, which is what ci.yml actually runs.
- "NODE_OPTIONS=--no-experimental-webstorage is mandatory on Node 22+" was
  false. tests/setup.ts installs the shim, CI runs Node 24 without the
  flag, and the suite was measured passing without it — 192 files / 5257
  tests, identical to the flagged run.

Also documents the third RL-20 problem, which needed no code: core.hooksPath
is exclusive, so `npm run hooks:install` silently disables any
.git/hooks/post-commit — including the one `graphify hook install` writes,
which CLAUDE.md tells agents to install. Nothing warned about that.

Verified: check:client 5257/192 green, check:rust 123 tests + clippy green,
--list prints every command, and the optional-tool skip path reports rather
than fails.

Refs RL-04 / L-04, RL-20 / L-14.

* feat(ci): fail on a document that contradicts the findings ledger

G-04's remaining half. The ledger is the source of truth for defect counts,
but nothing stopped a planning document from stating a different number and
nothing noticed when one did. `render-ledger.mjs --check` cannot help: it
validates the JSON schema and returns before rendering, so it never reads
FINDINGS.md and cannot see drift at all — and no workflow ran it anyway.

scripts/check-doc-counts.mjs counts ledger statuses and compares them to
what an allow-list of active documents claims, failing with file, line,
claimed value and actual. Wired into ci.yml as a job with no npm ci, since
the script imports nothing outside node:, and into the facade as
`npm run check:docs` — first in `check`, so a contradicted count does not
wait behind ten minutes of -race.

The patterns are narrow on purpose. A first attempt matched any
"<number> <status>" and flagged nineteen things, all false: "the 45 open P1
rows" (issue-register rows, not ledger findings), "All 8 findings F1-F8"
(a different register), "G-05 **refuted**" (an identifier), `">=20"` and
`CGO_ENABLED=0` (not counts at all). A check that cries wolf gets ignored,
which is the failure G-04 already describes. So a number is only read as a
claim in three shapes that cannot mean anything else: an enumeration of two
or more "<n> <status>" pairs, a status table row in a table that totals
itself, and "<n> records/findings" where the ledger is named within three
lines. Fifteen selftest assertions pin both directions, and the job runs
them before it runs the check.

It reads findings-ledger.json directly rather than importing
render-ledger.mjs for `validate`/`render`: that module ends in a bare
top-level `await main()` with no import.meta.main guard, so importing it
rewrites FINDINGS.md as a side effect.

Dated docs/audit-*.md are reported, never failed — they are snapshots
nobody maintains. audit-2026-08-19.md does claim zero open findings against
38 open, so b0-baseline's "No plan was found claiming '0 open findings'"
holds for docs/plans/ but not for docs/.

Not included: a real FINDINGS.md render-drift check. That is RL-07 and
belongs with the generated-artifact work, not here.

Verified: 27 claims across 9 documents agree; corrupting one count in
docs/plans/README.md fails the check naming that line, for both the status
and the total.

Refs G-04.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore: remove graphify knowledge graph tooling (#1413)

The committed knowledge graph and its PreToolUse hooks were steering every
codebase question through `graphify query` before any other tool could run.
Serena (gopls + rust-analyzer + tsserver over MCP) answers the same questions
from real language servers rather than a generated snapshot that goes stale
between rebuilds, so the graph no longer earns the ~20 MB it costs the tree.

Removed:
- `graphify-out/` untracked (7 files, ~20 MB) and now gitignored
- both `graphify hook-guard` PreToolUse hooks from `.claude/settings.json`
- the "Knowledge graph (graphify)" section of `CLAUDE.md`
- the `graphify-out/**` block from `.gitattributes` and `.gitignore`
- the graph-rebuild step from the `bughunt-run` skill, and the graph-edge
  guidance from the bughunt workflow prompt
- the graphify-specific `core.hooksPath` example in `ci-check` and
  `docs/contributing.md`, keeping the underlying warning in generic form

Also deletes the locally installed `post-commit` / `post-checkout` rebuild
hooks (untracked, not part of this diff).

This does not shrink clone size: the graph blobs stay in published history,
which `docs/plans/b1-repository-foundation-2026-08-25.md` explicitly rules out
rewriting. It does stop future refreshes from adding more.

Dated audit and plan documents keep their graphify references as a historical
record of the state they described.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* B1-3: repository hygiene gates (RL-19 / L-13, S-05) (#1414)

* chore(format): one Prettier config at the repository root

Every formatting rule in this repository lived under Client/ and covered
exactly two globs: Client/src/**/*.ts and Client/tests/**/*.ts. Root Markdown,
all of docs/, every YAML and JSON, all CSS, the root scripts and
tools/mcp-introspect were formatted by nothing. There was no .editorconfig.

The obvious fix -- a second Prettier config at the root for "everything else"
-- gives two configs and two ignore files that can silently disagree about the
same file. So the root takes ownership instead: config, ignore file and gate
move up, and Client/ folds in. Client's inline "prettier" block, its
.prettierignore, its format/format:check scripts and its now-unused prettier
devDependency are all deleted; knip would have failed client-check on that last
one.

The .prettierrc.json values are lifted byte-for-byte from Client/package.json,
which is what keeps the reformat commit free of client TypeScript churn: 87
tracked files need reformatting and not one of them is under Client/src or
Client/tests.

.prettierignore carries only what .gitignore does not. Prettier 3 reads the
root .gitignore by default, so node_modules/, dist/, coverage/,
Client/src/generated/ and docs/security-findings/ need no entry. It does NOT
read nested .gitignore files, which is why .remember/ is listed explicitly --
38 untracked per-machine scratch files were otherwise able to turn a shared
gate red. graphify-out/ is listed because its seven files are tracked and
.graphify_labels.json is signed byte-for-byte by its .sig, so formatting it
would silently invalidate the signature.

check:hygiene is registered in scripts/run.mjs and folded into check and
release:preflight. It deliberately contains no `gofmt -l` step: gofmt -l prints
offenders and still exits 0, so it cannot fail a build. Go formatting is
enforced separately.

shellcheck and actionlint take their file lists from `git ls-files`, never a
filesystem glob -- .claude/worktrees/ holds a gitignored pre-flatten copy of
the tree with three .sh files a glob would happily lint.

This commit leaves the tree non-conformant on purpose. The reformat is the next
commit, so the 87-file diff is reviewable separately from the rule that caused
it.

Not included: editorconfig-checker. .editorconfig is the editor baseline the
audit asked for; Prettier, gofmt and rustfmt already fail CI on the same
indentation and newline rules, so a fourth tool checking them again is a gate
with no failure mode of its own.

Verified: `npx prettier --check .` names 87 tracked files and zero untracked
ones; the same command listed 38 .remember/ scratch files before the ignore
entry and none after. `node scripts/run.mjs --list` resolves check:hygiene to 8
shell targets and 4 workflow targets. Both package.json files parse.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): reformat the tree to the repository Prettier rules

Mechanical. This commit is `npx prettier --write .` and nothing else -- the
rule that caused it landed in the previous commit so this diff can be reviewed
as a transformation rather than as 84 files of hunks.

84 tracked files: 54 Markdown, 7 .mjs, 7 JSON, 6 YAML, 4 .js, 3 CSS, 2
TypeScript (the two Playwright configs at Client's root, which the old
Client/src + Client/tests globs never covered). No file under Client/src or
Client/tests moves, because .prettierrc.json carries Client's former inline
values byte-for-byte.

Prettier rewrote 87 files, not 84. The three in .github/ISSUE_TEMPLATE/ had
CRLF on disk and differ only in line endings, which .gitattributes
(`* text=auto eol=lf`) already normalises, so their committed blobs are
unchanged. Worth knowing before someone reconciles the two numbers.

The largest single diff is .superpowers/findings-ledger.json at 7976 lines
rewritten. That is safe to format: nothing writes the ledger programmatically
-- render-ledger.mjs reads it and writes only FINDINGS.md -- so no tool will
fight Prettier over its style on the next hunt. FINDINGS.md itself is ignored
as generated.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style", so the pass is both complete and idempotent. All 7 reformatted JSON
files were parsed before and after and compared as values: semantically
identical, zero content changes. `node .superpowers/render-ledger.mjs --check`
still reports 348 valid findings and leaves FINDINGS.md untouched.
`node scripts/check-doc-counts.mjs` still passes its selftest and still agrees
on 27 claims across 9 watched documents -- table realignment did not break the
patterns it matches on. `node scripts/run.mjs --list` still parses.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(lint): enforce Go formatting in the Server linter

S-05: repository-wide Go formatting was not a required gate. The only gofmt
enforcement anywhere was .githooks/pre-commit, which is opt-in per clone
(`npm run hooks:install`), only sees staged files, and warns-and-skips when
gofmt is off PATH.

The obvious fix -- a `gofmt -l` step in CI -- does not work: `gofmt -l` prints
its offenders and still exits 0, so the step passes no matter what it finds.
scripts/run.mjs has the same problem, which is why check:hygiene has no Go step
either.

So gofmt goes where it can actually fail something: Server/.golangci.yml. The
file was already `version: "2"` but had no `formatters:` block at all, so the
19 enabled linters ran with zero formatters. In v2 gofmt/gofumpt/goimports
moved out of `linters.enable` into their own section with its own exclusions.
Adding it there means the gate reports through the Lint step of "Server Build &
Test", which is already pinned as required on dev -- no new job and no new pin.
Every tracked .go file is under Server/ (551 of them, one go.mod), so
Server-scoped is repository-wide here.

One file was genuinely misformatted: a one-space struct field alignment in
Server/admin/handlers_users_broadcast_test.go, fixed in the same commit because
a single line does not need its own reformat commit.

Trap worth recording: `gofmt -l .` on a Windows working tree lists every file
that has CRLF on disk, because gofmt normalises line endings. That reported 18
offenders here, 17 of them ghosts. The blobs are all LF -- .gitattributes
forces `eol=lf` -- so CI never saw them, and the honest test is to run gofmt
over `git show HEAD:<file>` rather than the working copy. Doing that across all
551 tracked Go files found exactly the one real offender above.

Verified both directions with golangci-lint v2 locally: `golangci-lint run
./...` reports 0 issues on the formatted tree; appending a misformatted
function to Server/auth/constants.go produces 2 gofmt findings; appending the
same misformatted function to Server/db/dbgen/admin.sql.go produces 0, so the
exclusion holds. Both files restored and verified clean afterwards.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): escape the NUL separator instead of embedding one

The `tracked()` helper added earlier in this branch splits `git ls-files -z`
output on NUL. The separator was written as a literal NUL byte rather than the
two-character JavaScript escape, so scripts/run.mjs became a binary file: `git
diff` refused to show it, `grep` reported "Binary file matches" instead of the
line, and `* text=auto` in .gitattributes stops normalising line endings for a
blob it detects as binary.

The code worked -- splitting on a raw NUL and splitting on "\0" are the same
operation -- which is exactly why this is worth fixing before it is inherited.
A source file that tooling classifies as binary is a file nobody can review.

Verified: zero NUL bytes remain, `grep -n "split("` now prints line 50 instead
of "Binary file matches", `node scripts/run.mjs --list` still resolves the same
8 shell and 4 workflow targets, and prettier still reports the file clean.

* chore(lint): enforce Rust formatting

Rust had no formatting gate of any kind: no rustfmt.toml, no `cargo fmt`
anywhere in CI, in scripts/run.mjs, in the Makefile or in the git hooks. Clippy
was the only Rust gate, and clippy does not check layout.

`cargo fmt --all -- --check` now runs in the rust-tests job, ahead of clippy: a
formatting failure is cheap to produce and cheap to fix, and there is no reason
to spend a clippy pass to surface one. The stable toolchain in that job
requested `components: clippy` only, so rustfmt is added there.

Only that job. ci.yml has a second, byte-identical `Install Rust` block in
tauri-build; it stays clippy-only, because a full desktop build is the wrong
place to discover a misplaced brace.

No rustfmt.toml. The default profile is the point of a baseline -- a config
file here would be a second opinion about style with nothing to say.
Client/src-tauri is a single `[package]`, not a workspace, so `--all` is a
safeguard against a future member rather than a fan-out today.

Verified: `node scripts/run.mjs --list` resolves check:rust to three steps with
`cargo fmt --all -- --check` first, `npm run format` now also runs `cargo fmt
--all`, and prettier reports ci.yml, run.mjs and the ci-check skill clean.
`cargo fmt --all -- --check` currently fails on 13 files -- that is the
reformat, and it is the next commit.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): reformat the Rust crate to rustfmt defaults

Mechanical. This commit is `cargo fmt --all` and nothing else; the gate that
demands it landed in the previous commit so this diff is reviewable on its own.

13 of the 17 tracked .rs files, +343/-164. The crate had never been formatted,
so the changes are the usual first-run set: aligned trailing comments collapsed
to single spaces, single-element slice literals folded onto one line, long
method chains broken across lines, closure bodies expanded into blocks.

Verified: `cargo fmt --all -- --check` is clean, so the pass is complete and
idempotent. `cargo clippy --all-targets -- -D warnings` finishes with no
warnings, and `cargo test --lib` reports 115 passed / 0 failed -- identical to
before the reformat, which is what "mechanical" has to mean for a commit that
touches this much of the crate.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): make the root facade actually run on Windows

Adding the first gate that a contributor would run from the repository root
exposed two bugs in the facade, both of which made it silently wrong on the
platform this project is developed on.

1. Every npm and npx step failed. bin() appends `.cmd` on Windows, but Node
   refuses to spawn a .cmd or .bat with shell:false -- the CVE-2024-27980
   mitigation -- and fails with EINVAL and a *null* exit status. run.mjs only
   special-cased ENOENT, so the result was `FAILED: npx prettier --check .
   exited null` with nothing to explain it. check:client has three npm steps and
   has never been able to run here.

   Fixed by spawning only the npm shims through a shell. They are concatenated
   into a single command string rather than passed as an args array, because
   shell:true plus a separate array is deprecated (DEP0190) and prints a warning
   on every invocation; no argument in this file contains a space.

2. Every optional() step was skipped, always. onPath() shelled out to
   `where` on Windows, but where.exe lives in C:\WINDOWS\System32, which a Git
   Bash PATH does not necessarily contain -- on this machine PATH carries
   System32\Wbem, System32\WindowsPowerShell\v1.0 and System32\OpenSSH but not
   System32 itself. The probe could not start, `probe.status === 0` was false,
   and golangci-lint and sqlc reported as "not installed" while installed.

   Fixed by resolving against PATH and PATHEXT directly. No subprocess, and no
   dependency on which directories happen to be on PATH.

A spawn error other than ENOENT now reports its code instead of surfacing as a
null exit status.

Verified: before, `node scripts/run.mjs check:hygiene` died with "exited null"
and both optional steps printed SKIP with the tools present on PATH. After, the
same command runs prettier, shellcheck and actionlint and prints
"check:hygiene: passed", with no deprecation warning. `golangci-lint` is
detected by the new onPath where the old one missed it.

Refs RL-20 / L-14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): ignore build output that nested gitignores hide

Prettier honours the root .gitignore and no other. Every build and scratch
directory in this repository is ignored by a *nested* one -- Client/.gitignore,
.serena/.gitignore, .superpowers/sdd/.gitignore -- so none of them were
excluded from the new repository-wide gate.

The effect is not subtle. Running `cargo test` once drops roughly 850
formattable files into Client/src-tauri/target/, and the hygiene gate goes from
clean to "Code style issues found in 939 files". CI never sees it, because a
fresh checkout has no build output; every contributor sees it on their second
command.

Mirrors the three nested files rather than inventing a list: dist, coverage,
playwright-report, test-results, .vite, src-tauri/target and src-tauri/gen from
Client/.gitignore, plus .serena/ and .superpowers/sdd/. node_modules needs no
entry -- Prettier ignores it by default.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style" with a fully populated Client/src-tauri/target/ present on disk, and
still names README.md when a misformatted table is appended to it.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(ci): shellcheck, actionlint, and a repository hygiene job

The last two gates RL-19 asks for. Neither existed: the shell scripts were
never linted, the workflows were never syntax-checked, and .githooks/pre-commit
carried hand-written `# shellcheck disable=` directives that nothing had ever
read.

New `hygiene` job, ubuntu-only and root-scoped, modelled on docs-consistency
for the same reason: every gate in it is platform-independent text analysis,
and .gitattributes pins eol=lf so a second OS would only re-prove line endings.
It runs `npm run check:hygiene` -- the same entry point a contributor runs, not
a parallel copy of the commands.

shellcheck ships in the runner image. actionlint does not, so it is pinned by
version and verified by sha256: an installer script piped from a branch would
be the one unverified download in a workflow file that pins every action by
commit SHA.

Prettier's step moves here from client-check, where it no longer belongs.

Both linters found real defects.

shellcheck, 3 findings in 8 scripts. Two are SC1125 errors in
.githooks/pre-commit: `# shellcheck disable=SC2086 - repo paths contain no
spaces` is not a valid directive. Trailing prose makes shellcheck discard the
rest of the line, so neither suppression was ever in effect -- and one of the
two was written earlier in this same branch, which is a fair demonstration of
why the gate is worth having. The prose moves to its own line above. The third
is SC2015 in start-server.sh, rewritten as an explicit if.

actionlint, 5 findings, all inside `run:` blocks it shellchecks once shellcheck
is on PATH. Three SC2015 in load-baseline.yml, rewritten as explicit ifs. Two
SC2035 in release.yml, where `sha256sum *` should not become `sha256sum ./*`:
the comment four lines above records that ParseChecksumFile exact-matches the
last field, so a "./" prefix would strand every deployed server exactly as a
"windows/" prefix would. `sha256sum -- *` satisfies the linter and leaves the
output bytes identical.

Verified all three gates in both directions with shellcheck 0.10.0 and
actionlint 1.7.7 on PATH. Passing: `node scripts/run.mjs check:hygiene` prints
"check:hygiene: passed" with all three steps run, not skipped. Failing:
appending `bait_fn() { cat $1; }` to Server/scripts/voice-test.sh fails on
SC2086; changing a runs-on to `ubunt-latest` fails on runner-label; appending a
misformatted table to README.md fails prettier. All three files restored and
confirmed clean afterwards. actionlint validates the new job in ci.yml itself.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): record B1 progress through B1-3

The header still read "B1-0 is complete; B1-1 is the next step" three merged
phases later. A plan that misstates where it is costs a reader the same
confusion whether it is stale by one phase or three.

B1-0 (#1410), B1-1 (#1411), B1-2 (#1412) and B1-3 (this branch) are done; B1-4,
dependency automation, is next.

Verified: `node scripts/check-doc-counts.mjs` still agrees on 27 claims across
9 watched documents -- this file is one of them -- and prettier reports it
clean.

* chore(ci): pin Repository Hygiene as a required check on dev

The second half of S-05. Its acceptance criterion is "tree is formatted AND a
fast required gate fails future drift" -- a check that runs but is not pinned
lets a formatting regression merge, so the gate is not a gate until this lands.

The name was read off PR #1414 with `gh pr checks` after the job reported
`pass` in 26s, not copied out of ci.yml. That order matters: the B0 script
records that three pinned names exist in no workflow file at all, and that a
required check which never reports blocks every PR forever.

Extends the existing script rather than adding a second one, per the B1 plan.

Also records, in the "deliberately NOT pinned" list, that Docs & Ledger
Consistency reports and passes on a dev PR yet is unpinned. That reads as an
oversight from the 2026-08-25 pass rather than a decision, but it belongs to
G-04, so it is documented here and not changed.

NOT APPLIED YET. Running this script now would pin a check that PR #1413 cannot
report -- its branch predates the hygiene job, so the job does not exist in its
workflow file and the check would never arrive. Run it after #1414 merges;
#1413 needs a rebase onto dev regardless.

Verified: shellcheck clean, the embedded JSON parses, and `check:hygiene`
passes with prettier, shellcheck and actionlint all running.

Refs S-05, RL-14 / G-03.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* B1-4: dependency automation (RL-05 / L-05, RL-18) (#1415)

* chore(deps): cover the root and mcp-introspect npm roots

The repository has three npm package roots — `/` (changelogen, prettier),
`/Client`, and `/tools/mcp-introspect` (@modelcontextprotocol/sdk, zod) —
each with its own package-lock.json, and `npm run bootstrap` runs `npm ci`
in all three. Dependabot watched exactly one of them. The root's prettier is
what the Repository Hygiene gate runs, so the formatting gate's own toolchain
was drifting unwatched.

The obvious fix is to collapse the three roots into an npm workspace and
watch one lockfile. Measured on npm 11.17 / Node 26 rather than assumed, that
trade is bad, and it is bad for different reasons than expected. Workspaces do
not break the things you would predict: `npm ci` inside `Client/` still exits
0, `npm run <script>` still resolves the hoisted binaries because npm prepends
every ancestor `node_modules/.bin` to PATH, and `engine-strict` still fails
the install on a wrong Node major. What they cost is ten CI steps keyed on
`cache-dependency-path: Client/package-lock.json` (six in ci.yml, four in the
tag-only, CI-ungated release.yml) pointing at a file that stops existing; the
Repository Hygiene job's deliberate root-only install growing 970 ms to
6172 ms and 39 to 318 packages unless every call site remembers
`--workspaces=false`; and one shared lockfile putting all three npm Dependabot
groups back into the same file, which is precisely the rebase storm the
grouping comment at the top of dependabot.yml exists to prevent. The measured
benefit is one 298 KB lockfile instead of three (17 KB / 253 KB / 42 KB) and
614 resolved packages deduped to 582 — 32 packages, 5.2% — with client install
time unchanged at 5642 ms against 5667 ms.

So the roots stay separate and each gets its own block, matching the four that
already exist: grouped to one PR, majors ignored, weekly on Monday. A single
block with `directories:` was rejected for the same reason as workspaces —
grouping only works while a group rewrites exactly one lockfile. The decision
and its numbers are recorded in docs/contributing.md under Dependency Policy,
so the next person to propose workspaces reads the measurement instead of
repeating it.

Verified: a coverage checker cross-references every `package-ecosystem` /
`directory` pair in dependabot.yml against every manifest in `git ls-files`,
in both directions. Against dev at 2a37f386 it reports `UNWATCHED npm
package.json` and `UNWATCHED npm tools/mcp-introspect/package.json` (plus
Server/Dockerfile, which the next commit covers). Against this commit both npm
rows read `ok`, and the forward direction confirms each newly declared
directory really holds a package.json. `npx prettier --check` reports both
edited files unchanged, so the B1-3 formatting gate stays green.

Not included: the docker ecosystem (next commit, RL-18); immutable image
digests for release/runtime containers, which is R-04's remaining half and
belongs to B6; and turning the coverage checker into a permanent
`check:hygiene` gate — a new manifest root can still drift unwatched, which is
how this gap arose, but that is new gate machinery rather than the coverage
this item asks for.

Refs RL-05 / L-05

* chore(deps): watch the server container base images

Server/Dockerfile pulls `golang:1.26-bookworm` to build and
`gcr.io/distroless/static-debian12` to run, and nothing watched either. Every
other dependency root in the repository is on a weekly Dependabot schedule, so
the one artefact that ships to users as a whole filesystem was the only one
whose upstream moved silently — including its CA certificates, which the
Dockerfile comment specifically calls out as the reason distroless was chosen
over scratch.

The obvious fix is to pin both images by digest and be done. That is the wrong
move here for two reasons. A digest pin with no automation behind it is worse
than a tag: it freezes the base image at whatever was current the day someone
typed it, and a frozen distroless base is a frozen CA bundle. And digest
refresh for release and runtime images is R-04's other half, scoped to B6
alongside the smoke tests that have to gate it — landing half of it here would
leave the digests pinned and the refresh unowned.

So this adds the `docker` ecosystem for /Server on the same terms as the five
blocks around it: grouped to one PR, majors ignored, weekly on Monday. Be
precise about what that actually buys, because it is less than the block
implies. Of the two images only `golang:1.26-bookworm` carries a comparable
version, so it is the only one Dependabot can act on today;
`gcr.io/distroless/static-debian12` has no version tag, and an untagged image
is not something a version update can move — it needs the digest pinning that
B6 owns. The comment above the block records that the Go builder tag tracks
Server/go.mod and every `actions/setup-go` in CI, so a `1.27-bookworm` PR is a
prompt to move all three together rather than a standalone merge.

Verified: the coverage checker cross-references every `package-ecosystem` /
`directory` pair against every manifest in `git ls-files`, in both directions.
Against dev at 2a37f386 the reverse direction reports `UNWATCHED docker
Server/Dockerfile`; against this commit every row reads `ok` and it exits 0 —
`ALL ROOTS WATCHED`, six blocks covering six manifest roots, with the forward
direction confirming /Server really holds a Dockerfile. `npx prettier --check`
passes on the edited file.

Not included: Server/docker-compose.yml and Server/docker-compose.otel.yml.
Their four images are `ghcr.io/j3vb/owncord-server:latest` (this repository's
own published image), `livekit/livekit-server:v1` (a floating major tag, and
majors are ignored everywhere), `jaegertracing/all-in-one:latest` and
`prom/prometheus:latest` — none of which a version update can move, so a
compose block would be configuration that provably produces nothing. Also not
included: immutable digests plus digest-refresh PRs with smoke tests for the
release and runtime images, which is the remainder of R-04 and belongs to B6.

Refs RL-18

* docs: apply skill-review findings to ci-check …

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 17:31:02 +02:00
J3vb fbb85b4d76 fix(b2-8): close the nine B2-tagged ledger findings (#1436)
* docs(b2-8): record the B2-1 pre-squash head and ledger the voice_join ordering hazard

- B2-1 evidence block: PR #1435 pre-squash head 069412db (refs/pull/1435/head), squash 1fe3df79
- Ledger OC-0349 (open, low): the joiner's own voice_state takes the hub queue while the
  rest of the join burst is written directly, so its position on the joiner's socket is
  not ordered (documented in docs/protocol.md during B2-1, not yet fixed)

* fix(client): 2 defect(s) (OC-0311, OC-0315)

OC-0311: scope the voice_leave E2EE participant-left notification to this client's own voice channel — the frame is broadcast to the whole channel read audience, so a peer leaving a channel we merely read could delete their key, clear their verification, and trigger a room-key rotation in our live session.

OC-0315: parse server timestamps with parseTimestamp instead of Date.parse in the reconnect replay gate and the clock-skew sample — the wire form is naive UTC with no 'Z', which Date.parse reads as local time, so an east-of-UTC viewer silently swallowed genuinely live messages.

* fix(voice): 1 defect(s) (OC-0316)

* fix(client): 1 defect(s) (OC-0317)

* fix(plugin): 1 defect(s) (OC-0318)

@
Route every directory-manifest resolution through loadManifestFromDir so
InstallFromZip and scanPluginDirectory apply identical plugin.toml over
plugin.json precedence. A TOML-only zip now installs, and a zip carrying
both manifests is rejected rather than validating one while the loader
later obeys the other.
@

* fix(client): 1 defect(s) (OC-0322)

* fix(ws): 1 defect(s) (OC-0337)

liveVoiceEventsSince's cold-tier fallback handed a cap-truncated window to
resuming clients as if it were complete. The query is oldest-first with a
LIMIT, so a full result means the NEWEST rows were dropped - for a voice
room, quite possibly a peer's voice_leave. Degrade to nil (the documented
best-effort miss) on a cap hit, matching reconnectSelectReplay's guard.

* fix(plugin): 1 defect(s) (OC-0338)

* fix(client): 1 defect(s) (OC-0328)

* docs(b2-8): ledger records, plan evidence and count claims for the nine fixes

- Ledger: OC-0311/0315/0316/0317/0318/0322/0328/0337/0338 -> fixed, each with
  commit, pinning test and revertProof: pass (verify-fixes.mjs 8/8, plus a hand
  RED/GREEN of the wazero-tagged OC-0318 parity test)
- Plan: B2-8 evidence block and status line (B2-0, B2-1, B2-8 landed; B2-2 next)
- Count claims in README, b0-baseline, hp-0-scorecard and the issue register
  follow the ledger (315 fixed / 30 open / 3 declined / 1 duplicate = 349)

* fix(ws): replay a complete cap-sized voice window instead of skipping it (OC-0337 follow-up)

Codex review on #1436: liveVoiceEventsSince decided truncation by
len(persisted) >= coldCap, so a complete window of exactly coldCap rows was
treated as truncated and the supplement returned nil. Fetch coldCap+1 rows
and discard only when the extra row exists. Test-first: the exact-cap case
fails before the change and passes after; the over-cap case still degrades
to nil.
2026-08-28 15:13:49 +00:00
J3vbandClaude Fable 5 1fe3df7962 test(b2-1): capture the epoch-1 protocol fixtures and retire S-15 (#1435)
* refactor(protocol): retire reserved voice_speakers and member_leave (S-15)

Neither type was ever emitted by the server; B2-1 clears them from the
schema before the epoch-1 wire fixtures are captured, so the frozen epoch
does not carry two dead message types.

Client: dropped the dead `ws.on(MEMBER_LEAVE)` / `ws.on(VOICE_SPEAKERS)`
dispatcher handlers, the `MemberLeavePayload` type and both `ServerMessage`
union members, and the tests that only exercised those WS paths.
`removeMember` (member_ban) and `setSpeakers` (LiveKit ActiveSpeakers) stay
live and keep their direct unit tests.

* test(ws): capture the epoch-1 wire fixtures

alpha.4 is the last client on the pre-epoch wire and B2-2 adds a protocol
epoch to the auth handshake next, so record what epoch 1 actually looks
like while it is still observable.

TestEpoch1Fixtures drives eleven journeys through the ws package's
in-process hub harness (full migrations, real hub, httptest WebSocket
server) and compares each journey's per-connection frame sequence with a
transcript under protocol/fixtures/epoch-1/:

- fresh-connect, auth-failure, ping
- chat-send-fanout, chat-edit-delete, reaction-add-remove
- typing, mark-read, dm-send
- resume-replay (last_seq + buffer-tier replay burst)
- voice-join-e2ee-leave (join, both voice_state forms, announce and
  offer relay, leave)

Volatile values are replaced by typed placeholders before both writing
and comparison -- any key that is id/seq/last_seq, ends in _id (except
channel_id and role_id) or _at, is timestamp/ts/last_seen, or contains
token, becomes "<class:json-type>" so a field that changes type is still
a diff, while everything else is compared verbatim.

Regenerate with: go test ./ws -run TestEpoch1Fixtures -update

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

* test(client): pin the epoch-1 auth frame contract

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

* test(updater): pin the epoch-1 manifest and client-update shapes

* test(ws): harden the epoch-1 fixtures against trailing frames and absent optionals

Every journey now ends with the ping/pong barrier on each recorded
connection, so a frame the server emits after the last read fails as
`expected "pong", got "X"` instead of going unrecorded; auth-failure
asserts the StatusPolicyViolation close instead, its socket being gone.

alice carries a display name, avatar, about text, custom status, identity
public key and an announce signature (bob carries none), so every optional
field is frozen in its present form as well as its absent one — a rename or
a retype of display_name or identity_public_key now moves a fixture.

The typing journey focuses the channel on "a" before typing: without the
subscription registerNow only makes for a focused client, its ping/pong
proved nothing about excludeUserID.

Comment fixes: the escaped placeholder form MarshalIndent would write, the
real (headroom) reason for the raised read limit, a note that bare id and
active_channel_id are normalised by design, and a .prettierignore line
saying these fixtures are verified by the Go comparison, not by git diff.

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

* docs(protocol): document the epoch-1 fixtures

* docs(protocol): match the epoch-1 wire where the fixtures contradicted the doc

- voice_state: note the unsequenced relay-to-joiner form (table row + section)
- auth_error: correct the example message and list the real rejection messages
- member_join/presence connect example: add the seq both frames actually carry
- chat_message.user: document display_name
- voice_join reply order: state that the joiner's own voice_state broadcast is not ordered against the other three frames

* test(ws): freeze the null forms of auth_ok and member_join user fields

buildAuthOK emits display_name/about/custom_status/avatar as
always-present nulls, but only alice — who has all four set — ever
authenticated on a recorded connection, so the fixtures froze those
fields in their populated form alone. A rename, a retype or a dropped
null would have moved nothing, on the very frame B2-2 edits.

fresh-connect now records bob's handshake too, on a second connection:
his auth_ok carries the four nulls, his member_join carries avatar null
with display_name and identity_public_key omitted, and alice — idle by
then, so her reads stay in hub order — records the same pair as an
already-connected observer sees it. In voice-join-e2ee-leave bob answers
alice's signed announce with a legacy unsigned one, which freezes the
absent form of signature next to her present one. The auth frames the
test writes now carry the correlation id the real client stamps on every
frame (ws.ts send()); normalisation renders it <id:string>.

expectClosed also asserts the close reason ("authentication failed"),
not just code 1008 — HP-2 asks for both.

Comment precision, no behaviour change: the barrier guarantee now states
that pong may overtake a pending LOW-priority frame (writePump) and that
no journey is affected because every barrier is sent on an idle
connection; the ping-budget ceiling is six connections, not four; the
typing journey cites handleChannelFocusV2 rather than registerNow as the
subscribe site; resume-replay's b barrier explains why moving it past
the resume would be a flake, not a fix; and the header notes that
normalisation hides that chat_send_ok.id echoes the request id.

The client contract test's cited range for ws.ts's send() call is
441-453, not 441-454.

Regenerate with: go test ./ws -run TestEpoch1Fixtures -update

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

* docs(protocol): shape-not-value fixture rule, voice_max_video default, auth close code

The fixture rule read "a fixture may only change with an epoch bump",
which is false for the seeded values the transcripts record verbatim:
role permission masks and colours, motd, server_name, voice_max_video,
the voice_config preset. A migration that changes a default mask diffs
fresh-connect.json, and the README told the author to revert a change
that never touched the wire.

Split the rule along shape versus value. A key set, a JSON type, a key
appearing or disappearing, or per-connection frame order is a protocol
change and earns fixtures/epoch-<n+1>/. A seeded default value is a seed
change: regenerate in the same PR and read the diff frame by frame.
Normalising those values is explicitly not the answer — a placeholder
over a mask or over an enum such as voice_config.threshold_mode would
hide the drift the fixtures exist to catch.

Two wire facts corrected against the fixtures:

- voice_max_video on an unconfigured channel is 25, not 0 (migration
  004 is DEFAULT 25); the doc listed it among the zero values.
- auth_error is followed by a close with code 1008 (policy violation)
  and reason "authentication failed" (serve.go:128), which the doc left
  as "closes the connection".

Also: voice_speakers moves from discord-parity's "still dead" list to
"came off the list" — it was retired earlier on this branch. And Kick
says sessions are revoked and sockets drop on the next sweep, which is
what ForceLogout does (moderation.go:236); it does not cut sockets.

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

* docs(protocol): fixture rule covers enum vocabularies; header comment precision

Re-check minors from the whole-branch review: a fixed-vocabulary value the client switches on is shape, not a seeded value; drop the false 'new default channel' clause (the seed asserts channel ids); the low-priority frame is presence_update, not connect presence.

* docs(plans): record B2-1 evidence for HP-2 (PR #1435, fixture commit SHAs)

* docs(protocol): additive changes stay within an epoch; bump only for what old clients cannot process

Codex review on #1435: B2-2 keeps protocol_epoch = 1 while adding auth/ready/auth_error fields, which the previous wording would have called a break to revert. An epoch is a compatibility boundary, not a snapshot: additive keys regenerate in the same PR and are documented; removals, renames, retypes, dropped frames and reordering bump the epoch. The plan's B2-1 evidence records the refinement and hands B2-2/B2-4 the open questions (additive-tolerant replay of the epoch-1 transcript; epoch 0 vs 1 naming).

* test(client): compare auth-frame key sets order-independently

Codex review on #1435: Object.keys preserves insertion order, so a harmless property reorder in ws.ts would fail the pin. Key order has no wire meaning; the Go fixtures already compare with sorted keys. Sort both sides.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 15:43:30 +02:00
J3vbandClaude Fable 5 fb6b51a062 chore(b2-0): release hygiene (#1434)
- dev branch protection: strict: true — script updated and applied;
  API read-back true, the 12 required checks unchanged
- release.yml: environment: release on the publish job, so the
  environment's required reviewer actually gates publishing
- docker-smoke.sh: export MSYS_NO_PATHCONV=1 (ENV-03) so Git Bash
  callers no longer have to set it; old script exits 1, new exits 0
- B2 plan: B2-0 marked done with the evidence HP-2 questions 1 and 7 cite

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 10:16:27 +02:00
J3vbandClaude Fable 5 b3f39419e9 docs: B2 execution plan and the 2026-08-28 roadmap amendments (#1433)
* docs(roadmap): record B0/B1 complete and point B2 at its execution plan

* docs(roadmap): keep the hard break after the status header

* docs(roadmap): write down the phase execution pattern B0 and B1 proved

* docs(roadmap): add the 2026-08-28 amendments to B3-B10

* docs(plans): add the B2 execution plan

* docs(plans): index the B2 plan and the roadmap amendment

* docs(plans): record that the owner already synced dev after alpha.4 (#1432)

* docs(plans): apply the final review — verify three claims, serialize B2-5, add the step table

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

* docs(plans): do not point readers at a public repro from the B2-9 table

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 07:36:40 +00:00
dd7ed091c0 chore: merge main into dev after the v1.2.0-alpha.4 release (#1432)
* ci(deps): bump anthropics/claude-code-action (#1404)

Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.193 to 1.0.199
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/9d7150bc8a3dae8149739a88019d192b579ad90c...dcb57747bfceeaa1fa72638cae52295d1d853d4a)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.199
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): pin rfd to tauri-plugin-dialog's major to unblock the cargo group (#1406)

The cargo-dependencies group PR (#1405) fails Rust Unit Tests on Linux:

    error: failed to run custom build command for `rfd v0.17.2`
    You need to choose at least one backend: `gtk3` or `xdg-portal`
    features for x86_64-linux

rfd is not really ours. It arrives in the tree via tauri-plugin-dialog,
which pins ^0.16; we declare it directly only for the fatal-startup
message box in lib.rs, where the Tauri app never finished building and
the plugin has no AppHandle to run a dialog through.

Cargo unifies features only within a semver-compatible version group, so
while both wanted ^0.16 there was a single rfd in the graph and the
plugin's backend features covered our `default-features = false`
declaration too. Bumping our direct dep to 0.17 forks rfd into two
crates: the plugin keeps 0.16.0 with its features, ours resolves to
0.17.2 with none, and rfd 0.17 added a build.rs assertion that aborts
the Linux build when no backend feature is set. Confirmed in the PR's
lockfile, which carries both 0.16.0 and 0.17.2.

Adding a Linux backend feature would be the wrong fix: it would paper
over the fork and still build rfd twice on every platform for one error
dialog. Our version has to track the plugin's instead, so ignore
semver-minor rfd updates (0.16 -> 0.17 for a 0.x crate) until
tauri-plugin-dialog moves. Patch updates inside 0.16.x still flow.

The remaining five crates in the group are unaffected; `windows` in fact
consolidates 3 versions down to 2.

Cargo.toml is comment-only here - no dependency, feature, or lockfile
change - so the build is untouched.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): bump log (#1407)

Bumps the cargo-dependencies group with 1 update in the /Client/tauri-client/src-tauri directory: [log](https://github.com/rust-lang/log).


Updates `log` from 0.4.33 to 0.4.34
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.33...0.4.34)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump anthropics/claude-code-action (#1408)

Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.199 to 1.0.200
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/dcb57747bfceeaa1fa72638cae52295d1d853d4a...24dcd50c0568f0fc9e9211213a4fd2d9eb15c4e0)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.200
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* release: v1.2.0-alpha.4 — 62 fixes plus the B0/B1 repository foundation (#1426)

* Fix 27 findings from 2026-08-21 bug hunt (#1400)

* chore(findings): record 2026-08-21 bug hunt (38 findings)

* fix(api): 1 defect(s) (OC-0240)

* fix(client): 1 defect(s) (OC-0241)

* fix(plugin): 2 defect(s) (OC-0243, OC-0265)

* fix(client): 3 defect(s) (OC-0244, OC-0256, OC-0259)

* fix(client): 1 defect(s) (OC-0247)

* fix(client): 2 defect(s) (OC-0248, OC-0258)

* fix(identity): 1 defect(s) (OC-0250)

* fix(ws): 3 defect(s) (OC-0252, OC-0269, OC-0272)

* fix(admin): 1 defect(s) (OC-0253)

* fix(client): 1 defect(s) (OC-0254)

* fix(voice): 1 defect(s) (OC-0255)

* fix(ws): 1 defect(s) (OC-0260)

* fix(client): 1 defect(s) (OC-0261)

* fix(client): 1 defect(s) (OC-0262)

* fix(client): 1 defect(s) (OC-0263)

* fix(client): 1 defect(s) (OC-0264)

* fix(client): 1 defect(s) (OC-0268)

* fix(ws): 1 defect(s) (OC-0273)

* fix(service): 1 defect(s) (OC-0275)

* style: satisfy golangci-lint and prettier on 2026-08-21 fix commits

- drop ineffectual backupDir reset before return (registry.go, OC-0265)
- reflow long boolean expression (attachments.ts, OC-0241)

* fix(client): 4 defect(s) (OC-0242, OC-0246, OC-0249, OC-0251)

* fix(voice): 1 defect(s) (OC-0267)

* fix(admin): 1 defect(s) (OC-0274)

* fix(voice): 1 defect(s) (OC-0245)

* fix(ws): 1 defect(s) (OC-0271)

* fix(voice): 2 defect(s) (OC-0239, OC-0257)

* fix(ws): 1 defect(s) (OC-0266)

* fix(voice): 1 defect(s) (OC-0270)

* style: clear golangci-lint modernize and prettier nits from 2026-08-21 fixes

- range-over-int and slices.Contains modernizations in new Go test files
- prettier reflow in dispatcher.ts

* chore(findings): mark 2026-08-21 hunt findings fixed/declined

37 fixed across the fix waves, OC-0238 declined (LiveKit webhook TLS
requires a product decision, not a mechanical patch).

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix: 35 findings from the 2026-08-22 bug hunt (#1402)

* fix(voice): 1 defect(s) (OC-0277)

* fix(voice): 1 defect(s) (OC-0278)

* fix(client): 1 defect(s) (OC-0280)

refreshDmSidebar() rebuilds the entire DM sidebar subtree on every
dmStore.channels change - which includes presence flips and new
messages, not just DM list changes. The "Find a conversation" filter
text and input focus live only in that destroyed subtree, so they were
silently wiped mid-typing. Capture and restore both across the
destroy+recreate cycle.

* fix(ws): 1 defect(s) (OC-0285)

* fix(client): 1 defect(s) (OC-0286)

* fix(client): 1 defect(s) (OC-0288)

Consume the legacy unscoped mute key after migrating it onto the first
host, so a brand-new host with no scoped key of its own no longer reads
through to the same legacy list and inherits another server's mutes.

* fix(voice): 1 defect(s) (OC-0290)

* fix(db): 1 defect(s) (OC-0293)

DecrementMentionCounts reversed mention_count bumps that were never
applied: message_mentions stores every resolved mention id including the
author's blockers, while applyMentionCounts excludes blockers before
incrementing. Deleting a blocked author's message therefore wiped an
unrelated, genuine mention badge on the same read_states row. Mirror the
block exclusion in the decrement UPDATE.

* fix(db): 1 defect(s) (OC-0294)

DeleteAccount soft-deletes the departing user's messages but never reversed the read_states.mention_count bumps those messages made, leaving phantom mention badges. Reverse them inline in the existing transaction, mirroring DecrementMentionCounts' guards.

* fix(client): 1 defect(s) (OC-0295)

MemberList rebuilt every row on any non-presence-only membersStore change
and on every roles_update, but registered each row's click/contextmenu
listeners on the component-lifetime disposable.signal, which only aborts
at destroy(). Discarded rows therefore stayed reachable (and their
listeners live) for the component's whole lifetime. Route per-row
listeners through a per-render AbortController that is aborted and
replaced at the top of every render, and aborted again in destroy().

* fix(identity): 1 defect(s) (OC-0297)

UpdateProfile's post-commit re-read of the user row could fail for reasons
unrelated to context cancellation (SQLITE_BUSY, I/O error, pool exhaustion)
and was reported as ErrInternal even though UpdateUserProfile had already
committed. Callers that treat any UpdateProfile error as proof the write
never landed — handleUploadAvatar deletes the file it just stored — would
delete a file the committed avatar column now points at, permanently
breaking the avatar with no user_update broadcast.

Since UpdateUserProfile only writes username/avatar/display_name/about,
merge those four onto the pre-write snapshot to reconstruct the committed
row without needing the re-read to succeed, and log the read failure.

* fix(ws): 2 defect(s) (OC-0298, OC-0299)

- OC-0298: applyConnectStatus stamped c.user.Status even when the
  UpdateUserStatus write failed, so auth_ok and the presence broadcast
  claimed a status users.status disagreed with, and buildReady's
  ListMembers read never self-corrected for the session.
- OC-0299: refreshUserSnapshot silently fell back to roleName "member"
  when the new role lookup failed, pinning the session to a fabricated
  role on the wire. It now fails closed like the sibling lookups in
  upgradeAndAuth and handleFreshConnect.

* fix(client): 1 defect(s) (OC-0300)

* fix(client): 1 defect(s) (OC-0301)

* fix(ws): 1 defect(s) (OC-0302)

* fix(api): 1 defect(s) (OC-0305)

handleDiagnosticsConnectivity used clientIP(r), ignoring cfg.Server.TrustedProxies, so behind a configured trusted reverse proxy the endpoint reported the proxy hop instead of the real client address. Use clientIPWithProxies with the parsed trusted-proxy nets, matching RateLimitMiddleware on the same route.

* fix(client): 2 defect(s) (OC-0306, OC-0308)

* fix(client): 1 defect(s) (OC-0307)

QuickSwitcher registered a per-row click listener against the
overlay-lifetime AbortSignal, but renderResults() rebuilds every row on
each keystroke, arrow key, and store refresh. Discarded rows kept their
listeners alive until the overlay closed. Replaced with one delegated
click listener on the stable results container, keyed off the
data-channelid each row already carries.

* fix(client): 1 defect(s) (OC-0310)

* fix(server): 3 defect(s) (OC-0279, OC-0291, OC-0292)

Reap a soft-deleted message's attachment files, count lapsed temporary
bans as active users in the require_2fa enrollment gate, and only apply
the 2FA-enrollment precondition when require_2fa itself is being enabled.

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

* test(api): sync apiTestSchema with the user_blocks migration

DeleteAccount's mention-count reversal joins user_blocks; the api
package's hand-rolled schema fixture predates migration 012.

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

* fix(client): 3 defect(s) (OC-0281, OC-0282, OC-0296)

Decouple the E2EE identity-mismatch modal and right-click popovers from
the sidebar's per-render abort signal, and let global drag listeners
survive a mid-drag re-render.

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

* fix(voice): 2 defect(s) (OC-0283, OC-0287)

Retire a departed peer's E2EE key unconditionally on leave, and surface
a failed microphone unmute instead of reporting an unmuted state the
room never saw.

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

* fix(client): 3 defect(s) (OC-0289, OC-0303, OC-0309)

Guard the DM call button against redialing the channel already joined,
resolve the incoming-call banner's caller through the nickname-aware
display name, and keep the DM profile sidebar subscribed to live
member/status updates.

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

* style(client): prettier-format the dm-store test

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

* fix(server): 1 defect(s) (OC-0284)

Make message soft-delete a compare-and-set so a repeated chat_delete
cannot reverse mention counts twice.

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

* fix(server): 2 defect(s) (OC-0276, OC-0304)

Re-sync a resumed connection's voice E2EE peer keys in registerNow
(announce frames are unsequenced and cannot be replayed), and apply the
live-connection presence rule to every DM payload DMService builds.

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

* chore(ledger): record the 2026-08-21 hunt findings as fixed

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

* chore(ledger): independent revert-proof pass for OC-0276..OC-0310

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

* refactor(service): extract DeleteMessage authorization into a helper

Keeps DeleteMessage under the cyclop complexity ceiling after the
OC-0284 guard.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore(ledger): record the 38 open findings from the 2026-08-22 hunt (#1403)

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

Co-authored-by: Claude <noreply@anthropic.com>

* chore(graphify): refresh knowledge graph

* fix: close the three B0 P0 gates and record a measured baseline (#1409)

* chore(security): stop tracking the private security-finding reports

docs/security-findings/ holds detailed reports for defects that are not yet
fixed. The directory was untracked but not ignored, so any 'git add .' would
have published seven unfixed vulnerability traces to a public repository.

Findings are coordinated through private GitHub Security Advisories
(docs/security.md); only opaque identifiers and safe status belong in tracked
plans.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): repair the two red P0 unit contracts (G-01, G-02)

G-02: noise-suppression-restart stubbed MediaStream with
vi.fn().mockImplementation(arrow), which is not constructible. Vitest 4 threw
'is not a constructor' at the new MediaStream([inputTrack]) call in
noise-suppression.ts before reaching any assertion. Replaced with a real
class; the OC-0277 assertions are unchanged.

G-01: message-list's OC-0217 guard was inverted, not merely stale. It spied on
AbortSignal.prototype.addEventListener and asserted zero abort registrations,
but the leak it names registered row listeners via
element.addEventListener(..., { signal }) — a path that never calls that
prototype method. Measured: the leak produces 0 registrations (test passes),
while the OC-0286 fix rotates a per-window AbortSignal.any and produces 5
across 5 distinct signals (test fails). The guard passed on the bug and failed
on the fix.

It now captures the signal each window's row listeners register against and
asserts the invariant its name always claimed: one signal per rendered window,
a fresh signal per jump, and every superseded window already aborted with
exactly one live. Verified both directions — green on the fix, and
'expected 1 to be 5' with beginRowRender() reverted to rowSignal = ac.signal.

Client suite: 5257 passed, 0 failed (was 5255 passed, 2 failed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): make the Playwright suite terminate

The runner finished every test and then never exited, printing no summary — so
the failure read as 'tests never finish' when it was 'process never exits'.
getActiveResourcesInfo() at hang time showed a live ProcessWrap plus several
PipeWrap: the Vite dev server was still running. Playwright's webServer
teardown does not kill it here.

Measured, full suite each time:

  npm run dev                        hangs, tests pass
  node node_modules/vite/bin/vite.js hangs, tests pass
  reuseExistingServer: false         hangs, tests pass
  gracefulShutdown SIGTERM/3s        hangs, tests pass
  npx vite                           exits, 290 of 293 FAIL
  no webServer (pre-started)         exits, 293 pass in 33s

npx only appears to fix it: npx exits once Vite is up, Playwright reads that as
the server dying and tears the group down mid-run, so later tests get
ERR_CONNECTION_REFUSED.

globalTeardown now kills the process listening on the dev port, releasing the
runner's handle. The webServer command spawns Vite's entry point directly so
the listening process is Playwright's own child — via 'npm run dev' the npm
process would still hold the handle open. It also reaps servers orphaned by an
interrupted run, which reuseExistingServer would otherwise silently adopt.

An earlier revision used netstat, which is not on PATH in every shell here; the
swallowed ENOENT made the fix look applied while the hang persisted. It now
uses PowerShell on Windows and lsof elsewhere, and warns on failure rather than
failing silently.

npm run test:e2e: exit 0, 293 passed, 37s, reproducible, no orphan listener.
playwright.config.prod.ts carried the same npm-wrapper shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(client): align .nvmrc with the Node version CI uses

Three versions were in play, not two: .nvmrc said 20, CI pins 24, and the
machine the audit was measured on runs 26. A baseline measured against .nvmrc
is not the baseline CI produces, which defeats the point of B0.

Scoped to .nvmrc only. The full single-source-of-truth work — package engines,
contributor docs, release — stays in B1 (RL-17 / C-01).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): add the beta audit set and the B0 baseline

The 2026-08-23 audit set has been sitting untracked: repository-health and
repository-layout audits, beta product requirements, requirement traceability,
the issue register, and the B0-B10 roadmap. They are the plan of record for
beta and belong in the repository.

Adds b0-baseline-2026-08-25.md, which supersedes the roadmap's 'current
evidence snapshot'. Every row is marked measured or carried, so nothing is
inherited silently. It also records three audit claims that did not survive
verification:

  - G-01 was an inverted guard, not a stale assertion — it passed on the bug
    and failed on the fix.
  - The Playwright hang matched none of the three hypotheses; the runner could
    not kill its own dev server.
  - The golangci-lint toolchain failure is refuted: 19 linters run, 0 issues,
    verified with -v to rule out the known zero-linters false-green.

Adds b0-dev-branch-protection.sh, which records the applied dev branch
protection and the reasoning behind each setting.

Security detail stays private: the register carries only opaque SEC-* families
and safe closure criteria, per the roadmap's public/private handling policy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(graphify): refresh the knowledge graph

Own commit, per CLAUDE.md — the graph payload does not belong in the diff of
the changes that triggered it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): add the active-plan index and fix a stale status header (G-04)

Planning documents had no recorded state, so a reader could not tell current
guidance from shipped history. docs/plans/README.md now indexes every plan as
active, partially implemented, design-only, or shipped, and names the source of
truth for each concern so a defect count is never read out of a plan.

Status is recorded in the index rather than by moving or rewriting the
historical plans, so links from audits and commit messages keep resolving.

One real stale claim found and fixed: audit-2026-08-19-remediation.md still
read 'in progress 2026-08-19' while its own phase table showed phases 1-6 done
2026-08-20 (merged 03fcb7d5, PR #1396) with only phase 7 pending. The header
had drifted because the table was updated in place and the header was not.
No plan was found claiming '0 open findings'.

Also records the Step 8 staleness pass in the B0 baseline: all 38 open OC
records still resolve to a live file:line at this commit, so none is superseded
by later work. Adjudicating them individually is bughunt-fix work, not B0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Update graph output files and manifest with new metadata

- Updated graph.html and graph.json with new binary data.
- Modified manifest.json to reflect changes in file modification times and AST hashes for several documents.
- Added new entry for README.md in the manifest with its corresponding metadata.

* docs(plans): close the Docker and coverage leftovers in the B0 baseline

Docker smoke: measured and passing. Image builds at 50.1 MB and boots on :8443
with TLS; docker-smoke.sh exits 0.

Server coverage: re-measured at 74.6% aggregate, confirming the figure carried
from the audit rather than continuing to inherit it.

Two findings from doing it:

ENV-03 — docker-smoke.sh cannot be run from Git Bash on Windows. MSYS path
conversion rewrites the container-internal /chatserver into
'C:/Program Files/Git/chatserver', so docker exec fails 127 and the script
reports 'container never reported healthy within 30s' — indistinguishable from
a real boot regression. MSYS_NO_PATHCONV=1 makes the same script pass. CI is
Linux and unaffected, but Windows is an official contributor platform (RL-20).

The CI Docker job is gated on main, so it is skipped for any PR targeting dev
— a dev-targeted change cannot get Docker evidence from CI at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(graphify): refresh the knowledge graph

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): B1 execution plan, and accept HP-0 (#1410)

* docs(plans): add the B1 repository-foundation execution plan

B1 is the isolated layout/contributor phase. This records the execution
order, the proof for each step, and what is out of scope.

Two findings worth surfacing before any B1 work starts:

- HP-0 was never formally accepted. The roadmap's B1 entry gate requires
  it; no scorecard artifact exists, no commit or document records an
  acceptance, and the B0 baseline still lists "Step 10: HP-0 sign-off"
  under "Not yet done in B0". The plan lists the five gaps that closing
  it requires, including pinning required status checks on dev -- which
  are still unset, so a dev PR can currently merge red.

- Several layout-audit claims do not survive verification against HEAD,
  matching the B0 pattern. RL-09's "no single command verifies both
  protocol consumers" is false (make protocol-verify does, and is
  enforced in CI, the pre-commit hook, and a contract test). RL-10's
  test-discovery side effect never fires (no _test.go in Server/scripts).
  RL-06's regeneration concern is refuted locally. RL-08 grows a
  toolchain constraint instead. RL-05, RL-07, RL-20 and RL-21 are each
  worse than written -- RL-20 includes a live bug where a missing `make`
  is reported as stale protocol constants.

The riskiest item, RL-01 (flatten Client/tauri-client into Client), gets
a full reference inventory and a mechanical proof for both commits: tree-
object equality for the pure move, and scripted-substitution replay for
the path rewrite. Release asset names and updater contracts are verified
independent of the directory name, so the move cannot rename an artifact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): correct the B1 status-check pin list from a live dev PR

The list was derived from ci.yml. Observing PR #1410's actual checks
found three that exist in no workflow file -- Analyze (go),
Analyze (javascript-typescript), Analyze (actions) -- because CodeQL
runs from GitHub default setup, configured in repository settings.
Reading .github/ alone misses them.

Also confirms the two negative predictions against a real dev-targeted
PR: Server Docker Build (verify) reports as "skipping", and Tauri Full
Build never appears in the check list at all. Neither may be pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): accept HP-0 and pin the dev required status checks

Closes B1's entry gate. All five B1-0 items are done.

The scorecard is the artifact the hold point asks for: one place that
answers its four questions, records what was accepted as a stated
limitation rather than claimed green, and part-closes R-08.

Required status checks are now pinned on dev -- ten of them. That was
B0's one outstanding step. Two things came out of doing it:

- The names cannot be inferred from ci.yml. Three of the ten (the
  Analyze jobs) exist in no workflow file, because CodeQL runs from
  GitHub default setup configured in repository settings. They were read
  off a live dev-targeted PR with `gh pr checks`.
- Server Docker Build, Tauri Full Build and the CodeQL aggregate are
  deliberately excluded. The first two report "skipping" on a dev PR --
  Tauri Full Build under its unexpanded matrix name, since the job is
  skipped before matrix expansion. Admin Panel E2E is excluded because
  continue-on-error makes it report success unconditionally.

Two prior claims are corrected rather than left to propagate:

- b0-dev-branch-protection.sh was written assuming repository-settings
  writes are blocked from the agent sandbox. They are not; the PUT
  succeeded. The script stays as the record of intent and the way to
  re-apply or undo.
- An earlier revision of the B1 plan said Tauri Full Build does not
  appear in a dev PR's check list at all. It does, as skipping.

Evidence closed out:

- Rust is no longer a carried row. Re-measured: 115 passed, cargo clippy
  --all-targets -- -D warnings at exit 0, confirming the carried figure.
- The 38 open ledger records are accepted as counted, non-stale and
  assigned: 11 medium / 27 low, zero high or critical, zero dead paths
  across all 348 re-verified at this commit, and none assigned to B1.
- The private security review is reconciled: 7 findings, 7 of 7 mapped
  to existing public rows, 0 unmapped. Summary is content-free; the
  detail stays in the untracked private reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: flatten Client/tauri-client into Client (B1-1) (#1411)

* refactor: move Client/tauri-client to Client (pure move, no content change)

* refactor: re-point paths after the Client flatten (mechanical, no behaviour change)

---------

Co-authored-by: Claude <noreply@anthropic.com>

* B1-2: truth, entry points, and contributor path (#1412)

* fix(hooks): guard on the command the hook actually runs

pre-commit probed one binary and invoked another. The protocol block
guarded on `command -v go` and then ran `make protocol-verify`; the sqlc
block guarded on `command -v sqlc` and ran `make sqlc-verify`. `make` is
not on PATH on a stock Windows box, so a contributor with Go installed
but no make had their commit rejected with

    pre-commit: FAIL: protocol constants are stale — run 'make
    protocol-generate' in Server/ and stage the result

when nothing had been generated and nothing compared. The real cause was
`make: not found`, and the advice the message gives fails the same way.

Rather than add a `command -v make` guard, inline what the two Makefile
targets reduce to — `sqlc generate` / `go run ./scripts/genprotocol`
followed by `git diff --exit-code`. Same semantics, one less prerequisite,
and it doubles as the make-free equivalent B1-2 asks for. The Makefile
targets stay for anyone who prefers them.

Also: the protocol block was the only one with no `else`, so a
contributor without Go got no check and no notice. It now warns like its
two siblings. And gofmt is a separate binary from go, so it is probed
separately.

Verified both directions with the hook body replayed verbatim:
- Go present, make absent -> passes, no staleness claimed.
- schema edited without regenerating -> fails, as it must.

Refs RL-20 / L-14.

* docs: state one branch and PR model

Active documents contradicted each other head-on. README.md and
docs/contributing.md said branch from `dev` and target `dev`; CLAUDE.md
said branch from `main`, PR to `main`. That is R-02, and the 2026-08-19
audit had already recorded it as D-06 without it being resolved.

`dev` is the answer, and the repository already behaves that way: B0 made
`dev` PR-only with ten required checks enforced on admins, and #1409,
#1410 and #1411 all landed there. `main` carries releases.

docs/contributing.md becomes the single source of truth. It now states the
model, what protection is actually applied, and the two consequences a
contributor meets on their first PR — that a self-mergeable PR still cannot
merge red, and that Docker and Tauri Full Build report as skipped against
`dev` rather than failing. Everywhere else summarises and links here.

- CLAUDE.md: corrected, with a link rather than a second copy.
- CONTRIBUTING.md: new. GitHub's contributing-guidelines affordance only
  resolves the root, .github/ or docs/ — `docs/contributing.md` is not a
  path it finds, so the link never appeared on issues or PRs.
- PULL_REQUEST_TEMPLATE.md: names the base branch, which it did not.
- bughunt-run skill: reviewed the branch against `origin/main`, which is
  the wrong base once every PR targets `dev`.

README.md already said `dev` and is left as the short summary it should be.
Dated audits and the historical remediation plan keep their `main`-era
wording — they are records.

Refs R-02.

* fix(hooks): pick the pre-push base from the nearest integration branch

pre-push decided which side's gates to run from
`git diff --name-only origin/main...HEAD`. That was right when everything
targeted `main`. Once `dev` became the integration branch it stopped being
right: a branch cut from `dev` diffed against `main` counts everything on
`dev` and not yet on `main` as "changed".

Measured on this branch: the old base reported 609 changed files, the new
one reports 6. So in practice the hook was running the full server build
matrix and the client typecheck plus eslint on every push, whatever the
change touched — the file-based narrowing it exists for never engaged.

Now it picks whichever of origin/dev, origin/main is nearest, by commits
between merge-base and HEAD, skipping a candidate that scores 0. Verified:
a branch cut from dev picks origin/dev (2 ahead); dev itself scores 0
against dev and picks origin/main (8 ahead), which is what a dev -> main
release PR wants. With no candidate resolvable it falls back to the
existing `__all__`, so an unfetched or shallow clone still runs everything.

Refs RL-20 / L-14, R-02.

* chore(node): one Node source of truth

`.nvmrc` and all ten `actions/setup-node` pins said 24; five active
documents and the repo's only `engines` block still said 20. A contributor
following the docs installed a version CI does not run.

Node 24 wins — it is what CI already runs. Every manifest now declares
`engines`, and `engine-strict=true` turns a wrong major into a failed
install rather than an `EBADENGINE` warning nobody reads. `>=24` rather
than `^24` so a Node 26 box keeps working; `Client/.nvmrc` stays the
human-facing pin and the docs point at it instead of restating a number.

The `.npmrc` is per package root, not one at the top. npm reads the
project `.npmrc` from the package directory and does not walk parents —
verified with a throwaway package requiring node >=99: with only a parent
`.npmrc` npm warned and exited 0; with one in the package directory it
failed `notsup`. A single root file would have left `Client/`, the package
that matters most, on warnings.

Five docs, not the four previously identified — `docs/mcp-introspect.md`
also said 20. And `docs/contributing.md` claimed "`.nvmrc` + CI both say
Node 20", which was wrong about both.

Verified both directions in all three package roots: Node 22 fails
`notsup`; Node 24 installs clean and `npm ci` passes in Client/.

Refs RL-17 / C-01, ENV-01.

* docs: add the documentation landing page

`docs/` had 24 top-level files and no index. The root README carried a
flat list of 22 links that had drifted: six documents were reachable from
nowhere at all — including both 2026-08-23 audits and the test audit — and
two entries were labelled "latest" while newer unlinked audits existed.

docs/README.md is the index RL-12 asked for. It groups by what a document
*is*, because that is what decides whether to trust it: guidance tells you
how to do something, reference describes a contract the code implements,
audits are dated snapshots nobody updates, plans record intent. Every
tracked file under docs/ now appears exactly once, and the audit table says
plainly that audit-2026-08-19.md still claims "0 open findings" when the
ledger has 38.

The root README keeps a short curated list and defers to the index, rather
than maintaining a second copy that drifts again. Two fixes while there:
`docs/plans/` was linked as a bare directory, unlike its two sibling
directory entries, and was annotated "each carries a verified status
header" — which docs/plans/README.md:7-9 explicitly contradicts, since a
plan's header is exactly the thing that drifts and the index is the
authority.

Verified: 78 relative links across the new and edited files resolve, and
no tracked docs/ file is unreachable from the index.

Refs RL-12 / R-06.

* feat(scripts): root command facade

Entry points existed only inside Server/ (a Makefile) and Client/ (npm
scripts). Nothing at the root told a new contributor where to start, and
the root package.json had three scripts, none of which built or tested
anything.

`npm run check` from the root now runs what CI gates on, and
check:server / check:client / check:rust run one stack. scripts/run.mjs
is dependency-free Node — the shape render-ledger.mjs already uses — so
`npm run check` works before `npm install` has.

Cross-platform by construction: every step is spawned with an explicit cwd
and no shell, so there is nothing to quote and no `cd &&` to behave
differently on Windows. npm and npx get their .cmd suffix there. No step
shells out to make.

The facade orchestrates; it is not a new required path. Each step prints
the command and the directory before running it, and those are exactly the
commands documented per-stack — so a server contributor can read the output
and type them instead, and still never needs Node. Tools CI installs but a
contributor may not have (golangci-lint, which has no wrapper in this repo
at all; sqlc, pinned by Server/sqlc.version) are skipped with a printed
reason rather than failing.

Three corrections to the ci-check skill while aligning it:

- `make sqlc-verify protocol-verify` replaced by what those targets reduce
  to, so the documented path does not require make either.
- `cargo test` -> `cargo test --lib`, which is what ci.yml actually runs.
- "NODE_OPTIONS=--no-experimental-webstorage is mandatory on Node 22+" was
  false. tests/setup.ts installs the shim, CI runs Node 24 without the
  flag, and the suite was measured passing without it — 192 files / 5257
  tests, identical to the flagged run.

Also documents the third RL-20 problem, which needed no code: core.hooksPath
is exclusive, so `npm run hooks:install` silently disables any
.git/hooks/post-commit — including the one `graphify hook install` writes,
which CLAUDE.md tells agents to install. Nothing warned about that.

Verified: check:client 5257/192 green, check:rust 123 tests + clippy green,
--list prints every command, and the optional-tool skip path reports rather
than fails.

Refs RL-04 / L-04, RL-20 / L-14.

* feat(ci): fail on a document that contradicts the findings ledger

G-04's remaining half. The ledger is the source of truth for defect counts,
but nothing stopped a planning document from stating a different number and
nothing noticed when one did. `render-ledger.mjs --check` cannot help: it
validates the JSON schema and returns before rendering, so it never reads
FINDINGS.md and cannot see drift at all — and no workflow ran it anyway.

scripts/check-doc-counts.mjs counts ledger statuses and compares them to
what an allow-list of active documents claims, failing with file, line,
claimed value and actual. Wired into ci.yml as a job with no npm ci, since
the script imports nothing outside node:, and into the facade as
`npm run check:docs` — first in `check`, so a contradicted count does not
wait behind ten minutes of -race.

The patterns are narrow on purpose. A first attempt matched any
"<number> <status>" and flagged nineteen things, all false: "the 45 open P1
rows" (issue-register rows, not ledger findings), "All 8 findings F1-F8"
(a different register), "G-05 **refuted**" (an identifier), `">=20"` and
`CGO_ENABLED=0` (not counts at all). A check that cries wolf gets ignored,
which is the failure G-04 already describes. So a number is only read as a
claim in three shapes that cannot mean anything else: an enumeration of two
or more "<n> <status>" pairs, a status table row in a table that totals
itself, and "<n> records/findings" where the ledger is named within three
lines. Fifteen selftest assertions pin both directions, and the job runs
them before it runs the check.

It reads findings-ledger.json directly rather than importing
render-ledger.mjs for `validate`/`render`: that module ends in a bare
top-level `await main()` with no import.meta.main guard, so importing it
rewrites FINDINGS.md as a side effect.

Dated docs/audit-*.md are reported, never failed — they are snapshots
nobody maintains. audit-2026-08-19.md does claim zero open findings against
38 open, so b0-baseline's "No plan was found claiming '0 open findings'"
holds for docs/plans/ but not for docs/.

Not included: a real FINDINGS.md render-drift check. That is RL-07 and
belongs with the generated-artifact work, not here.

Verified: 27 claims across 9 documents agree; corrupting one count in
docs/plans/README.md fails the check naming that line, for both the status
and the total.

Refs G-04.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore: remove graphify knowledge graph tooling (#1413)

The committed knowledge graph and its PreToolUse hooks were steering every
codebase question through `graphify query` before any other tool could run.
Serena (gopls + rust-analyzer + tsserver over MCP) answers the same questions
from real language servers rather than a generated snapshot that goes stale
between rebuilds, so the graph no longer earns the ~20 MB it costs the tree.

Removed:
- `graphify-out/` untracked (7 files, ~20 MB) and now gitignored
- both `graphify hook-guard` PreToolUse hooks from `.claude/settings.json`
- the "Knowledge graph (graphify)" section of `CLAUDE.md`
- the `graphify-out/**` block from `.gitattributes` and `.gitignore`
- the graph-rebuild step from the `bughunt-run` skill, and the graph-edge
  guidance from the bughunt workflow prompt
- the graphify-specific `core.hooksPath` example in `ci-check` and
  `docs/contributing.md`, keeping the underlying warning in generic form

Also deletes the locally installed `post-commit` / `post-checkout` rebuild
hooks (untracked, not part of this diff).

This does not shrink clone size: the graph blobs stay in published history,
which `docs/plans/b1-repository-foundation-2026-08-25.md` explicitly rules out
rewriting. It does stop future refreshes from adding more.

Dated audit and plan documents keep their graphify references as a historical
record of the state they described.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* B1-3: repository hygiene gates (RL-19 / L-13, S-05) (#1414)

* chore(format): one Prettier config at the repository root

Every formatting rule in this repository lived under Client/ and covered
exactly two globs: Client/src/**/*.ts and Client/tests/**/*.ts. Root Markdown,
all of docs/, every YAML and JSON, all CSS, the root scripts and
tools/mcp-introspect were formatted by nothing. There was no .editorconfig.

The obvious fix -- a second Prettier config at the root for "everything else"
-- gives two configs and two ignore files that can silently disagree about the
same file. So the root takes ownership instead: config, ignore file and gate
move up, and Client/ folds in. Client's inline "prettier" block, its
.prettierignore, its format/format:check scripts and its now-unused prettier
devDependency are all deleted; knip would have failed client-check on that last
one.

The .prettierrc.json values are lifted byte-for-byte from Client/package.json,
which is what keeps the reformat commit free of client TypeScript churn: 87
tracked files need reformatting and not one of them is under Client/src or
Client/tests.

.prettierignore carries only what .gitignore does not. Prettier 3 reads the
root .gitignore by default, so node_modules/, dist/, coverage/,
Client/src/generated/ and docs/security-findings/ need no entry. It does NOT
read nested .gitignore files, which is why .remember/ is listed explicitly --
38 untracked per-machine scratch files were otherwise able to turn a shared
gate red. graphify-out/ is listed because its seven files are tracked and
.graphify_labels.json is signed byte-for-byte by its .sig, so formatting it
would silently invalidate the signature.

check:hygiene is registered in scripts/run.mjs and folded into check and
release:preflight. It deliberately contains no `gofmt -l` step: gofmt -l prints
offenders and still exits 0, so it cannot fail a build. Go formatting is
enforced separately.

shellcheck and actionlint take their file lists from `git ls-files`, never a
filesystem glob -- .claude/worktrees/ holds a gitignored pre-flatten copy of
the tree with three .sh files a glob would happily lint.

This commit leaves the tree non-conformant on purpose. The reformat is the next
commit, so the 87-file diff is reviewable separately from the rule that caused
it.

Not included: editorconfig-checker. .editorconfig is the editor baseline the
audit asked for; Prettier, gofmt and rustfmt already fail CI on the same
indentation and newline rules, so a fourth tool checking them again is a gate
with no failure mode of its own.

Verified: `npx prettier --check .` names 87 tracked files and zero untracked
ones; the same command listed 38 .remember/ scratch files before the ignore
entry and none after. `node scripts/run.mjs --list` resolves check:hygiene to 8
shell targets and 4 workflow targets. Both package.json files parse.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): reformat the tree to the repository Prettier rules

Mechanical. This commit is `npx prettier --write .` and nothing else -- the
rule that caused it landed in the previous commit so this diff can be reviewed
as a transformation rather than as 84 files of hunks.

84 tracked files: 54 Markdown, 7 .mjs, 7 JSON, 6 YAML, 4 .js, 3 CSS, 2
TypeScript (the two Playwright configs at Client's root, which the old
Client/src + Client/tests globs never covered). No file under Client/src or
Client/tests moves, because .prettierrc.json carries Client's former inline
values byte-for-byte.

Prettier rewrote 87 files, not 84. The three in .github/ISSUE_TEMPLATE/ had
CRLF on disk and differ only in line endings, which .gitattributes
(`* text=auto eol=lf`) already normalises, so their committed blobs are
unchanged. Worth knowing before someone reconciles the two numbers.

The largest single diff is .superpowers/findings-ledger.json at 7976 lines
rewritten. That is safe to format: nothing writes the ledger programmatically
-- render-ledger.mjs reads it and writes only FINDINGS.md -- so no tool will
fight Prettier over its style on the next hunt. FINDINGS.md itself is ignored
as generated.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style", so the pass is both complete and idempotent. All 7 reformatted JSON
files were parsed before and after and compared as values: semantically
identical, zero content changes. `node .superpowers/render-ledger.mjs --check`
still reports 348 valid findings and leaves FINDINGS.md untouched.
`node scripts/check-doc-counts.mjs` still passes its selftest and still agrees
on 27 claims across 9 watched documents -- table realignment did not break the
patterns it matches on. `node scripts/run.mjs --list` still parses.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(lint): enforce Go formatting in the Server linter

S-05: repository-wide Go formatting was not a required gate. The only gofmt
enforcement anywhere was .githooks/pre-commit, which is opt-in per clone
(`npm run hooks:install`), only sees staged files, and warns-and-skips when
gofmt is off PATH.

The obvious fix -- a `gofmt -l` step in CI -- does not work: `gofmt -l` prints
its offenders and still exits 0, so the step passes no matter what it finds.
scripts/run.mjs has the same problem, which is why check:hygiene has no Go step
either.

So gofmt goes where it can actually fail something: Server/.golangci.yml. The
file was already `version: "2"` but had no `formatters:` block at all, so the
19 enabled linters ran with zero formatters. In v2 gofmt/gofumpt/goimports
moved out of `linters.enable` into their own section with its own exclusions.
Adding it there means the gate reports through the Lint step of "Server Build &
Test", which is already pinned as required on dev -- no new job and no new pin.
Every tracked .go file is under Server/ (551 of them, one go.mod), so
Server-scoped is repository-wide here.

One file was genuinely misformatted: a one-space struct field alignment in
Server/admin/handlers_users_broadcast_test.go, fixed in the same commit because
a single line does not need its own reformat commit.

Trap worth recording: `gofmt -l .` on a Windows working tree lists every file
that has CRLF on disk, because gofmt normalises line endings. That reported 18
offenders here, 17 of them ghosts. The blobs are all LF -- .gitattributes
forces `eol=lf` -- so CI never saw them, and the honest test is to run gofmt
over `git show HEAD:<file>` rather than the working copy. Doing that across all
551 tracked Go files found exactly the one real offender above.

Verified both directions with golangci-lint v2 locally: `golangci-lint run
./...` reports 0 issues on the formatted tree; appending a misformatted
function to Server/auth/constants.go produces 2 gofmt findings; appending the
same misformatted function to Server/db/dbgen/admin.sql.go produces 0, so the
exclusion holds. Both files restored and verified clean afterwards.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): escape the NUL separator instead of embedding one

The `tracked()` helper added earlier in this branch splits `git ls-files -z`
output on NUL. The separator was written as a literal NUL byte rather than the
two-character JavaScript escape, so scripts/run.mjs became a binary file: `git
diff` refused to show it, `grep` reported "Binary file matches" instead of the
line, and `* text=auto` in .gitattributes stops normalising line endings for a
blob it detects as binary.

The code worked -- splitting on a raw NUL and splitting on "\0" are the same
operation -- which is exactly why this is worth fixing before it is inherited.
A source file that tooling classifies as binary is a file nobody can review.

Verified: zero NUL bytes remain, `grep -n "split("` now prints line 50 instead
of "Binary file matches", `node scripts/run.mjs --list` still resolves the same
8 shell and 4 workflow targets, and prettier still reports the file clean.

* chore(lint): enforce Rust formatting

Rust had no formatting gate of any kind: no rustfmt.toml, no `cargo fmt`
anywhere in CI, in scripts/run.mjs, in the Makefile or in the git hooks. Clippy
was the only Rust gate, and clippy does not check layout.

`cargo fmt --all -- --check` now runs in the rust-tests job, ahead of clippy: a
formatting failure is cheap to produce and cheap to fix, and there is no reason
to spend a clippy pass to surface one. The stable toolchain in that job
requested `components: clippy` only, so rustfmt is added there.

Only that job. ci.yml has a second, byte-identical `Install Rust` block in
tauri-build; it stays clippy-only, because a full desktop build is the wrong
place to discover a misplaced brace.

No rustfmt.toml. The default profile is the point of a baseline -- a config
file here would be a second opinion about style with nothing to say.
Client/src-tauri is a single `[package]`, not a workspace, so `--all` is a
safeguard against a future member rather than a fan-out today.

Verified: `node scripts/run.mjs --list` resolves check:rust to three steps with
`cargo fmt --all -- --check` first, `npm run format` now also runs `cargo fmt
--all`, and prettier reports ci.yml, run.mjs and the ci-check skill clean.
`cargo fmt --all -- --check` currently fails on 13 files -- that is the
reformat, and it is the next commit.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): reformat the Rust crate to rustfmt defaults

Mechanical. This commit is `cargo fmt --all` and nothing else; the gate that
demands it landed in the previous commit so this diff is reviewable on its own.

13 of the 17 tracked .rs files, +343/-164. The crate had never been formatted,
so the changes are the usual first-run set: aligned trailing comments collapsed
to single spaces, single-element slice literals folded onto one line, long
method chains broken across lines, closure bodies expanded into blocks.

Verified: `cargo fmt --all -- --check` is clean, so the pass is complete and
idempotent. `cargo clippy --all-targets -- -D warnings` finishes with no
warnings, and `cargo test --lib` reports 115 passed / 0 failed -- identical to
before the reformat, which is what "mechanical" has to mean for a commit that
touches this much of the crate.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): make the root facade actually run on Windows

Adding the first gate that a contributor would run from the repository root
exposed two bugs in the facade, both of which made it silently wrong on the
platform this project is developed on.

1. Every npm and npx step failed. bin() appends `.cmd` on Windows, but Node
   refuses to spawn a .cmd or .bat with shell:false -- the CVE-2024-27980
   mitigation -- and fails with EINVAL and a *null* exit status. run.mjs only
   special-cased ENOENT, so the result was `FAILED: npx prettier --check .
   exited null` with nothing to explain it. check:client has three npm steps and
   has never been able to run here.

   Fixed by spawning only the npm shims through a shell. They are concatenated
   into a single command string rather than passed as an args array, because
   shell:true plus a separate array is deprecated (DEP0190) and prints a warning
   on every invocation; no argument in this file contains a space.

2. Every optional() step was skipped, always. onPath() shelled out to
   `where` on Windows, but where.exe lives in C:\WINDOWS\System32, which a Git
   Bash PATH does not necessarily contain -- on this machine PATH carries
   System32\Wbem, System32\WindowsPowerShell\v1.0 and System32\OpenSSH but not
   System32 itself. The probe could not start, `probe.status === 0` was false,
   and golangci-lint and sqlc reported as "not installed" while installed.

   Fixed by resolving against PATH and PATHEXT directly. No subprocess, and no
   dependency on which directories happen to be on PATH.

A spawn error other than ENOENT now reports its code instead of surfacing as a
null exit status.

Verified: before, `node scripts/run.mjs check:hygiene` died with "exited null"
and both optional steps printed SKIP with the tools present on PATH. After, the
same command runs prettier, shellcheck and actionlint and prints
"check:hygiene: passed", with no deprecation warning. `golangci-lint` is
detected by the new onPath where the old one missed it.

Refs RL-20 / L-14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): ignore build output that nested gitignores hide

Prettier honours the root .gitignore and no other. Every build and scratch
directory in this repository is ignored by a *nested* one -- Client/.gitignore,
.serena/.gitignore, .superpowers/sdd/.gitignore -- so none of them were
excluded from the new repository-wide gate.

The effect is not subtle. Running `cargo test` once drops roughly 850
formattable files into Client/src-tauri/target/, and the hygiene gate goes from
clean to "Code style issues found in 939 files". CI never sees it, because a
fresh checkout has no build output; every contributor sees it on their second
command.

Mirrors the three nested files rather than inventing a list: dist, coverage,
playwright-report, test-results, .vite, src-tauri/target and src-tauri/gen from
Client/.gitignore, plus .serena/ and .superpowers/sdd/. node_modules needs no
entry -- Prettier ignores it by default.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style" with a fully populated Client/src-tauri/target/ present on disk, and
still names README.md when a misformatted table is appended to it.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(ci): shellcheck, actionlint, and a repository hygiene job

The last two gates RL-19 asks for. Neither existed: the shell scripts were
never linted, the workflows were never syntax-checked, and .githooks/pre-commit
carried hand-written `# shellcheck disable=` directives that nothing had ever
read.

New `hygiene` job, ubuntu-only and root-scoped, modelled on docs-consistency
for the same reason: every gate in it is platform-independent text analysis,
and .gitattributes pins eol=lf so a second OS would only re-prove line endings.
It runs `npm run check:hygiene` -- the same entry point a contributor runs, not
a parallel copy of the commands.

shellcheck ships in the runner image. actionlint does not, so it is pinned by
version and verified by sha256: an installer script piped from a branch would
be the one unverified download in a workflow file that pins every action by
commit SHA.

Prettier's step moves here from client-check, where it no longer belongs.

Both linters found real defects.

shellcheck, 3 findings in 8 scripts. Two are SC1125 errors in
.githooks/pre-commit: `# shellcheck disable=SC2086 - repo paths contain no
spaces` is not a valid directive. Trailing prose makes shellcheck discard the
rest of the line, so neither suppression was ever in effect -- and one of the
two was written earlier in this same branch, which is a fair demonstration of
why the gate is worth having. The prose moves to its own line above. The third
is SC2015 in start-server.sh, rewritten as an explicit if.

actionlint, 5 findings, all inside `run:` blocks it shellchecks once shellcheck
is on PATH. Three SC2015 in load-baseline.yml, rewritten as explicit ifs. Two
SC2035 in release.yml, where `sha256sum *` should not become `sha256sum ./*`:
the comment four lines above records that ParseChecksumFile exact-matches the
last field, so a "./" prefix would strand every deployed server exactly as a
"windows/" prefix would. `sha256sum -- *` satisfies the linter and leaves the
output bytes identical.

Verified all three gates in both directions with shellcheck 0.10.0 and
actionlint 1.7.7 on PATH. Passing: `node scripts/run.mjs check:hygiene` prints
"check:hygiene: passed" with all three steps run, not skipped. Failing:
appending `bait_fn() { cat $1; }` to Server/scripts/voice-test.sh fails on
SC2086; changing a runs-on to `ubunt-latest` fails on runner-label; appending a
misformatted table to README.md fails prettier. All three files restored and
confirmed clean afterwards. actionlint validates the new job in ci.yml itself.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): record B1 progress through B1-3

The header still read "B1-0 is complete; B1-1 is the next step" three merged
phases later. A plan that misstates where it is costs a reader the same
confusion whether it is stale by one phase or three.

B1-0 (#1410), B1-1 (#1411), B1-2 (#1412) and B1-3 (this branch) are done; B1-4,
dependency automation, is next.

Verified: `node scripts/check-doc-counts.mjs` still agrees on 27 claims across
9 watched documents -- this file is one of them -- and prettier reports it
clean.

* chore(ci): pin Repository Hygiene as a required check on dev

The second half of S-05. Its acceptance criterion is "tree is formatted AND a
fast required gate fails future drift" -- a check that runs but is not pinned
lets a formatting regression merge, so the gate is not a gate until this lands.

The name was read off PR #1414 with `gh pr checks` after the job reported
`pass` in 26s, not copied out of ci.yml. That order matters: the B0 script
records that three pinned names exist in no workflow file at all, and that a
required check which never reports blocks every PR forever.

Extends the existing script rather than adding a second one, per the B1 plan.

Also records, in the "deliberately NOT pinned" list, that Docs & Ledger
Consistency reports and passes on a dev PR yet is unpinned. That reads as an
oversight from the 2026-08-25 pass rather than a decision, but it belongs to
G-04, so it is documented here and not changed.

NOT APPLIED YET. Running this script now would pin a check that PR #1413 cannot
report -- its branch predates the hygiene job, so the job does not exist in its
workflow file and the check would never arrive. Run it after #1414 merges;
#1413 needs a rebase onto dev regardless.

Verified: shellcheck clean, the embedded JSON parses, and `check:hygiene`
passes with prettier, shellcheck and actionlint all running.

Refs S-05, RL-14 / G-03.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* B1-4: dependency automation (RL-05 / L-05, RL-18) (#1415)

* chore(deps): cover the root and mcp-introspect npm roots

The repository has three npm package roots — `/` (changelogen, prettier),
`/Client`, and `/tools/mcp-introspect` (@modelcontextprotocol/sdk, zod) —
each with its own package-lock.json, and `npm run bootstrap` runs `npm ci`
in all three. Dependabot watched exactly one of them. The root's prettier is
what the Repository Hygiene gate runs, so the formatting gate's own toolchain
was drifting unwatched.

The obvious fix is to collapse the three roots into an npm workspace and
watch one lockfile. Measured on npm 11.17 / Node 26 rather than assumed, that
trade is bad, and it is bad for different reasons than expected. Workspaces do
not break the things you would predict: `npm ci` inside `Client/` still exits
0, `npm run <script>` still resolves the hoisted binaries because npm prepends
every ancestor `node_modules/.bin` to PATH, and `engine-strict` still fails
the install on a wrong Node major. What they cost is ten CI steps keyed on
`cache-dependency-path: Client/package-lock.json` (six in ci.yml, four in the
tag-only, CI-ungated release.yml) pointing at a file that stops existing; the
Repository Hygiene job's deliberate root-only install growing 970 ms to
6172 ms and 39 to 318 packages unless every call site remembers
`--workspaces=false`; and one shared lockfile putting all three npm Dependabot
groups back into the same file, which is precisely the rebase storm the
grouping comment at the top of dependabot.yml exists to prevent. The measured
benefit is one 298 KB lockfile instead of three (17 KB / 253 KB / 42 KB) and
614 resolved packages deduped to 582 — 32 packages, 5.2% — with client install
time unchanged at 5642 ms against 5667 ms.

So the roots stay separate and each gets its own block, matching the four that
already exist: grouped to one PR, majors ignored, weekly on Monday. A single
block with `directories:` was rejected for the same reason as workspaces —
grouping only works while a group rewrites exactly one lockfile. The decision
and its numbers are recorded in docs/contributing.md under Dependency Policy,
so the next person to propose workspaces reads the measurement instead of
repeating it.

Verified: a coverage checker cross-references every `package-ecosystem` /
`directory` pair in dependabot.yml against every manifest in `git ls-files`,
in both directions. Against dev at 2a37f386 it reports `UNWATCHED npm
package.json` and `UNWATCHED npm tools/mcp-introspect/package.json` (plus
Server/Dockerfile, which the next commit covers). Against this commit both npm
rows read `ok`, and the forward direction confirms each newly declared
directory really holds a package.json. `npx prettier --check` reports both
edited files unchanged, so the B1-3 formatting gate stays green.

Not included: the docker ecosystem (next commit, RL-18); immutable image
digests for release/runtime containers, which is R-04's remaining half and
belongs to B6; and turning the coverage checker into a permanent
`check:hygiene` gate — a new manifest root can still drift unwatched, which is
how this gap arose, but that is new gate machinery rather than the coverage
this item asks for.

Refs RL-05 / L-05

* chore(deps): watch the server container base images

Server/Dockerfile pulls `golang:1.26-bookworm` to build and
`gcr.io/distroless/static-debian12` to run, and nothing watched either. Every
other dependency root in the repository is on a weekly Dependabot schedule, so
the one artefact that ships to users as a whole filesystem was the only one
whose upstream moved silently — including its CA certificates, which the
Dockerfile comment specifically calls out as the reason distroless was chosen
over scratch.

The obvious fix is to pin both images by digest and be done. That is the wrong
move here for two reasons. A digest pin with no automation behind it is worse
than a tag: it freezes the base image at whatever was current the day someone
typed it, and a frozen distroless base is a frozen CA bundle. And digest
refresh for release and runtime images is R-04's other half, scoped to B6
alongside the smoke tests that have to gate it — landing half of it here would
leave the digests pinned and the refresh unowned.

So this adds the `docker` ecosystem for /Server on the same terms as the five
blocks around it: grouped to one PR, majors ignored, weekly on Monday. Be
precise about what that actually buys, because it is less than the block
implies. Of the two images only `golang:1.26-bookworm` carries a comparable
version, so it is the only one Dependabot can act on today;
`gcr.io/distroless/static-debian12` has no version tag, and an untagged image
is not something a version update can move — it needs the digest pinning that
B6 owns. The comment above the block records that the Go builder tag tracks
Server/go.mod and every `actions/setup-go` in CI, so a `1.27-bookworm` PR is a
prompt to move all three together rather than a standalone merge.

Verified: the coverage checker cross-references every `package-ecosystem` /
`directory` pair against every manifest in `git ls-files`, in both directions.
Against dev at 2a37f386 the reverse direction reports `UNWATCHED docker
Server/Dockerfile`; against this commit every row reads `ok` and it exits 0 —
`ALL ROOTS WATCHED`, six blocks covering six manifest roots, with the forward
direction confirming /Server really holds a Dockerfile. `npx prettier --check`
passes on the edited file.

Not included: Server/docker-compose.yml and Server/docker-compose.otel.yml.
Their four images are `ghcr.io/j3vb/owncord-server:latest` (this repository's
own published image), `livekit/livekit-server:v1` (a floating major tag, and
majors are ignored everywhere), `jaegertracing/all-in-one:latest` and
`prom/prometheus:latest` — none of which a version update can move, so a
compose block would be configuration that provably produces nothing. Also not
included: immutable digests plus digest-refresh PRs with smoke tests for the
release and runtime images, which is the remainder of R-04 and belongs to B6.

Refs RL-18

* docs: apply skill-review findings to ci-check and the project skills (#1416)

The observation log had accumulated 46 open entries against a last review of
2026-08-14. Seven of them target skills tracked in this repository and were
verified still-unapplied against the current files.

`ci-check` gains four things it was missing. It never mentioned `cargo audit`,
which CI runs pinned at 0.22.1 in `tauri-build` — the one gate that turns red
with zero local changes, because an upstream advisory breaks a branch that was
clean yesterday, and the one a hand-written mirror silently drops because no
edit provokes it. It never mentioned that `release.yml` is tag-triggered and
PR-ungated, so a smoke/sign/strip step added only there first executes on the
release; #1376 shipped a smoke harness whose own bug then blocked a release,
and #1378 fixed it structurally by extracting `Server/scripts/docker-smoke.sh`
for both workflows. And it had no guidance for reading a red check at all: a
new section adds causality-before-forensics triage (diff the changed-file set
against the failing job's input surface before opening a log — a workflow-only
diff cannot cause a Go goroutine leak), the lockfile-fork diagnosis for
dependency bumps (a 1 → 2 entry-count transition means the update forked the
dependency and revoked the features it was borrowing, so aligning versions is
the fix, not setting the feature the new copy demands), and the known-flake
table promoted to a signature-to-recovery index, now including the apt-mirror
hang that cancels `tauri-build` by timeout.

The baseline rule that came with the triage section needed adjusting rather
than transcribing. Its source observation recorded `golangci-lint`'s known-red
complexity baseline as 23 cyclop / 6 dupl / 21 funlen / 12 nestif; #1389
cleared that to zero, so quoting those numbers would have taught the reader to
excuse a failure that is now genuinely theirs. The rule is recorded without
them, stating that the repo currently carries no known-red gate and what to do
if one is ever reintroduced.

`protocol-change` claimed the schema is the source of truth without saying what
it covers. It holds message-type names only, so a payload-field change touches
the Go command/message files, the client types and `docs/protocol.md` and never
the schema — routing one through the regenerate cycle is wasted work. A table
splits the three cases, with the relay-handler caveat: a server that
re-serialises drops unknown fields, so a forwarded field is not backward
compatible with older servers.

`task-observer`'s numbering discipline treated collisions as a parallel-human
accident. They are structural in fan-out workflows, because a dispatched
subagent has the skill active in its own context and writes to the same log.

`bughunt-run` covered findings blocked by a circuit breaker but not findings
that went stale: a later hunt routinely fixes a blocked finding as a side
effect of an overlapping sibling, and a saved debris patch stops applying once
a refactor rewrites its files. Of 6 findings blocked on 2026-08-14, 2 were
already fixed 5 days later.

`docs/contributing.md` gains the commit-body convention that was being followed
without being written down anywhere — reasoning over diff-restatement, a
`Verified:` paragraph proving both directions, and an explicit `Not included:`
line. That last one is what keeps adjacent scope from becoming either silent
drift or an unnecessary blocking question.

Verified: each edit was checked against the live file before applying, which
changed two outcomes. Observation 50 (make the hunt's stop rule measure
coverage, not just quietness) is already implemented — `bughunt-run` documents
`coverage + dry is the real stop`, `stalledCoverage` and
`coverage.uncoveredAtStop`, landed by #1399 — so it is marked actioned rather
than re-applied. Observation 42 looked covered by the same grep and was not:
the existing text handles breaker-blocked findings, a different case from a
finding a sibling fix already closed. Confirmed absent before editing:
`cargo audit` and `release.yml` in ci-check, `payload` in protocol-change,
`subagent` in task-observer. `npm run check:hygiene` passes (prettier clean on
all five files); `npm run check:docs` passes.

Not included: the 21 open observations targeting `superpowers:*` plugin skills,
which live in a versioned plugin cache and…

* chore(deps): bump the npm-dependencies group across 1 directory with 3 updates (#1428)

Bumps the npm-dependencies group with 3 updates in the /Client directory: [eslint](https://github.com/eslint/eslint), [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `eslint` from 10.9.0 to 10.9.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.9.0...v10.9.1)

Updates `oxlint` from 1.79.0 to 1.80.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxlint_v1.80.0/npm/oxlint)

Updates `typescript-eslint` from 8.67.0 to 8.68.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.68.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.9.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: oxlint
  dependency-version: 1.80.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.68.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump anthropics/claude-code-action (#1429)

Bumps the actions-dependencies group with 1 update in the / directory: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.200 to 1.0.206
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/24dcd50c0568f0fc9e9211213a4fd2d9eb15c4e0...1f291e1cfe0f5fc21db2aef19af844591600ade7)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.206
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump golang from 1.26-bookworm to 1.27-bookworm in /Server in the docker-dependencies group across 1 directory (#1427)

* ci(deps): bump anthropics/claude-code-action (#1404)

Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.193 to 1.0.199
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/9d7150bc8a3dae8149739a88019d192b579ad90c...dcb57747bfceeaa1fa72638cae52295d1d853d4a)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.199
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): pin rfd to tauri-plugin-dialog's major to unblock the cargo group (#1406)

The cargo-dependencies group PR (#1405) fails Rust Unit Tests on Linux:

    error: failed to run custom build command for `rfd v0.17.2`
    You need to choose at least one backend: `gtk3` or `xdg-portal`
    features for x86_64-linux

rfd is not really ours. It arrives in the tree via tauri-plugin-dialog,
which pins ^0.16; we declare it directly only for the fatal-startup
message box in lib.rs, where the Tauri app never finished building and
the plugin has no AppHandle to run a dialog through.

Cargo unifies features only within a semver-compatible version group, so
while both wanted ^0.16 there was a single rfd in the graph and the
plugin's backend features covered our `default-features = false`
declaration too. Bumping our direct dep to 0.17 forks rfd into two
crates: the plugin keeps 0.16.0 with its features, ours resolves to
0.17.2 with none, and rfd 0.17 added a build.rs assertion that aborts
the Linux build when no backend feature is set. Confirmed in the PR's
lockfile, which carries both 0.16.0 and 0.17.2.

Adding a Linux backend feature would be the wrong fix: it would paper
over the fork and still build rfd twice on every platform for one error
dialog. Our version has to track the plugin's instead, so ignore
semver-minor rfd updates (0.16 -> 0.17 for a 0.x crate) until
tauri-plugin-dialog moves. Patch updates inside 0.16.x still flow.

The remaining five crates in the group are unaffected; `windows` in fact
consolidates 3 versions down to 2.

Cargo.toml is comment-only here - no dependency, feature, or lockfile
change - so the build is untouched.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): bump log (#1407)

Bumps the cargo-dependencies group with 1 update in the /Client/tauri-client/src-tauri directory: [log](https://github.com/rust-lang/log).


Updates `log` from 0.4.33 to 0.4.34
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.33...0.4.34)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump anthropics/claude-code-action (#1408)

Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.199 to 1.0.200
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/dcb57747bfceeaa1fa72638cae52295d1d853d4a...24dcd50c0568f0fc9e9211213a4fd2d9eb15c4e0)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.200
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* release: v1.2.0-alpha.4 — 62 fixes plus the B0/B1 repository foundation (#1426)

* Fix 27 findings from 2026-08-21 bug hunt (#1400)

* chore(findings): record 2026-08-21 bug hunt (38 findings)

* fix(api): 1 defect(s) (OC-0240)

* fix(client): 1 defect(s) (OC-0241)

* fix(plugin): 2 defect(s) (OC-0243, OC-0265)

* fix(client): 3 defect(s) (OC-0244, OC-0256, OC-0259)

* fix(client): 1 defect(s) (OC-0247)

* fix(client): 2 defect(s) (OC-0248, OC-0258)

* fix(identity): 1 defect(s) (OC-0250)

* fix(ws): 3 defect(s) (OC-0252, OC-0269, OC-0272)

* fix(admin): 1 defect(s) (OC-0253)

* fix(client): 1 defect(s) (OC-0254)

* fix(voice): 1 defect(s) (OC-0255)

* fix(ws): 1 defect(s) (OC-0260)

* fix(client): 1 defect(s) (OC-0261)

* fix(client): 1 defect(s) (OC-0262)

* fix(client): 1 defect(s) (OC-0263)

* fix(client): 1 defect(s) (OC-0264)

* fix(client): 1 defect(s) (OC-0268)

* fix(ws): 1 defect(s) (OC-0273)

* fix(service): 1 defect(s) (OC-0275)

* style: satisfy golangci-lint and prettier on 2026-08-21 fix commits

- drop ineffectual backupDir reset before return (registry.go, OC-0265)
- reflow long boolean expression (attachments.ts, OC-0241)

* fix(client): 4 defect(s) (OC-0242, OC-0246, OC-0249, OC-0251)

* fix(voice): 1 defect(s) (OC-0267)

* fix(admin): 1 defect(s) (OC-0274)

* fix(voice): 1 defect(s) (OC-0245)

* fix(ws): 1 defect(s) (OC-0271)

* fix(voice): 2 defect(s) (OC-0239, OC-0257)

* fix(ws): 1 defect(s) (OC-0266)

* fix(voice): 1 defect(s) (OC-0270)

* style: clear golangci-lint modernize and prettier nits from 2026-08-21 fixes

- range-over-int and slices.Contains modernizations in new Go test files
- prettier reflow in dispatcher.ts

* chore(findings): mark 2026-08-21 hunt findings fixed/declined

37 fixed across the fix waves, OC-0238 declined (LiveKit webhook TLS
requires a product decision, not a mechanical patch).

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix: 35 findings from the 2026-08-22 bug hunt (#1402)

* fix(voice): 1 defect(s) (OC-0277)

* fix(voice): 1 defect(s) (OC-0278)

* fix(client): 1 defect(s) (OC-0280)

refreshDmSidebar() rebuilds the entire DM sidebar subtree on every
dmStore.channels change - which includes presence flips and new
messages, not just DM list changes. The "Find a conversation" filter
text and input focus live only in that destroyed subtree, so they were
silently wiped mid-typing. Capture and restore both across the
destroy+recreate cycle.

* fix(ws): 1 defect(s) (OC-0285)

* fix(client): 1 defect(s) (OC-0286)

* fix(client): 1 defect(s) (OC-0288)

Consume the legacy unscoped mute key after migrating it onto the first
host, so a brand-new host with no scoped key of its own no longer reads
through to the same legacy list and inherits another server's mutes.

* fix(voice): 1 defect(s) (OC-0290)

* fix(db): 1 defect(s) (OC-0293)

DecrementMentionCounts reversed mention_count bumps that were never
applied: message_mentions stores every resolved mention id including the
author's blockers, while applyMentionCounts excludes blockers before
incrementing. Deleting a blocked author's message therefore wiped an
unrelated, genuine mention badge on the same read_states row. Mirror the
block exclusion in the decrement UPDATE.

* fix(db): 1 defect(s) (OC-0294)

DeleteAccount soft-deletes the departing user's messages but never reversed the read_states.mention_count bumps those messages made, leaving phantom mention badges. Reverse them inline in the existing transaction, mirroring DecrementMentionCounts' guards.

* fix(client): 1 defect(s) (OC-0295)

MemberList rebuilt every row on any non-presence-only membersStore change
and on every roles_update, but registered each row's click/contextmenu
listeners on the component-lifetime disposable.signal, which only aborts
at destroy(). Discarded rows therefore stayed reachable (and their
listeners live) for the component's whole lifetime. Route per-row
listeners through a per-render AbortController that is aborted and
replaced at the top of every render, and aborted again in destroy().

* fix(identity): 1 defect(s) (OC-0297)

UpdateProfile's post-commit re-read of the user row could fail for reasons
unrelated to context cancellation (SQLITE_BUSY, I/O error, pool exhaustion)
and was reported as ErrInternal even though UpdateUserProfile had already
committed. Callers that treat any UpdateProfile error as proof the write
never landed — handleUploadAvatar deletes the file it just stored — would
delete a file the committed avatar column now points at, permanently
breaking the avatar with no user_update broadcast.

Since UpdateUserProfile only writes username/avatar/display_name/about,
merge those four onto the pre-write snapshot to reconstruct the committed
row without needing the re-read to succeed, and log the read failure.

* fix(ws): 2 defect(s) (OC-0298, OC-0299)

- OC-0298: applyConnectStatus stamped c.user.Status even when the
  UpdateUserStatus write failed, so auth_ok and the presence broadcast
  claimed a status users.status disagreed with, and buildReady's
  ListMembers read never self-corrected for the session.
- OC-0299: refreshUserSnapshot silently fell back to roleName "member"
  when the new role lookup failed, pinning the session to a fabricated
  role on the wire. It now fails closed like the sibling lookups in
  upgradeAndAuth and handleFreshConnect.

* fix(client): 1 defect(s) (OC-0300)

* fix(client): 1 defect(s) (OC-0301)

* fix(ws): 1 defect(s) (OC-0302)

* fix(api): 1 defect(s) (OC-0305)

handleDiagnosticsConnectivity used clientIP(r), ignoring cfg.Server.TrustedProxies, so behind a configured trusted reverse proxy the endpoint reported the proxy hop instead of the real client address. Use clientIPWithProxies with the parsed trusted-proxy nets, matching RateLimitMiddleware on the same route.

* fix(client): 2 defect(s) (OC-0306, OC-0308)

* fix(client): 1 defect(s) (OC-0307)

QuickSwitcher registered a per-row click listener against the
overlay-lifetime AbortSignal, but renderResults() rebuilds every row on
each keystroke, arrow key, and store refresh. Discarded rows kept their
listeners alive until the overlay closed. Replaced with one delegated
click listener on the stable results container, keyed off the
data-channelid each row already carries.

* fix(client): 1 defect(s) (OC-0310)

* fix(server): 3 defect(s) (OC-0279, OC-0291, OC-0292)

Reap a soft-deleted message's attachment files, count lapsed temporary
bans as active users in the require_2fa enrollment gate, and only apply
the 2FA-enrollment precondition when require_2fa itself is being enabled.

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

* test(api): sync apiTestSchema with the user_blocks migration

DeleteAccount's mention-count reversal joins user_blocks; the api
package's hand-rolled schema fixture predates migration 012.

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

* fix(client): 3 defect(s) (OC-0281, OC-0282, OC-0296)

Decouple the E2EE identity-mismatch modal and right-click popovers from
the sidebar's per-render abort signal, and let global drag listeners
survive a mid-drag re-render.

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

* fix(voice): 2 defect(s) (OC-0283, OC-0287)

Retire a departed peer's E2EE key unconditionally on leave, and surface
a failed microphone unmute instead of reporting an unmuted state the
room never saw.

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

* fix(client): 3 defect(s) (OC-0289, OC-0303, OC-0309)

Guard the DM call button against redialing the channel already joined,
resolve the incoming-call banner's caller through the nickname-aware
display name, and keep the DM profile sidebar subscribed to live
member/status updates.

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

* style(client): prettier-format the dm-store test

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

* fix(server): 1 defect(s) (OC-0284)

Make message soft-delete a compare-and-set so a repeated chat_delete
cannot reverse mention counts twice.

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

* fix(server): 2 defect(s) (OC-0276, OC-0304)

Re-sync a resumed connection's voice E2EE peer keys in registerNow
(announce frames are unsequenced and cannot be replayed), and apply the
live-connection presence rule to every DM payload DMService builds.

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

* chore(ledger): record the 2026-08-21 hunt findings as fixed

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

* chore(ledger): independent revert-proof pass for OC-0276..OC-0310

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

* refactor(service): extract DeleteMessage authorization into a helper

Keeps DeleteMessage under the cyclop complexity ceiling after the
OC-0284 guard.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore(ledger): record the 38 open findings from the 2026-08-22 hunt (#1403)

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

Co-authored-by: Claude <noreply@anthropic.com>

* chore(graphify): refresh knowledge graph

* fix: close the three B0 P0 gates and record a measured baseline (#1409)

* chore(security): stop tracking the private security-finding reports

docs/security-findings/ holds detailed reports for defects that are not yet
fixed. The directory was untracked but not ignored, so any 'git add .' would
have published seven unfixed vulnerability traces to a public repository.

Findings are coordinated through private GitHub Security Advisories
(docs/security.md); only opaque identifiers and safe status belong in tracked
plans.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): repair the two red P0 unit contracts (G-01, G-02)

G-02: noise-suppression-restart stubbed MediaStream with
vi.fn().mockImplementation(arrow), which is not constructible. Vitest 4 threw
'is not a constructor' at the new MediaStream([inputTrack]) call in
noise-suppression.ts before reaching any assertion. Replaced with a real
class; the OC-0277 assertions are unchanged.

G-01: message-list's OC-0217 guard was inverted, not merely stale. It spied on
AbortSignal.prototype.addEventListener and asserted zero abort registrations,
but the leak it names registered row listeners via
element.addEventListener(..., { signal }) — a path that never calls that
prototype method. Measured: the leak produces 0 registrations (test passes),
while the OC-0286 fix rotates a per-window AbortSignal.any and produces 5
across 5 distinct signals (test fails). The guard passed on the bug and failed
on the fix.

It now captures the signal each window's row listeners register against and
asserts the invariant its name always claimed: one signal per rendered window,
a fresh signal per jump, and every superseded window already aborted with
exactly one live. Verified both directions — green on the fix, and
'expected 1 to be 5' with beginRowRender() reverted to rowSignal = ac.signal.

Client suite: 5257 passed, 0 failed (was 5255 passed, 2 failed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): make the Playwright suite terminate

The runner finished every test and then never exited, printing no summary — so
the failure read as 'tests never finish' when it was 'process never exits'.
getActiveResourcesInfo() at hang time showed a live ProcessWrap plus several
PipeWrap: the Vite dev server was still running. Playwright's webServer
teardown does not kill it here.

Measured, full suite each time:

  npm run dev                        hangs, tests pass
  node node_modules/vite/bin/vite.js hangs, tests pass
  reuseExistingServer: false         hangs, tests pass
  gracefulShutdown SIGTERM/3s        hangs, tests pass
  npx vite                           exits, 290 of 293 FAIL
  no webServer (pre-started)         exits, 293 pass in 33s

npx only appears to fix it: npx exits once Vite is up, Playwright reads that as
the server dying and tears the group down mid-run, so later tests get
ERR_CONNECTION_REFUSED.

globalTeardown now kills the process listening on the dev port, releasing the
runner's handle. The webServer command spawns Vite's entry point directly so
the listening process is Playwright's own child — via 'npm run dev' the npm
process would still hold the handle open. It also reaps servers orphaned by an
interrupted run, which reuseExistingServer would otherwise silently adopt.

An earlier revision used netstat, which is not on PATH in every shell here; the
swallowed ENOENT made the fix look applied while the hang persisted. It now
uses PowerShell on Windows and lsof elsewhere, and warns on failure rather than
failing silently.

npm run test:e2e: exit 0, 293 passed, 37s, reproducible, no orphan listener.
playwright.config.prod.ts carried the same npm-wrapper shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(client): align .nvmrc with the Node version CI uses

Three versions were in play, not two: .nvmrc said 20, CI pins 24, and the
machine the audit was measured on runs 26. A baseline measured against .nvmrc
is not the baseline CI produces, which defeats the point of B0.

Scoped to .nvmrc only. The full single-source-of-truth work — package engines,
contributor docs, release — stays in B1 (RL-17 / C-01).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): add the beta audit set and the B0 baseline

The 2026-08-23 audit set has been sitting untracked: repository-health and
repository-layout audits, beta product requirements, requirement traceability,
the issue register, and the B0-B10 roadmap. They are the plan of record for
beta and belong in the repository.

Adds b0-baseline-2026-08-25.md, which supersedes the roadmap's 'current
evidence snapshot'. Every row is marked measured or carried, so nothing is
inherited silently. It also records three audit claims that did not survive
verification:

  - G-01 was an inverted guard, not a stale assertion — it passed on the bug
    and failed on the fix.
  - The Playwright hang matched none of the three hypotheses; the runner could
    not kill its own dev server.
  - The golangci-lint toolchain failure is refuted: 19 linters run, 0 issues,
    verified with -v to rule out the known zero-linters false-green.

Adds b0-dev-branch-protection.sh, which records the applied dev branch
protection and the reasoning behind each setting.

Security detail stays private: the register carries only opaque SEC-* families
and safe closure criteria, per the roadmap's public/private handling policy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(graphify): refresh the knowledge graph

Own commit, per CLAUDE.md — the graph payload does not belong in the diff of
the changes that triggered it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): add the active-plan index and fix a stale status header (G-04)

Planning documents had no recorded state, so a reader could not tell current
guidance from shipped history. docs/plans/README.md now indexes every plan as
active, partially implemented, design-only, or shipped, and names the source of
truth for each concern so a defect count is never read out of a plan.

Status is recorded in the index rather than by moving or rewriting the
historical plans, so links from audits and commit messages keep resolving.

One real stale claim found and fixed: audit-2026-08-19-remediation.md still
read 'in progress 2026-08-19' while its own phase table showed phases 1-6 done
2026-08-20 (merged 03fcb7d5, PR #1396) with only phase 7 pending. The header
had drifted because the table was updated in place and the header was not.
No plan was found claiming '0 open findings'.

Also records the Step 8 staleness pass in the B0 baseline: all 38 open OC
records still resolve to a live file:line at this commit, so none is superseded
by later work. Adjudicating them individually is bughunt-fix work, not B0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Update graph output files and manifest with new metadata

- Updated graph.html and graph.json with new binary data.
- Modified manifest.json to reflect changes in file modification times and AST hashes for several documents.
- Added new entry for README.md in the manifest with its corresponding metadata.

* docs(plans): close the Docker and coverage leftovers in the B0 baseline

Docker smoke: measured and passing. Image builds at 50.1 MB and boots on :8443
with TLS; docker-smoke.sh exits 0.

Server coverage: re-measured at 74.6% aggregate, confirming the figure carried
from the audit rather than continuing to inherit it.

Two findings from doing it:

ENV-03 — docker-smoke.sh cannot be run from Git Bash on Windows. MSYS path
conversion rewrites the container-internal /chatserver into
'C:/Program Files/Git/chatserver', so docker exec fails 127 and the script
reports 'container never reported healthy within 30s' — indistinguishable from
a real boot regression. MSYS_NO_PATHCONV=1 makes the same script pass. CI is
Linux and unaffected, but Windows is an official contributor platform (RL-20).

The CI Docker job is gated on main, so it is skipped for any PR targeting dev
— a dev-targeted change cannot get Docker evidence from CI at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(graphify): refresh the knowledge graph

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): B1 execution plan, and accept HP-0 (#1410)

* docs(plans): add the B1 repository-foundation execution plan

B1 is the isolated layout/contributor phase. This records the execution
order, the proof for each step, and what is out of scope.

Two findings worth surfacing before any B1 work starts:

- HP-0 was never formally accepted. The roadmap's B1 entry gate requires
  it; no scorecard artifact exists, no commit or document records an
  acceptance, and the B0 baseline still lists "Step 10: HP-0 sign-off"
  under "Not yet done in B0". The plan lists the five gaps that closing
  it requires, including pinning required status checks on dev -- which
  are still unset, so a dev PR can currently merge red.

- Several layout-audit claims do not survive verification against HEAD,
  matching the B0 pattern. RL-09's "no single command verifies both
  protocol consumers" is false (make protocol-verify does, and is
  enforced in CI, the pre-commit hook, and a contract test). RL-10's
  test-discovery side effect never fires (no _test.go in Server/scripts).
  RL-06's regeneration concern is refuted locally. RL-08 grows a
  toolchain constraint instead. RL-05, RL-07, RL-20 and RL-21 are each
  worse than written -- RL-20 includes a live bug where a missing `make`
  is reported as stale protocol constants.

The riskiest item, RL-01 (flatten Client/tauri-client into Client), gets
a full reference inventory and a mechanical proof for both commits: tree-
object equality for the pure move, and scripted-substitution replay for
the path rewrite. Release asset names and updater contracts are verified
independent of the directory name, so the move cannot rename an artifact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): correct the B1 status-check pin list from a live dev PR

The list was derived from ci.yml. Observing PR #1410's actual checks
found three that exist in no workflow file -- Analyze (go),
Analyze (javascript-typescript), Analyze (actions) -- because CodeQL
runs from GitHub default setup, configured in repository settings.
Reading .github/ alone misses them.

Also confirms the two negative predictions against a real dev-targeted
PR: Server Docker Build (verify) reports as "skipping", and Tauri Full
Build never appears in the check list at all. Neither may be pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): accept HP-0 and pin the dev required status checks

Closes B1's entry gate. All five B1-0 items are done.

The scorecard is the artifact the hold point asks for: one place that
answers its four questions, records what was accepted as a stated
limitation rather than claimed green, and part-closes R-08.

Required status checks are now pinned on dev -- ten of them. That was
B0's one outstanding step. Two things came out of doing it:

- The names cannot be inferred from ci.yml. Three of the ten (the
  Analyze jobs) exist in no workflow file, because CodeQL runs from
  GitHub default setup configured in repository settings. They were read
  off a live dev-targeted PR with `gh pr checks`.
- Server Docker Build, Tauri Full Build and the CodeQL aggregate are
  deliberately excluded. The first two report "skipping" on a dev PR --
  Tauri Full Build under its unexpanded matrix name, since the job is
  skipped before matrix expansion. Admin Panel E2E is excluded because
  continue-on-error makes it report success unconditionally.

Two prior claims are corrected rather than left to propagate:

- b0-dev-branch-protection.sh was written assuming repository-settings
  writes are blocked from the agent sandbox. They are not; the PUT
  succeeded. The script stays as the record of intent and the way to
  re-apply or undo.
- An earlier revision of the B1 plan said Tauri Full Build does not
  appear in a dev PR's check list at all. It does, as skipping.

Evidence closed out:

- Rust is no longer a carried row. Re-measured: 115 passed, cargo clippy
  --all-targets -- -D warnings at exit 0, confirming the carried figure.
- The 38 open ledger records are accepted as counted, non-stale and
  assigned: 11 medium / 27 low, zero high or critical, zero dead paths
  across all 348 re-verified at this commit, and none assigned to B1.
- The private security review is reconciled: 7 findings, 7 of 7 mapped
  to existing public rows, 0 unmapped. Summary is content-free; the
  detail stays in the untracked private reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: flatten Client/tauri-client into Client (B1-1) (#1411)

* refactor: move Client/tauri-client to Client (pure move, no content change)

* refactor: re-point paths after the Client flatten (mechanical, no behaviour change)

---------

Co-authored-by: Claude <noreply@anthropic.com>

* B1-2: truth, entry points, and contributor path (#1412)

* fix(hooks): guard on the command the hook actually runs

pre-commit probed one binary and invoked another. The protocol block
guarded on `command -v go` and then ran `make protocol-verify`; the sqlc
block guarded on `command -v sqlc` and ran `make sqlc-verify`. `make` is
not on PATH on a stock Windows box, so a contributor with Go installed
but no make had their commit rejected with

    pre-commit: FAIL: protocol constants are stale — run 'make
    protocol-generate' in Server/ and stage the result

when nothing had been generated and nothing compared. The real cause was
`make: not found`, and the advice the message gives fails the same way.

Rather than add a `command -v make` guard, inline what the two Makefile
targets reduce to — `sqlc generate` / `go run ./scripts/genprotocol`
followed by `git diff --exit-code`. Same semantics, one less prerequisite,
and it doubles as the make-free equivalent B1-2 asks for. The Makefile
targets stay for anyone who prefers them.

Also: the protocol block was the only one with no `else`, so a
contributor without Go got no check and no notice. It now warns like its
two siblings. And gofmt is a separate binary from go, so it is probed
separately.

Verified both directions with the hook body replayed verbatim:
- Go present, make absent -> passes, no staleness claimed.
- schema edited without regenerating -> fails, as it must.

Refs RL-20 / L-14.

* docs: state one branch and PR model

Active documents contradicted each other head-on. README.md and
docs/contributing.md said branch from `dev` and target `dev`; CLAUDE.md
said branch from `main`, PR to `main`. That is R-02, and the 2026-08-19
audit had already recorded it as D-06 without it being resolved.

`dev` is the answer, and the repository already behaves that way: B0 made
`dev` PR-only with ten required checks enforced on admins, and #1409,
#1410 and #1411 all landed there. `main` carries releases.

docs/contributing.md becomes the single source of truth. It now states the
model, what protection is actually applied, and the two consequences a
contributor meets on their first PR — that a self-mergeable PR still cannot
merge red, and that Docker and Tauri Full Build report as skipped against
`dev` rather than failing. Everywhere else summarises and links here.

- CLAUDE.md: corrected, with a link rather than a second copy.
- CONTRIBUTING.md: new. GitHub's contributing-guidelines affordance only
  resolves the root, .github/ or docs/ — `docs/contributing.md` is not a
  path it finds, so the link never appeared on issues or PRs.
- PULL_REQUEST_TEMPLATE.md: names the base branch, which it did not.
- bughunt-run skill: reviewed the branch against `origin/main`, which is
  the wrong base once every PR targets `dev`.

README.md already said `dev` and is left as the short summary it should be.
Dated audits and the historical remediation plan keep their `main`-era
wording — they are records.

Refs R-02.

* fix(hooks): pick the pre-push base from the nearest integration branch

pre-push decided which side's gates to run from
`git diff --name-only origin/main...HEAD`. That was right when everything
targeted `main`. Once `dev` became the integration branch it stopped being
right: a branch cut from `dev` diffed against `main` counts everything on
`dev` and not yet on `main` as "changed".

Measured on this branch: the old base reported 609 changed files, the new
one reports 6. So in practice the hook was running the full server build
matrix and the client typecheck plus eslint on every push, whatever the
change touched — the file-based narrowing it exists for never engaged.

Now it picks whichever of origin/dev, origin/main is nearest, by commits
between merge-base and HEAD, skipping a candidate that scores 0. Verified:
a branch cut from dev picks origin/dev (2 ahead); dev itself scores 0
against dev and picks origin/main (8 ahead), which is what a dev -> main
release PR wants. With no candidate resolvable it falls back to the
existing `__all__`, so an unfetched or shallow clone still runs everything.

Refs RL-20 / L-14, R-02.

* chore(node): one Node source of truth

`.nvmrc` and all ten `actions/setup-node` pins said 24; five active
documents and the repo's only `engines` block still said 20. A contributor
following the docs installed a version CI does not run.

Node 24 wins — it is what CI already runs. Every manifest now declares
`engines`, and `engine-strict=true` turns a wrong major into a failed
install rather than an `EBADENGINE` warning nobody reads. `>=24` rather
than `^24` so a Node 26 box keeps working; `Client/.nvmrc` stays the
human-facing pin and the docs point at it instead of restating a number.

The `.npmrc` is per package root, not one at the top. npm reads the
project `.npmrc` from the package directory and does not walk parents —
verified with a throwaway package requiring node >=99: with only a parent
`.npmrc` npm warned and exited 0; with one in the package directory it
failed `notsup`. A single root file would have left `Client/`, the package
that matters most, on warnings.

Five docs, not the four previously identified — `docs/mcp-introspect.md`
also said 20. And `docs/contributing.md` claimed "`.nvmrc` + CI both say
Node 20", which was wrong about both.

Verified both directions in all three package roots: Node 22 fails
`notsup`; Node 24 installs clean and `npm ci` passes in Client/.

Refs RL-17 / C-01, ENV-01.

* docs: add the documentation landing page

`docs/` had 24 top-level files and no index. The root README carried a
flat list of 22 links that had drifted: six documents were reachable from
nowhere at all — including both 2026-08-23 audits and the test audit — and
two entries were labelled "latest" while newer unlinked audits existed.

docs/README.md is the index RL-12 asked for. It groups by what a document
*is*, because that is what decides whether to trust it: guidance tells you
how to do something, reference describes a contract the code implements,
audits are dated snapshots nobody updates, plans record intent. Every
tracked file under docs/ now appears exactly once, and the audit table says
plainly that audit-2026-08-19.md still claims "0 open findings" when the
ledger has 38.

The root README keeps a short curated list and defers to the index, rather
than maintaining a second copy that drifts again. Two fixes while there:
`docs/plans/` was linked as a bare directory, unlike its two sibling
directory entries, and was annotated "each carries a verified status
header" — which docs/plans/README.md:7-9 explicitly contradicts, since a
plan's header is exactly the thing that drifts and the index is the
authority.

Verified: 78 relative links across the new and edited files resolve, and
no tracked docs/ file is unreachable from the index.

Refs RL-12 / R-06.

* feat(scripts): root command facade

Entry points existed only inside Server/ (a Makefile) and Client/ (npm
scripts). Nothing at the root told a new contributor where to start, and
the root package.json had three scripts, none of which built or tested
anything.

`npm run check` from the root now runs what CI gates on, and
check:server / check:client / check:rust run one stack. scripts/run.mjs
is dependency-free Node — the shape render-ledger.mjs already uses — so
`npm run check` works before `npm install` has.

Cross-platform by construction: every step is spawned with an explicit cwd
and no shell, so there is nothing to quote and no `cd &&` to behave
differently on Windows. npm and npx get their .cmd suffix there. No step
shells out to make.

The facade orchestrates; it is not a new required path. Each step prints
the command and the directory before running it, and those are exactly the
commands documented per-stack — so a server contributor can read the output
and type them instead, and still never needs Node. Tools CI installs but a
contributor may not have (golangci-lint, which has no wrapper in this repo
at all; sqlc, pinned by Server/sqlc.version) are skipped with a printed
reason rather than failing.

Three corrections to the ci-check skill while aligning it:

- `make sqlc-verify protocol-verify` replaced by what those targets reduce
  to, so the documented path does not require make either.
- `cargo test` -> `cargo test --lib`, which is what ci.yml actually runs.
- "NODE_OPTIONS=--no-experimental-webstorage is mandatory on Node 22+" was
  false. tests/setup.ts installs the shim, CI runs Node 24 without the
  flag, and the suite was measured passing without it — 192 files / 5257
  tests, identical to the flagged run.

Also documents the third RL-20 problem, which needed no code: core.hooksPath
is exclusive, so `npm run hooks:install` silently disables any
.git/hooks/post-commit — including the one `graphify hook install` writes,
which CLAUDE.md tells agents to install. Nothing warned about that.

Verified: check:client 5257/192 green, check:rust 123 tests + clippy green,
--list prints every command, and the optional-tool skip path reports rather
than fails.

Refs RL-04 / L-04, RL-20 / L-14.

* feat(ci): fail on a document that contradicts the findings ledger

G-04's remaining half. The ledger is the source of truth for defect counts,
but nothing stopped a planning document from stating a different number and
nothing noticed when one did. `render-ledger.mjs --check` cannot help: it
validates the JSON schema and returns before rendering, so it never reads
FINDINGS.md and cannot see drift at all — and no workflow ran it anyway.

scripts/check-doc-counts.mjs counts ledger statuses and compares them to
what an allow-list of active documents claims, failing with file, line,
claimed value and actual. Wired into ci.yml as a job with no npm ci, since
the script imports nothing outside node:, and into the facade as
`npm run check:docs` — first in `check`, so a contradicted count does not
wait behind ten minutes of -race.

The patterns are narrow on purpose. A first attempt matched any
"<number> <status>" and flagged nineteen things, all false: "the 45 open P1
rows" (issue-register rows, not ledger findings), "All 8 findings F1-F8"
(a different register), "G-05 **refuted**" (an identifier), `">=20"` and
`CGO_ENABLED=0` (not counts at all). A check that cries wolf gets ignored,
which is the failure G-04 already describes. So a number is only read as a
claim in three shapes that cannot mean anything else: an enumeration of two
or more "<n> <status>" pairs, a status table row in a table that totals
itself, and "<n> records/findings" where the ledger is named within three
lines. Fifteen selftest assertions pin both directions, and the job runs
them before it runs the check.

It reads findings-ledger.json directly rather than importing
render-ledger.mjs for `validate`/`render`: that module ends in a bare
top-level `await main()` with no import.meta.main guard, so importing it
rewrites FINDINGS.md as a side effect.

Dated docs/audit-*.md are reported, never failed — they are snapshots
nobody maintains. audit-2026-08-19.md does claim zero open findings against
38 open, so b0-baseline's "No plan was found claiming '0 open findings'"
holds for docs/plans/ but not for docs/.

Not included: a real FINDINGS.md render-drift check. That is RL-07 and
belongs with the generated-artifact work, not here.

Verified: 27 claims across 9 documents agree; corrupting one count in
docs/plans/README.md fails the check naming that line, for both the status
and the total.

Refs G-04.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore: remove graphify knowledge graph tooling (#1413)

The committed knowledge graph and its PreToolUse hooks were steering every
codebase question through `graphify query` before any other tool could run.
Serena (gopls + rust-analyzer + tsserver over MCP) answers the same questions
from real language servers rather than a generated snapshot that goes stale
between rebuilds, so the graph no longer earns the ~20 MB it costs the tree.

Removed:
- `graphify-out/` untracked (7 files, ~20 MB) and now gitignored
- both `graphify hook-guard` PreToolUse hooks from `.claude/settings.json`
- the "Knowledge graph (graphify)" section of `CLAUDE.md`
- the `graphify-out/**` block from `.gitattributes` and `.gitignore`
- the graph-rebuild step from the `bughunt-run` skill, and the graph-edge
  guidance from the bughunt workflow prompt
- the graphify-specific `core.hooksPath` example in `ci-check` and
  `docs/contributing.md`, keeping the underlying warning in generic form

Also deletes the locally installed `post-commit` / `post-checkout` rebuild
hooks (untracked, not part of this diff).

This does not shrink clone size: the graph blobs stay in published history,
which `docs/plans/b1-repository-foundation-2026-08-25.md` explicitly rules out
rewriting. It does stop future refreshes from adding more.

Dated audit and plan documents keep their graphify references as a historical
record of the state they described.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* B1-3: repository hygiene gates (RL-19 / L-13, S-05) (#1414)

* chore(format): one Prettier config at the repository root

Every formatting rule in this repository lived under Client/ and covered
exactly two globs: Client/src/**/*.ts and Client/tests/**/*.ts. Root Markdown,
all of docs/, every YAML and JSON, all CSS, the root scripts and
tools/mcp-introspect were formatted by nothing. There was no .editorconfig.

The obvious fix -- a second Prettier config at the root for "everything else"
-- gives two configs and two ignore files that can silently disagree about the
same file. So the root takes ownership instead: config, ignore file and gate
move up, and Client/ folds in. Client's inline "prettier" block, its
.prettierignore, its format/format:check scripts and its now-unused prettier
devDependency are all deleted; knip would have failed client-check on that last
one.

The .prettierrc.json values are lifted byte-for-byte from Client/package.json,
which is what keeps the reformat commit free of client TypeScript churn: 87
tracked files need reformatting and not one of them is under Client/src or
Client/tests.

.prettierignore carries only what .gitignore does not. Prettier 3 reads the
root .gitignore by default, so node_modules/, dist/, coverage/,
Client/src/generated/ and docs/security-findings/ need no entry. It does NOT
read nested .gitignore files, which is why .remember/ is listed explicitly --
38 untracked per-machine scratch files were otherwise able to turn a shared
gate red. graphify-out/ is listed because its seven files are tracked and
.graphify_labels.json is signed byte-for-byte by its .sig, so formatting it
would silently invalidate the signature.

check:hygiene is registered in scripts/run.mjs and folded into check and
release:preflight. It deliberately contains no `gofmt -l` step: gofmt -l prints
offenders and still exits 0, so it cannot fail a build. Go formatting is
enforced separately.

shellcheck and actionlint take their file lists from `git ls-files`, never a
filesystem glob -- .claude/worktrees/ holds a gitignored pre-flatten copy of
the tree with three .sh files a glob would happily lint.

This commit leaves the tree non-conformant on purpose. The reformat is the next
commit, so the 87-file diff is reviewable separately from the rule that caused
it.

Not included: editorconfig-checker. .editorconfig is the editor baseline the
audit asked for; Prettier, gofmt and rustfmt already fail CI on the same
indentation and newline rules, so a fourth tool checking them again is a gate
with no failure mode of its own.

Verified: `npx prettier --check .` names 87 tracked files and zero untracked
ones; the same command listed 38 .remember/ scratch files before the ignore
entry and none after. `node scripts/run.mjs --list` resolves check:hygiene to 8
shell targets and 4 workflow targets. Both package.json files parse.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): reformat the tree to the repository Prettier rules

Mechanical. This commit is `npx prettier --write .` and nothing else -- the
rule that caused it landed in the previous commit so this diff can be reviewed
as a transformation rather than as 84 files of hunks.

84 tracked files: 54 Markdown, 7 .mjs, 7 JSON, 6 YAML, 4 .js, 3 CSS, 2
TypeScript (the two Playwright configs at Client's root, which the old
Client/src + Client/tests globs never covered). No file under Client/src or
Client/tests moves, because .prettierrc.json carries Client's former inline
values byte-for-byte.

Prettier rewrote 87 files, not 84. The three in .github/ISSUE_TEMPLATE/ had
CRLF on disk and differ only in line endings, which .gitattributes
(`* text=auto eol=lf`) already normalises, so their committed blobs are
unchanged. Worth knowing before someone reconciles the two numbers.

The largest single diff is .superpowers/findings-ledger.json at 7976 lines
rewritten. That is safe to format: nothing writes the ledger programmatically
-- render-ledger.mjs reads it and writes only FINDINGS.md -- so no tool will
fight Prettier over its style on the next hunt. FINDINGS.md itself is ignored
as generated.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style", so the pass is both complete and idempotent. All 7 reformatted JSON
files were parsed before and after and compared as values: semantically
identical, zero content changes. `node .superpowers/render-ledger.mjs --check`
still reports 348 valid findings and leaves FINDINGS.md untouched.
`node scripts/check-doc-counts.mjs` still passes its selftest and still agrees
on 27 claims across 9 watched documents -- table realignment did not break the
patterns it matches on. `node scripts/run.mjs --list` still parses.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(lint): enforce Go formatting in the Server linter

S-05: repository-wide Go formatting was not a required gate. The only gofmt
enforcement anywhere was .githooks/pre-commit, which is opt-in per clone
(`npm run hooks:install`), only sees staged files, and warns-and-skips when
gofmt is off PATH.

The obvious fix -- a `gofmt -l` step in CI -- does not work: `gofmt -l` prints
its offenders and still exits 0, so the step passes no matter what it finds.
scripts/run.mjs has the same problem, which is why check:hygiene has no Go step
either.

So gofmt goes where it can actually fail something: Server/.golangci.yml. The
file was already `version: "2"` but had no `formatters:` block at all, so the
19 enabled linters ran with zero formatters. In v2 gofmt/gofumpt/goimports
moved out of `linters.enable` into their own section with its own exclusions.
Adding it there means the gate reports through the Lint step of "Server Build &
Test", which is already pinned as required on dev -- no new job and no new pin.
Every tracked .go file is under Server/ (551 of them, one go.mod), so
Server-scoped is repository-wide here.

One file was genuinely misformatted: a one-space struct field alignment in
Server/admin/handlers_users_broadcast_test.go, fixed in the same commit because
a single line does not need its own reformat commit.

Trap worth recording: `gofmt -l .` on a Windows working tree lists every file
that has CRLF on disk, because gofmt normalises line endings. That reported 18
offenders here, 17 of them ghosts. The blobs are all LF -- .gitattributes
forces `eol=lf` -- so CI never saw them, and the honest test is to run gofmt
over `git show HEAD:<file>` rather than the working copy. Doing that across all
551 tracked Go files found exactly the one real offender above.

Verified both directions with golangci-lint v2 locally: `golangci-lint run
./...` reports 0 issues on the formatted tree; appending a misformatted
function to Server/auth/constants.go produces 2 gofmt findings; appending the
same misformatted function to Server/db/dbgen/admin.sql.go produces 0, so the
exclusion holds. Both files restored and verified clean afterwards.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): escape the NUL separator instead of embedding one

The `tracked()` helper added earlier in this branch splits `git ls-files -z`
output on NUL. The separator was written as a literal NUL byte rather than the
two-character JavaScript escape, so scripts/run.mjs became a binary file: `git
diff` refused to show it, `grep` reported "Binary file matches" instead of the
line, and `* text=auto` in .gitattributes stops normalising line endings for a
blob it detects as binary.

The code worked -- splitting on a raw NUL and splitting on "\0" are the same
operation -- which is exactly why this is worth fixing before it is inherited.
A source file that tooling classifies as binary is a file nobody can review.

Verified: zero NUL bytes remain, `grep -n "split("` now prints line 50 instead
of "Binary file matches", `node scripts/run.mjs --list` still resolves the same
8 shell and 4 workflow targets, and prettier still reports the file clean.

* chore(lint): enforce Rust formatting

Rust had no formatting gate of any kind: no rustfmt.toml, no `cargo fmt`
anywhere in CI, in scripts/run.mjs, in the Makefile or in the git hooks. Clippy
was the only Rust gate, and clippy does not check layout.

`cargo fmt --all -- --check` now runs in the rust-tests job, ahead of clippy: a
formatting failure is cheap to produce and cheap to fix, and there is no reason
to spend a clippy pass to surface one. The stable toolchain in that job
requested `components: clippy` only, so rustfmt is added there.

Only that job. ci.yml has a second, byte-identical `Install Rust` block in
tauri-build; it stays clippy-only, because a full desktop build is the wrong
place to discover a misplaced brace.

No rustfmt.toml. The default profile is the point of a baseline -- a config
file here would be a second opinion about style with nothing to say.
Client/src-tauri is a single `[package]`, not a workspace, so `--all` is a
safeguard against a future member rather than a fan-out today.

Verified: `node scripts/run.mjs --list` resolves check:rust to three steps with
`cargo fmt --all -- --check` first, `npm run format` now also runs `cargo fmt
--all`, and prettier reports ci.yml, run.mjs and the ci-check skill clean.
`cargo fmt --all -- --check` currently fails on 13 files -- that is the
reformat, and it is the next commit.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): reformat the Rust crate to rustfmt defaults

Mechanical. This commit is `cargo fmt --all` and nothing else; the gate that
demands it landed in the previous commit so this diff is reviewable on its own.

13 of the 17 tracked .rs files, +343/-164. The crate had never been formatted,
so the changes are the usual first-run set: aligned trailing comments collapsed
to single spaces, single-element slice literals folded onto one line, long
method chains broken across lines, closure bodies expanded into blocks.

Verified: `cargo fmt --all -- --check` is clean, so the pass is complete and
idempotent. `cargo clippy --all-targets -- -D warnings` finishes with no
warnings, and `cargo test --lib` reports 115 passed / 0 failed -- identical to
before the reformat, which is what "mechanical" has to mean for a commit that
touches this much of the crate.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): make the root facade actually run on Windows

Adding the first gate that a contributor would run from the repository root
exposed two bugs in the facade, both of which made it silently wrong on the
platform this project is developed on.

1. Every npm and npx step failed. bin() appends `.cmd` on Windows, but Node
   refuses to spawn a .cmd or .bat with shell:false -- the CVE-2024-27980
   mitigation -- and fails with EINVAL and a *null* exit status. run.mjs only
   special-cased ENOENT, so the result was `FAILED: npx prettier --check .
   exited null` with nothing to explain it. check:client has three npm steps and
   has never been able to run here.

   Fixed by spawning only the npm shims through a shell. They are concatenated
   into a single command string rather than passed as an args array, because
   shell:true plus a separate array is deprecated (DEP0190) and prints a warning
   on every invocation; no argument in this file contains a space.

2. Every optional() step was skipped, always. onPath() shelled out to
   `where` on Windows, but where.exe lives in C:\WINDOWS\System32, which a Git
   Bash PATH does not necessarily contain -- on this machine PATH carries
   System32\Wbem, System32\WindowsPowerShell\v1.0 and System32\OpenSSH but not
   System32 itself. The probe could not start, `probe.status === 0` was false,
   and golangci-lint and sqlc reported as "not installed" while installed.

   Fixed by resolving against PATH and PATHEXT directly. No subprocess, and no
   dependency on which directories happen to be on PATH.

A spawn error other than ENOENT now reports its code instead of surfacing as a
null exit status.

Verified: before, `node scripts/run.mjs check:hygiene` died with "exited null"
and both optional steps printed SKIP with the tools present on PATH. After, the
same command runs prettier, shellcheck and actionlint and prints
"check:hygiene: passed", with no deprecation warning. `golangci-lint` is
detected by the new onPath where the old one missed it.

Refs RL-20 / L-14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): ignore build output that nested gitignores hide

Prettier honours the root .gitignore and no other. Every build and scratch
directory in this repository is ignored by a *nested* one -- Client/.gitignore,
.serena/.gitignore, .superpowers/sdd/.gitignore -- so none of them were
excluded from the new repository-wide gate.

The effect is not subtle. Running `cargo test` once drops roughly 850
formattable files into Client/src-tauri/target/, and the hygiene gate goes from
clean to "Code style issues found in 939 files". CI never sees it, because a
fresh checkout has no build output; every contributor sees it on their second
command.

Mirrors the three nested files rather than inventing a list: dist, coverage,
playwright-report, test-results, .vite, src-tauri/target and src-tauri/gen from
Client/.gitignore, plus .serena/ and .superpowers/sdd/. node_modules needs no
entry -- Prettier ignores it by default.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style" with a fully populated Client/src-tauri/target/ present on disk, and
still names README.md when a misformatted table is appended to it.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(ci): shellcheck, actionlint, and a repository hygiene job

The last two gates RL-19 asks for. Neither existed: the shell scripts were
never linted, the workflows were never syntax-checked, and .githooks/pre-commit
carried hand-written `# shellcheck disable=` directives that nothing had ever
read.

New `hygiene` job, ubuntu-only and root-scoped, modelled on docs-consistency
for the same reason: every gate in it is platform-independent text analysis,
and .gitattributes pins eol=lf so a second OS would only re-prove line endings.
It runs `npm run check:hygiene` -- the same entry point a contributor runs, not
a parallel copy of the commands.

shellcheck ships in the runner image. actionlint does not, so it is pinned by
version and verified by sha256: an installer script piped from a branch would
be the one unverified download in a workflow file that pins every action by
commit SHA.

Prettier's step moves here from client-check, where it no longer belongs.

Both linters found real defects.

shellcheck, 3 findings in 8 scripts. Two are SC1125 errors in
.githooks/pre-commit: `# shellcheck disable=SC2086 - repo paths contain no
spaces` is not a valid directive. Trailing prose makes shellcheck discard the
rest of the line, so neither suppression was ever in effect -- and one of the
two was written earlier in this same branch, which is a fair demonstration of
why the gate is worth having. The prose moves to its own line above. The third
is SC2015 in start-server.sh, rewritten as an explicit if.

actionlint, 5 findings, all inside `run:` blocks it shellchecks once shellcheck
is on PATH. Three SC2015 in load-baseline.yml, rewritten as explicit ifs. Two
SC2035 in release.yml, where `sha256sum *` should not become `sha256sum ./*`:
the comment four lines above records that ParseChecksumFile exact-matches the
last field, so a "./" prefix would strand every deployed server exactly as a
"windows/" prefix would. `sha256sum -- *` satisfies the linter and leaves the
output bytes identical.

Verified all three gates in both directions with shellcheck 0.10.0 and
actionlint 1.7.7 on PATH. Passing: `node scripts/run.mjs check:hygiene` prints
"check:hygiene: passed" with all three steps run, not skipped. Failing:
appending `bait_fn() { cat $1; }` to Server/scripts/voice-test.sh fails on
SC2086; changing a runs-on to `ubunt-latest` fails on runner-label; appending a
misformatted table to README.md fails prettier. All three files restored and
confirmed clean afterwards. actionlint validates the new job in ci.yml itself.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): record B1 progress through B1-3

The header still read "B1-0 is complete; B1-1 is the next step" three merged
phases later. A plan that misstates where it is costs a reader the same
confusion whether it is stale by one phase or three.

B1-0 (#1410), B1-1 (#1411), B1-2 (#1412) and B1-3 (this branch) are done; B1-4,
dependency automation, is next.

Verified: `node scripts/check-doc-counts.mjs` still agrees on 27 claims across
9 watched documents -- this file is one of them -- and prettier reports it
clean.

* chore(ci): pin Repository Hygiene as a required check on dev

The second half of S-05. Its acceptance criterion is "tree is formatted AND a
fast required gate fails future drift" -- a check that runs but is not pinned
lets a formatting regression merge, so the gate is not a gate until this lands.

The name was read off PR #1414 with `gh pr checks` after the job reported
`pass` in 26s, not copied out of ci.yml. That order matters: the B0 script
records that three pinned names exist in no workflow file at all, and that a
required check which never reports blocks every PR forever.

Extends the existing script rather than adding a second one, per the B1 plan.

Also records, in the "deliberately NOT pinned" list, that Docs & Ledger
Consistency reports and passes on a dev PR yet is unpinned. That reads as an
oversight from the 2026-08-25 pass rather than a decision, but it belongs to
G-04, so it is documented here and not changed.

NOT APPLIED YET. Running this script now would pin a check that PR #1413 cannot
report -- its branch predates the hygiene job, so the job does not exist in its
workflow file and the check would never arrive. Run it after #1414 merges;
#1413 needs a rebase onto dev regardless.

Verified: shellcheck clean, the embedded JSON parses, and `check:hygiene`
passes with prettier, shellcheck and actionlint all running.

Refs S-05, RL-14 / G-03.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* B1-4: dependency automation (RL-05 / L-05, RL-18) (#1415)

* chore(deps): cover the root and mcp-introspect npm roots

The repository has three npm package roots — `/` (changelogen, prettier),
`/Client`, and `/tools/mcp-introspect` (@modelcontextprotocol/sdk, zod) —
each with its own package-lock.json, and `npm run bootstrap` runs `npm ci`
in all three. Dependabot watched exactly one of them. The root's prettier is
what the Repository Hygiene gate runs, so the formatting gate's own toolchain
was drifting unwatched.

The obvious fix is to collapse the three roots into an npm workspace and
watch one lockfile. Measured on npm 11.17 / Node 26 rather than assumed, that
trade is bad, and it is bad for different reasons than expected. Workspaces do
not break the things you would predict: `npm ci` inside `Client/` still exits
0, `npm run <script>` still resolves the hoisted binaries because npm prepends
every ancestor `node_modules/.bin` to PATH, and `engine-strict` still fails
the install on a wrong Node major. What they cost is ten CI steps keyed on
`cache-dependency-path: Client/package-lock.json` (six in ci.yml, four in the
tag-only, CI-ungated release.yml) pointing at a file that stops existing; the
Repository Hygiene job's deliberate root-only install growing 970 ms to
6172 ms and 39 to 318 packages unless every call site remembers
`--workspaces=false`; and one shared lockfile putting all three npm Dependabot
groups back into the same file, which is precisely the rebase storm the
grouping comment at the top of dependabot.yml exists to prevent. The measured
benefit is one 298 KB lockfile instead of three (17 KB / 253 KB / 42 KB) and
614 resolved packages deduped to 582 — 32 packages, 5.2% — with client install
time unchanged at 5642 ms against 5667 ms.

So the roots stay separate and each gets its own block, matching the four that
already exist: grouped to one PR, majors ignored, weekly on Monday. A single
block with `directories:` was rejected for the same reason as workspaces —
grouping only works while a group rewrites exactly one lockfile. The decision
and its numbers are recorded in docs/contributing.md under Dependency Policy,
so the next person to propose workspaces reads the measurement instead of
repeating it.

Verified: a coverage checker cross-references every `package-ecosystem` /
`directory` pair in dependabot.yml against every manifest in `git ls-files`,
in both directions. Against dev at 2a37f386 it reports `UNWATCHED npm
package.json` and `UNWATCHED npm tools/mcp-introspect/package.json` (plus
Server/Dockerfile, which the next commit covers). Against this commit both npm
rows read `ok`, and the forward direction confirms each newly declared
directory really holds a package.json. `npx prettier --check` reports both
edited files unchanged, so the B1-3 formatting gate stays green.

Not included: the docker ecosystem (next commit, RL-18); immutable image
digests for release/runtime containers, which is R-04's remaining half and
belongs to B6; and turning the coverage checker into a permanent
`check:hygiene` gate — a new manifest root can still drift unwatched, which is
how this gap arose, but that is new gate machinery rather than the coverage
this item asks for.

Refs RL-05 / L-05

* chore(deps): watch the server container base images

Server/Dockerfile pulls `golang:1.26-bookworm` to build and
`gcr.io/distroless/static-debian12` to run, and nothing watched either. Every
other dependency root in the repository is on a weekly Dependabot schedule, so
the one artefact that ships to users as a whole filesystem was the only one
whose upstream moved silently — including its CA certificates, which the
Dockerfile comment specifically calls out as the reason distroless was chosen
over scratch.

The obvious fix is to pin both images by digest and be done. That is the wrong
move here for two reasons. A digest pin with no automation behind it is worse
than a tag: it freezes the base image at whatever was current the day someone
typed it, and a frozen distroless base is a frozen CA bundle. And digest
refresh for release and runtime images is R-04's other half, scoped to B6
alongside the smoke tests that have to gate it — landing half of it here would
leave the digests pinned and the refresh unowned.

So this adds the `docker` ecosystem for /Server on the same terms as the five
blocks around it: grouped to one PR, majors ignored, weekly on Monday. Be
precise about what that actually buys, because it is less than the block
implies. Of the two images only `golang:1.26-bookworm` carries a comparable
version, so it is the only one Dependabot can act on today;
`gcr.io/distroless/static-debian12` has no version tag, and an untagged image
is not something a version update can move — it needs the digest pinning that
B6 owns. The comment above the block records that the Go builder tag tracks
Server/go.mod and every `actions/setup-go` in CI, so a `1.27-bookworm` PR is a
prompt to move all three together rather than a standalone merge.

Verified: the coverage checker cross-references every `package-ecosystem` /
`directory` pair against every manifest in `git ls-files`, in both directions.
Against dev at 2a37f386 the reverse direction reports `UNWATCHED docker
Server/Dockerfile`; against this commit every row reads `ok` and it exits 0 —
`ALL ROOTS WATCHED`, six blocks covering six manifest roots, with the forward
direction confirming /Server really holds a Dockerfile. `npx prettier --check`
passes on the edited file.

Not included: Server/docker-compose.yml and Server/docker-compose.otel.yml.
Their four images are `ghcr.io/j3vb/owncord-server:latest` (this repository's
own published image), `livekit/livekit-server:v1` (a floating major tag, and
majors are ignored everywhere), `jaegertracing/all-in-one:latest` and
`prom/prometheus:latest` — none of which a version update can move, so a
compose block would be configuration that provably produces nothing. Also not
included: immutable digests plus digest-refresh PRs with smoke tests for the
release and runtime images, which is the remainder of R-04 and belongs to B6.

Refs RL-18

* docs: apply skill-review findings to ci-check …

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 08:18:24 +02:00
J3vbandClaude Opus 5 e58df6802e fix(release): publish only this tag's changelog section as the release body (#1431)
`gh release create --notes-file CHANGELOG.md` hands GitHub the entire
file. v1.2.0-alpha.4 shipped with all 795 lines as its description: every
past release back to alpha.1, plus the "How to write an entry" style
guide, which is addressed to contributors and has no business on a
download page. A reader looking for what changed had to scroll past
three prior releases to find it.

Slice the section for the tag being published instead, and rewrite its
relative `docs/` links to absolute ones — they resolve against the
repository, so on a release page they 404 for every reader.

Fail closed when the section is missing. A published release with an
empty description has already been fetched by the time anyone notices;
a failed run can be re-run once the entry is written.

Also drop the `changelogen --output CHANGELOG.md` step and the `npm ci`
that fed it. It ran after the tag existed, so its from-tag and to-tag
were the same commit: it appended an empty `## <tag>...<tag>` heading
whose compare link pointed at itself. Nothing else consumed its output.
`npm run changelog` still exists for drafting an entry locally, before
tagging, which is where generating one is actually useful.

Verified against the committed CHANGELOG.md: alpha.4 yields the 73-line
curated section (3115 bytes, down from 49775), alpha.1 matches through
its titled heading, a bare `v1.2.0` correctly matches nothing rather
than swallowing alpha.4, and a missing section exits 1.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 07:53:40 +02:00
J3vbandClaude Opus 5 dbdb287651 chore(deps): point Dependabot at dev instead of the default branch (#1430)
Every block omitted `target-branch`, so Dependabot defaulted to `main` and
opened all seven ecosystems against the release branch. That contradicts the
branch model in CLAUDE.md and docs/contributing.md, where `dev` is the
integration branch and the only branch that takes PRs.

Retargeting by hand does not hold: `@dependabot rebase` recreates the PR
against the *configured* target, silently reverting the base back to `main`.
The config is the only durable place to fix it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 05:36:05 +00:00
64d2e1087b chore: merge main into dev to unblock the alpha.4 release PR (#1425)
* ci(deps): bump anthropics/claude-code-action (#1404)

Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.193 to 1.0.199
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/9d7150bc8a3dae8149739a88019d192b579ad90c...dcb57747bfceeaa1fa72638cae52295d1d853d4a)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.199
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): pin rfd to tauri-plugin-dialog's major to unblock the cargo group (#1406)

The cargo-dependencies group PR (#1405) fails Rust Unit Tests on Linux:

    error: failed to run custom build command for `rfd v0.17.2`
    You need to choose at least one backend: `gtk3` or `xdg-portal`
    features for x86_64-linux

rfd is not really ours. It arrives in the tree via tauri-plugin-dialog,
which pins ^0.16; we declare it directly only for the fatal-startup
message box in lib.rs, where the Tauri app never finished building and
the plugin has no AppHandle to run a dialog through.

Cargo unifies features only within a semver-compatible version group, so
while both wanted ^0.16 there was a single rfd in the graph and the
plugin's backend features covered our `default-features = false`
declaration too. Bumping our direct dep to 0.17 forks rfd into two
crates: the plugin keeps 0.16.0 with its features, ours resolves to
0.17.2 with none, and rfd 0.17 added a build.rs assertion that aborts
the Linux build when no backend feature is set. Confirmed in the PR's
lockfile, which carries both 0.16.0 and 0.17.2.

Adding a Linux backend feature would be the wrong fix: it would paper
over the fork and still build rfd twice on every platform for one error
dialog. Our version has to track the plugin's instead, so ignore
semver-minor rfd updates (0.16 -> 0.17 for a 0.x crate) until
tauri-plugin-dialog moves. Patch updates inside 0.16.x still flow.

The remaining five crates in the group are unaffected; `windows` in fact
consolidates 3 versions down to 2.

Cargo.toml is comment-only here - no dependency, feature, or lockfile
change - so the build is untouched.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): bump log (#1407)

Bumps the cargo-dependencies group with 1 update in the /Client/tauri-client/src-tauri directory: [log](https://github.com/rust-lang/log).


Updates `log` from 0.4.33 to 0.4.34
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.33...0.4.34)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump anthropics/claude-code-action (#1408)

Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.199 to 1.0.200
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/dcb57747bfceeaa1fa72638cae52295d1d853d4a...24dcd50c0568f0fc9e9211213a4fd2d9eb15c4e0)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.200
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix(client): strip tags to a fixpoint inside sanitizePassApprox

CodeQL alert 17 (js/incomplete-multi-character-sanitization, high) fires on
the single-pass `input.replace(/<[^>]*>/g, "")`: a lone replace can in
principle splice a fresh `<...>` out of the text either side of what it
removed. echoNormalize already loops sanitizePassApprox to a fixpoint, so
that was absorbed one level up and the output is unchanged -- but the
repetition is now where a reader (and the query) can see it.

sanitizePassApprox is a comparison normalizer, never rendered output: its
only consumer is the `===` echo match in isUnreconciledEcho. Not a
sanitization boundary, so this is a legibility fix, not a security one.

Client suite 5257/5257, tsc, lint, hygiene all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): put the strip-tags replace inside the loop body

The previous form hoisted the `replace` into the `for` header's init
expression, so CodeQL still reported it (alert 18, line 217 col 21) --
js/incomplete-multi-character-sanitization only credits a repeated
replacement when the call sits in the loop *body*, which is also the shape
the rule's own guidance shows.

Same fixpoint, same output; `while (out.includes("<"))` gives the loop a
real condition instead of `for (;;)`.

Client suite 5257/5257, tsc, lint, prettier green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 20:12:52 +00:00
J3vbandClaude Opus 5 4e7e4b12ac release: v1.2.0-alpha.4 (#1423)
Bumps the client version across every pin verify-versions enforces
(package.json, tauri.conf.json, Cargo.toml) plus the two lockfiles that carry
it, and the user-facing build examples in README.md, docs/deployment.md,
docs/quick-start.md, docs/api.md and the issue-form placeholders.

Deliberately NOT bumped: the v1.2.0-alpha.3 references in ci.yml,
release.yml and docker-smoke.sh, which record the release that published from
a red commit and are the reason the gate-evidence job exists; and the string in
scripts/check-doc-counts.mjs, which is a selftest fixture asserting a version
number is not read as a ledger claim. Rewriting either would falsify a record.

CHANGELOG's Unreleased section becomes v1.2.0-alpha.4.

Verified rather than assumed:
- npm ci exits 0, so package-lock.json still matches package.json.
- cargo metadata --locked exits 0, so Cargo.lock needs no regeneration.
- The verify-versions comparison was run locally against tag v1.2.0-alpha.4:
  all three sources agree, so the tag will not be rejected.
- npm run check passes end to end, exit 0.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:37:40 +02:00
J3vbandClaude Opus 5 9d46161cca docs: make the changelog a scannable list, and write down the rule (#1422)
The changelog had drifted into walls of text — v1.2.0-alpha.3's entry is a
handful of paragraphs where a single bullet runs eleven lines and names the
function that owned the bug. An operator cannot tell in ten seconds whether any
of it bit them, which is the only job this file has.

Adds a "How to write an entry" section to CHANGELOG.md as the rule: lead with
what is user-visible and what is not, group by an area a user recognises rather
than by subsystem or PR, one line per fix, say what was broken then what it does
now, plain language over symbol names, no OC-* ids or file paths, counts in a
summary line rather than on every bullet. Repository work that changes nothing
observable gets at most a short block at the end. Shipped entries are left
alone as history; the rule starts from the next release.

Rewrites Unreleased to follow it, which also closes a real gap: that section
documented B0/B1 repository plumbing and omitted all 62 operator-visible bug
fixes from #1400 and #1402. Exactly backwards — the invisible half was written
up and the half users would notice was not. A release cut from dev today would
have shipped a changelog that mentioned a directory rename and not "banned users
could still connect".

docs/contributing.md's PR process now points at the rule, since that is where a
contributor decides whether their change needs an entry.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:47:16 +00:00