autumn-web 0.9.0

An opinionated, convention-over-configuration web framework for Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
//! On-disk shape of a failure capsule.
//!
//! A capsule is a single JSON document describing one failed request: the
//! (redacted) request that produced it, the clock readings the handler took,
//! the database traffic it generated, and the outcome the client received.
//! Everything here is `serde`-round-trippable and versioned by
//! [`CAPSULE_FORMAT_VERSION`] so a capsule recorded by one build is either
//! replayable by another or rejected outright — never silently misread.
//!
//! Byte-valued fields (wire frames, bind parameters) are base64-encoded so a
//! capsule stays a plain, diffable JSON file.

// autumn-determinism-gate: production code in this module must read time and
// mint identifiers through the framework's injected seams (ClockSource /
// Entropy), never `Instant::now()` / `Utc::now()` / `SystemTime::now()` /
// `Uuid::new_v4()` directly. See CONTRIBUTING.md "Determinism seam gate"
// (issue #1797). Justify exceptions with
// #[allow(clippy::disallowed_methods, reason = "…")] at the narrowest scope.
#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
// autumn-panic-gate: request-path module — production code path must be panic-free.
// See CONTRIBUTING.md "Request-path panic gate". Justify exceptions with
// #[allow(clippy::<lint>, reason = "…")] at the narrowest scope.
#![cfg_attr(
    not(test),
    deny(
        clippy::unwrap_used,
        clippy::expect_used,
        clippy::panic,
        clippy::unreachable,
        clippy::todo,
        clippy::unimplemented,
        clippy::indexing_slicing,
        clippy::string_slice,
        clippy::arithmetic_side_effects,
    )
)]

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

/// Version of the capsule document format understood by this build.
///
/// Bumped whenever the schema changes in a way a previous reader cannot
/// tolerate. Replay refuses any capsule whose `format_version` differs.
///
/// A *semantic* field counts as such a change even though `serde` would
/// happily ignore it. [`Capsule::db_roles`] is the case that made this
/// concrete: a v1 reader skips the unknown field, rebuilds no database
/// topology for a capsule whose `db` is `null`, and a handler that checks
/// pool availability before querying takes a branch the recording never took
/// — a `mismatch` the guide tells operators to read as "the bug is gone".
/// Tolerating the document silently is precisely what the version gate exists
/// to prevent, so adding the field bumps the version.
///
/// | version | added |
/// | --- | --- |
/// | 1 | request, clock, database tape, outcome (#1598) |
/// | 2 | [`Capsule::db_roles`] |
/// | 3 | [`Capsule::effects`] (outbound HTTP, jobs, cache, mail, tenancy, randomness) and [`Capsule::job`] (#1634) |
/// | 4 | cache removals and untyped reads, failed tenant lookups, entropy draw widths (#2351) |
///
/// Version 3 is the same kind of semantic bump version 2 was, one seam wider:
/// a v2 reader would skip `effects` entirely, replay a handler whose outbound
/// call, cache read or minted identifier is nowhere in the document, and
/// report a verdict on a run that never met the recording's effects.
///
/// A new enum value (for example [`HttpErrorKind::FaultInjected`]) does not
/// bump the version: this build reads every v3 capsule, and a bump would make
/// each committed corpus unreadable. An older reader rejects a capsule that
/// holds the new value as malformed. A reader checks the version before the
/// rest, so from this build on a later bump reports as a version mismatch.
///
/// Version 4 records more than version 3. A v3 capsule has no entry for a
/// cache removal, an untyped cache read or a failed tenant lookup, and its
/// draw widths can be off by an enqueue's job id. This build still reads a v3
/// capsule ([`OLDEST_READABLE_FORMAT_VERSION`]), and replays it with the v3
/// rules for those seams, so a committed corpus keeps its verdicts.
pub const CAPSULE_FORMAT_VERSION: u32 = 4;

/// The oldest capsule format this build reads. See [`CAPSULE_FORMAT_VERSION`].
pub const OLDEST_READABLE_FORMAT_VERSION: u32 = 3;

/// Errors surfaced when reading a capsule back from disk.
#[derive(Debug)]
pub enum CapsuleError {
    /// The capsule file could not be read.
    Io(std::io::Error),
    /// The capsule was not valid JSON, or did not match the schema.
    Malformed(serde_json::Error),
    /// The capsule was written by an incompatible format version.
    VersionMismatch {
        /// The version recorded in the capsule.
        found: u32,
        /// The version this build understands.
        expected: u32,
    },
}

impl std::fmt::Display for CapsuleError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Io(error) => write!(f, "failed to read capsule: {error}"),
            Self::Malformed(error) => write!(f, "capsule is not a valid capsule document: {error}"),
            Self::VersionMismatch { found, expected } => {
                let direction = if found < expected {
                    "older than"
                } else {
                    "newer than"
                };
                write!(
                    f,
                    "capsule format version {found} is {direction} the version this build \
                     understands ({expected}), so replaying it would judge the handler against \
                     effects the document cannot describe. Re-record the capsule with this \
                     build, or replay it with the Autumn version that wrote it — see the \
                     \"Compatibility across Autumn versions\" section of the failure-capsule \
                     guide."
                )
            }
        }
    }
}

impl std::error::Error for CapsuleError {}

