newt-interaction 0.8.2

Renderer-neutral interaction protocol model for Newt Markup (epic #1803, slice A2)
Documentation
//! Identifiers. Three content-derived ids, and the locators that travel
//! beside them.
//!
//! The content-derived ids ([`DefinitionId`], [`InstanceId`], [`ResponseId`])
//! are newtypes over `ContentId` with **no string constructor** — the only
//! ways in are minting from a record and [`parse`](DefinitionId::parse),
//! which refuses any spelling that is not the id's own canonical rendering.
//! Two strings must never name one record.

use content_addressable::ContentId;
use serde::{Deserialize, Serialize};

use crate::error::ProtocolError;

/// Generate the shared body of a content-derived id newtype.
macro_rules! content_id_newtype {
    ($(#[$meta:meta])* $name:ident, $kind:literal) => {
        $(#[$meta])*
        #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
        #[serde(transparent)]
        // `ContentId` is a foreign type with no `JsonSchema` impl. In JSON
        // it renders as its canonical string, which is what a schema
        // consumer sees; in canonical DAG-CBOR it is a tag-42 LINK, which
        // JSON Schema has no way to express and the vectors' `links` field
        // documents instead.
        pub struct $name(
            #[cfg_attr(feature = "schema", schemars(with = "String"))] ContentId,
        );

        impl $name {
            /// Wrap an id already minted from the record it names.
            ///
            /// Crate-private on purpose: a public constructor would let any
            /// `ContentId` be DECLARED a definition id, which is the
            /// assigned-identity failure the whole crate exists to avoid.
            /// Records mint their own typed ids; the sanctioned ways in from
            /// outside are [`parse`](Self::parse), which refuses a
            /// non-canonical spelling, and serde at a wire boundary — after
            /// which A3 revalidates against the record anyway.
            #[must_use]
            pub(crate) fn from_content_id(id: ContentId) -> Self {
                Self(id)
            }

            /// The underlying content id.
            #[must_use]
            pub fn content_id(&self) -> &ContentId {
                &self.0
            }

            /// Parse an id from its canonical rendering.
            ///
            /// # Errors
            ///
            /// [`ProtocolError::NonCanonicalId`] when `text` parses but is not
            /// byte-identical to the id's own rendering — an alternate
            /// spelling would let two strings name one record.
            pub fn parse(text: &str) -> Result<Self, ProtocolError> {
                let id: ContentId = text.parse().map_err(|_| ProtocolError::InvalidId {
                    kind: $kind,
                    reason: format!("`{text}` is not a content id"),
                })?;
                if id.to_string() != text {
                    return Err(ProtocolError::NonCanonicalId {
                        presented: text.to_string(),
                    });
                }
                Ok(Self(id))
            }
        }

        impl std::fmt::Display for $name {
            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                write!(f, "{}", self.0)
            }
        }
    };
}

content_id_newtype!(
    /// The identity of an [`InteractionDefinition`](crate::InteractionDefinition)
    /// — and therefore its **exact form digest**. A response binds this, so a
    /// definition that changed by one byte cannot be answered with an offer
    /// minted against the old one.
    DefinitionId,
    "definition id"
);
content_id_newtype!(
    /// The identity of an [`InteractionInstance`](crate::InteractionInstance).
    /// Distinct from the instance's [`Nonce`], which routes.
    InstanceId,
    "instance id"
);
content_id_newtype!(
    /// The identity of a [`Response`](crate::Response).
    ResponseId,
    "response id"
);

/// Shared validation for author-assigned names: non-empty, ASCII
/// alphanumeric plus `-` and `_`.
fn validated_name(kind: &'static str, name: String) -> Result<String, ProtocolError> {
    if name.is_empty() {
        return Err(ProtocolError::InvalidId {
            kind,
            reason: "must not be empty".to_string(),
        });
    }
    if !name
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
    {
        return Err(ProtocolError::InvalidId {
            kind,
            reason: format!("`{name}` has characters outside [A-Za-z0-9_-]"),
        });
    }
    Ok(name)
}

/// A stable, author-assigned name for one control — one FIELD of an
/// interaction — inside a definition.
///
/// Deliberately not content-derived: a control id must survive being
/// written by a human in a `+++` envelope, and the definition it lives in
/// commits to it, so the definition's `ContentId` is what protects its
/// integrity.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
// Deserialization goes THROUGH the constructor. A derived impl on a
// transparent newtype builds the private field directly, so an invalid
// value arrives intact in canonical bytes that re-encode identically —
// invisible to the byte-equality gate, and a guarantee the type's own
// docs would otherwise be claiming falsely.
#[serde(into = "String", try_from = "String")]
pub struct ControlId(String);

#[cfg(feature = "schema")]
crate::string_scalar_schema!(ControlId, Some("^[A-Za-z0-9_-]+$"));

impl From<ControlId> for String {
    fn from(value: ControlId) -> Self {
        value.0
    }
}

impl TryFrom<String> for ControlId {
    type Error = ProtocolError;

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

impl ControlId {
    /// Build a control id: non-empty, ASCII alphanumeric plus `-` and `_`.
    ///
    /// # Errors
    ///
    /// [`ProtocolError::InvalidId`] when empty or outside the charset.
    pub fn new(name: impl Into<String>) -> Result<Self, ProtocolError> {
        Ok(Self(validated_name("control id", name.into())?))
    }

    /// The name as written.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// A stable, author-assigned name for one OPTION of a choice control.
///
/// A separate id space from [`ControlId`] on purpose. A choice is one
/// field with many mutually-exclusive options — the shape B0's permission
/// prompt already has: one question offering `[a]llow once`,
/// `[s]ession allow`, `[d]eny (default)` and the rest. Giving options
/// their own type means a submission naming a control and an option cannot
/// name two things of the same kind with no rule saying which is
/// authoritative; the ambiguity is gone by construction rather than by
/// prose.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
// Deserialization goes THROUGH the constructor. A derived impl on a
// transparent newtype builds the private field directly, so an invalid
// value arrives intact in canonical bytes that re-encode identically —
// invisible to the byte-equality gate, and a guarantee the type's own
// docs would otherwise be claiming falsely.
#[serde(into = "String", try_from = "String")]
pub struct OptionId(String);

#[cfg(feature = "schema")]
crate::string_scalar_schema!(OptionId, Some("^[A-Za-z0-9_-]+$"));

impl From<OptionId> for String {
    fn from(value: OptionId) -> Self {
        value.0
    }
}

impl TryFrom<String> for OptionId {
    type Error = ProtocolError;

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

impl OptionId {
    /// Build an option id: same shape rule as a control id.
    ///
    /// # Errors
    ///
    /// [`ProtocolError::InvalidId`] when empty or outside the charset.
    pub fn new(name: impl Into<String>) -> Result<Self, ProtocolError> {
        Ok(Self(validated_name("option id", name.into())?))
    }

    /// The name as written.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// A host-minted, fresh, unguessable handle for one live instance.
///
/// **The one thing here that is not content-derived, deliberately.** It makes
/// an instance unenumerable; it does not make its holder authorized. The
/// controller revalidates every response against the definition regardless of
/// who presented which nonce.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
// Deserialization goes THROUGH the constructor. A derived impl on a
// transparent newtype builds the private field directly, so an invalid
// value arrives intact in canonical bytes that re-encode identically —
// invisible to the byte-equality gate, and a guarantee the type's own
// docs would otherwise be claiming falsely.
#[serde(into = "String", try_from = "String")]
pub struct Nonce(String);

#[cfg(feature = "schema")]
crate::string_scalar_schema!(Nonce, None::<&str>);

impl From<Nonce> for String {
    fn from(value: Nonce) -> Self {
        value.0
    }
}

impl TryFrom<String> for Nonce {
    type Error = ProtocolError;

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

impl Nonce {
    /// Adopt a nonce minted by the host.
    ///
    /// # Errors
    ///
    /// [`ProtocolError::InvalidId`] when empty.
    pub fn new(value: impl Into<String>) -> Result<Self, ProtocolError> {
        let value = value.into();
        if value.is_empty() {
            return Err(ProtocolError::InvalidId {
                kind: "nonce",
                reason: "must not be empty".to_string(),
            });
        }
        Ok(Self(value))
    }

    /// The handle as minted.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// A definition's revision counter.
///
/// `Revision(0)` is a VALID revision, so a "consumed through" marker must be
/// `Option<Revision>` and never a `0` sentinel. That lesson is already paid
/// for in `newt_core::agentic::steering::Rev` (`steering.rs:110-115`); this
/// crate cannot depend on `newt-core` (dependency direction is binding), so
/// it carries the lesson rather than the type. #1828 records moving `Rev`
/// down here as a named follow-up.
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, Default,
)]
#[serde(transparent)]
pub struct Revision(u64);

impl Revision {
    /// The first revision of a definition.
    pub const FIRST: Self = Self(0);

    /// Adopt a revision number.
    #[must_use]
    pub fn new(value: u64) -> Self {
        Self(value)
    }

    /// The next revision.
    #[must_use]
    pub fn next(self) -> Self {
        Self(self.0.saturating_add(1))
    }

    /// The raw counter.
    #[must_use]
    pub fn get(self) -> u64 {
        self.0
    }
}

/// A caller-supplied key that makes a response replay-safe: the same key on
/// the same instance is the same submission, not a second one.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
// Deserialization goes THROUGH the constructor. A derived impl on a
// transparent newtype builds the private field directly, so an invalid
// value arrives intact in canonical bytes that re-encode identically —
// invisible to the byte-equality gate, and a guarantee the type's own
// docs would otherwise be claiming falsely.
#[serde(into = "String", try_from = "String")]
pub struct IdempotencyKey(String);

#[cfg(feature = "schema")]
crate::string_scalar_schema!(IdempotencyKey, None::<&str>);

impl From<IdempotencyKey> for String {
    fn from(value: IdempotencyKey) -> Self {
        value.0
    }
}

impl TryFrom<String> for IdempotencyKey {
    type Error = ProtocolError;

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

impl IdempotencyKey {
    /// Adopt an idempotency key.
    ///
    /// # Errors
    ///
    /// [`ProtocolError::InvalidId`] when empty.
    pub fn new(value: impl Into<String>) -> Result<Self, ProtocolError> {
        let value = value.into();
        if value.is_empty() {
            return Err(ProtocolError::InvalidId {
                kind: "idempotency key",
                reason: "must not be empty".to_string(),
            });
        }
        Ok(Self(value))
    }

    /// The key as supplied.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}