# DEV-B Architecture Review Notes — Rust Consumers (CLI, Server, WASM)

**Date:** 2026-05-04
**Scope:** `crates/relicario-cli/`, `crates/relicario-server/`, `crates/relicario-wasm/`
**Out of scope:** `relicario-core` internals (DEV-A), `extension/` and `tools/relay/` (DEV-C)
**Method:** read-only walk of every src + test file; `cargo check` and `cargo clippy` per crate; `cargo build --target wasm32-unknown-unknown` for the WASM crate.

## Summary

The consumer layer is in good shape conceptually but uneven in execution. **`relicario-server` is the highlight**: 189 lines, one obvious responsibility, every dependency on `relicario-core` is plaintext device metadata only — the "server only ever sees ciphertext" invariant is structurally enforced by the import surface, not just by convention. **`relicario-wasm` is small and clean but has one real Rust-side defect**: `SessionHandle` lacks an `impl Drop`, so when wasm-bindgen's auto-generated `.free()` runs, the master key stays in WASM linear memory until `lock()` is also called explicitly — defense-in-depth that is currently missing. **`relicario-cli` does its job correctly but is hard to read**: `src/main.rs` is a 2641-line file with no submodule boundaries between the clap surface, item builders, edit handlers, prompt helpers, and parsers — the single biggest readability blocker in the consumer layer. Across all three crates, naming and module structure are good; what hurts is duplicated boilerplate and the absence of a few obvious helpers.

---

## Findings

### relicario-cli

#### P1 — `main.rs` is a 2641-line monolith with no submodule boundaries
**File(s):** `crates/relicario-cli/src/main.rs:1-2641`
**Observation:** every subcommand handler, every per-type item builder, every per-type edit handler, prompt helpers, parsing helpers, the `ParamsFile` shape, the clap surface, and 24+ git shell-outs all live in one file. The clap surface (lines 1-455) reads as a tour of the product and is excellent; lines 456-2641 are a flat sequence of `cmd_*`, `build_*_item`, `edit_*`, prompt helpers, and parse helpers, all peers. A newcomer searching "where does add work?" finds `cmd_add` calling 7 different `build_*_item` functions (each ~50-60 lines) with no module boundaries.
**Why it matters:** this is the first file a newcomer opens. Today they have to scroll, not navigate.
**Suggested direction:** keep `main.rs` as clap definitions + `match` dispatcher only (~470 lines). Split into `commands/{add,get,list,edit,trash,backup,import,attach,settings,sync,status,device,recovery_qr}.rs`, plus `prompt.rs` (the six `prompt_*` helpers + `prompt_secret`) and `parse.rs` (`parse_month_year`, `base32_decode_lenient`, `guess_mime`). Same LOC, completely different reading experience.

#### P1 — Git invocation boilerplate duplicated ~16× with one-line errors
**File(s):** `crates/relicario-cli/src/main.rs:601, 602, 610, 986, 988, 1477, 1480, 1767, 1897, 1900, 2432, 2438, 2533, 2540` (and others)
**Observation:** every `git_command` invocation that bails uses the same shape: `git_command(repo).args([...]).status()? + if !status.success() { bail!("git foo failed") }`. Child stderr is inherited to the parent tty (which helps interactively) but in test runs and noninteractive tooling it is lost, and the bail message is just the verb (`"git commit failed"`). When this fires in the wild — pre-receive reject, missing remote, dirty tree, signing-key prompt — the user sees one line of "$verb failed" and nothing else.
**Why it matters:** this is the entire error UX for the git side of the CLI. Failure modes are common; the diagnostic is actively unhelpful.
**Suggested direction:** add `helpers::git_run(repo, args, context)` that uses `.output()` (capturing stderr), prints captured stderr unmodified on failure, and includes the human-readable `context` ("commit add: GitHub", "register device", "purge trashed item"). Replaces 16 copies with one-liners.

#### P2 — `build_*_item` functions mix prompting, parsing, and core construction
**File(s):** `crates/relicario-cli/src/main.rs:664-921`
Each `build_*` does its own prompt-or-flag fallback (`title.map(Ok).unwrap_or_else(|| prompt("Title"))?`), parses domain values (URL, MonthYear, base32 TOTP), then constructs an `ItemCore`. Adding a new item type is currently 50-80 lines of mechanical code. A `prompt_or_flag<T>` helper plus per-type builders that take already-validated values would compress this materially.

