# v0.8.1 Org Vault Item-Type Parity — 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:** Bring `relicario org add` / `relicario org edit` to full item-type parity with the personal vault by adding Card, Key, Document, and Totp support, with secrets entered via interactive prompts plus `--*-stdin` escape hatches, including the `relicario-server` hook change Document requires.

**Architecture:** Extract per-type item construction, secret resolution, and interactive-edit logic into one shared CLI module (`commands/item_build.rs`) that both the personal and org command handlers call — eliminating the current personal↔org builder duplication. Org gains collection-scoped attachment storage; the pre-receive hook is extended to grant-scope attachment write paths.

**Tech Stack:** Rust (`relicario-cli`, `relicario-core`, `relicario-server`), `clap`, `anyhow`, `zeroize::Zeroizing`, `assert_cmd` + `tempfile` for CLI integration tests, `ssh-keygen` for the org test fixture.

**Spec:** `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md`

## Global Constraints

- **Capitalization:** "Relicario" with a capital R in prose / UI / CLI help text; lowercase `relicario` for the binary, crate names, identifiers, and paths.
- **Secret-input model:** single-line secrets resolve as `--*-stdin` (read one trimmed-of-trailing-newline line from stdin) → else interactive `prompt_secret` (which honours `RELICARIO_TEST_ITEM_SECRET`). Multiline secrets (key material, note body) always read stdin to EOF; the `--*-stdin` flag only suppresses the interactive "paste; Ctrl-D" hint.
- **Field history:** core-field edits record prior values under synthetic `FieldId(format!("core:{key}"))` keys via `push_history` — never invent a different scheme.
- **Org commits:** every org write is committed through `crate::org_session::org_git_run` with the existing structured trailers (`Relicario-Actor`, `Relicario-Action`, `Relicario-Collection`, `Relicario-Item`). Do not introduce an unsigned commit path.
- **Org storage layout:** items `items/<slug>/<id>.enc`; attachments (new) `attachments/<slug>/<item-id>/<att-id>.enc`. The leading `<slug>` is what the hook authorizes against — it is never decrypted server-side.
- **Discipline:** DRY, YAGNI, TDD (failing test first), frequent commits. Conventional-commit messages.
- **Hook redeploy:** the `relicario-server` change (Dev-D) requires the deployed pre-receive hook to be rebuilt; the release notes must call this out.

## File Structure

| Path | Responsibility | Stream |
|---|---|---|
| `crates/relicario-cli/src/commands/item_build.rs` | **NEW.** Shared secret-resolution helpers, type parsers, per-type `build_*` item builders, per-type interactive `edit_*` helpers + `push_history`. | A |
| `crates/relicario-cli/src/commands/mod.rs` | Register `pub mod item_build;`. | A |
| `crates/relicario-cli/src/commands/add.rs` | `cmd_add` delegates to shared builders; sets `group`/`tags`/`favorite` post-build. | A |
| `crates/relicario-cli/src/commands/edit.rs` | `cmd_edit` delegates to shared `edit_*` helpers. | A |
| `crates/relicario-cli/src/main.rs` | Personal `AddKind` `--*-stdin` flags (A); org `OrgAddKind` Card/Key/Totp/Document variants + dispatch (B/C); `OrgCommands::Edit` → query-only interactive (B). | A/B/C |
| `crates/relicario-cli/src/commands/org.rs` | `commands::org::OrgAddKind` new variants; `run_add` arms; `run_edit` per-type interactive dispatch; `run_purge` removes attachments. | B/C |
| `crates/relicario-cli/src/org_session.rs` | Attachment path/save/load/remove methods + default cap constant. | C |
| `crates/relicario-cli/tests/org_items.rs` | Card/Key/Totp/Document add→get→edit→rm round-trips; secret masking; grant-denied attachment write. | B/C |
| `crates/relicario-server/src/lib.rs` | `classify_path` recognizes `attachments/<slug>/<item-id>/<att-id>.enc` → `Item{collection}`. | D |
| `crates/relicario-server/tests/org_hook.rs` | Attachment classification tests. | D |
| `crates/relicario-server/Cargo.toml` | Version bump. | D |
| `docs/FORMATS.md`, `crates/relicario-cli/ARCHITECTURE.md`, `docs/SECURITY.md` | Living-docs per stream. | C/A/D |

## Stream dependency graph

```
Dev-A (foundation: shared module + personal refactor + personal --*-stdin)
   ├──> Dev-B (org Card/Key/Totp)  ──> [org.rs] ──> Dev-C (org Document + attachments)
   └──> Dev-C (build_document)
Dev-D (server hook) ── independent, may start at minute zero
```

- **Dev-A** must merge before B and C integrate (they consume its public interface).
- **Dev-B** establishes the org per-type `OrgAddKind`/`run_edit` dispatch skeleton in `commands/org.rs`; **Dev-C** adds the Document arm into that skeleton, so **B merges before C** (C rebases on B). They share `org.rs` and `org_items.rs`.
- **Dev-D** touches only `relicario-server` — zero overlap; run it fully in parallel.

---

# Dev-A — Shared item-build foundation

**Interfaces produced (consumed by B and C):**

```rust
// crates/relicario-cli/src/commands/item_build.rs
use std::path::PathBuf;
use std::collections::HashMap;
use anyhow::Result;
use zeroize::Zeroizing;
use relicario_core::{Item, EncryptedAttachment, FieldId};
use relicario_core::item::FieldHistoryEntry;

pub(crate) type FieldHistory = HashMap<FieldId, Vec<FieldHistoryEntry>>;

// --- secret resolution ---
pub(crate) fn resolve_secret_line(from_stdin: bool, label: &str) -> Result<String>;
pub(crate) fn resolve_secret_multiline(from_stdin: bool, hint: &str) -> Result<String>;

// --- parsers ---
pub(crate) fn parse_card_kind(s: &str) -> Result<relicario_core::item_types::CardKind>;
pub(crate) fn parse_totp_algorithm(s: &str) -> Result<relicario_core::item_types::TotpAlgorithm>;

// --- builders (return Item with title + core only; caller sets group/tags/favorite) ---
pub(crate) fn build_login(title: String, username: Option<String>, url: Option<String>,
    password: Option<String>, password_stdin: bool, password_prompt: bool,
    totp_qr: Option<PathBuf>) -> Result<Item>;
pub(crate) fn build_secure_note(title: String, body: Option<String>, body_stdin: bool) -> Result<Item>;
pub(crate) fn build_identity(title: String, full_name: Option<String>, email: Option<String>,
    phone: Option<String>, date_of_birth: Option<String>) -> Result<Item>;
pub(crate) fn build_card(title: String, holder: Option<String>, expiry: Option<String>,
    kind: &str, number_stdin: bool, cvv_stdin: bool, pin_stdin: bool) -> Result<Item>;
pub(crate) fn build_key(title: String, label: Option<String>, algorithm: Option<String>,
    public_key: Option<String>, material_stdin: bool) -> Result<Item>;
pub(crate) fn build_totp(title: String, issuer: Option<String>, label: Option<String>,
    secret: Option<String>, secret_stdin: bool, period: u32, digits: u8, algorithm: &str) -> Result<Item>;
pub(crate) fn build_document(title: String, file: PathBuf,
    key: &Zeroizing<[u8; 32]>, max_bytes: u64) -> Result<(Item, EncryptedAttachment)>;

// --- interactive edit helpers (mutate core in place; record history) ---
pub(crate) fn edit_login(l: &mut relicario_core::item_types::LoginCore, history: &mut FieldHistory, totp_qr: Option<PathBuf>) -> Result<()>;
pub(crate) fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()>;
pub(crate) fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()>;
pub(crate) fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()>;
pub(crate) fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()>;
pub(crate) fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()>;
pub(crate) fn edit_document_message();
pub(crate) fn push_history(history: &mut FieldHistory, synthetic_key: &str, old_value: Zeroizing<String>);
```

