# Architecture — v1 (Markdown edition, corrected)

Companion to `functional-spec.md`. Mirrors `Task-Management-Architecture-v1.docx`.
Statuses: **DECIDED** (build on it) · **DEFAULT** (start here; switch path documented) · **OPEN** (audit/client pending). Canonical: `machine/decisions.json`. Page hierarchy: `routes.md` + `machine/routes.json`.

## 1 · Hard constraints

- **Fully air-gapped** factory LAN. No internet, ever: no FCM/Web Push, no Let's Encrypt, no external NTP, no pulls on site. Releases ship as files.
- **Raspberry Pi** hardware, one per role; databases on NVMe/SSD (SD cards die under DB writes).
- **Small scale**: ≈400 users, 3–8k task instances/day, 2–3 M rows/year, ~100–150 concurrent at morning peak. We split nodes for resilience and clean scaling, not load.
- **Nobody works at night** → guaranteed idle window (generation job, backups, maintenance).
- App stack fixed: Next.js App Router + Server Actions, Drizzle, Better Auth (offline credential auth), PostgreSQL.

## 2 · Topology — three Pi

```mermaid
flowchart LR
    subgraph LAN [FACTORY LAN — no internet, ever]
      C[Clients<br/>desktop browsers · Android Tauri shell] -->|HTTPS| P1
      subgraph P1 [Pi 1 · APP]
        caddy[Caddy — HTTPS, internal CA] --- next[Next.js] --- worker[pg-boss worker] --- ntfy[ntfy] --- chrony[chrony NTP]
      end
      P1 -->|SQL| P2[(Pi 2 · DB<br/>Postgres primary · NVMe)]
      P2 -->|streaming replication async| P3[(Pi 3 · DATA-2<br/>standby read-only · pg_dump · Uptime Kuma · DuckDB)]
      P3 -.-> USB[2 USB disks rotated,<br/>taken off site]
    end
```

One UPS powers the three Pi + switch. All nodes: Raspberry Pi OS Lite 64-bit + Docker Compose.

## 3 · Technology decisions

| Decision | Status | Essence | Switch path / trigger |
|---|---|---|---|
| PostgreSQL in Docker | **DECIDED** | schema, offboarding transactions, idempotent generation, KPI window functions already target it | partitioning not before ~50 M rows |
| No cache layer in v1 | **DEFAULT** | indexed Postgres answers in ms for 400 users; a cache mostly buys invalidation bugs; ClickHouse = wrong tool at this scale | measured slow query after indexing → read from standby, or Valkey on Pi 3 |
| **Jobs: event-driven first**; pg-boss inside Postgres | **DECIDED** (principle) / **DEFAULT** (pg-boss) | user-triggered work is real time: Server Action commits + enqueues in the same transaction → worker < 1 s → SSE. **Scheduled = exception**: nightly generation, Missed evaluation at cut-offs, backups. No polling, no system crontab | BullMQ + Redis if volume ×10 or queue UI needed — payloads identical, mechanical migration |
| DuckDB on the standby | **DECIDED** | ad-hoc analytics (customer tag, KPI) against Pi 3, zero prod impact; optional nightly Parquet export = free history | grow the export if analytics gets heavy |
| Internal CA (Caddy) + HTTPS everywhere | **DECIDED** | secure context required by service worker / notifications / cookies even offline; root cert installed per machine | none needed |
| Time: RTC + chrony + Asia/Bangkok | **DECIDED** | Missed cut-offs and KPI die on clock drift; Pi 5 battery RTC; chrony serves NTP to the LAN | none needed |
| Offline deploy: `docker save` → USB → `deploy.sh` | **DECIDED** | release = a file; load + migrate + compose up; previous version kept for rollback; builds fully self-contained (no CDN, pinned deps) | none needed |
| 3× Pi topology | **DECIDED** | see §2; spare flashed SD per role + spare Pi on shelf | move Pi 1 containers to any x86 mini-PC unchanged |

## 4 · Notifications — pluggable by design

Air gap rules out Web Push (VAPID → Google/Apple) and Mattermost mobile push (hosted proxy). Working fully offline:

- **In-app + SSE** — real-time list while the app is open (= working hours). **DECIDED**
- **ntfy** — tiny self-hosted server on Pi 1; Android app keeps a WebSocket to the LAN → real system notifications with zero internet; desktop can subscribe. **DEFAULT**
- **Mattermost / team chat** — **OPEN**. None exists today. Audit question: *notifications, or also a chat tool?* If chat is wanted, it's a separate project plugged in as one more driver.

The seam: application code never talks to a channel. Event → pg-boss → worker → adapter:

```ts
interface NotificationDriver {
  send(userId: string, n: { title: string; body: string; taskId?: string }): Promise<void>;
}
// v1: InAppDriver (DB row + SSE) · NtfyDriver (HTTP POST)
// future: MattermostDriver · EmailDriver · LineDriver — same interface, enabled by config
```

Adding Mattermost later ≈ one driver (a day) + a config switch.

## 5 · Integration & API — one module in a larger architecture

This system is the first module of a larger architecture. The anti-regression insurance is **where the business logic lives** — not a separate API server that would serve no consumer for years. Four cheap decisions make future extraction mechanical:

