Add long-form documentation under docs/

ober

06e5e0c1f1f35c9d0878769fb054a9c7d65e487b

diff --git a/.gitsafe.json b/.gitsafe.json
new file mode 100644
index 0000000..c54b83e
--- /dev/null
+++ b/.gitsafe.json
@@ -0,0 +1,14 @@
+{
+  "severity": "medium",
+  "entropy": true,
+  "exclude": [
+    "*.lock",
+    "go.sum",
+    "**/*.md",
+    "docs/**",
+    "vendor/**",
+    "node_modules/**",
+    "*.min.js",
+    "*.min.css"
+  ]
+}
diff --git a/PLAN.md b/PLAN.md
index 785bfd6..13bd567 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -1,5 +1,9 @@
 # jerboa-pgp — Plan
 
+This file is the original design sketch, kept for historical record. For
+the current state of the project see [README.md](README.md) and the
+documentation under [`docs/`](docs/).
+
 ## Motivation
 
 GPG has been around for 20+ years and almost nobody uses it. The data model
@@ -22,10 +26,11 @@ who would otherwise like to use it.
    (`age`, `ed25519-dalek`, `pgp`/rPGP, `zeroize`).
 4. **Jerboa for the human side.** CLI, file I/O, recipient parsing,
    output formatting — all in `(jerboa prelude)` style.
-5. **PGP as an outbound interop bolt-on.** You can encrypt *to* somebody's
-   OpenPGP public key (so they can decrypt with `gpg`), but native messages
-   are age. We do not implement inbound PGP decryption, PGP signing, or
-   PGP key management in v1.
+5. **PGP as a thin interop layer.** You can encrypt *to* and decrypt
+   *from* an OpenPGP key, and produce or verify OpenPGP detached
+   signatures, but native messages are age. Anything beyond round-trip
+   (keyservers, web-of-trust, subkey management) is explicitly out of
+   scope.
 
 ## Architecture
 
@@ -36,117 +41,92 @@ jerboa-pgp/
 │   └── src/
 │       ├── lib.rs           # extern "C" FFI surface
 │       ├── error.rs         # JPGP_E_* error codes
-│       ├── age_ops.rs       # age keygen / encrypt / decrypt
-│       ├── sig_ops.rs       # Ed25519 keygen / sign / verify
-│       ├── pgp_ops.rs       # rPGP: encrypt-to-PGP-recipient
-│       └── pass_ops.rs      # age scrypt: identity-file wrap/unwrap
+│       ├── util.rs          # buffer-output helpers
+│       ├── age_mod.rs       # age keygen / encrypt / decrypt
+│       ├── pass_mod.rs      # age scrypt: identity-file wrap/unwrap
+│       ├── sig_mod.rs       # Ed25519 keygen / sign / verify
+│       ├── pgp_mod.rs       # rPGP: encrypt-to-PGP-recipient
+│       └── pgp_io.rs        # rPGP: decrypt, sign, verify
 ├── pgp/                     # Jerboa source (.ss)
 │   ├── util.ss              # byte/string helpers, error type
 │   ├── ffi.ss               # foreign-procedure bindings to libjpgp_native
-│   ├── armor.ss             # ASCII-armor detect / strip
-│   ├── recipient.ss         # parse age vs OpenPGP recipients
+│   ├── armor.ss             # ASCII-armor detect / classify
+│   ├── recipient.ss         # parse age vs jpgp1 vs OpenPGP recipients
 │   ├── identity.ss          # load/save passphrase-wrapped identity
 │   ├── prompt.ss            # passphrase prompt (no echo)
 │   ├── cli.ss               # subcommand dispatch + arg parsing
 │   └── main.ss              # script entry point
+├── support/
+│   ├── binary-entry.ss      # entry compiled into the static binary
+│   └── build-binary.sh      # WPO + boot-embed + link wrapper
+├── doc/
+│   └── jpg.1                # mandoc man page
+├── completions/
+│   ├── jpg.bash
+│   └── _jpg
 ├── test/
-│   └── test-all.ss          # smoke tests
+│   ├── test-all.ss          # smoke tests
+│   └── interop-gpg.sh       # bidirectional gpg interop test
+├── docs/                    # Long-form docs (see docs/README.md)
 ├── Makefile
 └── README.md
 ```
 
-## FFI surface (Rust → Jerboa)
-
-All functions follow the buffer-output pattern from `jerboa-yubikey`: callers
-pass `(buf, buf_len, *out_len)`. If `buf` is NULL only the required length
-is written. Return is an `i32` `JPGP_E_*` code (0 = success).
-
-```
-jpgp_age_keygen(out_sec, sec_buf_len, *out_sec_len, out_pub, pub_buf_len, *out_pub_len) -> i32
-jpgp_age_encrypt(plain*, plain_len, recipients_cstr, out, buf_len, *out_len) -> i32
-jpgp_age_decrypt(cipher*, cipher_len, identity_cstr, out, buf_len, *out_len) -> i32
-
-jpgp_ed25519_keygen(*out_sk[64], *out_pk[32]) -> i32
-jpgp_ed25519_sign(sk[64], msg*, msg_len, *out_sig[64]) -> i32
-jpgp_ed25519_verify(pk[32], msg*, msg_len, sig[64]) -> i32  // 0=ok, JPGP_E_VERIFY=fail
-
-jpgp_pgp_encrypt(pubkey_armor_cstr, plain*, plain_len, out, buf_len, *out_len) -> i32
-
-jpgp_pass_encrypt(plain*, plain_len, passphrase_cstr, out, buf_len, *out_len) -> i32
-jpgp_pass_decrypt(cipher*, cipher_len, passphrase_cstr, out, buf_len, *out_len) -> i32
-```
-
-All entry points are `extern "C"` and wrap their body in `catch_unwind` so a
-panic never crosses the FFI boundary.
-
-## File formats
-
-### Identity file — `~/.jpgp/identity.age`
-
-An age-encrypted blob (passphrase recipient via scrypt). Plaintext:
-
-```
-jpgp-identity v1
-age: AGE-SECRET-KEY-1...
-ed25519: <base64 of 64-byte secret key>
-```
-
-### Public key file — `*.pub.jpgp`
-
-Single line, designed to paste into Slack/email:
-
-```
-jpgp1 age=age1abcd... ed25519=base64...
-```
-
-OpenPGP public keys (`.asc`) are detected by their `-----BEGIN PGP PUBLIC KEY BLOCK-----`
-header and routed to `jpgp_pgp_encrypt`.
-
-### Encrypted message
-
-Native: standard age armor (`-----BEGIN AGE ENCRYPTED FILE-----`).
-PGP-recipient: standard OpenPGP armor (`-----BEGIN PGP MESSAGE-----`).
-
-### Signature file — `*.sig`
-
-```
-jpgp-sig v1
-pubkey: <base64 of 32-byte Ed25519 pubkey>
-sig: <base64 of 64-byte Ed25519 signature>
-```
-
 ## CLI
 
 ```
