Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
radicle: introduce `cob::common::Title`
Merged did:key:z6MkkfM3...sVz5 opened 8 months ago

This patch adds the cob::common::Title struct, this allows instead of using std::str::String or generics and traits for it, to define more precisely what a title should be. It trims the provided string and makes sure it contains no carriage return or new line characters.

Where a std::str::String makes more sense so it’s easier to mutate it, we keep the code as is.

20 files changed +366 -187 2a0f6fd3 1d7478cd
modified crates/radicle-cli/src/commands/id.rs
@@ -4,6 +4,7 @@ use std::{ffi::OsString, io};
use anyhow::{anyhow, Context};

use radicle::cob::identity::{self, IdentityMut, Revision, RevisionId};
+
use radicle::cob::Title;
use radicle::identity::doc::update;
use radicle::identity::doc::update::EditVisibility;
use radicle::identity::{doc, Doc, Identity, RawDoc};
@@ -56,7 +57,7 @@ Options
#[derive(Clone, Debug, Default)]
pub enum Operation {
    Update {
-
        title: Option<String>,
+
        title: Option<Title>,
        description: Option<String>,
        delegate: Vec<Did>,
        rescind: Vec<Did>,
@@ -75,7 +76,7 @@ pub enum Operation {
    },
    EditRevision {
        revision: Rev,
-
        title: Option<String>,
+
        title: Option<Title>,
        description: Option<String>,
    },
    RedactRevision {
@@ -115,7 +116,7 @@ impl Args for Options {
        let mut op: Option<OperationName> = None;
        let mut revision: Option<Rev> = None;
        let mut rid: Option<RepoId> = None;
-
        let mut title: Option<String> = None;
+
        let mut title: Option<Title> = None;
        let mut description: Option<String> = None;
        let mut delegate: Vec<Did> = Vec::new();
        let mut rescind: Vec<Did> = Vec::new();
@@ -139,7 +140,8 @@ impl Args for Options {
                Long("title")
                    if op == Some(OperationName::Edit) || op == Some(OperationName::Update) =>
                {
-
                    title = Some(parser.value()?.to_string_lossy().into());
+
                    let val = parser.value()?;
+
                    title = Some(term::args::string(&val).try_into()?);
                }
                Long("description")
                    if op == Some(OperationName::Edit) || op == Some(OperationName::Update) =>
@@ -536,7 +538,7 @@ fn print_meta(revision: &Revision, previous: &Doc, profile: &Profile) -> anyhow:

    attrs.push([
        term::format::bold("Title").into(),
-
        term::label(revision.title.to_owned()),
+
        term::label(revision.title.to_string()),
    ]);
    attrs.push([
        term::format::bold("Revision").into(),
@@ -632,9 +634,9 @@ fn print(
}

fn edit_title_description(
-
    title: Option<String>,
+
    title: Option<Title>,
    description: Option<String>,
-
) -> anyhow::Result<Option<(String, String)>> {
+
) -> anyhow::Result<Option<(Title, String)>> {
    const HELP: &str = r#"<!--
Please enter a patch message for your changes. An empty
message aborts the patch proposal.
@@ -659,7 +661,7 @@ and description.
}

fn update<R, G>(
-
    title: Option<String>,
+
    title: Option<Title>,
    description: Option<String>,
    doc: Doc,
    current: &mut IdentityMut<R>,
modified crates/radicle-cli/src/commands/inbox.rs
@@ -364,7 +364,7 @@ impl NotificationRow {
    ) -> Self {
        Self {
            category: term::format::dim(category),
-
            summary: term::Paint::new(summary),
+
            summary: term::Paint::new(summary.to_string()),
            state,
            name: term::format::tertiary(name),
        }
@@ -455,7 +455,7 @@ impl NotificationRow {
            };
            (
                String::from("id"),
-
                rev.title.clone(),
+
                rev.title.to_string(),
                term::format::identity::state(&rev.state),
            )
        } else {
modified crates/radicle-cli/src/commands/issue.rs
@@ -9,7 +9,7 @@ use anyhow::{anyhow, Context as _};

use radicle::cob::common::{Label, Reaction};
use radicle::cob::issue::{CloseReason, State};
-
use radicle::cob::{issue, thread};
+
use radicle::cob::{issue, thread, Title};
use radicle::crypto;
use radicle::git::Oid;
use radicle::issue::cache::Issues as _;
@@ -107,11 +107,11 @@ pub enum Assigned {
pub enum Operation {
    Edit {
        id: Rev,
-
        title: Option<String>,
+
        title: Option<Title>,
        description: Option<String>,
    },
    Open {
-
        title: Option<String>,
+
        title: Option<Title>,
        description: Option<String>,
        labels: Vec<Label>,
        assignees: Vec<Did>,
@@ -189,7 +189,7 @@ impl Args for Options {
        let mut op: Option<OperationName> = None;
        let mut id: Option<Rev> = None;
        let mut assigned: Option<Assigned> = None;
-
        let mut title: Option<String> = None;
+
        let mut title: Option<Title> = None;
        let mut reaction: Option<Reaction> = None;
        let mut comment_id: Option<thread::CommentId> = None;
        let mut description: Option<String> = None;
@@ -236,7 +236,8 @@ impl Args for Options {
                Long("title")
                    if op == Some(OperationName::Open) || op == Some(OperationName::Edit) =>
                {
-
                    title = Some(parser.value()?.to_string_lossy().into());
+
                    let val = parser.value()?;
+
                    title = Some(term::args::string(&val).try_into()?);
                }
                Long("description")
                    if op == Some(OperationName::Open) || op == Some(OperationName::Edit) =>
@@ -828,7 +829,7 @@ where
}

fn open<R, G>(
-
    title: Option<String>,
+
    title: Option<Title>,
    description: Option<String>,
    labels: Vec<Label>,
    assignees: Vec<Did>,
@@ -849,7 +850,7 @@ where
        anyhow::bail!("aborting issue creation due to empty title or description");
    };
    let issue = cache.create(
-
        &title,
+
        title,
        description,
        labels.as_slice(),
        assignees.as_slice(),
@@ -867,7 +868,7 @@ fn edit<'a, 'g, R, G>(
    issues: &'g mut issue::Cache<issue::Issues<'a, R>, cob::cache::StoreWriter>,
    repo: &storage::git::Repository,
    id: Rev,
-
    title: Option<String>,
+
    title: Option<Title>,
    description: Option<String>,
    signer: &Device<G>,
) -> anyhow::Result<issue::IssueMut<'a, 'g, R, cob::cache::StoreWriter>>
@@ -896,7 +897,7 @@ where

    // Editing via the editor.
    let Some((title, description)) = term::issue::get_title_description(
-
        Some(title.unwrap_or(issue.title().to_owned())),
+
        title.and(Title::new(issue.title()).ok()),
        Some(description.unwrap_or(issue.description().to_owned())),
    )?
    else {
modified crates/radicle-cli/src/commands/patch/edit.rs
@@ -1,7 +1,7 @@
use super::*;

-
use radicle::cob;
use radicle::cob::patch;
+
use radicle::cob::{self, Title};
use radicle::crypto;
use radicle::node::device::Device;
use radicle::prelude::*;
@@ -24,21 +24,21 @@ pub fn run(
    let (title, description) = term::patch::get_edit_message(message, &patch)?;

    match revision_id {
-
        Some(id) => edit_revision(patch, id, title, description, &signer),
+
        Some(id) => edit_revision(patch, id, title.to_string(), description, &signer),
        None => edit_root(patch, title, description, &signer),
    }
}

fn edit_root<G>(
    mut patch: patch::PatchMut<'_, '_, Repository, cob::cache::StoreWriter>,
-
    title: String,
+
    title: Title,
    description: String,
    signer: &Device<G>,
) -> anyhow::Result<()>
where
    G: crypto::signature::Signer<crypto::Signature>,
{
-
    let title = if title != patch.title() {
+
    let title = if title.as_ref() != patch.title() {
        Some(title)
    } else {
        None
modified crates/radicle-cli/src/commands/publish.rs
@@ -2,6 +2,7 @@ use std::ffi::OsString;

use anyhow::{anyhow, Context as _};

+
use radicle::cob;
use radicle::identity::{Identity, Visibility};
use radicle::node::Handle as _;
use radicle::prelude::RepoId;
@@ -105,7 +106,12 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        doc.visibility = Visibility::Public;
    })?;

-
    identity.update("Publish repository", "", &doc, &signer)?;
+
    identity.update(
+
        cob::Title::new("Publish repository").unwrap(),
+
        "",
+
        &doc,
+
        &signer,
+
    )?;
    repo.sign_refs(&signer)?;
    repo.set_identity_head()?;
    let validations = repo.validate()?;
modified crates/radicle-cli/src/terminal/issue.rs
@@ -32,9 +32,9 @@ pub enum Format {
}

pub fn get_title_description(
-
    title: Option<String>,
+
    title: Option<cob::Title>,
    description: Option<String>,
-
) -> io::Result<Option<(String, String)>> {
+
) -> io::Result<Option<(cob::Title, String)>> {
    term::patch::Message::edit_title_description(title, description, OPEN_MSG)
}

modified crates/radicle-cli/src/terminal/patch.rs
@@ -8,8 +8,8 @@ use std::io::IsTerminal as _;

use thiserror::Error;

-
use radicle::cob;
use radicle::cob::patch;
+
use radicle::cob::{self, Title};
use radicle::git;
use radicle::patch::{Patch, PatchId};
use radicle::prelude::Profile;
@@ -72,14 +72,14 @@ impl Message {
    /// Open the editor with the given title and description (if any).
    /// Returns the edited title and description, or nothing if it couldn't be parsed.
    pub fn edit_title_description(
-
        title: Option<String>,
+
        title: Option<cob::Title>,
        description: Option<String>,
        help: &str,
-
    ) -> std::io::Result<Option<(String, String)>> {
+
    ) -> std::io::Result<Option<(Title, String)>> {
        let mut placeholder = String::new();

        if let Some(title) = title {
-
            placeholder.push_str(title.trim());
+
            placeholder.push_str(title.as_ref());
            placeholder.push('\n');
        }
        if let Some(description) = description {
@@ -94,12 +94,12 @@ impl Message {
            Some((x, y)) => (x, y),
            None => (output.as_str(), ""),
        };
-
        let (title, description) = (title.trim(), description.trim());

-
        if title.is_empty() | title.contains('\n') {
+
        let Ok(title) = Title::new(title) else {
            return Ok(None);
-
        }
-
        Ok(Some((title.to_owned(), description.to_owned())))
+
        };
+

+
        Ok(Some((title, description.trim().to_owned())))
    }

    pub fn append(&mut self, arg: &str) {
@@ -220,20 +220,19 @@ pub fn get_create_message(
    repo: &git::raw::Repository,
    base: &git::Oid,
    head: &git::Oid,
-
) -> Result<(String, String), Error> {
+
) -> Result<(Title, String), Error> {
    let display_msg = create_display_message(repo, base, head)?;
    let message = message.get(&display_msg)?;

    let (title, description) = message.split_once('\n').unwrap_or((&message, ""));
    let (title, description) = (title.trim().to_string(), description.trim().to_string());

-
    if title.is_empty() {
-
        return Err(io::Error::new(
+
    let title = Title::new(title.as_str()).map_err(|_| {
+
        io::Error::new(
            io::ErrorKind::InvalidInput,
            "a patch title must be provided",
        )
-
        .into());
-
    }
+
    })?;

    Ok((title, description))
}
@@ -249,7 +248,7 @@ fn edit_display_message(title: &str, description: &str) -> String {
pub fn get_edit_message(
    patch_message: term::patch::Message,
    patch: &cob::patch::Patch,
-
) -> io::Result<(String, String)> {
+
) -> io::Result<(Title, String)> {
    let display_msg = edit_display_message(patch.title(), patch.description());
    let patch_message = patch_message.get(&display_msg)?;
    let patch_message = patch_message.replace(PATCH_MSG.trim(), ""); // Delete help message.
@@ -259,12 +258,12 @@ pub fn get_edit_message(
        .unwrap_or((&patch_message, ""));
    let (title, description) = (title.trim().to_string(), description.trim().to_string());

-
    if title.is_empty() {
-
        return Err(io::Error::new(
+
    let title = Title::new(title.as_str()).map_err(|_| {
+
        io::Error::new(
            io::ErrorKind::InvalidInput,
            "a patch title must be provided",
-
        ));
-
    }
+
        )
+
    })?;

    Ok((title, description))
}
modified crates/radicle-cli/tests/commands.rs
@@ -2,6 +2,7 @@ use std::path::Path;
use std::str::FromStr;
use std::{net, thread, time};

+
use radicle::cob;
use radicle::git;
use radicle::node;
use radicle::node::address::Store as _;
@@ -1649,7 +1650,7 @@ fn test_cob_replication() {
    let mut bob_cache = radicle::cob::cache::InMemory::default();
    let issue = bob_issues
        .create(
-
            "Something's fishy",
+
            cob::Title::new("Something's fishy").unwrap(),
            "I don't know what it is",
            &[],
            &[],
@@ -1700,7 +1701,7 @@ fn test_cob_deletion() {
    let mut alice_issues = radicle::cob::issue::Cache::no_cache(&alice_repo).unwrap();
    let issue = alice_issues
        .create(
-
            "Something's fishy",
+
            cob::Title::new("Something's fishy").unwrap(),
            "I don't know what it is",
            &[],
            &[],
modified crates/radicle-node/src/test/node.rs
@@ -344,7 +344,7 @@ impl<G: Signer<Signature> + cyphernet::Ecdh> NodeHandle<G> {
    }

    /// Create an [`issue::Issue`] in the `NodeHandle`'s storage.
-
    pub fn issue(&self, rid: RepoId, title: &str, desc: &str) -> cob::ObjectId {
+
    pub fn issue(&self, rid: RepoId, title: cob::Title, desc: &str) -> cob::ObjectId {
        let repo = self.storage.repository(rid).unwrap();
        let mut issues = issue::Cache::no_cache(&repo).unwrap();
        *issues
modified crates/radicle-node/src/tests.rs
@@ -9,6 +9,7 @@ use std::sync::LazyLock;
use std::time;

use crossbeam_channel as chan;
+
use radicle::cob;
use radicle::identity::Visibility;
use radicle::node::address::Store as _;
use radicle::node::device::Device;
@@ -1013,7 +1014,14 @@ fn test_refs_announcement_offline() {
    let old_refs = RefsAt::new(&repo, alice.id).unwrap();
    let mut issues = radicle::issue::Cache::no_cache(&repo).unwrap();
    issues
-
        .create("Issue while offline!", "", &[], &[], [], alice.signer())
+
        .create(
+
            cob::Title::new("Issue while offline!").unwrap(),
+
            "",
+
            &[],
+
            &[],
+
            [],
+
            alice.signer(),
+
        )
        .unwrap();
    let new_refs = RefsAt::new(&repo, alice.id).unwrap();
    assert_ne!(old_refs, new_refs);
modified crates/radicle-node/src/tests/e2e.rs
@@ -1,5 +1,6 @@
use std::{collections::HashSet, thread, time};

+
use radicle::cob::Title;
use test_log::test;

use radicle::node::device::Device;
@@ -399,7 +400,7 @@ fn test_dont_fetch_owned_refs() {

    log::debug!(target: "test", "Fetch complete with {}", bob.id);

-
    alice.issue(acme, "Don't fetch self", "Use ^");
+
    alice.issue(acme, Title::new("Don't fetch self").unwrap(), "Use ^");
    let result = alice.handle.fetch(acme, bob.id, DEFAULT_TIMEOUT).unwrap();
    assert!(result.is_success())
}
@@ -478,7 +479,11 @@ fn test_missing_remote() {
    log::debug!(target: "test", "Fetch complete with {}", bob.id);
    rad::fork_remote(acme, &alice.id, &carol, &bob.storage).unwrap();

-
    alice.issue(acme, "Missing Remote", "Fixing the missing remote issue");
+
    alice.issue(
+
        acme,
+
        Title::new("Missing Remote").unwrap(),
+
        "Fixing the missing remote issue",
+
    );
    let result = bob.handle.fetch(acme, alice.id, DEFAULT_TIMEOUT).unwrap();
    assert!(result.is_success());
    log::debug!(target: "test", "Fetch complete with {}", bob.id);
@@ -504,7 +509,7 @@ fn test_fetch_preserve_owned_refs() {

    log::debug!(target: "test", "Fetch complete with {}", bob.id);

-
    alice.issue(acme, "Bug", "Bugs, bugs, bugs");
+
    alice.issue(acme, Title::new("Bug").unwrap(), "Bugs, bugs, bugs");

    let before = alice
        .storage
@@ -906,7 +911,7 @@ fn test_non_fastforward_sigrefs() {
    // Bob updates his refs.
    bob.issue(
        rid,
-
        "Updated Sigrefs",
+
        Title::new("Updated Sigrefs").unwrap(),
        "Updated sigrefs are harshing my vibes",
    );
    // Alice fetches from Bob.
@@ -999,7 +1004,7 @@ fn test_outdated_sigrefs() {

    let issue_id = eve.issue(
        rid,
-
        "Outdated Sigrefs",
+
        Title::new("Outdated Sigrefs").unwrap(),
        "Outdated sigrefs are harshing my vibes",
    );
    let repo = eve.storage.repository(rid).unwrap();
@@ -1093,7 +1098,7 @@ fn test_outdated_delegate_sigrefs() {

    alice.issue(
        rid,
-
        "Outdated Sigrefs",
+
        Title::new("Outdated Sigrefs").unwrap(),
        "Outdated sigrefs are harshing my vibes",
    );
    let repo = alice.storage.repository(rid).unwrap();
@@ -1150,7 +1155,11 @@ fn missing_default_branch() {

    // Fetching from still works despite not having
    // `refs/heads/master`, but has `rad/sigrefs`.
-
    bob.issue(rid, "Hello, Acme", "Popping in to say hello");
+
    bob.issue(
+
        rid,
+
        Title::new("Hello, Acme").unwrap(),
+
        "Popping in to say hello",
+
    );
    alice.handle.fetch(rid, bob.id, DEFAULT_TIMEOUT).unwrap();

    {
@@ -1244,7 +1253,9 @@ fn missing_delegate_default_branch() {
                doc.delegate(bob.signer.public_key().into());
            })
            .unwrap();
-
        let rev = identity.update("Add Bob", "", &doc, &alice.signer).unwrap();
+
        let rev = identity
+
            .update(Title::new("Add Bob").unwrap(), "", &doc, &alice.signer)
+
            .unwrap();
        repo.set_identity_head_to(rev).unwrap();

        let new = repo.identity_doc().unwrap().doc;
@@ -1261,7 +1272,7 @@ fn missing_delegate_default_branch() {
    // Create an issue to ensure there are new refs to fetch
    let issue = bob.issue(
        rid,
-
        "Delegate Issue",
+
        Title::new("Delegate Issue").unwrap(),
        "Further investigation into delegates",
    );
    let assert_bobs_issue_exists = |repo: &Repository| {
@@ -1349,7 +1360,7 @@ fn test_background_foreground_fetch() {
    // the new refs
    eve.issue(
        rid,
-
        "Outdated Sigrefs",
+
        Title::new("Outdated Sigrefs").unwrap(),
        "Outdated sigrefs are harshing my vibes",
    );
    let repo = eve.storage.repository(rid).unwrap();
@@ -1363,7 +1374,7 @@ fn test_background_foreground_fetch() {
        .unwrap();
    bob.issue(
        rid,
-
        "Concurrent fetches",
+
        Title::new("Concurrent fetches").unwrap(),
        "Concurrent fetches are harshing my vibes",
    );
    bob.handle.announce_refs(rid).unwrap();
@@ -1414,7 +1425,7 @@ fn test_catchup_on_refs_announcements() {
    bob.has_repository(&acme);

    log::debug!(target: "test", "Bob creating his issue..");
-
    bob.issue(acme, "Bob's issue", "[..]");
+
    bob.issue(acme, Title::new("Bob's issue").unwrap(), "[..]");
    bob.handle.announce_refs(acme).unwrap();

    log::debug!(target: "test", "Waiting for seed to fetch Bob's refs from Bob..");
modified crates/radicle-remote-helper/src/push.rs
@@ -500,7 +500,7 @@ where

    let patch = if opts.draft {
        patches.draft(
-
            &title,
+
            title,
            &description,
            patch::MergeTarget::default(),
            base,
@@ -510,7 +510,7 @@ where
        )
    } else {
        patches.create(
-
            &title,
+
            title,
            &description,
            patch::MergeTarget::default(),
            base,
modified crates/radicle/src/cob/common.rs
@@ -7,6 +7,7 @@ use std::str::FromStr;
use base64::prelude::{Engine, BASE64_STANDARD};
use localtime::LocalTime;
use serde::{Deserialize, Serialize};
+
use thiserror::Error;

use crate::git::Oid;
use crate::prelude::{Did, PublicKey};
@@ -42,6 +43,53 @@ impl Deref for Timestamp {
    }
}

+
#[derive(Error, Debug)]
+
pub enum TitleError {
+
    #[error("empty title")]
+
    EmptyTitle,
+
    #[error("invalid title")]
+
    InvalidTitle,
+
}
+

+
/// Title
+
#[derive(Display, Deserialize, Serialize, PartialEq, Eq, Clone, Debug)]
+
#[display(inner)]
+
pub struct Title(String);
+

+
impl Title {
+
    pub fn new(title: &str) -> Result<Self, TitleError> {
+
        if title.contains('\n') || title.contains('\r') {
+
            Err(TitleError::InvalidTitle)
+
        } else if title.is_empty() {
+
            Err(TitleError::EmptyTitle)
+
        } else {
+
            Ok(Self(title.trim().to_string()))
+
        }
+
    }
+
}
+

+
impl AsRef<str> for Title {
+
    fn as_ref(&self) -> &str {
+
        &self.0
+
    }
+
}
+

+
impl TryFrom<&str> for Title {
+
    type Error = TitleError;
+

+
    fn try_from(value: &str) -> Result<Self, Self::Error> {
+
        Self::new(value)
+
    }
+
}
+

+
impl TryFrom<String> for Title {
+
    type Error = TitleError;
+

+
    fn try_from(value: String) -> Result<Self, Self::Error> {
+
        Self::new(&value)
+
    }
+
}
+

/// Author.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct Author {
modified crates/radicle/src/cob/identity.rs
@@ -45,7 +45,7 @@ pub enum Action {
    #[serde(rename = "revision")]
    Revision {
        /// Short summary of changes.
-
        title: String,
+
        title: cob::Title,
        /// Longer comment on proposed changes.
        #[serde(default, skip_serializing_if = "String::is_empty")]
        description: String,
@@ -61,7 +61,7 @@ pub enum Action {
        /// The revision to edit.
        revision: RevisionId,
        /// Short summary of changes.
-
        title: String,
+
        title: cob::Title,
        /// Longer comment on proposed changes.
        #[serde(default, skip_serializing_if = "String::is_empty")]
        description: String,
@@ -207,7 +207,18 @@ impl Identity {
            "Initialize identity",
            &mut store,
            signer,
-
            |tx, repo| tx.revision("Initial revision", "", doc, None, repo, signer),
+
            |tx, repo| {
+
                tx.revision(
+
                    // SAFETY: "Initial revision" is a valid title
+
                    #[allow(clippy::unwrap_used)]
+
                    cob::Title::new("Initial revision").unwrap(),
+
                    "",
+
                    doc,
+
                    None,
+
                    repo,
+
                    signer,
+
                )
+
            },
        )?;

        Ok(IdentityMut {
@@ -680,7 +691,7 @@ pub struct Revision {
    /// Identity document blob at this revision.
    pub blob: Oid,
    /// Title of the proposal.
-
    pub title: String,
+
    pub title: cob::Title,
    /// State of the revision.
    pub state: State,
    /// Description of the proposal.
@@ -749,7 +760,7 @@ impl Revision {
impl Revision {
    fn new(
        id: RevisionId,
-
        title: String,
+
        title: cob::Title,
        description: String,
        author: Author,
        blob: Oid,
@@ -831,12 +842,12 @@ impl<R: ReadRepository> store::Transaction<Identity, R> {
    pub fn edit(
        &mut self,
        revision: RevisionId,
-
        title: impl ToString,
+
        title: cob::Title,
        description: impl ToString,
    ) -> Result<(), store::Error> {
        self.push(Action::RevisionEdit {
            revision,
-
            title: title.to_string(),
+
            title,
            description: description.to_string(),
        })
    }
@@ -849,7 +860,7 @@ impl<R: ReadRepository> store::Transaction<Identity, R> {
impl<R: WriteRepository> store::Transaction<Identity, R> {
    pub fn revision<G: crypto::signature::Signer<crypto::Signature>>(
        &mut self,
-
        title: impl ToString,
+
        title: cob::Title,
        description: impl ToString,
        doc: &Doc,
        parent: Option<RevisionId>,
@@ -867,7 +878,7 @@ impl<R: WriteRepository> store::Transaction<Identity, R> {

        // Revision metadata.
        self.push(Action::Revision {
-
            title: title.to_string(),
+
            title,
            description: description.to_string(),
            blob,
            parent,
@@ -929,7 +940,7 @@ where
    /// If the signer is the only delegate, the revision is accepted automatically.
    pub fn update<G>(
        &mut self,
-
        title: impl ToString,
+
        title: cob::Title,
        description: impl ToString,
        doc: &Doc,
        signer: &Device<G>,
@@ -977,7 +988,7 @@ where
    pub fn edit<G>(
        &mut self,
        revision: RevisionId,
-
        title: String,
+
        title: cob::Title,
        description: String,
        signer: &Device<G>,
    ) -> Result<EntryId, Error>
@@ -1033,7 +1044,7 @@ mod lookup {
mod test {
    use qcheck_macros::quickcheck;

-
    use crate::cob;
+
    use crate::cob::{self, Title};
    use crate::crypto::PublicKey;
    use crate::identity::did::Did;
    use crate::identity::doc::PayloadId;
@@ -1065,7 +1076,7 @@ mod test {
        let signer = &node.signer;
        let mut identity = Identity::load_mut(&*repo).unwrap();
        let mut doc = identity.doc().clone().edit();
-
        let title = "Identity update";
+
        let title = Title::new("Identity update").unwrap();
        let description = "";
        let r0 = identity.current;

@@ -1073,7 +1084,12 @@ mod test {
        assert!(identity.current().is_accepted());
        // Using an identical document to the current one fails.
        identity
-
            .update(title, description, &doc.clone().verified().unwrap(), signer)
+
            .update(
+
                title.clone(),
+
                description,
+
                &doc.clone().verified().unwrap(),
+
                signer,
+
            )
            .unwrap_err();
        assert_eq!(identity.current, r0);

@@ -1086,7 +1102,12 @@ mod test {
        doc.delegate(bob.public_key().into());
        // The update should go through now.
        let r1 = identity
-
            .update(title, description, &doc.clone().verified().unwrap(), signer)
+
            .update(
+
                title.clone(),
+
                description,
+
                &doc.clone().verified().unwrap(),
+
                signer,
+
            )
            .unwrap();
        assert!(identity.revision(&r1).unwrap().is_accepted());
        assert_eq!(identity.current, r1);
@@ -1095,7 +1116,12 @@ mod test {
        // signs it.
        doc.visibility = Visibility::private([]);
        let r2 = identity
-
            .update(title, description, &doc.clone().verified().unwrap(), signer)
+
            .update(
+
                title.clone(),
+
                description,
+
                &doc.clone().verified().unwrap(),
+
                signer,
+
            )
            .unwrap();
        // R1 is still the head.
        assert_eq!(identity.current, r1);
@@ -1126,20 +1152,24 @@ mod test {
        let signer = &node.signer;
        let mut identity = Identity::load_mut(&*repo).unwrap();
        let mut doc = identity.doc().clone().edit();
-
        let title = "Identity update";
        let description = "";

        // Let's add another delegate.
        doc.delegate(bob.public_key().into());
        let r1 = identity
-
            .update(title, description, &doc.clone().verified().unwrap(), signer)
+
            .update(
+
                cob::Title::new("Identity update").unwrap(),
+
                description,
+
                &doc.clone().verified().unwrap(),
+
                signer,
+
            )
            .unwrap();
        assert_eq!(identity.current, r1);

        doc.visibility = Visibility::private([]);
        let r2 = identity
            .update(
-
                "Make private",
+
                cob::Title::new("Make private").unwrap(),
                description,
                &doc.clone().verified().unwrap(),
                &node.signer,
@@ -1155,7 +1185,7 @@ mod test {
        doc.delegate(eve.public_key().into());
        let r3 = identity
            .update(
-
                "Add Eve",
+
                cob::Title::new("Add Eve").unwrap(),
                description,
                &doc.clone().verified().unwrap(),
                &node.signer,
@@ -1167,7 +1197,7 @@ mod test {
        doc.visibility = Visibility::Public;
        let r3 = identity
            .update(
-
                "Make public",
+
                cob::Title::new("Make public").unwrap(),
                description,
                &doc.verified().unwrap(),
                &node.signer,
@@ -1197,7 +1227,7 @@ mod test {
        alice_doc.delegate(bob.signer.public_key().into());
        let a1 = alice_identity
            .update(
-
                "Add Bob",
+
                cob::Title::new("Add Bob").unwrap(),
                "",
                &alice_doc.clone().verified().unwrap(),
                &alice.signer,
@@ -1214,7 +1244,7 @@ mod test {
        alice_doc.visibility = Visibility::private([]);
        let a2 = alice_identity
            .update(
-
                "Change visibility",
+
                cob::Title::new("Change visibility").unwrap(),
                "",
                &alice_doc.clone().clone().verified().unwrap(),
                &alice.signer,
@@ -1223,7 +1253,7 @@ mod test {
        // Bob makes the same change without knowing Alice already did.
        let b1 = bob_identity
            .update(
-
                "Make private",
+
                cob::Title::new("Make private").unwrap(),
                "",
                &alice_doc.verified().unwrap(),
                &bob.signer,
@@ -1265,7 +1295,7 @@ mod test {
        let a0 = alice_identity.root;
        let a1 = alice_identity
            .update(
-
                "Add Bob",
+
                cob::Title::new("Add Bob").unwrap(),
                "Eh.",
                &alice_doc.clone().clone().verified().unwrap(),
                &alice.signer,
@@ -1275,7 +1305,7 @@ mod test {
        alice_doc.visibility = Visibility::private([eve.signer.public_key().into()]);
        let a2 = alice_identity
            .update(
-
                "Change visibility",
+
                cob::Title::new("Change visibility").unwrap(),
                "Eh.",
                &alice_doc.verified().unwrap(),
                &alice.signer,
@@ -1315,7 +1345,7 @@ mod test {
        let a0 = alice_identity.root;
        let a1 = alice_identity // Change description to change traversal order.
            .update(
-
                "Add Bob and Eve",
+
                cob::Title::new("Add Bob and Eve").unwrap(),
                "Eh#!",
                &alice_doc.clone().verified().unwrap(),
                &alice.signer,
@@ -1325,7 +1355,7 @@ mod test {
        alice_doc.rescind(&eve.signer.public_key().into()).unwrap();
        let a2 = alice_identity
            .update(
-
                "Remove Eve",
+
                cob::Title::new("Remove Eve").unwrap(),
                "",
                &alice_doc.verified().unwrap(),
                &alice.signer,
@@ -1348,7 +1378,7 @@ mod test {
        let e1 = cob::git::stable::with_advanced_timestamp(|| {
            eve_identity
                .update(
-
                    "Change visibility",
+
                    cob::Title::new("Change visibility").unwrap(),
                    "",
                    &eve_doc.verified().unwrap(),
                    &eve.signer,
@@ -1391,7 +1421,7 @@ mod test {
        let a0 = alice_identity.root;
        let a1 = alice_identity
            .update(
-
                "Add Bob and Eve",
+
                cob::Title::new("Add Bob and Eve").unwrap(),
                "Eh!#",
                &alice_doc.clone().verified().unwrap(),
                &alice.signer,
@@ -1401,7 +1431,7 @@ mod test {
        alice_doc.visibility = Visibility::private([]);
        let a2 = alice_identity
            .update(
-
                "Change visibility",
+
                cob::Title::new("Change visibility").unwrap(),
                "",
                &alice_doc.verified().unwrap(),
                &alice.signer,
@@ -1426,7 +1456,7 @@ mod test {
        eve_doc.visibility = Visibility::private([eve.signer.public_key().into()]);
        let e2 = eve_identity
            .update(
-
                "Change visibility",
+
                cob::Title::new("Change visibility").unwrap(),
                "",
                &eve_doc.verified().unwrap(),
                &eve.signer,
@@ -1477,7 +1507,7 @@ mod test {
        let a0 = alice_identity.root;
        let a1 = alice_identity
            .update(
-
                "Add Bob and Eve",
+
                cob::Title::new("Add Bob and Eve").unwrap(),
                "",
                &alice_doc.verified().unwrap(),
                &alice.signer,
@@ -1503,7 +1533,7 @@ mod test {
        bob_doc.visibility = Visibility::private([]);
        let b1 = bob_identity
            .update(
-
                "Change visibility #1",
+
                cob::Title::new("Change visibility #1").unwrap(),
                "",
                &bob_doc.verified().unwrap(),
                &bob.signer,
@@ -1518,7 +1548,7 @@ mod test {
        eve_doc.visibility = Visibility::private([]);
        let e1 = eve_identity
            .update(
-
                "Change visibility #2",
+
                cob::Title::new("Change visibility #2").unwrap(),
                "Woops",
                &eve_doc.verified().unwrap(),
                &eve.signer,
@@ -1568,7 +1598,7 @@ mod test {
        doc.payload.insert(PayloadId::project(), prj.clone().into());
        identity
            .update(
-
                "Update description",
+
                cob::Title::new("Update description").unwrap(),
                "",
                &doc.clone().verified().unwrap(),
                &alice,
@@ -1579,7 +1609,12 @@ mod test {
        doc.delegate(bob.public_key().into());
        doc.threshold = 2;
        identity
-
            .update("Add bob", "", &doc.clone().verified().unwrap(), &alice)
+
            .update(
+
                cob::Title::new("Add bob").unwrap(),
+
                "",
+
                &doc.clone().verified().unwrap(),
+
                &alice,
+
            )
            .unwrap();

        // Add Eve as a delegate.
@@ -1587,7 +1622,12 @@ mod test {

        // Update with both Bob and Alice's signature.
        let revision = identity
-
            .update("Add eve", "", &doc.clone().verified().unwrap(), &alice)
+
            .update(
+
                cob::Title::new("Add eve").unwrap(),
+
                "",
+
                &doc.clone().verified().unwrap(),
+
                &alice,
+
            )
            .unwrap();
        identity.accept(&revision, &bob).unwrap();

@@ -1598,7 +1638,7 @@ mod test {

        let revision = identity
            .update(
-
                "Update description again",
+
                cob::Title::new("Update description again").unwrap(),
                "Bob's repository",
                &doc.verified().unwrap(),
                &bob,
modified crates/radicle/src/cob/issue.rs
@@ -12,9 +12,9 @@ use crate::cob;
use crate::cob::common::{Author, Authorization, Label, Reaction, Timestamp, Uri};
use crate::cob::store::Transaction;
use crate::cob::store::{Cob, CobAction};
-
use crate::cob::thread;
use crate::cob::thread::{Comment, CommentId, Thread};
use crate::cob::{op, store, ActorId, Embed, EntryId, ObjectId, TypeName};
+
use crate::cob::{thread, TitleError};
use crate::identity::doc::DocError;
use crate::node::device::Device;
use crate::node::NodeId;
@@ -43,6 +43,8 @@ pub enum Error {
    Thread(#[from] thread::Error),
    #[error("store: {0}")]
    Store(#[from] store::Error),
+
    #[error("invalid title: {0}")]
+
    TitleError(#[from] TitleError),
    /// Action not authorized.
    #[error("{0} not authorized to apply {1:?}")]
    NotAuthorized(ActorId, Action),
@@ -414,10 +416,7 @@ impl Issue {
                self.assignees = BTreeSet::from_iter(assignees);
            }
            Action::Edit { title } => {
-
                if title.contains('\n') || title.contains('\r') {
-
                    return Err(Error::InvalidTitle(title));
-
                }
-
                self.title = title;
+
                self.title = title.to_string();
            }
            Action::Lifecycle { state } => {
                self.state = state;
@@ -501,10 +500,8 @@ impl<R: ReadRepository> store::Transaction<Issue, R> {
    }

    /// Set the issue title.
-
    pub fn edit(&mut self, title: impl ToString) -> Result<(), store::Error> {
-
        self.push(Action::Edit {
-
            title: title.to_string(),
-
        })
+
    pub fn edit(&mut self, title: cob::Title) -> Result<(), store::Error> {
+
        self.push(Action::Edit { title })
    }

    /// Redact a comment.
@@ -621,7 +618,7 @@ where
    }

    /// Set the issue title.
-
    pub fn edit<G>(&mut self, title: impl ToString, signer: &Device<G>) -> Result<EntryId, Error>
+
    pub fn edit<G>(&mut self, title: cob::Title, signer: &Device<G>) -> Result<EntryId, Error>
    where
        G: crypto::signature::Signer<crypto::Signature>,
    {
@@ -811,7 +808,7 @@ where
    /// Create a new issue.
    pub fn create<'g, G, C>(
        &'g mut self,
-
        title: impl ToString,
+
        title: cob::Title,
        description: impl ToString,
        labels: &[Label],
        assignees: &[Did],
@@ -912,7 +909,7 @@ pub enum Action {

    /// Edit issue title.
    #[serde(rename = "edit")]
-
    Edit { title: String },
+
    Edit { title: cob::Title },

    /// Transition to a different state.
    #[serde(rename = "lifecycle")]
@@ -988,7 +985,7 @@ mod test {
        let mut eve_issues = Cache::no_cache(&*t.eve.repo).unwrap();
        let mut issue_alice = issues_alice
            .create(
-
                "Alice Issue",
+
                cob::Title::new("Alice Issue").unwrap(),
                "Alice's comment",
                &[],
                &[],
@@ -1077,7 +1074,7 @@ mod test {
        let assignee_two = Did::from(arbitrary::gen::<ActorId>(1));
        let issue = issues
            .create(
-
                "My first issue",
+
                cob::Title::new("My first issue").unwrap(),
                "Blah blah blah.",
                &[],
                &[assignee],
@@ -1116,7 +1113,7 @@ mod test {
        let assignee_two = Did::from(arbitrary::gen::<ActorId>(1));
        let mut issue = issues
            .create(
-
                "My first issue",
+
                cob::Title::new("My first issue").unwrap(),
                "Blah blah blah.",
                &[],
                &[assignee, assignee_two],
@@ -1146,7 +1143,7 @@ mod test {
        let mut issues = Cache::no_cache(&*repo).unwrap();
        let created = issues
            .create(
-
                "My first issue",
+
                cob::Title::new("My first issue").unwrap(),
                "Blah blah blah.",
                &[],
                &[],
@@ -1172,7 +1169,7 @@ mod test {
        let mut issues = Cache::no_cache(&*repo).unwrap();
        let mut issue = issues
            .create(
-
                "My first issue",
+
                cob::Title::new("My first issue").unwrap(),
                "Blah blah blah.",
                &[],
                &[],
@@ -1215,7 +1212,7 @@ mod test {
        let assignee_two = Did::from(arbitrary::gen::<ActorId>(1));
        let mut issue = issues
            .create(
-
                "My first issue",
+
                cob::Title::new("My first issue").unwrap(),
                "Blah blah blah.",
                &[],
                &[assignee, assignee_two],
@@ -1241,7 +1238,7 @@ mod test {

        let mut issue = issues
            .create(
-
                "My first issue",
+
                cob::Title::new("My first issue").unwrap(),
                "Blah blah blah.",
                &[],
                &[],
@@ -1250,7 +1247,9 @@ mod test {
            )
            .unwrap();

-
        issue.edit("Sorry typo", &node.signer).unwrap();
+
        issue
+
            .edit(cob::Title::new("Sorry typo").unwrap(), &node.signer)
+
            .unwrap();

        let id = issue.id;
        let issue = issues.get(&id).unwrap().unwrap();
@@ -1265,7 +1264,7 @@ mod test {
        let mut issues = Cache::no_cache(&*repo).unwrap();
        let mut issue = issues
            .create(
-
                "My first issue",
+
                cob::Title::new("My first issue").unwrap(),
                "Blah blah blah.",
                &[],
                &[],
@@ -1291,7 +1290,7 @@ mod test {
        let mut issues = Cache::no_cache(&*repo).unwrap();
        let mut issue = issues
            .create(
-
                "My first issue",
+
                cob::Title::new("My first issue").unwrap(),
                "Blah blah blah.",
                &[],
                &[],
@@ -1321,7 +1320,7 @@ mod test {
        let mut issues = Cache::no_cache(&*repo).unwrap();
        let mut issue = issues
            .create(
-
                "My first issue",
+
                cob::Title::new("My first issue").unwrap(),
                "Blah blah blah.",
                &[],
                &[],
@@ -1379,7 +1378,7 @@ mod test {
        let wontfix_label = Label::new("wontfix").unwrap();
        let mut issue = issues
            .create(
-
                "My first issue",
+
                cob::Title::new("My first issue").unwrap(),
                "Blah blah blah.",
                &[ux_label.clone()],
                &[],
@@ -1414,7 +1413,7 @@ mod test {
        let mut issues = Cache::no_cache(&*repo).unwrap();
        let mut issue = issues
            .create(
-
                "My first issue",
+
                cob::Title::new("My first issue").unwrap(),
                "Blah blah blah.",
                &[],
                &[],
@@ -1454,7 +1453,7 @@ mod test {
        let mut issues = Cache::no_cache(&*repo).unwrap();
        let mut issue = issues
            .create(
-
                "My first issue",
+
                cob::Title::new("My first issue").unwrap(),
                "Blah blah blah.",
                &[],
                &[],
@@ -1501,13 +1500,34 @@ mod test {
        let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
        let mut issues = Cache::no_cache(&*repo).unwrap();
        issues
-
            .create("First", "Blah", &[], &[], [], &node.signer)
+
            .create(
+
                cob::Title::new("First").unwrap(),
+
                "Blah",
+
                &[],
+
                &[],
+
                [],
+
                &node.signer,
+
            )
            .unwrap();
        issues
-
            .create("Second", "Blah", &[], &[], [], &node.signer)
+
            .create(
+
                cob::Title::new("Second").unwrap(),
+
                "Blah",
+
                &[],
+
                &[],
+
                [],
+
                &node.signer,
+
            )
            .unwrap();
        issues
-
            .create("Third", "Blah", &[], &[], [], &node.signer)
+
            .create(
+
                cob::Title::new("Third").unwrap(),
+
                "Blah",
+
                &[],
+
                &[],
+
                [],
+
                &node.signer,
+
            )
            .unwrap();

        let issues = issues
@@ -1530,7 +1550,7 @@ mod test {
        let mut issues = Cache::no_cache(&*repo).unwrap();
        let created = issues
            .create(
-
                "My first issue",
+
                cob::Title::new("My first issue").unwrap(),
                "Blah blah blah.\nYah yah yah",
                &[],
                &[],
@@ -1573,7 +1593,7 @@ mod test {
        };
        let mut issue = issues
            .create(
-
                "My first issue",
+
                cob::Title::new("My first issue").unwrap(),
                "Blah blah blah.",
                &[],
                &[],
@@ -1632,7 +1652,7 @@ mod test {
        };
        let mut issue = issues
            .create(
-
                "My first issue",
+
                cob::Title::new("My first issue").unwrap(),
                "Blah blah blah.",
                &[],
                &[],
@@ -1664,7 +1684,7 @@ mod test {
        let mut issues = Cache::no_cache(&*repo).unwrap();
        let mut issue = issues
            .create(
-
                "My first issue",
+
                cob::Title::new("My first issue").unwrap(),
                "Blah blah blah.",
                &[],
                &[],
@@ -1698,7 +1718,7 @@ mod test {
        let mut issues = Cache::no_cache(&*repo).unwrap();
        let mut issue = issues
            .create(
-
                "My first issue",
+
                cob::Title::new("My first issue").unwrap(),
                "Blah blah blah.",
                &[],
                &[],
@@ -1725,7 +1745,7 @@ mod test {
        let mut issues = Cache::no_cache(&*repo).unwrap();
        let issue = issues
            .create(
-
                "My first issue",
+
                cob::Title::new("My first issue").unwrap(),
                "Blah blah blah.",
                &[],
                &[],
@@ -1756,7 +1776,7 @@ mod test {
        let mut issues = Cache::no_cache(&*repo).unwrap();
        let mut issue = issues
            .create(
-
                "My first issue",
+
                cob::Title::new("My first issue").unwrap(),
                "Blah blah blah.",
                &[],
                &[],
modified crates/radicle/src/cob/issue/cache.rs
@@ -100,7 +100,7 @@ impl<'a, R, C> Cache<super::Issues<'a, R>, C> {
    /// main storage, and writing the update to the `cache`.
    pub fn create<'g, G>(
        &'g mut self,
-
        title: impl ToString,
+
        title: cob::Title,
        description: impl ToString,
        labels: &[Label],
        assignees: &[Did],
modified crates/radicle/src/cob/patch.rs
@@ -166,7 +166,10 @@ pub enum Action {
    // Actions on patch.
    //
    #[serde(rename = "edit")]
-
    Edit { title: String, target: MergeTarget },
+
    Edit {
+
        title: cob::Title,
+
        target: MergeTarget,
+
    },
    #[serde(rename = "label")]
    Label { labels: BTreeSet<Label> },
    #[serde(rename = "lifecycle")]
@@ -413,7 +416,7 @@ impl MergeTarget {
#[serde(rename_all = "camelCase")]
pub struct Patch {
    /// Title of the patch.
-
    pub(super) title: String,
+
    pub(super) title: cob::Title,
    /// Patch author.
    pub(super) author: Author,
    /// Current state of the patch.
@@ -446,7 +449,11 @@ pub struct Patch {

impl Patch {
    /// Construct a new patch object from a revision.
-
    pub fn new(title: String, target: MergeTarget, (id, revision): (RevisionId, Revision)) -> Self {
+
    pub fn new(
+
        title: cob::Title,
+
        target: MergeTarget,
+
        (id, revision): (RevisionId, Revision),
+
    ) -> Self {
        Self {
            title,
            author: revision.author.clone(),
@@ -463,7 +470,7 @@ impl Patch {

    /// Title of the patch.
    pub fn title(&self) -> &str {
-
        self.title.as_str()
+
        self.title.as_ref()
    }

    /// Current state of the patch.
@@ -1733,11 +1740,8 @@ impl Review {
}

impl<R: ReadRepository> store::Transaction<Patch, R> {
-
    pub fn edit(&mut self, title: impl ToString, target: MergeTarget) -> Result<(), store::Error> {
-
        self.push(Action::Edit {
-
            title: title.to_string(),
-
            target,
-
        })
+
    pub fn edit(&mut self, title: cob::Title, target: MergeTarget) -> Result<(), store::Error> {
+
        self.push(Action::Edit { title, target })
    }

    pub fn edit_revision(
@@ -2086,7 +2090,7 @@ where
    /// Edit patch metadata.
    pub fn edit<G, S>(
        &mut self,
-
        title: String,
+
        title: cob::Title,
        target: MergeTarget,
        signer: &Device<G>,
    ) -> Result<EntryId, Error>
@@ -2669,7 +2673,7 @@ where
    /// Open a new patch.
    pub fn create<'g, C, G>(
        &'g mut self,
-
        title: impl ToString,
+
        title: cob::Title,
        description: impl ToString,
        target: MergeTarget,
        base: impl Into<git::Oid>,
@@ -2698,7 +2702,7 @@ where
    /// Draft a patch. This patch will be created in a [`State::Draft`] state.
    pub fn draft<'g, C, G>(
        &'g mut self,
-
        title: impl ToString,
+
        title: cob::Title,
        description: impl ToString,
        target: MergeTarget,
        base: impl Into<git::Oid>,
@@ -2746,7 +2750,7 @@ where
    /// Create a patch. This is an internal function used by `create` and `draft`.
    fn _create<'g, C, G>(
        &'g mut self,
-
        title: impl ToString,
+
        title: cob::Title,
        description: impl ToString,
        target: MergeTarget,
        base: impl Into<git::Oid>,
@@ -3051,7 +3055,7 @@ mod test {
        let target = MergeTarget::Delegates;
        let patch = patches
            .create(
-
                "My first patch",
+
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                target,
                branch.base,
@@ -3091,7 +3095,7 @@ mod test {
        let mut patches = Cache::no_cache(&*alice.repo).unwrap();
        let patch = patches
            .create(
-
                "My first patch",
+
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
                branch.base,
@@ -3124,7 +3128,7 @@ mod test {
        let mut patches = Cache::no_cache(&*alice.repo).unwrap();
        let mut patch = patches
            .create(
-
                "My first patch",
+
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
                branch.base,
@@ -3155,7 +3159,7 @@ mod test {
        let mut patches = Cache::no_cache(&*alice.repo).unwrap();
        let mut patch = patches
            .create(
-
                "My first patch",
+
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
                branch.base,
@@ -3207,7 +3211,7 @@ mod test {
        let mut patches = Cache::no_cache(&*alice.repo).unwrap();
        let mut patch = patches
            .create(
-
                "My first patch",
+
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
                branch.base,
@@ -3254,7 +3258,7 @@ mod test {
                resolves: Default::default(),
            },
            Action::Edit {
-
                title: String::from("My patch"),
+
                title: cob::Title::new("My patch").unwrap(),
                target: MergeTarget::Delegates,
            },
        ]);
@@ -3305,7 +3309,7 @@ mod test {
                    resolves: Default::default(),
                },
                Action::Edit {
-
                    title: String::from("Some patch"),
+
                    title: cob::Title::new("Some patch").unwrap(),
                    target: MergeTarget::Delegates,
                },
            ],
@@ -3365,7 +3369,7 @@ mod test {
                resolves: Default::default(),
            },
            Action::Edit {
-
                title: String::from("My patch"),
+
                title: cob::Title::new("My patch").unwrap(),
                target: MergeTarget::Delegates,
            },
        ]);
@@ -3395,7 +3399,7 @@ mod test {
        let mut patches = Cache::no_cache(&*alice.repo).unwrap();
        let mut patch = patches
            .create(
-
                "My first patch",
+
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
                branch.base,
@@ -3440,7 +3444,7 @@ mod test {
        let mut patches = Cache::no_cache(&*alice.repo).unwrap();
        let mut patch = patches
            .create(
-
                "My first patch",
+
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
                branch.base,
@@ -3471,7 +3475,7 @@ mod test {
        let mut patches = Cache::no_cache(&*alice.repo).unwrap();
        let mut patch = patches
            .create(
-
                "My first patch",
+
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
                branch.base,
@@ -3522,7 +3526,7 @@ mod test {
        let mut patches = Cache::no_cache(&*alice.repo).unwrap();
        let mut patch = patches
            .create(
-
                "My first patch",
+
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
                branch.base,
@@ -3569,7 +3573,7 @@ mod test {
        let mut patches = Cache::no_cache(&*alice.repo).unwrap();
        let mut patch = patches
            .create(
-
                "My first patch",
+
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
                branch.base,
@@ -3624,7 +3628,7 @@ mod test {
        };
        let mut patch = patches
            .create(
-
                "My first patch",
+
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
                branch.base,
@@ -3673,7 +3677,7 @@ mod test {
        let mut patches = Cache::no_cache(&*repo).unwrap();
        let mut patch = patches
            .create(
-
                "My first patch",
+
                cob::Title::new("My first patch").unwrap(),
                "Blah blah blah.",
                MergeTarget::Delegates,
                branch.base,
modified crates/radicle/src/cob/patch/cache.rs
@@ -110,7 +110,7 @@ impl<'a, R, C> Cache<super::Patches<'a, R>, C> {
    /// main storage, and writing the update to the `cache`.
    pub fn create<'g, G>(
        &'g mut self,
-
        title: impl ToString,
+
        title: cob::Title,
        description: impl ToString,
        target: MergeTarget,
        base: impl Into<git::Oid>,
@@ -140,7 +140,7 @@ impl<'a, R, C> Cache<super::Patches<'a, R>, C> {
    /// to the `cache`.
    pub fn draft<'g, G>(
        &'g mut self,
-
        title: impl ToString,
+
        title: cob::Title,
        description: impl ToString,
        target: MergeTarget,
        base: impl Into<git::Oid>,
@@ -710,9 +710,8 @@ mod tests {
    use radicle_cob::ObjectId;

    use crate::cob::cache::{Store, Update, Write};
-
    use crate::cob::migrate;
    use crate::cob::thread::{Comment, Thread};
-
    use crate::cob::Author;
+
    use crate::cob::{migrate, Author, Title};
    use crate::patch::{
        ByRevision, MergeTarget, Patch, PatchCounts, PatchId, Revision, RevisionId, State, Status,
    };
@@ -767,13 +766,21 @@ mod tests {
        let mut cache = memory(repo);
        assert!(cache.is_empty().unwrap());

-
        let patch = Patch::new("Patch #1".to_string(), MergeTarget::Delegates, revision());
+
        let patch = Patch::new(
+
            Title::new("Patch #1").unwrap(),
+
            MergeTarget::Delegates,
+
            revision(),
+
        );
        let id = ObjectId::from_str("47799cbab2eca047b6520b9fce805da42b49ecab").unwrap();
        cache.update(&cache.rid(), &id, &patch).unwrap();

        let patch = Patch {
            state: State::Archived,
-
            ..Patch::new("Patch #2".to_string(), MergeTarget::Delegates, revision())
+
            ..Patch::new(
+
                Title::new("Patch #2").unwrap(),
+
                MergeTarget::Delegates,
+
                revision(),
+
            )
        };
        let id = ObjectId::from_str("ae981ded6ed2ed2cdba34c8603714782667f18a3").unwrap();
        cache.update(&cache.rid(), &id, &patch).unwrap();
@@ -803,7 +810,11 @@ mod tests {
            .collect::<BTreeSet<PatchId>>();

        for id in open_ids.iter() {
-
            let patch = Patch::new(id.to_string(), MergeTarget::Delegates, revision());
+
            let patch = Patch::new(
+
                Title::new(&id.to_string()).unwrap(),
+
                MergeTarget::Delegates,
+
                revision(),
+
            );
            cache
                .update(&cache.rid(), &PatchId::from(*id), &patch)
                .unwrap();
@@ -812,7 +823,11 @@ mod tests {
        for id in draft_ids.iter() {
            let patch = Patch {
                state: State::Draft,
-
                ..Patch::new(id.to_string(), MergeTarget::Delegates, revision())
+
                ..Patch::new(
+
                    Title::new(&id.to_string()).unwrap(),
+
                    MergeTarget::Delegates,
+
                    revision(),
+
                )
            };
            cache
                .update(&cache.rid(), &PatchId::from(*id), &patch)
@@ -822,7 +837,11 @@ mod tests {
        for id in archived_ids.iter() {
            let patch = Patch {
                state: State::Archived,
-
                ..Patch::new(id.to_string(), MergeTarget::Delegates, revision())
+
                ..Patch::new(
+
                    Title::new(&id.to_string()).unwrap(),
+
                    MergeTarget::Delegates,
+
                    revision(),
+
                )
            };
            cache
                .update(&cache.rid(), &PatchId::from(*id), &patch)
@@ -835,7 +854,11 @@ mod tests {
                    revision: arbitrary::oid().into(),
                    commit: arbitrary::oid(),
                },
-
                ..Patch::new(id.to_string(), MergeTarget::Delegates, revision())
+
                ..Patch::new(
+
                    Title::new(&id.to_string()).unwrap(),
+
                    MergeTarget::Delegates,
+
                    revision(),
+
                )
            };
            cache
                .update(&cache.rid(), &PatchId::from(*id), &patch)
@@ -869,7 +892,11 @@ mod tests {
        let mut patches = Vec::with_capacity(ids.len());

        for id in ids.iter() {
-
            let patch = Patch::new(id.to_string(), MergeTarget::Delegates, revision());
+
            let patch = Patch::new(
+
                Title::new(&id.to_string()).unwrap(),
+
                MergeTarget::Delegates,
+
                revision(),
+
            );
            cache
                .update(&cache.rid(), &PatchId::from(*id), &patch)
                .unwrap();
@@ -898,7 +925,7 @@ mod tests {
            .next()
            .expect("at least one revision should have been created");
        let mut patch = Patch::new(
-
            patch_id.to_string(),
+
            Title::new(&patch_id.to_string()).unwrap(),
            MergeTarget::Delegates,
            (*rev_id, rev.clone()),
        );
@@ -937,7 +964,11 @@ mod tests {
        let mut patches = Vec::with_capacity(ids.len());

        for id in ids.iter() {
-
            let patch = Patch::new(id.to_string(), MergeTarget::Delegates, revision());
+
            let patch = Patch::new(
+
                Title::new(&id.to_string()).unwrap(),
+
                MergeTarget::Delegates,
+
                revision(),
+
            );
            cache
                .update(&cache.rid(), &PatchId::from(*id), &patch)
                .unwrap();
@@ -964,7 +995,11 @@ mod tests {
        let mut patches = Vec::with_capacity(ids.len());

        for id in ids.iter() {
-
            let patch = Patch::new(id.to_string(), MergeTarget::Delegates, revision());
+
            let patch = Patch::new(
+
                Title::new(&id.to_string()).unwrap(),
+
                MergeTarget::Delegates,
+
                revision(),
+
            );
            cache
                .update(&cache.rid(), &PatchId::from(*id), &patch)
                .unwrap();
@@ -990,7 +1025,11 @@ mod tests {
            .collect::<BTreeSet<PatchId>>();

        for id in ids.iter() {
-
            let patch = Patch::new(id.to_string(), MergeTarget::Delegates, revision());
+
            let patch = Patch::new(
+
                Title::new(&id.to_string()).unwrap(),
+
                MergeTarget::Delegates,
+
                revision(),
+
            );
            cache
                .update(&cache.rid(), &PatchId::from(*id), &patch)
                .unwrap();
modified crates/radicle/src/cob/test.rs
@@ -7,9 +7,9 @@ use radicle_crypto::ssh::ExtendedSignature;
use serde::{Deserialize, Serialize};

use crate::cob::op::Op;
-
use crate::cob::patch;
use crate::cob::patch::Patch;
use crate::cob::store::encoding;
+
use crate::cob::{patch, Title};
use crate::cob::{Entry, History, Manifest, Timestamp, Version};
use crate::crypto::Signer;
use crate::git;
@@ -224,7 +224,7 @@ impl<G: Signer> Actor<G> {
    /// Create a patch.
    pub fn patch<R: ReadRepository>(
        &mut self,
-
        title: impl ToString,
+
        title: Title,
        description: impl ToString,
        base: git::Oid,
        oid: git::Oid,
@@ -239,7 +239,7 @@ impl<G: Signer> Actor<G> {
                    resolves: Default::default(),
                },
                patch::Action::Edit {
-
                    title: title.to_string(),
+
                    title,
                    target: patch::MergeTarget::default(),
                },
            ]),
modified crates/radicle/src/storage/refs.rs
@@ -477,7 +477,7 @@ mod tests {

    use super::*;
    use crate::assert_matches;
-
    use crate::{cob::identity::Identity, rad, test::fixtures, Storage};
+
    use crate::{cob::identity::Identity, cob::Title, rad, test::fixtures, Storage};

    #[quickcheck]
    fn prop_canonical_roundtrip(refs: Refs) {
@@ -554,10 +554,10 @@ mod tests {
            let mut london_ident = Identity::load_mut(&london).unwrap();

            paris_ident
-
                .update("Add Bob", "", &paris_doc, &alice)
+
                .update(Title::new("Add Bob").unwrap(), "", &paris_doc, &alice)
                .unwrap();
            london_ident
-
                .update("Add Bob", "", &london_doc, &alice)
+
                .update(Title::new("Add Bob").unwrap(), "", &london_doc, &alice)
                .unwrap();
        }