# As built

Written 4 August 2026, against `dev` at `859e97e`, and amended 5 August for
`/kpi` (`9b3b412`). Every claim below was checked against the code at those
commits, not against memory or earlier documents. The further you are from
them, the more you should verify before relying on a detail.

## What this is

An internal task tracker for a Thai jewellery factory. The facts that explain
most of the decisions in this codebase, and without which much of it reads as
arbitrary:

- **193 people in 46 departments**, imported from the client's own export
  (`data/employees.tsv`). Each person holds one position; the position, not the
  person, owns the job description and the recurring duties.
- **183 of the 193 read Thai.** Every interface string exists in English and
  Thai, names and titles stay exactly as the roster spells them, and Thai
  screens show Buddhist Era years (2569, not 2026).
- **The factory network is air-gapped.** No internet, no email, no CDN, no
  cloud service. Passwords are handed over on paper by HR. Everything ships as
  files, and the deployment target is three Raspberry Pis on the LAN
  (`deploy/compose.app.yaml`).
- **Terminals are shared.** A browser is nobody's own, so nothing personal is
  stored in it: language, tier and identity are read from the database on every
  request.
- Timezone is Asia/Bangkok everywhere. The factory has one clock and it is not
  the server's.

The stack: Next.js 16 (App Router, Server Actions), React 19, Better Auth,
Drizzle ORM 1.0 RC on PostgreSQL, Tailwind 4 with shadcn/ui, Bun for scripts
and tests. The normative specification lives outside this repository at
`/Users/humperdoo/Documents/Advmfr/STAFF-HR-ADVMFR/V2/docs 3/` (read only; its
`machine/` folder holds the facts). Note the folder name: a `V2/docs/` also
exists, it is older, four of its files differ and it is missing `routes.json`
and `routes.md` entirely. `docs 3` is the one this project has been read
against. When the code contradicts the specification the specification usually
wins, but not always, and the exceptions are listed at the end of this file. Work is tracked in Linear:
<https://linear.app/elevaseo/project/advmfr-hr-8264c367afa4/overview>.

## The screens

Every route under `app/`, with the guard it actually enforces. `requireUser`
redirects the signed-out to `/login`; `requireAtLeast(tier)` additionally
redirects a lower tier to `/tasks`. Guards live in `lib/services/session.ts`.

| Route | Guard | What it answers |
| --- | --- | --- |
| `/` | none | Redirects to `/tasks`. There is no per-tier landing. |
| `/login` | public | Sign in by username. Bilingual, no language switch (nobody is signed in to have one). |
| `/login/help` | public | Who resets a password and where they sit. The desk details in `lib/help-desks.ts` are invented by us and nobody has confirmed them (ELEVA-234). |
| `/tasks` | `requireUser` | My work: day, week or month window, carried-over tasks pinned above it. Same list for every tier. |
| `/tasks/[id]` | `requireUser`, then relation | The only task page. Actions come from the viewer's relations to this task; no relation at all is a 404. |
| `/tasks/new` | `requireUser` | Create a duty for a position, scoped to the creator's department (admin: everywhere). Staff reach the same screen for their own position only, which is ELEVA-155: creating is a permission the service enforces, never a tier the page hides behind. |
| `/review` | `requireUser` | Tasks waiting on me as checker or verifier. Deliberately not manager-guarded: three of the seven checkers in the roster are staff (ELEVA-148). |
| `/notifications` | `requireUser` | What I have been told. Opening the list is what marks it read. |
| `/team` | `requireAtLeast("manager")` | My department today: late, due today, waiting at the check, plus a thirty-day completion tile. Whole state in the URL. |
| `/kpi` | `requireUser` | Thirty days of completion. Staff see their own four figures and no table; a manager sees a row per person in their department. Not manager-guarded, and that is the point of ELEVA-116: a person evaluated on a number can see it. |
| `/positions` | `requireAtLeast("admin")` | The positions list. Admin-only is the narrower of two contradictory readings in the spec (ELEVA-139). |
| `/positions/[id]` | `requireAtLeast("admin")` | The job-description editor and the only write surface for duties. Also who is standing in for this position, and the way to open or end a cover. Projections that disagree with it are the bug. |
| `/admin/people` | `requireAtLeast("admin")` | The roster, and the way into one person. |
| `/admin/people/[id]` | `requireAtLeast("admin")` | Open a login, set a first password, archive a leaver. |
| `/admin/holidays` | `requireAtLeast("admin")` | The holiday calendar: annual CSV import and a year view. Third input to every date the engine computes. |
| `/admin/recurrence` | `requireAtLeast("admin")` | Read-only explorer of the recurrence rules, so they can be argued with without opening `lib/recurrence.ts`. |
| `/settings` | `requireUser` | Change password, change language. |
| `/api/auth/[...all]` | Better Auth | The auth handler, with four of its routes deliberately disabled (see `lib/auth.ts`). |
| `/api/notifications/stream` | session | Server-sent events: one wake-up per new notification, ids only, no payloads. |

