NextMyDay/PROJECT_PLAN.md

605 lines
40 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.** |
---
## 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.** |
---
## 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 |
---
## 11. Milestones (provisional)
| # | Milestone | Depends on | Notes |
|---|---|---|---|
| **Prep** | **Repo bootstrap** — `git init` · `.gitignore` · `config.example.yaml` · **`AGENT.md`** · `README.md` · commit workflow | — | **planning artefact — done first** |
| **M0** | **Auth + routing foundation** — router cascade, door, identity, session, GUID enroll/login, **`/setup` wizard** + top admin, roles, recovery, minimal `manager_user_id` | Prep | *starting after Prep* |
| **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 its own slices**; if a decision changes, **this plan is updated in the same change**.
- **Secrets are never committed** — `config.yaml` is gitignored from commit one.
## 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/`; 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.** |