# Architecture: relicario-cli

## What this crate is for

The `relicario` binary is the platform layer for `relicario-core`: it adds
filesystem layout, a hardened `git` shell-out, interactive `rpassword` prompts,
clipboard handoff, and a clap-based command surface. The crate has two design
roles. First, it is the developer / power-user surface that exposes everything
the core can do (every `ItemCore` variant, every `VaultSettings` knob, history
inspection, device key management). Second, it is the only working interface
during disaster recovery — the extension may be uninstalled, the device may be
new — so it intentionally maintains feature parity with the extension's vault
tab. It deliberately shells out to `git` rather than depending on libgit2 /
gitoxide; this keeps the dep tree slim, lets the user override `git` config
locally, and lets recovery debugging happen with familiar tooling.

## Module map

The crate is three files of source and a `tests/` directory. Each source file
has one job.

- **`src/main.rs`** (`main.rs:1-1719`) — clap surface plus every command
  handler. Internal structure: a top-level `Cli` / `Commands` enum
  (`main.rs:13-275`), a flat dispatcher `match` in `main()`
  (`main.rs:277-303`), per-command handlers named `cmd_<verb>`, and a layer of
  per-type item helpers (`build_<type>_item` for `cmd_add`, `edit_<type>` for
  `cmd_edit`). The per-type split is recent: commit `3f0f5b1` extracted
  ~217-line `match` arms in `cmd_add` and `cmd_edit` into focused functions,
  one per `ItemCore` variant, so each builder/editor reads top-to-bottom and
  can be tested through the same integration paths. Owns all clap argument
  parsing, all interactive prompts (`prompt`, `prompt_optional`, `prompt_keep`,
  `prompt_keep_opt`, `prompt_yesno`, `prompt_secret`), and the shared
  `commit_paths` helper that is the single chokepoint for git commits during
  vault mutations.

- **`src/session.rs`** (`session.rs:1-152`) — `UnlockedVault` lifecycle. Holds
  the derived master key in `Zeroizing<[u8; 32]>` for one CLI invocation; the
  key wipes via `Zeroize` on scope exit (`session.rs:22-25`). Owns the
  `unlock_interactive` flow (vault root walk → salt read → params read →
  reference image extract → passphrase prompt → KDF) at `session.rs:33-59`,
  the typed `load_*` / `save_*` accessors for `Item` / `Manifest` /
  `VaultSettings`, the `read_salt` / `read_params` helpers, the
  `RELICARIO_IMAGE` lookup, and `atomic_write` (`session.rs:144-151`) which
  every disk write to a vault file goes through. Owns the env-var escape
  hatches `RELICARIO_TEST_PASSPHRASE` (`session.rs:42`) and `RELICARIO_IMAGE`
  (`session.rs:125`) that integration tests use to bypass the TTY.