- **Service layer, transport-agnostic — DECIDED.** All business logic in `src/services/` (pure TS, Zod contracts in/out, Drizzle transactions inside). Server Actions = three-line adapters (auth + service call). **Hard rule, lint-enforced: no Drizzle import outside `services/`.** Exposing the same logic over REST/CLI/worker costs three lines each; extracting a standalone API server later = moving a folder.
- **Minimal, versioned REST facade from v1 — DECIDED.** Only the plausible seams, as Route Handlers in the same Next.js: `GET /api/v1/tasks`, task instances, departments & positions, KPI summaries, `POST /api/v1/tasks` (one-time — the most likely external need). Machine auth via hashed **API keys** (Better Auth = humans only). **OpenAPI generated from the Zod schemas** → `machine/openapi.json` once the routes exist.
- **Domain events (outbox) — DECIDED.** The transactional enqueue generalises into a `domain_events` table (`task.created · task.checked · task.missed · holder.changed…`) dispatched by the worker — notifications today, other modules' webhooks tomorrow. A future HR module reacting to departures polls nothing.
- **The database is private — DECIDED.** No other module ever writes to our Postgres — DB coupling is what kills modular architectures. Analytics reads: standby/DuckDB. Writes: API or events, full stop.
- **Deliberately not built** (triggers documented): separate API server (trigger: second heavy frontend or independent-scaling module — mechanical extraction) · GraphQL/gRPC (no consumer) · API gateway (three Raspberry Pi).

Two integrations already prove these seams in v1: the legacy employee-DB sync (stored employee code) and the customer DB (customer tag) — import jobs + references through exactly these seams.

## 6 · Data: replication, backups, restore

- **Streaming replication (async)** — Pi 3 standby, seconds behind, read-only queryable. Failover = `pg_ctl promote` + repoint `DATABASE_URL`. **DECIDED**
- **A replica is not a backup** (a bad DELETE replicates in seconds) → **nightly `pg_dump`** on Pi 3, 30 days retained.
- **Off-site without a network**: two USB disks rotated by hand weekly, one always outside the factory.
- **Restore is rehearsed**: before go-live + quarterly, restore a dump onto a spare image and boot against it.

## 7 · Security & time

- Better Auth, credential-based, fully local; employee code = sync ID only (username format = open point D).
- OS/image updates ship with each release bundle.

## 8 · Operations

- Uptime Kuma (Pi 3) pings app, Postgres, replication lag, ntfy — and alerts through ntfy. No Prometheus stack for three machines.
- One UPS; spare flashed media per role; hardware swap by local IT in minutes.

## 9 · Mobile: Tauri v2 thin shell

- **Strategy DECIDED**: Tauri v2 WebView shell → same Next.js app on the LAN. One codebase; Server Actions unchanged; shell adds home-screen presence, clean CA trust (Android network security config), native plugins later.
- **Android first — DEFAULT**: signed APK, sideload from a LAN page, updates likewise.
- **iOS — OPEN**: no free sideload (ad-hoc 100 devices, or Apple Enterprise ≈$300/yr); if the fleet audit shows few iPhones → browser, skip iOS.

## 10 · Scaling paths

| If this happens | Do this (no redesign) |
|---|---|
| Slow dashboard query | index → read from standby → only then Valkey |
| Notification volume ×10 / queue UI | BullMQ + Redis, same payloads |
| Client wants chat after all | deploy Mattermost separately; write `MattermostDriver` (~1 day); config switch |
| More compute | move Pi 1 containers to x86 mini-PC, Compose unchanged |
| Second factory site | second full stack per site; reconcile by exported files |
| Heavy analytics | grow the Parquet export + DuckDB |
| A new module needs our data | it consumes `/api/v1` or subscribes to domain events (webhooks) — **never the database**; independent scaling → extract `services/` behind the existing REST facade (mechanical) |

## 11 · Open infrastructure audits

Device fleet (shared desktops? Android/iOS split? BYOD on LAN?) · notifications vs chat · on-site IT (deploy.sh, USB rotation, hardware swap) · UPS spot + Wi-Fi coverage · hardware purchase (3× Pi 5 8 GB, 2× NVMe HAT+SSD, RTC batteries, UPS, 2 USB disks, spares).

## 12 · What blocks the build — and what does not

Functional open points A–G do **not** weigh the same. **C and E sit inside the core** (the recurrence engine) and must be answered **before that engine is built** — or weeks of code get re-dated:

| Point | Really decides | How we proceed |
|---|---|---|
| **C · Missed behaviour** (Narin) | the nightly generator's contract: does an unfinished daily instance survive into the next day, or close as Missed? | **BLOCKING.** Recommended default: mark Missed + remove from active list; history keeps everything |
| **E · Cut-offs & timestamps** | the instant that creates a Missed record = KPI truth; also the mechanism (global sweep vs one delayed job per instance) | **BLOCKING.** Recommended default: daily 23:59 Asia/Bangkok · weekly end of person's last working day · monthly last calendar day · record started/finished/checked/verified |
| A · Assignment matrix | who-can-assign-whom check only | provisional: any manager-level within own department; the matrix is data, not schema |
| D · Username & resets | login screen only | provisional: Admin sets usernames; Admin+HR reset |
| B · Dashboard example | one screen's layout | build that screen last |
| F & G · Confirmations | nothing yet | building stated reading; later change stays local |

## Next step

Build starts now on every DECIDED/DEFAULT choice — **except the recurrence engine, which waits on C and E**. Fastest unlock: send the client the two recommended defaults for a yes/adjust. A, B, D proceed on provisional defaults; remaining OPEN infra points gate only notification channels and mobile packaging.
