NextMyDay/PROJECT_PLAN.md
Claus Lohmar 04c2394c34 docs: record the M0 review fixes and correct the README
REVIEW-001 triage updated (H1, H2, M1c, M1s, L3s, L1c/L2s, L8c, M2s, M3s,
M2c, L1s, L5s, L4s, L7c marked FIXED; L3c, L4c, L5c, L6c stay open with the
reason), and each fix is tied to its code and test.

- SPEC-003: the door pipeline (XFF right→left, the per-request setup gate,
  the isolated hardening headers, the pinned setup module), §6 the `__Host-`
  cookie and the startup-generated signing keys, §7 loopback enforcement
  per request, new AC-10…AC-12, D-2 amended (session_key removed), and a new
  §11 that maps every REVIEW-001 finding to its fix.
- SPEC-005 / SPEC-006 / SPEC-007: the same three points where they belong
  (the wizard's loopback + module pin, the nudge's configured-origin signal,
  the async /access notification ruled in).
- PROJECT_PLAN §10 register: A13 re-stated as enforced (per request + pin);
  A2/A5/A8 re-stated as enforced where the review verified them; A3/A6/A9/A15
  now enforced by this pass; A11 left open (L4c). §14 change log entries.
- README: the status said "planning / pre-alpha · nothing is built" — it is
  stale. State M0+M0.1, note that /setup PERSISTS config.yaml and a restart
  adopts the generated secrets, and state the per-request loopback rule.
2026-10-14 11:30:17 +00:00

56 KiB
Raw Blame History

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

2026-10-14 — REVIEW-001 (M0, independent): the identity/session/CSRF core was sound; the perimeter was not. H1 · H2 · M1c · M1s · L3s · L1c/L2s · M2s · M3s · L1s · L5s · L4s · L7c are fixed (see docs/reviews/review-001-m0.md); L3c · L4c · L5c · L6c remain open.

# 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 enforced (M0 review verified)
A3 /{GUID} and /access rate-limited; generic responses (no enumeration/no timing oracle) HIGH enforced — limiter pruned & capped (M3s); /access notification is async (M2s)
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 enforced (M0 review verified)
A6 Trusted-proxy client IP — XFF set, not appended MED enforced — XFF resolved right→left, first untrusted hop (H1)
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 enforced (M0 review verified)
A9 TOTP secret encrypted at rest (key from config) HIGH enforced — key generated at startup (D-1/R-6); replay refused (L5s)
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 (L4c: Sessions.GC · UpdateWebAuthnSignCount unaudited)
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 per request (H2); the setup module pinned at the door (M1c/L3s); rate-limited; /setup → 404 once productive HIGH enforced
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 enforced — signing keys generated at startup in setup mode (M1s)

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.
2026-10-14 REVIEW-001 (M0) written — two independent passes (correctness · adversarial security). Verdict: the identity/session/CSRF core is sound (no auth bypass, no session forgery, no CSRF gap, no SQLi, no traversal) but the perimeter is weak: H2 (loopback enforced only at bind, not per request) and H1 (XFF resolved leftmost-untrusted, forgeable through an append-style proxy) are HIGH; plus M1c/M1s/M2s/M3s/M2c and L1c/L3s/L1s/L5s/L4s/L7c/L8c. docs/reviews/review-001-m0.md; register rows re-opened where the review contradicted "enforced".
2026-10-14 REVIEW-001 FIX PASS (security). H2 — a per-request loopback gate in dispatch while in setup mode (a non-loopback resolved client IP → 404; the bind check alone is defeated by a reverse proxy — the exact first-visitor-wins D-025 claims to close); SPEC-003 §7 + AC-10, A13 → enforced. H1 — ClientIP walks XFF right→left and returns the first untrusted hop, falling back to the peer (A6/AC-12); the unit test that encoded the vulnerable expectation is rewritten. M1c/L3s — the setup module is pinned at the door: serveSetupAction rejects Module != "setup", and any other resolution naming it 404s in every mode instead of falling through to serveGUID. M1s — the CSRF/cookie signing keys are generated at startup in setup mode (mirroring the TOTP key, D-1/R-6), so the wizard's ceremony is never signed with a shipped public placeholder; finish persists the same values. M2s — the /access notification runs off the request path (a Dispatch seam, go fn() by default, context detached) so a hit is not timed against a miss (A3). M3s — the rate limiter's state map is pruned (time-gated sweep at a size threshold) and capped (memory-DoS). L1s — the session cookie is __Host-myday_session over https (conditional: a browser rejects __Host- without Secure, which would break the http loopback bootstrap). L5s — TOTP replay refused: the highest accepted counter per identity is tracked (reset on re-enrollment). L4s — Permissions-Policy · Cross-Origin-Opener-Policy · Cross-Origin-Resource-Policy. L7c — the nudge's origin signal is the configured base_url host (falling back to the request Host), so a proxy rewriting Host cannot suppress it. SPEC-003 amended (§4/§6/§7/§9/§10 + §11 the fix table).
2026-10-14 REVIEW-001 FIX PASS (correctness & cleanup). L1c/L2s — dead secrets.session_key removed (config.go · setup.go · config.example.yaml; SPEC-003 D-2 amended). L8c — README corrected (M0/M0.1 status; /setup persists config.yaml and a restart adopts the generated secrets; the per-request loopback rule stated). M2c — an automated restart-survival test (cmd/myday/main_test.go) generates the TOTP key at startup, enrolls a secret, persists via the wizard, reopens the DB with the persisted key and asserts the secret still unseals — the D-1 invariant, previously covered only by a manual smoke. Gate green (build · vet · test -race), static CGO-free binary, routing purity intact; the H2 guard was reverted to confirm its test fails without it. Still open (deferred, low): L3c · L4c · L5c · L6c.