base
ober
b66d3e86fd294b01b2029fc9c2b50e47599a29d0
new file mode 100644 --- /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. new file mode 100644 --- /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),