/// A recorded failure: one request, everything it observed, and how it ended.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Capsule {
    /// Format version of this document; see [`CAPSULE_FORMAT_VERSION`].
    pub format_version: u32,
    /// Capsule identifier — the request id when one was available.
    pub id: String,
    /// When the capsule was written.
    pub captured_at: DateTime<Utc>,
    /// `autumn-web` version of the build that recorded it.
    pub autumn_version: String,
    /// Identity of the recording application.
    #[serde(default)]
    pub app: AppInfo,
    /// The redacted request that produced the failure.
    pub request: CapsuleRequest,
    /// What the client received.
    pub outcome: CapsuleOutcome,
    /// Clock readings taken during the request, in the order they were read.
    #[serde(default)]
    pub clock: Vec<DateTime<Utc>>,
    /// Monotonic clock readings taken during the request, in read order, as
    /// microseconds since the recording clock's origin. Serves
    /// `ClockSource::monotonic` during replay the way `clock` serves `now()`.
    /// Absent (empty) in capsules written before this field existed.
    #[serde(default)]
    pub clock_monotonic_us: Vec<u64>,
    /// Database traffic recorded for the request, when DB capture was active.
    #[serde(default)]
    pub db: Option<CapsuleDb>,
    /// Database roles the recording application had configured, whatever
    /// traffic the request produced.
    ///
    /// `db` is `None` when the request issued no wire traffic at all, which is
    /// a different fact from "this application has no database": a handler or
    /// state initializer that checks `state.pool()` or replica availability
    /// *before* querying would otherwise see a shape production never had.
    /// Absent in capsules recorded before this field existed.
    #[serde(default)]
    pub db_roles: Vec<String>,
    /// Set when a size cap stopped recording partway through; such a capsule
    /// is not replayable.
    #[serde(default)]
    pub truncated: bool,
    /// Human-readable notes about degraded capture (e.g. "db capture
    /// unavailable").
    #[serde(default)]
    pub notes: Vec<String>,
    /// Framework effects the recorded run produced outside the request and the
    /// database: outbound HTTP, job enqueues, cache reads and writes, mail
    /// sends, the resolved tenant, and the random bytes it drew (#1634).
    ///
    /// Each seam is served from here on replay, in recorded order, with the
    /// same no-live-effects posture the database tape has.
    #[serde(default)]
    pub effects: CapsuleEffects,
    /// The job this capsule replays, when the failure happened inside a job
    /// execution rather than while serving a request.
    ///
    /// `None` is the ordinary request capsule. When it is `Some`, `request`
    /// holds a synthetic descriptor of the job (so every field that names "the
    /// recorded entry point" still resolves) and replay dispatches the named
    /// job with the recorded payload instead of driving the router.
    #[serde(default)]
    pub job: Option<CapsuleJob>,
}

impl Capsule {
    /// Parse a capsule document, rejecting an incompatible format version.
    ///
    /// # Errors
    ///
    /// Returns [`CapsuleError::Malformed`] when the JSON does not match the
    /// schema and [`CapsuleError::VersionMismatch`] when the document was
    /// written by an incompatible build.
    pub fn from_json(json: &str) -> Result<Self, CapsuleError> {
        /// The version alone. Read first, so a document from another build
        /// reports its version, also when it holds a value this build does
        /// not know (#3071).
        #[derive(Deserialize)]
        struct VersionProbe {
            format_version: u32,
        }

        let probe: VersionProbe = serde_json::from_str(json).map_err(CapsuleError::Malformed)?;
        if !(OLDEST_READABLE_FORMAT_VERSION..=CAPSULE_FORMAT_VERSION)
            .contains(&probe.format_version)
        {
            return Err(CapsuleError::VersionMismatch {
                found: probe.format_version,
                expected: CAPSULE_FORMAT_VERSION,
            });
        }
        serde_json::from_str(json).map_err(CapsuleError::Malformed)
    }
}

/// Identity of the application that recorded a capsule.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppInfo {
    /// Application name, when the build exposed one.
    #[serde(default)]
    pub name: Option<String>,
    /// Active profile (e.g. `prod`).
    #[serde(default)]
    pub profile: Option<String>,
    /// Whether the recording binary was compiled with `debug_assertions` —
    /// `false` means a release build. `autumn replay` uses this to compile the
    /// replay binary the same way, so `cfg(debug_assertions)`-gated code and
    /// release-only failures behave as they did in the failing run. Absent in
    /// capsules recorded before this field existed.
    #[serde(default)]
    pub debug_assertions: Option<bool>,
}

/// The redacted request a capsule replays.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapsuleRequest {
    /// HTTP method (e.g. `GET`).
    pub method: String,
    /// Request target including the (redacted) query string.
    pub uri: String,
    /// Matched route template (e.g. `/users/{id}`), when routing had resolved.
    #[serde(default)]
    pub route: Option<String>,
    /// HTTP version, formatted as `http::Version` debug-prints it.
    pub http_version: String,
    /// Request headers in wire order, sensitive values already masked.
    pub headers: Vec<(String, String)>,
    /// Non-sensitive headers whose values are valid HTTP bytes but not valid
    /// UTF-8 (`obs-text` metadata), as `(name, base64(value))`. Kept apart so
    /// `headers` stays diffable text; replay restores both sets. A name with
    /// *any* obs-text value moves here wholesale — all its values, in
    /// original order — so `get_all(name)` order survives the split. Empty
    /// in capsules written before this field existed.
    #[serde(default)]
    pub binary_headers: Vec<(String, String)>,
    /// The (redacted) request body.
    pub body: CapsuleBody,
    /// Sorted list of what redaction masked, prefixed by location — e.g.
    /// `header:authorization`, `query:token`, `body:user.password`.
    #[serde(default)]
    pub redacted_keys: Vec<String>,
    /// The raw peer socket the request arrived on (`ConnectInfo`), before
    /// any trusted-proxy resolution — the proxy's own address and the real
    /// source port. Replay restores it verbatim so code inspecting the peer
    /// directly sees what the server saw.
    #[serde(default)]
    pub peer_addr: Option<std::net::SocketAddr>,
    /// The client address the trusted-proxies resolver settled on, when it
    /// ran. Replay re-anchors `ClientAddr` on this so identity-reading
    /// handlers reproduce without a real peer socket.
    #[serde(default)]
    pub client_addr: Option<std::net::IpAddr>,
    /// The external host the resolver settled on, restored so `ClientHost`
    /// replays the value the failing request saw rather than re-deriving one
    /// from an untrusted synthetic peer.
    #[serde(default)]
    pub client_host: Option<String>,
    /// The external scheme the resolver settled on, restored for
    /// `ClientScheme` for the same reason.
    #[serde(default)]
    pub client_scheme: Option<String>,
}