#### P2 — Pure parsers belong in core, not the CLI
**File(s):** `crates/relicario-cli/src/main.rs:942-980`
`base32_decode_lenient` and `parse_month_year` are pure parsing producing typed core values. Per the user's CLI/extension parity philosophy, these need to be reachable from WASM too — the extension will want them when it gets QR-import / month-year smart input. `MonthYear::parse` and an `ItemCore::parse_totp_seed` (or `Totp::parse_secret`) on the core side would avoid future duplication.

#### P2 — `refresh_groups_cache` invocation discipline is manual at 7 sites
**File(s):** `crates/relicario-cli/src/main.rs:641, 998, 1123, 1197, 1414, 1432, 1474` (and `helpers.rs:90-93` for the doc comment)
The plaintext `groups.cache` is updated by-hand after every manifest mutation. The "failures are silently swallowed" rationale is documented at `main.rs:462`, but the rule "every mutating handler must call this" is not enforced — easy to forget on a new command. Either invoke from `Vault::save_manifest` (couples session to cache layout — maybe wrong) or wrap in `Vault::after_manifest_change(&self, manifest: &Manifest)`.

#### P2 — `ParamsFile` is defined twice with mismatching shapes
**File(s):** `crates/relicario-cli/src/main.rs:2287` (write) vs `crates/relicario-cli/src/session.rs:114` (read)
The write side has `aead`, `salt_path`, `format_version`; the read side takes only `kdf`. Two definitions of the on-disk `params.json` shape, in different files, with overlapping but non-equal fields. A single `Params` struct in `relicario-core` (or in `session.rs`) used by both readers and writers would eliminate the drift risk.

#### P2 — `cmd_purge` and `cmd_trash_empty` duplicate the manifest-add-and-commit dance
**File(s):** `crates/relicario-cli/src/main.rs:1476-1480, 1896-1900`
Same three lines, same error strings, same git rm + git add + git commit per item. `cmd_trash_empty` invokes `purge_item` per item, each of which is its own three-git-invocation loop. Batching the staging would reduce a 50-item purge from 150 git invocations to 3.

#### P3 — Selection of the `let _ = entry;` pattern, repeated 6×
**File(s):** `crates/relicario-cli/src/main.rs:1170, 1407, 1426, 1469, 1913, 2030`
Drop-the-borrow-before-reborrow-mutably is a Rust newcomer's worst surprise. A NLL-friendly refactor (clone `id` and `title` eagerly, let the borrow end naturally) would make these lines disappear.

#### P3 — Other nits
- `cmd_recovery_qr_unwrap` does not check for empty input before base64 decode (`main.rs:2625-2630`)
- `cmd_recovery_qr_generate` mixes the QR ASCII and a "NOT been saved to disk" message both on stdout — pipe-unfriendly (`main.rs:2612-2614`)
- `Lock` subcommand is a no-op but visible in `--help`; either `#[command(hide = true)]` or accept the parity-with-extension argument and document why (`main.rs:445`, doc-comment at `:166`)
- `tests/attachments.rs:69-76` has a dead variable (`blob_path`) kept "to avoid an unused warning" — delete
- Three test-only env vars (`RELICARIO_TEST_PASSPHRASE`, `RELICARIO_TEST_ITEM_SECRET`, `RELICARIO_TEST_BACKUP_PASSPHRASE`) each duplicated under `#[cfg(debug_assertions)]/#[cfg(not(debug_assertions))]` — one macro would flatten ~30 lines
- `cmd_backup_export` still reads `devices.json` (with `[]` fallback) per a "Task 12 will remove" TODO at `:1535-1537`. If Task 12 has shipped, this code can simplify
- `format!("{:?}", e.r#type)` for the TYPE column at `main.rs:1158` — Debug-format for user-facing output. Add `Display` to `ItemType` in core
- `helpers.rs:37 #[allow(dead_code)] pub fn relicario_dir()` — helper has no callers but several `vault_dir.join(".relicario")` sites in main.rs should be using it
- `device.rs:94, 103, 120` and `gitea.rs:24, 26, 47, 77, 94, 101` have `#[allow(dead_code)]` markers without explanation. Either wire up or add `// TODO(<plan>):` so a newcomer knows whether they're scaffolding or scar tissue
- `gitea.rs` constructs a fresh `reqwest::blocking::Client::new()` per method call (3 sites) — make it a struct field
- `tests/edit_and_history.rs` writes hardcoded prompt sequences (`["", "", "", "", "", "y"]`) blindly; if main.rs reorders prompts, tests hang silently. A scripted-test layer (`expect_prompt("Title"); respond("");`) would survive refactors