- **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing:
  `find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories
  looking for a `.relicario/` marker; `vault_dir` and `relicario_dir` wrap it
  for `cwd`-rooted callers; `git_command` (`helpers.rs:45-55`) is the
  hardened-`git` factory that every git invocation in the crate (production
  code, not tests) goes through; `iso8601` (`helpers.rs:60-64`) formats Unix
  seconds for human-readable output (audit M11). The hardening is
  load-bearing — see Invariants & Gotchas below.

## Invariants & contracts

These are the load-bearing rules the crate relies on. Each has been verified
in code; cite the line if you change it.

- **Every vault-mutating command unlocks via `UnlockedVault`.** The struct
  holds the master key in `Zeroizing<[u8; 32]>` and drops via `Zeroize` on
  scope exit (`session.rs:22-25`). No command bypasses this except
  `cmd_generate` outside a vault dir and `cmd_init` (which derives the key
  inline before there is a vault to unlock).

- **Every `git` invocation in production code goes through
  `helpers::git_command`.** A grep for `Command::new("git")` outside
  `helpers.rs` finds zero hits in `src/`; the only other match is in
  `tests/edit_and_history.rs:18`, which is test-side verification of the git
  log and is exempt by design. `git_command` injects
  `core.hooksPath=/dev/null`, `commit.gpgsign=false`, and `core.editor=true`
  via `-c` flags (`helpers.rs:48-52`). Direct `Command::new("git")` would
  bypass the hardening — don't.

- **Every file write to a vault file uses `atomic_write`.** `atomic_write`
  (`session.rs:144-151`) writes `<path>.tmp` then renames over `<path>`; a
  partial write never appears as the live file. All `UnlockedVault::save_*`
  helpers route through it. (`cmd_init` writes pre-creation files via
  `fs::write` at `main.rs:373-393`; that path doesn't need atomicity because
  the vault doesn't exist yet — failure leaves a half-built vault that the
  next run rejects via `relicario_dir.exists()` at `main.rs:326`.)

- **Every commit during a mutating command uses `commit_paths`.**
  `commit_paths` (`main.rs:767-775`) does `git add <paths> && git commit -m
  <msg>` through the hardened wrapper. Commit message convention is
  `<verb>: <title> (<id>)` — `add:`, `edit:`, `trash:`, `restore:`, `purge:`,
  `attach:`, `detach:`, `settings: update`, `device: add <name>`, `device:
  revoke <name>`, `init: new relicario vault (format v2)`, `trash empty:
  purged N item(s)`. `cmd_purge` and `cmd_trash_empty` and `cmd_device` use
  `git_command` directly (not `commit_paths`) because they need a slightly
  different add/commit pattern; they still go through the hardened wrapper.

- **`cmd_generate` is the only command that runs without unlock — and only
  when invoked outside a vault directory.** Inside a vault,
  `cmd_generate` unlocks to read `settings.generator_defaults`
  (`main.rs:1440-1445`); explicit flags override the stored defaults. This is
  why the smoke-test `cargo run -p relicario-cli -- generate --length 32`
  works without any setup.

- **Item IDs are minted by core.** The CLI never constructs an `ItemId`
  directly; `Item::new` (called inside every `build_*_item`) does it via
  `relicario-core::ids::new_item_id`. `ItemId`s are 8-char hex.

- **Manifest is always saved last.** Within a single command, the order is:
  write item file → mutate manifest → save manifest → commit. If the process
  dies between step 1 and step 3, the next run sees an item file with no
  manifest entry; `cmd_status` / `cmd_list` ignore it because they read the
  manifest, not the directory. (Recovery would manually re-`add` to surface
  it.)

- **Vault root is always discovered, never assumed to be `cwd`.**
  `helpers::vault_dir` walks up from `cwd` looking for `.relicario/`, so any
  command run from a subdirectory of the vault works (verified by
  `vault_detection.rs:23-40`). v1 vaults using `.idfoto/` are naturally
  rejected because they don't contain `.relicario/` — no compat shim needed
  (`vault_detection.rs:42-59`).

- **`prompt_secret` reads `RELICARIO_TEST_ITEM_SECRET` before falling back to
  `rpassword`.** This is the only way integration tests can drive the
  per-item secret prompts (Login password, Card number, TOTP secret rotation,
  Key material) without a real TTY. The check is at `main.rs:308-313`.

## Key flows

### Vault init (`cmd_init`, `main.rs:315-418`)

1. Refuse if `.relicario/` already exists (`main.rs:326-328`).
2. Read passphrase twice (or once via `RELICARIO_TEST_PASSPHRASE`); confirm
   they match; run `validate_passphrase_strength` (zxcvbn-backed) and bail
   with audit-H3 message on weak input (`main.rs:331-348`).
3. Generate a 32-byte random `image_secret` via `OsRng`, embed it into the
   carrier JPEG via `imgsecret::embed`, write the stego output to `--output`
   (`main.rs:351-360`).
4. Generate a 32-byte salt and pin `KdfParams { argon2_m: 65536, argon2_t: 3,
   argon2_p: 4 }` (production-grade) at `main.rs:363-365`.
5. `derive_master_key(passphrase, image_secret, salt, params)` →
   `Zeroizing<[u8;32]>` (`main.rs:368`).
6. Create `.relicario/`, `items/`, `attachments/` dirs; write
   `.relicario/{salt, params.json, devices.json}`; encrypt and write
   `manifest.enc` (empty `Manifest::new()`) and `settings.enc`
   (`VaultSettings::default()`) (`main.rs:370-393`).
7. Write `.gitignore` listing the reference image filename (so the second
   factor never accidentally ends up in git) (`main.rs:396-400`).
8. `git init` then initial commit `init: new relicario vault (format v2)`
   via `git_command` (`main.rs:403-412`). Note the initial commit does NOT
   go through `commit_paths` — it precedes the existence of an
   `UnlockedVault`, so the path list is hand-spelled.

### Vault unlock (`UnlockedVault::unlock_interactive`, `session.rs:33-59`)

1. `vault_dir()` walks up from cwd to find `.relicario/`; bails with the
   "run `relicario init` first" message on miss (`helpers.rs:21-26`).
2. `read_salt` reads `.relicario/salt` (32 bytes; rejects any other length).
3. `read_params` deserializes `.relicario/params.json` and extracts the
   nested `kdf` sub-object as `KdfParams` (`session.rs:110-121`). The nested
   shape exists because `params.json` also stores `format_version`, `aead`,
   and `salt_path` for forward-compat probing.
4. `get_image_path` honours `RELICARIO_IMAGE`, then a `<vault>/reference.jpg`
   convention, then prompts (`session.rs:124-140`).
5. Read the reference image bytes; `imgsecret::extract` runs the DCT
   majority-vote decode to recover the 32-byte image secret
   (`session.rs:38-40`).
6. Read the passphrase via `RELICARIO_TEST_PASSPHRASE` or `rpassword`
   (`session.rs:42-49`).
7. `derive_master_key` produces the master key; `UnlockedVault { root,
   master_key }` is returned and lives until the command function returns.

### Item add (`cmd_add`, `main.rs:419-456`)

1. Unlock the vault and load the manifest.
2. Match on the `AddKind` variant and dispatch to the matching
   `build_<type>_item` helper (`main.rs:423-438`). Seven variants → seven
   builders; only `build_document_item` takes `&UnlockedVault` because it
   needs `attachment_caps` and writes the encrypted blob alongside the item.
3. The builder returns a fully-populated `Item` (with title, group, tags,
   favorite-flag, primary attachment if any).
4. Common wrap-up: `vault.save_item(&item)`, `manifest.upsert(&item)`,
   `vault.save_manifest(&manifest)`.
5. Build the path list — `items/<id>.enc`, `manifest.enc`, plus one
   `attachments/<id>/<aid>.enc` per attachment — and call `commit_paths`
   with message `add: <title> (<id>)` (`main.rs:444-452`).

### Item edit (`cmd_edit`, `main.rs:938-977`)

1. Unlock, load manifest, resolve query → item id, load the item.
2. Universally-editable fields (title, group, tags) are prompted via
   `prompt_keep` / `prompt_keep_opt` first; blank input keeps the current
   value (`main.rs:952-956`).
3. Borrow `&mut item.field_history` once into a local `history` binding
   (`main.rs:958`), then `match` on `&mut item.core` and dispatch to the
   per-type `edit_<type>` helper (`main.rs:959-967`). The history-tracking
   editors (`edit_login`, `edit_secure_note`, `edit_card`, `edit_key`,
   `edit_totp`) take `&mut FieldHistory`; the others (`edit_identity`,
   `edit_document_message`) don't.
4. Each editor that mutates a tracked secret calls `push_history(history,
   "<key>", old_value)` (`main.rs:1095-1109`) — see the History flow below
   for the synthetic-key convention.
5. `item.modified = now_unix()`, save, upsert manifest, commit
   `edit: <title> (<id>)`.

`edit_document_message` (`main.rs:1050-1052`) just prints "use `attach` /
`extract` instead" — Document items can't be field-edited; they're
attachment-shaped.

The `FieldHistory` type alias (`main.rs:983-986`) is purely cosmetic; it
exists so the editor signatures don't have to spell out the full
`HashMap<FieldId, Vec<FieldHistoryEntry>>`.

### History capture and view (`push_history` + `cmd_history`)

`push_history` (`main.rs:1095-1109`) records an old value under a synthetic
`FieldId(format!("core:{key}"))`. The `core:` prefix namespaces these keys so
they can never collide with real custom-field UUIDs from the typed-item
custom-fields work. The keys used in the codebase are:

- `core:login_password` (`main.rs:998`)
- `core:secure_note_body` (`main.rs:1012`)
- `core:card_number` (`main.rs:1031`)
- `core:key_material` (`main.rs:1045`)
- `core:totp_secret` (`main.rs:1063`)

`cmd_history` (`main.rs:1111-1159`) reads `item.field_history`, sorts the
keys, strips the `core:` prefix for display, and prints each entry list
masked or revealed depending on `--show`. The `--field <name>` filter
matches against either the stripped name (`login_password`) or the raw key
(`core:login_password`) so both forms work (`main.rs:1126-1129`). The
`relicario history bank --field totp_secret` form is what
`edit_and_history.rs` exercises.

### Trash & purge (`cmd_rm` / `cmd_restore` / `cmd_purge` / `cmd_trash_empty`)

- `cmd_rm` (`main.rs:1161-1176`) calls `Item::soft_delete()` (sets
  `trashed_at`), saves, upserts manifest, commits `trash:`.
- `cmd_restore` (`main.rs:1178-1193`) is the inverse: `Item::restore()`,
  same wrap-up, commit `restore:`.
- `cmd_purge` (`main.rs:1220-1237`) calls `purge_item` (`main.rs:1197-1218`)
  which removes the item file, the attachment dir, the manifest entry, and
  `git rm -rf --ignore-unmatch`s the paths. Then a single `git add
  manifest.enc` + commit `purge: <title> (<id>)`.
- `cmd_trash_empty` (`main.rs:1246-1282`) is the only multi-item mutating
  command. It loads settings once, iterates all items past their
  `trash_retention` window, calls `purge_item` for each, then does a single
  `git add manifest.enc` + commit `trash empty: purged N item(s)`. The
  single-unlock-per-batch shape was the fix in commit `b5015b3` — the
  earlier version re-prompted for the passphrase per item.

### Attach / detach / extract

- `cmd_attach` (`main.rs:1283-1339`) loads `attachment_caps` from settings
  and rejects if the item has hit `per_item_max_count`. `encrypt_attachment`
  enforces `per_attachment_max_bytes`. The encrypted blob lands at
  `attachments/<item_id>/<aid>.enc`; the `aid` is content-addressed by core.
  Commit message: `attach: <file> → <title> (<id>)`.
- `cmd_detach` (`main.rs:1376-1424`, added in `3f0f5b1`) removes one
  attachment from the item, deletes the encrypted blob, rewrites the item.
  Refuses if the target `aid` is a `Document` item's `primary_attachment`
  (`main.rs:1392-1400`) — that would orphan the item; use `purge` instead.
  Commit message: `detach: <filename> from <title> (<id>)`.
- `cmd_extract` (`main.rs:1354-1375`) decrypts the blob and writes the
  plaintext to `--out` or to `<filename>` in cwd. Read-only: no commit, no
  state mutation.
- `cmd_attachments` (`main.rs:1341-1352`) lists `aid`, size, mime, filename
  — read-only.

### Generate (`cmd_generate`, `main.rs:1426-1489`)

Has two distinct modes:

- **Outside a vault** — `vault_dir()` returns `Err`; `vault_defaults` stays
  `None`; defaults are hard-coded (`length: 20`, `symbols: SafeOnly`,
  `words: 5`, `separator: " "`, `Capitalization::Lower`). No unlock prompt.
- **Inside a vault** — `vault_dir()` succeeds; full unlock; load
  `settings.generator_defaults`. Explicit flags override the stored defaults
  field-by-field. `--bip39` flips mode; absent that flag, the mode is
  whatever the stored default is. Tests:
  `settings.rs::generate_uses_vault_default_length` (length-tracking) and
  `basic_flows.rs::generate_random_and_bip39` (no-vault smoke).

The two-mode shape is deliberate (see Gotchas) and is why `cmd_generate` is
the only command outside `cmd_init` that touches `helpers::vault_dir()`
directly instead of going through `UnlockedVault::unlock_interactive()`.

### Sync (`cmd_sync`, `main.rs:1582-1590`)

`git pull --rebase` then `git push`, both via the hardened wrapper. No
unlock — sync moves opaque ciphertext, the master key is never needed. This
is the only command that fails on conflict; it doesn't try to resolve.
Resolution happens manually in the user's git tooling.

### Status (`cmd_status`, `main.rs:1592-1631`, added in `3f0f5b1`)

Unlocks; loads manifest; counts items (active vs trashed), attachments
(count + total bytes), devices (parsed from `devices.json`); shells out to
`git log -1 --pretty=format:%h %s` for the last-commit summary line. All
read-only — no commit, no state change.

### Device management (`cmd_device`, `main.rs:1632-1702`)

Add: generate ed25519 keypair via `OsRng`, append `{name, public_key}` to
`.relicario/devices.json`, write the secret signing key to
`<config_dir>/relicario/devices/<name>.key` with `0o600` on Unix, commit
`device: add <name>`. List: print `name pubkey_hex`. Revoke: filter by name,
rewrite `devices.json`, commit `device: revoke <name>`. Note that device
keys are kept entirely separate from the KDF (passphrase × image stays
unchanged across device add/revoke), as per the design spec.

### Backup-passphrase-style commands (none yet)

The import / export / `import-lastpass` commands described in
`docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` are
not yet implemented. When they land they'll fit in the dispatcher
(`main.rs:279-302`) alongside `Sync` and `Status`. Don't add stubs here
until that work begins.

## Cross-cutting concerns

- **Error model.** Every `cmd_*` returns `anyhow::Result<()>`. Core errors
  bubble up through `?` from `RelicarioError`. Per-step context is added
  via `with_context(|| ...)` chains, e.g. `format!("failed to read {}",
  path.display())`. AEAD authentication failures intentionally surface as
  the ambiguous "wrong passphrase or corrupt vault" message from core — the
  CLI does not differentiate. clap argument errors are produced by clap
  (e.g., `--days` and `--forever` together fail at the
  `SettingsAction::TrashRetention` arm in `main.rs:1504-1510`).

- **Atomicity.** Every disk write to a vault file goes through
  `session.rs::atomic_write` (`session.rs:144-151`): write `<path>.tmp`, then
  rename over `<path>`. Manifest is the single source of truth and is
  always written *last* in any multi-file operation, so a process kill
  between item-write and manifest-write leaves an orphan item file (which
  doesn't appear in `list`/`status`) but never a manifest pointing to a
  missing file.

- **Git history as audit log.** Per-action commits, never amended, never
  squashed. The verb prefix on commit subjects (`add:`, `edit:`, `trash:`,
  `restore:`, `purge:`, `attach:`, `detach:`, `settings:`, `device:`,
  `init:`) makes `git log --oneline` a literal audit trail. Tests verify
  this by greping `git log` directly (e.g., `edit_and_history.rs:18-22`).

- **Where secrets live.**
  - Master key — `UnlockedVault.master_key: Zeroizing<[u8; 32]>`
    (`session.rs:24`). Wipes on drop.
  - Image secret — `Zeroizing<[u8; 32]>`, lives only inside
    `unlock_interactive` until the KDF call (`session.rs:40`).
  - Passphrase — `Zeroizing<String>` from `rpassword::prompt_password` or
    the env var (`session.rs:42-49`, `main.rs:333-342`).
  - Item secrets — `Zeroizing<String>` for `Login.password`, `Card.number`,
    `Card.cvv`, `Card.pin`, `Key.key_material`, `SecureNote.body`, and
    `Zeroizing<Vec<u8>>` for `TotpCore.config.secret` (decoded from
    base32). All flow through core types.
  - Clipboard copy — `Zeroizing<String>` cloned into the detached 30s
    auto-clear thread (`main.rs:873-889`).

- **Test escape hatches.** Three env vars exist for integration tests; all
  are read at exactly one site each:
  - `RELICARIO_TEST_PASSPHRASE` — `session.rs:42` (unlock) and
    `main.rs:333,338` (init).
  - `RELICARIO_IMAGE` — `session.rs:125` (image path resolution).
  - `RELICARIO_TEST_ITEM_SECRET` — `main.rs:309` (`prompt_secret` only).
  None of them have a production fall-through; absent the var, the code
  always prompts. They are safe in production binaries because the user
  would have to set them explicitly.

- **Generate-without-unlock is intentional.** It is NOT an oversight.
  `relicario generate --length 32` is the documented smoke test (see the
  repo's CLAUDE.md) and works as a standalone CSPRNG password generator
  outside any vault. Inside a vault it does require unlock — see Gotchas.

## Test architecture

All tests are integration tests; there are no `#[cfg(test)]` modules in
`src/main.rs` or `src/session.rs`. `helpers.rs` has four unit tests
(`helpers.rs:67-100`) that exercise vault-dir walking and `iso8601`
formatting in isolation. Everything else is `tests/`.