/// A captured request body.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CapsuleBody {
    /// The request carried no body.
    Absent,
    /// A UTF-8 body, stored verbatim after redaction.
    Text(String),
    /// A non-UTF-8 body, base64-encoded.
    Base64(String),
    /// The body was larger than the capture cap and was deliberately never
    /// consumed, so the handler still received it intact.
    Skipped {
        /// `Content-Length` the client declared, when it declared one.
        #[serde(default)]
        declared_len: Option<usize>,
    },
}

/// What the client received for the captured request.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CapsuleOutcome {
    /// An ordinary response (always a 5xx for a recorded capsule).
    Status {
        /// HTTP status code.
        code: u16,
        /// Error message, from `AutumnErrorInfo` when present.
        message: String,
        /// Problem Details `type` URI, when the error carried one.
        #[serde(default)]
        problem_type: Option<String>,
    },
    /// A caught handler panic, turned into a sanitized 500.
    Panic {
        /// Status the client received (always 500).
        status: u16,
        /// The panic payload.
        payload: String,
        /// Backtrace, when `RUST_BACKTRACE` was set.
        #[serde(default)]
        backtrace: Option<String>,
    },
}

/// Database traffic recorded for one request.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapsuleDb {
    /// One tape per pooled connection the request touched.
    pub connections: Vec<ConnectionTape>,
}

/// Everything recorded on a single pooled connection.
///
/// `prologue`, `statements` and `catalog` carry the connection's *history*
/// (birth-to-request setup, prepared-statement metadata, `pg_catalog` lookups)
/// so a replayed request sees a warm connection even though it was captured on
/// one that had already been used.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConnectionTape {
    /// Recorder-assigned connection identifier.
    pub id: u64,
    /// Which pool role recorded this connection: `"primary"` or `"replica"`.
    /// Replay rebuilds one stub pool per role so a write-then-read request
    /// claims each tape from the pool it was recorded on. Capsules written
    /// before this field existed deserialize as `"primary"`, matching what
    /// they were.
    #[serde(default = "default_tape_role")]
    pub role: String,
    /// Exchanges from connection birth up to the first request binding.
    #[serde(default)]
    pub prologue: Vec<Exchange>,
    /// Parse/Describe metadata keyed by SQL, replayed on demand.
    #[serde(default)]
    pub statements: Vec<Exchange>,
    /// `pg_catalog` / `information_schema` lookups, replayed on demand.
    #[serde(default)]
    pub catalog: Vec<Exchange>,
    /// The request's own exchanges, in order.
    #[serde(default)]
    pub exchanges: Vec<Exchange>,
}

/// The role a tape deserializes with when the capsule predates roles: every
/// pre-role capsule was recorded on the primary.
fn default_tape_role() -> String {
    "primary".to_owned()
}

/// The `role` string replica-recorded tapes carry.
pub const TAPE_ROLE_REPLICA: &str = "replica";
/// The `role` string primary-recorded tapes carry.
pub const TAPE_ROLE_PRIMARY: &str = "primary";

/// Which Postgres protocol carried an exchange.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExchangeProtocol {
    /// Simple `Query` protocol (`batch_execute`).
    Simple,
    /// Extended protocol (Parse/Bind/Execute).
    Extended,
}

/// One request/response round trip on a connection.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Exchange {
    /// Protocol that carried it.
    pub protocol: ExchangeProtocol,
    /// The SQL text the frontend sent.
    pub sql: String,
    /// Bind parameters, in order.
    #[serde(default)]
    pub binds: Vec<BindValue>,
    /// Raw backend frames, up to and including `ReadyForQuery`.
    #[serde(default, with = "b64")]
    pub response: Vec<u8>,
    /// Number of `DataRow` frames in `response`, for reporting.
    #[serde(default)]
    pub row_count: usize,
    /// Error text when the backend answered with `ErrorResponse`.
    #[serde(default)]
    pub error: Option<String>,
}

/// A single bind parameter.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BindValue {
    /// SQL `NULL` (wire length `-1`).
    Null,
    /// Raw parameter bytes.
    Value(#[serde(with = "b64")] Vec<u8>),
    /// A value byte-equal to something redaction masked. Excluded from replay
    /// bind comparison, because the capsule does not carry the real bytes.
    Masked,
}

// ── Effect tape (#1634) ─────────────────────────────────────────────────────

/// Every framework effect one recorded run produced outside the request and
/// the database.
///
/// Each list is in the order the run performed the effect. Ordering is
/// per-seam rather than global on purpose: two outbound calls the handler
/// `join!`s have no deterministic interleaving against each other, let alone
/// against a cache read on a third task, so a single global order would be a
/// fact the recording cannot actually establish — and replay would then report
/// a divergence for an interleaving that was never guaranteed.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapsuleEffects {
    /// Outbound HTTP request/response pairs made through
    /// [`http_client`](crate::http_client), in call order. Outbound webhook
    /// deliveries are covered here too: they send through the same client.
    #[serde(default)]
    pub http: Vec<HttpEffect>,
    /// Background jobs the run enqueued, in enqueue order.
    #[serde(default)]
    pub jobs: Vec<JobEffect>,
    /// Cache reads and writes, in call order.
    #[serde(default)]
    pub cache: Vec<CacheEffect>,
    /// Mail the run handed to the mailer, in send order.
    #[serde(default)]
    pub mail: Vec<MailEffect>,
    /// The tenant context the run resolved, when the app is multi-tenant.
    #[serde(default)]
    pub tenant: Option<TenantEffect>,
    /// Random byte draws taken through [`Entropy`](crate::entropy::Entropy),
    /// in draw order.
    #[serde(default)]
    pub random: Vec<RandomEffect>,
    /// Whether the app state had a cache when the run started (from the
    /// builder, a state initializer or a startup hook such as
    /// `RedisCachePlugin`). A cache call can take a different path without
    /// one. Replay builds no cache backend and runs no startup hook, so it
    /// installs a cache that stores nothing in the global cache and the app
    /// state: before the state initializers for a builder's cache, after
    /// them for a startup hook's, as production did. A state initializer
    /// that installs its own cache runs again, with its backend offline.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub state_cache: bool,
}

