# Enterprise Org Vault 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:** Implement git-native multi-user org vaults for security-conscious self-hosting shops — per-user repos + a shared org repo, X25519-wrapped org master key per member, **collection-scoped item storage**, role-based access, full **org item CRUD** (`add`/`get`/`list`/`edit`/`rm`/`restore`/`purge`), a **signature-verifying** pre-receive hook with path-scoped write authorization, a tamper-evident structured-git-trailer audit trail attributed to the **verified signer**, and **extension parity** (org switcher + read-only browse + the spec's vitest acceptance tests).

**Architecture:** The org repo is a separate git repository with a defined schema (`org.json`, `members.json`, `collections.json`, `keys/<member-id>.enc`, `manifest.enc`, and **collection-scoped** items at `items/<collection-slug>/<item-id>.enc`). Each member holds a copy of the 256-bit org master key wrapped (ECIES/X25519 + XChaCha20-Poly1305) to their existing ed25519 device key. Two enforcement boundaries work together: **(1) cryptographic** — only a wrapped-key holder can unwrap the org master key, and rotation re-encrypts every item blob under a fresh key; **(2) the git pre-receive hook** — every commit is **signature-verified** against `members.json` (signer resolved by ed25519 fingerprint), and writes are authorized by role (for management files) or by **collection path segment** (for item files). Item storage is collection-scoped precisely so the hook can authorize an item write by its leading path segment *without decrypting anything*. All org commits are **signed**; `org init` configures git signing and org commits route through a non-hardened `org_git_run` (the standard `helpers::git_run` force-disables signing). Both the CLI and the extension consume the same `relicario-core::org` module; the extension ships org switch + read in this phase (writes are a tracked follow-up).

**Tech Stack:** Rust, `x25519-dalek 2` with `features = ["static_secrets"]` (new, in `relicario-core`), `ed25519-dalek 2`, `sha2`, `chacha20poly1305 0.10`, `ssh-key 0.6` (new, in `relicario-cli` **and** `relicario-wasm`), `regex`, `tempfile`, `serde_json`, `clap`, `anyhow`, `zeroize`; WASM bindings (`relicario-wasm`, `base64`) + extension TypeScript with **vitest** for the parity acceptance tests.

**Multi-stream assignment for PM:**

| Stream | Tasks | Scope | Depends on |
|---|---|---|---|
| **Dev-A** | A1–A4 (+ **A5** docs, final) | `relicario-core` org module: deps, types, crypto, vault wrappers, integration tests; then the living-docs sweep | A5 depends on all streams merged |
| **Dev-B** | B1–B14 | `relicario-cli`: session, device helpers, admin commands, item CRUD, wiring | **A2–A3 merged first** (imports `relicario_core::org`); B9–B13 depend on B1; B14 depends on the command fns existing |
| **Dev-C** | C1–C2 | `relicario-server` pre-receive hook: signature + path-scoped authz, schema monotonicity | **A2–A3 merged first** (imports `relicario_core::org`) |
| **Dev-D** | D1–D4 | Extension parity: WASM org bindings, SW org session/handlers, vault-tab switcher, vitest tests | **A2–A3 merged first** (bindings call `relicario_core::{unwrap_org_key, decrypt_org_manifest, decrypt_item}`); independent of Dev-B/Dev-C |

> **Hard dependency note:** Dev-B, Dev-C, and Dev-D all import `relicario_core::org` types/functions, so **Tasks A2–A3 must be MERGED before those streams begin**. Within Dev-B, the item-CRUD tasks **B9–B13 depend on B1** (the collection-scoped `item_path` + session helpers), and **B14 depends on every `run_*` command fn existing** (it wires them into `main.rs`). A5 is the final living-docs task and runs after all code streams merge.

---

## File Map

| Action | Path | Responsibility |
|---|---|---|
| Modify | `crates/relicario-core/Cargo.toml` | Add `x25519-dalek = { version = "2", features = ["static_secrets"] }` |
| Create | `crates/relicario-core/src/org.rs` | Org types + crypto (IDs, members, collections, key wrap/unwrap; KDF intermediates in `Zeroizing`) |
| Modify | `crates/relicario-core/src/vault.rs` | Add `encrypt_org_manifest` / `decrypt_org_manifest` |
| Modify | `crates/relicario-core/src/lib.rs` | `pub mod org` + re-exports |
| Create | `crates/relicario-core/tests/org.rs` | Integration tests for org crypto |
| Modify | `crates/relicario-cli/Cargo.toml` | Add `ssh-key = "0.6"`; ensure `regex = "1"` + `tempfile = "3"` in `[dependencies]`; `ed25519-dalek` dev-dep |
| Create | `crates/relicario-cli/src/org_session.rs` | `UnlockedOrgVault` session type (collection-scoped `item_path`, item I/O helpers, fingerprint member match, signed `org_git_run`) |
| Modify | `crates/relicario-cli/src/device.rs` | `current_device_seed` / `current_device_pubkey` helpers |
| Create | `crates/relicario-cli/src/commands/org.rs` | All `relicario org` subcommands — admin **and** item commands |
| Modify | `crates/relicario-cli/src/commands/mod.rs` | `pub mod org` |
| Modify | `crates/relicario-cli/src/main.rs` | `Commands::Org` arm + `OrgCommands` (admin + item subcommands) |
| Modify | `crates/relicario-server/Cargo.toml` | Ensure `tempfile` in `[dependencies]`; add `[lib]` + `[[bin]]` |
| Create | `crates/relicario-server/src/lib.rs` | Pure path-classification + schema-version helpers (`classify_path`, `PathClass`, `extract_schema_version`) |
| Modify | `crates/relicario-server/src/main.rs` | `verify-org-commit` (signature + path/role authz + schema monotonicity) + `generate-org-hook` |
| Create | `crates/relicario-server/tests/org_hook.rs` | Hook path-classification + schema-version tests |
| Modify | `crates/relicario-wasm/src/lib.rs` | `org_open_with_registered_device` / `org_manifest_decrypt` / `org_item_decrypt` bindings |
| Modify | `crates/relicario-wasm/src/device.rs` | `unwrap_org_key` accessor — unwraps the org key using the in-memory `DEVICE_STATE` seed (never crosses to JS) |
| Modify | `crates/relicario-wasm/Cargo.toml` | `base64` (if absent) |
| Regenerate | `extension/wasm/relicario_wasm.{js,d.ts}`, `relicario_wasm_bg.wasm` | wasm-pack output consumed by the extension |
| Create | `extension/src/service-worker/org.ts` | SW org session + read ops |
| Modify | `extension/src/service-worker/index.ts` | Wire `org.setWasm`; clear org session on expiry |
| Modify | `extension/src/service-worker/router/popup-only.ts` | 4 org handler arms |
| Modify | `extension/src/shared/messages.ts` | Org message types + responses + capability-set entries |
| Create | `extension/src/vault/vault-org-switcher.ts` | Vault-tab org context switcher + read-only banner |
| Modify | `extension/src/vault/vault-sidebar.ts`, `vault-context.ts`, `vault.ts`, `vault.css` | Mount switcher; `activeOrgId` state; styling |
| Create | `extension/src/service-worker/__tests__/org.test.ts` | 3 spec-mandated vitest tests |
| Modify | living docs (see **A5**) | FORMATS, CRYPTO, DESIGN, SECURITY, core/cli/ext ARCHITECTURE, STATUS, ROADMAP |

---

## Dev-A — relicario-core org module

## [Dev-A] Task A1: Add x25519-dalek and stub org module

**Files:**
- Modify: `crates/relicario-core/Cargo.toml`
- Create: `crates/relicario-core/src/org.rs` (stub)
- Modify: `crates/relicario-core/src/lib.rs`

- [ ] **Step 1: Add x25519-dalek dependency**

In `crates/relicario-core/Cargo.toml`, add after the `ed25519-dalek` line:

```toml
x25519-dalek = { version = "2", features = ["static_secrets"] }
```

- [ ] **Step 2: Create org.rs stub**

Create `crates/relicario-core/src/org.rs` with just a module-level comment:

```rust
//! Org vault types, crypto, and schema for multi-user self-hosted deployments.
```

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

In `crates/relicario-core/src/lib.rs`, add after the `device` module block. Wire the `pub mod org;` line only, no `pub use` yet (the re-exports would fail until Task A2 defines the symbols):

```rust
pub mod org;
```

Then add the `pub use` items in Task A2 once the symbols exist.

- [ ] **Step 4: Verify it compiles**

```bash
cargo check -p relicario-core
```

Expected: compiles (stub is empty; only `pub mod org;` is wired).

- [ ] **Step 5: Verify the WASM target still builds**

`x25519-dalek` lands in the core that `relicario-wasm` compiles, so build the WASM target now to catch any feature-unification regression early:

```bash
cargo build -p relicario-wasm --target wasm32-unknown-unknown 2>&1 | tail -10
```

Expected: clean build.

- [ ] **Step 6: Commit**

```bash
git add crates/relicario-core/Cargo.toml crates/relicario-core/src/org.rs crates/relicario-core/src/lib.rs
git commit -m "feat(core/org): add x25519-dalek dep + stub org module"
```

---

## [Dev-A] Task A2: Org types — IDs, members, collections, org meta, ECIES wrap/unwrap

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

This task implements all org types plus the ECIES key-wrap/unwrap. Two corrections from the adversarial review are folded in: **H6** (all KDF intermediates carrying the DH secret are held in `Zeroizing`) and **H7** (the test keypair helper uses `PrivateKey::from(Ed25519Keypair::from(&signing_key))` — `ssh-key` 0.6.7 has no `From<SigningKey> for PrivateKey`).

- [ ] **Step 1: Write failing test for MemberId format**

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

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

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

    #[test]
    fn org_id_is_16_hex_chars() {
        let id = OrgId::new();
        assert_eq!(id.0.len(), 16);
    }
}
```

Add this at the bottom of `org.rs`, run:

```bash
cargo test -p relicario-core org::tests::member_id_is_16_hex_chars 2>&1 | tail -5
```

Expected: FAIL — `MemberId` not defined.

- [ ] **Step 2: Implement all org types + ECIES wrap/unwrap (with H6 Zeroizing folded in)**

Replace the stub `org.rs` with:

```rust
//! Org vault types, crypto, and schema for multi-user self-hosted deployments.

use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;

use crate::error::{RelicarioError, Result};
use crate::ids::ItemId;
use crate::item_types::ItemType;

// ── IDs ──────────────────────────────────────────────────────────────────────

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

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

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

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

impl MemberId {
    pub fn new() -> Self {
        let mut bytes = [0u8; 8];
        OsRng.fill_bytes(&mut bytes);
        Self(hex::encode(bytes))
    }
    pub fn as_str(&self) -> &str { &self.0 }
    pub fn is_valid(&self) -> bool {
        self.0.len() == 16 && self.0.chars().all(|c| c.is_ascii_hexdigit())
    }
}

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

// ── Roles ────────────────────────────────────────────────────────────────────

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OrgRole {
    Owner,
    Admin,
    Member,
}

impl OrgRole {
    pub fn can_manage_members(&self) -> bool {
        matches!(self, OrgRole::Owner | OrgRole::Admin)
    }
    pub fn can_manage_owners(&self) -> bool {
        matches!(self, OrgRole::Owner)
    }
}

// ── Members ──────────────────────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrgMember {
    pub member_id: MemberId,
    pub display_name: String,
    pub role: OrgRole,
    /// SSH public key string (openssh format: "ssh-ed25519 AAAA...")
    pub ed25519_pubkey: String,
    /// Collection slugs this member can access.
    #[serde(default)]
    pub collections: Vec<String>,
    pub added_at: i64,
    pub added_by: MemberId,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrgMembers {
    pub schema_version: u32,
    pub members: Vec<OrgMember>,
}

impl OrgMembers {
    pub fn new() -> Self {
        Self { schema_version: 1, members: Vec::new() }
    }

    pub fn find_by_id(&self, id: &MemberId) -> Option<&OrgMember> {
        self.members.iter().find(|m| &m.member_id == id)
    }

    pub fn find_by_id_mut(&mut self, id: &MemberId) -> Option<&mut OrgMember> {
        self.members.iter_mut().find(|m| &m.member_id == id)
    }

    pub fn validate(&self) -> Result<()> {
        for m in &self.members {
            if !m.member_id.is_valid() {
                return Err(RelicarioError::Format(
                    format!("invalid member_id: {}", m.member_id.0)
                ));
            }
        }
        Ok(())
    }
}

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

// ── Collections ───────────────────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CollectionDef {
    pub slug: String,
    pub display_name: String,
    pub created_by: MemberId,
    pub created_at: i64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrgCollections {
    pub schema_version: u32,
    pub collections: Vec<CollectionDef>,
}

impl OrgCollections {
    pub fn new() -> Self {
        Self { schema_version: 1, collections: Vec::new() }
    }

    pub fn contains_slug(&self, slug: &str) -> bool {
        self.collections.iter().any(|c| c.slug == slug)
    }

    pub fn validate(&self) -> Result<()> {
        for c in &self.collections {
            if c.slug.is_empty() || c.slug.contains('/') || c.slug.contains('.') {
                return Err(RelicarioError::Format(
                    format!("invalid collection slug: {:?}", c.slug)
                ));
            }
        }
        Ok(())
    }
}

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

// ── Org meta ─────────────────────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrgMeta {
    pub schema_version: u32,
    pub org_id: OrgId,
    pub display_name: String,
    pub created_at: i64,
}

impl OrgMeta {
    pub fn new(display_name: String) -> Self {
        Self {
            schema_version: 1,
            org_id: OrgId::new(),
            display_name,
            created_at: crate::time::now_unix(),
        }
    }
}

// ── Org manifest ─────────────────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrgManifestEntry {
    pub id: ItemId,
    pub r#type: ItemType,
    pub title: String,
    #[serde(default)]
    pub tags: Vec<String>,
    pub modified: i64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub trashed_at: Option<i64>,
    /// Collection this item belongs to.
    pub collection: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrgManifest {
    pub schema_version: u32,
    pub entries: Vec<OrgManifestEntry>,
}

impl OrgManifest {
    pub fn new() -> Self {
        Self { schema_version: 1, entries: Vec::new() }
    }

    /// Return only entries whose collection is in `member.collections`.
    pub fn filter_for_member(&self, member: &OrgMember) -> Self {
        let granted: std::collections::HashSet<&str> =
            member.collections.iter().map(|s| s.as_str()).collect();
        Self {
            schema_version: self.schema_version,
            entries: self.entries.iter()
                .filter(|e| granted.contains(e.collection.as_str()))
                .cloned()
                .collect(),
        }
    }
}

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

// ── Key wrap / unwrap (ECIES: X25519 + XChaCha20-Poly1305) ───────────────────

/// Generate a random 256-bit org master key.
pub fn generate_org_key() -> Zeroizing<[u8; 32]> {
    let mut key = Zeroizing::new([0u8; 32]);
    OsRng.fill_bytes(key.as_mut());
    key
}

/// Derive an X25519 static secret from an ed25519 seed (standard RFC 7748 path).
fn ed25519_seed_to_x25519_secret(seed: &[u8; 32]) -> x25519_dalek::StaticSecret {
    use sha2::{Digest, Sha512};
    let h = Sha512::digest(seed.as_ref());
    let mut scalar = [0u8; 32];
    scalar.copy_from_slice(&h[..32]);
    // RFC 7748 clamping
    scalar[0] &= 248;
    scalar[31] &= 127;
    scalar[31] |= 64;
    x25519_dalek::StaticSecret::from(scalar)
}

/// Parse an OpenSSH ed25519 public key string and return its X25519 form.
fn openssh_ed25519_to_x25519_pk(openssh: &str) -> Result<x25519_dalek::PublicKey> {
    use ssh_key::PublicKey;
    let pk = PublicKey::from_openssh(openssh.trim())
        .map_err(|e| RelicarioError::Format(format!("bad SSH pubkey: {e}")))?;
    let ed_bytes = pk.key_data().ed25519()
        .ok_or_else(|| RelicarioError::Format("expected ed25519 key".into()))?
        .0;
    let verifying = ed25519_dalek::VerifyingKey::from_bytes(&ed_bytes)
        .map_err(|e| RelicarioError::Format(format!("bad ed25519 pubkey: {e}")))?;
    Ok(x25519_dalek::PublicKey::from(verifying.to_montgomery().to_bytes()))
}

/// Wrap `org_key` for a recipient identified by their OpenSSH ed25519 public key.
///
/// Output layout: `ephemeral_x25519_pk(32) || version(1) || nonce(24) || ciphertext+tag`
pub fn wrap_org_key(org_key: &Zeroizing<[u8; 32]>, recipient_openssh_pubkey: &str) -> Result<Vec<u8>> {
    use sha2::{Digest, Sha256};
    use x25519_dalek::EphemeralSecret;

    let recipient_pk = openssh_ed25519_to_x25519_pk(recipient_openssh_pubkey)?;

    let ephemeral_sk = EphemeralSecret::random_from_rng(OsRng);
    let ephemeral_pk = x25519_dalek::PublicKey::from(&ephemeral_sk);

    let shared = ephemeral_sk.diffie_hellman(&recipient_pk);

    // Domain-separated KDF. All intermediates carrying the DH secret are held in
    // Zeroizing so they are wiped on drop (H6).
    let mut kdf_input: Zeroizing<Vec<u8>> = Zeroizing::new(Vec::with_capacity(32 + 32 + 32));
    kdf_input.extend_from_slice(shared.as_bytes());
    kdf_input.extend_from_slice(ephemeral_pk.as_bytes());
    kdf_input.extend_from_slice(recipient_pk.as_bytes());

    // Copy the digest straight into a Zeroizing array. The GenericArray returned
    // by Sha256::digest is not Zeroize (generic-array's impl is feature-gated and
    // not enabled here), so we move the bytes into an owned [u8; 32] whose own
    // Zeroize impl wipes them on drop.
    let mut wrap_key: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]);
    wrap_key.copy_from_slice(&Sha256::digest(kdf_input.as_slice()));

    let encrypted = crate::crypto::encrypt(&wrap_key, org_key.as_ref())?;

    let mut out = Vec::with_capacity(32 + encrypted.len());
    out.extend_from_slice(ephemeral_pk.as_bytes());
    out.extend_from_slice(&encrypted);
    Ok(out)
}

/// Unwrap a key blob produced by `wrap_org_key` using the recipient's ed25519 seed.
pub fn unwrap_org_key(wrapped: &[u8], ed25519_seed: &Zeroizing<[u8; 32]>) -> Result<Zeroizing<[u8; 32]>> {
    use sha2::{Digest, Sha256};

    // Minimum: 32 (ephemeral_pk) + 41 (version+nonce+tag for 32-byte plaintext)
    if wrapped.len() < 32 + 41 {
        return Err(RelicarioError::Format("wrapped key blob too short".into()));
    }

    let ephemeral_pk = x25519_dalek::PublicKey::from(
        <[u8; 32]>::try_from(&wrapped[..32]).unwrap()
    );
    let encrypted = &wrapped[32..];

    let recipient_sk = ed25519_seed_to_x25519_secret(ed25519_seed.as_ref());
    let recipient_pk = x25519_dalek::PublicKey::from(&recipient_sk);

    let shared = recipient_sk.diffie_hellman(&ephemeral_pk);

    let mut kdf_input: Zeroizing<Vec<u8>> = Zeroizing::new(Vec::with_capacity(32 + 32 + 32));
    kdf_input.extend_from_slice(shared.as_bytes());
    kdf_input.extend_from_slice(ephemeral_pk.as_bytes());
    kdf_input.extend_from_slice(recipient_pk.as_bytes());

    let mut wrap_key: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]);
    wrap_key.copy_from_slice(&Sha256::digest(kdf_input.as_slice()));

    let plaintext = Zeroizing::new(crate::crypto::decrypt(&wrap_key, encrypted)?);
    if plaintext.len() != 32 {
        return Err(RelicarioError::Format(
            format!("unwrapped key has wrong length: {}", plaintext.len())
        ));
    }

    let mut key = Zeroizing::new([0u8; 32]);
    key.copy_from_slice(&plaintext);
    Ok(key)
}

// ── Tests ─────────────────────────────────────────────────────────────────────

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

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

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

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

    #[test]
    fn org_role_can_manage_members() {
        assert!(OrgRole::Owner.can_manage_members());
        assert!(OrgRole::Admin.can_manage_members());
        assert!(!OrgRole::Member.can_manage_members());
    }

    #[test]
    fn collection_slug_validation_rejects_slash() {
        let mut c = OrgCollections::new();
        c.collections.push(CollectionDef {
            slug: "bad/slug".into(),
            display_name: "Bad".into(),
            created_by: MemberId::new(),
            created_at: 0,
        });
        assert!(c.validate().is_err());
    }

    #[test]
    fn filter_for_member_restricts_collections() {
        let mut manifest = OrgManifest::new();
        manifest.entries.push(OrgManifestEntry {
            id: ItemId::new(),
            r#type: crate::item_types::ItemType::SecureNote,
            title: "A".into(),
            tags: vec![],
            modified: 0,
            trashed_at: None,
            collection: "prod".into(),
        });
        manifest.entries.push(OrgManifestEntry {
            id: ItemId::new(),
            r#type: crate::item_types::ItemType::SecureNote,
            title: "B".into(),
            tags: vec![],
            modified: 0,
            trashed_at: None,
            collection: "dev".into(),
        });

        let member = OrgMember {
            member_id: MemberId::new(),
            display_name: "Alice".into(),
            role: OrgRole::Member,
            ed25519_pubkey: String::new(),
            collections: vec!["prod".into()],
            added_at: 0,
            added_by: MemberId::new(),
        };

        let filtered = manifest.filter_for_member(&member);
        assert_eq!(filtered.entries.len(), 1);
        assert_eq!(filtered.entries[0].collection, "prod");
    }

    #[test]
    fn generate_org_key_is_32_bytes() {
        let key = generate_org_key();
        assert_eq!(key.len(), 32);
    }

    /// Pinned RFC 8032 known-answer vector for the ed25519→X25519 map. The seed
    /// and expected X25519 public key are from ed25519-dalek's own reference
    /// test (`tests/x25519.rs`, section 7.1 vector A). The expected value is a
    /// HARD-CODED LITERAL — NOT recomputed by the production code path — so a
    /// correlated cross-crate-version regression in the birational map (where
    /// both our derivation and a naive re-derivation would drift together) is
    /// still caught. If this test ever fails after a dep bump, the wrap/unwrap
    /// keyspace changed and every existing `keys/<id>.enc` blob is invalidated.
    #[test]
    fn ed25519_to_x25519_pinned_rfc8032_vector() {
        let seed: [u8; 32] =
            hex::decode("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
                .unwrap()
                .try_into()
                .unwrap();
        // Derive the X25519 *public* key the same way wrap/unwrap derives the
        // recipient's static secret from a seed.
        let secret = ed25519_seed_to_x25519_secret(&seed);
        let public = x25519_dalek::PublicKey::from(&secret);
        assert_eq!(
            hex::encode(public.as_bytes()),
            "d85e07ec22b0ad881537c2f44d662d1a143cf830c57aca4305d85c7a90f6b62e",
        );
    }

    #[test]
    fn wrap_unwrap_round_trip() {
        // Generate an ed25519 keypair to act as the member's device key
        use ed25519_dalek::SigningKey;
        let mut seed = [0u8; 32];
        OsRng.fill_bytes(&mut seed);
        let signing_key = SigningKey::from_bytes(&seed);
        let pubkey_openssh = ssh_key::PrivateKey::from(
            ssh_key::private::Ed25519Keypair::from(&signing_key),
        )
        .public_key()
        .to_openssh()
        .expect("openssh");

        let org_key = generate_org_key();
        let wrapped = wrap_org_key(&org_key, &pubkey_openssh).expect("wrap");
        let seed_zeroizing = Zeroizing::new(seed);
        let unwrapped = unwrap_org_key(&wrapped, &seed_zeroizing).expect("unwrap");

        assert_eq!(*org_key, *unwrapped);
    }

    #[test]
    fn unwrap_with_wrong_seed_fails() {
        use ed25519_dalek::SigningKey;
        let mut seed = [0u8; 32];
        OsRng.fill_bytes(&mut seed);
        let signing_key = SigningKey::from_bytes(&seed);
        let pubkey_openssh = ssh_key::PrivateKey::from(
            ssh_key::private::Ed25519Keypair::from(&signing_key),
        )
        .public_key()
        .to_openssh()
        .expect("openssh");

        let org_key = generate_org_key();
        let wrapped = wrap_org_key(&org_key, &pubkey_openssh).expect("wrap");

        let wrong_seed = Zeroizing::new([0xFFu8; 32]);
        let result = unwrap_org_key(&wrapped, &wrong_seed);
        assert!(result.is_err());
    }
}
```

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

```bash
cargo test -p relicario-core org:: 2>&1 | tail -20
```

Expected: all org tests pass.

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

Replace the `pub mod org;` stub line in `crates/relicario-core/src/lib.rs` with:

```rust
pub mod org;
pub use org::{
    generate_org_key, unwrap_org_key, wrap_org_key,
    CollectionDef, MemberId, OrgCollections, OrgId, OrgManifest,
    OrgManifestEntry, OrgMember, OrgMembers, OrgMeta, OrgRole,
};
```

```bash
cargo check -p relicario-core
```

Expected: clean compile.

- [ ] **Step 5: Commit**

```bash
git add crates/relicario-core/src/org.rs crates/relicario-core/src/lib.rs
git commit -m "feat(core/org): org types, manifest, and X25519 key wrap/unwrap (Zeroizing KDF)"
```

---

## [Dev-A] Task A3: Org manifest vault wrappers

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

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

Add to `crates/relicario-core/src/vault.rs` tests block:

```rust
#[test]
fn org_manifest_round_trip() {
    use crate::org::{OrgManifest, OrgManifestEntry, MemberId};
    use crate::ids::ItemId;
    use crate::item_types::ItemType;

    let mut m = OrgManifest::new();
    m.entries.push(OrgManifestEntry {
        id: ItemId::new(),
        r#type: ItemType::SecureNote,
        title: "test".into(),
        tags: vec![],
        modified: 0,
        trashed_at: None,
        collection: "prod".into(),
    });
    let key = key();
    let bytes = encrypt_org_manifest(&m, &key).unwrap();
    let decoded = decrypt_org_manifest(&bytes, &key).unwrap();
    assert_eq!(decoded.entries.len(), 1);
    assert_eq!(decoded.entries[0].collection, "prod");
}
```

```bash
cargo test -p relicario-core vault::tests::org_manifest_round_trip 2>&1 | tail -5
```

Expected: FAIL — `encrypt_org_manifest` not defined.

- [ ] **Step 2: Add org manifest wrappers to vault.rs**

Add to `crates/relicario-core/src/vault.rs` (after the existing `decrypt_settings` function):

```rust
use crate::org::OrgManifest;

pub fn encrypt_org_manifest(manifest: &OrgManifest, org_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
    let json = serde_json::to_vec(manifest)?;
    let plaintext = Zeroizing::new(json);
    encrypt(org_key, plaintext.as_slice())
}

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

Also add the re-exports in `lib.rs` vault pub use block:

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

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

```bash
cargo test -p relicario-core vault::tests::org_manifest_round_trip
cargo test -p relicario-core
```

Expected: all tests pass.

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-core/src/vault.rs crates/relicario-core/src/lib.rs
git commit -m "feat(core/org): encrypt/decrypt_org_manifest vault wrappers"
```

---

## [Dev-A] Task A4: Full org lifecycle integration test (core)

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

This test exercises the complete core-level org flow without requiring a device key on disk. The keypair helper uses the **H7 fix** (`PrivateKey::from(Ed25519Keypair::from(&signing_key))`).

> **Note:** The old plan's "add ed25519-dalek and ssh-key as dev-dependencies" step is **removed** — these crates are already in `relicario-core`'s `[dependencies]`, so integration tests (which compile against the crate) already have them available. No `[dev-dependencies]` edit is required.

- [ ] **Step 1: Write the integration test**

Create `crates/relicario-core/tests/org.rs`:

```rust
use relicario_core::{
    generate_org_key, wrap_org_key, unwrap_org_key,
    encrypt_org_manifest, decrypt_org_manifest,
    OrgManifest, OrgManifestEntry, OrgMember, OrgMembers, OrgRole,
    MemberId, ItemId,
};
use relicario_core::item_types::ItemType;
use rand::rngs::OsRng;
use rand::RngCore;
use zeroize::Zeroizing;

