Merge origin/main (bounded_detail hotfix) into idunn/reincarnation-c2
Conflict in begin_post_fencing_abort: kept c2's now_millis binding and the hotfix's bounded_detail in place of truncate. The owner-wait note written by wait_for_owner now goes through bounded_detail too; every other c2/c2b/c2c detail writer already did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e5298584a4
2 changed files with 191 additions and 14 deletions
61
docs/deploy-provenance-target.md
Normal file
61
docs/deploy-provenance-target.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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<Self> {
|
||||
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<DeploymentTransaction>,
|
||||
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<DeploymentTransaction>,
|
||||
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(' | ||||