Skip to main content

cairn_mod/
service_record.rs

1//! `app.bsky.labeler.service` record rendering + idempotency hash (§F1, §6.4).
2//!
3//! This module is the pure core of #8: given a
4//! [`crate::config::LabelerConfigToml`], produce the JSON body that
5//! `cairn publish-service-record` PUTs to the operator's PDS, plus
6//! the content hash that powers idempotency.
7//!
8//! **Content-hash excludes `createdAt`.** Every startup generates a
9//! fresh `createdAt` candidate; hashing over it would make every
10//! publish appear changed, churning the PDS record unnecessarily.
11//! The published `createdAt` is preserved across unchanged republishes
12//! by rendering from `labeler_config.service_record_created_at` when
13//! the hash matches the prior value.
14//!
15//! The record shape is hand-rolled (§6.4) rather than relying on a
16//! lexicon validator. proto-blue-lex-data v0.2 doesn't carry
17//! `app.bsky.labeler.service`; adding atrium-api just for validation
18//! is deferred. A unit test in this module asserts every required
19//! field is present in rendered output — the forcing function.
20
21use serde::Serialize;
22use serde_json::Value;
23
24use crate::config::{
25    BlursToml, DefaultSettingToml, LabelValueDefinitionToml, LabelerConfigToml, LocaleToml,
26    SeverityToml,
27};
28
29/// `$type` value — the record's lexicon NSID (§6.4).
30pub const RECORD_TYPE: &str = "app.bsky.labeler.service";
31
32/// Collection NSID under which the record is stored in the operator's
33/// repo. Identical to `$type` per ATProto repo conventions.
34pub const RECORD_COLLECTION: &str = "app.bsky.labeler.service";
35
36/// `rkey` — §6.4 requires exactly `"self"`; any other rkey is a
37/// protocol violation.
38pub const RECORD_RKEY: &str = "self";
39
40/// Wire shape of the service record (§6.4). Field renames produce
41/// camelCase JSON; optional fields are `skip_serializing_if = "...is_none"`
42/// or `...is_empty`.
43#[derive(Debug, Clone, Serialize)]
44pub struct ServiceRecord {
45    /// `$type` — always `"app.bsky.labeler.service"` (§6.4).
46    #[serde(rename = "$type")]
47    pub record_type: &'static str,
48    /// RFC-3339 Z timestamp the consumer sees. Preserved across
49    /// content-unchanged republishes (§F1 idempotency); hashing
50    /// excludes this field.
51    #[serde(rename = "createdAt")]
52    pub created_at: String,
53    /// Required §6.4 policies block containing label values +
54    /// their definitions.
55    pub policies: Policies,
56    /// Optional §6.4 reason-type values the labeler accepts on
57    /// reports. Typically the `com.atproto.moderation.defs#reason*`
58    /// set matching `createReport` (§F11).
59    #[serde(rename = "reasonTypes", skip_serializing_if = "Vec::is_empty")]
60    pub reason_types: Vec<String>,
61    /// Optional §6.4 subject-type values the labeler handles
62    /// (e.g. `"account"`, `"record"`).
63    #[serde(rename = "subjectTypes", skip_serializing_if = "Vec::is_empty")]
64    pub subject_types: Vec<String>,
65    /// Optional §6.4 record-collection filter
66    /// (e.g. `"app.bsky.feed.post"`) narrowing which records this
67    /// labeler will emit labels for.
68    #[serde(rename = "subjectCollections", skip_serializing_if = "Vec::is_empty")]
69    pub subject_collections: Vec<String>,
70}
71
72/// `policies` block — §6.4 requires `labelValues`; definitions are
73/// recommended but optional (empty means "only global values, no
74/// custom definitions").
75#[derive(Debug, Clone, Serialize)]
76pub struct Policies {
77    /// Short-name list of labels the labeler emits. Every value
78    /// here that is NOT a §6.5 global well-known value should have
79    /// a matching entry in [`Self::label_value_definitions`].
80    #[serde(rename = "labelValues")]
81    pub label_values: Vec<String>,
82    /// Per-label metadata. §6.4 enforces non-empty locales per
83    /// definition; empty vec at the top level is legal.
84    #[serde(
85        rename = "labelValueDefinitions",
86        skip_serializing_if = "Vec::is_empty"
87    )]
88    pub label_value_definitions: Vec<LabelValueDefinition>,
89}
90
91/// One entry in `policies.labelValueDefinitions` — the rich
92/// metadata consumer UIs render per label (§6.4). `severity`,
93/// `blurs`, and `locales` are required per lexicon; the rest are
94/// optional.
95#[derive(Debug, Clone, Serialize)]
96pub struct LabelValueDefinition {
97    /// Label value this definition describes (matches a
98    /// `Policies::label_values` entry).
99    pub identifier: String,
100    /// §6.4 severity — `"inform"` / `"alert"` / `"none"`. Projected
101    /// from [`crate::config::SeverityToml`].
102    pub severity: &'static str,
103    /// §6.4 blur policy — `"content"` / `"media"` / `"none"`.
104    /// Projected from [`crate::config::BlursToml`].
105    pub blurs: &'static str,
106    /// Optional §6.4 consumer-side default — `"ignore"` / `"warn"`
107    /// / `"hide"`. Omitted entirely when `None` (serde
108    /// `skip_serializing_if`).
109    #[serde(rename = "defaultSetting", skip_serializing_if = "Option::is_none")]
110    pub default_setting: Option<&'static str>,
111    /// Optional §6.4 18+ gate. Omitted entirely when `None`.
112    #[serde(rename = "adultOnly", skip_serializing_if = "Option::is_none")]
113    pub adult_only: Option<bool>,
114    /// Non-empty per §6.4 — consumer UIs pick by locale.
115    pub locales: Vec<Locale>,
116}
117
118/// Localized display strings per §6.4 `labelValueDefinition.locales`.
119#[derive(Debug, Clone, Serialize)]
120pub struct Locale {
121    /// BCP-47 language tag (e.g. `"en"`, `"fr-CA"`).
122    pub lang: String,
123    /// Short display name shown in consumer UIs.
124    pub name: String,
125    /// Longer explanation, typically shown on tooltip / expand.
126    pub description: String,
127}
128
129/// Validation: every required §6.4 constraint that can't be enforced
130/// by serde alone. Called by [`render`] before emission; a failing
131/// record never reaches the PDS.
132#[derive(Debug, thiserror::Error)]
133pub enum RenderError {
134    /// Config declared no label values. §6.4 requires at least one
135    /// — an empty `labelValues` array would be a protocol-violating
136    /// record.
137    #[error("labeler.label_values is empty; the record must declare at least one label")]
138    NoLabelValues,
139    /// A label-value definition at the given index carries zero
140    /// locales. §6.4 requires at least one locale per definition.
141    #[error("labeler.label_value_definitions[{idx}] has no locales; §6.4 requires at least one")]
142    NoLocales {
143        /// Zero-based index into `label_value_definitions` of the
144        /// offending entry.
145        idx: usize,
146    },
147}
148
149/// Render the record body ready for PDS emission. `created_at` is
150/// the RFC-3339 timestamp the caller chose — use the prior record's
151/// value when hashes match (idempotency), or `time::OffsetDateTime`
152/// now-formatted otherwise.
153pub fn render(cfg: &LabelerConfigToml, created_at: &str) -> Result<ServiceRecord, RenderError> {
154    if cfg.label_values.is_empty() {
155        return Err(RenderError::NoLabelValues);
156    }
157    for (idx, def) in cfg.label_value_definitions.iter().enumerate() {
158        if def.locales.is_empty() {
159            return Err(RenderError::NoLocales { idx });
160        }
161    }
162    let definitions = cfg
163        .label_value_definitions
164        .iter()
165        .map(project_definition)
166        .collect();
167    Ok(ServiceRecord {
168        record_type: RECORD_TYPE,
169        created_at: created_at.to_string(),
170        policies: Policies {
171            label_values: cfg.label_values.clone(),
172            label_value_definitions: definitions,
173        },
174        reason_types: cfg.reason_types.clone(),
175        subject_types: cfg.subject_types.clone(),
176        subject_collections: cfg.subject_collections.clone(),
177    })
178}
179
180fn project_definition(src: &LabelValueDefinitionToml) -> LabelValueDefinition {
181    LabelValueDefinition {
182        identifier: src.identifier.clone(),
183        severity: severity_str(src.severity),
184        blurs: blurs_str(src.blurs),
185        default_setting: src.default_setting.map(default_setting_str),
186        adult_only: src.adult_only,
187        locales: src
188            .locales
189            .iter()
190            .map(|l: &LocaleToml| Locale {
191                lang: l.lang.clone(),
192                name: l.name.clone(),
193                description: l.description.clone(),
194            })
195            .collect(),
196    }
197}
198
199fn severity_str(s: SeverityToml) -> &'static str {
200    match s {
201        SeverityToml::Inform => "inform",
202        SeverityToml::Alert => "alert",
203        SeverityToml::None => "none",
204    }
205}
206
207fn blurs_str(b: BlursToml) -> &'static str {
208    match b {
209        BlursToml::Content => "content",
210        BlursToml::Media => "media",
211        BlursToml::None => "none",
212    }
213}
214
215fn default_setting_str(d: DefaultSettingToml) -> &'static str {
216    match d {
217        DefaultSettingToml::Ignore => "ignore",
218        DefaultSettingToml::Warn => "warn",
219        DefaultSettingToml::Hide => "hide",
220    }
221}
222
223/// Content hash (§F1 idempotency). SHA-256 of a serde-canonical JSON
224/// rendering **with `createdAt` removed**. Keyed-BTreeMap serialization
225/// gives deterministic ordering; reusing the existing `serde_json` path
226/// is sufficient because Cairn is both producer and consumer of this
227/// hash — we don't need to match any external canonicalization spec.
228///
229/// Returns 32 raw bytes; callers hex-encode for persistence.
230pub fn content_hash(record: &ServiceRecord) -> [u8; 32] {
231    content_hash_value(serde_json::to_value(record).expect("ServiceRecord serializes"))
232}
233
234/// `Value`-side of [`content_hash`]. Hashes any
235/// `app.bsky.labeler.service`-shaped JSON object via the same
236/// canonicalize-then-SHA-256 path, with `createdAt` excluded.
237///
238/// Used by `cairn serve`'s startup verify check (#8): the PDS
239/// returns the record body as `serde_json::Value` (the
240/// `GetRecordResponse.value` field), and the verify path hashes
241/// it directly without round-tripping through a `ServiceRecord`
242/// struct. The struct's `Serialize`-only derives use `&'static str`
243/// for several fields, which would prevent a symmetric `Deserialize`
244/// without a parallel read-side type — `Value` parsing sidesteps
245/// that.
246pub(crate) fn content_hash_value(mut v: Value) -> [u8; 32] {
247    if let Value::Object(map) = &mut v {
248        map.remove("createdAt");
249    }
250    // Re-serialize through a BTreeMap-backed path for stable key
251    // ordering regardless of serde_json's Value internals.
252    let canonical = canonicalize(v);
253    let bytes = serde_json::to_vec(&canonical).expect("Value serializes");
254    proto_blue_crypto::sha256(&bytes)
255}
256
257/// Recursively rebuild `Value` with `BTreeMap`-backed objects so
258/// key ordering is deterministic. `serde_json::Value::Object` uses
259/// `serde_json::Map` which, depending on feature flags, may or may
260/// not preserve insertion order; this walk forces lexicographic.
261fn canonicalize(v: Value) -> Value {
262    match v {
263        Value::Object(map) => {
264            let mut sorted: std::collections::BTreeMap<String, Value> =
265                std::collections::BTreeMap::new();
266            for (k, v) in map {
267                sorted.insert(k, canonicalize(v));
268            }
269            serde_json::to_value(sorted).expect("BTreeMap<String, Value> serializes")
270        }
271        Value::Array(xs) => Value::Array(xs.into_iter().map(canonicalize).collect()),
272        other => other,
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::config::{LabelValueDefinitionToml, LocaleToml};
280
281    fn sample_cfg() -> LabelerConfigToml {
282        LabelerConfigToml {
283            label_values: vec!["spam".into()],
284            label_value_definitions: vec![LabelValueDefinitionToml {
285                identifier: "spam".into(),
286                severity: SeverityToml::Alert,
287                blurs: BlursToml::None,
288                default_setting: Some(DefaultSettingToml::Warn),
289                adult_only: Some(false),
290                locales: vec![LocaleToml {
291                    lang: "en".into(),
292                    name: "Spam".into(),
293                    description: "Unsolicited promotional content.".into(),
294                }],
295            }],
296            reason_types: vec!["com.atproto.moderation.defs#reasonSpam".into()],
297            subject_types: vec!["account".into(), "record".into()],
298            subject_collections: vec!["app.bsky.feed.post".into()],
299        }
300    }
301
302    #[test]
303    fn rendered_record_has_every_required_field() {
304        let rec = render(&sample_cfg(), "2026-04-23T00:00:00.000Z").unwrap();
305        let v = serde_json::to_value(&rec).unwrap();
306        // §6.4 required top-level: $type, createdAt, policies.
307        assert_eq!(v["$type"], "app.bsky.labeler.service");
308        assert_eq!(v["createdAt"], "2026-04-23T00:00:00.000Z");
309        assert!(v["policies"].is_object());
310        // policies.labelValues required.
311        assert!(v["policies"]["labelValues"].is_array());
312        assert_eq!(v["policies"]["labelValues"][0], "spam");
313        // labelValueDefinitions required fields.
314        let def = &v["policies"]["labelValueDefinitions"][0];
315        for field in ["identifier", "severity", "blurs", "locales"] {
316            assert!(def.get(field).is_some(), "missing {field} in def: {def}");
317        }
318        // §6.4: locales must be non-empty.
319        assert!(!def["locales"].as_array().unwrap().is_empty());
320        let locale = &def["locales"][0];
321        for field in ["lang", "name", "description"] {
322            assert!(
323                locale.get(field).is_some(),
324                "missing {field} in locale: {locale}"
325            );
326        }
327    }
328
329    #[test]
330    fn empty_label_values_rejected() {
331        let mut cfg = sample_cfg();
332        cfg.label_values.clear();
333        assert!(matches!(
334            render(&cfg, "2026-04-23T00:00:00.000Z"),
335            Err(RenderError::NoLabelValues)
336        ));
337    }
338
339    #[test]
340    fn empty_locales_rejected() {
341        let mut cfg = sample_cfg();
342        cfg.label_value_definitions[0].locales.clear();
343        assert!(matches!(
344            render(&cfg, "2026-04-23T00:00:00.000Z"),
345            Err(RenderError::NoLocales { idx: 0 })
346        ));
347    }
348
349    #[test]
350    fn content_hash_excludes_created_at() {
351        let a = render(&sample_cfg(), "2026-04-23T00:00:00.000Z").unwrap();
352        let b = render(&sample_cfg(), "2030-12-31T23:59:59.999Z").unwrap();
353        assert_eq!(
354            content_hash(&a),
355            content_hash(&b),
356            "hashes must match when only createdAt differs"
357        );
358    }
359
360    #[test]
361    fn content_hash_changes_on_label_value_change() {
362        let a = render(&sample_cfg(), "2026-04-23T00:00:00.000Z").unwrap();
363        let mut cfg = sample_cfg();
364        cfg.label_values.push("abuse".into());
365        let b = render(&cfg, "2026-04-23T00:00:00.000Z").unwrap();
366        assert_ne!(content_hash(&a), content_hash(&b));
367    }
368
369    #[test]
370    fn content_hash_changes_on_severity_change() {
371        let a = render(&sample_cfg(), "2026-04-23T00:00:00.000Z").unwrap();
372        let mut cfg = sample_cfg();
373        cfg.label_value_definitions[0].severity = SeverityToml::Inform;
374        let b = render(&cfg, "2026-04-23T00:00:00.000Z").unwrap();
375        assert_ne!(content_hash(&a), content_hash(&b));
376    }
377
378    #[test]
379    fn content_hash_changes_on_locale_change() {
380        let a = render(&sample_cfg(), "2026-04-23T00:00:00.000Z").unwrap();
381        let mut cfg = sample_cfg();
382        cfg.label_value_definitions[0].locales.push(LocaleToml {
383            lang: "fr".into(),
384            name: "Spam".into(),
385            description: "Contenu promotionnel non sollicité.".into(),
386        });
387        let b = render(&cfg, "2026-04-23T00:00:00.000Z").unwrap();
388        assert_ne!(content_hash(&a), content_hash(&b));
389    }
390
391    #[test]
392    fn content_hash_is_stable_across_runs() {
393        // Two independent calls on the same config must produce the
394        // same hash — basic determinism.
395        let cfg = sample_cfg();
396        let a = render(&cfg, "2026-04-23T00:00:00.000Z").unwrap();
397        let b = render(&cfg, "2026-04-23T00:00:00.000Z").unwrap();
398        assert_eq!(content_hash(&a), content_hash(&b));
399    }
400
401    #[test]
402    fn optional_fields_omitted_when_empty() {
403        let mut cfg = sample_cfg();
404        cfg.reason_types.clear();
405        cfg.subject_types.clear();
406        cfg.subject_collections.clear();
407        let rec = render(&cfg, "2026-04-23T00:00:00.000Z").unwrap();
408        let v = serde_json::to_value(&rec).unwrap();
409        // skip_serializing_if = "Vec::is_empty" should omit empty arrays.
410        assert!(v.get("reasonTypes").is_none());
411        assert!(v.get("subjectTypes").is_none());
412        assert!(v.get("subjectCollections").is_none());
413    }
414}