docs: split CLAUDE.md into an always-loaded core plus dev-docs/
Nightly Build / build (push) Successful in 10s

CLAUDE.md had grown to 152 KB (~21k words, ~40k tokens) and is loaded into
every coding-agent session. The cost is not the cache read, it is attention:
the rules that are genuinely invariant were drowning in the mechanics of
subsystems that most tasks never touch.

The split criterion is blast radius, not importance. A rule a change anywhere
could violate stays in CLAUDE.md — the commit rule, the production/schema
constraint, domain neutrality, the event-bus rule, the crate boundaries, and
the module map. The mechanism of one subsystem moves to dev-docs/, opened on
entry to that subsystem via a routing table at the top of CLAUDE.md.

Nothing was rewritten: every section was moved verbatim by line range and
verified line-by-line against the original. The only edits are cross-reference
repairs ("see the DB section" -> a link), the promotion of headings in the
extracted files, and a condensed "Current state" whose full text now lives in
dev-docs/users-auth-and-boot.md.

CLAUDE.md: 152 KB -> 31 KB. Twelve subsystem files plus an index under
dev-docs/, which now carries the same standing rule as docs/ and CHANGELOG.md:
a change to a subsystem updates its dev-doc in the same change.

No CHANGELOG entry: this is documentation for coding agents with no observable
effect on the application.
This commit is contained in:
Daniele
2026-08-24 18:04:43 +01:00
parent 52a63286ce
commit 902f47ecd8
14 changed files with 569 additions and 398 deletions
+21
View File
@@ -0,0 +1,21 @@
*Skald dev-docs — architectural reference for coding agents. Index: [README.md](README.md) · Entry point: [../CLAUDE.md](../CLAUDE.md)*
**Read this when:** you touch login, sessions, `UserManager`, `UserContext`, encryption of a user DB, or what boot unlocks and spawns.
---
# Users, auth, crypto & boot
## `crates/skald-core/src/users/`
`UserManager` (§11): user directory CRUD on `system.db`, credential check, and the map `userid → SqlitePool` of **unlocked** databases. The pool *is* the unlock token — its connect options carry the DEK as SQLCipher's raw key, so an open pool means the key is in RAM (§9) and dropping it re-locks. Knows nothing about cookies: whatever maps an HTTP session to a user id sits above it. **A login is what unlocks an *encrypted* file only** — see the boot-unlock section below
## `crates/skald-core/src/crypto/`
Envelope encryption (§4/§5.1). A random 256-bit DEK encrypts `{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under `Argon2id(password, salt)`. **The AEAD tag is the password verifier** — one derivation both authenticates and yields the key, and no second hash sits in the admin-readable DB. Cleartext users store the Argon2id output directly, compared constant-time. Argon2 runs in `spawn_blocking` behind a 2-permit semaphore (256 MiB per derivation)
## Runtime state
`UserManager` (§11) is now **consumed**. Login exists (`crates/skald-core/src/auth/mod.rs`: `SessionStore``login`/`user_of`/`logout` plus `revoke_user`, the admin-side "drop every session of this user" used by `Skald::revoke_user_runtime`; the deny-by-default middleware is `src/frontend/api/guard.rs`, whose `require_auth` maps token → id and does **not** re-read the row, which is exactly why revocation must be pushed rather than polled; first admin created by `skald-setup`), and the per-user owner-bound runtime is `UserContext` (`crates/skald-core/src/skald/user_context.rs`) — resolved by `Skald::user_context` / the frontend's `require_context`, keyed off `UserManager::pool_of`, and carrying its **own `CancellationToken`** (a child of the instance one) so a single user's cron/hub/MCP loops can be stopped without touching anyone else's. The frontend owner call-sites (WS, sessions, inbox, approval-pending, projects, uploads, run-context, **cron**) route through the per-user pool; dev/stats read `llm_requests` — a *registry* table — from `system.db`, which is correct. The "owner-without-a-user" question resolved to **there isn't one**: every owner content belongs to a logged-in user (the admin included). The global owner-bound bundles (`Conversation`/`Tasks`: the "ownerless" `ChatSessionManager`, `ChatHub`, cron `TaskManager`) are still constructed but **inert** — their loops never spawn and nothing consumes their accessors; removing them is pending follow-on work (kept for now because `RunContextManager` shares the `Conversation` bundle and *is* used, being registry-backed). See blueprint §19.
**Boot unlocks the databases that have no key, and starts their runtimes.** §9 ties readability to a login, and for an encrypted file that *is* the mechanism — the key only exists once the password has been typed. For an unencrypted one it was a rule with nothing behind it: the data is already readable by anything in this process, so the only thing the login gated was the runtime. The cost was user-visible and looked like a bug — after every restart the Telegram bot answered *"your account is locked, log in via the web app"*, cron fired nothing and no background agent ran, until a human opened the SPA. So `Skald::new` calls `UserManager::unlock_all_unencrypted` (which registers the pools exactly as a login would, refusing an encrypted or inactive user), and `wiring::spawn_unlocked_user_runtimes` then builds a `UserContext` for each — **unlocking only makes the data readable; cron, the notify queue, the hub and the per-user MCP runtime all hang off the context**, so an instance is *working* only once those exist. That build is a background supervisor task, not part of `new()`: it starts every member's MCP servers inside their container, and the HTTP listener must not wait behind that. The same two steps run per user off the lifecycle bus (`UserCreated`, `UserActiveChanged{active:true}`, after the container `ensure`) so a member created at runtime does not wait for the next restart. Two boundaries are untouched and worth stating: **authentication is unaffected** (`SessionStore` sits above `UserManager`; no HTTP request authenticates as anyone because of this), and `open_unencrypted` still exists for the supervision path, still deliberately *not* registering its pool. The auto-unlock is deliberately not on a lazy path (e.g. inside `Skald::user_context`): `revoke_user_runtime` locks a pool synchronously and expects nothing to re-open it, so the writers of that map stay boot, login, and the lifecycle bus.