# Typed Items — Plan 1A: Rust Core Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Replace `relicario-core`'s single-`Entry` data model with the polymorphic typed-item system (Login, SecureNote, Identity, Card, Key, Document, TOTP) defined in `docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md`. Bake in the audit's crypto fixes (length-prefixed Argon2 input, Zeroize discipline, opaque Decrypt error, 16-char hex IDs, CSPRNG-only generators) and add support for sections, custom fields, attachments, password history, soft-delete, and vault settings.

**Architecture:** Pure Rust library; no filesystem, no network, no git. Bytes-in/bytes-out. Each typed core lives in its own `src/item_types/<type>.rs` module. The `ItemCore` enum + `match` exhaustiveness is the extension mechanism. Sensitive plaintext fields wrap `Zeroizing<>` at the struct level. Format version 2 — clean break from v1 (no migration). All sensitive fields serialize through `Zeroizing` so JSON-then-AEAD pipeline never leaves plaintext on the heap longer than necessary.

**Tech Stack:** Rust 2021, `serde` + `serde_json` (existing), `argon2` 0.5 (existing), `chacha20poly1305` 0.10 (existing), `rand` 0.8 (existing), plus new deps: `zeroize` 1, `zeroize_derive` 1, `zxcvbn` 3, `bip39` 2, `unicode-normalization` 0.1, `chrono` 0.4 (with `serde`), `hex` 0.4, `url` 2, `getrandom` 0.2 (with `js` feature defaulting off — wasm crate enables it).

**Spec:** `docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md`
**Audit:** `docs/superpowers/audits/2026-04-18-initial-security-audit.md`