- **`tests/common/mod.rs`** (`117 lines`) — the harness. `TestVault::init()`
  spins up a fresh `TempDir`, generates a 400×300 JPEG via
  `make_test_jpeg()` (deterministic noise; no binary fixtures), runs
  `relicario init --image carrier.jpg --output reference.jpg` with
  `RELICARIO_TEST_PASSPHRASE` set, and stashes the passphrase + reference
  image path on the struct. `run` and `run_with_input` are the two ways to
  invoke the binary against the test vault: both inherit
  `RELICARIO_IMAGE` + `RELICARIO_TEST_PASSPHRASE`; the latter pipes extra
  newlines into stdin (used for interactive prompts that aren't
  `rpassword`-driven). The note at the top warns Task 23 implementers
  about the new-item-password rpassword path; the fix landed as
  `RELICARIO_TEST_ITEM_SECRET` in commit `20350d5`.

- **`tests/basic_flows.rs`** (`136 lines`) — covers the init layout
  (`.relicario/{salt,params.json,devices.json}`, `manifest.enc`,
  `settings.enc`, `reference.jpg`, `.gitignore`, `.git`); the `params.json`
  v2 shape; `add login` + `list`; `get` masking semantics (with and
  without `--show`); the rm/restore/purge cycle including `list --trashed`;
  and the two-mode `generate` smoke (random length + bip39 word count) run
  outside a vault.

