diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs
index b0f1bf8..3b40610 100644
--- a/crates/relicario-cli/src/commands/org.rs
+++ b/crates/relicario-cli/src/commands/org.rs
@@ -329,6 +329,503 @@ fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result<MemberId> {
     }
 }
 
+// ═══════════ Item CRUD (B9-B13) ═══════════
+//
+// `org add` / `get` / `list` / `edit` / `rm` / `restore` / `purge` for items
+// stored under `items/<collection-slug>/<id>.enc`. Each public `run_org_*`
+// wrapper opens the org vault, resolves the calling member by device key, then
+// delegates the actual work to an inner `*_with` fn that takes an already-opened
+// `UnlockedOrgVault` + the caller's `OrgMember`. The split keeps the CRUD logic
+// testable in-process without device-fingerprint plumbing.
+//
+// Supported builders for `org add`/`org edit`: Login, SecureNote, Identity.
+// Card / Key / Document / Totp parity is deferred (those read secrets via
+// rpassword/stdin); see the follow-up note in the plan after B13.
+
+use relicario_core::{Item, ItemCore};
+
+use crate::org_session::UnlockedOrgVault;
+
+/// Item kinds `org add` supports without interactive prompts. This is the
+/// handler-side enum (no clap attributes, no `collection`/`tags` — those are
+/// threaded separately by B14's dispatch). Deliberately distinct from any
+/// clap-side enum so the handler stays unaware of clap.
+pub enum OrgAddKind {
+    Login {
+        title: String,
+        username: Option<String>,
+        url: Option<String>,
+        password: Option<String>,
+    },
+    SecureNote {
+        title: String,
+        body: String,
+    },
+    Identity {
+        title: String,
+        full_name: Option<String>,
+        email: Option<String>,
+        phone: Option<String>,
+    },
+}
+
+/// Build a typed `Item` from a non-interactive `OrgAddKind` plus tags.
+fn build_org_item(kind: OrgAddKind, tags: Vec<String>) -> Result<Item> {
+    use relicario_core::item_types::{IdentityCore, LoginCore, SecureNoteCore};
+    use zeroize::Zeroizing;
+
+    let mut item = match kind {
+        OrgAddKind::Login { title, username, url, password } => {
+            let parsed_url = match url {
+                Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
+                None => None,
+            };
+            let password = password.map(Zeroizing::new);
+            Item::new(title, ItemCore::Login(LoginCore {
+                username,
+                password,
+                url: parsed_url,
+                totp: None,
+            }))
+        }
+        OrgAddKind::SecureNote { title, body } => {
+            Item::new(title, ItemCore::SecureNote(SecureNoteCore {
+                body: Zeroizing::new(body),
+            }))
+        }
+        OrgAddKind::Identity { title, full_name, email, phone } => {
+            Item::new(title, ItemCore::Identity(IdentityCore {
+                full_name,
+                address: None,
+                phone,
+                email,
+                date_of_birth: None,
+            }))
+        }
+    };
+    item.tags = tags;
+    Ok(item)
+}
+
+/// Insert-or-replace an `OrgManifestEntry` (keyed by item id), mirroring the
+/// personal-vault `Manifest::upsert`. The collection slug is stored in plaintext
+/// inside the encrypted manifest.
+fn upsert_org_entry(
+    manifest: &mut relicario_core::OrgManifest,
+    item: &Item,
+    collection: &str,
+) {
+    let entry = relicario_core::OrgManifestEntry {
+        id: item.id.clone(),
+        r#type: item.r#type,
+        title: item.title.clone(),
+        tags: item.tags.clone(),
+        modified: item.modified,
+        trashed_at: item.trashed_at,
+        collection: collection.to_string(),
+    };
+    if let Some(slot) = manifest.entries.iter_mut().find(|e| e.id == item.id) {
+        *slot = entry;
+    } else {
+        manifest.entries.push(entry);
+    }
+}
+
+/// Resolve a query (exact id, else case-insensitive title substring) against an
+/// already-grant-filtered manifest.
+fn resolve_org_query<'a>(
+    manifest: &'a relicario_core::OrgManifest,
+    query: &str,
+) -> Result<&'a relicario_core::OrgManifestEntry> {
+    if let Some(entry) = manifest.entries.iter().find(|e| e.id.as_str() == query) {
+        return Ok(entry);
+    }
+    let needle = query.to_lowercase();
+    let hits: Vec<&relicario_core::OrgManifestEntry> = manifest.entries.iter()
+        .filter(|e| e.title.to_lowercase().contains(&needle))
+        .collect();
+    match hits.len() {
+        0 => anyhow::bail!("no item matches `{query}`"),
+        1 => Ok(hits[0]),
+        _ => {
+            let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect();
+            anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", "))
+        }
+    }
+}
+
+// ── add ──────────────────────────────────────────────────────────────────────
+
+/// `org add`: create a typed item in a collection the caller holds a grant for.
+pub fn run_org_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> {
+    let vault = crate::org_session::open_org_vault(Some(dir))?;
+    let caller = vault.current_member()?;
+    run_org_add_with(&vault, &caller, collection, kind, tags)
+}
+
+fn run_org_add_with(
+    vault: &UnlockedOrgVault,
+    caller: &OrgMember,
+    collection: &str,
+    kind: OrgAddKind,
+    tags: Vec<String>,
+) -> Result<()> {
+    // The slug must exist in collections.json…
+    let collections = vault.load_collections()?;
+    if !collections.contains_slug(collection) {
+        anyhow::bail!("collection `{collection}` does not exist — create it with `relicario org create-collection`");
+    }
+    // …and the caller must hold a grant for it.
+    UnlockedOrgVault::ensure_grant(caller, collection)?;
+
+    let item = build_org_item(kind, tags)?;
+    let item_rel = vault.save_item(collection, &item)?;
+
+    // Upsert the manifest entry, then re-encrypt the manifest.
+    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()
+    );
+    crate::org_session::org_git_run(
+        &vault.root,
+        &["add", &item_rel, "manifest.enc"],
+        "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(())
+}
+
+// ── list ─────────────────────────────────────────────────────────────────────
+
+/// `org list`: list items in the caller's granted collections (filtered by
+/// `OrgManifest::filter_for_member`). `trashed` toggles between live + trashed.
+pub fn run_org_list(dir: &Path, trashed: bool) -> Result<()> {
+    let vault = crate::org_session::open_org_vault(Some(dir))?;
+    let caller = vault.current_member()?;
+    run_org_list_with(&vault, &caller, trashed)
+}
+
+fn run_org_list_with(vault: &UnlockedOrgVault, caller: &OrgMember, trashed: bool) -> Result<()> {
+    let manifest = vault.load_manifest()?;
+
+    // filter_for_member restricts to the caller's granted collections.
+    let visible = manifest.filter_for_member(caller);
+
+    let mut entries: Vec<_> = visible.entries.iter()
+        .filter(|e| if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() })
+        .collect();
+    entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
+
+    if entries.is_empty() {
+        eprintln!("(no items match)");
+        return Ok(());
+    }
+
+    println!("{:<16}  {:<14}  {:<12}  TITLE", "ID", "TYPE", "COLLECTION");
+    for e in entries {
+        println!(
+            "{:<16}  {:<14}  {:<12}  {}",
+            e.id.as_str(),
+            format!("{:?}", e.r#type),
+            e.collection,
+            e.title
+        );
+    }
+    Ok(())
+}
+
+// ── get ──────────────────────────────────────────────────────────────────────
+
+/// `org get`: print one item, masking secrets unless `show`. The query resolves
+/// over the caller-visible manifest only; the resolved collection's grant is
+/// re-checked (defense in depth).
+pub fn run_org_get(dir: &Path, query: &str, show: bool) -> Result<()> {
+    let vault = crate::org_session::open_org_vault(Some(dir))?;
+    let caller = vault.current_member()?;
+    run_org_get_with(&vault, &caller, query, show)
+}
+
+fn run_org_get_with(vault: &UnlockedOrgVault, caller: &OrgMember, query: &str, show: bool) -> Result<()> {
+    use zeroize::Zeroizing;
+
+    let manifest = vault.load_manifest()?;
+    let visible = manifest.filter_for_member(caller);
+
+    let entry = resolve_org_query(&visible, query)?;
+    UnlockedOrgVault::ensure_grant(caller, &entry.collection)?;
+
+    let item = vault.load_item(&entry.collection, &entry.id)?;
+
+    println!("ID:         {}", item.id.as_str());
+    println!("Title:      {}", item.title);
+    println!("Type:       {:?}", item.r#type);
+    println!("Collection: {}", entry.collection);
+    if !item.tags.is_empty() { println!("Tags:       {}", item.tags.join(", ")); }
+    println!("Modified:   {}", crate::helpers::iso8601(item.modified));
+    if let Some(t) = item.trashed_at { println!("Trashed:    {}", crate::helpers::iso8601(t)); }
+    println!();
+
+    let primary_secret: Option<Zeroizing<String>> = match &item.core {
+        ItemCore::Login(l) => {
+            if let Some(u) = &l.username { println!("Username: {u}"); }
+            if let Some(u) = &l.url { println!("URL:      {u}"); }
+            l.password.clone()
+        }
+        ItemCore::SecureNote(n) => {
+            if show { println!("Body:\n{}", n.body.as_str()); }
+            else { println!("Body:     ********"); }
+            None
+        }
+        ItemCore::Identity(i) => {
+            if let Some(v) = &i.full_name { println!("Name:     {v}"); }
+            if let Some(v) = &i.email { println!("Email:    {v}"); }
+            if let Some(v) = &i.phone { println!("Phone:    {v}"); }
+            None
+        }
+        ItemCore::Card(c) => {
+            if let Some(h) = &c.holder { println!("Holder:   {h}"); }
+            c.number.clone()
+        }
+        ItemCore::Key(k) => {
+            if let Some(l) = &k.label { println!("Label:    {l}"); }
+            Some(k.key_material.clone())
+        }
+        ItemCore::Document(d) => {
+            println!("Filename: {}", d.filename);
+            println!("MIME:     {}", d.mime_type);
+            None
+        }
+        ItemCore::Totp(t) => {
+            if let Some(i) = &t.issuer { println!("Issuer:   {i}"); }
+            if let Some(l) = &t.label { println!("Label:    {l}"); }
+            None
+        }
+    };
+
+    if let Some(secret) = primary_secret {
+        if show {
+            println!("Secret:   {}", secret.as_str());
+        } else {
+            println!("Secret:   ********  (use --show to reveal)");
+        }
+    }
+    Ok(())
+}
+
+// ── edit ─────────────────────────────────────────────────────────────────────
+
+/// `org edit`: flag-driven field update for login / secure-note / identity.
+/// Blank flags keep their current value. The blob is re-saved in place, the
+/// manifest upserted, and the commit carries `Relicario-Action: item-update`.
+#[allow(clippy::too_many_arguments)]
+pub fn run_org_edit(
+    dir: &Path,
+    query: &str,
+    title: Option<String>,
+    username: Option<String>,
+    url: Option<String>,
+    password: Option<String>,
+    body: Option<String>,
+    email: Option<String>,
+    phone: Option<String>,
+    full_name: Option<String>,
+) -> Result<()> {
+    let vault = crate::org_session::open_org_vault(Some(dir))?;
+    let caller = vault.current_member()?;
+    run_org_edit_with(
+        &vault, &caller, query, title, username, url, password, body, email, phone, full_name,
+    )
+}
+
+#[allow(clippy::too_many_arguments)]
+fn run_org_edit_with(
+    vault: &UnlockedOrgVault,
+    caller: &OrgMember,
+    query: &str,
+    title: Option<String>,
+    username: Option<String>,
+    url: Option<String>,
+    password: Option<String>,
+    body: Option<String>,
+    email: Option<String>,
+    phone: Option<String>,
+    full_name: Option<String>,
+) -> Result<()> {
+    use relicario_core::now_unix;
+    use zeroize::Zeroizing;
+
+    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();
+    UnlockedOrgVault::ensure_grant(caller, &collection)?;
+
+    let mut item = vault.load_item(&collection, &id)?;
+
+    if let Some(t) = title { item.title = t; }
+
+    match &mut item.core {
+        ItemCore::Login(l) => {
+            if let Some(u) = username { l.username = Some(u); }
+            if let Some(u) = url {
+                l.url = Some(url::Url::parse(&u).with_context(|| format!("invalid URL: {u}"))?);
+            }
+            if let Some(p) = password { l.password = Some(Zeroizing::new(p)); }
+        }
+        ItemCore::SecureNote(n) => {
+            if let Some(b) = body { n.body = Zeroizing::new(b); }
+        }
+        ItemCore::Identity(i) => {
+            if let Some(v) = full_name { i.full_name = Some(v); }
+            if let Some(v) = email { i.email = Some(v); }
+            if let Some(v) = phone { i.phone = Some(v); }
+        }
+        _ => anyhow::bail!("org edit currently supports login, secure-note, and identity items"),
+    }
+
+    item.modified = now_unix();
+    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 edit: {} ({})",
+        crate::helpers::sanitize_for_commit(&item.title),
+        item.id.as_str()
+    );
+    let commit_msg = format!(
+        "{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-update\nRelicario-Collection: {}\nRelicario-Item: {}",
+        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 {}", item.id.as_str());
+    Ok(())
+}
+
+// ── trash lifecycle: rm / restore / purge ────────────────────────────────────
+
+/// Resolve a query to (collection, item) with grant enforcement. Shared by the
+/// trash-lifecycle commands.
+fn open_org_item(
+    vault: &UnlockedOrgVault,
+    caller: &OrgMember,
+    query: &str,
+) -> Result<(String, Item)> {
+    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();
+    UnlockedOrgVault::ensure_grant(caller, &collection)?;
+    let item = vault.load_item(&collection, &id)?;
+    Ok((collection, item))
+}
+
+/// `org rm`: soft-delete (sets `trashed_at`); reversible via `org restore`.
+pub fn run_org_rm(dir: &Path, query: &str) -> Result<()> {
+    let vault = crate::org_session::open_org_vault(Some(dir))?;
+    let caller = vault.current_member()?;
+    run_org_rm_with(&vault, &caller, query)
+}
+
+fn run_org_rm_with(vault: &UnlockedOrgVault, caller: &OrgMember, query: &str) -> Result<()> {
+    let (collection, mut item) = open_org_item(vault, caller, query)?;
+
+    item.soft_delete();
+    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 commit_msg = format!(
+        "org trash: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-delete\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 rm: git add")?;
+    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org rm: git commit")?;
+    println!("Moved to trash: {}", item.title);
+    Ok(())
+}
+
+/// `org restore`: clear `trashed_at`, bringing the item back into the live list.
+pub fn run_org_restore(dir: &Path, query: &str) -> Result<()> {
+    let vault = crate::org_session::open_org_vault(Some(dir))?;
+    let caller = vault.current_member()?;
+    run_org_restore_with(&vault, &caller, query)
+}
+
+fn run_org_restore_with(vault: &UnlockedOrgVault, caller: &OrgMember, query: &str) -> Result<()> {
+    let (collection, mut item) = open_org_item(vault, caller, query)?;
+
+    item.restore();
+    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 commit_msg = format!(
+        "org restore: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-restore\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 restore: git add")?;
+    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org restore: git commit")?;
+    println!("Restored: {}", item.title);
+    Ok(())
+}
+
+/// `org purge`: permanently delete the blob (git rm) and drop the manifest entry.
+pub fn run_org_purge(dir: &Path, query: &str) -> Result<()> {
+    let vault = crate::org_session::open_org_vault(Some(dir))?;
+    let caller = vault.current_member()?;
+    run_org_purge_with(&vault, &caller, query)
+}
+
+fn run_org_purge_with(vault: &UnlockedOrgVault, caller: &OrgMember, query: &str) -> Result<()> {
+    let (collection, item) = open_org_item(vault, caller, query)?;
+    let title = item.title.clone();
+    let id = item.id.clone();
+
+    // Remove the blob from disk, drop the manifest entry, stage with git rm.
+    vault.remove_item(&collection, &id)?;
+    let mut manifest = vault.load_manifest()?;
+    manifest.entries.retain(|e| e.id != id);
+    vault.save_manifest(&manifest)?;
+
+    let item_rel = format!("items/{}/{}.enc", collection, id.as_str());
+    crate::helpers::git_rm(&vault.root, &[item_rel], "org purge: git rm")?;
+    crate::org_session::org_git_run(&vault.root, &["add", "manifest.enc"], "org purge: git add manifest")?;
+
+    let commit_msg = format!(
+        "org purge: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-purge\nRelicario-Collection: {}\nRelicario-Item: {}",
+        crate::helpers::sanitize_for_commit(&title), id.as_str(),
+        caller.display_name, caller.member_id.as_str(), collection, id.as_str()
+    );
+    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org purge: git commit")?;
+    println!("Purged: {title}");
+    Ok(())
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -386,3 +883,390 @@ mod tests {
         assert!(members.find_by_id(&id).unwrap().collections.contains(&"dev".to_string()));
     }
 }
+
+// ═══════════ Item CRUD tests (B9-B13) ═══════════
+//
+// `relicario-cli` is a binary-only crate, so integration tests in `tests/`
+// can only drive the compiled binary — and the item subcommands are not wired
+// into `Commands::Org` dispatch yet (that is B14). These in-process unit tests
+// therefore exercise the CRUD logic through the inner `*_with` helpers against a
+// directly-constructed `UnlockedOrgVault` over a real git repo, which needs no
+// device-fingerprint plumbing. The public `run_org_*` wrappers add only the
+// open-vault + resolve-caller preamble, which the `tests/org_*` integration
+// suites in the plan cover once B14 lands the CLI dispatch.
+#[cfg(test)]
+mod crud_tests {
+    use super::*;
+    use relicario_core::{
+        encrypt_org_manifest, CollectionDef, ItemId, MemberId, OrgCollections, OrgManifest,
+        OrgMember, OrgRole,
+    };
+    use std::path::Path;
+    use std::process::Command;
+    use tempfile::TempDir;
+    use zeroize::Zeroizing;
+
+    /// A throwaway org vault: a real (unsigned-commit) git repo with the org
+    /// scaffold written and an `UnlockedOrgVault` holding a known key.
+    struct Fixture {
+        _dir: TempDir,
+        vault: UnlockedOrgVault,
+    }
+
+    fn git(root: &Path, args: &[&str]) {
+        let out = Command::new("git").current_dir(root).args(args).output().unwrap();
+        assert!(out.status.success(), "git {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr));
+    }
+
+    impl Fixture {
+        fn new() -> Self {
+            let dir = TempDir::new().unwrap();
+            let root = dir.path().to_path_buf();
+            std::fs::create_dir_all(root.join("items")).unwrap();
+            std::fs::create_dir_all(root.join("keys")).unwrap();
+
+            let org_key = Zeroizing::new([7u8; 32]);
+
+            // Scaffold the non-encrypted control files.
+            std::fs::write(
+                root.join("collections.json"),
+                serde_json::to_string_pretty(&OrgCollections::new()).unwrap(),
+            )
+            .unwrap();
+            // Empty encrypted manifest.
+            let manifest = OrgManifest::new();
+            std::fs::write(
+                root.join("manifest.enc"),
+                encrypt_org_manifest(&manifest, &org_key).unwrap(),
+            )
+            .unwrap();
+
+            // A real git repo, but with signing disabled so commits succeed
+            // without a device key (signature verification is Dev-C's hook).
+            git(&root, &["init", "-q"]);
+            git(&root, &["config", "user.name", "Test"]);
+            git(&root, &["config", "user.email", "test@relicario.test"]);
+            git(&root, &["config", "commit.gpgsign", "false"]);
+            git(&root, &["add", "."]);
+            git(&root, &["commit", "-q", "-m", "scaffold"]);
+
+            let vault = UnlockedOrgVault { root, org_key };
+            Fixture { _dir: dir, vault }
+        }
+
+        /// Add a collection to collections.json and return a member granted it.
+        fn with_collection(&self, slug: &str) -> OrgMember {
+            let mut collections = self.vault.load_collections().unwrap();
+            collections.collections.push(CollectionDef {
+                slug: slug.to_string(),
+                display_name: slug.to_string(),
+                created_by: MemberId::new(),
+                created_at: 0,
+            });
+            self.vault.save_collections(&collections).unwrap();
+            self.member(vec![slug.to_string()])
+        }
+
+        fn member(&self, collections: Vec<String>) -> OrgMember {
+            OrgMember {
+                member_id: MemberId("0123456789abcdef".into()),
+                display_name: "Alice".into(),
+                role: OrgRole::Owner,
+                ed25519_pubkey: "ssh-ed25519 AAAA fake".into(),
+                collections,
+                added_at: 0,
+                added_by: MemberId("0123456789abcdef".into()),
+            }
+        }
+
+        fn head_body(&self) -> String {
+            let out = Command::new("git")
+                .current_dir(&self.vault.root)
+                .args(["log", "-1", "--format=%B"])
+                .output()
+                .unwrap();
+            String::from_utf8_lossy(&out.stdout).to_string()
+        }
+
+        fn manifest_entry_for<'a>(
+            &self,
+            m: &'a OrgManifest,
+            title: &str,
+        ) -> Option<&'a relicario_core::OrgManifestEntry> {
+            m.entries.iter().find(|e| e.title == title)
+        }
+    }
+
+    fn login(title: &str, user: &str, pw: &str) -> OrgAddKind {
+        OrgAddKind::Login {
+            title: title.into(),
+            username: Some(user.into()),
+            url: Some("https://example.com".into()),
+            password: Some(pw.into()),
+        }
+    }
+
+    // ── B10: add ──────────────────────────────────────────────────────────────
+
+    #[test]
+    fn add_writes_collection_scoped_blob_and_manifest_and_trailers() {
+        let f = Fixture::new();
+        let caller = f.with_collection("prod");
+
+        run_org_add_with(&f.vault, &caller, "prod", login("GitHub", "alice", "hunter2"), vec![])
+            .unwrap();
+
+        // Blob lives under items/prod/, not flat items/.
+        let prod_dir = f.vault.root.join("items").join("prod");
+        let blobs: Vec<_> = std::fs::read_dir(&prod_dir).unwrap().collect();
+        assert_eq!(blobs.len(), 1, "expected exactly one blob under items/prod/");
+        assert!(!f.vault.root.join("items").join("GitHub.enc").exists());
+
+        // Manifest entry recorded with the collection.
+        let manifest = f.vault.load_manifest().unwrap();
+        let entry = f.manifest_entry_for(&manifest, "GitHub").expect("manifest entry");
+        assert_eq!(entry.collection, "prod");
+
+        // Commit trailers.
+        let body = f.head_body();
+        assert!(body.contains("Relicario-Action: item-create"), "body: {body}");
+        assert!(body.contains("Relicario-Collection: prod"), "body: {body}");
+        assert!(body.contains(&format!("Relicario-Item: {}", entry.id.as_str())), "body: {body}");
+        assert!(body.contains("Relicario-Actor: Alice 0123456789abcdef"), "body: {body}");
+    }
+
+    #[test]
+    fn add_secure_note_and_identity_round_trip() {
+        let f = Fixture::new();
+        let caller = f.with_collection("prod");
+
+        run_org_add_with(
+            &f.vault,
+            &caller,
+            "prod",
+            OrgAddKind::SecureNote { title: "Notes".into(), body: "secret-body".into() },
+            vec!["tag1".into()],
+        )
+        .unwrap();
+        run_org_add_with(
+            &f.vault,
+            &caller,
+            "prod",
+            OrgAddKind::Identity {
+                title: "Me".into(),
+                full_name: Some("Alice Anderson".into()),
+                email: Some("a@example.com".into()),
+                phone: None,
+            },
+            vec![],
+        )
+        .unwrap();
+
+        let manifest = f.vault.load_manifest().unwrap();
+        assert_eq!(manifest.entries.len(), 2);
+        let note = f.manifest_entry_for(&manifest, "Notes").unwrap();
+        assert_eq!(note.tags, vec!["tag1".to_string()]);
+        let note_item = f.vault.load_item("prod", &note.id).unwrap();
+        match &note_item.core {
+            ItemCore::SecureNote(n) => assert_eq!(n.body.as_str(), "secret-body"),
+            _ => panic!("expected secure note"),
+        }
+    }
+
+    #[test]
+    fn add_rejects_ungranted_collection() {
+        let f = Fixture::new();
+        // Collection exists, but the caller holds no grant for it.
+        let _ = f.with_collection("secret");
+        let caller = f.member(vec![]); // no grants
+
+        let err = run_org_add_with(&f.vault, &caller, "secret", login("X", "u", "p"), vec![])
+            .unwrap_err();
+        let msg = format!("{err:#}");
+        assert!(msg.contains("access denied") || msg.contains("grant"), "msg: {msg}");
+    }
+
+    #[test]
+    fn add_rejects_unknown_collection() {
+        let f = Fixture::new();
+        let caller = f.member(vec!["ghost".into()]); // grant for a slug that doesn't exist
+
+        let err = run_org_add_with(&f.vault, &caller, "ghost", login("X", "u", "p"), vec![])
+            .unwrap_err();
+        let msg = format!("{err:#}");
+        assert!(msg.contains("does not exist") || msg.contains("ghost"), "msg: {msg}");
+    }
+
+    // ── B11: get + list ───────────────────────────────────────────────────────
+
+    #[test]
+    fn list_filters_to_granted_collections() {
+        let f = Fixture::new();
+        // Two collections exist; caller is granted only `prod`.
+        let _ = f.with_collection("prod");
+        let _ = f.with_collection("secret");
+        let prod_caller = f.member(vec!["prod".into()]);
+        let secret_caller = f.member(vec!["secret".into()]);
+
+        run_org_add_with(&f.vault, &prod_caller, "prod", login("InProd", "u", "p"), vec![]).unwrap();
+        run_org_add_with(&f.vault, &secret_caller, "secret", login("InSecret", "u", "p"), vec![])
+            .unwrap();
+
+        // The prod caller's visible manifest excludes the secret entry.
+        let manifest = f.vault.load_manifest().unwrap();
+        let visible = manifest.filter_for_member(&prod_caller);
+        let titles: Vec<&str> = visible.entries.iter().map(|e| e.title.as_str()).collect();
+        assert!(titles.contains(&"InProd"));
+        assert!(!titles.contains(&"InSecret"), "leaked ungranted entry: {titles:?}");
+
+        // run_org_list_with returns Ok and prints only granted entries.
+        run_org_list_with(&f.vault, &prod_caller, false).unwrap();
+    }
+
+    #[test]
+    fn get_resolves_by_id_and_title_substring() {
+        let f = Fixture::new();
+        let caller = f.with_collection("prod");
+        run_org_add_with(&f.vault, &caller, "prod", login("GitHub", "alice", "hunter2"), vec![])
+            .unwrap();
+
+        let manifest = f.vault.load_manifest().unwrap();
+        let id = manifest.entries[0].id.as_str().to_string();
+
+        // exact id, case-insensitive substring, masked default + --show all OK.
+        run_org_get_with(&f.vault, &caller, &id, false).unwrap();
+        run_org_get_with(&f.vault, &caller, "github", false).unwrap();
+        run_org_get_with(&f.vault, &caller, "GitHub", true).unwrap();
+    }
+
+    #[test]
+    fn get_unknown_query_errors() {
+        let f = Fixture::new();
+        let caller = f.with_collection("prod");
+        run_org_add_with(&f.vault, &caller, "prod", login("GitHub", "alice", "hunter2"), vec![])
+            .unwrap();
+        let err = run_org_get_with(&f.vault, &caller, "nope", false).unwrap_err();
+        assert!(format!("{err:#}").contains("no item matches"));
+    }
+
+    #[test]
+    fn resolve_org_query_reports_ambiguity() {
+        let mut manifest = OrgManifest::new();
+        for title in ["Mail Personal", "Mail Work"] {
+            manifest.entries.push(relicario_core::OrgManifestEntry {
+                id: ItemId::new(),
+                r#type: relicario_core::ItemType::Login,
+                title: title.into(),
+                tags: vec![],
+                modified: 0,
+                trashed_at: None,
+                collection: "prod".into(),
+            });
+        }
+        let err = resolve_org_query(&manifest, "mail").unwrap_err();
+        assert!(format!("{err:#}").contains("ambiguous"), "{err:#}");
+    }
+
+    // ── B12: edit ─────────────────────────────────────────────────────────────
+
+    #[test]
+    fn edit_updates_login_field_and_writes_update_trailer() {
+        let f = Fixture::new();
+        let caller = f.with_collection("prod");
+        run_org_add_with(&f.vault, &caller, "prod", login("Mail", "old", "pw"), vec![]).unwrap();
+
+        run_org_edit_with(
+            &f.vault, &caller, "Mail",
+            None, Some("new-user".into()), None, None, None, None, None, None,
+        )
+        .unwrap();
+
+        // The blob now carries the new username.
+        let manifest = f.vault.load_manifest().unwrap();
+        let entry = f.manifest_entry_for(&manifest, "Mail").unwrap();
+        let item = f.vault.load_item("prod", &entry.id).unwrap();
+        match &item.core {
+            ItemCore::Login(l) => assert_eq!(l.username.as_deref(), Some("new-user")),
+            _ => panic!("expected login"),
+        }
+
+        let body = f.head_body();
+        assert!(body.contains("Relicario-Action: item-update"), "body: {body}");
+        assert!(body.contains("Relicario-Collection: prod"), "body: {body}");
+    }
+
+    #[test]
+    fn edit_can_retitle_and_keeps_unset_fields() {
+        let f = Fixture::new();
+        let caller = f.with_collection("prod");
+        run_org_add_with(&f.vault, &caller, "prod", login("Mail", "old", "pw"), vec![]).unwrap();
+
+        run_org_edit_with(
+            &f.vault, &caller, "Mail",
+            Some("Webmail".into()), None, None, None, None, None, None, None,
+        )
+        .unwrap();
+
+        let manifest = f.vault.load_manifest().unwrap();
+        assert!(f.manifest_entry_for(&manifest, "Webmail").is_some());
+        let entry = f.manifest_entry_for(&manifest, "Webmail").unwrap();
+        let item = f.vault.load_item("prod", &entry.id).unwrap();
+        match &item.core {
+            // username untouched (we passed None), password untouched.
+            ItemCore::Login(l) => assert_eq!(l.username.as_deref(), Some("old")),
+            _ => panic!("expected login"),
+        }
+    }
+
+    // ── B13: rm / restore / purge ─────────────────────────────────────────────
+
+    #[test]
+    fn rm_restore_purge_cycle() {
+        let f = Fixture::new();
+        let caller = f.with_collection("prod");
+        run_org_add_with(
+            &f.vault,
+            &caller,
+            "prod",
+            OrgAddKind::SecureNote { title: "Recovery".into(), body: "codes-here".into() },
+            vec![],
+        )
+        .unwrap();
+
+        // rm → trashed_at set, item drops out of the live list, shows in --trashed.
+        run_org_rm_with(&f.vault, &caller, "Recovery").unwrap();
+        let manifest = f.vault.load_manifest().unwrap();
+        let entry = f.manifest_entry_for(&manifest, "Recovery").unwrap();
+        assert!(entry.trashed_at.is_some(), "rm should set trashed_at");
+        assert!(f.head_body().contains("Relicario-Action: item-delete"));
+
+        // restore → trashed_at cleared.
+        run_org_restore_with(&f.vault, &caller, "Recovery").unwrap();
+        let manifest = f.vault.load_manifest().unwrap();
+        let entry = f.manifest_entry_for(&manifest, "Recovery").unwrap();
+        assert!(entry.trashed_at.is_none(), "restore should clear trashed_at");
+        assert!(f.head_body().contains("Relicario-Action: item-restore"));
+
+        // purge → blob gone from disk, manifest entry dropped, purge trailer.
+        run_org_purge_with(&f.vault, &caller, "Recovery").unwrap();
+        let prod_dir = f.vault.root.join("items").join("prod");
+        let count = std::fs::read_dir(&prod_dir).map(|d| d.count()).unwrap_or(0);
+        assert_eq!(count, 0, "blob not purged from items/prod/");
+        let manifest = f.vault.load_manifest().unwrap();
+        assert!(f.manifest_entry_for(&manifest, "Recovery").is_none(), "manifest entry not dropped");
+        assert!(f.head_body().contains("Relicario-Action: item-purge"));
+    }
+
+    #[test]
+    fn rm_enforces_grant_via_visible_manifest() {
+        let f = Fixture::new();
+        // owner adds into prod
+        let owner = f.with_collection("prod");
+        run_org_add_with(&f.vault, &owner, "prod", login("Secret", "u", "p"), vec![]).unwrap();
+
+        // a caller with no grant cannot even resolve the item (filtered out).
+        let outsider = f.member(vec![]);
+        let err = run_org_rm_with(&f.vault, &outsider, "Secret").unwrap_err();
+        assert!(format!("{err:#}").contains("no item matches"), "{err:#}");
+    }
+}