---

### relicario-server

#### Trust-model assessment (the headline question)
**The server respects the "ciphertext only" invariant. Confirmed.** The crate's entire `relicario-core` import surface is `DeviceEntry`, `RevokedEntry`, `fingerprint` (and `generate_keypair` in tests) — every one of those is plaintext-only device metadata. There is no import of `vault`, `crypto`, `imgsecret`, `item`, `manifest`, or `settings`. A grep over `crates/relicario-server/` for `decrypt|wrapped|encrypted|vault::` returns nothing. The Cargo.toml dep surface (`anyhow`, `clap`, `serde_json`, `tempfile`, `regex`) confirms it: there is no AEAD or KDF crate present. The server cannot decrypt the vault even in principle — it never reads the passphrase, the reference image, the salt, or the params.

The two on-disk files the server reads from the commit tree are `.relicario/devices.json` and `.relicario/revoked.json` (`main.rs:38, 48`), both plaintext metadata. All other operations are `git` subprocesses (`git show`, `git verify-commit --raw`, `git show -s --format=%ct`) plus local fingerprint computation. `generate-hook` emits a pure shell script that re-invokes `relicario-server verify-commit` per commit; it embeds no secret material. **No P1 findings.**

#### P2 — `generate-hook` assumes `$PATH`
**File(s):** `crates/relicario-server/src/main.rs:170`
The emitted script calls `relicario-server verify-commit "$commit"` with no path. Most Gitea deployments do not put `/usr/local/bin` on the hook process's `$PATH`, so this will fail with "command not found" or silently no-op. Either embed `std::env::current_exe()` at hook-generation time, or document an explicit `PATH=` line in the emitted script. There is no test that the generated hook actually executes.

#### P2 — Bootstrap branch is permissive
**File(s):** `crates/relicario-server/src/main.rs:38-44, 54-57`
A missing `.relicario/devices.json` at `newrev` is treated as bootstrap and accepted. Combined with "devices empty AND revoked empty → OK", an attacker who pushes a brand-new branch with no `.relicario/` directory could push arbitrary unsigned commits. There's no test for "second push to an established repo where devices.json was stripped." Worth either documenting the rule (first push wins; once devices.json exists in history, it can never be removed) or enforcing it: `oldrev != 0` should imply `devices.json` exists in `newrev`.

#### P2 — stdin-parsing lives in the shell hook only; binary alone is not hook-shaped
**File(s):** `crates/relicario-server/src/main.rs:130-189` (`generate_hook`)
The Rust binary's `verify-commit` takes a single SHA argument; the per-line `<old> <new> <ref>` parsing is delegated to bash in `generate_hook`. Defensible split, but means anyone wiring up a hook by hand cannot use the binary alone. A `verify-commit --from-stdin` mode (or at least a doc comment on `VerifyCommit` calling this out) would help.

#### P2 — Test coverage gaps vs. trust-model
**File(s):** `crates/relicario-server/tests/verify_commit.rs`
Tests cover: accepted, unregistered → reject, revoked-after → reject, revoked-before → accept. Missing: unsigned commit (no signature at all), malformed `devices.json` (parse error path on `:46`), bootstrap-empty-devices acceptance (`:54`), and one of the two stripped-`.relicario/` cases. Each is one extra `#[test]`.

#### P3 — Server nits
- Regex compiled per call at `main.rs:85` — use `LazyLock` or `once_cell::sync::Lazy`; the `expect("static regex")` comment hints the author knew
- The malformed-devices.json path at `:46` returns an `anyhow` chain on stderr without the consistent `REJECT: ...` prefix the rest of the file uses — ops parsing logs for `REJECT:` will miss it
- No `--version` flag exposed in clap (small ops courtesy)
- `generate-hook` doesn't tell users to `chmod +x` the result (one-line comment header in the emitted script would help)

---

### relicario-wasm

