Skip to main content

zenkey_fleet/model/
trace.rs

1//! Relating a sample to a call (#215) — from values in hand, no session.
2//!
3//! [`crate::bus::write::call_traced`] holds the session, makes the call and
4//! drains the window; everything that turns one observed sample into a
5//! [`TraceRow`] lives here, so the same rules run in a unit test with no
6//! bus — and so a future replay of a `.zrec` through a trace is a matter of
7//! feeding rows, not of re-deriving the attribution.
8//!
9//! The rule is a **naming heuristic** and says so in the report
10//! ([`crate::report::TRACE_CHAIN_RULE`]): RFC 05 §3's idiom puts the same
11//! first chunk on the request procedure (`artifact/request`), the status
12//! state (`state/<p>/artifact/<kind>`) and the audit event
13//! (`events/<p>/artifact/<ulid>`), and that shared chunk is the only
14//! declared link between them. A subject whose first chunk merely coincides
15//! is tagged the same way. Nothing here claims a cause; a relation is a
16//! statement about names in a registry.
17
18use crate::model::facts::{KeyDescription, KeyShape, Registration};
19use crate::model::timeline::{HlcStamp, TimelineRow};
20use crate::report::{Provenance, TraceRelation, TraceRow};
21use zenkey::slice::ProcedureDecl;
22
23/// What a trace attributes against: the called origin, the producer named
24/// on the call, and the procedure's first chunk.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct TraceTarget {
27    /// The origin chunk (`h-…` or `@service`).
28    pub origin: String,
29    /// The producer named on the call. `None` for a service origin, whose
30    /// keys carry no producer chunk (RFC 03 §1.5).
31    pub producer: Option<String>,
32    /// The first chunk of the procedure path — `artifact` for
33    /// `artifact/request`.
34    pub chain_chunk: String,
35    /// Whether a registry was loaded at all. Without one the chain is
36    /// unjudgeable, which is a third answer, not "undeclared" (O4).
37    pub registry_loaded: bool,
38}
39
40impl TraceTarget {
41    /// Which lane a described key falls in: `Some(relation)` for the called
42    /// origin, `None` for any other origin (or a key that is not a v1 key
43    /// under the base — those are somebody else's, counted alongside).
44    pub fn relation_of(&self, desc: &KeyDescription) -> Option<TraceRelation> {
45        let KeyShape::V1(facts) = &desc.facts.shape else {
46            return None;
47        };
48        if facts.origin != self.origin {
49            return None;
50        }
51        if !self.registry_loaded {
52            return Some(TraceRelation::SameOriginRegistryNotLoaded);
53        }
54        let in_chain = matches!(desc.facts.registration, Registration::Registered(_))
55            && facts.producer == self.producer
56            && facts.subject.first().map(String::as_str) == Some(self.chain_chunk.as_str());
57        Some(if in_chain {
58            TraceRelation::DeclaredChain
59        } else {
60            TraceRelation::SameOriginUndeclared
61        })
62    }
63}
64
65/// The procedure's idiom label: its declared `kind` token verbatim —
66/// `long-running`, `write`, `read` (RFC 08 §2) — or `undeclared`.
67///
68/// The token rather than [`zenkey::ProcedureKind`], because that enum knows
69/// `read` and `write` only and `long-running` is exactly the one a trace is
70/// for; the slice keeps an unknown token as spelled, so it is read from
71/// there.
72pub fn idiom_of(decl: Option<&ProcedureDecl>) -> String {
73    decl.and_then(|d| d.kind.as_ref())
74        .map(|k| k.token().to_string())
75        .unwrap_or_else(|| "undeclared".to_string())
76}
77
78/// `sample − reference` in milliseconds, both NTP64 (seconds in the high 32
79/// bits, a binary fraction in the low 32). Signed and truncated toward zero.
80pub fn hlc_delta_ms(sample_ntp64: u64, reference_ntp64: u64) -> i64 {
81    let diff = i128::from(sample_ntp64) - i128::from(reference_ntp64);
82    // Truncation toward zero on the *signed* difference, so −0.4 ms reads
83    // as 0 and not as −1: a sample stamped a hair before the reply is not
84    // reported a full millisecond before it.
85    i64::try_from(diff * 1000 / (1i128 << 32)).unwrap_or(if diff < 0 { i64::MIN } else { i64::MAX })
86}
87
88/// `self`, `foreign:<id>` or `unattributable:<id>` — the O7 provenance as
89/// one column, the stamper named wherever it is not the publisher.
90pub fn stamped_by(stamp: &HlcStamp) -> String {
91    match stamp.provenance {
92        Provenance::SelfStamped => "self".to_string(),
93        Provenance::Foreign => format!("foreign:{}", stamp.stamper),
94        Provenance::Unattributable => format!("unattributable:{}", stamp.stamper),
95    }
96}
97
98/// One trace row from a timeline row (the clocks and provenance are the
99/// timeline's, not a second vocabulary), the relation already decided, the
100/// HLC Δ present exactly when both the sample and the reply were stamped.
101pub fn trace_row(
102    row: &TimelineRow,
103    relation: TraceRelation,
104    reply_ntp64: Option<u64>,
105    break_before: Option<u64>,
106) -> TraceRow {
107    TraceRow {
108        key: row.key.clone(),
109        relation,
110        arrival_delta_ms: row.t_us as f64 / 1_000.0,
111        hlc: row.hlc.as_ref().map(HlcStamp::to_wire),
112        hlc_delta_ms: match (&row.hlc, reply_ntp64) {
113            (Some(h), Some(r)) => Some(hlc_delta_ms(h.ntp64, r)),
114            _ => None,
115        },
116        stamped_by: row.hlc.as_ref().map(stamped_by),
117        kind: row.kind,
118        payload_bytes: row.payload_bytes,
119        break_before,
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::model::facts::describe_key;
127    use crate::model::registry::SliceSet;
128    use crate::report::{LaneId, RowKind};
129    use zenkey::slice::{RegistrySlice, SubjectDecl};
130
131    const ORIGIN: &str = "h-3fa9c2d41b7e";
132
133    fn slices() -> SliceSet {
134        let mut slice = RegistrySlice::new("1.0", "t", "demo");
135        slice.subjects = vec![
136            SubjectDecl::new("artifact/{kind}", zenkey::Class::State),
137            SubjectDecl::new("artifact/{ulid}", zenkey::Class::Events),
138        ];
139        let mut proc_decl = ProcedureDecl::new("artifact/request");
140        proc_decl.kind = Some(zenkey::Declared::Other("long-running".into()));
141        slice.procedures = vec![proc_decl];
142        let mut other = RegistrySlice::new("1.0", "t", "other");
143        other.subjects = vec![SubjectDecl::new("artifact/copy", zenkey::Class::State)];
144        SliceSet::from_slices(vec![slice, other])
145    }
146
147    fn target(registry_loaded: bool) -> TraceTarget {
148        TraceTarget {
149            origin: ORIGIN.into(),
150            producer: Some("demo".into()),
151            chain_chunk: "artifact".into(),
152            registry_loaded,
153        }
154    }
155
156    /// The four answers of the naming rule, and the fifth that is not an
157    /// answer: another origin is nobody's lane.
158    #[test]
159    fn the_chain_is_the_registered_subject_sharing_the_first_chunk() {
160        let s = slices();
161        let rel = |key: &str| target(true).relation_of(&describe_key("", key, Some(&s)));
162        assert_eq!(
163            rel(&format!("v1/{ORIGIN}/state/demo/artifact/pcap")),
164            Some(TraceRelation::DeclaredChain)
165        );
166        assert_eq!(
167            rel(&format!("v1/{ORIGIN}/events/demo/artifact/01H")),
168            Some(TraceRelation::DeclaredChain)
169        );
170        // Same origin, another producer — even one whose subject shares the
171        // chunk: the chain is per called producer.
172        assert_eq!(
173            rel(&format!("v1/{ORIGIN}/state/other/artifact/copy")),
174            Some(TraceRelation::SameOriginUndeclared)
175        );
176        // Same producer, unregistered subject.
177        assert_eq!(
178            rel(&format!("v1/{ORIGIN}/telemetry/demo/noise")),
179            Some(TraceRelation::SameOriginUndeclared)
180        );
181        // Another origin: not attributed, not even "undeclared".
182        assert_eq!(rel("v1/h-bbbbbbbbbbbb/state/demo/artifact/pcap"), None);
183        // Not a v1 key at all: also nobody's.
184        assert_eq!(rel("plain/key"), None);
185    }
186
187    /// No registry: every same-origin sample is *unjudgeable*, and the
188    /// answer is that third state rather than "undeclared" (O4).
189    #[test]
190    fn without_a_registry_the_chain_is_unjudgeable_not_undeclared() {
191        let rel = |key: &str| target(false).relation_of(&describe_key("", key, None));
192        assert_eq!(
193            rel(&format!("v1/{ORIGIN}/state/demo/artifact/pcap")),
194            Some(TraceRelation::SameOriginRegistryNotLoaded)
195        );
196        assert_eq!(rel("v1/h-bbbbbbbbbbbb/state/demo/artifact/pcap"), None);
197    }
198
199    /// A service origin has no producer chunk (RFC 03 §1.5): the target
200    /// says `None`, and a service key matches on the chunk alone.
201    #[test]
202    fn a_service_target_matches_keys_with_no_producer_chunk() {
203        let mut slice = RegistrySlice::new("1.0", "t", "catalog");
204        slice.service_origin = Some(zenkey::Declared::Known(
205            zenkey::origin::ServiceOrigin::new("@catalog").unwrap(),
206        ));
207        slice.subjects = vec![SubjectDecl::new("entity/{id}", zenkey::Class::State)];
208        let s = SliceSet::from_slices(vec![slice]);
209        let t = TraceTarget {
210            origin: "@catalog".into(),
211            producer: None,
212            chain_chunk: "entity".into(),
213            registry_loaded: true,
214        };
215        assert_eq!(
216            t.relation_of(&describe_key("", "v1/@catalog/state/entity/x", Some(&s))),
217            Some(TraceRelation::DeclaredChain)
218        );
219    }
220
221    #[test]
222    fn the_idiom_is_the_declared_token_or_undeclared() {
223        let s = slices();
224        let decl = s.get("demo").unwrap().procedures.first();
225        assert_eq!(idiom_of(decl), "long-running");
226        assert_eq!(idiom_of(None), "undeclared");
227        let mut write = ProcedureDecl::new("set");
228        write.kind = Some(zenkey::Declared::Known(zenkey::ProcedureKind::Write));
229        assert_eq!(idiom_of(Some(&write)), "write");
230    }
231
232    /// NTP64 arithmetic: one second is `1 << 32`; signed, truncated toward
233    /// zero on both sides of the reply.
234    #[test]
235    fn hlc_delta_is_signed_milliseconds() {
236        let one_s = 1u64 << 32;
237        assert_eq!(hlc_delta_ms(one_s, 0), 1000);
238        assert_eq!(hlc_delta_ms(0, one_s), -1000);
239        // 0.4 ms either side rounds toward zero.
240        let point_four_ms = one_s * 4 / 10_000;
241        assert_eq!(hlc_delta_ms(point_four_ms, 0), 0);
242        assert_eq!(hlc_delta_ms(0, point_four_ms), 0);
243        assert_eq!(hlc_delta_ms(one_s + one_s / 2, one_s), 500);
244    }
245
246    /// The row carries the timeline's clocks: arrival always, the HLC pair
247    /// only when both the sample and the reply were stamped.
248    #[test]
249    fn a_trace_row_takes_its_clocks_from_the_timeline_row() {
250        let stamped = TimelineRow {
251            key: format!("v1/{ORIGIN}/state/demo/artifact/pcap"),
252            lane: LaneId::Origin {
253                origin: ORIGIN.into(),
254                producer: Some("demo".into()),
255            },
256            t_us: 12_345,
257            hlc: Some(HlcStamp {
258                ntp64: (1u64 << 32) * 2,
259                stamper: "33".into(),
260                provenance: Provenance::Foreign,
261            }),
262            source: None,
263            sn: None,
264            kind: RowKind::Put,
265            payload_bytes: 7,
266        };
267        let row = trace_row(
268            &stamped,
269            TraceRelation::DeclaredChain,
270            Some(1u64 << 32),
271            Some(3),
272        );
273        assert_eq!(row.arrival_delta_ms, 12.345);
274        assert_eq!(row.hlc.as_deref(), Some("8589934592/33"));
275        assert_eq!(row.hlc_delta_ms, Some(1000));
276        assert_eq!(row.stamped_by.as_deref(), Some("foreign:33"));
277        assert_eq!(row.break_before, Some(3));
278
279        // No reply HLC: the sample's own HLC still shows, the Δ does not.
280        let row = trace_row(&stamped, TraceRelation::DeclaredChain, None, None);
281        assert!(row.hlc.is_some());
282        assert_eq!(row.hlc_delta_ms, None);
283
284        let unstamped = TimelineRow {
285            hlc: None,
286            lane: LaneId::Unstamped,
287            ..stamped
288        };
289        let row = trace_row(
290            &unstamped,
291            TraceRelation::SameOriginUndeclared,
292            Some(1u64 << 32),
293            None,
294        );
295        assert_eq!(
296            (row.hlc, row.hlc_delta_ms, row.stamped_by),
297            (None, None, None)
298        );
299    }
300}