# Overseer — System Design Document
---
## 0. How to read this document
This document is the authoritative engineering plan for the first
release of **Overseer**, the family information system described in
`prd.md`. It is written so that a brand-new engineer can sit down,
read top to bottom, and start implementing without having to ask
clarifying questions. Each section explains *what* we are building,
*why* the choice was made, and *how* to implement it.
Wherever a section uses unfamiliar jargon, follow the embedded links
to the official docs:
- [SQLite documentation](https://www.sqlite.org/docs.html)
- [Inja template engine](https://github.com/pantor/inja)
- [htmx documentation](https://htmx.org/docs/)
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
- [Magick++ tutorial](https://imagemagick.org/Magick++/tutorial/Magick++_tutorial.pdf)
- [spdlog](https://github.com/gabime/spdlog)
- [yaml-cpp](https://github.com/jbeder/yaml-cpp)
- [cpp-httplib](https://github.com/yhirose/cpp-httplib) (used internally by `mw::HTTPServer`)
---
## 1. Goals and non-goals
### 1.1 Goals
1. Provide a **single self-hosted web service** for one family to
record, find, and edit information about household items. The
first module is **inventory** (storages + stuffs).
2. Be **modular**: a future *Doc* module must be addable without
re-architecting authentication, configuration, logging, or
templating.
3. Be **boring and reliable**: one SQLite file, one binary, one
YAML config, one systemd unit. No microservices, no message
queues, no JS build pipeline.
4. Keep the **client trivial**: server-rendered HTML plus htmx for
sprinklings of interactivity. No SPA framework.
5. Keep the **business logic reusable**: inventory logic must live
in a separate library so future surfaces (MCP, REST for agents)
can call it directly.
### 1.2 Non-goals (explicitly out of scope for v1)
- Per-row ACLs. Any authenticated user can read or modify any row.
- Agent / MCP / REST surface. Library is structured to allow it
later, but no endpoints are exposed in v1.
- Multi-family / multi-tenant operation. One process serves one
family.
- High availability / replication. The DB is a single file.
- Mobile native clients. The web UI is responsive HTML.
- The *Doc* module. Schemas and routes for it are not built; only
hooks for future modules are.
### 1.3 Success criteria
A release of v1 is considered done when:
1. A logged-in user can create a nested tree of storages and stuffs.
2. Each storage and stuff has a stable, shareable URL.
3. Uploading a photo produces an AVIF ≤1024px-long-side stored
exactly once even if uploaded multiple times.
4. Search across storages and stuffs returns results in <100 ms for
a corpus of 10 000 rows (very generous given the scale).
5. Restarting the binary on an older DB triggers migration and a
backup; restarting on a newer DB refuses to start.
---
## 2. High-level architecture
```
+-----------------------------------------------+
| Browser |
| server-rendered HTML + htmx fragments |
+-----------------------------+-----------------+
| HTTPS (reverse proxy)
v
+-------------------------------------------------------------+
| overseer (binary) |
| |
| +--------------------+ +-------------------------------+ |
| | web layer | | inventory module library | |
| | - mw::HTTPServer |-->| liboverseer_inventory | |
| | - Inja templates | | (Storage, Stuff, search, | |
| | - session/cookies | | move history, attachments) | |
| | - OIDC handler | +---------------+---------------+ |
| +---------+----------+ | |
| | v |
| | +------------------------------+ |
| +-------------->| data access layer | |
| | - mw::SQLite wrapper | |
| | - migrations | |
| | - attachment store | |
| +---------------+--------------+ |
| | |
+--------------------------------------------+----------------+
|
v
/var/lib/overseer/overseer.db
(single SQLite file; all data)
```
The shape to remember:
- One process, one binary (`overseer`).
- Three logical layers in-process: web, business logic, data.
- Business logic is a separate static library
(`liboverseer_inventory`) so it can be linked from a future agent
surface.
### 2.1 Why server-rendered HTML + htmx
The PRD mandates Inja + htmx with no frontend build step. The reason
this is a good choice for a tiny self-hosted family app:
- **No bundler, no node_modules, no transpile step.** Editing a
template only requires restarting the C++ process (or hot-reload
in dev mode — see §11.4).
- **htmx swaps HTML fragments.** The server keeps the source of
truth for every UI state. There is no client-side model to keep
in sync. The whole app behaves like classic server-rendered pages
with sprinkles of partial updates.
- **Read [htmx’s essays](https://htmx.org/essays/)** —
particularly *Hypermedia-Driven Applications* — to understand the
mental model before writing endpoints.
### 2.2 Why C++23 + libmw
`prd.md` mandates C++23 and that we use **libmw** where applicable.
libmw lives at `~/programs/libmw/includes/mw` and gives us:
- `mw::HTTPServer` — a thin wrapper around `cpp-httplib`
([http_server.hpp](../../libmw/includes/mw/http_server.hpp)).
- `mw::SQLite` and `mw::SQLiteStatement` — a thread-safe RAII
wrapper around `sqlite3` with templated `bind()` / `eval()` (see
[database.hpp](../../libmw/includes/mw/database.hpp)).
- `mw::AuthOpenIDConnect` — a discovery-based OIDC client
([auth.hpp](../../libmw/includes/mw/auth.hpp)).
- `mw::E<T>` and the `ASSIGN_OR_RETURN` macro for error propagation
([error.hpp](../../libmw/includes/mw/error.hpp),
[utils.hpp](../../libmw/includes/mw/utils.hpp)).
- `mw::SHA256Hasher` etc. for content hashing
([crypto.hpp](../../libmw/includes/mw/crypto.hpp)).
Use these first. Do not write a parallel SQLite or HTTP wrapper.
---
## 3. Repository layout
The repo is laid out so that the inventory logic is physically
separate from the web layer:
```
home-info/
├── prd.md
├── designs/
│ └── design-0-architecture.md (this file)
├── CMakeLists.txt
├── overseer.example.yaml (sample config for /etc/overseer.yaml)
├── src/
│ ├── overseer/ (the binary)
│ │ ├── main.cpp
│ │ ├── config.hpp / config.cpp (YAML loader)
│ │ ├── server.hpp / server.cpp (HTTP wiring; subclasses mw::HTTPServer)
│ │ ├── session.hpp / session.cpp (cookie/session store)
│ │ ├── auth_handler.hpp/.cpp (OIDC login/callback/logout)
│ │ ├── attachment_handler.hpp/.cpp (GET /attachment/{id})
│ │ ├── render.hpp / render.cpp (Inja env + helpers)
│ │ ├── module.hpp (Module interface; see §13)
│ │ └── modules/
│ │ └── inventory/ (inventory HTTP routes only)
│ │ ├── routes.hpp/.cpp
│ │ ├── views.hpp/.cpp (Inja-bound view models)
│ │ └── templates/ (*.html.inja)
│ ├── inventory/ (library — pure logic)
│ │ ├── include/inventory/
│ │ │ ├── storage.hpp
│ │ │ ├── stuff.hpp
│ │ │ ├── search.hpp
│ │ │ ├── attachment.hpp
│ │ │ └── repo.hpp (entry-point façade)
│ │ └── src/
│ │ ├── storage.cpp
│ │ ├── stuff.cpp
│ │ ├── search.cpp
│ │ └── attachment.cpp
│ ├── db/
│ │ ├── migrations.hpp/.cpp (registry of migrateDBXToY)
│ │ ├── migration_001_init.cpp (schema v1)
│ │ └── backup.hpp/.cpp (pre-migration backup)
│ └── static/
│ ├── css/
│ │ └── overseer.css (plain CSS, no Bootstrap)
│ └── js/
│ └── htmx.min.js (vendored — see §11.3)
└── tests/
├── inventory/ (library unit tests)
├── db/ (migration round-trip tests)
└── e2e/ (spin-up server, drive with HTTP)
```
**Naming follows the user style guide:** snake_case for file names,
CapCase for types, snake_case for variables, camelCase for
multi-word function names, lower-case for single-word functions,
UPPER_CASE for global constants (including enum cases). 4-space
indent, opening brace on its own line, no space before `(`.
---
## 4. Build system
### 4.1 CMake
We use **CMake ≥3.24** (matching the toolchain `nsblog` uses) and
**C++23**. Most third-party libraries are pulled in with
[FetchContent](https://cmake.org/cmake/help/latest/module/FetchContent.html)
so the project builds without distro-specific packages — this
matches the convention already used in
`~/programs/nsblog/CMakeLists.txt`. For an introduction to modern
CMake see [Modern CMake](https://cliutils.gitlab.io/modern-cmake/).
Targets:
| Target | Type | Description |
|---|---|---|
| `overseer_inventory` | static lib | The pure business-logic library |
| `overseer_db` | static lib | Migrations + low-level DB helpers |
| `overseer` | executable | The web binary; links the two libs above |
| `overseer_test` | executable | GoogleTest-based test runner |
### 4.2 External dependencies
Pulled in with `FetchContent`:
| Dependency | Purpose | Source |
|---|---|---|
| libmw | HTTP server, SQLite wrapper, OIDC, crypto | `https://github.com/MetroWind/libmw.git` |
| nlohmann/json | Required by Inja | `v3.11.3` |
| Inja | HTML templating | `pantor/inja` (main) |
| spdlog | Logging (with `SPDLOG_USE_STD_FORMAT=ON`) | `v1.12.0` |
| yaml-cpp | Config parsing | `jbeder/yaml-cpp` (`0.8.0`) |
| googletest | Unit tests / mocks | `v1.14.0` |
Pulled in via system packages (`find_package` / `pkg_check_modules`):
| Dependency | Purpose | Find call |
|---|---|---|
| libcurl | Transitively required by libmw | `find_package(CURL REQUIRED)` |
| sqlite3 | Storage engine | `find_package(SQLite3 REQUIRED)` |
| Magick++ (ImageMagick 7) | Image resize + AVIF encode | `pkg_check_modules(Magick REQUIRED Magick++)` |
Before `FetchContent_MakeAvailable(libmw …)`, set the libmw build
flags we need (same names nsblog uses):
```cmake
set(LIBMW_BUILD_URL ON)
set(LIBMW_BUILD_SQLITE ON)
set(LIBMW_BUILD_HTTP_SERVER ON)
set(LIBMW_BUILD_CRYPTO ON)
set(SPDLOG_USE_STD_FORMAT ON)
```
Link targets exposed by libmw: `mw::mw`, `mw::sqlite`,
`mw::http-server`, `mw::crypto` (plus `mw::url` if we ever need it).
Inja’s headers are accessed via `${inja_SOURCE_DIR}/single_include/inja`
and nlohmann/json via `${json_SOURCE_DIR}/single_include` — both
matching the include layout nsblog uses.
There is **no JS/Node toolchain**. `htmx.min.js` is vendored as a
file under `src/static/js/` (see §11.3).
### 4.3 Compile flags
- `-std=c++23 -Wall -Wextra -Wpedantic -Werror`
- `-fno-exceptions` is **not** used; we follow libmw’s style and use
`std::expected` for recoverable errors but allow exceptions from
third-party libs (Magick++, yaml-cpp) to bubble to the request
boundary, where they are caught and converted to a 500.
---
## 5. Configuration
### 5.1 File location and format
The PRD fixes the path: `/etc/overseer.yaml`. The path is **not**
overridable in production; for tests and dev we pass a path via
`--config /tmp/...`. The file is YAML 1.2 parsed by yaml-cpp.
Schema:
```yaml
# /etc/overseer.yaml
bind_address: "127.0.0.1" # bind IP or unix:/path/to/socket
port: 8080 # ignored if bind_address starts with unix:
log_level: "info" # trace|debug|info|warn|error|critical
db_path: "/var/lib/overseer/overseer.db" # optional override; default per PRD
oidc:
issuer_url: "https://keycloak.example.com/realms/family"
client_id: "overseer"
client_secret: "..." # secret; restrict file perms to 0600
redirect_uri: "https://overseer.example.com/oidc/callback"
session:
cookie_name: "overseer_sid" # optional
max_age_minutes: 480 # optional, default 8h
```
### 5.2 Loading
`config.hpp` declares:
```c++
struct OIDCConfig
{
std::string issuer_url;
std::string client_id;
std::string client_secret;
std::string redirect_uri;
};
struct SessionConfig
{
std::string cookie_name = "overseer_sid";
std::chrono::minutes max_age{480};
};
struct Config
{
std::string bind_address;
int port = 8080;
std::string log_level = "info";
std::string db_path = "/var/lib/overseer/overseer.db";
OIDCConfig oidc;
SessionConfig session;
};
mw::E<Config> loadConfig(const std::string& path);
```
`loadConfig` MUST:
1. `stat()` the file; return an error if it cannot be read.
2. Reject the file if its mode is world-readable when
`oidc.client_secret` is present (log a warning at minimum;
refuse to start if mode `& 0o007` is non-zero).
3. Parse with yaml-cpp. Wrap exceptions as `RuntimeError`.
4. Validate: `bind_address` non-empty; `port` in `[1, 65535]` when
not a unix socket; OIDC `issuer_url`, `client_id`,
`client_secret`, `redirect_uri` all non-empty.
5. Default optional fields explicitly (do not rely on
default-constructed values silently).
### 5.3 Why YAML
YAML is human-friendly for a single ops file, and yaml-cpp is a
mature C++ option. The cost (whitespace sensitivity) is acceptable
because the file is short and edited by one operator.
---
## 6. Database
All persistent state goes into **one SQLite file** at
`/var/lib/overseer/overseer.db`. This includes photos. SQLite was
chosen because:
- It is one file. Backups are `cp`. Atomicity is the engine’s
problem.
- It supports the scale (≤2 concurrent users, thousands of rows,
hundreds of small photos).
- It has FTS5 built in (for §10).
- `mw::SQLite` already wraps it ergonomically and thread-safely
using SQLite’s default *serialized* threading mode
([docs](https://www.sqlite.org/threadsafe.html)).
### 6.1 Connection lifecycle
A single `mw::SQLite` instance is owned by `Server`. The same
instance is shared across HTTP threads — see
[database.hpp](../../libmw/includes/mw/database.hpp) for why this is
safe.
On startup the connection runs the following pragmas, in order:
```sql
PRAGMA journal_mode=WAL; -- crash safety, better concurrency
PRAGMA synchronous=NORMAL; -- WAL-safe and fast enough
PRAGMA foreign_keys=ON; -- enforce FKs; SQLite default is OFF
PRAGMA busy_timeout=5000; -- avoid spurious SQLITE_BUSY for our 2-user load
PRAGMA temp_store=MEMORY;
```
These are documented in the [SQLite pragma reference](https://www.sqlite.org/pragma.html).
`foreign_keys=ON` is critical — SQLite ships with FK enforcement
off, which silently breaks our `ON DELETE` clauses if forgotten.
### 6.2 Schema (version 1)
This is the full DDL the **initial migration** must install. Every
column has a stated reason.
```sql
-- ----- attachments (deduplicated by sha256) ------------------------------
CREATE TABLE attachment
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
mime TEXT NOT NULL, -- always "image/avif" in v1
sha256 TEXT NOT NULL UNIQUE, -- lowercase hex of post-processed bytes
bytes BLOB NOT NULL -- the AVIF body itself
);
-- ----- storages (tree via adjacency list) --------------------------------
CREATE TABLE storage
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER NULL REFERENCES storage(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
description TEXT NULL,
attachment_id INTEGER NULL REFERENCES attachment(id) ON DELETE SET NULL,
UNIQUE(parent_id, name)
);
-- NB: SQLite treats NULLs as distinct in UNIQUE, so two roots with
-- identical names would slip past the constraint. We guard with an
-- additional partial index so root siblings are also unique:
CREATE UNIQUE INDEX storage_root_name_unique
ON storage(name) WHERE parent_id IS NULL;
CREATE INDEX storage_parent_idx ON storage(parent_id);
-- ----- stuffs ------------------------------------------------------------
CREATE TABLE stuff
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NULL,
attachment_id INTEGER NULL REFERENCES attachment(id) ON DELETE SET NULL,
storage_id INTEGER NOT NULL REFERENCES storage(id) ON DELETE RESTRICT
);
CREATE INDEX stuff_storage_idx ON stuff(storage_id);
-- ----- move history (rolling, max 10 per stuff) --------------------------
CREATE TABLE stuff_move
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
stuff_id INTEGER NOT NULL REFERENCES stuff(id) ON DELETE CASCADE,
storage_id INTEGER NOT NULL REFERENCES storage(id) ON DELETE RESTRICT,
moved_at INTEGER NOT NULL -- unix epoch seconds, written by app
);
CREATE INDEX stuff_move_stuff_idx ON stuff_move(stuff_id, moved_at DESC);
-- Trim the oldest entries so no stuff keeps more than 10 rows.
CREATE TRIGGER stuff_move_trim
AFTER INSERT ON stuff_move
BEGIN
DELETE FROM stuff_move
WHERE stuff_id = NEW.stuff_id
AND id IN (
SELECT id FROM stuff_move
WHERE stuff_id = NEW.stuff_id
ORDER BY moved_at DESC, id DESC
LIMIT -1 OFFSET 10
);
END;
-- ----- FTS5 virtual tables (full text search) ----------------------------
CREATE VIRTUAL TABLE storage_fts USING fts5(
name, description,
content='storage', content_rowid='id',
tokenize='unicode61 remove_diacritics 2'
);
CREATE VIRTUAL TABLE stuff_fts USING fts5(
name, description,
content='stuff', content_rowid='id',
tokenize='unicode61 remove_diacritics 2'
);
-- Sync triggers (one set per content table). The "external content"
-- pattern is documented at https://www.sqlite.org/fts5.html#external_content_tables .
CREATE TRIGGER storage_ai AFTER INSERT ON storage BEGIN
INSERT INTO storage_fts(rowid, name, description)
VALUES (NEW.id, NEW.name, NEW.description);
END;
CREATE TRIGGER storage_ad AFTER DELETE ON storage BEGIN
INSERT INTO storage_fts(storage_fts, rowid, name, description)
VALUES ('delete', OLD.id, OLD.name, OLD.description);
END;
CREATE TRIGGER storage_au AFTER UPDATE ON storage BEGIN
INSERT INTO storage_fts(storage_fts, rowid, name, description)
VALUES ('delete', OLD.id, OLD.name, OLD.description);
INSERT INTO storage_fts(rowid, name, description)
VALUES (NEW.id, NEW.name, NEW.description);
END;
-- (mirror triggers for stuff_ai / stuff_ad / stuff_au)
```
#### 6.2.1 Why adjacency list, not closure table
The PRD calls it out explicitly: scale is ≤2 concurrent users.
Recursive CTEs (`WITH RECURSIVE`,
[docs](https://www.sqlite.org/lang_with.html)) handle path / subtree
queries trivially at this scale, and adjacency lists are far simpler
to mutate. Caching paths in memory (a `unordered_map<int64_t,
std::string>`) is acceptable when needed for breadcrumbs.
#### 6.2.2 Why `ON DELETE RESTRICT` for `storage.parent_id` and `stuff.storage_id`
We never want a delete to silently destroy children. If a user tries
to delete a non-empty storage, the SQL FK will block it; the web
layer must catch this and prompt "this storage still contains
N items, delete them first or move them" (see §13.4).
#### 6.2.3 Why dedupe attachments by `sha256`
A family will photograph the same outlet box from the same angle
many times. Hashing the *post-processed* AVIF bytes means re-uploads
collapse into a single row, and re-attaching to a different stuff
costs only a foreign-key write.
### 6.3 Versioning and migrations
The schema version lives in SQLite’s built-in `PRAGMA user_version`
([docs](https://www.sqlite.org/pragma.html#pragma_user_version)). It
defaults to `0` on a freshly created file.
Each migration is a free function with this exact shape:
```c++
namespace overseer::db
{
mw::E<void> migrateDB0To1(mw::SQLite& db); // create v1 schema from empty
mw::E<void> migrateDB1To2(mw::SQLite& db); // future
// ...
}
```
`migrations.cpp` keeps a registry (a `std::vector` of
`{from, to, fn}` triples). The orchestrator is `migrateIfNeeded()`,
which runs at startup:
```
function migrateIfNeeded(db):
current = db.PRAGMA user_version
target = HIGHEST_KNOWN_VERSION // compile-time constant
if current == target: return ok
if current > target: return error "database is newer than binary"
backupDatabaseFile() // see §6.4
for each migration ordered by `from`:
if migration.from < current: skip
BEGIN TRANSACTION
migration.fn(db)
PRAGMA user_version = migration.to
COMMIT
return ok
```
Hard requirements (from the PRD):
- Each migration runs in **one transaction**. Use SQLite’s
`BEGIN IMMEDIATE; ... COMMIT;`. If the migration throws or
returns an error, `ROLLBACK`.
- The server **refuses to start** when `user_version > target`.
- A backup copy of the file is made **before any migration runs**
(not before each one — one backup per startup is sufficient and
cheaper). Skipped if `current == target`.
### 6.4 Backups
`backup.cpp` exposes:
```c++
mw::E<std::filesystem::path>
backupDatabaseFile(mw::SQLite& db, const std::string& db_path);
```
It performs:
1. Resolve `db_path` to an absolute path.
2. Compute timestamp `YYYYmmdd-HHMMSS` from `std::chrono::system_clock`.
3. Construct destination `db_path + ".bak-" + timestamp`.
4. Execute `VACUUM INTO '<dest>';` on the live connection. This is
SQLite's SQL-level online-backup form and is safe with the WAL
active
([docs](https://www.sqlite.org/lang_vacuum.html#vacuuminto)). It
produces a clean single-file copy without the WAL/SHM sidecars.
We deliberately do *not* `cp` the file because that can capture
an inconsistent WAL state. The lower-level
`sqlite3_backup_init / _step / _finish` API would produce an
equivalent snapshot, but `VACUUM INTO` keeps the call inside the
`mw::SQLite` facade and is sufficient for our pre-migration use
case.
5. Return the path of the backup file. Log it at `info` level.
These pre-migration snapshots are the **only** backups Overseer
itself produces. Routine / periodic backups of
`/var/lib/overseer/overseer.db` are an operator concern (e.g.
`restic`, `borg`, host-level snapshots) — backup policy varies per
deployment and doesn't belong inside the binary.
### 6.5 Initial migration (v0 → v1)
`migration_001_init.cpp` is the only migration that exists for v1.
It executes the entire DDL block of §6.2, as a single string passed
to `mw::SQLite::execute()` (which calls `sqlite3_exec` and supports
multi-statement scripts). The version is bumped to `1` afterwards.
---
## 7. Authentication
### 7.1 Flow
Authorization Code (no PKCE) against an external Keycloak. See
[OIDC Core §3.1](https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth).
Overseer is a **confidential** server-side client: the
`client_secret` lives only on the server, and the browser never
sees auth codes outside the TLS-terminated reverse proxy. Code
interception by a third party would already require a TLS break
*and* possession of the secret, so PKCE buys little extra in this
deployment. Login CSRF is still defended against by an opaque
`state` parameter (see §7.1.1).
Endpoints we add:
| Path | Method | Purpose |
|---|---|---|
| `/oidc/login` | GET | Generate `state`, remember it, 302 to Keycloak `authorization_endpoint`. |
| `/oidc/callback` | GET | Verify `state`, exchange `code` for tokens, fetch userinfo, create session, set session cookie, 302 to original `return_to`. |
| `/oidc/logout` | POST | Drop session, clear cookie, best-effort hit the IdP's `end_session_endpoint` (see §7.1.2). |
`mw::AuthOpenIDConnect::create()` (see
[auth.hpp](../../libmw/includes/mw/auth.hpp)) handles discovery
(`{issuer}/.well-known/openid-configuration`) and the code-to-token
exchange. Use it as-is.
#### 7.1.1 State generation
For each login:
1. Generate a 16-byte random buffer (`/dev/urandom` or
`RAND_bytes`). Encode as base64url-without-padding → that is the
`state`. It binds the callback to this exact login attempt and
prevents login CSRF.
2. libmw's `base64Encode` returns standard base64, so we replace
`+`→`-`, `/`→`_` and strip `=` padding. A short helper
`base64UrlNoPad()` in `auth_handler.cpp` is enough.
3. Persist `{state -> {return_to, created_at}}` for 5 minutes. The
store is in-memory (`std::unordered_map` guarded by
`std::mutex`) — we do not need persistence across restarts for a
5-minute artifact.
#### 7.1.2 Logout
`POST /oidc/logout`:
1. Look up the session and capture its `id_token` (to send as
`id_token_hint`).
2. Delete the in-memory session and clear the session cookie.
3. If the discovery document advertised an `end_session_endpoint`,
issue an asynchronous `GET` to it with `id_token_hint` and
`post_logout_redirect_uri` query parameters; log but do not
surface failures (the local session is already gone).
4. Respond `302` to `/`.
Do **not** hard-code the path: `end_session_endpoint` is optional
in the OIDC discovery document
([Keycloak OIDC discovery docs](https://www.keycloak.org/securing-apps/oidc-layers))
and may be absent on non-Keycloak IdPs. Read it from
`{issuer_url}/.well-known/openid-configuration`, which
`mw::AuthOpenIDConnect::create()` already fetches at startup and
exposes via its `endpoint_end_session` member (see
[auth.hpp](../../libmw/includes/mw/auth.hpp)). When the discovery
document does not contain the field, skip step 3 silently —
local logout is sufficient.
### 7.2 Sessions
After a successful callback, we mint a session:
- 32 bytes of randomness, base64url-no-pad → `session_id`. This is
what the cookie carries; the browser never sees tokens.
- Tokens and the userinfo struct are stored in an in-memory
`std::unordered_map<std::string, Session>` guarded by a mutex.
Sessions live in memory because the family-scale traffic is
trivially small, and forcing a re-login after a restart is
acceptable.
- Cookie attributes: `HttpOnly; Secure; SameSite=Lax; Path=/;
Max-Age=<configured>`. `Secure` is set unconditionally — the
reverse proxy must terminate TLS in production.
- Idle expiry: tied to the access token’s `expiration`. When the
access token is about to expire and `refresh_token` exists, call
`mw::AuthOpenIDConnect::refreshTokens()` lazily on the next
request.
### 7.3 Authorization
The PRD: every authenticated user can read and write everything.
There are no per-row ACLs. Implementation:
- `Server::requireAuth(req, res) -> std::optional<UserInfo>`: a
helper that pulls the cookie, looks up the session, and on
failure responds with `302 Location: /oidc/login?return_to=…`
(for HTML routes) or `401` (for htmx fragments that don’t want a
redirect). Inventory route handlers call this helper first thing.
- `who` for audit purposes: when we one day add an audit log, the
`UserInfo.name` and `UserInfo.id` are what we’ll record. v1 does
not need an audit log.
### 7.4 CSRF
htmx requests from our own pages travel via the session cookie. To
prevent cross-site form posts:
- Every mutating endpoint (`POST`, `PUT`, `DELETE`) requires either:
- The `HX-Request: true` header (set automatically by htmx for
its own requests) **plus** a same-origin check on the `Origin`
or `Referer` header, OR
- A double-submit CSRF token (`csrf_token` form field that must
match a `csrf_token` cookie). Templates inject the token.
The double-submit token is simpler to reason about and is what we
ship. The token is 16 random bytes per session, set as a
non-HttpOnly cookie so htmx can read it via
`hx-headers='{"X-CSRF-Token": "..."}'`. Reject mismatches with 403.
---
## 8. HTTP server
### 8.1 Why `mw::HTTPServer`
`mw::HTTPServer` wraps `cpp-httplib`. It already gives us start /
stop / wait, ListenAddress union of TCP + Unix socket, and the
`ASSIGN_OR_RESPOND_ERROR` macro that converts `mw::E<T>` failures
into HTTP responses with the right status. See
[http_server.hpp](../../libmw/includes/mw/http_server.hpp).
### 8.2 Subclassing
We define `class Server : public mw::HTTPServer`. `setup()` is the
single registration point:
```c++
void Server::setup()
{
server.set_logger(...); // pipe access logs to spdlog
server.set_default_headers(...); // X-Content-Type-Options etc.
server.set_exception_handler(...); // last-resort 500
auth_handler.registerRoutes(server);
attachment_handler.registerRoutes(server);
for(auto& mod : modules) mod->registerRoutes(server);
server.Get("/static/.*", [](auto& req, auto& res){ ... }); // static files
server.Get("/healthz", [](auto&, auto& res){ res.status = 204; });
}
```
### 8.3 Concurrency
`cpp-httplib` is thread-per-request by default. SQLite is serialized
so simultaneous handlers can call into the same `mw::SQLite`
instance. The in-memory session and CSRF maps are guarded by
`std::mutex`.
### 8.4 Static files
`/static/` serves files from `src/static/`. In production we either:
- Bundle them next to the binary in `/usr/share/overseer/static/`
and locate them via a compile-time `OVERSEER_STATIC_DIR`, or
- Let the reverse proxy serve them.
In dev mode `--static-dir <path>` overrides for hot edits.
### 8.5 Errors
- Use `mw::E<T>` everywhere a function can fail.
- At the route boundary, use `ASSIGN_OR_RESPOND_ERROR` (defined in
`mw/http_server.hpp`) to translate errors into HTTP responses.
- Unhandled C++ exceptions are caught by the `exception_handler`
and converted to a 500 with a short body and a logged stack
trace.
### 8.6 Logging
`spdlog` is configured in `main()`:
- Sinks: stderr (color) in foreground mode, `journald` when running
under systemd (detect via `JOURNAL_STREAM` env var; otherwise
still stderr — systemd captures it).
- Pattern: `[%l] [%t] %v`. We deliberately omit the timestamp from
the spdlog pattern because both downstream sinks add their own:
journald stamps every entry, and the stderr color sink under
systemd flows through the journal as well. Locally during dev,
the prefix-less pattern keeps the line short and readable.
- Level set from `config.log_level`.
- Access log line per request: method, path, status, ms,
bytes_out, user_id (when known).
---
## 9. Inventory module
This is the **only feature module** in v1. The split between the
library (`liboverseer_inventory`) and the HTTP routes
(`src/overseer/modules/inventory/`) is the central design call: it
satisfies the PRD’s requirement that future agent surfaces can
reuse the logic.
### 9.1 Domain types
```c++
namespace overseer::inventory
{
struct Storage
{
int64_t id = 0;
std::optional<int64_t> parent_id; // nullopt = root
std::string name;
std::optional<std::string> description;
std::optional<int64_t> attachment_id;
};
struct Stuff
{
int64_t id = 0;
std::string name;
std::optional<std::string> description;
std::optional<int64_t> attachment_id;
int64_t storage_id = 0;
};
struct StuffMove
{
int64_t storage_id;
mw::Time moved_at;
};
}
```
### 9.2 Library API (`inventory/repo.hpp`)
`InventoryRepo` is the façade everyone calls. It is constructed
with a `mw::SQLite&`. Methods return `mw::E<T>`.
```c++
class InventoryRepo
{
public:
explicit InventoryRepo(mw::SQLite& db);
// ---- storages ----------------------------------------------------
mw::E<int64_t> createStorage(const Storage& s);
mw::E<Storage> getStorage(int64_t id);
mw::E<std::vector<Storage>> listChildren(std::optional<int64_t> parent_id);
mw::E<std::vector<Storage>> pathTo(int64_t id); // root → id
mw::E<void> updateStorage(const Storage& s);
mw::E<void> deleteStorage(int64_t id); // fails if non-empty
// ---- stuffs ------------------------------------------------------
mw::E<int64_t> createStuff(const Stuff& s);
mw::E<Stuff> getStuff(int64_t id);
mw::E<std::vector<Stuff>> listInStorage(int64_t storage_id);
mw::E<void> updateStuff(const Stuff& s);
mw::E<void> moveStuff(int64_t stuff_id, int64_t new_storage_id);
mw::E<void> deleteStuff(int64_t id);
mw::E<std::vector<StuffMove>> recentMoves(int64_t stuff_id); // ≤10
// ---- search ------------------------------------------------------
mw::E<std::vector<Storage>> searchStorages(std::string_view q);
mw::E<std::vector<Stuff>> searchStuffs(std::string_view q);
// ---- attachments -------------------------------------------------
// Accepts ANY input image, processes to AVIF ≤1024px, dedupes, returns id.
mw::E<int64_t> putAttachment(std::span<const std::byte> bytes,
std::string_view source_mime);
mw::E<Attachment> getAttachment(int64_t id); // mime + bytes
};
```
The HTTP routes call **only** these methods; they never run SQL
directly. This is the seam that lets us later add an MCP server or
REST agent surface without going through the web layer.
### 9.3 Important implementation notes
#### 9.3.1 `moveStuff`
```
BEGIN IMMEDIATE
SELECT storage_id FROM stuff WHERE id=? -- current
if current == new_storage_id: COMMIT and return ok (no-op)
INSERT INTO stuff_move(stuff_id, storage_id, moved_at)
VALUES (?, current, unixepoch()) -- record OLD location
UPDATE stuff SET storage_id=? WHERE id=?
COMMIT
```
The trigger from §6.2 trims rows >10 automatically. We insert the
*previous* storage (history is "where I came from"), matching the
PRD’s "previous `storage_id` is appended".
#### 9.3.2 `deleteStorage`
A FK `ON DELETE RESTRICT` on both `storage.parent_id` and
`stuff.storage_id` enforces non-empty. The library translates the
SQLite constraint error into a typed `RuntimeError{"NOT_EMPTY"}` so
the HTTP layer can show a useful message.
#### 9.3.3 `pathTo`
```sql
WITH RECURSIVE path(id, parent_id, name, depth) AS (
SELECT id, parent_id, name, 0 FROM storage WHERE id = :id
UNION ALL
SELECT s.id, s.parent_id, s.name, p.depth + 1
FROM storage s JOIN path p ON s.id = p.parent_id
)
SELECT id, parent_id, name FROM path ORDER BY depth DESC;
```
Reversal yields a root-to-leaf list which we feed to breadcrumb
rendering.
---
## 10. Search
### 10.1 Building
FTS5 indexes are populated by the triggers defined in §6.2. New
rows in `storage` / `stuff` immediately become searchable; updates
delete-and-reinsert in the FTS shadow table; deletes remove.
### 10.2 Querying
`searchStorages("kitchen drawer")` runs:
```sql
SELECT s.id, s.parent_id, s.name, s.description, s.attachment_id
FROM storage s
JOIN storage_fts f ON f.rowid = s.id
WHERE storage_fts MATCH :q
ORDER BY rank
LIMIT 50;
```
Sanitization: the FTS5 query mini-language is permissive but
exposes operators (`AND`, `OR`, `NEAR`, `"phrase"`, `*`). For the UI
we accept the user’s string verbatim **but** wrap it as a
double-quoted phrase plus prefix wildcard to make typing simple:
`"<user input with internal quotes escaped>" *`. Power users can
opt into raw mode by prefixing `~` (UI affordance; not in v1).
Read the [FTS5 docs](https://www.sqlite.org/fts5.html) for syntax
and `rank` semantics before changing this.
### 10.3 UI behavior
A search box (top-right of every page) uses htmx:
```html
<input type="search" name="q"
hx-get="/inventory/search"
hx-trigger="input changed delay:200ms, search"
hx-target="#results"
hx-push-url="true"
placeholder="Search stuff and storages…">
<div id="results"></div>
```
The server endpoint returns a fragment listing matches across both
tables, grouped under "Storages" and "Stuffs". On `Enter` the
browser submits a real `GET /inventory/search?q=…` so the URL is
shareable.
---
## 11. Web UI
### 11.1 Inja templates
Inja templates are plain HTML with `{{ var }}`, `{% if %}`,
`{% for %}` tags ([README](https://github.com/pantor/inja)). We
keep a single `inja::Environment` in `Render`, with:
- `set_trim_blocks(true)` and `set_lstrip_blocks(true)` to keep
whitespace tidy.
- A custom callback `url_for("storage", id)` etc. so templates
don’t hardcode URLs.
- A custom callback `escape_attr(s)` mapped to `mw::escapeHTML`.
- The template root is `src/overseer/modules/inventory/templates/`
(per-module). v1 has no single `_base.html.inja`; the shared shell
is split into two partials (`_head.html.inja`, `_topbar.html.inja`)
that each page template `{% include %}`s directly. This keeps the
pages flat — inja is happy with partial includes and the per-page
`<head>` differs enough (page title, occasional inline data) that a
full base template was more friction than help. When the Doc module
lands it brings its own templates.
### 11.2 Template inventory (v1)
| Template | Renders |
|---|---|
| `_head.html.inja` | `<head>` partial: stylesheet, htmx, CSRF meta |
| `_topbar.html.inja` | nav bar partial: brand link, search box, user/logout |
| `_breadcrumbs.html.inja` | storage path breadcrumb partial |
| `storage_list.html.inja` | root view: tree of storages |
| `storage_show.html.inja` | one storage: photo, children, stuff in it |
| `storage_form.html.inja` | create/edit form (also used as htmx fragment) |
| `stuff_show.html.inja` | one stuff: photo, current location, recent moves |
| `stuff_form.html.inja` | create/edit form |
| `search_page.html.inja` | full search results page |
| `search_results.html.inja` | htmx fragment of grouped matches |
| `fragment_tree_node.html.inja` | one expandable tree row (lazy load) |
| `fragment_stuff_name.html.inja` | inline-edit display fragment (the clickable name) |
| `fragment_stuff_name_edit.html.inja` | inline-edit form fragment |
| `fragment_move_status.html.inja` | "moved to X" htmx response fragment |
| `error.html.inja` | styled error page (full HTML) |
### 11.3 htmx use cases (concrete)
| Interaction | Pattern |
|---|---|
| Expand a tree node | `hx-get="/inventory/storage/{id}/children"`, target a `<ul>` placeholder, `hx-swap="innerHTML"` |
| Live search | `hx-get` with `delay:200ms`, target results pane |
| Inline edit name | swap `<span>` for `<form>`, `hx-put="/inventory/stuff/{id}/name"`, on success `hx-swap` back to span |
| Delete without reload | `hx-delete="…"`, `hx-confirm="Sure?"`, `hx-swap="outerHTML"` on `<tr>` |
| Move a stuff | `<select>` with `hx-post="/inventory/stuff/{id}/move"`, swap status banner |
`htmx.min.js` is vendored at `src/static/js/htmx.min.js` (a single
file; no build step). The exact version is recorded in
`src/static/js/HTMX_VERSION.txt` so we know what we’re running.
### 11.4 CSS
Plain CSS, no framework, no Sass.
`src/static/css/overseer.css` contains:
- A small reset (box-sizing, body margin).
- CSS variables for color tokens; one light and one dark scheme
switched by `@media (prefers-color-scheme: dark)`.
- Grid for forms; flex for the top bar; a `.tree` block for the
storage tree (`details`/`summary` works fine, no JS needed for
the static parts).
### 11.5 Dev-mode template hot reload
When the binary is started with `--dev`, `inja::Environment` is
recreated on every request and templates are loaded from disk each
time. In production the env is built once at startup.
---
## 12. Attachments
### 12.1 Upload pipeline
1. Browser submits `multipart/form-data` with field `photo` to
`POST /inventory/storage/{id}/photo` (or `/stuff/{id}/photo`).
2. Handler validates `Content-Type` against an allow-list:
`image/jpeg`, `image/png`, `image/webp`, `image/heic`,
`image/avif`, `image/gif`. Reject otherwise (415).
3. Cap raw body size at 20 MiB
(`server.set_payload_max_length(20 * 1024 * 1024)`).
4. Hand bytes to `InventoryRepo::putAttachment()`.
**Coder availability.** Whether a given input format actually
decodes is determined by which delegates ImageMagick was compiled
against. HEIC in particular requires libheif and is not present in
every distribution build, so iPhone uploads on those hosts will
fail at decode time. At startup, log a single `warn`-level line
listing the coders `Magick::coderInfoList()` reports as readable
— that makes it obvious from the journal which formats this build
of Overseer can accept.
### 12.2 `putAttachment` internals
```
Magick::Image img;
img.read(Magick::Blob(bytes.data(), bytes.size())); // throws on bad input
size_t w = img.columns(), h = img.rows();
size_t longest = std::max(w, h);
if(longest > 1024)
{
double scale = 1024.0 / longest;
img.resize(Magick::Geometry(
size_t(w * scale + 0.5), size_t(h * scale + 0.5)));
}
img.strip(); // drop EXIF (privacy)
img.magick("AVIF");
img.quality(60); // good visual quality at small size
Magick::Blob out;
img.write(&out);
```
Magick++ throws `Magick::Exception` subclasses; catch them at the
boundary and translate to a 400 with a useful message
([reference](https://imagemagick.org/Magick++/Exception.html)).
Then:
```
sha = SHA256Hasher.hashToHexStr(out_bytes) // libmw helper
existing = SELECT id FROM attachment WHERE sha256=?
if existing: return existing.id // dedupe hit
INSERT INTO attachment(mime, sha256, bytes) VALUES ('image/avif', sha, out_bytes)
return last_insert_rowid()
```
### 12.3 Serving
`GET /attachment/{id}`:
1. `SELECT mime, sha256, bytes FROM attachment WHERE id=?`. 404 if
missing.
2. Compute `ETag: "<sha256>"`. If the request’s
`If-None-Match` equals this ETag, respond `304` with no body.
3. Otherwise respond `200` with:
- `Content-Type: image/avif`
- `Cache-Control: private, max-age=31536000, immutable`
- `ETag: "<sha256>"`
- The bytes.
The "immutable" caching is safe because attachments are
content-addressed by `sha256`; they cannot change in place. A
re-uploaded photo that hashes differently becomes a different `id`.
### 12.4 Why we do not put attachments on disk
The PRD mandates that all data — including attachments — live in
the single SQLite file. SQLite BLOB handling is fine up to ~1 GiB
per row; family photo thumbnails post-resize are ~50–150 KiB. A
single-file backup is the primary win.
---
## 13. URL design
URLs are stable, hackable, and module-prefixed so future modules
don’t collide.
### 13.1 Conventions
- Module routes are mounted under `/<module>/...` (e.g.
`/inventory/...`, future `/docs/...`).
- Cross-module global routes: `/`, `/static/...`, `/attachment/{id}`,
`/oidc/...`, `/healthz`.
- Trailing slashes are **not** used. Redirect `/path/` → `/path`.
- IDs are integers.
- HTML routes return full pages on `GET`; htmx-targeted routes
return a fragment when the request carries `HX-Request: true`,
else the full page (graceful degradation).
### 13.2 Inventory routes (v1)
| Method | Path | Purpose |
|---|---|---|
| GET | `/` | redirect to `/inventory` |
| GET | `/healthz` | liveness probe (204) |
| GET | `/inventory` | root storage tree |
| GET | `/inventory/search?q=` | search across both tables |
| GET | `/inventory/storage/new` | create form |
| POST | `/inventory/storage` | create |
| GET | `/inventory/storage/{id}` | show one storage |
| GET | `/inventory/storage/{id}/edit` | edit form |
| PUT | `/inventory/storage/{id}` | update |
| DELETE | `/inventory/storage/{id}` | delete (must be empty) |
| GET | `/inventory/storage/{id}/children` | htmx fragment: child rows |
| POST | `/inventory/storage/{id}/photo` | upload photo |
| GET | `/inventory/stuff/new?storage_id=` | create form |
| POST | `/inventory/stuff` | create |
| GET | `/inventory/stuff/{id}` | show one stuff |
| GET | `/inventory/stuff/{id}/edit` | edit form |
| PUT | `/inventory/stuff/{id}` | update |
| DELETE | `/inventory/stuff/{id}` | delete |
| POST | `/inventory/stuff/{id}/move` | record a move |
| POST | `/inventory/stuff/{id}/photo` | upload photo |
| GET | `/inventory/stuff/{id}/name` | htmx fragment: the name span (cancel target) |
| GET | `/inventory/stuff/{id}/name/edit` | htmx fragment: the inline-edit form |
| PUT | `/inventory/stuff/{id}/name` | inline rename; returns the span fragment on `HX-Request` |
| GET | `/attachment/{id}` | serve a stored attachment (image/avif) |
| GET | `/oidc/login` | start OIDC authorization-code flow |
| GET | `/oidc/callback` | OIDC callback, sets session cookies |
| POST | `/oidc/logout` | drop session + RP-initiated IdP logout |
Why `PUT`/`DELETE`: htmx supports them natively
([attrs](https://htmx.org/attributes/hx-put/)). Browsers don’t
expose `PUT`/`DELETE` on a `<form>` directly — that doesn’t matter
because we drive mutations from htmx, not bare forms.
### 13.3 Module interface
```c++
namespace overseer
{
class Server; // forward decl
class Module
{
public:
virtual ~Module() = default;
virtual std::string name() const = 0; // "inventory"
// `app` gives the module access to the repo, render, auth,
// expectedOrigin, etc. cpp-httplib's `s` is where the routes
// are actually registered; `app` is the surrounding context
// the handlers need.
virtual void registerRoutes(httplib::Server& s,
::overseer::Server& app) = 0;
virtual mw::E<void> migrate(mw::SQLite& db) { return {}; }
};
}
```
`Server` owns a `std::vector<std::unique_ptr<Module>>`. v1 contains
exactly one entry, `InventoryModule`. Adding the Doc module later
means a new subclass and one line in `main()`.
The two-arg form is deliberate: handlers are non-trivial enough that
they need the repo/render/auth on every call, and threading those
through closures or globals would be worse. Modules that don't need
the wider context can simply ignore `app`.
### 13.4 Standard error pages
| Status | When | Body |
|---|---|---|
| 400 | malformed input, image processing failed | `error.html.inja` with message |
| 403 | CSRF failure | static body |
| 404 | unknown route or row | `error.html.inja` |
| 409 | unique constraint (sibling name collision); non-empty delete | descriptive |
| 415 | unsupported image type | descriptive |
| 500 | uncaught | "Something went wrong"; details in logs |
---
## 14. Testing strategy
### 14.1 Layers
| Layer | Tool | What it tests |
|---|---|---|
| Library unit tests | GoogleTest | `InventoryRepo` against an in-memory `mw::SQLite::connectMemory()` DB |
| Migration tests | GoogleTest | Build empty DB → run migrations → assert schema/version; round-trip across all known versions |
| HTTP integration | GoogleTest + `httplib::Client` | Spin a `Server` on `127.0.0.1:0`, drive real requests, assert HTML structure (string contains is enough for v1) |
| Manual smoke | Browser | Test htmx flows visually |
### 14.2 Coverage targets
- 100 % of `InventoryRepo` methods exercised in unit tests for
both success and failure paths.
- All migrations tested for forward application.
- All routes exercised at least once at the integration layer.
### 14.3 Test data
A helper `seedFamily(InventoryRepo&)` in `tests/support/` creates a
3-level tree (Apartment > Room > Shelf), 20 stuffs, 5 attachments.
### 14.4 Why no end-to-end browser tests in v1
Family-scale; the cost of maintaining Playwright tests outweighs
the benefit when there are no JS-heavy interactions. htmx fragments
are exercisable via HTTP assertions.
---
## 15. Deployment
### 15.1 systemd unit (operator-owned, not shipped in the repo)
The operator (whoever is deploying) is expected to drop a unit file
at `/etc/systemd/system/overseer.service`. We do **not** ship the
unit in the repo — deployment layout is a packaging concern, not a
source concern. A reasonable starting point looks like:
```
[Unit]
Description=Overseer family information system
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/overseer --config /etc/overseer.yaml
User=overseer
Group=overseer
ProtectSystem=strict
ReadWritePaths=/var/lib/overseer
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
```
### 15.2 Filesystem permissions
- `/etc/overseer.yaml`: owner `root:overseer`, mode `0640`.
- `/var/lib/overseer/`: owner `overseer:overseer`, mode `0750`.
- `/var/lib/overseer/overseer.db`: owner `overseer:overseer`, mode
`0640`. WAL/SHM siblings inherit the same.
### 15.3 Reverse proxy
A reverse proxy (nginx / Caddy) terminates TLS and forwards to
`127.0.0.1:8080` (or a Unix socket). The binary itself does no TLS.
The proxy must set `X-Forwarded-Proto: https` so cookies are issued
with `Secure` correctly — or simply trust the operator and always
set `Secure` (we do this; the production deployment must always be
behind TLS).
### 15.4 Upgrade procedure
1. `systemctl stop overseer`.
2. Replace the binary.
3. `systemctl start overseer`.
4. Watch logs: on startup the new binary checks `user_version`, and
if it is below the binary’s target it backs up
`overseer.db` to `overseer.db.bak-<timestamp>` and runs
migrations. Refuses to start if the DB is newer than the binary
(see §6.3).
---
## 16. Future modules (just the seams)
This section is informational; nothing here is built in v1.
- **Doc module** — would add `document` and `revision` tables, a
`/docs/...` route prefix, and its own templates. It would
register as another `Module` subclass. Attachments table is
already module-agnostic, so PDFs and images attach the same way.
- **Agent surface** — would link `liboverseer_inventory` directly
(no HTTP) and expose either MCP or a JSON REST. Because the
library uses `mw::E<T>` already, the surface only has to convert
results to its on-the-wire format.
---
## 17. Recommended implementation order
1. CMake skeleton + `main()` that prints `hello`.
2. Config loader + sample YAML; refuse to start on bad config.
3. `mw::SQLite` connection + pragmas + empty file at `db_path`.
4. Migrations registry + v0→v1 migration + backup on apply.
5. `InventoryRepo::createStorage / getStorage / listChildren` plus
their tests (in-memory DB).
6. Bare HTTP server (no auth) with `/healthz` and `/inventory`
listing root storages via Inja.
7. Storage CRUD routes end-to-end.
8. Stuff CRUD + move history.
9. FTS5 triggers + search route.
10. Attachment pipeline + photo upload.
11. OIDC: login, callback, sessions, cookies, CSRF.
12. Polish CSS; htmx tree expansion; htmx live search; inline edit.
13. systemd unit; package; deploy behind a reverse proxy.
Each step ends with passing tests for the slice it added.