NextMyDay/docs/specs/spec-008-module-registry.md
Claus Lohmar 57610cdbae docs: add the project plan, M0/M0.1 specs, agent guide and README
- 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)
2026-10-14 10:53:44 +00:00

5.5 KiB
Raw Permalink Blame History

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 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)

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 <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.