diff --git a/docs/deploy-provenance-target.md b/docs/deploy-provenance-target.md new file mode 100644 index 0000000..74015c7 --- /dev/null +++ b/docs/deploy-provenance-target.md @@ -0,0 +1,61 @@ +# Deployment provenance instead of an approval gate: target + +Status: target, Self, 2026-10-19 (campaign `idunn-deploy-provenance` in +Eureka's mind). It states the ends. The map will live in +`deploy-provenance-map.md`. + +## Why + +Operator, 2026-10-19: + +> The rule about the deployment brake was instituted by a cautious agent, but +> it doesn't actually suit how GameCult operates. The whole point of Eureka is +> to give the Mind, Persona and Self agency over its own Body. It's requiring +> my approval for every deployment that violates our principles. Yes, we +> certainly want to know which agent approved the deployment and why (this is +> the authority provenance which we might want to add to the transaction), but +> that's not the same as gating every deploy on my approval. + +On the same day a StreamPixels deploy (tx-b40c7240) waited on the brake +because only the root-held operator identity could release it. The agent +running the deploy could neither proceed nor record why it should. + +`F:\Projects\CLAUDE.md` says a deployment brake *may* gate artifact, revision, +configuration, schema, unit or authority changes. It does not say that +operator approval is the gate. + +## Invariants + +1. **No deployment waits on a per-transaction operator approval.** An agent + acting for the Mind, Persona or Self may admit a deployment of a target it + is authorized for. +2. **Every transaction carries typed authority provenance.** That is: who + approved it (agent or instance, and faculty), under which session or + ruling, and why. It is written before Fencing, it is immutable, and + `idunn status` and history show it. It is part of the transaction + document, not a log line. +3. **A brake that survives is a stop, not a gate.** The operator or an + authorized agent can halt deployment of a target, and the halt is itself + provenance-bearing. The deployment brake and the continuity lifecycle brake + stay separate (`F:\Projects\CLAUDE.md` Infrastructure). Neither gates Idunn + itself, and the deployment brake never gates continuity. +4. **Existing invariants hold.** These are `idunn-reincarnation` target r2, + `route-continuity-target.md` 1-7 and `authority-map.md`. + +## Scope + +In: + +- The deployment-brake release path in Idunn and `idunn-provision`. +- The provenance record on the transaction. +- `idunn status` and history projection of the provenance. +- The gamecult-ops runbooks that describe a per-transaction operator release + (`heimdall-discord-launch.md`, `idunn-host-raven.md`, the StreamPixels + runbook). + +Out: + +- `idunn-reincarnation`. This campaign is mapped after that campaign's cut 2 + merges, and coordinates with it rather than bundling. +- Harness permission rules on the workstation, which are the operator's. +- Heimdall's onboarding failures. diff --git a/src/control_plane.rs b/src/control_plane.rs index ad06ea9..de88449 100644 --- a/src/control_plane.rs +++ b/src/control_plane.rs @@ -1330,7 +1330,7 @@ impl TargetSupervision { fn defer_continuity(&mut self, now: u64, reason: &str) { self.continuity_deferred_until = Some(now.saturating_add(CONTINUITY_DEFERRAL_MILLIS)); - self.continuity_deferral_reason = Some(reason.to_owned()); + self.continuity_deferral_reason = Some(bounded_detail(reason)); } fn validate(&self) -> Result<()> { @@ -2015,7 +2015,7 @@ impl DeploymentTransaction { fn rejected(command: &DeploymentCommand, error: anyhow::Error, now: u64) -> Result { let mut transaction = Self::new(command, command.selector.clone(), 0, None, now)?; - let detail = truncate(&format!("{error:#}"), 2048); + let detail = bounded_detail(&format!("{error:#}")); transaction.enter_phase(DeploymentPhase::Complete, now); transaction.last_error = Some(detail.clone()); transaction.pre_fencing_abort = Some(PreFencingAbort { @@ -3221,7 +3221,7 @@ fn cancel(store_path: &Path, command_id: &str, requested_by: &str) -> Result<()> } else { next.post_fencing_abort = Some(post_fencing_abort_intent(&next, &error)); } - next.last_error = Some(error); + next.last_error = Some(bounded_detail(&error)); next.updated_at_unix_millis = now_millis()?; replace_transaction(store_path, current, &next)?; println!("{command_id} cancellation requested; Idunn is performing exact candidate cleanup"); @@ -5958,7 +5958,7 @@ impl Engine { /// record's `last_error`, which status renders. Recording is best effort: /// a store too broken to take the note is already in stderr. fn note_fault(&self, what: &str, transaction_id: &str, error: &anyhow::Error) { - let detail = truncate(&format!("{error:#}"), 2048); + let detail = bounded_detail(&format!("{error:#}")); let offered = { let mut reports = self.fault_reports.lock().expect("fault report mutex"); reports @@ -5985,6 +5985,8 @@ impl Engine { /// Write `detail` as the record's `last_error`, touching nothing else and /// not archiving: a terminal record whose archive is the fault stays put. fn record_last_error(&self, transaction_id: &str, detail: &str) -> Result<()> { + let detail = bounded_detail(detail); + let detail = detail.as_str(); let snapshot = ControlSnapshot::read(&self.options.state_store)?; let Some(stored) = snapshot .transactions @@ -6694,7 +6696,7 @@ impl Engine { current.value.target ); let mut deferred = supervision.clone(); - deferred.defer_continuity(now, &truncate(&format!("{error:#}"), 2048)); + deferred.defer_continuity(now, &bounded_detail(&format!("{error:#}"))); self.write_target_supervision( snapshot.supervision_for(¤t.value.target), &deferred, @@ -8552,7 +8554,7 @@ impl Engine { held.target ); } - let detail = waiting_for_owner(&held.target, owner_id); + let detail = bounded_detail(&waiting_for_owner(&held.target, owner_id)); if held.last_error.as_deref() == Some(detail.as_str()) && held.phase_deadline.is_none() { return Ok(()); } @@ -8588,11 +8590,12 @@ impl Engine { current: &Stored, detail: &str, ) -> Result<()> { - if current.value.last_error.as_deref() == Some(detail) { + let detail = bounded_detail(detail); + if current.value.last_error.as_deref() == Some(detail.as_str()) { return Ok(()); } let mut next = current.value.clone(); - next.last_error = Some(detail.into()); + next.last_error = Some(detail); next.updated_at_unix_millis = now_millis()?; replace_transaction(&self.options.state_store, current, &next) } @@ -9663,7 +9666,7 @@ impl Engine { current: &Stored, error: &anyhow::Error, ) -> Result<()> { - let detail = truncate(&format!("{error:#}"), 2048); + let detail = bounded_detail(&format!("{error:#}")); if current.value.last_error.as_deref() == Some(detail.as_str()) { return Ok(()); } @@ -9723,8 +9726,8 @@ impl Engine { current.value.post_fencing_abort.is_none(), "post-fencing abort intent is already durable" ); - let abort = post_fencing_abort_intent(¤t.value, &truncate(&format!("{error:#}"), 2048)); let now = now_millis()?; + let abort = post_fencing_abort_intent(¤t.value, &bounded_detail(&format!("{error:#}"))); self.persist_same_phase(current, |next| { next.post_fencing_abort = Some(abort); next.retime(now); @@ -10101,7 +10104,7 @@ fn incumbent_was_stopped_during_fencing(fencing: &FencingEvidence) -> bool { fn pre_fencing_abort_intent(transaction: &DeploymentTransaction, error: &str) -> PreFencingAbort { PreFencingAbort { - error: truncate(error, 2048), + error: bounded_detail(error), candidate_cleanup: transaction.candidate_cleanup_owed(), topology_reconciliation: transaction.abort_topology_reconciliation(), source_cleanup: if transaction.command_kind == CommandKind::Deploy { @@ -10117,7 +10120,7 @@ fn post_fencing_abort_intent( error: &str, ) -> PostFencingAbort { PostFencingAbort { - error: truncate(error, 2048), + error: bounded_detail(error), route_restoration: if transaction.route_preflight.is_some() { CleanupEvidence::Pending } else { @@ -10390,8 +10393,30 @@ fn require_detail(value: &str, label: &str) -> Result<()> { Ok(()) } -fn truncate(value: &str, length: usize) -> String { - value.chars().take(length).collect() +const DETAIL_LIMIT: usize = 2048; +const DETAIL_HEAD: usize = 512; +const DETAIL_ELISION: &str = " +...[elided]... +"; +const DETAIL_EMPTY: &str = "(no detail)"; + +/// The only producer of persisted free-text details; `require_detail` is the +/// only judge, and this output always satisfies it. NUL is dropped, empty +/// input gets a fixed placeholder, and an over-long value keeps its head and +/// its tail (where a build log's error is) cut on char boundaries. +fn bounded_detail(value: &str) -> String { + let value = value.replace('', ""); + if value.is_empty() { + return DETAIL_EMPTY.to_owned(); + } + if value.len() <= DETAIL_LIMIT { + return value; + } + let head = value.floor_char_boundary(DETAIL_HEAD); + let tail = value.ceil_char_boundary( + value.len() - (DETAIL_LIMIT - DETAIL_HEAD - DETAIL_ELISION.len()), + ); + format!("{}{DETAIL_ELISION}{}", &value[..head], &value[tail..]) } fn sha256_id(bytes: &[u8]) -> String { @@ -19368,6 +19393,97 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide Ok(()) } + /// A source whose resolve step fails with a fixed message, as a build or + /// resolve step does when its output is a long log. + struct FailingSource { + message: String, + } + + impl SourcePort for FailingSource { + fn resolve( + &self, + _: &OperatorBinding, + _: &str, + _: u64, + ) -> Result { + bail!("{}", self.message) + } + + fn freeze(&self, _: &str, _: &CompiledDeploymentPlan) -> Result { + bail!("this source freezes nothing") + } + + fn observe_frozen( + &self, + _: &CompiledDeploymentPlan, + _: &FrozenSourceReceipt, + ) -> Result { + bail!("this source freezes nothing") + } + + fn cleanup(&self, _: &str, _: Option<&FrozenSourceReceipt>) -> Result<()> { + Ok(()) + } + } + + #[test] + fn bounded_detail_output_always_satisfies_require_detail() -> Result<()> { + for unit in ["a", "\u{e9}", "\u{2500}", "\u{1f600}"] { + for bytes in [0, 1, 511, 512, 513, 2047, 2048, 2049, 2600, 9000] { + // A prefix of one byte offsets every multibyte char against the cuts. + for prefix in ["", "x", "xx", "xxx"] { + let input = format!("{prefix}{}", unit.repeat(bytes / unit.len())); + let output = bounded_detail(&input); + require_detail(&output, "detail")?; + assert!(output.len() <= 2048, "{} bytes", output.len()); + } + } + } + let nul = bounded_detail("a\0b\0"); + require_detail(&nul, "detail")?; + assert_eq!(nul, "ab"); + assert_eq!(bounded_detail("\0"), bounded_detail("")); + require_detail(&bounded_detail(""), "detail")?; + let exact = "a".repeat(2048); + assert_eq!(bounded_detail(&exact), exact); + Ok(()) + } + + #[test] + fn bounded_detail_keeps_the_tail_of_a_long_error() { + let input = format!("first line\n{}\nthe last line", "\u{2500}".repeat(5000)); + let output = bounded_detail(&input); + assert!(output.starts_with("first line")); + assert!(output.ends_with("the last line")); + assert!(output.contains(DETAIL_ELISION)); + assert!(output.len() > 1024, "the budget was not used: {}", output.len()); + } + + #[test] + fn a_sealing_failure_with_a_long_multibyte_error_aborts_and_frees_the_target() -> Result<()> { + // 3000 chars of 3-byte box drawing: 9000 bytes, the shape of a build log. + let log = format!("{}\nthe real error is here", "\u{2500}".repeat(3000)); + let mut world = sealing_world(&unrouted_recipe())?; + world.engine.source = Arc::new(FailingSource { message: log }); + for _ in 0..8 { + world.engine.resume_backoff.lock().unwrap().clear(); + let _ = world.engine.run_scheduler_tick(); + } + assert!( + resident(&world).is_err(), + "the failed transaction still owns the target" + ); + let done = latest(&world)?; + let TransactionCompletion::FailedBeforeFencing { error } = + done.completion.context("the aborted transaction did not complete")? + else { + bail!("the transaction did not fail before fencing"); + }; + assert!(error.len() <= 2048, "{}", error.len()); + assert!(error.ends_with("the real error is here"), "the tail was lost"); + Ok(()) + } + #[test] fn admission_takes_a_recipe_that_declares_its_proof() -> Result<()> { let recipe = unrouted_recipe().replace(