aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/COMPLIANCE.md159
-rw-r--r--docs/DEPLOY.md156
-rw-r--r--docs/PROTOCOL.md469
-rw-r--r--docs/SCHEMA.md490
-rw-r--r--docs/STATE.md112
-rw-r--r--docs/TUI-GUIDELINES.md108
6 files changed, 1494 insertions, 0 deletions
diff --git a/docs/COMPLIANCE.md b/docs/COMPLIANCE.md
new file mode 100644
index 0000000..a152cd4
--- /dev/null
+++ b/docs/COMPLIANCE.md
@@ -0,0 +1,159 @@
+# bokf compliance notes
+
+Status: Draft 0.1 · 2026-09-17 · License: GPL-3.0-or-later
+
+This document maps Swedish bookkeeping requirements to concrete `bokf`
+features, and states plainly what the software does **not** do. It is a design
+document, not legal advice. Statutory references are indicative — verify the
+current wording, and engage a redovisningskonsult or revisor before relying on
+any of it.
+
+The short version: no Swedish authority certifies bookkeeping software.
+Compliance is always the responsibility of the bokföringsskyldige. `bokf`'s goal
+is to make the formal requirements hard to violate by construction: append-only
+data, enforced number series, verifiable history and locked periods.
+
+## 1. Framework
+
+| Source | What it governs |
+|---|---|
+| Bokföringslagen (1999:1078), BFL | Bookkeeping duty, löpande bokföring, verifikationer, bevarande |
+| BFNAR 2013:2, vägledning om räkenskapsinformation | Systemdokumentation, behandlingshistorik, rättelser, säkerhet |
+| Årsredovisningslagen (1995:1554), ÅRL | Årsredovisning, Bolagsverket filing |
+| BFNAR 2016:10 (K2), BFNAR 2012:1 (K3) | Accounting frameworks for AB |
+| Mervärdesskattelagen (2023:200), ML | Moms reporting |
+| SIE 4 | De facto exchange standard between systems, banks, accountants, auditors |
+| BAS-kontoplanen (FAR) | Standard chart of accounts; separately attributed seed data |
+| GDPR | Personal data in the ledger and in user management |
+
+## 2. Requirement → feature map
+
+| Requirement | Where in bokf | Status |
+|---|---|---|
+| **Löpande bokföring** (BFL 4 kap) — entries made continuously, in Swedish kronor, in chronological order | Every voucher has a date within its fiscal year; listings are ordered by date, series, number; server rejects out-of-range dates | MVP |
+| **Verifikationer** (BFL 5 kap) — every entry has a dated verification with underlag, amount, counterpart, and identifies the counterparty | `voucher.post` requires description, ≥ 2 balanced rows, account references, actor identity; underlag via immutable `attachments` linked to the voucher | MVP |
+| **Obruten nummerserie** per räkenskapsår | `sequences` incremented in the same transaction as the insert; a failed post consumes nothing, a committed post skips nothing; `UNIQUE(org, year, series, number)` | MVP |
+| **Oföränderlighet** — a verifikation may not be altered after the fact | `BEFORE UPDATE/DELETE` triggers abort on vouchers, rows, attachments and audit. No application command can change ledger rows | MVP |
+| **Rättelser** — errors corrected by a new verifikation that references the old (ändringsverifikat), never by editing | `voucher.correct` mirrors and posts a new voucher carrying `corrects_voucher_id`; original remains visible with both linked in reports | MVP |
+| **Behandlingshistorik** (BFNAR 2013:2) — who did what, when, with what result | `audit_log`, append-only, SHA-256 chained, one entry per mutation with actor user **and** token; `audit.verify` recomputes the chain | MVP |
+| **Systemdokumentation** (BFNAR 2013:2) | `docs/` (this file, `PROTOCOL.md`, `SCHEMA.md`), `meta.schema_version`, runtime `describe`; the operator must keep it current for their installation | MVP, operator duty |
+| **Bevarande i 7 år** (BFL 7 kap) | Append-only storage; no delete commands for ledger data; `archive`/disable instead of delete; `backup.snapshot` + restic pattern produces dated, restorable archives | MVP, operator duty |
+| **Tillgänglighet och läsbarhet** | JSON reports, SIE 4 export in CP437, SIE import; the archive is readable by any tool that reads SQLite or SIE | MVP |
+| **Huvudbok, saldon** (god redovisningssed) | `report.general_ledger`, `report.trial_balance`, `report.balance_sheet`, `report.income_statement` | MVP |
+| **Moms** (ML 2023:200) | `report.vat` computes the boxes from `report_rules`; declaration is filed with Skatteverket by the operator | MVP in-system; eSKD file generation roadmap |
+| **SIE 4** | `sie.export` (PC8/CP437) and `sie.import` (migration from Fortnox/Visma/BL) | MVP |
+| **Årsredovisning / INK2 / SRU** (ÅRL, K2) | Reports supply the figures; document generation and SRU files are roadmap | Roadmap |
+| **AGI, arbetsgivaravgifter, löner** | Not present | Roadmap |
+| **Fakturering, kund- och leverantörsreskontra** | Not present | Roadmap |
+| **Kontrolluppgifter, periodiska sammanställningar** | Not present | Roadmap / manual |
+| **GDPR** — lawful basis for ledger personal data, retention overrides erasure | Role-based access, revocable tokens, audit trail, local hosting; see §5 | MVP in-system, operator duty |
+
+## 3. How invariants are enforced
+
+The interesting detail is that compliance here is not a feature that can be
+switched off, or bypassed by a clever client:
+
+1. **The server owns the invariants.** Clients — including agents — cannot
+ construct a voucher that skips a number, exceeds a lock, or references
+ another org's account.
+2. **The database enforces tenancy.** Composite keys make cross-org references
+ impossible at the storage layer (`SCHEMA.md` §1, §4–7).
+3. **The database enforces immutability.** Triggers abort updates and deletes
+ on ledger and audit tables. This also holds for manual `sqlite3` access as
+ root.
+4. **History is verifiable.** Voucher and audit chains are recomputable by
+ `audit.verify`; tampering is detectable, not just forbidden.
+5. **Locks are explicit.** `period.lock` is owner-only and every unlock is
+ logged with a reason. `fiscal_year.close` is irreversible from the API.
+
+## 4. Verifikationer and the correction flow
+
+- A voucher = verifikation: unique number in an unbroken series, date,
+ description, rows, creator, timestamp, hash, linked underlag.
+- Corrections per BFNAR 2013:2: post an ändringsverifikat with
+ `voucher.correct`, referencing the original. Both remain in the ledger and in
+ every report; the link is machine-readable.
+- Deleting or hiding a posted voucher is not possible; there is no command for
+ it and no SQL path to it under normal operation.
+- Underlag (receipts, invoices) live in the same database as immutable blobs,
+ linked to exactly the vouchers they support. Missing-underlag is a
+ first-class query (`attachment.list {unlinked:...}` and per-voucher coverage).
+
+## 5. Data location, backups and the 7-year archive
+
+- Plan to host within the EES and keep the data available for inspection.
+ BFL's rules on where räkenskapsinformation may be kept and how it must be
+ presentable are precise — check the current 7 kap. wording for your case.
+ `bokf` makes no transfers anywhere; there is no telemetry and no cloud
+ dependency.
+- **Never back up live SQLite files with a file-copy tool.** Use
+ `backup.snapshot` (`VACUUM INTO`), which produces a consistent single file
+ with no downtime, then let restic pick that up. Exclude the live
+ `*.db`/`-wal`/`-shm` from restic.
+- Test restores on a schedule; a backup that has never been restored is not an
+ archive. The restore procedure is also the retrieval path for inspections.
+- 7 years = seven years after the calendar year in which the fiscal year
+ ended. Restic retention is not a legal retention policy by itself: keep at
+ least annual snapshots for the full period and store them somewhere you can
+ still read in a decade.
+
+## 6. Roles, audit and access
+
+| Actor | Typical holder | Sees |
+|---|---|---|
+| `owner` | You | Everything, including unlocks, closing and member management |
+| `bookkeeper` | Accounting help | Books and reports, posts and corrects, no irreversible ops |
+| `viewer` | Revisor, auditor, board member | Read-only, including audit history |
+| token | Agent or integration | Narrowed scope, its own audit identity, revocable instantly |
+
+Every mutation records the acting user **and** token, so machine-written
+entries are attributable to the machine and to the human who authorized it.
+Passwords and tokens never reach the audit log or the application log.
+
+## 7. GDPR notes
+
+- Ledger personal data (customer/supplier names, employee data later) is
+ processed under legal obligation (BFL), not consent; retention periods
+ override erasure requests. A data subject request must be answered within
+ those constraints, not by deleting bookkeeping.
+- User accounts (name, username, password hash, token metadata) are kept to
+ operate the service; disable instead of delete when history references them.
+- Because everything is single-host, data subject access requests can be
+ answered from reports and audit listings; there is no third party to notify.
+- Keep the host physically and network secured: LUKS at rest, no public
+ listener by default, tokens revocable, sessions short-lived.
+
+## 8. What the operator still must do
+
+The software does not file anything, sign anything, or know your business:
+
+- File momsdeklaration, AGI, INK2 and other declarations with Skatteverket
+ (upload files once the eSKD/SRU roadmap items land, or via the e-service).
+- Prepare and sign the årsredovisning and file it with Bolagsverket; K2
+ generation is roadmap, and the figures still need review.
+- Keep a current systemdokumentation for the installation (hardware, OS,
+ backup, access) — `docs/` is the starting point.
+- Decide the moms period, fiscal year and accounting framework per org
+ (`org.create` args) and keep them correct.
+- Test restores; hold two independent backup copies.
+- Have a redovisningskonsult or revisor review at least the first year.
+- Remember that only humans can exercise judgement: VAT treatment, fringe
+ benefits, cut-off, going concern.
+
+## 9. Honest status
+
+This is a draft specification. Nothing here has been audited, and the system
+has no production history yet. Treat generated reports and declaration
+figures as you would any new tool's output: review before filing. The
+compliance value of `bokf` rests on the enforced invariants above, not on
+approval by any authority — none exists.
+
+## 10. Roadmap, in rough order
+
+1. Ledger core + protocol + CLI + TUI (MVP).
+2. SIE round-trip test suite and migration dry-run against files exported from
+ Fortnox/Visma/BL.
+3. eSKD file for momsdeklaration; AGI file for payroll.
+4. K2 årsredovisning document generation + SRU files for INK2.
+5. Invoicing and reskontra; bank import (CSV, then PSD2).
+6. Peppol e-invoicing, if still relevant when it is due.
diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md
new file mode 100644
index 0000000..5def5df
--- /dev/null
+++ b/docs/DEPLOY.md
@@ -0,0 +1,156 @@
+# bokf — running in Docker
+
+Status: Draft 0.1 · 2026-09-17 · License: GPL-3.0-or-later
+
+Two roles:
+
+- **host** — runs Docker + the compose plugin only. No toolchain, no registry.
+- **dev machine** — has the source and builds the image; deploys with
+ `scripts/deploy.sh` over SSH.
+
+There is no forge or registry in this flow. Releases are git tags, images are
+transferred directly with `docker save | ssh docker load`.
+
+State lives in two bind mounts next to `compose.yaml`:
+
+| Host path | Container | Contents |
+|---|---|---|
+| `var/db` | `/var/lib/bokfd` | SQLite database, `backup/`, `export/` |
+| `var/run` | `/run/bokfd` | Unix socket (mode 0660, owned by uid 10001) |
+
+The database is a single SQLite file. Back up with `backup.snapshot`
+(`VACUUM INTO`) and point restic at `var/db/backup` — never at the live file.
+
+## First run
+
+On the dev machine, put the SSH target in `.env`:
+
+```sh
+cp .env.example .env
+# set BOKF_HOST=user@host and BOKF_REMOTE_DIR=/srv/bokf
+scripts/deploy.sh v0.1.0
+```
+
+The first run stops after shipping the image and compose.yaml, and prints the
+one-time init command. Run it on the host:
+
+```sh
+cd /srv/bokf
+docker compose run --rm -e BOKFD_PASSWORD='<admin-password>' bokfd init
+docker compose up -d
+docker compose ps # wait for "healthy"
+```
+
+`init` creates the admin user and must run before the first `up`; it refuses
+to touch an already initialized database. Subsequent `scripts/deploy.sh`
+runs update the image, tag and compose file, restart the daemon and wait for
+the healthcheck.
+
+## Deploying upgrades
+
+```sh
+git tag v0.1.1
+scripts/deploy.sh # tag defaults to git describe
+scripts/deploy.sh v0.1.1 # or pass one explicitly
+```
+
+The script:
+
+1. `make` + `make test` on the dev machine,
+2. builds `bokf:<tag>`: locally and ships it with `docker save | gzip | ssh
+ docker load`, or — when the host runs a different CPU architecture —
+ builds it natively on the host from a source tar,
+3. copies `compose.yaml` and writes `BOKF_IMAGE`/`BOKF_TAG` into the host's
+ `.env` (other keys are preserved),
+4. `docker compose up -d --no-build`, then polls the container healthcheck,
+5. on failure, puts the previous `BOKF_TAG` back and rolls back to the image
+ that is still loaded on the host.
+
+Cross-architecture builds are automatic: `uname -m` is compared over SSH and
+a mismatch switches to a remote build. Override with `BOKF_BUILD=local` or
+`BOKF_BUILD=remote` (also settable in `.env`). A remote build pulls the
+Debian base image inside a container, so the host needs outbound network
+access but still no toolchain.
+
+Tags are `git describe` output unless passed. Tag releases (`v*`) so rollback
+and support have meaningful versions. The rollback image must still exist on
+the host; don't prune before the new version has proven itself.
+
+Manual rollback — set `BOKF_TAG` on the host and restart:
+
+```sh
+cd /srv/bokf
+sed -i 's/^BOKF_TAG=.*/BOKF_TAG=v0.1.0/' .env
+docker compose up -d --no-build
+```
+
+## Optional: a bare repository on the host
+
+For an off-machine copy of the source and an optional auto-deploy hook:
+
+```sh
+ssh host 'git init --bare /srv/git/bokf.git'
+git remote add host ssh://host/srv/git/bokf.git
+git push host main
+```
+
+If the host also has the toolchain and a checkout whose origin is that bare
+repo, `deploy/post-receive.sample` can build, test and restart on every push
+to `main`. The normal `scripts/deploy.sh` flow does not need any of this.
+
+## Clients
+
+Run clients inside the container — no host toolchain needed:
+
+```sh
+docker compose exec bokfd bokftui # interactive TUI
+docker compose exec -e BOKFD_PASSWORD='<pw>' bokfd \
+ bokfctl --user admin fiscal_year.list
+```
+
+The clients honor `BOKFD_SOCKET`; a host-installed client can also point at
+`var/run/bokfd.sock`, but that file is owned by uid 10001, so the host user
+must be in that group (or use `sudo`).
+
+## Mock company
+
+```sh
+docker compose exec -e BOKFD_PASSWORD='<pw>' bokfd \
+ bokfctl --user admin org.create \
+ '{"name":"Mock AB","org_nr":"556000-0000","fiscal_year_start_month":1}'
+```
+
+Develop against this org; agents get their own API token
+(`bokfctl token.create ...`, shown once).
+
+## Backup and restore
+
+```sh
+docker compose exec -e BOKFD_PASSWORD='<pw>' bokfd \
+ bokfctl --user admin backup.snapshot
+ls var/db/backup # <db>-<timestamp>.db + .sha256
+```
+
+Restore:
+
+```sh
+docker compose stop
+cp var/db/backup/<snapshot>.db var/db/bokfd.db
+rm -f var/db/bokfd.db-wal var/db/bokfd.db-shm
+docker compose start
+```
+
+To replace the mock with a real database from another host, restore its
+snapshot the same way (same or newer bokf version; older schemas migrate
+forward). SIE import is the alternative once the importer's CRLF/`#RAR`
+fixes land.
+
+## Local development
+
+```sh
+make -j"$(nproc)" && make test # no Docker required
+docker compose up --build # same image, local var/ data dir
+```
+
+`scripts/deploy.sh` always runs the tests before shipping. `var/`, `.env`,
+`*.db` and `*.se` are gitignored — real books never enter the repository.
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
new file mode 100644
index 0000000..6dc8c7b
--- /dev/null
+++ b/docs/PROTOCOL.md
@@ -0,0 +1,469 @@
+# bokf protocol v1
+
+Status: Draft 0.1 · 2026-09-17 · License: GPL-3.0-or-later
+
+`bokf` is a self-hosted bookkeeping system for Swedish organizations (aktiebolag,
+enskild firma, handelsbolag, föreningar). One daemon owns the ledger. Every
+client — the CLI (`bokfctl`), the ncurses UI (`bokftui`) and any LLM agent —
+speaks exactly this protocol. There is no privileged client and no second API.
+
+## 1. Design principles
+
+1. **One API.** TUI, CLI and agent are peers. Anything one can do, all can do.
+2. **Self-describing.** A client can discover every command, argument and
+ permission at runtime (`describe`), plus prose workflow rules
+ (`agent.instructions`).
+3. **Dry run everywhere.** Every mutating command accepts `dry_run: true` and
+ must have no side effects when it is set.
+4. **The server owns the invariants.** Number series, balance, period locks,
+ append-only vouchers, hash chains and tenant isolation are enforced inside
+ the daemon, never trusted to clients.
+5. **Machine-first errors.** Stable error codes and machine-readable `details`.
+
+## 2. Components
+
+| Component | Role |
+|---|---|
+| `bokfd` | Daemon. Sole owner of the SQLite database. Serializes writes. |
+| `bokfctl` | Scriptable CLI client. First client built; used for tests and automation. |
+| `bokftui` | ncurses client for interactive reviewing and entry. |
+| agent | Any process using a token; a token is its own audit actor. |
+
+No client reads the database file or the filesystem directly. Backups are made
+by the daemon (`backup.snapshot`), not by clients.
+
+## 3. Transport
+
+### 3.1 Unix domain socket (default)
+
+- Default path: `/run/bokfd/bokfd.sock` (configurable).
+- File mode `0660`, owned by a dedicated group. Access to the socket is the
+ first line of defense.
+- Intended for `bokftui`, `bokfctl` and agents running on the same host.
+
+### 3.2 TCP (optional)
+
+- Disabled by default. When enabled it binds `127.0.0.1` unless explicitly
+ configured otherwise.
+- TLS is **not implemented in v1**. The intended deployments are:
+ - loopback + SSH tunnel (`ssh -L`), or
+ - a private overlay network (Tailscale/WireGuard), or
+ - a reverse proxy that terminates TLS in front of `bokfd`.
+- Every command on TCP requires authentication, including read commands.
+ `meta` and `health` are the only unauthenticated commands.
+
+### 3.3 Framing
+
+- UTF-8, one JSON object per line, `\n` terminated (JSON Lines / NDJSON).
+- One response line per request line, in order. Requests may be pipelined;
+ responses preserve order. Match on `id`.
+- Default maximum request line: 1 MiB. Because attachments travel base64 in
+ one line, the daemon accepts lines large enough for
+ `max_attachment_bytes` (default 10 MiB decoded, ~13.4 MiB encoded plus
+ envelope).
+- Idle connections are closed after 10 minutes. Sessions have their own TTL
+ (default 8 h, sliding).
+
+## 4. Authentication and authorization
+
+### 4.1 Sessions
+
+`session.open` authenticates with either a username/password or an API token
+and returns an opaque, high-entropy session id:
+
+```json
+{"v":1,"id":"1","cmd":"session.open","args":{"method":"password","username":"anders","password":"..."}}
+{"id":"1","ok":true,"result":{"session":"s_9f3...","user":{"id":1,"username":"anders","display_name":"Anders"},"orgs":[{"id":1,"name":"AB Ett","role":"owner"}],"active_org":1}}
+```
+
+- Sessions live in memory only. Restarting `bokfd` logs everyone out; that is
+ intentional. API tokens survive restarts.
+- Sliding TTL, `session_ttl` default 8 h. `session.close` ends one explicitly.
+- Passwords are stored as Argon2id hashes. Failed logins are rate limited per
+ peer (default: 5 failures per 15 minutes, then `RATE_LIMITED`).
+- Token lookups compare SHA-256 hashes in constant time. Token values are
+ shown exactly once at creation and are never logged.
+
+### 4.2 Tokens
+
+- Format: `bokf_` + 32 random bytes, base64url. Stored as SHA-256 hash.
+- A token is bound to one user and one org, has scopes (`read`, `write`,
+ `admin`) and is an independent audit actor (label shown in history).
+- Tokens are the intended mechanism for agents and for accountant/viewer
+ access. They can be revoked immediately (`token.revoke`).
+
+### 4.3 Roles and permissions
+
+Roles are per membership (org, user): `owner`, `bookkeeper`, `viewer`.
+Scopes on a token can narrow but never widen the user's role.
+
+| Capability | viewer | bookkeeper | owner | system admin |
+|---|---|---|---|---|
+| Read: vouchers, reports, audit, accounts | ● | ● | ● | ● |
+| `voucher.post`, `voucher.correct`, `attachment.put` | | ● | ● | |
+| `sie.import`, `account.create`, `account.update` | | ● | ● | |
+| `period.lock`, `fiscal_year.open/close`, `org.update` | | | ● | |
+| `org.member_*`, `token.create` for others | | | ● | |
+| `user.create`, any org | | | | ● |
+| `backup.snapshot` | | | ● | ● |
+
+Any authenticated user may create a new org (config `allow_org_create`,
+default true) and becomes its owner.
+
+### 4.4 Org scoping
+
+Every command executes in exactly one org context. The `org` field in the
+request selects it; without it the session's `active_org` is used. The server
+checks membership and role before touching data. Isolation is additionally
+enforced at the database level via composite keys (see `SCHEMA.md`).
+
+## 5. Message format
+
+### 5.1 Request
+
+| Field | Type | Notes |
+|---|---|---|
+| `v` | int | Required. Protocol version, currently `1`. |
+| `id` | string | Required. Client-generated; echoed in the response. |
+| `cmd` | string | Required. Command name, e.g. `voucher.post`. |
+| `session` | string | Session id from `session.open`. |
+| `org` | int | Optional org id. Overrides `active_org`. |
+| `dry_run` | bool | Optional, default false. No side effects when true. |
+| `args` | object | Command arguments. Required for commands that take any. |
+
+### 5.2 Response
+
+```json
+{"id":"42","ok":true,"result":{...},"warnings":[{"code":"FY_ENDING","message":"..."}]}
+{"id":"43","ok":false,"error":{"code":"UNBALANCED","message":"Debit and credit differ","details":{"difference_ore":1250}}}
+```
+
+`ok:true` always has `result` (may be `{}`). `ok:false` always has `error`.
+`warnings` is optional and only appears on success.
+
+### 5.3 Error codes
+
+`PARSE_ERROR`, `UNSUPPORTED_VERSION`, `UNKNOWN_COMMAND`, `INVALID_ARGS`,
+`AUTH_REQUIRED`, `AUTH_FAILED`, `SESSION_EXPIRED`, `RATE_LIMITED`,
+`ORG_REQUIRED`, `ORG_FORBIDDEN`, `FORBIDDEN`, `NOT_FOUND`, `CONFLICT`,
+`UNBALANCED`, `ACCOUNT_NOT_FOUND`, `ACCOUNT_INACTIVE`, `FISCAL_YEAR_NOT_FOUND`,
+`FISCAL_YEAR_CLOSED`, `PERIOD_LOCKED`, `DATE_OUT_OF_RANGE`, `IMMUTABLE`,
+`SEQUENCE_GAP`, `TOO_LARGE`, `UNSUPPORTED`, `DB_BUSY`, `INTERNAL`.
+
+Codes are stable; `message` is human-readable and may change. `details` is
+machine-readable where offered.
+
+### 5.4 Value conventions
+
+- **Money** is always integer **öre** (1/100 SEK) in JSON. Never floats.
+- **Dates** are `YYYY-MM-DD`. **Timestamps** are RFC 3339 UTC with `Z`.
+- **Account numbers** are strings (`"1930"`) to preserve leading zeros.
+- **Org numbers** are strings (`"5560123456"`), not integers.
+
+### 5.5 Idempotency
+
+Any mutating command may carry `args.client_ref`, a client-generated string
+(≤ 64 chars, unique per org). If the same `client_ref` is seen again, the
+server returns the original result with `"replayed":true` instead of posting
+twice. Agents should always set it on writes.
+
+### 5.6 Dry run
+
+`dry_run: true` performs the full validation path — role check, period lock,
+balance, account existence, VAT computation, number assignment preview — and
+returns exactly what would be written, with `"dry_run":true` in the result.
+Nothing is persisted, including audit entries.
+
+### 5.7 Batching
+
+```json
+{"v":1,"id":"7","cmd":"batch","session":"s_...","args":{"atomic":true,"requests":[
+ {"cmd":"voucher.post","args":{...}},
+ {"cmd":"attachment.put","args":{...}}
+]}}
+```
+
+Executes requests in order. `atomic:true` wraps them in one transaction.
+Each element's response is returned in `result.results`; execution stops at the
+first error unless `"continue_on_error":true`.
+
+### 5.8 Pagination
+
+List results have the shape `{"items":[...],"next_cursor":"..."}`.
+Pass `args.cursor` back to continue. `limit` defaults to 100, maximum 1000.
+Cursors are opaque.
+
+## 6. Discovery
+
+### 6.1 `meta` / `health` (unauthenticated)
+
+`meta` returns server version, protocol version `1`, enabled features
+(`checks`, `sie`, `vat_report`), configured limits and whether TCP is enabled.
+`health` returns `{"status":"ok"}`.
+
+### 6.2 `describe`
+
+Returns the full command catalogue. `describe {"cmd":"voucher.post"}` returns
+one entry. Each entry:
+
+```json
+{
+ "name":"voucher.post","summary":"Post an immutable voucher",
+ "permission":{"role":"bookkeeper","require_org":true},
+ "mutating":true,"dry_run":true,
+ "args":{"date":{"type":"date","required":true},
+ "rows":{"type":"array","min":2,"of":{...}}},
+ "result":{...},
+ "examples":[{"args":{...},"result":{...}}]
+}
+```
+
+This is the primary integration surface for agents: call `describe`, then act.
+
+### 6.3 `agent.instructions`
+
+Returns Markdown workflow rules served by the daemon itself, so operational
+policy versions with the software. It covers, at minimum:
+
+- authenticate with a token, pick org, never store passwords
+- always `dry_run` first, then post with the same `client_ref`
+- amounts are öre; accounts are strings; dates `YYYY-MM-DD`
+- never attempt to edit or delete: corrections are new vouchers
+ (`voucher.correct`)
+- receipts: `attachment.put` before or together with posting
+- locked periods and closed years are hard stops — ask the human
+- `fiscal_year.close`, `period.lock` and `sie.import` are irreversible
+ operations: confirm with the human first
+- how to read `report.vat` boxes and `report.balance_sheet`
+- on `CONFLICT`/`replayed`, fetch the existing object instead of retrying
+
+## 7. Commands
+
+Arguments are shown abbreviated; `describe` is authoritative.
+
+### 7.1 Session, orgs, users, tokens
+
+| Command | Args | Result |
+|---|---|---|
+| `session.open` | `method`, `username`, `password`, `token` | `session`, `user`, `orgs[]`, `active_org` |
+| `session.close` | — | `{}` |
+| `session.whoami` | — | `user`, `active_org`, `role`, `scopes` |
+| `session.use_org` | `org` | `active_org`, `role` |
+| `org.create` | `name`, `org_nr?`, `fiscal_year_start_month?`, `moms_period?`, `framework?` | `org` |
+| `org.get` / `org.list` | `org?` | `org` / `items[]` |
+| `org.update` | `org`, fields | `org` (owner) |
+| `org.member_list` | `org` | `items[{user,role}]` |
+| `org.member_add` | `org`, `username`, `role` | `{}` |
+| `org.member_set_role` | `org`, `username`, `role` | `{}` |
+| `org.member_remove` | `org`, `username` | `{}` |
+| `user.create` | `username`, `password`, `display_name`, `is_admin?` | `user` (system admin) |
+| `user.list` | — | `items[]` (system admin) |
+| `token.create` | `label`, `scopes[]`, `org`, `expires_at?` | `token` (shown once), `id` |
+| `token.list` / `token.revoke` | — / `id` | `items[]` / `{}` |
+
+### 7.2 Kontoplan
+
+| Command | Args | Notes |
+|---|---|---|
+| `account.list` | `active_only?` | BAS 2026 seeded at org creation |
+| `account.get` | `id` or `number` | |
+| `account.create` | `number`, `name`, `type`, `sru_code?`, `vat_code?` | bookkeeper |
+| `account.update` | `id`, `name?`, `sru_code?`, `vat_code?`, `active?` | bookkeeper, audited |
+
+Account `type` is one of `asset`, `liability`, `equity`, `revenue`, `expense`.
+
+### 7.3 Fiscal years and locks
+
+| Command | Args | Notes |
+|---|---|---|
+| `fiscal_year.list` / `fiscal_year.get` | `org?` / `id` | |
+| `fiscal_year.open` | `label`, `start_date`, `end_date` | owner |
+| `fiscal_year.close` | `id`, `confirm:true` | owner; irreversible |
+| `period.lock` | `fiscal_year`, `until`, `reason?` | owner; `until` inclusive |
+| `period.unlock` | `fiscal_year`, `reason` | owner; audited with reason |
+
+Postings dated on or before `locked_until` are rejected with `PERIOD_LOCKED`.
+Years with status `closed` reject all postings.
+
+### 7.4 Vouchers
+
+| Command | Args | Notes |
+|---|---|---|
+| `voucher.post` | `date`, `description?`, `rows[]` **or** `template`+`x`, `series?`, `corrects_voucher?`, `attachment_ids?`, `client_ref?` | dry-run supported |
+| `voucher.get` | `id` | |
+| `voucher.list` | `fiscal_year?`, `from?`, `to?`, `series?`, `account?`, `text?`, `limit`, `cursor` | |
+| `voucher.correct` | `voucher`, `description`, `date?`, `client_ref?` | creates ändringsverifikat |
+
+A row is `{"account":"1930","debit_ore":125000,"credit_ore":0,"description":"..."}`.
+Exactly one of `debit_ore`/`credit_ore` may be non-zero; at least two rows;
+sum debit = sum credit. `voucher.correct` mirrors the original rows, links the
+new voucher to the original via `corrects_voucher`, and posts it as a normal
+immutable voucher. The original is never touched.
+
+### 7.4.1 Templates (konteringsmallar)
+
+Instead of `rows`, a post may name a template and supply its variable:
+`{"template":"Försäljning 25%","x":"1250","date":"2026-01-15"}`.
+
+| Command | Args | Notes |
+|---|---|---|
+| `template.list` | `active_only?` | id, name, series, row_count |
+| `template.get` | `id` or `name` | includes rows with account, formula |
+| `template.create` | `name`, `series?`, `description?`, `rows[]{account,formula,description?}` | bookkeeper |
+| `template.update` | `id` or `name`, plus fields/`rows[]` to replace | bookkeeper |
+| `template.archive` | `id` or `name` | soft delete; bookkeeper |
+
+Formulas use `x`, decimal numbers and `+ - * /` with parentheses, evaluated
+in kronor. A positive result is a debit, a negative result a credit, a zero
+result drops the row. Rows are rounded to whole öre and the rounding
+remainder is assigned to the largest row so the voucher balances. `{x}` in
+the template description (used when the request carries none) is replaced by
+the amount. Unknown accounts and invalid formulas are rejected when the
+template is saved; `voucher.post` re-validates at apply time and reports the
+resolved rows in a dry run.
+
+### 7.4.2 Settings
+
+| Command | Args | Notes |
+|---|---|---|
+| `settings.get` | — | effective org settings (defaults included) |
+| `settings.set` | `key`, `value` | known keys: `default_series`, `attachment_dir` |
+
+`default_series` (1–8 characters, e.g. `A`, `V-`, `A `) is used when
+`voucher.post` carries no `series` and as the default series for new
+templates. `attachment_dir` (a path, up to 255 characters) is the folder the
+TUI file browser opens in when attaching underlag. Verification ids are the concatenation of series and number
+(`V-8`), and series are free-form: only an unbroken numbering per series is
+required.
+
+### 7.5 Attachments (underlag)
+
+| Command | Args | Notes |
+|---|---|---|
+| `attachment.put` | `filename`, `mime`, `content_base64`, `voucher_id?` | ≤ max_attachment_bytes |
+| `attachment.get` | `id` | returns base64 + sha256 |
+| `attachment.list` | `voucher_id?`, `unlinked?`, `limit`, `cursor` | inbox = `unlinked:true` |
+
+Attachments are immutable and content-addressed by SHA-256. They are linked to
+vouchers at posting time (`attachment_ids`) or afterwards via
+`attachment.put` with `voucher_id`; links are separate insert-only rows.
+
+### 7.6 Reports
+
+| Command | Args | Result |
+|---|---|---|
+| `report.trial_balance` | `fiscal_year`, `from?`, `to?`, `include_zero?` | saldo per account, IB/UB |
+| `report.income_statement` | `fiscal_year`, `from?`, `to?` | resultaträkning, K2-ish grouping |
+| `report.balance_sheet` | `fiscal_year`, `to?` | balansräkning |
+| `report.general_ledger` | `fiscal_year`, `accounts?`, `from?`, `to?` | huvudbok |
+| `report.voucher_list` | `fiscal_year`, `series?` | grundbok/verifikationslista |
+| `report.vat` | `from`, `to`, `period_type?` | momsdeklaration ruta för ruta |
+
+All reports are pure reads, respect locks, and return JSON rows. Amounts are
+öre. `report.vat` returns `{"boxes":[{"box":"05","label":"...","amount_ore":...}],"period":{...}}`.
+
+### 7.7 SIE 4
+
+| Command | Args | Result |
+|---|---|---|
+| `sie.export` | `fiscal_year`, `inline?` | by default writes `<export_dir>/<org>_<fy>.se` and returns `path`, `sha256`, `size`; `inline:true` also returns `content_base64` |
+| `sie.import` | `content_base64` or `path`, `dry_run?` | creates missing accounts and posts #VER as `source:"sie_import"`; only into an empty org fiscal year |
+
+SIE 4 files are written in CP437 with PC8 format, `#SIETYP 4`, `#FNR`, `#ORGNR`,
+`#KONTO`, `#IB`, `#UB`, `#RES`, `#VER`, `#TRANS`. Import is the migration path
+from Fortnox/Visma/BL and must be dry-run first; it reports exactly what would
+be created.
+
+### 7.8 Backup and audit
+
+| Command | Args | Result |
+|---|---|---|
+| `backup.snapshot` | `dest?` | `path`, `sha256`, `size`, `at` — uses SQLite `VACUUM INTO`, no downtime |
+| `audit.list` | `from?`, `to?`, `action?`, `actor?`, `limit`, `cursor` | behandlingshistorik |
+| `audit.verify` | `full?` | recomputes voucher and audit hash chains; `ok`, `checked`, first/last mismatch if any |
+
+`audit.verify` is cheap enough to run after every import and before every
+backup; `full:true` includes attachment hashes.
+
+## 8. The TUI is just a client
+
+`bokftui` logs in over the same socket, picks an org and issues the same
+commands. Implemented screens (0.1.0-dev):
+
+- **Inloggning** — server, user, password; org picker when several exist.
+- **Dashboard** — status line with org, fiscal year, role and user.
+- **Verifikat** — list and detail view (rows, attachments, hash, link to
+ corrected voucher); `c` posts an ändringsverifikat.
+- **Nytt verifikat** — row editor with live balance display, F5 dry-run
+ validation and F9 posting; one `client_ref` per form makes retries safe.
+ F4 applies a konteringsmall (prompts for template and `x`).
+- **Ingående balans** — the series `IB` voucher for the selected fiscal year;
+ enter accounts with signed amounts (positive debit, negative credit), the
+ editor posts deltas so existing entries are never edited.
+- **Välj organisation att representera** — login step two: pick which org the
+ session works in. `--org ID` skips the picker for scripts.
+- **Byt räkenskapsår** (dashboard) — pick from the org's fiscal years; shown
+ as `YYYY-MM-DD - YYYY-MM-DD` (plus label and open/closed) so broken fiscal
+ years are visible. All screens then work in the selected year.
+- **Mallar** — list, create and edit templates in the same form style as
+ vouchers (Tab, dynamic rows, F7 clear row, F5 validate, F9 save); archive
+ keeps the template but hides it from the list.
+- **Underlag** — inbox of unlinked attachments; `a` uploads a file.
+- **Rapporter** — saldobalans, resultaträkning, balansräkning, moms and
+ kontolista (all accounts with type, moms treatment, SRU and status).
+- **Revision** — chain verification and behandlingshistorik.
+
+**Ctrl+N is the universal "add" key**: it starts a new verifikat from the
+dashboard, the voucher list and the voucher detail view; a new mall from the
+Mallar menu; a new fiscal year from the year picker; and maps to the editor
+in Ingående balans and to file upload in Underlag. F5 is the universal
+refresh. Hints show the keys per screen.
+
+Still missing from the UI (API already supports): SIE export/import,
+period locks, member/token administration. The TUI holds no local state
+beyond the session and calls nothing but public commands.
+
+## 9. Versioning
+
+- `v` is the protocol major version. The server rejects unknown majors with
+ `UNSUPPORTED_VERSION`.
+- v1 evolves additively only: new commands, new optional fields, new error
+ codes. Removals or semantic changes require v2.
+- `meta.capabilities` lets clients feature-detect without version sniffing.
+
+## 10. Security notes
+
+- Bind nothing publicly by default. Loopback or Unix socket unless the operator
+ opts in.
+- Passwords: Argon2id (vendored reference implementation). Tokens: 256-bit
+ random, stored hashed, revocable, never logged. Sessions: memory only.
+- Audit and logs redact secrets: `session.open` records username and outcome,
+ never the password or token value.
+- Socket and database files are `0600`/`0660`; backups inherit the same
+ discipline.
+- For data at rest, prefer LUKS on the host. SQLCipher support is a possible
+ later option; not in v1.
+- The server answers `DB_BUSY` rather than blocking indefinitely when a writer
+ holds the database; clients should retry with backoff.
+
+## 11. Server configuration
+
+`/etc/bokfd/bokfd.conf`, overridable by `BOKFD_*` environment variables:
+
+| Key | Default | Meaning |
+|---|---|---|
+| `socket` | `/run/bokfd/bokfd.sock` | Unix socket path |
+| `tcp` | off | `host:port` to enable TCP |
+| `db` | `/var/lib/bokfd/bokfd.db` | SQLite database |
+| `backup_dir` | `/var/lib/bokfd/backup` | destination for `backup.snapshot` |
+| `export_dir` | `/var/lib/bokfd/export` | SIE exports |
+| `session_ttl` | `8h` | sliding session lifetime |
+| `max_line_bytes` | `1048576` | NDJSON line limit |
+| `max_attachment_bytes` | `10485760` (10 MiB) | decoded attachment limit |
+| `auth_fail_limit` | `5/15m` | login rate limit per peer |
+| `synchronous` | `FULL` | SQLite durability (`FULL`/`NORMAL`) |
+| `audit_reads` | `false` | log read commands too |
+| `allow_org_create` | `true` | any user may create an org |
+
+Container deployments mount the socket directory, database directory, backup
+and export directories as volumes; the daemon is otherwise stateless.
diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md
new file mode 100644
index 0000000..4b22271
--- /dev/null
+++ b/docs/SCHEMA.md
@@ -0,0 +1,490 @@
+# bokf database schema
+
+Status: Draft 0.1 · 2026-09-17 · License: GPL-3.0-or-later
+
+Storage is a single SQLite database (WAL mode) owned exclusively by `bokfd`.
+Clients never see it. This document defines the schema, the invariants it
+enforces, and the canonical hash encodings that make the ledger tamper-evident.
+
+## 1. Principles
+
+1. **STRICT tables.** Type errors are bugs, not data.
+2. **Integer öre.** All amounts are `INTEGER` öre. No floats anywhere.
+3. **Append-only ledger.** Vouchers, rows, attachments and audit entries can be
+ inserted but never updated or deleted. Corrections are new vouchers.
+4. **Tenant isolation in the engine.** Every tenant table has
+ `UNIQUE(org_id, id)` and every reference is a composite foreign key
+ `(org_id, target_id)`. A row cannot physically reference another org's data,
+ even if application code is wrong.
+5. **One writer.** `bokfd` serializes all writes; SQLite's single-writer model
+ is therefore never contended between threads.
+6. **Hash chains.** Voucher history and audit history are independently
+ SHA-256 chained and verifiable (`audit.verify`).
+
+## 2. Connection pragmas
+
+Every connection:
+
+```sql
+PRAGMA journal_mode = WAL;
+PRAGMA foreign_keys = ON;
+PRAGMA busy_timeout = 5000;
+PRAGMA synchronous = FULL; -- configurable: NORMAL for speed, FULL default
+PRAGMA wal_autocheckpoint = 1000;
+```
+
+Writes use `BEGIN IMMEDIATE`. This is what makes `DB_BUSY` a transient error
+instead of silent corruption.
+
+## 3. Overview
+
+```
+orgs ─┬─ memberships ── users ── api_tokens
+ ├─ accounts
+ ├─ fiscal_years ── sequences
+ ├─ vouchers ─┬─ voucher_rows
+ │ └─ voucher_attachments ── attachments
+ ├─ audit_log (global chain, org_id nullable)
+ ├─ idempotency
+ ├─ report_rules
+ └─ settings
+```
+
+`org_id` is present on every tenant row. `audit_log` is a global chain with a
+nullable `org_id`, because user and system events (logins, org creation) are
+not org-scoped.
+
+## 4. Identity and tenancy
+
+```sql
+CREATE TABLE orgs (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL,
+ org_nr TEXT, -- "5560123456", leading zeros preserved
+ vat_nr TEXT, -- "SE556012345601"
+ address TEXT,
+ postal_code TEXT,
+ city TEXT,
+ country TEXT NOT NULL DEFAULT 'SE',
+ email TEXT,
+ phone TEXT,
+ fiscal_year_start_month INTEGER NOT NULL DEFAULT 1 CHECK (fiscal_year_start_month BETWEEN 1 AND 12),
+ moms_period TEXT NOT NULL DEFAULT 'month' CHECK (moms_period IN ('month','quarter','year')),
+ framework TEXT NOT NULL DEFAULT 'K2' CHECK (framework IN ('K2','K3')),
+ created_at TEXT NOT NULL,
+ created_by INTEGER NOT NULL REFERENCES users(id),
+ archived_at TEXT
+) STRICT;
+
+CREATE TABLE users (
+ id INTEGER PRIMARY KEY,
+ username TEXT NOT NULL UNIQUE COLLATE NOCASE,
+ display_name TEXT NOT NULL,
+ pw_hash TEXT NOT NULL, -- Argon2id PHC string
+ is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0,1)),
+ created_at TEXT NOT NULL,
+ disabled_at TEXT
+) STRICT;
+
+CREATE TABLE memberships (
+ org_id INTEGER NOT NULL REFERENCES orgs(id),
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ role TEXT NOT NULL CHECK (role IN ('owner','bookkeeper','viewer')),
+ created_at TEXT NOT NULL,
+ PRIMARY KEY (org_id, user_id)
+) STRICT;
+
+CREATE TABLE api_tokens (
+ id INTEGER PRIMARY KEY,
+ org_id INTEGER NOT NULL REFERENCES orgs(id),
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ label TEXT NOT NULL,
+ token_hash BLOB NOT NULL UNIQUE CHECK (length(token_hash) = 32),
+ scopes TEXT NOT NULL, -- JSON array of 'read','write','admin'
+ created_at TEXT NOT NULL,
+ expires_at TEXT,
+ last_used_at TEXT,
+ revoked_at TEXT
+) STRICT;
+```
+
+Sessions are deliberately absent from the schema: they live in `bokfd` memory
+and die with the process.
+
+## 5. Kontoplan
+
+```sql
+CREATE TABLE accounts (
+ org_id INTEGER NOT NULL REFERENCES orgs(id),
+ id INTEGER PRIMARY KEY,
+ number TEXT NOT NULL CHECK (number GLOB '[0-9]*' AND length(number) BETWEEN 1 AND 10),
+ name TEXT NOT NULL, -- kontobeteckning (BFL 3 kap)
+ type TEXT NOT NULL CHECK (type IN ('asset','liability','equity','revenue','expense')),
+ sru_code TEXT, -- for SRU/INK2 mapping later
+ vat_code TEXT, -- default momskod for reporting
+ active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1)),
+ created_at TEXT NOT NULL,
+ updated_at TEXT,
+ UNIQUE (org_id, id),
+ UNIQUE (org_id, number)
+) STRICT;
+
+CREATE INDEX idx_accounts_type ON accounts(org_id, type);
+```
+
+Accounts are seeded from BAS 2026 at org creation (separately attributed seed
+data, see `THIRD_PARTY_NOTICES`). They are mutable configuration, not ledger
+data: renaming or deactivating an account is allowed and audited; posting to an
+inactive account is rejected unless the fiscal year is closed.
+
+## 6. Fiscal years, sequences and locks
+
+```sql
+CREATE TABLE fiscal_years (
+ org_id INTEGER NOT NULL REFERENCES orgs(id),
+ id INTEGER PRIMARY KEY,
+ label TEXT NOT NULL, -- "2026", "2025/2026"
+ start_date TEXT NOT NULL,
+ end_date TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','closed')),
+ locked_until TEXT, -- inclusive last locked date
+ created_at TEXT NOT NULL,
+ closed_at TEXT,
+ closed_by INTEGER REFERENCES users(id),
+ UNIQUE (org_id, id),
+ UNIQUE (org_id, label),
+ CHECK (start_date < end_date),
+ CHECK (locked_until IS NULL OR locked_until <= end_date)
+) STRICT;
+
+CREATE TABLE sequences (
+ org_id INTEGER NOT NULL,
+ fiscal_year_id INTEGER NOT NULL,
+ series TEXT NOT NULL, -- "A", "B", "IB", ...
+ next_number INTEGER NOT NULL DEFAULT 1,
+ PRIMARY KEY (org_id, fiscal_year_id, series),
+ FOREIGN KEY (org_id, fiscal_year_id) REFERENCES fiscal_years(org_id, id)
+) STRICT;
+```
+
+`next_number` is incremented in the same transaction as the voucher insert, so
+a failed post never consumes a number and a committed post never skips one.
+Numbering is per fiscal year and series, matching BFL's requirement of an
+unbroken series (obruten nummerserie).
+
+## 7. Vouchers (append-only)
+
+```sql
+CREATE TABLE vouchers (
+ org_id INTEGER NOT NULL REFERENCES orgs(id),
+ id INTEGER PRIMARY KEY,
+ fiscal_year_id INTEGER NOT NULL,
+ series TEXT NOT NULL,
+ number INTEGER NOT NULL CHECK (number > 0),
+ date TEXT NOT NULL CHECK (date LIKE '____-__-__'),
+ description TEXT NOT NULL CHECK (length(description) > 0),
+ source TEXT NOT NULL DEFAULT 'manual'
+ CHECK (source IN ('manual','agent','sie_import','system','ib')),
+ client_ref TEXT,
+ corrects_voucher_id INTEGER,
+ created_at TEXT NOT NULL,
+ created_by_user INTEGER NOT NULL REFERENCES users(id),
+ created_by_token INTEGER REFERENCES api_tokens(id),
+ hash_prev BLOB NOT NULL CHECK (length(hash_prev) = 32),
+ hash BLOB NOT NULL CHECK (length(hash) = 32),
+ UNIQUE (org_id, id),
+ UNIQUE (org_id, fiscal_year_id, series, number),
+ UNIQUE (org_id, client_ref),
+ FOREIGN KEY (org_id, fiscal_year_id) REFERENCES fiscal_years(org_id, id),
+ FOREIGN KEY (org_id, corrects_voucher_id) REFERENCES vouchers(org_id, id)
+) STRICT;
+
+CREATE TABLE voucher_rows (
+ org_id INTEGER NOT NULL,
+ id INTEGER PRIMARY KEY,
+ voucher_id INTEGER NOT NULL,
+ line_no INTEGER NOT NULL,
+ account_id INTEGER NOT NULL,
+ debit_ore INTEGER NOT NULL DEFAULT 0 CHECK (debit_ore >= 0),
+ credit_ore INTEGER NOT NULL DEFAULT 0 CHECK (credit_ore >= 0),
+ description TEXT,
+ CHECK ((debit_ore = 0) <> (credit_ore = 0)), -- exactly one side non-zero
+ CHECK (debit_ore > 0 OR credit_ore > 0),
+ UNIQUE (org_id, id),
+ UNIQUE (org_id, voucher_id, line_no),
+ FOREIGN KEY (org_id, voucher_id) REFERENCES vouchers(org_id, id),
+ FOREIGN KEY (org_id, account_id) REFERENCES accounts(org_id, id)
+) STRICT;
+
+CREATE INDEX idx_vouchers_date ON vouchers(org_id, date);
+CREATE INDEX idx_vouchers_fy ON vouchers(org_id, fiscal_year_id, series, number);
+CREATE INDEX idx_rows_voucher ON voucher_rows(org_id, voucher_id, line_no);
+CREATE INDEX idx_rows_account ON voucher_rows(org_id, account_id);
+```
+
+Immutability is enforced by triggers, not convention:
+
+```sql
+CREATE TRIGGER vouchers_no_update BEFORE UPDATE ON vouchers
+BEGIN SELECT RAISE(ABORT, 'vouchers are append-only'); END;
+
+CREATE TRIGGER vouchers_no_delete BEFORE DELETE ON vouchers
+BEGIN SELECT RAISE(ABORT, 'vouchers are append-only'); END;
+
+CREATE TRIGGER voucher_rows_no_update BEFORE UPDATE ON voucher_rows
+BEGIN SELECT RAISE(ABORT, 'voucher rows are append-only'); END;
+
+CREATE TRIGGER voucher_rows_no_delete BEFORE DELETE ON voucher_rows
+BEGIN SELECT RAISE(ABORT, 'voucher rows are append-only'); END;
+```
+
+Consequences worth stating explicitly:
+
+- There is no `updated_at` on a voucher. There is no way to change one,
+ including through `sqlite3` as root, without dropping the trigger first.
+- `corrects_voucher_id` points old → new is the reverse of the wording: the
+ **new** voucher carries `corrects_voucher_id = <original>`. The original stays
+ untouched, and reports show both.
+- Balance is enforced by the posting algorithm inside a transaction; `audit.verify`
+ additionally scans for any unbalanced voucher as a integrity backstop.
+
+### 7.1 Voucher hash (canonical bytes)
+
+```
+SHA256( "bokf-v1-voucher\0"
+ prev_hash[32]
+ org_id u64be
+ fiscal_year label u16len + utf8 bytes
+ series u16len + utf8 bytes
+ number u64be
+ date "YYYY-MM-DD" (10 bytes)
+ description u32len + utf8 bytes
+ row_count u32be
+ for each row, line_no order:
+ account number u16len + utf8 bytes
+ debit_ore u64be
+ credit_ore u64be
+ row description u32len + utf8 bytes )
+```
+
+`prev_hash` is the `hash` of the previous voucher **by `id`** (insertion
+order = posting order) in the same org, or 32 zero bytes for the first voucher
+of the org. The chain therefore spans fiscal years, and any retroactive edit,
+insertion or deletion breaks it at a detectable point.
+
+## 8. Attachments (underlag)
+
+```sql
+CREATE TABLE attachments (
+ org_id INTEGER NOT NULL REFERENCES orgs(id),
+ id INTEGER PRIMARY KEY,
+ sha256 BLOB NOT NULL CHECK (length(sha256) = 32),
+ filename TEXT NOT NULL,
+ mime TEXT NOT NULL,
+ size_bytes INTEGER NOT NULL CHECK (size_bytes >= 0),
+ content BLOB NOT NULL, -- stored in-DB; backups stay trivial
+ created_at TEXT NOT NULL,
+ created_by INTEGER NOT NULL REFERENCES users(id),
+ UNIQUE (org_id, id),
+ UNIQUE (org_id, sha256, filename)
+) STRICT;
+
+CREATE TABLE voucher_attachments (
+ org_id INTEGER NOT NULL,
+ voucher_id INTEGER NOT NULL,
+ attachment_id INTEGER NOT NULL,
+ created_at TEXT NOT NULL,
+ PRIMARY KEY (org_id, voucher_id, attachment_id),
+ FOREIGN KEY (org_id, voucher_id) REFERENCES vouchers(org_id, id),
+ FOREIGN KEY (org_id, attachment_id) REFERENCES attachments(org_id, id)
+) STRICT;
+```
+
+Attachments are content-addressed and immutable; linking is an insert into
+`voucher_attachments` and is itself audited. Unlinked attachments form the
+inbox the TUI shows. The 7-year archive rule means content must never be
+garbage-collected; deduplication by hash keeps repeated receipts cheap.
+
+## 9. Audit log and idempotency
+
+```sql
+CREATE TABLE audit_log (
+ seq INTEGER PRIMARY KEY,
+ org_id INTEGER REFERENCES orgs(id),
+ at TEXT NOT NULL, -- RFC 3339 UTC
+ actor_user_id INTEGER REFERENCES users(id),
+ actor_token_id INTEGER REFERENCES api_tokens(id),
+ action TEXT NOT NULL, -- "voucher.post", "auth.fail", ...
+ request_json TEXT NOT NULL DEFAULT '{}', -- redacted, exact bytes are hashed
+ result_code TEXT NOT NULL, -- "OK", "UNBALANCED", ...
+ hash_prev BLOB NOT NULL CHECK (length(hash_prev) = 32),
+ hash BLOB NOT NULL CHECK (length(hash) = 32)
+) STRICT;
+
+CREATE TRIGGER audit_no_update BEFORE UPDATE ON audit_log
+BEGIN SELECT RAISE(ABORT, 'audit log is append-only'); END;
+CREATE TRIGGER audit_no_delete BEFORE DELETE ON audit_log
+BEGIN SELECT RAISE(ABORT, 'audit log is append-only'); END;
+
+CREATE TABLE idempotency (
+ org_id INTEGER NOT NULL,
+ client_ref TEXT NOT NULL,
+ cmd TEXT NOT NULL,
+ response_json TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ PRIMARY KEY (org_id, client_ref),
+ FOREIGN KEY (org_id) REFERENCES orgs(id)
+) STRICT;
+```
+
+### 9.1 Audit hash (canonical bytes)
+
+```
+SHA256( "bokf-v1-audit\0"
+ prev_hash[32]
+ seq u64be
+ at u16len + utf8
+ actor_user_id u64be -- 0 = none
+ actor_token_id u64be -- 0 = none
+ org_id u64be -- 0 = none
+ action u16len + utf8
+ request_json u32len + exact stored bytes
+ result_code u16len + utf8 )
+```
+
+Hashing the stored bytes verbatim avoids canonicalization disputes. Secrets are
+redacted **before** the bytes are stored and hashed: `session.open` records
+`{"method":"password","username":"anders"}` and the outcome, never the
+password or token.
+
+Actions written to the log include: `auth.open`, `auth.fail`, `session.close`,
+`org.create`, `org.update`, `member.add`, `member.set_role`, `member.remove`,
+`user.create`, `token.create`, `token.revoke`, `account.create`,
+`account.update`, `fiscal_year.open`, `fiscal_year.close`, `period.lock`,
+`period.unlock`, `voucher.post`, `voucher.correct`, `attachment.put`,
+`attachment.link`, `sie.import`, `sie.export`, `backup.snapshot`,
+`settings.update`. Reads are logged only when `audit_reads = true`.
+
+## 10. Reporting rules and settings
+
+```sql
+CREATE TABLE report_rules (
+ org_id INTEGER NOT NULL REFERENCES orgs(id),
+ id INTEGER PRIMARY KEY,
+ report TEXT NOT NULL, -- "vat", "income_statement", ...
+ box TEXT NOT NULL, -- "05", "10", ...
+ match_type TEXT NOT NULL CHECK (match_type IN ('account','range','type')),
+ pattern TEXT NOT NULL, -- "2610" | "2610-2619" | "revenue"
+ sign INTEGER NOT NULL DEFAULT 1 CHECK (sign IN (1,-1)),
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ UNIQUE (org_id, id)
+) STRICT;
+
+CREATE TABLE settings (
+ org_id INTEGER NOT NULL REFERENCES orgs(id),
+ key TEXT NOT NULL,
+ value TEXT NOT NULL,
+ PRIMARY KEY (org_id, key)
+) STRICT;
+```
+
+`report_rules` is seeded per org with a moms mapping over BAS account ranges and
+is editable by owners when Skatteverket changes the blankett. The seed rules
+are data, not code: the system ships a reviewed default set per fiscal year and
+keeps older sets for older years.
+
+### 10.1 Voucher templates (schema v2)
+
+Konteringsmallar: named sets of rows with a formula over the variable `x`.
+Configuration data, mutable and audited, never part of the ledger.
+
+```sql
+CREATE TABLE voucher_templates (
+ org_id INTEGER NOT NULL REFERENCES orgs(id),
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL,
+ series TEXT NOT NULL DEFAULT 'A',
+ description TEXT NOT NULL DEFAULT '',
+ active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1)),
+ created_at TEXT NOT NULL,
+ updated_at TEXT,
+ UNIQUE (org_id, id),
+ UNIQUE (org_id, name)
+) STRICT;
+
+CREATE TABLE voucher_template_rows (
+ org_id INTEGER NOT NULL,
+ id INTEGER PRIMARY KEY,
+ template_id INTEGER NOT NULL,
+ line_no INTEGER NOT NULL,
+ account_id INTEGER NOT NULL,
+ formula TEXT NOT NULL CHECK (length(formula) > 0),
+ description TEXT,
+ UNIQUE (org_id, id),
+ UNIQUE (org_id, template_id, line_no),
+ FOREIGN KEY (org_id, template_id)
+ REFERENCES voucher_templates(org_id, id),
+ FOREIGN KEY (org_id, account_id) REFERENCES accounts(org_id, id)
+) STRICT;
+```
+
+Formulas are evaluated in kronor, rounded per row to whole öre, and the
+rounding remainder is assigned to the largest row before posting, so the
+resulting voucher always balances. See `PROTOCOL.md` §7.4.1.
+
+## 11. The posting algorithm
+
+Every write path (`voucher.post`, `voucher.correct`, `sie.import`) runs the same
+steps inside one `BEGIN IMMEDIATE` transaction:
+
+1. Authenticate, resolve org, check role/scope.
+2. Validate arguments; resolve accounts by number; reject inactive.
+3. Resolve fiscal year by date; reject `FISCAL_YEAR_CLOSED`,
+ `DATE_OUT_OF_RANGE`, `PERIOD_LOCKED`.
+4. Rows: ≥ 2, exactly one non-zero side per row, `sum(debit) == sum(credit)`,
+ else `UNBALANCED` with `difference_ore`.
+5. Reserve and increment `sequences.next_number`; the assigned number is the
+ only number the voucher can have (`SEQUENCE_GAP` on mismatch).
+6. Read `hash_prev` = last voucher hash by id for the org; compute `hash`.
+7. Insert voucher, rows, and `voucher_attachments` links.
+8. Insert `audit_log` entry with its own chain hash.
+9. Insert `idempotency` row if `client_ref` was given.
+10. Commit. On any failure, roll back everything — no number is consumed.
+
+`dry_run` runs steps 1–5 and 7's validation, computes the hash with the
+*candidate* number and a temporary `id`, and returns the preview without
+writing. The preview hash is not the final hash (the number could change if
+another voucher is posted in between) — clients must not persist it.
+
+## 12. Migrations and versioning
+
+- `meta(key TEXT PRIMARY KEY, value TEXT)` holds `schema_version` (integer)
+ and `created_at`. Current version: **2** (v2 adds the two template tables).
+- Migrations are forward-only, applied automatically at daemon start, each in
+ one transaction, and require an automatic `VACUUM INTO` snapshot next to the
+ database before starting (`bokfd.db.pre-migration-<version>`).
+- `audit.verify` must pass before and after any migration; migrations never
+ rewrite ledger rows.
+
+## 13. Integrity and operations
+
+- Daily sanity checks by the daemon: `PRAGMA quick_check`, `audit.verify`,
+ unbalanced-voucher scan. Failures are logged loudly and surfaced in `meta`.
+- `backup.snapshot` uses `VACUUM INTO` to a timestamped file: consistent, no
+ downtime, and safe for restic to pick up. The live `*.db`/`-wal`/`-shm` files
+ must never be handed to a file-copy backup tool.
+- Restore procedure: stop `bokfd`, replace the database with a snapshot, start,
+ run `audit.verify` and a report smoke test. Restores are tested on a
+ schedule; the same procedure is the 7-year archive retrieval path.
+- Size: SQLite with `page_size` default and WAL suits the load easily
+ (thousands of vouchers per year are trivial). Attachments dominate growth;
+ they live in the same file so a snapshot remains a single artifact.
+
+## 14. Seeds
+
+| Data | Source | Notes |
+|---|---|---|
+| BAS 2026 kontoplan | `data/bas2026.csv` | separately attributed (FAR); loaded at org creation |
+| Moms report rules | `data/vat_rules_<year>.json` | account → ruta mapping, reviewed per year |
+| Standard series | code | `A` (normal), `IB` (opening balances), `SIE` (import) |
+| SRU codes | `data/sru_map.csv` | for later INK2/SRU export |
diff --git a/docs/STATE.md b/docs/STATE.md
new file mode 100644
index 0000000..d9edcb0
--- /dev/null
+++ b/docs/STATE.md
@@ -0,0 +1,112 @@
+# bokf — project state
+
+Snapshot for resuming work in a new session. Read with `AGENTS.md` (rules)
+and `docs/TUI-GUIDELINES.md` (UI conventions). Dated 2026-09-17.
+
+## Status
+
+Working self-hosted bookkeeping system, not production-proven. Backend ledger
+core is complete; filing/year-end/payroll are not. TUI is usable and
+exercised via pty smoke tests; the test suite (`make test`) covers the
+server/protocol/ledger only.
+
+## Locked decisions
+
+1. **Name/license**: `bokf`, daemon `bokfd`, clients `bokfctl` (scriptable)
+ and `bokftui` (ncurses); GPL-3.0-or-later; repo `~/work/bokf`.
+2. **Stack**: C11, Makefile, vendored SQLite 3.53.4 / yyjson 0.13.0 / Argon2
+ 20190702 / SHA-256 (public domain). Only system dep: libncursesw.
+3. **Storage**: SQLite WAL, `synchronous=FULL`, STRICT tables, composite-key
+ tenant isolation, append-only triggers, `VACUUM INTO` snapshots. Postgres
+ deliberately rejected for now; keep DB access behind one layer for a later
+ port.
+4. **Protocol**: NDJSON over Unix socket (+ optional token TCP), protocol v1.
+ `dry_run` on every mutation, `client_ref` idempotency, stable error codes,
+ `describe` + `agent.instructions`, money in integer öre.
+5. **Auth**: multi-org; memberships owner/bookkeeper/viewer; API tokens bound
+ to user+org with scopes, shown once, revocable; sessions in memory;
+ Argon2id. Server messages English, UI Swedish.
+6. **Compliance design**: SHA-256 audit chain, SHA-256 voucher chain
+ (canonical encoding in `SCHEMA.md` §7.1/§9.1), period locks, fiscal year
+ close, corrections only as ändringsverifikat, SIE 4 (CP437) in/out.
+7. **Verifikat ids**: series is free text (`A`, `V-`, `A ` …); unbroken
+ numbering per fiscal year+series; id displayed as `series+number`
+ (`V-8`). Org setting `default_series` (Inställningar) for new vouchers and
+ new templates.
+8. **Templates** (`konteringsmallar`): server-side; formula language over `x`
+ with `+ - * /` and parentheses, positive=debit, negative=credit, zero rows
+ dropped; rounding remainder assigned to the largest row; `{x}` in the
+ description; `template.*` commands + `voucher.post {template,x}`; archive
+ instead of delete.
+9. **Ingående balans**: one series `IB` voucher per fiscal year (dated at
+ year start). Reports treat series IB as IB, not period movement; SIE
+ export/import round-trips without double counting; TUI editor posts only
+ deltas so nothing is ever edited.
+10. **Attachments**: content stored in the DB (BLOB), immutable, linked via
+ append-only `voucher_attachments`. Default limit 10 MiB
+ (`max_attachment_bytes`); the socket line limit is derived from it
+ (base64). TUI `Ctrl+F` attaches via file browser; the client checks the
+ size from `meta` first. Setting `attachment_dir` (tilde expanded).
+11. **UI keys**: Ctrl+N = add, F5 = refresh, Esc/q = back (never exits),
+ Ctrl+C = quit, 1–9/g = jump, F7 = clear row in editors, F9 = save.
+ Shared line editor and date field; see TUI-GUIDELINES.md.
+12. **Login**: two steps — credentials, then org picker ("Välj organisation
+ att representera"). No org switch in the dashboard; fiscal year switch is
+ on the dashboard. `--org ID` bypasses the picker.
+13. **Settings**: `settings.get`/`settings.set`; keys `default_series`,
+ `attachment_dir`.
+
+## Pending decisions
+
+- Link attachments after posting (API already supports
+ `attachment.put {voucher_id}`): proposed TUI actions — `Ctrl+F` in the
+ voucher detail to upload+link, and selecting an inbox item and pressing a
+ key to link it to a voucher picked from a list. Waiting for a go-ahead.
+- Priority between **eSKD moms filing** and **bokslut/K2+SRU** for the next
+ backend milestone (eSKD was suggested first).
+- Moms `report_rules` seed is a reviewed starter mapping only; must be
+ checked against the current Skatteverket blankett before filing.
+
+## Backlog (prioritized, from COMPLIANCE.md §10 and the audit)
+
+1. eSKD file generation for momsdeklaration.
+2. Bokslut automation (avskrivningar, periodiseringsfond, skatt,
+ resultatdisposition).
+3. K2 årsredovisning document + SRU files (INK2/INK2R/INK2S).
+4. `audit.verify` must also verify the **voucher** hash chain (today only the
+ audit chain is verified).
+5. `report.general_ledger` and `report.voucher_list` (documented, not
+ implemented).
+6. `describe` argument schemas (currently name/summary/permission only).
+7. Pre-migration `VACUUM INTO` snapshot (promised in SCHEMA.md, not built).
+8. Docker image + compose (multi-arch amd64/arm64, GHCR) and systemd unit.
+9. Password change, user disable, TOTP.
+10. Bank import/reconciliation (CSV first, then PSD2), invoicing/reskontra,
+ AGI/payroll if employees.
+11. SIE import only into an empty fiscal year; consider broader import.
+12. TUI polish: horizontal scrolling in long text fields, bracketed paste.
+
+## Environment / how to run
+
+- Demo: db `~/bokf-demo/bokfd.db`, socket `~/bokf-demo/bokfd.sock`,
+ pid file `~/bokf-demo/bokfd.pid`; login `admin` / `demo1234`.
+ Start TUI: `cd ~/work/bokf && BOKFD_SOCKET=$HOME/bokf-demo/bokfd.sock \
+ BOKFD_USER=admin BOKFD_PASSWORD=demo1234 ./build/bokftui`
+- Restart daemon: kill the pid file's process, then
+ `BOKFD_BACKUP_DIR=$HOME/bokf-demo/backup \
+ BOKFD_EXPORT_DIR=$HOME/bokf-demo/export setsid nohup \
+ ./build/bokfd --db $HOME/bokf-demo/bokfd.db \
+ --socket $HOME/bokf-demo/bokfd.sock > $HOME/bokf-demo/daemon.log 2>&1 &`
+- The user's own early instance was `/tmp/x.db` + `/tmp/bokfd.sock`
+ (schema v1, old binary) — recreate or migrate it with the current build if
+ it is still wanted.
+- TUI smoke tests: drive over a pty with `script -qec`; function-key escape
+ sequences are timing-sensitive there (not an app bug). `Ctrl+N/C/F` are
+ single bytes and reliable.
+
+## Known caveats
+
+- Never commit unless the human asks.
+- SQLite files must not be backed up live with restic; use
+ `backup.snapshot` (`VACUUM INTO`) and point restic at the snapshots.
+- Schema version is 2; forward migrations are in `db.c`.
diff --git a/docs/TUI-GUIDELINES.md b/docs/TUI-GUIDELINES.md
new file mode 100644
index 0000000..180c72b
--- /dev/null
+++ b/docs/TUI-GUIDELINES.md
@@ -0,0 +1,108 @@
+# bokftui guidelines
+
+Rules for the ncurses client so every view behaves the same. When in doubt,
+copy the behaviour of the voucher list / voucher form; they are the reference
+implementations. Inspired by Midnight Commander, htop, mutt and calcurse.
+
+## Session start
+
+After login the org picker ("Välj organisation att representera") is always
+shown; the selected org is fixed for the session (`--org ID` bypasses it for
+scripts). The fiscal year is chosen from the dashboard and is changeable
+during the session.
+
+## Universal keys
+
+| Key | Meaning |
+|---|---|
+| `Ctrl+N` | Add: new verifikat (dashboard, voucher list/detail), new mall (Mallar), new fiscal year (year picker), open editor (IB), upload file (Underlag) |
+| `F5` | Refresh the view |
+| `Esc` / `q` | Back one level. At the dashboard it does nothing — Esc never exits the app |
+| `Ctrl+C` | Quit the application (closes the session). The only key that exits |
+| `1`–`9` | In lists: jump to that row. In menus: activate that item |
+| `g` | Live goto: "Gå till rad/nummer:" updates the selection as you type digits (backspace steps back); Enter closes the prompt without opening anything |
+| arrows, PgUp/PgDn, Home/End | Move/scroll; selection always stays visible |
+| `e` | Edit the shown object (IB, where applicable) |
+| `a` | Add/upload (Underlag) |
+| `c` | Correct (voucher detail) |
+| `Ctrl+F` | Attach a file (voucher form) / pick a file (Underlag) via the file browser |
+| `F7` | Clear the current row — only inside row editors (never "new") |
+| `F9` | Save/post the current form |
+
+Every screen prints its keys in the footer via `hints()`. If a key exists, the
+footer shows it; if the footer shows it, the key works.
+
+## Lists (`select_list`, `menu`)
+
+- Rows are numbered `NN. text`, right-aligned so 2- and 3-digit numbers line up.
+- Verifikation ids are shown concatenated as `series+number` (`V-8`, `A8`),
+ using the org's `default_series` (Inställningar) for new vouchers.
+- The last row may be an action (e.g. `+ Nytt verifikat (Ctrl+N)`); selecting it
+ runs the action instead of opening a detail view.
+- Selection memory: lists remember the selected row by identity (voucher id),
+ not index, across detail round-trips, refreshes and screen re-entry.
+- Digits move the highlight; only Enter activates. Menus are the exception:
+ digits activate directly (they are shortcuts).
+- Empty lists are never a dead end: show a message or keep the add-row.
+
+## Forms
+
+- Layout: bold header fields at the top, then a bold column header, then rows;
+ footer hints carry the keys.
+- Field navigation: `Tab` / `Shift-Tab` forward/back, arrows Up/Down between
+ rows. `Enter` advances in simple forms; it never saves unless the screen is
+ a one-line prompt.
+- The active field is drawn reverse-video and holds the hardware cursor at the
+ caret. The caret is a real position: ←/→/Home/End/Del work inside the field,
+ `Ctrl+U` clears it (see `field_edit`).
+- First keystroke in a freshly focused field replaces its content
+ (`field_fresh`), so prefilled values like dates can be typed over.
+- Date fields (`date_field_edit`, `date_prompt`) accept digits only and insert
+ the dashes themselves: type `20260315` and the field shows `2026-03-15`.
+ Backspace deletes a digit (with its separator), ←/→/Home/End move by digit,
+ and the caret renders like in any other field. Validation (`util_parse_iso_date`)
+ happens on F5/F9.
+- Derived values (account names, balances) are dim and non-editable.
+- Row tables: always exactly one empty trailing row; entering data appends a
+ new empty row; an empty row followed by another empty row collapses.
+ `F7` clears the selected row.
+- Validation: `F5` validates without writing and reports exactly what is wrong
+ (field, row number). Server `dry_run` is used where available.
+- Saving: `F9` writes, shows a confirmation message, and returns to the
+ previous view. `Esc` cancels without saving.
+- File browser (`file_browser`): starts in the org's `attachment_dir` (or
+ `$HOME`; a leading `~` is expanded to `$HOME`), `.. (uppåt)` is the first row, directories sort first with a
+ trailing `/`, hidden files are skipped. Enter enters a directory or picks a
+ file; `Esc` cancels. Selected files are uploaded immediately as unlinked
+ underlag and linked when the voucher is posted.
+
+## Messages
+
+- Info/confirmation: `message(title, ...)` box, dismissed with Enter.
+- Quitting: only `Ctrl+C` (from anywhere) or "Logga ut / avsluta" ends the
+ app; `Esc`/`q` only navigate. After `Ctrl+C` every screen unwinds, the
+ session is closed and the terminal restored.
+- Errors: `show_error(title, resp)` prints `CODE: message` from the server
+ error object; if the response is missing/empty it says
+ "Inget svar från servern (kör daemonen?)". Never render an empty error.
+- Irreversible actions (close year, lock, archive) ask first.
+
+## Layout and text
+
+- Screen frame: title top-left, dim status line under it
+ (`org | label start - end | role | user`), hints in the last line.
+- Columns are padded with `pad_field()` (UTF-8 display width) and truncated to
+ their column; amounts right-aligned via `kr_format()`; never pad with `%s`
+ widths directly on user text.
+- Selection = reverse video, headers = bold, derived data = dim.
+- All user-visible TUI text is Swedish; server messages are English.
+
+## Adding a view — checklist
+
+1. Data comes from public protocol commands only.
+2. Wrap the screen in `frame()`/`hints()`; return `Esc`/`q` to the parent.
+3. Use `menu()` or `select_list()` instead of writing a new loop; pass
+ `allow_new`/`allow_refresh` so the universal keys apply.
+4. Forms use the shared editor (`field_edit`) and the row-normalising helpers.
+5. Support `F5` if the data can change elsewhere.
+6. Update `PROTOCOL.md` §8 and this file if you add a new key or interaction.