fn make_member_keypair() -> (Zeroizing<[u8; 32]>, String) {
    let mut seed = [0u8; 32];
    OsRng.fill_bytes(&mut seed);
    let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
    let pubkey_openssh = ssh_key::PrivateKey::from(
        ssh_key::private::Ed25519Keypair::from(&signing_key),
    )
    .public_key()
    .to_openssh()
    .expect("openssh");
    (Zeroizing::new(seed), pubkey_openssh)
}

#[test]
fn org_key_wrap_unwrap_round_trip() {
    let (seed, pubkey) = make_member_keypair();
    let org_key = generate_org_key();
    let wrapped = wrap_org_key(&org_key, &pubkey).expect("wrap");
    let unwrapped = unwrap_org_key(&wrapped, &seed).expect("unwrap");
    assert_eq!(*org_key, *unwrapped);
}

#[test]
fn revoked_member_cannot_decrypt_after_rotation() {
    // Alice and Bob both get access
    let (alice_seed, alice_pubkey) = make_member_keypair();
    let (_bob_seed, bob_pubkey) = make_member_keypair();

    let org_key = generate_org_key();
    let _alice_wrapped = wrap_org_key(&org_key, &alice_pubkey).expect("wrap alice");
    let _bob_wrapped = wrap_org_key(&org_key, &bob_pubkey).expect("wrap bob");

    // Rotate: new key, only Bob gets re-wrapped
    let new_org_key = generate_org_key();
    let new_bob_wrapped = wrap_org_key(&new_org_key, &bob_pubkey).expect("wrap bob new");

    // Alice tries to use old org_key — she can still decrypt old items,
    // but new_bob_wrapped was encrypted with new_org_key, not org_key.
    // Verify: unwrapping new_bob_wrapped with Alice's seed fails.
    let result = unwrap_org_key(&new_bob_wrapped, &alice_seed);
    assert!(result.is_err(), "Alice should not be able to unwrap Bob's new key blob");
}

#[test]
fn org_manifest_filter_restricts_to_granted_collections() {
    let mut manifest = OrgManifest::new();
    for (title, collection) in &[("A", "prod"), ("B", "dev"), ("C", "prod")] {
        manifest.entries.push(OrgManifestEntry {
            id: ItemId::new(),
            r#type: ItemType::SecureNote,
            title: title.to_string(),
            tags: vec![],
            modified: 0,
            trashed_at: None,
            collection: collection.to_string(),
        });
    }

    let member = OrgMember {
        member_id: MemberId::new(),
        display_name: "Alice".into(),
        role: OrgRole::Member,
        ed25519_pubkey: String::new(),
        collections: vec!["prod".into()],
        added_at: 0,
        added_by: MemberId::new(),
    };

    let filtered = manifest.filter_for_member(&member);
    assert_eq!(filtered.entries.len(), 2);
    assert!(filtered.entries.iter().all(|e| e.collection == "prod"));
}

#[test]
fn org_manifest_encrypt_decrypt_round_trip() {
    let key = generate_org_key();
    let mut manifest = OrgManifest::new();
    manifest.entries.push(OrgManifestEntry {
        id: ItemId::new(),
        r#type: ItemType::Login,
        title: "GitHub".into(),
        tags: vec!["work".into()],
        modified: 1748000000,
        trashed_at: None,
        collection: "eng-tools".into(),
    });

    let encrypted = encrypt_org_manifest(&manifest, &key).expect("encrypt");
    let decrypted = decrypt_org_manifest(&encrypted, &key).expect("decrypt");

    assert_eq!(decrypted.entries.len(), 1);
    assert_eq!(decrypted.entries[0].title, "GitHub");
    assert_eq!(decrypted.entries[0].collection, "eng-tools");
}

#[test]
fn members_validation_rejects_invalid_id() {
    let mut members = OrgMembers::new();
    members.members.push(OrgMember {
        member_id: MemberId("not-hex-lol!!".to_string()),
        display_name: "Bad".into(),
        role: OrgRole::Member,
        ed25519_pubkey: String::new(),
        collections: vec![],
        added_at: 0,
        added_by: MemberId::new(),
    });
    assert!(members.validate().is_err());
}
```

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

```bash
cargo test -p relicario-core --test org 2>&1 | tail -20
cargo test 2>&1 | tail -20
```

Expected: all 5 tests pass; full suite green.

- [ ] **Step 3: Final commit**

```bash
git add crates/relicario-core/tests/org.rs
git commit -m "test(core/org): full org lifecycle integration tests"
```

---

## Dev-B — relicario-cli (session, device, admin commands, item CRUD, wiring)

> **Stream prerequisite:** Tasks A2–A3 must be merged before Dev-B begins (every command imports `relicario_core::org` types/functions).

## [Dev-B] Task B1: UnlockedOrgVault session type (collection-scoped item_path + fingerprint member match + signed org_git_run)

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

`UnlockedOrgVault` holds the org master key for one CLI invocation. Two corrections from the review are baked in from the start: (1) `item_path` is **collection-scoped** — `items/<collection-slug>/<id>.enc` — so the hook can authorize item writes by leading path segment without decrypting; (2) the caller is matched to `members.json` by **ed25519 fingerprint** (via `relicario_core::fingerprint`), not brittle raw-OpenSSH-string equality. `org_git_run` runs **bare git** (NOT the hardened `helpers::git_run`, which force-disables signing) so org commits are signed.

- [ ] **Step 1: Implement UnlockedOrgVault**

Create `crates/relicario-cli/src/org_session.rs`:

```rust
//! Unlocked org vault session: holds the org master key for the duration of a
//! CLI invocation.

use std::fs;
use std::path::{Path, PathBuf};

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

use relicario_core::{
    decrypt_item, decrypt_org_manifest, encrypt_item, encrypt_org_manifest,
    Item, ItemId, MemberId, OrgCollections, OrgManifest, OrgMember, OrgMembers, OrgMeta,
};

pub struct UnlockedOrgVault {
    pub root: PathBuf,
    pub org_key: Zeroizing<[u8; 32]>,
}

impl UnlockedOrgVault {
    pub fn root(&self) -> &Path { &self.root }
    pub fn key(&self) -> &Zeroizing<[u8; 32]> { &self.org_key }

    pub fn manifest_path(&self) -> PathBuf { self.root.join("manifest.enc") }

    /// Collection-scoped item path: `items/<collection-slug>/<id>.enc`.
    /// The leading slug segment is what the pre-receive hook authorizes against
    /// members.json — it never decrypts the blob. The slug must be non-empty and
    /// already validated.
    pub fn item_path(&self, collection_slug: &str, id: &ItemId) -> PathBuf {
        self.root
            .join("items")
            .join(collection_slug)
            .join(format!("{}.enc", id.as_str()))
    }

    pub fn member_key_path(&self, id: &MemberId) -> PathBuf {
        self.root.join("keys").join(format!("{}.enc", id.as_str()))
    }
    pub fn members_path(&self) -> PathBuf { self.root.join("members.json") }
    pub fn collections_path(&self) -> PathBuf { self.root.join("collections.json") }
    pub fn org_meta_path(&self) -> PathBuf { self.root.join("org.json") }

    pub fn load_meta(&self) -> Result<OrgMeta> {
        let s = fs::read_to_string(self.org_meta_path()).context("read org.json")?;
        Ok(serde_json::from_str(&s).context("parse org.json")?)
    }

    pub fn load_members(&self) -> Result<OrgMembers> {
        let s = fs::read_to_string(self.members_path()).context("read members.json")?;
        Ok(serde_json::from_str(&s).context("parse members.json")?)
    }

    pub fn save_members(&self, members: &OrgMembers) -> Result<()> {
        let json = serde_json::to_string_pretty(members)?;
        atomic_write(&self.members_path(), json.as_bytes())
    }

    pub fn load_collections(&self) -> Result<OrgCollections> {
        let s = fs::read_to_string(self.collections_path()).context("read collections.json")?;
        Ok(serde_json::from_str(&s).context("parse collections.json")?)
    }

    pub fn save_collections(&self, collections: &OrgCollections) -> Result<()> {
        let json = serde_json::to_string_pretty(collections)?;
        atomic_write(&self.collections_path(), json.as_bytes())
    }

    pub fn load_manifest(&self) -> Result<OrgManifest> {
        let bytes = fs::read(self.manifest_path()).context("read manifest.enc")?;
        Ok(decrypt_org_manifest(&bytes, &self.org_key)?)
    }

    pub fn save_manifest(&self, manifest: &OrgManifest) -> Result<()> {
        let bytes = encrypt_org_manifest(manifest, &self.org_key)?;
        atomic_write(&self.manifest_path(), &bytes)
    }

    /// Encrypt + write an item under its collection directory, creating the
    /// directory if needed. Returns the repo-relative path for git staging.
    pub fn save_item(&self, collection_slug: &str, item: &Item) -> Result<String> {
        let path = self.item_path(collection_slug, &item.id);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("create {}", parent.display()))?;
        }
        let bytes = encrypt_item(item, &self.org_key)?;
        atomic_write(&path, &bytes)?;
        Ok(format!("items/{}/{}.enc", collection_slug, item.id.as_str()))
    }

    /// Read + decrypt an item from its collection directory.
    pub fn load_item(&self, collection_slug: &str, id: &ItemId) -> Result<Item> {
        let path = self.item_path(collection_slug, id);
        let bytes = fs::read(&path)
            .with_context(|| format!("read item {}", path.display()))?;
        Ok(decrypt_item(&bytes, &self.org_key)?)
    }

    /// Delete an item blob. Missing file is not an error (partial-write
    /// recovery, same as the personal-vault purge path).
    pub fn remove_item(&self, collection_slug: &str, id: &ItemId) -> Result<()> {
        let path = self.item_path(collection_slug, id);
        match fs::remove_file(&path) {
            Ok(()) => Ok(()),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(e) => Err(anyhow::Error::from(e)
                .context(format!("delete {}", path.display()))),
        }
    }

    /// Bail unless `member` has `slug` in their collection grants. The slug
    /// existence check is done separately by the caller against collections.json.
    pub fn ensure_grant(member: &OrgMember, slug: &str) -> Result<()> {
        if member.collections.iter().any(|c| c == slug) {
            Ok(())
        } else {
            bail!(
                "access denied: you do not have a grant for collection `{slug}` — ask an admin to run `relicario org grant`"
            )
        }
    }

    /// Load members.json and find the caller's member entry by matching the
    /// current device's ed25519 fingerprint against each member's pubkey
    /// fingerprint. Fingerprint comparison (not raw OpenSSH-string equality)
    /// tolerates comment/whitespace differences in the serialized key.
    pub fn current_member(&self) -> Result<relicario_core::OrgMember> {
        let device_fp = current_device_fingerprint()?;
        let members = self.load_members()?;
        members
            .members
            .into_iter()
            .find(|m| {
                relicario_core::fingerprint(&m.ed25519_pubkey)
                    .ok()
                    .as_deref()
                    == Some(device_fp.as_str())
            })
            .ok_or_else(|| {
                anyhow::anyhow!(
                    "your device key is not registered in this org — ask an admin to run `org add-member`"
                )
            })
    }
}

/// Locate the org vault root from RELICARIO_ORG_DIR env var or --dir flag value.
pub fn org_dir(dir_flag: Option<&std::path::Path>) -> Result<PathBuf> {
    if let Some(d) = dir_flag {
        return Ok(d.to_path_buf());
    }
    if let Ok(v) = std::env::var("RELICARIO_ORG_DIR") {
        return Ok(PathBuf::from(v));
    }
    bail!("org vault location required: set RELICARIO_ORG_DIR or pass --dir <path>")
}

/// Open an org vault: locate the root, read members.json to find the caller's
/// member entry (by ed25519 fingerprint), then unwrap their keys/<id>.enc to
/// recover the org master key.
pub fn open_org_vault(dir_flag: Option<&std::path::Path>) -> Result<UnlockedOrgVault> {
    let root = org_dir(dir_flag)?;

    let device_fp = current_device_fingerprint()?;
    let members_json = fs::read_to_string(root.join("members.json"))
        .context("read members.json — is this an org vault?")?;
    let members: OrgMembers = serde_json::from_str(&members_json).context("parse members.json")?;
    let member = members
        .members
        .iter()
        .find(|m| {
            relicario_core::fingerprint(&m.ed25519_pubkey)
                .ok()
                .as_deref()
                == Some(device_fp.as_str())
        })
        .ok_or_else(|| anyhow::anyhow!("your device key is not in this org"))?;

    // Load this member's wrapped key blob.
    let key_path = root
        .join("keys")
        .join(format!("{}.enc", member.member_id.as_str()));
    let wrapped =
        fs::read(&key_path).with_context(|| format!("read {}", key_path.display()))?;

    // Recover the device ed25519 seed and unwrap.
    let seed = current_device_seed()?;
    let org_key = relicario_core::unwrap_org_key(&wrapped, &seed)?;

    Ok(UnlockedOrgVault { root, org_key })
}

/// OpenSSH SHA-256 fingerprint of the active device's signing key.
fn current_device_fingerprint() -> Result<String> {
    let name = crate::device::current_device()?
        .ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
    let pub_path = crate::device::device_dir(&name)?.join("signing.pub");
    let pubkey = fs::read_to_string(&pub_path)
        .with_context(|| format!("read {}", pub_path.display()))?;
    Ok(relicario_core::fingerprint(pubkey.trim())?)
}

/// Recover the active device's ed25519 seed (the 32-byte private scalar source)
/// from its OpenSSH `signing.key`, for ECIES unwrap.
fn current_device_seed() -> Result<Zeroizing<[u8; 32]>> {
    let name = crate::device::current_device()?
        .ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
    let key_pem = crate::device::load_signing_key(&name)?;
    let private = ssh_key::PrivateKey::from_openssh(key_pem.as_str())
        .map_err(|e| anyhow::anyhow!("parse device signing key: {e}"))?;
    let ed = private
        .key_data()
        .ed25519()
        .ok_or_else(|| anyhow::anyhow!("device signing key is not ed25519"))?;
    // Ed25519PrivateKey derefs to its 32-byte seed.
    let seed_bytes: &[u8] = ed.private.as_ref();
    if seed_bytes.len() != 32 {
        anyhow::bail!("ed25519 seed has wrong length: {}", seed_bytes.len());
    }
    let mut seed = Zeroizing::new([0u8; 32]);
    seed.copy_from_slice(seed_bytes);
    Ok(seed)
}

pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
    let mut tmp = path.as_os_str().to_owned();
    tmp.push(".tmp");
    let tmp = PathBuf::from(tmp);
    fs::write(&tmp, data).with_context(|| format!("write {}", tmp.display()))?;
    fs::rename(&tmp, path).with_context(|| format!("rename {}", tmp.display()))?;
    Ok(())
}