**Out of scope for this plan (covered by Plans 1B and 1C):**
- WASM session-handle bridge (Plan 1B)
- CLI rewrite (Plan 1B)
- Browser extension changes (Plan 1C)
- imgsecret changes (audit M3 lives in Plan 1B's CLI work; the extension MAX_DIMENSION cap lives in Plan 1C)

---

## File Structure

```
crates/relicario-core/
├── Cargo.toml                                 # MODIFY: add new deps
└── src/
    ├── lib.rs                                 # MODIFY: module declarations, re-exports
    ├── error.rs                               # MODIFY: opaque Decrypt, new variants
    ├── crypto.rs                              # MODIFY: length-prefix KDF, Zeroize, NFC, VERSION_BYTE 0x02
    ├── imgsecret.rs                           # UNTOUCHED in this plan
    ├── entry.rs                               # DELETE (final step)
    ├── vault.rs                               # MODIFY: replace Entry helpers with Item helpers
    ├── ids.rs                                 # NEW: ItemId/FieldId/AttachmentId types + generators
    ├── time.rs                                # NEW: unix-seconds helpers + MonthYear
    ├── item.rs                                # NEW: Item, Section, Field, FieldKind, FieldValue, FieldHistoryEntry
    ├── attachment.rs                          # NEW: AttachmentRef, encrypt_attachment, decrypt_attachment
    ├── manifest.rs                            # REWRITE: typed-item Manifest + ManifestEntry + AttachmentSummary
    ├── settings.rs                            # NEW: VaultSettings + retention/generator/cap types
    ├── generators.rs                          # NEW: generate_password, generate_passphrase, rate_passphrase
    └── item_types/
        ├── mod.rs                             # NEW: ItemType + ItemCore enum + dispatch
        ├── login.rs                           # NEW: LoginCore
        ├── secure_note.rs                     # NEW: SecureNoteCore
        ├── identity.rs                        # NEW: IdentityCore
        ├── card.rs                            # NEW: CardCore + CardKind
        ├── key.rs                             # NEW: KeyCore
        ├── document.rs                        # NEW: DocumentCore
        └── totp.rs                            # NEW: TotpCore + TotpConfig + TotpAlgorithm + TotpKind
└── tests/
    ├── integration.rs                         # REWRITE: full workflow + two-factor independence (typed-item)
    ├── field_history.rs                       # NEW: history-tracking integration tests
    ├── attachments.rs                         # NEW: attachment round-trip + content-addressed AID
    ├── generators.rs                          # NEW: bias + bip39 sanity + zxcvbn gate tests
    └── format_v2.rs                           # NEW: VERSION_BYTE + length-prefix KDF tests + v1-rejection
```

**Conventions for tasks below:**
- Each task creates or modifies exactly the files listed under `**Files:**`.
- TDD: write the failing test first, run it to confirm it fails, write minimal code, run it to confirm it passes, commit.
- Commits use Conventional Commits (`feat:`, `test:`, `refactor:`, `chore:`).
- Co-author trailer on every commit: `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`.
- Run `cargo build -p relicario-core` and `cargo test -p relicario-core` between tasks to catch breakage early. Workspace will not build cleanly until Task 26 (when `relicario-cli` is updated to use the new types) — this plan deliberately leaves `relicario-cli` broken intermediate-state. Plan 1B fixes it.
- During this plan, `cargo check -p relicario-cli` is expected to fail. Use `cargo test -p relicario-core` (which doesn't build the CLI) to verify each task.

---

## Task 0: Add new dependencies to Cargo.toml

**Files:**
- Modify: `crates/relicario-core/Cargo.toml`

- [ ] **Step 1: Update `[dependencies]` section**

Replace the current `[dependencies]` section with:

```toml
[dependencies]
thiserror = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
argon2 = "0.5"
chacha20poly1305 = "0.10"
rand = "0.8"
sha2 = "0.10"
ed25519-dalek = { version = "2", features = ["rand_core"] }
image = { version = "0.25", default-features = false, features = ["jpeg"] }

# Typed-item additions
zeroize = { version = "1", features = ["zeroize_derive"] }
zxcvbn = { version = "3", default-features = false }
bip39 = { version = "2", default-features = false, features = ["std"] }
unicode-normalization = "0.1"
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] }
hex = "0.4"
url = { version = "2", features = ["serde"] }
getrandom = "0.2"

[dev-dependencies]
hex = "0.4"
```

- [ ] **Step 2: Verify deps resolve**

Run: `cargo check -p relicario-core --no-default-features 2>&1 | head -40`
Expected: no resolver errors; the existing `entry.rs` may report "unused import" warnings but should compile.

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-core/Cargo.toml
git commit -m "$(cat <<'EOF'
chore(core): add deps for typed-item rewrite

zeroize, zxcvbn, bip39, unicode-normalization, chrono, hex, url, getrandom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 1: ID types (ItemId, FieldId, AttachmentId)

**Files:**
- Create: `crates/relicario-core/src/ids.rs`
- Modify: `crates/relicario-core/src/lib.rs`

- [ ] **Step 1: Write failing tests in `src/ids.rs`**

Create `crates/relicario-core/src/ids.rs`:

```rust
//! Random and content-addressed identifiers for items, fields, and attachments.
//!
//! - `ItemId` and `FieldId` are random 16-char hex strings (64 bits of entropy)
//!   generated via `OsRng` (audit M8: bumped from the v1 8-char/32-bit format).
//! - `AttachmentId` is the first 16 hex chars of `sha256(plaintext)` —
//!   content-addressed so identical plaintext blobs deduplicate naturally in git.

use rand::rngs::OsRng;
use rand::RngCore;
use sha2::{Digest, Sha256};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ItemId(pub String);

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct FieldId(pub String);

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct AttachmentId(pub String);

impl ItemId {
    pub fn new() -> Self {
        let mut bytes = [0u8; 8];
        OsRng.fill_bytes(&mut bytes);
        Self(hex::encode(bytes))
    }
    pub fn as_str(&self) -> &str { &self.0 }
}

impl FieldId {
    pub fn new() -> Self {
        let mut bytes = [0u8; 8];
        OsRng.fill_bytes(&mut bytes);
        Self(hex::encode(bytes))
    }
    pub fn as_str(&self) -> &str { &self.0 }
}

impl AttachmentId {
    pub fn from_plaintext(plaintext: &[u8]) -> Self {
        let digest = Sha256::digest(plaintext);
        Self(hex::encode(&digest[..8]))
    }
    pub fn as_str(&self) -> &str { &self.0 }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn item_id_is_16_hex_chars() {
        let id = ItemId::new();
        assert_eq!(id.0.len(), 16);
        assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
    }

    #[test]
    fn item_ids_are_unique() {
        let mut seen = std::collections::HashSet::new();
        for _ in 0..10_000 {
            assert!(seen.insert(ItemId::new().0));
        }
    }

    #[test]
    fn field_id_is_16_hex_chars() {
        let id = FieldId::new();
        assert_eq!(id.0.len(), 16);
        assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
    }

    #[test]
    fn attachment_id_is_deterministic() {
        let plaintext = b"hello world";
        let a = AttachmentId::from_plaintext(plaintext);
        let b = AttachmentId::from_plaintext(plaintext);
        assert_eq!(a, b);
    }

    #[test]
    fn attachment_id_changes_with_plaintext() {
        let a = AttachmentId::from_plaintext(b"hello");
        let b = AttachmentId::from_plaintext(b"world");
        assert_ne!(a, b);
    }

    #[test]
    fn attachment_id_is_16_hex_chars() {
        let id = AttachmentId::from_plaintext(b"any bytes");
        assert_eq!(id.0.len(), 16);
        assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
    }

    #[test]
    fn ids_serialize_as_bare_strings() {
        let item = ItemId("abcdef0123456789".to_string());
        let json = serde_json::to_string(&item).unwrap();
        assert_eq!(json, "\"abcdef0123456789\"");

        let parsed: ItemId = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed, item);
    }
}
```

- [ ] **Step 2: Wire module into lib.rs**

Add to `crates/relicario-core/src/lib.rs` after the `pub mod error;` line:

```rust
pub mod ids;
pub use ids::{AttachmentId, FieldId, ItemId};
```

- [ ] **Step 3: Run tests, expect pass (these are unit tests in the new module)**

Run: `cargo test -p relicario-core --lib ids:: 2>&1 | tail -20`
Expected: 7 tests passed.

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-core/src/ids.rs crates/relicario-core/src/lib.rs
git commit -m "$(cat <<'EOF'
feat(core): add ItemId, FieldId, AttachmentId types

16-char hex (64-bit) random IDs for items and fields (audit M8).
AttachmentId is sha256(plaintext)[..16] for content-addressing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: Time helpers + MonthYear

**Files:**
- Create: `crates/relicario-core/src/time.rs`
- Modify: `crates/relicario-core/src/lib.rs`

- [ ] **Step 1: Write `src/time.rs` with tests**

```rust
//! Time helpers and the `MonthYear` type used for card expiries.

use serde::{Deserialize, Serialize};

/// Current Unix timestamp in seconds.
pub fn now_unix() -> i64 {
    chrono::Utc::now().timestamp()
}

/// Month + year (1-12 / e.g. 2026). Used for card expiries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct MonthYear {
    pub month: u8,
    pub year: u16,
}

impl MonthYear {
    pub fn new(month: u8, year: u16) -> Result<Self, &'static str> {
        if !(1..=12).contains(&month) {
            return Err("month must be 1..=12");
        }
        if year < 2000 || year > 2099 {
            return Err("year must be 2000..=2099");
        }
        Ok(Self { month, year })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn now_unix_is_positive_and_recent() {
        let t = now_unix();
        assert!(t > 1_700_000_000);  // after late 2023
        assert!(t < 4_000_000_000);  // before 2096
    }

    #[test]
    fn month_year_constructor_rejects_bad_month() {
        assert!(MonthYear::new(0, 2026).is_err());
        assert!(MonthYear::new(13, 2026).is_err());
        assert!(MonthYear::new(1, 2026).is_ok());
        assert!(MonthYear::new(12, 2026).is_ok());
    }

    #[test]
    fn month_year_constructor_rejects_bad_year() {
        assert!(MonthYear::new(1, 1999).is_err());
        assert!(MonthYear::new(1, 2100).is_err());
        assert!(MonthYear::new(1, 2000).is_ok());
        assert!(MonthYear::new(1, 2099).is_ok());
    }

    #[test]
    fn month_year_round_trips_through_json() {
        let my = MonthYear::new(7, 2030).unwrap();
        let json = serde_json::to_string(&my).unwrap();
        let parsed: MonthYear = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed, my);
    }
}
```

- [ ] **Step 2: Wire into lib.rs**

Add after the `pub mod ids;` line:

```rust
pub mod time;
pub use time::{now_unix, MonthYear};
```

- [ ] **Step 3: Run tests**

Run: `cargo test -p relicario-core --lib time:: 2>&1 | tail -20`
Expected: 4 tests passed.

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-core/src/time.rs crates/relicario-core/src/lib.rs
git commit -m "$(cat <<'EOF'
feat(core): add now_unix() and MonthYear

MonthYear used for card expiries; bounds 2000..=2099 are intentional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: Error type rewrite (opaque Decrypt + new variants)

**Files:**
- Modify: `crates/relicario-core/src/error.rs`

- [ ] **Step 1: Write failing test**

Append to `crates/relicario-core/src/error.rs` (inside a new `#[cfg(test)] mod tests` block at the end):

```rust
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn decrypt_error_message_is_opaque() {
        let err = RelicarioError::Decrypt;
        assert_eq!(format!("{}", err), "decryption failed");
    }

    #[test]
    fn weak_passphrase_carries_score() {
        let err = RelicarioError::WeakPassphrase { score: 1 };
        let s = format!("{}", err);
        assert!(s.contains("passphrase"));
        assert!(s.contains("strength"));
    }

    #[test]
    fn attachment_too_large_reports_sizes() {
        let err = RelicarioError::AttachmentTooLarge { size: 11_000_000, max: 10_485_760 };
        let s = format!("{}", err);
        assert!(s.contains("11000000"));
        assert!(s.contains("10485760"));
    }

    #[test]
    fn item_not_found_carries_id() {
        let err = RelicarioError::ItemNotFound("abc123".to_string());
        assert!(format!("{}", err).contains("abc123"));
    }

    #[test]
    fn unsupported_format_version_reports_byte() {
        let err = RelicarioError::UnsupportedFormatVersion { found: 0x01, expected: 0x02 };
        let s = format!("{}", err);
        assert!(s.contains("01") || s.contains("1"));
        assert!(s.contains("02") || s.contains("2"));
    }
}
```

- [ ] **Step 2: Run tests, expect fail (variants don't exist yet)**

Run: `cargo test -p relicario-core --lib error:: 2>&1 | tail -30`
Expected: compile errors about missing variants `WeakPassphrase`, `AttachmentTooLarge`, `ItemNotFound`, `UnsupportedFormatVersion`, plus the existing `Decrypt` message check failing.

- [ ] **Step 3: Update RelicarioError variants**

Replace the `RelicarioError` enum in `crates/relicario-core/src/error.rs` with:

```rust
#[derive(Debug, Error)]
pub enum RelicarioError {
    #[error("key derivation failed: {0}")]
    Kdf(String),

    #[error("encryption failed: {0}")]
    Encrypt(String),

    /// Authenticated decryption failed. Message intentionally opaque (audit M4).
    #[error("decryption failed")]
    Decrypt,

    #[error("invalid vault format: {0}")]
    Format(String),

    #[error("unsupported vault format version: found 0x{found:02x}, expected 0x{expected:02x}")]
    UnsupportedFormatVersion { found: u8, expected: u8 },

    /// An item was looked up by ID but does not exist in the manifest.
    #[error("item not found: {0}")]
    ItemNotFound(String),

    /// A passphrase failed the strength gate at vault creation (audit H3).
    #[error("passphrase strength insufficient (score {score}/4)")]
    WeakPassphrase { score: u8 },

    /// An attachment exceeded the per-attachment cap from VaultSettings.
    #[error("attachment too large: {size} bytes > {max} bytes max")]
    AttachmentTooLarge { size: u64, max: u64 },

    #[error("imgsecret: {0}")]
    ImgSecret(String),

    #[error("image too small: need at least {min_width}x{min_height}, got {actual_width}x{actual_height}")]
    ImageTooSmall {
        min_width: u32,
        min_height: u32,
        actual_width: u32,
        actual_height: u32,
    },

    #[error("extraction failed: no valid secret found in image")]
    ExtractionFailed,

    #[error("json error: {0}")]
    Json(#[from] serde_json::Error),

    #[error("device key error: {0}")]
    DeviceKey(String),
}
```

Keep the `pub type Result<T> = std::result::Result<T, RelicarioError>;` line at the end.

- [ ] **Step 4: Run tests, expect pass**

Run: `cargo test -p relicario-core --lib error:: 2>&1 | tail -30`
Expected: 5 tests passed. The `entry.rs` module may now have warnings about the removed `EntryNotFound` variant — ignore for now; Task 25 deletes `entry.rs`.

If `entry.rs` won't compile due to `EntryNotFound`, search-and-replace `RelicarioError::EntryNotFound` → `RelicarioError::ItemNotFound` in `entry.rs`, `vault.rs`, and any other file in the crate that references it. Re-run the tests above.

- [ ] **Step 5: Commit**

```bash
git add crates/relicario-core/src/error.rs crates/relicario-core/src/entry.rs crates/relicario-core/src/vault.rs
git commit -m "$(cat <<'EOF'
refactor(core): rewrite RelicarioError variants for typed items

- Decrypt is now opaque (audit M4)
- Add WeakPassphrase, AttachmentTooLarge, ItemNotFound, UnsupportedFormatVersion
- Rename EntryNotFound → ItemNotFound across remaining call sites

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 4: Crypto — length-prefixed Argon2 input + Zeroize + NFC

**Files:**
- Modify: `crates/relicario-core/src/crypto.rs`

- [ ] **Step 1: Add failing test for length-prefix construction**

Append inside the existing `#[cfg(test)] mod tests` block in `crates/relicario-core/src/crypto.rs`:

```rust
#[test]
fn length_prefix_eliminates_concatenation_ambiguity() {
    // Without length-prefix: ("abc", [0x44, ...]) and ("abcD", [...]) could collide.
    // With length-prefix: distinct inputs always yield distinct keys.
    let salt = [0u8; 32];
    let params = fast_params();

    // Pair A: passphrase "abc", image_secret starts with 0x44
    let mut img_a = [0u8; 32]; img_a[0] = 0x44;
    let key_a = derive_master_key(b"abc", &img_a, &salt, &params).unwrap();

    // Pair B: passphrase "abcD" (one extra char), image_secret starts with original byte 1
    let mut img_b = [0u8; 32]; img_b[0] = 0x44;  // same image
    let key_b = derive_master_key(b"abcD", &img_b, &salt, &params).unwrap();

    // With length-prefix, the keys MUST differ.
    assert_ne!(*key_a, *key_b);
}

#[test]
fn nfc_normalization_collapses_unicode_forms() {
    // "café" can be written as NFC (é = U+00E9) or NFD (e + U+0301).
    // Both must produce the same key after NFC normalization.
    let salt = [0u8; 32];
    let img = [0u8; 32];
    let params = fast_params();

    let nfc = "caf\u{00e9}".as_bytes();  // é precomposed
    let nfd = "cafe\u{0301}".as_bytes(); // e + combining acute

    let key_nfc = derive_master_key(nfc, &img, &salt, &params).unwrap();
    let key_nfd = derive_master_key(nfd, &img, &salt, &params).unwrap();

    assert_eq!(*key_nfc, *key_nfd);
}

#[test]
fn master_key_is_zeroized_on_drop() {
    // Smoke test: master_key returns a Zeroizing<[u8; 32]>, which compiles only if
    // we wrap correctly. The drop wipe is verified by the zeroize crate's tests.
    let salt = [0u8; 32];
    let img = [0u8; 32];
    let params = fast_params();
    let key: zeroize::Zeroizing<[u8; 32]> = derive_master_key(b"x", &img, &salt, &params).unwrap();
    assert_eq!(key.len(), 32);
}
```

- [ ] **Step 2: Run tests, expect fail (signature still returns `[u8; 32]`)**

Run: `cargo test -p relicario-core --lib crypto::tests::length_prefix_eliminates_concatenation_ambiguity 2>&1 | tail -10`
Expected: compile error about `Zeroizing<[u8; 32]>` mismatch with current `[u8; 32]` return; or the NFC test fails because no normalization is applied.

- [ ] **Step 3: Update `derive_master_key` signature and body**

Replace the `derive_master_key` function in `crates/relicario-core/src/crypto.rs` with:

```rust
use unicode_normalization::UnicodeNormalization;
use zeroize::Zeroizing;

pub fn derive_master_key(
    passphrase: &[u8],
    image_secret: &[u8; 32],
    salt: &[u8; 32],
    params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
    let argon2_params = Params::new(
        params.argon2_m,
        params.argon2_t,
        params.argon2_p,
        Some(32),
    )
    .map_err(|e| RelicarioError::Kdf(e.to_string()))?;

    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);

    // Normalize passphrase to NFC. Invalid UTF-8 bytes pass through unchanged.
    let nfc_passphrase: Vec<u8> = match std::str::from_utf8(passphrase) {
        Ok(s) => s.nfc().collect::<String>().into_bytes(),
        Err(_) => passphrase.to_vec(),
    };

    // Length-prefixed concatenation: [u64_be(len(passphrase))][passphrase]
    //                                 [u64_be(32)][image_secret]
    // Eliminates the (passphrase, image_secret) boundary ambiguity (audit H1).
    let mut password = Zeroizing::new(Vec::with_capacity(8 + nfc_passphrase.len() + 8 + 32));
    password.extend_from_slice(&(nfc_passphrase.len() as u64).to_be_bytes());
    password.extend_from_slice(&nfc_passphrase);
    password.extend_from_slice(&32u64.to_be_bytes());
    password.extend_from_slice(image_secret);

    let mut output = Zeroizing::new([0u8; 32]);
    argon2
        .hash_password_into(password.as_slice(), salt, output.as_mut())
        .map_err(|e| RelicarioError::Kdf(e.to_string()))?;

    Ok(output)
}
```

Existing tests will need their `derive_master_key` results dereferenced — the original assertions `assert_eq!(key1, key2)` still work because `Zeroizing<[u8; 32]>` derefs to `[u8; 32]` and `PartialEq` propagates. If any test fails to compile, change `assert_eq!(key1, key2)` to `assert_eq!(*key1, *key2)`.

- [ ] **Step 4: Run all crypto tests**

Run: `cargo test -p relicario-core --lib crypto:: 2>&1 | tail -30`
Expected: all crypto tests pass (including the existing ones and the three new ones).

- [ ] **Step 5: Commit**

```bash
git add crates/relicario-core/src/crypto.rs
git commit -m "$(cat <<'EOF'
feat(core): length-prefixed Argon2 input + NFC + Zeroize (audit H1, H2)

derive_master_key now:
- length-prefixes passphrase and image_secret to eliminate concatenation
  ambiguity (H1)
- normalizes passphrase to UTF-8 NFC before hashing
- returns Zeroizing<[u8; 32]> so the master key is wiped on drop (H2)
- wraps the intermediate password buffer in Zeroizing for the same reason

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 5: Crypto — VERSION_BYTE bump to 0x02 and explicit version error

**Files:**
- Modify: `crates/relicario-core/src/crypto.rs`

- [ ] **Step 1: Find the current VERSION_BYTE declaration**

Run: `grep -n VERSION_BYTE crates/relicario-core/src/crypto.rs`
Expected: a const like `pub const VERSION_BYTE: u8 = 0x01;` and references in `encrypt`/`decrypt`.

- [ ] **Step 2: Add failing test**

Append in the `crypto::tests` block:

```rust
#[test]
fn version_byte_is_0x02() {
    assert_eq!(VERSION_BYTE, 0x02);
}

#[test]
fn decrypt_rejects_v1_blob_with_typed_error() {
    // Construct a v1-style blob: [0x01][24 nonce bytes][16 tag bytes].
    let mut blob = vec![0x01u8];
    blob.extend_from_slice(&[0u8; 24]);
    blob.extend_from_slice(&[0u8; 16]);

    let key = Zeroizing::new([0u8; 32]);
    let err = decrypt(&blob, &key).expect_err("v1 blob should fail decrypt");
    match err {
        RelicarioError::UnsupportedFormatVersion { found, expected } => {
            assert_eq!(found, 0x01);
            assert_eq!(expected, 0x02);
        }
        other => panic!("expected UnsupportedFormatVersion, got {:?}", other),
    }
}
```

- [ ] **Step 3: Run tests, expect fail**

Run: `cargo test -p relicario-core --lib crypto::tests::version_byte_is_0x02 crypto::tests::decrypt_rejects_v1_blob_with_typed_error 2>&1 | tail -20`
Expected: first test fails (still 0x01), second test fails (current code returns `RelicarioError::Format(...)`).

- [ ] **Step 4: Update VERSION_BYTE and decrypt's version check**

In `crates/relicario-core/src/crypto.rs`:

1. Change `pub const VERSION_BYTE: u8 = 0x01;` → `pub const VERSION_BYTE: u8 = 0x02;`.
2. In `decrypt`, find the version-byte check (likely something like `if data[0] != VERSION_BYTE { return Err(RelicarioError::Format(...)) }`) and replace with:

```rust
if data[0] != VERSION_BYTE {
    return Err(RelicarioError::UnsupportedFormatVersion {
        found: data[0],
        expected: VERSION_BYTE,
    });
}
```

3. Update the `decrypt` signature to take `&Zeroizing<[u8; 32]>` instead of `&[u8; 32]` (so it composes with the new KDF return). Or keep `&[u8; 32]` and have callers `&*key` — pick the simpler call-site experience. **Recommendation: keep `&[u8; 32]` and have callers deref** — it minimizes churn.

- [ ] **Step 5: Run tests, expect pass**

Run: `cargo test -p relicario-core --lib crypto:: 2>&1 | tail -30`
Expected: all crypto tests pass. Existing round-trip tests using v0x01-encrypted fixtures may fail; those aren't expected to exist in-repo (tests generate fresh blobs), so this should be clean. If any pre-existing test fails because it hardcoded `0x01`, update to `0x02`.

- [ ] **Step 6: Commit**

```bash
git add crates/relicario-core/src/crypto.rs
git commit -m "$(cat <<'EOF'
feat(core): bump VERSION_BYTE to 0x02 with typed UnsupportedFormatVersion

Clean break from v1 — no migration. Decrypting a v1 blob now returns
RelicarioError::UnsupportedFormatVersion { found: 0x01, expected: 0x02 }.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 6: ItemType + ItemCore enum scaffold

**Files:**
- Create: `crates/relicario-core/src/item_types/mod.rs`
- Modify: `crates/relicario-core/src/lib.rs`

- [ ] **Step 1: Write `src/item_types/mod.rs` with placeholder cores**

```rust
//! Per-type "core" structs for typed items.
//!
//! Each variant lives in its own submodule. The `ItemCore` enum + match
//! exhaustiveness is the extension mechanism — adding a new variant later
//! means: create the submodule, add the enum variant, fix the match arms
//! the compiler points at, register the popup form (Plan 1C).

use serde::{Deserialize, Serialize};

pub mod login;
pub mod secure_note;
pub mod identity;
pub mod card;
pub mod key;
pub mod document;
pub mod totp;

pub use login::LoginCore;
pub use secure_note::SecureNoteCore;
pub use identity::IdentityCore;
pub use card::{CardCore, CardKind};
pub use key::KeyCore;
pub use document::DocumentCore;
pub use totp::{TotpCore, TotpConfig, TotpAlgorithm, TotpKind};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ItemType {
    Login,
    SecureNote,
    Identity,
    Card,
    Key,
    Document,
    Totp,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ItemCore {
    Login(LoginCore),
    SecureNote(SecureNoteCore),
    Identity(IdentityCore),
    Card(CardCore),
    Key(KeyCore),
    Document(DocumentCore),
    Totp(TotpCore),
}

impl ItemCore {
    pub fn item_type(&self) -> ItemType {
        match self {
            ItemCore::Login(_) => ItemType::Login,
            ItemCore::SecureNote(_) => ItemType::SecureNote,
            ItemCore::Identity(_) => ItemType::Identity,
            ItemCore::Card(_) => ItemType::Card,
            ItemCore::Key(_) => ItemType::Key,
            ItemCore::Document(_) => ItemType::Document,
            ItemCore::Totp(_) => ItemType::Totp,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn item_type_serializes_snake_case() {
        let json = serde_json::to_string(&ItemType::SecureNote).unwrap();
        assert_eq!(json, "\"secure_note\"");
    }
}
```

- [ ] **Step 2: Create empty submodule files (one per type) with `pub struct` stubs so `mod.rs` compiles**

Each file gets a minimal struct stub that we'll flesh out in subsequent tasks. The compiler must be happy after this step.

`crates/relicario-core/src/item_types/login.rs`:
```rust
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LoginCore {}
```

`crates/relicario-core/src/item_types/secure_note.rs`:
```rust
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SecureNoteCore {}
```

`crates/relicario-core/src/item_types/identity.rs`:
```rust
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct IdentityCore {}
```

`crates/relicario-core/src/item_types/card.rs`:
```rust
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CardCore {}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CardKind {
    #[default]
    Credit,
    Debit,
    Gift,
    Loyalty,
    Other,
}
```

`crates/relicario-core/src/item_types/key.rs`:
```rust
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct KeyCore {}
```

`crates/relicario-core/src/item_types/document.rs`:
```rust
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DocumentCore {}
```

`crates/relicario-core/src/item_types/totp.rs`:
```rust
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TotpCore {}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TotpConfig {}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TotpAlgorithm {
    #[default]
    Sha1,
    Sha256,
    Sha512,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TotpKind {
    Totp,
    Hotp { counter: u64 },
    Steam,
}

impl Default for TotpKind {
    fn default() -> Self { TotpKind::Totp }
}
```

- [ ] **Step 3: Wire item_types into lib.rs**

Add to `crates/relicario-core/src/lib.rs` after the `pub mod time;` line:

```rust
pub mod item_types;
pub use item_types::{ItemCore, ItemType};
```

- [ ] **Step 4: Run tests**

Run: `cargo test -p relicario-core --lib item_types:: 2>&1 | tail -10`
Expected: 1 test passed.

- [ ] **Step 5: Commit**

```bash
git add crates/relicario-core/src/item_types/ crates/relicario-core/src/lib.rs
git commit -m "$(cat <<'EOF'
feat(core): scaffold item_types module with ItemType + ItemCore enum

Stub LoginCore, SecureNoteCore, IdentityCore, CardCore, KeyCore,
DocumentCore, TotpCore. Tag-based serde representation with snake_case
discriminants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 7: Flesh out LoginCore

**Files:**
- Modify: `crates/relicario-core/src/item_types/login.rs`

- [ ] **Step 1: Write failing tests in `login.rs`**

Replace `crates/relicario-core/src/item_types/login.rs` with:

```rust
//! Login item core: username, password (Zeroizing), URL, optional TOTP.

use serde::{Deserialize, Serialize};
use url::Url;
use zeroize::Zeroizing;

use crate::item_types::TotpConfig;

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LoginCore {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub username: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub password: Option<Zeroizing<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<Url>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub totp: Option<TotpConfig>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_login_round_trips() {
        let login = LoginCore::default();
        let json = serde_json::to_string(&login).unwrap();
        let parsed: LoginCore = serde_json::from_str(&json).unwrap();
        assert!(parsed.username.is_none());
        assert!(parsed.password.is_none());
    }

    #[test]
    fn full_login_round_trips() {
        let login = LoginCore {
            username: Some("alice".into()),
            password: Some(Zeroizing::new("hunter2".into())),
            url: Some(Url::parse("https://github.com/login").unwrap()),
            totp: None,
        };
        let json = serde_json::to_string(&login).unwrap();
        let parsed: LoginCore = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.username.as_deref(), Some("alice"));
        assert_eq!(parsed.password.as_deref().map(String::as_str), Some("hunter2"));
        assert_eq!(parsed.url.as_ref().map(Url::as_str), Some("https://github.com/login"));
    }

    #[test]
    fn omitted_fields_dont_appear_in_json() {
        let login = LoginCore {
            username: Some("alice".into()),
            password: None,
            url: None,
            totp: None,
        };
        let json = serde_json::to_string(&login).unwrap();
        assert!(!json.contains("password"));
        assert!(!json.contains("url"));
        assert!(!json.contains("totp"));
        assert!(json.contains("alice"));
    }
}
```

- [ ] **Step 2: Run tests**

Run: `cargo test -p relicario-core --lib item_types::login 2>&1 | tail -20`
Expected: 3 tests passed.

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-core/src/item_types/login.rs
git commit -m "$(cat <<'EOF'
feat(core): flesh out LoginCore with Zeroizing<password> and Url

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 8: Flesh out SecureNoteCore

**Files:**
- Modify: `crates/relicario-core/src/item_types/secure_note.rs`

- [ ] **Step 1: Write tests + impl together (small file)**

Replace `secure_note.rs`:

```rust
//! Secure note: just a multiline body, Zeroizing.

use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SecureNoteCore {
    pub body: Zeroizing<String>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn secure_note_round_trips() {
        let note = SecureNoteCore { body: Zeroizing::new("a multi\nline note".into()) };
        let json = serde_json::to_string(&note).unwrap();
        let parsed: SecureNoteCore = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.body.as_str(), "a multi\nline note");
    }

    #[test]
    fn empty_body_round_trips() {
        let note = SecureNoteCore::default();
        let json = serde_json::to_string(&note).unwrap();
        let parsed: SecureNoteCore = serde_json::from_str(&json).unwrap();
        assert!(parsed.body.is_empty());
    }
}
```

- [ ] **Step 2: Run tests**

Run: `cargo test -p relicario-core --lib item_types::secure_note 2>&1 | tail -10`
Expected: 2 tests passed.

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-core/src/item_types/secure_note.rs
git commit -m "feat(core): flesh out SecureNoteCore (Zeroizing body)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 9: Flesh out IdentityCore

**Files:**
- Modify: `crates/relicario-core/src/item_types/identity.rs`

- [ ] **Step 1: Write tests + impl**

Replace `identity.rs`:

```rust
//! Identity: name, address, phone, email, date-of-birth.

use chrono::NaiveDate;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct IdentityCore {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub full_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub address: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub phone: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub email: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub date_of_birth: Option<NaiveDate>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn identity_full_round_trip() {
        let id = IdentityCore {
            full_name: Some("Alice Doe".into()),
            address: Some("123 Main St\nAnytown".into()),
            phone: Some("+1-555-0100".into()),
            email: Some("alice@example.com".into()),
            date_of_birth: NaiveDate::from_ymd_opt(1990, 4, 18),
        };
        let json = serde_json::to_string(&id).unwrap();
        let parsed: IdentityCore = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.full_name.as_deref(), Some("Alice Doe"));
        assert_eq!(parsed.date_of_birth, NaiveDate::from_ymd_opt(1990, 4, 18));
    }

    #[test]
    fn empty_identity_omits_all_fields() {
        let id = IdentityCore::default();
        let json = serde_json::to_string(&id).unwrap();
        assert_eq!(json, "{}");
    }
}
```

- [ ] **Step 2: Run tests**

Run: `cargo test -p relicario-core --lib item_types::identity 2>&1 | tail -10`
Expected: 2 tests passed.

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-core/src/item_types/identity.rs
git commit -m "feat(core): flesh out IdentityCore

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 10: Flesh out CardCore

**Files:**
- Modify: `crates/relicario-core/src/item_types/card.rs`

- [ ] **Step 1: Write tests + impl**

Replace `card.rs`:

```rust
//! Card: number, holder, expiry (MonthYear), CVV, PIN, kind.

