Skip to main content

agora_agentkit/govlog/
redactable.rs

1//! A value in an entry's `data` that a redaction may have replaced
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4
5use super::redaction_marker;
6use crate::ids::{GovernanceLogId, GovernanceLogPrefix};
7
8/// The `pattern` of a [`redaction_marker`]
9pub const REDACTION_MARKER_PATTERN: &str =
10    r"^\[redacted by AMD-[0-9]{4}-[0-9]{4}\]$";
11
12/// A value in an entry's `data`, or the [`redaction_marker`] a redaction
13/// left in its place.
14///
15/// On the wire it is `T` or the marker string; it serializes back to
16/// exactly what it parsed from. A `T` that is itself a string reading
17/// exactly like a marker parses as `Redacted`.
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19pub enum Redactable<T> {
20    Value(T),
21    /// Replaced by the redaction amendment (`AMD-`) this names
22    Redacted(GovernanceLogId),
23}
24
25impl<T> Redactable<T> {
26    /// The value, unless it was redacted
27    pub fn value(&self) -> Option<&T> {
28        match self {
29            Self::Value(v) => Some(v),
30            Self::Redacted(_) => None,
31        }
32    }
33
34    /// The amendment that redacted it, if one did
35    pub fn redacted_by(&self) -> Option<&GovernanceLogId> {
36        match self {
37            Self::Value(_) => None,
38            Self::Redacted(id) => Some(id),
39        }
40    }
41
42    pub fn is_redacted(&self) -> bool {
43        matches!(self, Self::Redacted(_))
44    }
45
46    pub fn map<U>(self, f: impl FnOnce(T) -> U) -> Redactable<U> {
47        match self {
48            Self::Value(v) => Redactable::Value(f(v)),
49            Self::Redacted(id) => Redactable::Redacted(id),
50        }
51    }
52}
53
54impl<T> From<T> for Redactable<T> {
55    fn from(value: T) -> Self {
56        Self::Value(value)
57    }
58}
59
60/// The amendment a [`redaction_marker`] names, if `s` is one
61fn parse_marker(s: &str) -> Option<GovernanceLogId> {
62    let inner = s.strip_prefix("[redacted by ")?.strip_suffix(']')?;
63    // Exact: a marker is written canonically, so lenient citation parsing
64    // (`AMD-2026-1`) must not turn look-alike text into a redaction.
65    if !GovernanceLogId::is_citation_shaped(inner) {
66        return None;
67    }
68    let id: GovernanceLogId = inner.parse().ok()?;
69    (id.prefix() == GovernanceLogPrefix::Amd).then_some(id)
70}
71
72impl<T: Serialize> Serialize for Redactable<T> {
73    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
74        match self {
75            Self::Value(v) => v.serialize(s),
76            Self::Redacted(id) => s.serialize_str(&redaction_marker(id)),
77        }
78    }
79}
80
81impl<'de, T: serde::de::DeserializeOwned> Deserialize<'de> for Redactable<T> {
82    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
83        let value = serde_json::Value::deserialize(d)?;
84        if let Some(id) = value.as_str().and_then(parse_marker) {
85            return Ok(Self::Redacted(id));
86        }
87        T::deserialize(value)
88            .map(Self::Value)
89            .map_err(serde::de::Error::custom)
90    }
91}
92
93// Hand-written so it inlines: `anyOf [T, marker]`, never a `$ref`.
94#[cfg(feature = "schemars")]
95impl<T: schemars::JsonSchema> schemars::JsonSchema for Redactable<T> {
96    fn inline_schema() -> bool {
97        true
98    }
99
100    fn schema_name() -> std::borrow::Cow<'static, str> {
101        format!("Redactable_{}", T::schema_name()).into()
102    }
103
104    fn schema_id() -> std::borrow::Cow<'static, str> {
105        format!("{}::Redactable<{}>", module_path!(), T::schema_id()).into()
106    }
107
108    fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
109        let value = g.subschema_for::<T>();
110        schemars::json_schema!({
111            "anyOf": [
112                value,
113                {
114                    "type": "string",
115                    "pattern": REDACTION_MARKER_PATTERN,
116                    "description": "Removed by the redaction amendment it names",
117                },
118            ],
119        })
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    fn amd() -> GovernanceLogId {
128        "AMD-2026-0009".parse().unwrap()
129    }
130
131    #[test]
132    fn the_marker_parses_as_redacted_and_writes_back_the_same() {
133        let marker = serde_json::Value::String(redaction_marker(&amd()));
134        let text: Redactable<String> =
135            serde_json::from_value(marker.clone()).unwrap();
136        assert_eq!(text, Redactable::Redacted(amd()));
137        assert_eq!(serde_json::to_value(&text).unwrap(), marker);
138
139        let list: Redactable<Vec<u32>> =
140            serde_json::from_value(marker.clone()).unwrap();
141        assert_eq!(list.redacted_by(), Some(&amd()));
142        assert_eq!(serde_json::to_value(&list).unwrap(), marker);
143    }
144
145    #[test]
146    fn anything_else_is_a_value() {
147        for s in [
148            "[redacted by GOV-2026-0001]",
149            "[redacted by AMD-2026-001]",
150            "redacted by AMD-2026-0001",
151            " [redacted by AMD-2026-0001]",
152        ] {
153            let v: Redactable<String> =
154                serde_json::from_value(s.into()).unwrap();
155            assert_eq!(v, Redactable::Value(s.to_string()));
156        }
157        assert!(serde_json::from_value::<Redactable<u32>>("x".into()).is_err());
158    }
159
160    #[test]
161    fn the_pattern_matches_the_marker() {
162        let p = REDACTION_MARKER_PATTERN;
163        let body = &p[1..p.len() - 1];
164        let marker = redaction_marker(&amd());
165        // No regex dependency: the pattern's fixed text plus the id pattern
166        assert_eq!(
167            body.replace(r"\[", "[")
168                .replace(r"\]", "]")
169                .replace("[0-9]{4}-[0-9]{4}", "2026-0009"),
170            marker
171        );
172    }
173
174    #[cfg(feature = "schemars")]
175    #[test]
176    fn schema_is_ref_free() {
177        let text = crate::responses::inline_schema_for::<
178            Redactable<Vec<Redactable<String>>>,
179        >()
180        .to_string();
181        assert!(!text.contains("$ref"), "{text}");
182        assert!(!text.contains("$defs"), "{text}");
183        assert!(text.contains("anyOf"), "{text}");
184    }
185}