/// Run `git <args>` in the org repo, capturing output and replaying it on
/// failure. Unlike `crate::helpers::git_run`, this does NOT inject
/// `commit.gpgsign=false` / `core.hooksPath=/dev/null`: org commits MUST be
/// signed (the pre-receive hook verifies every commit's signature), and the
/// repo's signing config is established by `configure_git_signing` during
/// `org init`.
pub(crate) fn org_git_run(root: &Path, args: &[&str], context: &str) -> Result<()> {
    let output = std::process::Command::new("git")
        .current_dir(root)
        .args(args)
        .output()
        .with_context(|| format!("{context}: failed to spawn git"))?;
    if !output.status.success() {
        if !output.stdout.is_empty() {
            eprint!("{}", String::from_utf8_lossy(&output.stdout));
        }
        if !output.stderr.is_empty() {
            eprint!("{}", String::from_utf8_lossy(&output.stderr));
        }
        anyhow::bail!("{context}: git failed ({})", output.status);
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;
    use std::fs;

    fn make_vault(key: Zeroizing<[u8; 32]>) -> (TempDir, UnlockedOrgVault) {
        let dir = TempDir::new().unwrap();
        let root = dir.path().to_path_buf();
        fs::create_dir_all(root.join("items")).unwrap();
        fs::create_dir_all(root.join("keys")).unwrap();
        let vault = UnlockedOrgVault { root, org_key: key };
        (dir, vault)
    }

    #[test]
    fn unlocked_org_vault_paths() {
        let key = Zeroizing::new([0u8; 32]);
        let (dir, vault) = make_vault(key);
        let root = dir.path().to_path_buf();
        assert_eq!(vault.manifest_path(), root.join("manifest.enc"));
        assert_eq!(
            vault.member_key_path(&MemberId("abc0def1abc0def1".into())),
            root.join("keys/abc0def1abc0def1.enc")
        );
        assert_eq!(
            vault.item_path("prod", &relicario_core::ItemId("0123456789abcdef".into())),
            root.join("items/prod/0123456789abcdef.enc")
        );
    }

    #[test]
    fn save_and_load_manifest() {
        let key = Zeroizing::new([0xAAu8; 32]);
        let (dir, vault) = make_vault(key);
        let _ = dir; // keep alive
        let mut m = OrgManifest::new();
        m.entries.push(relicario_core::OrgManifestEntry {
            id: relicario_core::ItemId::new(),
            r#type: relicario_core::ItemType::SecureNote,
            title: "test".into(),
            tags: vec![],
            modified: 0,
            trashed_at: None,
            collection: "prod".into(),
        });
        vault.save_manifest(&m).unwrap();
        let loaded = vault.load_manifest().unwrap();
        assert_eq!(loaded.entries.len(), 1);
    }

    #[test]
    fn save_and_load_members() {
        let key = Zeroizing::new([0u8; 32]);
        let (dir, vault) = make_vault(key);
        let _ = dir;
        let members = OrgMembers::new();
        vault.save_members(&members).unwrap();
        let loaded = vault.load_members().unwrap();
        assert_eq!(loaded.schema_version, 1);
    }
}
```

> **Note:** `current_device()`, `device_dir(name)`, and `load_signing_key(name)` are real accessors in `crates/relicario-cli/src/device.rs` (device.rs:32, :27, :95). `relicario_core::fingerprint` is re-exported at core `lib.rs:94`. The `ItemId(..)`/`MemberId(..)` tuple constructors in the tests are valid because both are `pub` tuple structs. If `item_path`/`save_item`/`load_item`/`remove_item`/`ensure_grant` warn as unused at this point, that is fine — B9–B13 consume them in the same PR series; do NOT add `#[allow(dead_code)]`.

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

In `crates/relicario-cli/src/main.rs`, add after the existing `mod session;` line:

```rust
mod org_session;
```

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

```bash
cargo test -p relicario-cli org_session 2>&1 | tail -20
```

Expected: all org_session tests pass.

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-cli/src/org_session.rs crates/relicario-cli/src/main.rs
git commit -m "feat(cli/org): UnlockedOrgVault session (collection-scoped item_path, fingerprint match, signed org_git_run)"
```

---

## [Dev-B] Task B2: Device seed/pubkey helpers + `ssh-key` CLI dep

**Files:**
- Modify: `crates/relicario-cli/Cargo.toml`
- Modify: `crates/relicario-cli/src/device.rs`

> **Context (verified against the real codebase):** Device keys live under `~/.config/relicario/devices/<name>/signing.key` (OpenSSH private) + `signing.pub` (OpenSSH public single line). The active device name is in `~/.config/relicario/devices/current`. There is **no** `~/.config/relicario/device.key` and **no** `RELICARIO_DEVICE_KEY` env var. Use the real accessors already in `crates/relicario-cli/src/device.rs`: `current_device() -> Result<Option<String>>` (device.rs:32), `device_dir(name) -> Result<PathBuf>` (device.rs:27), `load_signing_key(name) -> Result<Zeroizing<String>>` (device.rs:95, returns the OpenSSH private PEM text). The seed-extraction path is verified against `ssh-key` 0.6.7: `PrivateKey::from_openssh(&pem)` → `.key_data()` → `.ed25519()` (`Option<&Ed25519Keypair>`) → `.private` (`Ed25519PrivateKey`) → `.as_ref()` (`&[u8; 32]`). The core crate already uses this exact chain in `relicario-core/src/device.rs:64-69`.

- [ ] **Step 1: Add `ssh-key` to the CLI crate's dependencies**

`ssh-key` is **not** currently a dependency of `relicario-cli` (verified: no `ssh-key` line in `crates/relicario-cli/Cargo.toml`). It *is* already in the workspace lock at version **0.6.7** (pulled in by `relicario-core`). In `crates/relicario-cli/Cargo.toml`, under `[dependencies]`, add the line after the `qrcode` entry:

```toml
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
```

The full `[dependencies]` tail should read:

```toml
reqwest = { version = "0.12", features = ["blocking", "json"] }
qrcode = { version = "0.14", features = ["svg"] }
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
```

> **Why these features:** `ed25519` enables the `Ed25519Keypair`/`Ed25519PrivateKey` accessors and the `key_data().ed25519()` path; `std` enables `PrivateKey::from_openssh` / `PublicKey::to_openssh`. This matches the feature set `relicario-core` already relies on, so resolution stays at 0.6.7 — no new lockfile entry, no version bump.

- [ ] **Step 2: Confirm the dependency resolves without changing the lock version**

```bash
cargo tree -p relicario-cli -i ssh-key 2>&1 | head -5
grep -A1 'name = "ssh-key"' Cargo.lock | head -3
```

Expected: `ssh-key v0.6.7` appears for `relicario-cli`, and `Cargo.lock` still pins `version = "0.6.7"` (no second copy of `ssh-key` added).

- [ ] **Step 3: Add a failing unit test for the round-trip**

Append this test module to the end of `crates/relicario-cli/src/device.rs`. It generates a keypair with `relicario_core::device::generate_keypair()`, writes it to a temp device dir, points `current` at it, and asserts the seed we extract re-derives the same OpenSSH public key.

> **Note:** This test mutates `XDG_CONFIG_HOME` to redirect `dirs::config_dir()`; it serializes via a `Mutex` and restores the env afterwards. Requires the `tempfile` dev-dependency (already present in `[dev-dependencies]`).

```rust
#[cfg(test)]
mod seed_helper_tests {
    use super::*;
    use std::sync::Mutex;

    // dirs::config_dir() reads process-wide env; serialize these tests.
    static ENV_LOCK: Mutex<()> = Mutex::new(());

    #[test]
    fn current_device_seed_and_pubkey_round_trip() {
        let _guard = ENV_LOCK.lock().unwrap();
        let tmp = tempfile::tempdir().unwrap();
        let prev_xdg = std::env::var_os("XDG_CONFIG_HOME");
        std::env::set_var("XDG_CONFIG_HOME", tmp.path());

        // Generate a real ed25519 device keypair (OpenSSH text) via core.
        let (private_openssh, public_openssh) =
            relicario_core::device::generate_keypair().unwrap();

        // Lay out devices/test-dev/{signing.key,signing.pub} + devices/current.
        let dir = device_dir("test-dev").unwrap();
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(dir.join("signing.key"), private_openssh.as_str()).unwrap();
        std::fs::write(dir.join("signing.pub"), &public_openssh).unwrap();
        set_current_device("test-dev").unwrap();

        // pubkey helper returns exactly the stored OpenSSH public line.
        let got_pub = current_device_pubkey().unwrap();
        assert_eq!(got_pub.trim(), public_openssh.trim());

        // seed helper returns the 32-byte ed25519 seed; re-derive the public
        // key from it and confirm it matches.
        let seed = current_device_seed().unwrap();
        let signing = ed25519_dalek::SigningKey::from_bytes(&seed);
        let derived = signing.verifying_key();
        let parsed_pub = ssh_key::PublicKey::from_openssh(&public_openssh).unwrap();
        let parsed_bytes: &[u8] = parsed_pub.key_data().ed25519().unwrap().as_ref();
        assert_eq!(derived.as_bytes().as_slice(), parsed_bytes);

        // restore env
        match prev_xdg {
            Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
            None => std::env::remove_var("XDG_CONFIG_HOME"),
        }
    }
}
```

> **New dev-dependency note:** This test references `ed25519_dalek::SigningKey`. `ed25519-dalek` is **not** a dev-dependency of `relicario-cli`. Add it to `crates/relicario-cli/Cargo.toml` under `[dev-dependencies]` (already in the workspace lock at the version `relicario-core` uses, so no new lock entry):
>
> ```toml
> ed25519-dalek = "2"
> ```

Run it — it must **fail to compile** now (the two helper fns don't exist yet):

```bash
cargo test -p relicario-cli --lib seed_helper_tests 2>&1 | tail -20
```

Expected: compile error `cannot find function 'current_device_seed'` (and `current_device_pubkey`).

- [ ] **Step 4: Implement the two helpers**

Add these two public functions to `crates/relicario-cli/src/device.rs`, immediately after `load_signing_key` (after device.rs:100). Do **not** add a `device_key_path()`.

```rust
/// Read the active device's ed25519 public key (OpenSSH single-line format,
/// e.g. `ssh-ed25519 AAAA... comment`) from `signing.pub`.
///
/// Errors if no device is selected (`devices/current` missing/empty) — the
/// caller should hint the user to run `relicario device add` first.
pub fn current_device_pubkey() -> Result<String> {
    let name = current_device()?
        .ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
    let path = device_dir(&name)?.join("signing.pub");
    let pubkey = fs::read_to_string(&path)
        .with_context(|| format!("read signing.pub for device '{name}'"))?;
    let trimmed = pubkey.trim();
    if trimmed.is_empty() {
        anyhow::bail!("signing.pub for device '{name}' is empty");
    }
    Ok(trimmed.to_string())
}

/// Read the active device's 32-byte ed25519 seed from `signing.key`
/// (OpenSSH private-key format).
///
/// The seed is the secret scalar used to sign org commits and to unwrap the
/// org key. It is returned in `Zeroizing` so it is wiped on drop. Errors if no
/// device is selected, the key file is unreadable, or the key is not ed25519.
pub fn current_device_seed() -> Result<Zeroizing<[u8; 32]>> {
    let name = current_device()?
        .ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
    // load_signing_key reads signing.key as OpenSSH private-key text.
    let pem = load_signing_key(&name)?;
    let private = ssh_key::PrivateKey::from_openssh(pem.as_str())
        .map_err(|e| anyhow::anyhow!("parse signing.key for device '{name}': {e}"))?;
    let keypair = private
        .key_data()
        .ed25519()
        .ok_or_else(|| anyhow::anyhow!("signing.key for device '{name}' is not ed25519"))?;
    // Ed25519PrivateKey::as_ref() yields &[u8; 32] (verified: ssh-key 0.6.7
    // private/ed25519.rs:42). Copy into a Zeroizing array so the seed is wiped.
    let mut seed = Zeroizing::new([0u8; 32]);
    seed.copy_from_slice(keypair.private.as_ref());
    Ok(seed)
}
```

> **Import note:** `fs`, `Context`, `Result`, and `Zeroizing` are already imported at the top of `device.rs` (lines 13–17). `ssh_key` is referenced by fully-qualified path. `anyhow::anyhow!` / `anyhow::bail!` are used fully-qualified to match the existing style.

> **Note on `current_device_seed` overlap:** `org_session.rs` (Task B1) defines its own private `current_device_seed`/`current_device_fingerprint` for the unwrap path. These device.rs helpers are the *public* device-module surface (also used by Task B4's signing test and by any future caller). They are not the same functions; B1's are module-private to `org_session`. Both reading the same on-disk key is intentional and correct.

- [ ] **Step 5: Verify the test goes green + regression**

```bash
cargo test -p relicario-cli --lib seed_helper_tests 2>&1 | tail -20
cargo check -p relicario-cli 2>&1 | tail -5
cargo test -p relicario-cli --lib 2>&1 | tail -15
```

Expected: `current_device_seed_and_pubkey_round_trip ... ok`; clean check; all lib tests pass.

- [ ] **Step 6: Commit**

```bash
git add crates/relicario-cli/Cargo.toml Cargo.lock crates/relicario-cli/src/device.rs
git commit -m "feat(cli/device): current_device_seed + current_device_pubkey helpers

Read the active device's ed25519 seed/pubkey from
devices/<name>/signing.{key,pub}. Adds ssh-key (0.6) as a CLI dep
(already at 0.6.7 in the workspace lock via relicario-core) and
ed25519-dalek as a dev-dep for the round-trip test.

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

---

## [Dev-B] Task B3: Org commands module stub + `pub mod org` wiring

**Files:**
- Create: `crates/relicario-cli/src/commands/org.rs` (stub)
- Modify: `crates/relicario-cli/src/commands/mod.rs`

- [ ] **Step 1: Check existing module layout**

```bash
grep -n "pub mod" crates/relicario-cli/src/commands/mod.rs | head -30
```

- [ ] **Step 2: Create org commands stub**

Create `crates/relicario-cli/src/commands/org.rs`:

```rust
//! `relicario org` subcommands for multi-user org vault management.

use anyhow::Result;

pub fn run_init(_dir: &std::path::Path, _name: &str) -> Result<()> {
    todo!("org init")
}
```

Add to `crates/relicario-cli/src/commands/mod.rs`:

```rust
pub mod org;
```

- [ ] **Step 3: Verify compile**

```bash
cargo check -p relicario-cli
```

Expected: clean (`todo!` is fine at compile time).

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/commands/mod.rs
git commit -m "feat(cli/org): org commands module stub + pub mod wiring"
```

---

## [Dev-B] Task B4: `org init` (with git signing)

**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs`
- Create: `crates/relicario-cli/tests/org_init.rs`
- Create: `crates/relicario-cli/tests/org_init_signing.rs`

`org init` creates the org directory structure, generates the org master key, wraps it to the caller's device key, writes all initial files, runs `git init`, **configures git signing** (`configure_git_signing`), and makes a **signed** bootstrap commit via `org_git_run`.

> **Why signing is load-bearing:** `crate::helpers::git_run` (helpers.rs:73) flows through `git_command` (helpers.rs:46), which hard-codes `-c commit.gpgsign=false` (helpers.rs:51). Any commit made through `git_run` is **unsigned** — and the pre-receive hook (Dev-C) rejects/cannot-attribute unsigned commits. So `org init` must (1) call `configure_git_signing(dir, &device_name)` (device.rs:133) to set `gpg.format=ssh`, `user.signingkey=<signing.key>`, `commit.gpgsign=true`, and (2) make the bootstrap commit via `org_git_run` (Task B1), which does NOT disable signing. This task folds the org-init structure/wrap logic and the signing wiring into one — there is **no** separate standalone signing task.

- [ ] **Step 1: Write the plain integration test**

Create `crates/relicario-cli/tests/org_init.rs`:

```rust
use tempfile::TempDir;

fn run(args: &[&str]) -> std::process::Output {
    std::process::Command::new(env!("CARGO_BIN_EXE_relicario"))
        .args(args)
        .output()
        .expect("run relicario")
}

#[test]
#[ignore] // requires a device key on disk; run manually or via org_init_signing
fn org_init_creates_expected_files() {
    let dir = TempDir::new().unwrap();
    let path = dir.path().to_str().unwrap();
    // `--dir` is a subcommand-scoped global on `org` (B14), so it must come
    // AFTER `org init`, not before it (matches B10's OrgFixture).
    let out = run(&["org", "init", "--dir", path, "--name", "Test Org"]);
    assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
    assert!(dir.path().join("org.json").exists());
    assert!(dir.path().join("members.json").exists());
    assert!(dir.path().join("collections.json").exists());
    assert!(dir.path().join("manifest.enc").exists());
    assert!(dir.path().join(".git").exists());
}
```

- [ ] **Step 2: Write the failing signing integration test**

Create `crates/relicario-cli/tests/org_init_signing.rs`:

```rust
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;

fn relicario(config_home: &Path, args: &[&str]) -> std::process::Output {
    Command::new(env!("CARGO_BIN_EXE_relicario"))
        .env("XDG_CONFIG_HOME", config_home)
        .env("HOME", config_home) // belt-and-suspenders for dirs on all platforms
        .args(args)
        .output()
        .expect("run relicario")
}

fn git(repo: &Path, args: &[&str]) -> std::process::Output {
    Command::new("git")
        .current_dir(repo)
        .args(args)
        .output()
        .expect("run git")
}

#[test]
fn org_init_produces_a_signed_initial_commit() {
    let cfg = TempDir::new().unwrap();
    let org = TempDir::new().unwrap();

    // Register a device so current_device_pubkey()/configure_git_signing work.
    let add = relicario(cfg.path(), &["device", "add", "--name", "test-dev", "--local"]);
    assert!(
        add.status.success(),
        "device add failed: {}",
        String::from_utf8_lossy(&add.stderr)
    );

    // Initialize the org vault. `--dir` is a subcommand-scoped global on `org`
    // (B14), so it comes AFTER `org init` (matches B10's OrgFixture).
    let init = relicario(
        cfg.path(),
        &["org", "init", "--dir", org.path().to_str().unwrap(), "--name", "Acme"],
    );
    assert!(
        init.status.success(),
        "org init failed: {}",
        String::from_utf8_lossy(&init.stderr)
    );

    // The org repo must be configured to sign.
    let cfg_out = git(org.path(), &["config", "commit.gpgsign"]);
    assert_eq!(
        String::from_utf8_lossy(&cfg_out.stdout).trim(),
        "true",
        "org repo must have commit.gpgsign=true"
    );

    // The HEAD commit object must carry a signature header.
    let head = git(org.path(), &["cat-file", "commit", "HEAD"]);
    let body = String::from_utf8_lossy(&head.stdout);
    assert!(
        body.contains("gpgsig "),
        "HEAD commit must be signed (no gpgsig header found):\n{body}"
    );

    // And it must actually verify against the configured signer.
    let verify = git(org.path(), &["verify-commit", "HEAD"]);
    assert!(
        verify.status.success(),
        "git verify-commit HEAD failed: {}",
        String::from_utf8_lossy(&verify.stderr)
    );
}
```

> **Test-harness assumption:** `relicario device add --name <n> --local` creates the device keys under `$XDG_CONFIG_HOME/relicario/devices/<n>/` and sets `current` without contacting Gitea. If the real `device add` surface differs (verify against `main.rs` `DeviceAction` and `commands::device`), adjust the flags — but the assertions (signed HEAD, `commit.gpgsign=true`, `verify-commit` success) are load-bearing and must stay. If `device add` cannot run offline in CI, lay out `devices/<n>/signing.{key,pub}` + `current` directly using `relicario_core::device::generate_keypair()` (same layout as Task B2 Step 3).

```bash
cargo test -p relicario-cli --test org_init_signing 2>&1 | tail -25
```

Expected red: `commit.gpgsign` empty or `gpgsig ` header absent (init not implemented / committed via unsigned path).

- [ ] **Step 3: Implement `run_init` (structure + wrap + signing + signed commit)**

Replace `run_init` stub in `commands/org.rs`:

```rust
use std::fs;
use std::path::Path;
use anyhow::{Context, Result};
use relicario_core::{
    generate_org_key, wrap_org_key,
    CollectionDef, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta, OrgRole, OrgMember,
    encrypt_org_manifest,
};
use crate::org_session::atomic_write;

pub fn run_init(dir: &Path, name: &str) -> Result<()> {
    // Create directory structure
    fs::create_dir_all(dir.join("items")).context("create items/")?;
    fs::create_dir_all(dir.join("keys")).context("create keys/")?;

    // Get caller's device info
    let device_pubkey = crate::device::current_device_pubkey()
        .context("read device key — run `relicario device add` first")?;

    // Generate org master key
    let org_key = generate_org_key();

    // Wrap org key to caller's device key
    let wrapped = wrap_org_key(&org_key, &device_pubkey)
        .context("wrap org key to device key")?;

    // Create initial members.json with caller as owner
    let caller_id = MemberId::new();
    let now = relicario_core::now_unix();
    let member = OrgMember {
        member_id: caller_id.clone(),
        display_name: whoami(),
        role: OrgRole::Owner,
        ed25519_pubkey: device_pubkey,
        collections: vec![],
        added_at: now,
        added_by: caller_id.clone(),
    };
    let mut members = OrgMembers::new();
    members.members.push(member);

    // Write wrapped key
    let key_path = dir.join("keys").join(format!("{}.enc", caller_id.as_str()));
    fs::write(&key_path, &wrapped).context("write caller key blob")?;

    // Write org.json
    let meta = OrgMeta::new(name.to_string());
    let meta_json = serde_json::to_string_pretty(&meta)?;
    atomic_write(&dir.join("org.json"), meta_json.as_bytes())?;

    // Write members.json
    let members_json = serde_json::to_string_pretty(&members)?;
    atomic_write(&dir.join("members.json"), members_json.as_bytes())?;

    // Write collections.json (empty)
    let collections = OrgCollections::new();
    let coll_json = serde_json::to_string_pretty(&collections)?;
    atomic_write(&dir.join("collections.json"), coll_json.as_bytes())?;

    // Write empty manifest.enc
    let manifest = OrgManifest::new();
    let manifest_bytes = encrypt_org_manifest(&manifest, &org_key)?;
    atomic_write(&dir.join("manifest.enc"), &manifest_bytes)?;

    // git init, then configure THIS repo to sign commits with the active device
    // key. Org commits must be signed; the pre-receive hook verifies every one.
    crate::helpers::git_run(dir, &["init"], "git init")?;
    let device_name = crate::device::current_device()?
        .ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
    crate::device::configure_git_signing(dir, &device_name)
        .context("configure org repo signing")?;

    // Stage everything and make the signed bootstrap commit via org_git_run
    // (which does NOT disable signing, unlike helpers::git_run).
    crate::org_session::org_git_run(dir, &["add", "."], "git add")?;
    let commit_msg = format!(
        "init: org vault \"{name}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: org-init",
        members.members[0].display_name,
        caller_id.as_str()
    );
    crate::org_session::org_git_run(dir, &["commit", "-m", &commit_msg], "git commit")?;

    println!("Org vault initialized at {}", dir.display());
    println!("Your member ID: {}", caller_id.as_str());
    Ok(())
}

fn whoami() -> String {
    std::env::var("USER")
        .or_else(|_| std::env::var("USERNAME"))
        .unwrap_or_else(|_| "unknown".into())
}
```

> **Verified:** `configure_git_signing(vault_root: &Path, name: &str) -> Result<()>` (device.rs:133). The trailer uses `Relicario-Actor: <name> <member_id>` (space-separated, no angle brackets) per the canonical contract — the hook ignores the trailer for actor attribution (it uses the verified signer), but keeping it contract-shaped matters for the audit `TAMPERED` cross-check.

- [ ] **Step 4: Verify both tests + build**

```bash
cargo test -p relicario-cli --test org_init_signing 2>&1 | tail -25
cargo build -p relicario-cli 2>&1 | tail -10
```

Expected: `org_init_produces_a_signed_initial_commit ... ok`; clean build.

- [ ] **Step 5: Commit**

```bash
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/tests/org_init.rs crates/relicario-cli/tests/org_init_signing.rs
git commit -m "feat(cli/org): org init — structure + wrap + configure_git_signing + signed bootstrap commit"
```

---

## [Dev-B] Task B5: `org add-member` / `remove-member` / `set-role` (with owner-only role-gating)

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

All three commands share the open-vault → edit members.json → signed commit pattern. **Role-gating correction:** `add-member` only checked `can_manage_members()` and then trusted the caller-supplied `role` unconditionally — an admin could mint a new owner. The fix adds an owner-only gate so non-owners cannot create owner/admin members (spec line 148/273).

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

Add to `commands/org.rs`:

```rust
#[cfg(test)]
mod tests {
    use super::*;
    use relicario_core::{MemberId, OrgMembers, OrgRole, OrgMember};

    fn alice() -> OrgMember {
        OrgMember {
            member_id: MemberId::new(),
            display_name: "Alice".into(),
            role: OrgRole::Member,
            ed25519_pubkey: "ssh-ed25519 AAAA fake".into(),
            collections: vec![],
            added_at: 0,
            added_by: MemberId::new(),
        }
    }

    #[test]
    fn set_role_changes_role() {
        let mut members = OrgMembers::new();
        let a = alice();
        let id = a.member_id.clone();
        members.members.push(a);
        if let Some(m) = members.find_by_id_mut(&id) {
            m.role = OrgRole::Admin;
        }
        assert_eq!(members.find_by_id(&id).unwrap().role, OrgRole::Admin);
    }
}
```

```bash
cargo test -p relicario-cli commands::org::tests 2>&1 | tail -5
```

Expected: PASS (pure logic test).

- [ ] **Step 2: Implement add-member (with owner-only escalation guard)**

Add to `commands/org.rs`:

```rust
pub fn run_add_member(
    dir: &Path,
    pubkey: &str,
    name: &str,
    role: OrgRole,
) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    if !caller.role.can_manage_members() {
        anyhow::bail!("only owners and admins can add members");
    }
    // Privilege-escalation guard: only an owner may create an owner or admin.
    if matches!(role, OrgRole::Owner | OrgRole::Admin) && !caller.role.can_manage_owners() {
        anyhow::bail!("only owners can add members with the owner or admin role");
    }

    let mut members = vault.load_members()?;

    // Check pubkey not already present
    if members.members.iter().any(|m| m.ed25519_pubkey.trim() == pubkey.trim()) {
        anyhow::bail!("this public key is already registered in the org");
    }

    let new_id = MemberId::new();
    let now = relicario_core::now_unix();
    let wrapped = wrap_org_key(vault.key(), pubkey)
        .context("wrap org key to new member's key")?;

    fs::write(vault.member_key_path(&new_id), &wrapped)
        .context("write member key blob")?;

    members.members.push(OrgMember {
        member_id: new_id.clone(),
        display_name: name.to_string(),
        role,
        ed25519_pubkey: pubkey.trim().to_string(),
        collections: vec![],
        added_at: now,
        added_by: caller.member_id.clone(),
    });
    vault.save_members(&members)?;

    let commit_msg = format!(
        "org: add member \"{name}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: member-add\nRelicario-Member: {}",
        caller.display_name, caller.member_id.as_str(), new_id.as_str()
    );
    crate::org_session::org_git_run(
        &vault.root,
        &["add", "members.json", &format!("keys/{}.enc", new_id.as_str())],
        "git add",
    )?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;

    println!("Added {} ({})", name, new_id.as_str());
    Ok(())
}
```

> `role: OrgRole` is the existing parameter; `OrgRole` derives `Copy`, so `matches!(role, ...)` does not move it.

- [ ] **Step 3: Implement remove-member**

```rust
pub fn run_remove_member(dir: &Path, member_id_prefix: &str) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    if !caller.role.can_manage_members() {
        anyhow::bail!("only owners and admins can remove members");
    }

    let mut members = vault.load_members()?;
    let target_id = resolve_member_id(&members, member_id_prefix)?;

    let target = members.find_by_id(&target_id).unwrap();
    if target.role == OrgRole::Owner && !caller.role.can_manage_owners() {
        anyhow::bail!("only owners can remove other owners");
    }
    let target_name = target.display_name.clone();

    // Delete key blob
    let key_path = vault.member_key_path(&target_id);
    if key_path.exists() { fs::remove_file(&key_path).context("delete key blob")?; }

    members.members.retain(|m| m.member_id != target_id);
    vault.save_members(&members)?;

    let commit_msg = format!(
        "org: remove member \"{target_name}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: member-remove\nRelicario-Member: {}",
        caller.display_name, caller.member_id.as_str(), target_id.as_str()
    );
    crate::org_session::org_git_run(
        &vault.root,
        &["add", "members.json", &format!("keys/{}.enc", target_id.as_str())],
        "git add",
    )?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;

    eprintln!("⚠ Run `relicario org rotate-key --dir {}` to complete revocation.", vault.root.display());
    println!("Removed {}", target_name);
    Ok(())
}
```

- [ ] **Step 4: Implement set-role**

```rust
pub fn run_set_role(dir: &Path, member_id_prefix: &str, role: OrgRole) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;

    let mut members = vault.load_members()?;
    let target_id = resolve_member_id(&members, member_id_prefix)?;

    if matches!(role, OrgRole::Admin | OrgRole::Owner) && !caller.role.can_manage_owners() {
        anyhow::bail!("only owners can promote to admin or owner");
    }
    if !caller.role.can_manage_members() {
        anyhow::bail!("only owners and admins can change roles");
    }

    let target = members.find_by_id_mut(&target_id)
        .ok_or_else(|| anyhow::anyhow!("member not found"))?;
    let old_role = target.role;
    target.role = role;
    vault.save_members(&members)?;

    let commit_msg = format!(
        "org: set role {} → {:?}\n\nRelicario-Actor: {} {}\nRelicario-Action: member-role-change\nRelicario-Member: {}",
        target_id.as_str(), role,
        caller.display_name, caller.member_id.as_str(),
        target_id.as_str()
    );
    crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;

    println!("Changed role {:?} → {:?}", old_role, role);
    Ok(())
}

/// Resolve a member_id prefix (or full ID) to a MemberId.
fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result<MemberId> {
    let hits: Vec<_> = members.members.iter()
        .filter(|m| m.member_id.as_str().starts_with(prefix))
        .collect();
    match hits.len() {
        0 => anyhow::bail!("no member matches `{prefix}`"),
        1 => Ok(hits[0].member_id.clone()),
        _ => anyhow::bail!("ambiguous prefix `{prefix}` — {} matches", hits.len()),
    }
}
```

- [ ] **Step 5: Compile + commit**

```bash
cargo build -p relicario-cli 2>&1 | tail -10
git add crates/relicario-cli/src/commands/org.rs
git commit -m "feat(cli/org): add-member (owner-only escalation guard), remove-member, set-role"
```

---

## [Dev-B] Task B6: `org create-collection` / `grant` / `revoke`

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

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

Add to the `tests` block in `commands/org.rs`:

```rust
#[test]
fn grant_adds_slug_to_member_collections() {
    let mut members = OrgMembers::new();
    let a = alice();
    let id = a.member_id.clone();
    members.members.push(a);

    let m = members.find_by_id_mut(&id).unwrap();
    if !m.collections.contains(&"prod".to_string()) {
        m.collections.push("prod".to_string());
    }
    assert!(members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string()));
}

#[test]
fn revoke_removes_slug_from_member_collections() {
    let mut members = OrgMembers::new();
    let mut a = alice();
    a.collections = vec!["prod".into(), "dev".into()];
    let id = a.member_id.clone();
    members.members.push(a);

    let m = members.find_by_id_mut(&id).unwrap();
    m.collections.retain(|s| s != "prod");
    assert!(!members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string()));
    assert!(members.find_by_id(&id).unwrap().collections.contains(&"dev".to_string()));
}
```

```bash
cargo test -p relicario-cli commands::org::tests 2>&1 | tail -5
```

Expected: all pass.

- [ ] **Step 2: Implement create-collection**

```rust
pub fn run_create_collection(dir: &Path, slug: &str, display_name: &str) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    if !caller.role.can_manage_members() {
        anyhow::bail!("only owners and admins can create collections");
    }

    let mut collections = vault.load_collections()?;
    if collections.contains_slug(slug) {
        anyhow::bail!("collection `{slug}` already exists");
    }
    if slug.is_empty() || slug.contains('/') || slug.contains('.') {
        anyhow::bail!("invalid slug `{slug}` — no slashes or dots, no empty string");
    }

    collections.collections.push(CollectionDef {
        slug: slug.to_string(),
        display_name: display_name.to_string(),
        created_by: caller.member_id.clone(),
        created_at: relicario_core::now_unix(),
    });
    vault.save_collections(&collections)?;

    let commit_msg = format!(
        "org: create collection \"{slug}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-create\nRelicario-Collection: {slug}",
        caller.display_name, caller.member_id.as_str()
    );
    crate::org_session::org_git_run(&vault.root, &["add", "collections.json"], "git add")?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;

    println!("Created collection `{slug}`");
    Ok(())
}
```

- [ ] **Step 3: Implement grant**

```rust
pub fn run_grant(dir: &Path, member_id_prefix: &str, slug: &str) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    if !caller.role.can_manage_members() {
        anyhow::bail!("only owners and admins can grant collection access");
    }

    let collections = vault.load_collections()?;
    if !collections.contains_slug(slug) {
        anyhow::bail!("collection `{slug}` does not exist — create it first");
    }

    let mut members = vault.load_members()?;
    let target_id = resolve_member_id(&members, member_id_prefix)?;
    let target = members.find_by_id_mut(&target_id).unwrap();
    if target.collections.contains(&slug.to_string()) {
        anyhow::bail!("member already has access to `{slug}`");
    }
    target.collections.push(slug.to_string());
    vault.save_members(&members)?;

    let commit_msg = format!(
        "org: grant {slug} to {}\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-grant\nRelicario-Collection: {slug}\nRelicario-Member: {}",
        target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str()
    );
    crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;

    println!("Granted `{slug}` to {}", target_id.as_str());
    Ok(())
}
```

- [ ] **Step 4: Implement revoke**

```rust
pub fn run_revoke(dir: &Path, member_id_prefix: &str, slug: &str) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    if !caller.role.can_manage_members() {
        anyhow::bail!("only owners and admins can revoke collection access");
    }

    let mut members = vault.load_members()?;
    let target_id = resolve_member_id(&members, member_id_prefix)?;
    let target = members.find_by_id_mut(&target_id).unwrap();
    if !target.collections.contains(&slug.to_string()) {
        anyhow::bail!("member does not have access to `{slug}`");
    }
    target.collections.retain(|s| s != slug);
    vault.save_members(&members)?;

    let commit_msg = format!(
        "org: revoke {slug} from {}\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-revoke\nRelicario-Collection: {slug}\nRelicario-Member: {}",
        target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str()
    );
    crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;

    println!("Revoked `{slug}` from {}", target_id.as_str());
    Ok(())
}
```

- [ ] **Step 5: Compile + commit**

```bash
cargo build -p relicario-cli 2>&1 | tail -5
git add crates/relicario-cli/src/commands/org.rs
git commit -m "feat(cli/org): create-collection, grant, revoke commands"
```

---

## [Dev-B] Task B7: `org rotate-key` (re-encrypt every item blob + race abort)

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

`rotate-key` generates a new org master key, re-wraps it for all current members, **re-encrypts every item blob AND the manifest** under the new key, and commits. Two corrections from the review: **(H9)** the old code re-encrypted only `manifest.enc`, leaving every `items/<slug>/<id>.enc` under the *old* key — a removed member with a clone could still decrypt all pre-rotation items; we now walk and re-encrypt every blob. **(race)** the old pull-failure handler swallowed *all* errors (including a genuine non-fast-forward / concurrent rotation) and proceeded; we now abort on a real conflict with the exact spec string while still distinguishing a missing remote.

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

Add to tests block:

```rust
#[test]
fn new_key_differs_from_old_key() {
    let k1 = relicario_core::generate_org_key();
    let k2 = relicario_core::generate_org_key();
    assert_ne!(*k1, *k2);
}
```

```bash
cargo test -p relicario-cli commands::org::tests::new_key_differs 2>&1 | tail -5
```

Expected: PASS.

- [ ] **Step 2: Implement rotate-key**

```rust
pub fn run_rotate_key(dir: &Path) -> Result<()> {
    // Pull latest state first to detect a concurrent rotation. We must
    // distinguish three outcomes:
    //   * success                       -> proceed
    //   * no upstream / no remote        -> local-only org, proceed
    //   * non-fast-forward / conflict    -> concurrent rotation, ABORT
    let pull = std::process::Command::new("git")
        .current_dir(dir)
        .args(["pull", "--rebase"])
        .output()
        .context("spawn git pull --rebase")?;
    if !pull.status.success() {
        let stderr = String::from_utf8_lossy(&pull.stderr);
        let no_upstream = stderr.contains("no tracking information")
            || stderr.contains("There is no tracking information")
            || stderr.contains("does not appear to be a git repository")
            || stderr.contains("Could not read from remote repository")
            || stderr.contains("No remote repository specified");
        if no_upstream {
            eprintln!("Note: no upstream configured; proceeding with local state.");
        } else {
            // Best-effort: leave the working tree clean for the retry.
            let _ = std::process::Command::new("git")
                .current_dir(dir)
                .args(["rebase", "--abort"])
                .output();
            anyhow::bail!(
                "Concurrent key rotation detected — pull and re-run org rotate-key."
            );
        }
    }

    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    if !caller.role.can_manage_owners() {
        anyhow::bail!("only owners can rotate the org master key");
    }

    let members = vault.load_members()?;
    let new_org_key = relicario_core::generate_org_key();

    // Re-wrap the org key for every current member.
    let mut staged_paths: Vec<String> = Vec::new();
    for member in &members.members {
        let wrapped = wrap_org_key(&new_org_key, &member.ed25519_pubkey)
            .with_context(|| format!("wrap key for {}", member.display_name))?;
        let key_path = vault.member_key_path(&member.member_id);
        fs::write(&key_path, &wrapped)
            .with_context(|| format!("write key for {}", member.display_name))?;
        staged_paths.push(format!("keys/{}.enc", member.member_id.as_str()));
    }

    // Re-encrypt EVERY item blob under the new key. Items live collection-scoped
    // at items/<collection-slug>/<id>.enc. Decrypt with the old key (held in the
    // open vault session) and re-encrypt with the new one, in place. Without this
    // a removed member who kept the old key + a clone could still decrypt every
    // pre-rotation item.
    let items_root = vault.root().join("items");
    if items_root.is_dir() {
        for slug_entry in fs::read_dir(&items_root).context("read items/")? {
            let slug_entry = slug_entry.context("read items/ entry")?;
            let slug_dir = slug_entry.path();
            if !slug_dir.is_dir() {
                continue;
            }
            let slug = slug_entry.file_name().to_string_lossy().to_string();
            for item_entry in fs::read_dir(&slug_dir)
                .with_context(|| format!("read items/{slug}/"))?
            {
                let item_entry = item_entry.context("read item entry")?;
                let item_path = item_entry.path();
                if item_path.extension().and_then(|e| e.to_str()) != Some("enc") {
                    continue;
                }
                let old_bytes = fs::read(&item_path)
                    .with_context(|| format!("read {}", item_path.display()))?;
                let item = relicario_core::decrypt_item(&old_bytes, vault.key())
                    .with_context(|| format!("decrypt {}", item_path.display()))?;
                let new_bytes = relicario_core::encrypt_item(&item, &new_org_key)
                    .with_context(|| format!("re-encrypt {}", item_path.display()))?;
                crate::org_session::atomic_write(&item_path, &new_bytes)?;
                let file_name = item_entry.file_name().to_string_lossy().to_string();
                staged_paths.push(format!("items/{slug}/{file_name}"));
            }
        }
    }

    // Re-encrypt the manifest with the new key.
    let manifest = vault.load_manifest()?;
    let new_manifest_bytes = relicario_core::encrypt_org_manifest(&manifest, &new_org_key)?;
    crate::org_session::atomic_write(&vault.manifest_path(), &new_manifest_bytes)?;
    staged_paths.push("manifest.enc".to_string());

    // Commit
    let mut add_args = vec!["add"];
    let path_refs: Vec<&str> = staged_paths.iter().map(|s| s.as_str()).collect();
    add_args.extend_from_slice(&path_refs);
    crate::org_session::org_git_run(&vault.root, &add_args, "git add")?;

    let commit_msg = format!(
        "org: rotate org master key\n\nRelicario-Actor: {} {}\nRelicario-Action: key-rotate",
        caller.display_name, caller.member_id.as_str()
    );
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;

    println!(
        "Key rotated. {} member key(s) re-wrapped; all item blobs + manifest re-encrypted.",
        members.members.len()
    );
    Ok(())
}
```

> **Verified:** `decrypt_item(&[u8], &Zeroizing<[u8;32]>) -> Result<Item>` and `encrypt_item(&Item, &Zeroizing<[u8;32]>) -> Result<Vec<u8>>` (re-exported at core lib.rs:81–82). `vault.key()` returns the OLD key; `vault.root()` returns `&PathBuf`. The `git add` step stages `staged_paths`, now including every `items/<slug>/<file>.enc`.

- [ ] **Step 3: Build + commit**

```bash
cargo build -p relicario-cli 2>&1 | tail -5
git add crates/relicario-cli/src/commands/org.rs
git commit -m "feat(cli/org): rotate-key — re-encrypt every item blob + abort on concurrent rotation"
```

---

## [Dev-B] Task B8: `org status` + `org audit` (verified-signer attribution + TAMPERED flag)

**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs`
- Modify: `crates/relicario-cli/Cargo.toml` (ensure `regex` + `tempfile` in `[dependencies]`)

`status` prints members + collections with no decryption. `audit` parses `git log`, **resolves each commit's verified signer** to a member and reports *that* as the actor (trailers are advisory), flags trailer/signer mismatch as `TAMPERED`, and frames records with `%x1e`/`%x1f` (so multi-line trailer values cannot misalign records) using the **committer** date (`%cI`).

> **Dependency note:** `run_audit`/`resolve_signer` need `regex` and `tempfile` available to `relicario-cli` at runtime (not just dev). Add to `crates/relicario-cli/Cargo.toml` `[dependencies]` if absent: `regex = "1"` and `tempfile = "3"` (both already in the workspace lock via relicario-server).

- [ ] **Step 1: Implement status**

```rust
pub fn run_status(dir: &Path) -> Result<()> {
    let root = crate::org_session::org_dir(Some(dir))?;

    let meta: relicario_core::OrgMeta = {
        let s = fs::read_to_string(root.join("org.json")).context("read org.json")?;
        serde_json::from_str(&s)?
    };
    let members: OrgMembers = {
        let s = fs::read_to_string(root.join("members.json")).context("read members.json")?;
        serde_json::from_str(&s)?
    };
    let collections: OrgCollections = {
        let s = fs::read_to_string(root.join("collections.json")).context("read collections.json")?;
        serde_json::from_str(&s)?
    };

    println!("Org: {} ({})", meta.display_name, meta.org_id.as_str());
    println!();
    println!("Members ({}):", members.members.len());
    for m in &members.members {
        let colls = if m.collections.is_empty() {
            "(no collections)".to_string()
        } else {
            m.collections.join(", ")
        };
        println!("  {:?}  {}  {}  [{}]", m.role, m.member_id.as_str(), m.display_name, colls);
    }
    println!();
    println!("Collections ({}):", collections.collections.len());
    for c in &collections.collections {
        println!("  {}  —  {}", c.slug, c.display_name);
    }
    Ok(())
}
```

- [ ] **Step 2: Write failing test for audit trailer parsing**

Add to tests block:

```rust
#[test]
fn parse_trailers_extracts_relicario_fields() {
    // Contract trailer shape: "Relicario-Actor: <name> <member_id>".
    let raw = "Relicario-Actor: alice a1b2c3d4e5f6a1b2\nRelicario-Action: item-create\nRelicario-Collection: prod\n";
    let event = parse_trailer_block("abc123", "2026-06-06T12:00:00+00:00", raw);
    assert_eq!(event.action.as_deref(), Some("item-create"));
    assert_eq!(event.collection.as_deref(), Some("prod"));
    // The verified actor_id is resolved later from the signature, not the trailer;
    // the trailer only populates trailer_actor_id here.
    assert_eq!(event.trailer_actor_id.as_deref(), Some("a1b2c3d4e5f6a1b2"));
    assert_eq!(event.actor_id, None);
    assert!(!event.tampered);
}
```

```bash
cargo test -p relicario-cli commands::org::tests::parse_trailers 2>&1 | tail -5
```

Expected: FAIL — `parse_trailer_block` not defined.

- [ ] **Step 3: Implement audit (struct, trailer parser, signer resolver, run_audit)**

```rust
#[derive(Debug, serde::Serialize)]
pub struct AuditEvent {
    pub commit: String,
    pub timestamp: String,
    /// Actor as resolved from the VERIFIED signing key (authoritative).
    pub actor_name: Option<String>,
    pub actor_id: Option<String>,
    /// Actor id as CLAIMED by the commit trailer (advisory; for tamper-checking).
    pub trailer_actor_id: Option<String>,
    pub action: Option<String>,
    pub collection: Option<String>,
    pub item_id: Option<String>,
    pub device_id: Option<String>,
    /// True when the trailer's claimed actor disagrees with the verified signer,
    /// or when no current member matches the signing key.
    pub tampered: bool,
}

fn parse_trailer_block(commit: &str, timestamp: &str, trailers: &str) -> AuditEvent {
    let mut ev = AuditEvent {
        commit: commit.to_string(),
        timestamp: timestamp.to_string(),
        actor_name: None,
        actor_id: None,
        trailer_actor_id: None,
        action: None,
        collection: None,
        item_id: None,
        device_id: None,
        tampered: false,
    };
    for line in trailers.lines() {
        let line = line.trim();
        if let Some(rest) = line.strip_prefix("Relicario-Actor:") {
            // Contract format: "<name> <member_id>" (member_id is the last token).
            let rest = rest.trim();
            if let Some((_name, id)) = rest.rsplit_once(' ') {
                ev.trailer_actor_id = Some(id.trim().to_string());
            } else if !rest.is_empty() {
                ev.trailer_actor_id = Some(rest.to_string());
            }
        } else if let Some(v) = line.strip_prefix("Relicario-Action:") {
            ev.action = Some(v.trim().to_string());
        } else if let Some(v) = line.strip_prefix("Relicario-Collection:") {
            ev.collection = Some(v.trim().to_string());
        } else if let Some(v) = line.strip_prefix("Relicario-Item:") {
            ev.item_id = Some(v.trim().to_string());
        } else if let Some(v) = line.strip_prefix("Relicario-Device:") {
            ev.device_id = Some(v.trim().to_string());
        }
    }
    ev
}

/// Resolve a commit's SSH signature fingerprint to a current member, mirroring
/// the pre-receive hook: build an allowed_signers from members.json, inject it
/// via GIT_CONFIG_*, run `git verify-commit --raw`, parse the SHA256: key from
/// stderr. Returns None if the commit is unsigned or the signer is not a member.
fn resolve_signer<'m>(
    root: &Path,
    commit: &str,
    members: &'m relicario_core::OrgMembers,
) -> Option<&'m relicario_core::OrgMember> {
    use std::io::Write;
    let mut tmp = tempfile::NamedTempFile::new().ok()?;
    for m in &members.members {
        let _ = writeln!(tmp, "relicario {}", m.ed25519_pubkey.trim());
    }
    let allowed_path = tmp.path();

    let output = std::process::Command::new("git")
        .current_dir(root)
        .args(["verify-commit", "--raw", commit])
        .env("GIT_CONFIG_COUNT", "1")
        .env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
        .env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
        .output()
        .ok()?;
    let stderr = String::from_utf8_lossy(&output.stderr);

    let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").ok()?;
    let fp = re.captures(&stderr)?.get(1)?.as_str().to_string();

    members.members.iter().find(|m| {
        relicario_core::fingerprint(&m.ed25519_pubkey).ok().as_deref() == Some(fp.as_str())
    })
}