Two sidebar entries still point at `#` and are not built: **My job
description** and **Reminders** (`components/app-sidebar.tsx`). The entries
stay so the shape of the finished thing is visible; everything else in the
sidebar is real. **KPI** was the third until `9b3b412`. It appears once, in the
group that describes what the reader will find on it: under My work for staff,
because there it is their own four figures, and under My department for
everybody else, because there it is a department.

Staff see two entries nobody else does, and both point at a url another tier
reaches by a different door: **My recurring to-dos** is `/tasks/new`, which a
manager reaches as Create and assign, and **KPI** as described above. Neither is
drawn for a manager, because the same link twice in one sidebar reads as broken. There is no `/preview/*` any more: those were a staging
area, and the screens that graduated moved to the real paths above while the
placeholder tier dashboards were deleted.

## The data model

`lib/db/schema.ts` is the authority and is thoroughly commented; read it
rather than this. The shape, briefly:

- `departments`, `positions`, `people`: the organisation. A position owns the
  rank (`level`, six client ranks collapsed to three tiers only by `tierOf`),
  the job description and the duties; people pass through it. A person's
  department is reached through their position, never copied.
- `holidays`: the factory's closed days, keyed on the day itself.
- `position_covers`: somebody standing in for a position over a window of days.
  Both dates are inclusive and both are `NOT NULL`, because a `daterange` with a
  null bound is null and overlaps nothing, so an open-ended cover would silently
  stop being checked against the others. A Postgres exclusion constraint over
  `(position_id, daterange(from_date, until_date, '[]'))` makes two covers on
  one position on one day impossible, which matters because two would send the
  same task to two people and neither would know who owed it. Drizzle 1.0 has no
  builder for `EXCLUDE`, so that one constraint lives in a hand-written
  migration beside the generated one.
- `recurring_templates`: a duty attached to a position, one of six occurrences
  (`once`, `daily`, `weekly`, `monthly`, `quarterly`, `general`). `general`
  is a job-description line that never generates a task. Check constraints
  force each occurrence to carry exactly the schedule fields it needs.
- `task_instances`: one occurrence of a duty on one day, the row a person
  works on. Five statuses (`todo`, `started`, `finished`, `checked`,
  `missed`); `verified` is deliberately not a status but a pair of timestamps.
  The unique index on `(recurring_template_id, due_date)` is what makes
  generation idempotent.
- `task_submissions`: what the assignee handed in when finishing, one row per
  task. A prototype shape; the client has not said what a submission should
  contain (ELEVA-121).
- `domain_events` and `notifications`: an outbox and its fan-out. The event
  says what happened, written in the same transaction as the change; the
  notification says who was told, one row per recipient per event, unique on
  that pair.
- Better Auth's own tables live in `lib/db/auth-schema.ts`, generated by its
  CLI. The link to a person is `people.auth_user_id`, kept on our side because
  the generated table would drop an added column on regeneration.

Nothing is ever deleted: rows are archived (`archived_at`) or deactivated
(`active`). The only `on delete cascade` anywhere belongs to Better Auth's
`session` and `account`, and `lib/db/schema.test.ts` asserts there are exactly
those two.

## The invariants

These are what a newcomer breaks first. Each was learned by breaking it.

