Idunn: rollback drift after freezing, cross-binding refusal, and docs

Tests: a previous release that drifts after the rollback was frozen ends
FailedBeforeFencing at the Starting-time verify with current untouched (fails if
that verify is removed); rollback_source refuses a previous release deployed
under another release root or current link. Docs: CommandKind::Rollback needs no
schema change but an Idunn at 9f15cee or earlier cannot decode it (forward-only);
flipped_at ordering and the observed-link check; a rollback cannot be cancelled.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Eureka 2026-11-13 17:03:48 +00:00
commit 7ba27c362e
2 changed files with 59 additions and 0 deletions

View file

@ -254,6 +254,14 @@ as it is at rollback time, and its sha256 is recorded in the rollback's own
flip evidence (it may differ from the original release's; the copy inside the
rolled-back-to release is replaced). Nothing prunes release directories.
`CommandKind::Rollback` is stored in the existing v5 command and transaction
records with no schema change; an Idunn at 9f15cee or earlier cannot decode
them, so this is forward-only, like v5 itself. The newest flip is ordered by
`flipped_at` wall clock, and the observed-link check makes a clock step refuse
the rollback rather than mis-flip. A rollback cannot be cancelled, like a static
deploy. A rollback is also refused when the previous release was deployed under
a different release root or current link than the newest one.
## Typed deployment state
Mutable control-plane state will be CultCache. Runtime integration must use

View file

@ -7099,6 +7099,15 @@ impl Engine {
.rev()
.find(|transaction| transaction.flip.as_ref().is_some_and(|f| f.release_id == previous))
.with_context(|| format!("no record of a transaction that flipped the previous release {previous}"))?;
// The prior plan is reused verbatim, but the link was observed through
// the newest plan's binding: they must be the same release root and link.
let prior_binding = required(&prior.plan, "previous release plan")?.parsed_inputs()?.1;
let prior_workload = prior_binding.workload.static_release()?;
ensure!(
prior_workload.release_root == workload.release_root
&& prior_workload.current_link == workload.current_link,
"the previous release was deployed under a different release root or current link than the newest; rollback does not cross bindings"
);
self.static_release.verify_installed(
workload,
required(&prior.sealed_release, "previous sealed release")?,
@ -26742,5 +26751,47 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide
assert!(command("rollback-1", CommandKind::Deploy).validate().is_err());
Ok(())
}
#[cfg(unix)]
#[test]
fn static_rollback_drift_after_freezing_ends_before_fencing_with_current_untouched() -> Result<()> {
let w = static_world()?;
let (first, second) = w.two_deploys()?;
let id = w.submit_rollback("rollback-1")?;
let waiting = w.run_to_brake(&id)?;
// Frozen and intact; now the previous release drifts before Starting.
std::fs::write(w.release_dir(&first).join("dist/index.html"), b"tampered")?;
w.release_brake(&waiting)?;
w.tick_until(&id, "the rollback end", |t| t.is_terminal())?;
let ended = latest_id(&w.world, &id)?;
let Some(TransactionCompletion::FailedBeforeFencing { error }) = &ended.completion else {
bail!("the drifted rollback did not fail before fencing: {ended:?}");
};
assert!(error.contains("differs from its sealed receipt"), "{error}");
assert!(ended.flip.is_none());
assert_eq!(w.current_release().as_deref(), Some(second.as_str()));
Ok(())
}
#[cfg(unix)]
#[test]
fn static_rollback_refuses_a_previous_release_deployed_under_another_binding() -> Result<()> {
let w = static_world()?;
w.two_deploys()?;
let snapshot = ControlSnapshot::read(&w.world.state_store)?;
let mut history = read_history_transactions(&w.world.state_store);
// The older transaction's plan names another release root.
let older = history
.iter_mut()
.min_by_key(|t| t.created_at_unix_millis)
.context("no history")?;
let plan = older.plan.as_mut().context("no plan")?;
let text = String::from_utf8(plan.binding_blob.clone())?;
assert!(text.contains("/releases\""));
plan.binding_blob = text.replace("/releases\"", "/releases-old\"").into_bytes();
let error = w.world.engine.rollback_source(&snapshot, &history, "service").unwrap_err();
assert!(format!("{error:#}").contains("does not cross bindings"), "{error:#}");
Ok(())
}
// END static-release tests
}