- PROJECT_PLAN.md: the decision log of record through D-042 and the M0.1 slice - docs/specs: SPEC-002…009 — routing · door · identity/sessions · /setup · enroll/login · roles/chain/recovery · module registry · the web layer & PWA (plus docs/specs/README.md, the index) - AGENT.md: the working rules — commit AND push at every submilestone (gate green), Conventional Commits, never commit secrets - README.md: the quickstart, the first-run/SSH-tunnel steps and the localhost/loopback caveat (SPEC-009 §13 Q-3): with an https base URL the CSRF/session cookies are Secure, so reach the app at localhost/127.0.0.1 - config.example.yaml: the settings template (D-035)
5.5 KiB
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/<name>/that self-registers viainit(). - A generated aggregator (
internal/modules/imports_gen.go, fromgo 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)
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;
requestsper 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 <module>/<version>; 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.