### Task A1: Shared module scaffold — secret resolution + parsers

**Files:**
- Create: `crates/relicario-cli/src/commands/item_build.rs`
- Modify: `crates/relicario-cli/src/commands/mod.rs` (add `pub mod item_build;`)
- Test: inline `#[cfg(test)]` in `item_build.rs`

- [ ] **Step 1: Register the module.** In `crates/relicario-cli/src/commands/mod.rs` add (alongside the other `pub mod` lines):

```rust
pub mod item_build;
```

- [ ] **Step 2: Write the failing parser tests.** Create `crates/relicario-cli/src/commands/item_build.rs` with only the test module first:

```rust
#[cfg(test)]
mod tests {
    use super::*;
    use relicario_core::item_types::{CardKind, TotpAlgorithm};

    #[test]
    fn card_kind_parses_known_values() {
        assert_eq!(parse_card_kind("credit").unwrap(), CardKind::Credit);
        assert_eq!(parse_card_kind("loyalty").unwrap(), CardKind::Loyalty);
    }

    #[test]
    fn card_kind_rejects_unknown() {
        assert!(parse_card_kind("platinum").is_err());
    }

    #[test]
    fn totp_algorithm_is_case_insensitive() {
        assert_eq!(parse_totp_algorithm("SHA256").unwrap(), TotpAlgorithm::Sha256);
    }

    #[test]
    fn totp_algorithm_rejects_unknown() {
        assert!(parse_totp_algorithm("md5").is_err());
    }
}
```

- [ ] **Step 3: Run to verify failure.**
Run: `cargo test -p relicario-cli --lib item_build`
Expected: FAIL — `parse_card_kind`/`parse_totp_algorithm` not found.

- [ ] **Step 4: Implement helpers + parsers.** Prepend above the test module in `item_build.rs`:

```rust
//! Shared per-type item construction + interactive editing for both the
//! personal vault (`commands/add.rs`, `commands/edit.rs`) and the org vault
//! (`commands/org.rs`). Centralizing it keeps the two surfaces from drifting.

use std::collections::HashMap;
use std::path::PathBuf;

use anyhow::{Context, Result};
use zeroize::Zeroizing;

use relicario_core::item::FieldHistoryEntry;
use relicario_core::item_types::{CardKind, TotpAlgorithm};
use relicario_core::{EncryptedAttachment, FieldId, Item, ItemCore};

pub(crate) type FieldHistory = HashMap<FieldId, Vec<FieldHistoryEntry>>;

/// Resolve a single-line secret: from stdin when `from_stdin`, else an
/// interactive masked prompt (which honours `RELICARIO_TEST_ITEM_SECRET`).
pub(crate) fn resolve_secret_line(from_stdin: bool, label: &str) -> Result<String> {
    if from_stdin {
        let mut s = String::new();
        std::io::stdin().read_line(&mut s)?;
        Ok(s.trim_end_matches(['\n', '\r']).to_string())
    } else {
        crate::prompt::prompt_secret(&format!("{label}: "))
    }
}

/// Resolve a multiline secret (key material, note body). Both paths read stdin
/// to EOF; the interactive path first prints `hint` to stderr.
pub(crate) fn resolve_secret_multiline(from_stdin: bool, hint: &str) -> Result<String> {
    if !from_stdin {
        eprintln!("{hint}");
    }
    let mut s = String::new();
    std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
    Ok(s)
}

pub(crate) fn parse_card_kind(s: &str) -> Result<CardKind> {
    Ok(match s {
        "credit" => CardKind::Credit,
        "debit" => CardKind::Debit,
        "gift" => CardKind::Gift,
        "loyalty" => CardKind::Loyalty,
        "other" => CardKind::Other,
        other => anyhow::bail!("unknown card kind: {other}"),
    })
}

pub(crate) fn parse_totp_algorithm(s: &str) -> Result<TotpAlgorithm> {
    Ok(match s.to_ascii_lowercase().as_str() {
        "sha1" => TotpAlgorithm::Sha1,
        "sha256" => TotpAlgorithm::Sha256,
        "sha512" => TotpAlgorithm::Sha512,
        other => anyhow::bail!("unknown algorithm: {other}"),
    })
}
```

- [ ] **Step 5: Run to verify pass.**
Run: `cargo test -p relicario-cli --lib item_build`
Expected: PASS (4 tests).

- [ ] **Step 6: Commit.**

```bash
git add crates/relicario-cli/src/commands/item_build.rs crates/relicario-cli/src/commands/mod.rs
git commit -m "feat(cli): shared item_build module — secret resolution + type parsers"
```

### Task A2: Move interactive edit helpers into the shared module

**Files:**
- Modify: `crates/relicario-cli/src/commands/item_build.rs` (add `edit_*` + `push_history`)
- Modify: `crates/relicario-cli/src/commands/edit.rs` (delete the moved fns; call shared ones)
- Test: existing `crates/relicario-cli/tests/edit_and_history.rs` (behavior-preserving)

**Interfaces produced:** the six `edit_*` functions + `edit_document_message` + `push_history` (signatures in the Dev-A header).

- [ ] **Step 1: Move the helpers verbatim.** Cut these functions from `crates/relicario-cli/src/commands/edit.rs` and paste them into `item_build.rs`: `edit_login`, `edit_secure_note`, `edit_identity`, `edit_card`, `edit_key`, `edit_document_message`, `edit_totp`, `push_history`, and the local `type FieldHistory` (delete the duplicate `type FieldHistory` in edit.rs — use the shared one). Change each from private `fn` to `pub(crate) fn`. They already use `crate::prompt::*`, `relicario_core::*`, `crate::parse::base32_decode_lenient` — keep those paths (they resolve identically from `item_build.rs`). Add the needed `use` lines to `item_build.rs`:

```rust
use crate::parse::base32_decode_lenient;
use crate::prompt::{prompt_keep_opt, prompt_secret, prompt_yesno};
use relicario_core::time::now_unix;
```

- [ ] **Step 2: Rewire edit.rs dispatch.** In `crates/relicario-cli/src/commands/edit.rs::cmd_edit`, the per-type match becomes calls into the shared module:

```rust
    let history = &mut item.field_history;
    match &mut item.core {
        ItemCore::Login(l)      => crate::commands::item_build::edit_login(l, history, totp_qr)?,
        ItemCore::SecureNote(n) => crate::commands::item_build::edit_secure_note(n, history)?,
        ItemCore::Identity(i)   => crate::commands::item_build::edit_identity(i)?,
        ItemCore::Card(c)       => crate::commands::item_build::edit_card(c, history)?,
        ItemCore::Key(k)        => crate::commands::item_build::edit_key(k, history)?,
        ItemCore::Document(_)   => crate::commands::item_build::edit_document_message(),
        ItemCore::Totp(t)       => crate::commands::item_build::edit_totp(t, history)?,
    }
```

Remove now-unused imports from `edit.rs` (e.g. `prompt_secret`, `prompt_yesno`, `base32_decode_lenient` if no longer referenced). Keep `prompt_keep`/`prompt_keep_opt` only if the title/group/tags prompts at the top of `cmd_edit` still use them.