**A client component may import types from anywhere, but values only from a
module with nothing under it.** Importing a value from a service pulls the
Postgres driver into the browser bundle and the build fails with
`Can't resolve 'tls'`. This has happened three times, which is why
`lib/levels.ts`, `lib/choice.ts` and `lib/task-views.ts` exist as pure value
modules (`lib/task-views.ts` tells the story in its header). When a client
component needs a value a service produces, the value crosses as a prop or a
new pure module is made; the service is never imported.

**No Drizzle outside `lib/db/` and `lib/services/`.** Enforced by
`no-restricted-imports` in `eslint.config.mjs`, and it has caught real
mistakes, not hypothetical ones (`lib/db/panel.ts` records being caught in the
wrong folder). Screens and Server Actions are thin adapters over services.

**Scope is asked twice: once to build a list, once on the way in.** A list
narrowed on the way out is a list somebody widens by posting an id that was
never on it. So `withinScope` in `lib/services/duties.ts` filters what a
manager is offered *and* `createDuty` re-checks the posted position and
checker against the same condition; every verb in
`lib/services/task-commands.ts` reloads the task and recomputes the viewer's
relations before writing; `/tasks/[id]` 404s a viewer with no relation even
though the id was valid.

**Actions come from `availableActions(status, relations)` for this viewer and
this task, never from the tier.** `lib/task-actions.ts` is the one place the
rule lives; `permits` in `task-commands.ts` is the same function asked on the
server, so the buttons a page draws and the verbs the server accepts cannot be
computed by different code. Hiding a button in the browser is not a
permission. Nothing anywhere branches on the tier to decide a task action;
`admin` is simply a relation that satisfies the others.

**Days are `YYYY-MM-DD` strings in Asia/Bangkok; instants are `timestamptz`.
Never compare one to the other.** A due date is a day on a wall calendar and is
stored as `date`, carried as a string end to end (the driver in
`lib/db/client.ts` is told not to turn it into a `Date`). `isLateOnDay`
compares days, `isLate` compares instants, and they are two functions so the
comparison cannot be mixed. `lib/i18n/dates.ts` likewise formats days in UTC
(so the formatter cannot shift them) and moments in Bangkok, with separate
functions. The system this replaces dated every row a day early on a UTC host,
permanently; that is the failure all of this exists to prevent.

**Every interface string is a key with an English and a Thai value.**
`lib/i18n/messages.ts`: `MessageKey` is `keyof typeof EN` and `TH` is typed
`Record<MessageKey, string>`, so a string added in one language and forgotten
in the other fails `tsc`, not a code review. There is deliberately no fallback
to English on a missing key. Names, titles and departments are data, never
translated. Notifications store a `NoticeKey`, never a sentence, so the words
are chosen in the reader's language at the moment they look.

**An event is recorded in the same transaction as the change that caused it.**
See `finish`, `approve`, `sendBack` in `task-commands.ts` and `markMissedAsOf`
in `generation.ts`: the status update and `recordEvent` share one
`db.transaction`, so an event cannot survive a rolled-back change and a
committed change cannot lose its event. Dispatch (fan-out to notifications)
happens after commit, best effort, and the nightly drain recovers anything a
crash left owed; the unique index on `(event_id, person_id)` is what makes
retrying safe.

## Decisions somebody would otherwise undo

Each of these looks like an oversight or an over-complication until you know
the reason. The long versions are in the files named.

**The username is not the employee code** (`lib/auth.ts`,
`lib/db/schema.ts` on `people.employeeCode`). It was, for a day. The client's
written answer to their own question 10 says the code is an internal
identifier for syncing with the accounting database, never the login. HR
assigns usernames; the person row links the two.

**Every account carries a derived email `<username>@advmfr.local`**
(`lib/auth.ts`). Better Auth requires an email; `.local` cannot resolve (RFC
6762), so it can never reach a real inbox, and it is never shown or typed.

**Four Better Auth HTTP routes are disabled** (`disabledPaths` in
`lib/auth.ts`): `/sign-up/email` (let anybody squat a username),
`/update-user` (let anybody rename their own login and lock themselves out of
HR's view of it, demonstrated), `/is-username-available` (a roster oracle with
no session needed), `/change-password` (an uncounted second door beside the
Server Action). It is `disabledPaths` and not `disableSignUp` because the
latter would also break the server-side `auth.api.signUpEmail` that account
provisioning uses. The distinction is the whole fix.

