- 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)
107 lines
5.5 KiB
Markdown
107 lines
5.5 KiB
Markdown
# 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`)
|
||
|
||
```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.
|