- [ ] **Step 3: Run to verify it fails first if anything diverged.**
Run: `cargo build -p relicario-cli`
Expected: compiles. If a moved fn references a symbol not imported in `item_build.rs`, fix the `use`.

- [ ] **Step 4: Run the edit/history tests.**
Run: `cargo test -p relicario-cli --test edit_and_history`
Expected: PASS — behavior is unchanged (functions only moved + re-exported).

- [ ] **Step 5: Commit.**

```bash
git add crates/relicario-cli/src/commands/item_build.rs crates/relicario-cli/src/commands/edit.rs
git commit -m "refactor(cli): move per-type edit helpers into shared item_build module"
```

### Task A3: Move add builders into the shared module

**Files:**
- Modify: `crates/relicario-cli/src/commands/item_build.rs` (add `build_*`)
- Modify: `crates/relicario-cli/src/commands/add.rs` (delegate; set group/tags/favorite)
- Test: existing `crates/relicario-cli/tests/basic_flows.rs`, `attachments.rs`

**Interfaces produced:** the seven `build_*` functions (signatures in the Dev-A header).

- [ ] **Step 1: Add the builders to `item_build.rs`.** Port the bodies from the current `add.rs` `build_*_item` fns, with these changes: builders take an already-resolved `title: String`; they set only `title` + core (NOT group/tags/favorite); secrets go through `resolve_secret_line`/`resolve_secret_multiline`:

```rust
pub(crate) fn build_login(
    title: String, username: Option<String>, url: Option<String>,
    password: Option<String>, password_stdin: bool, password_prompt: bool,
    totp_qr: Option<PathBuf>,
) -> Result<Item> {
    use relicario_core::item_types::{LoginCore, TotpAlgorithm, TotpConfig, TotpKind};
    let parsed_url = match url {
        Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
        None => None,
    };
    let password = if let Some(p) = password {
        Some(Zeroizing::new(p))
    } else if password_stdin {
        Some(Zeroizing::new(resolve_secret_line(true, "Password")?))
    } else if password_prompt {
        Some(Zeroizing::new(crate::prompt::prompt_secret("Password: ")?))
    } else {
        None
    };
    let totp = if let Some(path) = totp_qr {
        let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
        let secret_bytes = base32_decode_lenient(&secret_b32)?;
        Some(TotpConfig {
            secret: Zeroizing::new(secret_bytes), algorithm: TotpAlgorithm::Sha1,
            digits: 6, period_seconds: 30, kind: TotpKind::Totp,
        })
    } else { None };
    Ok(Item::new(title, ItemCore::Login(LoginCore { username, password, url: parsed_url, totp })))
}

pub(crate) fn build_secure_note(title: String, body: Option<String>, body_stdin: bool) -> Result<Item> {
    use relicario_core::item_types::SecureNoteCore;
    let body = match body {
        Some(b) => b,
        None => resolve_secret_multiline(body_stdin, "Enter note body; end with Ctrl-D on a blank line:")?,
    };
    Ok(Item::new(title, ItemCore::SecureNote(SecureNoteCore { body: Zeroizing::new(body) })))
}

pub(crate) fn build_identity(
    title: String, full_name: Option<String>, email: Option<String>,
    phone: Option<String>, date_of_birth: Option<String>,
) -> Result<Item> {
    use relicario_core::item_types::IdentityCore;
    let dob = match date_of_birth {
        Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
            .with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
        None => None,
    };
    Ok(Item::new(title, ItemCore::Identity(IdentityCore {
        full_name, address: None, phone, email, date_of_birth: dob,
    })))
}

pub(crate) fn build_card(
    title: String, holder: Option<String>, expiry: Option<String>, kind: &str,
    number_stdin: bool, cvv_stdin: bool, pin_stdin: bool,
) -> Result<Item> {
    use relicario_core::item_types::CardCore;
    let number = Zeroizing::new(resolve_secret_line(number_stdin, "Card number")?);
    let cvv = resolve_secret_line(cvv_stdin, "CVV (blank to skip)")?;
    let cvv = if cvv.is_empty() { None } else { Some(Zeroizing::new(cvv)) };
    let pin = resolve_secret_line(pin_stdin, "PIN (blank to skip)")?;
    let pin = if pin.is_empty() { None } else { Some(Zeroizing::new(pin)) };
    let parsed_expiry = match expiry { Some(s) => Some(crate::parse::parse_month_year(&s)?), None => None };
    Ok(Item::new(title, ItemCore::Card(CardCore {
        number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parse_card_kind(kind)?,
    })))
}

pub(crate) fn build_key(
    title: String, label: Option<String>, algorithm: Option<String>,
    public_key: Option<String>, material_stdin: bool,
) -> Result<Item> {
    use relicario_core::item_types::KeyCore;
    let key_material = resolve_secret_multiline(material_stdin, "Paste key material; end with Ctrl-D on a blank line:")?;
    if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
    Ok(Item::new(title, ItemCore::Key(KeyCore {
        key_material: Zeroizing::new(key_material), label, public_key, algorithm,
    })))
}

pub(crate) fn build_totp(
    title: String, issuer: Option<String>, label: Option<String>,
    secret: Option<String>, secret_stdin: bool, period: u32, digits: u8, algorithm: &str,
) -> Result<Item> {
    use relicario_core::item_types::{TotpConfig, TotpCore, TotpKind};
    let secret_b32 = match secret {
        Some(s) => s,
        None => resolve_secret_line(secret_stdin, "TOTP secret (base32)")?,
    };
    let secret_bytes = base32_decode_lenient(&secret_b32)?;
    Ok(Item::new(title, ItemCore::Totp(TotpCore {
        config: TotpConfig {
            secret: Zeroizing::new(secret_bytes), algorithm: parse_totp_algorithm(algorithm)?,
            digits, period_seconds: period, kind: TotpKind::Totp,
        },
        issuer, label,
    })))
}

pub(crate) fn build_document(
    title: String, file: PathBuf, key: &Zeroizing<[u8; 32]>, max_bytes: u64,
) -> Result<(Item, EncryptedAttachment)> {
    use relicario_core::item_types::DocumentCore;
    use relicario_core::{encrypt_attachment, AttachmentRef};
    let bytes = std::fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?;
    let enc = encrypt_attachment(&bytes, key, max_bytes)?;
    let filename = file.file_name()
        .ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
        .to_string_lossy().into_owned();
    let mime_type = crate::parse::guess_mime(&filename);
    let primary_attachment = enc.id.clone();
    let mut item = Item::new(title, ItemCore::Document(DocumentCore {
        filename: filename.clone(), mime_type: mime_type.clone(), primary_attachment: primary_attachment.clone(),
    }));
    item.attachments.push(AttachmentRef {
        id: primary_attachment, filename, mime_type, size: bytes.len() as u64, created: item.created,
    });
    Ok((item, enc))
}
```

- [ ] **Step 2: Rewire `cmd_add`.** In `crates/relicario-cli/src/commands/add.rs`, delete the local `build_*_item` fns and replace `cmd_add`'s match so each arm resolves `title` (via `prompt_or_flag`), calls the shared builder, then sets `group`/`tags`/`favorite`. Document writes the encrypted blob to the personal path. Example arms:

