Skip to main content

waggle_core/
resolve.rs

1//! Resolution: manifest + context + now → [`Resolution`] (design doc `02`).
2//!
3//! Invariant I-4 *by signature*: this function takes a manifest it was
4//! handed, a context, and a time value — it cannot read a store, cannot
5//! write an event, cannot block a redirect. Recording is the host's
6//! separate, asynchronous act.
7
8use serde::Serialize;
9
10use crate::context::ResolverContext;
11use crate::manifest::{AttributionManifest, Disposition};
12use crate::matcher::{select_variant, Selected};
13use crate::time::Timestamp;
14
15/// Default advisory freshness window when a variant declares none: 15
16/// minutes. A resolution is knowledge, not a lease (G-3) — this is the
17/// "re-resolve before acting" hint, not an enforcement mechanism.
18pub const DEFAULT_REVALIDATE_MS: u64 = 15 * 60 * 1000;
19
20/// What a consumer holds after resolving (doc `02 §2`, rev 2.1 G-3).
21#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
22pub struct Resolution<'m> {
23    /// Lifecycle disposition at `as_of`.
24    pub disposition: Disposition,
25    /// The selected variant, borrowed from the manifest (zero-copy).
26    /// `None` when the disposition withholds content (revoked) or —
27    /// impossible for minted manifests — nothing matched.
28    #[serde(skip)]
29    pub variant: Option<Selected<'m>>,
30    /// The instant this resolution reflects. Always present: resolutions
31    /// are point-in-time and say so.
32    pub as_of: Timestamp,
33    /// Advisory: re-resolve before acting after this instant (G-3).
34    pub revalidate_after: Timestamp,
35}
36
37/// Resolve `manifest` for `ctx` at `now`. Pure, total, deterministic —
38/// the sealed matcher does the selection; disposition gates what is served.
39///
40/// Serving rules: `Active` and `Expired` serve content (expiry policy —
41/// redirect-with-warning vs tombstone — belongs to hosts, doc `02`);
42/// `Superseded` serves content *and* the pointer (late resolvers follow
43/// it); `Revoked` serves nothing.
44#[must_use]
45pub fn resolve<'m>(
46    manifest: &'m AttributionManifest,
47    ctx: &ResolverContext,
48    now: Timestamp,
49) -> Resolution<'m> {
50    let disposition = manifest.disposition(now);
51    let variant = match disposition {
52        Disposition::Revoked { .. } => None,
53        _ => select_variant(&manifest.variants, ctx),
54    };
55    let window = variant
56        .and_then(|s| s.variant.revalidate_after_ms)
57        .unwrap_or(DEFAULT_REVALIDATE_MS);
58    Resolution {
59        disposition,
60        variant,
61        as_of: now,
62        revalidate_after: now.plus_ms(window),
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use crate::{CanonicalUrl, Channel, MintOptions, MintSpec, Sharer};
70
71    fn minted() -> AttributionManifest {
72        let mut entropy = |buf: &mut [u8]| {
73            buf.fill(11);
74            Ok(())
75        };
76        crate::mint(
77            MintSpec::new(
78                CanonicalUrl::new("ws://a/report.md").unwrap(),
79                Sharer::new("lead").unwrap(),
80                Channel::subagent_general(),
81            ),
82            &MintOptions::default(),
83            &mut entropy,
84            Timestamp::from_unix_ms(1_000),
85        )
86        .unwrap()
87    }
88
89    #[test]
90    fn g3_resolution_carries_freshness() {
91        // 15 §5.1 `g3_resolution_carries_freshness`.
92        let m = minted();
93        let now = Timestamp::from_unix_ms(2_000);
94        let r = resolve(&m, &ResolverContext::anonymous_agent(), now);
95        assert_eq!(r.as_of, now);
96        assert_eq!(r.revalidate_after, now.plus_ms(DEFAULT_REVALIDATE_MS));
97        assert!(
98            r.variant.is_some(),
99            "minted manifests always serve (catch-all)"
100        );
101    }
102
103    #[test]
104    fn variant_declared_window_overrides_default() {
105        let mut m = minted();
106        m.variants[0].revalidate_after_ms = Some(1_000);
107        let now = Timestamp::from_unix_ms(0);
108        let r = resolve(&m, &ResolverContext::human(), now);
109        assert_eq!(r.revalidate_after, Timestamp::from_unix_ms(1_000));
110    }
111
112    #[test]
113    fn revoked_serves_nothing_superseded_serves_with_pointer() {
114        let mut m = minted();
115        let now = Timestamp::from_unix_ms(5_000);
116
117        let other = crate::Token::parse("next1").unwrap();
118        m.superseded_by = Some(other);
119        let r = resolve(&m, &ResolverContext::human(), now);
120        assert_eq!(r.disposition, Disposition::Superseded { by: other });
121        assert!(
122            r.variant.is_some(),
123            "superseded still serves; the pointer travels with it"
124        );
125
126        m.revoked_at = Some(now);
127        let r = resolve(&m, &ResolverContext::human(), now);
128        assert_eq!(r.disposition, Disposition::Revoked { at: now });
129        assert!(r.variant.is_none(), "revoked serves nothing");
130    }
131
132    #[test]
133    fn resolve_is_pure_same_inputs_same_output() {
134        let m = minted();
135        let ctx = ResolverContext::anonymous_agent();
136        let now = Timestamp::from_unix_ms(9);
137        assert_eq!(resolve(&m, &ctx, now), resolve(&m, &ctx, now));
138    }
139}