use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;

use crate::time::MonthYear;

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CardCore {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub number: Option<Zeroizing<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub holder: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub expiry: Option<MonthYear>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cvv: Option<Zeroizing<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pin: Option<Zeroizing<String>>,
    #[serde(default)]
    pub kind: CardKind,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CardKind {
    #[default]
    Credit,
    Debit,
    Gift,
    Loyalty,
    Other,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn card_full_round_trip() {
        let card = CardCore {
            number: Some(Zeroizing::new("4111111111111111".into())),
            holder: Some("Alice Doe".into()),
            expiry: Some(MonthYear::new(12, 2030).unwrap()),
            cvv: Some(Zeroizing::new("123".into())),
            pin: Some(Zeroizing::new("0000".into())),
            kind: CardKind::Credit,
        };
        let json = serde_json::to_string(&card).unwrap();
        let parsed: CardCore = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.holder.as_deref(), Some("Alice Doe"));
        assert_eq!(parsed.kind, CardKind::Credit);
        assert_eq!(parsed.expiry, Some(MonthYear::new(12, 2030).unwrap()));
    }

    #[test]
    fn card_kind_default_is_credit() {
        let json = "{}";
        let card: CardCore = serde_json::from_str(json).unwrap();
        assert_eq!(card.kind, CardKind::Credit);
    }

    #[test]
    fn card_kind_serializes_snake_case() {
        let json = serde_json::to_string(&CardKind::Loyalty).unwrap();
        assert_eq!(json, "\"loyalty\"");
    }
}
```

- [ ] **Step 2: Run tests**

Run: `cargo test -p relicario-core --lib item_types::card 2>&1 | tail -10`
Expected: 3 tests passed.

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-core/src/item_types/card.rs
git commit -m "feat(core): flesh out CardCore + CardKind

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 11: Flesh out KeyCore

**Files:**
- Modify: `crates/relicario-core/src/item_types/key.rs`

- [ ] **Step 1: Write tests + impl**

```rust
//! Key: arbitrary key material (Zeroizing), label, public key, algorithm.

use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct KeyCore {
    pub key_material: Zeroizing<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub public_key: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub algorithm: Option<String>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn key_round_trip() {
        let k = KeyCore {
            key_material: Zeroizing::new("-----BEGIN OPENSSH PRIVATE KEY-----\n...".into()),
            label: Some("yubikey-backup".into()),
            public_key: Some("ssh-ed25519 AAAAC3...".into()),
            algorithm: Some("ed25519".into()),
        };
        let json = serde_json::to_string(&k).unwrap();
        let parsed: KeyCore = serde_json::from_str(&json).unwrap();
        assert!(parsed.key_material.starts_with("-----BEGIN"));
        assert_eq!(parsed.algorithm.as_deref(), Some("ed25519"));
    }

    #[test]
    fn empty_key_material_round_trips() {
        let k = KeyCore::default();
        let json = serde_json::to_string(&k).unwrap();
        let parsed: KeyCore = serde_json::from_str(&json).unwrap();
        assert!(parsed.key_material.is_empty());
    }
}
```

- [ ] **Step 2: Run tests**

Run: `cargo test -p relicario-core --lib item_types::key 2>&1 | tail -10`
Expected: 2 tests passed.

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-core/src/item_types/key.rs
git commit -m "feat(core): flesh out KeyCore

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 12: Flesh out DocumentCore

**Files:**
- Modify: `crates/relicario-core/src/item_types/document.rs`

- [ ] **Step 1: Write tests + impl**

```rust
//! Document: filename + mime + pointer to the primary attachment blob.

use serde::{Deserialize, Serialize};

use crate::ids::AttachmentId;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocumentCore {
    pub filename: String,
    pub mime_type: String,
    pub primary_attachment: AttachmentId,
}