- **`tests/edit_and_history.rs`** (`191 lines`) — drives `edit` end-to-end
  by piping stdin lines (blank to keep, `y` to confirm) plus
  `RELICARIO_TEST_ITEM_SECRET` for the rpassword leg. `edit_password_*`
  verifies the item file is rewritten and the `edit: bank` commit lands.
  The four `history_command_*` tests cover masked listing, `--show`
  reveal, "no history captured" output, and per-field filtering. The
  `edit_totp_rotates_secret_and_captures_history` test (added 2026-04-27
  in commit `3f0f5b1` — fixes a stub at the old `main.rs:925`) drives the
  full TOTP edit including issuer / label / secret rotation.

- **`tests/attachments.rs`** (`106 lines`) — `attach`/`attachments`/
  `extract` round-trip (verifies the bytes survive the encrypt-decrypt
  hop); `detach` removes both the attachment ref and the encrypted blob
  on disk; `detach` rejects an unknown `aid`; `attach` rejects payloads
  over `per_attachment_max_bytes`. The detach test (`detach_*`) and the
  cap test were added in `3f0f5b1` / `20350d5` respectively.

- **`tests/settings.rs`** (`135 lines`) — `settings show` and
  `settings trash-retention --days 60` round-trip; the conflicting-flags
  rejection (`--days` + `--forever`); the
  `generate_uses_vault_default_length` test that verifies (a) default
  vault length is 20, (b) updating `settings generator-defaults --length
  32` changes the default, (c) explicit `--length 8` overrides the stored
  default; the multi-shape `cmd_status` smoke; and the
  `generate_works_outside_vault` test that verifies the no-unlock path
  works in a bare `TempDir` with no `.relicario/`.