impl CapsuleEffects {
    /// Whether the run recorded no effects at all.
    #[must_use]
    pub const fn is_empty(&self) -> bool {
        self.http.is_empty()
            && self.jobs.is_empty()
            && self.cache.is_empty()
            && self.mail.is_empty()
            && self.tenant.is_none()
            && self.random.is_empty()
    }
}

/// One outbound HTTP request and the response it received.
///
/// Headers and structured bodies are masked through the same
/// `[log] filter_parameters` list the inbound request is: an outbound
/// `Authorization` header carries a downstream credential exactly the way an
/// inbound one carries the caller's.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HttpEffect {
    /// HTTP method the client sent.
    pub method: String,
    /// Absolute request URL, with its query string redacted.
    pub url: String,
    /// Request headers the caller set, already masked.
    #[serde(default)]
    pub request_headers: Vec<(String, String)>,
    /// The (redacted) request body.
    #[serde(default = "absent_body")]
    pub request_body: CapsuleBody,
    /// Status the peer answered with. `0` when the call never got a response
    /// (see `error`).
    pub status: u16,
    /// The URL the response ultimately came from, when redirects moved it.
    ///
    /// Absent when it is the request URL, which is the ordinary case. A handler
    /// that inspects `Response::url()` — to decide whether it was redirected,
    /// or to resolve a relative link against the final location — would
    /// otherwise branch differently under replay than it did live.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub final_url: Option<String>,
    /// Response headers, already masked.
    #[serde(default)]
    pub response_headers: Vec<(String, String)>,
    /// The (redacted) response body.
    #[serde(default = "absent_body")]
    pub response_body: CapsuleBody,
    /// Transport-level failure text, when the call produced no response at
    /// all. Replay reproduces the failure rather than a status.
    ///
    /// This is the failure's *exact* `Display` output, because replay hands it
    /// back verbatim: a handler that propagates the error puts this text in the
    /// capsule's outcome, and comparing outcomes is a string comparison.
    #[serde(default)]
    pub error: Option<String>,
    /// Which [`ClientError`](crate::http_client::ClientError) the failure was.
    ///
    /// The text alone is not enough: code that branches on the variant — a
    /// `matches!(err, ClientError::CircuitBreakerOpen)` retry guard — takes a
    /// different path if replay hands back a catch-all, and the capsule would
    /// grade a run that never happened. Absent on capsules recorded before the
    /// kind was tracked, which replay treats as [`HttpErrorKind::Transport`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error_kind: Option<HttpErrorKind>,
}

/// Which outbound-HTTP failure a capsule recorded.
///
/// Named rather than structural so replay can rebuild the *observable* error,
/// including for variants whose payload cannot be reconstructed from the
/// display text alone. [`Transport`](Self::Transport) is the honest fallback:
/// a `reqwest::Error` has no public constructor, so the recorded text is all
/// there is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HttpErrorKind {
    /// An underlying transport error; only its text survives.
    Transport,
    /// JSON (de)serialisation failed; only its text survives.
    Json,
    /// No mock matched the outgoing request.
    NoMock,
    /// The outbound circuit breaker was open.
    CircuitBreakerOpen,
    /// The client-side adaptive throttle rejected the call (issue #3068).
    ThrottledLocally,
    /// The SSRF policy blocked the address.
    SsrfBlocked,
    /// A redirect chain exceeded the hop limit.
    TooManyRedirects,
    /// A redirect target was rejected by the validator.
    RedirectRejected,
    /// A URL could not be parsed or resolved.
    InvalidUrl,
    /// Staging fault injection failed the call (issue #3071).
    FaultInjected,
}

impl Default for HttpEffect {
    /// An exchange with nothing recorded, for tests and fixtures to fill in the
    /// one or two fields they are about.
    fn default() -> Self {
        Self {
            method: String::new(),
            url: String::new(),
            request_headers: Vec::new(),
            request_body: CapsuleBody::Absent,
            status: 0,
            final_url: None,
            response_headers: Vec::new(),
            response_body: CapsuleBody::Absent,
            error: None,
            error_kind: None,
        }
    }
}

impl HttpEffect {
    /// The placeholder a reserved-but-unfinished tape slot holds.
    ///
    /// A concurrent call takes its tape position when it *starts*, so a slot
    /// exists before its response does. A run cut short mid-call leaves one of
    /// these behind, and a placeholder that names itself is far easier to read
    /// in a capsule than a silently zeroed record.
    #[must_use]
    pub fn pending() -> Self {
        Self {
            method: String::new(),
            url: String::new(),
            request_headers: Vec::new(),
            request_body: CapsuleBody::Absent,
            status: 0,
            final_url: None,
            response_headers: Vec::new(),
            response_body: CapsuleBody::Absent,
            error: Some(PENDING_EFFECT.to_owned()),
            error_kind: None,
        }
    }
}

/// The `error` text a reserved-but-never-completed effect carries.
pub const PENDING_EFFECT: &str =
    "the recording ended before this effect completed; the capsule is incomplete";

/// A background job the recorded run enqueued.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobEffect {
    /// Registered job name.
    pub name: String,
    /// The (redacted) JSON payload.
    pub payload: serde_json::Value,
    /// Delay the enqueue asked for, in seconds, when it was a scheduled
    /// enqueue.
    #[serde(default)]
    pub delay_secs: Option<i64>,
    /// The absolute instant an `enqueue_at`-family call asked for.
    ///
    /// Recorded beside `delay_secs` rather than folded into it because the two
    /// say different things. A relative enqueue states a delay and the deadline
    /// follows from when it ran; an absolute one states the deadline itself,
    /// and the delay recorded for it would differ between capture and replay
    /// purely because time passed. Comparing an absolute enqueue on its own
    /// terms is what lets a changed deadline be noticed at all.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub due_at: Option<chrono::DateTime<chrono::Utc>>,
    /// The absolute instant the caller asked for, when it was already past.
    ///
    /// An enqueue runs a past deadline at once, so capture records `due_at:
    /// None` and keeps the deadline here. Replay compares the deadline the
    /// caller gave against this field. An `*_after_commit` registration keeps
    /// its deadline in `due_at`, and leaves this field empty.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub requested_due_at: Option<chrono::DateTime<chrono::Utc>>,
    /// The error the backend returned, when the enqueue **failed**.
    ///
    /// A handler whose recorded 500 was caused by `enqueue(..).await?` — the
    /// queue was down, the channel was closed — must meet that error again on
    /// replay. Recording only the attempt would hand it `Ok(())` and send it
    /// down the success path the failing run never took.
    #[serde(default)]
    pub error: Option<String>,
    /// The HTTP status of [`error`](Self::error). Replay gives the error this
    /// status, so a handler that branches on it takes the same path. `None`
    /// (and a capsule from before this field) replays as a 500.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error_status: Option<u16>,
}

