# SPEC-008 — module registry & the Modules page | Field | Value | |---|---| | **Status** | **Implemented** (2026-10-13) — see §9; `build`/`vet`/`test`/`-race` green · static binary · live-smoked | | **Created** | 2026-10-13 | | **Author** | OpenArchitect | | **Target milestone** | **M0** | | **Governing decisions** | **D-027** · **D-028** · **D-036** · **D-037** · **D-038** · `PROJECT_PLAN.md` §4.6–4.8 | | **Related** | SPEC-002 (routing) · SPEC-003 (door) | --- ## 1. Goal The **module registry** — build-time discovery, the module contract, and the core **Modules** page that **enables/disables** optional modules. Modules are **compiled in**, activated at runtime. ## 2. Non-goals Module internals (each module owns its routes, handlers, tables). Third-party/WASM plugins (future). ## 3. Discovery (D-028) — the Caddy pattern - A module is a package under `internal/modules//` that **self-registers** via `init()`. - A **generated aggregator** (`internal/modules/imports_gen.go`, from `go generate`) blank-imports every module dir — **the directory is the source of truth**. - Adding a module = add the dir → `go generate` → rebuild. **No core edits.** - **Deterministic order** (defined sequence, never map iteration) — migrations and precedence depend on it. **Log the loaded set at startup.** ## 4. The module contract (`app.go`) ```go type Module interface { Name() string // the PARAM2 segment Required() bool // false = optional (can be disabled) Migrations() []Migration // the module's own schema, versioned Routes() []Route // path-mode functions (§4.2) Actions() []Action // handler-mode functions (§4.3) Register(r *Registry) error Start(ctx context.Context) error // optional — background work (integrations) } ``` ## 5. The module skeleton (D-037) ``` internal/modules/expense/ app.go the manifest (above) routing.go routes + actions handlers.go the action handlers queries.go its own tables (sqlc) — as needed migrations/ its own schema (embedded) — as needed web/ its templates/partials — as needed ``` ## 6. Enable / disable (D-038) - State lives in **`config.yaml`** → `modules:` map (D-035). - **Disabled** → the module is **not in the registry**: its routes return **404**, its nav is hidden. **Its data is never deleted.** Re-enabling restores it. - **Required** modules (the core; `requests` per the "must-have" call) **cannot be disabled**. - **Toggles are audited** (A11). Applied at **startup**; a live remount is out of scope. ## 7. The Modules page (core) - Lists every module: name · kind · required · enabled. - Toggling writes `config.yaml` (the D-035 caveat: needs write access, else "edit and restart"). - A required module shows as **locked**. ## 8. Acceptance criteria | # | Criterion | |---|---| | AC-1 | A new module dir + `go generate` + rebuild → it appears, with **no core edits** | | AC-2 | Registration order is deterministic; the loaded set is logged at startup | | AC-3 | Disabling a module → its routes 404 and its nav hides; **its tables are intact** | | AC-4 | A **required** module cannot be disabled (API + UI) | | AC-5 | Each module's migrations run in registration order; the core owns the runner | | AC-6 | Every enable/disable is audited (A11) | --- ## 9. Implementation notes (accepted rulings) Built 2026-10-13. `go build` · `go vet` · `go test` (incl. `-race`) green — old and new packages; static CGO-free binary; live-smoked (startup logs the loaded set; setup mode still serves only `/setup`). `internal/core/routing` purity intact. **Files:** `internal/core/module/` (the contract + registry + Modules page) · `internal/modules/{generate.go,imports_gen.go,gen/}` (the `go generate` discovery tool) · `internal/modules/home/` (the reference required module) · `cmd/myday/main.go` (wiring). | # | Choice made by the builder | Ruling | |---|---|---| | **D-1** | The contract lives in `internal/core/module`; a module's manifest is `app.go`. | **Accepted** — matches D-036/D-037. | | **D-2** | `Routes()`/`Actions()` are **declarative**; `Register(r)` is the self-registration hook. | **Accepted.** | | **D-3** | `module` imports `server` (for `Handler`/`RequestContext`); no cycle, but the registry depends on the door. | **Accepted for M0**; a neutral `handoff` package to invert the layering is a **follow-up**. | | **D-4** | `Kind()` is optional (`KindOf`); default `module`. | **Accepted.** | | **D-5** | Migrations run for **enabled** modules only, versions prefixed `/`; disabling never drops tables. | **Accepted.** | | **D-6** | The Modules page is **admin-only** (`GET /{GUID}/core/modules` · `POST /{GUID}?do=core&action=modules.toggle`). | **Accepted** — only the admin toggles modules. | | **D-7** | Toggles write `config.yaml` and apply **at next start** (D-038). | **Accepted.** | | **D-8** | `Registry.Nav()` is the nav seam; rendering is SPEC-009. | **Accepted.** | | **D-9** | Core dispatch modules take precedence over a same-named discovered module; **no guard yet**. | **Fix** — add a **name-collision guard** (a colliding discovered module must **fail to load**, error + log). **Follow-up.** | **Step 0 (R-7) landed with this slice** — recovery approval authority is the **chain**: `users.CanApprove(approver, requester, approverIsAncestor)`, with the walk in `recovery.isAncestor`. `users_test.go` and `spec007_recovery_test.go` cover it.