impl Default for DocumentCore {
    fn default() -> Self {
        Self {
            filename: String::new(),
            mime_type: "application/octet-stream".into(),
            primary_attachment: AttachmentId(String::new()),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn document_round_trip() {
        let doc = DocumentCore {
            filename: "passport.pdf".into(),
            mime_type: "application/pdf".into(),
            primary_attachment: AttachmentId("0123456789abcdef".into()),
        };
        let json = serde_json::to_string(&doc).unwrap();
        let parsed: DocumentCore = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.filename, "passport.pdf");
        assert_eq!(parsed.primary_attachment.as_str(), "0123456789abcdef");
    }
}
```

- [ ] **Step 2: Run tests**

Run: `cargo test -p relicario-core --lib item_types::document 2>&1 | tail -10`
Expected: 1 test passed.

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-core/src/item_types/document.rs
git commit -m "feat(core): flesh out DocumentCore

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 13: Flesh out TotpCore + TotpConfig

**Files:**
- Modify: `crates/relicario-core/src/item_types/totp.rs`

- [ ] **Step 1: Write tests + impl**

Replace `totp.rs`:

```rust
//! TOTP: standalone 2FA item type. Also reused as TotpConfig field on Login.

use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TotpCore {
    pub config: TotpConfig,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub issuer: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TotpConfig {
    /// Raw bytes of the TOTP secret (decoded from base32 when imported).
    pub secret: Zeroizing<Vec<u8>>,
    pub algorithm: TotpAlgorithm,
    pub digits: u8,
    pub period_seconds: u32,
    pub kind: TotpKind,
}

impl Default for TotpConfig {
    fn default() -> Self {
        Self {
            secret: Zeroizing::new(Vec::new()),
            algorithm: TotpAlgorithm::Sha1,
            digits: 6,
            period_seconds: 30,
            kind: TotpKind::Totp,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TotpAlgorithm {
    #[default]
    Sha1,
    Sha256,
    Sha512,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TotpKind {
    Totp,
    Hotp { counter: u64 },
    Steam,
}

impl Default for TotpKind {
    fn default() -> Self { TotpKind::Totp }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn totp_default_is_sha1_6_30_totp() {
        let cfg = TotpConfig::default();
        assert_eq!(cfg.algorithm, TotpAlgorithm::Sha1);
        assert_eq!(cfg.digits, 6);
        assert_eq!(cfg.period_seconds, 30);
        assert_eq!(cfg.kind, TotpKind::Totp);
    }

    #[test]
    fn totp_round_trip() {
        let core = TotpCore {
            config: TotpConfig {
                secret: Zeroizing::new(vec![0x12, 0x34, 0x56]),
                algorithm: TotpAlgorithm::Sha256,
                digits: 8,
                period_seconds: 60,
                kind: TotpKind::Totp,
            },
            issuer: Some("github".into()),
            label: Some("alice@github".into()),
        };
        let json = serde_json::to_string(&core).unwrap();
        let parsed: TotpCore = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.config.digits, 8);
        assert_eq!(parsed.config.algorithm, TotpAlgorithm::Sha256);
        assert_eq!(parsed.issuer.as_deref(), Some("github"));
    }

    #[test]
    fn hotp_carries_counter() {
        let cfg = TotpConfig { kind: TotpKind::Hotp { counter: 42 }, ..TotpConfig::default() };
        let json = serde_json::to_string(&cfg).unwrap();
        let parsed: TotpConfig = serde_json::from_str(&json).unwrap();
        match parsed.kind {
            TotpKind::Hotp { counter } => assert_eq!(counter, 42),
            other => panic!("expected Hotp, got {:?}", other),
        }
    }

    #[test]
    fn steam_kind_serializes() {
        let cfg = TotpConfig { kind: TotpKind::Steam, ..TotpConfig::default() };
        let json = serde_json::to_string(&cfg).unwrap();
        assert!(json.contains("steam"));
    }
}
```

- [ ] **Step 2: Run tests**

Run: `cargo test -p relicario-core --lib item_types::totp 2>&1 | tail -10`
Expected: 4 tests passed.

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-core/src/item_types/totp.rs
git commit -m "feat(core): flesh out TotpCore + TotpConfig + TotpAlgorithm + TotpKind

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 14: ItemCore round-trip test

**Files:**
- Modify: `crates/relicario-core/src/item_types/mod.rs`

- [ ] **Step 1: Add test exercising the tag-based ItemCore enum**

Append to the `tests` module in `crates/relicario-core/src/item_types/mod.rs`:

```rust
#[test]
fn item_core_login_round_trip_via_tag() {
    use zeroize::Zeroizing;
    let core = ItemCore::Login(LoginCore {
        username: Some("alice".into()),
        password: Some(Zeroizing::new("hunter2".into())),
        url: None,
        totp: None,
    });
    let json = serde_json::to_string(&core).unwrap();
    // Tag-based: outer object has "type": "login"
    assert!(json.contains("\"type\":\"login\""));
    let parsed: ItemCore = serde_json::from_str(&json).unwrap();
    assert_eq!(parsed.item_type(), ItemType::Login);
}

#[test]
fn item_core_secure_note_round_trip_via_tag() {
    use zeroize::Zeroizing;
    let core = ItemCore::SecureNote(SecureNoteCore { body: Zeroizing::new("hello".into()) });
    let json = serde_json::to_string(&core).unwrap();
    assert!(json.contains("\"type\":\"secure_note\""));
    let parsed: ItemCore = serde_json::from_str(&json).unwrap();
    assert_eq!(parsed.item_type(), ItemType::SecureNote);
}

#[test]
fn item_core_round_trips_for_all_seven_types() {
    use zeroize::Zeroizing;
    use crate::ids::AttachmentId;

    let cores = vec![
        ItemCore::Login(LoginCore::default()),
        ItemCore::SecureNote(SecureNoteCore::default()),
        ItemCore::Identity(IdentityCore::default()),
        ItemCore::Card(CardCore::default()),
        ItemCore::Key(KeyCore::default()),
        ItemCore::Document(DocumentCore {
            filename: "x".into(),
            mime_type: "text/plain".into(),
            primary_attachment: AttachmentId("0123456789abcdef".into()),
        }),
        ItemCore::Totp(TotpCore::default()),
    ];
    for core in cores {
        let expected_type = core.item_type();
        let json = serde_json::to_string(&core).unwrap();
        let parsed: ItemCore = serde_json::from_str(&json).expect("round-trip failed");
        assert_eq!(parsed.item_type(), expected_type);
    }
}
```

- [ ] **Step 2: Run tests**

Run: `cargo test -p relicario-core --lib item_types:: 2>&1 | tail -20`
Expected: all item_types tests pass (including the 3 new ones).

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-core/src/item_types/mod.rs
git commit -m "test(core): exhaustive round-trip for all seven ItemCore variants

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 15: FieldKind + FieldValue + Field

**Files:**
- Create: `crates/relicario-core/src/item.rs` (partial — Field/FieldKind/FieldValue/Section first)
- Modify: `crates/relicario-core/src/lib.rs`

- [ ] **Step 1: Write `src/item.rs` with FieldKind/FieldValue/Field/Section**

```rust
//! Item envelope, sections, and custom fields.
//!
//! `FieldKind` and `FieldValue` are kept as parallel enums (rather than collapsing
//! to a single tagged enum) so the kind can be queried without inspecting the value.
//! Validation invariant: kind and value's discriminants must match — enforced at
//! construction (`Field::new`) and during deserialization (`Field::validate`).

use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use url::Url;
use zeroize::Zeroizing;

use crate::error::{RelicarioError, Result};
use crate::ids::{AttachmentId, FieldId};
use crate::item_types::TotpConfig;
use crate::time::MonthYear;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FieldKind {
    Text,
    Multiline,
    Password,
    Concealed,
    Url,
    Email,
    Phone,
    Date,
    MonthYear,
    Totp,
    Reference,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum FieldValue {
    Text(String),
    Multiline(String),
    Password(Zeroizing<String>),
    Concealed(Zeroizing<String>),
    Url(Url),
    Email(String),
    Phone(String),
    Date(NaiveDate),
    MonthYear(MonthYear),
    Totp(TotpConfig),
    Reference(AttachmentId),
}

impl FieldValue {
    pub fn kind(&self) -> FieldKind {
        match self {
            FieldValue::Text(_) => FieldKind::Text,
            FieldValue::Multiline(_) => FieldKind::Multiline,
            FieldValue::Password(_) => FieldKind::Password,
            FieldValue::Concealed(_) => FieldKind::Concealed,
            FieldValue::Url(_) => FieldKind::Url,
            FieldValue::Email(_) => FieldKind::Email,
            FieldValue::Phone(_) => FieldKind::Phone,
            FieldValue::Date(_) => FieldKind::Date,
            FieldValue::MonthYear(_) => FieldKind::MonthYear,
            FieldValue::Totp(_) => FieldKind::Totp,
            FieldValue::Reference(_) => FieldKind::Reference,
        }
    }

    /// True if this kind triggers field-history capture on update.
    pub fn is_history_tracked(&self) -> bool {
        matches!(self, FieldValue::Password(_) | FieldValue::Concealed(_) | FieldValue::Totp(_))
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Field {
    pub id: FieldId,
    pub label: String,
    pub kind: FieldKind,
    pub value: FieldValue,
    #[serde(default)]
    pub hidden_by_default: bool,
}

impl Field {
    /// Construct a field, deriving `kind` from `value`.
    pub fn new(label: String, value: FieldValue) -> Self {
        let kind = value.kind();
        Self {
            id: FieldId::new(),
            label,
            kind,
            value,
            hidden_by_default: matches!(kind, FieldKind::Password | FieldKind::Concealed),
        }
    }

    /// Verify kind/value discriminants match. Called after deserialization.
    pub fn validate(&self) -> Result<()> {
        if self.kind != self.value.kind() {
            return Err(RelicarioError::Format(format!(
                "field {}: kind {:?} does not match value discriminant {:?}",
                self.id.as_str(),
                self.kind,
                self.value.kind()
            )));
        }
        Ok(())
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Section {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    pub fields: Vec<Field>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn field_value_kind_matches() {
        let v = FieldValue::Text("hello".into());
        assert_eq!(v.kind(), FieldKind::Text);
    }

    #[test]
    fn password_field_marked_history_tracked() {
        assert!(FieldValue::Password(Zeroizing::new("x".into())).is_history_tracked());
        assert!(FieldValue::Concealed(Zeroizing::new("x".into())).is_history_tracked());
        assert!(FieldValue::Totp(TotpConfig::default()).is_history_tracked());
        assert!(!FieldValue::Text("x".into()).is_history_tracked());
        assert!(!FieldValue::Url(Url::parse("https://example.com").unwrap()).is_history_tracked());
    }

    #[test]
    fn field_new_derives_kind_from_value() {
        let f = Field::new("Password".into(), FieldValue::Password(Zeroizing::new("x".into())));
        assert_eq!(f.kind, FieldKind::Password);
        assert!(f.hidden_by_default);
    }

    #[test]
    fn field_new_text_not_hidden() {
        let f = Field::new("Username".into(), FieldValue::Text("alice".into()));
        assert!(!f.hidden_by_default);
    }

    #[test]
    fn field_validate_catches_kind_value_mismatch() {
        let f = Field {
            id: FieldId::new(),
            label: "x".into(),
            kind: FieldKind::Password,
            value: FieldValue::Text("not actually a password".into()),
            hidden_by_default: false,
        };
        assert!(f.validate().is_err());
    }

    #[test]
    fn field_round_trips() {
        let f = Field::new("Recovery code".into(), FieldValue::Concealed(Zeroizing::new("abcd-efgh".into())));
        let json = serde_json::to_string(&f).unwrap();
        let parsed: Field = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.label, "Recovery code");
        assert_eq!(parsed.kind, FieldKind::Concealed);
        parsed.validate().unwrap();
    }

    #[test]
    fn section_round_trip() {
        let s = Section {
            name: Some("Recovery codes".into()),
            fields: vec![
                Field::new("code1".into(), FieldValue::Concealed(Zeroizing::new("abc".into()))),
                Field::new("code2".into(), FieldValue::Concealed(Zeroizing::new("def".into()))),
            ],
        };
        let json = serde_json::to_string(&s).unwrap();
        let parsed: Section = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.name.as_deref(), Some("Recovery codes"));
        assert_eq!(parsed.fields.len(), 2);
    }
}
```

- [ ] **Step 2: Wire into lib.rs**

Add after `pub mod item_types;`:

```rust
pub mod item;
pub use item::{Field, FieldKind, FieldValue, Section};
```

- [ ] **Step 3: Run tests**

Run: `cargo test -p relicario-core --lib item:: 2>&1 | tail -20`
Expected: 7 tests passed.

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-core/src/item.rs crates/relicario-core/src/lib.rs
git commit -m "$(cat <<'EOF'
feat(core): add Field, FieldKind, FieldValue, Section

Parallel kind/value enums with a validate() invariant. Password,
Concealed, and Totp kinds are marked history-tracked so the Item setter
(next task) can decide whether to capture history on update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 16: AttachmentRef + AttachmentSummary

**Files:**
- Create: `crates/relicario-core/src/attachment.rs`
- Modify: `crates/relicario-core/src/lib.rs`

- [ ] **Step 1: Write `src/attachment.rs`**

```rust
//! Attachment refs (carried on Item) and summaries (carried in Manifest).
//!
//! Encryption helpers (`encrypt_attachment`, `decrypt_attachment`) are added
//! later in Task 22 once the crypto module is settled.

use serde::{Deserialize, Serialize};

use crate::ids::AttachmentId;

/// Reference to an attachment, carried on the Item record.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttachmentRef {
    pub id: AttachmentId,
    pub filename: String,
    pub mime_type: String,
    /// Plaintext size in bytes.
    pub size: u64,
    /// Unix-seconds when this attachment was added.
    pub created: i64,
}

/// Compact summary of an attachment, carried in the Manifest so the popup
/// can show attachment indicators without decrypting the item file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttachmentSummary {
    pub id: AttachmentId,
    pub filename: String,
    pub mime_type: String,
    pub size: u64,
}

impl From<&AttachmentRef> for AttachmentSummary {
    fn from(r: &AttachmentRef) -> Self {
        Self {
            id: r.id.clone(),
            filename: r.filename.clone(),
            mime_type: r.mime_type.clone(),
            size: r.size,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn attachment_ref_round_trip() {
        let r = AttachmentRef {
            id: AttachmentId("0123456789abcdef".into()),
            filename: "doc.pdf".into(),
            mime_type: "application/pdf".into(),
            size: 12345,
            created: 1_700_000_000,
        };
        let json = serde_json::to_string(&r).unwrap();
        let parsed: AttachmentRef = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.filename, "doc.pdf");
        assert_eq!(parsed.size, 12345);
    }

    #[test]
    fn attachment_summary_from_ref() {
        let r = AttachmentRef {
            id: AttachmentId("aabb".into()),
            filename: "x.txt".into(),
            mime_type: "text/plain".into(),
            size: 5,
            created: 0,
        };
        let s: AttachmentSummary = (&r).into();
        assert_eq!(s.filename, "x.txt");
        assert_eq!(s.id, r.id);
    }
}
```

- [ ] **Step 2: Wire into lib.rs**

Add after `pub mod item;`:

```rust
pub mod attachment;
pub use attachment::{AttachmentRef, AttachmentSummary};
```

- [ ] **Step 3: Run tests**

Run: `cargo test -p relicario-core --lib attachment:: 2>&1 | tail -10`
Expected: 2 tests passed.

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-core/src/attachment.rs crates/relicario-core/src/lib.rs
git commit -m "feat(core): add AttachmentRef + AttachmentSummary

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 17: Item envelope + field history

**Files:**
- Modify: `crates/relicario-core/src/item.rs`

- [ ] **Step 1: Append Item, FieldHistoryEntry, and the setter**

Append to `crates/relicario-core/src/item.rs` (after the existing Section definition, before the test module):

```rust
use std::collections::HashMap;

use crate::attachment::AttachmentRef;
use crate::ids::ItemId;
use crate::item_types::{ItemCore, ItemType};
use crate::time::now_unix;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldHistoryEntry {
    pub value: Zeroizing<String>,
    pub replaced_at: i64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Item {
    pub id: ItemId,
    pub title: String,
    pub r#type: ItemType,
    #[serde(default)]
    pub tags: Vec<String>,
    #[serde(default)]
    pub favorite: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub group: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub notes: Option<String>,
    pub created: i64,
    pub modified: i64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub trashed_at: Option<i64>,
    pub core: ItemCore,
    #[serde(default)]
    pub sections: Vec<Section>,
    #[serde(default)]
    pub attachments: Vec<AttachmentRef>,
    #[serde(default)]
    pub field_history: HashMap<FieldId, Vec<FieldHistoryEntry>>,
}

impl Item {
    /// Construct a new Item from a typed core; auto-fills id, type, timestamps.
    pub fn new(title: String, core: ItemCore) -> Self {
        let now = now_unix();
        let r#type = core.item_type();
        Self {
            id: ItemId::new(),
            title,
            r#type,
            tags: Vec::new(),
            favorite: false,
            group: None,
            notes: None,
            created: now,
            modified: now,
            trashed_at: None,
            core,
            sections: Vec::new(),
            attachments: Vec::new(),
            field_history: HashMap::new(),
        }
    }

    /// Replace a custom field's value, capturing the previous value into
    /// field_history if the field's kind is history-tracked.
    pub fn set_field_value(&mut self, field_id: &FieldId, new_value: FieldValue) -> Result<()> {
        for section in &mut self.sections {
            if let Some(field) = section.fields.iter_mut().find(|f| &f.id == field_id) {
                if field.value.kind() != new_value.kind() {
                    return Err(RelicarioError::Format(format!(
                        "field {}: cannot change kind from {:?} to {:?}",
                        field.id.as_str(), field.value.kind(), new_value.kind()
                    )));
                }
                if field.value.is_history_tracked() {
                    let serialized = serialize_history_value(&field.value)?;
                    self.field_history
                        .entry(field.id.clone())
                        .or_default()
                        .push(FieldHistoryEntry { value: serialized, replaced_at: now_unix() });
                }
                field.value = new_value;
                self.modified = now_unix();
                return Ok(());
            }
        }
        Err(RelicarioError::Format(format!("field {} not found", field_id.as_str())))
    }

    pub fn soft_delete(&mut self) {
        self.trashed_at = Some(now_unix());
        self.modified = now_unix();
    }

    pub fn restore(&mut self) {
        self.trashed_at = None;
        self.modified = now_unix();
    }

    pub fn is_trashed(&self) -> bool {
        self.trashed_at.is_some()
    }
}

/// Serialize a FieldValue to the string form stored in field_history.
fn serialize_history_value(value: &FieldValue) -> Result<Zeroizing<String>> {
    let s = match value {
        FieldValue::Password(p) => Zeroizing::new(p.as_str().to_owned()),
        FieldValue::Concealed(c) => Zeroizing::new(c.as_str().to_owned()),
        FieldValue::Totp(cfg) => {
            // Store the base32-encoded secret string for human-recognizability.
            let s = base32_encode(&cfg.secret);
            Zeroizing::new(s)
        }
        _ => return Err(RelicarioError::Format("not a history-tracked kind".into())),
    };
    Ok(s)
}

/// Minimal RFC 4648 base32 (no padding) for TOTP secret history serialization.
fn base32_encode(bytes: &[u8]) -> String {
    const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
    let mut out = String::new();
    let mut buffer: u32 = 0;
    let mut bits: u32 = 0;
    for &b in bytes {
        buffer = (buffer << 8) | (b as u32);
        bits += 8;
        while bits >= 5 {
            let idx = ((buffer >> (bits - 5)) & 0x1f) as usize;
            out.push(ALPHA[idx] as char);
            bits -= 5;
        }
    }
    if bits > 0 {
        let idx = ((buffer << (5 - bits)) & 0x1f) as usize;
        out.push(ALPHA[idx] as char);
    }
    out
}
```

- [ ] **Step 2: Append tests for Item**

Append to the existing `tests` module in `crates/relicario-core/src/item.rs`:

```rust
#[test]
fn new_item_has_timestamps_and_id() {
    let core = ItemCore::SecureNote(crate::item_types::SecureNoteCore::default());
    let item = Item::new("note".into(), core);
    assert_eq!(item.id.0.len(), 16);
    assert_eq!(item.r#type, ItemType::SecureNote);
    assert!(item.created > 0);
    assert_eq!(item.created, item.modified);
    assert!(item.field_history.is_empty());
}

#[test]
fn soft_delete_and_restore_round_trip() {
    let core = ItemCore::Login(crate::item_types::LoginCore::default());
    let mut item = Item::new("login".into(), core);
    assert!(!item.is_trashed());
    item.soft_delete();
    assert!(item.is_trashed());
    item.restore();
    assert!(!item.is_trashed());
}

#[test]
fn set_field_value_captures_history_for_password() {
    let core = ItemCore::Login(crate::item_types::LoginCore::default());
    let mut item = Item::new("login".into(), core);
    let pw_field = Field::new("Password".into(), FieldValue::Password(Zeroizing::new("old".into())));
    let pw_id = pw_field.id.clone();
    item.sections.push(Section { name: None, fields: vec![pw_field] });

    item.set_field_value(&pw_id, FieldValue::Password(Zeroizing::new("new".into()))).unwrap();
    let hist = item.field_history.get(&pw_id).expect("history should exist");
    assert_eq!(hist.len(), 1);
    assert_eq!(hist[0].value.as_str(), "old");
}

#[test]
fn set_field_value_does_not_capture_history_for_text() {
    let core = ItemCore::Login(crate::item_types::LoginCore::default());
    let mut item = Item::new("login".into(), core);
    let f = Field::new("nickname".into(), FieldValue::Text("a".into()));
    let fid = f.id.clone();
    item.sections.push(Section { name: None, fields: vec![f] });

    item.set_field_value(&fid, FieldValue::Text("b".into())).unwrap();
    assert!(item.field_history.get(&fid).is_none_or(|v| v.is_empty()));
}

#[test]
fn set_field_value_rejects_kind_change() {
    let core = ItemCore::Login(crate::item_types::LoginCore::default());
    let mut item = Item::new("login".into(), core);
    let f = Field::new("x".into(), FieldValue::Text("a".into()));
    let fid = f.id.clone();
    item.sections.push(Section { name: None, fields: vec![f] });

    let err = item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("p".into())));
    assert!(err.is_err());
}

#[test]
fn item_serializes_with_minimal_optional_fields() {
    let core = ItemCore::SecureNote(crate::item_types::SecureNoteCore::default());
    let item = Item::new("note".into(), core);
    let json = serde_json::to_string(&item).unwrap();
    // No "trashed_at" or "group" or "notes" should appear when None
    assert!(!json.contains("trashed_at"));
    assert!(!json.contains("\"group\""));
}

#[test]
fn full_item_round_trip() {
    let core = ItemCore::Login(crate::item_types::LoginCore {
        username: Some("alice".into()),
        password: Some(Zeroizing::new("hunter2".into())),
        url: Some(Url::parse("https://github.com").unwrap()),
        totp: None,
    });
    let mut item = Item::new("GitHub".into(), core);
    item.tags = vec!["work".into()];
    item.favorite = true;
    item.notes = Some("notes".into());

    let json = serde_json::to_string(&item).unwrap();
    let parsed: Item = serde_json::from_str(&json).unwrap();
    assert_eq!(parsed.title, "GitHub");
    assert_eq!(parsed.tags, vec!["work".to_string()]);
    assert!(parsed.favorite);
    match parsed.core {
        ItemCore::Login(l) => {
            assert_eq!(l.username.as_deref(), Some("alice"));
        }
        other => panic!("expected Login, got {:?}", other),
    }
}
```

- [ ] **Step 3: Wire into lib.rs**

Add `Item, FieldHistoryEntry` to the existing item re-export line:

```rust
pub use item::{Field, FieldHistoryEntry, FieldKind, FieldValue, Item, Section};
```

- [ ] **Step 4: Run tests**

Run: `cargo test -p relicario-core --lib item:: 2>&1 | tail -30`
Expected: all item tests pass (7 original + 7 new = 14).

- [ ] **Step 5: Commit**

```bash
git add crates/relicario-core/src/item.rs crates/relicario-core/src/lib.rs
git commit -m "$(cat <<'EOF'
feat(core): add Item envelope with field history + soft-delete

set_field_value() captures old values for Password, Concealed, and Totp
kinds. Soft-delete via trashed_at timestamp; restore clears it. Kind
changes on set_field_value are rejected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 18: Manifest rewrite

**Files:**
- Modify: `crates/relicario-core/src/manifest.rs` (rename from a future migration; for now write to a new file)
- Create: `crates/relicario-core/src/manifest.rs`
- Modify: `crates/relicario-core/src/lib.rs`

> Note: `entry.rs` currently holds the old Manifest. Don't touch it until Task 25 — it'll keep compiling alongside the new module.

- [ ] **Step 1: Write `src/manifest.rs`**

```rust
//! New typed-item manifest. Lives next to the old entry.rs Manifest
//! during this rewrite; entry.rs is deleted in Task 25.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;

use crate::attachment::AttachmentSummary;
use crate::ids::ItemId;
use crate::item::Item;
use crate::item_types::ItemType;

pub const MANIFEST_SCHEMA_VERSION: u32 = 2;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
    pub schema_version: u32,
    pub items: HashMap<ItemId, ManifestEntry>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestEntry {
    pub id: ItemId,
    pub r#type: ItemType,
    pub title: String,
    #[serde(default)]
    pub tags: Vec<String>,
    #[serde(default)]
    pub favorite: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub group: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub icon_hint: Option<String>,
    pub modified: i64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub trashed_at: Option<i64>,
    #[serde(default)]
    pub attachment_summaries: Vec<AttachmentSummary>,
}

impl Manifest {
    pub fn new() -> Self {
        Self { schema_version: MANIFEST_SCHEMA_VERSION, items: HashMap::new() }
    }

    pub fn upsert(&mut self, item: &Item) {
        let entry = ManifestEntry::from_item(item);
        self.items.insert(item.id.clone(), entry);
    }

    pub fn remove(&mut self, id: &ItemId) -> Option<ManifestEntry> {
        self.items.remove(id)
    }

    pub fn get(&self, id: &ItemId) -> Option<&ManifestEntry> {
        self.items.get(id)
    }

    /// Case-insensitive substring match on title and tags.
    pub fn search(&self, query: &str) -> Vec<&ManifestEntry> {
        let q = query.to_lowercase();
        self.items
            .values()
            .filter(|e| {
                e.title.to_lowercase().contains(&q)
                    || e.tags.iter().any(|t| t.to_lowercase().contains(&q))
            })
            .collect()
    }
}

impl Default for Manifest {
    fn default() -> Self { Self::new() }
}

impl ManifestEntry {
    pub fn from_item(item: &Item) -> Self {
        Self {
            id: item.id.clone(),
            r#type: item.r#type,
            title: item.title.clone(),
            tags: item.tags.clone(),
            favorite: item.favorite,
            group: item.group.clone(),
            icon_hint: derive_icon_hint(item),
            modified: item.modified,
            trashed_at: item.trashed_at,
            attachment_summaries: item.attachments.iter().map(Into::into).collect(),
        }
    }
}

/// Derive an icon hint string from an item — for Login items, this is the URL hostname.
fn derive_icon_hint(item: &Item) -> Option<String> {
    use crate::item_types::ItemCore;
    match &item.core {
        ItemCore::Login(l) => l.url.as_ref().and_then(|u| u.host_str().map(str::to_owned)),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::item_types::{ItemCore, LoginCore, SecureNoteCore};

    #[test]
    fn empty_manifest_has_schema_v2() {
        let m = Manifest::new();
        assert_eq!(m.schema_version, MANIFEST_SCHEMA_VERSION);
        assert!(m.items.is_empty());
    }

    #[test]
    fn upsert_and_search() {
        let mut m = Manifest::new();
        let mut item = Item::new("GitHub".into(), ItemCore::Login(LoginCore::default()));
        item.tags = vec!["work".into()];
        m.upsert(&item);

        let results = m.search("github");
        assert_eq!(results.len(), 1);
        let by_tag = m.search("work");
        assert_eq!(by_tag.len(), 1);
    }

    #[test]
    fn icon_hint_is_login_url_host() {
        use url::Url;
        let mut m = Manifest::new();
        let core = ItemCore::Login(LoginCore {
            url: Some(Url::parse("https://api.github.com/login").unwrap()),
            ..Default::default()
        });
        let item = Item::new("X".into(), core);
        m.upsert(&item);
        let entry = m.items.values().next().unwrap();
        assert_eq!(entry.icon_hint.as_deref(), Some("api.github.com"));
    }

    #[test]
    fn icon_hint_is_none_for_non_login() {
        let mut m = Manifest::new();
        let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore::default()));
        m.upsert(&item);
        let entry = m.items.values().next().unwrap();
        assert!(entry.icon_hint.is_none());
    }

    #[test]
    fn manifest_round_trips() {
        let mut m = Manifest::new();
        let item = Item::new("X".into(), ItemCore::SecureNote(SecureNoteCore::default()));
        m.upsert(&item);
        let json = serde_json::to_string(&m).unwrap();
        let parsed: Manifest = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.schema_version, MANIFEST_SCHEMA_VERSION);
        assert_eq!(parsed.items.len(), 1);
    }
}

use crate::item::Item as _;  // ensure import compiles even if unused above
```

The trailing `use` is paranoid — remove it if it triggers a warning.

- [ ] **Step 2: Wire into lib.rs**

Add after `pub mod attachment;`:

```rust
pub mod manifest;
pub use manifest::{Manifest, ManifestEntry, MANIFEST_SCHEMA_VERSION};
```

This will conflict with the existing `pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};` line. Since `entry.rs` is going away in Task 25, **remove the conflicting types from the entry re-export now**:

Change the existing `pub use entry::{...}` line to:

```rust
pub use entry::{generate_entry_id, Entry};
```

Anything else that imports `Manifest` or `ManifestEntry` directly from `relicario_core::entry` will break. Find and update with:

```bash
grep -rn "relicario_core::entry::\(Manifest\|ManifestEntry\)\|entry::\(Manifest\|ManifestEntry\)" crates/relicario-core/src/
```

Update those imports to `crate::manifest::Manifest` etc. The most likely culprit is `vault.rs`. If `vault.rs` won't compile, leave it — Task 21 rewrites it.

- [ ] **Step 3: Run new manifest tests**

Run: `cargo test -p relicario-core --lib manifest:: 2>&1 | tail -20`
Expected: 5 tests passed.

The crate as a whole may not compile yet because `vault.rs` references the old `Manifest` from `entry.rs`. Run `cargo check -p relicario-core 2>&1 | tail -20` to confirm the only errors are in `vault.rs`. We'll fix `vault.rs` in Task 21.

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-core/src/manifest.rs crates/relicario-core/src/lib.rs
git commit -m "$(cat <<'EOF'
feat(core): add typed-item Manifest with schema_version 2

ManifestEntry holds the per-item browse summary including derived
icon_hint (Login URL hostname) and attachment_summaries. Search matches
title or tag substring case-insensitively.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 19: Settings module

**Files:**
- Create: `crates/relicario-core/src/settings.rs`
- Modify: `crates/relicario-core/src/lib.rs`

- [ ] **Step 1: Write `src/settings.rs`**

```rust
//! Vault-level settings: trash retention, history retention, generator
//! defaults, attachment caps, autofill TOFU acks.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VaultSettings {
    pub trash_retention: TrashRetention,
    pub field_history_retention: HistoryRetention,
    pub generator_defaults: GeneratorRequest,
    pub attachment_caps: AttachmentCaps,
    /// hostname → unix-seconds first-acked
    #[serde(default)]
    pub autofill_origin_acks: HashMap<String, i64>,
}

impl Default for VaultSettings {
    fn default() -> Self {
        Self {
            trash_retention: TrashRetention::Days(30),
            field_history_retention: HistoryRetention::Forever,
            generator_defaults: GeneratorRequest::default(),
            attachment_caps: AttachmentCaps::default(),
            autofill_origin_acks: HashMap::new(),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TrashRetention {
    Days(u32),
    Forever,
}

impl TrashRetention {
    pub fn should_purge(&self, trashed_at: i64, now: i64) -> bool {
        match self {
            TrashRetention::Forever => false,
            TrashRetention::Days(d) => now - trashed_at > (*d as i64) * 86_400,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum HistoryRetention {
    LastN(u32),
    Days(u32),
    Forever,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum GeneratorRequest {
    Bip39 {
        word_count: u32,
        separator: String,
        capitalization: Capitalization,
    },
    Random {
        length: u32,
        classes: CharClasses,
        symbol_charset: SymbolCharset,
    },
}

impl Default for GeneratorRequest {
    fn default() -> Self {
        GeneratorRequest::Random {
            length: 20,
            classes: CharClasses { lower: true, upper: true, digits: true, symbols: true },
            symbol_charset: SymbolCharset::SafeOnly,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Capitalization {
    Lower,
    Upper,
    FirstOfEach,
    Title,
    Mixed,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct CharClasses {
    pub lower: bool,
    pub upper: bool,
    pub digits: bool,
    pub symbols: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SymbolCharset {
    SafeOnly,
    Extended,
    Custom(String),
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct AttachmentCaps {
    pub per_attachment_max_bytes: u64,
    pub per_item_max_count: u32,
    pub per_vault_soft_cap_bytes: u64,
    pub per_vault_hard_cap_bytes: u64,
}

impl Default for AttachmentCaps {
    fn default() -> Self {
        Self {
            per_attachment_max_bytes: 10 * 1024 * 1024,
            per_item_max_count: 20,
            per_vault_soft_cap_bytes: 100 * 1024 * 1024,
            per_vault_hard_cap_bytes: 500 * 1024 * 1024,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn defaults_match_spec() {
        let s = VaultSettings::default();
        assert!(matches!(s.trash_retention, TrashRetention::Days(30)));
        assert!(matches!(s.field_history_retention, HistoryRetention::Forever));
        assert_eq!(s.attachment_caps.per_attachment_max_bytes, 10 * 1024 * 1024);
        assert_eq!(s.attachment_caps.per_item_max_count, 20);
    }

    #[test]
    fn trash_retention_purges_after_days() {
        let r = TrashRetention::Days(30);
        let now = 1_000_000_000;
        let recently_trashed = now - 29 * 86_400;
        let long_trashed = now - 31 * 86_400;
        assert!(!r.should_purge(recently_trashed, now));
        assert!(r.should_purge(long_trashed, now));
    }

    #[test]
    fn trash_retention_forever_never_purges() {
        let r = TrashRetention::Forever;
        assert!(!r.should_purge(0, 1_000_000_000));
    }

    #[test]
    fn settings_round_trip() {
        let s = VaultSettings::default();
        let json = serde_json::to_string(&s).unwrap();
        let parsed: VaultSettings = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.attachment_caps.per_attachment_max_bytes,
                   s.attachment_caps.per_attachment_max_bytes);
    }

    #[test]
    fn random_generator_default_is_20_safe() {
        match VaultSettings::default().generator_defaults {
            GeneratorRequest::Random { length, classes, symbol_charset } => {
                assert_eq!(length, 20);
                assert!(classes.lower && classes.upper && classes.digits && classes.symbols);
                assert!(matches!(symbol_charset, SymbolCharset::SafeOnly));
            }
            _ => panic!("expected Random default"),
        }
    }
}
```

- [ ] **Step 2: Wire into lib.rs**

Add after `pub mod manifest;`:

```rust
pub mod settings;
pub use settings::{
    AttachmentCaps, Capitalization, CharClasses, GeneratorRequest, HistoryRetention,
    SymbolCharset, TrashRetention, VaultSettings,
};
```

- [ ] **Step 3: Run tests**

Run: `cargo test -p relicario-core --lib settings:: 2>&1 | tail -20`
Expected: 5 tests passed.

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-core/src/settings.rs crates/relicario-core/src/lib.rs
git commit -m "feat(core): add VaultSettings with retention + generator + caps

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 20: Generators — random password (unbiased)

**Files:**
- Create: `crates/relicario-core/src/generators.rs`
- Modify: `crates/relicario-core/src/lib.rs`

- [ ] **Step 1: Write `src/generators.rs` with random-password impl**

```rust
//! Password and passphrase generators. CSPRNG-only; rejection-sampled to
//! eliminate modulo bias. Strength rating via zxcvbn.

use rand::distributions::{Distribution, Uniform};
use rand::rngs::OsRng;
use zeroize::Zeroizing;

use crate::error::{RelicarioError, Result};
use crate::settings::{CharClasses, GeneratorRequest, SymbolCharset};

const SAFE_SYMBOLS: &[u8] = b"!@#$%^&*-_=+";
const EXTENDED_SYMBOLS: &[u8] = b"!@#$%^&*-_=+~?.";
const LOWER: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
const UPPER: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const DIGITS: &[u8] = b"0123456789";

pub fn generate_password(req: &GeneratorRequest) -> Result<Zeroizing<String>> {
    match req {
        GeneratorRequest::Random { length, classes, symbol_charset } => {
            random_password(*length, classes, symbol_charset)
        }
        GeneratorRequest::Bip39 { .. } => Err(RelicarioError::Format(
            "use generate_passphrase() for BIP39 requests".into(),
        )),
    }
}

fn random_password(
    length: u32,
    classes: &CharClasses,
    symbol_charset: &SymbolCharset,
) -> Result<Zeroizing<String>> {
    if length == 0 || length > 128 {
        return Err(RelicarioError::Format("length must be 1..=128".into()));
    }
    let mut charset: Vec<u8> = Vec::new();
    if classes.lower { charset.extend_from_slice(LOWER); }
    if classes.upper { charset.extend_from_slice(UPPER); }
    if classes.digits { charset.extend_from_slice(DIGITS); }
    if classes.symbols {
        let symbols: &[u8] = match symbol_charset {
            SymbolCharset::SafeOnly => SAFE_SYMBOLS,
            SymbolCharset::Extended => EXTENDED_SYMBOLS,
            SymbolCharset::Custom(s) => s.as_bytes(),
        };
        charset.extend_from_slice(symbols);
    }
    if charset.is_empty() {
        return Err(RelicarioError::Format("at least one character class required".into()));
    }

    let dist = Uniform::from(0..charset.len());
    let mut rng = OsRng;
    let bytes: Vec<u8> = (0..length).map(|_| charset[dist.sample(&mut rng)]).collect();
    Ok(Zeroizing::new(String::from_utf8(bytes).expect("ascii-only charset")))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn random_default_password_is_20_chars() {
        let req = GeneratorRequest::default();
        let pw = generate_password(&req).unwrap();
        assert_eq!(pw.len(), 20);
    }

    #[test]
    fn rejects_zero_length() {
        let req = GeneratorRequest::Random {
            length: 0,
            classes: CharClasses { lower: true, upper: false, digits: false, symbols: false },
            symbol_charset: SymbolCharset::SafeOnly,
        };
        assert!(generate_password(&req).is_err());
    }

    #[test]
    fn rejects_no_classes() {
        let req = GeneratorRequest::Random {
            length: 8,
            classes: CharClasses { lower: false, upper: false, digits: false, symbols: false },
            symbol_charset: SymbolCharset::SafeOnly,
        };
        assert!(generate_password(&req).is_err());
    }

    #[test]
    fn lower_only_password_uses_lowercase() {
        let req = GeneratorRequest::Random {
            length: 100,
            classes: CharClasses { lower: true, upper: false, digits: false, symbols: false },
            symbol_charset: SymbolCharset::SafeOnly,
        };
        let pw = generate_password(&req).unwrap();
        assert!(pw.chars().all(|c| c.is_ascii_lowercase()));
    }

    #[test]
    fn safe_symbols_excludes_quotes_and_brackets() {
        let req = GeneratorRequest::Random {
            length: 1000,
            classes: CharClasses { lower: false, upper: false, digits: false, symbols: true },
            symbol_charset: SymbolCharset::SafeOnly,
        };
        let pw = generate_password(&req).unwrap();
        for c in pw.chars() {
            assert!(!matches!(c, '\'' | '"' | '`' | ',' | ';' | ':' | '{' | '}' | '[' | ']' | '<' | '>' | '(' | ')' | '|' | '\\' | '/' | '?'),
                    "safe charset must not include {c}");
        }
    }
}
```

- [ ] **Step 2: Wire into lib.rs**

Add after `pub mod settings;`:

```rust
pub mod generators;
pub use generators::generate_password;
```

- [ ] **Step 3: Run tests**

Run: `cargo test -p relicario-core --lib generators:: 2>&1 | tail -20`
Expected: 5 tests passed.

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-core/src/generators.rs crates/relicario-core/src/lib.rs
git commit -m "$(cat <<'EOF'
feat(core): add CSPRNG random password generator with safe charset

Uses rand::distributions::Uniform for unbiased sampling (audit H6).
Safe symbols = !@#$%^&*-_=+ (excludes characters that web forms
commonly reject).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 21: Generators — BIP39 passphrase + zxcvbn rating

**Files:**
- Modify: `crates/relicario-core/src/generators.rs`

- [ ] **Step 1: Append BIP39 + zxcvbn impl**

Append to `crates/relicario-core/src/generators.rs`:

```rust
use bip39::{Language, Mnemonic};

use crate::settings::Capitalization;

pub fn generate_passphrase(req: &GeneratorRequest) -> Result<Zeroizing<String>> {
    match req {
        GeneratorRequest::Bip39 { word_count, separator, capitalization } => {
            bip39_passphrase(*word_count, separator, *capitalization)
        }
        GeneratorRequest::Random { .. } => Err(RelicarioError::Format(
            "use generate_password() for Random requests".into(),
        )),
    }
}

fn bip39_passphrase(word_count: u32, separator: &str, cap: Capitalization) -> Result<Zeroizing<String>> {
    if !matches!(word_count, 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12) {
        return Err(RelicarioError::Format("word_count must be 3..=12".into()));
    }
    // bip39 generates from entropy — entropy bits = word_count * 11 - checksum
    // For 12 words = 128 bits entropy; for fewer words we pick a multiple of 32
    let entropy_bytes = match word_count {
        3 => 4,   // 32 bits
        4..=5 => 8,   // 64 bits
        6 => 8,
        7..=8 => 16,  // 128 bits
        9..=10 => 20, // 160 bits
        11..=12 => 24, // 192 bits — 12 words wants 16 bytes; we'll trim
        _ => unreachable!(),
    };
    let mut entropy = Zeroizing::new(vec![0u8; entropy_bytes]);
    OsRng.fill_bytes(entropy.as_mut_slice());
    let m = Mnemonic::from_entropy_in(Language::English, &entropy)
        .map_err(|e| RelicarioError::Format(format!("bip39: {e}")))?;
    let words: Vec<String> = m.word_iter().take(word_count as usize).map(|w| {
        match cap {
            Capitalization::Lower => w.to_ascii_lowercase(),
            Capitalization::Upper => w.to_ascii_uppercase(),
            Capitalization::FirstOfEach | Capitalization::Title => {
                let mut chars = w.chars();
                chars.next().map(|c| c.to_ascii_uppercase().to_string())
                    .unwrap_or_default() + chars.as_str()
            }
            Capitalization::Mixed => {
                w.chars().enumerate().map(|(i, c)| {
                    if i % 2 == 0 { c.to_ascii_uppercase() } else { c }
                }).collect()
            }
        }
    }).collect();
    Ok(Zeroizing::new(words.join(separator)))
}

/// Returns zxcvbn's 0-4 score (higher is stronger) and the estimated guesses.
pub struct StrengthEstimate {
    pub score: u8,
    pub guesses_log10: f64,
}

pub fn rate_passphrase(p: &str) -> StrengthEstimate {
    let est = zxcvbn::zxcvbn(p, &[]);
    StrengthEstimate {
        score: est.score().into(),
        guesses_log10: est.guesses_log10(),
    }
}

/// Strength gate at vault creation (audit H3): require score >= 3.
pub fn validate_passphrase_strength(p: &str) -> Result<()> {
    let est = rate_passphrase(p);
    if est.score < 3 {
        return Err(RelicarioError::WeakPassphrase { score: est.score });
    }
    Ok(())
}

#[cfg(test)]
mod bip39_tests {
    use super::*;

    #[test]
    fn bip39_default_is_5_space_separated_words() {
        let req = GeneratorRequest::Bip39 {
            word_count: 5,
            separator: " ".into(),
            capitalization: Capitalization::Lower,
        };
        let pw = generate_passphrase(&req).unwrap();
        assert_eq!(pw.split(' ').count(), 5);
    }

    #[test]
    fn bip39_dash_separator() {
        let req = GeneratorRequest::Bip39 {
            word_count: 4,
            separator: "-".into(),
            capitalization: Capitalization::Lower,
        };
        let pw = generate_passphrase(&req).unwrap();
        assert_eq!(pw.split('-').count(), 4);
        assert!(!pw.contains(' '));
    }

    #[test]
    fn bip39_first_of_each_capitalizes() {
        let req = GeneratorRequest::Bip39 {
            word_count: 5,
            separator: " ".into(),
            capitalization: Capitalization::FirstOfEach,
        };
        let pw = generate_passphrase(&req).unwrap();
        for word in pw.split(' ') {
            let first = word.chars().next().unwrap();
            assert!(first.is_ascii_uppercase(), "word {word} should start uppercase");
        }
    }

    #[test]
    fn bip39_rejects_bad_word_count() {
        let req = GeneratorRequest::Bip39 {
            word_count: 2,
            separator: " ".into(),
            capitalization: Capitalization::Lower,
        };
        assert!(generate_passphrase(&req).is_err());
    }

    #[test]
    fn rate_passphrase_strong_one_passes_gate() {
        // 5-word bip39 passphrase
        let req = GeneratorRequest::Bip39 {
            word_count: 6,
            separator: " ".into(),
            capitalization: Capitalization::Lower,
        };
        let pw = generate_passphrase(&req).unwrap();
        assert!(validate_passphrase_strength(&pw).is_ok());
    }

    #[test]
    fn rate_passphrase_weak_fails_gate() {
        assert!(validate_passphrase_strength("password").is_err());
        assert!(validate_passphrase_strength("12345678").is_err());
        assert!(validate_passphrase_strength("hunter2").is_err());
    }
}
```

- [ ] **Step 2: Update lib.rs re-exports**

Update the generators re-export:

```rust
pub use generators::{generate_passphrase, generate_password, rate_passphrase, validate_passphrase_strength, StrengthEstimate};
```

- [ ] **Step 3: Run tests**

Run: `cargo test -p relicario-core --lib generators:: 2>&1 | tail -30`
Expected: all generator tests pass.

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-core/src/generators.rs crates/relicario-core/src/lib.rs
git commit -m "$(cat <<'EOF'
feat(core): add BIP39 passphrase generator + zxcvbn strength gate

generate_passphrase honors word_count (3-12), separator, capitalization.
validate_passphrase_strength enforces zxcvbn score >= 3 (audit H3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 22: Attachment encrypt/decrypt

**Files:**
- Modify: `crates/relicario-core/src/attachment.rs`

- [ ] **Step 1: Append encrypt_attachment + decrypt_attachment**

Append to `crates/relicario-core/src/attachment.rs`:

```rust
use zeroize::Zeroizing;

use crate::crypto::{decrypt, encrypt};
use crate::error::{RelicarioError, Result};

/// Encrypted attachment with the AID derived from plaintext content.
pub struct EncryptedAttachment {
    pub id: AttachmentId,
    pub bytes: Vec<u8>,
}

pub fn encrypt_attachment(
    plaintext: &[u8],
    master_key: &Zeroizing<[u8; 32]>,
    max_bytes: u64,
) -> Result<EncryptedAttachment> {
    if plaintext.len() as u64 > max_bytes {
        return Err(RelicarioError::AttachmentTooLarge {
            size: plaintext.len() as u64,
            max: max_bytes,
        });
    }
    let id = AttachmentId::from_plaintext(plaintext);
    let bytes = encrypt(plaintext, master_key)?;
    Ok(EncryptedAttachment { id, bytes })
}

pub fn decrypt_attachment(
    encrypted: &[u8],
    master_key: &Zeroizing<[u8; 32]>,
) -> Result<Zeroizing<Vec<u8>>> {
    let plaintext = decrypt(encrypted, master_key)?;
    Ok(Zeroizing::new(plaintext))
}

#[cfg(test)]
mod crypto_tests {
    use super::*;
    use zeroize::Zeroizing;

    fn key() -> Zeroizing<[u8; 32]> {
        Zeroizing::new([0x42u8; 32])
    }

    #[test]
    fn attachment_round_trip() {
        let plaintext = b"the quick brown fox jumps over the lazy dog";
        let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap();
        let dec = decrypt_attachment(&enc.bytes, &key()).unwrap();
        assert_eq!(dec.as_slice(), plaintext);
    }

    #[test]
    fn attachment_id_matches_sha256() {
        let plaintext = b"hello world";
        let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap();
        assert_eq!(enc.id, AttachmentId::from_plaintext(plaintext));
    }

    #[test]
    fn oversize_attachment_rejected() {
        let plaintext = vec![0u8; 11_000_000];
        let err = encrypt_attachment(&plaintext, &key(), 10 * 1024 * 1024);
        assert!(matches!(err, Err(RelicarioError::AttachmentTooLarge { .. })));
    }

    #[test]
    fn wrong_key_fails_with_opaque_decrypt() {
        let plaintext = b"x";
        let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap();
        let wrong = Zeroizing::new([0u8; 32]);
        let err = decrypt_attachment(&enc.bytes, &wrong);
        assert!(matches!(err, Err(RelicarioError::Decrypt)));
    }
}
```

You'll need to verify the `crypto::encrypt` signature accepts `&Zeroizing<[u8; 32]>`. If it currently takes `&[u8; 32]`, adapt by passing `&master_key` (Deref coerces). If the signature is something else (e.g. takes `&[u8]`), match what's there.

- [ ] **Step 2: Wire into lib.rs**

Update the attachment re-export:

```rust
pub use attachment::{decrypt_attachment, encrypt_attachment, AttachmentRef, AttachmentSummary, EncryptedAttachment};
```

- [ ] **Step 3: Run tests**

Run: `cargo test -p relicario-core --lib attachment:: 2>&1 | tail -20`
Expected: 6 tests passed (2 original + 4 new).

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-core/src/attachment.rs crates/relicario-core/src/lib.rs
git commit -m "$(cat <<'EOF'
feat(core): add encrypt_attachment + decrypt_attachment

AttachmentId is derived from sha256(plaintext) so identical content
deduplicates naturally. Size cap enforced at encrypt time, returning
RelicarioError::AttachmentTooLarge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 23: Vault helpers — encrypt/decrypt Item, Manifest, VaultSettings

**Files:**
- Modify: `crates/relicario-core/src/vault.rs` (full rewrite)

- [ ] **Step 1: Replace `crates/relicario-core/src/vault.rs`**

```rust
//! Typed wrappers around `crypto::{encrypt, decrypt}` for the new typed-item
//! data model. Each function does JSON-serialize → encrypt or decrypt → JSON-parse.
//!
//! v1 helpers (encrypt_entry / decrypt_entry / encrypt_manifest with the old
//! Manifest type) are intentionally NOT carried forward. The CLI rewrite in
//! Plan 1B switches to the new helpers.

use zeroize::Zeroizing;

use crate::crypto::{decrypt, encrypt};
use crate::error::Result;
use crate::item::Item;
use crate::manifest::Manifest;
use crate::settings::VaultSettings;

pub fn encrypt_item(item: &Item, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
    let json = serde_json::to_vec(item)?;
    let plaintext = Zeroizing::new(json);
    encrypt(&plaintext, master_key)
}

pub fn decrypt_item(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<Item> {
    let plaintext = decrypt(encrypted, master_key)?;
    let plaintext = Zeroizing::new(plaintext);
    let item: Item = serde_json::from_slice(&plaintext)?;
    Ok(item)
}

pub fn encrypt_manifest(manifest: &Manifest, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
    let json = serde_json::to_vec(manifest)?;
    let plaintext = Zeroizing::new(json);
    encrypt(&plaintext, master_key)
}

pub fn decrypt_manifest(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<Manifest> {
    let plaintext = decrypt(encrypted, master_key)?;
    let plaintext = Zeroizing::new(plaintext);
    let manifest: Manifest = serde_json::from_slice(&plaintext)?;
    Ok(manifest)
}

pub fn encrypt_settings(settings: &VaultSettings, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
    let json = serde_json::to_vec(settings)?;
    let plaintext = Zeroizing::new(json);
    encrypt(&plaintext, master_key)
}

pub fn decrypt_settings(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<VaultSettings> {
    let plaintext = decrypt(encrypted, master_key)?;
    let plaintext = Zeroizing::new(plaintext);
    let settings: VaultSettings = serde_json::from_slice(&plaintext)?;
    Ok(settings)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::item_types::{ItemCore, SecureNoteCore};

    fn key() -> Zeroizing<[u8; 32]> { Zeroizing::new([0x33u8; 32]) }

    #[test]
    fn item_round_trip() {
        let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore {
            body: Zeroizing::new("hello".into()),
        }));
        let bytes = encrypt_item(&item, &key()).unwrap();
        let decoded = decrypt_item(&bytes, &key()).unwrap();
        assert_eq!(decoded.title, "note");
    }

    #[test]
    fn manifest_round_trip() {
        let mut m = Manifest::new();
        let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default()));
        m.upsert(&item);
        let bytes = encrypt_manifest(&m, &key()).unwrap();
        let decoded = decrypt_manifest(&bytes, &key()).unwrap();
        assert_eq!(decoded.items.len(), 1);
    }

    #[test]
    fn settings_round_trip() {
        let s = VaultSettings::default();
        let bytes = encrypt_settings(&s, &key()).unwrap();
        let decoded = decrypt_settings(&bytes, &key()).unwrap();
        assert_eq!(decoded.attachment_caps.per_attachment_max_bytes,
                   s.attachment_caps.per_attachment_max_bytes);
    }
}
```

- [ ] **Step 2: Update lib.rs vault re-export**

Replace the existing `pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};` line with:

```rust
pub use vault::{
    decrypt_item, decrypt_manifest, decrypt_settings,
    encrypt_item, encrypt_manifest, encrypt_settings,
};
```

The `Manifest` re-export is now unambiguous because `entry.rs::Manifest` was already removed from the re-export in Task 18.

- [ ] **Step 3: Run vault tests**

Run: `cargo test -p relicario-core --lib vault:: 2>&1 | tail -20`
Expected: 3 tests passed.

If `cargo build -p relicario-core` still fails, it's because something elsewhere in the crate (likely the integration test) references `encrypt_entry` or the old types. Search and remove or comment out — Task 26 rewrites the integration test:

```bash
grep -rn 'encrypt_entry\|decrypt_entry\|generate_entry_id' crates/relicario-core/
```

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-core/src/vault.rs crates/relicario-core/src/lib.rs
git commit -m "$(cat <<'EOF'
feat(core): rewrite vault.rs for typed items

encrypt_item / decrypt_item / encrypt_manifest / decrypt_manifest /
encrypt_settings / decrypt_settings. All plaintext flows through
Zeroizing so JSON buffers are wiped on drop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 24: Field history retention pruning

**Files:**
- Modify: `crates/relicario-core/src/item.rs`

- [ ] **Step 1: Write failing test**

Append to the `tests` module in `crates/relicario-core/src/item.rs`:

```rust
#[test]
fn prune_history_keeps_last_n() {
    use crate::settings::HistoryRetention;

    let core = ItemCore::Login(crate::item_types::LoginCore::default());
    let mut item = Item::new("x".into(), core);
    let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
    let fid = f.id.clone();
    item.sections.push(Section { name: None, fields: vec![f] });

    for i in 1..=5 {
        item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}").into()))).unwrap();
    }
    assert_eq!(item.field_history[&fid].len(), 5);