```rust
use crate::commands::item_build as ib;
use crate::prompt::{prompt_or_flag, prompt_or_flag_optional};

let item = match kind {
    AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite, totp_qr } => {
        let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
        let username = prompt_or_flag_optional(username, "Username", |s| Ok(s.to_string()))?;
        let url = prompt_or_flag_optional(url, "URL", |s| Ok(s.to_string()))?;
        let mut item = ib::build_login(title, username, url, password, /*password_stdin*/ false, password_prompt, totp_qr)?;
        item.group = group; item.tags = tags; item.favorite = favorite;
        item
    }
    AddKind::Card { title, holder, expiry, kind, group, tags } => {
        let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
        let mut item = ib::build_card(title, holder, expiry, &kind, false, false, false)?;
        item.group = group; item.tags = tags;
        item
    }
    AddKind::Document { title, file, group, tags } => {
        let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
        let caps = vault.load_settings()?.attachment_caps;
        let (mut item, enc) = ib::build_document(title, file, vault.key(), caps.per_attachment_max_bytes)?;
        item.group = group; item.tags = tags;
        let att_dir = vault.root().join("attachments").join(item.id.as_str());
        std::fs::create_dir_all(&att_dir)?;
        std::fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?;
        item
    }
    // SecureNote / Identity / Key / Totp arms follow the same shape, passing
    // `false` for the new *_stdin booleans (A4 wires the real flags).
    // ...
};
```

> The `*_stdin` booleans are hard-wired `false` here; Task A4 adds the flags and threads the real values. This keeps A3 a pure, behavior-preserving refactor.

- [ ] **Step 3: Run the failing build.**
Run: `cargo build -p relicario-cli`
Expected: compiles after all seven arms are ported.

- [ ] **Step 4: Run personal add tests.**
Run: `cargo test -p relicario-cli --test basic_flows --test attachments`
Expected: PASS — personal add behavior unchanged.

- [ ] **Step 5: Commit.**

```bash
git add crates/relicario-cli/src/commands/item_build.rs crates/relicario-cli/src/commands/add.rs
git commit -m "refactor(cli): personal add delegates to shared item_build builders"
```

### Task A4: Add `--*-stdin` secret flags to the personal CLI

**Files:**
- Modify: `crates/relicario-cli/src/main.rs` (`AddKind` variants + dispatch)
- Modify: `crates/relicario-cli/src/commands/add.rs` (thread the new booleans)
- Test: `crates/relicario-cli/tests/basic_flows.rs` (new stdin-driven case)

- [ ] **Step 1: Write the failing test.** Append to `crates/relicario-cli/tests/basic_flows.rs` a test that adds a Card non-interactively via `--*-stdin` (model the secret-piping on the existing fixture; pipe `number\ncvv\npin\n`):

```rust
#[test]
fn add_card_via_stdin_flags_is_non_interactive() {
    let v = TestVault::new(); // existing personal-vault fixture in this file
    let mut cmd = v.cmd(&["add", "card", "--title", "Visa", "--kind", "credit",
                          "--number-stdin", "--cvv-stdin", "--pin-stdin"]);
    cmd.write_stdin("4111111111111111\n123\n4321\n");
    let out = cmd.output().unwrap();
    assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
    // The card lands and its number is masked without --show.
    let got = v.run(&["get", "Visa"]);
    assert!(String::from_utf8_lossy(&got.stdout).contains("********"));
}
```

> If the personal fixture in `basic_flows.rs` lacks a `write_stdin` helper, use `assert_cmd`'s `Command::write_stdin` on the `cargo_bin` command directly, mirroring `org_items.rs`'s `run` but with `Stdio::piped()` stdin.

- [ ] **Step 2: Run to verify failure.**
Run: `cargo test -p relicario-cli --test basic_flows add_card_via_stdin_flags`
Expected: FAIL — `--number-stdin` is an unknown argument.

- [ ] **Step 3: Add the flags.** In `crates/relicario-cli/src/main.rs`, extend the relevant `AddKind` variants with boolean stdin flags (clap `#[arg(long)]`, default false). Card example:

```rust
    Card {
        #[arg(long)] title: Option<String>,
        #[arg(long)] holder: Option<String>,
        #[arg(long)] expiry: Option<String>,
        #[arg(long, default_value = "credit")] kind: String,
        #[arg(long)] group: Option<String>,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
        #[arg(long)] number_stdin: bool,
        #[arg(long)] cvv_stdin: bool,
        #[arg(long)] pin_stdin: bool,
    },
```

Add `password_stdin` to `Login`, `body_stdin` to `SecureNote`, `material_stdin` to `Key`, `secret_stdin` to `Totp`. (Document has no secret flag.)

- [ ] **Step 4: Thread the booleans.** Update `cmd_add` arms to pass the real flag values instead of the hard-wired `false` from A3 (e.g. `ib::build_card(title, holder, expiry, &kind, number_stdin, cvv_stdin, pin_stdin)?`). Destructure the new fields in each `AddKind` arm.

- [ ] **Step 5: Run to verify pass.**
Run: `cargo test -p relicario-cli --test basic_flows`
Expected: PASS (incl. the new case).

- [ ] **Step 6: Update CLI architecture doc.** In `crates/relicario-cli/ARCHITECTURE.md`, document the new `commands/item_build.rs` module (shared builders + edit helpers) and the `--*-stdin` secret convention. Cite `item_build.rs` for the secret-resolution rule.

- [ ] **Step 7: Commit.**

```bash
git add crates/relicario-cli/src/main.rs crates/relicario-cli/src/commands/add.rs crates/relicario-cli/tests/basic_flows.rs crates/relicario-cli/ARCHITECTURE.md
git commit -m "feat(cli): --*-stdin secret flags for personal add (non-interactive secrets)"
```

---

# Dev-B — Org Card / Key / Totp parity

**Consumes:** the Dev-A shared module (`crate::commands::item_build`).
**Produces:** the org per-type `add`/`edit` dispatch skeleton that Dev-C extends with Document.

### Task B1: Extend `commands::org::OrgAddKind` + `run_add` for Card/Key/Totp

**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs` (`OrgAddKind` enum + `build_org_item` → shared builders)

**Interfaces produced:**

```rust
// commands/org.rs
pub(crate) enum OrgAddKind {
    Login { title: String, username: Option<String>, url: Option<String>,
            password: Option<String>, password_stdin: bool },
    SecureNote { title: String, body: Option<String>, body_stdin: bool },
    Identity { title: String, full_name: Option<String>, email: Option<String>, phone: Option<String> },
    Card { title: String, holder: Option<String>, expiry: Option<String>, kind: String,
           number_stdin: bool, cvv_stdin: bool, pin_stdin: bool },
    Key { title: String, label: Option<String>, algorithm: Option<String>,
          public_key: Option<String>, material_stdin: bool },
    Totp { title: String, issuer: Option<String>, label: Option<String>,
           secret: Option<String>, secret_stdin: bool, period: u32, digits: u8, algorithm: String },
    // Document added by Dev-C.
}
fn build_org_item(kind: OrgAddKind) -> Result<Item>;  // tags set by run_add caller
```

- [ ] **Step 1: Replace `build_org_item` to delegate to shared builders.** In `crates/relicario-cli/src/commands/org.rs`:

```rust
use crate::commands::item_build as ib;