pub fn run_audit(
    dir: &Path,
    since: Option<&str>,
    member_filter: Option<&str>,
    collection_filter: Option<&str>,
    action_filter: Option<&str>,
    format: &str,
) -> Result<()> {
    // Spec surface is `--format <table|json>` (default table). Accept only those.
    let json = match format {
        "json" => true,
        "table" => false,
        other => anyhow::bail!("unknown --format `{other}` — use table or json"),
    };
    let root = crate::org_session::org_dir(Some(dir))?;

    // members.json — needed to resolve each commit's verified signer to a member.
    let members: relicario_core::OrgMembers = {
        let s = fs::read_to_string(root.join("members.json")).context("read members.json")?;
        serde_json::from_str(&s).context("parse members.json")?
    };

    // git log framed with a record separator (%x1e, U+001E) PER COMMIT and a
    // field separator (%x1f, U+001F) between fields, so multi-line trailer
    // values cannot misalign record boundaries. Committer date (%cI), not
    // author date: it is what revocation/audit is anchored to.
    let fmt = "%x1e%H%x1f%cI%x1f%(trailers:only=true,unfold=true)";
    let mut args: Vec<String> = vec!["log".into(), format!("--format={fmt}")];
    if let Some(s) = since {
        args.push(format!("--since={s}"));
    }

    let output = std::process::Command::new("git")
        .current_dir(&root)
        .args(&args)
        .output()
        .context("git log")?;
    let log = String::from_utf8_lossy(&output.stdout);

    let mut events: Vec<AuditEvent> = Vec::new();
    for record in log.split('\u{1e}') {
        let record = record.trim_start_matches('\n');
        if record.trim().is_empty() {
            continue;
        }
        let mut fields = record.splitn(3, '\u{1f}');
        let commit = fields.next().unwrap_or("").trim();
        let ts = fields.next().unwrap_or("").trim();
        let trailers = fields.next().unwrap_or("");
        if commit.is_empty() {
            continue;
        }

        let mut ev = parse_trailer_block(commit, ts, trailers);
        if ev.action.is_none() {
            continue; // not an org commit
        }

        // Resolve the VERIFIED signer and attribute it as the authoritative actor.
        match resolve_signer(&root, commit, &members) {
            Some(m) => {
                ev.actor_name = Some(m.display_name.clone());
                ev.actor_id = Some(m.member_id.as_str().to_string());
                // Tampered if the trailer claims a different actor than the signer.
                if let Some(claimed) = ev.trailer_actor_id.as_deref() {
                    if claimed != m.member_id.as_str() {
                        ev.tampered = true;
                    }
                }
            }
            None => {
                // No current member matched the signature -> cannot trust the
                // trailer's claimed actor.
                ev.tampered = true;
            }
        }

        if let Some(mid) = member_filter {
            // Filter on the VERIFIED actor id, not the spoofable trailer.
            if ev.actor_id.as_deref() != Some(mid) {
                continue;
            }
        }
        if let Some(col) = collection_filter {
            if ev.collection.as_deref() != Some(col) {
                continue;
            }
        }
        if let Some(act) = action_filter {
            if ev.action.as_deref() != Some(act) {
                continue;
            }
        }
        events.push(ev);
    }

    if json {
        println!("{}", serde_json::to_string_pretty(&events)?);
    } else {
        println!("{:<44} {:<26} {:<20} {:<18} {}", "COMMIT", "TIMESTAMP", "ACTION", "ACTOR", "FLAG");
        for ev in &events {
            println!("{:<44} {:<26} {:<20} {:<18} {}",
                ev.commit,
                ev.timestamp,
                ev.action.as_deref().unwrap_or("-"),
                ev.actor_name.as_deref().unwrap_or("<unverified>"),
                if ev.tampered { "TAMPERED" } else { "" },
            );
        }
    }
    Ok(())
}
```

> **Verified:** mirrors `relicario-server`'s `verify_commit` exactly (temp allowed_signers prefixed `relicario `, `GIT_CONFIG_COUNT/KEY_0/VALUE_0 = gpg.ssh.allowedSignersFile`, `git verify-commit --raw`, regex `key (SHA256:[A-Za-z0-9+/]+)` over stderr, fingerprint match via `relicario_core::fingerprint`). git 2.54.0 supports `%(trailers:only=true,unfold=true)` and `%x1e`/`%x1f`; `%x1e` prefixes each record so `split('\u{1e}')` yields a leading empty element (filtered by the empty-record guard). `%cI` emits strict ISO 8601 with offset.

- [ ] **Step 4: Run the trailer test + build**

```bash
cargo test -p relicario-cli commands::org::tests::parse_trailers 2>&1 | tail -5
cargo build -p relicario-cli 2>&1 | tail -5
```

Expected: PASS; clean build.

- [ ] **Step 5: Commit**

```bash
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/Cargo.toml Cargo.lock
git commit -m "feat(cli/org): status + audit (verified-signer attribution, TAMPERED flag, committer-date framing)"
```

---

## [Dev-B] Task B9: Confirm collection-scoped item helpers on `UnlockedOrgVault`

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

> **Reconciliation note:** The collection-scoped `item_path(collection_slug, id)` and the item I/O helpers (`save_item`, `load_item`, `remove_item`, `ensure_grant`) were already defined in **Task B1** to avoid a duplicate `item_path` definition. This task is the checkpoint that those helpers exist with the exact signatures the item commands (B10–B13) consume. It adds **no new `item_path` definition** — there is exactly ONE, in B1.

- [ ] **Step 1: Verify the helper surface exists**

```bash
grep -n "pub fn item_path\|pub fn save_item\|pub fn load_item\|pub fn remove_item\|pub fn ensure_grant" crates/relicario-cli/src/org_session.rs
```

Expected: each appears exactly once. `item_path(&self, collection_slug: &str, id: &ItemId)`, `save_item(&self, collection_slug, &Item) -> Result<String>`, `load_item(&self, collection_slug, &ItemId) -> Result<Item>`, `remove_item(&self, collection_slug, &ItemId) -> Result<()>`, `ensure_grant(member: &OrgMember, slug: &str) -> Result<()>`.

```bash
grep -rn "item_path" crates/relicario-cli/src/ | grep -v "collection_slug"
```

Expected: the only non-`collection_slug` matches are the personal-vault `session.rs` (a different type — leave it alone) and the B1 doc-comment lines. No flat `items/<id>.enc` call sites.

- [ ] **Step 2: Compile check**

```bash
cargo build -p relicario-cli 2>&1 | tail -15
```

Expected: clean build (unused-helper warnings are fine — B10–B13 consume them in the same PR series; do NOT add `#[allow(dead_code)]`).

> No commit for this task — it is a verification gate. If a helper is missing, fix it in B1's file and amend B1's commit (or add a follow-up commit) before proceeding.

---

## [Dev-B] Task B10: `org add` — create a typed item in a collection

**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs`
- Modify: `crates/relicario-cli/src/main.rs`
- Create: `crates/relicario-cli/tests/org_items.rs`

`run_add` mirrors the personal-vault `cmd_add`: build a typed `Item`, write the encrypted blob (collection-scoped), upsert the manifest entry, re-encrypt the manifest, commit with structured trailers. The caller's grant is enforced and the collection must exist. Supports Login, SecureNote, and Identity (the three non-interactive builders); Card/Key/Document/Totp parity is deferred (see the follow-up note after B13).

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

Create `crates/relicario-cli/tests/org_items.rs`. The harness seeds a device signing key under a tempdir `XDG_CONFIG_HOME`, runs `org init` to create the owner + wrap the org key, then drives `org add` / `org get` / `org list`.

```rust
mod common;

use assert_cmd::cargo::CommandCargoExt as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use tempfile::TempDir;

/// A throwaway org vault with a device signing key wired via XDG_CONFIG_HOME.
struct OrgFixture {
    _config: TempDir,
    vault: TempDir,
    xdg: PathBuf,
}

impl OrgFixture {
    /// Generate an ed25519 signing key in OpenSSH format using ssh-keygen and
    /// register it as the current device, then `org init`.
    fn new() -> Self {
        let config = TempDir::new().unwrap();
        let xdg = config.path().to_path_buf();
        let devices = xdg.join("relicario").join("devices").join("laptop");
        std::fs::create_dir_all(&devices).unwrap();

        // Generate an OpenSSH ed25519 keypair without a passphrase.
        let keyfile = devices.join("signing.key");
        let status = Command::new("ssh-keygen")
            .args(["-t", "ed25519", "-N", "", "-C", "relicario-test", "-f"])
            .arg(&keyfile)
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .expect("ssh-keygen");
        assert!(status.success(), "ssh-keygen failed");
        // ssh-keygen writes signing.key + signing.key.pub; rename the .pub to signing.pub.
        std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap();
        // Mark this device current.
        std::fs::write(
            xdg.join("relicario").join("devices").join("current"),
            "laptop\n",
        )
        .unwrap();

        let vault = TempDir::new().unwrap();
        let f = OrgFixture { _config: config, vault, xdg };

        let out = f.run(&["org", "init", "--dir", f.vault_str(), "--name", "Acme"]);
        assert!(out.status.success(), "org init failed: {}", String::from_utf8_lossy(&out.stderr));
        f
    }

    fn vault_path(&self) -> &Path { self.vault.path() }
    fn vault_str(&self) -> &str { self.vault.path().to_str().unwrap() }

    fn run(&self, args: &[&str]) -> std::process::Output {
        let mut cmd = Command::cargo_bin("relicario").unwrap();
        cmd.env("XDG_CONFIG_HOME", &self.xdg)
            .env("RELICARIO_ORG_DIR", self.vault.path())
            .args(args)
            .stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        cmd.output().unwrap()
    }

    /// Owner member id printed by `org init`/`org status`. We read it from
    /// members.json directly to avoid parsing stdout.
    fn owner_member_id(&self) -> String {
        let s = std::fs::read_to_string(self.vault.path().join("members.json")).unwrap();
        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
        v["members"][0]["member_id"].as_str().unwrap().to_string()
    }
}

#[test]
fn org_add_get_list_round_trip() {
    let f = OrgFixture::new();
    let owner = f.owner_member_id();

    // Create a collection and grant the owner access to it.
    let out = f.run(&["org", "create-collection", "prod", "--name", "Production"]);
    assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr));
    let out = f.run(&["org", "grant", &owner, "prod"]);
    assert!(out.status.success(), "grant: {}", String::from_utf8_lossy(&out.stderr));

    // Add a login into the prod collection.
    let out = f.run(&[
        "org", "add", "login", "--collection", "prod",
        "--title", "GitHub", "--username", "alice",
        "--url", "https://github.com", "--password", "hunter2",
    ]);
    assert!(out.status.success(), "org add: {}", String::from_utf8_lossy(&out.stderr));

    // The blob must live under items/prod/, NOT flat items/.
    let prod_dir = f.vault_path().join("items").join("prod");
    let blobs: Vec<_> = std::fs::read_dir(&prod_dir).unwrap().collect();
    assert_eq!(blobs.len(), 1, "expected one blob under items/prod/");

    // list shows it.
    let out = f.run(&["org", "list"]);
    let stdout = String::from_utf8_lossy(&out.stdout).to_string();
    assert!(stdout.contains("GitHub"), "list missing GitHub: {stdout}");

    // get masks by default.
    let out = f.run(&["org", "get", "GitHub"]);
    let stdout = String::from_utf8_lossy(&out.stdout).to_string();
    assert!(stdout.contains("********"), "expected masked secret: {stdout}");
    assert!(!stdout.contains("hunter2"), "leaked plaintext: {stdout}");

    // get --show reveals.
    let out = f.run(&["org", "get", "GitHub", "--show"]);
    let stdout = String::from_utf8_lossy(&out.stdout).to_string();
    assert!(stdout.contains("hunter2"), "expected plaintext with --show: {stdout}");

    // The commit trailer records the action + collection + item.
    let log = Command::new("git")
        .args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
        .output()
        .unwrap();
    let body = String::from_utf8_lossy(&log.stdout).to_string();
    assert!(body.contains("Relicario-Action: item-create"), "missing action trailer: {body}");
    assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}");
    assert!(body.contains("Relicario-Item: "), "missing item trailer: {body}");
}

#[test]
fn org_add_rejects_ungranted_collection() {
    let f = OrgFixture::new();
    // Create the collection but do NOT grant the owner.
    let out = f.run(&["org", "create-collection", "secret", "--name", "Secret"]);
    assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr));

    let out = f.run(&[
        "org", "add", "login", "--collection", "secret",
        "--title", "X", "--username", "u", "--password", "p",
    ]);
    assert!(!out.status.success(), "add into ungranted collection must fail");
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(stderr.contains("access denied") || stderr.contains("grant"), "unexpected error: {stderr}");
}

#[test]
fn org_add_rejects_unknown_collection() {
    let f = OrgFixture::new();
    let out = f.run(&[
        "org", "add", "login", "--collection", "ghost",
        "--title", "X", "--username", "u", "--password", "p",
    ]);
    assert!(!out.status.success(), "add into nonexistent collection must fail");
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(stderr.contains("does not exist") || stderr.contains("ghost"), "unexpected error: {stderr}");
}
```

```bash
cargo test -p relicario-cli --test org_items 2>&1 | tail -20
```

Expected: FAIL to compile (`org add` subcommand + `run_add` not defined yet).

> **Note:** This test requires `ssh-keygen` on `PATH`. `mod common;` reuses `tests/common/mod.rs` for `Command::cargo_bin`-style ergonomics via `assert_cmd` (already a dev-dependency via `basic_flows.rs`).

- [ ] **Step 2: Implement `run_add` and the three item builders in `commands/org.rs`**

Append after the existing command fns:

```rust
use relicario_core::{Item, ItemCore, ItemId};

/// Item kinds `org add` supports without interactive prompts.
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>,
    },
}

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

pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> {
    use crate::org_session::UnlockedOrgVault;

    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;

    // 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 (collection slug stored plaintext inside the
    // encrypted 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(())
}

/// Insert-or-replace an `OrgManifestEntry` mirroring the personal-vault
/// `Manifest::upsert`. Keyed by item id.
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);
    }
}
```

> **Note:** Keep exactly one set of imports for `Item, ItemCore, ItemId`. If an earlier task already imported them at module scope, drop the `use relicario_core::{Item, ItemCore, ItemId};` line here. Do not import `encrypt_org_manifest` — `vault.save_manifest` already wraps it.

- [ ] **Step 3: Add the `Add` variant to `OrgCommands` and dispatch (extends B14)**

In `crates/relicario-cli/src/main.rs`, add to the `OrgCommands` enum:

```rust
    /// Add an item to a collection in the org vault.
    Add {
        #[command(subcommand)]
        kind: OrgAddKind,
    },
```

Add a top-level clap subcommand enum next to `OrgCommands`:

```rust
#[derive(Subcommand)]
pub(crate) enum OrgAddKind {
    /// A login (username / url / password).
    Login {
        #[arg(long)] collection: String,
        #[arg(long)] title: String,
        #[arg(long)] username: Option<String>,
        #[arg(long)] url: Option<String>,
        #[arg(long)] password: Option<String>,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
    },
    /// A secure note.
    SecureNote {
        #[arg(long)] collection: String,
        #[arg(long)] title: String,
        #[arg(long)] body: String,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
    },
    /// An identity record.
    Identity {
        #[arg(long)] collection: String,
        #[arg(long)] title: String,
        #[arg(long)] full_name: Option<String>,
        #[arg(long)] email: Option<String>,
        #[arg(long)] phone: Option<String>,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
    },
}
```

In the `Commands::Org { dir, subcommand }` dispatch block, add an arm to the inner `match subcommand`:

```rust
        OrgCommands::Add { kind } => {
            let d = crate::org_session::org_dir(dir_path)?;
            let (collection, add_kind, tags) = match kind {
                OrgAddKind::Login { collection, title, username, url, password, tags } => (
                    collection,
                    commands::org::OrgAddKind::Login { title, username, url, password },
                    tags,
                ),
                OrgAddKind::SecureNote { collection, title, body, tags } => (
                    collection,
                    commands::org::OrgAddKind::SecureNote { title, body },
                    tags,
                ),
                OrgAddKind::Identity { collection, title, full_name, email, phone, tags } => (
                    collection,
                    commands::org::OrgAddKind::Identity { title, full_name, email, phone },
                    tags,
                ),
            };
            commands::org::run_add(&d, &collection, add_kind, tags)?;
        }
```

> **Note:** The clap-side `OrgAddKind` (in `main.rs`, with `collection`/`tags` per-variant) and the handler-side `commands::org::OrgAddKind` (no `collection`/`tags`) are deliberately two distinct enums so the handler stays unaware of clap. Do not merge them.

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

```bash
cargo test -p relicario-cli --test org_items org_add_rejects 2>&1 | tail -20
```

Expected: both guard tests PASS. (The full `org_add_get_list_round_trip` passes once B11 lands `get`/`list`.)

- [ ] **Step 5: Commit**

```bash
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs crates/relicario-cli/tests/org_items.rs
git commit -m "feat(cli/org): org add — collection-scoped typed item create with grant guard"
```

---

## [Dev-B] Task B11: `org get` + `org list`

**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs`
- Modify: `crates/relicario-cli/src/main.rs`

`run_get` mirrors `commands/get.rs` masking; `run_list` uses `OrgManifest::filter_for_member` so a member only ever sees entries in collections they hold a grant for. Both enforce that the caller has a grant for the target item's collection.

- [ ] **Step 1: Implement `run_list`**

Append to `commands/org.rs`:

```rust
pub fn run_list(dir: &Path, trashed: bool) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    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(())
}
```

- [ ] **Step 2: Implement `run_get` (query resolution + masking)**

Append to `commands/org.rs`. The query resolver runs over the caller-visible manifest only:

```rust
pub fn run_get(dir: &Path, query: &str, show: bool) -> Result<()> {
    use relicario_core::ItemCore;
    use zeroize::Zeroizing;

    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)?;
    // Double-check the grant for the resolved collection (defense in depth).
    crate::org_session::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(())
}

/// 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(", "))
        }
    }
}
```

- [ ] **Step 3: Add `Get` / `List` variants + dispatch (extends B14)**

In `main.rs` `OrgCommands` enum, add:

```rust
    /// Print an org item (secrets masked unless --show).
    Get {
        /// Item id or case-insensitive title substring.
        query: String,
        #[arg(long)] show: bool,
    },
    /// List org items visible to you (filtered by your collection grants).
    List {
        #[arg(long)] trashed: bool,
    },
```

In the `match subcommand` dispatch, add:

```rust
        OrgCommands::Get { query, show } => {
            let d = crate::org_session::org_dir(dir_path)?;
            commands::org::run_get(&d, &query, show)?;
        }
        OrgCommands::List { trashed } => {
            let d = crate::org_session::org_dir(dir_path)?;
            commands::org::run_list(&d, trashed)?;
        }
```

- [ ] **Step 4: Run the full add/get/list integration test**

```bash
cargo test -p relicario-cli --test org_items 2>&1 | tail -30
```

Expected: all three tests in `org_items.rs` now PASS.

- [ ] **Step 5: Commit**

```bash
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs
git commit -m "feat(cli/org): org get + list with per-member grant filtering"
```

---

## [Dev-B] Task B12: `org edit`

**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs`
- Modify: `crates/relicario-cli/src/main.rs`

`run_edit` mirrors `commands/edit.rs` for the three supported types but takes new values from flags. It enforces the caller's grant, re-saves the blob in place under `items/<slug>/`, upserts the manifest, and commits with `Relicario-Action: item-update`.

- [ ] **Step 1: Write a failing integration test (append to `tests/org_items.rs`)**

```rust
#[test]
fn org_edit_updates_fields_and_commits_update_trailer() {
    let f = OrgFixture::new();
    let owner = f.owner_member_id();
    assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
    assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
    assert!(f.run(&[
        "org", "add", "login", "--collection", "prod",
        "--title", "Mail", "--username", "old", "--password", "pw",
    ]).status.success());

    // Edit the username.
    let out = f.run(&[
        "org", "edit", "Mail", "--username", "new-user",
    ]);
    assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr));

    // get --show reflects the new username.
    let out = f.run(&["org", "get", "Mail", "--show"]);
    let stdout = String::from_utf8_lossy(&out.stdout).to_string();
    assert!(stdout.contains("new-user"), "edit did not take: {stdout}");

    let log = Command::new("git")
        .args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
        .output().unwrap();
    let body = String::from_utf8_lossy(&log.stdout).to_string();
    assert!(body.contains("Relicario-Action: item-update"), "missing update trailer: {body}");
    assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}");
}
```

```bash
cargo test -p relicario-cli --test org_items org_edit 2>&1 | tail -20
```

Expected: FAIL — `org edit` not defined.

- [ ] **Step 2: Implement `run_edit`**

Append to `commands/org.rs`:

```rust
pub fn run_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<()> {
    use relicario_core::time::now_unix;
    use relicario_core::ItemCore;
    use zeroize::Zeroizing;

    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)?;

    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(())
}
```

- [ ] **Step 3: Add `Edit` variant + dispatch (extends B14)**

In `main.rs` `OrgCommands`:

```rust
    /// Edit an org item's fields (flag-driven; blank flags keep current values).
    Edit {
        /// Item id or case-insensitive title substring.
        query: String,
        #[arg(long)] title: Option<String>,
        #[arg(long)] username: Option<String>,
        #[arg(long)] url: Option<String>,
        #[arg(long)] password: Option<String>,
        #[arg(long)] body: Option<String>,
        #[arg(long)] email: Option<String>,
        #[arg(long)] phone: Option<String>,
        #[arg(long)] full_name: Option<String>,
    },
```

Dispatch arm:

```rust
        OrgCommands::Edit { query, title, username, url, password, body, email, phone, full_name } => {
            let d = crate::org_session::org_dir(dir_path)?;
            commands::org::run_edit(&d, &query, title, username, url, password, body, email, phone, full_name)?;
        }