#### P1 — `SessionHandle` has no `impl Drop`; master key leaks on JS GC / `.free()`
**File(s):** `crates/relicario-wasm/src/lib.rs:15-23` and `crates/relicario-wasm/src/session.rs:1-58`
**Observation:** `SessionHandle` is `pub struct SessionHandle(u32)` with no `Drop` impl. wasm-bindgen auto-generates a JS-side `.free()` that, on the Rust side, drops the `SessionHandle` wrapper — i.e. drops a `u32`, a no-op. The `SESSIONS` HashMap entry stays alive **with the master key + image_secret still in WASM linear memory** until JS calls the explicit `lock(handle)` function (which calls `session::remove`). I confirmed via `grep -n "impl Drop" crates/relicario-wasm/src/*.rs` — empty.
**Why it matters:** every `.free()` callsite that does not also call `lock()` first is a key-residency window of unbounded duration. wasm-bindgen does **not** auto-call `free()` on JS GC, but JS code that does call `.free()` reasonably expects the Rust side to clean up. The current contract requires JS to call `lock()` *and then* `free()`, which is asymmetric and easy to get wrong on the JS side (see boundary notes for DEV-C).
**Suggested direction:** add
```rust
impl Drop for SessionHandle {
    fn drop(&mut self) { session::remove(self.0); }
}
```
to `lib.rs`. `lock()` becomes the explicit "I am done now" call; `.free()` (auto or manual on the JS side) is the safety net. Defense in depth — the cost is one impl block. Worth a `wasm-bindgen-test` covering construct → drop → confirm registry empty.

#### P2 — Redundant `need_key` + `with(...).unwrap()` double-lookup
**File(s):** `crates/relicario-wasm/src/lib.rs:73, 84, 92, 103, 111, 122, 160, 172`
Every per-call op does `need_key(handle)?` and then `session::with(handle.0, |k| ...).unwrap()`. Two HashMap lookups per call, with the second `.unwrap()` justified only because the first proved the key existed. Single-threaded WASM makes this safe today, but if anyone ever introduces a reentrant path (`Serializer` callback that calls back into WASM), the assumption breaks. Refactor to a single `session::with(...).ok_or_else(|| JsError::new("invalid or locked session handle"))?` helper.

#### P2 — `Vec<u8>` getters clone on every read
**File(s):** `crates/relicario-wasm/src/lib.rs:141-150` (`EncryptedAttachment::aid` and `bytes`)
Each call clones the whole field. JS can call `enc.bytes` repeatedly without realising. For attachment payloads (potentially MB-sized), that's a real cost. Either consume by value or document "call once, cache locally."

#### P2 — Naming: `wasm_*_recovery_qr` prefix is inconsistent with everything else
**File(s):** `crates/relicario-wasm/src/lib.rs:497, 510`
`wasm_generate_recovery_qr` and `wasm_unwrap_recovery_qr` are the only exports with the `wasm_` prefix. Rename to `generate_recovery_qr_svg` and `unwrap_recovery_qr` (and update `extension/src/wasm.d.ts` accordingly). Trivial breaking rename, do it before any new caller appears.

#### P2 — `device.rs` and `session.rs` use different concurrency primitives
**File(s):** `crates/relicario-wasm/src/device.rs` (`Lazy<Mutex<...>>`) vs `crates/relicario-wasm/src/session.rs:14-17` (`thread_local! { RefCell<HashMap<...>> }`)
Both work in single-threaded WASM. The inconsistency hurts readability — pick one pattern. `thread_local! + RefCell` is fine and avoids `Mutex` overhead; `Mutex` over `Lazy` is closer to typical Rust idioms. Either is defensible; consistency is the win.

#### P3 — WASM nits
- All `#[wasm_bindgen]` exports use snake_case (`manifest_encrypt`, `parse_lastpass_csv_json`); JS idiom is camelCase. The `wasm.d.ts` mirrors snake_case verbatim, so it's consistent — but if DEV-C ever wants idiomatic JS naming, `#[wasm_bindgen(js_name = "manifestEncrypt")]` is the path. Decide once, cite the decision in the module doc
- `lib.rs:50` comment "Subsequent wasm_bindgen fns added in Tasks 19-21" is stale historical scaffolding; remove
- `device.rs:18 #[allow(dead_code)] deploy_private` — wire it or remove it
- `session.rs:24 if *n == 0 { *n = 1; }` runs on every insert; logically only matters after wraparound — move into the `wrapping_add` returned-zero branch
- `session_tests` mod inside `lib.rs:522-591` covers session + lastpass; rename or split
- `SessionHandle` doc-comment says "opaque to JS" but `value()` getter (`:21-22`) makes the u32 visible — align the comment with the API
- `pack_backup_json` does six `b64.decode().map_err(...)` blocks (`lib.rs:387-410`) — a small `b64_decode(s) -> Result<Vec<u8>, JsError>` helper would compress ~20 lines
- `get_field_history` walks sections and constructs `serde_json::json!` manually rather than serializing a typed struct (`lib.rs:262-295`); the `_ => String::new()` fallback at `:277` silently swallows future `FieldValue` variants. Use exhaustive match