impl Default for JobEffect {
    /// An enqueue with nothing recorded; see [`HttpEffect::default`].
    fn default() -> Self {
        Self {
            name: String::new(),
            payload: serde_json::Value::Null,
            delay_secs: None,
            due_at: None,
            requested_due_at: None,
            error: None,
            error_status: None,
        }
    }
}

impl JobEffect {
    /// The placeholder a reserved-but-unfinished tape slot holds; see
    /// [`HttpEffect::pending`].
    #[must_use]
    pub fn pending() -> Self {
        Self {
            name: String::new(),
            payload: serde_json::Value::Null,
            delay_secs: None,
            due_at: None,
            requested_due_at: None,
            error: Some(PENDING_EFFECT.to_owned()),
            error_status: None,
        }
    }
}

/// The job a job-scoped capsule replays.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapsuleJob {
    /// Registered job name.
    pub name: String,
    /// The (redacted) JSON payload the failing execution ran with.
    pub payload: serde_json::Value,
}

/// One cache interaction.
///
/// Values are base64-encoded JSON — the representation
/// [`insert_cached`](crate::cache::insert_cached) already produces for
/// cross-replica backends — so a recorded hit can be handed back to
/// [`get_cached`](crate::cache::get_cached) on replay without the capsule
/// having to carry Rust types.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "op")]
pub enum CacheEffect {
    /// A read. `value` is `None` for a miss, and for a hit whose value the
    /// backend could not serialize (an in-process-only type) — the two are
    /// distinguished on replay by whether the key appears at all.
    Get {
        /// Cache key read.
        key: String,
        /// base64 of the JSON bytes served, when the read hit and the value
        /// was serializable.
        #[serde(default)]
        value: Option<String>,
    },
    /// A write.
    Insert {
        /// Cache key written.
        key: String,
        /// base64 of the JSON bytes written.
        value: String,
        /// Time-to-live the write asked for, in seconds, when it set one.
        ///
        /// Part of the effect, not decoration: a five-second entry and a
        /// permanent one are different cache mutations, and without this a
        /// change of expiry consumes the same tape entry and replays clean.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        ttl_secs: Option<u64>,
    },
    /// A removal of one key ([`Cache::invalidate`](crate::cache::Cache::invalidate)
    /// or [`Cache::invalidate_async`](crate::cache::Cache::invalidate_async)).
    Invalidate {
        /// Cache key removed.
        key: String,
        /// The failure the async form returned, if any.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        error: Option<CacheInvalidationError>,
    },
    /// A removal of one namespace
    /// ([`Cache::invalidate_namespace`](crate::cache::Cache::invalidate_namespace)
    /// or its async form).
    InvalidateNamespace {
        /// Namespace removed.
        namespace: String,
        /// The failure the backend returned, if any. The sync form maps
        /// `false` to a failure.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        error: Option<CacheInvalidationError>,
    },
    /// A removal of all entries ([`Cache::clear`](crate::cache::Cache::clear)).
    Clear,
}

/// A recorded [`InvalidationError`](crate::cache::InvalidationError).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheInvalidationError {
    /// Attempts the backend made.
    pub attempts: u32,
    /// Why it failed.
    pub reason: String,
}

impl CacheEffect {
    /// The key (or namespace) this interaction touched. Empty for
    /// [`Clear`](Self::Clear).
    #[must_use]
    pub fn key(&self) -> &str {
        match self {
            Self::Get { key, .. } | Self::Insert { key, .. } | Self::Invalidate { key, .. } => key,
            Self::InvalidateNamespace { namespace, .. } => namespace,
            Self::Clear => "",
        }
    }

    /// The placeholder a reserved-but-unfinished tape slot holds; see
    /// [`HttpEffect::pending`].
    #[must_use]
    pub fn pending() -> Self {
        Self::Invalidate {
            key: String::new(),
            error: Some(CacheInvalidationError {
                attempts: 0,
                reason: PENDING_EFFECT.to_owned(),
            }),
        }
    }

    /// Whether replay hands this entry to the code as input. Only a read
    /// is input; every other entry is a write that replay compares.
    #[must_use]
    pub const fn is_read(&self) -> bool {
        matches!(self, Self::Get { .. })
    }
}

/// One message handed to the mailer.
///
/// Replay asserts the send happened and never delivers it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MailEffect {
    /// `To` recipients. `Mail` carries no `Cc`/`Bcc`, so neither does this.
    #[serde(default)]
    pub to: Vec<String>,
    /// `From`, when the message set one explicitly.
    #[serde(default)]
    pub from: Option<String>,
    /// Subject line, masked like any other recorded text.
    pub subject: String,
    /// The (redacted) body a capsule compares on: plain text when the message
    /// had one, HTML otherwise.
    #[serde(default = "absent_body")]
    pub body: CapsuleBody,
    /// The *other* alternative of a multipart message, when it had one.
    ///
    /// Recorded separately because a message can change in one alternative
    /// alone — a rewritten HTML template with an untouched text fallback is a
    /// materially different email — and comparing only the preferred body
    /// would serve the recorded success to a run that now sends something
    /// else.
    #[serde(default = "absent_body")]
    pub alternate_body: CapsuleBody,
    /// `Reply-To`, when the message set one.
    #[serde(default)]
    pub reply_to: Option<String>,
    /// `List-Unsubscribe`, when the message set one.
    #[serde(default)]
    pub list_unsubscribe: Option<String>,
    /// Caller-set headers beyond the ones above, already masked.
    #[serde(default)]
    pub extra_headers: Vec<(String, String)>,
    /// What the message carried, by name and digest — never the bytes.
    #[serde(default)]
    pub attachments: Vec<MailAttachmentEffect>,
    /// The delivery error the recorded send produced, when it failed.
    ///
    /// Its exact `Display` output, for the same reason
    /// [`HttpEffect::error`] is.
    #[serde(default)]
    pub error: Option<String>,
    /// Which [`MailError`](crate::mail::MailError) the failure was, so replay
    /// can reproduce the variant a handler branches on and not only its text.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error_kind: Option<MailErrorKind>,
}