    item.prune_history(&HistoryRetention::LastN(3), 0);
    assert_eq!(item.field_history[&fid].len(), 3);
    // Keeps the MOST RECENT 3
    assert_eq!(item.field_history[&fid][0].value.as_str(), "v2");
}

#[test]
fn prune_history_drops_old_entries_by_days() {
    use crate::settings::HistoryRetention;

    let core = ItemCore::Login(crate::item_types::LoginCore::default());
    let mut item = Item::new("x".into(), core);
    let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
    let fid = f.id.clone();
    item.sections.push(Section { name: None, fields: vec![f] });

    let now = 1_000_000_000;
    item.field_history.insert(fid.clone(), vec![
        FieldHistoryEntry { value: Zeroizing::new("old".into()),    replaced_at: now - 100 * 86_400 },
        FieldHistoryEntry { value: Zeroizing::new("recent".into()), replaced_at: now -   1 * 86_400 },
    ]);

    item.prune_history(&HistoryRetention::Days(30), now);
    assert_eq!(item.field_history[&fid].len(), 1);
    assert_eq!(item.field_history[&fid][0].value.as_str(), "recent");
}

#[test]
fn prune_history_forever_keeps_all() {
    use crate::settings::HistoryRetention;

    let core = ItemCore::Login(crate::item_types::LoginCore::default());
    let mut item = Item::new("x".into(), core);
    item.field_history.insert(FieldId::new(), vec![
        FieldHistoryEntry { value: Zeroizing::new("a".into()), replaced_at: 0 },
        FieldHistoryEntry { value: Zeroizing::new("b".into()), replaced_at: 0 },
    ]);
    item.prune_history(&HistoryRetention::Forever, 1_000_000_000);
    assert_eq!(item.field_history.values().next().unwrap().len(), 2);
}
```

- [ ] **Step 2: Run tests, expect fail**

Run: `cargo test -p relicario-core --lib item::tests::prune_history 2>&1 | tail -10`
Expected: compile error — `prune_history` not defined.

- [ ] **Step 3: Implement `prune_history` on Item**

Add to the `impl Item` block in `crates/relicario-core/src/item.rs`:

```rust
pub fn prune_history(&mut self, retention: &crate::settings::HistoryRetention, now: i64) {
    use crate::settings::HistoryRetention;
    for history in self.field_history.values_mut() {
        match retention {
            HistoryRetention::Forever => {}
            HistoryRetention::LastN(n) => {
                let n = *n as usize;
                if history.len() > n {
                    let drop_count = history.len() - n;
                    history.drain(..drop_count);
                }
            }
            HistoryRetention::Days(d) => {
                let cutoff = now - (*d as i64) * 86_400;
                history.retain(|e| e.replaced_at > cutoff);
            }
        }
    }
}
```

- [ ] **Step 4: Run tests, expect pass**

Run: `cargo test -p relicario-core --lib item::tests::prune_history 2>&1 | tail -10`
Expected: 3 tests passed.

- [ ] **Step 5: Commit**

```bash
git add crates/relicario-core/src/item.rs
git commit -m "$(cat <<'EOF'
feat(core): add Item::prune_history honoring retention policy