---

### Cross-cutting (all three crates)

1. **Pure parsers / formatters belong in core.** `parse_month_year`, `base32_decode_lenient`, `guess_mime` (CLI), and `get_field_history`'s walk (WASM) are all examples of logic that today lives in a consumer crate but logically belongs in `relicario-core` so all consumers share it. The CLI/extension parity philosophy makes this concrete: anything the CLI does in pure logic, the extension will eventually need too.

2. **Error formatting is inconsistent.** CLI uses `anyhow::bail!` with a mix of sentence-case ("Settings updated.") and lowercase fragments ("git commit failed"). Server is uniformly `eprintln!("REJECT: ...")` *except* for the malformed-devices.json path that surfaces an anyhow chain. WASM uses `JsError::new(&e.to_string())` mostly, but the messages range from "salt must be exactly 32 bytes" to whatever `RelicarioError::Display` produces. A short style note ("user-facing errors lead with sentence-case context, internal errors use lowercase") plus an audit pass would unify these.

3. **`#[allow(dead_code)]` without explanation appears in cli/device.rs, cli/gitea.rs, and wasm/device.rs.** Each one is either "API completeness for a feature that hasn't shipped" or "scar tissue from a refactor." A newcomer cannot tell which. Either delete or annotate with `// TODO(<plan>):`.

4. **Layering is correct.** None of the three consumer crates reaches past `relicario-core`'s public API. The CLI doesn't import from server or wasm; server doesn't import from CLI or wasm; wasm doesn't import from CLI or server. The only shared concept (device entries, fingerprints) is correctly a core export. ¡Chido! [cool!]

5. **Workspace `Cargo.toml` working-tree changes are cosmetic** (version bumps `0.2.0 → 0.5.0` on cli/core/wasm). No structural concern. Worth committing or reverting before the next merge to keep `git status` quiet.

---

## File-by-file walk

### relicario-cli