/// One attachment a recorded message carried.
///
/// The bytes themselves are *not* recorded: an attachment is routinely
/// megabytes, and it is exactly the kind of payload — an invoice, an export —
/// a capsule has no business copying onto disk. A digest settles the only
/// question replay needs to answer, which is whether the same file is still
/// being sent, and leaks nothing if the capsule is shared.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct MailAttachmentEffect {
    /// The filename the recipient would see.
    pub filename: String,
    /// The declared content type.
    pub content_type: String,
    /// Length of the attachment in bytes.
    pub len: usize,
    /// Lowercase hex SHA-256 of the attachment's bytes.
    pub sha256: String,
}

/// Which mail failure a capsule recorded. See [`HttpErrorKind`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MailErrorKind {
    /// The message itself was rejected as invalid.
    InvalidMessage,
    /// The mail runtime was unavailable.
    RuntimeUnavailable,
    /// A recipient or sender address failed to parse.
    InvalidAddress,
    /// The message failed to build.
    Build,
    /// The SMTP transport refused the send.
    Smtp,
    /// A file-transport write failed.
    Io,
    /// Every recipient was on the suppression list.
    AllRecipientsSuppressed,
    /// CSS inlining failed.
    CssInline,
    /// `deliver_later` in production had no durable queue.
    NoDurableQueueInProduction,
    /// Anything else; only the recorded text survives.
    Other,
}

impl Default for MailEffect {
    /// A send with nothing recorded; see [`HttpEffect::default`].
    fn default() -> Self {
        Self {
            to: Vec::new(),
            from: None,
            subject: String::new(),
            body: CapsuleBody::Absent,
            alternate_body: CapsuleBody::Absent,
            reply_to: None,
            list_unsubscribe: None,
            extra_headers: Vec::new(),
            attachments: Vec::new(),
            error: None,
            error_kind: None,
        }
    }
}

impl MailEffect {
    /// The placeholder a reserved-but-unfinished tape slot holds; see
    /// [`HttpEffect::pending`].
    #[must_use]
    pub fn pending() -> Self {
        Self {
            to: Vec::new(),
            from: None,
            subject: String::new(),
            body: CapsuleBody::Absent,
            alternate_body: CapsuleBody::Absent,
            reply_to: None,
            list_unsubscribe: None,
            extra_headers: Vec::new(),
            attachments: Vec::new(),
            error: Some(PENDING_EFFECT.to_owned()),
            error_kind: None,
        }
    }
}

/// The tenant context the recorded run resolved.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TenantEffect {
    /// Resolved tenant id, or `None` when the run resolved no tenant.
    #[serde(default)]
    pub id: Option<String>,
}

/// One draw from the framework's entropy source.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RandomEffect {
    /// The bytes the draw produced.
    #[serde(with = "b64")]
    pub bytes: Vec<u8>,
}

/// `serde(default)` for the body fields on effect records: an effect whose
/// document omits a body carried none.
const fn absent_body() -> CapsuleBody {
    CapsuleBody::Absent
}

/// base64 (standard alphabet) serde adapter for byte fields.
pub(crate) mod b64 {
    use base64::Engine as _;
    use base64::engine::general_purpose::STANDARD;
    use serde::{Deserialize as _, Deserializer, Serializer};

    pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
        serializer.serialize_str(&STANDARD.encode(bytes))
    }

    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
        let encoded = String::deserialize(deserializer)?;
        STANDARD
            .decode(encoded.as_bytes())
            .map_err(serde::de::Error::custom)
    }
}

/// Builders that assemble capsule fixtures without a live database.
///
/// Replay tests need capsules whose tapes look exactly like recorded ones, but
/// standing up Postgres for every such test is far too slow. These builders
/// take the response frames as a prebuilt byte blob (the wire module owns
/// frame construction) and wrap the surrounding bookkeeping.
#[cfg(any(test, feature = "test-support"))]
pub mod test_support {
    use super::{
        AppInfo, BindValue, CAPSULE_FORMAT_VERSION, Capsule, CapsuleBody, CapsuleDb,
        CapsuleEffects, CapsuleOutcome, CapsuleRequest, ConnectionTape, Exchange, ExchangeProtocol,
    };

    /// An extended-protocol exchange with prebuilt backend frames.
    #[must_use]
    pub fn exchange(sql: &str, binds: Vec<BindValue>, response: Vec<u8>) -> Exchange {
        Exchange {
            protocol: ExchangeProtocol::Extended,
            sql: sql.to_owned(),
            binds,
            response,
            row_count: 0,
            error: None,
        }
    }

    /// A simple-protocol (`batch_execute`) exchange with prebuilt frames.
    #[must_use]
    pub fn simple_exchange(sql: &str, response: Vec<u8>) -> Exchange {
        Exchange {
            protocol: ExchangeProtocol::Simple,
            sql: sql.to_owned(),
            binds: Vec::new(),
            response,
            row_count: 0,
            error: None,
        }
    }