- **`tests/vault_detection.rs`** (`59 lines`) — three tests covering audit
  L8: `list` refuses without a marker; `list` from a nested subdirectory
  finds the parent `.relicario/`; a v1 `.idfoto/` directory is rejected
  with the `.relicario` hint in the error message.

The whole test suite uses `assert_cmd` to spawn the real binary against a
real temp directory, so they exercise actual fs / git / KDF code paths.
The KDF runs with the production-grade `m=64MiB, t=3, p=4` parameters in
the test path (`main.rs:365`), which is why init takes a noticeable beat
in the test runner. The core's "fast Argon2id for tests" CLAUDE.md note
applies to `relicario-core` unit tests, not these CLI integration tests.

## Gotchas & non-obvious decisions

- **`cmd_generate` runs without unlock outside a vault, but with unlock
  inside.** This is two ergonomic guarantees in one command. Outside, it's
  a fast standalone CSPRNG tool — useful for smoke tests, scripts, and any
  user who installed `relicario` just for the generator. Inside, it
  consults `settings.generator_defaults` so the user gets the policy they
  configured. The branch is the `vault_dir().is_ok()` check at
  `main.rs:1440`. Tests pin both behaviours.

- **TOTP edit pushes history under the synthetic key `core:totp_secret`,
  not `core:totp` or anything else.** This is what `relicario history
  <query> --field totp_secret` matches against. The naming convention
  ("type underscore field") is shared across all five history-tracked
  fields (see Invariants). If you add a new history-tracked field, pick a
  matching `<type>_<field>` form so the user-facing `--field` filter
  stays predictable.

