base

ober

b66d3e86fd294b01b2029fc9c2b50e47599a29d0

diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..938e24d
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,574 @@
+## STOP: Editing `.ss`/`.sls` Files — Mandatory Rules
+
+These rules exist because local-model sessions have lost **hours** fighting
+parenthesis imbalances that these rules would have prevented in seconds.
+They override every habit from other editors and languages.
+
+1. **NEVER use `edit`, `write`, `sed`, `python`, `perl`, or `awk` to modify
+   `*.ss` or `*.sls` files.** Use the jerboa-mcp tools instead:
+   - Add a top-level form → `jerboa_balanced_insert` (anchor = one unique
+     complete form, e.g. the `def` above the insertion point).
+   - Replace exact text → `jerboa_balanced_replace`. It is **dry-run by
+     default** — pass `dry_run: false` to actually write.
+   - Create a whole new file → `jerboa_write_file` (use `verify: true` to
+     reject unbalanced content before it lands).
+2. **After EVERY `.ss` change, run `jerboa_check_balance` before building.**
+   The balanced tools check automatically; if you bypassed them, check now.
+3. **If a file ever becomes unbalanced, STOP.** Do NOT count parens by hand,
+   do NOT write paren-counting scripts, do NOT poke one character at a time.
+   Recovery is exactly one of:
+   - `git checkout -- <file>` and redo the edit with `jerboa_balanced_insert`
+     (preferred — one command, seconds), or
+   - `jerboa_repair_balance` (dry-run shows the plan; `apply: true` writes).
+4. **Keep closer-runs short.** Never hand-write code that ends in more than
+   ~4 consecutive closers (`))))]` runs). Flatten deep nesting with helper
+   `def`s, `let*`, or cond `=>` clauses so no edit ever depends on counting
+   a long `)` run.
+5. **`(def ...)` after an expression in a body is invalid.** Internal defines
+   must come first in a body, or use `let`/`let*`. The build error
+   "invalid context for definition" means you violated this — or a missing
+   paren above glued two top-level forms together (check balance first).
+
+## The Jerboa Language — Quick Reference
+
+Jerboa is a Scheme dialect built on Chez Scheme. It is Gerbil-inspired but its own language. **All user-facing code is `.ss` files. Never write `.sls` files for the user** — those are internal implementation files.
+
+### File Structure
+
+Every Jerboa file looks like this:
+
+```scheme
+(import (jerboa prelude))    ;; ONE import gives you the ENTIRE language
+;; Optional extra imports for modules NOT in the prelude:
+;; (import (std net request))
+
+(def (my-function x y)
+  (+ x y))
+
+(displayln (my-function 1 2))
+```
+
+Run with: `~/.local/bin/jerboa run file.ss` (or `jerboa run file.ss` if `~/.local/bin` is on your PATH).
+
+**NEVER** write `(library ...)` forms — that's `.sls` internal syntax.
+
+### Reader Syntax Extensions
+
+```
+[...]                → plain parentheses — same as Gerbil and Chez Scheme
+{method obj args}    → (~ obj 'method args)  — method dispatch
+name:                → keyword #:name
+:std/sort            → (std sort)        — Gerbil-style module path
+#<<END ... END       → heredoc string
+```
+
+Square brackets `[...]` are interchangeable with `(...)`, exactly like Gerbil and stock Chez Scheme. You can freely use them in bindings, match clauses, and anywhere you'd use parentheses:
+```scheme
+;; All of these are correct:
+(let ([x 1] [y 2]) (+ x y))
+(for/collect ([x (in-range 5)]) (* x x))
+(match val ([list a b] (+ a b)))
+(cond [(> x 0) "positive"] [else "non-positive"])
+```
+
+### CRITICAL: Things That DO NOT EXIST in Jerboa/Chez
+
+Claude frequently hallucinates these from Gerbil, Gambit, Racket, or R7RS training data.
+**NONE of them are real in Jerboa/Chez. STOP and use the correct form.**
+
+#### AI Compatibility Aliases (these now work in the prelude)
+The following names from other Scheme dialects are aliased in `(jerboa prelude)`:
+- `hash-has-key?` → `hash-key?` (Racket)
+- `hash-table-set!` → `hash-put!` (Racket)
+- `directory-exists?` → `file-directory?` (Gambit)
+- `eql?` → `eqv?` (Common Lisp)
+- `random-integer` → `random` (Gambit)
+- `read-line` → `get-line` wrapper (Gambit) — works with or without port arg
+- `force-output` → `flush-output-port` wrapper (Gambit) — works with or without port arg
+- `string-map` → char-level map (Racket/R7RS) — `(string-map f str)`
+
+#### Hallucinated Functions (still do NOT exist)
+- `symbol<?` — use `(lambda (a b) (string<? (symbol->string a) (symbol->string b)))`
+- `string-contains?` — use `(string-contains str sub)` (returns index or #f, NOT boolean)
+- `define-struct` — use `(defstruct name (fields ...))`
+- `raise` with a string — use `(error 'who "message" irritants ...)`
+- `environment-bound?` — Gerbil-only. No direct Chez equivalent
+
+#### Gerbil/Gambit-isms (from training data — wrong in Jerboa)
+- `time->seconds` — use `(time-second (current-time))` for epoch seconds
+- `thread-sleep!` — Gambit. Use `(sleep (make-time 'time-duration 0 seconds))`
+- `thread-yield` — no Chez equivalent. Use `(sleep (make-time 'time-duration 0 0))` as workaround
+- `path-expand` with 2 args — Gerbil takes `(path-expand rel base)`. Jerboa takes 1 arg. Use `(path-join base rel)` for 2-arg version
+- `process-status` — Gerbil. Use `(std misc process)` API in Jerboa
+- `user-info-home` — Gerbil. Use `(getenv "HOME")`
+- `the-environment` — Gerbil. Use `(interaction-environment)` in Chez
+- `condition/report-string` — Gerbil. Use `(with-output-to-string (lambda () (display-condition c)))`
+- `make-class-type` — Gerbil. Use `(defstruct ...)` or `(defclass ...)` in Jerboa
+- `string-subst` — Gerbil. Not in prelude. Use `(string-replace str old new)` or implement manually
+- `open-fd-pair` — Gambit. Does not exist in Chez; requires different API
+
+#### R6RS/Racket-isms (wrong variant)
+- `make-equal-hashtable` — R6RS. Use `(make-hash-table)` from Jerboa prelude
+- `arithmetic-shift` — Racket. Use `(bitwise-arithmetic-shift n k)` or `(ash n k)` in Chez
+- `pregexp-match` — Racket. Use `(std text regex)` or `(std pregexp)` API in Jerboa
+
+### CRITICAL: Common Arity Mistakes
+
+- `(list-of? pred)` → returns a PREDICATE. It takes 1 arg. Use: `((list-of? number?) lst)`
+- `(maybe pred)` → returns a PREDICATE. It takes 1 arg. Use: `((maybe string?) val)`
+- `(in-range end)` or `(in-range start end)` or `(in-range start end step)` — NOT `(in-range start step end)`
+- `(hash-ref ht key)` or `(hash-ref ht key default)` — NOT `(hash-ref key ht)`
+- `(string-split str delimiter)` where delimiter is a CHAR: `(string-split "a,b" #\,)`
+- `(make-rwlock)` — takes **0 args**, NOT `(make-rwlock 'name)` (Gerbil takes a name; Jerboa does not)
+- `(path-expand path)` — takes **1 arg**, NOT `(path-expand rel base)` (Gerbil takes 2; use `path-join` for 2-arg)
+- `(sort list predicate)` — Jerboa `(std sort)`/prelude order. Raw Chez `sort` is predicate-first, but Jerboa-facing code should use list first.
+
+### Core Forms (all from `(import (jerboa prelude))`)
+
+#### Definitions
+```scheme
+(def x 42)                              ;; variable
+(def (f x y) (+ x y))                  ;; function
+(def (f x (y 10)) body)                ;; optional param with default
+(def (f x . rest) body)                ;; rest args
+(def* f ((x) ...) ((x y) ...))         ;; multi-arity
+(defrule (name pat) template)           ;; macro
+```
+
+#### Data Structures
+```scheme
+(defstruct point (x y))                ;; → make-point, point?, point-x, point-y, point-x-set!
+(defstruct (circle shape) (radius))    ;; inheritance (single only)
+(defmethod (area (self circle)) body)  ;; method on type
+(~ obj 'method arg ...)                ;; dispatch (or {method obj arg ...})
+(defrecord person (name age))          ;; struct + pretty-print + ->alist
+(define-enum color (red green blue))   ;; → color-red, color?, color->name
+```
+
+#### Pattern Matching
+```scheme
+(match value
+  (42 "exact")                          ;; literal
+  ((list a b c) (+ a b c))             ;; list destructure
+  ((cons h t) h)                        ;; pair
+  ((? number?) "num")                   ;; predicate
+  ((? string? s) (string-upcase s))    ;; predicate + bind
+  ((and (? number?) (? positive?)) "positive number")
+  ((or "yes" "y") #t)
+  ((=> string->number n) n)            ;; view pattern
+  (n (where (> n 0)) "positive")       ;; guard
+  (_ "default"))                        ;; wildcard
+```
+
+#### Error Handling
+```scheme
+(try expr (catch (e) handler) (finally cleanup))
+(try expr (catch (error? e) handler))
+(unwind-protect body cleanup)
+(with-resource (var init cleanup) body)
+```
+
+#### Result Type (Rust-inspired ok/err)
+```scheme
+(ok 42)  (err "bad")  (ok? r)  (err? r)
+(unwrap (ok 42))         ;; → 42 (raises on err)
+(unwrap-or (err "x") 0)  ;; → 0
+(map-ok f result)  (map-err f result)
+(and-then result f)       ;; monadic bind
+(try-result expr)         ;; exceptions → (err condition)
+(try-result* expr)        ;; exceptions → (err "message string")
+(sequence-results list-of-results)  ;; → (ok list) or first (err)
+(->? (ok 10) (+ 5) (* 2))  ;; → (ok 30), short-circuits on err
+```
+
+#### Iterators
+```scheme
+(for ((x (in-range 5))) (displayln x))
+(for/collect ((x (in-range 5))) (* x x))           ;; → (0 1 4 9 16)
+(for/fold ((sum 0)) ((x (in-range 10))) (+ sum x)) ;; → 45
+(for/or ((x lst)) (and (pred? x) x))               ;; first truthy
+(for/and ((x lst)) (pred? x))                       ;; all truthy
+
+;; Iterators: in-list, in-vector, in-string, in-range, in-hash-keys,
+;; in-hash-values, in-hash-pairs, in-naturals, in-indexed,
+;; in-port, in-lines, in-chars, in-bytes, in-producer
+```
+
+#### Threading Macros
+```scheme
+(-> x (f a) (g b))       ;; thread first: (g (f x a) b)
+(->> x (f a) (g b))      ;; thread last:  (g b (f a x))
+(as-> x v (f v) (g v))   ;; named
+(some-> x (f) (g))       ;; short-circuit on #f
+(cond-> x test (f) t2 (g))  ;; conditional steps
+(->? (ok x) (f) (g))     ;; result-aware thread first
+```
+
+#### Ergo Typing
+```scheme
+(: expr pred?)                    ;; checked cast
+(using (p (make-point 1 2) : point?)
+  (+ p.x p.y))                   ;; dot-access → (point-x p) etc.
+((list-of? number?) '(1 2 3))    ;; predicate factory → #t
+((maybe string?) #f)              ;; accepts #f or string → #t
+```
+
+#### Hash Tables
+```scheme
+(def ht (make-hash-table))
+(hash-put! ht "key" "val")
+(hash-ref ht "key")              ;; error if missing
+(hash-ref ht "key" "default")    ;; with default
+(hash-get ht "key")              ;; → val or #f
+(hash-key? ht "key")             ;; → #t/#f
+(hash-remove! ht "key")
+(hash->list ht)  (hash-keys ht)  (hash-values ht)
+(hash-for-each (lambda (k v) ...) ht)
+(list->hash-table '(("a" . 1) ("b" . 2)))
+```
+
+#### Strings
+```scheme
+(string-split "a,b,c" #\,)       ;; → ("a" "b" "c")  NOTE: char delimiter
+(string-join '("a" "b") ",")     ;; → "a,b"
+(string-trim "  hi  ")           ;; → "hi"
+(string-prefix? "he" "hello")    ;; → #t
+(string-suffix? "lo" "hello")    ;; → #t
+(string-contains "hello" "ell")  ;; → 1 (index, not boolean!)
+(string-empty? "")               ;; → #t
+(str "age: " 42 "!")             ;; → "age: 42!" (auto-coerce)
+```
+
+#### Lists
+```scheme
+(flatten '(1 (2 (3))))    ;; → (1 2 3)
+(unique '(1 2 2 3))       ;; → (1 2 3)
+(take lst n)  (drop lst n)  (take-last lst n)  (drop-last lst n)
+(every pred lst)  (any pred lst)  (filter-map f lst)
+(group-by f lst)  (zip lst1 lst2)  (frequencies lst)
+(partition pred lst)  (interleave l1 l2)  (mapcat f lst)
+(distinct lst)  (keep f lst)  (split-at lst n)
+(append-map f lst)  (snoc lst elem)
+```
+
+#### Functional Combinators
+```scheme
+(compose f g)  (comp f g)       ;; (f (g x))
+(partial f arg ...)              ;; partial application
+(complement pred)  (negate pred) ;; logical not
+(identity x)  (constantly v)     ;; basic combinators
+(curry f arg)  (flip f)          ;; currying, arg swap
+(conjoin p1 p2)  (disjoin p1 p2) ;; predicate AND/OR
+(juxt f g)                        ;; → (lambda (x) (list (f x) (g x)))
+(cut f <> y)                      ;; SRFI-26 partial: (lambda (x) (f x y))
+```
+
+#### JSON
+```scheme
+(string->json-object "{\"key\":\"val\"}")  ;; → hash table
+(json-object->string ht)                    ;; → JSON string
+(read-json port)  (write-json obj port)
+```
+
+#### CSV
+```scheme
+(csv->alists "name,age\nAlice,30")  ;; → (((name . "Alice") (age . "30")))
+(read-csv-file "data.csv")          ;; → list of row lists
+(write-csv-file "out.csv" rows)
+```
+
+#### DateTime
+```scheme
+(datetime-now)  (datetime-utc-now)
+(make-datetime 2026 3 27 12 0 0)
+(parse-datetime "2026-03-27T12:00:00Z")
+(datetime->iso8601 dt)  (datetime->epoch dt)
+(datetime-add dt duration)  (datetime-diff dt1 dt2)
+(datetime<? dt1 dt2)  (day-of-week dt)  (leap-year? 2024)
+```
+
+#### Paths
+```scheme
+(path-join "/home" "user" "f.txt")  ;; → "/home/user/f.txt"
+(path-directory "/a/b/f.txt")       ;; → "/a/b"
+(path-extension "file.txt")         ;; → "txt"
+(path-absolute? "/home")            ;; → #t
+```
+
+#### File I/O
+```scheme
+(read-file-string "f.txt")         ;; → entire file
+(read-file-lines "f.txt")          ;; → list of lines
+(write-file-string "f.txt" "data")
+```
+
+#### Pretty Printing
+```scheme
+(pp expr)  (pp-to-string expr)  (pprint expr)
+```
+
+#### Formatting
+```scheme
+(format "~a is ~a" "Alice" 30)  ;; → "Alice is 30"
+(printf "x = ~a\n" 42)
+(displayln "hello" " " "world")
+```
+
+#### Anaphoric / Conditional Binding
+```scheme
+(awhen (find x) (use it))          ;; binds result to `it`
+(aif (find x) (use it) (default))
+(when-let (x (find y)) (use x))
+(if-let (x (find y)) (use x) (default))
+```
+
+#### Loops
+```scheme
+(while test body ...)
+(until test body ...)
+(dotimes (i 10) body ...)   ;; 0..9
+```
+
+#### Misc Sugar
+```scheme
+(assert! (> x 0))  (assert! (> x 0) "message")
+(alist (name "Alice") (age 30))  ;; → ((name . "Alice") (age . 30))
+(let-alist data (name age) body)
+```
+
+### What's NOT in the Prelude (requires separate import)
+
+```scheme
+(import (std net request))      ;; HTTP client
+(import (std net httpd))        ;; HTTP server
+(import (std db sqlite))        ;; SQLite
+(import (std actor))            ;; Actor system
+(import (std async))            ;; Async/await
+(import (std crypto digest))    ;; SHA, MD5
+(import (std text regex))       ;; Regex
+(import (std text xml))         ;; XML
+(import (std text yaml))        ;; YAML
+(import (std os env))           ;; Environment variables
+(import (std os signal))        ;; Signal handling
+(import (std security sandbox)) ;; Sandboxing
+```
+
+### Chez Scheme Conflicts (handled by prelude)
+
+The prelude shadows these Chez builtins with Jerboa versions:
+`make-hash-table`, `hash-table?`, `sort`, `sort!`, `printf`, `fprintf`,
+`path-extension`, `path-absolute?`, `with-input-from-string`,
+`with-output-to-string`, `iota`, `1+`, `1-`, `partition`,
+`make-date`, `make-time`
+
+All standard Chez Scheme is still available — the prelude just re-exports
+improved versions of the above.
+
+---
+
+## Repository Boundaries
+
+When working in a Jerboa project, **ONLY modify files in the current repo** unless the user explicitly names another path.
+
+### Git Hosting & Release Distribution: git.jerboa.sh Only
+
+**Never use SourceHut or GitHub** — not for remotes, not for release
+artifacts, not for dependency fetches, not as a fallback. All jerboa repos
+are hosted on `git.jerboa.sh` with remotes of the form:
+
+```
+ssh://git@git.jerboa.sh:2222/ober/<repo>.git
+```
+
+Pin dependencies to exact commits from that host (via `jpkg.sexp`/`jpkg.lock`
+or a `support/fetch-deps.sh` vendor script). Release artifacts are published
+through the git.jerboa.sh release mechanism for the tagged release; do not
+reintroduce SourceHut (`hut`, `git.sr.ht`) or GitHub release paths.
+
+Common sibling repos that exist but must NOT be touched without explicit instruction:
+- `~/mine/jerboa-mcp` — Legacy node MCP, superseded. The active MCP server now lives in THIS repo at `mcp/` + `data/`. Don't modify the legacy repo unless told.
+- `~/mine/jerboa-shell` — Only modify when user explicitly says to work there.
+- `~/mine/gerbil-mcp` — **NEVER touch**. Deprecated.
+- `~/mine/gerbil-orig` — Read-only reference for upstream Gerbil. Never modify.
+
+If a user instruction mentions a file path, use EXACTLY that path. Do not substitute a similar-looking path from another repo.
+
+### Never Reference Sibling Checkouts in Build Files
+
+Build files (Makefile, shell scripts, CI config) must **never** resolve a
+dependency via a relative sibling path (`../jerboa-foo`) or an absolute
+`~/mine/jerboa-foo` path. That layout is specific to this one machine —
+other users and CI do not have it. Always vendor instead: fetch/clone the
+dependency into `vendor/` (or this repo's equivalent) at build time, or use
+a pinned-release fetch script, so the build is reproducible without
+assuming any sibling checkout exists.
+
+A sibling-path fallback is not just a portability bug: it can silently
+substitute a full alternate source tree (build config, embedded data,
+secrets) for the vendored one, with no equivalent safety default, changing
+what actually gets built without any indication. If you find one
+(`grep -rn '\.\./jerboa\|~/mine/jerboa'` over Makefiles/scripts), remove it
+and vendor properly instead.
+
+---
+
+## Build & Verification
+
+After modifying any `.ss` or `.sls` (Jerboa Scheme) source files, always run the build command and fix any errors before moving on. Common issues include: missing imports, wrong function names, and duplicate definitions.
+
+### Stale Artifacts
+
+If edits seem to have no effect after `make build`, delete stale compiled files:
+```bash
+find lib -name "*.so" -delete && find lib -name "*.wpo" -delete && make build
+```
+Run `jerboa_stale_static` to detect stale `.so` files before debugging "why doesn't my edit work?".
+
+## Pre-commit Requirements
+
+**ALWAYS** run a clean build **before** committing any code to this repository. Pick the right target for the *current* platform:
+
+- **Linux**: run `make podman-build` — the Podman image must build cleanly against the full musl-static release pipeline.
+- **macOS / FreeBSD / other**: run `make binary` — the native local build must succeed. Do **not** run `make podman-build` here; Podman on non-Linux hosts is slow and not the canonical pipeline for those platforms.
+
+Do not commit if the build fails.
+
+## Act First, Read Less
+
+When making changes, read only what you need to make the edit, then make it.
+Do not read more than 3 files before acting. Do not re-read files you already
+read. Do not verify things you already know. If you have enough context to make
+a change, make it. The user will interrupt you if you are wrong.
+
+## Jerboa MCP Tools — MANDATORY Usage
+
+Jerboa is a niche Scheme dialect with limited training data. **Never guess — always verify** with MCP tools. Tool descriptions are available at runtime via the MCP server; this section covers **when** and **why** to use each tool.
+
+### MANDATORY Workflow Order (for writing new code)
+
+1. **`jerboa_howto`** — BEFORE writing code, search cookbook for verified patterns
+2. **`jerboa_module_exports`** / **`jerboa_function_signature`** — confirm APIs exist and check arities
+3. Write the code
+4. **`jerboa_verify`** — combined syntax + compile + lint + arity + duplicate check (use instead of individual tools)
+5. **`jerboa_security_scan`** — for code involving FFI, shell commands, or file I/O
+
+Security fixes must update `data/security-rules.sexp` in the same commit when
+the vulnerable pattern is mechanically detectable. If a useful scanner rule
+would be too noisy, document the reason in the fix review or changelog instead
+of silently skipping scanner coverage.
+
+### Essential Tools (use proactively)
+
+| When | Tool |
+|---|---|
+| Before writing ANY Jerboa code | `jerboa_howto` — cookbook has verified patterns with correct imports |
+| Check what a module exports | `jerboa_module_exports` — never guess function names |
+| Check function arity/args | `jerboa_function_signature` — prevents wrong-arg-count errors |
+| Validate your code | `jerboa_verify` — one-stop syntax+compile+lint+arity check |
+| Test expressions interactively | `jerboa_eval` — use `imports` param for modules, `env` for FFI paths |
+| Persistent interactive testing | `jerboa_repl_session` — maintains state across evaluations |
+| Debug an error message | `jerboa_explain_error` + `jerboa_error_fix_lookup` |
+| Understand unfamiliar code | `jerboa_file_summary` + `jerboa_document_symbols` |
+| Find where something is defined | `jerboa_find_definition` — source file, module, kind, arity |
+| Search for symbol by substring | `jerboa_apropos` or `jerboa_smart_complete` |
+| Build the project | `jerboa_build_and_report` or `jerboa_make` — prefer over bash `make` |
+| Run tests | `jerboa_run_tests` — prefer over ad hoc shell test invocations |
+| Check for stale .so artifacts | `jerboa_stale_static` — common cause of "edit has no effect" |
+| Macro expansion | `jerboa_expand_macro` / `jerboa_trace_macro` |
+| Inspect struct/class types | `jerboa_class_info` — fields, inheritance, constructor signature |
+| FFI work | `jerboa_ffi_scaffold` / `jerboa_ffi_type_check` / `jerboa_ffi_null_safety` |
+| Port Gerbil code | `jerboa_migration_check` + `jerboa_translate_scheme` |
+| Detect paren imbalance | `jerboa_check_balance` — use BEFORE `make build` after deep edits |
+| Edit a `.ss` file | `jerboa_balanced_insert` / `jerboa_balanced_replace` — NEVER raw `edit`/`sed`/`python` (see top of file) |
+| Create a `.ss` file | `jerboa_write_file` — whole-file atomic write, `verify: true` rejects unbalanced content |
+| File already unbalanced | `jerboa_repair_balance` — dry-run repair plan; or `git checkout -- <file>` and redo with `balanced_insert` |
+| Full project audit | `jerboa_project_health_check` — balance, exports, cycles, duplicates |
+| Security audit | `jerboa_security_audit` + `jerboa_import_policy_check` |
+| Static build audit | `jerboa_static_symbol_audit` + `jerboa_boot_library_audit` |
+| Explore a module | `jerboa_module_catalog` (replaces multiple `jerboa_doc` calls) |
+| Look up any symbol | `jerboa_doc` — type, arity, qualified name, related symbols |
+| Read stdlib source | `jerboa_stdlib_source` — see internal implementations |
+
+### Cookbook & Knowledge Management
+
+- **`jerboa_howto`** / **`jerboa_howto_get`**: Search and retrieve verified recipes
+- **`jerboa_howto_add`**: Save new patterns to cookbook (MANDATORY when you discover something non-trivial)
+- **`jerboa_howto_run`** / **`jerboa_howto_verify`**: Validate recipes still work
+- **`jerboa_error_fix_add`**: Save error→fix mappings for common mistakes
+- **`jerboa_anti_pattern_lookup`**: Search reusable local-model mistakes and failed strategies
+
+**The knowledge base is `data/*.sexp` in THIS repo**, embedded into `jmcp` at build time. The write tools above edit it live — the server reads `data/` from disk first, with the embedded copy as fallback (`JERBOA_MCP_REPO` points every client at this repo). When you add a stdlib/language feature, also update `data/` (a cookbook recipe + `api-signatures.sexp` + `changelog.sexp`) and **commit it**. Run `make jmcp` (or `make jmcp-portable`) only to refresh the embedded copy shipped in portable binaries.
+
+### Code Generation & Refactoring
+
+`jerboa_rename_symbol`, `jerboa_balanced_replace`, `jerboa_balanced_insert`, `jerboa_write_file`, `jerboa_repair_balance`, `jerboa_wrap_form`, `jerboa_splice_form`, `jerboa_scaffold_test`, `jerboa_generate_module`, `jerboa_translate_scheme`, `jerboa_project_template`, `jerboa_httpd_handler_scaffold`, `jerboa_db_pattern_scaffold`, `jerboa_actor_ensemble_scaffold`
+
+### Feature Suggestions
+
+- **`jerboa_list_features`** / **`jerboa_suggest_feature`** / **`jerboa_vote_feature`**: Track and submit tooling improvement ideas
+
+---
+
+## MANDATORY: Save What You Learn
+
+Jerboa is niche — every non-trivial pattern you discover prevents future sessions from re-discovering it.
+
+### Save to cookbook (`jerboa_howto_add`) whenever you:
+- Discover a working pattern through `jerboa_eval` or trial-and-error
+- Figure out correct imports, arities, or calling conventions that weren't obvious
+- Find a workaround for a Jerboa quirk or undocumented behavior
+
+**Before saving**: check `jerboa_howto` to avoid duplicates. **Do NOT save**: trivial one-liners, project-specific logic, or existing recipes.
+
+**Recipe format**: `id` (kebab-case), `tags` (4-6 search keywords incl. module name), `imports` (all required), `code` (complete working example), `notes` (gotchas/alternatives).
+
+### Save anti-patterns (`data/anti-patterns.sexp`) whenever you:
+- See a plausible local-model strategy that failed verification
+- Find a weak verifier pattern that allowed false success
+- See a repeated repair loop, such as broad-reading after a concrete error
+- Find a generic runtime mistake, such as missing lower-bound checks before vector access
+
+**Before saving**: check `jerboa_anti_pattern_lookup` to avoid duplicates. If none exists, call `jerboa_anti_pattern_add`; only edit `data/anti-patterns.sexp` directly if the writer tool is unavailable. Save the normalized reusable mistake, not the whole trace or benchmark name.
+
+**Anti-pattern format**: `id`, `title`, `kinds`, `severity`, `tags`, `pattern`, `avoid`, `advice`, `tools`.
+
+### Save error fixes (`jerboa_error_fix_add`) whenever you:
+- See exact compiler/runtime/verifier text with a repeatable repair
+- Hit an error that `jerboa_failure_advisor` should classify better next time
+- Debug a local-model generated-code failure where a short diagnosis prevents another failed iteration
+
+**Before saving**: check `jerboa_error_fix_lookup` with the exact error text. **Do NOT save**: one-off project business-logic mistakes.
+
+**Error-fix format**: `id`, `pattern`, `fix`; optional `type`, `explanation`, `code_example`.
+
+### Suggest tooling improvements (`jerboa_suggest_feature`) whenever you:
+- Make multiple sequential tool calls that could be one tool
+- Fall back to bash because an MCP tool is missing or insufficient
+
+**Before suggesting**: check `jerboa_list_features`; vote with `jerboa_vote_feature` if it already exists.
+
+### Save Discoveries Mechanisms
+- **`/save-discoveries` skill**: invoke anytime to review session and save recipes, anti-patterns, error fixes, feature suggestions, and security patterns
+- **PreCompact hook**: add `PreCompact` hook with `type: "prompt"` in `.claude/settings.json` to auto-save before context compaction
+
+---
+
+## Common Workflows
+
+- **Write new code**: `jerboa_howto` -> `jerboa_module_exports` -> write code -> `jerboa_verify` -> `jerboa_security_scan`
+- **Debug an error**: `jerboa_explain_error` -> follow suggested tools -> `jerboa_howto` for fix patterns
+- **Understand unfamiliar code**: `jerboa_file_summary` -> `jerboa_document_symbols` -> `jerboa_module_deps`
+- **Refactor a module**: `jerboa_check_exports` -> `jerboa_find_callers` -> `jerboa_rename_symbol` -> `jerboa_check_import_conflicts`
+- **Build project**: `jerboa_build_conflict_check` -> `jerboa_make` -> `jerboa_build_and_report`
+- **Port from Gerbil**: `jerboa_migration_check` -> `jerboa_translate_scheme` -> `jerboa_verify` -> `jerboa_check_syntax`
+- **Audit project quality**: `jerboa_verify` -> `jerboa_lint` -> `jerboa_dead_code` -> `jerboa_dependency_cycles`
+- **Debug a crash**: `jerboa_stale_static` -> `jerboa_bisect_crash` -> `jerboa_ffi_type_check`
+- **Learn a module**: `jerboa_stdlib_source` -> `jerboa_module_catalog` -> `jerboa_module_quickstart`
+- **Security audit**: `jerboa_security_audit` -> `jerboa_import_policy_check` -> `jerboa_unsafe_import_lint`
+- **Static build audit**: `jerboa_static_symbol_audit` -> `jerboa_boot_library_audit` -> `jerboa_rust_musl_build`
+- **Safe-by-default check**: `jerboa_safe_prelude_check` -> `jerboa_resource_leak_check` -> `jerboa_safe_prelude_generate`
+- **Debug editor command**: `jerboa_command_trace` with `project_path` and `buffer_type`
+
+## Workflow Conventions
+
+When implementing new features, always complete the documentation update in the same session. Document non-trivial solutions as howto recipes in the cookbook system.
diff --git a/plan.md b/plan.md
new file mode 100644
index 0000000..5d8eba0
--- /dev/null
+++ b/plan.md
@@ -0,0 +1,1207 @@
+# jerboa-jmap — Full Implementation Plan
+
+A zero-knowledge JMAP email-fetch server in Jerboa + Rust, pairing with
+`jerboa-smtp`, storing age-encrypted mail in `jerboa-sqlite`, and serving an
+Android APK client generated with `jerboa-android`. Ships as a static binary
+inside a FreeBSD jail.
+
+This document is the complete handoff for an implementing agent (local LLM).
+It is written to be executed top-to-bottom. Follow the phases in order; each
+phase has acceptance gates. Do not skip gates.
+
+---
+
+## 0. Ground rules (read first, obey always)
+
+### 0.1 Repository boundaries — HARD CONSTRAINTS
+
+- **This repo (`~/mine/jerboa-jmap`) is the ONLY repo you may modify.** All new
+  code lives here.
+- **`~/mine/jerboa-smtp` is READ-ONLY.** Another agent is actively working in
+  it. Do not edit, do not commit, do not "fix" anything there. Integrate with
+  it exclusively through its on-disk outputs and its documented config/schema
+  contract (§5.1). If the contract is ambiguous, read its source; never change
+  it.
+- These repos are **consume-only dependencies**, pinned by commit, fetched from
+  `git.jerboa.sh` — do not modify them either:
+  - `~/mine/jerboa` (language + stdlib; read for API reference)
+  - `~/mine/jerboa-sqlite` (pure-Jerboa SQLite engine — the mail store)
+  - `~/mine/jerboa-pgp` (reference for pure-Rust crypto FFI + static binary build)
+  - `~/mine/jerboa-android` (APK generator; read for spec format, invoke as a tool)
+  - `~/mine/jerboa-mail` (pure-Jerboa MIME parser; optional, admin tooling only)
+- If a dependency genuinely lacks something, implement the missing piece **in
+  this repo** and note it as a possible upstream contribution. Do not fork-patch
+  vendored code; wrap it.
+
+### 0.2 Git hosting — git.jerboa.sh ONLY
+
+- **Never use SourceHut. Never use GitHub.** Not for remotes, not for release
+  artifacts, not for dependency fetches, not as "fallback".
+- Remote pattern (verified across sibling repos):
+
+  ```
+  ssh://git@git.jerboa.sh:2222/ober/<repo>.git
+  ```
+
+  This repo's origin: `ssh://git@git.jerboa.sh:2222/ober/jerboa-jmap.git`
+- Dependency fetches (vendoring, jpkg pins) use the same host:
+  `ssh://git@git.jerboa.sh:2222/ober/jerboa-sqlite.git`, etc., pinned to exact
+  commit hashes.
+
+### 0.3 No C libraries — HARD CONSTRAINT
+
+The entire dependency graph of the shipped artifacts (server binary, APK
+native code) must contain **zero C libraries**:
+
+- **NO OpenSSL / LibreSSL / BoringSSL.** This excludes `~/mine/jerboa-crypto`
+  and `~/mine/jerboa-ssl` (both are OpenSSL FFI shims). Do not import them.
+- **NO libsqlite3.** Use `jerboa-sqlite` (pure Jerboa reimplementation). Never
+  touch `~/mine/jerboa-sqlite-ffi` (its README states it is a differential-test
+  oracle only, never a runtime dependency).
+- **NO libsodium / libffi / etc.**
+- TLS = **rustls 0.23 + ring** via Jerboa's own `(std net tls-rustls)` /
+  `(std net httpsd)`, backed by the `jerboa-native-rs` Rust crate already in
+  the jerboa tree. ring is Rust+asm, not a "C library" in the threat sense this
+  rule targets (no OpenSSL-class C attack surface); it is already vetted and
+  shipped by the jerboa project itself.
+- Crypto for message encryption = **pure-Rust `age` crate stack**
+  (X25519 + ChaCha20-Poly1305), exactly the stack `jerboa-pgp` already ships:
+  `age 0.11`, `ed25519-dalek 2.1`, `zeroize`, `rand 0.8`, `secrecy`, `sha2`,
+  `base64`, `smallvec`. Compile it into a `staticlib` named `jjmap-native`
+  (new crate, in this repo, modeled directly on `jerboa-pgp/pgp-native`).
+- Android platform APIs (framework `android.database.sqlite`, Android Keystore,
+  OkHttp) are platform system APIs, not linked C libraries; they are allowed.
+  Do **not** add SQLCipher, Conscrypt-from-source, or any bundled `.so` written
+  in C. The one `.so` we ship in the APK is our own Rust `libjjmap_android.so`
+  (age decrypt) — Rust, built by us.
+
+### 0.4 Jerboa authoring rules (for the implementing LLM)
+
+This repo's `AGENTS.md` is binding. Summary of the highest-risk items:
+
+- **NEVER edit `.ss` files with raw `edit`/`sed`/`python`.** Use
+  `jerboa_balanced_insert`, `jerboa_balanced_replace` (dry-run by default;
+  pass `dry_run: false` to write), or `jerboa_write_file` (`verify: true`).
+  Run `jerboa_check_balance` after every `.ss` change.
+- **Never guess Jerboa APIs.** Use `jerboa_howto`, `jerboa_module_exports`,
+  `jerboa_function_signature`, then `jerboa_verify` after writing. The
+  `AGENTS.md` hallucination list (`time->seconds`, `thread-sleep!`,
+  `make-equal-hashtable`, 2-arg `path-expand`, …) is real — re-read it.
+- After any `.ss` change: build (`make build`) before moving on.
+- Non-trivial discoveries → save with `jerboa_howto_add` /
+  `jerboa_error_fix_add` / `jerboa_anti_pattern_add` per `AGENTS.md`.
+
+---
+
+## 1. Mission
+
+Build **`jjmapd`**, a JMAP (RFC 8620 core + RFC 8621 mail subset) HTTPS server
+with these properties:
+
+1. **Zero-knowledge storage.** Mail arrives via `jerboa-smtp` (which delivers
+   plaintext to per-recipient Maildirs — its existing behavior, unchanged).
+   Our **ingestor** encrypts each message with the recipient's **age public
+   key** (X25519) within seconds of delivery and stores only ciphertext in
+   `jerboa-sqlite`. Plaintext is deleted after the DB commit. The server
+   **never possesses any private key**.
+2. **The Android APK is the only decryption point.** The age identity
+   (X25519 secret key) is generated on-device, stored wrapped by the Android
+   Keystore, and never leaves the phone. The public key is provisioned to the
+   server out-of-band (admin CLI).
+3. **Sync, not POP.** Messages persist on the server after the APK fetches
+   them. The JMAP protocol surface has **no delete** (`Email/set` destroy is
+   rejected `forbidden`). The APK keeps a local decrypted cache; the server
+   copy is the durable archive.
+4. **Static binary, FreeBSD jail.** One self-contained `jjmapd` binary
+   (Chez `compile-program` with boot-embedded Rust staticlibs, the
+   `jerboa-pgp` `jpg-bin` pattern), cross-built for `x86_64-unknown-freebsd`,
+   running as an unprivileged user in a minimal jail with rctl limits.
+
+### Non-goals (v1)
+
+- Sending mail / submission (JMAP `Email/set` create + submission). The SMTP
+  repo owns sending when it grows AUTH/submission. We document a future
+  handoff point only.
+- Server-side search of message content (impossible by design — ciphertext).
+- Multi-device key sync (v1 = one identity per device; a message encrypted to
+  multiple device keys is a supported extension, §4.4).
+- Webmail UI, IMAP, POP3, calendars/contacts (JMAP for Contacts/Calendars).
+- EventSource push (phase-gated optional; APK v1 polls via WorkManager).
+
+---
+
+## 2. Architecture
+
+```
+                         Internet
+                            │ SMTP :25
+                            ▼
+                 ┌──────────────────────┐
+                 │   jerboa-smtp jail   │  (READ-ONLY, other agent's repo)
+                 │  jsmtp serve/deliver │
+                 │  writes plaintext    │
+                 │  RFC5322 to Maildir  │
+                 └─────────┬────────────┘
+                           │ shared nullfs/ZFS dataset (read-write for both,
+                           │ separate dirs; see §11.3)
+                           ▼  <maildir_root>/<recipient>/new/*
+        ┌──────────────────────────────────────────────────┐
+        │                 mail jail (this project)         │
+        │                                                  │
+        │  jjmapd ingest (fiber/poll loop)                 │
+        │    scan new/ → sha256 → age-encrypt (Rust FFI)   │
+        │    → INSERT into jerboa-sqlite → fsync →         │
+        │    delete plaintext                              │
+        │    no recipient key? → quarantine + alert        │
+        │                                                  │
+        │  jjmapd serve (HTTPS, rustls via std net httpsd) │
+        │    /.well-known/jmap   session                   │
+        │    POST /jmap/         JMAP API (JSON)           │
+        │    GET /jmap/download/… Blob/download (ciphertext)│
+        │    Bearer auth (SHA-256 hashed tokens)           │
+        │    rate limits, security headers, request caps   │
+        │                                                  │
+        │  store: /jmap/db/mail.sqlite  (jerboa-sqlite)    │
+        └──────────────────────┬───────────────────────────┘
+                               │ TLS 1.3 (rustls), TCP :443→jail :8443 (pf rdr)
+                               ▼
+                    ┌─────────────────────┐
+                    │  Android APK        │
+                    │  (jerboa-android    │
+                    │   generated Kotlin) │
+                    │  • SPKI pin + Bearer│
+                    │  • WorkManager sync │
+                    │  • Blob/download →  │
+                    │    age-decrypt via  │
+                    │    libjjmap_android │
+                    │    (Rust JNI .so)   │
+                    │  • Keystore-wrapped │
+                    │    age identity     │
+                    │  • local cache      │
+                    └─────────────────────┘
+```
+
+**Trust boundary summary:** plaintext exists (a) transiently in the smtp→maildir
+window (seconds), (b) in the ingestor's memory, (c) on the phone after
+decryption. At rest on the server: only age ciphertext + metadata.
+
+---
+
+## 3. Threat model & security invariants
+
+### 3.1 Adversaries
+
+- Server compromise (full root in the mail jail): attacker reads the DB.
+  **They get ciphertext + metadata only.** No private keys on the box. This is
+  the primary design goal.
+- Network MITM between APK and server: defeated by TLS 1.3 (rustls) + client
+  SPKI pin; optional mTLS as phase-2 hardening.
+- Hostile inbound email content: the ingestor **never parses MIME**. It treats
+  the message as opaque bytes: hash → encrypt → store. MIME parsing happens
+  only on the APK, post-decryption, in Kotlin, in an app sandbox.
+- Hostile JMAP clients: unauthenticated requests get only the session resource
+  (which itself requires auth, see §8.2); JSON bodies are size-capped; all
+  inputs validated; SQL via prepared statements only (jerboa-sqlite bind API).
+- Token theft: tokens are 256-bit random, stored as SHA-256 hashes
+  (constant-time compared), revocable via admin CLI, rate-limited per-token.
+
+### 3.2 Invariants (test for all of them in CI)
+
+1. The string bytes of any private key (`AGE-SECRET-KEY-1…`) never appear in
+   the server repo, config examples, DB, logs, or jail. CI grep gate.
+2. After ingest completes for a message, no plaintext copy exists under
+   `maildir_root` or anywhere in the mail jail filesystem.
+3. `jjmapd` never writes outside: `db_path` (+journal), quarantine dir, its
+   own log stream. Tested by running under a read-only-root jail config.
+4. Every JMAP response is valid JSON; every error uses RFC 8620 error shapes;
+   no stack traces, paths, or internals leak in error bodies.
+5. All SQL uses bind parameters. Grep gate: no string interpolation into SQL.
+6. TLS: negotiated protocol ≥ TLS 1.3 enforced; startup self-check logs the
+   rustls config; handshake with TLS 1.2-only client must fail (test with
+   `openssl s_client -tls1_2`).
+7. Request body cap (default 1 MiB) enforced before JSON parse; blob download
+   responses stream with backpressure (chunked responder), never load whole
+   DB into memory.
+8. Ingest is idempotent: kill -9 between any two steps leaves the system
+   consistent (dedupe by `UNIQUE(username, sha256)`).
+
+### 3.3 Metadata honesty
+
+The server necessarily learns: recipient, arrival time, size, IP of fetchers,
+sync patterns. Subject/From/To/body live inside the ciphertext. This is
+documented in `docs/threat-model.md` (you will write it) so operators
+understand the residual exposure.
+
+---
+
+## 4. Cryptography design
+
+### 4.1 Primitives (all pure Rust)
+
+| Purpose | Algorithm | Crate (pinned) |
+|---|---|---|
+| Message content encryption | age v1 format, X25519 recipient stanza, ChaCha20-Poly1305 payload, scrypt NOT used (recipient mode only) | `age = "0.11"` (default-features=false, features=["armor"] for CLI interop) |
+| Randomness | OS CSPRNG via `getrandom` | `rand = "0.8"`, `rand_core = "0.6"` |
+| Token hashing / message digest | SHA-256 | `sha2 = "0.10"` (default-features=false) |
+| Constant-time compare | `subtle` (add dep) | `subtle = "2"` |
+| Secret hygiene | zeroize on drop | `zeroize = "1.7"`, `secrecy = "0.8"` |
+| Base64 (CLI/JSON interop) | | `base64 = "0.22"` |
+
+Dependency policy mirrors `jerboa-pgp/pgp-native/Cargo.toml`: minimal features,
+`opt-level = "z"`, `lto = true`, `codegen-units = 1`, `strip = true`, vendored
+(`cargo vendor` into `rust/vendor`), and `cargo audit -D warnings` as a
+release gate. Copy `jerboa-pgp`'s audit/verify Makefile targets as the
+starting point.
+
+### 4.2 Key model
+
+- Each **mailbox user** has one or more **recipient public keys**
+  (`age1…` strings). Rows in `recipient_keys`. Multiple keys per user =
+  multi-device (each phone its own identity). The age format natively supports
+  multiple recipient stanzas; the ingestor encrypts to ALL active keys of the
+  user in one pass.
+- **No server-side private keys. Anywhere.** Decryption FFI exists only in the
+  optional admin recovery tool (§9.3, `jjmap admin decrypt-file`) which is a
+  separate binary path, run by an operator on an offline machine with the key
+  material supplied at runtime — never in the jail.
+- APK identity generation: on first run, Kotlin calls into
+  `libjjmap_android.so` (`jjmap_age_keygen()` → secret + public). Secret is
+  AES-GCM-wrapped with an Android Keystore key (hardware-backed where
+  available, `setUserAuthenticationRequired(true)` optional biometric gate)
+  and stored in app-private storage. Public key is displayed as text + QR for
+  out-of-band provisioning.
+
+### 4.3 Provisioning flow (operator runbook, also in §11.6)
+
+```
+phone:  jjmap_age_keygen() → shows "age1abc…"
+admin:  jjmap user add alice@example.org
+admin:  jjmap key add alice@example.org age1abc…     # writes recipient_keys
+admin:  jjmap token issue alice@example.org --label "pixel-8"
+        → prints bearer token ONCE (stored SHA-256 only)
+phone:  Settings → server URL, token, SPKI pin (from `jjmap tls-pin`)
+```
+
+### 4.4 Rotation & revocation
+
+- New device: `key add` (old key stays active). Lost device: `key disable`
+  (ingest stops encrypting to it; old ciphertext stays decryptable by whoever
+  holds it — documented). Token revoke: `token disable`.
+- Re-encryption to a new key is explicitly out of scope (requires plaintext or
+  recipient cooperation); document as accepted limitation.
+
+### 4.5 Envelope format on disk
+
+`messages.payload` (BLOB) = raw **age v1 binary** (not armor) of the complete
+unmodified RFC5322 message bytes. `payload_format = 'age-v1'`. Armor is used
+only at CLI boundaries (`jjmap admin encrypt-file/decrypt-file`, tests).
+
+---
+
+## 5. Component A — the ingestor
+
+### 5.1 Contract with jerboa-smtp (READ-ONLY; verify at integration time)
+
+Observed from `~/mine/jerboa-smtp` (confirm against its source when wiring;
+do not modify that repo):
+
+- Config (`examples/jsmtp.json`) keys: `maildir_root`, `local_recipients`,
+  `local_domains`, `spool_root`, `max_message_bytes` (default **65536** —
+  deployment note: raise there if larger mail is wanted; that's their repo's
+  decision, not ours).
+- Delivery (`lib/jerboa-smtp/maildir.ss`): `recipient-maildir-path(root,
+  recipient)` → per-recipient Maildir with the standard `tmp/ new/ cur/`
+  triplet; messages are natively published into **`new/`** via
+  `openat(O_EXCL)` + `linkat` (no-overwrite, fsync'd). Filenames are unique.
+- The smtp `deliver` worker is idempotent and marker-based; once a file sits
+  in `<maildir_root>/<recipient>/new/<name>` it is a fully-written, durable
+  message.
+
+**Integration surface = exactly that directory tree.** We read `new/`; we do
+not touch `tmp/` or `cur/`; we never write into the smtp repo's spool.
+
+### 5.2 Ingest algorithm (crash-safe, idempotent)
+
+State per candidate file `F = <maildir_root>/<user>/new/<name>`:
+
+1. **Stat & stability check.** Skip if size == 0 or mtime younger than
+   `ingest_min_age_seconds` (default 2s) — avoids racing the publisher.
+   (Their `linkat` publish is atomic, so this is belt-and-braces.)
+2. **Read bytes** (cap `max_message_bytes`, default 25 MiB; oversize →
+   quarantine, §5.3). Compute `sha256`.
+3. **Key lookup.** All active `recipient_keys` for `user`. None → quarantine.
+4. **Encrypt.** One `jjmap_age_encrypt_multi(pubkeys, bytes)` FFI call →
+   ciphertext (single age payload, N recipient stanzas).
+5. **Store.** In the single-writer DB actor:
+   `BEGIN IMMEDIATE; INSERT INTO messages (…) VALUES (…)
+   ON CONFLICT(username, sha256) DO NOTHING RETURNING id;`
+   - If a row was returned: also `INSERT INTO changes (… 'created' …)`; commit;
+     fsync (jerboa-sqlite DELETE-journal commit path already fsyncs; verify —
+     if not, add explicit fsync call around commit and record the gap).
+   - If no row (dedupe hit): commit nothing; this is a re-ingest after a
+     crash; proceed to delete.
+6. **Delete plaintext.** Unlink `F` only after the commit returned success.
+7. Loop; then sleep `ingest_poll_seconds` (default 5). Also wake on SIGHUP.
+
+Zeroization: the plaintext bytevector is explicitly overwritten
+(`bytevector-fill!` 0) in a `finally` after encryption, before the next file.
+The Rust side zeroizes its own buffers (`zeroize`).
+
+### 5.3 Quarantine
+
+Move (rename within same fs; copy+fsync+unlink across fs) to
+`<quarantine_root>/<user>/<sha256>.eml` (mode 0600, dir 0700, on the
+encrypted ZFS dataset) + log line + `INSERT INTO changes (… 'quarantined' …)`
+is NOT done (not a mailbox object). Operator resolves by adding a key /
+raising caps, then runs `jjmap ingest-once --quarantine` which re-runs the
+same algorithm over the quarantine tree. Quarantine holds **plaintext** — the
+dataset it lives on must be the ZFS-native-encrypted dataset (§11.3), and the
+runbook must say so.
+
+### 5.4 Backfill
+
+On first boot with existing maildir content, the same algorithm drains the
+backlog naturally. No special mode; just run `ingest-once` before starting the
+daemon for operator confidence.
+
+---
+
+## 6. Component B — storage (jerboa-sqlite)
+
+### 6.1 Why, and the maturity caveat
+
+`jerboa-sqlite` is a pure-Jerboa SQLite engine (tokenizer→parser→VDBE→B-tree→
+pager) targeting SQLite 3.54.0 semantics. Write path is phase-4-complete
+(STRICT tables, UNIQUE indexes, UPSERT, RETURNING, DELETE-mode rollback
+journal with hot-journal recovery) with **locking/fsync/WAL hardening still
+planned** upstream. Therefore:
+
+- **Single connection, single writer, serialized everything.** One DB actor
+  (a dedicated thread with a mailbox-queue of thunks) owns the one open
+  connection. All ingest writes AND all JMAP reads go through it. No
+  concurrent connections. This sidesteps the unfinished locking work entirely.
+  At our scale (a personal/small server, sync workloads) this is not a
+  bottleneck.
+- Journal mode: default DELETE mode. Do not attempt WAL.
+- Integrity: `jjmap dbcheck` runs `PRAGMA integrity_check` equivalent +
+  schema verification on startup; refuse to serve on failure.
+- Backups: `jjmap backup <dst>` = stop-writer + copy db file + journal state
+  check; document that online `cp` without the writer stopped is unsupported.
+
+### 6.2 Consume it correctly
+
+- Add as a **jpkg dependency pinned to an exact commit** from
+  `ssh://git@git.jerboa.sh:2222/ober/jerboa-sqlite.git` in `jpkg.sexp` +
+  `jpkg.lock` (mirror how `~/mine/jerboa-smtp/jpkg.sexp` declares deps).
+  **Never** reference `../jerboa-sqlite` or any sibling path from build files
+  (this repo's AGENTS.md forbids sibling-path resolution; vendor via pinned
+  fetch). Provide `support/fetch-deps.sh` that clones the pinned commits into
+  `vendor/` for hermetic CI builds.
+- Import surface (module path inside the vendored lib is `(jsqlite api)` —
+  verify with `jerboa_module_exports` at integration time; the README lists
+  `src/jsqlite/api.ss` exporting the `sqlite3_*`-shaped API:
+  open/prepare/step/reset/finalize, typed binds, column accessors,
+  `sqlite-exec`/`sqlite-query`).
+
+### 6.3 Schema (version 1; migrations via `meta.schema_version`)
+
+```sql
+CREATE TABLE meta (
+  k TEXT PRIMARY KEY,
+  v TEXT NOT NULL
+) STRICT;
+
+CREATE TABLE users (
+  username  TEXT PRIMARY KEY,              -- full address, lowercase, validated
+  created   INTEGER NOT NULL,              -- epoch seconds
+  disabled  INTEGER NOT NULL DEFAULT 0
+) STRICT;
+
+CREATE TABLE recipient_keys (
+  username  TEXT NOT NULL REFERENCES users(username),