Skip to main content

saya_agent/protocol/
contracts.rs

1use async_trait::async_trait;
2use saya_types::{ClaimId, ClaimStatus};
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct AgentRequest {
7    pub prompt: String,
8    pub profile_names: Vec<String>,
9    pub model: String,
10    /// Optional extra system context appended to the base SAYA system prompt (e.g. available database connections).
11    #[serde(default, skip_serializing_if = "Option::is_none")]
12    pub system_prompt: Option<String>,
13    #[serde(default)]
14    pub history: Vec<ChatMessage>,
15    /// Untrusted, labelled database context rendered into the user turn — never the
16    /// system message. `#[serde(default)]` keeps old serialized requests deserializable;
17    /// `skip_serializing_if` keeps an empty vector off the wire.
18    #[serde(default, skip_serializing_if = "Vec::is_empty")]
19    pub context_blocks: Vec<ContextBlock>,
20}
21
22/// A labelled, untrusted chunk of database context (a contract, a schema note, a
23/// comment) that reaches the model as quoted data inside the user turn, never as
24/// policy in the system message. Nothing populates this in Phase 2a; Phase 2b wires
25/// recall in.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct ContextBlock {
28    /// Short machine-ish label for the block's source, e.g. "database-contracts".
29    pub label: String,
30    /// The block's content. Untrusted.
31    pub body: String,
32    /// True when the source had more to give than the caller's budget allowed.
33    pub truncated: bool,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37pub struct ChatMessage {
38    pub role: String,
39    pub content: String,
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub tool_calls: Vec<ToolCall>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub tool_call_id: Option<String>,
44}
45
46impl ChatMessage {
47    pub fn text(role: &str, content: impl Into<String>) -> Self {
48        Self {
49            role: role.into(),
50            content: content.into(),
51            tool_calls: Vec::new(),
52            tool_call_id: None,
53        }
54    }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
58pub struct ToolCall {
59    pub id: String,
60    pub name: String,
61    pub arguments: serde_json::Value,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65pub struct ToolMetadata {
66    pub name: String,
67    pub status: String,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ChatRequest {
72    pub model: String,
73    pub messages: Vec<ChatMessage>,
74    pub tools: Vec<ToolDefinition>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct ChatResponse {
79    pub message: ChatMessage,
80}
81
82/// The three distinguishable states of a turn's recall, as
83/// [`AgentEvent::KnowledgeSupplied`] carries them (spec P1b §3). `Off` (recall
84/// disabled by config), `Skipped` (the privacy gate closed — SAYA was not
85/// allowed to look), and `Ran` (recall ran against the store) are three facts a
86/// user reads differently; collapsing them into a single "no event" would hide
87/// the distinction between "SAYA was not allowed to look" and "SAYA looked and
88/// had nothing". `Ran { store_unavailable: true }` records a store failure that
89/// degraded recall to an empty result — the turn still completes (recall is
90/// fail-soft, spec §3).
91#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
92#[serde(rename_all = "snake_case")]
93#[non_exhaustive]
94pub enum KnowledgeOutcome {
95    /// Recall is off by config; SAYA did not look.
96    Off,
97    /// The privacy gate closed; SAYA was not allowed to look. No store query.
98    Skipped,
99    /// Recall ran against the store. `store_unavailable` is true when a store
100    /// failure degraded recall to an empty result.
101    Ran { store_unavailable: bool },
102}
103
104/// One claim as **supplied** to a turn's context block, in the DTO shape that
105/// crosses the crate boundary into [`AgentEvent::KnowledgeSupplied`]. Carries
106/// the claim id (so a later phase can name exactly which saved claims shaped
107/// an answer), its kind, the short rendered value the prompt block shows, a
108/// column when the claim is column-scoped, and its persisted status — so a
109/// `Candidate` reads as `candidate`, distinct from `confirmed` (spec P1b §4.5).
110///
111/// No raw payload, evidence, or SQL. `value` is the same short rendered form
112/// the prompt block already shows (a column name, an alias), not the stored
113/// payload — and it is named **supplied**, never *used*: a confirmed claim
114/// being supplied does not mean the generated SQL honoured it (we have
115/// measured that it frequently does not).
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
117pub struct SuppliedClaimDto {
118    pub claim_id: ClaimId,
119    /// The claim kind token (`table_alias`, `default_time_column`, …).
120    pub kind: String,
121    /// The short rendered value the prompt block shows, not the stored payload.
122    pub value: String,
123    /// A column name when the claim is column-scoped; `None` for table-level
124    /// claims. `skip_serializing_if` keeps it off the wire when absent.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub column: Option<String>,
127    pub status: ClaimStatus,
128}
129
130/// One object's claims, as supplied to the turn, in the DTO shape that crosses
131/// the crate boundary into [`AgentEvent::KnowledgeSupplied`]. `profile` is the
132/// human-facing profile **name**, never the opaque [`saya_types::ProfileIdentity`]
133/// — the identity has no field here, by construction (spec P1b §3). `schema_state`
134/// is the contract's aggregated state token (`current` / `needs_review` /
135/// `live_schema_unavailable`); `stale` never appears (a contract aggregating to
136/// `Stale` is dropped by the model-path policy before supply).
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
138pub struct SuppliedContractDto {
139    /// The human-facing profile name. Never the opaque identity.
140    pub profile: String,
141    /// The object's qualified name (`catalog.schema.object`).
142    pub object: String,
143    /// The aggregated schema state token; `stale` never appears here.
144    pub schema_state: String,
145    pub claims: Vec<SuppliedClaimDto>,
146}
147
148/// One candidate claim **proposed** (persisted) this turn, in the DTO shape that
149/// crosses the crate boundary into [`AgentEvent::KnowledgeProposed`] (spec P2d).
150/// Mirrors [`SuppliedClaimDto`]'s vocabulary — same `claim_id` / `kind` / `value`
151/// / `column` / `status` — and adds `profile` and `object`, because a proposal is
152/// a single flat claim, not a claim nested under a contract stanza. `profile` is
153/// the human-facing profile **name**, never the opaque
154/// [`saya_types::ProfileIdentity`] (no identity field, by construction).
155///
156/// `value` is the same short rendered form a later recall would show (a column
157/// name, an alias), reusing the recall render path's `claim_value` so a proposal
158/// can never name a value recall would not — not the stored payload. `status` is
159/// the status the claim *landed with*: `contract_propose` stores only a
160/// `Candidate`, so a `KnowledgeProposed` event never reads as established (a
161/// candidate is inert until a human confirms it). No raw SQL, evidence, or cells.
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
163pub struct ProposedClaimDto {
164    pub claim_id: ClaimId,
165    /// The human-facing profile name. Never the opaque identity.
166    pub profile: String,
167    /// The object's qualified name (`catalog.schema.object`).
168    pub object: String,
169    /// The claim kind token (`table_alias`, `default_time_column`, …).
170    pub kind: String,
171    /// The short rendered value, not the stored payload.
172    pub value: String,
173    /// A column name when the claim is column-scoped; `None` for table-level
174    /// claims. `skip_serializing_if` keeps it off the wire when absent.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub column: Option<String>,
177    /// The status the claim landed with — `Candidate` for a proposal.
178    pub status: ClaimStatus,
179}
180
181/// One confirmed claim the turn's SQL contradicted, in the DTO shape that
182/// crosses the crate boundary into [`AgentEvent::KnowledgeOverridden`] (spec
183/// A1). Mirrors nothing about a claim being *used* — the finding says the claim
184/// was contradicted and names the time-named columns the SQL **referenced**
185/// instead, which is all the extractor can prove from names. `claimed_value`
186/// is the value the claim specifies (the claimed time column), carried so a
187/// render can say "where you specified Y". No opaque identity, no raw SQL.
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
189pub struct OverrideFindingDto {
190    pub claim_id: ClaimId,
191    /// The claim kind token. Only `default_time_column` is ever produced.
192    pub kind: String,
193    /// The value the claim specifies — for `default_time_column`, the claimed
194    /// time column. "Where you specified Y" in the render.
195    pub claimed_value: String,
196    /// Time-named columns the SQL referenced instead, as written, sorted for
197    /// determinism. Observed references, not an asserted "used" column.
198    pub observed_columns: Vec<String>,
199}
200
201// `arguments` carries a `serde_json::Value`, which is not `Eq`, so this enum is
202// `PartialEq` only.
203#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
204#[serde(tag = "type", rename_all = "snake_case")]
205#[non_exhaustive]
206pub enum AgentEvent {
207    AssistantText {
208        text: String,
209    },
210    /// A tool was requested. `arguments` is the raw call payload (e.g. the SQL),
211    /// surfaced so the user can see exactly what will run before approving it.
212    ToolRequested {
213        name: String,
214        #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
215        arguments: serde_json::Value,
216    },
217    ToolCompleted {
218        name: String,
219        summary: String,
220    },
221    ToolDenied {
222        name: String,
223        reason: String,
224    },
225    /// What recall **supplied** to this turn's context block, emitted once per
226    /// turn *before* any provider request (so a reader can see what shaped the
227    /// SQL before it runs, not after — spec P1b §1/§2). The payload says
228    /// **supplied**, never *used*: a confirmed claim being supplied does not
229    /// mean the generated SQL honoured it. Carries at most what recall supplied
230    /// (already capped: ≤5 objects, ≤12 claims/object); no raw SQL or evidence.
231    KnowledgeSupplied {
232        outcome: KnowledgeOutcome,
233        contracts: Vec<SuppliedContractDto>,
234        /// Claims the byte or count bounds dropped (not the schema policy). A
235        /// non-zero count is the event's way of saying "the list above is a
236        /// subset, not the whole"; zero means the supply path kept everything.
237        dropped_by_bounds: usize,
238    },
239    /// A candidate claim was **proposed** — persisted — this turn (spec P2d).
240    /// Emitted once per persisted proposal, at the moment the store accepts it
241    /// (the `Stored` arm), so a refused, duplicate, or validation-failed proposal
242    /// emits nothing: the event names what was *written*, never what was merely
243    /// *asked for*. Carries the persisted claim's id, profile name, object, kind,
244    /// rendered value, and the `Candidate` status it landed with — never the
245    /// opaque identity, raw SQL, or evidence. Bounded by the tool's per-turn
246    /// proposal cap (≤8); the event stream inherits that bound, so no unbounded
247    /// field is needed.
248    KnowledgeProposed {
249        claim: ProposedClaimDto,
250    },
251    /// Post-turn extraction has started. The answer is already streamed and on
252    /// screen at this point, but the turn is not over: extraction is a second
253    /// provider call that the loop awaits, so an adapter stays busy until it
254    /// resolves. Emitted so that wait can be labelled — an unexplained spinner
255    /// after a finished answer reads as a hang, which is what forces the
256    /// extraction budget to be tighter than the work needs.
257    ///
258    /// Carries nothing. It is a progress signal, not content: an adapter with
259    /// no progress surface (the headless renderer) is right to ignore it.
260    KnowledgeLearningStarted,
261    /// A confirmed claim the turn's SQL **contradicted** — spec A1. Emitted at
262    /// most once per turn, after the loop, carrying every finding the detector
263    /// raised across the turn's statements. Silent when there is nothing to say
264    /// (the detector fails closed on unparseable SQL, partial column lists, joins,
265    /// and ambiguous objects); no event is emitted for an empty finding set.
266    ///
267    /// The finding says the claim was contradicted and names the time-named
268    /// columns the SQL **referenced** — observed references, not "the time column
269    /// SAYA used": from names alone the role of a column (predicate vs projection)
270    /// is unknowable, so the finding stops at "these were referenced where the
271    /// claim named a different column." No opaque identity, no raw SQL.
272    KnowledgeOverridden {
273        findings: Vec<OverrideFindingDto>,
274    },
275    /// Post-turn extraction was **skipped after the turn already succeeded** —
276    /// the turn's answer is unaffected, but no memory was recorded for it. Emitted
277    /// at most once per turn, after the loop, only when extraction was *expected*
278    /// to run (the gate admitted it) and then failed unexpectedly: it timed out
279    /// or the provider/parse/ingest step errored. A gate that *declines* emits
280    /// nothing — declining is the common case on ordinary turns and a line every
281    /// turn would be noise; only an unexpected failure surfaces. Carries the
282    /// reason so a render can distinguish "timed out" from "failed" without
283    /// re-deriving it. No raw response, no payload (spec packet-54 decision 1/2).
284    KnowledgeLearningSkipped {
285        reason: LearningSkipReason,
286    },
287    Complete,
288}
289
290/// Why post-turn extraction was skipped after the gate admitted it
291/// (`AgentEvent::KnowledgeLearningSkipped`, spec packet-54 decision 1). Two
292/// unexpected outcomes — a timeout and an error — each surface; a gate decline
293/// is silent and has no variant here. `#[non_exhaustive]` so a future cause
294/// (e.g. a bounded-cancel) can be added without breaking serialization.
295#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
296#[serde(rename_all = "snake_case")]
297#[non_exhaustive]
298pub enum LearningSkipReason {
299    /// Extraction exceeded the post-turn timeout. The turn's answer is already
300    /// in hand; learning is bounded so a long hang never gates the prompt.
301    TimedOut,
302    /// The provider, parse, or ingest step errored. Distinct from a timeout so a
303    /// render can name the right thing without re-deriving the outcome.
304    Failed,
305}
306
307impl AgentEvent {
308    pub fn assistant_text(text: impl Into<String>) -> Self {
309        Self::AssistantText { text: text.into() }
310    }
311
312    pub fn tool_requested(name: impl Into<String>, arguments: serde_json::Value) -> Self {
313        Self::ToolRequested {
314            name: name.into(),
315            arguments,
316        }
317    }
318
319    /// Builds the per-turn `KnowledgeSupplied` event from recall's outcome, the
320    /// supplied contracts, and the count the bounds dropped.
321    pub fn knowledge_supplied(
322        outcome: KnowledgeOutcome,
323        contracts: Vec<SuppliedContractDto>,
324        dropped_by_bounds: usize,
325    ) -> Self {
326        Self::KnowledgeSupplied {
327            outcome,
328            contracts,
329            dropped_by_bounds,
330        }
331    }
332
333    /// Builds the per-proposal `KnowledgeProposed` event for one persisted
334    /// candidate claim. The caller is the propose tool, at the `Stored` arm.
335    pub fn knowledge_proposed(claim: ProposedClaimDto) -> Self {
336        Self::KnowledgeProposed { claim }
337    }
338
339    /// Builds the per-turn `KnowledgeOverridden` event carrying every finding
340    /// the detector raised across the turn's statements. The caller is the
341    /// runtime, after the loop drains the override log; an empty `findings`
342    /// means the caller emits nothing (spec A1: "if it returns nothing, say
343    /// nothing").
344    pub fn knowledge_overridden(findings: Vec<OverrideFindingDto>) -> Self {
345        Self::KnowledgeOverridden { findings }
346    }
347
348    /// Builds the per-turn `KnowledgeLearningSkipped` event the runtime emits
349    /// when the gate admitted extraction but it then timed out or errored (spec
350    /// packet-54). The caller is the runtime, after the loop; a gate decline
351    /// never calls this — declining is silent, and only an unexpected failure
352    /// surfaces.
353    pub fn knowledge_learning_skipped(reason: LearningSkipReason) -> Self {
354        Self::KnowledgeLearningSkipped { reason }
355    }
356
357    pub fn complete() -> Self {
358        Self::Complete
359    }
360}
361
362#[async_trait]
363pub trait ApprovalDecider: Send + Sync {
364    /// Decides whether a tool call may run. `arguments` is the raw call payload
365    /// so implementations can show the user what they are approving.
366    async fn approve(&self, tool: &ToolDefinition, arguments: &serde_json::Value) -> bool;
367}
368
369pub struct AllowReadOnlyApproval;
370
371#[async_trait]
372impl ApprovalDecider for AllowReadOnlyApproval {
373    async fn approve(&self, _: &ToolDefinition, _: &serde_json::Value) -> bool {
374        true
375    }
376}
377
378#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
379#[non_exhaustive]
380pub enum ProviderError {
381    #[error("provider request failed: {0}")]
382    Request(String),
383    #[error("provider returned an invalid response")]
384    InvalidResponse,
385    #[error("provider is not configured: {0}")]
386    Configuration(String),
387    #[error("provider stream was cancelled")]
388    Cancelled,
389}
390
391impl ProviderError {
392    pub fn configuration(message: impl Into<String>) -> Self {
393        Self::Configuration(message.into())
394    }
395}
396
397#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
398pub enum ToolError {
399    #[error("data sharing is disabled for this cloud provider")]
400    DataSharingDisabled,
401    #[error("invalid query arguments")]
402    InvalidQueryArguments,
403    #[error("unsupported read-only tool")]
404    UnsupportedTool,
405    #[error("invalid tool arguments: expected an object")]
406    ArgumentsNotObject,
407    #[error("invalid tool arguments: unsupported property")]
408    UnsupportedProperty,
409    #[error("invalid tool arguments: connection must be a string")]
410    ConnectionNotString,
411    #[error("invalid tool arguments: sql must be a string")]
412    SqlNotString,
413    #[error("no database profile is selected")]
414    NoConnectionSelected,
415    #[error("unknown connection \"{target}\"; available connections: {available}")]
416    UnknownConnection { target: String, available: String },
417    #[error("read-only query failed")]
418    QueryFailed,
419    #[error("read-only query failed: {0}")]
420    QueryFailedDetail(String),
421    #[error("read-only query timed out")]
422    QueryTimedOut,
423    #[error("query result unavailable")]
424    QueryResultUnavailable,
425    #[error("schema discovery failed: {0}")]
426    SchemaDiscoveryFailed(String),
427    #[error("{0}")]
428    Chart(String),
429}
430
431/// What local state a tool may touch — contracts, the schema cache, anything
432/// persisted on the user's machine. Declared per tool so "may this tool write
433/// local state?" is a property the loop reads rather than something inferred
434/// from a tool's name. Phase 3a introduces the type; Phase 3c adds the first
435/// tool that declares `WriteCandidate`.
436#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
437#[serde(rename_all = "snake_case")]
438#[non_exhaustive]
439pub enum LocalStateEffect {
440    /// Touches no local state.
441    #[default]
442    None,
443    /// Reads local state (contracts, cache) and writes nothing.
444    Read,
445    /// May persist a *candidate* claim. Never a confirmed one — confirmation is a
446    /// human action and has no tool.
447    WriteCandidate,
448}
449
450#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
451pub struct ToolEffect {
452    pub database_data: bool,
453    pub external_side_effect: bool,
454    pub requires_approval: bool,
455    /// What local state this tool may touch. `#[serde(default)]` keeps the
456    /// pre-3a serialized form (no key) deserializing to `None`.
457    #[serde(default)]
458    pub local_state: LocalStateEffect,
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize)]
462pub struct ToolDefinition {
463    pub name: String,
464    pub description: String,
465    pub read_only: bool,
466    pub parameters: serde_json::Value,
467    pub effect: ToolEffect,
468}
469
470#[async_trait]
471pub trait ToolExecutor: Send + Sync {
472    async fn execute(
473        &self,
474        name: &str,
475        arguments: serde_json::Value,
476    ) -> Result<serde_json::Value, ToolError>;
477}
478
479#[cfg(test)]
480mod tests {
481    use super::LocalStateEffect;
482
483    /// The back-compat guarantee: a `ToolEffect` serialized before this slice
484    /// (no `local_state` key) deserializes to the default `None`.
485    #[test]
486    fn tool_effect_without_local_state_key_defaults_to_none() {
487        let json = r#"{
488            "database_data": false,
489            "external_side_effect": false,
490            "requires_approval": false
491        }"#;
492        let effect: super::ToolEffect = serde_json::from_str(json).expect("old form deserializes");
493        assert_eq!(effect.local_state, LocalStateEffect::None);
494    }
495
496    /// Each variant round-trips through snake_case.
497    #[test]
498    fn local_state_effect_round_trips_through_snake_case() {
499        for (variant, expected) in [
500            (LocalStateEffect::None, "none"),
501            (LocalStateEffect::Read, "read"),
502            (LocalStateEffect::WriteCandidate, "write_candidate"),
503        ] {
504            let text = serde_json::to_string(&variant).expect("serializes");
505            assert_eq!(text, format!("\"{expected}\""), "{variant:?}");
506            let back: LocalStateEffect = serde_json::from_str(&text).expect("deserializes back");
507            assert_eq!(back, variant, "{variant:?}");
508        }
509    }
510
511    /// `KnowledgeOverridden` serializes under its `knowledge_overridden` type tag
512    /// (spec A1) and carries the finding's fields, with no opaque identity — the
513    /// DTO has no such field, by construction.
514    #[test]
515    fn knowledge_overridden_serializes_with_type_tag_and_findings() {
516        use super::{AgentEvent, OverrideFindingDto};
517        use saya_types::ClaimId;
518        let event = AgentEvent::knowledge_overridden(vec![OverrideFindingDto {
519            claim_id: ClaimId::parse("c-rental-time").unwrap(),
520            kind: "default_time_column".into(),
521            claimed_value: "return_date".into(),
522            observed_columns: vec!["rental_date".into()],
523        }]);
524        let json = serde_json::to_string(&event).expect("serializes");
525        assert!(json.contains(r#""type":"knowledge_overridden""#), "{json}");
526        assert!(
527            json.contains("return_date"),
528            "carries the claimed value: {json}"
529        );
530        assert!(
531            json.contains("rental_date"),
532            "carries the observed column: {json}"
533        );
534        // No opaque identity field exists on the DTO; a fabricated one must not
535        // appear in the serialized event.
536        let fake_identity =
537            "sha256:9f2a8c7b1e4d0a6f3c5b8e2d7a9f1c4b6e8a0d2f4c6b8e0a2d4f6c8b0e2d4f6";
538        assert!(!json.contains(fake_identity), "identity leaked: {json}");
539    }
540
541    /// `KnowledgeLearningSkipped` serializes under its `knowledge_learning_skipped`
542    /// type tag and carries the reason; both reasons round-trip (spec packet-54
543    /// decision 1 — `#[non_exhaustive]` enum with the same derive set as siblings).
544    #[test]
545    fn knowledge_learning_skipped_serializes_with_type_tag_and_reason() {
546        use super::{AgentEvent, LearningSkipReason};
547        for (reason, token) in [
548            (LearningSkipReason::TimedOut, "timed_out"),
549            (LearningSkipReason::Failed, "failed"),
550        ] {
551            let event = AgentEvent::knowledge_learning_skipped(reason);
552            let json = serde_json::to_string(&event).expect("serializes");
553            assert!(
554                json.contains(r#""type":"knowledge_learning_skipped""#),
555                "type tag for {reason:?}: {json}"
556            );
557            assert!(
558                json.contains(&format!(r#""reason":"{token}""#)),
559                "reason token for {reason:?}: {json}"
560            );
561            let back: AgentEvent = serde_json::from_str(&json).expect("deserializes back");
562            assert_eq!(back, event, "round-trips for {reason:?}");
563        }
564    }
565}