40 KiB
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:
- The path is the request. Identity lives in the path; the query carries only transient state.
- One door. The door identifies and routes; the handler authenticates and owns content.
- Identification ≠ authorisation. The GUID is a username (public, bookmarkable); the session cookie is the credential. A bookmark is never a login.
- The same three rules at every level: presence (empty → default / 404) · whitelist (known
values) ·
default:(a classifier — match a pattern, else 404). - Delete, don't redirect. Reserved names from one source.
- 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-processRequestContext. 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
GETmay carrydo/action(a read action); aPOSTmay be a plain path submit. Method-agnostic is robust. - Both doors resolve to the same tuple
(module, function, params): the router reads it fromPARAM[1..n], the handler fromdo/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. (authis retired — enrollment is GUID-driven.) PARAM2is a module registry (§4.6):home·request·approve·org·time·absence·reports.- Lists & filters:
…/request/open·…/request/closed·…/approve/open·…/approve/closedare path values (D-018), so a filtered view is a bookmarkable page, not query state. PARAM3whitelist underrequest=open·closed(views) andpto·equipment(new request forms); a number falls to thedefault: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:
/homematches at level 1 and never touches SQL. /{GUID}/homepasses thedefault: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, fromgo 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/actionmode). - 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.
timemode (D-030):presence(in/out/away) ortimesheet(exact hours) — an employment attribute on the user (core/hr), read by thetimemodule.
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
setupis a reservedPARAM1(always — so no user can ever be namedsetup).- In setup mode,
/setupis 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 to127.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_adminchanges 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_idrelates 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_idships 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. |
| 2026-10-13 | SUPERSEDED by D-021/D-022 — no separate super account. | ||
| 2026-10-13 | SUPERSEDED by D-022 — the first user is the top admin. | ||
| 2026-10-13 | 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:
mainis 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.yamlis gitignored from commit one.
13. Conventions
- Language/stack: Go; single binary; SQLite (WAL, foreign keys ON, CGO-free driver);
sqlc; embedded migrations;slogwith 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. |