```

- [ ] **Step 4: Run the test**

```bash
cargo test -p relicario-cli --test org_items org_edit 2>&1 | tail -20
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs
git commit -m "feat(cli/org): org edit — flag-driven field update for login/note/identity"
```

---

## [Dev-B] Task B13: `org rm` / `org restore` / `org purge`

**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs`
- Modify: `crates/relicario-cli/src/main.rs`
- Create: `crates/relicario-cli/tests/org_lifecycle.rs` (L6 spec-mandated lifecycle tests)

Trash lifecycle mirrors `commands/trash.rs`: `rm` soft-deletes (sets `trashed_at`), `restore` clears it, `purge` permanently `git rm`s the blob and drops the manifest entry. All three enforce the caller's grant.

- [ ] **Step 1: Write a failing integration test (append to `tests/org_items.rs`)**

```rust
#[test]
fn org_rm_restore_purge_cycle() {
    let f = OrgFixture::new();
    let owner = f.owner_member_id();
    assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
    assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
    assert!(f.run(&[
        "org", "add", "secure-note", "--collection", "prod",
        "--title", "Recovery", "--body", "codes-here",
    ]).status.success());

    // rm → appears only with --trashed.
    assert!(f.run(&["org", "rm", "Recovery"]).status.success());
    let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string();
    assert!(!listed.contains("Recovery"), "trashed item still in default list: {listed}");
    let trashed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string();
    assert!(trashed.contains("Recovery"), "trashed item not in --trashed list: {trashed}");

    // restore → back in default list.
    assert!(f.run(&["org", "restore", "Recovery"]).status.success());
    let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string();
    assert!(listed.contains("Recovery"), "restore did not bring it back: {listed}");

    // purge → blob gone, entry gone, item-purge trailer.
    assert!(f.run(&["org", "purge", "Recovery"]).status.success());
    let prod_dir = f.vault_path().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 listed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string();
    assert!(!listed.contains("Recovery"), "purged item still listed: {listed}");

    let log = Command::new("git")
        .args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
        .output().unwrap();
    let body = String::from_utf8_lossy(&log.stdout).to_string();
    assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}");
}
```

```bash
cargo test -p relicario-cli --test org_items org_rm_restore_purge 2>&1 | tail -20
```

Expected: FAIL — `org rm`/`restore`/`purge` not defined.

- [ ] **Step 2: Implement `run_rm`, `run_restore`, `run_purge`**

Append to `commands/org.rs`:

```rust
/// Resolve a query to (collection, item) with grant enforcement. Used by the
/// trash-lifecycle commands.
fn open_org_item(
    vault: &crate::org_session::UnlockedOrgVault,
    caller: &relicario_core::OrgMember,
    query: &str,
) -> Result<(String, relicario_core::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();
    crate::org_session::UnlockedOrgVault::ensure_grant(caller, &collection)?;
    let item = vault.load_item(&collection, &id)?;
    Ok((collection, item))
}

pub fn run_rm(dir: &Path, query: &str) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    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(())
}

pub fn run_restore(dir: &Path, query: &str) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    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(())
}

pub fn run_purge(dir: &Path, query: &str) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    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(())
}
```

> **Note:** `git_rm` is `pub` in `crate::helpers` (helpers.rs:93, signature `git_rm(repo: &Path, paths: &[String], context: &str)`) and uses `git rm -rf --ignore-unmatch`, so a missing blob is tolerated. The grant guard runs *before* any deletion.

- [ ] **Step 3: Add `Rm` / `Restore` / `Purge` variants + dispatch (extends B14)**

In `main.rs` `OrgCommands`:

```rust
    /// Soft-delete an org item (reversible via `org restore`).
    Rm { query: String },
    /// Restore a soft-deleted org item.
    Restore { query: String },
    /// Permanently purge an org item (deletes the encrypted blob).
    Purge { query: String },
```

Dispatch arms:

```rust
        OrgCommands::Rm { query } => {
            let d = crate::org_session::org_dir(dir_path)?;
            commands::org::run_rm(&d, &query)?;
        }
        OrgCommands::Restore { query } => {
            let d = crate::org_session::org_dir(dir_path)?;
            commands::org::run_restore(&d, &query)?;
        }
        OrgCommands::Purge { query } => {
            let d = crate::org_session::org_dir(dir_path)?;
            commands::org::run_purge(&d, &query)?;
        }
```

- [ ] **Step 4: Run the trash test + the full org_items suite + workspace verify**

```bash
cargo test -p relicario-cli --test org_items 2>&1 | tail -30
cargo test 2>&1 | tail -25
```

Expected: all `org_items.rs` tests PASS; green across all crates. No `todo!`/`unimplemented!` left in the org item path.

- [ ] **Step 4b: Author the spec-required lifecycle integration tests (L6)**

The spec's Testing Strategy (CLI section) mandates four scenarios not yet
covered by `org_items.rs`. Create `crates/relicario-cli/tests/org_lifecycle.rs`
with a self-contained two-device fixture (it must be standalone — integration
test files are separate compilation units, so it cannot share `org_items.rs`'s
`OrgFixture`). Tests:

- (a) a **forged-trailer** commit is flagged `TAMPERED` by `org audit`;
- (b) `org audit --format json` is valid JSON with the expected action values;
- (c) a **concurrent `rotate-key`** aborts with the exact spec error string;
- (d) **`remove-member → rotate-key`** → the removed member's old clone cannot
  decrypt an item while a remaining member can.

```rust
use assert_cmd::cargo::CommandCargoExt as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use tempfile::TempDir;

/// A device home + an org vault. A second device can be wired for multi-member.
struct Dev {
    xdg: PathBuf,
    _config: TempDir,
}

impl Dev {
    fn new(name: &str) -> Self {
        let config = TempDir::new().unwrap();
        let xdg = config.path().to_path_buf();
        let devices = xdg.join("relicario").join("devices").join(name);
        std::fs::create_dir_all(&devices).unwrap();
        let keyfile = devices.join("signing.key");
        let st = Command::new("ssh-keygen")
            .args(["-t", "ed25519", "-N", "", "-C", "relicario-test", "-f"])
            .arg(&keyfile)
            .stdout(Stdio::null()).stderr(Stdio::null())
            .status().expect("ssh-keygen");
        assert!(st.success());
        std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap();
        std::fs::write(xdg.join("relicario").join("devices").join("current"), format!("{name}\n")).unwrap();
        Dev { xdg, _config: config }
    }

    fn pubkey(&self, name: &str) -> String {
        std::fs::read_to_string(
            self.xdg.join("relicario").join("devices").join(name).join("signing.pub"),
        ).unwrap().trim().to_string()
    }

    fn run(&self, vault: &Path, args: &[&str]) -> std::process::Output {
        let mut cmd = Command::cargo_bin("relicario").unwrap();
        cmd.env("XDG_CONFIG_HOME", &self.xdg)
            .env("RELICARIO_ORG_DIR", vault)
            .args(args)
            .stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
        cmd.output().unwrap()
    }
}

fn owner_member_id(vault: &Path) -> String {
    let s = std::fs::read_to_string(vault.join("members.json")).unwrap();
    let v: serde_json::Value = serde_json::from_str(&s).unwrap();
    v["members"][0]["member_id"].as_str().unwrap().to_string()
}

/// Set up an org with the owner granted `prod` and one login item in it.
fn setup_with_item() -> (Dev, TempDir, String) {
    let dev = Dev::new("laptop");
    let vault = TempDir::new().unwrap();
    let v = vault.path();
    assert!(dev.run(v, &["org", "init", "--dir", v.to_str().unwrap(), "--name", "Acme"]).status.success());
    let owner = owner_member_id(v);
    assert!(dev.run(v, &["org", "create-collection", "prod", "--name", "Prod"]).status.success());
    assert!(dev.run(v, &["org", "grant", &owner, "prod"]).status.success());
    assert!(dev.run(v, &[
        "org", "add", "login", "--collection", "prod",
        "--title", "GitHub", "--username", "alice", "--password", "hunter2",
    ]).status.success());
    (dev, vault, owner)
}

// (b) audit --format json parses + has expected actions.
#[test]
fn audit_format_json_is_valid_and_has_actions() {
    let (dev, vault, _owner) = setup_with_item();
    let out = dev.run(vault.path(), &["org", "audit", "--format", "json"]);
    assert!(out.status.success(), "audit json: {}", String::from_utf8_lossy(&out.stderr));
    let stdout = String::from_utf8_lossy(&out.stdout);
    let events: serde_json::Value = serde_json::from_str(&stdout).expect("audit json must parse");
    let arr = events.as_array().expect("array");
    let actions: Vec<&str> = arr.iter()
        .filter_map(|e| e["action"].as_str())
        .collect();
    assert!(actions.contains(&"org-init"), "actions: {actions:?}");
    assert!(actions.contains(&"collection-create"), "actions: {actions:?}");
    assert!(actions.contains(&"item-create"), "actions: {actions:?}");
    // Honest signer attribution: none of these should be TAMPERED (signer == trailer).
    assert!(arr.iter().all(|e| e["tampered"] == serde_json::Value::Bool(false)));
}

// (a) a forged-trailer commit is flagged TAMPERED.
#[test]
fn forged_trailer_commit_is_flagged_tampered() {
    let (dev, vault, owner) = setup_with_item();
    let v = vault.path();

    // Hand-craft a SIGNED commit whose trailer CLAIMS a different actor id than
    // the real signer. We reuse the org repo's own signing config (set by
    // `org init`), so the commit verifies — but the trailer lies.
    std::fs::write(v.join("decoy.txt"), "x").unwrap();
    let git = |args: &[&str]| {
        Command::new("git").current_dir(v).args(args)
            .env("XDG_CONFIG_HOME", &dev.xdg)
            .output().unwrap()
    };
    assert!(git(&["add", "decoy.txt"]).status.success());
    let forged_msg = format!(
        "forged\n\nRelicario-Actor: impostor ffffffffffffffff\nRelicario-Action: item-update\nRelicario-Member: {owner}"
    );
    // commit -S uses the repo's configured signing key (the real owner key).
    let c = git(&["commit", "-S", "-m", &forged_msg]);
    assert!(c.status.success(), "forged commit: {}", String::from_utf8_lossy(&c.stderr));

    let out = dev.run(v, &["org", "audit", "--format", "json"]);
    let events: serde_json::Value =
        serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
    let forged = events.as_array().unwrap().iter()
        .find(|e| e["action"] == "item-update")
        .expect("forged item-update event present");
    // Trailer claims ffff... but the verified signer is the owner → TAMPERED.
    assert_eq!(forged["tampered"], serde_json::Value::Bool(true));
    assert_eq!(forged["actor_id"].as_str(), Some(owner.as_str()));
}

// (c) concurrent rotate-key aborts with the exact spec error string.
#[test]
fn concurrent_rotate_key_aborts_with_spec_string() {
    let (dev, vault, _owner) = setup_with_item();
    let origin = TempDir::new().unwrap();
    let v = vault.path();
    let git = |args: &[&str]| Command::new("git").current_dir(v).args(args)
        .env("XDG_CONFIG_HOME", &dev.xdg).output().unwrap();

    // Make a bare origin and push, so a divergent upstream can be simulated.
    assert!(Command::new("git").args(["init", "--bare", origin.path().to_str().unwrap()])
        .output().unwrap().status.success());
    assert!(git(&["remote", "add", "origin", origin.path().to_str().unwrap()]).status.success());
    assert!(git(&["push", "-u", "origin", "HEAD"]).status.success());

    // Diverge upstream: a second clone commits + pushes a non-fast-forward.
    let clone2 = TempDir::new().unwrap();
    assert!(Command::new("git")
        .args(["clone", origin.path().to_str().unwrap(), clone2.path().to_str().unwrap()])
        .output().unwrap().status.success());
    std::fs::write(clone2.path().join("upstream.txt"), "u").unwrap();
    for a in [&["add", "upstream.txt"][..], &["-c", "user.email=u@u", "-c", "user.name=u", "commit", "-m", "upstream"][..], &["push", "origin", "HEAD:master"][..], &["push", "origin", "HEAD:main"][..]] {
        let _ = Command::new("git").current_dir(clone2.path()).args(a).output();
    }
    // Local also makes a commit so the histories truly diverge.
    std::fs::write(v.join("local.txt"), "l").unwrap();
    assert!(git(&["add", "local.txt"]).status.success());
    assert!(git(&["-c", "commit.gpgsign=false", "commit", "-m", "local"]).status.success());

    let out = dev.run(v, &["org", "rotate-key"]);
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(!out.status.success(), "rotate-key should abort on a concurrent rotation");
    assert!(
        stderr.contains("Concurrent key rotation detected — pull and re-run org rotate-key."),
        "missing spec error string: {stderr}"
    );
}

// (d) remove-member → rotate-key → old clone cannot decrypt; remaining member can.
#[test]
fn removed_member_clone_cannot_decrypt_after_rotation() {
    // Owner laptop sets up the org + a second member "bob".
    let (owner_dev, vault, _owner) = setup_with_item();
    let v = vault.path();
    let bob = Dev::new("bob-laptop");
    let bob_pub = bob.pubkey("bob-laptop");

    // Owner adds Bob and grants him prod.
    assert!(owner_dev.run(v, &["org", "add-member", "--key", &bob_pub, "--name", "Bob", "--role", "member"]).status.success());
    let members = std::fs::read_to_string(v.join("members.json")).unwrap();
    let mv: serde_json::Value = serde_json::from_str(&members).unwrap();
    let bob_id = mv["members"].as_array().unwrap().iter()
        .find(|m| m["display_name"] == "Bob").unwrap()["member_id"].as_str().unwrap().to_string();
    assert!(owner_dev.run(v, &["org", "grant", &bob_id, "prod"]).status.success());

    // Bob clones the vault dir (his device, his key blob is present).
    let bob_clone = TempDir::new().unwrap();
    let cp = Command::new("cp").args(["-r", v.to_str().unwrap(), bob_clone.path().to_str().unwrap()]).output().unwrap();
    assert!(cp.status.success());
    let bob_vault = bob_clone.path();
    // Bob can read the item BEFORE removal.
    let pre = bob.run(bob_vault, &["org", "get", "GitHub", "--show"]);
    assert!(String::from_utf8_lossy(&pre.stdout).contains("hunter2"), "bob should read pre-removal");

    // Owner removes Bob and rotates the key in the live vault.
    assert!(owner_dev.run(v, &["org", "remove-member", &bob_id]).status.success());
    assert!(owner_dev.run(v, &["org", "rotate-key"]).status.success());

    // Owner (remaining member) can still decrypt in the live vault.
    let owner_get = owner_dev.run(v, &["org", "get", "GitHub", "--show"]);
    assert!(String::from_utf8_lossy(&owner_get.stdout).contains("hunter2"), "owner must still read");

    // Copy the rotated item + manifest into Bob's stale clone (simulating a
    // pull) — his OLD key blob can no longer unwrap the rotated org key.
    let _ = Command::new("cp").args(["-r",
        v.join("items").to_str().unwrap(), bob_vault.to_str().unwrap()]).output();
    let _ = std::fs::copy(v.join("manifest.enc"), bob_vault.join("manifest.enc"));
    let post = bob.run(bob_vault, &["org", "get", "GitHub", "--show"]);
    assert!(!post.status.success() || !String::from_utf8_lossy(&post.stdout).contains("hunter2"),
        "removed member must NOT decrypt post-rotation: {}", String::from_utf8_lossy(&post.stdout));
}
```

```bash
cargo test -p relicario-cli --test org_lifecycle 2>&1 | tail -30
```

Expected: all four lifecycle tests pass. Requires `ssh-keygen` + a signing-capable `git` on PATH.

- [ ] **Step 5: Commit**

```bash
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs crates/relicario-cli/tests/org_items.rs crates/relicario-cli/tests/org_lifecycle.rs
git commit -m "feat(cli/org): org rm/restore/purge trash lifecycle (collection-scoped)"
```

> **Follow-up note (not a task):** `org add`/`org edit` cover Login, SecureNote, and Identity (the non-interactive builders). Card / Key / Document / Totp parity is deferred because those builders read secrets via `rpassword`/stdin; wiring them needs `--*-stdin` flags or a test escape hatch and belongs in a follow-up parity task. The CLI/extension-parity philosophy applies to the *extension* of this surface (Dev-D), not to per-type coverage within this CLI-only plan.

---

## [Dev-B] Task B14: Wire `Commands::Org` into `main.rs` (admin + item subcommands + lifecycle stubs)

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

This is the integration task that defines the `Commands::Org` arm, the `OrgCommands` enum, and the dispatch block. **Depends on every `run_*` command fn existing** (B4–B13). The item subcommand variants (`Add`/`Get`/`List`/`Edit`/`Rm`/`Restore`/`Purge`) and their dispatch arms are added by B10–B13 *extending* this enum/block; this task lands the admin variants plus the dispatch skeleton, and adds minimal stubs for `transfer-ownership` and `delete-org` (which the spec lists under Admin Operations).

- [ ] **Step 1: Read the current Commands enum**

```bash
grep -n "Subcommand\|enum Commands\|match cli.command" crates/relicario-cli/src/main.rs | head -40
```

- [ ] **Step 2: Add the `Org` arm to the `Commands` enum**

```rust
/// Manage a multi-user org vault.
Org {
    /// Path to the org vault directory (overrides RELICARIO_ORG_DIR).
    #[arg(long, global = true)]
    dir: Option<PathBuf>,
    #[command(subcommand)]
    subcommand: OrgCommands,
},
```

- [ ] **Step 3: Add the `OrgCommands` enum (admin variants + lifecycle stubs)**

The item variants (`Add`/`Get`/`List`/`Edit`/`Rm`/`Restore`/`Purge`) are added by B10–B13. The admin + lifecycle variants:

```rust
#[derive(Subcommand)]
enum OrgCommands {
    /// Create a new org vault.
    Init {
        #[arg(long)]
        name: String,
    },
    /// Add a member to the org.
    AddMember {
        /// OpenSSH ed25519 public key of the new member.
        #[arg(long)]
        key: String,
        /// Display name.
        #[arg(long)]
        name: String,
        /// Role: owner, admin, or member.
        #[arg(long, default_value = "member")]
        role: String,
    },
    /// Remove a member from the org.
    RemoveMember {
        /// Member ID prefix.
        member_id: String,
    },
    /// Change a member's role.
    SetRole {
        member_id: String,
        role: String,
    },
    /// Create a collection.
    CreateCollection {
        slug: String,
        #[arg(long)]
        name: String,
    },
    /// Grant a member access to a collection.
    Grant {
        member_id: String,
        collection: String,
    },
    /// Revoke a member's access to a collection.
    Revoke {
        member_id: String,
        collection: String,
    },
    /// Rotate the org master key (run after removing a member).
    RotateKey,
    /// Transfer ownership to another member (owner only). By default the caller
    /// is demoted to admin; pass --keep-owner for explicit co-ownership.
    TransferOwnership {
        member_id: String,
        /// Keep the caller as an owner too (co-ownership) instead of demoting.
        #[arg(long)]
        keep_owner: bool,
    },
    /// Delete the org (owner only; requires --confirm).
    DeleteOrg {
        #[arg(long)]
        confirm: bool,
    },
    /// Show org members and collections.
    Status,
    /// Query the org audit log.
    Audit {
        #[arg(long)]
        since: Option<String>,
        #[arg(long)]
        member: Option<String>,
        #[arg(long)]
        collection: Option<String>,
        #[arg(long)]
        action: Option<String>,
        /// Output format: `table` (default) or `json`.
        #[arg(long, default_value = "table")]
        format: String,
    },
    // Item subcommands (Add/Get/List/Edit/Rm/Restore/Purge) are added by
    // Tasks B10–B13, which extend this enum.
}
```

- [ ] **Step 4: Add the dispatch block + `parse_org_role` helper**

```rust
Commands::Org { dir, subcommand } => {
    let dir_path = dir.as_deref();
    match subcommand {
        OrgCommands::Init { name } => {
            // Resolve via org_dir so `org init` honors RELICARIO_ORG_DIR too
            // (while still accepting --dir), like every other org arm (L1).
            let d = crate::org_session::org_dir(dir_path)?;
            commands::org::run_init(&d, &name)?;
        }
        OrgCommands::AddMember { key, name, role } => {
            let d = crate::org_session::org_dir(dir_path)?;
            let role = parse_org_role(&role)?;
            commands::org::run_add_member(&d, &key, &name, role)?;
        }
        OrgCommands::RemoveMember { member_id } => {
            let d = crate::org_session::org_dir(dir_path)?;
            commands::org::run_remove_member(&d, &member_id)?;
        }
        OrgCommands::SetRole { member_id, role } => {
            let d = crate::org_session::org_dir(dir_path)?;
            let role = parse_org_role(&role)?;
            commands::org::run_set_role(&d, &member_id, role)?;
        }
        OrgCommands::CreateCollection { slug, name } => {
            let d = crate::org_session::org_dir(dir_path)?;
            commands::org::run_create_collection(&d, &slug, &name)?;
        }
        OrgCommands::Grant { member_id, collection } => {
            let d = crate::org_session::org_dir(dir_path)?;
            commands::org::run_grant(&d, &member_id, &collection)?;
        }
        OrgCommands::Revoke { member_id, collection } => {
            let d = crate::org_session::org_dir(dir_path)?;
            commands::org::run_revoke(&d, &member_id, &collection)?;
        }
        OrgCommands::RotateKey => {
            let d = crate::org_session::org_dir(dir_path)?;
            commands::org::run_rotate_key(&d)?;
        }
        OrgCommands::TransferOwnership { member_id, keep_owner } => {
            let d = crate::org_session::org_dir(dir_path)?;
            commands::org::run_transfer_ownership(&d, &member_id, keep_owner)?;
        }
        OrgCommands::DeleteOrg { confirm } => {
            let d = crate::org_session::org_dir(dir_path)?;
            commands::org::run_delete_org(&d, confirm)?;
        }
        OrgCommands::Status => {
            let d = crate::org_session::org_dir(dir_path)?;
            commands::org::run_status(&d)?;
        }
        OrgCommands::Audit { since, member, collection, action, format } => {
            let d = crate::org_session::org_dir(dir_path)?;
            commands::org::run_audit(&d, since.as_deref(), member.as_deref(),
                collection.as_deref(), action.as_deref(), &format)?;
        }
        // Item dispatch arms (Add/Get/List/Edit/Rm/Restore/Purge) added by
        // Tasks B10–B13.
    }
}
```

```rust
fn parse_org_role(s: &str) -> anyhow::Result<relicario_core::OrgRole> {
    match s {
        "owner" => Ok(relicario_core::OrgRole::Owner),
        "admin" => Ok(relicario_core::OrgRole::Admin),
        "member" => Ok(relicario_core::OrgRole::Member),
        other => anyhow::bail!("unknown role `{other}` — use owner, admin, or member"),
    }
}
```

- [ ] **Step 5: Implement `run_transfer_ownership` and `run_delete_org` in `commands/org.rs`**

The spec lists both under Admin Operations (owner-only). `transfer-ownership` is a **real transfer**: it promotes the target to Owner AND demotes the caller to Admin, unless `--keep-owner` is passed (explicit co-ownership, leaving the caller an owner too). `delete-org` requires `--confirm` and removes the working tree's org files (a LOCAL tombstone — see the tracked gap below). Minimal correct implementations:

```rust
pub fn run_transfer_ownership(dir: &Path, member_id_prefix: &str, keep_owner: bool) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    if !caller.role.can_manage_owners() {
        anyhow::bail!("only an owner can transfer ownership");
    }
    let mut members = vault.load_members()?;
    let target_id = resolve_member_id(&members, member_id_prefix)?;
    if target_id == caller.member_id {
        anyhow::bail!("you are already the owner");
    }
    // Promote the target to Owner.
    {
        let target = members.find_by_id_mut(&target_id)
            .ok_or_else(|| anyhow::anyhow!("member not found"))?;
        target.role = OrgRole::Owner;
    }
    // Real transfer: also demote the CALLER to Admin, unless --keep-owner was
    // passed (explicit co-ownership). The spec says "owner → another member",
    // so demotion is the default.
    if !keep_owner {
        if let Some(me) = members.find_by_id_mut(&caller.member_id) {
            me.role = OrgRole::Admin;
        }
    }
    vault.save_members(&members)?;

    let mode = if keep_owner { "co-ownership (caller kept owner)" } else { "caller demoted to admin" };
    let commit_msg = format!(
        "org: transfer ownership to {} ({mode})\n\nRelicario-Actor: {} {}\nRelicario-Action: ownership-transfer\nRelicario-Member: {}",
        target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str()
    );
    crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
    if keep_owner {
        println!("Ownership shared with {} (you remain an owner).", target_id.as_str());
    } else {
        println!("Ownership transferred to {} (you are now an admin).", target_id.as_str());
    }
    Ok(())
}

pub fn run_delete_org(dir: &Path, confirm: bool) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    if !caller.role.can_manage_owners() {
        anyhow::bail!("only an owner can delete the org");
    }
    if !confirm {
        anyhow::bail!("refusing to delete org without --confirm");
    }
    let commit_msg = format!(
        "org: delete org\n\nRelicario-Actor: {} {}\nRelicario-Action: org-delete",
        caller.display_name, caller.member_id.as_str()
    );
    // Remove org files (the git history is retained as the audit record).
    for f in ["org.json", "members.json", "collections.json", "manifest.enc"] {
        let _ = fs::remove_file(vault.root.join(f));
    }
    let _ = std::fs::remove_dir_all(vault.root.join("items"));
    let _ = std::fs::remove_dir_all(vault.root.join("keys"));
    crate::org_session::org_git_run(&vault.root, &["add", "-A"], "git add")?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
    println!("Org deleted (git history retained as audit record).");
    Ok(())
}
```

> **Tracked gap:** `delete-org`'s tombstone-commit semantics (whether the hook should *allow* a protected-file deletion by an owner) is a design corner the pre-receive hook (C1) currently REJECTs (`enforce_schema_monotonicity` forbids deleting protected JSON). This is an acknowledged interaction: for phase 1, `delete-org` is a **local** operation; pushing a delete to a hook-protected remote is out of scope and tracked as a follow-up. `transfer-ownership` is fully hook-compatible (it only mutates `members.json` roles, signed by the owner).

- [ ] **Step 6: Build and test help output**

```bash
cargo build -p relicario-cli 2>&1 | tail -10
./target/debug/relicario org --help
./target/debug/relicario org init --help
```

Expected: clean build; help text for all org subcommands (admin + item).

- [ ] **Step 7: Commit**

```bash
git add crates/relicario-cli/src/main.rs crates/relicario-cli/src/commands/org.rs
git commit -m "feat(cli): wire Commands::Org (admin + item subcommands) + transfer-ownership/delete-org"
```

---

## Dev-C — relicario-server pre-receive hook

> **Stream prerequisite:** Tasks A2–A3 must be merged before Dev-C begins (the hook imports `relicario_core::org::{OrgMember, OrgMembers, OrgCollections}`). No new Cargo deps beyond ensuring `tempfile` is a runtime dependency.
>
> **Dependency on the corrected item layout:** `classify_path` assumes `items/<slug>/<id>.enc`. It must match `UnlockedOrgVault::item_path(collection_slug, id)` from Task B1. If item commits ever used a flat `items/<id>.enc`, every item commit would be `Rejected`.

## [Dev-C] Task C1: `verify-org-commit` — signature + path-scoped authorization

**Files:**
- Modify: `crates/relicario-server/Cargo.toml`
- Modify: `crates/relicario-server/src/main.rs`
- Create: `crates/relicario-server/src/lib.rs`
- Create: `crates/relicario-server/tests/org_hook.rs`

The org pre-receive path mirrors the existing `verify_commit` (main.rs:37–153): build a temp `allowed_signers` from member ed25519 pubkeys, inject `gpg.ssh.allowedSignersFile` via `GIT_CONFIG_*`, run `git verify-commit --raw`, and parse the `key (SHA256:...)` fingerprint from **stderr**. We do **not** use `%GF` (it is empty without an allowed-signers file configured server-side). The verified signer's fingerprint maps to a current member via `relicario_core::fingerprint`; **that member is the authority** for every authorization check (trailers are advisory and ignored here).

