Skip to main content

max / makenotwork

Add the artifact and evidence contract crate Sando and Bento independently grew the same defect: gates vouch for one thing, the deploy ships another, and nothing guarantees they match. Both had to remember to bind evidence to bytes, and both forgot. This makes the binding the wire format between them instead of a discipline inside each. Two documents. ArtifactRecord is the handover: the manifest, the digest that names it, the provenance, and what the builder proved. EvidenceRecord is an append, naming its subject by digest, so a later producer never rewrites the handover. Both sit beside a bundle, never inside it. Evidence names the digest of what it vouches for and the digest covers every file in the bundle, so evidence stored inside would change the digest it names. Evidence also keeps arriving after the build, and if it lived in the bundle then vouching for an artifact would change that artifact's identity. Scope is a type, not a convention: a build host has no environment, so an ArtifactRecord carrying an environment-scoped gate is refused. Gate detail stays untyped so neither daemon's failure vocabulary has to move here, and learning a new failure mode stays a one-repo change. Define and emit only; nothing consumes it yet.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 23:05 UTC
Signed with PGP, not checked
Commit: 5d01d00690e8b562e54c43f367f84d9eb191c582
Parent: 19f471b
7 files changed, +1558 insertions, -0 deletions
@@ -1,0 +1,492 @@
1 + # This file is automatically @generated by Cargo.
2 + # It is not intended for manual editing.
3 + version = 4
4 +
5 + [[package]]
6 + name = "android_system_properties"
7 + version = "0.1.6"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
10 + dependencies = [
11 + "libc",
12 + ]
13 +
14 + [[package]]
15 + name = "autocfg"
16 + version = "1.5.1"
17 + source = "registry+https://github.com/rust-lang/crates.io-index"
18 + checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
19 +
20 + [[package]]
21 + name = "block-buffer"
22 + version = "0.10.4"
23 + source = "registry+https://github.com/rust-lang/crates.io-index"
24 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
25 + dependencies = [
26 + "generic-array",
27 + ]
28 +
29 + [[package]]
30 + name = "bumpalo"
31 + version = "3.20.3"
32 + source = "registry+https://github.com/rust-lang/crates.io-index"
33 + checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
34 +
35 + [[package]]
36 + name = "cc"
37 + version = "1.4.0"
38 + source = "registry+https://github.com/rust-lang/crates.io-index"
39 + checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
40 + dependencies = [
41 + "find-msvc-tools",
42 + "shlex",
43 + ]
44 +
45 + [[package]]
46 + name = "cfg-if"
47 + version = "1.0.4"
48 + source = "registry+https://github.com/rust-lang/crates.io-index"
49 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
50 +
51 + [[package]]
52 + name = "chrono"
53 + version = "0.4.45"
54 + source = "registry+https://github.com/rust-lang/crates.io-index"
55 + checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
56 + dependencies = [
57 + "iana-time-zone",
58 + "js-sys",
59 + "num-traits",
60 + "serde",
61 + "wasm-bindgen",
62 + "windows-link",
63 + ]
64 +
65 + [[package]]
66 + name = "core-foundation-sys"
67 + version = "0.8.7"
68 + source = "registry+https://github.com/rust-lang/crates.io-index"
69 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
70 +
71 + [[package]]
72 + name = "cpufeatures"
73 + version = "0.2.17"
74 + source = "registry+https://github.com/rust-lang/crates.io-index"
75 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
76 + dependencies = [
77 + "libc",
78 + ]
79 +
80 + [[package]]
81 + name = "crypto-common"
82 + version = "0.1.7"
83 + source = "registry+https://github.com/rust-lang/crates.io-index"
84 + checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
85 + dependencies = [
86 + "generic-array",
87 + "typenum",
88 + ]
89 +
90 + [[package]]
91 + name = "digest"
92 + version = "0.10.7"
93 + source = "registry+https://github.com/rust-lang/crates.io-index"
94 + checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
95 + dependencies = [
96 + "block-buffer",
97 + "crypto-common",
98 + ]
99 +
100 + [[package]]
101 + name = "find-msvc-tools"
102 + version = "0.1.9"
103 + source = "registry+https://github.com/rust-lang/crates.io-index"
104 + checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
105 +
106 + [[package]]
107 + name = "futures-core"
108 + version = "0.3.33"
109 + source = "registry+https://github.com/rust-lang/crates.io-index"
110 + checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
111 +
112 + [[package]]
113 + name = "futures-task"
114 + version = "0.3.33"
115 + source = "registry+https://github.com/rust-lang/crates.io-index"
116 + checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
117 +
118 + [[package]]
119 + name = "futures-util"
120 + version = "0.3.33"
121 + source = "registry+https://github.com/rust-lang/crates.io-index"
122 + checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
123 + dependencies = [
124 + "futures-core",
125 + "futures-task",
126 + "pin-project-lite",
127 + "slab",
128 + ]
129 +
130 + [[package]]
131 + name = "generic-array"
132 + version = "0.14.7"
133 + source = "registry+https://github.com/rust-lang/crates.io-index"
134 + checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
135 + dependencies = [
136 + "typenum",
137 + "version_check",
138 + ]
139 +
140 + [[package]]
141 + name = "iana-time-zone"
142 + version = "0.1.65"
143 + source = "registry+https://github.com/rust-lang/crates.io-index"
144 + checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
145 + dependencies = [
146 + "android_system_properties",
147 + "core-foundation-sys",
148 + "iana-time-zone-haiku",
149 + "js-sys",
150 + "log",
151 + "wasm-bindgen",
152 + "windows-core",
153 + ]
154 +
155 + [[package]]
156 + name = "iana-time-zone-haiku"
157 + version = "0.1.2"
158 + source = "registry+https://github.com/rust-lang/crates.io-index"
159 + checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
160 + dependencies = [
161 + "cc",
162 + ]
163 +
164 + [[package]]
165 + name = "itoa"
166 + version = "1.0.18"
167 + source = "registry+https://github.com/rust-lang/crates.io-index"
168 + checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
169 +
170 + [[package]]
171 + name = "js-sys"
172 + version = "0.3.103"
173 + source = "registry+https://github.com/rust-lang/crates.io-index"
174 + checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
175 + dependencies = [
176 + "cfg-if",
177 + "futures-util",
178 + "wasm-bindgen",
179 + ]
180 +
181 + [[package]]
182 + name = "libc"
183 + version = "0.2.189"
184 + source = "registry+https://github.com/rust-lang/crates.io-index"
185 + checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
186 +
187 + [[package]]
188 + name = "log"
189 + version = "0.4.33"
190 + source = "registry+https://github.com/rust-lang/crates.io-index"
191 + checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
192 +
193 + [[package]]
194 + name = "memchr"
195 + version = "2.8.3"
196 + source = "registry+https://github.com/rust-lang/crates.io-index"
197 + checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
198 +
199 + [[package]]
200 + name = "num-traits"
201 + version = "0.2.19"
202 + source = "registry+https://github.com/rust-lang/crates.io-index"
203 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
204 + dependencies = [
205 + "autocfg",
206 + ]
207 +
208 + [[package]]
209 + name = "once_cell"
210 + version = "1.21.4"
211 + source = "registry+https://github.com/rust-lang/crates.io-index"
212 + checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
213 +
214 + [[package]]
215 + name = "ops-artifact"
216 + version = "0.1.0"
217 + dependencies = [
218 + "chrono",
219 + "serde",
220 + "serde_json",
221 + "sha2",
222 + ]
223 +
224 + [[package]]
225 + name = "pin-project-lite"
226 + version = "0.2.17"
227 + source = "registry+https://github.com/rust-lang/crates.io-index"
228 + checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
229 +
230 + [[package]]
231 + name = "proc-macro2"
232 + version = "1.0.107"
233 + source = "registry+https://github.com/rust-lang/crates.io-index"
234 + checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
235 + dependencies = [
236 + "unicode-ident",
237 + ]
238 +
239 + [[package]]
240 + name = "quote"
241 + version = "1.0.47"
242 + source = "registry+https://github.com/rust-lang/crates.io-index"
243 + checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
244 + dependencies = [
245 + "proc-macro2",
246 + ]
247 +
248 + [[package]]
249 + name = "rustversion"
250 + version = "1.0.23"
251 + source = "registry+https://github.com/rust-lang/crates.io-index"
252 + checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
253 +
254 + [[package]]
255 + name = "serde"
256 + version = "1.0.229"
257 + source = "registry+https://github.com/rust-lang/crates.io-index"
258 + checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
259 + dependencies = [
260 + "serde_core",
261 + "serde_derive",
262 + ]
263 +
264 + [[package]]
265 + name = "serde_core"
266 + version = "1.0.229"
267 + source = "registry+https://github.com/rust-lang/crates.io-index"
268 + checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
269 + dependencies = [
270 + "serde_derive",
271 + ]
272 +
273 + [[package]]
274 + name = "serde_derive"
275 + version = "1.0.229"
276 + source = "registry+https://github.com/rust-lang/crates.io-index"
277 + checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
278 + dependencies = [
279 + "proc-macro2",
280 + "quote",
281 + "syn 3.0.3",
282 + ]
283 +
284 + [[package]]
285 + name = "serde_json"
286 + version = "1.0.151"
287 + source = "registry+https://github.com/rust-lang/crates.io-index"
288 + checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
289 + dependencies = [
290 + "itoa",
291 + "memchr",
292 + "serde",
293 + "serde_core",
294 + "zmij",
295 + ]
296 +
297 + [[package]]
298 + name = "sha2"
299 + version = "0.10.9"
300 + source = "registry+https://github.com/rust-lang/crates.io-index"
301 + checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
302 + dependencies = [
303 + "cfg-if",
304 + "cpufeatures",
305 + "digest",
306 + ]
307 +
308 + [[package]]
309 + name = "shlex"
310 + version = "2.0.1"
311 + source = "registry+https://github.com/rust-lang/crates.io-index"
312 + checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
313 +
314 + [[package]]
315 + name = "slab"
316 + version = "0.4.12"
317 + source = "registry+https://github.com/rust-lang/crates.io-index"
318 + checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
319 +
320 + [[package]]
321 + name = "syn"
322 + version = "2.0.119"
323 + source = "registry+https://github.com/rust-lang/crates.io-index"
324 + checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
325 + dependencies = [
326 + "proc-macro2",
327 + "quote",
328 + "unicode-ident",
329 + ]
330 +
331 + [[package]]
332 + name = "syn"
333 + version = "3.0.3"
334 + source = "registry+https://github.com/rust-lang/crates.io-index"
335 + checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
336 + dependencies = [
337 + "proc-macro2",
338 + "quote",
339 + "unicode-ident",
340 + ]
341 +
342 + [[package]]
343 + name = "typenum"
344 + version = "1.20.1"
345 + source = "registry+https://github.com/rust-lang/crates.io-index"
346 + checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
347 +
348 + [[package]]
349 + name = "unicode-ident"
350 + version = "1.0.24"
351 + source = "registry+https://github.com/rust-lang/crates.io-index"
352 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
353 +
354 + [[package]]
355 + name = "version_check"
356 + version = "0.9.5"
357 + source = "registry+https://github.com/rust-lang/crates.io-index"
358 + checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
359 +
360 + [[package]]
361 + name = "wasm-bindgen"
362 + version = "0.2.126"
363 + source = "registry+https://github.com/rust-lang/crates.io-index"
364 + checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
365 + dependencies = [
366 + "cfg-if",
367 + "once_cell",
368 + "rustversion",
369 + "wasm-bindgen-macro",
370 + "wasm-bindgen-shared",
371 + ]
372 +
373 + [[package]]
374 + name = "wasm-bindgen-macro"
375 + version = "0.2.126"
376 + source = "registry+https://github.com/rust-lang/crates.io-index"
377 + checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
378 + dependencies = [
379 + "quote",
380 + "wasm-bindgen-macro-support",
381 + ]
382 +
383 + [[package]]
384 + name = "wasm-bindgen-macro-support"
385 + version = "0.2.126"
386 + source = "registry+https://github.com/rust-lang/crates.io-index"
387 + checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
388 + dependencies = [
389 + "bumpalo",
390 + "proc-macro2",
391 + "quote",
392 + "syn 2.0.119",
393 + "wasm-bindgen-shared",
394 + ]
395 +
396 + [[package]]
397 + name = "wasm-bindgen-shared"
398 + version = "0.2.126"
399 + source = "registry+https://github.com/rust-lang/crates.io-index"
400 + checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
401 + dependencies = [
402 + "unicode-ident",
403 + ]
404 +
405 + [[package]]
406 + name = "windows-core"
407 + version = "0.62.2"
408 + source = "registry+https://github.com/rust-lang/crates.io-index"
409 + checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
410 + dependencies = [
411 + "windows-implement",
412 + "windows-interface",
413 + "windows-link",
414 + "windows-result",
415 + "windows-strings",
416 + ]
417 +
418 + [[package]]
419 + name = "windows-implement"
420 + version = "0.60.2"
421 + source = "registry+https://github.com/rust-lang/crates.io-index"
422 + checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
423 + dependencies = [
424 + "proc-macro2",
425 + "quote",
426 + "syn 2.0.119",
427 + ]
428 +
429 + [[package]]
430 + name = "windows-interface"
431 + version = "0.59.3"
432 + source = "registry+https://github.com/rust-lang/crates.io-index"
433 + checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
434 + dependencies = [
435 + "proc-macro2",
436 + "quote",
437 + "syn 2.0.119",
438 + ]
439 +
440 + [[package]]
441 + name = "windows-link"
442 + version = "0.2.1"
443 + source = "registry+https://github.com/rust-lang/crates.io-index"
444 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
445 +
446 + [[package]]
447 + name = "windows-result"
448 + version = "0.4.1"
449 + source = "registry+https://github.com/rust-lang/crates.io-index"
450 + checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
451 + dependencies = [
452 + "windows-link",
453 + ]
454 +
455 + [[package]]
456 + name = "windows-strings"
457 + version = "0.5.1"
458 + source = "registry+https://github.com/rust-lang/crates.io-index"
459 + checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
460 + dependencies = [
461 + "windows-link",
462 + ]
463 +
464 + [[package]]
465 + name = "zmij"
466 + version = "1.0.23"
467 + source = "registry+https://github.com/rust-lang/crates.io-index"
468 + checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
469 +
470 + [[patch.unused]]
471 + name = "synckit-client"
472 + version = "0.7.0"
473 +
474 + [[patch.unused]]
475 + name = "synckit-config"
476 + version = "0.1.2"
477 +
478 + [[patch.unused]]
479 + name = "docengine"
480 + version = "0.4.0"
481 +
482 + [[patch.unused]]
483 + name = "kberg"
484 + version = "0.1.0"
485 +
486 + [[patch.unused]]
487 + name = "painhours"
488 + version = "0.1.0"
489 +
490 + [[patch.unused]]
491 + name = "tagtree"
492 + version = "0.4.0"
@@ -1,0 +1,44 @@
1 + [package]
2 + name = "ops-artifact"
3 + version = "0.1.0"
4 + edition = "2024"
5 + license = "MIT"
6 + description = "The artifact and evidence contract between the system that builds a release and the system that advances it. Names what bytes exist, what source they came from, and what has been proven about them."
7 +
8 + [dependencies]
9 + serde = { version = "1.0.228", features = ["derive"] }
10 + serde_json = "1"
11 + chrono = { version = "0.4", features = ["serde"] }
12 + sha2 = "0.10"
13 +
14 + [lints.rust]
15 + unused = "warn"
16 + unreachable_pub = "warn"
17 +
18 + [lints.clippy]
19 + pedantic = { level = "warn", priority = -1 }
20 + # Allow-list tuned from a measured breakdown across server/multithreaded/pter
21 + # (2026-07-22). These are the high-churn / low-signal pedantic lints; everything
22 + # else in `pedantic` stays a warning. Keep this block identical across repos.
23 + module_name_repetitions = "allow"
24 + # Doc lints. No docs-completeness push is underway.
25 + missing_errors_doc = "allow"
26 + missing_panics_doc = "allow"
27 + doc_markdown = "allow"
28 + # Numeric casts. Endemic and mostly intentional in size and byte math.
29 + cast_possible_truncation = "allow"
30 + cast_sign_loss = "allow"
31 + cast_precision_loss = "allow"
32 + cast_possible_wrap = "allow"
33 + cast_lossless = "allow"
34 + # Subjective structure and style nags. High churn, low signal.
35 + must_use_candidate = "allow"
36 + too_many_lines = "allow"
37 + struct_excessive_bools = "allow"
38 + similar_names = "allow"
39 + items_after_statements = "allow"
40 + single_match_else = "allow"
41 + # Frequent false-positives in TUI and router-heavy code.
42 + match_same_arms = "allow"
43 + unnecessary_wraps = "allow"
44 + type_complexity = "allow"
@@ -1,0 +1,166 @@
1 + //! What has been proven about the bytes, and by whom.
2 +
3 + use chrono::{DateTime, Utc};
4 +
5 + /// What a gate's evidence is *about*.
6 + ///
7 + /// This is the boundary between the two controllers, expressed as a type
8 + /// rather than as a convention. A build host can prove things about an
9 + /// artifact: it compiled, its tests passed, it is signed and notarized. It
10 + /// cannot prove anything about that artifact in production, because it does
11 + /// not have production: no restored dump to migrate, no nodes to probe, no
12 + /// clock that has been running for 48 hours.
13 + ///
14 + /// Keeping the two apart in the type means an evaluator never has to decide
15 + /// whether `boot_smoke` from a build host counts. It cannot be handed one.
16 + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17 + #[serde(tag = "kind", rename_all = "snake_case")]
18 + pub enum Scope {
19 + /// About the artifact itself: clippy, unit tests, `cargo_audit`, signing,
20 + /// notarization, Gatekeeper. True wherever the bytes go.
21 + Artifact,
22 + /// About the artifact in one environment: `migration_dry_run`,
23 + /// `boot_smoke` against a restored dump, `node_health`, `burn_in`.
24 + /// Says nothing about the same bytes anywhere else, so it names where.
25 + Environment { env: String },
26 + }
27 +
28 + /// Passed, failed, or could not run.
29 + ///
30 + /// `Blocked` is not a third flavour of failure. The gate did not run because it
31 + /// owes a precondition somebody can satisfy out of band (no backup fetched, the
32 + /// burn-in clock never started). Collapsing it into `Failed` turns "not yet"
33 + /// into "no", which is how an operator learns to route around a gate.
34 + #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
35 + #[serde(rename_all = "snake_case")]
36 + pub enum Verdict {
37 + Passed,
38 + Failed,
39 + Blocked,
40 + }
41 +
42 + impl Verdict {
43 + pub fn is_passed(self) -> bool {
44 + matches!(self, Verdict::Passed)
45 + }
46 + }
47 +
48 + /// One gate, run once, against one artifact.
49 + ///
50 + /// `detail` is deliberately untyped. Both controllers already have rich,
51 + /// divergent failure vocabularies (Sando's `GateFailure` names the migration
52 + /// that drifted), and hoisting either into this crate would make the contract
53 + /// the union of two daemons' internals and force a coordinated change every
54 + /// time one of them learns a new failure mode. The envelope is shared; what a
55 + /// producer puts inside stays its own. A reader that does not know the producer
56 + /// still gets `verdict` and `summary`, which is what deciding requires.
57 + #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
58 + pub struct GateRecord {
59 + /// The gate's name in its producer's vocabulary (`cargo_test`, `boot_smoke`).
60 + pub gate: String,
61 + pub scope: Scope,
62 + pub verdict: Verdict,
63 + /// One line, human-facing. `12 test(s) failed; first panic: ...`
64 + pub summary: String,
65 + pub ran_at: DateTime<Utc>,
66 + /// Where the producer kept the full output, in the producer's own terms.
67 + #[serde(default, skip_serializing_if = "Option::is_none")]
68 + pub log_ref: Option<String>,
69 + /// The producer's own typed outcome, verbatim.
70 + #[serde(default, skip_serializing_if = "Option::is_none")]
71 + pub detail: Option<serde_json::Value>,
72 + }
73 +
74 + impl GateRecord {
75 + pub fn new(
76 + gate: impl Into<String>,
77 + scope: Scope,
78 + verdict: Verdict,
79 + summary: impl Into<String>,
80 + ran_at: DateTime<Utc>,
81 + ) -> Self {
82 + Self {
83 + gate: gate.into(),
84 + scope,
85 + verdict,
86 + summary: summary.into(),
87 + ran_at,
88 + log_ref: None,
89 + detail: None,
90 + }
91 + }
92 +
93 + #[must_use]
94 + pub fn with_log_ref(mut self, log_ref: impl Into<String>) -> Self {
95 + self.log_ref = Some(log_ref.into());
96 + self
97 + }
98 +
99 + #[must_use]
100 + pub fn with_detail(mut self, detail: serde_json::Value) -> Self {
101 + self.detail = Some(detail);
102 + self
103 + }
104 + }
105 +
106 + #[cfg(test)]
107 + mod tests {
108 + use super::*;
109 +
110 + fn at() -> DateTime<Utc> {
111 + DateTime::<Utc>::from_timestamp(1_754_000_000, 0).unwrap()
112 + }
113 +
114 + #[test]
115 + fn an_environment_scope_names_its_environment() {
116 + let g = GateRecord::new(
117 + "boot_smoke",
118 + Scope::Environment {
119 + env: "tier:a".into(),
120 + },
121 + Verdict::Passed,
122 + "served /health in 340ms",
123 + at(),
124 + );
125 + let v = serde_json::to_value(&g).unwrap();
126 + assert_eq!(v["scope"]["kind"], "environment");
127 + assert_eq!(v["scope"]["env"], "tier:a");
128 + assert_eq!(v["verdict"], "passed");
129 + }
130 +
131 + #[test]
132 + fn a_producers_own_failure_type_survives_verbatim() {
133 + // Sando's GateFailure, serialized by Sando, carried through untouched.
134 + let detail = serde_json::json!({
135 + "kind": "migration_drift",
136 + "migration": "0047_widgets",
137 + });
138 + let g = GateRecord::new(
139 + "migration_dry_run",
140 + Scope::Environment {
141 + env: "tier:a".into(),
142 + },
143 + Verdict::Failed,
144 + "migration 0047_widgets previously applied but missing",
145 + at(),
146 + )
147 + .with_detail(detail.clone());
148 + let back: GateRecord = serde_json::from_str(&serde_json::to_string(&g).unwrap()).unwrap();
149 + assert_eq!(back.detail.unwrap(), detail);
150 + }
151 +
152 + #[test]
153 + fn optional_fields_are_absent_rather_than_null() {
154 + let g = GateRecord::new("clippy", Scope::Artifact, Verdict::Passed, "clean", at());
155 + let v = serde_json::to_value(&g).unwrap();
156 + assert!(v.get("log_ref").is_none());
157 + assert!(v.get("detail").is_none());
158 + }
159 +
160 + #[test]
161 + fn blocked_is_not_passed() {
162 + assert!(!Verdict::Blocked.is_passed());
163 + assert!(!Verdict::Failed.is_passed());
164 + assert!(Verdict::Passed.is_passed());
165 + }
166 + }
@@ -1,0 +1,131 @@
1 + //! The artifact and evidence contract between the system that builds a release
2 + //! and the system that advances it.
3 + //!
4 + //! Design + rationale: maintainer wiki.
5 + //! <!-- wiki: sando-bento-boundary -->
6 + //!
7 + //! Sando and Bento independently grew the same defect: gates vouch for one
8 + //! thing, the deploy ships another, and nothing guarantees they match. Both had
9 + //! to remember to bind evidence to bytes, and both forgot. Two systems that
10 + //! forgot the same thing separately will forget it again, so this crate makes
11 + //! the binding the wire format between them rather than a discipline inside
12 + //! each. A builder cannot hand over an artifact without a digest and a
13 + //! provenance; an evaluator cannot evaluate one it was not handed. There is no
14 + //! second channel.
15 + //!
16 + //! # The documents
17 + //!
18 + //! - [`ArtifactRecord`] — the handover. These bytes ([`Manifest`],
19 + //! [`BundleDigest`]), from this source ([`Provenance`]), and what the builder
20 + //! proved about them.
21 + //! - [`EvidenceRecord`] — an append. What a later producer proved about a
22 + //! bundle it did not build, naming it by digest.
23 + //!
24 + //! Both sit **beside** a bundle, never inside it. Evidence names the digest of
25 + //! what it vouches for, and the digest covers every file in the bundle, so
26 + //! evidence stored inside would change the digest it names. That circularity is
27 + //! the short argument; the longer one is that evidence keeps arriving after the
28 + //! build, and if it lived in the bundle then vouching for an artifact would
29 + //! change the artifact's identity, so the thing that passed would never be the
30 + //! thing that ships.
31 + //!
32 + //! # What this crate does not do
33 + //!
34 + //! It has no opinion on where the documents land, and no I/O. Bento's dist tree
35 + //! and Sando's content-addressed release directories are laid out differently
36 + //! and neither has to move for this to be shared. It also does not model either
37 + //! daemon's failure vocabulary: [`GateRecord::detail`] carries a producer's own
38 + //! typed outcome verbatim, so learning a new failure mode stays a one-repo
39 + //! change.
40 +
41 + mod evidence;
42 + mod manifest;
43 + mod provenance;
44 + mod record;
45 +
46 + pub use evidence::{GateRecord, Scope, Verdict};
47 + pub use manifest::{BundleDigest, Manifest, ManifestEntry};
48 + pub use provenance::Provenance;
49 + pub use record::{ArtifactRecord, CONTRACT_VERSION, EvidenceRecord};
50 +
51 + /// Every way a document can fail to be one.
52 + #[derive(Debug)]
53 + pub enum ContractError {
54 + /// A hash field was not 64 lowercase hex characters.
55 + NotASha256(String),
56 + /// A provenance git sha was not 40 lowercase hex characters. Abbreviated
57 + /// shas compare unequal to full ones without being wrong, which turns a
58 + /// mismatch into a non-answer.
59 + NotAGitSha(String),
60 + /// A manifest path was absolute, backslashed, or walked upward.
61 + UnsafePath(String),
62 + /// Two manifest entries named the same path.
63 + DuplicatePath(String),
64 + /// A manifest with no files. It hashes fine, which is the problem: it is a
65 + /// stable identity for nothing, shared by every empty bundle.
66 + EmptyManifest,
67 + /// A `MANIFEST` line was not `<sha256> <path>`. Carries the 1-based line.
68 + MalformedManifestLine(usize),
69 + /// A required provenance field was empty.
70 + MissingProvenance(&'static str),
71 + /// The record's digest is not the digest of the manifest it carries.
72 + DigestMismatch { claimed: String, computed: String },
73 + /// A builder handed over evidence about an environment it does not have.
74 + EnvironmentEvidenceFromBuilder { gate: String, env: String },
75 + /// A document written against a contract version this reader does not know.
76 + UnknownContractVersion(u32),
77 + /// The JSON did not parse as the document it claimed to be.
78 + Malformed(serde_json::Error),
79 + }
80 +
81 + impl std::fmt::Display for ContractError {
82 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 + match self {
84 + ContractError::NotASha256(s) => {
85 + write!(f, "`{s}` is not a sha256 (64 lowercase hex characters)")
86 + }
87 + ContractError::NotAGitSha(s) => {
88 + write!(
89 + f,
90 + "`{s}` is not a full git sha (40 lowercase hex characters)"
91 + )
92 + }
93 + ContractError::UnsafePath(p) => write!(
94 + f,
95 + "manifest path `{p}` must be relative, forward-slashed, and stay inside the bundle"
96 + ),
97 + ContractError::DuplicatePath(p) => {
98 + write!(f, "manifest lists `{p}` twice, so it describes two bundles")
99 + }
100 + ContractError::EmptyManifest => write!(f, "manifest lists no files"),
101 + ContractError::MalformedManifestLine(n) => {
102 + write!(f, "manifest line {n} is not `<sha256> <path>`")
103 + }
104 + ContractError::MissingProvenance(field) => write!(f, "`{field}` is empty"),
105 + ContractError::DigestMismatch { claimed, computed } => write!(
106 + f,
107 + "record claims digest {claimed} but its manifest hashes to {computed}"
108 + ),
109 + ContractError::EnvironmentEvidenceFromBuilder { gate, env } => write!(
110 + f,
111 + "gate `{gate}` is scoped to environment `{env}`, which a build host does not have"
112 + ),
113 + ContractError::UnknownContractVersion(v) => {
114 + write!(
115 + f,
116 + "contract version {v}, this reader knows {CONTRACT_VERSION}"
117 + )
118 + }
119 + ContractError::Malformed(e) => write!(f, "malformed document: {e}"),
120 + }
121 + }
122 + }
123 +
124 + impl std::error::Error for ContractError {
125 + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
126 + match self {
127 + ContractError::Malformed(e) => Some(e),
128 + _ => None,
129 + }
130 + }
131 + }
@@ -1,0 +1,293 @@
1 + //! What bytes exist: the per-file manifest and the digest that names it.
2 +
3 + use crate::ContractError;
4 + use sha2::{Digest, Sha256};
5 +
6 + /// One file in a bundle: its sha256 and its path relative to the bundle root.
7 + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
8 + pub struct ManifestEntry {
9 + pub sha256: String,
10 + pub path: String,
11 + }
12 +
13 + /// Every file in a bundle, sorted by path.
14 + ///
15 + /// Sorted, and constructible only through checks: a manifest is what the digest
16 + /// is computed over, so two producers listing the same files must produce the
17 + /// same text or the digest stops being an identity. Sorting is the ordering
18 + /// half of that. Rejecting duplicate paths is the other half, since a repeated
19 + /// path with two different hashes is a manifest that describes two bundles.
20 + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
21 + #[serde(transparent)]
22 + pub struct Manifest {
23 + entries: Vec<ManifestEntry>,
24 + }
25 +
26 + impl Manifest {
27 + /// Build a manifest from `(path, sha256)` pairs in any order.
28 + pub fn new<I, P, S>(files: I) -> Result<Self, ContractError>
29 + where
30 + I: IntoIterator<Item = (P, S)>,
31 + P: Into<String>,
32 + S: Into<String>,
33 + {
34 + let mut entries: Vec<ManifestEntry> = files
35 + .into_iter()
36 + .map(|(path, sha256)| ManifestEntry {
37 + path: path.into(),
38 + sha256: sha256.into(),
39 + })
40 + .collect();
41 + for e in &entries {
42 + check_sha256(&e.sha256)?;
43 + check_path(&e.path)?;
44 + }
45 + entries.sort_by(|a, b| a.path.cmp(&b.path));
46 + if let Some(dup) = entries.windows(2).find(|w| w[0].path == w[1].path) {
47 + return Err(ContractError::DuplicatePath(dup[0].path.clone()));
48 + }
49 + if entries.is_empty() {
50 + return Err(ContractError::EmptyManifest);
51 + }
52 + Ok(Self { entries })
53 + }
54 +
55 + pub fn entries(&self) -> &[ManifestEntry] {
56 + &self.entries
57 + }
58 +
59 + /// The `MANIFEST` file's contents: `<sha256> <path>`, one per line,
60 + /// trailing newline. Deliberately the shape `sha256sum -c` reads, so the
61 + /// file is checkable on a node with no tooling of ours installed.
62 + pub fn to_text(&self) -> String {
63 + let mut s = String::new();
64 + for e in &self.entries {
65 + s.push_str(&e.sha256);
66 + s.push_str(" ");
67 + s.push_str(&e.path);
68 + s.push('\n');
69 + }
70 + s
71 + }
72 +
73 + /// Parse a `MANIFEST` back. Round-trips [`Manifest::to_text`].
74 + pub fn parse(text: &str) -> Result<Self, ContractError> {
75 + let mut files = Vec::new();
76 + for (n, line) in text.lines().enumerate() {
77 + if line.trim().is_empty() {
78 + continue;
79 + }
80 + // Split on the two-space separator rather than on whitespace: a path
81 + // may contain single spaces, and splitting greedily would silently
82 + // truncate one. `sha256sum` has the same convention.
83 + let (sha, path) = line
84 + .split_once(" ")
85 + .ok_or(ContractError::MalformedManifestLine(n + 1))?;
86 + files.push((path.to_string(), sha.to_string()));
87 + }
88 + Self::new(files)
89 + }
90 +
91 + /// The bundle digest: sha256 of the manifest text.
92 + ///
93 + /// Hashing the manifest rather than the primary binary is the point. Two
94 + /// bundles whose binaries are identical and whose assets differ hash the
95 + /// same if you hash only the binary, which is the drift that crash-looped
96 + /// production on a missing `CDN_BASE_URL` (postmortem 2026-07-09 #2).
97 + pub fn digest(&self) -> BundleDigest {
98 + let mut hasher = Sha256::new();
99 + hasher.update(self.to_text().as_bytes());
100 + BundleDigest(hex_lower(&hasher.finalize()))
101 + }
102 + }
103 +
104 + /// The identity of a set of bytes: 64 lowercase hex, the sha256 of a manifest.
105 + ///
106 + /// Not a substitute for the git sha, and neither is a substitute for it. The
107 + /// sha says what source; the digest says what bytes. Cargo builds are not
108 + /// bit-reproducible by default, so one commit built twice gives two digests:
109 + /// a digest match across hosts proves more than a sha match, and a digest
110 + /// cannot answer "have we already built this commit".
111 + #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
112 + #[serde(transparent)]
113 + pub struct BundleDigest(String);
114 +
115 + impl BundleDigest {
116 + pub fn parse(s: &str) -> Result<Self, ContractError> {
117 + check_sha256(s)?;
118 + Ok(Self(s.to_string()))
119 + }
120 +
121 + pub fn as_str(&self) -> &str {
122 + &self.0
123 + }
124 +
125 + /// The first 16 hex characters, which is what a content-addressed directory
126 + /// is named. The full 64 is what gets stored and compared.
127 + pub fn short(&self) -> &str {
128 + &self.0[..16]
129 + }
130 + }
131 +
132 + impl std::fmt::Display for BundleDigest {
133 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 + self.0.fmt(f)
135 + }
136 + }
137 +
138 + fn check_sha256(s: &str) -> Result<(), ContractError> {
139 + if s.len() == 64
140 + && s.bytes()
141 + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
142 + {
143 + Ok(())
144 + } else {
145 + Err(ContractError::NotASha256(s.to_string()))
146 + }
147 + }
148 +
149 + /// A manifest path is relative, forward-slashed, and does not walk upward.
150 + ///
151 + /// The node verifies a bundle by joining these onto a release directory, so a
152 + /// path that escapes it would have the verifier hash a file the bundle does not
153 + /// contain, and a deploy write one there.
154 + fn check_path(p: &str) -> Result<(), ContractError> {
155 + let bad = p.is_empty()
156 + || p.starts_with('/')
157 + || p.contains('\\')
158 + || p.split('/').any(|c| c == ".." || c == ".")
159 + || p.contains('\n');
160 + if bad {
161 + return Err(ContractError::UnsafePath(p.to_string()));
162 + }
163 + Ok(())
164 + }
165 +
166 + fn hex_lower(bytes: &[u8]) -> String {
167 + use std::fmt::Write as _;
168 + let mut s = String::with_capacity(bytes.len() * 2);
169 + for b in bytes {
170 + let _ = write!(s, "{b:02x}");
171 + }
172 + s
173 + }
174 +
175 + #[cfg(test)]
176 + mod tests {
177 + use super::*;
178 +
179 + fn sha(byte: u8) -> String {
180 + std::iter::repeat_n(format!("{byte:02x}"), 32).collect()
181 + }
182 +
183 + #[test]
184 + fn entries_sort_by_path_so_input_order_cannot_change_the_digest() {
185 + let a = Manifest::new([
186 + ("static/app.css", sha(1)),
187 + ("makenotwork", sha(2)),
188 + ("companions/mnw-cli", sha(3)),
189 + ])
190 + .unwrap();
191 + let b = Manifest::new([
192 + ("companions/mnw-cli", sha(3)),
193 + ("static/app.css", sha(1)),
194 + ("makenotwork", sha(2)),
195 + ])
196 + .unwrap();
197 + assert_eq!(a.to_text(), b.to_text());
198 + assert_eq!(a.digest(), b.digest());
199 + let paths: Vec<&str> = a.entries().iter().map(|e| e.path.as_str()).collect();
200 + assert_eq!(
201 + paths,
202 + ["companions/mnw-cli", "makenotwork", "static/app.css"]
203 + );
204 + }
205 +
206 + #[test]
207 + fn text_round_trips() {
208 + let m = Manifest::new([("docs/index.html", sha(9)), ("makenotwork", sha(10))]).unwrap();
209 + let back = Manifest::parse(&m.to_text()).unwrap();
210 + assert_eq!(m, back);
211 + assert_eq!(m.digest(), back.digest());
212 + }
213 +
214 + #[test]
215 + fn a_path_containing_a_space_survives_the_round_trip() {
216 + // Splitting on whitespace rather than on the two-space separator would
217 + // truncate this to "Release" and quietly describe a different file.
218 + let m = Manifest::new([("docs/Release Notes.html", sha(4))]).unwrap();
219 + let back = Manifest::parse(&m.to_text()).unwrap();
220 + assert_eq!(back.entries()[0].path, "docs/Release Notes.html");
221 + }
222 +
223 + #[test]
224 + fn the_digest_is_the_sha256_of_the_manifest_text() {
225 + let m = Manifest::new([("a", sha(0))]).unwrap();
226 + let mut hasher = Sha256::new();
227 + hasher.update(m.to_text().as_bytes());
228 + assert_eq!(m.digest().as_str(), hex_lower(&hasher.finalize()));
229 + assert_eq!(m.digest().short().len(), 16);
230 + }
231 +
232 + #[test]
233 + fn a_changed_asset_changes_the_digest_even_with_the_binary_untouched() {
234 + // The CDN_BASE_URL postmortem in one assertion: hashing only the binary
235 + // would call these two bundles the same thing.
236 + let before = Manifest::new([("makenotwork", sha(7)), ("static/app.css", sha(1))]).unwrap();
237 + let after = Manifest::new([("makenotwork", sha(7)), ("static/app.css", sha(2))]).unwrap();
238 + assert_ne!(before.digest(), after.digest());
239 + }
240 +
241 + #[test]
242 + fn a_duplicate_path_is_refused() {
243 + let err = Manifest::new([("makenotwork", sha(1)), ("makenotwork", sha(2))]).unwrap_err();
244 + assert!(matches!(err, ContractError::DuplicatePath(p) if p == "makenotwork"));
245 + }
246 +
247 + #[test]
248 + fn an_empty_manifest_is_refused() {
249 + // A bundle with no files has a perfectly good digest (the hash of the
250 + // empty string), which is the problem: it is a stable identity for
251 + // nothing at all, and every empty bundle shares it.
252 + let files: Vec<(String, String)> = Vec::new();
253 + assert!(matches!(
254 + Manifest::new(files).unwrap_err(),
255 + ContractError::EmptyManifest
256 + ));
257 + }
258 +
259 + #[test]
260 + fn paths_that_escape_the_bundle_are_refused() {
261 + for p in ["/etc/passwd", "../secrets", "a/../../b", "c:\\windows"] {
262 + assert!(
263 + matches!(
264 + Manifest::new([(p, sha(1))]).unwrap_err(),
265 + ContractError::UnsafePath(_)
266 + ),
267 + "{p} should be refused"
268 + );
269 + }
270 + }
271 +
272 + #[test]
273 + fn a_hash_that_is_not_a_sha256_is_refused() {
274 + for h in ["", "abc", &"g".repeat(64), &"AB".repeat(32)] {
275 + assert!(
276 + matches!(
277 + Manifest::new([("a", h)]).unwrap_err(),
278 + ContractError::NotASha256(_)
279 + ),
280 + "{h} should be refused"
281 + );
282 + }
283 + }
284 +
285 + #[test]
286 + fn a_malformed_line_names_its_line_number() {
287 + let text = format!("{} ok\nnot-a-manifest-line\n", sha(1));
288 + assert!(matches!(
289 + Manifest::parse(&text).unwrap_err(),
290 + ContractError::MalformedManifestLine(2)
291 + ));
292 + }
293 + }
@@ -1,0 +1,126 @@
1 + //! What source the bytes came from, and which machine turned one into the other.
2 +
3 + use crate::ContractError;
4 + use chrono::{DateTime, Utc};
5 +
6 + /// Everything needed to rebuild the artifact, or to prove it was not rebuilt.
7 + ///
8 + /// `git_sha` is the commit the build host was pinned to, not the branch tip at
9 + /// pull time. That distinction is the whole reason this field is here: a
10 + /// multi-arch release whose hosts each pulled independently ships several
11 + /// commits under one version label, and nothing downstream can tell.
12 + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
13 + pub struct Provenance {
14 + /// The app this release is of, as the controller keys it (`goingson`).
15 + pub app: String,
16 + /// The release version. A label, not an identity: it does not change per
17 + /// commit, and two builds can share it. The digest is the identity.
18 + pub version: String,
19 + /// The release tag the build was pinned to (`v0.4.1`, `pom-v0.4.1`).
20 + pub tag: String,
21 + /// The commit that tag resolved to, full 40 hex.
22 + pub git_sha: String,
23 + /// `os/arch` as the topology names it (`linux/x86_64`, `macos/aarch64`).
24 + pub target: String,
25 + /// The host that compiled it. A release is native per architecture, so this
26 + /// also says which machine to look at when only one target is wrong.
27 + pub build_host: String,
28 + /// `rustc --version` verbatim from the build host.
29 + pub toolchain: String,
30 + pub built_at: DateTime<Utc>,
31 + }
32 +
33 + impl Provenance {
34 + /// Reject a provenance that cannot identify what it describes.
35 + ///
36 + /// Every field here is load-bearing at intake, and an empty one is worse
37 + /// than a missing document: it satisfies a shape check while telling the
38 + /// evaluator nothing. The git sha is length-checked because a short sha
39 + /// silently stops matching a full one, and comparing provenance across
40 + /// hosts is the point.
41 + pub fn validate(&self) -> Result<(), ContractError> {
42 + for (name, value) in [
43 + ("app", &self.app),
44 + ("version", &self.version),
45 + ("tag", &self.tag),
46 + ("target", &self.target),
47 + ("build_host", &self.build_host),
48 + ("toolchain", &self.toolchain),
49 + ] {
50 + if value.trim().is_empty() {
51 + return Err(ContractError::MissingProvenance(name));
52 + }
53 + }
54 + let sha_ok = self.git_sha.len() == 40
55 + && self
56 + .git_sha
57 + .bytes()
58 + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
59 + if !sha_ok {
60 + return Err(ContractError::NotAGitSha(self.git_sha.clone()));
61 + }
62 + Ok(())
63 + }
64 + }
65 +
66 + #[cfg(test)]
67 + mod tests {
68 + use super::*;
69 +
70 + fn good() -> Provenance {
71 + Provenance {
72 + app: "goingson".into(),
73 + version: "0.4.1".into(),
74 + tag: "v0.4.1".into(),
75 + git_sha: "a".repeat(40),
76 + target: "linux/x86_64".into(),
77 + build_host: "fw13".into(),
78 + toolchain: "rustc 1.97.0 (deadbeef 2026-07-01)".into(),
79 + built_at: DateTime::<Utc>::from_timestamp(1_754_000_000, 0).unwrap(),
80 + }
81 + }
82 +
83 + #[test]
84 + fn a_complete_provenance_validates() {
85 + good().validate().unwrap();
86 + }
87 +
88 + #[test]
89 + fn each_empty_field_is_named() {
90 + let cases: [(&str, fn(&mut Provenance)); 6] = [
91 + ("app", |p| p.app = String::new()),
92 + ("version", |p| p.version = String::new()),
93 + ("tag", |p| p.tag = String::new()),
94 + ("target", |p| p.target = String::new()),
95 + ("build_host", |p| p.build_host = " ".into()),
96 + ("toolchain", |p| p.toolchain = String::new()),
97 + ];
98 + for (field, break_it) in cases {
99 + let mut p = good();
100 + break_it(&mut p);
101 + assert!(
102 + matches!(p.validate().unwrap_err(), ContractError::MissingProvenance(f) if f == field),
103 + "{field} should be reported by name"
104 + );
105 + }
106 + }
107 +
108 + #[test]
109 + fn a_short_sha_is_refused() {
110 + // Abbreviated shas compare unequal to full ones without being wrong,
111 + // which turns a provenance mismatch into a provenance non-answer.
112 + let mut p = good();
113 + p.git_sha = "a".repeat(12);
114 + assert!(matches!(
115 + p.validate().unwrap_err(),
116 + ContractError::NotAGitSha(_)
117 + ));
118 + }
119 +
120 + #[test]
121 + fn round_trips_through_json() {
122 + let p = good();
123 + let back: Provenance = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
124 + assert_eq!(p, back);
125 + }
126 + }
@@ -1,0 +1,306 @@
1 + //! The two documents that cross the boundary.
2 +
3 + use crate::{BundleDigest, ContractError, GateRecord, Manifest, Provenance, Scope};
4 +
5 + /// The version of this contract. Bumped when a change would make an older
6 + /// reader misread a newer document, not when a field is added.
7 + pub const CONTRACT_VERSION: u32 = 1;
8 +
9 + /// What the builder hands over: these bytes, from this source, and here is what
10 + /// I proved about them.
11 + ///
12 + /// Written **beside** the bundle, never inside it. Evidence names the digest of
13 + /// what it vouches for, and the digest covers every file in the bundle, so
14 + /// evidence stored inside would change the digest it names. Beyond the
15 + /// circularity, the evaluator appends its own evidence later; if evidence lived
16 + /// in the bundle, vouching for an artifact would alter the artifact's identity,
17 + /// and the thing that passed would no longer be the thing that ships.
18 + #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
19 + pub struct ArtifactRecord {
20 + pub contract: u32,
21 + /// Which system produced this (`bento`).
22 + pub producer: String,
23 + /// Identity of the bundle. Always equal to `manifest.digest()`; carried
24 + /// explicitly so a reader can check the manifest it was handed is the one
25 + /// the digest was computed from.
26 + pub digest: BundleDigest,
27 + pub manifest: Manifest,
28 + pub provenance: Provenance,
29 + /// Gates the builder ran. Artifact-scoped by construction.
30 + pub gates: Vec<GateRecord>,
31 + }
32 +
33 + impl ArtifactRecord {
34 + pub fn new(
35 + producer: impl Into<String>,
36 + manifest: Manifest,
37 + provenance: Provenance,
38 + gates: Vec<GateRecord>,
39 + ) -> Result<Self, ContractError> {
40 + let record = Self {
41 + contract: CONTRACT_VERSION,
42 + producer: producer.into(),
43 + digest: manifest.digest(),
44 + manifest,
45 + provenance,
46 + gates,
47 + };
48 + record.validate()?;
49 + Ok(record)
50 + }
51 +
52 + /// Everything an intake has to check before it may believe the document.
53 + ///
54 + /// Run on write as well as on read. A producer that emits a record it would
55 + /// itself reject has a bug now, not at the far end of a transfer where the
56 + /// error reads as corruption.
57 + pub fn validate(&self) -> Result<(), ContractError> {
58 + if self.contract != CONTRACT_VERSION {
59 + return Err(ContractError::UnknownContractVersion(self.contract));
60 + }
61 + if self.producer.trim().is_empty() {
62 + return Err(ContractError::MissingProvenance("producer"));
63 + }
64 + if self.digest != self.manifest.digest() {
65 + return Err(ContractError::DigestMismatch {
66 + claimed: self.digest.to_string(),
67 + computed: self.manifest.digest().to_string(),
68 + });
69 + }
70 + self.provenance.validate()?;
71 + // A builder has no environment, so it cannot have observed one. This is
72 + // the boundary refusing to be crossed by accident rather than a style
73 + // rule: an environment-scoped gate from a build host is either
74 + // mislabelled or measures a machine nothing will run on.
75 + for g in &self.gates {
76 + if let Scope::Environment { env } = &g.scope {
77 + return Err(ContractError::EnvironmentEvidenceFromBuilder {
78 + gate: g.gate.clone(),
79 + env: env.clone(),
80 + });
81 + }
82 + }
83 + Ok(())
84 + }
85 +
86 + /// True iff every gate the builder ran passed. Not a decision: whether the
87 + /// artifact advances is the evaluator's call, and it will have gates of its
88 + /// own that this record cannot speak to.
89 + pub fn all_gates_passed(&self) -> bool {
90 + self.gates.iter().all(|g| g.verdict.is_passed())
91 + }
92 +
93 + pub fn to_json(&self) -> String {
94 + // Pretty, with a trailing newline: these land on disk next to a bundle
95 + // and get read by a person before they are ever read by a program.
96 + let mut s = serde_json::to_string_pretty(self).unwrap_or_default();
97 + s.push('\n');
98 + s
99 + }
100 +
101 + pub fn parse(json: &str) -> Result<Self, ContractError> {
102 + let record: Self = serde_json::from_str(json).map_err(ContractError::Malformed)?;
103 + record.validate()?;
104 + Ok(record)
105 + }
106 + }
107 +
108 + /// What a later producer adds: I did not build this, and here is what I proved
109 + /// about it.
110 + ///
111 + /// Separate from [`ArtifactRecord`] so that appending evidence never rewrites
112 + /// the handover. Each producer owns one document; nobody edits anybody else's.
113 + /// `subject` is the only link, which is what makes the binding between evidence
114 + /// and bytes structural rather than a discipline each system has to remember.
115 + #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
116 + pub struct EvidenceRecord {
117 + pub contract: u32,
118 + /// Which system produced this (`sando`).
119 + pub producer: String,
120 + /// The bundle this evidence is about.
121 + pub subject: BundleDigest,
122 + pub gates: Vec<GateRecord>,
123 + }
124 +
125 + impl EvidenceRecord {
126 + pub fn new(
127 + producer: impl Into<String>,
128 + subject: BundleDigest,
129 + gates: Vec<GateRecord>,
130 + ) -> Result<Self, ContractError> {
131 + let record = Self {
132 + contract: CONTRACT_VERSION,
133 + producer: producer.into(),
134 + subject,
135 + gates,
136 + };
137 + record.validate()?;
138 + Ok(record)
139 + }
140 +
141 + pub fn validate(&self) -> Result<(), ContractError> {
142 + if self.contract != CONTRACT_VERSION {
143 + return Err(ContractError::UnknownContractVersion(self.contract));
144 + }
145 + if self.producer.trim().is_empty() {
146 + return Err(ContractError::MissingProvenance("producer"));
147 + }
148 + Ok(())
149 + }
150 +
151 + pub fn to_json(&self) -> String {
152 + let mut s = serde_json::to_string_pretty(self).unwrap_or_default();
153 + s.push('\n');
154 + s
155 + }
156 +
157 + pub fn parse(json: &str) -> Result<Self, ContractError> {
158 + let record: Self = serde_json::from_str(json).map_err(ContractError::Malformed)?;
159 + record.validate()?;
160 + Ok(record)
161 + }
162 + }
163 +
164 + #[cfg(test)]
165 + mod tests {
166 + use super::*;
167 + use crate::Verdict;
168 + use chrono::{DateTime, Utc};
169 +
170 + fn at() -> DateTime<Utc> {
171 + DateTime::<Utc>::from_timestamp(1_754_000_000, 0).unwrap()
172 + }
173 +
174 + fn manifest() -> Manifest {
175 + Manifest::new([
176 + ("GoingsOn.AppImage", "1".repeat(64)),
177 + ("GoingsOn.AppImage.minisig", "2".repeat(64)),
178 + ])
179 + .unwrap()
180 + }
181 +
182 + fn provenance() -> Provenance {
183 + Provenance {
184 + app: "goingson".into(),
185 + version: "0.4.1".into(),
186 + tag: "v0.4.1".into(),
187 + git_sha: "a".repeat(40),
188 + target: "linux/x86_64".into(),
189 + build_host: "fw13".into(),
190 + toolchain: "rustc 1.97.0".into(),
191 + built_at: at(),
192 + }
193 + }
194 +
195 + fn gate(name: &str, verdict: Verdict) -> GateRecord {
196 + GateRecord::new(name, Scope::Artifact, verdict, "ok", at())
197 + }
198 +
199 + fn record() -> ArtifactRecord {
200 + ArtifactRecord::new(
201 + "bento",
202 + manifest(),
203 + provenance(),
204 + vec![
205 + gate("clippy", Verdict::Passed),
206 + gate("cargo_test", Verdict::Passed),
207 + ],
208 + )
209 + .unwrap()
210 + }
211 +
212 + #[test]
213 + fn the_digest_is_the_manifests_and_the_record_round_trips() {
214 + let r = record();
215 + assert_eq!(r.digest, manifest().digest());
216 + assert!(r.all_gates_passed());
217 + let back = ArtifactRecord::parse(&r.to_json()).unwrap();
218 + assert_eq!(r, back);
219 + }
220 +
221 + #[test]
222 + fn a_record_whose_digest_does_not_match_its_manifest_is_refused() {
223 + // The whole point of the handover: a document claiming to describe
224 + // bytes it does not describe must not survive a read.
225 + let mut r = record();
226 + r.digest = BundleDigest::parse(&"f".repeat(64)).unwrap();
227 + assert!(matches!(
228 + r.validate().unwrap_err(),
229 + ContractError::DigestMismatch { .. }
230 + ));
231 + assert!(ArtifactRecord::parse(&serde_json::to_string(&r).unwrap()).is_err());
232 + }
233 +
234 + #[test]
235 + fn a_builder_cannot_hand_over_environment_evidence() {
236 + let g = GateRecord::new(
237 + "boot_smoke",
238 + Scope::Environment {
239 + env: "tier:a".into(),
240 + },
241 + Verdict::Passed,
242 + "served /health",
243 + at(),
244 + );
245 + let err = ArtifactRecord::new("bento", manifest(), provenance(), vec![g]).unwrap_err();
246 + assert!(
247 + matches!(err, ContractError::EnvironmentEvidenceFromBuilder { gate, env }
248 + if gate == "boot_smoke" && env == "tier:a")
249 + );
250 + }
251 +
252 + #[test]
253 + fn a_failing_gate_does_not_invalidate_the_record() {
254 + // A record of a failed build is a legitimate document. Refusing to emit
255 + // one would leave the only evidence of the failure in a log.
256 + let r = ArtifactRecord::new(
257 + "bento",
258 + manifest(),
259 + provenance(),
260 + vec![gate("cargo_test", Verdict::Failed)],
261 + )
262 + .unwrap();
263 + assert!(!r.all_gates_passed());
264 + }
265 +
266 + #[test]
267 + fn a_future_contract_version_is_refused_rather_than_guessed_at() {
268 + let mut r = record();
269 + r.contract = CONTRACT_VERSION + 1;
270 + assert!(matches!(
271 + r.validate().unwrap_err(),
272 + ContractError::UnknownContractVersion(_)
273 + ));
274 + }
275 +
276 + #[test]
277 + fn evidence_names_the_bundle_it_is_about_and_may_be_environment_scoped() {
278 + let e = EvidenceRecord::new(
279 + "sando",
280 + manifest().digest(),
281 + vec![GateRecord::new(
282 + "burn_in",
283 + Scope::Environment {
284 + env: "tier:a".into(),
285 + },
286 + Verdict::Blocked,
287 + "12 hours remaining of 48",
288 + at(),
289 + )],
290 + )
291 + .unwrap();
292 + let back = EvidenceRecord::parse(&e.to_json()).unwrap();
293 + assert_eq!(back.subject, manifest().digest());
294 + assert_eq!(back, e);
295 + }
296 +
297 + #[test]
298 + fn appending_evidence_does_not_touch_the_handover() {
299 + // Two documents, one subject. The evaluator's write cannot change the
300 + // builder's document, and neither can change the digest.
301 + let handover = record();
302 + let _ = EvidenceRecord::new("sando", handover.digest.clone(), vec![]).unwrap();
303 + assert_eq!(handover, record());
304 + assert_eq!(handover.digest, manifest().digest());
305 + }
306 + }