**The sign-in screen distinguishes three failures the library deliberately
does not** (`lib/services/sign-in.ts`). Better Auth answers every failure with
one generic sentence so a stranger cannot enumerate users; here the audience
is a factory roster on its own LAN, mostly reading a second language, and
"unknown user", "no account opened yet" and "wrong password" send people to
three different remedies. ELEVA-133 records the trade. The rate-limit answer
is the one that reveals nothing, so the limiter cannot become a faster oracle.

**Sign-in attempts are limited in-process, per username, before the hash**
(`lib/attempt-limit.ts`). Better Auth's own limiter only sees HTTP traffic
through its router, and the login posts to a Server Action that calls
`auth.api` directly, so without this the real sign-in path was unlimited.
Keyed on username, not IP, because on a proxied LAN an address names almost
nobody. Ten failures per ten minutes, counted before the deliberately
expensive password hash is paid for. The counters are a Map in one process; a
second app instance would silently double the budget, and the file says what
to do then.

**The language lives on the person, not in the browser**
(`people.language`, `components/language-switch.tsx`). Shared terminals: a
preference on the device is inherited by whoever walks up next, and with 183
of 193 reading Thai the inheritor is usually the one it is wrong for. Thai is
the default. The switch is drawn twice on purpose (sidebar and `/settings`),
each language labelled in itself (`ไทย`, never "Thai"), because the person who
needs it cannot read the menu it would otherwise hide in. Signed out there is
no person, so `/login` shows both languages and has no switch.

**Thai dates use the Buddhist calendar, spelled out** (`lib/i18n/dates.ts`).
`th-TH-u-ca-buddhist`, explicitly, not left to the locale default that is true
until an ICU upgrade makes it false.

**Missed work is still owed** (`isOwed` in `lib/task-actions.ts`,
`markMissedAsOf` in `lib/services/generation.ts`). The client answered on 4
August (ELEVA-156): nothing writes work off. `missed` is not terminal, the
task stays on its assignee's list, and the sweep marks status without removing
anything. A restarted missed task gets honestly re-marked missed that night,
because it is still past its cycle and still not done; excluding it would be
writing work off by another door.

**`finish` leads on a missed task** (`availableActions`). Finishing is the
only press that clears the debt; Start is still offered under it for somebody
who wants the row to say they have picked it up, but pressing Start changes
nothing anybody is waiting for.

**`verified` is not a status** (`taskStatus` in `lib/db/schema.ts`, `verify`
in `task-commands.ts`). Verification is a random-sample audit after `checked`;
a task is complete at `checked` and an audit that never happens must not leave
it reading as incomplete. Two timestamps record it, a check constraint forbids
verifying the unchecked, and `verify` is guarded by `verified_at IS NULL` so a
second press cannot overwrite who audited and when.

**`late` is never stored.** It is `status IN (todo, started) AND past due`,
computed at the moment somebody looks. A stored `late_at` would make a task
late when a job happened to run rather than when the day passed.