Authorization rules enforced per commit:
1. **Every** commit must carry a GOOD signature whose fingerprint maps to a current member. Unsigned / unknown-signer → REJECT.
2. **Protected paths** (`members.json`, `collections.json`, `org.json`): signer must have `role.can_manage_members()` (Owner or Admin).
3. **Item writes** `items/<slug>/<id>.enc`: the leading path segment `<slug>` must appear in the signing member's `collections` grant list. (Owners/Admins are **not** auto-granted — grants are explicit.) Additionally (**L5**), `<slug>` must exist in `collections.json` as of the commit — a write into a granted-but-deleted collection is rejected.
4. **Owner-only elevation** (`members.json`): only an Owner may introduce a new owner/admin or elevate an existing member to owner/admin. `can_manage_members()` (Owner OR Admin) is sufficient to *write* members.json, but minting/elevating owner/admin requires `can_manage_owners()` (Owner). (**H-C1**)
5. Schema-version monotonicity for the three JSON files (Task C2).
6. Root commit (no parent) → inspect full tree, allow as genesis. Merge commit (>1 parent) → REJECT.

- [ ] **Step 1: Ensure `tempfile` is a runtime dependency**

Confirm `tempfile` is under `[dependencies]` (not only `[dev-dependencies]`) in `crates/relicario-server/Cargo.toml`. If the `[dependencies]` table lacks it, add:

```toml
tempfile = "3"
```

```bash
cargo build -p relicario-server 2>&1 | tail -5
```

Expected: clean build (the existing `verify_commit` already references `tempfile`, so it is almost certainly already present; this is a guard).

- [ ] **Step 2: Write failing unit tests for path-segment parsing**

Create `crates/relicario-server/tests/org_hook.rs`:

```rust
// Integration tests for relicario-server org-hook path classification.

use relicario_server::{classify_path, PathClass};

#[test]
fn protected_files_are_classified_protected() {
    assert_eq!(classify_path("members.json"), PathClass::Protected);
    assert_eq!(classify_path("collections.json"), PathClass::Protected);
    assert_eq!(classify_path("org.json"), PathClass::Protected);
}

#[test]
fn item_write_yields_collection_slug() {
    assert_eq!(
        classify_path("items/prod/a1b2c3d4e5f6a1b2.enc"),
        PathClass::Item { collection: "prod".to_string() }
    );
}

#[test]
fn item_write_nested_slug_takes_leading_segment_only() {
    // Slugs cannot contain '/', so a 4-segment path is malformed → Rejected.
    assert_eq!(
        classify_path("items/prod/sub/x.enc"),
        PathClass::Rejected("items path must be items/<slug>/<id>.enc".to_string())
    );
}

#[test]
fn key_blobs_and_manifest_are_unrestricted() {
    // keys/<id>.enc and manifest.enc are written by org operations; the SIGNATURE
    // check (every commit must be signed by a current member) is the gate for them.
    assert_eq!(classify_path("keys/a1b2c3d4e5f6a1b2.enc"), PathClass::Unrestricted);
    assert_eq!(classify_path("manifest.enc"), PathClass::Unrestricted);
}

#[test]
fn items_without_slug_segment_are_rejected() {
    // Flat items/<id>.enc (the OLD, now-removed layout) is no longer valid.
    assert_eq!(
        classify_path("items/a1b2c3d4e5f6a1b2.enc"),
        PathClass::Rejected("items path must be items/<slug>/<id>.enc".to_string())
    );
}

#[test]
fn empty_slug_segment_is_rejected() {
    assert_eq!(
        classify_path("items//x.enc"),
        PathClass::Rejected("empty collection slug in items path".to_string())
    );
}
```

```bash
cargo test -p relicario-server --test org_hook 2>&1 | tail -20
```

Expected: FAIL to compile — `classify_path` / `PathClass` not defined and no library target yet.

- [ ] **Step 3: Add a library target with the pure helpers**

Create `crates/relicario-server/src/lib.rs`:

```rust
//! Library surface for relicario-server, exposing pure helpers used by the
//! pre-receive hooks so they can be unit-tested.

/// Classification of a single changed path inside an org repo.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathClass {
    /// `members.json`, `collections.json`, `org.json` — only Owner/Admin may write.
    Protected,
    /// `items/<slug>/<id>.enc` — writer must hold a grant for `<slug>`.
    Item { collection: String },
    /// `keys/<id>.enc`, `manifest.enc`, `.gitignore`, etc. — gated only by the
    /// per-commit signature check (signer must be a current member).
    Unrestricted,
    /// Structurally invalid path; commit must be rejected.
    Rejected(String),
}

/// Classify a repo-relative path. Pure; no I/O.
pub fn classify_path(path: &str) -> PathClass {
    match path {
        "members.json" | "collections.json" | "org.json" => return PathClass::Protected,
        _ => {}
    }

    if let Some(rest) = path.strip_prefix("items/") {
        // Expect exactly: <slug>/<id>.enc  → two segments after the prefix.
        let segments: Vec<&str> = rest.split('/').collect();
        if segments.len() != 2 {
            return PathClass::Rejected("items path must be items/<slug>/<id>.enc".to_string());
        }
        let slug = segments[0];
        if slug.is_empty() {
            return PathClass::Rejected("empty collection slug in items path".to_string());
        }
        return PathClass::Item { collection: slug.to_string() };
    }

    PathClass::Unrestricted
}
```

Wire the lib into `Cargo.toml`. In `crates/relicario-server/Cargo.toml`, add an explicit `[lib]` + `[[bin]]` pair:

```toml
[lib]
name = "relicario_server"
path = "src/lib.rs"

[[bin]]
name = "relicario-server"
path = "src/main.rs"
```

In `crates/relicario-server/src/main.rs`, import the helpers (after the existing `use` block):

```rust
use relicario_server::{classify_path, PathClass};
```

- [ ] **Step 4: Run the path-classification tests**

```bash
cargo test -p relicario-server --test org_hook 2>&1 | tail -20
```

Expected: all six `org_hook` tests pass.

- [ ] **Step 5: Add the `VerifyOrgCommit` / `GenerateOrgHook` subcommands**

In `crates/relicario-server/src/main.rs`, add to the `Commands` enum (after `GenerateHook`):

```rust
    /// Verify a commit to an org vault: signature + role/path authorization.
    VerifyOrgCommit {
        /// The commit SHA to verify.
        commit: String,
    },
    /// Generate an org pre-receive hook script.
    GenerateOrgHook,
```

Add to the `match cli.command` block in `main()`:

```rust
        Commands::VerifyOrgCommit { commit } => verify_org_commit(&commit),
        Commands::GenerateOrgHook => generate_org_hook(),
```

- [ ] **Step 6: Implement the signer-resolution helper (mirrors `verify_commit`)**

```rust
use relicario_core::org::{OrgCollections, OrgMember, OrgMembers};

/// Verify the SSH signature on `commit` against the given org members and return
/// the matching member. On any failure (unsigned, malformed, or unknown signer)
/// this prints REJECT and calls `std::process::exit(1)`; it only returns on success.
fn verify_org_signer(commit: &str, members: &OrgMembers) -> OrgMember {
    // Build a temp allowed-signers file from every current member's pubkey.
    let tmp = match tempfile::tempdir() {
        Ok(t) => t,
        Err(e) => {
            eprintln!("REJECT: org commit {commit} — cannot create tempdir: {e}");
            std::process::exit(1);
        }
    };
    let allowed_path = tmp.path().join("allowed_signers");
    let mut allowed_body = String::new();
    for m in &members.members {
        allowed_body.push_str("relicario ");
        allowed_body.push_str(m.ed25519_pubkey.trim());
        allowed_body.push('\n');
    }
    if let Err(e) = fs::write(&allowed_path, &allowed_body) {
        eprintln!("REJECT: org commit {commit} — cannot write allowed_signers: {e}");
        std::process::exit(1);
    }

    // Run git verify-commit --raw with the allowed-signers file injected.
    let output = match Command::new("git")
        .args(["verify-commit", "--raw", commit])
        .env("GIT_CONFIG_COUNT", "1")
        .env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
        .env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
        .output()
    {
        Ok(o) => o,
        Err(e) => {
            eprintln!("REJECT: org commit {commit} — git verify-commit failed to run: {e}");
            std::process::exit(1);
        }
    };
    let stderr = String::from_utf8_lossy(&output.stderr);

    // Parse the SHA-256 fingerprint from stderr (same regex as verify_commit).
    let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").expect("static regex");
    let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) {
        Some(m) => m.as_str().to_string(),
        None => {
            eprintln!(
                "REJECT: org commit {commit} — no valid signature found (stderr: {})",
                stderr.trim()
            );
            std::process::exit(1);
        }
    };

    // Map fingerprint → member via relicario_core::fingerprint over each pubkey.
    for m in &members.members {
        if let Ok(fp) = relicario_core::fingerprint(&m.ed25519_pubkey) {
            if fp == signing_fp {
                return m.clone();
            }
        }
    }

    eprintln!(
        "REJECT: org commit {commit} — signer (fingerprint {signing_fp}) is not a current org member"
    );
    std::process::exit(1);
}
```

- [ ] **Step 7: Implement `verify_org_commit` (parent/merge handling + per-path authorization)**

> `enforce_schema_monotonicity` is defined in Task C2. The two tasks land in the same file and share one commit boundary at the end of C2 — do not run the full build until C2 has added that function.

```rust
fn verify_org_commit(commit: &str) -> Result<()> {
    // Determine parent count from %P (space-separated parent SHAs; empty = root).
    let parents_out = Command::new("git")
        .args(["show", "-s", "--format=%P", commit])
        .output()
        .context("git show parents")?;
    let parents_line = String::from_utf8_lossy(&parents_out.stdout);
    let parents: Vec<&str> = parents_line.split_whitespace().collect();

    // Merge commits are rejected. Org repos are linear (CLI uses pull --rebase).
    if parents.len() > 1 {
        eprintln!(
            "REJECT: org commit {commit} — merge commits are not allowed in org vaults \
             ({} parents); rebase instead",
            parents.len()
        );
        std::process::exit(1);
    }
    let is_root = parents.is_empty();

    // Load members.json AS OF THIS COMMIT so the genesis commit can authorize itself.
    let members_json = match git_show(commit, "members.json") {
        Ok(s) => s,
        Err(_) => {
            if is_root {
                eprintln!("OK: org commit {commit} (root bootstrap - no members.json yet)");
                return Ok(());
            }
            eprintln!("REJECT: org commit {commit} — members.json missing from non-root commit");
            std::process::exit(1);
        }
    };
    let members: OrgMembers =
        serde_json::from_str(&members_json).context("parse members.json")?;
    if members.members.is_empty() {
        if is_root {
            eprintln!("OK: org commit {commit} (root bootstrap - empty member list)");
            return Ok(());
        }
        eprintln!("REJECT: org commit {commit} — members.json has no members");
        std::process::exit(1);
    }
    members
        .validate()
        .map_err(|e| anyhow::anyhow!("members.json invalid: {e}"))?;

    // Verify the signature and resolve the signing member (exits on failure).
    let signer = verify_org_signer(commit, &members);

    // Enumerate changed paths. Root has no parent to diff, so use ls-tree.
    let changed_paths: Vec<String> = if is_root {
        let out = Command::new("git")
            .args(["ls-tree", "-r", "--name-only", commit])
            .output()
            .context("git ls-tree")?;
        String::from_utf8_lossy(&out.stdout)
            .lines()
            .map(|l| l.trim().to_string())
            .filter(|l| !l.is_empty())
            .collect()
    } else {
        let out = Command::new("git")
            .args(["diff-tree", "--no-commit-id", "-r", "--name-only", commit])
            .output()
            .context("git diff-tree")?;
        String::from_utf8_lossy(&out.stdout)
            .lines()
            .map(|l| l.trim().to_string())
            .filter(|l| !l.is_empty())
            .collect()
    };

    // Authorize each changed path against the signing member's role/grants.
    // collections.json (as of this commit) is loaded lazily on the first item
    // path, for the L5 slug-existence check.
    let mut collection_slugs: Option<Vec<String>> = None;
    for path in &changed_paths {
        match classify_path(path) {
            PathClass::Rejected(why) => {
                eprintln!("REJECT: org commit {commit} — invalid path `{path}`: {why}");
                std::process::exit(1);
            }
            PathClass::Protected => {
                if !signer.role.can_manage_members() {
                    eprintln!(
                        "REJECT: org commit {commit} — member '{}' (role {:?}) may not write protected file `{path}`",
                        signer.display_name, signer.role
                    );
                    std::process::exit(1);
                }
                // Privilege-escalation gate: only an Owner may INTRODUCE or
                // ELEVATE an owner/admin. An Admin may write members.json but
                // must not mint owners/admins server-side (spec §148/158/271).
                if path == "members.json" {
                    enforce_owner_only_elevation(commit, is_root, &members, &signer);
                }
            }
            PathClass::Item { collection } => {
                // The signing member must hold an explicit grant for the slug.
                if !signer.collections.iter().any(|c| c == &collection) {
                    eprintln!(
                        "REJECT: org commit {commit} — member '{}' lacks a grant for collection `{collection}` (path `{path}`)",
                        signer.display_name
                    );
                    std::process::exit(1);
                }
                // Slug-existence (L5): the collection must exist in
                // collections.json AS OF THIS COMMIT. A write into a
                // granted-but-deleted (or never-created) collection is rejected.
                let known = collection_slugs.get_or_insert_with(|| {
                    git_show(commit, "collections.json")
                        .ok()
                        .and_then(|s| serde_json::from_str::<OrgCollections>(&s).ok())
                        .map(|c| c.collections.into_iter().map(|d| d.slug).collect::<Vec<_>>())
                        .unwrap_or_default()
                });
                if !known.iter().any(|s| s == &collection) {
                    eprintln!(
                        "REJECT: org commit {commit} — item write to collection `{collection}` whose slug is absent from collections.json (path `{path}`)"
                    );
                    std::process::exit(1);
                }
            }
            PathClass::Unrestricted => {
                // keys/<id>.enc, manifest.enc, etc. — signature check already passed.
            }
        }
    }

    // Schema-version monotonicity for the three JSON files (Task C2).
    enforce_schema_monotonicity(commit, is_root, &changed_paths)?;

    eprintln!(
        "OK: org commit {commit} verified — signed by '{}' ({:?}), {} path(s) authorized",
        signer.display_name,
        signer.role,
        changed_paths.len()
    );
    Ok(())
}

/// Reject the commit unless every newly-introduced or elevated owner/admin is
/// authorized: `can_manage_members()` alone is insufficient — an Admin must NOT
/// be able to mint owners/admins server-side; only an Owner may. We diff the
/// new members.json (already loaded) against the parent's members.json by
/// member_id and flag any member that becomes Owner/Admin (new entry, or a
/// role elevated up to Owner/Admin). On genesis (root), the sole bootstrap
/// owner the commit introduces is allowed (it has no parent baseline).
///
/// CRITICAL: the signer's authority is judged on their role in the PARENT
/// commit (`parent_role(signer)`), NOT the post-change `signer.role` carried in
/// the commit under verification. Reading `signer.role` would let an Admin
/// self-promote to Owner in the same commit and then self-authorize that very
/// promotion (the gate would see the already-elevated role and pass) — the
/// exact escalation this exists to stop. A signer absent from the parent
/// (`None`) has no prior authority and is rejected.
///
/// `git_show_parent` is defined in Task C2 (same file, same crate).
fn enforce_owner_only_elevation(
    commit: &str,
    is_root: bool,
    new_members: &OrgMembers,
    signer: &OrgMember,
) {
    use relicario_core::org::OrgRole;

    let is_privileged = |r: OrgRole| matches!(r, OrgRole::Owner | OrgRole::Admin);

    // Genesis: the bootstrap commit introduces the sole owner; allow it.
    if is_root {
        return;
    }

    // Parent baseline. If members.json did not exist in the parent, every
    // privileged member here is "new" and must be owner-signed.
    let parent_members: Vec<(String, OrgRole)> = match git_show_parent(commit, "members.json") {
        Ok(s) => serde_json::from_str::<OrgMembers>(&s)
            .map(|m| {
                m.members
                    .into_iter()
                    .map(|m| (m.member_id.0, m.role))
                    .collect()
            })
            .unwrap_or_default(),
        Err(_) => Vec::new(),
    };
    let parent_role = |id: &str| -> Option<OrgRole> {
        parent_members.iter().find(|(mid, _)| mid == id).map(|(_, r)| *r)
    };

    // The signer's authority = their PARENT role. A member absent from the parent
    // (brand new) has no prior authority and cannot mint owners/admins. This is
    // judged BEFORE the loop and never reads the post-change `signer.role`.
    let signer_parent = parent_role(signer.member_id.as_str());
    let signer_may_manage_owners = signer_parent.map_or(false, |r| r.can_manage_owners());

    for m in &new_members.members {
        if !is_privileged(m.role) {
            continue;
        }
        // Privileged now. Skip ONLY if the role is unchanged from the parent
        // (a no-op same-role entry). Any CHANGE into a privileged role — a new
        // privileged member, Member→Admin/Owner, or Admin→Owner — must be
        // owner-signed. (A naive "already privileged" skip is WRONG: Admin is
        // also privileged, so it would let an Admin be elevated to Owner
        // unchecked — the exact escalation this gate exists to stop.)
        if parent_role(m.member_id.as_str()) == Some(m.role) {
            continue; // unchanged role — not an introduction or elevation
        }
        // A new owner/admin, or a member elevated to owner/admin → owner-only,
        // judged by the signer's PRE-commit (parent) authority — never the
        // post-change `signer.role`.
        if !signer_may_manage_owners {
            eprintln!(
                "REJECT: org commit {commit} — member '{}' (parent role {:?}) may not introduce or \
                 elevate owner/admin '{}' to {:?}; only an owner may",
                signer.display_name, signer_parent, m.display_name, m.role
            );
            std::process::exit(1);
        }
    }
}
```

- [ ] **Step 8: Implement `generate_org_hook`**

```rust
fn generate_org_hook() -> Result<()> {
    print!(
        r#"#!/bin/bash
# Relicario org pre-receive hook -- verify signatures + role/path authorization

while read oldrev newrev refname; do
  [ "$newrev" = "0000000000000000000000000000000000000000" ] && continue

  if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then
    commits=$(git rev-list "$newrev")
  else
    commits=$(git rev-list "$oldrev..$newrev")
  fi

  for commit in $commits; do
    relicario-server verify-org-commit "$commit" || exit 1
  done
done
"#
    );
    Ok(())
}
```