Forever, LastN, and Days policies all covered. Tests verify drop order
(keeps newest), days cutoff, and forever-no-op semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 25: Delete entry.rs and finalize lib.rs

**Files:**
- Delete: `crates/relicario-core/src/entry.rs`
- Modify: `crates/relicario-core/src/lib.rs`

- [ ] **Step 1: Delete entry.rs**

```bash
git rm crates/relicario-core/src/entry.rs
```

- [ ] **Step 2: Remove all `entry` references from lib.rs**

Open `crates/relicario-core/src/lib.rs` and:

1. Remove the line `pub mod entry;`.
2. Remove the line `pub use entry::{generate_entry_id, Entry};` (and any related re-exports).
3. Update the doc comment header — the existing module comment lists `entry`, `vault`, etc. Rewrite the module list to match the new layout. Recommended replacement:

```rust
//! ## Modules
//!
//! - [`error`] — The unified error type ([`RelicarioError`]).
//! - [`crypto`] — Argon2id KDF (length-prefixed inputs, Zeroizing output) and
//!   XChaCha20-Poly1305 AEAD with VERSION_BYTE 0x02.
//! - [`ids`] — `ItemId`, `FieldId`, and content-addressed `AttachmentId`.
//! - [`time`] — unix-seconds + `MonthYear` for card expiries.
//! - [`item_types`] — Per-type cores (`LoginCore`, `SecureNoteCore`, etc.) and the
//!   `ItemCore`/`ItemType` enums.
//! - [`item`] — `Item` envelope, `Field`, `FieldKind`, `FieldValue`, `Section`,
//!   `FieldHistoryEntry`.
//! - [`attachment`] — `AttachmentRef`, `AttachmentSummary`, encrypt/decrypt helpers.
//! - [`manifest`] — Browse-without-decrypt index (schema_version 2).
//! - [`settings`] — Vault-level retention, generator defaults, attachment caps.
//! - [`generators`] — CSPRNG password + BIP39 passphrase generators; zxcvbn
//!   strength gate.
//! - [`vault`] — Typed encrypt/decrypt wrappers (Item, Manifest, VaultSettings).
//! - [`imgsecret`] — DCT-based steganography for the second auth factor.
```