-jpgp keygen [--out PATH]              Generate identity (prompts passphrase)
-jpgp pubkey [--out PATH]              Print this identity's public key line
-jpgp encrypt -r RECIPIENT [-i IN] [-o OUT]   Encrypt; auto-routes age/PGP
-jpgp decrypt [-i IN] [-o OUT]         Decrypt (age only in v1)
-jpgp sign    [-i IN] [-o OUT.sig]     Sign a file/stdin
-jpgp verify  SIG_FILE [-i IN]         Verify
-jpgp version
+jpg keygen [--out PATH]              Generate identity (prompts passphrase)
+jpg pubkey [--identity P] [--out P]  Print this identity's public key line
+jpg list [--identity P]              Show identity info
+jpg fingerprint [--identity P]       Print SHA256: fingerprint
+jpg encrypt [-r R ...] [-s] [-i I] [-o O]   Encrypt; auto-routes age/PGP
+jpg decrypt [-s] [-i I] [-o O] [--identity P] [--pgp-key SEC.asc] [--pgp-pass P]
+jpg sign    [-i I] [-o O] [--identity P] [--pgp-key SEC.asc] [--pgp-pass P]
+jpg verify  SIG [-i I] [--pubkey P] [--pgp-pubkey PUB.asc]
+jpg version
 ```
 
-Out of scope for v1: `jpgp key list/import-pgp/export`, agent, decrypt-PGP,
-sign-PGP, certifications, expiry, revocation. These can be added once the
-core round-trip is solid.
-
-## Out-of-scope items
-
-- **Inbound PGP decryption.** You can't decrypt a PGP-encrypted message sent
-  to your old GPG key. (Future: `jpgp legacy-decrypt` that imports a GPG
-  secret key one-time.)
-- **PGP signing.** Sign outputs are jpgp/minisign-style, not PGP.
-- **Web of trust / certifications.** Public keys are identified by fingerprint
-  (or human nickname you assign locally). No third-party trust signing.
-- **Keyservers.** Public keys are exchanged out-of-band (paste, email,
-  file). Future: an optional fetch from a contact's HTTPS URL.
+For full details see [`doc/jpg.1`](doc/jpg.1).
+
+## What v1 ships
+
+All items below are implemented and tested:
+
+- `keygen` / `pubkey` / `list` / `fingerprint`
+- `encrypt` (age, multi-recipient, symmetric) and `encrypt -r FOO.asc`
+  for OpenPGP recipients
+- `decrypt` (age, symmetric) and `decrypt --pgp-key SECRET.asc` for
+  inbound OpenPGP
+- `sign` / `verify` (Ed25519 native) and `--pgp-key` variants that
+  produce / verify OpenPGP detached signatures
+- Self-contained `jpg` binary built with Chez `compile-program` + WPO +
+  boot-file embedding (~5 MB Mach-O / ELF)
+- Bidirectional gpg interop tested end-to-end (`make test-interop`)
+- mandoc man page, bash + zsh completions
+
+## Out-of-scope (still)
+
+- **Keyservers / WKD.** Public keys are exchanged out-of-band (paste,
+  email, file). No fetch over the network.
+- **Web of trust / certifications.** Public keys are identified by their
+  SHA-256 fingerprint; there is no third-party trust signing.
+- **Subkey hierarchy.** Each identity has one age key and one Ed25519
+  key. No rotation across subkeys, no signing of subkeys.
+- **AEAD OpenPGP packets** (the gnupg-proprietary OCB packet, tag 20).
+  rPGP doesn't parse them. See [docs/INTEROP.md](docs/INTEROP.md) for
+  how to make gpg emit SEIPDv1 instead.
+- **Agent / passphrase caching.** Each operation prompts. There is no
+  ssh-agent-style daemon.
 
 ## Build
 
 ```
 make run ARGS='keygen'   # interpreter
 make test                # smoke tests