- [ ] **Step 9: Build (depends on Task C2's `enforce_schema_monotonicity`)**

> Do not run the full build until Task C2 has added `enforce_schema_monotonicity` to the same file. Proceed to C2, then return.

```bash
cargo build -p relicario-server 2>&1 | tail -15
```

- [ ] **Step 10: Commit (combined with Task C2 — see C2 Step 4)**

This task does not commit on its own; the signature/auth code and the schema-monotonicity code land together. Proceed to Task C2.

---

## [Dev-C] Task C2: Schema-version monotonicity + JSON validation

**Files:**
- Modify: `crates/relicario-server/src/main.rs`
- Modify: `crates/relicario-server/src/lib.rs`
- Modify: `crates/relicario-server/tests/org_hook.rs`
- Create: `crates/relicario-server/tests/org_hook_signed.rs` (H-C1 owner-only-elevation signed-commit test)

Every push that touches `members.json`, `collections.json`, or `org.json` must not **decrease** its `schema_version` (compared against `{commit}^:file`). On the root commit any starting version is accepted. We also re-validate `collections.json` here (members is validated in C1).

- [ ] **Step 1: Write failing test for the version-extraction helper**

Append to `crates/relicario-server/tests/org_hook.rs`:

```rust
use relicario_server::extract_schema_version;

#[test]
fn extract_schema_version_reads_field() {
    let json = r#"{ "schema_version": 3, "members": [] }"#;
    assert_eq!(extract_schema_version(json).unwrap(), 3);
}

#[test]
fn extract_schema_version_errors_on_missing_field() {
    let json = r#"{ "members": [] }"#;
    assert!(extract_schema_version(json).is_err());
}

#[test]
fn extract_schema_version_errors_on_garbage() {
    assert!(extract_schema_version("not json").is_err());
}
```

```bash
cargo test -p relicario-server --test org_hook 2>&1 | tail -10
```

Expected: FAIL — `extract_schema_version` not defined.

- [ ] **Step 2: Implement `extract_schema_version` in the lib**

Add to `crates/relicario-server/src/lib.rs`:

```rust
/// Extract the `schema_version` field from any org JSON document.
/// Returns an error if the field is absent or not a u32.
pub fn extract_schema_version(json: &str) -> Result<u32, String> {
    let value: serde_json::Value =
        serde_json::from_str(json).map_err(|e| format!("parse json: {e}"))?;
    value
        .get("schema_version")
        .and_then(|v| v.as_u64())
        .map(|n| n as u32)
        .ok_or_else(|| "missing or non-integer schema_version".to_string())
}
```

`serde_json` is already in `[dependencies]`, so the lib target picks it up — no Cargo.toml change.

```bash
cargo test -p relicario-server --test org_hook extract_schema_version 2>&1 | tail -10
```

Expected: the three `extract_schema_version` tests pass.

- [ ] **Step 3: Implement `enforce_schema_monotonicity` in main.rs**

```rust
use relicario_server::extract_schema_version;

/// For each protected JSON file changed in this commit, ensure schema_version did
/// not decrease vs the parent commit, and re-validate collections.json structure.
fn enforce_schema_monotonicity(
    commit: &str,
    is_root: bool,
    changed_paths: &[String],
) -> Result<()> {
    const VERSIONED: [&str; 3] = ["members.json", "collections.json", "org.json"];

    for file in VERSIONED {
        if !changed_paths.iter().any(|p| p == file) {
            continue;
        }

        // A deletion of a protected file is not allowed.
        let new_content = match git_show(commit, file) {
            Ok(s) => s,
            Err(_) => {
                eprintln!(
                    "REJECT: org commit {commit} — protected file `{file}` was deleted; \
                     org vaults never delete {file}"
                );
                std::process::exit(1);
            }
        };
        let new_version = match extract_schema_version(&new_content) {
            Ok(v) => v,
            Err(e) => {
                eprintln!("REJECT: org commit {commit} — `{file}` invalid: {e}");
                std::process::exit(1);
            }
        };

        // collections.json structural validation.
        if file == "collections.json" {
            match serde_json::from_str::<relicario_core::org::OrgCollections>(&new_content) {
                Ok(c) => {
                    if let Err(e) = c.validate() {
                        eprintln!("REJECT: org commit {commit} — collections.json invalid: {e}");
                        std::process::exit(1);
                    }
                }
                Err(e) => {
                    eprintln!("REJECT: org commit {commit} — collections.json parse error: {e}");
                    std::process::exit(1);
                }
            }
        }

        // On the root commit there is no parent baseline; any starting version is fine.
        if is_root {
            continue;
        }

        // Parent version: if the file did not exist in the parent (newly added),
        // there is no prior version to regress against — accept.
        if let Ok(old_content) = git_show_parent(commit, file) {
            let old_version = match extract_schema_version(&old_content) {
                Ok(v) => v,
                Err(_) => {
                    continue;
                }
            };
            if new_version < old_version {
                eprintln!(
                    "REJECT: org commit {commit} — `{file}` schema_version decreased \
                     ({old_version} -> {new_version})"
                );
                std::process::exit(1);
            }
        }
    }

    Ok(())
}

/// Read a file from a commit's FIRST PARENT tree: `git show {commit}^:{path}`.
fn git_show_parent(commit: &str, path: &str) -> Result<String> {
    let output = Command::new("git")
        .args(["show", &format!("{}^:{}", commit, path)])
        .output()
        .context("git show parent")?;
    if !output.status.success() {
        anyhow::bail!("git show {}^:{} failed", commit, path);
    }
    Ok(String::from_utf8(output.stdout)?)
}
```

- [ ] **Step 3b: Add the signed-commit hook integration test (H-C1: owner-only elevation)**

Create `crates/relicario-server/tests/org_hook_signed.rs`. This drives the full
`verify-org-commit` against a real ssh-signed org repo, asserting the
escalation gate: **an admin self-promoting to owner is REJECTED; an owner
promoting an admin is ACCEPTED.** It reuses the signing/git helpers from the
existing `verify_commit.rs` pattern.

```rust
//! Integration tests for `relicario-server verify-org-commit` privilege gating.
//!
//! H-C1: only an Owner may introduce or elevate an owner/admin. An Admin who
//! writes members.json must not be able to mint owners/admins.

use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use assert_cmd::Command as AssertCommand;
use predicates::prelude::*;
use relicario_core::device::generate_keypair;
use tempfile::TempDir;

fn write_keypair(dir: &Path, name: &str) -> (PathBuf, String) {
    let (priv_pem, pub_line) = generate_keypair().expect("generate keypair");
    let priv_path = dir.join(format!("{name}.key"));
    fs::write(&priv_path, priv_pem.as_str()).unwrap();
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap();
    }
    (priv_path, pub_line)
}

fn git(repo: &Path, args: &[&str]) {
    let status = Command::new("git").current_dir(repo).args(args).status().unwrap();
    assert!(status.success(), "git {args:?} failed");
}

/// members.json content with two members; `member_id`s are fixed 16-hex.
fn members_json(owner_pub: &str, admin_pub: &str, admin_role: &str) -> String {
    format!(
        r#"{{
  "schema_version": 1,
  "members": [
    {{ "member_id": "1111111111111111", "display_name": "Owner", "role": "owner",
       "ed25519_pubkey": "{}", "collections": [], "added_at": 0, "added_by": "1111111111111111" }},
    {{ "member_id": "2222222222222222", "display_name": "Admin", "role": "{admin_role}",
       "ed25519_pubkey": "{}", "collections": [], "added_at": 0, "added_by": "1111111111111111" }}
  ]
}}"#,
        owner_pub.trim(),
        admin_pub.trim()
    )
}

/// Stage members.json, sign the commit with `signing_key`, return its SHA.
fn signed_members_commit(
    repo: &Path,
    signing_key: &Path,
    allowed: &Path,
    msg: &str,
    content: &str,
) -> String {
    fs::write(repo.join("members.json"), content).unwrap();
    git(repo, &["add", "members.json"]);
    let status = Command::new("git")
        .current_dir(repo)
        .args([
            "-c", "gpg.format=ssh",
            "-c", &format!("user.signingkey={}", signing_key.display()),
            "-c", &format!("gpg.ssh.allowedSignersFile={}", allowed.display()),
            "commit", "-S", "-q", "-m", msg,
        ])
        .status()
        .unwrap();
    assert!(status.success());
    let out = Command::new("git").current_dir(repo).args(["rev-parse", "HEAD"]).output().unwrap();
    String::from_utf8(out.stdout).unwrap().trim().to_string()
}

/// Set up an org repo whose root commit (signed by the owner) registers an
/// owner + an admin. Returns (repo tmp, owner priv, admin priv, allowed file).
fn bootstrap() -> (TempDir, PathBuf, PathBuf, PathBuf) {
    let tmp = TempDir::new().unwrap();
    let repo = tmp.path();
    git(repo, &["init", "-q", "-b", "main"]);
    git(repo, &["config", "user.email", "t@t"]);
    git(repo, &["config", "user.name", "t"]);

    let (owner_priv, owner_pub) = write_keypair(repo, "owner");
    let (admin_priv, admin_pub) = write_keypair(repo, "admin");

    let allowed = repo.join("allowed_signers");
    fs::write(
        &allowed,
        format!("relicario {}\nrelicario {}\n", owner_pub.trim(), admin_pub.trim()),
    )
    .unwrap();

    // Genesis: owner registers both members (admin starts as `admin`).
    let genesis = members_json(&owner_pub, &admin_pub, "admin");
    signed_members_commit(repo, &owner_priv, &allowed, "org-init", &genesis);

    // also write org.json + collections.json so later commits are well-formed
    fs::write(repo.join("org.json"),
        r#"{"schema_version":1,"org_id":"abc0abc0abc0abc0","display_name":"Acme","created_at":0}"#).unwrap();
    fs::write(repo.join("collections.json"), r#"{"schema_version":1,"collections":[]}"#).unwrap();
    git(repo, &["add", "org.json", "collections.json"]);
    // sign this housekeeping commit with the owner too
    let _ = signed_members_commit(repo, &owner_priv, &allowed, "scaffold",
        &members_json(&owner_pub, &admin_pub, "admin"));

    (tmp, owner_priv, admin_priv, allowed)
}

#[test]
fn admin_self_promote_to_owner_is_rejected() {
    let (tmp, owner_priv, admin_priv, allowed) = bootstrap();
    let repo = tmp.path();
    let owner_pub = fs::read_to_string(repo.join("allowed_signers")).unwrap();
    // Reconstruct pubkeys from the allowed_signers file (two "relicario <pub>" lines).
    let lines: Vec<String> = owner_pub.lines()
        .map(|l| l.trim_start_matches("relicario ").to_string()).collect();
    let (op, ap) = (lines[0].clone(), lines[1].clone());
    let _ = owner_priv;

    // Admin signs a members.json that elevates THEMSELVES to owner.
    let escalated = members_json(&op, &ap, "owner");
    let sha = signed_members_commit(repo, &admin_priv, &allowed, "self-promote", &escalated);

    AssertCommand::cargo_bin("relicario-server")
        .unwrap()
        .current_dir(repo)
        .args(["verify-org-commit", &sha])
        .assert()
        .failure()
        .stderr(predicate::str::contains("only an owner"));
}

#[test]
fn owner_promoting_an_admin_is_accepted() {
    let (tmp, owner_priv, _admin_priv, allowed) = bootstrap();
    let repo = tmp.path();
    let allowed_body = fs::read_to_string(repo.join("allowed_signers")).unwrap();
    let lines: Vec<String> = allowed_body.lines()
        .map(|l| l.trim_start_matches("relicario ").to_string()).collect();
    let (op, ap) = (lines[0].clone(), lines[1].clone());

    // Owner signs a members.json that elevates the admin to owner — allowed.
    let promoted = members_json(&op, &ap, "owner");
    let sha = signed_members_commit(repo, &owner_priv, &allowed, "promote-admin", &promoted);

    AssertCommand::cargo_bin("relicario-server")
        .unwrap()
        .current_dir(repo)
        .args(["verify-org-commit", &sha])
        .assert()
        .success();
}
```

```bash
cargo test -p relicario-server --test org_hook_signed 2>&1 | tail -20
```

Expected: both tests pass once the full hook (C1 + C2) is built. Requires `git` with ssh-signing support on PATH.

- [ ] **Step 4: Build the whole crate, run all server tests, then commit**

```bash
cargo build -p relicario-server 2>&1 | tail -15
cargo test -p relicario-server 2>&1 | tail -25
```

Expected: clean build; all `org_hook` + `org_hook_signed` tests pass; existing `verify_commit` tests still pass.

```bash
cargo run -p relicario-server -- generate-org-hook | head -20
cargo run -p relicario-server -- verify-org-commit --help 2>&1 | head -10
```

Expected: the org hook script prints with the `verify-org-commit` loop; `--help` shows the subcommand.

```bash
git add crates/relicario-server/Cargo.toml crates/relicario-server/src/lib.rs crates/relicario-server/src/main.rs crates/relicario-server/tests/org_hook.rs crates/relicario-server/tests/org_hook_signed.rs
git commit -m "feat(server): verify-org-commit — signature + path-scoped role/grant auth + owner-only elevation + schema monotonicity"
```

---

## Dev-D — extension (CLI/extension parity)

> **Stream prerequisite:** Tasks A2–A3 must be merged before Dev-D begins (the WASM bindings call `relicario_core::{unwrap_org_key, decrypt_org_manifest, decrypt_item}`). Independent of Dev-B/Dev-C.
>
> **Scope:** This cluster ships extension org support as **read + switch only** (list orgs, open an org, browse items, read a single item, switch back to Personal cleanly). Full extension-side org item *creation/editing* is explicitly a tracked follow-up (see the note at the end of Task D4) — org writes require signed, collection-scoped commits and the browser GitHosts write via the Contents REST API with no commit-signing path.

## [Dev-D] Task D1: WASM org bindings (unwrap org key → SessionHandle, org manifest/item decrypt)

**Why this task exists:** The current WASM surface has **no** org functions. `manifest_decrypt`/`item_decrypt` are strictly typed (`Manifest`/`Item`) and keyed by an opaque `SessionHandle` whose master key never crosses into JS. An `OrgManifest` is a different serde schema, so it cannot reuse `manifest_decrypt`. These bindings add the org read path while preserving the "key never leaves WASM" invariant: the unwrapped org key is dropped straight into the existing session table and only a `u32` handle is returned to JS.

**Files:**
- Modify: `crates/relicario-wasm/src/lib.rs`
- Modify: `crates/relicario-wasm/src/device.rs` (add the `unwrap_org_key` accessor reading DEVICE_STATE)
- Modify: `crates/relicario-wasm/Cargo.toml` (add `ssh-key`; `base64` already present)
- Rebuild artifact: `extension/wasm/relicario_wasm.js`, `relicario_wasm_bg.wasm`, `relicario_wasm.d.ts`

- [ ] **Step 1: Confirm the core org API the binding depends on exists**

Dev-A's Tasks A2/A3 must be merged first. Verify:

```bash
cargo doc -p relicario-core --no-deps 2>/dev/null
grep -n "pub fn unwrap_org_key\|pub fn decrypt_org_manifest\|pub fn decrypt_item" \
  crates/relicario-core/src/org.rs crates/relicario-core/src/vault.rs
```

Expected: `unwrap_org_key(&[u8], &Zeroizing<[u8;32]>) -> Result<Zeroizing<[u8;32]>>` in `org.rs`; `decrypt_org_manifest(&[u8], &Zeroizing<[u8;32]>) -> Result<OrgManifest>` in `vault.rs`; `decrypt_item(&[u8], &Zeroizing<[u8;32]>) -> Result<Item>` re-exported. If any signature differs, STOP and reconcile.

- [ ] **Step 2: Confirm/add the `base64` and `ssh-key` dependencies**

```bash
grep -n "^base64\|^ssh-key" crates/relicario-wasm/Cargo.toml
```

`base64` is already a dependency of `relicario-wasm` (`base64 = "0.22"`); confirm it matches the workspace lock (`grep -A1 'name = "base64"' Cargo.lock` → `0.22.1`) and do **not** add a second version.

`ssh-key` is **NOT** currently a dependency of `relicario-wasm` (verified: no `ssh-key` line in `crates/relicario-wasm/Cargo.toml`). The new `org_open_with_registered_device` binding parses the device PEM held in WASM `DEVICE_STATE` via `ssh_key::PrivateKey::from_openssh`, so add under `[dependencies]` in `crates/relicario-wasm/Cargo.toml`:

```toml
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
```

`ssh-key` is already in the workspace lock at **0.6.7** (pulled in by `relicario-core`), and the `ed25519` + `std` feature set matches what core already relies on, so resolution stays at 0.6.7 — no new lockfile entry. Confirm:

```bash
cargo tree -p relicario-wasm -i ssh-key 2>&1 | head -3
grep -A1 'name = "ssh-key"' Cargo.lock | head -3
```

Expected: `ssh-key v0.6.7` for `relicario-wasm`; `Cargo.lock` still pins a single `version = "0.6.7"`.

- [ ] **Step 3: Add a failing wasm-bindgen-test for the round trip**

The org key is unwrapped INSIDE WASM using the device seed held in `DEVICE_STATE`
(the same in-memory state `sign_for_git` reads). The device private key NEVER
crosses to JS, so the test registers a device via `register_device(...)` first,
then drives `org_open_handle(&wrapped)` (which reads `DEVICE_STATE` internally).

Add to the `#[cfg(test)] mod tests` block near the bottom of `crates/relicario-wasm/src/lib.rs`:

```rust
#[test]
fn org_open_unwraps_into_a_handle_and_decrypts_manifest() {
    use zeroize::Zeroizing;

    // Register a device in DEVICE_STATE — this is the only place the private
    // key lives, and org_open reads it from there (never from JS).
    device::clear_device();
    let (signing_pub, _deploy_pub) = device::register_device("test-dev").unwrap();

    // Org key wrapped to that device's public key, and an org manifest
    // encrypted under the org key.
    let org_key = relicario_core::generate_org_key();
    let wrapped = relicario_core::wrap_org_key(&org_key, &signing_pub).unwrap();

    let mut manifest = relicario_core::OrgManifest::new();
    manifest.entries.push(relicario_core::OrgManifestEntry {
        id: relicario_core::ItemId::new(),
        r#type: relicario_core::ItemType::SecureNote,
        title: "secret".into(),
        tags: vec![],
        modified: 0,
        trashed_at: None,
        collection: "prod".into(),
    });
    let enc_manifest = relicario_core::encrypt_org_manifest(&manifest, &org_key).unwrap();

    // Exercise the internal helper the wasm-bindgen export wraps. It unwraps
    // using the registered DEVICE_STATE seed — no JS-side private key.
    let handle = org_open_handle(&wrapped).expect("org_open_handle");
    let out = session::with(handle, |k| {
        relicario_core::decrypt_org_manifest(&enc_manifest, k)
    })
    .unwrap()
    .unwrap();
    assert_eq!(out.entries.len(), 1);
    assert_eq!(out.entries[0].collection, "prod");
    session::remove(handle);
    device::clear_device();

    let _ = Zeroizing::new([0u8; 0]);
}

#[test]
fn org_open_without_registered_device_errors() {
    device::clear_device();
    let org_key = relicario_core::generate_org_key();
    // We need *some* wrapped blob; wrap to a throwaway device, then clear state.
    let (pub_key, _d) = device::register_device("throwaway").unwrap();
    let wrapped = relicario_core::wrap_org_key(&org_key, &pub_key).unwrap();
    device::clear_device();
    assert!(org_open_handle(&wrapped).is_err());
}
```

```bash
cargo test -p relicario-wasm org_open 2>&1 | tail -8
```

Expected: FAIL — `org_open_handle` undefined.

- [ ] **Step 4: Add the `unwrap_org_key` accessor to the WASM device module**

The device private key lives ONLY in `DEVICE_STATE` (a `Zeroizing<String>`
OpenSSH PEM) inside WASM linear memory; `sign_for_git` is the existing reader.
Add a sibling that unwraps an org-key blob using that same in-memory seed and
returns the org master key — the seed never leaves WASM. Append to
`crates/relicario-wasm/src/device.rs`:

```rust
use relicario_core::unwrap_org_key as core_unwrap_org_key;

/// Unwrap an org-key blob (`keys/<member-id>.enc`) using the registered
/// device's ed25519 seed, held in DEVICE_STATE. The seed never crosses to JS.
/// Errors if no device has been registered in this session.
pub fn unwrap_org_key(wrapped: &[u8]) -> Result<Zeroizing<[u8; 32]>, String> {
    let guard = DEVICE_STATE.lock().unwrap();
    let state = guard
        .as_ref()
        .ok_or_else(|| "no device registered".to_string())?;

    // Extract the 32-byte ed25519 seed from the stored OpenSSH private PEM,
    // mirroring relicario-core's sign() / current_device_seed parsing chain.
    let private = ssh_key::PrivateKey::from_openssh(state.signing_private.as_str())
        .map_err(|e| format!("parse device key: {e}"))?;
    let ed = private
        .key_data()
        .ed25519()
        .ok_or_else(|| "device key is not ed25519".to_string())?;
    let seed_slice: &[u8] = ed.private.as_ref();
    if seed_slice.len() != 32 {
        return Err("ed25519 seed has wrong length".to_string());
    }
    let mut seed = Zeroizing::new([0u8; 32]);
    seed.copy_from_slice(seed_slice);

    core_unwrap_org_key(wrapped, &seed).map_err(|e| e.to_string())
}
```

> **DEVICE_STATE is session-only (explicit prerequisite, not an assumption):**
> `DEVICE_STATE` is in-memory and is populated by `register_device(...)`; it is
> cleared by `clear_device()` and on SW eviction. There is NO persisted device
> private key. Therefore **org-open requires the device to be registered/unlocked
> in the current SW session** — the same lifecycle as a personal-vault unlock. The
> SW must `register_device` (or restore device state) before `org_open` can
> succeed; this is a stated precondition wired in D2, not a silent assumption.

- [ ] **Step 5: Implement the lib bindings**

Add to `crates/relicario-wasm/src/lib.rs`:

```rust
/// Internal: unwrap the org-key blob using the registered device seed held in
/// WASM DEVICE_STATE, then insert the org key into the session table. Returns
/// the raw u32 handle. The device seed and org key never cross to JS.
fn org_open_handle(wrapped_key: &[u8]) -> Result<u32, JsError> {
    use zeroize::Zeroizing;

    let org_key = device::unwrap_org_key(wrapped_key)
        .map_err(|e| JsError::new(&format!("org open: {e}")))?;

    // image_secret is unused for org sessions — store zeros.
    let handle = session::insert(org_key, Zeroizing::new([0u8; 32]));
    Ok(handle)
}

/// Unwrap the caller's wrapped org-key blob using the REGISTERED device key
/// (held in WASM DEVICE_STATE) and return an opaque SessionHandle bound to the
/// org master key. The device private key never crosses to JS, and the org key
/// stays inside WASM linear memory — JS receives only the handle. Requires a
/// device to have been registered in this session (see clear/register lifecycle).
#[wasm_bindgen]
pub fn org_open_with_registered_device(wrapped_key: &[u8]) -> Result<SessionHandle, JsError> {
    let handle = org_open_handle(wrapped_key)?;
    Ok(SessionHandle(handle))
}

/// Decrypt an org manifest ciphertext under an org SessionHandle.
#[wasm_bindgen]
pub fn org_manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
    need_key(handle)?;
    let out = session::with(handle.0, |k| relicario_core::decrypt_org_manifest(encrypted, k))
        .unwrap()
        .map_err(|e| JsError::new(&e.to_string()))?;
    js_value_for(&out)
}

/// Decrypt an org item ciphertext under an org SessionHandle.
#[wasm_bindgen]
pub fn org_item_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
    need_key(handle)?;
    let out = session::with(handle.0, |k| relicario_core::decrypt_item(encrypted, k))
        .unwrap()
        .map_err(|e| JsError::new(&e.to_string()))?;
    js_value_for(&out)
}
```

> **Notes for the implementer:**
> - `SessionHandle` is a tuple struct `SessionHandle(u32)` and `need_key`, `session::with`, `session::insert`, `js_value_for` already exist in this file. `SessionHandle.0` is accessible because the binding code is in the same module.
> - `lock(&SessionHandle)` already removes the session entry, so the existing JS `wasm.lock(handle)` path zeroes the org key too — no new teardown export needed.
> - `ssh-key` is newly added in Step 2 (it was NOT previously a dependency of `relicario-wasm`). `ed25519_dalek`, `base64`, `zeroize`, `relicario_core` are already dependencies.

- [ ] **Step 6: Run the unit tests**

```bash
cargo test -p relicario-wasm org_open 2>&1 | tail -8
```

Expected: both `org_open_*` tests PASS.

- [ ] **Step 7: Rebuild the WASM artifact the extension consumes**

```bash
cd crates/relicario-wasm && wasm-pack build --target web --out-dir ../../extension/wasm 2>&1 | tail -15
```

Then confirm the new exports landed:

```bash
grep -n "export function org_open_with_registered_device\|export function org_manifest_decrypt\|export function org_item_decrypt" \
  extension/wasm/relicario_wasm.d.ts
```

Expected: all three present.

- [ ] **Step 8: Commit**

```bash
git add crates/relicario-wasm/src/lib.rs crates/relicario-wasm/src/device.rs crates/relicario-wasm/Cargo.toml extension/wasm/
git commit -m "feat(wasm/org): org_open_with_registered_device + org_manifest/item_decrypt bindings (seed stays in WASM)"
```

---

## [Dev-D] Task D2: SW org session module + handlers (list_orgs / open_org / get_org_items / get_org_item)

**Files:**
- Create: `extension/src/service-worker/org.ts`
- Modify: `extension/src/service-worker/router/popup-only.ts` (add 4 handler arms)
- Modify: `extension/src/shared/messages.ts` (add 4 message types + responses + capability-set entries)
- Modify: `extension/src/service-worker/index.ts` (clear org session on session-expiry)

**Storage model (matches existing extension conventions):**
- Org configs live in `chrome.storage.local` under key `orgConfigs`: an array `OrgConfigEntry[]`.
- The unwrapped org master key lives **only** as a `SessionHandle` in a module-scope variable in `org.ts` — **never** written to `chrome.storage.local` or IndexedDB.
- Offline → read-only is derived, not stored: any `git.readFile` rejection while an org session is open flips an in-memory `readOnly` flag.

- [ ] **Step 1: Add the message types and capability-set entries**

In `extension/src/shared/messages.ts`, add to the `PopupMessage` union (after the `get_vault_status` arm):

```typescript
  | { type: 'list_orgs' }
  | { type: 'open_org'; orgId: string }
  | { type: 'get_org_items'; collection?: string }
  | { type: 'get_org_item'; id: ItemId }
```

Add these typed responses near the other `*Response` interfaces:

```typescript
export interface OrgSummary {
  orgId: string;
  label: string;
}

export interface ListOrgsResponse extends Extract<Response, { ok: true }> {
  data: { orgs: OrgSummary[]; activeOrgId: string | null };
}

export interface OpenOrgResponse extends Extract<Response, { ok: true }> {
  data: { orgId: string; label: string; readOnly: boolean };
}

export interface GetOrgItemsResponse extends Extract<Response, { ok: true }> {
  data: {
    items: Array<{
      id: ItemId; type: string; title: string; tags: string[];
      modified: number; trashed_at?: number; collection: string;
    }>;
    readOnly: boolean;
  };
}

export interface GetOrgItemResponse extends Extract<Response, { ok: true }> {
  data: { item: Item; readOnly: boolean };
}
```

Add the four types to `POPUP_ONLY_TYPES`:

```typescript
  'create_vault', 'attach_vault', 'get_vault_status',
  'list_orgs', 'open_org', 'get_org_items', 'get_org_item',
```

- [ ] **Step 2: Create the SW org session module**

Create `extension/src/service-worker/org.ts`:

```typescript
/// Org-vault session + read operations for the service worker.
///
/// Mirrors session.ts/vault.ts for the personal vault, but for org repos:
/// - Holds at most one unwrapped org SessionHandle in module memory. The org
///   master key lives inside WASM linear memory; JS only ever sees the opaque
///   handle. NEVER persisted.
/// - Org configs live in chrome.storage.local under `orgConfigs` — config only.
/// - Offline is derived: a failed git.readFile flips the active session's
///   readOnly flag.

import type { SessionHandle } from '../../wasm/relicario_wasm';
import type { GitHost } from './git-host';
import { createGitHost } from './git-host';
import type { Item } from '../shared/types';

export interface OrgConfigEntry {
  orgId: string;
  label: string;
  hostType: 'gitea' | 'github';
  hostUrl: string;
  repoPath: string;
  apiToken: string;
  /// Path inside the org repo of THIS device's wrapped org-key blob,
  /// e.g. "keys/<member-id>.enc".
  wrappedKeyPath: string;
}

interface ActiveOrgSession {
  orgId: string;
  label: string;
  handle: SessionHandle;
  git: GitHost;
  readOnly: boolean;
  /// This device's member id (the leading `<member-id>` of `keys/<id>.enc`,
  /// i.e. the SAME identity used to select the wrapped-key blob). Used to
  /// resolve the active member in members.json for grant-filtering.
  memberId: string;
  /// Collection slugs this member is granted, resolved from members.json at
  /// open time. getOrgItems/getOrgItem filter the manifest to these — the TS
  /// equivalent of core `OrgManifest::filter_for_member`.
  grants: string[];
}

interface OrgMemberJson {
  member_id: string;
  display_name: string;
  role: string;
  ed25519_pubkey: string;
  collections?: string[];
}
interface OrgMembersJson { schema_version: number; members: OrgMemberJson[]; }

/// Extract `<member-id>` from a `keys/<member-id>.enc` wrapped-key path.
function memberIdFromKeyPath(wrappedKeyPath: string): string {
  const base = wrappedKeyPath.split('/').pop() ?? '';
  return base.replace(/\.enc$/, '');
}

// Module-scope, single active org session. NEVER serialized.
let active: ActiveOrgSession | null = null;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let wasm: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function setWasm(w: any): void { wasm = w; }
function requireWasm(): any {
  if (!wasm) throw new Error('WASM module not initialized');
  return wasm;
}

export async function loadOrgConfigs(): Promise<OrgConfigEntry[]> {
  const r = await chrome.storage.local.get('orgConfigs');
  const raw = r.orgConfigs;
  return Array.isArray(raw) ? (raw as OrgConfigEntry[]) : [];
}

export function getActiveOrgId(): string | null {
  return active ? active.orgId : null;
}

export function isReadOnly(): boolean {
  return active ? active.readOnly : false;
}

/// Tear down the active org session: lock (zeroes the org key in WASM) and drop.
export function clearActiveOrg(): void {
  if (active) {
    try { requireWasm().lock(active.handle); } catch { /* may already be gone */ }
    try { (active.handle as unknown as { free?: () => void }).free?.(); } catch { /* idempotent */ }
    active = null;
  }
}

/// Open (unlock) an org by id: build its GitHost, fetch this device's wrapped
/// key blob, and unwrap into an org SessionHandle via WASM.
///
/// The device private key is NOT read from JS storage — it lives only inside
/// WASM DEVICE_STATE (populated by register_device at unlock time). The binding
/// `org_open_with_registered_device(wrapped)` unwraps the org key using that
/// in-memory seed without ever exposing it. PRECONDITION: a device must be
/// registered in this SW session (same lifecycle as personal-vault unlock);
/// the WASM binding returns a `device_not_registered`-style error otherwise.
export async function openOrg(orgId: string): Promise<{ orgId: string; label: string; readOnly: boolean }> {
  const w = requireWasm();
  const configs = await loadOrgConfigs();
  const cfg = configs.find((c) => c.orgId === orgId);
  if (!cfg) throw new Error('org_not_found');

  const git = createGitHost(cfg.hostType, cfg.hostUrl, cfg.repoPath, cfg.apiToken);

  const readOnly = false;
  let wrapped: Uint8Array;
  try {
    wrapped = await git.readFile(cfg.wrappedKeyPath);
  } catch {
    throw new Error('org_offline');
  }

  // Resolve this device's member entry + grants from members.json (public,
  // unencrypted). The member id is the SAME identity used to pick the wrapped
  // key blob (the `<member-id>` segment of wrappedKeyPath).
  const memberId = memberIdFromKeyPath(cfg.wrappedKeyPath);
  let grants: string[] = [];
  try {
    const membersCt = await git.readFile('members.json');
    const membersJson = JSON.parse(
      new TextDecoder().decode(membersCt),
    ) as OrgMembersJson;
    const me = (membersJson.members ?? []).find((m) => m.member_id === memberId);
    grants = me?.collections ?? [];
  } catch {
    throw new Error('org_offline');
  }

  // Unwrap inside WASM using the registered device seed (never crosses to JS).
  const handle = w.org_open_with_registered_device(wrapped) as SessionHandle;

  clearActiveOrg();
  active = { orgId: cfg.orgId, label: cfg.label, handle, git, readOnly, memberId, grants };
  return { orgId: cfg.orgId, label: cfg.label, readOnly };
}

interface OrgManifestEntryJson {
  id: string; type: string; title: string; tags?: string[];
  modified: number; trashed_at?: number; collection: string;
}
interface OrgManifestJson { schema_version: number; entries: OrgManifestEntryJson[]; }

/// Fetch + decrypt the active org's manifest, optionally filtered to one collection.
export async function getOrgItems(
  collection?: string,
): Promise<{ items: OrgManifestEntryJson[]; readOnly: boolean }> {
  const w = requireWasm();
  if (!active) throw new Error('org_locked');
  let ciphertext: Uint8Array;
  try {
    ciphertext = await active.git.readFile('manifest.enc');
  } catch {
    active.readOnly = true;
    throw new Error('org_offline');
  }
  const manifest = w.org_manifest_decrypt(active.handle, ciphertext) as OrgManifestJson;
  // Grant-filter FIRST: a member only ever sees entries in their granted
  // collections (the TS mirror of core OrgManifest::filter_for_member).
  const granted = new Set(active.grants);
  const visible = (manifest.entries ?? []).filter((e) => granted.has(e.collection));
  const all = visible.filter((e) => e.trashed_at === undefined);
  const slug = collection?.toLowerCase();
  const items = slug ? all.filter((e) => e.collection.toLowerCase() === slug) : all;
  return {
    items: items.map((e) => ({ ...e, tags: e.tags ?? [] })),
    readOnly: active.readOnly,
  };
}

/// Fetch + decrypt a single org item. Items are collection-scoped on disk
/// (items/<slug>/<id>.enc), so we read the manifest entry first to learn the slug.
export async function getOrgItem(id: string): Promise<{ item: Item; readOnly: boolean }> {
  const w = requireWasm();
  if (!active) throw new Error('org_locked');

  let manifestCt: Uint8Array;
  try {
    manifestCt = await active.git.readFile('manifest.enc');
  } catch {
    active.readOnly = true;
    throw new Error('org_offline');
  }
  const manifest = w.org_manifest_decrypt(active.handle, manifestCt) as OrgManifestJson;
  const entry = manifest.entries.find((e) => e.id === id);
  if (!entry) throw new Error('item_not_found');
  // Enforce the grant on read: refuse items in collections this member is not
  // granted, even if the id is known (mirrors core filter_for_member + the CLI
  // ensure_grant defense-in-depth).
  if (!active.grants.includes(entry.collection)) throw new Error('item_not_found');

  let itemCt: Uint8Array;
  try {
    itemCt = await active.git.readFile(`items/${entry.collection}/${id}.enc`);
  } catch {
    active.readOnly = true;
    throw new Error('org_offline');
  }
  const item = w.org_item_decrypt(active.handle, itemCt) as Item;
  return { item, readOnly: active.readOnly };
}

/// Build the list-orgs payload from config; the active id reflects the live
/// in-memory session, not anything persisted.
export async function listOrgs(): Promise<{
  orgs: Array<{ orgId: string; label: string }>;
  activeOrgId: string | null;
}> {
  const configs = await loadOrgConfigs();
  return {
    orgs: configs.map((c) => ({ orgId: c.orgId, label: c.label })),
    activeOrgId: getActiveOrgId(),
  };
}
```

- [ ] **Step 3: Wire `setWasm(orgModule)` at SW init**

In `extension/src/service-worker/index.ts`, import the org module:

```typescript
import * as org from './org';
```

Inside `initWasm()`, right after `vault.setWasm(wasmBindings);`:

```typescript
  org.setWasm(wasmBindings);
```

And in the `sessionTimer.onExpired(...)` callback (after `state.gitHost = null;`), tear the org session down too:

```typescript
  org.clearActiveOrg();
```

- [ ] **Step 4: Add the four handler arms**

In `extension/src/service-worker/router/popup-only.ts`, add an import:

```typescript
import * as org from '../org';
```

Add these arms inside the `switch (msg.type)` in `handle(...)`, after the `get_vault_status` arm:

```typescript
    case 'list_orgs': {
      const data = await org.listOrgs();
      return { ok: true, data };
    }

    case 'open_org': {
      try {
        const data = await org.openOrg(msg.orgId);
        return { ok: true, data };
      } catch (err) {
        return { ok: false, error: err instanceof Error ? err.message : String(err) };
      }
    }

    case 'get_org_items': {
      try {
        const data = await org.getOrgItems(msg.collection);
        return { ok: true, data };
      } catch (err) {
        return { ok: false, error: err instanceof Error ? err.message : String(err) };
      }
    }

    case 'get_org_item': {
      try {
        const data = await org.getOrgItem(msg.id);
        return { ok: true, data };
      } catch (err) {
        return { ok: false, error: err instanceof Error ? err.message : String(err) };
      }
    }
```

> Recommended: also call `org.clearActiveOrg()` in the existing `lock` arm for symmetric teardown.

- [ ] **Step 5: Type-check the whole extension (NOT bare `tsc --noEmit`)**

```bash
cd extension && npm run build:all 2>&1 | tail -20
```

Expected: clean build. The new `org_open_with_registered_device`/`org_manifest_decrypt`/`org_item_decrypt` resolve against the regenerated `relicario_wasm.d.ts` from Task D1.

- [ ] **Step 6: Commit**

```bash
git add extension/src/service-worker/org.ts extension/src/service-worker/index.ts \
  extension/src/service-worker/router/popup-only.ts extension/src/shared/messages.ts
git commit -m "feat(ext/sw): org session module + list_orgs/open_org/get_org_items/get_org_item handlers"
```

---

## [Dev-D] Task D3: Vault-tab org switcher (Personal + each configured org)

**Files:**
- Create: `extension/src/vault/vault-org-switcher.ts`
- Modify: `extension/src/vault/vault-sidebar.ts` (mount the switcher)
- Modify: `extension/src/vault/vault-context.ts`, `vault.ts` (add `activeOrgId` state)
- Modify: `extension/src/vault/vault.css` (switcher + read-only banner styling)

The switcher is a small `<select>` at the top of the vault-tab sidebar: "Personal" plus one option per configured org. Selecting an org sends `open_org`, then `get_org_items`, and renders the org's items. Selecting "Personal" restores the personal manifest. When the active org session is read-only (offline), a banner shows above the list.

- [ ] **Step 1: Implement the switcher**

Create `extension/src/vault/vault-org-switcher.ts`:

```typescript
/// Vault-tab org context switcher: "Personal" + each configured org.
///
/// Switching to an org opens it through the SW (open_org) and replaces the
/// list pane's entries with the org manifest projection. Switching back to
/// Personal restores the personal manifest. The org master key never reaches
/// this module — it stays inside WASM behind the SW.

import { sendMessage } from '../shared/state';
import type { VaultController, VaultEntry } from './vault-context';
import type {
  ListOrgsResponse, OpenOrgResponse, GetOrgItemsResponse,
} from '../shared/messages';

const SWITCHER_ID = 'vault-org-switcher';
const BANNER_ID = 'vault-org-readonly-banner';

/// Render the switcher into `host`. Idempotent — clears and rebuilds.
export async function renderOrgSwitcher(ctx: VaultController, host: HTMLElement): Promise<void> {
  const resp = await sendMessage({ type: 'list_orgs' });
  if (!resp.ok) return;
  const { orgs, activeOrgId } = (resp as ListOrgsResponse).data;

  // No orgs configured → render nothing (keeps the personal-only UI clean).
  if (orgs.length === 0) {
    host.querySelector(`#${SWITCHER_ID}`)?.remove();
    return;
  }

  let select = host.querySelector<HTMLSelectElement>(`#${SWITCHER_ID}`);
  if (!select) {
    select = document.createElement('select');
    select.id = SWITCHER_ID;
    select.className = 'org-switcher';
    select.setAttribute('aria-label', 'Vault context');
    host.prepend(select);
    select.addEventListener('change', () => { void onSwitch(ctx, select!.value); });
  }

  const opts = ['<option value="personal">Personal</option>']
    .concat(orgs.map((o) =>
      `<option value="${o.orgId}">${ctx.state ? escapeOption(o.label) : o.label}</option>`));
  select.innerHTML = opts.join('');
  select.value = activeOrgId ?? 'personal';
}