fn build_org_item(kind: OrgAddKind) -> Result<Item> {
    match kind {
        OrgAddKind::Login { title, username, url, password, password_stdin } =>
            ib::build_login(title, username, url, password, password_stdin, false, None),
        OrgAddKind::SecureNote { title, body, body_stdin } =>
            ib::build_secure_note(title, body, body_stdin),
        OrgAddKind::Identity { title, full_name, email, phone } =>
            ib::build_identity(title, full_name, email, phone, None),
        OrgAddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin } =>
            ib::build_card(title, holder, expiry, &kind, number_stdin, cvv_stdin, pin_stdin),
        OrgAddKind::Key { title, label, algorithm, public_key, material_stdin } =>
            ib::build_key(title, label, algorithm, public_key, material_stdin),
        OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm } =>
            ib::build_totp(title, issuer, label, secret, secret_stdin, period, digits, &algorithm),
    }
}
```

- [ ] **Step 2: Update `run_add` to set tags after build.** `run_add`'s signature stays `run_add(dir, collection, kind: OrgAddKind, tags: Vec<String>)`; after `let item = build_org_item(kind)?;` add `let mut item = item; item.tags = tags;` (or fold into the existing flow). The rest of `run_add` (grant check, `save_item`, manifest upsert, signed commit) is unchanged.

- [ ] **Step 3: Build.**
Run: `cargo build -p relicario-cli`
Expected: compiles (main.rs dispatch updated in B2 — expect an error there until B2; build `commands/org.rs` in isolation by temporarily leaving the old main.rs mapping is not possible, so do B1+B2 together before building). Proceed to B2, then build.

- [ ] **Step 4: Commit (with B2).** Combined commit at end of B2.

### Task B2: Wire `main.rs` clap surface + dispatch for org Card/Key/Totp

**Files:**
- Modify: `crates/relicario-cli/src/main.rs` (`OrgAddKind` clap enum + the `OrgCommands::Add` dispatch)

- [ ] **Step 1: Extend the clap `OrgAddKind` enum.** Add Card/Key/Totp variants mirroring personal flags plus `--collection`, `--title` (required), and the `--*-stdin` flags:

```rust
    Card {
        #[arg(long)] collection: String,
        #[arg(long)] title: String,
        #[arg(long)] holder: Option<String>,
        #[arg(long)] expiry: Option<String>,
        #[arg(long, default_value = "credit")] kind: String,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
        #[arg(long)] number_stdin: bool,
        #[arg(long)] cvv_stdin: bool,
        #[arg(long)] pin_stdin: bool,
    },
    Key {
        #[arg(long)] collection: String,
        #[arg(long)] title: String,
        #[arg(long)] label: Option<String>,
        #[arg(long)] algorithm: Option<String>,
        #[arg(long)] public_key: Option<String>,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
        #[arg(long)] material_stdin: bool,
    },
    Totp {
        #[arg(long)] collection: String,
        #[arg(long)] title: String,
        #[arg(long)] issuer: Option<String>,
        #[arg(long)] label: Option<String>,
        #[arg(long)] secret: Option<String>,
        #[arg(long, default_value_t = 30)] period: u32,
        #[arg(long, default_value_t = 6)] digits: u8,
        #[arg(long, default_value = "sha1")] algorithm: String,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
        #[arg(long)] secret_stdin: bool,
    },
```

Also add `#[arg(long)] password_stdin: bool` to the existing clap `OrgAddKind::Login` and `#[arg(long)] body_stdin: bool` to `SecureNote` (and make `SecureNote.body` `Option<String>` so it can be omitted for stdin/prompt).

- [ ] **Step 2: Extend the dispatch mapping.** In `main.rs`'s `OrgCommands::Add` match, add arms mapping each clap variant to `commands::org::OrgAddKind`:

```rust
    OrgAddKind::Card { collection, title, holder, expiry, kind, tags, number_stdin, cvv_stdin, pin_stdin } => (
        collection,
        commands::org::OrgAddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin },
        tags,
    ),
    OrgAddKind::Key { collection, title, label, algorithm, public_key, tags, material_stdin } => (
        collection,
        commands::org::OrgAddKind::Key { title, label, algorithm, public_key, material_stdin },
        tags,
    ),
    OrgAddKind::Totp { collection, title, issuer, label, secret, period, digits, algorithm, tags, secret_stdin } => (
        collection,
        commands::org::OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm },
        tags,
    ),
```

Update the existing Login/SecureNote arms to pass the new `password_stdin`/`body_stdin` fields.

- [ ] **Step 3: Build.**
Run: `cargo build -p relicario-cli`
Expected: compiles.

- [ ] **Step 4: Commit B1+B2.**

```bash
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs
git commit -m "feat(cli/org): org add parity for Card/Key/Totp via shared builders"
```

### Task B3: Convert org `edit` to per-type interactive dispatch

