Files
OwnCord/Server/invariants/db_import_boundary.go
T
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

141 lines
9.7 KiB
Go

package invariants
import (
"go/ast"
"go/token"
"strings"
)
// dbImportBoundaryID is the rule's stable id (a const for the same
// initialization-cycle reason as syncutilLocksID).
const dbImportBoundaryID = "db-import-boundary"
// dbImportPath is the persistence package every rule here is about.
const dbImportPath = "github.com/J3vb/OwnCord/Server/db"
// DBImportEntry is one row of the B3-0 boundary inventory: why a production
// file above the domain layer is allowed to import db, and where B3-8 sends
// it. Dispositions are the layout-refactor supplement's four:
//
// - move: persistence or domain decisions that belong behind a service;
// Family names the service that takes them.
// - adapter: a transport adapter that uses db types or pure helpers only
// (response shapes, status helpers) — no persistence calls.
// - boundary: an explicit composition or transaction boundary (the process
// entry, a CLI, health probing) that legitimately owns a handle.
// - remove: the import is unnecessary and goes.
//
// docs/architecture/server-boundaries.md is generated from this map by
// `go run ./cmd/dbinventory`; edit here, then regenerate.
type DBImportEntry struct {
Disposition string
Family string
Note string
}
// DBImportAllow is the inventory. A production file outside db/ and service/
// that imports db and is not listed here fails db-import-boundary; a listed
// file that stops importing db fails TestDBImportAllowIsLive. B3-2 and B3-8
// delete rows as families move — the list only shrinks.
var DBImportAllow = map[string]DBImportEntry{
// ── admin ─────────────────────────────────────────────────────────────
"admin/admin.go": {"boundary", "", "holds the handle for the admin mux; no calls"},
"admin/api.go": {"boundary", "", "passes the handle to handlers; no calls"},
"admin/backup_maintenance.go": {"boundary", "", "scheduled backup mechanics on the maintenance tick; settings via the service"},
"admin/handlers_backup.go": {"boundary", "", "backup create/list/delete/restore owns the handle: VACUUM INTO, WAL checkpoint, close-and-swap"},
"admin/handlers_channel_perms.go": {"move", "channel", "override CRUD decides permission policy in the handler"},
"admin/handlers_channels.go": {"move", "channel", "channel CRUD + audit"},
"admin/handlers_roles.go": {"move", "role", "two reads; service/role.go already owns the writes"},
"admin/handlers_tokens.go": {"move", "auth", "API-token CRUD duplicated in token_cli.go"},
"admin/handlers_users.go": {"move", "user", "user list, stats, lookups"},
"admin/helpers.go": {"adapter", "", "Role/User types in response helpers"},
"admin/logstream.go": {"boundary", "", "handle threaded to the SSE stream's auth check; no calls"},
"admin/middleware.go": {"move", "auth", "owner gate re-reads the role — OC-0345"},
"admin/setup_handler.go": {"move", "auth", "first-run owner creation (setup sub-family)"},
"admin/setup_wizard.go": {"move", "auth", "BeginTx for the wizard; setup sub-family"},
"admin/types.go": {"adapter", "", "response DTOs; the one GetRoleByID moves with handlers_users"},
// ── api ───────────────────────────────────────────────────────────────
"api/channel_handler.go": {"adapter", "", "response types only; service owns the calls"},
"api/dm_handler.go": {"adapter", "", "DM response types + pure status helpers"},
"api/emoji_handler.go": {"adapter", "", "Emoji/User types only"},
"api/gif_handler.go": {"adapter", "", "handle in the signature, unused for calls"},
"api/invite_handler.go": {"adapter", "", "Invite/User types only"},
"api/middleware.go": {"move", "auth", "session/API-token touch and revoke"},
"api/plugins_handler.go": {"adapter", "", "db.Auditor is the seam; WriteAudit only"},
"api/profile_handler.go": {"move", "upload", "avatar upload creates the attachment row"},
"api/router.go": {"boundary", "", "health probe (PingRead, SQLDb); hub construction left in B3-3"},
"api/upload_handler.go": {"move", "upload", "attachment access + a raw QueryRowContext"},
// ── auth ──────────────────────────────────────────────────────────────
"auth/helpers.go": {"adapter", "", "db.User type in a helper signature"},
"auth/resolve.go": {"adapter", "", "Session/APIToken/Role/User types; resolution is injected"},
// ── composition roots and tools ───────────────────────────────────────
// B3-3 moved the process composition root out of main.go: internal/app
// owns the handle from open to close, and main.go no longer imports db.
"internal/app/app.go": {"boundary", "", "the App holds the handle for its lifetime; no calls"},
"internal/app/database.go": {"boundary", "", "opens the handle, migrates, clears stale state at boot"},
"internal/app/hub.go": {"boundary", "", "hands the handle to the hub and the service layer it builds"},
"internal/app/maintenance.go": {"boundary", "", "periodic worker: expired sessions, backups, orphan attachments"},
"internal/app/persistence.go": {"boundary", "", "event persister, audit writer and the boot seq seed own the handle"},
"internal/app/plugins.go": {"boundary", "", "passes the handle to the plugin registry as its store; no calls"},
"token_cli.go": {"move", "auth", "API-token CLI duplicates admin/handlers_tokens.go"},
"cmd/seed/main.go": {"boundary", "", "developer seeding tool owns its handle"},
"cmd/seed/profile_alpha.go": {"boundary", "", "the alpha profile writes through the handle main.go owns"},
"cmd/gendocs/main.go": {"boundary", "", "docs generator migrates its own in-memory catalog"},
"plugin/pluginstore.go": {"adapter", "", "PluginRow type only; the store is injected"},
// ── ws ────────────────────────────────────────────────────────────────
"ws/client.go": {"adapter", "", "db.User type on the connection"},
"ws/deps.go": {"move", "channel", "role and DM-membership reads behind the hub's deps"},
"ws/event.go": {"adapter", "", "pure BroadcastStatus helper"},
"ws/event_persister.go": {"adapter", "", "PersistedEvent type; store is an interface"},
"ws/eventstore.go": {"adapter", "", "PersistedEvent type; store is an interface"},
"ws/handlers.go": {"move", "channel", "channel, role, session-ban and DM reads in command handlers"},
"ws/handlers_chat.go": {"adapter", "", "pure NewDMChannelInfo helper"},
"ws/hub.go": {"boundary", "", "Hub state holds the handle the families read through; no calls"},
"ws/hub_options.go": {"boundary", "", "construction validates and stores the handle; no calls"},
"ws/hub_broadcast.go": {"move", "channel", "member broadcast payloads read the user and role they announce"},
"ws/hub_presence.go": {"adapter", "", "presence coalescer; pure BroadcastStatus helper and the MemberSummary shape"},
"ws/hub_visibility.go": {"move", "channel", "visibility and audience resolution reads channels, overrides, participants, users"},
"ws/hub_sweep.go": {"move", "voice", "stale-voice sweep reads and leaves"},
"ws/messages.go": {"adapter", "", "wire types + pure status helpers"},
"ws/replay.go": {"move", "connection", "reconnect replay selection and delivery; serve.go's row split with its code in B3-5"},
"ws/serve.go": {"move", "connection", "connect/disconnect lifecycle; B3-5 splits it by family first"},
"ws/serve_auth.go": {"move", "auth", "handshake auth: session, user and role lookups, connect audit, failed-handshake teardown"},
"ws/serve_pumps.go": {"move", "user", "MarkUserDisconnected on pump exit"},
"ws/serve_ready.go": {"move", "channel", "ready snapshot and fresh-connect: channels, overrides, unreads, DMs, members, stale-voice cleanup"},
"ws/voice_join.go": {"move", "voice", "voice state reads and writes"},
"ws/voice_moderation.go": {"move", "voice", "mute/deafen/move persist voice state"},
}
// dbImportBoundary fails on any production file above the domain layer that
// imports db without an inventory row. db/ and service/ are the layers that
// may import it; everything else must be in DBImportAllow, which is the B3-0
// inventory (docs/architecture/server-boundaries.md is generated from it).
var dbImportBoundary = Rule{
ID: dbImportBoundaryID,
Scope: nil, // every directory; the layers that may import are excluded in Check
Check: checkDBImportBoundary,
}
func checkDBImportBoundary(f *ast.File, fset *token.FileSet, rel string) []Violation {
if strings.HasPrefix(rel, "db/") || strings.HasPrefix(rel, "service/") {
return nil
}
if _, listed := DBImportAllow[rel]; listed {
return nil
}
for _, imp := range f.Imports {
if strings.Trim(imp.Path.Value, `"`) != dbImportPath {
continue
}
return []Violation{{
Rule: dbImportBoundaryID,
File: rel,
Line: fset.Position(imp.Pos()).Line,
Msg: "imports Server/db above the domain layer without an inventory row; " +
"route the call through a service (see docs/architecture/server-boundaries.md), " +
"or add a DBImportAllow entry with a disposition and reason",
}}
}
return nil
}