Skip to main content

waggle_core/
log.rs

1//! The log record: one append-only stream carries events **and** manifest
2//! lifecycle (design doc `04 §1`). Minting itself is a record — the
3//! manifest table anywhere in the system is a fold over this stream,
4//! rebuildable, never the truth.
5
6use serde::{Deserialize, Serialize};
7
8use crate::event::{Event, Seq};
9use crate::manifest::AttributionManifest;
10use crate::time::Timestamp;
11use crate::token::Token;
12
13/// A change to a manifest's mutable sections. Lifecycle changes are CAS
14/// (C-9, checked at the store's commit point); cosmetic changes are LWW by
15/// commit order.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "kebab-case", tag = "kind")]
18pub enum Change {
19    /// Lifecycle: withdraw the token (tombstone, cascades to children).
20    Revoked,
21    /// Lifecycle: replace with a corrected token.
22    Superseded {
23        /// The replacement token.
24        by: Token,
25    },
26    /// Lifecycle: set or clear expiry.
27    ExpirySet {
28        /// New expiry (`None` clears).
29        expires_at: Option<Timestamp>,
30    },
31    /// Cosmetic: set or clear the campaign label.
32    CampaignSet {
33        /// New campaign (`None` clears).
34        campaign: Option<String>,
35    },
36    /// Cosmetic: set one label.
37    LabelSet {
38        /// Label key.
39        key: String,
40        /// Label value.
41        value: String,
42    },
43    /// Cosmetic: remove one label.
44    LabelUnset {
45        /// Label key.
46        key: String,
47    },
48}
49
50impl Change {
51    /// Lifecycle changes require CAS (`expected_version`); cosmetic ones
52    /// are LWW. The split is normative (C-9, doc `02`).
53    #[must_use]
54    pub fn is_lifecycle(&self) -> bool {
55        matches!(
56            self,
57            Self::Revoked | Self::Superseded { .. } | Self::ExpirySet { .. }
58        )
59    }
60}
61
62/// One record in the append-only log. A closed enum: new kinds are
63/// additive; folds ignore kinds they don't know (doc `13 §4`), which is
64/// what keeps the replay promise compatible across schema growth.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "kebab-case", tag = "record")]
67pub enum LogRecord {
68    /// A token was born: the full manifest is the payload (doc `04 §1`).
69    Minted {
70        /// The manifest as minted (`version == 1`).
71        manifest: Box<AttributionManifest>,
72    },
73    /// A manifest's mutable sections changed.
74    Mutation {
75        /// The token whose manifest changed.
76        token: Token,
77        /// When.
78        at: Timestamp,
79        /// Per-token sequence (store-assigned; dedup key with `token`).
80        seq: Seq,
81        /// What changed.
82        change: Change,
83    },
84    /// A funnel event (payload-free — I-1).
85    Event(Event),
86}
87
88impl LogRecord {
89    /// The token this record belongs to.
90    #[must_use]
91    pub fn token(&self) -> Token {
92        match self {
93            Self::Minted { manifest } => manifest.token,
94            Self::Mutation { token, .. } => *token,
95            Self::Event(e) => e.token,
96        }
97    }
98
99    /// The per-token sequence. `Minted` is always seq 0 — birth precedes
100    /// everything (the store assigns 1.. to subsequent records).
101    #[must_use]
102    pub fn seq(&self) -> Seq {
103        match self {
104            Self::Minted { .. } => Seq(0),
105            Self::Mutation { seq, .. } => *seq,
106            Self::Event(e) => e.seq,
107        }
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::{CanonicalUrl, Channel, MintOptions, MintSpec, Sharer};
115
116    #[test]
117    fn lifecycle_split_is_exact() {
118        assert!(Change::Revoked.is_lifecycle());
119        assert!(Change::Superseded {
120            by: Token::parse("abc").unwrap()
121        }
122        .is_lifecycle());
123        assert!(Change::ExpirySet { expires_at: None }.is_lifecycle());
124        assert!(!Change::CampaignSet { campaign: None }.is_lifecycle());
125        assert!(!Change::LabelSet {
126            key: "k".into(),
127            value: "v".into()
128        }
129        .is_lifecycle());
130        assert!(!Change::LabelUnset { key: "k".into() }.is_lifecycle());
131    }
132
133    #[test]
134    fn record_identity_and_serde() {
135        let mut entropy = |buf: &mut [u8]| {
136            buf.fill(3);
137            Ok(())
138        };
139        let m = crate::mint(
140            MintSpec::new(
141                CanonicalUrl::new("ws://a/b").unwrap(),
142                Sharer::new("lead").unwrap(),
143                Channel::subagent_general(),
144            ),
145            &MintOptions::default(),
146            &mut entropy,
147            crate::Timestamp::from_unix_ms(1),
148        )
149        .unwrap();
150        let token = m.token;
151        let rec = LogRecord::Minted {
152            manifest: Box::new(m),
153        };
154        assert_eq!(rec.token(), token);
155        assert_eq!(rec.seq(), Seq(0));
156
157        let json = serde_json::to_string(&rec).unwrap();
158        let back: LogRecord = serde_json::from_str(&json).unwrap();
159        assert_eq!(back, rec);
160        assert!(
161            json.contains("\"record\":\"minted\""),
162            "tagged for the JSONL wire format"
163        );
164    }
165}