**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs` (`run_edit`)
- Modify: `crates/relicario-cli/src/main.rs` (`OrgCommands::Edit` args + dispatch)

**Rationale:** match the personal `edit` (interactive, "blank to keep", field-history) by reusing the shared `edit_*` helpers. The flat `--username/--url/...` flags are dropped; non-interactive tests drive the prompts via piped stdin + `RELICARIO_TEST_ITEM_SECRET`.

- [ ] **Step 1: Rewrite `run_edit`.** Replace the flat-flag signature with `run_edit(dir: &Path, query: &str, totp_qr: Option<PathBuf>)`:

```rust
pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>) -> Result<()> {
    use relicario_core::time::now_unix;
    use relicario_core::ItemCore;
    use crate::commands::item_build as ib;

    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    let manifest = vault.load_manifest()?;
    let visible = manifest.filter_for_member(&caller);
    let entry = resolve_org_query(&visible, query)?;
    let collection = entry.collection.clone();
    let id = entry.id.clone();
    crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?;

    let mut item = vault.load_item(&collection, &id)?;
    eprintln!("Editing: {} ({}) — leave a prompt blank to keep the current value.", item.title, item.id.as_str());
    if let Some(v) = crate::prompt::prompt_keep("Title", &item.title)? { item.title = v; }

    let history = &mut item.field_history;
    match &mut item.core {
        ItemCore::Login(l)      => ib::edit_login(l, history, totp_qr)?,
        ItemCore::SecureNote(n) => ib::edit_secure_note(n, history)?,
        ItemCore::Identity(i)   => ib::edit_identity(i)?,
        ItemCore::Card(c)       => ib::edit_card(c, history)?,
        ItemCore::Key(k)        => ib::edit_key(k, history)?,
        ItemCore::Document(_)   => ib::edit_document_message(),
        ItemCore::Totp(t)       => ib::edit_totp(t, history)?,
    }

    item.modified = now_unix();
    let item_rel = vault.save_item(&collection, &item)?;
    let mut m = vault.load_manifest()?;
    upsert_org_entry(&mut m, &item, &collection);
    vault.save_manifest(&m)?;
    let commit_msg = format!(
        "org edit: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-edit\nRelicario-Collection: {}\nRelicario-Item: {}",
        crate::helpers::sanitize_for_commit(&item.title), item.id.as_str(),
        caller.display_name, caller.member_id.as_str(), collection, item.id.as_str(),
    );
    crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org edit: git add")?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?;
    println!("Updated {} ({}) in `{}`", item.title, item.id.as_str(), collection);
    Ok(())
}
```

- [ ] **Step 2: Update `OrgCommands::Edit` clap + dispatch.** In `main.rs`, change the `Edit` variant to `{ #[arg(long)] query: String (or positional), #[arg(long)] totp_qr: Option<PathBuf> }` and the dispatch to `commands::org::run_edit(&d, &query, totp_qr)?`. Remove the dropped `--username/--url/--password/--body/--email/--phone/--full_name` fields.

- [ ] **Step 3: Build.**
Run: `cargo build -p relicario-cli`
Expected: compiles.

- [ ] **Step 4: Commit.**

```bash
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs
git commit -m "feat(cli/org): interactive per-type org edit via shared edit helpers"
```

### Task B4: Integration tests — org Card/Key/Totp round-trips

**Files:**
- Modify: `crates/relicario-cli/tests/org_items.rs` (fixture stdin helper + new tests)

- [ ] **Step 1: Add a stdin-piping run helper to `OrgFixture`.**

```rust
    /// Like `run`, but pipes `stdin_data` into the child's stdin (for --*-stdin
    /// secrets and interactive edit prompts).
    fn run_stdin(&self, args: &[&str], stdin_data: &str) -> std::process::Output {
        use std::io::Write as _;
        let mut child = Command::cargo_bin("relicario").unwrap()
            .env("XDG_CONFIG_HOME", &self.xdg)
            .env("RELICARIO_ORG_DIR", self.vault.path())
            .args(args)
            .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped())
            .spawn().unwrap();
        child.stdin.as_mut().unwrap().write_all(stdin_data.as_bytes()).unwrap();
        child.wait_with_output().unwrap()
    }
```

- [ ] **Step 2: Write the failing Card test.** Assumes the fixture already creates a collection `eng` and grants the owner (reuse the existing helper in this file that the Login/Identity tests use; if it is inline, replicate the `org create-collection` + `org grant` calls):

```rust
#[test]
fn org_add_card_via_stdin_then_get_masks_secret() {
    let f = OrgFixture::new();
    f.create_collection_and_grant("eng"); // existing helper used by the Login tests
    let out = f.run_stdin(
        &["org", "add", "card", "--collection", "eng", "--title", "Corp Visa",
          "--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin"],
        "4111111111111111\n123\n4321\n",
    );
    assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr));

    let got = f.run(&["org", "get", "Corp Visa"]);
    let stdout = String::from_utf8_lossy(&got.stdout);
    assert!(stdout.contains("Corp Visa"));
    assert!(stdout.contains("********"), "card number must be masked without --show");
    assert!(!stdout.contains("4111111111111111"), "secret leaked without --show");
}
```

- [ ] **Step 3: Run to verify failure.**
Run: `cargo test -p relicario-cli --test org_items org_add_card`
Expected: FAIL — `card` subcommand unknown (until B1/B2 merged).

- [ ] **Step 4: Add Key + Totp + edit tests.** Key (material via stdin → `org get` shows `Label`, secret masked), Totp (`--secret` flag value → `org get` shows issuer/label), and an edit round-trip using `run_stdin` to drive the interactive prompts (e.g. change a Card holder: pipe `\nn\n` to keep title, answer holder, decline number change — match the exact prompt order of `edit_card`).

```rust
#[test]
fn org_add_totp_with_secret_flag_round_trips() {
    let f = OrgFixture::new();
    f.create_collection_and_grant("eng");
    let out = f.run(&["org", "add", "totp", "--collection", "eng", "--title", "AWS root",
                      "--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP"]);
    assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr));
    let got = f.run(&["org", "get", "AWS root"]);
    assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer:   AWS"));
}
```

- [ ] **Step 5: Run all org tests.**
Run: `cargo test -p relicario-cli --test org_items`
Expected: PASS.

- [ ] **Step 6: Commit.**

```bash
git add crates/relicario-cli/tests/org_items.rs
git commit -m "test(cli/org): Card/Key/Totp add/get/edit round-trips via stdin"
```

---

# Dev-C — Org Document + attachment storage

**Consumes:** Dev-A `build_document`; Dev-B's `OrgAddKind` + `run_add`/`run_edit` skeleton in `org.rs` (rebase on B).

### Task C1: Org attachment storage in `org_session`

**Files:**
- Modify: `crates/relicario-cli/src/org_session.rs`

**Interfaces produced:**

```rust
// org_session.rs
pub const DEFAULT_ORG_ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024; // mirrors personal default; see note
impl UnlockedOrgVault {
    pub fn attachment_path(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> PathBuf;
    pub fn save_attachment(&self, collection_slug: &str, item_id: &ItemId, enc: &EncryptedAttachment) -> Result<String>;
    pub fn load_attachment(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> Result<Zeroizing<Vec<u8>>>;
    pub fn remove_item_attachments(&self, collection_slug: &str, item_id: &ItemId) -> Result<()>;
}
```

> **Cap value:** confirm `25 * 1024 * 1024` against the personal default at `crates/relicario-core/src/settings.rs` (`attachment_caps.per_attachment_max_bytes`). Use the personal default's exact value and cite its source line in a doc comment (code-constant-pinning rule).

- [ ] **Step 1: Write the failing test.** Add to `org_session.rs` an inline `#[cfg(test)]` round-trip:

```rust
#[cfg(test)]
mod attachment_tests {
    use super::*;
    use relicario_core::encrypt_attachment;
    use tempfile::TempDir;

    #[test]
    fn attachment_round_trip_collection_scoped() {
        let dir = TempDir::new().unwrap();
        let vault = UnlockedOrgVault { root: dir.path().to_path_buf(), org_key: Zeroizing::new([7u8; 32]) };
        let item_id = ItemId::new();
        let enc = encrypt_attachment(b"hello world", &vault.org_key, DEFAULT_ORG_ATTACHMENT_MAX_BYTES).unwrap();
        let rel = vault.save_attachment("eng", &item_id, &enc).unwrap();
        assert_eq!(rel, format!("attachments/eng/{}/{}.enc", item_id.as_str(), enc.id.as_str()));
        let got = vault.load_attachment("eng", &item_id, &enc.id).unwrap();
        assert_eq!(got.as_slice(), b"hello world");
        vault.remove_item_attachments("eng", &item_id).unwrap();
        assert!(vault.load_attachment("eng", &item_id, &enc.id).is_err());
    }
}
```

> Verify `ItemId::new()` exists (used throughout core); if the constructor differs, build an id the same way `Item::new` does.

- [ ] **Step 2: Run to verify failure.**
Run: `cargo test -p relicario-cli --lib attachment_round_trip`
Expected: FAIL — methods/constant not found.

- [ ] **Step 3: Implement.** Add to `org_session.rs` (imports: `AttachmentId`, `EncryptedAttachment`, `decrypt_attachment` from `relicario_core`):

```rust
/// Default per-attachment cap for org vaults. Org has no settings.enc, so this
/// mirrors the personal-vault default at crates/relicario-core/src/settings.rs.
pub const DEFAULT_ORG_ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024;

impl UnlockedOrgVault {
    pub fn attachment_path(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> PathBuf {
        self.root.join("attachments").join(collection_slug)
            .join(item_id.as_str()).join(format!("{}.enc", att_id.as_str()))
    }

    pub fn save_attachment(&self, collection_slug: &str, item_id: &ItemId, enc: &EncryptedAttachment) -> Result<String> {
        let path = self.attachment_path(collection_slug, item_id, &enc.id);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
        }
        atomic_write(&path, &enc.bytes)?;
        Ok(format!("attachments/{}/{}/{}.enc", collection_slug, item_id.as_str(), enc.id.as_str()))
    }

    pub fn load_attachment(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> Result<Zeroizing<Vec<u8>>> {
        let path = self.attachment_path(collection_slug, item_id, att_id);
        let bytes = fs::read(&path).with_context(|| format!("read attachment {}", path.display()))?;
        Ok(relicario_core::decrypt_attachment(&bytes, &self.org_key)?)
    }

    pub fn remove_item_attachments(&self, collection_slug: &str, item_id: &ItemId) -> Result<()> {
        let dir = self.root.join("attachments").join(collection_slug).join(item_id.as_str());
        match fs::remove_dir_all(&dir) {
            Ok(()) => Ok(()),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(e) => Err(anyhow::Error::from(e).context(format!("remove {}", dir.display()))),
        }
    }
}
```

- [ ] **Step 4: Run to verify pass.**
Run: `cargo test -p relicario-cli --lib attachment_round_trip`
Expected: PASS.

- [ ] **Step 5: Commit.**

```bash
git add crates/relicario-cli/src/org_session.rs
git commit -m "feat(cli/org): collection-scoped attachment storage + default cap"
```

### Task C2: Org `add document` + commit the attachment path

**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs` (`OrgAddKind::Document`, `run_add` document handling)
- Modify: `crates/relicario-cli/src/main.rs` (clap `OrgAddKind::Document` + dispatch)

- [ ] **Step 1: Add the Document variant + builder call.** In `commands/org.rs`, add `Document { title: String, file: PathBuf }` to `OrgAddKind`. Because Document must write an attachment blob and stage its path, special-case it in `run_add` rather than `build_org_item`:

```rust
pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> {
    use crate::org_session::{UnlockedOrgVault, DEFAULT_ORG_ATTACHMENT_MAX_BYTES};
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    let collections = vault.load_collections()?;
    if !collections.contains_slug(collection) {
        anyhow::bail!("collection `{collection}` does not exist — create it with `relicario org create-collection`");
    }
    UnlockedOrgVault::ensure_grant(&caller, collection)?;

    // Build the item; Document also yields an encrypted attachment to persist.
    let (mut item, attachment_rel): (relicario_core::Item, Option<String>) = match kind {
        OrgAddKind::Document { title, file } => {
            let (item, enc) = crate::commands::item_build::build_document(
                title, file, vault.key(), DEFAULT_ORG_ATTACHMENT_MAX_BYTES)?;
            let rel = vault.save_attachment(collection, &item.id, &enc)?;
            (item, Some(rel))
        }
        other => (build_org_item(other)?, None),
    };
    item.tags = tags;

    let item_rel = vault.save_item(collection, &item)?;
    let mut manifest = vault.load_manifest()?;
    upsert_org_entry(&mut manifest, &item, collection);
    vault.save_manifest(&manifest)?;

    let subject = format!("org add: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str());
    let commit_msg = format!(
        "{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-create\nRelicario-Collection: {}\nRelicario-Item: {}",
        caller.display_name, caller.member_id.as_str(), collection, item.id.as_str());

    let mut add_args: Vec<&str> = vec!["add", &item_rel, "manifest.enc"];
    if let Some(ref rel) = attachment_rel { add_args.insert(1, rel); }
    crate::org_session::org_git_run(&vault.root, &add_args, "org add: git add")?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org add: git commit")?;
    println!("Added {} ({}) to `{}`", item.title, item.id.as_str(), collection);
    Ok(())
}
```

> Note: `build_org_item` loses its `tags` responsibility (Dev-B already moved tag-setting into `run_add`). Confirm the B1 version matches `fn build_org_item(kind) -> Result<Item>` with no tags.

- [ ] **Step 2: Add clap `OrgAddKind::Document` + dispatch in `main.rs`:**

```rust
    Document {
        #[arg(long)] collection: String,
        #[arg(long)] title: String,
        #[arg(long)] file: std::path::PathBuf,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
    },
```

Dispatch arm:

```rust
    OrgAddKind::Document { collection, title, file, tags } => (
        collection,
        commands::org::OrgAddKind::Document { title, file },
        tags,
    ),
```

- [ ] **Step 3: Build.**
Run: `cargo build -p relicario-cli`
Expected: compiles.

- [ ] **Step 4: Commit.**

```bash
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs
git commit -m "feat(cli/org): org add document with collection-scoped attachment"
```

### Task C3: Purge removes attachments + Document edit via `--file`

**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs` (`run_purge`, Document edit)

- [ ] **Step 1: Purge removes the attachment directory.** In `run_purge` (hard delete), after `vault.remove_item(&collection, &id)?` add `vault.remove_item_attachments(&collection, &id)?;` and stage the deletions in the commit (the attachment files are removed from the worktree; `git add -A`-style staging of the item + attachment paths). Match the existing `run_purge` commit-staging pattern; if it uses explicit paths, add the attachment dir.

- [ ] **Step 2: Document edit via `--file`.** Since `edit_document_message()` tells personal users to use `attach`/`extract` (which org lacks), give org Document edit a real path: extend `run_edit` so a Document item with a provided `--file` replaces the primary attachment. Simplest: add an optional `file: Option<PathBuf>` parameter to `run_edit`; in the `ItemCore::Document(d)` arm, if `file` is `Some`, build a new `EncryptedAttachment`, remove the old attachment dir, save the new blob, and update `d.filename`/`d.mime_type`/`d.primary_attachment` + `item.attachments`. If `file` is `None`, call `ib::edit_document_message()` (a no-op note). Wire `--file` into `OrgCommands::Edit` clap + dispatch.

```rust
        ItemCore::Document(d) => {
            if let Some(path) = file {
                let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
                let enc = relicario_core::encrypt_attachment(&bytes, vault.key(),
                    crate::org_session::DEFAULT_ORG_ATTACHMENT_MAX_BYTES)?;
                vault.remove_item_attachments(&collection, &id)?;
                let _rel = vault.save_attachment(&collection, &id, &enc)?;
                let filename = path.file_name().unwrap().to_string_lossy().into_owned();
                d.mime_type = crate::parse::guess_mime(&filename);
                d.primary_attachment = enc.id.clone();
                item.attachments = vec![relicario_core::AttachmentRef {
                    id: enc.id, filename: filename.clone(), mime_type: d.mime_type.clone(),
                    size: bytes.len() as u64, created: now_unix(),
                }];
                d.filename = filename;
            } else {
                ib::edit_document_message();
            }
        }
```

> When `--file` replaces the attachment, the commit must stage the new attachment path too — extend the `org_git_run "add"` arg list in `run_edit` to include the new attachment rel path (capture it from `save_attachment`).

- [ ] **Step 3: Build.**
Run: `cargo build -p relicario-cli`
Expected: compiles.

- [ ] **Step 4: Commit.**

```bash
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs
git commit -m "feat(cli/org): org document edit via --file + purge removes attachments"
```

### Task C4: Integration tests — org Document + docs

**Files:**
- Modify: `crates/relicario-cli/tests/org_items.rs`
- Modify: `docs/FORMATS.md`

- [ ] **Step 1: Write the failing Document test.**

```rust
#[test]
fn org_add_document_stores_collection_scoped_attachment() {
    let f = OrgFixture::new();
    f.create_collection_and_grant("eng");
    let src = f.vault.path().join("note.txt"); // any temp file
    std::fs::write(&src, b"secret memo").unwrap();
    let out = f.run(&["org", "add", "document", "--collection", "eng",
                      "--title", "Q3 Memo", "--file", src.to_str().unwrap()]);
    assert!(out.status.success(), "add doc: {}", String::from_utf8_lossy(&out.stderr));

    // An encrypted attachment exists under attachments/eng/<item>/<att>.enc.
    let att_root = f.vault.path().join("attachments").join("eng");
    assert!(att_root.exists(), "attachment dir missing");
    let got = f.run(&["org", "get", "Q3 Memo"]);
    let stdout = String::from_utf8_lossy(&got.stdout);
    assert!(stdout.contains("Filename: note.txt"));
}
```

- [ ] **Step 2: Run to verify failure.**
Run: `cargo test -p relicario-cli --test org_items org_add_document`
Expected: FAIL — `document` subcommand unknown (until C2 merged).

- [ ] **Step 3: Add a purge-removes-attachment test.**

```rust
#[test]
fn org_purge_document_removes_attachment_dir() {
    let f = OrgFixture::new();
    f.create_collection_and_grant("eng");
    let src = f.vault.path().join("d.bin");
    std::fs::write(&src, b"bytes").unwrap();
    f.run(&["org", "add", "document", "--collection", "eng", "--title", "Doc", "--file", src.to_str().unwrap()]);
    f.run(&["org", "rm", "Doc"]);       // trash
    let out = f.run(&["org", "purge", "Doc"]); // hard delete
    assert!(out.status.success(), "purge: {}", String::from_utf8_lossy(&out.stderr));
    let att_root = f.vault.path().join("attachments").join("eng");
    let empty = !att_root.exists() || std::fs::read_dir(&att_root).map(|mut d| d.next().is_none()).unwrap_or(true);
    assert!(empty, "attachment dir should be gone after purge");
}
```

- [ ] **Step 4: Run all org tests.**
Run: `cargo test -p relicario-cli --test org_items`
Expected: PASS.

- [ ] **Step 5: Update FORMATS.md.** Add the org attachment layout (`attachments/<slug>/<item-id>/<att-id>.enc`) and the default cap constant, citing `crates/relicario-cli/src/org_session.rs` (`DEFAULT_ORG_ATTACHMENT_MAX_BYTES`).

- [ ] **Step 6: Commit.**

```bash
git add crates/relicario-cli/tests/org_items.rs docs/FORMATS.md
git commit -m "test(cli/org): org document storage round-trip + purge; FORMATS attachment layout"
```

---

# Dev-D — Server hook: grant-scope attachment paths

**Independent — no dependency on A/B/C.**

### Task D1: Extend `classify_path` for attachments

**Files:**
- Modify: `crates/relicario-server/src/lib.rs`
- Modify: `crates/relicario-server/tests/org_hook.rs`
- Modify: `crates/relicario-server/Cargo.toml` (version bump)
- Modify: `docs/SECURITY.md`

- [ ] **Step 1: Write the failing tests.** Append to `crates/relicario-server/tests/org_hook.rs`:

```rust
#[test]
fn attachment_path_is_collection_scoped() {
    assert_eq!(
        classify_path("attachments/prod/a1b2c3d4e5f6a1b2/0011223344556677.enc"),
        PathClass::Item { collection: "prod".to_string() }
    );
}

#[test]
fn attachment_wrong_segment_count_is_rejected() {
    assert_eq!(
        classify_path("attachments/prod/onlytwo.enc"),
        PathClass::Rejected("attachments path must be attachments/<slug>/<item-id>/<att-id>.enc".to_string())
    );
}

#[test]
fn attachment_empty_or_dotted_slug_is_rejected() {
    assert!(matches!(classify_path("attachments//item/att.enc"), PathClass::Rejected(_)));
    assert!(matches!(classify_path("attachments/../item/att.enc"), PathClass::Rejected(_)));
}
```

- [ ] **Step 2: Run to verify failure.**
Run: `cargo test -p relicario-server --test org_hook attachment_`
Expected: FAIL — attachments classify as `Unrestricted`.

- [ ] **Step 3: Implement the attachments branch.** In `crates/relicario-server/src/lib.rs::classify_path`, before the final `PathClass::Unrestricted`, add:

```rust
    if let Some(rest) = path.strip_prefix("attachments/") {
        // Expect exactly: <slug>/<item-id>/<att-id>.enc → three segments.
        let segments: Vec<&str> = rest.split('/').collect();
        if segments.len() != 3 {
            return PathClass::Rejected(
                "attachments path must be attachments/<slug>/<item-id>/<att-id>.enc".to_string());
        }
        let slug = segments[0];
        if slug.is_empty() {
            return PathClass::Rejected("empty collection slug in attachments path".to_string());
        }
        if slug.contains('.') {
            return PathClass::Rejected(format!("invalid collection slug: {:?}", slug));
        }
        return PathClass::Item { collection: slug.to_string() };
    }
```

Update the `PathClass::Item` doc comment to mention both `items/<slug>/<id>.enc` and `attachments/<slug>/<item-id>/<att-id>.enc`.

- [ ] **Step 4: Run to verify pass.**
Run: `cargo test -p relicario-server`
Expected: PASS (existing + 3 new tests). The hook's `main.rs` authorization loop already handles `PathClass::Item { collection }` (grant + slug-existence) — no `main.rs` change needed; attachment writes are now grant-scoped automatically.

- [ ] **Step 5: Version bump + SECURITY doc.** Bump `version` in `crates/relicario-server/Cargo.toml` (patch bump). In `docs/SECURITY.md`, note that org attachment writes are now grant-scoped (previously `Unrestricted`), and that deploying this requires rebuilding the pre-receive hook on the server.

- [ ] **Step 6: Commit.**

```bash
git add crates/relicario-server/src/lib.rs crates/relicario-server/tests/org_hook.rs crates/relicario-server/Cargo.toml docs/SECURITY.md
git commit -m "feat(server): grant-scope org attachment write paths in pre-receive hook"
```

---

# Integration & release (after all four streams merge)

- [ ] **Full test sweep.** Run `cargo test` (all crates) + `cargo build -p relicario-wasm --target wasm32-unknown-unknown`. Expected: green.
- [ ] **Type-check the extension.** `cd extension && npm run build:all` (the extension is unchanged, but verify nothing broke the workspace). Expected: clean.
- [ ] **STATUS.md / ROADMAP.md / CHANGELOG.md.** Mark org item-type parity (Card/Key/Document/Totp) + grant-scoped attachments landed; move them to shipped; note extension org *writes* (Plan B-2) still deferred.
- [ ] **Release.** Cut v0.8.1 via the release workflow (`Workflow({name:"release", args:{action:"release", release:"v0.8.1"}})`). The release notes MUST call out the **coordinated `relicario-server` redeploy** (rebuild the pre-receive hook) so attachment writes are grant-scoped in production.

---

## Self-Review (plan author)

**Spec coverage:**
- Shared builder module + `--*-stdin` (spec §Design.1, .2) → Dev-A (A1–A4). ✓
- Org Card/Key/Totp add+edit parity (spec §Design.2, .3) → Dev-B (B1–B4). ✓
- Org Document + attachment storage + cap (spec §Design.4) → Dev-C (C1–C4). ✓
- Hook grant-scoping (spec §Design.5) → Dev-D (D1). ✓
- Tests (spec §Design.6) → B4, C4, D1 + integration sweep. ✓
- Living docs (spec §Design.7) → A4 (cli ARCHITECTURE), C4 (FORMATS), D1 (SECURITY), release task (STATUS/ROADMAP/CHANGELOG). ✓
- Out-of-scope (extension writes, phase-2, configurable cap) → not planned. ✓

**Placeholder scan:** No "TBD"/"similar to Task N"/bare "add error handling". Concrete code in every code step. Two explicit verify-against-source notes (cap value vs `settings.rs`; `ItemId::new()` constructor) are flagged as verification steps, not placeholders.

**Type consistency:** `OrgAddKind` (commands layer) used identically in B1/B2/C2; `build_document` returns `(Item, EncryptedAttachment)` consumed in A3 (personal) and C2 (org); `DEFAULT_ORG_ATTACHMENT_MAX_BYTES` defined in C1, used in C2/C3; `run_edit(dir, query, totp_qr[, file])` — note: C3 adds a `file` param to the B3 signature, so **C3 must update the B3 `run_edit` signature and its `main.rs` dispatch together** (called out in C3 Step 2).
