Skip to main content

max / makenotwork

Answer the producer before believing the bytes, not after `POST /intake` returned `{"accepted": true}` and then verified the bundle in a spawned task. Its own docstring claimed the opposite — "the bytes are proved against the record before anything else happens, so a bundle that drifted in transit is refused with the offending file named rather than gated and shipped" — and the verification really did refuse it, correctly, into a log line nobody was waiting on. Found by running it. A drifted bundle handed over by Bento came back 200, so the handoff succeeded, so the target run went green, so the release looked fine and the refusal existed only in this daemon's run row. That is the exact shape the boundary exists to close: evidence vouching for one thing while something else ships, arrived at from the other direction. It also silently defeated `MNW@aaf6d23b`, which made a failed handoff fail the build. There is no failure to observe if the call always succeeds. `intake_and_gate` splits into `accept_intake` and `gate_intake`, on different clocks because they answer to different people. Acceptance is the producer's question — do you believe these bytes — and it is fast, since hashing the bundle is the work the transfer just did, so the handler does it inline and returns the refusal with the file named. Gating is this daemon's question, takes as long as a tier's gates take, and stays spawned. The producer's job ended when the bytes were believed. Verified end to end against a scratch sandod on loopback, driving bento's real `handoff::send` through a real `[handoff.pom]` config: a good bundle publishes content-addressed and `current` moves; a bundle whose bytes were tampered with after its record was written comes back 409 naming `pom`, leaves the staged bytes alone, and fails the send.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-07 03:06 UTC
Signed with PGP, not checked
Commit: 38adf019771b09b681f33d52f142a4a4c156de3f
Parent: b0d8e6f
2 files changed, +84 insertions, -50 deletions
@@ -454,21 +454,18 @@
454 454 /// atomic same-filesystem rename); getting the bytes there is the transport's
455 455 /// job, not this function's.
456 456 #[allow(clippy::too_many_arguments)]
457 - pub async fn intake_and_gate(
458 - pool: SqlitePool,
459 - cfg: Arc<AppConfig>,
460 - topo: Arc<Topology>,
461 - staged: PathBuf,
462 - record_json: String,
463 - events: crate::events::EventTx,
457 + pub async fn accept_intake(
458 + pool: &SqlitePool,
459 + cfg: &AppConfig,
460 + staged: &Path,
461 + record_json: &str,
464 462 run_id: RunId,
465 - deploy_lock: Arc<tokio::sync::Mutex<()>>,
466 - ) -> Result<()> {
467 - crate::runs::set_phase(&pool, run_id, crate::runs::Phase::Staging)
463 + ) -> Result<Published> {
464 + crate::runs::set_phase(pool, run_id, crate::runs::Phase::Staging)
468 465 .await
469 466 .ok();
470 467
471 - let accepted = crate::intake::accept(&cfg.release_root, &staged, &record_json)
468 + let accepted = crate::intake::accept(&cfg.release_root, staged, record_json)
472 469 .await
473 470 .map_err(|e| anyhow::anyhow!("{e}"))?;
474 471
@@ -491,9 +488,9 @@
491 488 )
492 489 })?;
493 490
494 - crate::runs::set_version(&pool, run_id, &version).await.ok();
491 + crate::runs::set_version(pool, run_id, &version).await.ok();
495 492 upsert_version_row(
496 - &pool,
493 + pool,
497 494 &cfg.id,
498 495 &version,
499 496 &git_sha,
@@ -507,13 +504,32 @@
507 504 digest_full: accepted.record.digest.to_string(),
508 505 platform: Some(platform),
509 506 };
510 - record_identity(&pool, &cfg, &published, run_id).await?;
507 + record_identity(pool, cfg, &published, run_id).await?;
508 + Ok(published)
509 + }
511 510
512 - // An intake carries no worktree, and the gates that need one refuse rather
513 - // than resolve against nothing. That is the boundary showing up in the type:
514 - // artifact-scoped gates belong to the builder (wiki [[sando-bento-boundary]]),
515 - // so a tier that asks Sando to re-run them against an accepted artifact is
516 - // misconfigured and should be told so.
511 + /// Gate an artifact that has already been accepted.
512 + ///
513 + /// Split from [`accept_intake`] so the two can be answered on different clocks.
514 + /// Acceptance is fast and is the producer's business — it either believes the
515 + /// bytes or names the file that drifted — so the caller waits for it and gets
516 + /// the verdict. Gating is Sando's business and can take an hour, so the caller
517 + /// does not.
518 + ///
519 + /// An intake carries no worktree, and the gates that need one refuse rather than
520 + /// resolve against nothing. That is the boundary showing up in the type:
521 + /// artifact-scoped gates belong to the builder (wiki [[sando-bento-boundary]]),
522 + /// so a tier that asks Sando to re-run them against an accepted artifact is
523 + /// misconfigured and should be told so.
524 + pub async fn gate_intake(
525 + pool: SqlitePool,
526 + cfg: Arc<AppConfig>,
527 + topo: Arc<Topology>,
528 + published: Published,
529 + events: crate::events::EventTx,
530 + run_id: RunId,
531 + deploy_lock: Arc<tokio::sync::Mutex<()>>,
532 + ) -> Result<()> {
517 533 record_and_gate(
518 534 pool,
519 535 cfg,
@@ -603,7 +619,12 @@
603 619
604 620 /// A bundle that has been hashed and published content-addressed. Both paths
605 621 /// produce one; nothing downstream can tell them apart.
606 - struct Published {
622 + ///
623 + /// Public because the intake route now hands one from `accept_intake` to
624 + /// `gate_intake`: proving the bytes answers the producer, gating them does not,
625 + /// so the two run on different clocks and the value passes between them.
626 + #[derive(Debug)]
627 + pub struct Published {
607 628 version: Version,
608 629 released: PathBuf,
609 630 digest_full: String,
@@ -853,8 +874,8 @@
853 874 #[cfg(test)]
854 875 mod tests {
855 876 use super::{
856 - BuildArtifact, check_build_host, checkout_aux_repos, intake_and_gate, runtime_hostname,
857 - stage_and_gate, tail,
877 + BuildArtifact, accept_intake, check_build_host, checkout_aux_repos, gate_intake,
878 + runtime_hostname, stage_and_gate, tail,
858 879 };
859 880 use crate::config::{AppConfig, TestTarget};
860 881 use crate::domain::{GitSha, RunId, Version};
@@ -1050,12 +1071,16 @@
1050 1071 .unwrap();
1051 1072 let record = record_for(&staged, "1.2.3", "linux/aarch64").await;
1052 1073
1053 - intake_and_gate(
1074 + // Two calls now, on purpose: the route answers its caller on the first
1075 + // and spawns the second. Acceptance is what the producer waits for.
1076 + let published = accept_intake(&pool, &cfg, &staged, &record, run_id)
1077 + .await
1078 + .expect("the bytes are believed");
1079 + gate_intake(
1054 1080 pool.clone(),
1055 1081 cfg.clone(),
1056 1082 topo,
1057 - staged.clone(),
1058 - record,
1083 + published,
1059 1084 crate::events::channel(),
1060 1085 run_id,
1061 1086 deploy_lock,
@@ -1117,18 +1142,12 @@
1117 1142 .await
1118 1143 .unwrap();
1119 1144
1120 - let err = intake_and_gate(
1121 - pool.clone(),
1122 - cfg,
1123 - topo,
1124 - staged.clone(),
1125 - record,
1126 - crate::events::channel(),
1127 - run_id,
1128 - deploy_lock,
1129 - )
1130 - .await
1131 - .expect_err("a drifted bundle is refused");
1145 + // Refused by ACCEPTANCE, not by gating — which is what lets the route
1146 + // answer the producer with the refusal instead of a `202`-shaped lie.
1147 + let _ = (&topo, &deploy_lock);
1148 + let err = accept_intake(&pool, &cfg, &staged, &record, run_id)
1149 + .await
1150 + .expect_err("a drifted bundle is refused");
1132 1151 assert!(err.to_string().contains("makenotwork"), "{err}");
1133 1152
1134 1153 // Nothing advanced, and the bytes were left where they were.
@@ -570,27 +570,42 @@
570 570
571 571 tracing::info!(staged = %staged_abs.display(), run_id = %run_id, "artifact intake requested");
572 572
573 + // Prove the bytes BEFORE answering. This has to be synchronous, and the
574 + // reason is the whole contract: the producer treats a non-success here as a
575 + // failed handoff and fails its own build on it. Proving in a spawned task
576 + // and answering `accepted: true` first would tell Bento the artifact landed
577 + // and only then discover it had not — a corrupt bundle would go green
578 + // upstream and be visible solely in this daemon's run row, which is exactly
579 + // the "evidence vouches for one thing, the deploy ships another" class the
580 + // boundary exists to close.
581 + //
582 + // It is affordable: hashing the bundle is the work the transfer just did.
583 + let accepted =
584 + match crate::build::accept_intake(&s.pool, &s.cfg, &staged_abs, &body.record, run_id).await
585 + {
586 + Ok(p) => p,
587 + Err(e) => {
588 + let msg = format!("{e:#}");
589 + tracing::error!(error = %msg, "refused an intake");
590 + crate::runs::mark_failed(&s.pool, run_id, &msg).await.ok();
591 + return Err(crate::error::Error::GateBlocked(msg));
592 + }
593 + };
594 +
595 + // Gating is not. It runs the host tier's gates, which can take an hour, and
596 + // whether the artifact advances is this daemon's business rather than the
597 + // builder's. The producer's job ended when the bytes were believed.
573 598 let pool = s.pool.clone();
574 599 let pool_for_task = s.pool.clone();
575 600 let cfg = s.cfg.clone();
576 601 let topo = s.topo.clone();
577 602 let events = s.events.clone();
578 603 let deploy_lock = s.deploy_lock.clone();
579 - let record = body.record;
580 604 tokio::spawn(async move {
581 - if let Err(e) = crate::build::intake_and_gate(
582 - pool,
583 - cfg,
584 - topo,
585 - staged_abs,
586 - record,
587 - events,
588 - run_id,
589 - deploy_lock,
590 - )
591 - .await
605 + if let Err(e) =
606 + crate::build::gate_intake(pool, cfg, topo, accepted, events, run_id, deploy_lock).await
592 607 {
593 - tracing::error!(error = %e, "intake pipeline failed");
608 + tracing::error!(error = %e, "gating an accepted artifact failed");
594 609 crate::runs::mark_failed(&pool_for_task, run_id, &format!("{e:#}"))
595 610 .await
596 611 .ok();