Skip to main content

sim_lib_view_device/
consent.rs

1//! Session-bound device consent receipts.
2
3use sim_kernel::{CapabilityName, Cx, Error, EventKind, EventLedger, Expr, Ref, Result, Symbol};
4use sim_lib_scene::{GlanceCard, GlanceMetric};
5use sim_value::{access, build};
6
7/// Capability helpers for device-local sensors and actuators.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum DeviceCapability {
10    /// Pose or spatial tracking samples.
11    Pose,
12    /// Camera frames or still captures.
13    Camera,
14    /// Health or biometric samples.
15    Health,
16    /// Location samples.
17    Location,
18    /// Microphone input samples.
19    Mic,
20    /// Vendor diagnostic reports.
21    VendorReport,
22}
23
24impl DeviceCapability {
25    /// All baseline device capabilities.
26    pub const ALL: [Self; 6] = [
27        Self::Pose,
28        Self::Camera,
29        Self::Health,
30        Self::Location,
31        Self::Mic,
32        Self::VendorReport,
33    ];
34
35    /// Stable capability name, suitable for [`Cx::require`].
36    pub fn as_str(self) -> &'static str {
37        match self {
38            Self::Pose => "device/pose",
39            Self::Camera => "device/camera",
40            Self::Health => "device/health",
41            Self::Location => "device/location",
42            Self::Mic => "device/mic",
43            Self::VendorReport => "device/vendor-report",
44        }
45    }
46
47    /// Returns the kernel capability name.
48    pub fn capability_name(self) -> CapabilityName {
49        CapabilityName::new(self.as_str())
50    }
51
52    /// Returns the visible consent grant symbol.
53    pub fn grant_symbol(self) -> Symbol {
54        Symbol::qualified("device", self.local_name())
55    }
56
57    /// Resolves a baseline helper from a capability name.
58    pub fn from_name(name: &str) -> Option<Self> {
59        Self::ALL
60            .into_iter()
61            .find(|capability| capability.as_str() == name)
62    }
63
64    fn local_name(self) -> &'static str {
65        self.as_str()
66            .strip_prefix("device/")
67            .expect("device capability names keep the device/ prefix")
68    }
69}
70
71/// Stable device-edge session identity.
72#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
73pub struct EdgeId(Symbol);
74
75impl EdgeId {
76    /// Builds an edge id from a stable symbol.
77    pub fn new(symbol: Symbol) -> Self {
78        Self(symbol)
79    }
80
81    /// Builds an edge id in the `device/session` namespace.
82    pub fn named(name: impl Into<String>) -> Self {
83        Self(Symbol::qualified("device/session", name.into()))
84    }
85
86    /// Returns the backing symbol.
87    pub fn as_symbol(&self) -> &Symbol {
88        &self.0
89    }
90
91    /// Encodes this id as expression data.
92    pub fn to_expr(&self) -> Expr {
93        Expr::Symbol(self.0.clone())
94    }
95
96    /// Decodes an edge id from expression data.
97    pub fn from_expr(expr: &Expr) -> Result<Self> {
98        match expr {
99            Expr::Symbol(symbol) => Ok(Self(symbol.clone())),
100            _ => Err(Error::TypeMismatch {
101                expected: "device edge id symbol",
102                found: "non-symbol",
103            }),
104        }
105    }
106}
107
108impl From<Symbol> for EdgeId {
109    fn from(symbol: Symbol) -> Self {
110        Self::new(symbol)
111    }
112}
113
114/// A visible consent receipt bound to one device-edge session.
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct ConsentReceipt {
117    /// Granted device capabilities, as visible `device/*` symbols.
118    pub grants: Vec<Symbol>,
119    /// Modeled retention window in milliseconds.
120    pub retain_ms: u64,
121    /// Fields or streams that must be redacted by downstream reapers.
122    pub redact: Vec<Symbol>,
123    /// Session that owns the visible consent.
124    pub session: EdgeId,
125    /// Ledger sequence number.
126    pub seq: u64,
127}
128
129impl ConsentReceipt {
130    /// Builds a receipt with an explicit ledger sequence.
131    pub fn new(
132        grants: Vec<Symbol>,
133        retain_ms: u64,
134        redact: Vec<Symbol>,
135        session: EdgeId,
136        seq: u64,
137    ) -> Self {
138        Self {
139            grants: dedup_symbols(grants),
140            retain_ms,
141            redact: dedup_symbols(redact),
142            session,
143            seq,
144        }
145    }
146
147    /// Encodes the receipt as ordinary expression data.
148    pub fn to_expr(&self) -> Expr {
149        build::map(vec![
150            ("kind", build::qsym("device", "consent-receipt")),
151            ("grants", symbol_list(&self.grants)),
152            ("retain-ms", build::uint(self.retain_ms)),
153            ("redact", symbol_list(&self.redact)),
154            ("session", self.session.to_expr()),
155            ("seq", build::uint(self.seq)),
156        ])
157    }
158
159    /// Decodes a receipt from expression data.
160    pub fn from_expr(expr: &Expr) -> Result<Self> {
161        ensure_kind(expr)?;
162        let grants = symbol_vec(access::required(expr, "grants", "device consent receipt")?)?;
163        let retain_ms = uint_field(expr, "retain-ms")?;
164        let redact = symbol_vec(access::required(expr, "redact", "device consent receipt")?)?;
165        let session =
166            EdgeId::from_expr(access::required(expr, "session", "device consent receipt")?)?;
167        let seq = uint_field(expr, "seq")?;
168        Ok(Self::new(grants, retain_ms, redact, session, seq))
169    }
170
171    /// Renders the receipt as a compact scene badge for the active session.
172    pub fn to_badge_scene(&self) -> Expr {
173        sim_lib_scene::badge("ok", &format!("consent {}", self.seq))
174    }
175
176    /// Renders the receipt as a `scene/glance` card.
177    pub fn to_glance_scene(&self) -> Expr {
178        GlanceCard::new(
179            "Device consent",
180            Some(GlanceMetric::new(
181                "session",
182                self.session.as_symbol().as_qualified_str(),
183            )),
184            None,
185            "info",
186            1,
187        )
188        .to_scene()
189    }
190}
191
192/// Records a consent receipt in a kernel event ledger and returns it with the
193/// assigned sequence.
194pub fn record_consent_receipt(
195    ledger: &mut EventLedger,
196    run: Ref,
197    grants: Vec<Symbol>,
198    retain_ms: u64,
199    redact: Vec<Symbol>,
200    session: EdgeId,
201) -> Result<ConsentReceipt> {
202    let next_seq = ledger.len_for_run(&run) as u64;
203    let event = ledger.push(
204        run,
205        EventKind::Card {
206            subject: Ref::Symbol(session.as_symbol().clone()),
207            card: Ref::Symbol(Symbol::qualified(
208                "device/consent",
209                format!("receipt-{next_seq}"),
210            )),
211        },
212    )?;
213    Ok(ConsentReceipt::new(
214        grants, retain_ms, redact, session, event.seq,
215    ))
216}
217
218/// Requires both a kernel grant and a session-bound visible consent receipt.
219pub fn require_with_consent(
220    cx: &Cx,
221    name: &str,
222    receipt: &ConsentReceipt,
223    session: &EdgeId,
224) -> Result<()> {
225    cx.require(&CapabilityName::new(name.to_owned()))?;
226    if &receipt.session != session {
227        return Err(Error::HostError(format!(
228            "{name}: consent not for this session"
229        )));
230    }
231    if !receipt
232        .grants
233        .iter()
234        .any(|grant| grant_matches(grant, name))
235    {
236        return Err(Error::HostError(format!("{name} requires visible consent")));
237    }
238    Ok(())
239}
240
241fn ensure_kind(expr: &Expr) -> Result<()> {
242    match access::field_sym(expr, "kind") {
243        Some(kind)
244            if kind.namespace.as_deref() == Some("device")
245                && kind.name.as_ref() == "consent-receipt" =>
246        {
247            Ok(())
248        }
249        _ => Err(Error::HostError(
250            "expected device/consent-receipt".to_owned(),
251        )),
252    }
253}
254
255fn uint_field(expr: &Expr, name: &str) -> Result<u64> {
256    let value = access::required(expr, name, "device consent receipt")?;
257    match value {
258        Expr::Number(number) if number.domain.namespace.is_none() => number
259            .canonical
260            .parse()
261            .map_err(|_| Error::Eval(format!("device consent receipt field {name} is not u64"))),
262        _ => Err(Error::Eval(format!(
263            "device consent receipt field {name} is not u64"
264        ))),
265    }
266}
267
268fn symbol_vec(expr: &Expr) -> Result<Vec<Symbol>> {
269    match expr {
270        Expr::List(items) => items
271            .iter()
272            .map(|item| match item {
273                Expr::Symbol(symbol) => Ok(symbol.clone()),
274                _ => Err(Error::TypeMismatch {
275                    expected: "symbol list",
276                    found: "non-symbol",
277                }),
278            })
279            .collect(),
280        _ => Err(Error::TypeMismatch {
281            expected: "symbol list",
282            found: "non-list",
283        }),
284    }
285}
286
287fn symbol_list(symbols: &[Symbol]) -> Expr {
288    build::list(symbols.iter().cloned().map(Expr::Symbol).collect())
289}
290
291fn grant_matches(grant: &Symbol, name: &str) -> bool {
292    grant.as_qualified_str() == name
293}
294
295fn dedup_symbols(mut symbols: Vec<Symbol>) -> Vec<Symbol> {
296    symbols.sort();
297    symbols.dedup();
298    symbols
299}