aboutsummaryrefslogtreecommitdiff
path: root/docs/PROTOCOL.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/PROTOCOL.md')
-rw-r--r--docs/PROTOCOL.md469
1 files changed, 469 insertions, 0 deletions
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.