- **`Cargo.toml`** — 18 runtime + 4 dev deps, all reasonable. `arboard` (clipboard), `rqrr` (QR decode), `qrcode` (QR encode), `image`, `rpassword`, `dirs`, `reqwest` blocking + JSON for Gitea, `data-encoding`, `tar`. Platform concerns ferried correctly away from core.
- **`src/main.rs`** — entrypoint, dispatcher, **and** every command handler, item builder, edit handler, prompt helper, parse helper. 2641 lines, 71 functions. Clap surface (lines 1-455) is itself a tour of the product and is excellent. Past line 456 the file becomes flat; see P1 #1.
- **`src/helpers.rs`** — the cleanest file. ~239 lines: `vault_dir`, `git_command` (with `core.hooksPath=/dev/null`, `commit.gpgsign=false`, `core.editor=true` hardening — newcomers should read the comment at `:42-45`), `iso8601`, `humanize_age`, `groups_cache_path`, `sanitize_for_commit`, `decode_totp_qr`. Has its own `#[cfg(test)] mod tests` covering `humanize_age`, `sanitize_for_commit`, `iso8601`. `find_vault_dir_from` walks parents. Plaintext `groups.cache` is a deliberate trade-off and the doc-comment at `:90-93` is admirably explicit.
- **`src/session.rs`** — short and clear (~151 lines). `UnlockedVault { root, master_key: Zeroizing<[u8; 32]> }` plus `load_*`/`save_*` for Item, Manifest, VaultSettings. `unlock_interactive()` does passphrase prompt → image extract → KDF. `key()` accessor returns `&Zeroizing<...>` used at 4 call sites; consider passing through `encrypt_attachment`/`decrypt_attachment` methods so the key never leaves this module. `read_params` defines an inner `ParamsFile` struct that mismatches the writer in `main.rs:2287` — see P2.
- **`src/device.rs`** — ~169 lines. ed25519 keypair storage under `~/.config/relicario/devices/<name>/`. `configure_git_signing` runs four `git config` calls. Three `#[allow(dead_code)]` items (`load_signing_key`, `load_deploy_key`, `delete_device_keys`) — API completeness without callers.
- **`src/gitea.rs`** — ~117 lines. Plain blocking reqwest client. `create_deploy_key` parses the JSON response into `DeployKey { id, title, key }` (latter two `#[allow(dead_code)]`). `delete_deploy_key` treats 404 as success — sensible. Each method allocates a fresh `reqwest::blocking::Client::new()`.
- **`tests/basic_flows.rs`** — 137 lines. Tests at the right level: spawn binary via `assert_cmd`, assert on stdout/stderr/exit. `init_creates_expected_layout`, `add_login_then_list_shows_it`, `get_masks_by_default_shows_with_flag`, `rm_restore_purge_cycle`, `generate_random_and_bip39`. Solid CLI-surface coverage.
- **`tests/edit_and_history.rs`** — 191 lines. Most subtle test, interactive `edit` flow. `run_edit_with_pw_change` and `run_edit_totp` write hardcoded prompt sequences (`["", "", "", "", "", "y"]`) to stdin — fragile to prompt reordering. See P3.
- **`tests/attachments.rs`** — 106 lines. Round-trip attach → extract → detach. Has a dead variable kept "to avoid an unused warning" (P3). `attach_rejects_over_cap` exercises only the per-attachment cap, not per-item or per-vault — coverage gap.
- **`tests/settings.rs`** — 158 lines. `settings_roundtrip_trash_retention`, conflicting-flags rejection, generator-defaults end-to-end, `status` command coverage including `last_backup` round-trip. Solid.
- **`tests/backup.rs`** — 142 lines. Export/restore round-trip, `--include-image`, `--no-history`, refusal of non-empty target, wrong-passphrase failure. Excellent coverage.
- **`tests/import_lastpass.rs`** — 127 lines. Importer integration: success, single-commit guarantee, zero-items rejection, header validation, duplicate-import-IDs-are-unique invariant.
- **`tests/smart_inputs.rs`** — 210 lines. Completion-script smoke tests, groups-cache write/suppress, `rate` command (strong/weak/`-` stdin), `--totp-qr` via in-process synthesized QR PNG (`make_test_qr`) — adheres to "synthetic fixtures, no binary blobs."
- **`tests/vault_detection.rs`** — 59 lines. `list_refuses_without_vault_marker`, `get_finds_vault_in_parent_dir`, `v1_vault_is_rejected_with_clear_error` (`.idfoto/` ignored because lookup is for `.relicario/`).
- **`tests/common/mod.rs`** — 132 lines. `TestVault` harness; `init()` creates a `TempDir`, generates synthetic JPEG via `make_test_jpeg`, runs binary with `RELICARIO_TEST_PASSPHRASE` set. `run`, `run_with_input`, `run_with_backup_pass` variants. No shared global state — parallel-test safe.

### relicario-server

- **`src/main.rs`** — 189 lines, very legible. Two clap subcommands cleanly mapped. `verify_commit` reads devices.json + revoked.json from the commit's tree (`git show`), spawns `git verify-commit --raw` with a dynamically-built allowed-signers file injected via `GIT_CONFIG_*` env vars (a clever touch — chido [cool] — avoids touching user gitconfig), parses the SHA-256 fingerprint from stderr, then enforces revocation-first then registration logic. Uses committer timestamp (not author) for revocation cutoff (`:115-123`) — correct for non-rebased histories. All rejection paths use `eprintln!("REJECT: ...")` with actionable context (commit SHA, fingerprint, reason) and exit 1 — visible to the pushing client via `git push` stderr. `generate_hook` emits a clean bash script handling branch creation (`oldrev = 000...`) and branch deletion (`newrev = 000...`).
- **`tests/verify_commit.rs`** — 230 lines, four named scenarios mapping to audit S1. Each test stands up a tempdir git repo, generates real ed25519 keypairs via `relicario_core::device::generate_keypair`, signs a commit with explicit committer date, and shells out to the cargo-built binary via `assert_cmd`. Coverage gaps noted in P2.
- **`Cargo.toml`** — minimal, no surprises. Importantly: no AEAD or KDF crate, which structurally guarantees the server cannot decrypt.

### relicario-wasm

