mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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.
This commit is contained in:
+7
-1
@@ -11,7 +11,9 @@ prometheus.
|
|||||||
- `db/` hand-written query wrappers; `db/dbgen/` is generated (see `db-change`)
|
- `db/` hand-written query wrappers; `db/dbgen/` is generated (see `db-change`)
|
||||||
- `cmd/` executable tooling, one `package main` per subdirectory —
|
- `cmd/` executable tooling, one `package main` per subdirectory —
|
||||||
`cmd/genprotocol/` regenerates the protocol constants from `protocol/schema.json`,
|
`cmd/genprotocol/` regenerates the protocol constants from `protocol/schema.json`,
|
||||||
`cmd/seed/` fills a dev database (`go run ./cmd/seed -confirm-dev`).
|
`cmd/seed/` fills a dev database (`go run ./cmd/seed -confirm-dev`),
|
||||||
|
`cmd/dbinventory/` prints the `db`-importer table for
|
||||||
|
`docs/architecture/server-boundaries.md` (exits 1 on an unlisted importer).
|
||||||
`scripts/` holds shell/JS tooling only; no Go entry point lives there
|
`scripts/` holds shell/JS tooling only; no Go entry point lives there
|
||||||
- `admin/` web admin panel · `updater/` self-update + signature verification ·
|
- `admin/` web admin panel · `updater/` self-update + signature verification ·
|
||||||
`plugin/` WASM plugin runtime (`-tags wazero`) · `telemetry/` OTel (`-tags otel`)
|
`plugin/` WASM plugin runtime (`-tags wazero`) · `telemetry/` OTel (`-tags otel`)
|
||||||
@@ -34,3 +36,7 @@ prometheus.
|
|||||||
- Prefer the standard library. `syncutil` exists so lock usage is uniform and
|
- Prefer the standard library. `syncutil` exists so lock usage is uniform and
|
||||||
detectable; do not hand-roll around it. `Server/invariants/` enforces this
|
detectable; do not hand-roll around it. `Server/invariants/` enforces this
|
||||||
at `go test` time; exceptions are greppable via `grep -rn "invariant:allow" Server/`.
|
at `go test` time; exceptions are greppable via `grep -rn "invariant:allow" Server/`.
|
||||||
|
- Only `db/` and `service/` import `db` freely. Any other production file that
|
||||||
|
imports it needs a row in `invariants/db_import_boundary.go` (`DBImportAllow`)
|
||||||
|
with a disposition and reason — the B3 inventory, which only shrinks. New
|
||||||
|
persistence goes behind a service, not into a handler.
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
// Command dbinventory lists every production Go file above the domain layer
|
||||||
|
// that imports the db package, and what it uses it for: db.* types, db.*
|
||||||
|
// package functions and sentinels, and method calls on a *db.DB value.
|
||||||
|
//
|
||||||
|
// It is the measurement behind docs/architecture/server-boundaries.md (B3-0)
|
||||||
|
// and prints a Markdown table so the document can be regenerated:
|
||||||
|
//
|
||||||
|
// cd Server && go run ./cmd/dbinventory
|
||||||
|
//
|
||||||
|
// The analysis is syntactic (go/parser + go/ast, no type information), like
|
||||||
|
// Server/invariants: a *db.DB method call is recognised when the receiver is
|
||||||
|
// an identifier declared with type *db.DB in the same file (parameter, result,
|
||||||
|
// var, or a name assigned from db.Open*), or a selector whose final field is
|
||||||
|
// declared *db.DB anywhere in the same package (h.db.X, s.deps.DB.X). That
|
||||||
|
// covers every shape in the tree today; a new shape shows up as a file with
|
||||||
|
// an import and no recorded use, which is itself a row worth reading.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"io/fs"
|
||||||
|
"maps"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/J3vb/OwnCord/Server/invariants"
|
||||||
|
)
|
||||||
|
|
||||||
|
const dbImportPath = "github.com/J3vb/OwnCord/Server/db"
|
||||||
|
|
||||||
|
// layerDirs are the top-level packages that may import db freely and are
|
||||||
|
// therefore not inventoried. Matched on the root-relative path, so a nested
|
||||||
|
// directory that happens to share a name (api/service/) is still inventoried
|
||||||
|
// — the same rule db-import-boundary applies.
|
||||||
|
var layerDirs = map[string]bool{"db": true, "service": true}
|
||||||
|
|
||||||
|
// skipNames are never code at any depth: fixtures and vendored JS.
|
||||||
|
var skipNames = map[string]bool{"testdata": true, "node_modules": true}
|
||||||
|
|
||||||
|
type kind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
kindType kind = iota
|
||||||
|
kindFunc
|
||||||
|
kindValue // var or const, e.g. sentinel errors
|
||||||
|
)
|
||||||
|
|
||||||
|
type fileUse struct {
|
||||||
|
rel string
|
||||||
|
types map[string]int
|
||||||
|
funcs map[string]int
|
||||||
|
values map[string]int
|
||||||
|
methods map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
root := flag.String("root", ".", "Server module root")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
dbKinds, err := declKinds(fset, filepath.Join(*root, "db"))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := productionFiles(*root)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 1: parse everything, collect struct fields typed *db.DB per package.
|
||||||
|
parsed := map[string]*ast.File{}
|
||||||
|
fieldsByPkg := map[string]map[string]bool{}
|
||||||
|
for _, rel := range files {
|
||||||
|
f, err := parser.ParseFile(fset, filepath.Join(*root, rel), nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
parsed[rel] = f
|
||||||
|
alias := dbAlias(f)
|
||||||
|
if alias == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dir := path.Dir(rel)
|
||||||
|
if fieldsByPkg[dir] == nil {
|
||||||
|
fieldsByPkg[dir] = map[string]bool{}
|
||||||
|
}
|
||||||
|
for name := range dbDBFields(f, alias) {
|
||||||
|
fieldsByPkg[dir][name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: per-file uses.
|
||||||
|
var rows []fileUse
|
||||||
|
for _, rel := range files {
|
||||||
|
f := parsed[rel]
|
||||||
|
alias := dbAlias(f)
|
||||||
|
if alias == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows = append(rows, analyze(f, rel, alias, dbKinds, fieldsByPkg[path.Dir(rel)]))
|
||||||
|
}
|
||||||
|
sort.Slice(rows, func(i, j int) bool { return rows[i].rel < rows[j].rel })
|
||||||
|
printTable(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// productionFiles returns slash-separated .go paths under root, excluding
|
||||||
|
// tests and skipDirs, sorted.
|
||||||
|
func productionFiles(root string) ([]string, error) {
|
||||||
|
var out []string
|
||||||
|
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
if p == root {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if layerDirs[filepath.ToSlash(rel)] || skipNames[d.Name()] || strings.HasPrefix(d.Name(), ".") {
|
||||||
|
return fs.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = append(out, filepath.ToSlash(rel))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
sort.Strings(out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// declKinds parses the db package's production files and maps every exported
|
||||||
|
// top-level name to its kind, so a db.X selector can be classified exactly.
|
||||||
|
func declKinds(fset *token.FileSet, dir string) (map[string]kind, error) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
kinds := map[string]kind{}
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing %s: %w", name, err)
|
||||||
|
}
|
||||||
|
for _, decl := range f.Decls {
|
||||||
|
switch d := decl.(type) {
|
||||||
|
case *ast.FuncDecl:
|
||||||
|
if d.Recv == nil && d.Name.IsExported() {
|
||||||
|
kinds[d.Name.Name] = kindFunc
|
||||||
|
}
|
||||||
|
case *ast.GenDecl:
|
||||||
|
for _, spec := range d.Specs {
|
||||||
|
switch s := spec.(type) {
|
||||||
|
case *ast.TypeSpec:
|
||||||
|
if s.Name.IsExported() {
|
||||||
|
kinds[s.Name.Name] = kindType
|
||||||
|
}
|
||||||
|
case *ast.ValueSpec:
|
||||||
|
for _, n := range s.Names {
|
||||||
|
if n.IsExported() {
|
||||||
|
kinds[n.Name] = kindValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kinds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dbAlias returns the local name the file imports the db package under, or
|
||||||
|
// "" if it does not import it.
|
||||||
|
func dbAlias(f *ast.File) string {
|
||||||
|
for _, imp := range f.Imports {
|
||||||
|
if strings.Trim(imp.Path.Value, `"`) != dbImportPath {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if imp.Name != nil {
|
||||||
|
return imp.Name.Name
|
||||||
|
}
|
||||||
|
return "db"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDBPtr reports whether expr is *<alias>.DB.
|
||||||
|
func isDBPtr(expr ast.Expr, alias string) bool {
|
||||||
|
star, ok := expr.(*ast.StarExpr)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
sel, ok := star.X.(*ast.SelectorExpr)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
x, ok := sel.X.(*ast.Ident)
|
||||||
|
return ok && x.Name == alias && sel.Sel.Name == "DB"
|
||||||
|
}
|
||||||
|
|
||||||
|
// dbDBFields returns the names of struct fields typed *db.DB in the file.
|
||||||
|
func dbDBFields(f *ast.File, alias string) map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
st, ok := n.(*ast.StructType)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, fld := range st.Fields.List {
|
||||||
|
if isDBPtr(fld.Type, alias) {
|
||||||
|
for _, name := range fld.Names {
|
||||||
|
out[name.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyze(f *ast.File, rel, alias string, dbKinds map[string]kind, dbFields map[string]bool) fileUse {
|
||||||
|
u := fileUse{rel: rel, types: map[string]int{}, funcs: map[string]int{}, values: map[string]int{}, methods: map[string]int{}}
|
||||||
|
dbVars := collectDBVars(f, alias)
|
||||||
|
countMethodCalls(f, dbVars, dbFields, u.methods)
|
||||||
|
classifySelectors(f, alias, dbKinds, &u)
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// pkgSelector returns the selector's field name when expr is <alias>.X.
|
||||||
|
func pkgSelector(expr ast.Expr, alias string) (string, bool) {
|
||||||
|
sel, ok := expr.(*ast.SelectorExpr)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
pkg, ok := sel.X.(*ast.Ident)
|
||||||
|
if !ok || pkg.Name != alias {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return sel.Sel.Name, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectDBVars returns identifiers declared with type *db.DB: params,
|
||||||
|
// results, struct fields, vars, and names assigned from a db.Open* call.
|
||||||
|
func collectDBVars(f *ast.File, alias string) map[string]bool {
|
||||||
|
dbVars := map[string]bool{}
|
||||||
|
add := func(names []*ast.Ident) {
|
||||||
|
for _, n := range names {
|
||||||
|
dbVars[n.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
switch x := n.(type) {
|
||||||
|
case *ast.Field:
|
||||||
|
if isDBPtr(x.Type, alias) {
|
||||||
|
add(x.Names)
|
||||||
|
}
|
||||||
|
case *ast.ValueSpec:
|
||||||
|
if x.Type != nil && isDBPtr(x.Type, alias) {
|
||||||
|
add(x.Names)
|
||||||
|
}
|
||||||
|
case *ast.AssignStmt:
|
||||||
|
for i, rhs := range x.Rhs {
|
||||||
|
if openAssign(rhs, alias) && i < len(x.Lhs) {
|
||||||
|
if id, ok := x.Lhs[i].(*ast.Ident); ok {
|
||||||
|
dbVars[id.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return dbVars
|
||||||
|
}
|
||||||
|
|
||||||
|
// openAssign reports whether expr is a call to <alias>.Open*(...).
|
||||||
|
func openAssign(expr ast.Expr, alias string) bool {
|
||||||
|
call, ok := expr.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
name, ok := pkgSelector(call.Fun, alias)
|
||||||
|
return ok && strings.HasPrefix(name, "Open")
|
||||||
|
}
|
||||||
|
|
||||||
|
// countMethodCalls tallies calls whose receiver is a *db.DB identifier or a
|
||||||
|
// selector ending in a *db.DB struct field.
|
||||||
|
func countMethodCalls(f *ast.File, dbVars, dbFields map[string]bool, methods map[string]int) {
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
call, ok := n.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch x := sel.X.(type) {
|
||||||
|
case *ast.Ident:
|
||||||
|
if dbVars[x.Name] {
|
||||||
|
methods[sel.Sel.Name]++
|
||||||
|
}
|
||||||
|
case *ast.SelectorExpr:
|
||||||
|
if dbFields[x.Sel.Name] {
|
||||||
|
methods[sel.Sel.Name]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// classifySelectors buckets every <alias>.X selector by what db declares X as.
|
||||||
|
func classifySelectors(f *ast.File, alias string, dbKinds map[string]kind, u *fileUse) {
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
expr, ok := n.(ast.Expr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
name, ok := pkgSelector(expr, alias)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch k, known := dbKinds[name]; {
|
||||||
|
case !known:
|
||||||
|
u.values["?"+name]++
|
||||||
|
case k == kindType:
|
||||||
|
u.types[name]++
|
||||||
|
case k == kindFunc:
|
||||||
|
u.funcs[name]++
|
||||||
|
default:
|
||||||
|
u.values[name]++
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func joined(m map[string]int) string {
|
||||||
|
if len(m) == 0 {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
parts := make([]string, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
if m[k] > 1 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s×%d", k, m[k]))
|
||||||
|
} else {
|
||||||
|
parts = append(parts, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "`" + strings.Join(parts, "` `") + "`"
|
||||||
|
}
|
||||||
|
|
||||||
|
func sum(m map[string]int) int {
|
||||||
|
n := 0
|
||||||
|
for _, v := range m {
|
||||||
|
n += v
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func printTable(rows []fileUse) {
|
||||||
|
byPkg := map[string]int{}
|
||||||
|
byDisposition := map[string]int{}
|
||||||
|
byFamily := map[string]int{}
|
||||||
|
typeOnly, unlisted := 0, 0
|
||||||
|
fmt.Println("| File | `db.*` types | `db.*` funcs and sentinels | `*db.DB` method calls | Shape | Disposition | Family | Why |")
|
||||||
|
fmt.Println("| --- | --- | --- | --- | --- | --- | --- | --- |")
|
||||||
|
for _, r := range rows {
|
||||||
|
byPkg[path.Dir(r.rel)]++
|
||||||
|
shape := "calls"
|
||||||
|
if sum(r.funcs)+sum(r.values)+sum(r.methods) == 0 {
|
||||||
|
shape = "type-only"
|
||||||
|
typeOnly++
|
||||||
|
}
|
||||||
|
entry, listed := invariants.DBImportAllow[r.rel]
|
||||||
|
if !listed {
|
||||||
|
unlisted++
|
||||||
|
entry = invariants.DBImportEntry{Disposition: "**UNLISTED**", Note: "fails db-import-boundary"}
|
||||||
|
}
|
||||||
|
byDisposition[entry.Disposition]++
|
||||||
|
if entry.Family != "" {
|
||||||
|
byFamily[entry.Family]++
|
||||||
|
}
|
||||||
|
family := entry.Family
|
||||||
|
if family == "" {
|
||||||
|
family = "—"
|
||||||
|
}
|
||||||
|
fmt.Printf("| `%s` | %s | %s | %s | %s | %s | %s | %s |\n",
|
||||||
|
r.rel, joined(r.types), mergeFV(r), joined(r.methods), shape, entry.Disposition, family, entry.Note)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n%d files import `db` outside `db/` and `service/` (%s); %d are type-only; %d unlisted.\n",
|
||||||
|
len(rows), countList(byPkg), typeOnly, unlisted)
|
||||||
|
fmt.Printf("Dispositions: %s. Move targets: %s.\n", countList(byDisposition), countList(byFamily))
|
||||||
|
stale := 0
|
||||||
|
present := map[string]bool{}
|
||||||
|
for _, r := range rows {
|
||||||
|
present[r.rel] = true
|
||||||
|
}
|
||||||
|
for rel := range invariants.DBImportAllow {
|
||||||
|
if !present[rel] {
|
||||||
|
stale++
|
||||||
|
fmt.Printf("STALE allowlist row (file no longer imports db): `%s`\n", rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if unlisted > 0 || stale > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countList renders a count map as "a 1, b 2", keys sorted.
|
||||||
|
func countList(m map[string]int) string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
parts := make([]string, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s %d", k, m[k]))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeFV(r fileUse) string {
|
||||||
|
m := make(map[string]int, len(r.funcs)+len(r.values))
|
||||||
|
for k, v := range r.funcs {
|
||||||
|
m[k+"()"] = v
|
||||||
|
}
|
||||||
|
maps.Copy(m, r.values)
|
||||||
|
return joined(m)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestProductionFilesExemptsOnlyTopLevelLayers pins the walker's exemption to
|
||||||
|
// the root-relative path: Server/db and Server/service are the layers that
|
||||||
|
// may import db; a nested directory that shares a name (api/service/) is
|
||||||
|
// production code above the domain layer and must be inventoried. Tests,
|
||||||
|
// testdata and hidden directories are skipped at any depth.
|
||||||
|
func TestProductionFilesExemptsOnlyTopLevelLayers(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
for _, p := range []string{
|
||||||
|
"api/w.go",
|
||||||
|
"api/w_test.go",
|
||||||
|
"api/service/x.go",
|
||||||
|
"api/db/y.go",
|
||||||
|
"service/y.go",
|
||||||
|
"db/z.go",
|
||||||
|
"db/dbgen/q.go",
|
||||||
|
"ws/testdata/fixture.go",
|
||||||
|
".hidden/h.go",
|
||||||
|
"main.go",
|
||||||
|
} {
|
||||||
|
full := filepath.Join(root, filepath.FromSlash(p))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(full, []byte("package x\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err := productionFiles(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{"api/db/y.go", "api/service/x.go", "api/w.go", "main.go"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("productionFiles = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
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": {"move", "settings-ops", "BackupToSafe, integrity check, settings reads"},
|
||||||
|
"admin/handlers_backup.go": {"move", "settings-ops", "backup trigger and download; raw SQLDb for VACUUM INTO"},
|
||||||
|
"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_settings.go": {"move", "settings-ops", "BeginTx in a handler; TOTP census"},
|
||||||
|
"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/auth_handler.go": {"move", "auth", "B3-2 slice: register/login/logout/delete own the DB"},
|
||||||
|
"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 leaves in B3-3"},
|
||||||
|
"api/totp_handler.go": {"move", "auth", "B3-2 slice: TOTP enrol/verify write the user row"},
|
||||||
|
"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 ───────────────────────────────────────
|
||||||
|
"main.go": {"boundary", "", "process composition root; B3-3 moves it to internal/app"},
|
||||||
|
"token_cli.go": {"move", "auth", "API-token CLI duplicates admin/handlers_tokens.go"},
|
||||||
|
"cmd/seed/main.go": {"boundary", "", "developer seeding tool owns its handle"},
|
||||||
|
"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": {"move", "settings-ops", "GetSetting at construction"},
|
||||||
|
"ws/hub_broadcast.go": {"move", "channel", "visibility refresh reads channels, roles, users"},
|
||||||
|
"ws/hub_sweep.go": {"move", "voice", "stale-voice sweep reads and leaves"},
|
||||||
|
"ws/messages.go": {"adapter", "", "wire types + pure status helpers"},
|
||||||
|
"ws/serve.go": {"move", "connection", "connect/disconnect lifecycle; B3-5 splits it by family first"},
|
||||||
|
"ws/serve_auth.go": {"move", "auth", "session lookup on the WebSocket handshake"},
|
||||||
|
"ws/serve_pumps.go": {"move", "user", "MarkUserDisconnected on pump exit"},
|
||||||
|
"ws/serve_ready.go": {"move", "channel", "ready snapshot: channels, overrides, unreads, DMs, members"},
|
||||||
|
"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
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package invariants
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDBImportBoundary(t *testing.T) {
|
||||||
|
const importDB = `import "github.com/J3vb/OwnCord/Server/db"`
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
src string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unlisted api file importing db is flagged",
|
||||||
|
path: "api/brand_new_handler.go",
|
||||||
|
src: "package api\n" + importDB + "\nvar _ *db.DB\n",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "aliased import is still flagged",
|
||||||
|
path: "ws/brand_new.go",
|
||||||
|
src: "package ws\nimport store \"github.com/J3vb/OwnCord/Server/db\"\nvar _ *store.DB\n",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "listed file is allowed",
|
||||||
|
path: "api/auth_handler.go",
|
||||||
|
src: "package api\n" + importDB + "\nvar _ *db.DB\n",
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "service may import db",
|
||||||
|
path: "service/anything.go",
|
||||||
|
src: "package service\n" + importDB + "\nvar _ *db.DB\n",
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "db itself is out of scope",
|
||||||
|
path: "db/anything.go",
|
||||||
|
src: "package db\n" + importDB + "\n",
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unlisted file without the import is clean",
|
||||||
|
path: "api/brand_new_handler.go",
|
||||||
|
src: "package api\nimport \"net/http\"\nvar _ http.Handler\n",
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a sibling module path is not the db package",
|
||||||
|
path: "api/brand_new_handler.go",
|
||||||
|
src: "package api\nimport \"github.com/J3vb/OwnCord/Server/db/dbgen\"\nvar _ dbgen.Queries\n",
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
got := checkSourceWith([]Rule{dbImportBoundary}, fset, tt.path, []byte(tt.src))
|
||||||
|
if len(got) != tt.want {
|
||||||
|
t.Fatalf("want %d violation(s), got %d: %v", tt.want, len(got), got)
|
||||||
|
}
|
||||||
|
for _, v := range got {
|
||||||
|
if v.Rule != dbImportBoundaryID {
|
||||||
|
t.Errorf("rule id = %q, want %q", v.Rule, dbImportBoundaryID)
|
||||||
|
}
|
||||||
|
if !strings.Contains(v.Msg, "server-boundaries.md") {
|
||||||
|
t.Errorf("message must point at the inventory document: %q", v.Msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDBImportAllowIsLive keeps the inventory honest in the other direction:
|
||||||
|
// every allowlisted path must exist and still import db. A row for a file
|
||||||
|
// that moved behind a service (or was renamed) is stale and must be deleted —
|
||||||
|
// the list only shrinks.
|
||||||
|
func TestDBImportAllowIsLive(t *testing.T) {
|
||||||
|
for rel, entry := range DBImportAllow {
|
||||||
|
p := filepath.Join("..", filepath.FromSlash(rel))
|
||||||
|
src, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("DBImportAllow[%q]: %v — delete the row", rel, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(src), `"`+dbImportPath+`"`) {
|
||||||
|
t.Errorf("DBImportAllow[%q] no longer imports db — delete the row", rel)
|
||||||
|
}
|
||||||
|
switch entry.Disposition {
|
||||||
|
case "move":
|
||||||
|
if entry.Family == "" {
|
||||||
|
t.Errorf("DBImportAllow[%q]: a move needs a target family", rel)
|
||||||
|
}
|
||||||
|
case "adapter", "boundary", "remove":
|
||||||
|
if entry.Family != "" {
|
||||||
|
t.Errorf("DBImportAllow[%q]: %s rows carry no family", rel, entry.Disposition)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Errorf("DBImportAllow[%q]: unknown disposition %q", rel, entry.Disposition)
|
||||||
|
}
|
||||||
|
if entry.Note == "" {
|
||||||
|
t.Errorf("DBImportAllow[%q]: the reason is mandatory", rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ func (r Rule) inScope(dir string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rules is the registry every gate runs.
|
// Rules is the registry every gate runs.
|
||||||
var Rules = []Rule{syncutilLocks}
|
var Rules = []Rule{syncutilLocks, dbImportBoundary}
|
||||||
|
|
||||||
// allowPrefix introduces a line-scoped suppression:
|
// allowPrefix introduces a line-scoped suppression:
|
||||||
//
|
//
|
||||||
|
|||||||
+12
-11
@@ -11,17 +11,18 @@ natively) followed by a prose explanation and a **Source of truth** file list.
|
|||||||
|
|
||||||
## Index
|
## Index
|
||||||
|
|
||||||
| Doc | Diagrams | Covers |
|
| Doc | Diagrams | Covers |
|
||||||
| ---------------------------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| [system-overview.md](system-overview.md) | D1 System context, D8 Deployment topology | All processes, trust boundaries, ports, single-instance constraints |
|
| [system-overview.md](system-overview.md) | D1 System context, D8 Deployment topology | All processes, trust boundaries, ports, single-instance constraints |
|
||||||
| [server.md](server.md) | D2 Server package map, D3 REST request lifecycle | Go package structure, DB-access styles, middleware chain |
|
| [server.md](server.md) | D2 Server package map, D3 REST request lifecycle | Go package structure, DB-access styles, middleware chain |
|
||||||
| [websocket.md](websocket.md) | D4 WS connect / replay / dispatch | Real-time engine: auth handshake, 3-tier reconnect replay, backpressure, typed dispatch |
|
| [websocket.md](websocket.md) | D4 WS connect / replay / dispatch | Real-time engine: auth handshake, 3-tier reconnect replay, backpressure, typed dispatch |
|
||||||
| [data-model.md](data-model.md) | D5 Entity-relationship overview | All 26 tables from migrations 001–028, grouped by domain |
|
| [data-model.md](data-model.md) | D5 Entity-relationship overview | All 26 tables from migrations 001–028, grouped by domain |
|
||||||
| [voice-e2ee.md](voice-e2ee.md) | D6 Voice + E2EE flow | LiveKit token flow, loopback TLS tunnel, ECDH key-holder relay |
|
| [voice-e2ee.md](voice-e2ee.md) | D6 Voice + E2EE flow | LiveKit token flow, loopback TLS tunnel, ECDH key-holder relay |
|
||||||
| [client.md](client.md) | D7 Client module map | Tauri client: bootstrap, dispatcher, stores, Rust sidecars (structure, as-built) |
|
| [client.md](client.md) | D7 Client module map | Tauri client: bootstrap, dispatcher, stores, Rust sidecars (structure, as-built) |
|
||||||
| [ux/](ux/README.md) | UX flow + state diagrams | Client **behavior** spec (target state): what every view does and how it reacts to events, permissions, and failure |
|
| [ux/](ux/README.md) | UX flow + state diagrams | Client **behavior** spec (target state): what every view does and how it reacts to events, permissions, and failure |
|
||||||
| [platform-contracts.md](platform-contracts.md) | — | Desktop/browser **seam** (target state): where native dependencies will be isolated, and the three that have no browser equivalent |
|
| [platform-contracts.md](platform-contracts.md) | — | Desktop/browser **seam** (target state): where native dependencies will be isolated, and the three that have no browser equivalent |
|
||||||
| [plugins.md](plugins.md) | — | Experimental WASM plugin boundary: off twice and compiled out of releases, no API promise, post-beta candidates, core that never moves |
|
| [server-boundaries.md](server-boundaries.md) | — | B3-0 inventory: every file above the domain layer that imports `db`, with a disposition and target family; hub setters, locks and the start/stop defer stack; the auth slice's before-graph. Generated table, enforced by `db-import-boundary` |
|
||||||
|
| [plugins.md](plugins.md) | — | Experimental WASM plugin boundary: off twice and compiled out of releases, no API promise, post-beta candidates, core that never moves |
|
||||||
|
|
||||||
### Structure vs. behavior
|
### Structure vs. behavior
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# Server boundaries — database-call and lifecycle inventory
|
||||||
|
|
||||||
|
**Written:** 2026-08-29 (B3-0), measured at `dev` `ad4defc2`.
|
||||||
|
**Owner:** the B3 plan,
|
||||||
|
[plans/b3-server-architecture-guardrails-2026-08-29.md](../plans/b3-server-architecture-guardrails-2026-08-29.md).
|
||||||
|
**Regenerate the first table:** `cd Server && go run ./cmd/dbinventory` and
|
||||||
|
paste its output between the markers below. The tool exits non-zero when a
|
||||||
|
file imports `db` without a row, or a row names a file that no longer imports
|
||||||
|
it — the same two failures `go test ./invariants/` reports.
|
||||||
|
|
||||||
|
This is the inventory the roadmap's B3 entry gate asks for ("hotspots and
|
||||||
|
direct database call sites have an owned inventory") and the evidence its exit
|
||||||
|
gate consumes ("every direct database use above the domain layer is justified
|
||||||
|
or removed"). It answers three questions for every production Go file above
|
||||||
|
the domain layer: does it import `db`; what does it use `db` for; and what
|
||||||
|
happens to that use — one of four dispositions from the
|
||||||
|
[layout-refactor supplement](../plans/developer-experience-layout-refactor-2026-08-29.md):
|
||||||
|
|
||||||
|
| Disposition | Meaning | Rows |
|
||||||
|
| ----------- | --------------------------------------------------------------------------------------------------------------------- | ---: |
|
||||||
|
| `move` | persistence or a domain decision that belongs behind a service; **Family** names the service B3-8 (or B3-2) builds | 28 |
|
||||||
|
| `adapter` | a transport adapter that uses `db` types or pure helpers only — response shapes, status helpers — no persistence call | 17 |
|
||||||
|
| `boundary` | an explicit composition or transaction boundary that legitimately owns a handle (process entry, CLIs, health probe) | 6 |
|
||||||
|
| `remove` | the import is unnecessary and goes | 0 |
|
||||||
|
|
||||||
|
The rows live in code, not only here: `Server/invariants/db_import_boundary.go`
|
||||||
|
holds them as `DBImportAllow`, the `db-import-boundary` rule fails any new
|
||||||
|
importer that has no row, and `TestDBImportAllowIsLive` fails any row whose
|
||||||
|
file stopped importing `db`. The list only shrinks — B3-2 deletes the two auth
|
||||||
|
handler rows, B3-8 deletes a family's rows as it moves.
|
||||||
|
|
||||||
|
## How the measurement works
|
||||||
|
|
||||||
|
`Server/cmd/dbinventory` is syntactic (`go/parser` + `go/ast`, no type
|
||||||
|
information), like the invariants package. It records three things per file:
|
||||||
|
|
||||||
|
- **`db.*` types** — selectors that `db` declares as types (`db.User`,
|
||||||
|
`db.Channel`, …). A file whose only use is types is **type-only**: it
|
||||||
|
shapes data, it does not persist.
|
||||||
|
- **`db.*` funcs and sentinels** — package-level functions (`db.WriteAudit()`,
|
||||||
|
`db.Open()`) and values (`db.ErrNotFound`). Pure helpers such as
|
||||||
|
`db.StatusForViewer()` and `db.BroadcastStatus()` land here too; they are
|
||||||
|
computations over already-loaded rows, not queries.
|
||||||
|
- **`*db.DB` method calls** — calls whose receiver is an identifier declared
|
||||||
|
`*db.DB` in the file (parameter, result, var, or assigned from `db.Open*`),
|
||||||
|
or a selector whose final field is declared `*db.DB` anywhere in the same
|
||||||
|
package (`h.db.X`, `s.deps.DB.X`). This is the persistence surface the
|
||||||
|
dispositions are about.
|
||||||
|
|
||||||
|
A shape the walker cannot see (a `*db.DB` reaching a file through an untyped
|
||||||
|
interface, say) would show up as a row with an import and nothing recorded —
|
||||||
|
which is a row worth reading, and none exists today.
|
||||||
|
|
||||||
|
## Database-call inventory
|
||||||
|
|
||||||
|
<!-- dbinventory:start -->
|
||||||
|
|
||||||
|
| File | `db.*` types | `db.*` funcs and sentinels | `*db.DB` method calls | Shape | Disposition | Family | Why |
|
||||||
|
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ----------- | ------------ | --------------------------------------------------------------- |
|
||||||
|
| `admin/admin.go` | `DB` | — | — | type-only | boundary | — | holds the handle for the admin mux; no calls |
|
||||||
|
| `admin/api.go` | `DB` | — | — | type-only | boundary | — | passes the handle to handlers; no calls |
|
||||||
|
| `admin/backup_maintenance.go` | `DB×3` | `CheckBackupIntegrity()` `ErrNotFound×2` `WriteAudit()×2` | `BackupToSafe` `GetSetting×2` | calls | move | settings-ops | BackupToSafe, integrity check, settings reads |
|
||||||
|
| `admin/handlers_backup.go` | `DB×5` | `CheckBackupIntegrity()×2` `WriteAudit()×2` | `BackupToSafe×2` `Close` `LogAudit` `SQLDb` | calls | move | settings-ops | backup trigger and download; raw SQLDb for VACUUM INTO |
|
||||||
|
| `admin/handlers_channel_perms.go` | `Channel` `ChannelRoleOverride×2` `ChannelUserOverride×2` `DB×8` `Role×2` `User×2` | `WriteAudit()×4` | `DeleteChannelOverride` `DeleteChannelUserOverride` `GetChannel` `GetChannelPermissions×2` `GetRoleByID×3` `GetUserByID` `GetUserChannelPermissions×2` `ListChannelRoleOverrides` `ListChannelUserOverrides` `ListUserIDsByRole×2` `UpsertChannelOverride` `UpsertChannelUserOverride` | calls | move | channel | override CRUD decides permission policy in the handler |
|
||||||
|
| `admin/handlers_channels.go` | `Channel×2` `ChannelUpdate×2` `DB×6` | `WriteAudit()×3` | `AdminCreateChannel` `AdminDeleteChannel` `AdminUpdateChannel×2` `GetAuditLog` `GetChannel×4` `ListChannels` | calls | move | channel | channel CRUD + audit |
|
||||||
|
| `admin/handlers_roles.go` | `DB×4` | — | `GetRoleByID` `ListRoles` | calls | move | role | two reads; service/role.go already owns the writes |
|
||||||
|
| `admin/handlers_settings.go` | `DB×4` | `ErrNotFound` `WriteAudit()` | `BeginTx` `CountUsersWithoutTOTP` `GetAllSettings×2` `GetSetting` | calls | move | settings-ops | BeginTx in a handler; TOTP census |
|
||||||
|
| `admin/handlers_tokens.go` | `DB×3` `User` | `WriteAudit()×2` | `CreateAPIToken` `GetOwnerUser` `GetUserByUsername` `ListAPITokens` `RevokeAPIToken` | calls | move | auth | API-token CRUD duplicated in token_cli.go |
|
||||||
|
| `admin/handlers_users.go` | `DB×4` `Role` `User` | — | `GetServerStats` `GetUserByID×2` `ListAllUsers` | calls | move | user | user list, stats, lookups |
|
||||||
|
| `admin/helpers.go` | `Role×2` `User` | — | — | type-only | adapter | — | Role/User types in response helpers |
|
||||||
|
| `admin/logstream.go` | `DB×3` | — | — | type-only | boundary | — | handle threaded to the SSE stream's auth check; no calls |
|
||||||
|
| `admin/middleware.go` | `DB×3` `Role` `User` | — | `GetRoleByID` | calls | move | auth | owner gate re-reads the role — OC-0345 |
|
||||||
|
| `admin/setup_handler.go` | `DB×4` | `ErrConflict` `WriteAudit()×2` | `CreateChannel×2` `CreateInvite` `CreateOwnerIfEmpty` `CreateSession` `GetSetting×3` `UserCount` | calls | move | auth | first-run owner creation (setup sub-family) |
|
||||||
|
| `admin/setup_wizard.go` | `DB` | — | `BeginTx` | calls | move | auth | BeginTx for the wizard; setup sub-family |
|
||||||
|
| `admin/types.go` | `Channel×3` `DB` `Role` `User` `UserWithRole` | — | `GetRoleByID` | calls | adapter | — | response DTOs; the one GetRoleByID moves with handlers_users |
|
||||||
|
| `api/auth_handler.go` | `DB×11` `Session` `User×5` | `ErrLastAdmin` `ErrNotFound×2` `IsUniqueConstraintError()` `WriteAudit()×5` | `CreateSession×2` `CreateUserWithInvite` `DeleteAccount` `DeleteSession` `GetSetting` `GetUserByID` `GetUserByUsername` `UpdateUserCustomStatus` | calls | move | auth | B3-2 slice: register/login/logout/delete own the DB |
|
||||||
|
| `api/channel_handler.go` | `DB` `MessageAPIResponse×2` `MessageSearchResult×2` `ReactionUser` `User×8` | — | — | type-only | adapter | — | response types only; service owns the calls |
|
||||||
|
| `api/dm_handler.go` | `DB` `DMChannelInfo` `DMUser×2` `User×8` | `NewDMChannelInfo()` `StatusForViewer()` | — | calls | adapter | — | DM response types + pure status helpers |
|
||||||
|
| `api/emoji_handler.go` | `DB` `Emoji×3` `User×2` | — | — | type-only | adapter | — | Emoji/User types only |
|
||||||
|
| `api/gif_handler.go` | `DB` | — | — | type-only | adapter | — | handle in the signature, unused for calls |
|
||||||
|
| `api/invite_handler.go` | `DB` `Invite` `User×2` | — | — | type-only | adapter | — | Invite/User types only |
|
||||||
|
| `api/middleware.go` | `DB` `Role` | — | `DeleteSession` `TouchAPIToken` `TouchSession` | calls | move | auth | session/API-token touch and revoke |
|
||||||
|
| `api/plugins_handler.go` | `Auditor×2` | `WriteAudit()` | — | calls | adapter | — | db.Auditor is the seam; WriteAudit only |
|
||||||
|
| `api/profile_handler.go` | `DB×2` `Session×2` `User×6` | — | `CreateAttachment` | calls | move | upload | avatar upload creates the attachment row |
|
||||||
|
| `api/router.go` | `DB×4` | — | `PingRead` `SQLDb` | calls | boundary | — | health probe (PingRead, SQLDb); hub construction leaves in B3-3 |
|
||||||
|
| `api/totp_handler.go` | `DB×5` `Session×2` `User×4` | `WriteAudit()×3` | `DeleteOtherSessions×2` `GetUserByID` `UpdateUserTOTPSecret×2` | calls | move | auth | B3-2 slice: TOTP enrol/verify write the user row |
|
||||||
|
| `api/upload_handler.go` | `AttachmentAccess×2` `DB×5` `Role` `User×3` | — | `CreateAttachment` `GetAttachmentWithChannel` `IsAvatarFileURL` `IsDMParticipant` `QueryRowContext` | calls | move | upload | attachment access + a raw QueryRowContext |
|
||||||
|
| `auth/helpers.go` | `User` | — | — | type-only | adapter | — | db.User type in a helper signature |
|
||||||
|
| `auth/resolve.go` | `APIToken` `Role×2` `Session×2` `User×2` | — | — | type-only | adapter | — | Session/APIToken/Role/User types; resolution is injected |
|
||||||
|
| `cmd/seed/main.go` | `DB×6` | `Migrate()` `Open()` | `Close` `CreateChannel` `CreateMessage×2` `CreateUser` `GetOrCreateDMChannel` `GetUserByUsername` `ListChannels` `QueryRowContext` | calls | boundary | — | developer seeding tool owns its handle |
|
||||||
|
| `main.go` | `AuditWriter×2` `DB×10` | `ErrNotFound` `Migrate()` `NewAuditWriter()` `OpenWithMaxReaders()` | `ClearAllVoiceStates` `Close` `DeleteExpiredSessions` `DeleteOrphanedAttachments` `GetMaxEventSeq` `GetSetting` `ResetAllUserStatuses` `SetAuditWriter` `SetSetting` | calls | boundary | — | process composition root; B3-3 moves it to internal/app |
|
||||||
|
| `plugin/pluginstore.go` | `PluginRow×3` | — | — | type-only | adapter | — | PluginRow type only; the store is injected |
|
||||||
|
| `token_cli.go` | `DB×3` `User` | `Migrate()` `OpenShared()` `WriteAudit()×3` | `Close` `CreateAPIToken` `GetOwnerUser` `GetUserByUsername` `ListAPITokens` `RevokeAPIToken` `RevokeAPITokenByLabel` | calls | move | auth | API-token CLI duplicates admin/handlers_tokens.go |
|
||||||
|
| `ws/client.go` | `User×2` | — | — | type-only | adapter | — | db.User type on the connection |
|
||||||
|
| `ws/deps.go` | `Channel` `DB×5` | — | `GetRoleForUser×3` `IsDMParticipant` | calls | move | channel | role and DM-membership reads behind the hub's deps |
|
||||||
|
| `ws/event.go` | — | `BroadcastStatus()` | — | calls | adapter | — | pure BroadcastStatus helper |
|
||||||
|
| `ws/event_persister.go` | `PersistedEvent×2` | — | — | type-only | adapter | — | PersistedEvent type; store is an interface |
|
||||||
|
| `ws/eventstore.go` | `PersistedEvent×3` | — | — | type-only | adapter | — | PersistedEvent type; store is an interface |
|
||||||
|
| `ws/handlers.go` | `User` | — | `GetChannel` `GetRoleForUser` `GetSessionWithBanStatus` `IsDMParticipant` | calls | move | channel | channel, role, session-ban and DM reads in command handlers |
|
||||||
|
| `ws/handlers_chat.go` | — | `NewDMChannelInfo()` | — | calls | adapter | — | pure NewDMChannelInfo helper |
|
||||||
|
| `ws/hub.go` | `DB×2` | — | `GetSetting×2` | calls | move | settings-ops | GetSetting at construction |
|
||||||
|
| `ws/hub_broadcast.go` | `Channel×4` `Emoji` `Role` | `BroadcastStatus()` | `GetChannel×2` `GetDMParticipantIDs` `GetRoleForUser` `GetUserByID×2` `ListChannels` | calls | move | channel | visibility refresh reads channels, roles, users |
|
||||||
|
| `ws/hub_sweep.go` | `User` | — | `GetAllVoiceStates` `GetChannel` `GetChannelVoiceStates` `GetSessionsWithBanStatusBatch` `LeaveVoiceChannelIfMatch×2` | calls | move | voice | stale-voice sweep reads and leaves |
|
||||||
|
| `ws/messages.go` | `Channel×4` `DMChannelInfo×2` `DMUser×2` `Emoji` `Role×3` `User×2` `VoiceState` | `BroadcastStatus()` `StatusForViewer()` | — | calls | adapter | — | wire types + pure status helpers |
|
||||||
|
| `ws/serve.go` | `ChannelOverride` `DB×9` `PersistedEvent` `User` `VoiceState` | `ConnectStatus()` `StatusOffline` `WriteAudit()` | `GetChannelOverridesFor` `GetRoleByID×4` `GetUserByID×2` `GetUserDMChannelIDs` `GetVoiceState` `LeaveVoiceChannelIfMatch` `ListChannels` `MarkUserDisconnected` `UpdateUserStatus` | calls | move | connection | connect/disconnect lifecycle; B3-5 splits it by family first |
|
||||||
|
| `ws/serve_auth.go` | `DB` `User` | — | `GetSessionByTokenHash` `GetUserByID` | calls | move | auth | session lookup on the WebSocket handshake |
|
||||||
|
| `ws/serve_pumps.go` | — | `StatusOffline` | `MarkUserDisconnected` | calls | move | user | MarkUserDisconnected on pump exit |
|
||||||
|
| `ws/serve_ready.go` | `Channel×10` `ChannelOverride×6` `ChannelUnread×2` `DB×5` `DMChannelInfo×4` `MemberSummary×3` `Role×4` `User` `VoiceState×4` | `StatusOffline×3` | `GetAllVoiceStates` `GetChannelOverridesFor` `GetChannelUnreadCounts` `GetUserDMChannels` `ListChannels` `ListMembers` `ListRoles` | calls | move | channel | ready snapshot: channels, overrides, unreads, DMs, members |
|
||||||
|
| `ws/voice_join.go` | `Channel×3` `ChannelOverride` `VoiceState×5` | `ErrChannelFull` | `GetChannel×2` `GetChannelOverridesFor` `GetChannelVoiceStates` `GetRoleForUser` `GetVoiceState×6` `JoinVoiceChannel` `JoinVoiceChannelIfCapacity` `LeaveVoiceChannelIfMatch` `SetVoiceServerDeafen` `SetVoiceServerMute` | calls | move | voice | voice state reads and writes |
|
||||||
|
| `ws/voice_moderation.go` | `Role` `VoiceState×3` | `WriteAudit()` | `CountChannelVoiceUsers` `GetChannel×2` `GetRoleForUser` `GetVoiceState×2` `SetVoiceServerDeafen×2` `SetVoiceServerMute×2` | calls | move | voice | mute/deafen/move persist voice state |
|
||||||
|
|
||||||
|
51 files import `db` outside `db/` and `service/` (. 2, admin 16, api 12, auth 2, cmd/seed 1, plugin 1, ws 17); 14 are type-only; 0 unlisted.
|
||||||
|
Dispositions: adapter 17, boundary 6, move 28. Move targets: auth 9, channel 6, connection 1, role 1, settings-ops 4, upload 2, user 2, voice 3.
|
||||||
|
|
||||||
|
<!-- dbinventory:end -->
|
||||||
|
|
||||||
|
Reading the table:
|
||||||
|
|
||||||
|
- **Type-only files (14)** need no service; they stay `adapter`. The `db`
|
||||||
|
types they use are the wire and response shapes. Whether those types should
|
||||||
|
live outside `db` is a B3-8 question per family, not a boundary violation.
|
||||||
|
- **`ws/serve_ready.go`** (45 references, 7 distinct queries) is the single
|
||||||
|
heaviest reader: the ready snapshot reads channels, overrides, unreads, DM
|
||||||
|
channels, members, roles and voice states in one place. B3-5 keeps it as
|
||||||
|
the "fresh-connect initialisation" file and B3-8's channel family gives it
|
||||||
|
a snapshot service.
|
||||||
|
- **Two raw SQL escapes** exist above the domain layer:
|
||||||
|
`api/upload_handler.go` (`QueryRowContext`) and `admin/handlers_backup.go`
|
||||||
|
(`SQLDb` for `VACUUM INTO`). Both are `move`; the backup one may end as an
|
||||||
|
explicit `boundary` once `settings-ops` owns backups — the row is decided
|
||||||
|
when that family moves, not now.
|
||||||
|
- **Duplicated persistence** is visible in the families: API-token CRUD in
|
||||||
|
`admin/handlers_tokens.go` and `token_cli.go`; owner-role reads in
|
||||||
|
`admin/middleware.go` (OC-0345) and `api/middleware.go`; voice state in
|
||||||
|
`ws/hub_sweep.go`, `voice_join.go`, `voice_moderation.go`. One service per
|
||||||
|
family removes each duplicate.
|
||||||
|
- **`ws/serve.go`** is the one `connection` row: it is not a domain family but
|
||||||
|
the connect/disconnect lifecycle that touches four of them. B3-5 splits it
|
||||||
|
by responsibility first; the pieces then join their families' rows.
|
||||||
|
|
||||||
|
## Hub lifecycle inventory
|
||||||
|
|
||||||
|
Input to B3-3 (`internal/app/`) and B3-4 (constructor options). Measured at
|
||||||
|
the same commit.
|
||||||
|
|
||||||
|
### Construction and setters (S-11)
|
||||||
|
|
||||||
|
`ws.NewHub(database, limiter, svc)` is called **once**, inside
|
||||||
|
`api.NewRouter` (`Server/api/router.go:106`) — not in `main.go`. The seven
|
||||||
|
post-construction setters and where they are called:
|
||||||
|
|
||||||
|
| Setter | Declared | Called from | Required before `Run`? |
|
||||||
|
| ------------------------- | ---------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| `SetPluginRegistry` | `ws/hub_events.go:60` | `api/router.go:325` | only when plugins are enabled — optional collaborator |
|
||||||
|
| `SetPluginEventSink` | `ws/hub_events.go:70` | `api/router.go:328` | same |
|
||||||
|
| `SetLiveKit` | `ws/hub_livekit.go:10` | `api/router.go:342` | **yes** for voice: every voice join needs the token signer; nil means voice silently fails |
|
||||||
|
| `SetLiveKitProcess` | `ws/hub_livekit.go:48` | `api/router.go:360` | only when the supervised LiveKit process is configured |
|
||||||
|
| `SetEventPersister` | `ws/hub_events.go:40` | `main.go:453` | **yes** when persistence is on: events emitted before it is set are not persisted |
|
||||||
|
| `SetEventStore` | `ws/hub_events.go:48` | `main.go:454` | **yes** for replay: resume without a store answers a full `ready` |
|
||||||
|
| `SetPendingVoiceModFlags` | `ws/voice_moderation.go:599` | voice moderation paths at runtime | no — genuinely replaceable runtime state; stays a setter |
|
||||||
|
|
||||||
|
Two owners (the router and `main.go`) set collaborators on one hub, and the
|
||||||
|
hub starts (`hub.Run`, `ws/hub.go:273`) with no check that the required ones
|
||||||
|
are present. B3-3 moves construction into `internal/app/` so there is one
|
||||||
|
call site; B3-4 turns the four "yes" rows into validated `HubOptions` and
|
||||||
|
leaves the three optional ones as setters with that reason written beside
|
||||||
|
them.
|
||||||
|
|
||||||
|
### Locks
|
||||||
|
|
||||||
|
Five locks on `Hub`, all `syncutil` (so the `-tags deadlock` pass sees them):
|
||||||
|
|
||||||
|
| Lock | Declared | Guards (from the field comment) |
|
||||||
|
| ------------- | --------------- | ----------------------------------------------------------------------- |
|
||||||
|
| `mu` | `ws/hub.go:25` | the client registry and subscriptions |
|
||||||
|
| `seqMu` | `ws/hub.go:58` | seq assignment + replay insertion + delivery order, serialised together |
|
||||||
|
| `settingsMu` | `ws/hub.go:122` | the cached server settings |
|
||||||
|
| `keyHolderMu` | `ws/hub.go:130` | the voice E2EE key-holder map |
|
||||||
|
| `presenceMu` | `ws/hub.go:135` | presence coalescing state |
|
||||||
|
|
||||||
|
**The lock order is not written down anywhere** — not in `Server/CLAUDE.md`,
|
||||||
|
`docs/architecture/websocket.md`, or `hub.go`. It is proven only by the
|
||||||
|
`-tags deadlock -count=10 ./ws/` pass. B3-5 records the order in `hub.go`'s
|
||||||
|
package comment before it moves a single function, so every pure-move commit
|
||||||
|
has something to be checked against.
|
||||||
|
|
||||||
|
### Start, drain, stop (`main.go` `run`, `main.go:107`)
|
||||||
|
|
||||||
|
Start order, then the `defer` stack that undoes it (LIFO — the last started
|
||||||
|
is the first stopped):
|
||||||
|
|
||||||
|
| # | Start (`main.go`) | Stop (`defer`, in registration order — runs in reverse) |
|
||||||
|
| --- | -------------------------------------------------------- | ---------------------------------------------------------- |
|
||||||
|
| 1 | background context | `bgCancel()` `:118` |
|
||||||
|
| 2 | `runOpenDatabase` → `db.OpenWithMaxReaders` `:314-322` | `database.Close()` `:148` |
|
||||||
|
| 3 | `runInitDatabase` → `db.Migrate` `:333-347` | — |
|
||||||
|
| 4 | `runInitTelemetry` `:369` | `telemetryStop()` `:156` |
|
||||||
|
| 5 | `runInitPlugins` `:392` | `runClosePlugins` `:161` |
|
||||||
|
| 6 | `api.NewRouter` → **hub built here** `:164` | `routerCleanup()` `:165`, then `hub.GracefulStop()` `:172` |
|
||||||
|
| 7 | `runStartEventPersistence` `:435` (sets persister/store) | `runStopEventPersistence` `:176` |
|
||||||
|
| 8 | `runStartAuditWriter` `:488` | `runStopAuditWriter` `:186` |
|
||||||
|
| 9 | `runStartACME` `:505` | via `runShutdownServers` `:667` |
|
||||||
|
| 10 | `runStartMaintenance` `:527` | `maintenanceStop()` `:207` |
|
||||||
|
| 11 | `signal.NotifyContext` `:214` | `stop()` `:215` |
|
||||||
|
| 12 | `runServeAndWait` `:629` → `runShutdownServers` `:667` | `srv.Shutdown`, `acmeSrv.Shutdown`, hub drain |
|
||||||
|
|
||||||
|
Three facts B3-3's composite close must preserve, each already encoded in a
|
||||||
|
comment at the cited line: the audit writer's stop is registered **after**
|
||||||
|
`database.Close` so it flushes before the handle goes (`:183-186`); event
|
||||||
|
persistence stops before the LIFO-later `database.Close` so no prune is still
|
||||||
|
running (`:476`); `hub.GracefulStop` must run even on an early return so the
|
||||||
|
supervised LiveKit process is not orphaned (`:168-172`). Any early `return
|
||||||
|
err` between steps 2 and 12 relies on this defer stack — there is no single
|
||||||
|
close function, which is exactly what B3-3's failure-injection test pins.
|
||||||
|
|
||||||
|
## Auth slice — before-state dependency graph
|
||||||
|
|
||||||
|
The three files B3-2 moves, and what they depend on at `ad4defc2`. The
|
||||||
|
after-state table is appended by B3-2.
|
||||||
|
|
||||||
|
| File | Imports (module-internal) | `db` symbols used |
|
||||||
|
| ---------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `api/auth_handler.go` | `auth`, `db`, `permissions`, `service` | types `DB`, `Session`, `User`; funcs `WriteAudit`, `IsUniqueConstraintError`; sentinels `ErrLastAdmin`, `ErrNotFound`; methods `CreateSession`, `CreateUserWithInvite`, `DeleteAccount`, `DeleteSession`, `GetSetting`, `GetUserByID`, `GetUserByUsername`, `UpdateUserCustomStatus` |
|
||||||
|
| `api/totp_handler.go` | `auth`, `db` | types `DB`, `Session`, `User`; func `WriteAudit`; methods `DeleteOtherSessions`, `GetUserByID`, `UpdateUserTOTPSecret` |
|
||||||
|
| `auth/*.go` (10 files) | `db` (types only, in `helpers.go`, `resolve.go`), `config`, `syncutil` | types `User`, `Session`, `APIToken`, `Role` — no method calls; `auth` is a leaf that computes and does not persist |
|
||||||
|
|
||||||
|
Eleven distinct `*db.DB` methods across the two handlers. That is the upper
|
||||||
|
bound of the interface `api/auth_deps.go` declares in B3-2; the after-state
|
||||||
|
row must show the handlers importing neither `db` nor `service` directly.
|
||||||
|
|
||||||
|
## Client baselines are not here
|
||||||
|
|
||||||
|
The supplement's Phase 1 item 5 (client native-import, Rust-command,
|
||||||
|
import-cycle, timer/listener, bundle, coverage and mutation baselines) is B7's
|
||||||
|
entry work, recorded there. Nothing under `Client/` was measured for this
|
||||||
|
document.
|
||||||
@@ -9,6 +9,11 @@ LiveKit for voice, Wazero for plugins (build-tag gated), optional OpenTelemetry
|
|||||||
|
|
||||||
## D2 — Package map
|
## D2 — Package map
|
||||||
|
|
||||||
|
Which of these packages may import `db`, and what every file above the domain
|
||||||
|
layer does with it, is inventoried in
|
||||||
|
[server-boundaries.md](server-boundaries.md) (B3-0) and enforced by the
|
||||||
|
`db-import-boundary` rule in `Server/invariants/`.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
subgraph entry ["Process entry"]
|
subgraph entry ["Process entry"]
|
||||||
|
|||||||
+14
-13
@@ -10,19 +10,20 @@ authority**.
|
|||||||
|
|
||||||
## Active — these drive current work
|
## Active — these drive current work
|
||||||
|
|
||||||
| Plan | State |
|
| Plan | State |
|
||||||
| ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| [beta-product-requirements-2026-08-23](beta-product-requirements-2026-08-23.md) | Approved beta scope, frozen. 57 `BPR-*` requirements. |
|
| [beta-product-requirements-2026-08-23](beta-product-requirements-2026-08-23.md) | Approved beta scope, frozen. 57 `BPR-*` requirements. |
|
||||||
| [repo-health-roadmap-2026-08-23](repo-health-roadmap-2026-08-23.md) | Phase order and gates, B0–B10. **B0, B1 and B2 complete** (HP-0, HP-1 and HP-2 all accepted); **B3 is next** — opens with the layout-refactor first slice. B3–B10 not started. **Amended 2026-08-28:** dated lines in B3–B10, a "Phase execution pattern" section, and a truthful status header. **Amended 2026-08-29:** B3/B7/B9 lines binding the layout-refactor supplement below; current slice updated for B2-2. |
|
| [repo-health-roadmap-2026-08-23](repo-health-roadmap-2026-08-23.md) | Phase order and gates, B0–B10. **B0, B1 and B2 complete** (HP-0, HP-1 and HP-2 all accepted); **B3 is next** — opens with the layout-refactor first slice. B3–B10 not started. **Amended 2026-08-28:** dated lines in B3–B10, a "Phase execution pattern" section, and a truthful status header. **Amended 2026-08-29:** B3/B7/B9 lines binding the layout-refactor supplement below; current slice updated for B2-2. |
|
||||||
| [repo-health-issue-register-2026-08-23](repo-health-issue-register-2026-08-23.md) | 88 planning rows. Public-safe; not a replacement for the ledger. |
|
| [repo-health-issue-register-2026-08-23](repo-health-issue-register-2026-08-23.md) | 88 planning rows. Public-safe; not a replacement for the ledger. |
|
||||||
| [beta-requirements-traceability-2026-08-23](beta-requirements-traceability-2026-08-23.md) | Requirement → phase → evidence map. No row is release-qualified. |
|
| [beta-requirements-traceability-2026-08-23](beta-requirements-traceability-2026-08-23.md) | Requirement → phase → evidence map. No row is release-qualified. |
|
||||||
| [b0-baseline-2026-08-25](b0-baseline-2026-08-25.md) | **Supersedes the roadmap's "current evidence snapshot."** B0 measurements and dispositions. |
|
| [b0-baseline-2026-08-25](b0-baseline-2026-08-25.md) | **Supersedes the roadmap's "current evidence snapshot."** B0 measurements and dispositions. |
|
||||||
| [b1-repository-foundation-2026-08-25](b1-repository-foundation-2026-08-25.md) | **B1-0 through B1-8 all done.** B1 execution plan. Re-verifies every RL-\* claim against HEAD; several are refuted. |
|
| [b1-repository-foundation-2026-08-25](b1-repository-foundation-2026-08-25.md) | **B1-0 through B1-8 all done.** B1 execution plan. Re-verifies every RL-\* claim against HEAD; several are refuted. |
|
||||||
| [hp-0-scorecard-2026-08-25](hp-0-scorecard-2026-08-25.md) | **HP-0 accepted 2026-08-25.** The single baseline-acceptance artifact. Part-closes `R-08`. |
|
| [hp-0-scorecard-2026-08-25](hp-0-scorecard-2026-08-25.md) | **HP-0 accepted 2026-08-25.** The single baseline-acceptance artifact. Part-closes `R-08`. |
|
||||||
| [hp-1-scorecard-2026-08-27](hp-1-scorecard-2026-08-27.md) | **HP-1 accepted 2026-08-27.** Structural-diff proofs for the flatten and module rename, plus the B1 exit gate. |
|
| [hp-1-scorecard-2026-08-27](hp-1-scorecard-2026-08-27.md) | **HP-1 accepted 2026-08-27.** Structural-diff proofs for the flatten and module rename, plus the B1 exit gate. |
|
||||||
| [b2-protocol-trust-compat-2026-08-28](b2-protocol-trust-compat-2026-08-28.md) | **B2 complete — HP-2 accepted 2026-08-29.** B2-0, B2-1, B2-8 done 2026-08-28; B2-2 (B2-3/B2-4 folded in), B2-5, B2-6, B2-7, B2-9 done 2026-08-29. Scorecard below. |
|
| [b2-protocol-trust-compat-2026-08-28](b2-protocol-trust-compat-2026-08-28.md) | **B2 complete — HP-2 accepted 2026-08-29.** B2-0, B2-1, B2-8 done 2026-08-28; B2-2 (B2-3/B2-4 folded in), B2-5, B2-6, B2-7, B2-9 done 2026-08-29. Scorecard below. |
|
||||||
| [hp-2-scorecard-2026-08-29](hp-2-scorecard-2026-08-29.md) | **HP-2 accepted 2026-08-29.** Seven questions answered with commands; B2 exit gate, nine conditions met (1 at the slim epoch scope, 4 with one E2EE gap disclaimed). Owner follow-ups that do not gate B3: BPR-051 reader line, SEC-01/SEC-04 advisory IDs. |
|
| [hp-2-scorecard-2026-08-29](hp-2-scorecard-2026-08-29.md) | **HP-2 accepted 2026-08-29.** Seven questions answered with commands; B2 exit gate, nine conditions met (1 at the slim epoch scope, 4 with one E2EE gap disclaimed). Owner follow-ups that do not gate B3: BPR-051 reader line, SEC-01/SEC-04 advisory IDs. |
|
||||||
| [audit-2026-08-19-remediation](audit-2026-08-19-remediation.md) | Phases 1–6 done 2026-08-20; **phase 7 pending**. Its header still reads "in progress 2026-08-19" — stale; the phase table is correct. |
|
| [b3-server-architecture-guardrails-2026-08-29](b3-server-architecture-guardrails-2026-08-29.md) | **B3 in progress from 2026-08-29.** Execution plan: B3-0 inventory → B3-1/B3-2 auth slice → HP-3 → lifecycle, hub options, `ws` split, families; guardrails and the alpha dataset beside the slice. B3-0 (inventory + `db-import-boundary` rule) in review 2026-08-29. |
|
||||||
|
| [audit-2026-08-19-remediation](audit-2026-08-19-remediation.md) | Phases 1–6 done 2026-08-20; **phase 7 pending**. Its header still reads "in progress 2026-08-19" — stale; the phase table is correct. |
|
||||||
|
|
||||||
## Partially implemented
|
## Partially implemented
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
**Base commit:** `bf7b886d` (`dev`, post-PR #1445); HP-2 accepted 2026-08-29
|
**Base commit:** `bf7b886d` (`dev`, post-PR #1445); HP-2 accepted 2026-08-29
|
||||||
([hp-2-scorecard-2026-08-29.md](hp-2-scorecard-2026-08-29.md)) — claims
|
([hp-2-scorecard-2026-08-29.md](hp-2-scorecard-2026-08-29.md)) — claims
|
||||||
verified at `bf7b886d`
|
verified at `bf7b886d`
|
||||||
**Status:** drafted — entry gate 2 of 3 met at draft time (see below); no step
|
**Status:** in progress — plan merged 2026-08-29 (PR #1447 = `ad4defc2`);
|
||||||
started. Update this line, not only the step table, when a step lands.
|
B3-0 in review 2026-08-29 (evidence in its section; closes entry-gate item 3).
|
||||||
|
Update this line, not only the step table, when a step lands.
|
||||||
|
|
||||||
Primary inputs:
|
Primary inputs:
|
||||||
|
|
||||||
@@ -36,14 +37,14 @@ surface to it.
|
|||||||
| **B3-2** | The auth vertical slice (S-10): route → `service.AuthService` → `db`, behaviour-neutral | 2–3 days | B3-6, B3-7 |
|
| **B3-2** | The auth vertical slice (S-10): route → `service.AuthService` → `db`, behaviour-neutral | 2–3 days | B3-6, B3-7 |
|
||||||
| **HP-3** | First vertical-slice review — scorecard | — | — |
|
| **HP-3** | First vertical-slice review — scorecard | — | — |
|
||||||
| **B3-3** | Lifecycle extraction: `main.go` → `internal/app/` with one composite close contract | 1–2 days | B3-4 |
|
| **B3-3** | Lifecycle extraction: `main.go` → `internal/app/` with one composite close contract | 1–2 days | B3-4 |
|
||||||
| **B3-4** | Hub constructor options (S-11): required collaborators validated at construction | 1 day | B3-3 |
|
| **B3-4** | Hub constructor options (S-11): required collaborators validated at construction | 1 day | after B3-3 |
|
||||||
| **B3-5** | `ws` in-package split (S-08): responsibilities into named files, pure moves + adjacent rewrites | 2–3 days | after B3-3/B3-4 |
|
| **B3-5** | `ws` in-package split (S-08): responsibilities into named files, pure moves + adjacent rewrites | 2–3 days | after B3-3/B3-4 |
|
||||||
| **B3-6** | Guardrails: coverage floor (S-06), hub simulation + fault transport + fuzz seeds, benchmarks, rules | 3–4 days | B3-0..B3-2 |
|
| **B3-6** | Guardrails: coverage floor (S-06), hub simulation + fault transport + fuzz seeds, benchmarks, rules | 3–4 days | B3-0..B3-2 |
|
||||||
| **B3-7** | Alpha-shaped test dataset: seed profile + anonymised `v1.2.0-alpha.4` snapshot | 1–2 days | B3-0..B3-2 |
|
| **B3-7** | Alpha-shaped test dataset: seed profile + anonymised `v1.2.0-alpha.4` snapshot | 1–2 days | B3-0..B3-2 |
|
||||||
| **B3-8** | Remaining domain families behind services (S-09), one PR each; S-03/S-04 fold into the channel family | spread | after HP-3, per-family |
|
| **B3-8** | Remaining domain families behind services (S-09), one PR each; S-03/S-04 fold into the channel family | spread | after HP-3, per-family |
|
||||||
| **B3-9** | The B3-tagged findings: OC-0323, OC-0345, OC-0346 (test-first, `bughunt-fix` shape) | 1 day | any |
|
| **B3-9** | The B3-tagged findings: OC-0323, OC-0345, OC-0346 (test-first, `bughunt-fix` shape) | 1 day | any |
|
||||||
|
|
||||||
Order: B3-0 → B3-1 → B3-2 → **HP-3** → B3-3 + B3-4 → B3-5 → B3-8. B3-6, B3-7
|
Order: B3-0 → B3-1 → B3-2 → **HP-3** → B3-3 → B3-4 → B3-5 → B3-8. B3-6, B3-7
|
||||||
and B3-9 run beside the slice (roadmap "Safe parallelism": guardrail tooling
|
and B3-9 run beside the slice (roadmap "Safe parallelism": guardrail tooling
|
||||||
and baseline measurement may run while the first vertical slice is prepared)
|
and baseline measurement may run while the first vertical slice is prepared)
|
||||||
provided they do not touch `Server/api/auth_handler.go`, `Server/auth/` or
|
provided they do not touch `Server/api/auth_handler.go`, `Server/auth/` or
|
||||||
@@ -64,21 +65,21 @@ migration, predicate or hub lifecycle ownership.
|
|||||||
Every claim the roadmap's B3 section and the supplement rest on, re-tested
|
Every claim the roadmap's B3 section and the supplement rest on, re-tested
|
||||||
against `bf7b886d`. Commands are the ones B3-0 automates.
|
against `bf7b886d`. Commands are the ones B3-0 automates.
|
||||||
|
|
||||||
| Claim | Verdict | What it means for the work |
|
| Claim | Verdict | What it means for the work |
|
||||||
| ---------------------------------------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 44 production files in `ws`/`admin`/`api` import `db` (17/16/11) | **Confirmed, 45** | `ws` 17, `admin` 16, `api` **12** (`grep -l '"github.com/J3vb/OwnCord/Server/db"'` over non-test files). `service` imports it from 16 of 18 files — expected, it is the layer that should. `auth` from 2 of 10. B3-0 lists every file, not the count. |
|
| 44 production files in `ws`/`admin`/`api` import `db` (17/16/11) | **Confirmed, 45** | `ws` 17, `admin` 16, `api` **12** (`grep -l '"github.com/J3vb/OwnCord/Server/db"'` over non-test files). `service` imports it from 16 of 18 files — expected, it is the layer that should. `auth` from 2 of 10. B3-0 lists every file, not the count. |
|
||||||
| `main.go` exceeds 1,000 lines and owns twelve responsibilities | **Confirmed** | 1,019 lines. B3-3 moves the wiring into `internal/app/`; `main.go` stays the `go build .` entry. |
|
| `main.go` exceeds 1,000 lines and owns twelve responsibilities | **Confirmed** | 1,019 lines. B3-3 moves the wiring into `internal/app/`; `main.go` stays the `go build .` entry. |
|
||||||
| `hub_broadcast.go`, `serve.go`, `hub.go` are the coordination hotspots | **Confirmed** | 1,032 / 990 / 819 lines; `router.go` 721, `voice_join.go` 680. `ws` is 45 production files, 12,738 lines. B3-5 splits inside the package — the supplement's rule ("keep `ws` one package while its lock invariants need shared private state") stands. |
|
| `hub_broadcast.go`, `serve.go`, `hub.go` are the coordination hotspots | **Confirmed** | 1,032 / 990 / 819 lines; `router.go` 721, `voice_join.go` 680. `ws` is 45 production files, 12,738 lines. B3-5 splits inside the package — the supplement's rule ("keep `ws` one package while its lock invariants need shared private state") stands. |
|
||||||
| Hub wiring uses post-construction setters (S-11) | **Confirmed, 7** | `SetEventPersister`, `SetEventStore`, `SetPluginRegistry`, `SetPluginEventSink` (`hub_events.go`), `SetLiveKit`, `SetLiveKitProcess` (`hub_livekit.go`), `SetPendingVoiceModFlags` (`voice_moderation.go:599`). B3-4 decides for each: required (constructor option, validated) or genuinely replaceable (setter stays). |
|
| Hub wiring uses post-construction setters (S-11) | **Confirmed, 7** | `SetEventPersister`, `SetEventStore`, `SetPluginRegistry`, `SetPluginEventSink` (`hub_events.go`), `SetLiveKit`, `SetLiveKitProcess` (`hub_livekit.go`), `SetPendingVoiceModFlags` (`voice_moderation.go:599`). Called from two places: `api/router.go` (`NewHub` at `:106`, plugin and LiveKit setters `:325-360`) and `main.go:453-454` (persister, event store) — so construction has two owners today, which is why B3-4 follows B3-3. B3-4 decides for each: required (constructor option, validated) or genuinely replaceable (setter stays). |
|
||||||
| Auth routes consume raw database ownership (S-10) | **Confirmed** | `api/auth_handler.go` (786 lines) takes `*db.DB` in `MountAuthRoutes`, `handleRegister`, `handleLogin`, `loginAuthenticate`, `handleLogout`, `handleDeleteAccount`, `issueSession`, `isRequire2FAEnabled`, `isRegistrationOpen`, `getBooleanSetting`; 26 `db.` references. `totp_handler.go` 475 lines, same shape. No `service/auth.go` exists. |
|
| Auth routes consume raw database ownership (S-10) | **Confirmed** | `api/auth_handler.go` (786 lines) takes `*db.DB` in `MountAuthRoutes`, `handleRegister`, `handleLogin`, `loginAuthenticate`, `handleLogout`, `handleDeleteAccount`, `issueSession`, `isRequire2FAEnabled`, `isRegistrationOpen`, `getBooleanSetting`; 26 `db.` references. `totp_handler.go` 475 lines, same shape. No `service/auth.go` exists. |
|
||||||
| A useful service seam exists but does not own all use cases | **Confirmed** | `Server/service/` has 18 production files (block, channel, dm, emoji, invite, mentions, message\*, moderation, permission, role, user). Nothing for auth, sessions, TOTP, uploads, settings, audit or plugins. |
|
| A useful service seam exists but does not own all use cases | **Confirmed** | `Server/service/` has 18 production files (block, channel, dm, emoji, invite, mentions, message\*, moderation, permission, role, user). Nothing for auth, sessions, TOTP, uploads, settings, audit or plugins. |
|
||||||
| Coverage is 74.6% with no floor (S-06) | **Confirmed** | `ci.yml:73` runs `go test -race -coverprofile=coverage.out -cover` and uploads the profile (`:91-96`); nothing reads it. B0 measured 74.6% (`b0-baseline-2026-08-25.md:46`). B3-6 adds the floor at exactly that number. |
|
| Coverage is 74.6% with no floor (S-06) | **Confirmed** | `ci.yml:73` runs `go test -race -coverprofile=coverage.out -cover` and uploads the profile (`:91-96`); nothing reads it. B0 measured 74.6% (`b0-baseline-2026-08-25.md:46`). B3-6 adds the floor at exactly that number. |
|
||||||
| Tier 3 (hub simulation, fault transport, model tests) is designed, not built | **Confirmed** | `bug-detection-improvements.md` §Tier 3; `make fuzz` (Tier 1a) exists (`Server/Makefile:5`). No `ws` simulation test, no fault-injecting transport, no `fc.commands` model test in `Client/tests/unit/*.property.test.ts`. B3-6 builds 3b and 3c; 3a is a client test file and is included (it touches no client structure — B7's rule is about `src/`). |
|
| Tier 3 (hub simulation, fault transport, model tests) is designed, not built | **Confirmed** | `bug-detection-improvements.md` §Tier 3; `make fuzz` (Tier 1a) exists (`Server/Makefile:5`). No `ws` simulation test, no fault-injecting transport, no `fc.commands` model test in `Client/tests/unit/*.property.test.ts`. B3-6 builds 3b and 3c; 3a is a client test file and is included (it touches no client structure — B7's rule is about `src/`). |
|
||||||
| `Server/invariants` has rules to extend | **Confirmed, one rule** | `Rules = []Rule{syncutilLocks}` (`invariants.go:64`). The `seq-enqueue-paired` rule the 2026-08-18 measurement recorded as "adopted narrowed" was never merged under that name — `git log -S seq-enqueue-paired` is empty at `bf7b886d` — so B3-6 item 7 adds `authz-chokepoint` as the registry's second rule and does not build on a sibling that does not exist. `authz-chokepoint` gets HP-2 question 5's residue as its allowlist. |
|
| `Server/invariants` has rules to extend | **Confirmed, one rule** | `Rules = []Rule{syncutilLocks}` (`invariants.go:64`). The `seq-enqueue-paired` rule the 2026-08-18 measurement recorded as "adopted narrowed" was never merged under that name — `git log -S seq-enqueue-paired` is empty at `bf7b886d` — so B3-6 item 7 adds `authz-chokepoint` as the registry's second rule and does not build on a sibling that does not exist. `authz-chokepoint` gets HP-2 question 5's residue as its allowlist. |
|
||||||
| The seed tool has no alpha-shaped profile (workstream 12) | **Confirmed** | `Server/cmd/seed/main.go` (372 lines) takes `-db` and `-confirm-dev` only. No snapshot exists anywhere in the tree. B3-7 builds both. |
|
| The seed tool has no alpha-shaped profile (workstream 12) | **Confirmed** | `Server/cmd/seed/main.go` (372 lines) takes `-db` and `-confirm-dev` only. No snapshot exists anywhere in the tree. B3-7 builds both. |
|
||||||
| Docker smoke never runs on `dev` (workstream 16) | **Confirmed** | `Server Docker Build (verify)` skips on every `dev` PR (HP-2 gate run; #1444, #1445 both `skipping`). B3-6 adds a `schedule:` trigger to `ci.yml` scoped to that job. |
|
| Docker smoke never runs on `dev` (workstream 16) | **Confirmed** | `Server Docker Build (verify)` skips on every `dev` PR (HP-2 gate run; #1444, #1445 both `skipping`). B3-6 adds a `schedule:` trigger to `ci.yml` scoped to that job. |
|
||||||
| Permission rules are mirrored (workstream 7) | **Refuted, done in B2-5** | Six predicates in `Server/permissions/predicates.go`, parity tables in `service/` and `ws/`; residue classified in HP-2 question 5. Workstream 7 reduces to the `authz-chokepoint` rule (workstream 15) and to keeping the residue table current when B3-8 moves families. |
|
| Permission rules are mirrored (workstream 7) | **Refuted, done in B2-5** | Six predicates in `Server/permissions/predicates.go`, parity tables in `service/` and `ws/`; residue classified in HP-2 question 5. Workstream 7 reduces to the `authz-chokepoint` rule (workstream 15) and to keeping the residue table current when B3-8 moves families. |
|
||||||
| Register rows OC-0323, OC-0345, OC-0346 are open | **Confirmed** | All three `open`, low, in `.superpowers/findings-ledger.json`; B3/B5, B3/B4, B3/B6 tags. Roadmap rule 2: B3 cannot exit with any of them open unless re-tagged with a written reason in HP-3. |
|
| Register rows OC-0323, OC-0345, OC-0346 are open | **Confirmed** | All three `open`, low, in `.superpowers/findings-ledger.json`; B3/B5, B3/B4, B3/B6 tags. Roadmap rule 2: B3 cannot exit with any of them open unless re-tagged with a written reason in HP-3. |
|
||||||
|
|
||||||
Net effect: workstream 7 is already done; the inventory (entry-gate item 3)
|
Net effect: workstream 7 is already done; the inventory (entry-gate item 3)
|
||||||
is the first real work; the auth slice is exactly the size S-10 says; `api`
|
is the first real work; the auth slice is exactly the size S-10 says; `api`
|
||||||
@@ -136,6 +137,63 @@ Exit: every `db` importer has a disposition; the rule is green on HEAD and
|
|||||||
red on a synthetic violation; the graph table exists for the three auth
|
red on a synthetic violation; the graph table exists for the three auth
|
||||||
files. One PR.
|
files. One PR.
|
||||||
|
|
||||||
|
**Evidence, 2026-08-29** — branch `feat/b3-0-boundary-inventory` from `dev`
|
||||||
|
`ad4defc2`; PR to `dev` recorded below. Closes entry-gate item 3.
|
||||||
|
|
||||||
|
- **Inventory:** `docs/architecture/server-boundaries.md`, linked from
|
||||||
|
`docs/architecture/server.md` §D2 and `docs/architecture/README.md`. The
|
||||||
|
table is generated by `Server/cmd/dbinventory` (syntactic, `go/ast`; no
|
||||||
|
`x/tools` dependency added) and the rows live as `DBImportAllow` in
|
||||||
|
`Server/invariants/db_import_boundary.go`, so the document and the gate
|
||||||
|
cannot drift: the tool prints each row's disposition from the map and exits
|
||||||
|
1 on an unlisted importer or a stale row. Measured: **51 files** import
|
||||||
|
`db` outside `db/` and `service/` (ws 17, admin 16, api 12, auth 2, root 2,
|
||||||
|
cmd/seed 1, plugin 1) — the supplement's 44 counted three packages, and
|
||||||
|
`api` is 12, not 11. **14 are type-only** (they use `db.User`-style shapes
|
||||||
|
and never persist) — a distinction the `git grep` count could not make.
|
||||||
|
Dispositions: **move 28** (auth 9, channel 6, settings-ops 4, voice 3,
|
||||||
|
upload 2, user 2, role 1, connection 1), **adapter 17**, **boundary 6**,
|
||||||
|
remove 0. The regex the plan sketched over-counted: `db.` in comments
|
||||||
|
matched, and `*db.DB` method calls (`database.GetUserByID`) do not contain
|
||||||
|
`db.` at all — the AST walk resolves `*db.DB` receivers (params, fields,
|
||||||
|
`db.Open*` assignments) instead.
|
||||||
|
- **Rule:** `db-import-boundary`, the registry's second rule. Unit RED:
|
||||||
|
`TestDBImportBoundary` "unlisted api file importing db is flagged" and the
|
||||||
|
aliased-import case. Real-tree RED, B2-7 style — a probe `api/zz_probe.go`
|
||||||
|
importing `db`:
|
||||||
|
|
||||||
|
```
|
||||||
|
--- FAIL: TestServerInvariants (0.04s)
|
||||||
|
invariants_test.go:20: api/zz_probe.go:3: [db-import-boundary] 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Probe deleted (`git status` clean), `go test ./invariants/` green.
|
||||||
|
`TestDBImportAllowIsLive` is the other direction: every row must name a
|
||||||
|
file that exists and still imports `db`, carry a reason, and pair `move`
|
||||||
|
with a family — so the list can only shrink honestly.
|
||||||
|
|
||||||
|
- **Hub lifecycle:** seven setters with declaration and call site — `NewHub`
|
||||||
|
is called in `api/router.go:106`, four setters from the router and two
|
||||||
|
from `main.go:453-454`; four are required before `Run` (LiveKit signer,
|
||||||
|
persister, event store — and `SetLiveKit` fails voice silently when
|
||||||
|
absent), three are genuinely optional. Five `syncutil` locks listed with
|
||||||
|
what each guards; **the lock order is not written down anywhere** — only
|
||||||
|
the `-tags deadlock` pass proves it — so B3-5 records it in `hub.go` before
|
||||||
|
its first move. The `run()` start order and its twelve-entry defer stack
|
||||||
|
are tabulated with the three ordering facts B3-3's composite close must
|
||||||
|
keep (audit writer after `database.Close`, persistence before it,
|
||||||
|
`GracefulStop` on early return).
|
||||||
|
- **Auth before-graph:** `api/auth_handler.go` imports `auth`, `db`,
|
||||||
|
`permissions`, `service` and calls 8 distinct `*db.DB` methods;
|
||||||
|
`totp_handler.go` imports `auth`, `db` and calls 3; `auth/` itself is a
|
||||||
|
leaf (types only). Eleven methods is the upper bound of B3-2's interface.
|
||||||
|
- Phase 1 item 5 (client baselines) recorded as B7 entry work, not done here.
|
||||||
|
- Gates before commit: `check:docs`, `check:hygiene`; from `Server/` the four
|
||||||
|
build-tag variants, `go vet`, `go test -race ./...`, `go test -tags deadlock
|
||||||
|
./ws/`, `golangci-lint run` (the tool's first version tripped `cyclop` at
|
||||||
|
30 and the deprecated `parser.ParseDir` — split into helpers, `os.ReadDir`
|
||||||
|
- `ParseFile`).
|
||||||
|
|
||||||
## B3-1 — Auth characterization tests
|
## B3-1 — Auth characterization tests
|
||||||
|
|
||||||
Layout-refactor Phase 2 item 1 and the supplement's PR strategy step 1
|
Layout-refactor Phase 2 item 1 and the supplement's PR strategy step 1
|
||||||
@@ -234,8 +292,11 @@ Roadmap workstream 8; supplement Phase 3 item 1. After HP-3.
|
|||||||
|
|
||||||
1. `Server/internal/app/` owns: config and data-directory preparation,
|
1. `Server/internal/app/` owns: config and data-directory preparation,
|
||||||
database open/migrate, telemetry, plugin registry, event persistence,
|
database open/migrate, telemetry, plugin registry, event persistence,
|
||||||
audit writer, maintenance workers, HTTP server construction, health,
|
audit writer, maintenance workers, **hub construction** (moved out of
|
||||||
replay seeding, and **one composite close** — `App.Close(ctx)` that stops
|
`api.NewRouter`, `router.go:106`, together with the plugin and LiveKit
|
||||||
|
setters at `:325-360`; `NewRouter` gains a `*ws.Hub` parameter and stops
|
||||||
|
returning one), HTTP server construction, health, replay seeding, and
|
||||||
|
**one composite close** — `App.Close(ctx)` that stops
|
||||||
in the reverse of start order and reports the first error without skipping
|
in the reverse of start order and reports the first error without skipping
|
||||||
later closes. `main.go` becomes `cfg := …; app, err := app.New(cfg); err =
|
later closes. `main.go` becomes `cfg := …; app, err := app.New(cfg); err =
|
||||||
app.Run(ctx)`.
|
app.Run(ctx)`.
|
||||||
@@ -258,9 +319,14 @@ Exit: `main.go` under 150 lines; the failure-injection test green under
|
|||||||
|
|
||||||
## B3-4 — Hub constructor options (S-11)
|
## B3-4 — Hub constructor options (S-11)
|
||||||
|
|
||||||
Roadmap workstream 6; supplement Phase 3 item 2. Parallel with B3-3 (touches
|
Roadmap workstream 6; supplement Phase 3 item 2. **After B3-3, not
|
||||||
`ws/hub*.go` and the one construction site, not `main.go`'s other blocks —
|
parallel with it:** at `bf7b886d` the production `ws.NewHub` call is inside
|
||||||
coordinate the construction-site line).
|
`api.NewRouter` (`Server/api/router.go:106`), which also wires
|
||||||
|
`SetPluginRegistry`/`SetPluginEventSink` (`:325-328`) and
|
||||||
|
`SetLiveKit`/`SetLiveKitProcess` (`:342-360`), while `main.go:453-454` sets
|
||||||
|
the event persister and store after the router returns. Two construction
|
||||||
|
boundaries means two owners; B3-3 collapses them first (below), then B3-4
|
||||||
|
has one call site to change.
|
||||||
|
|
||||||
1. From B3-0's setter table: each of the seven becomes either a field of
|
1. From B3-0's setter table: each of the seven becomes either a field of
|
||||||
`HubOptions` validated in `NewHub` (required → construction fails without
|
`HubOptions` validated in `NewHub` (required → construction fails without
|
||||||
@@ -268,8 +334,10 @@ coordinate the construction-site line).
|
|||||||
is replaceable at runtime (`SetPendingVoiceModFlags` is the likely
|
is replaceable at runtime (`SetPendingVoiceModFlags` is the likely
|
||||||
survivor; `SetLiveKitProcess` depends on whether the supervised process can
|
survivor; `SetLiveKitProcess` depends on whether the supervised process can
|
||||||
restart).
|
restart).
|
||||||
2. `NewHub` returns `(*Hub, error)`; the single call site (`main.go`, or
|
2. `NewHub` returns `(*Hub, error)`; the single call site — `internal/app/`
|
||||||
`internal/app/` once B3-3 lands) passes everything it used to set. Tests
|
after B3-3, which passes the built `*ws.Hub` into `api.NewRouter` instead
|
||||||
|
of having the router construct it — passes everything the seven setters
|
||||||
|
used to set. Tests
|
||||||
that build a `Hub` use a `testHubOptions()` helper so 170+ test files do
|
that build a `Hub` use a `testHubOptions()` helper so 170+ test files do
|
||||||
not each grow a struct literal.
|
not each grow a struct literal.
|
||||||
3. RED first: a test that constructs a `Hub` without a required collaborator
|
3. RED first: a test that constructs a `Hub` without a required collaborator
|
||||||
@@ -355,10 +423,23 @@ each item is its own PR so none blocks another. Nothing here edits
|
|||||||
`permissions.HasPerm` call in `api/`. B3-8 shrinks the allowlist as
|
`permissions.HasPerm` call in `api/`. B3-8 shrinks the allowlist as
|
||||||
families move.
|
families move.
|
||||||
8. **Docker smoke nightly on `dev` (workstream 16).** `ci.yml` gains
|
8. **Docker smoke nightly on `dev` (workstream 16).** `ci.yml` gains
|
||||||
`schedule: [{cron: "0 3 * * *"}]` and the `Server Docker Build (verify)`
|
`schedule: [{cron: "0 3 * * *"}]`. The `Server Docker Build (verify)`
|
||||||
job's condition becomes `github.ref == 'refs/heads/main' ||
|
job's condition (`ci.yml:520`, today
|
||||||
github.event_name == 'schedule'`; `concurrency` and `timeout-minutes`
|
`github.ref_name == 'main' || github.base_ref == 'main'`) **keeps both
|
||||||
already present (B1-7's guard check enforces both).
|
existing terms** — a PR to `main` runs on the synthetic
|
||||||
|
`refs/pull/<n>/merge` ref and is matched by `base_ref`, not `ref_name` —
|
||||||
|
and adds `|| github.event_name == 'schedule'`. A scheduled run executes
|
||||||
|
the workflow file from the default branch (`main`) and the job's
|
||||||
|
`actions/checkout` has no `ref`, so it would smoke `main`; the checkout
|
||||||
|
step gains `ref: ${{ github.event_name == 'schedule' && 'dev' || '' }}`
|
||||||
|
(empty = the event's own ref, unchanged for pushes and PRs). The nightly
|
||||||
|
therefore builds `dev`'s `Server/` from a workflow definition taken from
|
||||||
|
`main` — acceptable while the job's steps are identical on both branches,
|
||||||
|
and the reason the job is not moved to its own workflow file.
|
||||||
|
`concurrency` and `timeout-minutes` are already present (B1-7's guard
|
||||||
|
check enforces both). Proof before enabling the schedule: a
|
||||||
|
`workflow_dispatch` run with the same `ref` expression and a
|
||||||
|
`git rev-parse HEAD` step showing `dev`'s SHA.
|
||||||
9. **Machine-readable contract drift (workstream 10).** `check:server`
|
9. **Machine-readable contract drift (workstream 10).** `check:server`
|
||||||
already diffs the two generators; this adds `docs/api.md` route-table
|
already diffs the two generators; this adds `docs/api.md` route-table
|
||||||
generation from the mounted router (the absence test's walker, printed as
|
generation from the mounted router (the absence test's walker, printed as
|
||||||
@@ -374,11 +455,20 @@ baseline) in this section's evidence block.
|
|||||||
|
|
||||||
Roadmap workstream 12. Beside the slice.
|
Roadmap workstream 12. Beside the slice.
|
||||||
|
|
||||||
1. `Server/cmd/seed -profile alpha` — deterministic (fixed seed, fixed clock):
|
1. `Server/cmd/seed -profile alpha` — deterministic (fixed seed, fixed
|
||||||
member count, channel count, message volume, attachment count, role and
|
clock). `load-baseline.yml` defines only `users` (default 100) and one
|
||||||
override distribution, DM share and voice-session history matching the
|
channel, so it cannot be the source of the shape; the profile is defined
|
||||||
documented alpha shape (numbers from the load-baseline workflow's
|
here and lives as constants in `Server/cmd/seed/profile_alpha.go`, and
|
||||||
parameters, `load-baseline.yml`, so the two agree). `-confirm-dev` stays
|
`load-baseline.yml`'s `users` default is the one value the two share:
|
||||||
|
**100 users** (4 roles: owner, admin, moderator, member — 1/2/5/92), **12
|
||||||
|
channels** (10 text, 2 voice; 3 with role overrides, 2 with user
|
||||||
|
overrides, 1 archived), **20,000 messages** over 30 simulated days with a
|
||||||
|
diurnal curve, **300 attachments** (image/audio/video/other 60/10/10/20 %,
|
||||||
|
sizes 10 KB–5 MB), **15 % of messages in DMs** across 40 DM pairs,
|
||||||
|
**200 voice sessions** (1–45 min, 2–6 participants), **500 reactions**,
|
||||||
|
**30 invites** (10 revoked), **1 plugin row** (disabled). A number that
|
||||||
|
B3-7 finds unrepresentative is changed in `profile_alpha.go` with the
|
||||||
|
reason in its evidence block — never silently. `-confirm-dev` stays
|
||||||
mandatory.
|
mandatory.
|
||||||
2. One anonymised `v1.2.0-alpha.4` snapshot at `Server/testdata/snapshots/v1.2.0-alpha.4.sqlite`
|
2. One anonymised `v1.2.0-alpha.4` snapshot at `Server/testdata/snapshots/v1.2.0-alpha.4.sqlite`
|
||||||
(Git LFS if over 5 MB; the path documented in `docs/deployment.md`
|
(Git LFS if over 5 MB; the path documented in `docs/deployment.md`
|
||||||
|
|||||||
@@ -1249,7 +1249,10 @@ in this order:
|
|||||||
(PR #1443 = `88c7a824`) and B2-9 — **done 2026-08-29**;
|
(PR #1443 = `88c7a824`) and B2-9 — **done 2026-08-29**;
|
||||||
6. HP-2 — **accepted 2026-08-29**
|
6. HP-2 — **accepted 2026-08-29**
|
||||||
([hp-2-scorecard-2026-08-29.md](hp-2-scorecard-2026-08-29.md)); B2 is
|
([hp-2-scorecard-2026-08-29.md](hp-2-scorecard-2026-08-29.md)); B2 is
|
||||||
closed and B3 opens as described below.
|
closed;
|
||||||
|
7. B3 — **started 2026-08-29**, execution plan
|
||||||
|
[b3-server-architecture-guardrails-2026-08-29.md](b3-server-architecture-guardrails-2026-08-29.md);
|
||||||
|
B3-0 (boundary inventory) is the first step, then the auth slice to HP-3.
|
||||||
|
|
||||||
Do not begin B3 domain extraction, client platform extraction, or browser work
|
Do not begin B3 domain extraction, client platform extraction, or browser work
|
||||||
before HP-2 closes. When it does, B3 opens with the "First actionable slice"
|
before HP-2 closes. When it does, B3 opens with the "First actionable slice"
|
||||||
|
|||||||
Reference in New Issue
Block a user