a73x

34500cce

Wireformat overhaul: separate signatures, manifest blob, namespaced actions

a73x   2026-03-21 12:10

- Store Ed25519 signature and pubkey as separate blobs in commit tree
  instead of embedding in event.json (closes 91cd185f)
- Add manifest.json blob to every event commit for version migration
  (closes dd572839)
- Namespace action types: issue.open, patch.create, etc. (closes e23bf237)

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

diff --git a/src/dag.rs b/src/dag.rs
index c56b42b..d585071 100644
--- a/src/dag.rs
+++ b/src/dag.rs
@@ -3,7 +3,10 @@ use git2::{Oid, Repository, Sort};
use crate::error::Error;
use crate::event::{Action, Event};
use crate::identity::author_signature;
use crate::signing::{sign_event, SignedEvent};
use crate::signing::sign_event;

/// The manifest blob content included in every event commit tree.
const MANIFEST_JSON: &[u8] = br#"{"version":1,"format":"git-collab"}"#;

/// Create an orphan commit (no parents) with the given event.
/// Returns the new commit OID which also serves as the entity ID.
@@ -12,12 +15,19 @@ pub fn create_root_event(
    event: &Event,
    signing_key: &ed25519_dalek::SigningKey,
) -> Result<Oid, Error> {
    let signed = sign_event(event, signing_key)?;
    let json = serde_json::to_vec_pretty(&signed)?;
    let blob_oid = repo.blob(&json)?;
    let detached = sign_event(event, signing_key)?;
    let event_json = serde_json::to_vec_pretty(event)?;

    let event_blob = repo.blob(&event_json)?;
    let sig_blob = repo.blob(detached.signature.as_bytes())?;
    let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?;
    let manifest_blob = repo.blob(MANIFEST_JSON)?;

    let mut tb = repo.treebuilder(None)?;
    tb.insert("event.json", blob_oid, 0o100644)?;
    tb.insert("event.json", event_blob, 0o100644)?;
    tb.insert("signature", sig_blob, 0o100644)?;
    tb.insert("pubkey", pubkey_blob, 0o100644)?;
    tb.insert("manifest.json", manifest_blob, 0o100644)?;
    let tree_oid = tb.write()?;
    let tree = repo.find_tree(tree_oid)?;

@@ -35,12 +45,19 @@ pub fn append_event(
    event: &Event,
    signing_key: &ed25519_dalek::SigningKey,
) -> Result<Oid, Error> {
    let signed = sign_event(event, signing_key)?;
    let json = serde_json::to_vec_pretty(&signed)?;
    let blob_oid = repo.blob(&json)?;
    let detached = sign_event(event, signing_key)?;
    let event_json = serde_json::to_vec_pretty(event)?;

    let event_blob = repo.blob(&event_json)?;
    let sig_blob = repo.blob(detached.signature.as_bytes())?;
    let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?;
    let manifest_blob = repo.blob(MANIFEST_JSON)?;

    let mut tb = repo.treebuilder(None)?;
    tb.insert("event.json", blob_oid, 0o100644)?;
    tb.insert("event.json", event_blob, 0o100644)?;
    tb.insert("signature", sig_blob, 0o100644)?;
    tb.insert("pubkey", pubkey_blob, 0o100644)?;
    tb.insert("manifest.json", manifest_blob, 0o100644)?;
    let tree_oid = tb.write()?;
    let tree = repo.find_tree(tree_oid)?;

@@ -71,13 +88,7 @@ pub fn walk_events(repo: &Repository, ref_name: &str) -> Result<Vec<(Oid, Event)
            .get_name("event.json")
            .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?;
        let blob = repo.find_blob(entry.id())?;
        let content = blob.content();
        // Try SignedEvent first, fall back to plain Event for backward compat
        let event: Event = if let Ok(signed) = serde_json::from_slice::<SignedEvent>(content) {
            signed.event
        } else {
            serde_json::from_slice(content)?
        };
        let event: Event = serde_json::from_slice(blob.content())?;
        events.push((oid, event));
    }
    Ok(events)
@@ -123,11 +134,18 @@ pub fn reconcile(
        action: Action::Merge,
    };

    let signed = sign_event(&merge_event, signing_key)?;
    let json = serde_json::to_vec_pretty(&signed)?;
    let blob_oid = repo.blob(&json)?;
    let detached = sign_event(&merge_event, signing_key)?;
    let event_json = serde_json::to_vec_pretty(&merge_event)?;
    let event_blob = repo.blob(&event_json)?;
    let sig_blob = repo.blob(detached.signature.as_bytes())?;
    let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?;
    let manifest_blob = repo.blob(MANIFEST_JSON)?;

    let mut tb = repo.treebuilder(None)?;
    tb.insert("event.json", blob_oid, 0o100644)?;
    tb.insert("event.json", event_blob, 0o100644)?;
    tb.insert("signature", sig_blob, 0o100644)?;
    tb.insert("pubkey", pubkey_blob, 0o100644)?;
    tb.insert("manifest.json", manifest_blob, 0o100644)?;
    let tree_oid = tb.write()?;
    let tree = repo.find_tree(tree_oid)?;

diff --git a/src/event.rs b/src/event.rs
index c320717..ec1a83c 100644
--- a/src/event.rs
+++ b/src/event.rs
@@ -16,33 +16,43 @@ pub struct Event {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Action {
    #[serde(rename = "issue.open")]
    IssueOpen {
        title: String,
        body: String,
    },
    #[serde(rename = "issue.comment")]
    IssueComment {
        body: String,
    },
    #[serde(rename = "issue.close")]
    IssueClose {
        reason: Option<String>,
    },
    #[serde(rename = "issue.edit")]
    IssueEdit {
        title: Option<String>,
        body: Option<String>,
    },
    #[serde(rename = "issue.label")]
    IssueLabel {
        label: String,
    },
    #[serde(rename = "issue.unlabel")]
    IssueUnlabel {
        label: String,
    },
    #[serde(rename = "issue.assign")]
    IssueAssign {
        assignee: String,
    },
    #[serde(rename = "issue.unassign")]
    IssueUnassign {
        assignee: String,
    },
    #[serde(rename = "issue.reopen")]
    IssueReopen,
    #[serde(rename = "patch.create")]
    PatchCreate {
        title: String,
        body: String,
@@ -51,25 +61,32 @@ pub enum Action {
        #[serde(default, skip_serializing_if = "Option::is_none")]
        fixes: Option<String>,
    },
    #[serde(rename = "patch.revise")]
    PatchRevise {
        body: Option<String>,
    },
    #[serde(rename = "patch.review")]
    PatchReview {
        verdict: ReviewVerdict,
        body: String,
    },
    #[serde(rename = "patch.comment")]
    PatchComment {
        body: String,
    },
    #[serde(rename = "patch.inline_comment")]
    PatchInlineComment {
        file: String,
        line: u32,
        body: String,
    },
    #[serde(rename = "patch.close")]
    PatchClose {
        reason: Option<String>,
    },
    #[serde(rename = "patch.merge")]
    PatchMerge,
    #[serde(rename = "collab.merge")]
    Merge,
}

diff --git a/src/signing.rs b/src/signing.rs
index bcc95bd..089a249 100644
--- a/src/signing.rs
+++ b/src/signing.rs
@@ -6,7 +6,6 @@ use base64::Engine;
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use git2::Oid;
use rand_core::OsRng;
use serde::{Deserialize, Serialize};

use git2::{Repository, Sort};

@@ -28,12 +27,11 @@ pub fn signing_key_dir() -> Result<PathBuf, Error> {
    }
}

/// Wrapper around Event that adds Ed25519 signature fields.
/// Serialized as the event.json blob in git commits.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedEvent {
    #[serde(flatten)]
    pub event: Event,
/// Detached signature data for an event commit.
/// Stored as separate blobs (`signature` and `pubkey`) in the commit tree,
/// alongside the plain `event.json`.
#[derive(Debug, Clone)]
pub struct DetachedSignature {
    pub signature: String,
    pub pubkey: String,
}
@@ -152,33 +150,35 @@ pub fn canonical_json(event: &Event) -> Result<Vec<u8>, Error> {
    Ok(json.into_bytes())
}

/// Sign an Event with the given signing key, producing a SignedEvent.
pub fn sign_event(event: &Event, signing_key: &SigningKey) -> Result<SignedEvent, Error> {
/// Sign an Event with the given signing key, producing a detached signature.
pub fn sign_event(event: &Event, signing_key: &SigningKey) -> Result<DetachedSignature, Error> {
    let canonical = canonical_json(event)?;
    let signature = signing_key.sign(&canonical);
    let verifying_key = signing_key.verifying_key();

    Ok(SignedEvent {
        event: event.clone(),
    Ok(DetachedSignature {
        signature: STANDARD.encode(signature.to_bytes()),
        pubkey: STANDARD.encode(verifying_key.to_bytes()),
    })
}

/// Verify a SignedEvent's signature against its embedded public key.
/// Verify a detached signature against an event and its public key.
///
/// Returns `Missing` if signature or pubkey fields are empty,
/// `Valid` if the signature checks out, `Invalid` otherwise.
pub fn verify_signed_event(signed: &SignedEvent) -> Result<VerifyStatus, Error> {
    if signed.signature.is_empty() || signed.pubkey.is_empty() {
pub fn verify_detached(
    event: &Event,
    sig: &DetachedSignature,
) -> Result<VerifyStatus, Error> {
    if sig.signature.is_empty() || sig.pubkey.is_empty() {
        return Ok(VerifyStatus::Missing);
    }

    let sig_bytes = match STANDARD.decode(&signed.signature) {
    let sig_bytes = match STANDARD.decode(&sig.signature) {
        Ok(b) => b,
        Err(_) => return Ok(VerifyStatus::Invalid),
    };
    let pubkey_bytes = match STANDARD.decode(&signed.pubkey) {
    let pubkey_bytes = match STANDARD.decode(&sig.pubkey) {
        Ok(b) => b,
        Err(_) => return Ok(VerifyStatus::Invalid),
    };
@@ -198,7 +198,7 @@ pub fn verify_signed_event(signed: &SignedEvent) -> Result<VerifyStatus, Error> 
        Err(_) => return Ok(VerifyStatus::Invalid),
    };

    let canonical = canonical_json(&signed.event)?;
    let canonical = canonical_json(event)?;

    match verifying_key.verify(&canonical, &signature) {
        Ok(()) => Ok(VerifyStatus::Valid),
@@ -208,9 +208,8 @@ pub fn verify_signed_event(signed: &SignedEvent) -> Result<VerifyStatus, Error> 

/// Walk the DAG for the given ref and verify every event commit's signature.
///
/// For each commit, reads `event.json` from the tree:
/// - If it deserializes as a `SignedEvent`, calls `verify_signed_event()`.
/// - If it only deserializes as a plain `Event` (no signature/pubkey), marks as `Missing`.
/// For each commit, reads `event.json`, `signature`, and `pubkey` blobs from the tree.
/// If signature/pubkey blobs are missing, marks as `Missing`.
///
/// Returns one `SignatureVerificationResult` per commit.
pub fn verify_ref(
@@ -227,28 +226,42 @@ pub fn verify_ref(
        let oid = oid_result?;
        let commit = repo.find_commit(oid)?;
        let tree = commit.tree()?;
        let entry = tree

        // Read event.json
        let event_entry = tree
            .get_name("event.json")
            .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?;
        let blob = repo.find_blob(entry.id())?;
        let content = blob.content();
        let event_blob = repo.find_blob(event_entry.id())?;
        let event: Event = serde_json::from_slice(event_blob.content())?;

        // Try to deserialize as SignedEvent first
        if let Ok(signed) = serde_json::from_slice::<SignedEvent>(content) {
            if signed.signature.is_empty() || signed.pubkey.is_empty() {
                results.push(SignatureVerificationResult {
                    commit_id: oid,
                    status: VerifyStatus::Missing,
                    pubkey: None,
                    error: Some("missing signature".to_string()),
                });
            } else {
                match verify_signed_event(&signed)? {
        // Read signature and pubkey blob OIDs
        let sig_oid = tree.get_name("signature").map(|e| e.id());
        let pubkey_oid = tree.get_name("pubkey").map(|e| e.id());

        match (sig_oid, pubkey_oid) {
            (Some(sid), Some(pid)) => {
                let sig_blob = repo.find_blob(sid)?;
                let pubkey_blob = repo.find_blob(pid)?;
                let sig_str = std::str::from_utf8(sig_blob.content())
                    .unwrap_or("")
                    .trim()
                    .to_string();
                let pubkey_str = std::str::from_utf8(pubkey_blob.content())
                    .unwrap_or("")
                    .trim()
                    .to_string();

                let detached = DetachedSignature {
                    signature: sig_str.clone(),
                    pubkey: pubkey_str.clone(),
                };

                match verify_detached(&event, &detached)? {
                    VerifyStatus::Valid => {
                        results.push(SignatureVerificationResult {
                            commit_id: oid,
                            status: VerifyStatus::Valid,
                            pubkey: Some(signed.pubkey),
                            pubkey: Some(pubkey_str),
                            error: None,
                        });
                    }
@@ -256,7 +269,7 @@ pub fn verify_ref(
                        results.push(SignatureVerificationResult {
                            commit_id: oid,
                            status: VerifyStatus::Invalid,
                            pubkey: Some(signed.pubkey),
                            pubkey: Some(pubkey_str),
                            error: Some("invalid signature".to_string()),
                        });
                    }
@@ -269,25 +282,23 @@ pub fn verify_ref(
                        });
                    }
                    VerifyStatus::Untrusted => {
                        // verify_signed_event never returns Untrusted,
                        // but handle it for exhaustiveness
                        results.push(SignatureVerificationResult {
                            commit_id: oid,
                            status: VerifyStatus::Untrusted,
                            pubkey: Some(signed.pubkey),
                            pubkey: Some(pubkey_str),
                            error: Some("untrusted key".to_string()),
                        });
                    }
                }
            }
        } else {
            // Plain Event without signature fields
            results.push(SignatureVerificationResult {
                commit_id: oid,
                status: VerifyStatus::Missing,
                pubkey: None,
                error: Some("missing signature".to_string()),
            });
            _ => {
                results.push(SignatureVerificationResult {
                    commit_id: oid,
                    status: VerifyStatus::Missing,
                    pubkey: None,
                    error: Some("missing signature".to_string()),
                });
            }
        }
    }

@@ -300,7 +311,7 @@ mod tests {
    use crate::event::{Action, Author};

    #[test]
    fn signed_event_flatten_round_trip() {
    fn detached_signature_round_trip() {
        let event = Event {
            timestamp: "2026-03-21T00:00:00Z".to_string(),
            author: Author {
@@ -312,18 +323,14 @@ mod tests {
                body: "Body".to_string(),
            },
        };
        let signed = SignedEvent {
            event,
            signature: "dGVzdA==".to_string(),
            pubkey: "cHVia2V5".to_string(),
        };
        let json = serde_json::to_string_pretty(&signed).unwrap();
        let deserialized: SignedEvent = serde_json::from_str(&json).unwrap();
        assert_eq!(deserialized.signature, "dGVzdA==");
        assert_eq!(deserialized.pubkey, "cHVia2V5");
        match deserialized.event.action {
            Action::IssueOpen { ref title, .. } => assert_eq!(title, "Test"),
            _ => panic!("Wrong action type after round-trip"),
        }

        let sk = SigningKey::generate(&mut rand_core::OsRng);
        let sig = sign_event(&event, &sk).unwrap();

        assert!(!sig.signature.is_empty());
        assert!(!sig.pubkey.is_empty());

        let status = verify_detached(&event, &sig).unwrap();
        assert_eq!(status, VerifyStatus::Valid);
    }
}
diff --git a/tests/collab_test.rs b/tests/collab_test.rs
index 48a1868..d68835b 100644
--- a/tests/collab_test.rs
+++ b/tests/collab_test.rs
@@ -5,7 +5,7 @@ use tempfile::TempDir;
use git_collab::dag;
use git_collab::error::Error;
use git_collab::event::{Action, Author, Event, ReviewVerdict};
use git_collab::signing::{self, SignedEvent, VerifyStatus};
use git_collab::signing::{self, DetachedSignature, VerifyStatus};
use git_collab::state::{self, IssueState, IssueStatus, PatchState, PatchStatus};

use common::{
@@ -510,20 +510,40 @@ fn test_signed_event_in_dag() {
    let ref_name = format!("refs/collab/issues/{}", id);
    repo.reference(&ref_name, oid, false, "test open").unwrap();

    // Read the raw blob from the commit and deserialize as SignedEvent
    // Read the raw blobs from the commit tree
    let tip = repo.refname_to_id(&ref_name).unwrap();
    let commit = repo.find_commit(tip).unwrap();
    let tree = commit.tree().unwrap();
    let entry = tree.get_name("event.json").unwrap();
    let blob = repo.find_blob(entry.id()).unwrap();
    let signed: SignedEvent = serde_json::from_slice(blob.content()).unwrap();

    // Assert signature and pubkey are present
    assert!(!signed.signature.is_empty(), "signature should be present");
    assert!(!signed.pubkey.is_empty(), "pubkey should be present");

    // Verify the signature
    let status = signing::verify_signed_event(&signed).unwrap();
    // event.json should be a plain Event (no signature fields)
    let event_entry = tree.get_name("event.json").unwrap();
    let event_blob = repo.find_blob(event_entry.id()).unwrap();
    let event: Event = serde_json::from_slice(event_blob.content()).unwrap();
    assert!(matches!(event.action, Action::IssueOpen { .. }));

    // signature and pubkey should be separate blobs
    let sig_entry = tree.get_name("signature").expect("signature blob should exist");
    let pk_entry = tree.get_name("pubkey").expect("pubkey blob should exist");
    let sig_blob = repo.find_blob(sig_entry.id()).unwrap();
    let pk_blob = repo.find_blob(pk_entry.id()).unwrap();
    let sig_str = std::str::from_utf8(sig_blob.content()).unwrap();
    let pk_str = std::str::from_utf8(pk_blob.content()).unwrap();
    assert!(!sig_str.is_empty(), "signature should be present");
    assert!(!pk_str.is_empty(), "pubkey should be present");

    // manifest.json should exist
    let manifest_entry = tree.get_name("manifest.json").expect("manifest.json should exist");
    let manifest_blob = repo.find_blob(manifest_entry.id()).unwrap();
    let manifest_str = std::str::from_utf8(manifest_blob.content()).unwrap();
    assert!(manifest_str.contains("\"version\":1"), "manifest should contain version");
    assert!(manifest_str.contains("\"format\":\"git-collab\""), "manifest should contain format");

    // Verify the detached signature
    let detached = DetachedSignature {
        signature: sig_str.to_string(),
        pubkey: pk_str.to_string(),
    };
    let status = signing::verify_detached(&event, &detached).unwrap();
    assert_eq!(status, VerifyStatus::Valid, "signature should verify as valid");

    // walk_events should still extract the Event correctly
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index dc34a98..f5343aa 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -272,14 +272,18 @@ impl TestRepo {
    }
}

/// Create an unsigned event commit (plain Event JSON, no signature fields).
/// Create an unsigned event commit (plain Event JSON, no signature/pubkey blobs).
/// Returns the commit OID.
pub fn create_unsigned_event(repo: &Repository, event: &Event) -> git2::Oid {
    let json = serde_json::to_vec_pretty(event).unwrap();
    let blob_oid = repo.blob(&json).unwrap();
    let manifest = br#"{"version":1,"format":"git-collab"}"#;
    let manifest_blob = repo.blob(manifest).unwrap();

    let mut tb = repo.treebuilder(None).unwrap();
    tb.insert("event.json", blob_oid, 0o100644).unwrap();
    tb.insert("manifest.json", manifest_blob, 0o100644).unwrap();
    // No signature or pubkey blobs — this is an unsigned event
    let tree_oid = tb.write().unwrap();
    let tree = repo.find_tree(tree_oid).unwrap();

@@ -288,18 +292,27 @@ pub fn create_unsigned_event(repo: &Repository, event: &Event) -> git2::Oid {
        .unwrap()
}

/// Create a tampered event commit: sign the event, then modify the body but keep
/// Create a tampered event commit: sign the event, then modify the event.json but keep
/// the original signature. Returns the commit OID.
pub fn create_tampered_event(repo: &Repository, event: &Event) -> git2::Oid {
    let sk = test_signing_key();
    let mut signed = signing::sign_event(event, &sk).unwrap();
    let detached = signing::sign_event(event, &sk).unwrap();

    // Tamper with the event content while keeping the original signature
    signed.event.timestamp = "2099-01-01T00:00:00Z".to_string();
    let json = serde_json::to_vec_pretty(&signed).unwrap();
    let blob_oid = repo.blob(&json).unwrap();
    let mut tampered_event = event.clone();
    tampered_event.timestamp = "2099-01-01T00:00:00Z".to_string();
    let json = serde_json::to_vec_pretty(&tampered_event).unwrap();
    let event_blob = repo.blob(&json).unwrap();
    let sig_blob = repo.blob(detached.signature.as_bytes()).unwrap();
    let pubkey_blob = repo.blob(detached.pubkey.as_bytes()).unwrap();
    let manifest = br#"{"version":1,"format":"git-collab"}"#;
    let manifest_blob = repo.blob(manifest).unwrap();

    let mut tb = repo.treebuilder(None).unwrap();
    tb.insert("event.json", blob_oid, 0o100644).unwrap();
    tb.insert("event.json", event_blob, 0o100644).unwrap();
    tb.insert("signature", sig_blob, 0o100644).unwrap();
    tb.insert("pubkey", pubkey_blob, 0o100644).unwrap();
    tb.insert("manifest.json", manifest_blob, 0o100644).unwrap();
    let tree_oid = tb.write().unwrap();
    let tree = repo.find_tree(tree_oid).unwrap();

diff --git a/tests/signing_test.rs b/tests/signing_test.rs
index 04db8ce..c56c581 100644
--- a/tests/signing_test.rs
+++ b/tests/signing_test.rs
@@ -1,7 +1,7 @@
use git_collab::event::{Action, Author, Event};
use git_collab::signing::{
    canonical_json, generate_keypair, load_signing_key, load_verifying_key, sign_event,
    verify_signed_event, SignedEvent, VerifyStatus,
    verify_detached, DetachedSignature, VerifyStatus,
};
use tempfile::tempdir;

@@ -19,7 +19,7 @@ fn make_event() -> Event {
    }
}

// ── T004: Key generation and storage ──
// -- T004: Key generation and storage --

#[test]
fn generate_keypair_creates_key_files() {
@@ -93,7 +93,7 @@ fn load_verifying_key_missing_returns_error() {
    );
}

// ── T005: Sign/verify round-trip ──
// -- T005: Sign/verify round-trip --

#[test]
fn sign_event_produces_nonempty_signature_and_pubkey() {
@@ -103,33 +103,33 @@ fn sign_event_produces_nonempty_signature_and_pubkey() {
    let sk = load_signing_key(&config).unwrap();

    let event = make_event();
    let signed = sign_event(&event, &sk).unwrap();
    let detached = sign_event(&event, &sk).unwrap();

    assert!(!signed.signature.is_empty(), "signature should not be empty");
    assert!(!signed.pubkey.is_empty(), "pubkey should not be empty");
    assert!(!detached.signature.is_empty(), "signature should not be empty");
    assert!(!detached.pubkey.is_empty(), "pubkey should not be empty");

    // Verify they are valid base64
    use base64::engine::general_purpose::STANDARD;
    use base64::Engine;
    STANDARD
        .decode(&signed.signature)
        .decode(&detached.signature)
        .expect("signature should be valid base64");
    STANDARD
        .decode(&signed.pubkey)
        .decode(&detached.pubkey)
        .expect("pubkey should be valid base64");
}

#[test]
fn verify_valid_signed_event_returns_valid() {
fn verify_valid_detached_signature_returns_valid() {
    let dir = tempdir().unwrap();
    let config = dir.path().join("git-collab");
    generate_keypair(&config).unwrap();
    let sk = load_signing_key(&config).unwrap();

    let event = make_event();
    let signed = sign_event(&event, &sk).unwrap();
    let detached = sign_event(&event, &sk).unwrap();

    let status = verify_signed_event(&signed).unwrap();
    let status = verify_detached(&event, &detached).unwrap();
    assert_eq!(status, VerifyStatus::Valid);
}

@@ -141,29 +141,29 @@ fn verify_tampered_event_returns_invalid() {
    let sk = load_signing_key(&config).unwrap();

    let event = make_event();
    let mut signed = sign_event(&event, &sk).unwrap();
    let detached = sign_event(&event, &sk).unwrap();

    // Tamper with the event
    signed.event.author.name = "Mallory".to_string();
    let mut tampered = event.clone();
    tampered.author.name = "Mallory".to_string();

    let status = verify_signed_event(&signed).unwrap();
    let status = verify_detached(&tampered, &detached).unwrap();
    assert_eq!(status, VerifyStatus::Invalid);
}

#[test]
fn verify_missing_signature_returns_missing() {
    let event = make_event();
    let signed = SignedEvent {
        event,
    let detached = DetachedSignature {
        signature: String::new(),
        pubkey: String::new(),
    };

    let status = verify_signed_event(&signed).unwrap();
    let status = verify_detached(&event, &detached).unwrap();
    assert_eq!(status, VerifyStatus::Missing);
}

// ── T006: Canonical serialization ──
// -- T006: Canonical serialization --

#[test]
fn canonical_json_deterministic() {
@@ -176,31 +176,26 @@ fn canonical_json_deterministic() {
}

#[test]
fn signed_event_json_contains_all_fields() {
fn detached_signature_fields_are_valid() {
    let dir = tempdir().unwrap();
    let config = dir.path().join("git-collab");
    generate_keypair(&config).unwrap();
    let sk = load_signing_key(&config).unwrap();

    let event = make_event();
    let signed = sign_event(&event, &sk).unwrap();
    let detached = sign_event(&event, &sk).unwrap();

    let json = serde_json::to_string(&signed).unwrap();
    let value: serde_json::Value = serde_json::from_str(&json).unwrap();
    let obj = value.as_object().unwrap();

    // Flattened event fields
    assert!(obj.contains_key("timestamp"), "missing timestamp");
    assert!(obj.contains_key("author"), "missing author");
    assert!(obj.contains_key("action") || obj.contains_key("type"), "missing action/type");

    // Signature fields
    assert!(obj.contains_key("signature"), "missing signature");
    assert!(obj.contains_key("pubkey"), "missing pubkey");
    // Verify signature and pubkey are valid base64 strings
    use base64::engine::general_purpose::STANDARD;
    use base64::Engine;
    let sig_bytes = STANDARD.decode(&detached.signature).unwrap();
    let pk_bytes = STANDARD.decode(&detached.pubkey).unwrap();
    assert_eq!(sig_bytes.len(), 64, "Ed25519 signature should be 64 bytes");
    assert_eq!(pk_bytes.len(), 32, "Ed25519 public key should be 32 bytes");
}

#[test]
fn signed_event_flatten_round_trip_with_tagged_enum() {
fn event_json_uses_namespaced_action_types() {
    let event = Event {
        timestamp: "2026-03-21T12:00:00Z".to_string(),
        author: Author {
@@ -216,23 +211,13 @@ fn signed_event_flatten_round_trip_with_tagged_enum() {
        },
    };

    let signed = SignedEvent {
        event,
        signature: "dGVzdHNpZw==".to_string(),
        pubkey: "dGVzdGtleQ==".to_string(),
    };
    let json = serde_json::to_string(&event).unwrap();
    assert!(json.contains("\"type\":\"patch.create\""), "action type should be namespaced: {}", json);

    let json = serde_json::to_string_pretty(&signed).unwrap();
    let deserialized: SignedEvent = serde_json::from_str(&json).unwrap();

    assert_eq!(deserialized.signature, signed.signature);
    assert_eq!(deserialized.pubkey, signed.pubkey);
    match deserialized.event.action {
        Action::PatchCreate {
            ref title,
            ref fixes,
            ..
        } => {
    // Round-trip
    let deserialized: Event = serde_json::from_str(&json).unwrap();
    match deserialized.action {
        Action::PatchCreate { ref title, ref fixes, .. } => {
            assert_eq!(title, "Fix bug");
            assert_eq!(fixes.as_deref(), Some("deadbeef"));
        }
diff --git a/tests/sync_test.rs b/tests/sync_test.rs
index 34e6eb0..b73adb5 100644
--- a/tests/sync_test.rs
+++ b/tests/sync_test.rs
@@ -609,22 +609,38 @@ fn test_reconciliation_merge_commit_is_signed() {
    let commit = alice_repo.find_commit(tip).unwrap();
    // The tip should be the merge commit (it's the most recent)
    let tree = commit.tree().unwrap();
    let entry = tree.get_name("event.json").unwrap();
    let blob = alice_repo.find_blob(entry.id()).unwrap();
    let signed: signing::SignedEvent = serde_json::from_slice(blob.content()).unwrap();

    // Read event.json as a plain Event
    let event_entry = tree.get_name("event.json").unwrap();
    let event_blob = alice_repo.find_blob(event_entry.id()).unwrap();
    let event: git_collab::event::Event = serde_json::from_slice(event_blob.content()).unwrap();

    assert!(
        matches!(signed.event.action, Action::Merge),
        matches!(event.action, Action::Merge),
        "Expected tip commit to be a Merge event, got {:?}",
        signed.event.action
        event.action
    );

    // Read pubkey from separate blob
    let pk_entry = tree.get_name("pubkey").expect("pubkey blob should exist");
    let pk_blob = alice_repo.find_blob(pk_entry.id()).unwrap();
    let commit_pubkey = std::str::from_utf8(pk_blob.content()).unwrap().trim().to_string();

    assert_eq!(
        signed.pubkey, syncing_pubkey,
        commit_pubkey, syncing_pubkey,
        "Merge commit should be signed by the syncing user's key"
    );

    // Verify the signature is cryptographically valid
    let status = signing::verify_signed_event(&signed).unwrap();
    // Read signature from separate blob and verify
    let sig_entry = tree.get_name("signature").expect("signature blob should exist");
    let sig_blob = alice_repo.find_blob(sig_entry.id()).unwrap();
    let sig_str = std::str::from_utf8(sig_blob.content()).unwrap().trim().to_string();

    let detached = signing::DetachedSignature {
        signature: sig_str,
        pubkey: commit_pubkey,
    };
    let status = signing::verify_detached(&event, &detached).unwrap();
    assert_eq!(
        status,
        signing::VerifyStatus::Valid,