- **`Cargo.toml`** — 27 lines. Right deps. `getrandom = { features = ["js"] }` correctly enabled for browser entropy routing. `image` is dev-only — good. `relicario-core/Cargo.toml` does NOT enable the `js` getrandom feature (correct: core stays platform-agnostic), and the wasm crate "lifts" the feature flag for the dep graph.
- **`src/lib.rs`** — 591 lines, the bulk of the surface. Module doc-comment is concise. Imports are sprinkled mid-file (`:52, :138, :180, :310, :340, :469, :491`) instead of clustered at the top — historical from incremental authoring per Task 19/20/21 markers. Consolidating saves scroll. Sections are demarcated by `// ── ... ──` dividers which help.
- **`src/session.rs`** — 57 lines. `SessionData { master_key, image_secret }` stored in `thread_local! { RefCell<HashMap<u32, _>> }`, monotonic `NEXT_HANDLE` u32 (skips 0 on wraparound). `insert`, `with`, `with_image_secret`, `remove`, `clear` (test-only). Missing `impl Drop for SessionHandle` — see P1.
- **`src/device.rs`** — 71 lines. Clean. `Zeroizing<String>` for private keys (correct — `String::zeroize` wipes the heap allocation). Uses `Lazy<Mutex<DeviceState>>` (different pattern from `session.rs` — see P2).

### Build / clippy

- `cargo check -p relicario-cli` — clean
- `cargo check -p relicario-server` — clean
- `cargo check -p relicario-wasm` — clean
- `cargo build -p relicario-wasm --target wasm32-unknown-unknown` — clean, finishes in ~6s, zero warnings
- `cargo clippy --workspace` — silent (per subagent reports)

---

## Boundary notes for DEV-C

These are the items that look fine from the Rust side but DEV-C must verify on the TypeScript side:

1. **CRITICAL — every `.free()` callsite on a `SessionHandle`.** wasm-bindgen's auto-generated `.free()` does NOT remove the entry from the Rust-side `SESSIONS` registry today, because there is no `impl Drop for SessionHandle` (P1 above). Until that lands, every `.free()` callsite in TypeScript that does not first call `wasm.lock(handle)` is a master-key residency window in WASM linear memory of unbounded duration. Audit `extension/src` for every `.free()` on a `SessionHandle`. The Rust-side fix is preferred (defense in depth); DEV-C should also confirm that every TS-side lock path calls `wasm.lock(handle)` before `.free()` regardless.
2. **Verify every `.free()` callsite, full stop.** Same principle applies to `EncryptedAttachment.free()` and `TotpCode.free()`. wasm-bindgen will not call `free` automatically when the JS object is GC'd — JS GC does not trigger Rust drop. Any handle that goes out of scope without explicit `.free()` leaks in WASM linear memory.
3. **`unlock()` failure semantics.** When unlock throws (bad passphrase, bad params_json, salt wrong length, image_secret extract failure), no `SessionHandle` is created. TS callers should not wrap in `try { ... } finally { handle.free() }` because the handle var will be undefined.
4. **`manifest_decrypt`/`item_decrypt`/`settings_decrypt` return `JsValue` typed as `unknown` in TS.** Rust uses `serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true)` (`lib.rs:65`). Verify TS doesn't assume `Map` semantics anywhere — should be plain JS objects. Binary fields decode to `Uint8Array`; confirm TS doesn't try to `JSON.stringify` a decrypted item containing binary.
5. **`*_encrypt` functions take `*_json: string`.** TS must `JSON.stringify` before calling. wasm-bindgen TS bindings will catch this at compile time, but verify no `as any` casts bypass.
6. **`totp_compute(_, now_unix_seconds: bigint)` and `attachment_encrypt(_, _, max_bytes: bigint)`** — TS must use `BigInt(...)`, not `number`. wasm-bindgen throws at runtime on mismatch.
7. **`Uint8Array` arguments are copied into WASM linear memory.** TS doesn't need defensive copies. But a `Uint8Array` view onto a `SharedArrayBuffer` may behave differently — verify TS isn't passing those.
8. **`EncryptedAttachment.aid` and `.bytes` clone on every read** (P2). TS code that does `enc.bytes` twice does double work + double copy. Cache locally.
9. **`SessionHandle.value` getter is exposed.** It's a u32 monotonic counter. If TS ever logs it (telemetry/debug), it's a session correlation identifier that survives across handles.
10. **`get_field_history` returns JS objects with snake_case keys** (`field_id`, `field_name`, `current_value`, `changed_at`). Verify TS components consume these names — easy mismatch with TS-side camelCase conventions.
11. **`register_device`/`sign_for_git`/`get_device_info`/`clear_device` are global, not per-session** — backed by `static DEVICE_STATE` (`Lazy<Mutex<>>`). Single SW = single state, fine. If TS ever instantiates multiple WASM modules (e.g. one per offscreen doc), each gets its own state — verify TS uses one shared init path.
12. **Naming inconsistency**: only `wasm_generate_recovery_qr` and `wasm_unwrap_recovery_qr` carry the `wasm_` prefix. If DEV-C maintains a name-mapping table or auto-generated wrappers, flag for a rename pass.
13. **`parse_lastpass_csv_json` returns `string` (JSON-encoded), not a `JsValue`.** TS must `JSON.parse` the result — different shape from `manifest_decrypt` which returns already-deserialized `unknown`. Verify `import_lastpass.ts` does `JSON.parse(...)` on the result.
14. **All exports are snake_case in JS.** If DEV-C ever wants idiomatic camelCase, the mechanism is `#[wasm_bindgen(js_name = "...")]` per export. Decide once, before the surface grows.