    /// A connection tape carrying only request exchanges.
    #[must_use]
    pub const fn connection_tape(id: u64, exchanges: Vec<Exchange>) -> ConnectionTape {
        ConnectionTape {
            id,
            // `String::new()` is const; an empty role reads as primary
            // everywhere a role is consulted, matching pre-role capsules.
            role: String::new(),
            prologue: Vec::new(),
            statements: Vec::new(),
            catalog: Vec::new(),
            exchanges,
        }
    }

    /// A minimal `GET` request record for a fixture capsule.
    #[must_use]
    pub fn request(method: &str, uri: &str) -> CapsuleRequest {
        CapsuleRequest {
            method: method.to_owned(),
            uri: uri.to_owned(),
            route: None,
            http_version: "HTTP/1.1".to_owned(),
            headers: Vec::new(),
            binary_headers: Vec::new(),
            body: CapsuleBody::Absent,
            redacted_keys: Vec::new(),
            peer_addr: None,
            client_addr: None,
            client_host: None,
            client_scheme: None,
        }
    }

    /// A fixture capsule at the current format version.
    #[must_use]
    pub fn capsule(request: CapsuleRequest, outcome: CapsuleOutcome) -> Capsule {
        Capsule {
            format_version: CAPSULE_FORMAT_VERSION,
            id: "fixture".to_owned(),
            captured_at: crate::time::ambient_now(),
            autumn_version: env!("CARGO_PKG_VERSION").to_owned(),
            app: AppInfo::default(),
            request,
            outcome,
            clock: Vec::new(),
            clock_monotonic_us: Vec::new(),
            db: None,
            db_roles: Vec::new(),
            truncated: false,
            notes: Vec::new(),
            effects: CapsuleEffects::default(),
            job: None,
        }
    }