- **`detach` refuses a Document item's primary attachment.**
  (`main.rs:1392-1400`) Document items model "this item *is* a file"; the
  primary blob isn't optional. The error directs the user to `purge`
  instead. Non-primary attachments on a Document (e.g., a scanned
  contract with an addendum) detach normally.

- **Per-type `build_*_item` / `edit_*` helpers exist by design after the
  `3f0f5b1` refactor.** Before the refactor, `cmd_add` and `cmd_edit`
  carried 217-line `match` arms. The split-out functions are easier to
  read, easier to test individually (the existing integration tests still
  drive them through the same paths), and easier to grow when a new
  `ItemCore` variant lands. Keep this shape — don't fold them back.

- **Why the CLI shells out to `git`, not libgit2 / gitoxide.** Three
  reasons. (1) Dep tree: pulling in libgit2 doubles compile time and
  adds a C dependency. (2) Override surface: users can put any
  `~/.gitconfig` they want and it Just Works (subject to the hardening
  flags). (3) Recovery: when something is wrong with a vault, the user
  can poke around with `git log`, `git show`, `git fsck` directly; the
  CLI's git interactions are not opaque.

- **The hardened-`git` injection set is load-bearing.** `git_command`
  prepends three `-c` flags before the user-supplied args
  (`helpers.rs:48-52`):
  - `core.hooksPath=/dev/null` — a malicious or buggy hook in a cloned
    vault could otherwise run arbitrary code on every commit. Master key
    is in memory at the time of commit; this matters.
  - `commit.gpgsign=false` — if the user has global GPG signing on, the
    GPG agent prompt would block on `git commit` and hold the master
    key alive in memory until the user types the passphrase. Disable it
    for relicario commits.
  - `core.editor=true` — `true(1)` exits 0 with no output. If `git`
    decides to drop into `$EDITOR` (rebase conflict markers, missing
    `-m`), this neutralises it without crashing the rebase. We pass
    `-m <msg>` ourselves; this flag is the seatbelt.
  All three were added together in audit H4. A user can still run
  `git` themselves with their own config to inspect or repair the
  vault — the hardening only applies to relicario's invocations.