-make binary              # native binary `jpgp`
-make install             # → ~/.local/bin/jpgp
+make test-interop        # gpg interop
+make binary              # native binary `jpg`
+make install             # → ~/.local/bin/jpg + ~/.local/lib/libjpgp_native
 ```
+
+See [docs/BUILDING.md](docs/BUILDING.md) for what the binary build
+actually does.
diff --git a/README.md b/README.md
index 3f62f3e..d234dbc 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,9 @@ v1 implements:
 - Self-contained `jpg-bin` (Chez `compile-program` + boot-embedded)
 - Bidirectional gpg interop tested end-to-end (`make test-interop`)
 
-See `PLAN.md` for the design and out-of-scope items.
+See [`docs/`](docs/) for the architecture, FFI surface, on-disk
+formats, gpg interop notes, build internals, and threat model.
+[`PLAN.md`](PLAN.md) is the original design sketch.
 
 ## Why
 
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..466139a
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,211 @@
+# Architecture
+
+`jerboa-pgp` is two halves stitched together by a small C ABI:
+
+- **`pgp-native/`** — a pure-Rust crate exposing all cryptographic
+  primitives behind a flat `extern "C"` surface. Built as
+  `cdylib` + `staticlib` (`libjpgp_native.{dylib,so}` + `.a`).
+- **`pgp/`** — Jerboa code (Chez Scheme + `(jerboa prelude)`) providing
+  argument parsing, file I/O, recipient routing, identity-file
+  management, passphrase prompting, and shell-friendly behaviour.
+
+Nothing in `pgp/` does cryptography. Nothing in `pgp-native/` knows
+about a CLI. The two never share heap memory — every value crossing the
+boundary is either a primitive scalar, a length-prefixed byte slice, or
+a NUL-terminated UTF-8 string.
+
+```
+                +----------------------- jpg(1) --------------------+
+                |                                                   |
+        argv -->|  main.ss --> (pgp cli) --> (pgp identity) -etc.   |
+                |                  |                                |
+                |                  v                                |
+                |              (pgp ffi)   foreign-procedure        |
+                +------------------|--------------------------------+
+                                   |  C ABI (extern "C")
+                +------------------|--------------------------------+
+                |                  v                                |
+                |   libjpgp_native (Rust)                           |
+                |     age_mod  pass_mod  sig_mod  pgp_mod  pgp_io   |
+                |     (age)    (age-pw)  (ed25519-dalek)    (rPGP)  |
+                +---------------------------------------------------+
+```
+
+## Rust side: `pgp-native/src/`
+
+| File         | Purpose                                                     |
+|--------------|-------------------------------------------------------------|
+| `lib.rs`     | The whole `extern "C"` surface. Each entry point wraps its body in `catch_unwind` and routes the result through the `(buf, buf_len, *out_len)` pattern (see `util.rs::write_out`). |
+| `error.rs`   | `JPGP_OK = 0` plus the `JPGP_E_*` constants. These are stable; they're the only thing the Jerboa side sees on failure. |
+| `util.rs`    | `write_out` (the buffer-output helper), `slice_from` (turn `(ptr, len)` into a slice), `cstr_to_str`. All `unsafe` is concentrated here. |
+| `age_mod.rs` | age X25519 keygen, recipient encrypt, recipient decrypt. Uses the `age` crate with armor enabled. |
+| `pass_mod.rs`| Wraps/unwraps blobs with an age scrypt passphrase recipient. This is what protects the identity file. |
+| `sig_mod.rs` | Ed25519 keygen / sign / verify (the `ed25519-dalek` crate). |
+| `pgp_mod.rs` | Outbound OpenPGP: parse an armored public key, pick the encryption subkey (or primary), encrypt to SEIPDv1 with AES-256. |
+| `pgp_io.rs`  | Inbound OpenPGP and signing: decrypt SEIPDv1, sign data with a secret key, verify a detached signature against a public key.|
+
+### The FFI guard
+
+Every `extern "C"` entry point looks like this:
+
+```rust
+pub unsafe extern "C" fn jpgp_age_encrypt(...) -> i32 {
+    guard(|| {
+        let pt = match unsafe { slice_from(plain, plain_len) } {
+            Some(s) => s,
+            None    => return JPGP_E_INVALID_INPUT,
+        };
+        // ...
+    })
+}
+
+fn guard<F: FnOnce() -> i32>(f: F) -> i32 {
+    match catch_unwind(AssertUnwindSafe(f)) {
+        Ok(code) => code,
+        Err(_)   => JPGP_E_INTERNAL,
+    }
+}
+```
+
+A Rust panic must never unwind across the FFI boundary (that's undefined
+behaviour). `guard` traps it and returns `JPGP_E_INTERNAL`. Combined
+with `panic = "abort"` in the release profile, panic paths are
+essentially impossible in production builds.
+
+### The release profile
+
+```toml
+[profile.release]
+opt-level = "z"     # size, not speed
+lto = true
+codegen-units = 1
+strip = true
+panic = "abort"
+```
+
+The Rust crate's final size matters more than its raw speed — most CPU
+time in this tool is spent in the audited crypto primitives (which the
+compiler already inlines and vectorises), not in our wrapping. Stripped
+LTO yields a ~3 MB dylib.
+
+## Jerboa side: `pgp/`
+
+| Library         | Purpose                                                     |
+|-----------------|-------------------------------------------------------------|
+| `(pgp util)`    | `jpgp-error`, `bv->u32-le`, file-I/O helpers (`read-file-bytes`, `write-file-bytes`, `write-stdout-bytes`). All other libraries import from this. |
+| `(pgp ffi)`     | The only place that talks to `libjpgp_native`. Locates and loads the dylib, defines one `foreign-procedure` binding per Rust function, wraps each in a Scheme-shaped procedure that returns bytevectors / values. |
+| `(pgp armor)`   | Pure-text classifier: given a string, is it age-armored, a PGP pubkey block, a PGP message, a `jpgp1` line, or an age recipient? Used by `recipient.ss` and by `decrypt` to refuse the wrong tool. |
+| `(pgp recipient)` | Parses recipient strings/files into tagged values: `('age S)`, `('jpgp ALIST)`, `('pgp ARMOR)`. Also formats outgoing `jpgp1` lines. |
+| `(pgp identity)` | The `identity` defstruct (age sk/pk + Ed25519 sk/pk), `save-identity` / `load-identity` (which call `jpgp-pass-encrypt` / `jpgp-pass-decrypt`), and `identity-pubkey-line` (formats `jpgp1 age=... ed25519=...`). |
+| `(pgp prompt)`  | Reads a passphrase with `stty -echo`. `read-passphrase-confirm` loops until two reads match. |
+| `(pgp cli)`     | The whole user-facing surface. `parse-opts` (tiny flag parser), one `cmd-*` per subcommand, and a `run-cli` dispatcher. This is where format auto-detection happens (e.g. `decrypt` refuses an OpenPGP message unless `--pgp-key` was supplied). |
+| `pgp/main.ss`   | Script entry point. Sets `library-directories`, imports `(pgp cli)`, and calls `(run-cli (command-line-arguments))`. |
+
+### Library boundary discipline
+
+`(pgp cli)` never imports `(pgp ffi)` for anything that needs raw
+bytes. Instead, all crypto goes through one of the higher-level
+modules (`identity`, `recipient`, etc.), which in turn calls `ffi`.
+This means:
+
+- A wrong-passphrase failure in `load-identity` comes back as a single
+  `(error 'jpgp-pass-decrypt "wrong passphrase")` — the CLI prints a
+  clean message instead of stack-tracing.
+- Adding a new crypto operation is two edits: one in `(pgp ffi)` for
+  the binding, one in the relevant high-level module that gives it
+  Jerboa-shaped types.
+
+## Runtime flow
+
+### `jpg keygen --out PATH`
+
+```
+cmd-keygen           (pgp cli)
+  read-passphrase-confirm    (pgp prompt)
+  generate-identity          (pgp identity)
+    jpgp-age-keygen          (pgp ffi)    →  Rust age_mod::keygen
+    jpgp-ed25519-keygen      (pgp ffi)    →  Rust sig_mod::keygen
+  save-identity              (pgp identity)
+    identity->text                          (formats plaintext)
+    jpgp-pass-encrypt         (pgp ffi)   →  Rust pass_mod::encrypt
+    write-file-bytes          (pgp util)
+    system "chmod 600 ..."
+```
+
+### `jpg encrypt -r alice.pub.jpgp -i secret.txt -o secret.txt.age`
+
+```
+cmd-encrypt          (pgp cli)
+  read-file-bytes "secret.txt"   (pgp util)
+  recipient-from-file …          (pgp recipient)
+    blob-kind / classify          → 'jpgp-pubkey
+    parse-jpgp-pubkey-line       → '((age . "age1…") (ed25519 . #vu8(…)))
+  (all-age-style? recs)          → #t
+  jpgp-age-encrypt plain joined  (pgp ffi) → Rust age_mod::encrypt
+  write-file-bytes "secret.txt.age"
+```
+
+### `jpg encrypt -r alice.asc -i secret.txt -o secret.asc` (PGP)
+
+Same path until `recipient-from-file`, which classifies the input as a
+`pgp-pubkey`. Then `cmd-encrypt` takes the single-PGP-recipient branch:
+
+```
+  jpgp-pgp-encrypt armor plain   (pgp ffi) → Rust pgp_mod::encrypt
+                                            (SEIPDv1 / AES-256)
+```
+
+### `jpg decrypt -i secret.txt.age` (auto-detect)
+
+`cmd-decrypt` reads the ciphertext, decodes it as UTF-8 best-effort,
+and uses `(pgp armor)` to see whether it's a PGP message. If it is, and
+the user did not pass `--pgp-key`, the tool exits with a clear error
+("re-run with --pgp-key SECRET.asc"). Otherwise it loads the identity
+and calls `jpgp-age-decrypt`.
+
+### `jpg verify SIG -i FILE`
+
+`cmd-verify` reads `SIG` as text and inspects the armor header:
+
+- `-----BEGIN PGP SIGNATURE-----` → OpenPGP path, requires
+  `--pgp-pubkey PUB.asc`, calls `jpgp-pgp-verify`.
+- Otherwise treats it as a `jpgp` signature blob (`-----BEGIN JPGP
+  SIGNATURE-----` with `pk:` and `sig:` lines), extracts the bytes,
+  and calls `jpgp-ed25519-verify`.
+
+If the user passes `--pubkey FILE`, the embedded `pk:` from the blob is
+ignored in favour of the supplied `jpgp1` line — this defends against
+the trivial attack of replacing the embedded pubkey with one whose
+holder will sign anything.
+
+## The `(buf, buf_len, *out_len)` pattern
+
+Variable-length Rust output is returned through a caller-supplied
+buffer:
+
+```
+i32 jpgp_X(...inputs..., u8* out, u32 out_buf_len, u32* out_len);
+```
+
+- `*out_len` is always written (even if `out` is null).
+- If `out_len_in < needed`, returns `JPGP_E_INSUFFICIENT_BUFFER`.
+- Otherwise writes `needed` bytes and returns `JPGP_OK`.
+
+The Jerboa side (`call-with-buffer` in `pgp/ffi.ss`) tries an initial
+64 KB buffer, and if that's too small, looks at `*out_len` to retry
+with an exactly-sized buffer. No allocator state is shared.
+
+See [FFI.md](FFI.md) for the full ABI.
+
+## Where the binary comes from
+
+`support/build-binary.sh` does what Jerboa's own `build-binary.sh` does,
+except it passes *both* the project's lib directory and Jerboa's lib so
+WPO can resolve `(pgp ...)` *and* `(jerboa prelude)` / `(std ...)`. The
+output is a single static binary that bundles `petite.boot`,
+`scheme.boot`, and a whole-program-optimised `program.boot` containing
+all the project Scheme code. The native crypto lib still lives outside
+the binary and is loaded at runtime — set `JPGP_NATIVE_LIB` to override
+the search.
+
+See [BUILDING.md](BUILDING.md) for the step-by-step.
diff --git a/docs/BUILDING.md b/docs/BUILDING.md
new file mode 100644
index 0000000..071e136
--- /dev/null
+++ b/docs/BUILDING.md
@@ -0,0 +1,224 @@
+# Building
+
+There are three useful build targets:
+
+| Target               | Result                                                              |
+|----------------------|---------------------------------------------------------------------|
+| `make build-native`  | the Rust dylib only (`libjpgp_native.{dylib,so}`)                   |
+| `make run ARGS=...`  | run under the Chez interpreter (fast iteration, needs Jerboa repo)  |
+| `make binary`        | self-contained `jpg-bin` (5 MB Mach-O / ELF)                        |
+| `make install`       | `binary` + copy to `~/.local/{bin,lib,share/man/man1}`              |
+
+## Prerequisites
+
+- **Rust** (any 2021-edition stable) — built with `cargo`, no nightly.
+- **Chez Scheme** built from source — `make binary` calls into Chez's
+  C API to embed boot files. Pre-built Chez packages from Linux distros
+  often omit the headers (`scheme.h`) and static lib (`libkernel.a`)
+  that the binary build needs.
+- **Jerboa** checked out next to this repo (`~/mine/jerboa` by default,
+  or set `JERBOA_HOME`). The Jerboa stdlib (`lib/std/...`) is imported
+  by the project Scheme code.
+
+The Makefile assumes:
+```
+JERBOA_HOME ?= $(realpath $(CURDIR)/../jerboa)
+SCHEME      ?= $(JERBOA_HOME)/.chez/bin/scheme
+```
+
+Both can be overridden on the make command line:
+
+```
+make binary JERBOA_HOME=/opt/jerboa SCHEME=/opt/chez/bin/scheme
+```
+
+## Development cycle (interpreter mode)
+
+```
+make build-native
+make run ARGS='keygen --out /tmp/test.key'
+make run ARGS='version'
+make test
+make test-interop
+```
+
+`make run` invokes Chez `--script` against `pgp/main.ss` with both the
+project root and `$JERBOA_HOME/lib` on the `--libdirs` path. Sources
+are compiled on demand into `.so` files alongside the `.ss` files (Chez
+caches these), and `libjpgp_native.dylib` is dynamically loaded by
+`(pgp ffi)` on first call.
+
+This path is fast — about 100 ms cold start including library compile
+on a first invocation, then under 30 ms for subsequent runs. Good for
+development; not what you want to ship.
+
+## The binary build
+
+`make binary` runs `support/build-binary.sh` and produces a single
+~5 MB executable that bundles the Chez runtime and all project Scheme
+code. It still needs `libjpgp_native.{dylib,so}` at runtime, loaded
+from `~/.local/lib` (or the cargo target dir, or `$JPGP_NATIVE_LIB`).
+
+The script does four steps:
+
+### Step 1 — Whole-program optimisation
+
+```
+$SCHEME --libdirs "$JPGP_REPO:$JERBOA_HOME/lib" \
+        --script  "$JERBOA_HOME/support/build-boot.ss" \
+                  $ENTRY $WPO_SO $OBJ_DIR
+```
+
+Chez's `compile-whole-program` walks every `(import ...)` reachable
+from `support/binary-entry.ss`, compiles each library to a `.wpo`
+object, then merges them into a single `.wp.so` that is itself a
+complete Chez program — no further library resolution needed at runtime.
+
+The `--libdirs` argument passes **both** the project root (so `(pgp
+cli)` etc. resolve) and the Jerboa lib (so `(jerboa prelude)` and
+`(std ...)` resolve). Jerboa's own `build-binary.sh` hardcodes only its
+own lib path, which is why we ship our own wrapper rather than calling
+it directly.
+
+`binary-entry.ss` is the entry point compiled in. It strips an optional
+leading `--` from `command-line-arguments` (Chez `--script` leaves one
+behind when invoked with `-- $(ARGS)`), then calls `(run-cli args)`.
+
+### Step 2 — Embed boot files as C arrays
+
+The Chez runtime expects to load `petite.boot` and `scheme.boot` from
+disk on startup. To make the final binary self-contained, we embed
+those (plus the just-built `program.wp.so`) as `static const unsigned
+char` arrays in a generated header:
+
+```
+embed "$CSV_DIR/petite.boot" petite_boot
+embed "$CSV_DIR/scheme.boot" scheme_boot
+embed "$WPO_SO"              program_boot
+```
+
+`$CSV_DIR` is the Chez installation's `csvN.N/<machine-type>` directory
+— the script searches `/usr/local/lib`, `/opt/homebrew/lib`,
+`$JERBOA_CHEZ_PREFIX/lib`, etc. for a directory containing
+`libkernel.a`, `scheme.h`, and `petite.boot` simultaneously. For Jerboa
+users, `$JERBOA_HOME/.chez/lib/csvN.N/<machine-type>` is the first
+match.
+
+### Step 3 — Generate `main.c`
+
+A tiny C entry point:
+
+```c
+int main(int argc, const char *argv[]) {
+    Sscheme_init(NULL);
+    Sregister_boot_file_bytes("petite", petite_boot_data, petite_boot_size);
+    Sregister_boot_file_bytes("scheme", scheme_boot_data, scheme_boot_size);
+    Sbuild_heap(NULL, NULL);
+
+    const char *prog_path = write_program_tmpfile();
+    Sscheme_program(prog_path, argc, argv);
+    unlink(prog_path);
+
+    Sscheme_deinit();
+    return 0;
+}
+```
+
+The boot files are registered in-memory, the heap is built, then the
+program boot is dumped to a tmp file and handed to `Sscheme_program`.
+The tmp file is unlinked immediately after `Sscheme_program` returns
+(or after `exit`, since `unlink` doesn't have to wait for the process
+to exit on POSIX).
+
+### Step 4 — Compile and link
+
+```
+$CC -I$CSV_DIR -O2 \
+    -o $OUTPUT $OUTPUT-main.c \
+    $CSV_DIR/libkernel.a \
+    [$CSV_DIR/liblz4.a $CSV_DIR/libz.a if present] \
+    $OS_LIBS
+```
+
+`$OS_LIBS` depends on the OS:
+
+| OS       | Libraries                                  |
+|----------|--------------------------------------------|
+| Linux    | `-lm -ldl -lpthread -ltinfo` (or `-lncurses`) |
+| Darwin   | `-lm -lpthread -lncurses -liconv`          |
+| FreeBSD  | `-lm -lpthread -lncurses -L/usr/local/lib -liconv` |
+
+Compression libs (`liblz4`, `libz`) are linked only if present in
+`$CSV_DIR` — Chez uses them to compress its boot files but the runtime
+will fall back without them.
+
+The final binary is `-O2` for size/speed balance; bumping to `-Oz`
+shaves another ~200 KB but slows startup measurably.
+
+## Install layout
+
+`make install` puts:
+
+```
+~/.local/bin/jpg                          (the binary, 5 MB)
+~/.local/lib/libjpgp_native.{dylib,so}    (Rust crypto, 3 MB)
+~/.local/share/man/man1/jpg.1             (mandoc man page)
+```
+
+Shell completions are not installed automatically — copy them from
+`completions/` to wherever your distro expects them:
+
+```
+completions/jpg.bash → ~/.local/share/bash-completion/completions/jpg
+completions/_jpg     → any directory in $fpath
+```
+
+`make install-script` is an alternative target that wraps the dev
+interpreter as a shell stub (`exec $SCHEME --script $REPO/pgp/main.ss
+-- "$@"`). It's useful when you want a `jpg` on `$PATH` but you're
+iterating fast and don't want to rebuild the binary. The stub depends
+on the source tree continuing to exist at the same path.
+
+## Static / musl builds
+
+The binary build is dynamic: `libkernel.a` is the only Chez-side static
+input, but `libjpgp_native.dylib` is loaded at runtime and `$OS_LIBS`
+links against system shared libraries. For a fully static musl build
+you'd need to:
+
+- Build Chez against musl (non-trivial — Chez's bootstrapping uses gcc
+  intrinsics that musl handles, but the build scripts have Linux-glibc
+  assumptions).
+- Build `libjpgp_native` as a `staticlib` and link it directly into the
+  final binary instead of dyloading.
+- Replace `-lncurses` / `-ltinfo` with their static equivalents from
+  the musl toolchain.
+
+The Rust side is already configured as `crate-type = ["staticlib",
+"cdylib"]`, so the `.a` is built every time. This isn't wired into the
+Makefile yet — see [`pgp-native/Cargo.toml`](../pgp-native/Cargo.toml).
+
+## Reproducible builds
+
+The current build is not bit-for-bit reproducible. Embedded boot files
+are deterministic given a fixed Chez install, and the Rust release
+profile (`opt-level = "z"`, `lto = true`, `codegen-units = 1`) is
+mostly deterministic given a pinned toolchain. The variable input is
+the C compiler's use of timestamps and build-tree paths in the final
+ELF/Mach-O. Pinning `SOURCE_DATE_EPOCH` and stripping with `strip -p`
+would close the gap; not a priority for v1.
+
+## Cleaning
+
+```
+make clean
+```
+
+Removes:
+- the Rust `target/` directory (`cargo clean`)
+- compiled `.so` / `.dylib` outputs in the project tree
+- `*.wpo` files
+- `jpg-bin` and old `jpgp-bin` artifacts
+
+This does NOT remove user data (`~/.jpgp/identity.age`,
+`~/.local/...`).
diff --git a/docs/FFI.md b/docs/FFI.md
new file mode 100644
index 0000000..0863cec
--- /dev/null
+++ b/docs/FFI.md
@@ -0,0 +1,224 @@
+# FFI surface — `libjpgp_native`
+
+The Rust crate exposes a flat C ABI. Every entry point is `extern "C"`,
+returns `i32` (a `JPGP_E_*` code), and uses the `(buf, buf_len,
+*out_len)` pattern for variable-length output.
+
+The ABI is the only contract between the two halves. The Jerboa side
+(`pgp/ffi.ss`) wraps each call so callers see normal Scheme values, but
+the wire format below is what's actually crossed.
+
+## ABI version
+
+```c
+uint32_t jpgp_abi_version(void);   // currently returns 1
+```
+
+Bumped on any incompatible change to the surface below. The Jerboa side
+does not currently refuse to load on a mismatch — it's there so future
+versions can.
+
+## Error codes
+
+Defined in [`pgp-native/src/error.rs`](../pgp-native/src/error.rs).
+
+| Code | Name                          | Meaning                                            |
+|------|-------------------------------|----------------------------------------------------|
+| 0    | `JPGP_OK`                     | success                                            |
+| 1    | `JPGP_E_INTERNAL`             | a Rust panic was caught                            |
+| 2    | `JPGP_E_INVALID_INPUT`        | a pointer was null when it shouldn't be, or a length was wrong |
+| 3    | `JPGP_E_INSUFFICIENT_BUFFER`  | caller's buffer too small; `*out_len` is the needed size |
+| 4    | `JPGP_E_KEYGEN`               | keygen RNG failure (vanishingly rare)              |
+| 5    | `JPGP_E_ENCRYPT`              | underlying encrypt failed                          |
+| 6    | `JPGP_E_DECRYPT`              | underlying decrypt failed (bad ciphertext, wrong key) |
+| 7    | `JPGP_E_BAD_PASSPHRASE`       | scrypt unwrap with wrong passphrase                |
+| 8    | `JPGP_E_VERIFY`               | a signature check returned false                   |
+| 9    | `JPGP_E_PARSE_KEY`            | a key blob (age or armored OpenPGP) wouldn't parse |
+| 10   | `JPGP_E_NO_RECIPIENT`         | recipient list was empty or contained nothing usable |
+| 11   | `JPGP_E_PGP`                  | rPGP returned an error during composition          |
+
+These constants are *stable*. Adding new codes is fine; renumbering
+existing ones is not — the Jerboa side compares against the integer
+values in `pgp/ffi.ss`.
+
+## The buffer-output convention
+
+For every call that returns variable-length data:
+
+```c
+int32_t jpgp_X(
+    /* inputs */ ...,
+    uint8_t *out,
+    uint32_t out_buf_len,
+    uint32_t *out_len
+);
+```
+
+Rules (implemented in `pgp-native/src/util.rs::write_out`):
+
+1. `*out_len` is always written, even when `out` is null.
+2. If `out` is null, returns `JPGP_OK` after writing `*out_len`
+   (size-query path).
+3. If `out_buf_len < *out_len`, returns `JPGP_E_INSUFFICIENT_BUFFER`.
+4. Otherwise copies `*out_len` bytes into `out` and returns `JPGP_OK`.
+
+The Jerboa side starts with a 64 KB buffer
+([`pgp/ffi.ss::initial-out-size`](../pgp/ffi.ss)) and retries with the
+exactly-needed size if the first call returns
+`JPGP_E_INSUFFICIENT_BUFFER`. No allocator state crosses the boundary.
+
+## The panic guard
+
+Every function wraps its body in `catch_unwind`:
+
+```rust
+fn guard<F: FnOnce() -> i32>(f: F) -> i32 {
+    match catch_unwind(AssertUnwindSafe(f)) {
+        Ok(code) => code,
+        Err(_)   => JPGP_E_INTERNAL,
+    }
+}
+```
+
+Combined with `panic = "abort"` in the release profile, panic paths are
+effectively unreachable. The guard exists so even debug builds can't
+unwind across `extern "C"` (UB).
+
+## Functions
+
+Signatures below are reproduced from `pgp-native/src/lib.rs`. Pointer
+parameters annotated `*const T` are read-only; `*mut T` are writable
+for the advertised length. Slices may be `(NULL, 0)` for empty inputs.
+
+### age — keygen, encrypt, decrypt
+
+```c
+int32_t jpgp_age_keygen(
+    uint8_t *sec_buf, uint32_t sec_buf_len, uint32_t *sec_out_len,
+    uint8_t *pub_buf, uint32_t pub_buf_len, uint32_t *pub_out_len);
+```
+
+Generates a fresh X25519 identity. Writes the secret string
+(`AGE-SECRET-KEY-1…`) to `sec_buf` and the public string (`age1…`) to
+`pub_buf`. Both follow the buffer-output rules.
+
+```c
+int32_t jpgp_age_encrypt(
+    const uint8_t *plain, uint32_t plain_len,
+    const char    *recipients_cstr,     // newline-separated age1...
+    uint8_t       *out, uint32_t out_buf_len, uint32_t *out_len);
+
+int32_t jpgp_age_decrypt(
+    const uint8_t *cipher, uint32_t cipher_len,
+    const char    *identity_cstr,       // "AGE-SECRET-KEY-1..."
+    uint8_t       *out, uint32_t out_buf_len, uint32_t *out_len);
+```
+
+`recipients_cstr` is parsed line-by-line; blank lines and `#`-comment
+lines are skipped. Output is ASCII-armored age.
+
+### Passphrase wrap/unwrap (used for the identity file)
+
+```c
+int32_t jpgp_pass_encrypt(
+    const uint8_t *plain, uint32_t plain_len,
+    const char    *passphrase_cstr,
+    uint8_t       *out, uint32_t out_buf_len, uint32_t *out_len);
+
+int32_t jpgp_pass_decrypt(
+    const uint8_t *cipher, uint32_t cipher_len,
+    const char    *passphrase_cstr,
+    uint8_t       *out, uint32_t out_buf_len, uint32_t *out_len);
+```
+
+These wrap age's `scrypt` recipient mode. `_decrypt` returns
+`JPGP_E_BAD_PASSPHRASE` on the wrong passphrase.
+
+### Ed25519
+
+```c
+int32_t jpgp_ed25519_keygen(uint8_t *out_sk /*32*/, uint8_t *out_pk /*32*/);
+
+int32_t jpgp_ed25519_sign(
+    const uint8_t *sk /*32*/,
+    const uint8_t *msg, uint32_t msg_len,
+    uint8_t       *out_sig /*64*/);
+
+int32_t jpgp_ed25519_verify(
+    const uint8_t *pk /*32*/,
+    const uint8_t *msg, uint32_t msg_len,
+    const uint8_t *sig /*64*/);
+```
+
+`verify` returns `JPGP_OK` on a good signature, `JPGP_E_VERIFY` on a
+bad one. All buffer sizes are fixed; there is no size-query path.
+
+### OpenPGP
+
+```c
+int32_t jpgp_pgp_encrypt(
+    const char    *pubkey_armor_cstr,        // -----BEGIN PGP PUBLIC KEY BLOCK-----
+    const uint8_t *plain, uint32_t plain_len,
+    uint8_t       *out, uint32_t out_buf_len, uint32_t *out_len);
+
+int32_t jpgp_pgp_decrypt(
+    const char    *secret_armor_cstr,        // -----BEGIN PGP PRIVATE KEY BLOCK-----
+    const char    *passphrase_cstr,          // "" if key is unencrypted
+    const uint8_t *cipher, uint32_t cipher_len,
+    uint8_t       *out, uint32_t out_buf_len, uint32_t *out_len);
+
+int32_t jpgp_pgp_sign(
+    const char    *secret_armor_cstr,
+    const char    *passphrase_cstr,
+    const uint8_t *msg, uint32_t msg_len,
+    uint8_t       *out, uint32_t out_buf_len, uint32_t *out_len);
+
+int32_t jpgp_pgp_verify(
+    const char    *pubkey_armor_cstr,
+    const char    *sig_armor_cstr,
+    const uint8_t *msg, uint32_t msg_len);
+```
+
+`pgp_encrypt` uses SEIPDv1 with AES-256, picking the first encryption
+subkey or falling back to the primary key. `pgp_sign` produces a V4
+detached signature with SHA-256 and an `IssuerFingerprint` subpacket.
+`pgp_verify` tries the primary then every subkey, returning `JPGP_OK`
+on any match.
+
+The full rationale for SEIPDv1 (vs OCB) and the gpg-side
+gymnastics is in [INTEROP.md](INTEROP.md).
+
+### SHA-256
+
+```c
+int32_t jpgp_sha256(
+    const uint8_t *input, uint32_t input_len,
+    uint8_t       *out /*32*/);
+```
+
+Used for `jpg fingerprint`. We expose this rather than re-implementing
+SHA-256 in Scheme because (a) the `sha2` crate is already in the
+dependency graph for rPGP, and (b) we already trust the FFI surface for
+much more sensitive primitives.
+
+## Lifetimes and aliasing
+
+No FFI call retains a pointer past its return. Inputs are consumed
+synchronously; outputs are written into the caller's buffer. There is
+no callback or async path.
+
+## Loading the library
+
+`pgp/ffi.ss` tries paths in this order, falling through to the next on
+any error:
+
+1. `$JPGP_NATIVE_LIB` (if set)
+2. `$JPGP_DIR/pgp-native/target/release/libjpgp_native.{dylib,so}`
+3. `$JPGP_DIR/pgp-native/target/debug/...`
+4. `/usr/local/lib/...`
+5. `/opt/homebrew/lib/...`
+6. `$HOME/.local/lib/...`
+7. plain `libjpgp_native.{dylib,so}` (system loader)
+
+`(jpgp-available?)` returns `#f` if all paths failed, and every wrapper
+calls `need-lib` first to give a clean error.
diff --git a/docs/FORMATS.md b/docs/FORMATS.md
new file mode 100644
index 0000000..fd32da8
--- /dev/null
+++ b/docs/FORMATS.md
@@ -0,0 +1,185 @@
+# On-disk formats
+
+`jerboa-pgp` deals with five distinct artifacts:
+
+1. The identity file
+2. The `jpgp1` public-key line
+3. age-armored ciphertext (native encryption)
+4. `JPGP` signature blobs (native signatures)
+5. OpenPGP armor (interop with gpg)
+
+This document specifies each format and points at the code that
+produces or consumes it.
+
+## 1. Identity file — `~/.jpgp/identity.age`
+
+A passphrase-protected age blob. The outer layer is standard age armor
+with a `scrypt` recipient (no recipient key bytes, just KDF
+parameters):
+
+```
+-----BEGIN AGE ENCRYPTED FILE-----
+YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNjcnlwdCBlNHpEbnNlNExtN1RyZGdL
+...
+-----END AGE ENCRYPTED FILE-----
+```
+
+Decrypted, the plaintext is a small text record:
+
+```
+jpgp-identity v1
+age: AGE-SECRET-KEY-1QQPYHL45Z3M9LRMV43LXMW9F8L65EXPK0XNVTU2LJV30Q66NXFHSNAFV4R
+age-pub: age1ydnymp4uvyfu46c3yph46m4qvfh4d8a8ssvw48tdpwwa2q72txuqz0fc8h
+ed25519-sk: dGVzdC1zZWNyZXQta2V5LWJ5dGVzLTMyLWNoYXJzLWxvbmcyMzM=
+ed25519-pk: dGVzdC1wdWJsaWMta2V5LWJ5dGVzLTMyLWNoYXJzLWxvbmcyMjI=
+```
+
+Field semantics:
+
+| Field         | Required | Encoding                                            |
+|---------------|----------|-----------------------------------------------------|
+| `jpgp-identity vN` | yes | first line, version stamp (currently `v1`)      |
+| `age:`        | yes      | age secret key (`AGE-SECRET-KEY-1…`, bech32)        |
+| `age-pub:`    | no       | age public key (`age1…`, bech32). Optional only because v0 files predate the field; new files always include it. |
+| `ed25519-sk:` | yes      | base64 of the 32-byte Ed25519 secret seed           |
+| `ed25519-pk:` | yes      | base64 of the 32-byte Ed25519 public key            |
+
+`#`-comments and unknown lines are ignored, so the format is
+forward-compatible — adding a future `cv25519:` (or whatever) doesn't
+break old `jpg` builds.
+
+The file is created `chmod 600` (best-effort via `system "chmod"`); it
+should not be group- or world-readable.
+
+Code: `pgp/identity.ss` (`save-identity` / `load-identity` /
+`parse-identity-text`).
+
+## 2. Public-key line — `jpgp1 ...`
+
+A single line, designed to paste into Slack, email, or
+`authorized_keys`-style file the way an SSH key would:
+
+```
+jpgp1 age=age1ydnymp4uvyfu46c3yph46m4qvfh4d8a8ssvw48tdpwwa2q72txuqz0fc8h ed25519=dGVzdC1wdWJsaWMta2V5LWJ5dGVzLTMyLWNoYXJzLWxvbmcyMjI=
+```
+
+Grammar:
+
+```
+LINE   := "jpgp1" SP (FIELD SP)* FIELD NL?
+FIELD  := KEY "=" VALUE
+KEY    := identifier (a-z, 0-9, '-')
+VALUE  := no SP, no '='
+```
+
+Fields:
+
+| Key       | Value                                                                            |
+|-----------|----------------------------------------------------------------------------------|
+| `age`     | age public key (`age1…`, bech32)                                                 |
+| `ed25519` | base64 of the 32-byte Ed25519 verifying key                                      |
+
+Lines containing keys the reader doesn't recognise are accepted (the
+unknown fields are kept in the parsed alist but unused). This means
+future fields like `cv25519=` can be added without bumping `jpgp1`.
+
+A bare `age1…` recipient on its own is also accepted as a recipient
+input — `jpg encrypt -r age1…` works without wrapping it in `jpgp1`.
+The classification happens in `(pgp armor)`:
+
+```scheme
+(blob-kind "age1...")               → 'age-recipient
+(blob-kind "jpgp1 age=… ed25519=…") → 'jpgp-pubkey
+(blob-kind "-----BEGIN PGP ...")    → 'pgp-pubkey or 'pgp-cipher
+(blob-kind "-----BEGIN AGE ...")    → 'age-cipher