Skip to main content

max / makenotwork

Thread the app dimension through Sando's queries Every statement that reads or writes per-product state now names the product. The startup guard that refused a second app goes with it. The dangerous ones were never the inserts. sync's stale-row sweep asks which rows are not in this TOML, so unscoped it answers "all of the other product's" — already fixed. advance_tier moves whichever tier_state row matches a bare tier name, so promoting pom would have advanced MNW's host tier. state_view joined tiers to tier_state on name alone. And unsatisfied_gates read gate evidence by (tier, version), which two products can collide on in the same week. Three reads stay deliberately unscoped, and say so: the startup sweeps for orphaned build runs and in-flight deploys, which reconcile a daemon that was running every product, and the per-id updates, since a run id and a deploy id are unique across products. runs::get reads the app from the run's own row rather than taking it as an argument. The row is the authority on which product a run belongs to, and asking the caller would let a lookup for one product's run return another's gates.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 23:53 UTC
Signed with PGP, not checked
Commit: 57a3bf8f4cb53b21becad1f956a04737216d519e
Parent: 8a9a1af
8 files changed, +336 insertions, -146 deletions
@@ -199,7 +199,7 @@
199 199 /// Pull one configured dump into its `local_path`.
200 200 async fn fetch_one(
201 201 pool: &SqlitePool,
202 - _cfg: &Arc<AppConfig>,
202 + cfg: &Arc<AppConfig>,
203 203 backup: &crate::topology::BackupConfig,
204 204 force: bool,
205 205 ) -> Result<FetchedBackup> {
@@ -228,8 +228,10 @@
228 228 // truncated server dump through. The first-ever fetch of a name (no prior
229 229 // row) falls back to the absolute floor.
230 230 let last_size: Option<i64> = sqlx::query_scalar(
231 - "SELECT byte_size FROM backups WHERE name = ? ORDER BY fetched_at DESC LIMIT 1",
231 + "SELECT byte_size FROM backups
232 + WHERE app = ? AND name = ? ORDER BY fetched_at DESC LIMIT 1",
232 233 )
234 + .bind(&cfg.id)
233 235 .bind(&name)
234 236 .fetch_optional(pool)
235 237 .await?;
@@ -326,9 +328,10 @@
326 328 // reference a path that no longer exists — keep the table from growing.
327 329 let mut tx = pool.begin().await?;
328 330 sqlx::query(
329 - "INSERT INTO backups (name, fetched_at, source, local_path, byte_size) \
330 - VALUES (?, ?, ?, ?, ?)",
331 + "INSERT INTO backups (app, name, fetched_at, source, local_path, byte_size) \
332 + VALUES (?, ?, ?, ?, ?, ?)",
331 333 )
334 + .bind(&cfg.id)
332 335 .bind(&name)
333 336 .bind(Utc::now().to_rfc3339())
334 337 .bind(&source)
@@ -336,7 +339,10 @@
336 339 .bind(size)
337 340 .execute(&mut *tx)
338 341 .await?;
339 - sqlx::query("DELETE FROM backups WHERE fetched_at < datetime('now', '-30 days')")
342 + // Scoped: one product's fetch is not a licence to prune another's history,
343 + // and the 30-day window is about this product's own overwritten dumps.
344 + sqlx::query("DELETE FROM backups WHERE app = ? AND fetched_at < datetime('now', '-30 days')")
345 + .bind(&cfg.id)
340 346 .execute(&mut *tx)
341 347 .await?;
342 348 tx.commit().await?;
@@ -211,9 +211,10 @@
211 211 }
212 212
213 213 sqlx::query(
214 - "INSERT OR IGNORE INTO versions (version, git_sha, built_at, artifact_path)
215 - VALUES (?, ?, ?, ?)",
214 + "INSERT OR IGNORE INTO versions (app, version, git_sha, built_at, artifact_path)
215 + VALUES (?, ?, ?, ?, ?)",
216 216 )
217 + .bind(&cfg.id)
217 218 .bind(&version)
218 219 .bind(&sha)
219 220 .bind(Utc::now().to_rfc3339())
@@ -450,8 +451,9 @@
450 451 deploy::finalize_local_release(host_release_root, &staging, digest.short()).await?;
451 452
452 453 let staged_bin = released.join(cfg.primary_bin());
453 - sqlx::query("UPDATE versions SET artifact_path = ? WHERE version = ?")
454 + sqlx::query("UPDATE versions SET artifact_path = ? WHERE app = ? AND version = ?")
454 455 .bind(staged_bin.to_string_lossy().as_ref())
456 + .bind(&cfg.id)
455 457 .bind(&art.version)
456 458 .execute(&pool)
457 459 .await?;
@@ -502,7 +504,7 @@
502 504 // ultra-fuzz Run 2, S1). Held only for the atomic UPDATE, never the gates.
503 505 {
504 506 let _deploy_guard = deploy_lock.lock().await;
505 - crate::runs::advance_tier(&pool, "host", &art.version, Some(run_id.0)).await?;
507 + crate::runs::advance_tier(&pool, &cfg.id, "host", &art.version, Some(run_id.0)).await?;
506 508 }
507 509 // Terminal verdict: unlike the phase pings above (best-effort), a dropped
508 510 // pass/fail write leaves the run wedged at `building`. Log it loudly if it
@@ -515,7 +517,7 @@
515 517 } else {
516 518 // Pull the first red gate's typed summary into the run so the API
517 519 // answers "which gate, and why" — not just "failed".
518 - let summary = crate::runs::first_failed_gate_summary(&pool, &art.version)
520 + let summary = crate::runs::first_failed_gate_summary(&pool, &cfg.id, &art.version)
519 521 .await
520 522 .unwrap_or_else(|| "host pipeline red".to_string());
521 523 if let Err(e) = crate::runs::mark_failed(&pool, run_id, &summary).await {
@@ -689,9 +691,13 @@
689 691 .await
690 692 .unwrap();
691 693
692 - let run_id = crate::runs::create(&pool, &git_sha.to_string())
693 - .await
694 - .unwrap();
694 + let run_id = crate::runs::create(
695 + &pool,
696 + &crate::domain::AppId::default(),
697 + &git_sha.to_string(),
698 + )
699 + .await
700 + .unwrap();
695 701
696 702 let cfg = AppConfig {
697 703 id: crate::domain::AppId::default(),
@@ -5,7 +5,7 @@
5 5
6 6 use crate::classify;
7 7 use crate::config::AppConfig;
8 - use crate::domain::{GateKind, GateRunId, TierId, Version};
8 + use crate::domain::{AppId, GateKind, GateRunId, TierId, Version};
9 9 use crate::events::{self, Event, EventTx};
10 10 use crate::outcome::{GateBlocker, GateFailure, GateOutcome, LogRef, PassNote};
11 11 use crate::topology::Gate;
@@ -97,9 +97,11 @@
97 97 let started_at = Utc::now().to_rfc3339();
98 98
99 99 let id: i64 = sqlx::query_scalar(
100 - "INSERT INTO gate_runs (version, tier, gate_kind, started_at, build_id) VALUES (?, ?, ?, ?, ?)
100 + "INSERT INTO gate_runs (app, version, tier, gate_kind, started_at, build_id)
101 + VALUES (?, ?, ?, ?, ?, ?)
101 102 RETURNING id",
102 103 )
104 + .bind(&ctx.cfg.id)
103 105 .bind(&ctx.version)
104 106 .bind(&ctx.tier)
105 107 .bind(kind)
@@ -931,8 +933,10 @@
931 933 let label = check.dir.display().to_string();
932 934
933 935 let backup: Option<(String, String)> = sqlx::query_as(
934 - "SELECT local_path, fetched_at FROM backups WHERE name = ? ORDER BY id DESC LIMIT 1",
936 + "SELECT local_path, fetched_at FROM backups
937 + WHERE app = ? AND name = ? ORDER BY id DESC LIMIT 1",
935 938 )
939 + .bind(&ctx.cfg.id)
936 940 .bind(&check.backup)
937 941 .fetch_optional(&ctx.pool)
938 942 .await?;
@@ -1386,7 +1390,8 @@
1386 1390 // runs first among the host gates, but staging precedes all gating, so the
1387 1391 // artifact path is already recorded.
1388 1392 let bin: Option<(String,)> =
1389 - sqlx::query_as("SELECT artifact_path FROM versions WHERE version = ?")
1393 + sqlx::query_as("SELECT artifact_path FROM versions WHERE app = ? AND version = ?")
1394 + .bind(&ctx.cfg.id)
1390 1395 .bind(&ctx.version)
1391 1396 .fetch_optional(&ctx.pool)
1392 1397 .await?;
@@ -1859,7 +1864,8 @@
1859 1864
1860 1865 async fn boot_smoke(ctx: &GateCtx, run_id: GateRunId) -> Result<GateOutcome> {
1861 1866 let bin: Option<(String,)> =
1862 - sqlx::query_as("SELECT artifact_path FROM versions WHERE version = ?")
1867 + sqlx::query_as("SELECT artifact_path FROM versions WHERE app = ? AND version = ?")
1868 + .bind(&ctx.cfg.id)
1863 1869 .bind(&ctx.version)
1864 1870 .fetch_optional(&ctx.pool)
1865 1871 .await?;
@@ -2247,9 +2253,15 @@
2247 2253 /// by the promote-time gate check (`unsatisfied_gates`) so a stale `blocked`
2248 2254 /// row never masks an elapsed — or not-yet-elapsed — window. The `burn_in` gate
2249 2255 /// runner below wraps the same state with a richer outcome for `/state`.
2250 - pub async fn burn_in_satisfied(pool: &SqlitePool, tier: &TierId, hours: u32) -> Result<bool> {
2256 + pub async fn burn_in_satisfied(
2257 + pool: &SqlitePool,
2258 + app: &AppId,
2259 + tier: &TierId,
2260 + hours: u32,
2261 + ) -> Result<bool> {
2251 2262 let started: Option<String> =
2252 - sqlx::query_scalar("SELECT burn_in_started_at FROM tier_state WHERE tier = ?")
2263 + sqlx::query_scalar("SELECT burn_in_started_at FROM tier_state WHERE app = ? AND tier = ?")
2264 + .bind(app)
2253 2265 .bind(tier)
2254 2266 .fetch_optional(pool)
2255 2267 .await?
@@ -2266,7 +2278,8 @@
2266 2278 // has elapsed. The clock is started by /promote when a version lands on
2267 2279 // the burn-in tier.
2268 2280 let started: Option<String> =
2269 - sqlx::query_scalar("SELECT burn_in_started_at FROM tier_state WHERE tier = ?")
2281 + sqlx::query_scalar("SELECT burn_in_started_at FROM tier_state WHERE app = ? AND tier = ?")
2282 + .bind(&ctx.cfg.id)
2270 2283 .bind(&ctx.tier)
2271 2284 .fetch_optional(&ctx.pool)
2272 2285 .await?
@@ -2297,9 +2310,11 @@
2297 2310 // look for a prior confirmation row.
2298 2311 let prior_at: Option<String> = sqlx::query_scalar(
2299 2312 "SELECT finished_at FROM gate_runs
2300 - WHERE tier = ? AND version = ? AND gate_kind = 'manual_confirm' AND status = 'passed'
2313 + WHERE app = ? AND tier = ? AND version = ? AND gate_kind = 'manual_confirm'
2314 + AND status = 'passed'
2301 2315 ORDER BY id DESC LIMIT 1",
2302 2316 )
2317 + .bind(&ctx.cfg.id)
2303 2318 .bind(&ctx.tier)
2304 2319 .bind(&ctx.version)
2305 2320 .fetch_optional(&ctx.pool)
@@ -103,29 +103,6 @@
103 103 }),
104 104 );
105 105 }
106 - // TEMPORARY, and loud on purpose. The config and the schema carry the
107 - // product dimension; the queries do not yet — they address `versions`,
108 - // `tier_state`, `gate_runs` and the rest without naming an app. With one
109 - // product configured that is correct, because every row is that product's.
110 - // With two it is silent cross-product corruption: pom's promote would read
111 - // MNW's tier state and advance it.
112 - //
113 - // So refuse, rather than run wrong. This check is deleted by the commit that
114 - // finishes threading `app` through the queries, and until then it is what
115 - // makes a half-finished migration safe instead of a trap.
116 - anyhow::ensure!(
117 - apps.len() == 1,
118 - "{} apps configured ({}), but Sando's queries are still app-blind: they \
119 - address versions/tier_state/gate_runs without naming a product, so a \
120 - second app would read and advance the first one's state. Configure one \
121 - app until that threading lands (GoingsOn sando `{}`).",
122 - apps.len(),
123 - apps.keys()
124 - .map(ToString::to_string)
125 - .collect::<Vec<_>>()
126 - .join(", "),
127 - "b072577a",
128 - );
129 106 let apps = Arc::new(apps);
130 107 // The product a request that names none is about. First in declared order,
131 108 // which for a legacy config is the only one there is.
@@ -34,6 +34,7 @@
34 34 //! and settled to a terminal `failed` so the audit trail carries no eternal
35 35 //! in-flight row.
36 36
37 + use crate::domain::AppId;
37 38 use anyhow::Result;
38 39 use chrono::{DateTime, Utc};
39 40 use sqlx::{Row, SqlitePool};
@@ -57,8 +58,12 @@
57 58 /// stands — and settles orphaned `in_progress` rows. Returns how many tiers were
58 59 /// flagged. Run once at startup, before serving.
59 60 pub async fn recover_unrecorded_deploys(pool: &SqlitePool) -> Result<u64> {
61 + // Every product's tiers, deliberately: startup reconciles the whole daemon,
62 + // and a promote interrupted by the restart is no less interrupted for
63 + // belonging to another product. Each tier is then compared only against its
64 + // own product's deploy trail.
60 65 let tiers = sqlx::query(
61 - "SELECT tier, current_version, current_build_id, advanced_at, partial_reason
66 + "SELECT app, tier, current_version, current_build_id, advanced_at, partial_reason
62 67 FROM tier_state",
63 68 )
64 69 .fetch_all(pool)
@@ -66,13 +71,14 @@
66 71
67 72 let mut flagged = 0u64;
68 73 for row in tiers {
74 + let app: AppId = AppId::new(row.get::<String, _>("app"));
69 75 let tier: String = row.get("tier");
70 76 // A tier already flagged partial carries a more specific reason from the
71 77 // path that flagged it; don't overwrite it.
72 78 if row.get::<Option<String>, _>("partial_reason").is_some() {
73 79 continue;
74 80 }
75 - let Some(landing) = latest_deploy(pool, &tier).await? else {
81 + let Some(landing) = latest_deploy(pool, &app, &tier).await? else {
76 82 continue;
77 83 };
78 84 let current_version: Option<String> = row.get("current_version");
@@ -118,14 +124,16 @@
118 124 // reason since the read above (startup is single-threaded, but the guard
119 125 // costs nothing and states the intent).
120 126 let res = sqlx::query(
121 - "UPDATE tier_state SET partial_reason = ? WHERE tier = ? AND partial_reason IS NULL",
127 + "UPDATE tier_state SET partial_reason = ?
128 + WHERE app = ? AND tier = ? AND partial_reason IS NULL",
122 129 )
123 130 .bind(&reason)
131 + .bind(&app)
124 132 .bind(&tier)
125 133 .execute(pool)
126 134 .await?;
127 135 if res.rows_affected() > 0 {
128 - tracing::error!(tier = %tier, %reason, "startup reconcile: interrupted/unrecorded deploy on tier");
136 + tracing::error!(%app, tier = %tier, %reason, "startup reconcile: interrupted/unrecorded deploy on tier");
129 137 flagged += 1;
130 138 }
131 139 }
@@ -139,11 +147,12 @@
139 147
140 148 /// The most recent deploy on `tier`, any outcome. Rows are ordered by `id`
141 149 /// (monotonic autoincrement), so this is the last one regardless of clock skew.
142 - async fn latest_deploy(pool: &SqlitePool, tier: &str) -> Result<Option<LastDeploy>> {
150 + async fn latest_deploy(pool: &SqlitePool, app: &AppId, tier: &str) -> Result<Option<LastDeploy>> {
143 151 let row = sqlx::query(
144 152 "SELECT version, build_id, outcome, started_at, finished_at FROM deploys
145 - WHERE tier = ? ORDER BY id DESC LIMIT 1",
153 + WHERE app = ? AND tier = ? ORDER BY id DESC LIMIT 1",
146 154 )
155 + .bind(app)
147 156 .bind(tier)
148 157 .fetch_optional(pool)
149 158 .await?;
@@ -160,6 +169,9 @@
160 169 }
161 170
162 171 /// Mark every `in_progress` deploy row terminal-failed with an interruption note,
172 + /// across every product: the daemon that was interrupted was running all of
173 + /// them, so an unscoped sweep is the accurate one here.
174 + ///
163 175 /// mirroring [`crate::runs::recover_orphaned_running`] for build runs. Returns the
164 176 /// number of rows settled.
165 177 async fn settle_orphaned_in_progress(pool: &SqlitePool) -> Result<u64> {
@@ -15,7 +15,7 @@
15 15 //! build-step compile error, the first red gate, or the task-level catch for
16 16 //! pre-build bails. Later writes are silent no-ops.
17 17
18 - use crate::domain::{RunId, Version};
18 + use crate::domain::{AppId, RunId, Version};
19 19 use anyhow::Result;
20 20 use chrono::Utc;
21 21 use serde::Serialize;
@@ -42,12 +42,13 @@
42 42 }
43 43 }
44 44
45 - /// Insert a fresh `building` run for `sha` and return its id.
46 - pub async fn create(pool: &SqlitePool, sha: &str) -> Result<RunId> {
45 + /// Insert a fresh `building` run for `app` at `sha` and return its id.
46 + pub async fn create(pool: &SqlitePool, app: &AppId, sha: &str) -> Result<RunId> {
47 47 let id: i64 = sqlx::query_scalar(
48 - "INSERT INTO build_runs (sha, phase, result, started_at)
49 - VALUES (?, 'queued', 'building', ?) RETURNING id",
48 + "INSERT INTO build_runs (app, sha, phase, result, started_at)
49 + VALUES (?, ?, 'queued', 'building', ?) RETURNING id",
50 50 )
51 + .bind(app)
51 52 .bind(sha)
52 53 .bind(Utc::now().to_rfc3339())
53 54 .fetch_one(pool)
@@ -99,6 +100,7 @@
99 100 /// 009 / [`crate::reconcile`]).
100 101 pub async fn advance_tier(
101 102 pool: &SqlitePool,
103 + app: &AppId,
102 104 tier: &str,
103 105 version: &Version,
104 106 build_id: Option<i64>,
@@ -112,12 +114,13 @@
112 114 current_build_id = ?,
113 115 burn_in_started_at = ?,
114 116 advanced_at = ?
115 - WHERE tier = ?",
117 + WHERE app = ? AND tier = ?",
116 118 )
117 119 .bind(version)
118 120 .bind(build_id)
119 121 .bind(&now)
120 122 .bind(&now)
123 + .bind(app)
121 124 .bind(tier)
122 125 .execute(pool)
123 126 .await?;
@@ -253,7 +256,7 @@
253 256 /// Load a run plus its host-tier gate statuses. `None` if the id is unknown.
254 257 pub async fn get(pool: &SqlitePool, run_id: RunId) -> Result<Option<RunView>> {
255 258 let Some(row) = sqlx::query(
256 - "SELECT id, sha, version, phase, result, started_at, finished_at, failure_summary
259 + "SELECT id, app, sha, version, phase, result, started_at, finished_at, failure_summary
257 260 FROM build_runs WHERE id = ?",
258 261 )
259 262 .bind(run_id.0)
@@ -264,17 +267,24 @@
264 267 };
265 268
266 269 let version: Option<String> = row.get("version");
267 - // Gates are keyed by (tier, version); a build run drives the `host` tier.
270 + // The run's own app, read from its row rather than passed in: a run id is
271 + // unique across products, and the row is the authority on which product it
272 + // belongs to. Asking the caller would let a lookup for one product's run
273 + // return another's gates.
274 + let app: String = row.get("app");
275 + // Gates are keyed by (app, tier, version); a build run drives the `host` tier.
268 276 // Latest row per gate_kind, matching `/state`'s per-tier query shape.
269 277 let gates: Vec<RunGateView> = if let Some(ver) = version.as_deref() {
270 278 sqlx::query(
271 279 "SELECT gate_kind, status, log_ref
272 280 FROM gate_runs g
273 - WHERE tier = 'host' AND version = ?1
281 + WHERE app = ?1 AND tier = 'host' AND version = ?2
274 282 AND id = (SELECT MAX(id) FROM gate_runs
275 - WHERE tier = 'host' AND version = ?1 AND gate_kind = g.gate_kind)
283 + WHERE app = ?1 AND tier = 'host' AND version = ?2
284 + AND gate_kind = g.gate_kind)
276 285 ORDER BY gate_kind",
277 286 )
287 + .bind(&app)
278 288 .bind(ver)
279 289 .fetch_all(pool)
280 290 .await?
@@ -317,12 +327,14 @@
317 327 pub elapsed_s: i64,
318 328 }
319 329
320 - /// The most recent build run, for `/state`. `None` until the first `/rebuild`.
321 - pub async fn latest_summary(pool: &SqlitePool) -> Result<Option<BuildSummary>> {
330 + /// The most recent build run for `app`, for `/state`. `None` until that
331 + /// product's first `/rebuild`.
332 + pub async fn latest_summary(pool: &SqlitePool, app: &AppId) -> Result<Option<BuildSummary>> {
322 333 let Some(row) = sqlx::query(
323 334 "SELECT id, sha, version, phase, result, failure_summary, started_at, finished_at
324 - FROM build_runs ORDER BY id DESC LIMIT 1",
335 + FROM build_runs WHERE app = ? ORDER BY id DESC LIMIT 1",
325 336 )
337 + .bind(app)
326 338 .fetch_optional(pool)
327 339 .await?
328 340 else {
@@ -359,12 +371,17 @@
359 371 /// any — used by the build pipeline to populate `failure_summary` when
360 372 /// `run_all` reports a red pipeline. Reads the typed `outcome_json` so the
361 373 /// stored headline matches what the TUI renders.
362 - pub async fn first_failed_gate_summary(pool: &SqlitePool, version: &Version) -> Option<String> {
374 + pub async fn first_failed_gate_summary(
375 + pool: &SqlitePool,
376 + app: &AppId,
377 + version: &Version,
378 + ) -> Option<String> {
363 379 let row = sqlx::query(
364 380 "SELECT gate_kind, outcome_json FROM gate_runs
365 - WHERE tier = 'host' AND version = ? AND status = 'failed'
381 + WHERE app = ? AND tier = 'host' AND version = ? AND status = 'failed'
366 382 ORDER BY id ASC LIMIT 1",
367 383 )
384 + .bind(app)
368 385 .bind(version.to_string())
369 386 .fetch_optional(pool)
370 387 .await
@@ -399,10 +416,77 @@
399 416 pool
400 417 }
401 418
419 + /// Two products do not see each other's builds or tier state.
420 + ///
421 + /// The whole point of the app dimension, in one test. Before it, every one
422 + /// of these reads answered from a single global pile: pom's `/state` would
423 + /// report MNW's latest build, and advancing pom's `host` tier would move
424 + /// MNW's — silently, because a shared row looks exactly like a correct one.
425 + #[tokio::test]
426 + async fn one_apps_state_is_invisible_to_another() {
427 + let pool = pool().await;
428 + let mnw = AppId::new("mnw");
429 + let pom = AppId::new("pom");
430 +
431 + // Each product needs its own tier and version rows to advance against.
432 + for app in [&mnw, &pom] {
433 + sqlx::query("INSERT INTO tiers (app, name, ord) VALUES (?, 'host', 0)")
434 + .bind(app)
435 + .execute(&pool)
436 + .await
437 + .unwrap();
438 + sqlx::query("INSERT INTO tier_state (app, tier) VALUES (?, 'host')")
439 + .bind(app)
440 + .execute(&pool)
441 + .await
442 + .unwrap();
443 + sqlx::query(
444 + "INSERT INTO versions (app, version, git_sha, built_at, artifact_path)
445 + VALUES (?, '1.0.0', 'sha', '2026-08-06T00:00:00Z', '/r')",
446 + )
447 + .bind(app)
448 + .execute(&pool)
449 + .await
450 + .unwrap();
451 + }
452 +
453 + let mnw_run = create(&pool, &mnw, "aaaaaaa").await.unwrap();
454 + let pom_run = create(&pool, &pom, "bbbbbbb").await.unwrap();
455 +
456 + // Latest build is per product, not "whichever ran last".
457 + assert_eq!(
458 + latest_summary(&pool, &mnw).await.unwrap().unwrap().sha,
459 + "aaaaaaa"
460 + );
461 + assert_eq!(
462 + latest_summary(&pool, &pom).await.unwrap().unwrap().sha,
463 + "bbbbbbb",
464 + "pom's newest build is pom's, even though mnw's is older"
465 + );
466 +
467 + // Advancing one product's `host` tier leaves the other's alone.
468 + let v = Version::parse("1.0.0").unwrap();
469 + advance_tier(&pool, &pom, "host", &v, Some(pom_run.0))
470 + .await
471 + .unwrap();
472 + let mnw_current: Option<String> =
473 + sqlx::query_scalar("SELECT current_version FROM tier_state WHERE app = 'mnw'")
474 + .fetch_one(&pool)
475 + .await
476 + .unwrap();
477 + assert_eq!(
478 + mnw_current, None,
479 + "advancing pom's host tier must not advance mnw's"
480 + );
481 +
482 + // And a run resolves its gates through its own product.
483 + assert_eq!(get(&pool, mnw_run).await.unwrap().unwrap().sha, "aaaaaaa");
484 + }
485 +
402 486 #[tokio::test]
403 487 async fn create_then_get_roundtrips_building() {
404 488 let pool = pool().await;
405 - let id = create(&pool, "abc1234").await.unwrap();
489 + let id = create(&pool, &AppId::default(), "abc1234").await.unwrap();
406 490 let v = get(&pool, id).await.unwrap().expect("run exists");
407 491 assert_eq!(v.sha, "abc1234");
408 492 assert_eq!(v.result, "building");
@@ -417,9 +501,9 @@
417 501 let pool = pool().await;
418 502 // Two in-flight runs (as if the daemon died mid-build) + one already
419 503 // settled, which must be left untouched.
420 - let run_a = create(&pool, "aaaaaaa").await.unwrap();
421 - let run_b = create(&pool, "bbbbbbb").await.unwrap();
422 - let run_c = create(&pool, "ccccccc").await.unwrap();
504 + let run_a = create(&pool, &AppId::default(), "aaaaaaa").await.unwrap();
505 + let run_b = create(&pool, &AppId::default(), "bbbbbbb").await.unwrap();
506 + let run_c = create(&pool, &AppId::default(), "ccccccc").await.unwrap();
423 507 mark_passed(&pool, run_c).await.unwrap();
424 508
425 509 let reconciled = recover_orphaned_running(&pool).await.unwrap();
@@ -445,7 +529,7 @@
445 529 #[tokio::test]
446 530 async fn phase_and_version_advance_then_pass() {
447 531 let pool = pool().await;
448 - let id = create(&pool, "abc1234").await.unwrap();
532 + let id = create(&pool, &AppId::default(), "abc1234").await.unwrap();
449 533 set_phase(&pool, id, Phase::Compiling).await.unwrap();
450 534 let ver: Version = "0.10.2".parse().unwrap();
451 535 set_version(&pool, id, &ver).await.unwrap();
@@ -461,7 +545,7 @@
461 545 #[tokio::test]
462 546 async fn first_terminal_write_wins() {
463 547 let pool = pool().await;
464 - let id = create(&pool, "abc1234").await.unwrap();
548 + let id = create(&pool, &AppId::default(), "abc1234").await.unwrap();
465 549 mark_failed(&pool, id, "error[E0063]: missing field user_pages_host")
466 550 .await
467 551 .unwrap();
@@ -482,7 +566,7 @@
482 566 #[tokio::test]
483 567 async fn phase_write_after_terminal_is_noop() {
484 568 let pool = pool().await;
485 - let id = create(&pool, "abc1234").await.unwrap();
569 + let id = create(&pool, &AppId::default(), "abc1234").await.unwrap();
486 570 mark_passed(&pool, id).await.unwrap();
487 571 set_phase(&pool, id, Phase::Gating).await.unwrap();
488 572 let v = get(&pool, id).await.unwrap().unwrap();
@@ -504,11 +588,19 @@
504 588 #[tokio::test]
505 589 async fn latest_summary_reports_most_recent_run() {
506 590 let pool = pool().await;
507 - assert!(latest_summary(&pool).await.unwrap().is_none());
508 - let _old = create(&pool, "old1234").await.unwrap();
509 - let new = create(&pool, "new5678").await.unwrap();
591 + assert!(
592 + latest_summary(&pool, &AppId::default())
593 + .await
594 + .unwrap()
595 + .is_none()
596 + );
597 + let _old = create(&pool, &AppId::default(), "old1234").await.unwrap();
598 + let new = create(&pool, &AppId::default(), "new5678").await.unwrap();
510 599 set_phase(&pool, new, Phase::Compiling).await.unwrap();
511 - let sum = latest_summary(&pool).await.unwrap().expect("a run exists");
600 + let sum = latest_summary(&pool, &AppId::default())
601 + .await
602 + .unwrap()
603 + .expect("a run exists");
512 604 assert_eq!(sum.run_id, new.0);
513 605 assert_eq!(sum.sha, "new5678");
514 606 assert_eq!(sum.phase, "compiling");
@@ -524,7 +616,7 @@
524 616 #[tokio::test]
525 617 async fn failure_summary_is_bounded() {
526 618 let pool = pool().await;
527 - let id = create(&pool, "abc1234").await.unwrap();
619 + let id = create(&pool, &AppId::default(), "abc1234").await.unwrap();
528 620 mark_failed(&pool, id, &"x".repeat(5_000)).await.unwrap();
529 621 let v = get(&pool, id).await.unwrap().unwrap();
530 622 assert!(v.failure_summary.unwrap().len() <= 600);
@@ -151,9 +151,11 @@
151 151 ts.current_version, ts.previous_version, ts.burn_in_started_at,
152 152 ts.partial_reason
153 153 FROM tiers t
154 - LEFT JOIN tier_state ts ON ts.tier = t.name
154 + LEFT JOIN tier_state ts ON ts.app = t.app AND ts.tier = t.name
155 + WHERE t.app = ?
155 156 ORDER BY t.ord",
156 157 )
158 + .bind(&s.cfg.id)
157 159 .fetch_all(&s.pool)
158 160 .await?;
159 161
@@ -163,7 +165,8 @@
163 165 let current_version: Option<String> = r.get("current_version");
164 166
165 167 let nodes: Vec<String> =
166 - sqlx::query_scalar("SELECT name FROM nodes WHERE tier = ? ORDER BY name")
168 + sqlx::query_scalar("SELECT name FROM nodes WHERE app = ? AND tier = ? ORDER BY name")
169 + .bind(&s.cfg.id)
167 170 .bind(&name)
168 171 .fetch_all(&s.pool)
169 172 .await?;
@@ -177,9 +180,10 @@
177 180 current_version.clone()
178 181 } else {
179 182 sqlx::query_scalar(
180 - "SELECT version FROM gate_runs WHERE tier = ?
183 + "SELECT version FROM gate_runs WHERE app = ? AND tier = ?
181 184 ORDER BY id DESC LIMIT 1",
182 185 )
186 + .bind(&s.cfg.id)
183 187 .bind(&name)
184 188 .fetch_optional(&s.pool)
185 189 .await?
@@ -190,11 +194,13 @@
190 194 sqlx::query(
191 195 "SELECT gate_kind, finished_at, status, outcome_json, log_ref
192 196 FROM gate_runs g
193 - WHERE tier = ?1 AND version = ?2
197 + WHERE app = ?1 AND tier = ?2 AND version = ?3
194 198 AND id = (SELECT MAX(id) FROM gate_runs
195 - WHERE tier = ?1 AND version = ?2 AND gate_kind = g.gate_kind)
199 + WHERE app = ?1 AND tier = ?2 AND version = ?3
200 + AND gate_kind = g.gate_kind)
196 201 ORDER BY gate_kind",
197 202 )
203 + .bind(&s.cfg.id)
198 204 .bind(&name)
199 205 .bind(ver)
200 206 .fetch_all(&s.pool)
@@ -228,7 +234,7 @@
228 234 });
229 235 }
230 236
231 - let build = crate::runs::latest_summary(&s.pool).await?;
237 + let build = crate::runs::latest_summary(&s.pool, &s.cfg.id).await?;
232 238 Ok(StateView {
233 239 sandod_version: env!("CARGO_PKG_VERSION"),
234 240 tiers,
@@ -293,12 +299,14 @@
293 299 .find(|t| t.name == tier)
294 300 .ok_or(crate::error::Error::NotFound)?;
295 301
296 - let row: Option<(Option<String>, Option<String>)> =
297 - sqlx::query_as("SELECT current_version, previous_version FROM tier_state WHERE tier = ?")
298 - .bind(&tier)
299 - .fetch_optional(&s.pool)
300 - .await
301 - .map_err(crate::error::Error::Db)?;
302 + let row: Option<(Option<String>, Option<String>)> = sqlx::query_as(
303 + "SELECT current_version, previous_version FROM tier_state WHERE app = ? AND tier = ?",
304 + )
305 + .bind(&s.cfg.id)
306 + .bind(&tier)
307 + .fetch_optional(&s.pool)
308 + .await
309 + .map_err(crate::error::Error::Db)?;
302 310 let (Some(current_str), Some(previous_str)) = row.unwrap_or((None, None)) else {
303 311 return Err(crate::error::Error::GateBlocked(
304 312 "no previous_version to roll back to".into(),
@@ -310,7 +318,8 @@
310 318 .map_err(|e| crate::error::Error::Other(anyhow::anyhow!(e)))?;
311 319
312 320 let bin: Option<(String,)> =
313 - sqlx::query_as("SELECT artifact_path FROM versions WHERE version = ?")
321 + sqlx::query_as("SELECT artifact_path FROM versions WHERE app = ? AND version = ?")
322 + .bind(&s.cfg.id)
314 323 .bind(&previous)
315 324 .fetch_optional(&s.pool)
316 325 .await
@@ -378,10 +387,11 @@
378 387 "UPDATE tier_state
379 388 SET current_version = ?, previous_version = NULL,
380 389 burn_in_started_at = NULL, advanced_at = ?
381 - WHERE tier = ?",
390 + WHERE app = ? AND tier = ?",
382 391 )
383 392 .bind(&previous)
384 393 .bind(&now)
394 + .bind(&s.cfg.id)
385 395 .bind(&tier)
386 396 .execute(&s.pool)
387 397 .await
@@ -455,7 +465,7 @@
455 465 // One pollable resource per triggered build. Created before the spawn so
456 466 // the run id is in the response even if the task is aborted milliseconds
457 467 // later by a still-newer /rebuild.
458 - let run_id = crate::runs::create(&s.pool, sha.as_str())
468 + let run_id = crate::runs::create(&s.pool, &s.cfg.id, sha.as_str())
459 469 .await
460 470 .map_err(crate::error::Error::Other)?;
461 471
@@ -665,7 +675,8 @@
665 675 .ok_or(crate::error::Error::NotFound)?;
666 676
667 677 let version_str: Option<String> =
668 - sqlx::query_scalar("SELECT current_version FROM tier_state WHERE tier = ?")
678 + sqlx::query_scalar("SELECT current_version FROM tier_state WHERE app = ? AND tier = ?")
679 + .bind(&s.cfg.id)
669 680 .bind(&target.name)
670 681 .fetch_optional(&s.pool)
671 682 .await
@@ -687,9 +698,10 @@
687 698 let outcome_json = serde_json::to_string(&outcome)
688 699 .map_err(|e| crate::error::Error::Other(anyhow::anyhow!(e)))?;
689 700 sqlx::query(
690 - "INSERT INTO gate_runs (version, tier, gate_kind, started_at, finished_at, status, outcome_json)
691 - VALUES (?, ?, 'manual_confirm', ?, ?, 'passed', ?)",
701 + "INSERT INTO gate_runs (app, version, tier, gate_kind, started_at, finished_at, status, outcome_json)
702 + VALUES (?, ?, ?, 'manual_confirm', ?, ?, 'passed', ?)",
692 703 )
704 + .bind(&s.cfg.id)
693 705 .bind(&version).bind(&target.name).bind(&now).bind(&now).bind(&outcome_json)
694 706 .execute(&s.pool).await.map_err(crate::error::Error::Db)?;
695 707
@@ -1053,9 +1065,17 @@
1053 1065 // A tier that configures no gates has nothing to satisfy.
1054 1066 let pool = fresh_pool().await;
1055 1067 seed(&pool, "host", "0.8.12").await;
1056 - let pending = unsatisfied_gates(&pool, &tid("host"), &[], "0.8.12", None, false)
1057 - .await
1058 - .unwrap();
1068 + let pending = unsatisfied_gates(
1069 + &pool,
1070 + &crate::domain::AppId::default(),
1071 + &tid("host"),
1072 + &[],
1073 + "0.8.12",
1074 + None,
1075 + false,
1076 + )
1077 + .await
1078 + .unwrap();
1059 1079 assert_eq!(pending, Vec::<String>::new());
1060 1080 }
1061 1081
@@ -1067,10 +1087,17 @@
1067 1087 // straight through to prod.
1068 1088 let pool = fresh_pool().await;
1069 1089 seed(&pool, "a", "0.8.12").await;
1070 - let pending =
1071 - unsatisfied_gates(&pool, &tid("a"), &[Gate::BootSmoke], "0.8.12", None, false)
1072 - .await
1073 - .unwrap();
1090 + let pending = unsatisfied_gates(
1091 + &pool,
1092 + &crate::domain::AppId::default(),
1093 + &tid("a"),
1094 + &[Gate::BootSmoke],
1095 + "0.8.12",
1096 + None,
1097 + false,
1098 + )
1099 + .await
1100 + .unwrap();
1074 1101 assert_eq!(pending, vec!["boot_smoke".to_string()]);
1075 1102 }
1076 1103
@@ -1082,6 +1109,7 @@
1082 1109 insert_gate(&pool, "host", "0.8.12", "boot_smoke", 1).await;
1083 1110 let pending = unsatisfied_gates(
1084 1111 &pool,
1112 + &crate::domain::AppId::default(),
1085 1113 &tid("host"),
1086 1114 &[Gate::CargoTest, Gate::BootSmoke],
1087 1115 "0.8.12",
@@ -1103,6 +1131,7 @@
1103 1131 insert_gate(&pool, "host", "0.8.12", "cargo_test", 1).await;
1104 1132 let pending = unsatisfied_gates(
1105 1133 &pool,
1134 + &crate::domain::AppId::default(),
1106 1135 &tid("host"),
1107 1136 &[Gate::CargoTest],
1108 1137 "0.8.12",
@@ -1151,6 +1180,7 @@
1151 1180 insert_confirm(&pool, "a", "0.8.12", landed - chrono::Duration::hours(1)).await;
1152 1181 let pending = unsatisfied_gates(
1153 1182 &pool,
1183 + &crate::domain::AppId::default(),
1154 1184 &tid("a"),
1155 1185 &[Gate::ManualConfirm],
1156 1186 "0.8.12",
@@ -1169,6 +1199,7 @@
1169 1199 insert_confirm(&pool, "a", "0.8.12", landed + chrono::Duration::minutes(5)).await;
1170 1200 let pending = unsatisfied_gates(
1171 1201 &pool,
1202 + &crate::domain::AppId::default(),
1172 1203 &tid("a"),
1173 1204 &[Gate::ManualConfirm],
1174 1205 "0.8.12",
@@ -1189,6 +1220,7 @@
1189 1220 insert_confirm(&pool, "a", "0.8.12", chrono::Utc::now()).await;
1190 1221 let pending = unsatisfied_gates(
1191 1222 &pool,
1223 + &crate::domain::AppId::default(),
1192 1224 &tid("a"),
1193 1225 &[Gate::ManualConfirm],
1194 1226 "0.8.12",
@@ -1215,17 +1247,33 @@
1215 1247 insert_gate(&pool, "a", "0.8.12", "cargo_test", 0).await;
1216 1248 let gates = [Gate::BurnIn { hours: 48 }, Gate::CargoTest];
1217 1249
1218 - let normal = unsatisfied_gates(&pool, &tid("a"), &gates, "0.8.12", None, false)
1219 - .await
1220 - .unwrap();
1250 + let normal = unsatisfied_gates(
1251 + &pool,
1252 + &crate::domain::AppId::default(),
1253 + &tid("a"),
1254 + &gates,
1255 + "0.8.12",
1256 + None,
1257 + false,
1258 + )
1259 + .await
1260 + .unwrap();
1221 1261 assert_eq!(
1222 1262 normal,
1223 1263 vec!["burn_in".to_string(), "cargo_test".to_string()]
1224 1264 );
1225 1265
1226 - let with_hotfix = unsatisfied_gates(&pool, &tid("a"), &gates, "0.8.12", None, true)
1227 - .await
1228 - .unwrap();
1266 + let with_hotfix = unsatisfied_gates(
1267 + &pool,
1268 + &crate::domain::AppId::default(),
1269 + &tid("a"),
1270 + &gates,
1271 + "0.8.12",
1272 + None,
1273 + true,
1274 + )
1275 + .await
1276 + .unwrap();
1229 1277 assert_eq!(with_hotfix, vec!["cargo_test".to_string()]);
1230 1278 }
1231 1279
@@ -1242,6 +1290,7 @@
1242 1290 .unwrap();
1243 1291 let pending = unsatisfied_gates(
1244 1292 &pool,
1293 + &crate::domain::AppId::default(),
1245 1294 &tid("a"),
1246 1295 &[Gate::BurnIn { hours: 48 }],
1247 1296 "0.8.12",
@@ -1267,6 +1316,7 @@
1267 1316
1268 1317 let pending = unsatisfied_gates(
1269 1318 &pool,
1319 + &crate::domain::AppId::default(),
1270 1320 &tid("host"),
1271 1321 &[Gate::CargoTest],
1272 1322 "0.8.12",
@@ -1295,6 +1345,7 @@
1295 1345
1296 1346 let pending = unsatisfied_gates(
1297 1347 &pool,
1348 + &crate::domain::AppId::default(),
1298 1349 &tid("host"),
1299 1350 &[Gate::CargoTest],
1300 1351 "0.8.12",
@@ -1403,7 +1454,7 @@
1403 1454 async fn get_run_returns_view_with_gates() {
1404 1455 let state = test_state().await;
1405 1456 // A run that reached version 0.10.2 and ran two host gates (one red).
1406 - let run_id = crate::runs::create(&state.pool, "abc1234def")
1457 + let run_id = crate::runs::create(&state.pool, &state.cfg.id, "abc1234def")
1407 1458 .await
1408 1459 .unwrap();
1409 1460 let ver: crate::domain::Version = "0.10.2".parse().unwrap();
@@ -1441,7 +1492,7 @@
1441 1492 #[tokio::test]
1442 1493 async fn get_run_wait_returns_immediately_when_settled() {
1443 1494 let state = test_state().await;
1444 - let run_id = crate::runs::create(&state.pool, "abc1234def")
1495 + let run_id = crate::runs::create(&state.pool, &state.cfg.id, "abc1234def")
1445 1496 .await
1446 1497 .unwrap();
1447 1498 crate::runs::mark_passed(&state.pool, run_id).await.unwrap();
@@ -1465,7 +1516,7 @@
1465 1516 #[tokio::test]
1466 1517 async fn get_run_wait_returns_building_at_timeout() {
1467 1518 let state = test_state().await;
1468 - let run_id = crate::runs::create(&state.pool, "abc1234def")
1519 + let run_id = crate::runs::create(&state.pool, &state.cfg.id, "abc1234def")
1469 1520 .await
1470 1521 .unwrap();
1471 1522
@@ -1733,7 +1784,7 @@
1733 1784 // Exercise the sealed forward-advance primitive itself — the same op
1734 1785 // /promote and the host build path both call (S1), not a copy of its SQL.
1735 1786 let v = crate::domain::Version::parse("2.0.0").unwrap();
1736 - crate::runs::advance_tier(&pool, "a", &v, None)
1787 + crate::runs::advance_tier(&pool, &crate::domain::AppId::default(), "a", &v, None)
1737 1788 .await
1738 1789 .unwrap();
1739 1790
@@ -2518,7 +2569,9 @@
2518 2569 assert!(view.build.is_none());
2519 2570
2520 2571 // A failed run must surface its cause in /state, not just in /runs.
2521 - let run_id = crate::runs::create(&state.pool, "deadbeef").await.unwrap();
2572 + let run_id = crate::runs::create(&state.pool, &state.cfg.id, "deadbeef")
2573 + .await
2574 + .unwrap();
2522 2575 crate::runs::mark_failed(&state.pool, run_id, "cargo_test: 3 test(s) failed")
2523 2576 .await
2524 2577 .unwrap();
@@ -2861,6 +2914,7 @@
2861 2914 // b1's own evidence satisfies.
2862 2915 let ok = unsatisfied_gates(
2863 2916 &pool,
2917 + &crate::domain::AppId::default(),
2864 2918 &tid("a"),
2865 2919 &[Gate::CargoTest],
2866 2920 "3.0.0",
@@ -2875,6 +2929,7 @@
2875 2929 // is the hole — a rebuild reusing the version must not ride b1's evidence.
2876 2930 let bad = unsatisfied_gates(
2877 2931 &pool,
2932 + &crate::domain::AppId::default(),
2878 2933 &tid("a"),
2879 2934 &[Gate::CargoTest],
2880 2935 "3.0.0",
@@ -2890,9 +2945,17 @@
2890 2945 );
2891 2946
2892 2947 // Legacy (pre-identity) callers still resolve by version string.
2893 - let legacy = unsatisfied_gates(&pool, &tid("a"), &[Gate::CargoTest], "3.0.0", None, false)
2894 - .await
2895 - .unwrap();
2948 + let legacy = unsatisfied_gates(
2949 + &pool,
2950 + &crate::domain::AppId::default(),
2951 + &tid("a"),
2952 + &[Gate::CargoTest],
2953 + "3.0.0",
2954 + None,
2955 + false,
2956 + )
2957 + .await
2958 + .unwrap();
2896 2959 assert!(legacy.is_empty(), "version-keyed legacy path unchanged");
2897 2960 }
2898 2961
@@ -49,8 +49,9 @@
49 49 "SELECT ts.current_build_id, br.version, br.staged_path
50 50 FROM tier_state ts
51 51 LEFT JOIN build_runs br ON br.id = ts.current_build_id
52 - WHERE ts.tier = ?",
52 + WHERE ts.app = ? AND ts.tier = ?",
53 53 )
54 + .bind(&s.cfg.id)
54 55 .bind(&source.name)
55 56 .fetch_optional(&s.pool)
56 57 .await
@@ -92,8 +93,9 @@
92 93 let version_str = match body.version.clone() {
93 94 Some(v) => v,
94 95 None => sqlx::query_scalar::<_, Option<String>>(
95 - "SELECT current_version FROM tier_state WHERE tier = ?",
96 + "SELECT current_version FROM tier_state WHERE app = ? AND tier = ?",
96 97 )
98 + .bind(&s.cfg.id)
97 99 .bind(&source.name)
98 100 .fetch_optional(&s.pool)
99 101 .await
@@ -107,7 +109,8 @@
107 109 })?,
108 110 };
109 111 let bin: Option<(String,)> =
110 - sqlx::query_as("SELECT artifact_path FROM versions WHERE version = ?")
112 + sqlx::query_as("SELECT artifact_path FROM versions WHERE app = ? AND version = ?")
113 + .bind(&s.cfg.id)
111 114 .bind(&version_str)
112 115 .fetch_optional(&s.pool)
113 116 .await
@@ -149,6 +152,7 @@
149 152 }
150 153 let pending = unsatisfied_gates(
151 154 &s.pool,
155 + &s.cfg.id,
152 156 &source.name,
153 157 &effective_gates,
154 158 &version_str,
@@ -168,7 +172,8 @@
168 172 // The version this tier was running before this promote — the rollback
169 173 // target if a canary node fails partway through a multi-node rollout.
170 174 let prev_version: Option<String> =
171 - sqlx::query_scalar("SELECT current_version FROM tier_state WHERE tier = ?")
175 + sqlx::query_scalar("SELECT current_version FROM tier_state WHERE app = ? AND tier = ?")
176 + .bind(&s.cfg.id)
172 177 .bind(&target.name)
173 178 .fetch_optional(&s.pool)
174 179 .await
@@ -204,9 +209,10 @@
204 209 // any row still `in_progress` at startup is an orphan the reconcile settles
205 210 // and flags ([`crate::reconcile`]).
206 211 let deploy_id: i64 = sqlx::query_scalar(
207 - "INSERT INTO deploys (version, tier, node, started_at, outcome, hotfix, reset_burn_in, build_id)
208 - VALUES (?, ?, ?, ?, 'in_progress', ?, ?, ?) RETURNING id",
212 + "INSERT INTO deploys (app, version, tier, node, started_at, outcome, hotfix, reset_burn_in, build_id)
213 + VALUES (?, ?, ?, ?, ?, 'in_progress', ?, ?, ?) RETURNING id",
209 214 )
215 + .bind(&s.cfg.id)
210 216 .bind(&version).bind(&target.name).bind(&node.name)
211 217 .bind(&started)
212 218 .bind(body.hotfix as i64).bind(body.reset_burn_in as i64)
@@ -456,12 +462,13 @@
456 462 // serialized against rollback and the host build path's advance.
457 463 // reset_burn_in on the *source* tier nulls its clock only when the operator
458 464 // explicitly asked.
459 - crate::runs::advance_tier(&s.pool, target.name.as_str(), &version, build_id)
465 + crate::runs::advance_tier(&s.pool, &s.cfg.id, target.name.as_str(), &version, build_id)
460 466 .await
461 467 .map_err(crate::error::Error::Db)?;
462 468
463 469 if body.reset_burn_in {
464 - sqlx::query("UPDATE tier_state SET burn_in_started_at = NULL WHERE tier = ?")
470 + sqlx::query("UPDATE tier_state SET burn_in_started_at = NULL WHERE app = ? AND tier = ?")
471 + .bind(&s.cfg.id)
465 472 .bind(&source.name)
466 473 .execute(&s.pool)
467 474 .await
@@ -553,7 +560,8 @@
553 560 prev_version: &str,
554 561 ) -> RollbackReport {
555 562 let bin: Option<(String,)> =
556 - match sqlx::query_as("SELECT artifact_path FROM versions WHERE version = ?")
563 + match sqlx::query_as("SELECT artifact_path FROM versions WHERE app = ? AND version = ?")
564 + .bind(&s.cfg.id)
557 565 .bind(prev_version)
558 566 .fetch_optional(&s.pool)
559 567 .await
@@ -647,11 +655,13 @@
647 655 /// the flag is logged, never propagated — the caller is already on an error path
648 656 /// and the worse outcome is to mask the original failure with a bookkeeping one.
649 657 pub(super) async fn set_partial(s: &AppState, tier: &crate::domain::TierId, reason: &str) {
650 - if let Err(e) = sqlx::query("UPDATE tier_state SET partial_reason = ? WHERE tier = ?")
651 - .bind(reason)
652 - .bind(tier)
653 - .execute(&s.pool)
654 - .await
658 + if let Err(e) =
659 + sqlx::query("UPDATE tier_state SET partial_reason = ? WHERE app = ? AND tier = ?")
660 + .bind(reason)
661 + .bind(&s.cfg.id)
662 + .bind(tier)
663 + .execute(&s.pool)
664 + .await
655 665 {
656 666 tracing::error!(tier = %tier, reason, error = %e,
657 667 "failed to record tier partial state; the fleet may be inconsistent without a /state flag");
@@ -663,10 +673,12 @@
663 673 /// visible nuisance, not a safety regression (the operator sees a partial marker
664 674 /// on a tier that is actually fine, and re-checks).
665 675 pub(super) async fn clear_partial(s: &AppState, tier: &crate::domain::TierId) {
666 - if let Err(e) = sqlx::query("UPDATE tier_state SET partial_reason = NULL WHERE tier = ?")
667 - .bind(tier)
668 - .execute(&s.pool)
669 - .await
676 + if let Err(e) =
677 + sqlx::query("UPDATE tier_state SET partial_reason = NULL WHERE app = ? AND tier = ?")
678 + .bind(&s.cfg.id)
679 + .bind(tier)
680 + .execute(&s.pool)
681 + .await
670 682 {
671 683 tracing::warn!(tier = %tier, error = %e, "failed to clear tier partial flag");
672 684 }
@@ -697,6 +709,7 @@
697 709 /// advance, so it belongs to the build now current on the tier.
698 710 pub(super) async fn unsatisfied_gates(
699 711 pool: &sqlx::SqlitePool,
712 + app: &crate::domain::AppId,
700 713 tier: &crate::domain::TierId,
701 714 gates: &[crate::topology::Gate],
702 715 version: &str,
@@ -712,7 +725,7 @@
712 725 if hotfix {
713 726 continue;
714 727 }
715 - let ok = crate::gates::burn_in_satisfied(pool, tier, *hours)
728 + let ok = crate::gates::burn_in_satisfied(pool, app, tier, *hours)
716 729 .await
717 730 .map_err(crate::error::Error::Other)?;
718 731 if !ok {
@@ -729,22 +742,26 @@
729 742 // No baseline (NULL) => fail closed: require a fresh confirm.
730 743 let confirmed_at: Option<String> = sqlx::query_scalar(
731 744 "SELECT finished_at FROM gate_runs
732 - WHERE tier = ?1 AND version = ?2 AND gate_kind = 'manual_confirm' AND status = 'passed'
745 + WHERE app = ?1 AND tier = ?2 AND version = ?3
746 + AND gate_kind = 'manual_confirm' AND status = 'passed'
733 747 ORDER BY id DESC LIMIT 1",
734 748 )
749 + .bind(app)
735 750 .bind(tier.as_str())
736 751 .bind(version)
737 752 .fetch_optional(pool)
738 753 .await
739 754 .map_err(crate::error::Error::Db)?
740 755 .flatten();
741 - let landed_at: Option<String> =
742 - sqlx::query_scalar("SELECT burn_in_started_at FROM tier_state WHERE tier = ?")
743 - .bind(tier.as_str())
744 - .fetch_optional(pool)
745 - .await
746 - .map_err(crate::error::Error::Db)?
747 - .flatten();
756 + let landed_at: Option<String> = sqlx::query_scalar(
757 + "SELECT burn_in_started_at FROM tier_state WHERE app = ? AND tier = ?",
758 + )
759 + .bind(app)
760 + .bind(tier.as_str())
761 + .fetch_optional(pool)
762 + .await
763 + .map_err(crate::error::Error::Db)?
764 + .flatten();
748 765 let fresh = match (confirmed_at, landed_at) {
749 766 (Some(c), Some(l)) => {
750 767 match (
@@ -784,17 +801,19 @@
784 801 let status: Option<String> = match build_id {
785 802 Some(bid) => sqlx::query_scalar(
786 803 "SELECT status FROM gate_runs
787 - WHERE tier = ?1 AND build_id = ?2 AND gate_kind = ?3
804 + WHERE app = ?1 AND tier = ?2 AND build_id = ?3 AND gate_kind = ?4
788 805 ORDER BY id DESC LIMIT 1",
789 806 )
807 + .bind(app)
790 808 .bind(tier.as_str())
791 809 .bind(bid)
792 810 .bind(kind.as_str()),
793 811 None => sqlx::query_scalar(
794 812 "SELECT status FROM gate_runs
795 - WHERE tier = ?1 AND version = ?2 AND gate_kind = ?3
813 + WHERE app = ?1 AND tier = ?2 AND version = ?3 AND gate_kind = ?4
796 814 ORDER BY id DESC LIMIT 1",
797 815 )
816 + .bind(app)
798 817 .bind(tier.as_str())
799 818 .bind(version)
800 819 .bind(kind.as_str()),