The full final lib.rs should look like (re-export section):

```rust
pub mod error;
pub use error::{RelicarioError, Result};

pub mod crypto;
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE};

pub mod ids;
pub use ids::{AttachmentId, FieldId, ItemId};

pub mod time;
pub use time::{now_unix, MonthYear};

pub mod item_types;
pub use item_types::{ItemCore, ItemType};

pub mod item;
pub use item::{Field, FieldHistoryEntry, FieldKind, FieldValue, Item, Section};

pub mod attachment;
pub use attachment::{decrypt_attachment, encrypt_attachment, AttachmentRef, AttachmentSummary, EncryptedAttachment};

pub mod manifest;
pub use manifest::{Manifest, ManifestEntry, MANIFEST_SCHEMA_VERSION};

pub mod settings;
pub use settings::{
    AttachmentCaps, Capitalization, CharClasses, GeneratorRequest, HistoryRetention,
    SymbolCharset, TrashRetention, VaultSettings,
};

pub mod generators;
pub use generators::{generate_passphrase, generate_password, rate_passphrase, validate_passphrase_strength, StrengthEstimate};

pub mod vault;
pub use vault::{
    decrypt_item, decrypt_manifest, decrypt_settings,
    encrypt_item, encrypt_manifest, encrypt_settings,
};

pub mod imgsecret;
```

- [ ] **Step 3: Verify the crate builds**

