NextMyDay/PROJECT_PLAN.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

635 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# PROJECT_PLAN.md — MyDay
> **Product:** MyDay — an employee time, absence & reporting app for **Sechpoint**.
> **Owner:** Claus (Sechpoint) · **Architect:** OpenArchitect
> **Created:** 2026-10-13 · **Status:** living document — updated as decisions are made.
>
> **How to read this:** §1–§2 say *what* MyDay is. §3–§7 describe *how it works*, in the order it
> is built: **routing → bootstrap/top admin → auth → onboarding & roles**. §8+ are reference tables.
---
## 1. What MyDay is
A **self-hosted, single-tenant** employee application delivered as a **PWA** (mobile + desktop).
It is *not* a multi-tenant SaaS. Each company runs its own instance with one data boundary.
**Shape:** a workday-style employee tool covering **time & absence**, **reporting**, and the
**company org structure** those two depend on.
**Modular:** a small core (**`MyDay.auth`** — identity · session · the door) plus registered
**modules** mounted at **`/{GUID}/{module}/…`** (§4.6).
---
## 2. Scope
| In scope | Notes |
|---|---|
| **Org structure** | users, the **manager hierarchy**, departments, positions |
| **Time** | clock in/out or timesheets, hours worked |
| **Absence** | leave requests, approvals, balances |
| **Reporting** | aggregations/rollups over the above |
**Out of scope (for now):** payroll · recruiting/ATS · benefits · finance — the rest of Workday.
### 2.1 Future / potential extensions (parked)
- **AI expense processing** — receipts → structured expenses.
- **IDM for other products** — MyDay's identity (GUID · Passkey/TOTP · sessions · roles) as an
**identity provider (SSO/OIDC)** for other Sechpoint products.
> Both are **out of current scope**. Recorded so the identity model is designed with them in mind
> (stable `user_id`, clean credentials, roles) — but **not built**.
---
## 3. Guiding doctrine (adapted)
MyDay adapts two documents from the GMS project — **guidelines, not scope**: **ROUTING.md**
(the URL/cascade model + the one-door front-controller) and **SECURITY.md** (a deferred-risk
register with explicit gates — *"the redesign will solve it"* is how bugs survive).
Carried over:
1. **The path is the request.** Identity lives in the path; the query carries only transient state.
2. **One door.** The door *identifies* and routes; the handler *authenticates* and owns content.
3. **Identification ≠ authorisation.** The GUID is a *username* (public, bookmarkable); the
**session cookie is the credential**. A bookmark is never a login.
4. **The same three rules at every level:** presence (empty → default / 404) · whitelist (known
values) · `default:` (a **classifier** — match a pattern, else 404).
5. **Delete, don't redirect.** Reserved names from one source.
6. **Security debt is explicit and gated.**
> **Divergence from GMS:** GMS threads context between *separate processes* via `X-*-*` headers.
> MyDay is **one binary** — the hand-over is an in-process `RequestContext`. Discipline identical;
> field names kept so a future split is mechanical.
---
## 4. The routing concept
A **dual-mode door** over a **path cascade**. One request resolves to one context — which
principal, which page or action — and then a handler executes it.
### 4.1 The dual dispatch (level 1) — two doors, one destination
```
DOOR (top level)
parse: method · path · query · body
├─ carries routing fields? (do / action)
│ NO → ROUTER : explode path → PARAM[] → cascade (§4.2)
│ YES → HANDLER : do = {module} · action = {function} (§4.3)
└─ resolve identity (GUID) + session + role gate
↓
(module, function, params) → the module's function
```
- The branch is keyed on the **routing fields, not the HTTP method** — a `GET` may carry
`do`/`action` (a read action); a `POST` may be a plain path submit. Method-agnostic is robust.
- Both doors resolve to the **same tuple** `(module, function, params)`: the router reads it from
`PARAM[1..n]`, the handler from `do`/`action`. One registry, one set of module functions.
- The **identity / session / role gate runs before** the module function — a module never sees a
request it shouldn't.
### 4.2 The path cascade
```
/{domain}/ → 302 → /home (the ONE canonical redirect)
/{domain}/home → neutral landing page (public, no user)
/{domain}/access → password / access recovery
/{domain}/setup → the bootstrap wizard (setup mode · loopback only)
/{domain}/api | static | media | ws | dev → reserved infrastructure
/{domain}/holy-shit → 404 (unknown · not reserved · not a GUID)
/{domain}/{GUID} → GUID lookup → user
not found → 404
found · NOT enrolled → ENROLL (mandatory TOTP + Passkey)
found · enrolled · no session → LOGIN (Passkey, or TOTP)
found · enrolled · session → /{GUID}/home
/{GUID}/home → the user landing page
/{GUID}/request → list MY requests (all)
/{GUID}/request/open → list · OPEN view
/{GUID}/request/closed → list · CLOSED view
/{GUID}/request/pto → a new PTO request (form)
/{GUID}/request/equipment → a new equipment request (form)
/{GUID}/request/{request_id} → my request detail (classifier: id → lookup → 404)
/{GUID}/approve → list requests to approve (all)
/{GUID}/approve/open → list · OPEN view
/{GUID}/approve/closed → list · CLOSED view
/{GUID}/approve/{request_id} → view + act on a request (classifier: id → lookup → 404)
```
- The **GUID is always visible** in every authenticated path — identity never leaves the URL.
- **Reserved `PARAM1` = `home · access · setup · api · static · media · ws · dev`** — one source
constant; a GUID can never equal a reserved name. *(`auth` is retired — enrollment is GUID-driven.)*
- **`PARAM2` is a module registry** (§4.6): `home` · `request` · `approve` · `org` · `time` · `absence` · `reports`.
- **Lists & filters:** `…/request/open` · `…/request/closed` · `…/approve/open` · `…/approve/closed`
are **path values** (D-018), so a filtered view is a **bookmarkable page**, not query state.
- **`PARAM3` whitelist under `request`** = `open` · `closed` (views) and `pto` · `equipment` (new
request forms); a **number** falls to the `default:` classifier (id); anything else → 404.
### 4.3 The action mode — two routing fields (`do` + `action`)
A mutation carries **two routing fields**: `do` selects the **module**, `action` the **function**.
```
POST /{GUID}?do={module}&action={function}&… (a module action)
├─ do=hr & action=save-data
├─ do=time & action=clock-in
└─ do=requests & action=submit
POST /access?do=auth&action=request (recovery request)
```
- **Both fields are whitelisted** — `do` → the module registry (unknown → 404); `action` → the
module's action set (unknown → 404). The same three rules as the path.
- The **GUID still rides the path** (identity is never a form field); form **parameters** are
separate fields/query.
- **Not a security control.** Hiding the action in a field is *obscurity*; every action still
requires **session auth + authorisation (ownership / role) + CSRF** (A8).
### 4.4 Worked example — why `home` works at two levels
**It is two switches, not one tag.** The *level* decides which switch runs.
```
LEVEL 1 switch (PARAM1)
"" → 302 /home
"home" → neutral landing ← "home" is a CASE HERE
"access" → recovery
"setup" → the bootstrap wizard (setup mode only)
"api"|"static"|"media"|"ws"|"dev" → infra
default: user = SQL lookup(PARAM1)
not found → 404
not enrolled → ENROLL
no session → LOGIN
else → LEVEL 2 (GUID context established)
LEVEL 2 switch (PARAM2) — reached ONLY inside a resolved branch
# GUID branch:
"" → /{GUID}/home (the branch default)
"home" → user landing ← "home" is a CASE HERE TOO
"request" → LEVEL 3 (open | closed | pto | equipment | {id})
"approve" → LEVEL 3 (open | closed | {id})
default: → 404
```
- Reserved cases are checked **before** the DB: `/home` matches at level 1 and never touches SQL.
- `/{GUID}/home` passes the `default:` classifier first (SQL lookup → user) → enters level 2 →
`PARAM2 = "home"` → the **user's** landing.
- **The enroll/login gate sits between level 1 and level 2** for path requests — a GUID branch is
never dispatched to level 2 unauthenticated.
### 4.5 The GUID lifecycle (enrolled Y/N)
```
a user is created → identity minted (status: pending_enrollment) + GUID
→ the invite carries /{GUID}
→ the enrollment window is BOUNDED (TTL); an un-enrolled GUID is a bearer (A1)
GET /{GUID} → not enrolled → mandatory enroll (Passkey + TOTP) → status: active
→ enrolled → Passkey assertion (TOTP fallback) → session → /{GUID}/home
recovery → NEW GUID minted · OLD identity revoked + GUID tombstoned (never recycled)
```
### 4.6 The module registry — `/{GUID}/{module}/{function}/{param}`
The `PARAM2` switch is a **registry of modules**, not a hard-coded list. Everything the app can do
hangs off a module:
```
/{GUID}/{module}/{function}/{param…}
core registry owned by the module
```
- **`PARAM2` = the module** — a registered name (whitelist); unknown → 404.
- **`PARAM3+` = the module's own cascade** — the same three rules, one level down. The core never
parses a module's internals.
- **The core enforces auth / session / role *before* dispatch** — a module never sees an
unauthenticated request.
- Modules are named **`MyDay.<module>`** (`MyDay.auth`, `MyDay.time`, …).
| | Owns |
|---|---|
| **The core (`MyDay.auth` + the door)** | routing cascade · the registry · identity/GUID · sessions · Passkey/TOTP · roles + the chain · **user management** · audit · CSRF · config |
| **A module** | its routes (functions) · its handlers · **its own tables** · its templates/assets |
- A module depends only on **core interfaces — never on another module directly**.
- A module may contribute to shared extension points: nav entries, **request types**, approval
handlers, report sources.
- **Planned modules:** see the catalogue in **§4.8**.
### 4.7 Drop-in modules & discovery
**A module is a directory.** `internal/modules/<name>/` — self-contained: routes, handlers, **its
own migrations**, templates. Drop it in, and it joins the registry.
**Discovery is at BUILD time, not runtime.** Go has no safe runtime plugin loading (the `plugin`
package needs an exact toolchain match, is Linux-only, and effectively CGO). So, following the
**Caddy** pattern:
- each module **self-registers** into the core registry from its package `init()`;
- a generated aggregator (`internal/modules/imports_gen.go`, from `go generate`) blank-imports every
module directory — so **the directory is the source of truth**;
- adding a module = add the directory → `go generate` → rebuild. **No core edits.**
**Guardrails:**
- **Deterministic order** — modules register in a defined sequence (never map iteration); migrations
and route precedence depend on it.
- **Log the loaded module set at startup** — a visible registry beats hidden magic.
- **A module owns its tables**; migrations come from the module, versioned, applied in order.
- A module may expose an optional `Start(ctx)` for background work (**integration wrappers**, sync).
- A module registers **both** its **routes** (path mode) and its **actions** (`do` / `action` mode).
- **Module manifest** (`app.go`): the module declares its **name**, whether it is **required**, its
**migrations**, and the extension points it contributes.
- **Enable / disable:** optional modules can be switched off from a core **Modules** page
(config-backed, D-038). Disabling **unmounts routes + hides nav — it never deletes data**.
### 4.8 The module catalogue (provisional)
Two kinds: the **core** (not a module) and **modules**.
| Module | Kind | Owns |
|---|---|---|
| **core `MyDay.auth`** | core | identity · sessions · Passkey/TOTP · roles · the chain · the door · the registry · **user management** |
| `org` | records | departments · positions · the richer hierarchy |
| `time` | domain | **presence + hours — one module.** A per-user **mode**: `presence` (in/out/away) or `timesheet` (exact hours, pay-by-hour). Optional **geo check-in** (D-031). |
| `absence` | domain | PTO · leave balances |
| `requests` | workflow | the **issue/ticket engine + approve queue** (pluggable request types) — the "must-have" |
| `hr` | records | simple HR records |
| `expense` | domain | expense claims · receipts |
| `reports` | analytical | read model · rollups |
| `integrations` | integration | wrappers to external apps (later; may need `Start()`) |
**Extension points** a module can contribute to: request types (`requests`), nav entries, dashboard
widgets, report sources.
- **Location (optional, D-031):** **no tracking, no geofencing** — the audit log simply records an
approximate **location derived from the client IP**. A VPN defeats it, and that's accepted.
- **`time` mode (D-030):** `presence` (in/out/away) or `timesheet` (exact hours) — an **employment
attribute** on the user (core/`hr`), read by the `time` module.
> **Naming note:** *user management belongs to the **core**, not a module* — it manages identities,
> roles, and the chain, which the core owns. So "management" decomposes into **core (users)** +
> **`requests` (tickets/approvals)** + **`hr` (records)**.
---
## 5. Bootstrap & the top admin
**There is no separate "super" account.** The **first user is the top admin** — a normal user with
the highest role, the **root of the chain**. When the `users` table is empty, the instance is in
**setup mode**.
### 5.1 First run — the `/setup` wizard
```
fresh deploy → config.setup: true → the app serves ONLY /setup (loopback-only, D-025)
Step 1 the domain / base URL
Step 2 the top admin — name, email, Passkey + TOTP enrollment (user_id = 1, role: admin)
Step 3 mail / SMTP · locale · timezone
Finish → settings persisted → the app runs in PRODUCTIVE MODE (setup: false)
→ /setup now 404s (delete, don't redirect); re-run only via config.setup: true + restart
```
- `setup` is a **reserved `PARAM1`** (always — so no user can ever be named `setup`).
- In setup mode, `/setup` is the **only served route**, and it is **loopback-only** (D-025).
- **It supersedes `{bootstrap_guid}`** — the wizard *is* the bootstrap; the operator runs it once
over an **SSH tunnel** to `127.0.0.1:8080`, then exposes the instance.
- *README: do NOT expose MyDay publicly before the first admin exists.*
### 5.2 Admin recovery — a config-gated enrolment link
```
sysadmin sets the recovery approval flag in config (e.g. admin_recovery: true) → restart
→ the top admin's (user 1) identity is ROTATED → status: pending_enrollment
→ the system EMAILS the enrolment link to user 1's ADDRESS ON RECORD
→ the admin re-enrolls Passkey + TOTP
→ audited · the flag is cleared
```
- **Dual control.** Without the config flag **nothing happens** — no rotation, no email. The
operator holds one half; the admin's **inbox** holds the other.
- The top admin **has an email** (collected by `/setup`, §5.1), so the link has somewhere to go.
The config flag is the *approval*; the inbox is the *delivery*.
### 5.3 Rules of the top admin
- The top admin **cannot be deleted** (by themselves or anyone) — lockout prevention. Only
`reset_admin` changes the account.
- Recommended (O-7): **exactly one** admin at the top; everyone else is director / manager / team.
---
## 6. The auth concept
### 6.1 Identity anchors
| Anchor | Meaning | Rotates? |
|---|---|---|
| **`user_id`** | internal, stable person id (**the first user = 1 = the top admin**); never in a URL | **no** |
| **`guid`** | the access username in the path | **yes** (recovery) |
| **`email`** | contact + recovery authority | no |
- `user_id` relates to the GUID and the email. **Stable user ≠ rotating identity.**
- Credentials hang off an **identity**; only one active identity per user (D-008).
- **GUIDs are never recycled** — a tombstoned GUID stays reserved forever.
### 6.2 The two factors
**Passkey (primary) + TOTP (fallback) — only.** No OTP-by-email as a login factor; no recovery
codes. Email is *never* a login factor; it is only the recovery **channel**.
### 6.3 Enroll vs login — one path, two states
```
GET /{GUID} → found · not enrolled → ENROLL (mandatory Passkey + TOTP)
→ found · enrolled → LOGIN (Passkey assertion, or TOTP) → session
→ not found → 404
```
**The GUID is identification; the session is the credential.** The door resolves *which* user; the
handler proves *you are* them.
### 6.4 Recovery
| Who | Path |
|---|---|
| **team / manager / director** | `/access` → email on record → **manager escalation** (approve/deny) → new GUID |
| **top admin** | **`reset_admin`** config toggle → identity rotated → re-enroll (§5.2) |
```
GET /access → enter email on record
POST /access?do=request → ALWAYS generic ("if a record exists, we've emailed you")
└─ on match: recovery_request(status: pending_approval) · notify approver chain + employee
Approver (a role above the requester) — authenticated session
→ approve / deny (approver ≠ requester, always; both audited)
└─ on approval: revoke OLD identity · mint NEW GUID · email the new link
Employee re-enrolls Passkey + TOTP under the NEW GUID → /{old GUID} permanently dead
```
**Why this is safe:** email compromise alone can only *initiate*; credentials require a **second,
independent human approval** from the chain. With identity rotation (D-007), a compromised inbox
cannot take over an account on its own.
**Edge cases:** approver must be above the requester · never the requester · pending requests
expire (~72h), capped per user.
---
## 7. Onboarding & roles
### 7.1 Roles (KISS)
```
admin → director → manager → team
(top) (employee)
```
| Role | May create | Their manager (default) |
|---|---|---|
| **admin** | director · manager · team | — (root) |
| **director** | manager · team | the admin |
| **manager** | team | a director (or admin) |
| **team** | — | a manager |
**One role per user** — the role **ripples down** (a director can do all a manager and a team member can). No tick-boxes, no ACL (D-034). The **chain** (`manager_user_id`) is separate: it answers *who approves you*, not *what you may do*.
### 7.2 Manager first, then team
**The chain is built top-down**, so a manager always exists before their reports do:
```
the top admin (first user)
└─ director created by the admin (admin approves their recovery)
└─ manager created by the director (the director approves recovery)
└─ team created by the manager (the manager approves recovery)
```
### 7.3 Who may create a user
- **You may create only roles *below* your own** (admin → director → manager → team).
- The **new user's manager defaults to the creator**.
- This **unifies creation authority and recovery authority** on the same chain — no one can invent
a peer or top-level account and sidestep escalation.
### 7.4 The minimal hierarchy (M0)
- A **self-referential `users.manager_user_id`** ships in **M0**, because onboarding *and* recovery
both need to answer *"who is above this person?"* (D-020).
- Departments, positions, and richer org charts come in **M1**.
- Onboarding is **not** manager-approved for the *initial* invite (the creator's authority + the
emailed `/{GUID}` link is the control); **recovery** is the step that requires approval.
---
## 8. Decisions log
| ID | Date | Decision | Rationale / notes |
|---|---|---|---|
| **S-001** | 2026-10-13 | Scope = **org structure + time + absence + reporting**. | Payroll/recruiting/benefits excluded. |
| **S-002** | 2026-10-13 | **Self-hosted, single-tenant.** No tenant `{slug}`. | GMS's `{slug}/{GUID}` collapses to one level. |
| **S-003** | 2026-10-13 | **Language: Go — confirmed, not Rust.** | I/O-bound CRUD + HTTP, self-hosted single binary. The "no runtime plugins" limit is solved by build-time registration (**Caddy** does this); **Rust's dynamic loading is *no better*** (no stable ABI). Go wins on iteration speed, ecosystem (WebAuthn · TOTP · SQLite · sqlc), and IDM precedent. **Rust is reserved** for a future CPU-heavy module/service (e.g. on-device AI) — a contained component, not a rewrite. |
| **D-001** | 2026-10-13 | **The GUID is the username**, at `PARAM1`, **always visible** in the path. | Unguessable (≥128-bit). The **session is the credential**. |
| **D-002** | 2026-10-13 | The door **resolves**; the handler **authenticates**. | A bookmark is never a login. |
| **D-003** | 2026-10-13 | **One binary**, headless door, **in-process `RequestContext`**. | Self-host simplicity; GMS field names kept for a future split. |
| **D-004** | 2026-10-13 | **The path cascade** (§4.2): `PARAM1` = a reserved workflow **or a GUID**; the GUID's namespace carries the app. Empty `PARAM1` → 302 `/home`. | Reserved: `home · access · setup · api · static · media · ws · dev`. |
| **D-005** | 2026-10-13 | **Two factors only: Passkey (primary) + TOTP (fallback).** | No OTP-by-email login factor; no recovery codes. |
| **D-006** | 2026-10-13 | **Enrollment is GUID-driven — no `/auth/enroll/{token}`.** `GET /{GUID}`: N → enroll, Y → login. The invite carries `/{GUID}`. | Un-enrolled GUID is a bearer → **bounded** window (A1). |
| **D-007** | 2026-10-13 | **Recovery rotates the identity:** new GUID, old revoked; **all previous auth records invalid.** | Old GUID tombstoned; sessions/passkeys/TOTP destroyed. |
| **D-008** | 2026-10-13 | **Stable user record ≠ rotating identity.** Credentials hang off an identity. **GUIDs never recycled.** | Employment survives rotation. |
| **D-009** | 2026-10-13 | **Manager escalation.** Every non-admin user sits below someone. | Recovery needs **approval from the chain**. Email *initiates*; a human *authorises*. |
| **D-010** | 2026-10-13 | **`config.yaml` holds config + secrets.** Gitignored; `config.example.yaml` shipped; env override. | The DB is the truth for identities/credentials. |
| **D-011** | 2026-10-13 | **PWA `start_url: "/"`.** The door resolves the session and redirects to the current GUID. | Install survives identity rotation. |
| **D-012** | 2026-10-13 | **SQLite** datastore. | WAL · FK ON · **CGO-free driver** · file backup. **One instance only** (single-writer). |
| **D-013** | 2026-10-13 | **Internal `user_id`** relates to the **GUID** and the **email**; never in a URL. **The first user = 1 = the top admin.** | The person is permanent; the GUID is the revocable door. |
| ~~D-014~~ | 2026-10-13 | ~~Super GUID in config~~ | **SUPERSEDED by D-021/D-022** — no separate super account. |
| ~~D-015~~ | 2026-10-13 | ~~Mandatory first-run super rollout~~ | **SUPERSEDED by D-022** — the first user is the top admin. |
| ~~D-016~~ | 2026-10-13 | ~~Super enable/disable toggle~~ | **SUPERSEDED by D-023** — `reset_admin`. |
| **D-017** | 2026-10-13 | **Dual dispatch:** path requests *or* `do={action}` requests. `do` = hidden form field. | Form state, not identity. **Not a security control** (A8). |
| **D-018** | 2026-10-13 | **List filters ride the PATH**, not the query: `…/request/open` · `…/approve/closed`. | A filtered view is a **page**; deliberate divergence from GMS. |
| **D-019** | 2026-10-13 | **Onboarding is top-down (manager first, then team).** Creation authority = a role **above** the new user; the new user's manager defaults to the creator. | Unifies creation + recovery authority on one chain. |
| **D-020** | 2026-10-13 | **Minimal hierarchy in M0:** self-referential `users.manager_user_id`. | Onboarding and recovery both need the chain; richer org structure is M1. |
| **D-021** | 2026-10-13 | **No super account — the first user is the top admin** (`user_id = 1`, role `admin`, root of the chain). | Removes a standing privileged account. |
| **D-022** | 2026-10-13 | **Bootstrap:** `users` empty → **setup mode**; the top admin is created at a **gated** path; setup ends at the first user. | **Gate it** (loopback and/or setup token) or "first user" = "first visitor". |
| **D-023** | 2026-10-13 | **Admin recovery = `reset_admin` config toggle** — rotate the top admin's identity to `pending_enrollment`, log the new GUID; audited; auto-clears. | The config is the offline-held root of trust. |
| **D-024** | 2026-10-13 | **Roles (KISS): `admin > director > manager > team`.** You may create only roles **below** your own; the new user's manager defaults to the creator. | Answers "who may create a user?" |
| **D-025** | 2026-10-13 | **The bootstrap is loopback-only, enforced.** While in **setup mode** the app **refuses to serve a non-loopback address**; the operator reaches it over an **SSH tunnel** to `127.0.0.1:8080`. Optional `{bootstrap_guid}` = defence-in-depth. | Closes first-visitor-wins **in code**, not only in the README. |
| **D-026** | 2026-10-13 | **A `/setup` web wizard is the bootstrap** (a reserved `PARAM1`). It configures the **domain**, creates the **top admin** (user 1) with Passkey + TOTP, and mail/locale — **setup-mode only · loopback-only** — then the app runs productive. `config.setup` forces the mode. | Supersedes `{bootstrap_guid}`; configuration through a UI instead of hand-editing config. |
| **D-027** | 2026-10-13 | **Modular architecture.** The **core is `MyDay.auth`** (identity · session · roles · the door · the **module registry**); features are **modules** mounted at **`/{GUID}/{module}/{function}/{param}`**. A module depends only on core interfaces and **owns its own tables**. | The natural generalisation of the `PARAM2` switch — the same URL shape now carries a plugin model. Makes `expense`/IDM additive, not invasive. |
| **D-028** | 2026-10-13 | **Drop-in modules, discovered at BUILD time.** A module is a directory (`internal/modules/<name>/`) that **self-registers** via `init()`; a **generated aggregator** blank-imports every module dir, so the directory is the source of truth. **No runtime plugins.** | The **Caddy** pattern. Drop a dir → `go generate` → rebuild. Deterministic order; log the loaded set at startup. |
| **D-029** | 2026-10-13 | **User management is CORE, not a module.** Identities, roles, and the chain live in `MyDay.auth`. | "Management" decomposes into **core (users)** + `requests` (workflow) + `hr` (records). |
| **D-030** | 2026-10-13 | **`presence` and `time` unify into one module (`time`).** A per-user **mode** decides the flow: `presence` (simple in/out/away) or `timesheet` (exact hours, pay-by-hour). | The mode is an **employment attribute** on the user (core/`hr`), read by the module. `presence` becomes a *view*, not a module. |
| **D-031** | 2026-10-13 | **Location = audit only. No tracking, no geofencing.** The audit log records an approximate **location derived from the client IP**. Off by default; a VPN defeats it (accepted). | Deliberately shallow — enough for a security trail, nowhere near geo-tracking. |
| **D-032** | 2026-10-13 | **Actions carry TWO routing fields: `do={module}` + `action={function}`** (both whitelisted). The GUID still rides the **path**. | The PHP-style simplicity for module dispatch (`do=hr&action=save-data`). Modules register routes **and** actions. |
| **D-033** | 2026-10-13 | **Admin recovery: a config approval flag emails the enrolment link to user 1's address on record.** Without the flag, **nothing happens**. | **Dual control** — the operator's config half + the admin's inbox half. Refines D-023. |
| **D-034** | 2026-10-13 | **Roles: simple tiers, NO ACL, ONE role per user.** `admin > director > manager > team`; the assigned role **ripples down** — it implies all lower roles. *(No tick-boxes.)* | KISS — basic levels only, no permission matrix. The **chain** (`manager_user_id`) stays separate from roles. |
| **D-035** | 2026-10-13 | **Settings live in the CONFIG FILE** (infra · secrets · domain · mail · locale). The **DB holds operational data only**. | Owner's call: editing a setting in the file beats a DB migration. Caveat: the app must be able to write `config.yaml` (or the wizard prints the values for the operator). |
| **D-036** | 2026-10-13 | **Layout: separated dirs, one tree** — `internal/core/` (the door · registry · identity · DB) and `internal/modules/<name>/` (**self-contained vertical slices**: routes · actions · handlers · queries · migrations · templates). | The boundary becomes **physical**, and each module stays truly drop-in. Rejected: one flat dir. |
| **D-037** | 2026-10-13 | **Every module has the same default skeleton** — `app.go` (the manifest: name · required · migrations · nav · `Start`) · `routing.go` (routes + actions) · `handlers.go` (the action handlers). More files as needed. | Predictability + drop-in: every module reads the same way. |
| **D-038** | 2026-10-13 | **Modules are enabled/disabled from a core Modules page** (state in config, D-035). Disabling unmounts routes + hides nav; **it never deletes data**. **Required** modules can't be disabled. | Runtime activation of compiled-in modules. Toggles are **audited** (A11). |
| **D-039** | 2026-10-13 | **Two level-1 entry points — ROUTER (path) and HANDLER (actions) — both resolve to `(module, function, params)`.** The branch is keyed on the **routing fields** (`do`/`action`), **not** the HTTP method. Identity + role gate run **before** the module function. | The top-level dispatch, made explicit: **two doors, one destination.** |
| **D-040** | 2026-10-13 | **Agent-workflow variables live in `.opencode/git.env`** (gitignored), with a committed `.opencode/git.env.example`. Git-native settings (identity · remote · branch · pull) live in repo-local `.git/config`. | Two-tier config (SPEC-001 A-04): git owns what git owns; automation knobs stay out of git. |
| **D-041** | 2026-10-14 | **The setup wizard enrolls TOTP only.** Any **TOTP login with no passkey** is **nudged** to enroll one, with *not now* / ***don't ask again*** (`users.passkey_opt_out`). The nudge is **suppressed while the origin is loopback** (or in setup mode). | Resolves **O-13**: a passkey enrolled over loopback binds RP ID `127.0.0.1` and is unusable at the public domain. Passkeys are encouraged, not forced — TOTP stays a valid factor (D-005). |
| **D-042** | 2026-10-14 | **TOTP-only enrollment is specific to the bootstrap admin (`/setup`).** **Invited users enroll both factors** (Passkey + TOTP) at the real origin; the passkey **nudge** remains the safety net for any identity without a passkey. | Resolves **O-14** (owner, Option A): keep the **strongest default** where the origin permits it. |
---
## 9. Parked / open questions
| # | Item | Status |
|---|---|---|
| O-1 / O-2 | First-run bootstrap · super recovery | **SUPERSEDED** by §5 (D-021..D-023) |
| O-3 | Recovery approver before the hierarchy exists | **RESOLVED** by top-down onboarding (D-019): the admin approves the first directors; thereafter the chain. |
| O-4 | Datastore | **RESOLVED** (D-012, SQLite) |
| O-5 | `reset_admin` semantics | **RESOLVED** (D-033): rotate the identity · **config approval flag** · email the enrolment link to user 1's address on record · flag cleared. |
| O-6 | GUID placement for actions | **RESOLVED** (D-032): the GUID **rides the path**; `do` / `action` carry the module/function, never identity. |
| O-7 | One top admin or several | **RESOLVED** — **exactly one** ("the Highlander"). The root of the chain; recovered via D-033. |
| O-8 | Bootstrap gate | **RESOLVED** (D-025): **loopback-only, enforced** · SSH-tunnel first step · optional `bootstrap_guid`. |
| O-9 | "KISS" = the **principle** | **RESOLVED** (D-034): a **simple tiered model, no ACL** — `admin > director > manager > team`; a role **implies all lower roles**; roles assigned via **multi-select tick boxes**. |
| O-10 | Where settings live | **RESOLVED** (D-035): **the config file** — settings are simpler to change there than via a DB migration. The **DB holds operational data only**. |
| O-11 | The `requests` engine (data-model) | **RESOLVED / build-time** — the dispatch is settled (D-032 · D-039). Whether `requests` is a generic engine or `absence`/`time` own their flows is an **internal data-model choice inside the module**, decided at build (M3). **Not a plan-blocker.** |
| O-13 | WebAuthn origin binding at bootstrap | **RESOLVED** (D-041): the wizard enrolls **TOTP only**; the passkey is added at first login on the real origin, via a **nudge** with an opt-out marker. |
| O-14 | Enrollment reach of the passkey nudge | **RESOLVED** (owner, Option A · D-042): TOTP-only is the **bootstrap admin only**; **invited users enroll both factors**. The nudge is the safety net for any identity without a passkey. |
| O-15 | Module names vs reserved level-1 workflows | **RESOLVED** (SPEC-009 §13 · Q-1): **extend the guard** — a discovered module named for a reserved level-1 workflow (`static` · `access` · `api` · `media` · `ws` · `dev`) **fails to load**, **except `home`** (a legitimate module). |
---
## 10. Security register (deferred, gated)
> **THE GATE — re-check every open item before the first real client / beta.** The threat model
> flips from *"nobody has access"* to *"strangers do."*
| # | Item | Sev | Status |
|---|---|---|---|
| A1 | **Enrollment window bounded** — un-enrolled GUID is a bearer; TTL + invite delivery + rate limit | HIGH | designed (D-006) |
| A2 | Empty/deleted session id rejected on **every** path | HIGH | open |
| A3 | `/{GUID}` and `/access` rate-limited; **generic** responses (no enumeration/no timing oracle) | HIGH | open |
| A4 | Recovery requires **manager escalation**; approver above the requester; both audited | HIGH | designed (D-009) |
| A5 | PWA service worker never caches authenticated responses or `/{GUID}` across sessions | MED | open |
| A6 | Trusted-proxy client IP — XFF **set**, not appended | MED | open |
| A7 | **Email = recovery initiation only**; a human must approve | HIGH | designed (D-009) |
| A8 | **Actions (`do=`) are form state, not a control** — server-side authz + CSRF on every action | HIGH | open |
| A9 | TOTP secret **encrypted at rest** (key from config) | HIGH | open |
| A10 | GUID **tombstoned, never recycled** | HIGH | designed (D-008) |
| A11 | Every auth event audited (bootstrap, enroll, login, recovery request/approve/deny, revoke, `reset_admin`) | HIGH | open |
| A12 | Changing the **email on record** is a privileged, audited operation | HIGH | open |
| A13 | **Setup window hardened** — `/setup` served **only** in setup mode; **loopback-only (enforced)**; rate-limited; `/setup` → 404 once productive | HIGH | designed (D-025/D-026) |
| A14 | **Top admin protected** — cannot be deleted; `reset_admin` rotations audited; startup warning while setup mode is reachable | HIGH | open |
| A15 | **Pre-session CSRF** — the anonymous forms (`/setup` · `/access` · enroll/login) need a signed **double-submit** cookie (SPEC-005/006/007) | HIGH | open |
---
## 11. Milestones (provisional)
| # | Milestone | Depends on | Notes |
|---|---|---|---|
| **Prep** | **Repo bootstrap** — git ✅ · `.gitignore` ✅ · `docs/specs/` ✅ · `AGENT.md` ✅ · `README.md` ✅ · `config.example.yaml` ✅ | — | **done** |
| **M0** | **Auth + routing foundation** — router cascade, door, identity, session, GUID enroll/login, **`/setup` wizard** + top admin, roles, recovery, minimal `manager_user_id` | Prep | **✅ done** (SPEC-002…008) |
| **M1** | **Org structure** — departments, positions, richer hierarchy | M0 | enriches the chain |
| **M2** | **Time** — clock/timesheets | M1 | kiosk/shared-device mode is a design concern here |
| **M3** | **Absence** — leave, approvals, balances | M1, M2 | `request/pto` lands here |
| **M4** | **Reporting** — rollups and aggregations | M2, M3 | own read model; likely its own workflow |
---
## 12. Repository & project setup (planned)
**Not built yet** — these are planned artefacts, to be created at the **Prep** step before M0.
| Artifact | Purpose |
|---|---|
| **git repo + `.gitignore`** | `git init`; the real `config.yaml` is excluded **from the very first commit** (D-010); `config.example.yaml` is committed. |
| **`AGENT.md`** | the repo's **operating instructions for AI agents**: what MyDay is, the doctrine (§3–§4), the **rule of the door**, the stack, and a pointer to **this plan as the source of truth**. |
| **`README.md`** | human quickstart — build, run, config, **and the first-run rule: do NOT expose MyDay publicly before the first admin exists; bootstrap over an SSH tunnel to `127.0.0.1:8080`.** |
| **`docs/`** | this plan at the root; specs later in `docs/specs/`. |
| **CI (later)** | build + test on push. |
### 12.1 Layout (D-036)
```
myday/
cmd/myday/ entrypoint — assembles config → core → modules → server
internal/core/ THE CORE (MyDay.auth)
routing/ the pure routing model (no HTTP, no DB)
httpx/ door helpers (session cookie · client IP · redaction)
auth/ identity · session · passkey · TOTP
users/ users · roles · the manager chain
module/ the module contract + the registry
store/ the DB connection + the migration runner
web/ the shared shell / layout
internal/modules/ first-party modules — ONE DIR EACH (drop-in)
expense/ app.go · routing.go · handlers.go (default skeleton)
+ queries.go · migrations/ · web/ (as needed)
time/ absence/ requests/ org/ hr/ reports/ integrations/
docs/
config.example.yaml
AGENT.md README.md PROJECT_PLAN.md
```
**The core owns the DB connection and the migration runner; each module owns its own tables,
queries, and migrations** (embedded, applied in registration order). Templates: the shared
shell/layout lives in `core/web`; a module's views live with the module.
### 12.2 Git integration
- **Branching:** `main` is the source of truth; work happens on short-lived feature branches.
- **Commits:** **Conventional Commits** (`feat:` · `fix:` · `docs:` · `chore:` …), one logical change each.
- **The agent commits AND pushes at every submilestone** (each spec slice or revision) once the gate
(`build` · `vet` · `test -race`) is green; if a decision changes, **this plan is updated in the same commit**.
- **Secrets are never committed** — `config.yaml` is gitignored from commit one.
- **The agent git contract** is `.opencode/git.env` (gitignored); `.opencode/git.env.example` is
committed. Git-native settings are **repo-local** (`.git/config`) — **never** a global identity.
- **Remote & credentials:** the remote URL is **clean** (no token); the token lives in
`~/.git-credentials` (mode `600`). **Rotate any token ever transmitted in plaintext.**
## 13. Conventions
- **Language/stack:** Go; **single binary**; **SQLite** (WAL, foreign keys ON, CGO-free driver);
`sqlc`; embedded migrations; `slog` with a correlation id.
- **Docs:** specs live in `docs/specs/` (index: `docs/specs/README.md`); this file is the decision log of record.
- **Source doctrine:** GMS `ROUTING.md` + `SECURITY.md` (references, not scope).
- **Rule of the door:** the door resolves; the handler authenticates; the handler never parses a raw path.
---
## 14. Change log
| Date | Change |
|---|---|
| 2026-10-13 | Created. Logged S-001..S-002, D-001..D-011; auth model, security register, milestones. |
| 2026-10-13 | SQLite (D-012); `user_id`/GUID/email anchors (D-013); super account (D-014..D-016). |
| 2026-10-13 | **Routing corrected** — GUID at `PARAM1`, always visible (D-004); enrollment GUID-driven (D-006); dual dispatch + `do=` (D-017); `home` = two switches; list filters on the path (D-018). |
| 2026-10-13 | **Reordered for build sequence** — routing → super → auth → onboarding; manager-first (D-019); minimal M0 hierarchy (D-020). |
| 2026-10-13 | **Super replaced by the first user = top admin** (D-021); gated bootstrap (D-022); `reset_admin` recovery (D-023); roles `admin>director>manager>team` (D-024). Opened O-5..O-9. |
| 2026-10-13 | Added **§12 Repository & project setup (planned)** — git bootstrap, **`AGENT.md`**, README, git workflow; **Prep** milestone added before M0. *(Planning only — nothing built.)* |
| 2026-10-13 | **Bootstrap made loopback-only, enforced (D-025)** — closes first-visitor-wins in code; README documents the SSH-tunnel first step. Resolved O-8; A13 → designed. |
| 2026-10-13 | **`/setup` wizard is the bootstrap** (D-026) — reserved route, setup-mode-only, loopback-only; configures domain + top admin + mail/locale; supersedes `{bootstrap_guid}`. Added **§2.1 future extensions** (AI expenses · IDM); opened O-10. |
| 2026-10-13 | **Modular architecture (D-027)** — core = `MyDay.auth`; modules mounted at `/{GUID}/{module}/{function}/{param}`; added **§4.6 the module registry** (core/module split, module isolation). Opened O-11 (module boundaries). |
| 2026-10-13 | **Drop-in modules, build-time discovery (D-028)** — Caddy pattern: self-registering dirs + a generated aggregator; no runtime plugins. **User management is core (D-029).** Added **§4.7 discovery** + **§4.8 the module catalogue** (`org` · `time` · `presence` · `absence` · `requests` · `hr` · `expense` · `reports` · `integrations`). |
| 2026-10-13 | **Language confirmed: Go, not Rust (S-003)** — the plugin limit is solved by build-time registration; Rust's dynamic loading is no better. **`presence` + `time` unified** with a per-user mode (D-030); **geo restored as an optional, consent-gated geofenced check-in** (D-031). |
| 2026-10-13 | Closed O-5 and reshaped D-031/O-11. **Actions = `do` + `action`** two-field routing (D-032); **admin recovery = config flag → email the enrolment link to user 1** (D-033, dual control); **location reduced to audit-only (IP-derived)** (D-031 revised); O-11 now purely the `requests` data-model question. |
| 2026-10-13 | Closed **O-7** (one admin — the Highlander), **O-9** (KISS = principle; tiered roles, no ACL, D-034), **O-10** (settings in the config file, D-035). Added **D-036 layout** (separated `core/` + `modules/`), rewrote **§12.1**. |
| 2026-10-13 | **D-034 amended** — one role per user, ripple-down, **no tick-boxes**. **D-037** module skeleton (`app.go` · `routing.go` · `handlers.go`); **D-038** Modules page (enable/disable, required modules, never deletes data); §12.1 shows the module skeleton. |
| 2026-10-13 | **Level-1 dispatch made explicit (D-039)** — ROUTER (path) vs HANDLER (`do`/`action`), keyed on the **routing fields, not the method**; both resolve to `(module, function, params)`. **O-11 closed** as a build-time data-model detail. **The open list is now empty.** |
| 2026-10-13 | **Prep artefacts written** — `AGENT.md` · `README.md` · `config.example.yaml`. **D-040** the `.opencode/git.env` contract (SPEC-001 A-04); §12.2 updated with the git/credential contract; Prep marked done. |
| 2026-10-13 | **M0 specs started** — **SPEC-002** (the routing model · the Stage-0 gate) · **SPEC-003** (the core door) · **SPEC-004** (identity & sessions) · `docs/specs/README.md` index. Next: SPEC-005 (`/setup`), SPEC-006 (enroll/login), SPEC-007 (roles/chain/recovery), SPEC-008 (registry). |
| 2026-10-13 | **M0 spec set COMPLETE** — **SPEC-005** (`/setup` wizard) · **SPEC-006** (enroll/login) · **SPEC-007** (roles/chain/recovery) · **SPEC-008** (module registry + Modules page). All eight specs are *Ready for implementation*; the index reflects it. |
| 2026-10-13 | **SPEC-002 ✅ IMPLEMENTED** — `go.mod` · `cmd/myday` stub · `internal/core/routing` (pure model + all 22 test rows + a real purity assertion). `build`/`vet`/`test` green. Builder's D-1..D-6 rulings accepted and recorded in SPEC-002 §7. **Next build slice: SPEC-004.** |
| 2026-10-13 | **SPEC-004 ✅ IMPLEMENTED** — `core/store` (modernc SQLite · WAL · migrations) · `core/audit` · `core/users` · `core/auth` (identities · sessions · AEAD TOTP sealer). AC-1..8 green, `-race` clean, **static CGO-free binary**. **R-1 ruling**: audit metadata threads via `context.Context`; SPEC-004 §5 rewritten. Next: **SPEC-003**. |
| 2026-10-13 | **SPEC-003 ✅ IMPLEMENTED** — `core/config` · `core/httpx` · `core/middleware` · `core/server` (the door) · `main` wired. AC-1..9 green, static binary, loopback fail-closed, live-smoked. Rulings: **R-4** (context actor for identity ops) · **R-5 → A15** (pre-session CSRF, also for enroll/login). Next: **SPEC-006**. |
| 2026-10-13 | **SPEC-006 ✅ IMPLEMENTED** — WebAuthn (`webauthn.Service`, fake in tests) · TOTP (sealed secret) · the GUID state machine in `auth/flow` · pre-session double-submit CSRF (**A15**) · enrollment window. AC-1..7 green, `-race` clean, live-smoked. **R-4** applied. **F-1** (login screen 200) → folded into SPEC-005; **SPEC-009** (web/PWA) recorded. Next: **SPEC-005**. |
| 2026-10-13 | **F-1 FIXED + SPEC-005 ✅ IMPLEMENTED** — the **login screen returns 200** (a rendered page; only a *failed credential* is 401/400). The **`/setup` wizard** (`core/setup`): setup mode is **latched at startup** (`config.setup` OR not-yet-bootstrapped) and cleared by an atomic `Done()` — so it holds through the whole wizard and avoids a per-request DB hit / data race; the door serves **only `/setup`** (+ static) in setup mode; steps domain → top admin (**reusing SPEC-006's enroll ceremony**, `flow.Deps.SuccessPath`) → mail/locale; persists `config.yaml` with `setup: false` (or **prints** when unwritable). One admin only; pre-session CSRF (**A15**) on every step. AC-1..6 green, `-race` clean, static binary, live-smoked (incl. the fail-safe `setup:false`/empty-DB path). Additive: `Identities.ByUserID` · `config.Marshal`. Class B flagged (secrets not generated; WebAuthn RP built at startup). Next: **SPEC-007**. |
| 2026-10-13 | **SPEC-005 rulings** — **R-6**: the wizard **generates strong secrets** when a secret is empty/placeholder. **F-2**: `Cache-Control: no-store` on the enroll/TOTP (`otpauth`) responses. **O-13 opened** (§9): a passkey enrolled over plain-HTTP loopback binds RP ID `127.0.0.1` — unusable at the public domain; **owner decision**. Next: **SPEC-007**. |
| 2026-10-13 | **SPEC-007 ✅ IMPLEMENTED** — `core/mail` (fake + SMTP) · `core/recovery` (`/access` flow · **D-033** admin path) · `core/server/roles.go` (the **D-034** RoleGate policy). AC-1..6 green, `-race` clean, live-smoked (generic response · admin flag on/off). **Step 0** (R-6 secrets · F-2 `no-store`) landed. **R-7 ruling**: recovery approval is the **chain** (manager upward) or the admin, never "role above" — folded into SPEC-008. Next: **SPEC-008**. |
| 2026-10-13 | **SPEC-008 ✅ IMPLEMENTED → M0 COMPLETE.** `core/module` (contract · registry · Modules page) · `internal/modules/` (the `go generate` discovery + the reference `home` module) · wiring. AC-1..6 green, `-race` clean, static binary, live-smoked (a dropped module registered with **no core edits**). **R-7** applied. Follow-ups: module **name-collision guard** · a neutral `handoff` package (invert `module → server`) · nav rendering is SPEC-009. **All M0 specs (002–008) implemented.** |
| 2026-10-14 | **O-13 RESOLVED (D-041).** The setup wizard enrolls **TOTP only**; any **TOTP login with no passkey** is **nudged** to add one (*not now* / *don't ask again* → `users.passkey_opt_out`). The nudge is **suppressed on loopback / in setup mode** (a `127.0.0.1` passkey is dead at the public domain). SPEC-005/006/004 revised. |
| 2026-10-14 | **D-041 IMPLEMENTED.** Migration `0002_passkey_opt_out.sql` · `users.PasskeyOptOut`/`SetPasskeyOptOut` · `/setup` step 2 enrolls **TOTP only** (`flow.Deps.TOTPOnlyEnroll`, the wizard's own flow) · the **passkey nudge** after a TOTP login (screen `GET /{GUID}/auth/passkey`, actions `passkey.add`/`later`/`never`, session-gated). AC-8…AC-11 + revised setup ACs green, `-race` clean, static binary. |
| 2026-10-14 | **D-041 rulings** — `passkey.later` is a skip (nothing persisted, **not** audited); `passkey.never` / `passkey.add` **are** audited. The nudge fires only on the **active-identity** TOTP branch; any lookup error **fails safe** (no nudge). **O-14 opened** (§9): the nudge's reach — keep both-factor onboarding, or go **TOTP-first universal**. |
| 2026-10-14 | **O-14 RESOLVED (D-042, Option A)** — TOTP-only enrollment is the **bootstrap admin only**; **invited users enroll both factors**; the nudge is the safety net. **SPEC-009 (web layer & PWA)** written — the thin *make-it-usable* slice (shell · nav · vanilla WebAuthn JS · manifest · a service worker with **no authenticated caching**, A5); folds in the SPEC-008 collision guard. **Next: build SPEC-009.** |
| 2026-10-14 | **SPEC-009 ✅ IMPLEMENTED → M0.1 (human-usable).** `internal/core/web/` — the **shell** (`layout.html` · `NavLister`/`Render` · nav from `Registry.Nav()`, D-038) and the embedded assets (`app.css` · **`webauthn.js`** — the three ceremonies, vanilla, **no inline scripts** · `sw.js` + the `CacheablePrefixes` policy, **A5** · `manifest.webmanifest` with **`start_url: "/"`**, D-011 · icons) · the reserved **`static`** workflow (`core/server/static.go`) · every screen restyled inside the shell (`/setup` · enroll · login · nudge · dashboard · `/access` · Modules page · `/home`) · **Step 0 the module name-collision guard** (`module.CoreDispatchNames` · `ErrCoreDispatchName` — a colliding module now **fails to load**, error + log, SPEC-008 §9 D-9). AC-3…AC-7 asserted **without a browser**; live-smoked end to end from a copy of `config.example.yaml` (bootstrap → TOTP login → **nudge** → dashboard → restart). `-race` clean · static CGO-free binary · `routing` purity intact. **D-1 (flag):** the **TOTP key is now generated at startup** — D-041 enrolls TOTP *before* `finish` persists the R-6 secrets, so a fresh copy of the example could never complete `/setup` ("no TOTP sealer configured"); the ruling R-6 is unchanged, only the timing. **D-2:** `home` is excluded from the collision guard (the dashboard *is* the discovered reference module). **O-15 opened** (§9): should a module named for a reserved level-1 workflow be refused too? |
| 2026-10-14 | **SPEC-009 rulings (§13)** — **D-1 accepted** (the TOTP key is generated at **startup**; it fixed a real blocker — a fresh `config.example.yaml` could never complete `/setup`); **D-2 accepted** (the collision guard = the core dispatch modules `auth·setup·recovery·core`; `home` is a level-1 *workflow*). **O-15 RESOLVED**: extend the guard to the reserved level-1 names, **except `home`**. Q-3 → a **README caveat** (reach loopback via `localhost`). Guard extension + README caveat folded into the next slice. |
| 2026-10-13 | **SPEC-008 ✅ IMPLEMENTED + Step 0 (R-7)** — the **module registry** & the core **Modules page**: `core/module` (the contract · Registry · page) · `internal/modules/` (**build-time discovery**, D-028: a `go generate` tool writes `imports_gen.go` from the directories) · the reference required **`home`** dashboard module · `main` wiring (load → migrations → mount → log). **R-7 landed**: `users.CanApprove(approver, requester, approverIsAncestor)` — approval authority is the **chain** (manager upward) or the admin (`recovery.isAncestor`). AC-1..6 green, `-race` clean, static binary, live-smoked; `core/routing` purity intact. **M0 complete.** Next: **M1 — org structure**. |