Skip to main content

zenkey_fleet/tape/
generate.rs

1//! `zenctl gen` (#162) — the registry-driven pattern generator.
2//!
3//! The opposite artifact of the spray demo: spray is deliberately hardcoded
4//! adversarial weirdness; `gen` reads a registry and produces **conforming**
5//! traffic — every declared subject of a producer, schema-synthesized
6//! payloads ([`crate::tape::synth`]), declared QoS, class-conscious rates. It is a
7//! mock producer for testing consumers, not a load cannon.
8//!
9//! Everything rides the existing seams: keys assemble from the declared
10//! patterns, bodies encode through [`SchemaStore::encode`] (the same
11//! validating ladder `zenctl pub` writes through), publications are declared
12//! (P7), and every sample carries the RFC 09 §5.3 synthetic marker (v1.19) —
13//! someone's `doctor --for` must be able to tell this traffic from real.
14
15use std::time::Duration;
16
17use crate::{Error, Result};
18use zenkey::grammar::with_base;
19use zenkey::pattern::{PatternChunk, SubjectPattern};
20use zenkey::qos::QosProfile;
21use zenkey::schema::SchemaSet;
22use zenkey::{Class, Declared, RateClass};
23
24use crate::model::decode::SchemaStore;
25use crate::model::registry::SliceSet;
26use crate::report::{Fault, GenPlanEntry, GenReport};
27use crate::tape::synth::Synth;
28
29/// The RFC 09 §5.3 marker (v1.19): every synthetic sample's attachment.
30pub fn synthetic_marker(tool: &str, origin: &str, fault: Option<&str>) -> Vec<u8> {
31    let mut obj = serde_json::json!({
32        "synthetic": true,
33        "tool": tool,
34        "origin": origin,
35    });
36    if let Some(kind) = fault {
37        obj["fault"] = kind.into();
38    }
39    serde_json::to_vec(&obj).expect("the marker serializes")
40}
41
42impl Fault {
43    /// The kebab-case kind name — the CLI token and the marker's `"fault"`
44    /// value.
45    pub fn as_str(self) -> &'static str {
46        match self {
47            Fault::Truncate => "truncate",
48            Fault::WrongType => "wrong-type",
49            Fault::ExtraField => "extra-field",
50            Fault::UnregisteredKey => "unregistered-key",
51            Fault::WrongQos => "wrong-qos",
52            Fault::MissingEncoding => "missing-encoding",
53            Fault::Unstamped => "unstamped",
54        }
55    }
56
57    /// Every kind, for a CLI error message and the round-trip test.
58    pub const ALL: [Fault; 7] = [
59        Fault::Truncate,
60        Fault::WrongType,
61        Fault::ExtraField,
62        Fault::UnregisteredKey,
63        Fault::WrongQos,
64        Fault::MissingEncoding,
65        Fault::Unstamped,
66    ];
67
68    /// Parse one kind, naming the vocabulary on a miss (spray's decline
69    /// precedent: an unknown kind is refused, never silently ignored).
70    pub fn parse(s: &str) -> Result<Fault> {
71        Fault::ALL
72            .into_iter()
73            .find(|f| f.as_str() == s)
74            .ok_or_else(|| {
75                let known = Fault::ALL.map(Fault::as_str).join(", ");
76                Error::unaskable(
77                    format!("--fault {s:?}"),
78                    format!("is not a known fault kind — known kinds: {known}"),
79                )
80            })
81    }
82
83    /// Perturb the wire key: only [`Fault::UnregisteredKey`] moves it (a
84    /// trailing chunk the registry never declared). Every other kind leaves
85    /// the declared key untouched and perturbs a different dimension.
86    fn perturb_key(self, key: &str) -> String {
87        match self {
88            Fault::UnregisteredKey => format!("{key}/unregistered"),
89            _ => key.to_string(),
90        }
91    }
92
93    /// Perturb the QoS profile: only [`Fault::WrongQos`] swaps it, to a
94    /// profile deliberately unlike the declared one.
95    fn perturb_qos(self, declared: QosProfile) -> QosProfile {
96        match self {
97            Fault::WrongQos if declared == QosProfile::Sampled => QosProfile::Transition,
98            Fault::WrongQos => QosProfile::Sampled,
99            _ => declared,
100        }
101    }
102
103    /// Whether this fault drops the declared wire encoding.
104    fn drops_encoding(self) -> bool {
105        matches!(self, Fault::MissingEncoding)
106    }
107
108    /// Whether this fault omits the HLC timestamp the valid path stamps.
109    fn drops_timestamp(self) -> bool {
110        matches!(self, Fault::Unstamped)
111    }
112
113    /// Perturb the encoded body bytes, post-synthesis and post-encode — so
114    /// the deviation bypasses the validating encoder that produced the valid
115    /// bytes (that is the whole point: near-valid traffic that violates on
116    /// the wire). Key/QoS/encoding/timestamp faults leave the body alone.
117    fn perturb_body(self, bytes: Vec<u8>) -> Vec<u8> {
118        match self {
119            Fault::Truncate => {
120                let n = bytes.len() / 2;
121                let mut out = bytes;
122                out.truncate(n);
123                out
124            }
125            Fault::WrongType => {
126                // A bare JSON string where a structured type is declared —
127                // built directly, never through the schema-validating encoder.
128                serde_json::to_vec(&serde_json::Value::String("fault:wrong-type".into()))
129                    .expect("a string serializes")
130            }
131            Fault::ExtraField => match serde_json::from_slice::<serde_json::Value>(&bytes) {
132                Ok(serde_json::Value::Object(mut m)) => {
133                    m.insert("_fault".into(), serde_json::Value::Bool(true));
134                    serde_json::to_vec(&serde_json::Value::Object(m)).expect("object serializes")
135                }
136                Ok(other) => {
137                    // Not an object: wrap it so the extra key still rides.
138                    let wrapped = serde_json::json!({ "_orig": other, "_fault": true });
139                    serde_json::to_vec(&wrapped).expect("object serializes")
140                }
141                Err(_) => {
142                    // Non-JSON body (cdr/protobuf): append the marker bytes —
143                    // still an undeclared trailer the decoder must survive.
144                    let mut out = bytes;
145                    out.extend_from_slice(b"_fault");
146                    out
147                }
148            },
149            _ => bytes,
150        }
151    }
152
153    /// The printable per-key delta from valid — what the plan states before
154    /// anything touches the bus (honesty: the tool says what it will do).
155    /// `valid` is the entry as synthesized, before this fault's perturbation.
156    fn delta(self, valid: &GenPlanEntry) -> String {
157        match self {
158            Fault::Truncate => {
159                "payload truncated to half its encoded bytes — a partial frame".into()
160            }
161            Fault::WrongType => format!(
162                "body replaced with a JSON string where {} is declared",
163                valid.type_name
164            ),
165            Fault::ExtraField => "an undeclared `_fault` field added to the body".into(),
166            Fault::UnregisteredKey => format!(
167                "key → {} (an unregistered subject; RFC 09 §5.1 O1: a fact to report)",
168                self.perturb_key(&valid.key)
169            ),
170            Fault::WrongQos => format!(
171                "qos {} → {} (declared profile not honoured, RFC 04 §3)",
172                valid.qos,
173                self.perturb_qos(QosProfile::from_name(&valid.qos).unwrap_or(QosProfile::Sampled))
174                    .name()
175            ),
176            Fault::MissingEncoding => match &valid.encoding {
177                Some(e) => format!("wire encoding {e} omitted"),
178                None => "no wire encoding set (none was declared either)".into(),
179            },
180            Fault::Unstamped => "no HLC timestamp — state LWW cannot order it (RFC 04 §4)".into(),
181        }
182    }
183}
184
185/// The send-timing shapes.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum GenPattern {
188    /// Fixed interval.
189    Steady,
190    /// Interval jittered ±30%, seeded — reproducible irregularity.
191    Jitter,
192    /// The per-second budget sent at once, then a pause.
193    Burst,
194    /// Rate climbs linearly from ~0 to the full rate over the duration.
195    Ramp,
196}
197
198/// What to generate.
199#[derive(Debug, Clone)]
200pub struct GenSpec {
201    /// The origin chunk the generated keys claim (`h-…`). Stated, printed,
202    /// and stamped into the marker — impersonation is the feature, and the
203    /// marker is what keeps it honest.
204    pub origin: String,
205    /// Only this producer's subjects (else: every host producer in the set).
206    pub producer: Option<String>,
207    /// Only subjects whose declared path contains this.
208    pub subject: Option<String>,
209    /// `{var}` values by name; unnamed vars get deterministic synthetic
210    /// values (stated in the plan).
211    pub vars: Vec<(String, String)>,
212    /// Override every entry's rate (Hz). `None` = the registry-driven
213    /// defaults: telemetry 1 Hz, state ttl/2 refresh, events inside their
214    /// declared budget.
215    pub rate_hz: Option<f64>,
216    pub pattern: GenPattern,
217    pub duration: Duration,
218    /// Drives synthesis and jitter — same seed, same run.
219    pub seed: u64,
220    /// The tool name stamped into the marker.
221    pub tool: String,
222    /// Fault kinds to inject (#163). Empty = conforming traffic. Non-empty
223    /// expands the plan to one variant per (subject × fault), each carrying a
224    /// single `fault=<kind>` marker and a printable delta — double-guarded at
225    /// the CLI edge (`--i-know` plus an explicit endpoint/`--base`).
226    pub faults: Vec<Fault>,
227}
228
229/// A deterministic chunk-safe value for an unnamed `{var}` (lowercase
230/// alphanumerics only, RFC 03 §2's charset).
231fn synthetic_var(name: &str) -> String {
232    let clean: String = name
233        .chars()
234        .filter(|c| c.is_ascii_alphanumeric())
235        .flat_map(|c| c.to_lowercase())
236        .collect();
237    if clean.is_empty() {
238        "v1".into()
239    } else {
240        format!("{clean}1")
241    }
242}
243
244/// Resolve the run's plan against the slices: which keys, which shapes,
245/// which rates. Schema ladder per type: the producer's live `describe`
246/// (when a session is given) > the offline `--schema-set` document > a
247/// placeholder `{}` body with a stated note.
248pub async fn build_plan(
249    fleet: Option<&crate::Fleet<'_>>,
250    store: &SchemaStore,
251    slices: &SliceSet,
252    base: &str,
253    schema_set: Option<&SchemaSet>,
254    spec: &GenSpec,
255) -> Result<Vec<GenPlanEntry>> {
256    let session = fleet.map(crate::Fleet::session);
257
258    let mut plan = Vec::new();
259
260    for slice in slices.slices() {
261        if slice.service_origin.is_some() {
262            // Impersonating a service origin (@catalog) would collide with
263            // the real service's single-writer claim (RFC 06 §5.3) — out of
264            // scope, stated rather than silently skipped.
265            continue;
266        }
267        if let Some(p) = &spec.producer
268            && &slice.name != p
269        {
270            continue;
271        }
272        for subject in &slice.subjects {
273            if let Some(filter) = &spec.subject
274                && !subject.path.contains(filter.as_str())
275            {
276                continue;
277            }
278            let pattern = SubjectPattern::parse(&subject.path).map_err(|e| {
279                Error::unaskable(format!("{}/{}", slice.name, subject.path), e.to_string())
280            })?;
281            let mut tail: Vec<String> = Vec::new();
282            let mut synthetic_vars: Vec<String> = Vec::new();
283            let mut unique_tail_idx = None;
284            for chunk in pattern.chunks() {
285                match chunk {
286                    PatternChunk::Literal(l) => tail.push(l.clone()),
287                    PatternChunk::Var(name) | PatternChunk::Rest(name) => {
288                        let value = spec
289                            .vars
290                            .iter()
291                            .find(|(k, _)| k == name)
292                            .map(|(_, v)| v.clone())
293                            .unwrap_or_else(|| {
294                                synthetic_vars.push(name.clone());
295                                synthetic_var(name)
296                            });
297                        if subject.class.is(&Class::Events) {
298                            // The last variable is the per-send unique id
299                            // (events keys are write-once, RFC 04 §1.3).
300                            unique_tail_idx = Some(tail.len());
301                        }
302                        tail.push(value);
303                    }
304                }
305            }
306            let key = with_base(
307                base,
308                format!(
309                    "v1/{}/{}/{}/{}",
310                    spec.origin,
311                    subject.class,
312                    slice.name,
313                    tail.join("/")
314                ),
315            );
316            // The unique chunk's index in the FULL key: base chunks +
317            // v1/origin/class/producer (4) + its index in the tail.
318            let base_chunks = if base.is_empty() {
319                0
320            } else {
321                base.split('/').count()
322            };
323            let unique_chunk = unique_tail_idx.map(|i| base_chunks + 4 + i);
324
325            // The slice already recognised the token on parse — a declared
326            // profile this build cannot name is not a profile it can honour,
327            // so it falls to the default exactly as an absent one does.
328            let (qos, qos_source) = match subject.qos.as_ref().and_then(Declared::known) {
329                Some(q) => (*q, "declared"),
330                None => (QosProfile::Sampled, "default"),
331            };
332
333            // Rate: override > class default. Events are additionally
334            // capped at their declared budget for the run.
335            let mut events_cap = None;
336            let mut note: Option<String> = None;
337            let rate_hz = match subject.class.known() {
338                Some(Class::Events) => {
339                    let cap_h = subject
340                        .rate
341                        .as_ref()
342                        .and_then(RateClass::cap_per_hour)
343                        .unwrap_or(1);
344                    let cap_run = ((f64::from(u32::try_from(cap_h.min(3600)).unwrap_or(3600))
345                        * spec.duration.as_secs_f64())
346                        / 3600.0)
347                        .floor()
348                        .max(1.0) as u64;
349                    events_cap = Some(cap_run.min(cap_h));
350                    // Spread the budget over the run.
351                    (events_cap.unwrap_or(1) as f64 / spec.duration.as_secs_f64()).min(1.0)
352                }
353                Some(Class::State) => match subject.ttl_s {
354                    // Refresh at ttl/2 (RFC 04 §1.2).
355                    Some(ttl) if ttl > 0 => 2.0 / ttl as f64,
356                    _ => 0.5,
357                },
358                _ => 1.0,
359            };
360            let rate_hz = spec.rate_hz.unwrap_or(rate_hz).clamp(0.001, 1000.0);
361
362            // The schema ladder.
363            let mut body_source = "placeholder";
364            let mut schema = None;
365            if let Some(session) = session
366                && let Some(s) = store
367                    .schema_for(session, &slice.name, &subject.type_name)
368                    .await
369            {
370                schema = Some(s);
371                body_source = "describe";
372            }
373            if schema.is_none()
374                && let Some(set) = schema_set
375                && let Some(s) = set.get(&subject.type_name)
376            {
377                schema = Some(s.clone());
378                body_source = "schema-set";
379            }
380            if schema.is_none() {
381                note = Some(format!(
382                    "no schema for {} — sending a placeholder {{}} body, labelled",
383                    subject.type_name
384                ));
385            }
386            if !synthetic_vars.is_empty() {
387                let vars = synthetic_vars.join(", ");
388                note = Some(match note.take() {
389                    Some(n) => format!("{n}; synthetic values for {{{vars}}}"),
390                    None => format!("synthetic values for {{{vars}}} (override with --var)"),
391                });
392            }
393            let encoding =
394                crate::bus::body::encode_encoding(None, subject.encoding.as_ref(), schema.as_ref());
395
396            let valid = GenPlanEntry {
397                key,
398                class: subject.class.token().to_string(),
399                producer: slice.name.clone(),
400                type_name: subject.type_name.clone(),
401                qos: qos.name().to_string(),
402                qos_source,
403                rate_hz,
404                body_source,
405                encoding,
406                events_cap,
407                note,
408                fault: None,
409                fault_delta: None,
410                schema,
411                unique_chunk,
412            };
413
414            if spec.faults.is_empty() {
415                plan.push(valid);
416                continue;
417            }
418            // One variant per fault kind: the delta is computed against the
419            // valid entry, then the static perturbations (key/QoS/encoding)
420            // are baked into the variant's fields — the body/timestamp faults
421            // ride at send time off `fault` (see `run_gen`). Every variant
422            // carries a single `fault=<kind>` marker.
423            for &fault in &spec.faults {
424                let mut variant = valid.clone();
425                variant.fault_delta = Some(fault.delta(&valid));
426                variant.key = fault.perturb_key(&valid.key);
427                variant.qos = fault
428                    .perturb_qos(QosProfile::from_name(&valid.qos).unwrap_or(QosProfile::Sampled))
429                    .name()
430                    .to_string();
431                if fault.drops_encoding() {
432                    variant.encoding = None;
433                }
434                variant.fault = Some(fault);
435                plan.push(variant);
436            }
437        }
438    }
439    Ok(plan)
440}
441
442/// The serving halves of a mock producer, alive while held: each declared
443/// responder is *driven* by its own task (a [`crate::bus::producer::Responder`]
444/// is pull-based — a responder nobody drives answers nobody). Dropping this
445/// aborts the drivers, which undeclares their queryables.
446#[derive(Debug)]
447pub struct MockProducer {
448    /// How many `@rpc` keys are being answered.
449    pub keys: usize,
450    tasks: Vec<tokio::task::JoinHandle<()>>,
451}
452
453impl Drop for MockProducer {
454    fn drop(&mut self) {
455        for t in &self.tasks {
456            t.abort();
457        }
458    }
459}
460
461/// Serve the RFC 08 halves for the impersonated producers (`--serve-describe`):
462/// `introspect` answers with the slice's verbatim TOML, `describe` with the
463/// schema-set document — a consumer under test can fetch shapes from this
464/// mock exactly as it would from the real producer.
465pub async fn serve_describe(
466    fleet: &crate::Fleet<'_>,
467    origin: &str,
468    slices: &SliceSet,
469    schema_set: Option<&SchemaSet>,
470    producer: Option<&str>,
471) -> Result<MockProducer> {
472    let (session, base) = (fleet.session(), fleet.base());
473
474    // The bring-up discipline (RFC 04 §5 via `crate::bus::producer::BringUp`):
475    // every queryable is declared — awaited, on its own concrete key —
476    // before this function returns, so a consumer under test that sees the
477    // mock exists can already call it, and RFC 08 §6.1's bounded grace has
478    // no spawn race to tolerate. The mock deliberately never declares
479    // `alive` (`without_alive`): a tool answering for a producer must not
480    // also claim its presence (RFC 13 §5).
481    let mut up = crate::bus::producer::BringUp::new(session);
482    let mut bodies: Vec<(Vec<u8>, &'static str)> = Vec::new();
483    for (slice, raw) in slices.entries() {
484        if slice.service_origin.is_some() {
485            continue;
486        }
487        if let Some(p) = producer
488            && slice.name != p
489        {
490            continue;
491        }
492        if raw.is_empty() {
493            continue; // a bus-built set has no verbatim TOML to serve
494        }
495        let introspect = with_base(base, format!("v1/{origin}/@rpc/{}/introspect", slice.name));
496        up.serve(&introspect).await?;
497        bodies.push((raw.as_bytes().to_vec(), "text/plain"));
498        if let Some(set) = schema_set {
499            let describe = with_base(base, format!("v1/{origin}/@rpc/{}/describe", slice.name));
500            up.serve(&describe).await?;
501            bodies.push((set.to_json().into_bytes(), "application/json"));
502        }
503    }
504    // Drive each declared responder: every incoming query gets its static
505    // answer, replied on the responder's own concrete key (RFC 05 §2.1).
506    let responders = up.without_alive();
507    let keys = responders.len();
508    let mut tasks = Vec::new();
509    for (responder, (body, encoding)) in responders.into_iter().zip(bodies) {
510        tasks.push(tokio::spawn(async move {
511            while let Some(query) = responder.next().await {
512                // Surfaced, not swallowed (#346), for the same reason
513                // `MockResponder` carries `ServedQuery::reply_error`: a mock
514                // whose answers never leave the process must say so, or its
515                // silence reads as service on the asking side (RFC 05 §3.1 —
516                // silence needs attribution, on the answering side too).
517                if let Err(e) = responder.reply(&query, body.clone(), Some(encoding)).await {
518                    tracing::warn!(key = %responder.key(), "mock producer reply failed: {e}");
519                }
520            }
521        }));
522    }
523    Ok(MockProducer { keys, tasks })
524}
525
526/// Run the plan: every entry publishes on its own schedule until the
527/// duration elapses. Bodies synthesize per tick and encode through a
528/// per-task [`DecoderRegistry`](zenkey::schema::decode::DecoderRegistry);
529/// a refused body is counted and reported.
530///
531/// No [`SchemaStore`]: the plan already carries every schema the run needs
532/// ([`build_plan`] is where the store is asked), and the parameter it used
533/// to take was discarded on the first line.
534///
535/// **Nothing outlives this call** (#326). The entries run in a
536/// [`JoinSet`](tokio::task::JoinSet), which aborts what it still holds when
537/// it is dropped, and the join loop shuts the set down — aborted *and*
538/// awaited — before it returns for any reason. A detached generator is
539/// synthetic traffic with no owner and nothing left to stop it before its own
540/// deadline (RFC 13 §5: the etiquette is the generator's, and a tool that has
541/// stopped reporting must also have stopped publishing). The same holds for
542/// cancelling this future: dropping the `JoinSet` aborts every entry.
543pub async fn run_gen(
544    fleet: &crate::Fleet<'_>,
545    plan: &[GenPlanEntry],
546    spec: &GenSpec,
547) -> Result<GenReport> {
548    let session = fleet.session();
549
550    let synth = Synth::new(spec.seed);
551
552    let deadline = tokio::time::Instant::now() + spec.duration;
553
554    let total_s = spec.duration.as_secs_f64();
555
556    let mut tasks: tokio::task::JoinSet<(usize, u64, u64, Vec<String>)> =
557        tokio::task::JoinSet::new();
558
559    for (i, entry) in plan.iter().enumerate() {
560        let entry = entry.clone();
561        let session = session.clone();
562        // Per-entry marker: a faulted sample additionally carries
563        // `fault=<kind>` (RFC 09 §5.3), so a capture or doctor listen can
564        // attribute exactly which deviation it saw.
565        let marker = synthetic_marker(&spec.tool, &spec.origin, entry.fault.map(Fault::as_str));
566        let store_encoding = entry.encoding.clone();
567        let pattern = spec.pattern;
568        let seed = spec.seed;
569        tasks.spawn(async move {
570            let registry = zenkey::schema::decode::DecoderRegistry::new();
571            let started = tokio::time::Instant::now();
572            let mut sent = 0u64;
573            let mut refused = 0u64;
574            let mut first_errors: Vec<String> = Vec::new();
575            let record_err = |e: String, refused: &mut u64, errs: &mut Vec<String>| {
576                *refused += 1;
577                if errs.len() < 3 {
578                    errs.push(e);
579                }
580            };
581            // A long-lived publication for repeated keys; events declare
582            // per send on their unique key.
583            let publication = if entry.unique_chunk.is_none() {
584                match crate::bus::write::declare_publication(
585                    &session,
586                    &entry.key,
587                    QosProfile::from_name(&entry.qos).unwrap_or(QosProfile::Sampled),
588                    entry.encoding.as_deref(),
589                )
590                .await
591                {
592                    Ok(p) => Some(p),
593                    Err(e) => {
594                        return (i, 0, 1, vec![format!("{}: declare: {e}", entry.key)]);
595                    }
596                }
597            } else {
598                None
599            };
600
601            let base_interval = Duration::from_secs_f64(1.0 / entry.rate_hz);
602            let mut tick: u64 = 0;
603            let run_over = tokio::time::sleep_until(deadline);
604            tokio::pin!(run_over);
605            loop {
606                if let Some(cap) = entry.events_cap
607                    && sent >= cap
608                {
609                    // The declared budget is spent; the entry idles out the
610                    // rest of the run rather than out-shouting the registry —
611                    // on the run's own timer, not a second one.
612                    (&mut run_over).await;
613                    break;
614                }
615                // Body: synthesize + encode, or the labelled placeholder.
616                let bytes = match &entry.schema {
617                    Some(schema) => match synth.instance(schema, tick) {
618                        Some(value) => {
619                            let wire = zenkey::schema::WireEncoding::from_encoding_str(
620                                store_encoding.as_deref().unwrap_or("application/json"),
621                            );
622                            match registry.encode(schema, &value, &wire) {
623                                Ok(b) => b,
624                                Err(e) => {
625                                    record_err(
626                                        format!("{}: encode: {e}", entry.key),
627                                        &mut refused,
628                                        &mut first_errors,
629                                    );
630                                    tick += 1;
631                                    continue;
632                                }
633                            }
634                        }
635                        None => b"{}".to_vec(),
636                    },
637                    None => b"{}".to_vec(),
638                };
639                // The fault (if any) perturbs the valid bytes post-encode, so
640                // the deviation bypasses the validating encoder that made them
641                // (#163). Key/QoS/encoding faults were already baked into the
642                // entry at plan time; here ride the body and timestamp faults.
643                let bytes = match entry.fault {
644                    Some(f) => f.perturb_body(bytes),
645                    None => bytes,
646                };
647                // Valid samples carry an HLC timestamp (state LWW, RFC 04 §4);
648                // the `unstamped` fault omits it, the one deviation a doctor
649                // freshness check can then catch.
650                let stamp = if entry.fault.map(Fault::drops_timestamp).unwrap_or(false) {
651                    None
652                } else {
653                    Some(session.new_timestamp())
654                };
655                let outcome = match &publication {
656                    Some(p) => p.send_stamped(bytes, Some(marker.clone()), stamp).await,
657                    None => {
658                        // Events: a fresh write-once key per send.
659                        let key = unique_key(&entry, seed, sent);
660                        match crate::bus::write::declare_publication(
661                            &session,
662                            &key,
663                            QosProfile::from_name(&entry.qos).unwrap_or(QosProfile::Sampled),
664                            entry.encoding.as_deref(),
665                        )
666                        .await
667                        {
668                            Ok(p) => {
669                                let r = p.send_stamped(bytes, Some(marker.clone()), stamp).await;
670                                let _ = p.undeclare().await;
671                                r
672                            }
673                            Err(e) => Err(e),
674                        }
675                    }
676                };
677                match outcome {
678                    Ok(()) => sent += 1,
679                    Err(e) => record_err(
680                        format!("{}: send: {e}", entry.key),
681                        &mut refused,
682                        &mut first_errors,
683                    ),
684                }
685                tick += 1;
686
687                // Pattern-shaped pacing, all deterministic.
688                let interval = match pattern {
689                    GenPattern::Steady => base_interval,
690                    GenPattern::Jitter => {
691                        let f = 0.7 + 0.6 * halton(seed ^ (i as u64) ^ tick);
692                        base_interval.mul_f64(f)
693                    }
694                    GenPattern::Burst => {
695                        let per_burst = entry.rate_hz.ceil().max(1.0) as u64;
696                        if tick.is_multiple_of(per_burst) {
697                            Duration::from_secs(1)
698                        } else {
699                            Duration::ZERO
700                        }
701                    }
702                    GenPattern::Ramp => {
703                        let progress = (started.elapsed().as_secs_f64() / total_s).clamp(0.05, 1.0);
704                        base_interval.div_f64(progress)
705                    }
706                };
707                // The run's own deadline is one timer (#346); the interval
708                // is genuinely per-iteration, because it moves — `Ramp`
709                // recomputes it every pass.
710                tokio::select! {
711                    _ = tokio::time::sleep(interval) => {}
712                    () = &mut run_over => break,
713                }
714                if tokio::time::Instant::now() >= deadline {
715                    break;
716                }
717            }
718            if let Some(p) = publication {
719                let _ = p.undeclare().await;
720            }
721            (i, sent, refused, first_errors)
722        });
723    }
724
725    // Joined in completion order, aggregated in plan order: the report's
726    // `first_errors` names the plan's first entries to complain, not the
727    // scheduler's.
728    let mut done: Vec<Option<(u64, u64, Vec<String>)>> = vec![None; plan.len()];
729    let mut failed: Option<Error> = None;
730    while let Some(joined) = tasks.join_next().await {
731        match joined {
732            Ok((i, s, r, errs)) => done[i] = Some((s, r, errs)),
733            Err(e) => {
734                failed = Some(Error::Internal(format!("a gen task did not join: {e}")));
735                break;
736            }
737        }
738    }
739    // Whatever is still running is aborted **and waited for** before this
740    // returns — on the happy path the set is already empty, and on a panic
741    // this is what keeps the surviving entries from publishing on into a run
742    // nobody is reporting (#326).
743    tasks.shutdown().await;
744    if let Some(e) = failed {
745        return Err(e);
746    }
747
748    let mut sent = 0u64;
749    let mut refused = 0u64;
750    let mut first_errors = Vec::new();
751    for (s, r, errs) in done.into_iter().flatten() {
752        sent += s;
753        refused += r;
754        for e in errs {
755            if first_errors.len() < 5 {
756                first_errors.push(e);
757            }
758        }
759    }
760    Ok(GenReport {
761        duration_s: spec.duration.as_secs_f64(),
762        entries: plan.len(),
763        sent,
764        refused,
765        first_errors,
766    })
767}
768
769/// Events keys are write-once: rebuild the key with the unique chunk set to
770/// a fresh, deterministic, chunk-safe id.
771fn unique_key(entry: &GenPlanEntry, seed: u64, n: u64) -> String {
772    let Some(idx) = entry.unique_chunk else {
773        return entry.key.clone();
774    };
775    let id = format!("{:012x}{:04x}", seed & 0xffff_ffff_ffff, n & 0xffff);
776    entry
777        .key
778        .split('/')
779        .enumerate()
780        .map(|(i, c)| if i == idx { id.as_str() } else { c })
781        .collect::<Vec<_>>()
782        .join("/")
783}
784
785/// A low-discrepancy pseudo-random in [0,1) — deterministic, no RNG dep.
786fn halton(n: u64) -> f64 {
787    let mut f = 1.0;
788    let mut r = 0.0;
789    let mut i = n.wrapping_mul(2654435761) % 4096 + 1;
790    while i > 0 {
791        f /= 2.0;
792        r += f * (i % 2) as f64;
793        i /= 2;
794    }
795    r
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801
802    const SLICES: &str = r#"
803[registry]
804version = "1.0"
805app = "t"
806convention = 1
807[producer]
808name = "demo"
809[[subject]]
810path = "health"
811class = "state"
812type = "Health"
813qos = "transition"
814ttl_s = 30
815[[subject]]
816path = "cpu/{core}/usage"
817class = "telemetry"
818type = "Point"
819[[subject]]
820path = "boom/{id}"
821class = "events"
822type = "Boom"
823rate = "rare"
824"#;
825
826    fn spec() -> GenSpec {
827        GenSpec {
828            origin: "h-abababababab".into(),
829            producer: None,
830            subject: None,
831            vars: vec![("core".into(), "cpu0".into())],
832            rate_hz: None,
833            pattern: GenPattern::Steady,
834            duration: Duration::from_secs(10),
835            seed: 42,
836            tool: "zenctl gen".into(),
837            faults: vec![],
838        }
839    }
840
841    async fn plan_for(base: &str) -> Vec<GenPlanEntry> {
842        let slices =
843            SliceSet::from_slices(vec![zenkey::parse_slice(SLICES).expect("fixture parses")]);
844        let store = SchemaStore::new(base, Duration::from_millis(100));
845        let set = SchemaSet::parse(
846            r#"{"schema_version":1,"app":"t","types":{
847                "Health":{"kind":"json-schema","hash":"","schema":{"type":"object",
848                    "properties":{"ok":{"type":"boolean"}}}}}}"#,
849        )
850        .expect("set parses");
851        build_plan(None, &store, &slices, base, Some(&set), &spec())
852            .await
853            .expect("plan builds")
854    }
855
856    /// The plan is the registry, resolved: declared QoS with its source,
857    /// class-driven rates (state ttl/2, events inside their budget), the
858    /// schema ladder's rung named per entry, vars filled as given or
859    /// synthesized with a note.
860    #[tokio::test]
861    async fn the_plan_resolves_declared_qos_rates_and_the_schema_ladder() {
862        let plan = plan_for("").await;
863        assert_eq!(plan.len(), 3);
864
865        let health = &plan[0];
866        assert_eq!(health.key, "v1/h-abababababab/state/demo/health");
867        assert_eq!(
868            (health.qos.as_str(), health.qos_source),
869            ("transition", "declared")
870        );
871        assert!(
872            (health.rate_hz - 2.0 / 30.0).abs() < 1e-9,
873            "{}",
874            health.rate_hz
875        );
876        assert_eq!(health.body_source, "schema-set");
877        assert!(health.note.is_none());
878
879        let cpu = &plan[1];
880        assert_eq!(cpu.key, "v1/h-abababababab/telemetry/demo/cpu/cpu0/usage");
881        assert_eq!((cpu.qos.as_str(), cpu.qos_source), ("sampled", "default"));
882        assert_eq!(cpu.rate_hz, 1.0);
883        assert_eq!(cpu.body_source, "placeholder");
884        assert!(
885            cpu.note.as_deref().unwrap_or("").contains("no schema"),
886            "{:?}",
887            cpu.note
888        );
889
890        let boom = &plan[2];
891        assert_eq!(boom.class, "events");
892        assert_eq!(boom.events_cap, Some(1), "rare = 1/h caps a 10s run at 1");
893        assert!(boom.unique_chunk.is_some(), "events keys are write-once");
894        assert!(
895            boom.note.as_deref().unwrap_or("").contains("{id}"),
896            "the synthesized var is stated: {:?}",
897            boom.note
898        );
899    }
900
901    /// The unique chunk lands where the `{id}` was, under any base depth.
902    #[tokio::test]
903    async fn events_keys_get_a_fresh_id_where_the_var_was() {
904        for base in ["", "acme", "acme/fleet-a"] {
905            let plan = plan_for(base).await;
906            let boom = plan.iter().find(|e| e.class == "events").unwrap();
907            let k1 = unique_key(boom, 42, 0);
908            let k2 = unique_key(boom, 42, 1);
909            assert_ne!(k1, k2, "each send gets its own key ({base:?})");
910            let tail1: Vec<&str> = k1.split('/').collect();
911            let tail2: Vec<&str> = k2.split('/').collect();
912            assert_eq!(tail1.len(), tail2.len());
913            let diffs: Vec<usize> = (0..tail1.len()).filter(|&i| tail1[i] != tail2[i]).collect();
914            assert_eq!(diffs.len(), 1, "only the id chunk moves ({base:?})");
915            assert!(
916                k1.ends_with(tail1[diffs[0]]),
917                "the id is the declared {{id}} position ({base:?}): {k1}"
918            );
919        }
920    }
921
922    /// The marker is exactly the RFC 09 §5.3 shape #161's detector reads.
923    #[test]
924    fn the_marker_round_trips_through_the_doctors_detector() {
925        let m = synthetic_marker("zenctl gen", "h-abababababab", None);
926        let v: serde_json::Value = serde_json::from_slice(&m).unwrap();
927        assert_eq!(v["synthetic"], true);
928        assert_eq!(v["tool"], "zenctl gen");
929        assert_eq!(v["origin"], "h-abababababab");
930        assert!(v.get("fault").is_none(), "no fault key unless injecting");
931        let f = synthetic_marker("zenctl gen", "h-abababababab", Some("truncate"));
932        let v: serde_json::Value = serde_json::from_slice(&f).unwrap();
933        assert_eq!(v["fault"], "truncate");
934    }
935
936    /// Every kind's CLI token round-trips, and an unknown kind is refused with
937    /// the vocabulary named (spray's decline precedent, applied to a flag).
938    #[test]
939    fn fault_kinds_parse_and_an_unknown_is_refused() {
940        for f in Fault::ALL {
941            assert_eq!(Fault::parse(f.as_str()).unwrap(), f);
942        }
943        let err = Fault::parse("scramble").unwrap_err().to_string();
944        assert!(err.contains("is not a known fault kind"), "{err}");
945        assert!(err.contains("truncate"), "the vocabulary is named: {err}");
946    }
947
948    /// With faults requested the plan expands to one variant per (subject ×
949    /// fault); each states its printable delta and bakes the static
950    /// perturbations (key/QoS/encoding) into its fields, leaving the body and
951    /// timestamp faults for send time.
952    #[tokio::test]
953    async fn faults_expand_the_plan_one_variant_per_kind_with_a_stated_delta() {
954        let slices =
955            SliceSet::from_slices(vec![zenkey::parse_slice(SLICES).expect("fixture parses")]);
956        let store = SchemaStore::new("", Duration::from_millis(100));
957        let mut spec = spec();
958        spec.faults = Fault::ALL.to_vec();
959        let plan = build_plan(None, &store, &slices, "", None, &spec)
960            .await
961            .expect("plan builds");
962        // Three subjects × seven faults.
963        assert_eq!(plan.len(), 3 * 7);
964        assert!(
965            plan.iter()
966                .all(|e| e.fault.is_some() && e.fault_delta.is_some()),
967            "every faulted entry names its kind and delta"
968        );
969
970        // The `health` state subject, one variant per kind — the static
971        // perturbations are visible in the fields.
972        let health: Vec<&GenPlanEntry> = plan
973            .iter()
974            .filter(|e| e.key.starts_with("v1/h-abababababab/state/demo/health"))
975            .collect();
976        assert_eq!(health.len(), 7);
977
978        let unregistered = health
979            .iter()
980            .find(|e| e.fault == Some(Fault::UnregisteredKey))
981            .unwrap();
982        assert_eq!(
983            unregistered.key,
984            "v1/h-abababababab/state/demo/health/unregistered"
985        );
986
987        let wrong_qos = health
988            .iter()
989            .find(|e| e.fault == Some(Fault::WrongQos))
990            .unwrap();
991        assert_ne!(
992            wrong_qos.qos, "transition",
993            "the declared profile is not honoured"
994        );
995
996        let missing_enc = health
997            .iter()
998            .find(|e| e.fault == Some(Fault::MissingEncoding))
999            .unwrap();
1000        assert!(
1001            missing_enc.encoding.is_none(),
1002            "the wire encoding is dropped"
1003        );
1004
1005        // The body/timestamp faults leave the entry's fields at the valid
1006        // resolution — they ride at send time.
1007        let truncate = health
1008            .iter()
1009            .find(|e| e.fault == Some(Fault::Truncate))
1010            .unwrap();
1011        assert_eq!(truncate.qos, "transition");
1012        assert!(truncate.key.ends_with("/health"));
1013    }
1014
1015    /// The post-encode body perturbations produce exactly the deviation each
1016    /// kind names — and never route through the validating encoder (that is
1017    /// why they can violate the schema at all).
1018    #[test]
1019    fn body_faults_perturb_the_encoded_bytes() {
1020        let valid = br#"{"ok":true,"load":3}"#.to_vec();
1021
1022        let truncated = Fault::Truncate.perturb_body(valid.clone());
1023        assert_eq!(truncated.len(), valid.len() / 2, "half the bytes survive");
1024
1025        let wrong = Fault::WrongType.perturb_body(valid.clone());
1026        let v: serde_json::Value = serde_json::from_slice(&wrong).unwrap();
1027        assert!(v.is_string(), "a bare string where an object was declared");
1028
1029        let extra = Fault::ExtraField.perturb_body(valid.clone());
1030        let v: serde_json::Value = serde_json::from_slice(&extra).unwrap();
1031        assert_eq!(v["_fault"], true, "the undeclared field rides");
1032        assert_eq!(v["ok"], true, "the valid fields survive alongside it");
1033    }
1034}