Run: `cargo build -p relicario-core 2>&1 | tail -20`
Expected: clean build with no warnings about `entry`. (`cargo build` of the whole workspace will still fail because `relicario-cli` references the old types — that's expected and is fixed in Plan 1B.)

Run: `cargo test -p relicario-core --lib 2>&1 | tail -20`
Expected: all unit tests pass.

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-core/src/lib.rs
git commit -m "$(cat <<'EOF'
refactor(core): delete entry.rs; finalize typed-item lib.rs re-exports

The old Entry/ManifestEntry/Manifest types are gone. CLI/extension
references break and will be fixed by Plans 1B and 1C respectively.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 26: Integration test — full typed-item workflow

**Files:**
- Modify: `crates/relicario-core/tests/integration.rs` (full rewrite)

- [ ] **Step 1: Rewrite `crates/relicario-core/tests/integration.rs`**

Replace the entire contents of `crates/relicario-core/tests/integration.rs` with:

```rust
//! End-to-end integration tests for the typed-item core.

use relicario_core::{
    crypto::KdfParams,
    derive_master_key, encrypt_item, decrypt_item,
    encrypt_manifest, decrypt_manifest,
    encrypt_settings, decrypt_settings,
    Field, FieldValue, Item, ItemCore, Manifest, Section, VaultSettings,
};
use relicario_core::item_types::{LoginCore, SecureNoteCore};
use url::Url;
use zeroize::Zeroizing;

fn fast_params() -> KdfParams {
    KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }
}

#[test]
fn full_workflow_login_and_note() {
    let salt = [0xAAu8; 32];
    let img = [0xBBu8; 32];
    let key = derive_master_key(b"correct horse battery staple", &img, &salt, &fast_params()).unwrap();

    let mut manifest = Manifest::new();
    let settings = VaultSettings::default();

    // Add a Login
    let login = Item::new("GitHub".into(), ItemCore::Login(LoginCore {
        username: Some("alice".into()),
        password: Some(Zeroizing::new("hunter2".into())),
        url: Some(Url::parse("https://github.com").unwrap()),
        totp: None,
    }));
    manifest.upsert(&login);
    let login_blob = encrypt_item(&login, &key).unwrap();

    // Add a SecureNote
    let note = Item::new("recovery".into(), ItemCore::SecureNote(SecureNoteCore {
        body: Zeroizing::new("recovery codes go here".into()),
    }));
    manifest.upsert(&note);
    let note_blob = encrypt_item(&note, &key).unwrap();

    // Encrypt manifest + settings
    let manifest_blob = encrypt_manifest(&manifest, &key).unwrap();
    let settings_blob = encrypt_settings(&settings, &key).unwrap();

    // Decrypt + verify
    let m = decrypt_manifest(&manifest_blob, &key).unwrap();
    assert_eq!(m.items.len(), 2);

    let l: Item = decrypt_item(&login_blob, &key).unwrap();
    let n: Item = decrypt_item(&note_blob, &key).unwrap();
    let s: VaultSettings = decrypt_settings(&settings_blob, &key).unwrap();

    assert_eq!(l.title, "GitHub");
    assert_eq!(n.title, "recovery");
    assert_eq!(s.attachment_caps.per_attachment_max_bytes, 10 * 1024 * 1024);
}

#[test]
fn two_factor_independence() {
    // Same passphrase, different image_secret → different keys.
    let salt = [0u8; 32];
    let img_a = [0x01u8; 32];
    let img_b = [0x02u8; 32];

    let key_a = derive_master_key(b"same-passphrase", &img_a, &salt, &fast_params()).unwrap();
    let key_b = derive_master_key(b"same-passphrase", &img_b, &salt, &fast_params()).unwrap();
    assert_ne!(*key_a, *key_b);

    // Different passphrase, same image_secret → different keys.
    let key_c = derive_master_key(b"other-passphrase", &img_a, &salt, &fast_params()).unwrap();
    assert_ne!(*key_a, *key_c);
}

#[test]
fn field_history_persists_through_round_trip() {
    let salt = [0u8; 32];
    let img = [0u8; 32];
    let key = derive_master_key(b"x", &img, &salt, &fast_params()).unwrap();

    let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default()));
    let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
    let fid = f.id.clone();
    item.sections.push(Section { name: None, fields: vec![f] });
    item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap();
    item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v2".into()))).unwrap();

    let blob = encrypt_item(&item, &key).unwrap();
    let decoded = decrypt_item(&blob, &key).unwrap();
    let hist = decoded.field_history.get(&fid).unwrap();
    assert_eq!(hist.len(), 2);
    assert_eq!(hist[0].value.as_str(), "v0");
    assert_eq!(hist[1].value.as_str(), "v1");
}

#[test]
fn wrong_key_fails_with_opaque_decrypt() {
    use relicario_core::RelicarioError;

    let salt = [0u8; 32];
    let img = [0u8; 32];
    let right = derive_master_key(b"correct", &img, &salt, &fast_params()).unwrap();
    let wrong = derive_master_key(b"wrong", &img, &salt, &fast_params()).unwrap();

    let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default()));
    let blob = encrypt_item(&item, &right).unwrap();
    let err = decrypt_item(&blob, &wrong);
    assert!(matches!(err, Err(RelicarioError::Decrypt)));
}
```

- [ ] **Step 2: Run integration tests**

Run: `cargo test -p relicario-core --test integration 2>&1 | tail -20`
Expected: 4 tests passed.

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-core/tests/integration.rs
git commit -m "$(cat <<'EOF'
test(core): rewrite integration test for typed items

- full_workflow_login_and_note: round-trips Login + SecureNote + Manifest + Settings
- two_factor_independence: confirms image_secret + passphrase combine into the master key
- field_history_persists_through_round_trip: history survives encrypt/decrypt
- wrong_key_fails_with_opaque_decrypt: opaque error per audit M4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 27: Integration test — attachments

**Files:**
- Create: `crates/relicario-core/tests/attachments.rs`

- [ ] **Step 1: Write `tests/attachments.rs`**

```rust
//! Attachment encrypt/decrypt + content-addressed AID + cap enforcement.

use relicario_core::{
    AttachmentId, RelicarioError,
    crypto::KdfParams,
    decrypt_attachment, derive_master_key, encrypt_attachment,
};
use zeroize::Zeroizing;

fn fast_params() -> KdfParams { KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } }

fn make_key() -> Zeroizing<[u8; 32]> {
    derive_master_key(b"x", &[0u8; 32], &[0u8; 32], &fast_params()).unwrap()
}

#[test]
fn attachment_round_trip_5kb() {
    let plaintext: Vec<u8> = (0..5000u32).map(|i| (i & 0xff) as u8).collect();
    let key = make_key();
    let enc = encrypt_attachment(&plaintext, &key, 10 * 1024 * 1024).unwrap();
    assert_eq!(enc.id, AttachmentId::from_plaintext(&plaintext));

    let dec = decrypt_attachment(&enc.bytes, &key).unwrap();
    assert_eq!(&*dec, &plaintext);
}

#[test]
fn identical_plaintexts_yield_identical_aids() {
    let plaintext = b"hello world";
    let key = make_key();
    let a = encrypt_attachment(plaintext, &key, 1024).unwrap();
    let b = encrypt_attachment(plaintext, &key, 1024).unwrap();
    assert_eq!(a.id, b.id);
    // (Bytes will differ because nonce is random per-encryption — that's expected.)
}

#[test]
fn cap_enforcement_at_exact_max() {
    let plaintext = vec![0u8; 1024];
    let key = make_key();
    // Exactly at max — should pass
    let _ = encrypt_attachment(&plaintext, &key, 1024).unwrap();
    // One byte over — should fail
    let err = encrypt_attachment(&plaintext, &key, 1023);
    match err {
        Err(RelicarioError::AttachmentTooLarge { size, max }) => {
            assert_eq!(size, 1024);
            assert_eq!(max, 1023);
        }
        other => panic!("expected AttachmentTooLarge, got {other:?}"),
    }
}
```

- [ ] **Step 2: Run**

Run: `cargo test -p relicario-core --test attachments 2>&1 | tail -20`
Expected: 3 tests passed.

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-core/tests/attachments.rs
git commit -m "test(core): integration tests for attachments (round-trip, AID, caps)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 28: Integration test — generator unbiased + zxcvbn gate

**Files:**
- Create: `crates/relicario-core/tests/generators.rs`

- [ ] **Step 1: Write `tests/generators.rs`**

```rust
//! Generator integration tests — unbiased sampling (smoke), BIP39 sanity,
//! zxcvbn strength gate.

use relicario_core::{
    Capitalization, CharClasses, GeneratorRequest, SymbolCharset,
    generate_passphrase, generate_password, validate_passphrase_strength,
};

#[test]
fn random_password_class_balance_is_reasonable() {
    // Sample 10000 chars; each class should appear roughly proportionally.
    let req = GeneratorRequest::Random {
        length: 10000,
        classes: CharClasses { lower: true, upper: true, digits: true, symbols: true },
        symbol_charset: SymbolCharset::SafeOnly,
    };
    let pw = generate_password(&req).unwrap();

    let lower = pw.chars().filter(|c| c.is_ascii_lowercase()).count();
    let upper = pw.chars().filter(|c| c.is_ascii_uppercase()).count();
    let digits = pw.chars().filter(|c| c.is_ascii_digit()).count();
    let symbols = pw.len() - lower - upper - digits;

    // Charset sizes: lower 26 + upper 26 + digits 10 + safe_symbols 12 = 74
    // Expect proportions roughly: 26/74 ≈ 35%, 10/74 ≈ 14%, 12/74 ≈ 16%
    // Allow ±5pp slop.
    let total = pw.len() as f64;
    let assert_pct = |actual: usize, expected_pct: f64| {
        let pct = (actual as f64) / total * 100.0;
        assert!((pct - expected_pct).abs() < 5.0,
                "actual {pct:.1}% vs expected {expected_pct:.1}%");
    };
    assert_pct(lower, 26.0 / 74.0 * 100.0);
    assert_pct(upper, 26.0 / 74.0 * 100.0);
    assert_pct(digits, 10.0 / 74.0 * 100.0);
    assert_pct(symbols, 12.0 / 74.0 * 100.0);
}

#[test]
fn bip39_5_word_passphrase_passes_zxcvbn_gate() {
    let req = GeneratorRequest::Bip39 {
        word_count: 5,
        separator: " ".into(),
        capitalization: Capitalization::Lower,
    };
    let pw = generate_passphrase(&req).unwrap();
    validate_passphrase_strength(&pw).expect("5-word bip39 should pass score >= 3");
}

#[test]
fn common_weak_passphrases_fail_gate() {
    for weak in &["password", "12345678", "letmein", "qwertyui", "hunter2"] {
        assert!(validate_passphrase_strength(weak).is_err(),
                "expected '{weak}' to fail gate");
    }
}

#[test]
fn random_passwords_are_unique_across_calls() {
    let req = GeneratorRequest::default();
    let mut seen = std::collections::HashSet::new();
    for _ in 0..1000 {
        let pw = generate_password(&req).unwrap();
        assert!(seen.insert(pw.as_str().to_owned()));
    }
}
```

- [ ] **Step 2: Run**

Run: `cargo test -p relicario-core --test generators 2>&1 | tail -20`
Expected: 4 tests passed. (Class-balance test has some statistical slop — re-run if it flakes; 5pp tolerance is generous.)

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-core/tests/generators.rs
git commit -m "test(core): integration tests for generators (balance, BIP39, gate)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 29: Integration test — format v2 + length-prefix KDF

**Files:**
- Create: `crates/relicario-core/tests/format_v2.rs`

- [ ] **Step 1: Write `tests/format_v2.rs`**

```rust
//! Format v2 invariants: VERSION_BYTE = 0x02, v1 blobs are rejected with
//! UnsupportedFormatVersion, length-prefix construction guarantees domain
//! separation.

use relicario_core::{
    RelicarioError,
    crypto::{KdfParams, VERSION_BYTE},
    decrypt, derive_master_key, encrypt,
};
use zeroize::Zeroizing;

fn fast_params() -> KdfParams { KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } }

#[test]
fn version_byte_is_2() {
    assert_eq!(VERSION_BYTE, 0x02);
}

#[test]
fn fresh_ciphertext_starts_with_0x02() {
    let key = Zeroizing::new([0u8; 32]);
    let ct = encrypt(b"hello", &key).unwrap();
    assert_eq!(ct[0], 0x02);
}

#[test]
fn v1_blob_is_rejected_with_unsupported_format_version() {
    // v1 layout: [0x01][24 nonce bytes][16 tag bytes]
    let mut blob = vec![0x01u8];
    blob.extend_from_slice(&[0u8; 24 + 16]);
    let key = Zeroizing::new([0u8; 32]);
    let err = decrypt(&blob, &key);
    match err {
        Err(RelicarioError::UnsupportedFormatVersion { found, expected }) => {
            assert_eq!(found, 0x01);
            assert_eq!(expected, 0x02);
        }
        other => panic!("expected UnsupportedFormatVersion, got {other:?}"),
    }
}

#[test]
fn length_prefix_distinguishes_concat_collisions() {
    let salt = [0u8; 32];
    let img = [0x44u8; 32];
    let p1 = b"abc";
    let p2 = b"abcD";  // Pre-length-prefix, ("abc", [0x44, ...]) and ("abcD", ...)
                       // could be made to collide. With length-prefix they cannot.
    let k1 = derive_master_key(p1, &img, &salt, &fast_params()).unwrap();
    let k2 = derive_master_key(p2, &img, &salt, &fast_params()).unwrap();
    assert_ne!(*k1, *k2);
}
```

- [ ] **Step 2: Run**

Run: `cargo test -p relicario-core --test format_v2 2>&1 | tail -20`
Expected: 4 tests passed.

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-core/tests/format_v2.rs
git commit -m "$(cat <<'EOF'
test(core): integration tests for format v2 invariants

VERSION_BYTE = 0x02; v1 blobs rejected with UnsupportedFormatVersion;
length-prefix Argon2 input distinguishes collision-engineerable pairs
(audit H1 regression test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 30: Field history retention integration test

**Files:**
- Create: `crates/relicario-core/tests/field_history.rs`

- [ ] **Step 1: Write `tests/field_history.rs`**

```rust
//! Field history end-to-end: capture on update, prune by retention policy,
//! survive encrypt/decrypt round-trip.

use relicario_core::{
    Field, FieldValue, HistoryRetention, Item, ItemCore, Section,
    crypto::KdfParams,
    derive_master_key, decrypt_item, encrypt_item,
};
use relicario_core::item_types::LoginCore;
use zeroize::Zeroizing;

fn key() -> Zeroizing<[u8; 32]> {
    derive_master_key(b"x", &[0u8; 32], &[0u8; 32], &KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }).unwrap()
}

#[test]
fn password_field_history_captured_on_update() {
    let mut item = Item::new("login".into(), ItemCore::Login(LoginCore::default()));
    let f = Field::new("password".into(), FieldValue::Password(Zeroizing::new("v0".into())));
    let fid = f.id.clone();
    item.sections.push(Section { name: None, fields: vec![f] });

    item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap();
    item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v2".into()))).unwrap();
    item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v3".into()))).unwrap();

    let hist = item.field_history.get(&fid).expect("history exists");
    assert_eq!(hist.len(), 3);
    assert_eq!(hist[0].value.as_str(), "v0");
    assert_eq!(hist[2].value.as_str(), "v2");
}

#[test]
fn prune_last_n_keeps_most_recent() {
    let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default()));
    let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
    let fid = f.id.clone();
    item.sections.push(Section { name: None, fields: vec![f] });
    for i in 1..=10 {
        item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}").into()))).unwrap();
    }
    item.prune_history(&HistoryRetention::LastN(3), 0);
    let hist = &item.field_history[&fid];
    assert_eq!(hist.len(), 3);
    // Most recent 3: v7, v8, v9 (v10's predecessor v9 was the latest captured)
    assert!(hist.last().unwrap().value.as_str().starts_with('v'));
}

#[test]
fn history_survives_encrypt_decrypt() {
    let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default()));
    let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
    let fid = f.id.clone();
    item.sections.push(Section { name: None, fields: vec![f] });
    item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap();

    let blob = encrypt_item(&item, &key()).unwrap();
    let decoded = decrypt_item(&blob, &key()).unwrap();

    let hist = decoded.field_history.get(&fid).expect("history survived");
    assert_eq!(hist.len(), 1);
    assert_eq!(hist[0].value.as_str(), "v0");
}
```

- [ ] **Step 2: Run**

Run: `cargo test -p relicario-core --test field_history 2>&1 | tail -20`
Expected: 3 tests passed.

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-core/tests/field_history.rs
git commit -m "test(core): field history integration (capture, prune, round-trip)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 31: Final pass — full test run + doc comments

**Files:**
- N/A (verification only)

- [ ] **Step 1: Run the full core test suite**

Run: `cargo test -p relicario-core 2>&1 | tail -40`
Expected: ALL tests pass — unit tests in every module + integration tests in `tests/integration.rs`, `tests/attachments.rs`, `tests/generators.rs`, `tests/format_v2.rs`, `tests/field_history.rs`.

If anything fails, investigate, fix, re-run, and add a separate commit for each fix.

- [ ] **Step 2: Run `cargo doc -p relicario-core --no-deps`**

Run: `cargo doc -p relicario-core --no-deps 2>&1 | tail -10`
Expected: clean docs build (no `missing_docs` errors are required since we don't enforce them, but warnings about broken intra-doc links should be fixed).

- [ ] **Step 3: Run `cargo clippy -p relicario-core --all-targets`**

Run: `cargo clippy -p relicario-core --all-targets 2>&1 | tail -20`
Expected: clean (or pre-existing warnings only — no new clippy lints introduced by this plan).

- [ ] **Step 4: Verify the workspace builds for at least the core crate**

Run: `cargo build -p relicario-core --release 2>&1 | tail -10`
Expected: clean release build.

- [ ] **Step 5: Tag a milestone**

```bash
git tag plan-1a-rust-core-complete
git log --oneline -1
```

This gives Plan 1B a clear starting point.

---

## Acceptance Criteria

After Task 31:

1. `cargo test -p relicario-core` passes all unit + integration tests (no failures, no ignored tests).
2. `cargo build -p relicario-core --release` succeeds clean.
3. `cargo clippy -p relicario-core --all-targets` has no new lint regressions.
4. `crates/relicario-core/src/entry.rs` no longer exists.
5. `lib.rs` re-exports the new typed-item API exactly as listed in Task 25.
6. The audit fixes baked into this plan are demonstrable:
   - `derive_master_key` returns `Zeroizing<[u8; 32]>` (H2)
   - Argon2 input is length-prefixed (H1, regression test in `tests/format_v2.rs`)
   - Passphrase is NFC-normalized before hashing (H1, regression test in `crypto::tests::nfc_normalization_collapses_unicode_forms`)
   - `RelicarioError::Decrypt` has no internal-detail string (M4)
   - Item / Field / Attachment IDs are 16 hex chars (M8)
   - `generate_password` uses `Uniform` (no modulo bias, H6 — H5 is in Plan 1B's WASM crate)
   - `validate_passphrase_strength` enforces zxcvbn score ≥ 3 (H3)
7. The `relicario-cli` crate is **expected to fail compilation** at the end of this plan. Plan 1B fixes it.

## Hand-off to Plan 1B

Plan 1B (CLI + WASM bridge) starts from the `plan-1a-rust-core-complete` tag and:

- Rewrites `crates/relicario-cli/src/main.rs` to use the new typed-item API.
- Adds the `relicario-wasm` session-handle bridge.
- Implements audit fixes outside the data-model surface: H4 (hardened git shell-out), H5 (CSPRNG in WASM), H6 (the CLI side, already covered partially by Task 20), H7 (rpassword 7.x), M3 (imgsecret MAX_DIMENSION), M6 (clipboard zeroize), M7 (no plaintext to stdout), M11 (ISO-8601 timestamps), L8 (vault dir detection).
- Writes integration tests for the CLI commands.

Plan 1C (extension) starts from Plan 1B's completion tag and tackles the browser-side rewrite.