    /// Attach connection tapes to a fixture capsule.
    #[must_use]
    pub fn with_connections(mut capsule: Capsule, connections: Vec<ConnectionTape>) -> Capsule {
        capsule.db = Some(CapsuleDb { connections });
        capsule
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sample() -> Capsule {
        let mut capsule = test_support::capsule(
            test_support::request("POST", "/orders?page=2"),
            CapsuleOutcome::Status {
                code: 500,
                message: "boom".to_owned(),
                problem_type: Some("https://autumn.dev/problems/internal".to_owned()),
            },
        );
        capsule.id = "req-1".to_owned();
        capsule.request.headers = vec![
            ("content-type".to_owned(), "application/json".to_owned()),
            ("authorization".to_owned(), "[FILTERED]".to_owned()),
        ];
        capsule.request.body = CapsuleBody::Text("{\"a\":1}".to_owned());
        capsule.request.redacted_keys = vec!["header:authorization".to_owned()];
        capsule.clock = vec![Utc::now()];
        capsule.notes = vec!["db capture unavailable".to_owned()];
        capsule = test_support::with_connections(
            capsule,
            vec![test_support::connection_tape(
                1,
                vec![test_support::exchange(
                    "SELECT 1",
                    vec![
                        BindValue::Null,
                        BindValue::Value(vec![0xDE, 0xAD, 0xBE, 0xEF]),
                        BindValue::Masked,
                    ],
                    vec![b'Z', 0, 0, 0, 5, b'I'],
                )],
            )],
        );
        capsule
    }

    /// #1634 Phase 0: the effect tape round-trips, and every kind survives.
    #[test]
    fn capsule_effects_round_trip() {
        let mut capsule = sample();
        capsule.effects.http.push(HttpEffect {
            method: "POST".to_owned(),
            url: "https://payments.example/charge".to_owned(),
            request_headers: vec![("authorization".to_owned(), "[FILTERED]".to_owned())],
            request_body: CapsuleBody::Text("{\"amount\":10}".to_owned()),
            status: 502,
            final_url: Some("https://payments.example/charge/final".to_owned()),
            response_headers: vec![("content-type".to_owned(), "application/json".to_owned())],
            response_body: CapsuleBody::Base64("AAEC".to_owned()),
            error: None,
            error_kind: Some(HttpErrorKind::CircuitBreakerOpen),
        });
        capsule.effects.jobs.push(JobEffect {
            name: "send_receipt".to_owned(),
            payload: serde_json::json!({"order": 7}),
            delay_secs: Some(30),
            due_at: None,
            requested_due_at: None,
            error: None,
            error_status: None,
        });
        capsule.effects.cache.push(CacheEffect::Get {
            key: "user:7".to_owned(),
            value: Some("eyJhIjoxfQ==".to_owned()),
        });
        capsule.effects.cache.push(CacheEffect::Insert {
            key: "user:7".to_owned(),
            value: "eyJhIjoxfQ==".to_owned(),
            ttl_secs: None,
        });
        capsule.effects.mail.push(MailEffect {
            to: vec!["a@example.com".to_owned()],
            from: Some("noreply@example.com".to_owned()),
            subject: "Receipt".to_owned(),
            body: CapsuleBody::Text("thanks".to_owned()),
            alternate_body: CapsuleBody::Text("<p>thanks</p>".to_owned()),
            reply_to: Some("support@example.com".to_owned()),
            list_unsubscribe: Some("<https://example.com/u>".to_owned()),
            extra_headers: vec![("x-campaign".to_owned(), "receipts".to_owned())],
            attachments: vec![MailAttachmentEffect {
                filename: "invoice.pdf".to_owned(),
                content_type: "application/pdf".to_owned(),
                len: 4,
                sha256: "ab".repeat(32),
            }],
            error: None,
            error_kind: Some(MailErrorKind::AllRecipientsSuppressed),
        });
        capsule.effects.tenant = Some(TenantEffect {
            id: Some("acme".to_owned()),
        });
        capsule.effects.random.push(RandomEffect {
            bytes: vec![0xAA, 0xBB, 0xCC],
        });

        let json = serde_json::to_string(&capsule).expect("capsule serializes");
        let parsed = Capsule::from_json(&json).expect("capsule round-trips");
        assert_eq!(parsed.effects, capsule.effects);
        assert_eq!(
            parsed.effects.random.first().map(|draw| draw.bytes.clone()),
            Some(vec![0xAA, 0xBB, 0xCC]),
            "random draws are byte-exact after the base64 hop"
        );
    }

    /// A job-entry capsule names the job it replays instead of a route.
    #[test]
    fn capsule_job_entry_round_trips() {
        let mut capsule = sample();
        capsule.job = Some(CapsuleJob {
            name: "send_receipt".to_owned(),
            payload: serde_json::json!({"order": 7}),
        });
        let json = serde_json::to_string(&capsule).expect("capsule serializes");
        let parsed = Capsule::from_json(&json).expect("capsule round-trips");
        assert_eq!(
            parsed.job.as_ref().map(|job| job.name.as_str()),
            Some("send_receipt")
        );
    }

    /// The effect tape is a semantic addition a v2 reader would skip while
    /// happily reporting a reproduction, so it bumps the format version.
    ///
    /// Asserted through the gate rather than against the constant: a bare
    /// `CAPSULE_FORMAT_VERSION >= 3` is true forever and proves nothing about
    /// what the reader actually does with a v2 document.
    #[test]
    fn a_capsule_without_the_effect_tape_cannot_be_read_by_this_build() {
        let mut json = serde_json::to_value(sample()).expect("capsule serializes");
        json["format_version"] = serde_json::json!(2);
        // Exactly what a v2 capsule looks like: no `effects`, no `job`.
        if let serde_json::Value::Object(map) = &mut json {
            map.remove("effects");
            map.remove("job");
        }
        Capsule::from_json(&json.to_string()).expect_err(
            "a document with no effect tape must not load as one that has an empty one",
        );
    }

    /// A capsule written before the effect tape existed must be refused, not
    /// read with empty seams — the "spurious pass" the compatibility AC forbids.
    #[test]
    fn a_pre_effects_capsule_is_refused_by_the_version_gate() {
        let mut capsule = sample();
        capsule.format_version = 2;
        let json = serde_json::to_string(&capsule).expect("capsule serializes");
        let error = Capsule::from_json(&json).expect_err("a v2 capsule must be refused");
        let message = error.to_string();
        assert!(
            message.contains("older") || message.contains("re-record"),
            "the refusal must be actionable: {message}"
        );
    }

    /// #2351: a v3 capsule still loads, so a committed corpus stays readable.
    #[test]
    fn a_v3_capsule_still_loads() {
        let mut capsule = sample();
        capsule.format_version = 3;
        let json = serde_json::to_string(&capsule).expect("capsule serializes");
        let parsed = Capsule::from_json(&json).expect("a v3 capsule loads");
        assert_eq!(parsed.format_version, 3);
    }

    /// A reader checks the version before the rest, so a capsule from a
    /// newer build reports a version mismatch, not a malformed document,
    /// also when it holds a value this build does not know.
    #[test]
    fn a_newer_capsule_is_a_version_mismatch_even_with_unknown_values() {
        let mut json = serde_json::to_value(sample()).expect("capsule serializes");
        json["format_version"] = serde_json::json!(CAPSULE_FORMAT_VERSION + 1);
        json["outcome"] = serde_json::json!("a_value_from_the_future");
        match Capsule::from_json(&json.to_string()) {
            Err(CapsuleError::VersionMismatch { found, .. }) => {
                assert_eq!(found, CAPSULE_FORMAT_VERSION + 1);
            }
            other => panic!("expected a version mismatch, got {other:?}"),
        }
    }

    #[test]
    fn capsule_json_roundtrips_v1() {
        let capsule = sample();
        let json = serde_json::to_string(&capsule).expect("capsule serializes");
        let parsed = Capsule::from_json(&json).expect("capsule round-trips");

        assert_eq!(parsed.format_version, CAPSULE_FORMAT_VERSION);
        assert_eq!(parsed.id, "req-1");
        assert_eq!(parsed.request, capsule.request);
        assert_eq!(parsed.outcome, capsule.outcome);
        assert_eq!(parsed.clock, capsule.clock);
        assert_eq!(parsed.db, capsule.db);
        assert_eq!(parsed.notes, capsule.notes);

        // Byte fields survive the base64 hop intact.
        let db = parsed.db.expect("db tape present");
        let exchange = db
            .connections
            .first()
            .and_then(|tape| tape.exchanges.first())
            .expect("one exchange");
        assert_eq!(exchange.response, vec![b'Z', 0, 0, 0, 5, b'I']);
        assert_eq!(
            exchange.binds,
            vec![
                BindValue::Null,
                BindValue::Value(vec![0xDE, 0xAD, 0xBE, 0xEF]),
                BindValue::Masked,
            ]
        );
    }

    #[test]
    fn capsule_with_unknown_future_field_still_loads() {
        let json = serde_json::to_value(sample()).expect("capsule serializes");
        let mut object = match json {
            serde_json::Value::Object(map) => map,
            other => panic!("capsule must serialize to an object, got {other}"),
        };
        object.insert("future_knob".to_owned(), serde_json::json!({"a": [1, 2]}));
        let json = serde_json::Value::Object(object).to_string();

        let parsed = Capsule::from_json(&json)
            .expect("a capsule carrying an unknown field must still load (forward compatibility)");
        assert_eq!(parsed.id, "req-1");
    }

    #[test]
    fn load_rejects_format_version_mismatch() {
        let mut capsule = sample();
        capsule.format_version = CAPSULE_FORMAT_VERSION + 1;
        let json = serde_json::to_string(&capsule).expect("capsule serializes");

        let error = Capsule::from_json(&json)
            .expect_err("a future format version must be rejected, not silently read");
        match error {
            CapsuleError::VersionMismatch { found, expected } => {
                assert_eq!(found, CAPSULE_FORMAT_VERSION + 1);
                assert_eq!(expected, CAPSULE_FORMAT_VERSION);
            }
            other => panic!("expected a version mismatch, got {other}"),
        }
        assert!(
            CapsuleError::VersionMismatch {
                found: 99,
                expected: CAPSULE_FORMAT_VERSION,
            }
            .to_string()
            .contains("format version 99"),
            "the mismatch message must name the offending version"
        );
    }
}