---

## Beginner-friendliness assessment

For a competent dev who's never written Rust:

- **Server is ideal.** 189 lines, one trust-model question, every dependency is plaintext metadata. A newcomer can read it end-to-end in under ten minutes and walk away understanding what the server does and does not do. The single change that would help most is a paragraph-length module-level comment near the top of `main.rs` explaining *why* the server only verifies signatures (because the vault is encrypted client-side, the server has no key, the hook's only job is to gate writes by device authenticity). That paragraph would make the trust story self-evident on first contact.

- **WASM is approachable but has one cliff.** 720 lines across 3 files; `lib.rs` reads top-to-bottom okay; the doc-comment on `SessionHandle` is clear about the opaque-handle contract; the section dividers help. The cliff is the missing `Drop` impl: a beginner reasonably assumes wasm-bindgen handles registry cleanup automatically (it does not). A short comment in `session.rs` saying "**Drop the SessionHandle ≠ remove from registry; you must call `lock()`**" would prevent that mistake — but better to fix the missing `Drop` so the comment isn't needed.

- **CLI has one big roadblock and a lot of small ones.** `helpers.rs`, `session.rs`, `device.rs`, `gitea.rs` all read like real code: small modules, focused responsibilities, doc-comment headers, sensible names. `helpers.rs`'s tests double as documentation. The clap surface in `main.rs:1-455` is itself a product tour. Past line 456, `main.rs` becomes a 2200-line flat file with no submodule boundaries. A newcomer searching "where does add work?" finds `cmd_add` calling 7 different `build_*_item` functions (each ~50-60 lines) plus 6 prompt helpers, all peers. Plus the Rust-specific tripwires — the `let _ = entry;` pattern repeated 6×, the `Zeroizing` newtype, the `Option<Foo>::map(Ok).unwrap_or_else(...)?` chain at every builder — compound the unfamiliarity.

**Single biggest change across the consumer layer:** split `crates/relicario-cli/src/main.rs` into a folder. Keep `main.rs` as clap definitions + dispatcher (~470 lines, very readable). Move every `cmd_*` to `commands/<name>.rs`, prompts to `prompt.rs`, parsers to `parse.rs`. Same LOC, completely different reading experience — and this is the precondition for fixing the duplicated git boilerplate (CLI P1 #2) and centralizing `refresh_groups_cache`.

---

## Open questions for DEV-B (escalated to PM separately)

1. Was `impl Drop for SessionHandle` deliberately omitted (e.g. to avoid double-free if JS holds two refs to the same handle)? If yes, document it; if no, this is the headline fix.
2. CLI `parse_month_year`, `base32_decode_lenient`, `guess_mime` — should they migrate to `relicario-core` for CLI/extension parity?
3. Is "missing `.relicario/devices.json` = bootstrap = accept" intended in perpetuity, or should it be tightened once a repo has any non-empty devices.json in history?
4. Is the `Lock` no-op CLI subcommand worth keeping visible in `--help` for parity with the extension, or hide behind `#[command(hide = true)]`?
5. `cmd_backup_export` still reads `devices.json` per a "Task 12 will remove" TODO. Is Task 12 landed, deferred, or abandoned?