**Temporary cover redirects the assignee, it does not copy the duty**
(`lib/cover.ts`, `position_covers`). The client's answer reads "the tasks should
be copied temporarily into his original position", and a literal reading would
duplicate the duty rows onto the stand-in's position. That was considered and
rejected: it would make the stand-in's own job description display duties that
are not their job, and it needs a back-link and a cleanup pass. Instead, for a
window of days the position's duties resolve to the stand-in, which produces
exactly what the client described (the work appears in their list, it leaves the
holder's) with one table and nothing to clean up. The stand-in's own working
week decides which days the work falls on, because they are the person who has
to be there. Ending a cover is not a second mechanism: it shortens the window,
and a cover that never started is deleted rather than shortened, since nothing
was generated from it and there is no history to protect. Instances already
generated never move, which is the sentence `active` already carries.

**Cover is decided in a pure function, not in the query** (`dutyHolderOn` in
`lib/cover.ts`). The generator's query fetches facts only, holders and cover
rows; the window boundary is deliberately not a `WHERE` clause, because a
boundary written in SQL is a boundary no test can move. Same reason
`lib/recurrence.ts` is pure.

**Cover has its own `withinScope`, and not the duties service's**
(`lib/services/covers.ts`). Opening or ending a cover is a manager's act inside
their own department, an admin's anywhere, and never a staff member's, even for
the position they hold themselves: reusing `withinScope` from `duties.ts` would
have given a staff member their own position here, which is somebody handing
their own duties to a colleague and walking away from them. The asymmetry beside
it is deliberate and is written down where somebody would narrow it: the
position being covered is scoped, the person standing in is not, because
covering somebody usually is somebody from another department, and naming a
person is not a write against that person. A position id that does not exist and
one the caller may not touch return the same refusal key, so the guard cannot be
used to ask whether an id exists.

**A wrong due date is permanent, so the engine is conservative**
(`lib/services/generation.ts`, `task_instances` comment). Generation is
idempotent on `(template, due_date)`; the same index that makes reruns safe
makes them unable to correct a wrong date, they collide with it. Hence: dates
computed only by `lib/recurrence.ts` and `lib/calendar.ts` in Bangkok strings,
seven days of look-back so an outage leaves no hole, and only fourteen days of
look-ahead, because every day generated early is a day a late-loaded holiday
can no longer move.

**`currentUser` may only be memoised per request** (`lib/services/session.ts`,
React `cache`). A page, the shell and the layout all ask who is looking; the
memo collapses three identical lookups. But tier and language are read fresh
from the roster each request on purpose: a tier cached in a cookie survives a
demotion, a language cached anywhere survives being changed on a shared
terminal. There is no `requireTier` any more, only `requireAtLeast`, because
the tiers nest and hiding a link is not a guard.

**`task.missed` events are recorded and told to nobody** (`FAN_OUT` in
`lib/services/notifications.ts`). The sweep marks dozens a night; fanning them
out would bury the handful of notifications somebody is actually waiting for.
The event is still written because other consumers may want it. Likewise the
actor is never notified of their own act, which in small departments is the
normal case, not a corner.

**A first password is three groups of four, from an alphabet with no O, 0, I,
l or 1** (`lib/first-password.ts`). It is read off a paper slip and typed on a
factory terminal; those five characters are where retyping fails, and twelve
characters is under sixty bits, which is far past what an air-gapped LAN
needs.

**Send-back returns the task to `started`, not `todo`, and keeps
`finished_at`** (`sendBack` in `task-commands.ts`). The work exists and needs
correcting, so offering Start again would be wrong, and blanking `finished_at`
would delete the only record a first attempt happened. Note the schema comment
disagrees; see the last section.

**`close` writes `checked`** (`close` in `task-commands.ts`). A one-time
task's creator completing it is the same fact as a checker approving; a sixth
`closed` status would give every report two spellings of complete.

**Opening the notification list marks it read** (`markShownRead`,
`app/notifications/page.tsx`). The badge answers "is there anything I have not
seen"; a badge that survives the list being opened is lying, and only the ids
actually rendered are marked.

## Running it

Environment comes from `.env` (see `.env.example`), validated in `lib/env.ts`.
The scripts that matter, from `package.json`:

| Script | What it does |
| --- | --- |
| `bun run dev` | Next.js dev server. Use d3k for browser verification; never `next build` during development. |
| `bun test` | Bun's test runner over the `*.test.ts` files (pure logic: calendar, recurrence, actions, limiter, schema assertions). |
| `bun run lint` / `bunx tsc --noEmit` | ESLint (including the Drizzle boundary rule) and the type check that also enforces the EN/TH dictionary. |
| `bun run db:migrate` / `db:generate` / `db:studio` | Drizzle-kit: apply migrations from `drizzle/`, generate new ones, browse the database. |
| `bun run db:seed` | Load the client's roster (`data/employees.tsv`) idempotently, by natural key, never by truncation. |
| `bun run db:provision <code> <username>` | Open a login for a person: create the Better Auth account, print a generated first password once. |
| `bun run db:password <code> <password>` | Set a password knowing only the employee code (the in-person reset). |
| `bun run db:demo` | A month of plausible work across 23 of the 46 departments, simulated through the engine rather than written as statuses. Safe to re-run: a second run performs nothing. |
| `bun run db:panel` | The development account panel: one account per situation worth looking at, idempotent. |
| `bun run job:daily [YYYY-MM-DD]` | The nightly job: generate the window, mark what ran out of time, drain the outbox. Safe to run twice or late. |

`docs/demonstration.md` is the running order for showing this to the client:
which account for which screen, and the open question worth asking while each
one is up. `docs/test-accounts.csv` lists the eleven development accounts and
says what each one demonstrates (an admin in English, a manager with a department to
manage, the heaviest backlog, the two named verifiers, a leaver who is refused
at sign-in and whose past work still names her, and so on). Read it rather
than inventing accounts; `db:panel` recreates them. These credentials are
guessable on purpose and ELEVA-221 records that none may survive a deployment.

The demonstration data itself is `lib/db/seed-demo.ts`, and the one rule it
keeps is that it never writes a status. It generates instances with
`generateFor`, walks the month a day at a time performing the acts a person
would have performed through the same functions the buttons call, and runs
`markMissedAsOf` for each night; every `missed` row is the sweep's answer and
not a fixture's. It also carries the three shapes that could not be reached
before: duties that name a verifier, a position with nobody in it, and a
position with two holders, the last two made by transferring one person onto a
colleague's chair in Metal Center. `lib/demo-duties.ts` holds the catalogue of
invented work and `lib/demo-plan.ts` the deterministic mixture of fates and
the instants they happened at; both are pure and both are tested.

## What is not built

Tracked in Linear, which is the authority on current state; this is only the
map. The project:
<https://linear.app/elevaseo/project/advmfr-hr-8264c367afa4/overview>.

- The two `#` sidebar entries: My job description and Reminders.
- **Notifications' second half.** The outbox, the in-app driver and the SSE
  stream work end to end. There is no ntfy driver yet (the server is in the
  compose file, nothing sends to it), no per-person channel preferences
  (ELEVA-154, which is also why `/settings` has no notification card), and no
  per-channel delivery state, which an HTTP channel will need because a POST
  cannot be made idempotent by a unique index the way an insert can.
- **Deployment.** `deploy/` holds a Dockerfile and a compose file for the
  two-Pi target, including a placeholder loop that runs the nightly job at
  02:00 Bangkok until a pg-boss worker exists. None of it has run at the
  factory.
- **Open client questions.** Do not invent answers to these: what a submission
  should contain (ELEVA-121, why `task_submissions` is minimal), what a
  vacancy should do (ELEVA-131: there are now two to look at, one deliberate
  and one from the leaver, and the duties of an empty chair go on generating
  tasks that wait), how 190 people receive a first password with no email
  (ELEVA-134), positions with several holders (ELEVA-137: the client's
  spreadsheet still gives all 193 exactly one, and `db:demo` makes one
  two-holder position so the behaviour can be seen — the task is left
  unassigned), who sees `/positions` (ELEVA-139), whether `/review`
  should be guarded (ELEVA-148), whether the issue flag outlives the cut-off
  (ELEVA-156 answered the owed half only).

## Where the code contradicts a comment or a document

The code wins in every case; the stale text is listed so nobody re-derives a
falsehood from it. All of it sits outside `docs/` and was left as found.

- `lib/db/schema.ts`, comment on `sent_back_at`: says send-back is `finished`
  to `todo`. The code (`sendBack` in `lib/services/task-commands.ts`) returns
  the task to `started`, and its comment explains why the schema's reading
  contradicts the row.
- `lib/services/sign-in.ts`, comment on the `ok` outcome: says the tier comes
  back "so the form knows where to send them". The form
  (`components/sign-in-form.tsx`) ignores it and always lands on `/tasks`; the
  tier in the outcome is currently unread by any caller.
- `app/login/page.tsx`, header: says signing in is by employee code, decided
  3 August, and that the screen is static and nothing authenticates. Both
  predate the 4 August correction: sign-in is by username, through a Server
  Action, against Better Auth.
- `lib/i18n/messages.ts`, header: cites "the third rule in
  docs/next-session.md". The rule it means (the client-bundle rule) is in this
  document now, and it was the first rule in the file it cited.
- `AGENTS.md` (repo root): still says the recurrence engine and the missed
  evaluation must not be built until open points C and E are answered. They
  were answered on 4 August and both are built and tested.
- `docs/next-session.md` was a session plan against a commit four migrations
  back; its route table described `/preview/*` screens that no longer exist.
  It has been replaced by a short handover that points here.