- **`cmd_init` uses production-grade `KdfParams { m: 65536, t: 3, p: 4
  }`** (`main.rs:365`), even in tests. `RELICARIO_TEST_PASSPHRASE`
  bypasses the prompt but does not lower the KDF cost. This is a
  trade-off: integration tests pay the full Argon2id cost (~half a
  second per init on a modern machine), but the same code path runs in
  production. Don't lower the params here — the core's test-only fast
  params are for `relicario-core` unit tests.

- **`params.json` has a nested `kdf` object, not a flat one.**
  `read_params` (`session.rs:110-121`) deserializes via a private
  `ParamsFile { kdf: KdfParams }` struct. The nesting exists so
  `format_version`, `aead`, and `salt_path` can co-exist in the same
  file for forward-compat. An earlier version of `read_params` tried
  to deserialize the whole file as `KdfParams` and failed silently —
  that bug was fixed in commit `b263c27`.

- **`commit_paths` is the convention but not always the call site.**
  `cmd_purge`, `cmd_trash_empty`, and `cmd_device` use `git_command`
  directly because their add/commit pattern doesn't quite fit
  `commit_paths(vault, msg, &[paths...])`. They still use the
  hardened wrapper, just at one level lower. If you find yourself
  writing a new command with the same shape, prefer `commit_paths`;
  reach for `git_command` directly only when you need the slightly
  different control flow these three have.

- **Initial commit at `cmd_init` does not use `commit_paths`.**
  Reason: `commit_paths` takes `&UnlockedVault`, but `cmd_init` doesn't
  construct one — it uses the master key inline before the vault
  exists. The init commit goes through `git_command` directly
  (`main.rs:403-412`). This is the only production code site outside
  `commit_paths` that does so.

- **`Lock` is a no-op (`main.rs:301`).** The CLI doesn't cache a
  session — every command re-derives the master key. The command
  exists only for UX parity with the extension, where `lock` actually
  evicts a cached session. Printed message: `no cached session to
  lock`.

- **`resolve_query` accepts an item id or a case-insensitive title
  substring** (`main.rs:855-871`). Exact id-match wins; otherwise it
  defers to `Manifest::search`. Multi-hit substring matches are
  rejected with an "ambiguous" error listing the matched titles. This
  is why every `cmd_*` that takes a `query: String` (get, edit,
  history, rm, restore, purge, attach, attachments, extract, detach)
  works the same way.
