Skip to main content

rustev_contract/
screening.rs

1//! Authored screening and its plan bindings (spec 035, 3.4 and 3.5.8).
2//! Only document-local shape is checked here; compilation owns semantic checks.
3use serde::{Deserialize, Serialize};
4
5use crate::actionability::Address;
6use crate::ids::{ArtifactId, DescriptorId};
7
8/// The canonical definition screening block.
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
10#[serde(deny_unknown_fields)]
11pub struct Screening {
12    #[serde(deserialize_with = "positive")]
13    #[schemars(range(min = 1))]
14    pub max_text_units: u64,
15    #[serde(deserialize_with = "bounded_list")]
16    #[schemars(length(min = 1, max = 32))]
17    pub entries: Vec<ScreeningEntry>,
18}
19
20/// An authored entry. Every member is required, including explicit policies.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
22#[serde(deny_unknown_fields)]
23pub struct ScreeningEntry {
24    #[serde(deserialize_with = "name")]
25    pub id: String,
26    #[serde(deserialize_with = "fields")]
27    #[schemars(length(min = 1, max = 32))]
28    pub fields: Vec<String>,
29    pub item_id: ScreeningItemId,
30    #[serde(deserialize_with = "name")]
31    pub screener: String,
32    pub on_block: OnBlock,
33    #[serde(deserialize_with = "address")]
34    pub address: Address,
35    pub on_unavailable: OnUnavailable,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
39#[serde(rename_all = "snake_case")]
40pub enum ScreeningItemId {
41    None,
42    // Variant-level `untagged` (serde 1.0.181 and later): `"none"` or the
43    // bare `{"field": ...}` object, as 3.4.1 writes it; the wire tests
44    // round-trip both forms byte for byte.
45    #[serde(untagged)]
46    Field(ScreeningItemField),
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
50#[serde(deny_unknown_fields)]
51pub struct ScreeningItemField {
52    #[serde(deserialize_with = "name")]
53    pub field: String,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
57#[serde(rename_all = "snake_case")]
58pub enum OnBlock {
59    Refuse,
60    DropField,
61    DropText,
62    DropItem,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
66#[serde(rename_all = "snake_case")]
67pub enum OnUnavailable {
68    FailClosed,
69    SkipGeneration,
70    FailOpen,
71}
72
73/// Plan 3 retains the canonical block and each lowered step's binding.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
75#[serde(deny_unknown_fields)]
76pub struct PlanScreening {
77    pub screening: Screening,
78    #[serde(deserialize_with = "nonempty_steps")]
79    #[schemars(length(min = 1))]
80    pub steps: Vec<ScreeningStep>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
84#[serde(deny_unknown_fields)]
85pub struct ScreeningStep {
86    #[serde(deserialize_with = "nonempty")]
87    pub step: String,
88    #[serde(deserialize_with = "name")]
89    pub entry: String,
90    #[serde(deserialize_with = "field_reference")]
91    pub field: String,
92    pub binding: ScreenerBinding,
93    pub consequence: OnBlock,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
97#[serde(deny_unknown_fields)]
98pub struct ScreenerBinding {
99    #[serde(deserialize_with = "name")]
100    pub backend_id: String,
101    pub artifact: ArtifactId,
102    pub descriptor: DescriptorId,
103}
104
105// Optional members may be absent, never explicitly null (descriptor precedent).
106pub(crate) fn present<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(
107    deserializer: D,
108) -> Result<Option<T>, D::Error> {
109    T::deserialize(deserializer).map(Some)
110}
111
112fn positive<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
113    let n = u64::deserialize(d)?;
114    if n == 0 {
115        return Err(serde::de::Error::custom("max_text_units must be positive"));
116    }
117    Ok(n)
118}
119
120fn bounded_list<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(
121    d: D,
122) -> Result<Vec<T>, D::Error> {
123    let values = Vec::<T>::deserialize(d)?;
124    if !(1..=32).contains(&values.len()) {
125        return Err(serde::de::Error::custom("expected 1 to 32 elements"));
126    }
127    Ok(values)
128}
129
130// The authored name rule used by core's structure check (spec 002).
131fn valid_name(s: &str) -> bool {
132    !s.is_empty() && !s.contains(':') && !s.chars().any(char::is_whitespace)
133}
134
135fn name<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
136    let s = String::deserialize(d)?;
137    if !valid_name(&s) {
138        return Err(serde::de::Error::custom("invalid screening name"));
139    }
140    Ok(s)
141}
142
143fn reference(s: &str) -> bool {
144    let Some(path) = s.strip_prefix("input:") else {
145        return false;
146    };
147    let (input, field) = match path.split_once('/') {
148        Some((input, field)) => (input.strip_suffix("[]").unwrap_or(input), Some(field)),
149        None => (path, None),
150    };
151    let component = |s: &str| valid_name(s) && !s.contains(['/', '[', ']']);
152    component(input) && field.is_none_or(component)
153}
154
155fn fields<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
156    let values: Vec<String> = bounded_list(d)?;
157    if !values.iter().all(|s| reference(s)) {
158        return Err(serde::de::Error::custom(
159            "invalid screening field reference",
160        ));
161    }
162    Ok(values)
163}
164
165fn address<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Address, D::Error> {
166    let value = Address::deserialize(d)?;
167    // An override naming `caller` parses: compilation refuses it as spec
168    // 033's `caller_cannot_act` (category 17, spec 035 3.5.7).
169    if let Address::Override(a) = &value
170        && a.reason.is_empty()
171    {
172        return Err(serde::de::Error::custom(
173            "invalid screening address override",
174        ));
175    }
176    Ok(value)
177}
178
179fn nonempty_steps<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<ScreeningStep>, D::Error> {
180    let steps = Vec::<ScreeningStep>::deserialize(d)?;
181    if steps.is_empty() {
182        return Err(serde::de::Error::custom("screening steps must be nonempty"));
183    }
184    Ok(steps)
185}
186
187fn nonempty<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
188    let s = String::deserialize(d)?;
189    if s.is_empty() {
190        return Err(serde::de::Error::custom(
191            "screening string must be nonempty",
192        ));
193    }
194    Ok(s)
195}
196
197fn optional_nonempty<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
198    let s = Option::<String>::deserialize(d)?;
199    if s.as_ref().is_some_and(String::is_empty) {
200        return Err(serde::de::Error::custom(
201            "screening string must be nonempty",
202        ));
203    }
204    Ok(s)
205}
206
207fn field_reference<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
208    let s = String::deserialize(d)?;
209    if !reference(&s) {
210        return Err(serde::de::Error::custom(
211            "invalid screening field reference",
212        ));
213    }
214    Ok(s)
215}
216
217/// The domain of a digest of screened UTF-8 bytes (spec 035, 3.13.1).
218pub const SCREEN_TEXT_TAG: &str = "rustev.screen-text/1";
219
220pub fn screen_text_digest(text: &str) -> crate::ids::ContentDigest {
221    crate::ids::ContentDigest::from_digest(crate::canonical::tagged_digest(
222        SCREEN_TEXT_TAG,
223        text.as_bytes(),
224    ))
225}
226
227/// Projection members are JSON values, not a contract value enum.
228pub fn withheld_screen_blocked() -> serde_json::Value {
229    serde_json::json!({"withheld": "screen_blocked"})
230}
231
232/// Details of a screening notice. Never includes screened text.
233#[derive(
234    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
235)]
236#[serde(deny_unknown_fields)]
237pub struct ScreeningNotice {
238    #[serde(deserialize_with = "name")]
239    pub entry: String,
240    #[serde(deserialize_with = "field_reference")]
241    pub field: String,
242    pub consequence: ScreenConsequence,
243    pub items: Vec<String>,
244}
245
246#[derive(
247    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
248)]
249#[serde(rename_all = "snake_case")]
250pub enum ScreenConsequence {
251    None,
252    Refuse,
253    DropField,
254    DropText,
255    DropItem,
256    FailClosed,
257    SkipGeneration,
258    FailOpen,
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
262#[serde(rename_all = "snake_case", deny_unknown_fields)]
263pub enum ScreenVerdict {
264    Pass,
265    Blocked,
266    Unavailable { reason: crate::judgment::Unresolved },
267    Empty,
268}
269
270/// One screened value's evidence, in lowered-step and item order.
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
272#[serde(deny_unknown_fields)]
273pub struct ScreeningEvidence {
274    #[serde(deserialize_with = "name")]
275    pub entry: String,
276    #[serde(deserialize_with = "evidence_field_reference")]
277    pub field: String,
278    /// Null means this field is not a list item.
279    #[serde(default, deserialize_with = "optional_nonempty")]
280    pub item: Option<String>,
281    pub text_digest: crate::ids::ContentDigest,
282    pub verdict: ScreenVerdict,
283    pub categories: Vec<String>,
284    pub consequence: ScreenConsequence,
285    #[serde(deserialize_with = "name")]
286    pub backend_id: String,
287    pub artifact: ArtifactId,
288    pub descriptor: DescriptorId,
289    #[serde(deserialize_with = "nonempty")]
290    pub step: String,
291}
292
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
294#[serde(deny_unknown_fields)]
295pub struct ScreenRequest {
296    pub cache: ScreenCache,
297    pub units: ScreenChargedUnits,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
301#[serde(rename_all = "snake_case", deny_unknown_fields)]
302pub enum ScreenCache {
303    Hit {
304        #[serde(deserialize_with = "nonempty")]
305        source_attempt: String,
306    },
307    Miss,
308    Disabled,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
312#[serde(deny_unknown_fields)]
313pub struct ScreenChargedUnits {
314    /// Sum of computed bounds for dispatched attempts. Absent when no
315    /// attempt was dispatched (including cache hits and withdrawal).
316    #[serde(
317        default,
318        skip_serializing_if = "Option::is_none",
319        deserialize_with = "present"
320    )]
321    #[schemars(with = "u64")]
322    pub computed: Option<u64>,
323    /// Sum of reported units, when any attempt reported units. Charges for
324    /// attempts without a report still use their computed bound.
325    #[serde(
326        default,
327        skip_serializing_if = "Option::is_none",
328        deserialize_with = "present"
329    )]
330    #[schemars(with = "u64")]
331    pub reported: Option<u64>,
332    pub charged: u64,
333    pub basis: ScreenUnitBasis,
334    /// Present only for a reported charge above its computed bound.
335    /// Mirrors the cost ledger's bound-violation observation (3.12.3).
336    #[serde(
337        default,
338        skip_serializing_if = "Option::is_none",
339        deserialize_with = "present"
340    )]
341    #[schemars(with = "ScreenUnitBoundViolation")]
342    pub bound_violation: Option<ScreenUnitBoundViolation>,
343}
344
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
346#[serde(rename_all = "snake_case")]
347pub enum ScreenUnitBasis {
348    Reported,
349    Computed,
350    CacheHit,
351}
352
353#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
354#[serde(deny_unknown_fields)]
355pub struct ScreenUnitBoundViolation {
356    pub computed: u64,
357    pub reported: u64,
358}
359
360impl<'de> Deserialize<'de> for ScreenUnitBoundViolation {
361    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
362        #[derive(Deserialize)]
363        #[serde(deny_unknown_fields)]
364        struct Wire {
365            computed: u64,
366            reported: u64,
367        }
368
369        let wire = Wire::deserialize(d)?;
370        if wire.reported <= wire.computed {
371            return Err(serde::de::Error::custom(
372                "reported screening units must exceed the computed bound",
373            ));
374        }
375        Ok(Self {
376            computed: wire.computed,
377            reported: wire.reported,
378        })
379    }
380}
381
382pub(crate) fn unavailable_fields<'de, D: serde::Deserializer<'de>>(
383    d: D,
384) -> Result<Vec<String>, D::Error> {
385    let fields = Vec::<String>::deserialize(d)?;
386    if fields.is_empty()
387        || !fields.iter().all(|s| reference(s))
388        || fields
389            .windows(2)
390            .any(|w| w[0].as_bytes() >= w[1].as_bytes())
391    {
392        return Err(serde::de::Error::custom(
393            "screen_unavailable fields must be nonempty, valid, sorted and unique",
394        ));
395    }
396    Ok(fields)
397}
398
399pub(crate) fn evidence_list<'de, D: serde::Deserializer<'de>>(
400    d: D,
401) -> Result<Option<Vec<ScreeningEvidence>>, D::Error> {
402    let entries = Vec::<ScreeningEvidence>::deserialize(d)?;
403    if entries.is_empty() {
404        return Err(serde::de::Error::custom(
405            "screening evidence must be nonempty",
406        ));
407    }
408    Ok(Some(entries))
409}
410
411fn evidence_field_reference<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
412    let s = String::deserialize(d)?;
413    let output = s
414        .strip_prefix("output:")
415        .and_then(|p| p.split_once('/'))
416        .is_some_and(|(step, field)| valid_name(step) && valid_name(field));
417    if reference(&s) || output {
418        Ok(s)
419    } else {
420        Err(serde::de::Error::custom("invalid screening evidence field"))
421    }
422}