function escapeOption(s: string): string {
  return s.replace(/[&<>"]/g, (c) =>
    ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c] ?? c));
}

/// Handle a switcher change. "personal" restores the personal manifest;
/// any org id opens that org and loads its items into the list pane.
async function onSwitch(ctx: VaultController, value: string): Promise<void> {
  if (value === 'personal') {
    setReadOnlyBanner(false);
    ctx.state.activeOrgId = null;
    await ctx.loadManifest(); // re-loads the personal manifest into state.entries
    ctx.renderListPane();
    return;
  }

  const opened = await sendMessage({ type: 'open_org', orgId: value });
  if (!opened.ok) {
    ctx.state.error = opened.error === 'org_offline'
      ? 'This org is unavailable offline.'
      : `Could not open org: ${opened.error}`;
    ctx.renderPane();
    return;
  }
  const openData = (opened as OpenOrgResponse).data;
  ctx.state.activeOrgId = openData.orgId;

  const itemsResp = await sendMessage({ type: 'get_org_items' });
  if (!itemsResp.ok) {
    if (itemsResp.error === 'org_offline') {
      setReadOnlyBanner(true);
    } else {
      ctx.state.error = `Could not load org items: ${itemsResp.error}`;
      ctx.renderPane();
      return;
    }
  } else {
    const data = (itemsResp as GetOrgItemsResponse).data;
    setReadOnlyBanner(data.readOnly);
    // Project org entries into the same [id, entry] tuple shape the list pane
    // consumes for the personal manifest. The entry value type is the pinned
    // `VaultState.entries` element type (extended with an optional
    // `collection?: string` — see the implementer note), so this assigns
    // without a cast and the D2→D3 contract is compiler-checked.
    const projected: VaultEntry[] = data.items.map((e) => ({
      id: e.id, type: e.type, title: e.title, tags: e.tags,
      favorite: false, modified: e.modified, trashed_at: e.trashed_at,
      // org-specific projection field; optional on VaultEntry, harmless for
      // the personal list renderer.
      collection: e.collection,
    }));
    ctx.state.entries = projected.map((entry) => [entry.id, entry]);
  }
  ctx.state.selectedId = null;
  ctx.state.selectedItem = null;
  ctx.renderSidebarCategories();
  ctx.renderListPane();
}

function setReadOnlyBanner(show: boolean): void {
  const existing = document.getElementById(BANNER_ID);
  if (!show) { existing?.remove(); return; }
  if (existing) return;
  const banner = document.createElement('div');
  banner.id = BANNER_ID;
  banner.className = 'org-readonly-banner';
  banner.textContent = 'Read-only — org is unavailable offline.';
  const pane = document.getElementById('vault-list') ?? document.body;
  pane.prepend(banner);
}
```

> **Implementer notes:**
> - `ctx.loadManifest()`, `ctx.renderListPane()`, `ctx.renderSidebarCategories()`, `ctx.renderPane()`, and `ctx.state` are all on the existing `VaultController`.
> - `VaultState` needs a new optional field `activeOrgId: string | null`. Add it to the `VaultState` interface in `vault-context.ts` and initialize `activeOrgId: null` in `vault.ts`.
> - **Pin the entry element type (L2):** the personal manifest's entry value type must be a named, exported type — `VaultEntry` — exported from `vault-context.ts` and used as `entries: Array<[ItemId, VaultEntry]>` in `VaultState`. Add an optional `collection?: string` field to `VaultEntry`. The org switcher then imports `VaultEntry` and builds `VaultEntry[]` directly, so the D2→D3 projection is compiler-checked end-to-end instead of being silenced by a `as typeof ctx.state.entries` cast. If `VaultEntry` does not yet exist as a distinct type, extract it from the current inline entry-value type in `vault-context.ts` in this task.

- [ ] **Step 2: Mount the switcher in the sidebar**

In `extension/src/vault/vault-sidebar.ts`, import and call from `wireSidebar`:

```typescript
import { renderOrgSwitcher } from './vault-org-switcher';
```

Inside `wireSidebar(ctx)`, after the existing `refreshStatus()` call:

```typescript
  const sidebarNav = document.getElementById('vault-sidebar') ?? document.body;
  void renderOrgSwitcher(ctx, sidebarNav as HTMLElement);
```

(Verify the actual sidebar container id in `vault.html` — use that element as the mount host.)

- [ ] **Step 3: Add minimal styling**

In `extension/src/vault/vault.css`, append:

```css
.org-switcher {
  width: 100%;
  margin-bottom: 0.5rem;
  background: var(--bg-elev, #1b1b1b);
  color: var(--fg, #ddd);
  border: 1px solid var(--border, #333);
  font: inherit;
  padding: 0.25rem;
}

.org-readonly-banner {
  background: #4a3a00;
  color: #ffd24a;
  padding: 0.35rem 0.6rem;
  font-size: 0.85em;
  border-bottom: 1px solid #6a5400;
}
```

- [ ] **Step 4: Type-check via build:all**

```bash
cd extension && npm run build:all 2>&1 | tail -20
```

Expected: clean.

- [ ] **Step 5: Commit**

```bash
git add extension/src/vault/vault-org-switcher.ts extension/src/vault/vault-sidebar.ts \
  extension/src/vault/vault-context.ts extension/src/vault/vault.ts extension/src/vault/vault.css
git commit -m "feat(ext/vault): org context switcher (Personal + configured orgs) with read-only banner"
```

---

## [Dev-D] Task D4: The 3 spec-mandated vitest tests

**Files:**
- Create: `extension/src/service-worker/__tests__/org.test.ts`
- Modify: `extension/ARCHITECTURE.md` (storage table + module map)

These follow the existing vitest style: `vi.mock` the SW's `git-host` module, a `chrome.storage.local` shim, and a fake wasm object — exactly as in `vault.test.ts` and `router.test.ts`.

- [ ] **Step 1: Write the three tests**

Create `extension/src/service-worker/__tests__/org.test.ts`:

```typescript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GitHost } from '../git-host';
import * as gitHostMod from '../git-host';

// --- Mock git-host: org.ts builds its GitHost via createGitHost ---

function makeHostMock(opts: { offline?: boolean } = {}): GitHost {
  const membersJson = JSON.stringify({
    schema_version: 1,
    members: [{
      member_id: 'm0', display_name: 'Me', role: 'member',
      ed25519_pubkey: 'ssh-ed25519 AAAA fake', collections: ['prod'],
    }],
  });
  const reader = vi.fn().mockImplementation(async (path: string) => {
    if (opts.offline) throw new Error(`network: ${path}`);
    if (path === 'keys/m0.enc') return new Uint8Array([0xaa, 0xbb]); // wrapped org key blob
    if (path === 'members.json') return new TextEncoder().encode(membersJson); // grant source
    if (path === 'manifest.enc') return new Uint8Array([0x01, 0x02]); // org manifest ct
    if (path === 'items/prod/itemprod00000001.enc') return new Uint8Array([0x03]);
    throw new Error(`404: ${path}`);
  });
  return {
    readFile: reader,
    writeFile: vi.fn(),
    writeFileCreateOnly: vi.fn(),
    deleteFile: vi.fn(),
    listDir: vi.fn().mockResolvedValue([]),
    lastCommit: vi.fn().mockResolvedValue(null),
    putBlob: vi.fn(),
    getBlob: vi.fn(),
    deleteBlob: vi.fn(),
    lastSyncAt: null,
    ahead: 0,
    behind: 0,
  } as unknown as GitHost;
}

let offlineMode = false;
vi.mock('../git-host', async () => {
  const actual = await vi.importActual<typeof import('../git-host')>('../git-host');
  return {
    ...actual,
    createGitHost: vi.fn(() => makeHostMock({ offline: offlineMode })),
  };
});

import * as org from '../org';

// --- chrome.storage.local shim ---

function mockStorage(initial: Record<string, unknown>) {
  const store = { ...initial };
  // @ts-expect-error test harness
  globalThis.chrome = {
    storage: {
      local: {
        get: vi.fn((k: string | string[]) => {
          const arr = Array.isArray(k) ? k : [k];
          const out: Record<string, unknown> = {};
          for (const key of arr) if (key in store) out[key] = store[key];
          return Promise.resolve(out);
        }),
        set: vi.fn((kv: Record<string, unknown>) => { Object.assign(store, kv); return Promise.resolve(); }),
      },
    },
    runtime: { id: 'relicario-test-id', getURL: (p: string) => `chrome-extension://relicario-test-id/${p}` },
  };
  return store;
}

const ORG_CONFIGS = [{
  orgId: 'org1', label: 'Acme', hostType: 'gitea', hostUrl: 'https://g',
  repoPath: 'acme/vault', apiToken: 't', wrappedKeyPath: 'keys/m0.enc',
}];

// --- Fake wasm: org_open_with_registered_device returns an opaque handle,
//     decrypt fns return JSON ---

const ORG_MANIFEST = {
  schema_version: 1,
  entries: [
    {
      id: 'itemprod00000001', type: 'secure_note', title: 'Prod secret',
      tags: [], modified: 100, collection: 'prod',
    },
    // m0 is NOT granted `dev` — this entry must be filtered out on read.
    {
      id: 'itemdev000000001', type: 'secure_note', title: 'Dev secret',
      tags: [], modified: 100, collection: 'dev',
    },
  ],
};

function makeWasm() {
  const handle = { __org: true, free: vi.fn() };
  return {
    _handle: handle,
    // Org open unwraps inside WASM using the registered device seed; the SW
    // passes only the wrapped-key bytes — no device private key.
    org_open_with_registered_device: vi.fn(() => handle),
    org_manifest_decrypt: vi.fn(() => ORG_MANIFEST),
    org_item_decrypt: vi.fn(() => ({
      id: 'itemprod00000001', type: 'secure_note', title: 'Prod secret',
      core: { type: 'secure_note', body: 'top secret' }, attachments: [],
      tags: [], modified: 100, favorite: false,
    })),
    lock: vi.fn(),
  };
}

describe('SW org context', () => {
  let store: Record<string, unknown>;
  let wasm: ReturnType<typeof makeWasm>;

  beforeEach(() => {
    offlineMode = false;
    // No device_private_key in storage — the device seed lives only in WASM
    // DEVICE_STATE; the SW never persists or reads it from chrome.storage.
    store = mockStorage({ orgConfigs: ORG_CONFIGS });
    wasm = makeWasm();
    org.setWasm(wasm);
    vi.mocked(gitHostMod.createGitHost).mockImplementation(() => makeHostMock({ offline: offlineMode }));
  });

  afterEach(() => {
    org.clearActiveOrg();
    vi.restoreAllMocks();
  });

  // 1) Org context switching replaces the personal manifest cleanly.
  it('open_org + get_org_items returns only the org manifest entries', async () => {
    const opened = await org.openOrg('org1');
    expect(opened).toMatchObject({ orgId: 'org1', label: 'Acme', readOnly: false });
    // The binding takes ONLY the wrapped-key bytes — no device private key.
    expect(wasm.org_open_with_registered_device).toHaveBeenCalledWith(expect.any(Uint8Array));

    const { items, readOnly } = await org.getOrgItems();
    expect(readOnly).toBe(false);
    expect(items).toHaveLength(1);
    expect(items[0]).toMatchObject({ id: 'itemprod00000001', collection: 'prod', title: 'Prod secret' });

    // Single org item read is collection-scoped: items/<slug>/<id>.enc.
    const single = await org.getOrgItem('itemprod00000001');
    expect(single.item).toMatchObject({ id: 'itemprod00000001' });
    expect(wasm.org_item_decrypt).toHaveBeenCalled();

    // Switching context away (back to Personal) tears the org session down.
    org.clearActiveOrg();
    expect(org.getActiveOrgId()).toBeNull();
  });

  // 1b) Grant-filtering: a member only sees entries in their granted
  // collections. m0 holds `prod` but not `dev`, so the dev entry is hidden,
  // and a direct get_org_item for it is refused.
  it('filters org items to the member grant list', async () => {
    await org.openOrg('org1');
    const { items } = await org.getOrgItems();
    expect(items).toHaveLength(1);
    expect(items.map((i) => i.collection)).toEqual(['prod']);
    expect(items.some((i) => i.id === 'itemdev000000001')).toBe(false);

    // Even by id, an ungranted item is not readable.
    await expect(org.getOrgItem('itemdev000000001')).rejects.toThrow('item_not_found');
  });

  // 2) Org master key is never persisted.
  it('never writes the unwrapped org key to chrome.storage.local', async () => {
    await org.openOrg('org1');
    await org.getOrgItems();

    const setFn = (globalThis as unknown as {
      chrome: { storage: { local: { set: ReturnType<typeof vi.fn> } } };
    }).chrome.storage.local.set;
    expect(setFn).not.toHaveBeenCalled();

    const handleReturn = wasm.org_open_with_registered_device.mock.results[0].value;
    expect(handleReturn).toBe(wasm._handle);
    expect(handleReturn).not.toBeInstanceOf(Uint8Array);

    org.clearActiveOrg();
    expect(wasm.lock).toHaveBeenCalledWith(wasm._handle);
  });

  // 3) Offline read triggers read-only.
  it('flips read-only / org_offline when the git host is unreachable', async () => {
    await org.openOrg('org1');
    expect(org.isReadOnly()).toBe(false);

    offlineMode = true;
    await expect(org.getOrgItems()).rejects.toThrow('org_offline');
    expect(org.isReadOnly()).toBe(true);
  });

  it('open_org itself reports org_offline when the wrapped key cannot be fetched', async () => {
    offlineMode = true;
    await expect(org.openOrg('org1')).rejects.toThrow('org_offline');
    expect(org.getActiveOrgId()).toBeNull();
  });
});
```

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

```bash
cd extension && npx vitest run src/service-worker/__tests__/org.test.ts 2>&1 | tail -25
```

Expected: all tests pass.

- [ ] **Step 3: Run the full extension test + build verify**

```bash
cd extension && npm test 2>&1 | tail -25
cd extension && npm run build:all 2>&1 | tail -20
```

Expected: full vitest suite green; clean `build:all`.

- [ ] **Step 4: Update extension ARCHITECTURE.md + commit**

Add `orgConfigs` to the `chrome.storage.local` table in `extension/ARCHITECTURE.md` ("array of `OrgConfigEntry` — org repo coordinates + this device's wrapped-key path; **config only, no secrets**"), add `service-worker/org.ts` and `vault/vault-org-switcher.ts` to the module map, and note the invariant: "the unwrapped org master key lives only as a `SessionHandle` in `org.ts` module memory — never persisted, zeroed on lock/timeout via `wasm.lock`."

```bash
git add extension/src/service-worker/__tests__/org.test.ts extension/ARCHITECTURE.md
git commit -m "test(ext/sw): org context switch, no-key-persistence, offline read-only + ARCHITECTURE docs"
```

> **Tracked follow-up (do NOT implement here):** extension-side org *writes* — `add_org_item` / `update_org_item` / `delete_org_item`, collection-scoped writes, manifest re-encrypt, and **signed** commits from the browser. The browser GitHosts write via the Contents REST API with no commit-signing path, while the corrected org design requires every org commit to be signed. File it as "Plan B-2: extension org writes" against the org-vault epic.

---

## Dev-A — docs (final)

## [Dev-A] Task A5: Living-docs update (final, after all code streams merge)

**Files:**
- Modify: `docs/FORMATS.md`, `docs/CRYPTO.md`, `DESIGN.md`, `docs/SECURITY.md`,
  `crates/relicario-core/ARCHITECTURE.md`, `crates/relicario-cli/ARCHITECTURE.md`,
  `extension/ARCHITECTURE.md`, `STATUS.md`, `ROADMAP.md`

Per the spec's "Living-Docs Impact" section and CLAUDE.md living-docs discipline, this task records the new on-disk formats, the new crypto path, the new dependencies, and the honest limitations. It runs **after** A1–A4, all of Dev-B/C/D have merged. Concrete content per doc:

- [ ] **Step 1: `docs/FORMATS.md`** — add the org wire formats:
  - The four org JSON files: `org.json`, `members.json` (incl. `ed25519_pubkey` OpenSSH + `collections` grant array + `role`), `collections.json`, with their `schema_version` fields.
  - The `keys/<member-id>.enc` wrapped-blob layout: `ephemeral_x25519_pubkey(32) || version(1) || nonce(24) || ciphertext+tag`; note the wrapping key is `SHA-256(dh_shared || ephemeral_pubkey || recipient_pubkey)`.
  - **Collection-scoped** item path `items/<collection-slug>/<item-id>.enc` (same `.enc` format as personal items; blob does not name its collection — the directory path does).
  - `manifest.enc` org variant (each entry carries a `collection` slug).

- [ ] **Step 2: `docs/CRYPTO.md`** — add the ECIES org-key path:
  - ed25519→X25519 conversion (SHA-512(seed)[:32] + RFC 7748 clamp for the scalar; birational Montgomery map for the point), domain-separated KDF, all secret intermediates in `Zeroizing`.
  - Org-key wrap/unwrap diagram; note org crypto bypasses Argon2id (X25519-based, key directly used for XChaCha20-Poly1305).
  - **Key-rotation re-encryption:** rotate-key generates a fresh org key, re-wraps for remaining members, AND re-encrypts every `items/<slug>/<id>.enc` blob + the manifest under the new key.

- [ ] **Step 3: `DESIGN.md`** — cross-codebase structure:
  - Add the org-master-key row to the secrets map (256-bit random, wrapped per-member to device key; never escrowed; owners can always re-grant).
  - Add the `x25519-dalek` (core, `features = ["static_secrets"]`) and `ssh-key` (cli **and** wasm) dependencies to the build/dep matrix.
  - Note `relicario-server` gains an org mode (`verify-org-commit` / `generate-org-hook`) and the new `[lib]` target.

- [ ] **Step 4: `docs/SECURITY.md`** — threat model + honest limitations:
  - Org device-key auth (signer resolved by ed25519 fingerprint).
  - The **signature-verifying** pre-receive hook: every org commit must carry a GOOD signature from a current member; writes authorized by role (protected files) or collection path segment (items); **only an owner may introduce or elevate an owner/admin** (admins cannot mint owners/admins server-side); merge commits rejected; schema_version monotonic.
  - The **audit action vocabulary** matching the spec's Action Vocabulary table: `item-create` / `item-update` / `item-delete` (trash) / `item-restore` / `item-purge`; `member-add` / `member-remove` / `member-role-change`; `collection-create` / `collection-grant` / `collection-revoke`; `key-rotate`; `org-init` / `ownership-transfer` / `org-delete`. (Trash emits `item-delete`, restore emits `item-restore` — keep docs and the CLI emitters in B13 in lock-step.)
  - The honest limitations verbatim from the spec: **shared org master key — reads are not cryptographically scoped per collection** (the hook scopes writes + the client filters the manifest, but one org key opens everything; for cryptographic separation use a separate org vault — per-collection subkeys are a phase-2 non-goal); **no read audit** (git records writes, not reads); **no "hide value."**; **`delete-org` is a LOCAL tombstone in phase 1** — the hook rejects protected-file deletion, so a delete cannot be pushed to a hook-protected remote.

- [ ] **Step 5: `crates/relicario-core/ARCHITECTURE.md`** — add the `org` module:
  - Module map entry for `org.rs` (IDs, roles, members, collections, manifest, ECIES wrap/unwrap) + the `encrypt_org_manifest`/`decrypt_org_manifest` vault wrappers.
  - Invariant: KDF intermediates carrying the DH secret are held in `Zeroizing`.

- [ ] **Step 6: `crates/relicario-cli/ARCHITECTURE.md`** — add the org command surface:
  - `org_session.rs` (`UnlockedOrgVault`, collection-scoped `item_path`, fingerprint member match, signed `org_git_run`).
  - `commands/org.rs` (admin + item commands); device `current_device_seed`/`current_device_pubkey` helpers; the `ssh-key`/`regex`/`tempfile` deps.
  - Note: org commits are SIGNED (`configure_git_signing` in `org init`; `org_git_run` does not force `commit.gpgsign=false` the way `helpers::git_run` does).

- [ ] **Step 7: `extension/ARCHITECTURE.md`** — confirm the storage table + module map entries added in Task D4 are present (`orgConfigs`, `service-worker/org.ts`, `vault/vault-org-switcher.ts`, the SessionHandle-only invariant). Add a short "org context" subsection describing the switch + read flow and the offline read-only derivation.

- [ ] **Step 8: `STATUS.md` / `ROADMAP.md`**:
  - `STATUS.md`: mark the org-vault track landed (CLI admin + item CRUD, signature-verifying hook, extension switch/read parity); list tracked follow-ups: Card/Key/Document/Totp `org add`/`edit` parity, extension org *writes* (Plan B-2), and the phase-2 items (SSO/LDAP, read audit, per-collection subkeys, HTTP management plane).
  - `ROADMAP.md`: move the org-vault milestone to shipped and scope the phase-2 follow-ups.

- [ ] **Step 9: Code-constant pinning check**

Per CLAUDE.md discipline rule 2, any code constant cited in these docs (wrapped-blob byte layout, `MANIFEST_SCHEMA_VERSION`, the KDF construction) must cite the source file + line. Grep your new doc text for cited constants and confirm each has a `file:line` reference.

- [ ] **Step 10: Commit**

```bash
git add docs/FORMATS.md docs/CRYPTO.md DESIGN.md docs/SECURITY.md \
  crates/relicario-core/ARCHITECTURE.md crates/relicario-cli/ARCHITECTURE.md \
  extension/ARCHITECTURE.md STATUS.md ROADMAP.md
git commit -m "docs(org): living-docs sweep — org formats, ECIES crypto, signature-verifying hook, honest limitations"
```

---

## Self-Review

**Spec coverage check** — every spec requirement maps to a task:

| Spec requirement | Covered by task(s) |
|---|---|
| Org master key (256-bit random, wrapped per-member ECIES/X25519) | A2, A4 |
| `org.json` / `members.json` / `collections.json` data model | A2, B4 |
| `keys/<member-id>.enc` wrapped-blob layout | A2, B4, B5 |
| **Collection-scoped item storage** `items/<slug>/<id>.enc` | B1, B9, B10 (write); C1 `classify_path` (authz); D2 (read) |
| `manifest.enc` org variant (entry carries `collection`) | A2, A3 |
| ed25519→X25519 ECIES wrap/unwrap; Zeroizing KDF intermediates (H6) | A2 |
| Roles owner/admin/member; admin cannot mint owner/admin (role-gating) | A2, B5 |
| Collection grants in `members.json` | A2, B6 |
| Manifest filtering per member grants (read) | A2, A4, B11 |
| **Org item CRUD** — add / get / list / edit / rm / restore / purge | B10, B11, B12, B13 |
| `org init` (with **git signing** configured) | B4 |
| `org add-member` / `remove-member` / `set-role` | B5 |
| `org create-collection` / `grant` / `revoke` | B6 |
| `org rotate-key` — re-wrap + **re-encrypt every item blob & manifest** | B7 |
| Rotate-key concurrent-rotation **race abort** (spec error string) | B7 |
| `org transfer-ownership` / `delete-org` | B14 (transfer fully; delete-org local, push tracked gap) |
| `org status` (no decryption) | B8 |
| `org audit` — **verified-signer attribution**, **TAMPERED** flag, `%cI` + `%x1e/%x1f` framing, `--format json` | B8 |
| `relicario org` wired into `main.rs` (admin + item subcommands) | B14 |
| Pre-receive hook: **per-commit signature verification** (signer→member by fingerprint) | C1 |
| Pre-receive hook: protected-file role authz (owner/admin) | C1 |
| Pre-receive hook: **item path → collection-grant** authorization | C1 |
| Pre-receive hook: genesis allowed, **merge rejected** | C1 |
| Pre-receive hook: **schema_version monotonicity** + JSON validation | C2 |
| `generate-org-hook` script | C1 |
| Signature-verifying hook uses allowed-signers + `git verify-commit` (NOT `%GF`) | C1 |
| **Extension parity:** WASM org bindings (key never leaves WASM) | D1 |
| **Extension parity:** SW org session + handlers; org key only in SessionHandle | D2 |
| **Extension parity:** vault-tab org switcher + offline read-only banner | D3 |
| **Extension parity:** 3 spec-mandated vitest tests (switch / no-persist / offline) | D4 |
| Offline → read-only (CLI + extension) | B7-adjacent (no-remote distinction), D2/D3 |
| Member device key lost → owner re-grants (no escrow) | B5/B6 (re-add + grant) |
| **Living-docs** sweep (FORMATS/CRYPTO/DESIGN/SECURITY/ARCHITECTUREs/STATUS/ROADMAP) | A5 |
| Honest limitations recorded (shared key, no read audit, no hide-value) | A5 (docs/SECURITY.md) |
| SSO/SAML/LDAP, read audit, per-collection subkeys, HTTP plane | **Phase 2 — out of scope** |

**Placeholder scan:** No TBD/TODO/"similar to Task N"/phantom-task references. The flat-`items/` layout, the `%GF` fingerprint source, the unsigned `git_run` commit path, the `device.key`/`RELICARIO_DEVICE_KEY` storage model, and "items do not need re-encryption" are all removed.

**Type consistency:** `MemberId`, `OrgRole`, `OrgMembers`, `OrgMember`, `OrgManifest`, `OrgManifestEntry`, `CollectionDef`, `OrgCollections`, `OrgMeta` defined in A2, used consistently in B1–B14, C1–C2. `wrap_org_key`/`unwrap_org_key`/`generate_org_key`/`encrypt_org_manifest`/`decrypt_org_manifest` defined in A2–A3, used in B4–B13, C2, D1. `UnlockedOrgVault::item_path(collection_slug, id)` has exactly ONE definition (B1), consumed by B7–B13 and matched by C1's `classify_path` and D2's read path.
