Skip to main content

max / makenotwork

Give Sando an intake path for artifacts it did not build Everything downstream of build.rs assumed Sando compiled the thing it is about to ship. Removing that assumption is what lets the boundary exist: Bento builds, Sando decides whether the result advances, and deciding requires being handed bytes. Being handed bytes requires a reason to believe they are the ones the evidence is about, so nothing here is taken on trust. The record must be internally consistent, the bytes on disk must hash to its manifest file for file, and the recomputed digest must equal the claimed one. A drifted bundle is refused with the file named, which is why the manifest is per-file rather than one hash over a tarball. Verification goes through Sando's own bundle walker rather than the contract's, because the node verifies with that same code after the transfer, and proving intake with a different implementation would leave a gap exactly where the two disagree. A test pins them to identical text. Intake decides identity, not policy. An artifact whose builder gates failed is accepted and reports it: whether it may advance belongs to the gating layer, which cannot judge what it was never handed.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 23:21 UTC
Signed with PGP, not checked
Commit: 8c72c5a59cc9812232da2e4c64b94a87ce0ab041
Parent: c453b30
4 files changed, +387 insertions, -12 deletions
M sando/Cargo.lock +19 -12
@@ -1175,6 +1175,16 @@
1175 1175 source = "registry+https://github.com/rust-lang/crates.io-index"
1176 1176 checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
1177 1177
1178 + [[package]]
1179 + name = "ops-artifact"
1180 + version = "0.1.0"
1181 + dependencies = [
1182 + "chrono",
1183 + "serde",
1184 + "serde_json",
1185 + "sha2 0.10.9",
1186 + ]
1187 +
1178 1188 [[package]]
1179 1189 name = "ops-core"
1180 1190 version = "0.1.0"
@@ -1618,6 +1628,7 @@
1618 1628 "axum",
1619 1629 "chrono",
1620 1630 "http-body-util",
1631 + "ops-artifact",
1621 1632 "ops-core",
1622 1633 "ops-exec",
1623 1634 "ops-status",
@@ -2966,6 +2977,14 @@
2966 2977 source = "registry+https://github.com/rust-lang/crates.io-index"
2967 2978 checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
2968 2979
2980 + [[patch.unused]]
2981 + name = "synckit-client"
2982 + version = "0.7.0"
2983 +
2984 + [[patch.unused]]
2985 + name = "synckit-config"
2986 + version = "0.1.2"
2987 +
2969 2988 [[patch.unused]]
2970 2989 name = "kberg"
2971 2990 version = "0.1.0"
@@ -2981,15 +3000,3 @@
2981 3000 [[patch.unused]]
2982 3001 name = "docengine"
2983 3002 version = "0.4.0"
2984 -
2985 - [[patch.unused]]
2986 - name = "synckit-client"
2987 - version = "0.6.0"
2988 -
2989 - [[patch.unused]]
2990 - name = "synckit-config"
2991 - version = "0.1.2"
2992 -
2993 - [[patch.unused]]
2994 - name = "supernote-push"
2995 - version = "0.1.0"
@@ -11,6 +11,7 @@
11 11 [dependencies]
12 12 axum = { version = "0.8.8", features = ["macros", "ws"] }
13 13 tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "net", "signal", "fs", "process"] }
14 + ops-artifact = { path = "../../shared/ops-artifact" }
14 15 ops-core = { path = "../../shared/ops-core" }
15 16 ops-status = { path = "../../shared/ops-status" }
16 17 ops-exec = { path = "../../shared/ops-exec" }
@@ -25,6 +25,7 @@
25 25 pub mod events;
26 26 pub mod gates;
27 27 pub mod git;
28 + pub mod intake;
28 29 pub mod outcome;
29 30 pub mod reconcile;
30 31 pub mod routes;
@@ -1,0 +1,366 @@
1 + //! Accepting an artifact Sando did not build.
2 + //!
3 + //! Design: wiki [[sando-bento-boundary]], [[release-artifact-identity]].
4 + //!
5 + //! Everything downstream of `build.rs` assumes Sando compiled the thing it is
6 + //! about to ship. That assumption is what the boundary removes: Bento builds,
7 + //! Sando decides whether the result advances. Deciding requires being handed
8 + //! bytes, and being handed bytes requires a reason to believe they are the ones
9 + //! the evidence is about.
10 + //!
11 + //! That belief is not taken on trust here. An accepted artifact has been checked
12 + //! three ways, each catching something the others cannot:
13 + //!
14 + //! 1. The record is internally consistent — its claimed digest is the digest of
15 + //! the manifest it carries. Catches a hand-edited or truncated document.
16 + //! 2. The bytes on disk hash to that manifest, file for file. Catches a bundle
17 + //! that drifted in transit, or one swapped for another, and names the file.
18 + //! 3. The digest of what arrived equals the digest the record claims. Implied by
19 + //! (2), and checked anyway because it is the value everything downstream
20 + //! keys on, and a mismatch here means the two computations disagree.
21 + //!
22 + //! Intake is about identity, not policy. A record whose builder gates failed is
23 + //! accepted and says so: whether that artifact may advance is the gating layer's
24 + //! call, and it cannot make that call about an artifact it was never handed.
25 +
26 + use crate::bundle;
27 + use ops_artifact::ArtifactRecord;
28 + use std::path::{Path, PathBuf};
29 +
30 + /// An artifact that arrived with credible paperwork, published into the local
31 + /// release root.
32 + #[derive(Debug)]
33 + pub struct AcceptedArtifact {
34 + /// The builder's document, verified against the bytes.
35 + pub record: ArtifactRecord,
36 + /// The content-addressed directory the bundle now lives at.
37 + pub released: PathBuf,
38 + }
39 +
40 + impl AcceptedArtifact {
41 + /// True iff every gate the builder ran passed. Not a decision to advance:
42 + /// Sando's own gates have not run yet, and they are the ones about this
43 + /// artifact in an environment.
44 + pub fn builder_gates_passed(&self) -> bool {
45 + self.record.all_gates_passed()
46 + }
47 + }
48 +
49 + /// Why an artifact was turned away.
50 + ///
51 + /// Typed rather than a string because the operator response differs per case
52 + /// and only one of these is a transfer problem. A record that does not parse is
53 + /// a producer bug; a manifest mismatch is corruption or substitution; a failure
54 + /// to publish is a local disk problem on this machine.
55 + #[derive(Debug)]
56 + pub enum IntakeError {
57 + /// The record did not parse, or failed its own validation.
58 + BadRecord(ops_artifact::ContractError),
59 + /// The bundle could not be hashed (unreadable, or not a directory).
60 + Unreadable(String),
61 + /// The bytes are not the ones the record describes. `first_difference` names
62 + /// a file the two disagree about, when one can be identified.
63 + ManifestMismatch {
64 + first_difference: Option<String>,
65 + claimed_files: usize,
66 + found_files: usize,
67 + },
68 + /// The recomputed digest is not the one claimed.
69 + DigestMismatch { claimed: String, computed: String },
70 + /// Publishing the verified bundle into the release root failed.
71 + Publish(String),
72 + }
73 +
74 + impl std::fmt::Display for IntakeError {
75 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 + match self {
77 + IntakeError::BadRecord(e) => write!(f, "artifact record is not usable: {e}"),
78 + IntakeError::Unreadable(d) => write!(f, "could not read the incoming bundle: {d}"),
79 + IntakeError::ManifestMismatch {
80 + first_difference: Some(p),
81 + ..
82 + } => write!(
83 + f,
84 + "the bundle is not what its record describes; `{p}` differs. \
85 + Refusing an artifact whose evidence is about other bytes"
86 + ),
87 + IntakeError::ManifestMismatch {
88 + first_difference: None,
89 + claimed_files,
90 + found_files,
91 + } => write!(
92 + f,
93 + "the bundle is not what its record describes: the record lists {claimed_files} \
94 + file(s), the bundle holds {found_files}"
95 + ),
96 + IntakeError::DigestMismatch { claimed, computed } => write!(
97 + f,
98 + "bundle digest {computed} does not match the claimed {claimed}"
99 + ),
100 + IntakeError::Publish(d) => write!(f, "publishing the accepted bundle failed: {d}"),
101 + }
102 + }
103 + }
104 +
105 + impl std::error::Error for IntakeError {}
106 +
107 + /// Verify an incoming bundle against its record and publish it
108 + /// content-addressed into `release_root`.
109 + ///
110 + /// `staged` must already be a directory under `release_root/staging/`, because
111 + /// publishing is an atomic same-filesystem rename and a cross-device staging dir
112 + /// would silently become a copy. The caller owns getting the bytes there; this
113 + /// owns deciding whether they may stay.
114 + pub async fn accept(
115 + release_root: &Path,
116 + staged: &Path,
117 + record_json: &str,
118 + ) -> Result<AcceptedArtifact, IntakeError> {
119 + let record = ArtifactRecord::parse(record_json).map_err(IntakeError::BadRecord)?;
120 +
121 + // Hash what actually arrived. Sando's own bundle walker, deliberately: the
122 + // artifact will later be verified on the node by the same code, so intake
123 + // proving the bundle with a different implementation than the deploy uses
124 + // would leave a gap exactly where the two disagree.
125 + let computed = bundle::digest_dir(staged)
126 + .await
127 + .map_err(|e| IntakeError::Unreadable(format!("{e:#}")))?;
128 +
129 + let claimed_manifest = record.manifest.to_text();
130 + if computed.manifest != claimed_manifest {
131 + return Err(manifest_mismatch(&claimed_manifest, &computed.manifest));
132 + }
133 + if computed.full != record.digest.as_str() {
134 + return Err(IntakeError::DigestMismatch {
135 + claimed: record.digest.to_string(),
136 + computed: computed.full,
137 + });
138 + }
139 +
140 + // Write the MANIFEST into the bundle so the node can verify per file after
141 + // the transfer. Excluded from its own hash, so this does not change what was
142 + // just proved.
143 + tokio::fs::write(
144 + staged.join(bundle::MANIFEST_NAME),
145 + computed.manifest.as_bytes(),
146 + )
147 + .await
148 + .map_err(|e| IntakeError::Publish(format!("writing MANIFEST: {e}")))?;
149 +
150 + let released = crate::deploy::finalize_local_release(release_root, staged, computed.short())
151 + .await
152 + .map_err(|e| IntakeError::Publish(format!("{e:#}")))?;
153 +
154 + tracing::info!(
155 + app = %record.provenance.app,
156 + version = %record.provenance.version,
157 + target = %record.provenance.target,
158 + producer = %record.producer,
159 + digest = %record.digest.short(),
160 + "accepted an artifact built elsewhere"
161 + );
162 + Ok(AcceptedArtifact { record, released })
163 + }
164 +
165 + /// Name a file the two manifests disagree about, for the error.
166 + ///
167 + /// A digest mismatch alone says only "not the same"; the useful half is which
168 + /// file drifted, which is the whole reason the manifest is per-file rather than
169 + /// one hash over a tarball.
170 + fn manifest_mismatch(claimed: &str, computed: &str) -> IntakeError {
171 + let paths = |text: &str| -> Vec<(String, String)> {
172 + text.lines()
173 + .filter_map(|l| l.split_once(" "))
174 + .map(|(sha, path)| (path.to_string(), sha.to_string()))
175 + .collect()
176 + };
177 + let (a, b) = (paths(claimed), paths(computed));
178 + let first_difference = a
179 + .iter()
180 + .find(|(path, sha)| !b.iter().any(|(p, s)| p == path && s == sha))
181 + .or_else(|| b.iter().find(|(path, _)| !a.iter().any(|(p, _)| p == path)))
182 + .map(|(path, _)| path.clone());
183 + IntakeError::ManifestMismatch {
184 + first_difference,
185 + claimed_files: a.len(),
186 + found_files: b.len(),
187 + }
188 + }
189 +
190 + #[cfg(test)]
191 + mod tests {
192 + use super::*;
193 + use chrono::{DateTime, Utc};
194 + use ops_artifact::{GateRecord, Manifest, Provenance, Scope, Verdict};
195 +
196 + fn at() -> DateTime<Utc> {
197 + DateTime::<Utc>::from_timestamp(1_754_000_000, 0).unwrap()
198 + }
199 +
200 + async fn write(root: &Path, rel: &str, bytes: &[u8]) {
201 + let p = root.join(rel);
202 + tokio::fs::create_dir_all(p.parent().unwrap())
203 + .await
204 + .unwrap();
205 + tokio::fs::write(&p, bytes).await.unwrap();
206 + }
207 +
208 + /// A staging dir under `release_root`, holding a small bundle.
209 + async fn staged_bundle(release_root: &Path) -> PathBuf {
210 + let staged = release_root.join("staging").join("1");
211 + write(&staged, "pom", b"binary bytes").await;
212 + write(&staged, "static/app.css", b"body{}").await;
213 + staged
214 + }
215 +
216 + fn provenance() -> Provenance {
217 + Provenance {
218 + app: "pom".into(),
219 + version: "0.4.1".into(),
220 + tag: "pom-v0.4.1".into(),
221 + git_sha: "a".repeat(40),
222 + target: "linux/aarch64".into(),
223 + build_host: "astra".into(),
224 + toolchain: "rustc 1.97.0".into(),
225 + built_at: at(),
226 + }
227 + }
228 +
229 + /// The record Bento would have written for `staged`.
230 + async fn record_for(staged: &Path, gates: Vec<GateRecord>) -> String {
231 + let computed = bundle::digest_dir(staged).await.unwrap();
232 + let manifest = Manifest::parse(&computed.manifest).unwrap();
233 + ArtifactRecord::new("bento", manifest, provenance(), gates)
234 + .unwrap()
235 + .to_json()
236 + }
237 +
238 + fn passing_gate() -> GateRecord {
239 + GateRecord::new(
240 + "prebuild",
241 + Scope::Artifact,
242 + Verdict::Passed,
243 + "prebuild passed in 90s",
244 + at(),
245 + )
246 + }
247 +
248 + /// The load-bearing agreement: Sando's bundle walker and the contract's
249 + /// manifest must produce the same text, byte for byte. They are separate
250 + /// implementations on either side of the boundary, and a divergence would
251 + /// make every honest artifact look corrupt.
252 + #[tokio::test]
253 + async fn sandos_walker_and_the_contract_manifest_agree_exactly() {
254 + let dir = tempfile::tempdir().unwrap();
255 + let staged = staged_bundle(dir.path()).await;
256 + let computed = bundle::digest_dir(&staged).await.unwrap();
257 + let parsed = Manifest::parse(&computed.manifest).unwrap();
258 + assert_eq!(parsed.to_text(), computed.manifest);
259 + assert_eq!(parsed.digest().as_str(), computed.full);
260 + }
261 +
262 + #[tokio::test]
263 + async fn a_matching_bundle_is_accepted_and_published_content_addressed() {
264 + let dir = tempfile::tempdir().unwrap();
265 + let staged = staged_bundle(dir.path()).await;
266 + let json = record_for(&staged, vec![passing_gate()]).await;
267 +
268 + let accepted = accept(dir.path(), &staged, &json).await.unwrap();
269 + assert!(accepted.builder_gates_passed());
270 + assert!(
271 + accepted.released.ends_with(accepted.record.digest.short()),
272 + "published at {}, expected a dir named for the digest",
273 + accepted.released.display()
274 + );
275 + // The MANIFEST rides along for node-side verification.
276 + assert!(accepted.released.join(bundle::MANIFEST_NAME).exists());
277 + // Staging is gone: the bundle was renamed, not copied.
278 + assert!(!staged.exists());
279 + }
280 +
281 + #[tokio::test]
282 + async fn a_bundle_that_drifted_is_refused_and_names_the_file() {
283 + // The failure this whole boundary exists to make impossible: evidence
284 + // that vouches for one set of bytes arriving with another.
285 + let dir = tempfile::tempdir().unwrap();
286 + let staged = staged_bundle(dir.path()).await;
287 + let json = record_for(&staged, vec![passing_gate()]).await;
288 + write(&staged, "static/app.css", b"tampered").await;
289 +
290 + let err = accept(dir.path(), &staged, &json).await.unwrap_err();
291 + match err {
292 + IntakeError::ManifestMismatch {
293 + first_difference: Some(p),
294 + ..
295 + } => assert_eq!(p, "static/app.css"),
296 + other => panic!("expected a manifest mismatch naming the file, got {other}"),
297 + }
298 + // Nothing was published.
299 + assert!(!dir.path().join("releases").exists());
300 + }
301 +
302 + #[tokio::test]
303 + async fn an_extra_file_in_the_bundle_is_refused() {
304 + // Same bytes for everything the record lists, plus something it does not.
305 + // The digest would differ, but the useful error names the intruder.
306 + let dir = tempfile::tempdir().unwrap();
307 + let staged = staged_bundle(dir.path()).await;
308 + let json = record_for(&staged, vec![passing_gate()]).await;
309 + write(&staged, "companions/unexpected", b"who put this here").await;
310 +
311 + let err = accept(dir.path(), &staged, &json).await.unwrap_err();
312 + match err {
313 + IntakeError::ManifestMismatch {
314 + first_difference: Some(p),
315 + ..
316 + } => assert_eq!(p, "companions/unexpected"),
317 + other => panic!("expected the extra file to be named, got {other}"),
318 + }
319 + }
320 +
321 + #[tokio::test]
322 + async fn a_record_that_does_not_parse_is_refused_before_anything_is_hashed() {
323 + let dir = tempfile::tempdir().unwrap();
324 + let staged = staged_bundle(dir.path()).await;
325 + let err = accept(dir.path(), &staged, "{\"not\": \"a record\"}")
326 + .await
327 + .unwrap_err();
328 + assert!(matches!(err, IntakeError::BadRecord(_)), "{err}");
329 + assert!(staged.exists(), "a refused intake leaves the bytes alone");
330 + }
331 +
332 + #[tokio::test]
333 + async fn a_record_whose_digest_was_edited_is_refused() {
334 + // Editing the digest to match tampered bytes does not help: the record
335 + // validates its own digest against its own manifest first.
336 + let dir = tempfile::tempdir().unwrap();
337 + let staged = staged_bundle(dir.path()).await;
338 + let json = record_for(&staged, vec![passing_gate()]).await;
339 + let forged = json.replace(&record_digest(&json), &"f".repeat(64));
340 + let err = accept(dir.path(), &staged, &forged).await.unwrap_err();
341 + assert!(matches!(err, IntakeError::BadRecord(_)), "{err}");
342 + }
343 +
344 + #[tokio::test]
345 + async fn a_failed_builder_gate_is_accepted_and_reported_not_hidden() {
346 + // Intake decides identity, not whether the thing may ship. Refusing here
347 + // would leave the evidence of a failed build nowhere Sando can see it.
348 + let dir = tempfile::tempdir().unwrap();
349 + let staged = staged_bundle(dir.path()).await;
350 + let failed = GateRecord::new(
351 + "prebuild",
352 + Scope::Artifact,
353 + Verdict::Failed,
354 + "12 test(s) failed",
355 + at(),
356 + );
357 + let json = record_for(&staged, vec![failed]).await;
358 + let accepted = accept(dir.path(), &staged, &json).await.unwrap();
359 + assert!(!accepted.builder_gates_passed());
360 + }
361 +
362 + fn record_digest(json: &str) -> String {
363 + let v: serde_json::Value = serde_json::from_str(json).unwrap();
364 + v["digest"].as_str().unwrap().to_string()
365 + }
366 + }