Files
relicario/docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md
adlee-was-taken f27dc72e96 docs(plan): v0.8.1 org item-type parity — 4-stream multi-agent plan
Dev-A shared item_build foundation + personal --*-stdin; Dev-B org
Card/Key/Totp; Dev-C org Document + attachment storage; Dev-D server
hook grant-scoping. TDD tasks with full code; A gates B/C, D independent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5JvzEse4xUxLZKhofyeCD
2026-06-20 16:48:46 -04:00

59 KiB
Raw Blame History

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>.encItem{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):

// 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):

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:
#[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:

//! 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.

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:
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:
    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.

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:
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:
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.

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):

#[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:

    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.

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:

// 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:
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:

    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:
    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.

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>):
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.

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.

    /// 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):
#[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).

#[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.

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:

// 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:
#[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):

/// 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.

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:

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:
    Document {
        #[arg(long)] collection: String,
        #[arg(long)] title: String,
        #[arg(long)] file: std::path::PathBuf,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
    },

Dispatch arm:

    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.

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.

        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.

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.

#[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.

#[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.

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:

#[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:

    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.

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 (A1A4). ✓
  • Org Card/Key/Totp add+edit parity (spec §Design.2, .3) → Dev-B (B1B4). ✓
  • Org Document + attachment storage + cap (spec §Design.4) → Dev-C (C1C4). ✓
  • 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).