From 7ea4ea3d647a779beafb678180a546dcfc42423c Mon Sep 17 00:00:00 2001 From: Tee8z Date: Mon, 19 Oct 2026 14:40:04 -0400 Subject: [PATCH] feat(agents): add the shared Sandbox lifecycle contract Add a small library target to buzz-backend-kubernetes that holds the Agent Sandbox session lifecycle: the annotation keys and values, a pure classifier that maps an observed Sandbox, Pod, and apiserver time to one lifecycle state, and the checkpoint receipt that binds a tombstone to the session's own checkpoint prefix. The provider binary and the in-cluster reaper share it, so naming.rs now re-exports the management marker from the library instead of spelling it twice. The harness writes the saved checkpoint key to the kubelet-mounted termination log as {"version":1,"checkpoint":""}. It never creates the file, so local runs are unaffected. Signed-off-by: Tee8z --- crates/buzz-acp/src/activity.rs | 11 +- crates/buzz-acp/src/checkpoint.rs | 181 ++++++- crates/buzz-backend-kubernetes/Cargo.toml | 4 + crates/buzz-backend-kubernetes/src/lib.rs | 8 + .../buzz-backend-kubernetes/src/lifecycle.rs | 508 ++++++++++++++++++ .../src/lifecycle_tests.rs | 465 ++++++++++++++++ crates/buzz-backend-kubernetes/src/naming.rs | 21 +- 7 files changed, 1167 insertions(+), 31 deletions(-) create mode 100644 crates/buzz-backend-kubernetes/src/lib.rs create mode 100644 crates/buzz-backend-kubernetes/src/lifecycle.rs create mode 100644 crates/buzz-backend-kubernetes/src/lifecycle_tests.rs diff --git a/crates/buzz-acp/src/activity.rs b/crates/buzz-acp/src/activity.rs index 3a52ba11203..f6fcd2606c7 100644 --- a/crates/buzz-acp/src/activity.rs +++ b/crates/buzz-acp/src/activity.rs @@ -32,7 +32,7 @@ impl ActivityRecorder { pub(crate) fn record(&mut self, event: &ObserverEvent) -> Option { let mut details = json!({}); let kind = match event.kind.as_str() { - "checkpoint_started" => event.kind.as_str(), + "checkpoint_started" | "checkpoint_receipt_failed" => event.kind.as_str(), "checkpoint_failed" => { details["reason"] = event.payload["reason"].as_str().into(); details["exit_code"] = event.payload["exit_code"].as_i64().into(); @@ -305,6 +305,15 @@ mod tests { assert!(recorder.record(&e).is_none()); } + #[test] + fn checkpoint_receipt_failures_are_platform_records() { + let mut recorder = ActivityRecorder::new(); + let mut e = event(json!({"error_kind": "permission denied"})); + e.kind = "checkpoint_receipt_failed".into(); + let record = recorder.record(&e).unwrap(); + assert_eq!(record["event"], "checkpoint_receipt_failed"); + } + #[test] fn active_tool_tracking_is_bounded() { let mut recorder = ActivityRecorder::new(); diff --git a/crates/buzz-acp/src/checkpoint.rs b/crates/buzz-acp/src/checkpoint.rs index ed347739e7f..4fb685c61eb 100644 --- a/crates/buzz-acp/src/checkpoint.rs +++ b/crates/buzz-acp/src/checkpoint.rs @@ -1,4 +1,5 @@ //! Checkpoint the workspace after all adapter processes have stopped. +use std::path::Path; use std::time::Duration; use serde::Deserialize; @@ -82,21 +83,62 @@ fn receipt(bytes: &[u8]) -> Result { "repositories":saved.repositories,"bytes":saved.bytes})) } +/// Kubernetes container termination-message path. The kubelet mounts it into +/// every container, and the in-cluster reaper reads the receipt back from the +/// agent container's terminated status (`lifecycle::CheckpointReceipt` in +/// `buzz-backend-kubernetes`). +const TERMINATION_LOG: &str = "/dev/termination-log"; + +/// Termination-message wire format: `{"version":1,"checkpoint":""}`. +/// Field order is the declaration order, so the bytes are stable. +#[derive(serde::Serialize)] +struct TerminationReceipt<'a> { + version: u32, + checkpoint: &'a str, +} + pub(crate) async fn save(observer: Option<&ObserverHandle>) -> anyhow::Result<()> { let mut command = Command::new("buzz-agent-checkpoint"); command.arg("save"); - save_with_command(observer, command, Duration::from_secs(120)) - .await - .map_err(|error| { - anyhow::Error::new(error) - .context("checkpoint failed; retain the Sandbox workspace for explicit recovery") - }) + save_with_command( + observer, + command, + Duration::from_secs(120), + Path::new(TERMINATION_LOG), + ) + .await + .map_err(|error| { + anyhow::Error::new(error) + .context("checkpoint failed; retain the Sandbox workspace for explicit recovery") + }) +} + +/// Record the saved checkpoint key as the container's termination message. +/// The file is never created: it exists only where the kubelet mounted it, so +/// `NotFound` is a local run and succeeds without writing. Any other open or +/// write failure is returned. +fn write_termination_receipt(path: &Path, key: &str) -> std::io::Result<()> { + let receipt = serde_json::to_string(&TerminationReceipt { + version: 1, + checkpoint: key, + })?; + let mut file = match std::fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + }; + std::io::Write::write_all(&mut file, receipt.as_bytes()) } async fn save_with_command( observer: Option<&ObserverHandle>, mut command: Command, timeout: Duration, + termination_log: &Path, ) -> Result<(), CheckpointError> { let emit = |kind, details| { if let Some(observer) = observer { @@ -112,6 +154,17 @@ async fn save_with_command( command.process_group(0); match run_checkpoint(command, timeout).await { Ok(details) => { + if let Some(key) = details["key"].as_str() { + // The checkpoint itself is saved and the stop proceeds; the + // reaper finds no receipt and treats the checkpoint as missing. + if let Err(error) = write_termination_receipt(termination_log, key) { + tracing::warn!(%error, "could not write the checkpoint termination receipt"); + emit( + "checkpoint_receipt_failed", + serde_json::json!({"error_kind": error.kind().to_string()}), + ); + } + } emit("checkpoint_saved", details); Ok(()) } @@ -170,6 +223,9 @@ async fn run_checkpoint( mod tests { use super::*; + #[cfg(unix)] + const NO_LOG_PATH: &str = "/nonexistent/termination-log"; + fn valid_receipt() -> serde_json::Value { serde_json::json!({"version":1,"key":"developer/session/id.tar.gz", "sha256":"a".repeat(64),"repositories":2,"bytes":100}) @@ -181,9 +237,14 @@ mod tests { let mut command = Command::new("sh"); command.args(["-c", "head -c 2049 /dev/zero; sleep 60"]); let started = std::time::Instant::now(); - let error = save_with_command(None, command, Duration::from_secs(10)) - .await - .unwrap_err(); + let error = save_with_command( + None, + command, + Duration::from_secs(10), + Path::new(NO_LOG_PATH), + ) + .await + .unwrap_err(); assert!(matches!(error, CheckpointError::ReceiptOversized)); assert!(started.elapsed() < Duration::from_secs(2)); } @@ -237,11 +298,14 @@ mod tests { let observer = ObserverHandle::in_process(); let mut command = Command::new("sh"); command.args(["-c", script]); - assert!( - save_with_command(Some(&observer), command, Duration::from_millis(100)) - .await - .is_err() - ); + assert!(save_with_command( + Some(&observer), + command, + Duration::from_millis(100), + Path::new(NO_LOG_PATH) + ) + .await + .is_err()); let events = observer.snapshot(); assert_eq!( events @@ -255,7 +319,13 @@ mod tests { let observer = ObserverHandle::in_process(); let command = Command::new("/nonexistent/checkpoint-helper"); assert!(matches!( - save_with_command(Some(&observer), command, Duration::from_secs(1)).await, + save_with_command( + Some(&observer), + command, + Duration::from_secs(1), + Path::new(NO_LOG_PATH) + ) + .await, Err(CheckpointError::Spawn(_)) )); let failed = observer.snapshot().last().unwrap().clone(); @@ -278,7 +348,13 @@ mod tests { pid_file.to_str().unwrap(), ]); assert!(matches!( - save_with_command(None, command, Duration::from_secs(5)).await, + save_with_command( + None, + command, + Duration::from_secs(5), + Path::new(NO_LOG_PATH) + ) + .await, Err(CheckpointError::ExitStatus(_)) )); let pid: i32 = std::fs::read_to_string(&pid_file) @@ -302,10 +378,15 @@ mod tests { #[tokio::test] async fn helper_success_emits_verified_receipt_after_exit() { let observer = ObserverHandle::in_process(); - let valid = valid_receipt().to_string(); + let valid = serde_json::json!({"version":1,"key":"developer/session/id.tar.gz", + "sha256":"a".repeat(64),"repositories":2,"bytes":100}) + .to_string(); let mut command = Command::new("sh"); command.args(["-c", "printf '%s' \"$1\"", "checkpoint-test", &valid]); - save_with_command(Some(&observer), command, Duration::from_secs(1)) + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("termination-log"); + std::fs::write(&log, "stale").unwrap(); + save_with_command(Some(&observer), command, Duration::from_secs(1), &log) .await .unwrap(); let events = observer.snapshot(); @@ -313,5 +394,69 @@ mod tests { assert_eq!(events[0].kind, "checkpoint_started"); assert_eq!(events[1].kind, "checkpoint_saved"); assert_eq!(events[1].payload["key"], "developer/session/id.tar.gz"); + // Exact bytes: the reaper's `CheckpointReceipt` tests parse this literal. + assert_eq!( + std::fs::read_to_string(&log).unwrap(), + r#"{"version":1,"checkpoint":"developer/session/id.tar.gz"}"# + ); + } + + /// A mounted log that cannot be written is reported, not swallowed; the + /// checkpoint itself still counts as saved. + #[cfg(unix)] + #[tokio::test] + async fn unwritable_receipt_is_reported_without_failing_the_checkpoint() { + let observer = ObserverHandle::in_process(); + let valid = serde_json::json!({"version":1,"key":"developer/session/id.tar.gz", + "sha256":"a".repeat(64),"repositories":2,"bytes":100}) + .to_string(); + let mut command = Command::new("sh"); + command.args(["-c", "printf '%s' \"$1\"", "checkpoint-test", &valid]); + // A directory exists but cannot be opened for writing. + let dir = tempfile::tempdir().unwrap(); + save_with_command(Some(&observer), command, Duration::from_secs(1), dir.path()) + .await + .unwrap(); + let kinds: Vec<_> = observer + .snapshot() + .into_iter() + .map(|event| event.kind) + .collect(); + assert_eq!( + kinds, + [ + "checkpoint_started", + "checkpoint_receipt_failed", + "checkpoint_saved" + ] + ); + } + + /// Local runs have no kubelet-mounted file; the receipt must never create + /// one, and a failed checkpoint must never write one. + #[cfg(unix)] + #[tokio::test] + async fn receipt_is_never_created_and_never_written_on_failure() { + let dir = tempfile::tempdir().unwrap(); + let absent = dir.path().join("termination-log"); + let valid = serde_json::json!({"version":1,"key":"developer/session/id.tar.gz", + "sha256":"a".repeat(64),"repositories":2,"bytes":100}) + .to_string(); + let mut command = Command::new("sh"); + command.args(["-c", "printf '%s' \"$1\"", "checkpoint-test", &valid]); + save_with_command(None, command, Duration::from_secs(1), &absent) + .await + .unwrap(); + assert!(!absent.exists()); + let present = dir.path().join("present"); + std::fs::write(&present, "").unwrap(); + let mut failing = Command::new("sh"); + failing.args(["-c", "exit 1"]); + assert!( + save_with_command(None, failing, Duration::from_secs(1), &present) + .await + .is_err() + ); + assert_eq!(std::fs::read_to_string(&present).unwrap(), ""); } } diff --git a/crates/buzz-backend-kubernetes/Cargo.toml b/crates/buzz-backend-kubernetes/Cargo.toml index d8b3d2bd907..98067dce335 100644 --- a/crates/buzz-backend-kubernetes/Cargo.toml +++ b/crates/buzz-backend-kubernetes/Cargo.toml @@ -6,6 +6,10 @@ rust-version.workspace = true license.workspace = true description = "Kubernetes backend provider for Buzz remote agents (docs/remote-agents.md)" +[lib] +name = "buzz_backend_kubernetes" +path = "src/lib.rs" + [[bin]] name = "buzz-backend-kubernetes" path = "src/main.rs" diff --git a/crates/buzz-backend-kubernetes/src/lib.rs b/crates/buzz-backend-kubernetes/src/lib.rs new file mode 100644 index 00000000000..a6f305825a4 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/lib.rs @@ -0,0 +1,8 @@ +//! Library surface of the Kubernetes provider: the Agent Sandbox session +//! lifecycle shared with the in-cluster `buzz-sandbox-reaper`. +//! +//! Deliberately small. The provider binary ships inside the desktop app, so +//! anything a consumer of this library needs (S3, HTTP servers, metrics) must +//! live in that consumer, never here. + +pub mod lifecycle; diff --git a/crates/buzz-backend-kubernetes/src/lifecycle.rs b/crates/buzz-backend-kubernetes/src/lifecycle.rs new file mode 100644 index 00000000000..67cf4c645cb --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/lifecycle.rs @@ -0,0 +1,508 @@ +//! Agent Sandbox session lifecycle, shared by the desktop provider and the +//! in-cluster `buzz-sandbox-reaper` reconciler. +//! +//! The classifier here is pure: callers observe the Sandbox, its Pod, and the +//! apiserver clock, and this module decides which lifecycle state the session +//! is in. Planning in the reaper turns a state into at most one action. No +//! state maps to "set `operatingMode: Running`" or "create a Sandbox": a +//! session that has ended is only ever resumed by an explicit recovery deploy. + +use chrono::{DateTime, Duration, Utc}; +use k8s_openapi::api::core::v1::Pod; +use kube::core::{ApiResource, DynamicObject, GroupVersionKind}; +use kube::ResourceExt; + +/// `app.kubernetes.io/managed-by` label key and the value this project writes. +pub const LABEL_MANAGED_BY: &str = "app.kubernetes.io/managed-by"; +/// Management marker value for every object the provider creates. +pub const MANAGED_BY: &str = "buzz-backend-kubernetes"; +/// Object-layout schema label key. +pub const LABEL_BINDING_VERSION: &str = "buzz.block.xyz/binding-version"; +/// Binding version of controller-owned Sandbox sessions. +pub const SANDBOX_BINDING_VERSION: &str = "2"; +/// Full agent pubkey annotation. +pub const ANNOTATION_PUBKEY_FULL: &str = "buzz.block.xyz/agent-pubkey-full"; + +/// UID of the first Pod the session's credentials were bound to. +pub const INITIAL_POD: &str = "buzz.block.xyz/initial-pod-uid"; +/// Session generation; also the checkpoint key segment. +pub const GENERATION: &str = "buzz.block.xyz/generation"; +/// Verified owner pubkey (hex) that launched the session. +pub const OWNER: &str = "buzz.block.xyz/owner-pubkey"; +/// Channel the session is bound to. +pub const CHANNEL: &str = "buzz.block.xyz/channel-id"; +/// Thread root the session is bound to. +pub const THREAD: &str = "buzz.block.xyz/thread-root"; +/// Session identifier (equal to the generation). +pub const SESSION: &str = "buzz.block.xyz/session-id"; +/// `enabled` when the session was launched with checkpoint-on-stop. +pub const CHECKPOINTING: &str = "buzz.block.xyz/checkpointing"; +/// Checkpoint key a recovered session restores from on first start. +pub const RESTORE_CHECKPOINT: &str = "buzz.block.xyz/restore-checkpoint"; + +/// `ending` | `ended`. +pub const LIFECYCLE: &str = "buzz.block.xyz/lifecycle"; +/// Why the session ended; see [`EndedReason`]. +pub const ENDED_REASON: &str = "buzz.block.xyz/ended-reason"; +/// RFC 3339 apiserver time at which the tombstone was written. +pub const ENDED_AT: &str = "buzz.block.xyz/ended-at"; +/// `` | `missing` | `unconfigured`. +pub const CHECKPOINT: &str = "buzz.block.xyz/checkpoint"; +/// Generation of the tombstone a recovered session replaced. +pub const RECOVERED_FROM: &str = "buzz.block.xyz/recovered-from"; + +/// [`LIFECYCLE`] value while a stop is draining. +pub const LIFECYCLE_ENDING: &str = "ending"; +/// [`LIFECYCLE`] value of a tombstone. +pub const LIFECYCLE_ENDED: &str = "ended"; +/// [`CHECKPOINT`] value when no verified checkpoint exists. +pub const CHECKPOINT_MISSING: &str = "missing"; +/// [`CHECKPOINT`] value when the session never had checkpointing. +pub const CHECKPOINT_UNCONFIGURED: &str = "unconfigured"; +/// [`CHECKPOINTING`] value written when checkpoint-on-stop is configured. +pub const CHECKPOINTING_ENABLED: &str = "enabled"; +/// [`CHECKPOINTING`] value written when checkpoint-on-stop is not configured. +pub const CHECKPOINTING_DISABLED: &str = "disabled"; + +/// Agent container name inside the Sandbox Pod. +pub const AGENT_CONTAINER: &str = "agent"; + +/// Age after which an unbound Sandbox or a never-started bound Pod is abandoned. +pub const BIND_TIMEOUT_SECS: i64 = 1200; +/// How long a bound session's Pod may be absent before the session is lost. +pub const LOST_GRACE_SECS: i64 = 60; +/// Hold before a completed session without a checkpoint is tombstoned. +pub const MISSING_CHECKPOINT_HOLD_SECS: i64 = 24 * 60 * 60; +/// Tombstone retention; matches the 30-day checkpoint object expiry. +pub const ENDED_RETENTION_SECS: i64 = 30 * 24 * 60 * 60; + +/// The Agent Sandbox `v1beta1` resource. +pub fn sandbox_resource() -> ApiResource { + ApiResource::from_gvk(&GroupVersionKind::gvk( + "agents.x-k8s.io", + "v1beta1", + "Sandbox", + )) +} + +/// Read one annotation. +pub fn annotation<'a>(object: &'a DynamicObject, key: &str) -> Option<&'a str> { + object + .metadata + .annotations + .as_ref()? + .get(key) + .map(String::as_str) +} + +/// Is `pod` controlled by `sandbox`? +pub fn owned_pod(pod: &Pod, sandbox: &DynamicObject) -> bool { + sandbox.metadata.uid.as_ref().is_some_and(|uid| { + pod.owner_references().iter().any(|r| { + &r.uid == uid + && r.kind == "Sandbox" + && r.api_version == "agents.x-k8s.io/v1beta1" + && r.controller == Some(true) + }) + }) +} + +/// `spec.operatingMode` of a Sandbox. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OperatingMode { + Running, + Suspended, + /// Absent or a value this version does not know; never acted on. + Other, +} + +impl OperatingMode { + /// Read `spec.operatingMode`. + pub fn of(sandbox: &DynamicObject) -> Self { + match sandbox.data["spec"]["operatingMode"].as_str() { + Some("Running") => Self::Running, + Some("Suspended") => Self::Suspended, + _ => Self::Other, + } + } +} + +/// The [`LIFECYCLE`] annotation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Lifecycle { + /// No lifecycle record: an ordinary (possibly live) session. + Live, + /// A stop was requested and is draining. + Ending, + /// A tombstone. + Ended, + /// A value this version does not write, or (from + /// [`Observation::from_objects`]) an `ending` record whose [`ENDED_REASON`] + /// it cannot read; the object is left alone. + Unrecognized, +} + +impl Lifecycle { + /// Read the [`LIFECYCLE`] annotation. + pub fn of(sandbox: &DynamicObject) -> Self { + match annotation(sandbox, LIFECYCLE) { + None => Self::Live, + Some(LIFECYCLE_ENDING) => Self::Ending, + Some(LIFECYCLE_ENDED) => Self::Ended, + Some(_) => Self::Unrecognized, + } + } +} + +/// Why a session ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EndedReason { + Stopped, + Completed, + Replaced, + Lost, + BindingFailed, + BindAbandoned, +} + +impl EndedReason { + /// Annotation spelling. + pub fn as_str(self) -> &'static str { + match self { + Self::Stopped => "stopped", + Self::Completed => "completed", + Self::Replaced => "replaced", + Self::Lost => "lost", + Self::BindingFailed => "binding-failed", + Self::BindAbandoned => "bind-abandoned", + } + } + + /// Parse the annotation spelling. + pub fn parse(value: &str) -> Option { + [ + Self::Stopped, + Self::Completed, + Self::Replaced, + Self::Lost, + Self::BindingFailed, + Self::BindAbandoned, + ] + .into_iter() + .find(|reason| reason.as_str() == value) + } +} + +/// Agent container progress. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Container { + /// No running or terminated state has ever been reported. + NeverStarted, + Running, + Terminated { + finished_at: Option>, + /// Raw termination message (bounded by the kubelet to 4 KiB). + message: Option, + }, +} + +/// The observed Pod named after the Sandbox. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PodObservation { + /// Empty when the Pod reports no UID; such a Pod never matches a binding. + pub uid: String, + pub created: Option>, + pub deleting: bool, + /// Controlled by the observed Sandbox. + pub owned: bool, + pub container: Container, +} + +impl PodObservation { + /// Observe a Pod against its Sandbox. + pub fn from_pod(pod: &Pod, sandbox: &DynamicObject) -> Self { + let state = pod + .status + .as_ref() + .and_then(|s| s.container_statuses.as_ref()) + .and_then(|containers| containers.iter().find(|c| c.name == AGENT_CONTAINER)) + .and_then(|c| c.state.as_ref()); + let container = match state { + Some(state) if state.terminated.is_some() => { + let terminated = state.terminated.as_ref(); + Container::Terminated { + finished_at: terminated.and_then(|t| t.finished_at.as_ref()).map(|t| t.0), + // A failed final save may leave an older harness receipt. + // Only a successful exit attests a fully drained workspace. + message: terminated + .filter(|t| t.exit_code == 0) + .and_then(|t| t.message.clone()), + } + } + Some(state) if state.running.is_some() => Container::Running, + _ => Container::NeverStarted, + }; + Self { + uid: pod.metadata.uid.clone().unwrap_or_default(), + created: pod.metadata.creation_timestamp.as_ref().map(|t| t.0), + deleting: pod.metadata.deletion_timestamp.is_some(), + owned: owned_pod(pod, sandbox), + container, + } + } +} + +/// Everything the classifier needs, extracted from live objects. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Observation { + /// Apiserver clock. + pub now: DateTime, + pub managed: bool, + pub binding_version: Option, + pub owner: Option, + pub created: Option>, + pub deleting: bool, + pub operating_mode: OperatingMode, + pub lifecycle: Lifecycle, + pub ended_reason: Option, + pub ended_at: Option>, + pub initial_pod: Option, + pub pod: Option, + /// First apiserver time at which the bound Pod was seen absent. + pub pod_missing_since: Option>, +} + +impl Observation { + /// Observe a Sandbox and the Pod with its name, if any. + pub fn from_objects( + sandbox: &DynamicObject, + pod: Option<&Pod>, + now: DateTime, + pod_missing_since: Option>, + ) -> Self { + let labels = sandbox.labels(); + let reason = annotation(sandbox, ENDED_REASON); + let ended_reason = reason.and_then(EndedReason::parse); + // An absent reason means `stopped`; an unreadable one is not absent, + // so a draining stop is never finalized under a guessed reason. + let lifecycle = match Lifecycle::of(sandbox) { + Lifecycle::Ending if reason.is_some() && ended_reason.is_none() => { + Lifecycle::Unrecognized + } + lifecycle => lifecycle, + }; + Self { + now, + managed: labels.get(LABEL_MANAGED_BY).map(String::as_str) == Some(MANAGED_BY), + binding_version: labels.get(LABEL_BINDING_VERSION).cloned(), + owner: annotation(sandbox, OWNER).map(str::to_string), + created: sandbox.metadata.creation_timestamp.as_ref().map(|t| t.0), + deleting: sandbox.metadata.deletion_timestamp.is_some(), + operating_mode: OperatingMode::of(sandbox), + lifecycle, + ended_reason, + ended_at: annotation(sandbox, ENDED_AT) + .and_then(|t| DateTime::parse_from_rfc3339(t).ok()) + .map(|t| t.with_timezone(&Utc)), + initial_pod: annotation(sandbox, INITIAL_POD).map(str::to_string), + pod: pod.map(|pod| PodObservation::from_pod(pod, sandbox)), + pod_missing_since, + } + } +} + +/// Why an object is left alone. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Ignored { + /// Missing the management marker or binding version 2. + Unmarked, + /// Owner annotation differs from the namespace's configured owner. + ForeignOwner, + /// The Pod with the Sandbox's name is not controlled by it. + ForeignPod, + /// Suspended or otherwise not Running, without a lifecycle record. + NotRunning, + /// An `ended` lifecycle that is not Suspended, an unknown lifecycle, or a + /// bound session whose Pod reports no UID. + Inconsistent, +} + +impl Ignored { + /// Event/metric spelling. + pub fn as_str(self) -> &'static str { + match self { + Self::Unmarked => "unmarked", + Self::ForeignOwner => "foreign-owner", + Self::ForeignPod => "foreign-pod", + Self::NotRunning => "not-running", + Self::Inconsistent => "inconsistent", + } + } +} + +/// A session's lifecycle state (spec table). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum State { + Ignored(Ignored), + /// Running, no first-Pod binding yet; `abandoned` once the Sandbox is + /// [`BIND_TIMEOUT_SECS`] old. + Binding { + abandoned: bool, + }, + /// Bound to this Pod, whose container never started; `expired` once the + /// Pod is [`BIND_TIMEOUT_SECS`] old (until then a deploy repairs it). + BindingIncomplete { + expired: bool, + }, + Active, + /// Pod deleting, or a stop is draining while the Pod still exists. + Draining, + /// Stop requested and its Pod is gone; finalize with the recorded reason. + Ending { + reason: EndedReason, + }, + /// The bound container exited. + Completed { + finished_at: Option>, + message: Option, + }, + /// The controller replaced the first Pod. + Replaced, + /// The bound Pod is gone; `confirmed` after [`LOST_GRACE_SECS`]. + Lost { + confirmed: bool, + }, + /// A tombstone; `expired` once retained for [`ENDED_RETENTION_SECS`]. + Ended { + expired: bool, + }, +} + +fn age(now: DateTime, since: Option>) -> Option { + since.map(|since| now - since) +} + +fn at_least(now: DateTime, since: Option>, secs: i64) -> bool { + age(now, since).is_some_and(|age| age >= Duration::seconds(secs)) +} + +/// Classify one observed session. `expected_owner` is the namespace's +/// configured owner (hex); a mismatching Sandbox is never touched. +pub fn classify(observation: &Observation, expected_owner: &str) -> State { + let o = observation; + if !o.managed || o.binding_version.as_deref() != Some(SANDBOX_BINDING_VERSION) { + return State::Ignored(Ignored::Unmarked); + } + if o.owner.as_deref() != Some(expected_owner) { + return State::Ignored(Ignored::ForeignOwner); + } + if o.deleting { + return State::Draining; + } + match o.lifecycle { + Lifecycle::Ended if o.operating_mode == OperatingMode::Suspended => { + // Without a parseable timestamp the tombstone is kept forever: + // a deferred deletion is free, a wrong one loses the record. + return State::Ended { + expired: at_least(o.now, o.ended_at, ENDED_RETENTION_SECS), + }; + } + Lifecycle::Ending => { + return match &o.pod { + Some(pod) if pod.owned => State::Draining, + Some(_) => State::Ignored(Ignored::ForeignPod), + None => State::Ending { + reason: o.ended_reason.unwrap_or(EndedReason::Stopped), + }, + }; + } + Lifecycle::Ended | Lifecycle::Unrecognized => return State::Ignored(Ignored::Inconsistent), + Lifecycle::Live => {} + } + if o.operating_mode != OperatingMode::Running { + return State::Ignored(Ignored::NotRunning); + } + if let Some(pod) = &o.pod { + if !pod.owned { + return State::Ignored(Ignored::ForeignPod); + } + if pod.deleting { + return State::Draining; + } + } + let Some(initial) = o.initial_pod.as_deref() else { + return State::Binding { + abandoned: at_least(o.now, o.created, BIND_TIMEOUT_SECS), + }; + }; + let Some(pod) = &o.pod else { + return State::Lost { + confirmed: at_least(o.now, o.pod_missing_since, LOST_GRACE_SECS), + }; + }; + // Every apiserver Pod has a UID. Without one, the binding cannot be + // compared, and a guessed `Replaced` would tombstone a live session. + if pod.uid.is_empty() { + return State::Ignored(Ignored::Inconsistent); + } + if pod.uid != initial { + return State::Replaced; + } + match &pod.container { + Container::NeverStarted => State::BindingIncomplete { + expired: at_least(o.now, pod.created, BIND_TIMEOUT_SECS), + }, + Container::Running => State::Active, + Container::Terminated { + finished_at, + message, + } => State::Completed { + finished_at: *finished_at, + message: message.clone(), + }, + } +} + +/// The checkpoint receipt the harness writes to its termination message. +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +pub struct CheckpointReceipt { + pub version: u32, + pub checkpoint: String, +} + +/// Parse a termination-message receipt and bind it to the session's own +/// prefix (`/`). A receipt naming any other +/// generation or developer is refused: a compromised Pod must not be able to +/// point a tombstone at someone else's checkpoint. +pub fn receipt_key(message: &str, session_prefix: &str) -> Option { + let receipt: CheckpointReceipt = serde_json::from_str(message.trim()).ok()?; + (receipt.version == 1 && is_session_key(&receipt.checkpoint, session_prefix)) + .then_some(receipt.checkpoint) +} + +/// Is `key` one checkpoint object directly under `session_prefix` +/// (`/`)? +pub fn is_session_key(key: &str, session_prefix: &str) -> bool { + let Some(rest) = key.strip_prefix(session_prefix) else { + return false; + }; + !session_prefix.is_empty() + && session_prefix.ends_with('/') + && !rest.is_empty() + && !rest.contains('/') + && !key.contains("..") + && key.len() <= 512 + && key + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-/._".contains(&b)) +} + +/// Is a tombstone `checkpoint` value an actual key? +pub fn is_checkpoint_key(value: &str) -> bool { + !value.is_empty() && value != CHECKPOINT_MISSING && value != CHECKPOINT_UNCONFIGURED +} + +#[cfg(test)] +#[path = "lifecycle_tests.rs"] +mod tests; diff --git a/crates/buzz-backend-kubernetes/src/lifecycle_tests.rs b/crates/buzz-backend-kubernetes/src/lifecycle_tests.rs new file mode 100644 index 00000000000..75107780f01 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/lifecycle_tests.rs @@ -0,0 +1,465 @@ +use super::*; +use serde_json::json; + +const OWNER_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + +fn t(secs: i64) -> DateTime { + DateTime::from_timestamp(1_800_000_000 + secs, 0).unwrap() +} + +/// A Running, managed, owner-matching Sandbox created at t(0) with nothing else. +fn base(now: i64) -> Observation { + Observation { + now: t(now), + managed: true, + binding_version: Some("2".into()), + owner: Some(OWNER_HEX.into()), + created: Some(t(0)), + deleting: false, + operating_mode: OperatingMode::Running, + lifecycle: Lifecycle::Live, + ended_reason: None, + ended_at: None, + initial_pod: None, + pod: None, + pod_missing_since: None, + } +} + +fn pod(uid: &str, container: Container) -> PodObservation { + PodObservation { + uid: uid.into(), + created: Some(t(0)), + deleting: false, + owned: true, + container, + } +} + +fn bound(now: i64, container: Container) -> Observation { + Observation { + initial_pod: Some("uid-1".into()), + pod: Some(pod("uid-1", container)), + ..base(now) + } +} + +fn classify_one(o: &Observation) -> State { + classify(o, OWNER_HEX) +} + +#[test] +fn a_failed_container_cannot_attest_its_final_checkpoint() { + let sandbox: DynamicObject = serde_json::from_value(json!({ + "apiVersion":"agents.x-k8s.io/v1beta1", "kind":"Sandbox", + "metadata":{"name":"session","uid":"sandbox"} + })) + .unwrap(); + for exit_code in [0, 1, 137] { + let pod: Pod = serde_json::from_value(json!({ + "metadata":{"name":"session","uid":"pod"}, + "status":{"containerStatuses":[{ + "name":"agent","image":"test","imageID":"test","ready":false,"restartCount":0, + "state":{"terminated":{"exitCode":exit_code,"message":"checkpoint receipt"}} + }]} + })) + .unwrap(); + let Container::Terminated { message, .. } = + PodObservation::from_pod(&pod, &sandbox).container + else { + panic!("the container must remain terminated"); + }; + assert_eq!( + message.as_deref(), + (exit_code == 0).then_some("checkpoint receipt") + ); + } +} + +#[test] +fn binding_and_bind_abandoned_split_at_the_bind_timeout() { + assert_eq!( + classify_one(&base(BIND_TIMEOUT_SECS - 1)), + State::Binding { abandoned: false } + ); + assert_eq!( + classify_one(&base(BIND_TIMEOUT_SECS)), + State::Binding { abandoned: true } + ); + // An unbound Sandbox whose Pod exists is still Binding. + let with_pod = Observation { + pod: Some(pod("uid-1", Container::Running)), + ..base(5) + }; + assert_eq!(classify_one(&with_pod), State::Binding { abandoned: false }); + // No creation timestamp never ages into abandonment. + let undated = Observation { + created: None, + ..base(BIND_TIMEOUT_SECS * 10) + }; + assert_eq!(classify_one(&undated), State::Binding { abandoned: false }); +} + +#[test] +fn binding_incomplete_expires_on_pod_age() { + assert_eq!( + classify_one(&bound(BIND_TIMEOUT_SECS - 1, Container::NeverStarted)), + State::BindingIncomplete { expired: false } + ); + assert_eq!( + classify_one(&bound(BIND_TIMEOUT_SECS, Container::NeverStarted)), + State::BindingIncomplete { expired: true } + ); +} + +#[test] +fn active_completed_and_replaced() { + assert_eq!(classify_one(&bound(10, Container::Running)), State::Active); + let terminated = Container::Terminated { + finished_at: Some(t(5)), + message: Some("m".into()), + }; + assert_eq!( + classify_one(&bound(10, terminated)), + State::Completed { + finished_at: Some(t(5)), + message: Some("m".into()) + } + ); + let replaced = Observation { + pod: Some(pod("uid-2", Container::Running)), + ..bound(10, Container::Running) + }; + assert_eq!(classify_one(&replaced), State::Replaced); +} + +#[test] +fn lost_is_confirmed_only_after_the_grace() { + let missing = |now, since| Observation { + pod: None, + pod_missing_since: since, + ..bound(now, Container::Running) + }; + assert_eq!( + classify_one(&missing(100, None)), + State::Lost { confirmed: false } + ); + assert_eq!( + classify_one(&missing(100, Some(t(100 - LOST_GRACE_SECS + 1)))), + State::Lost { confirmed: false } + ); + assert_eq!( + classify_one(&missing(100, Some(t(100 - LOST_GRACE_SECS)))), + State::Lost { confirmed: true } + ); +} + +#[test] +fn draining_covers_deleting_objects_and_ending_with_a_pod() { + let mut deleting_pod = bound(10, Container::Running); + deleting_pod.pod.as_mut().unwrap().deleting = true; + assert_eq!(classify_one(&deleting_pod), State::Draining); + let deleting_sandbox = Observation { + deleting: true, + ..bound(10, Container::Running) + }; + assert_eq!(classify_one(&deleting_sandbox), State::Draining); + let ending = Observation { + lifecycle: Lifecycle::Ending, + operating_mode: OperatingMode::Suspended, + ..bound(10, Container::Running) + }; + assert_eq!(classify_one(&ending), State::Draining); + let ended_pod = Observation { + pod: None, + ended_reason: Some(EndedReason::Stopped), + ..ending + }; + assert_eq!( + classify_one(&ended_pod), + State::Ending { + reason: EndedReason::Stopped + } + ); +} + +#[test] +fn ended_tombstones_expire_after_thirty_days_and_never_without_a_date() { + let ended = |now, at| Observation { + operating_mode: OperatingMode::Suspended, + lifecycle: Lifecycle::Ended, + ended_at: at, + ..base(now) + }; + assert_eq!( + classify_one(&ended(ENDED_RETENTION_SECS - 1, Some(t(0)))), + State::Ended { expired: false } + ); + assert_eq!( + classify_one(&ended(ENDED_RETENTION_SECS, Some(t(0)))), + State::Ended { expired: true } + ); + assert_eq!( + classify_one(&ended(ENDED_RETENTION_SECS * 2, None)), + State::Ended { expired: false } + ); + // `ended` while Running is somebody else's edit: hands off. + let running = Observation { + operating_mode: OperatingMode::Running, + ..ended(ENDED_RETENTION_SECS * 2, Some(t(0))) + }; + assert_eq!( + classify_one(&running), + State::Ignored(Ignored::Inconsistent) + ); +} + +#[test] +fn foreign_unmarked_and_owner_mismatched_objects_are_ignored() { + let unmarked = Observation { + managed: false, + ..base(BIND_TIMEOUT_SECS * 2) + }; + assert_eq!(classify_one(&unmarked), State::Ignored(Ignored::Unmarked)); + let bare_pod_binding = Observation { + binding_version: Some("1".into()), + ..base(BIND_TIMEOUT_SECS * 2) + }; + assert_eq!( + classify_one(&bare_pod_binding), + State::Ignored(Ignored::Unmarked) + ); + for owner in [None, Some("2".repeat(64))] { + let foreign = Observation { + owner, + ..base(BIND_TIMEOUT_SECS * 2) + }; + assert_eq!( + classify_one(&foreign), + State::Ignored(Ignored::ForeignOwner) + ); + } + let mut foreign_pod = bound(10, Container::Running); + foreign_pod.pod.as_mut().unwrap().owned = false; + assert_eq!( + classify_one(&foreign_pod), + State::Ignored(Ignored::ForeignPod) + ); + let suspended = Observation { + operating_mode: OperatingMode::Suspended, + ..base(BIND_TIMEOUT_SECS * 2) + }; + assert_eq!( + classify_one(&suspended), + State::Ignored(Ignored::NotRunning) + ); +} + +#[test] +fn observation_reads_live_objects() { + let sandbox: DynamicObject = serde_json::from_value(json!({ + "apiVersion": "agents.x-k8s.io/v1beta1", "kind": "Sandbox", + "metadata": {"name": "s", "uid": "sandbox-uid", + "creationTimestamp": "2027-01-15T08:00:00Z", + "labels": {(LABEL_MANAGED_BY): MANAGED_BY, (LABEL_BINDING_VERSION): "2"}, + "annotations": {(OWNER): OWNER_HEX, (INITIAL_POD): "pod-uid", + (LIFECYCLE): "ending", (ENDED_REASON): "stopped"}}, + "spec": {"operatingMode": "Suspended"} + })) + .unwrap(); + let pod: Pod = serde_json::from_value(json!({ + "metadata": {"name": "s", "uid": "pod-uid", "ownerReferences": [{ + "apiVersion": "agents.x-k8s.io/v1beta1", "kind": "Sandbox", + "name": "s", "uid": "sandbox-uid", "controller": true}]}, + "status": {"containerStatuses": [{"name": "agent", "image": "i", "imageID": "", + "ready": false, "restartCount": 0, + "state": {"terminated": {"exitCode": 0, "message": "{}", + "finishedAt": "2027-01-15T09:00:00Z"}}}]} + })) + .unwrap(); + let observed = Observation::from_objects(&sandbox, Some(&pod), t(0), None); + assert!(observed.managed); + assert_eq!(observed.ended_reason, Some(EndedReason::Stopped)); + let pod = observed.pod.as_ref().unwrap(); + assert!(pod.owned); + assert!(matches!( + &pod.container, + Container::Terminated { message: Some(m), finished_at: Some(_) } if m == "{}" + )); + assert_eq!(classify_one(&observed), State::Draining); +} + +#[test] +fn receipts_bind_to_the_session_prefix() { + let prefix = "dev/0123456789abcdef0123456789abcdef/"; + let key = format!("{prefix}6f9619ff-8b86-d011-b42d-00cf4fc964ff.tar.gz"); + let receipt = json!({"version": 1, "checkpoint": key}).to_string(); + assert_eq!(receipt_key(&receipt, prefix).as_deref(), Some(key.as_str())); + // Another generation, another developer, traversal, nesting, junk. + for bad in [ + json!({"version": 1, "checkpoint": "dev/ffffffffffffffffffffffffffffffff/x.tar.gz"}), + json!({"version": 1, "checkpoint": "other/0123456789abcdef0123456789abcdef/x.tar.gz"}), + json!({"version": 1, "checkpoint": format!("{prefix}../x.tar.gz")}), + json!({"version": 1, "checkpoint": format!("{prefix}a/b.tar.gz")}), + json!({"version": 1, "checkpoint": prefix}), + json!({"version": 2, "checkpoint": key}), + json!({"version": 1, "checkpoint": key, "extra": true}), + ] { + assert_eq!(receipt_key(&bad.to_string(), prefix), None, "{bad}"); + } + assert_eq!(receipt_key("not json", prefix), None); + assert_eq!(receipt_key(&receipt, ""), None); + // The exact bytes buzz-acp's checkpoint tests assert it writes. + assert_eq!( + receipt_key( + r#"{"version":1,"checkpoint":"developer/session/id.tar.gz"}"#, + "developer/session/" + ) + .as_deref(), + Some("developer/session/id.tar.gz") + ); +} + +#[test] +fn reasons_round_trip_and_checkpoint_markers_are_not_keys() { + for reason in [ + EndedReason::Stopped, + EndedReason::Completed, + EndedReason::Replaced, + EndedReason::Lost, + EndedReason::BindingFailed, + EndedReason::BindAbandoned, + ] { + assert_eq!(EndedReason::parse(reason.as_str()), Some(reason)); + } + assert_eq!(EndedReason::parse("bogus"), None); + assert!(is_checkpoint_key("dev/g/x.tar.gz")); + assert!(!is_checkpoint_key(CHECKPOINT_MISSING)); + assert!(!is_checkpoint_key(CHECKPOINT_UNCONFIGURED)); + assert!(!is_checkpoint_key("")); +} + +#[test] +fn unknown_lifecycle_and_operating_mode_values_are_left_alone() { + let unknown_lifecycle = Observation { + lifecycle: Lifecycle::Unrecognized, + ..bound(10, Container::Running) + }; + assert_eq!( + classify_one(&unknown_lifecycle), + State::Ignored(Ignored::Inconsistent) + ); + let unknown_mode = Observation { + operating_mode: OperatingMode::Other, + ..bound(10, Container::Running) + }; + assert_eq!( + classify_one(&unknown_mode), + State::Ignored(Ignored::NotRunning) + ); + let mut ending_foreign_pod = Observation { + lifecycle: Lifecycle::Ending, + operating_mode: OperatingMode::Suspended, + ..bound(10, Container::Running) + }; + ending_foreign_pod.pod.as_mut().unwrap().owned = false; + assert_eq!( + classify_one(&ending_foreign_pod), + State::Ignored(Ignored::ForeignPod) + ); +} + +#[test] +fn lifecycle_and_operating_mode_read_their_wire_spellings() { + let object = |annotations: serde_json::Value, mode: serde_json::Value| -> DynamicObject { + serde_json::from_value(json!({ + "apiVersion": "agents.x-k8s.io/v1beta1", "kind": "Sandbox", + "metadata": {"name": "s", "annotations": annotations}, + "spec": {"operatingMode": mode} + })) + .unwrap() + }; + let cases = [ + (json!({}), Lifecycle::Live), + (json!({(LIFECYCLE): "ending"}), Lifecycle::Ending), + (json!({(LIFECYCLE): "ended"}), Lifecycle::Ended), + (json!({(LIFECYCLE): "Ended"}), Lifecycle::Unrecognized), + ]; + for (annotations, expected) in cases { + assert_eq!( + Lifecycle::of(&object(annotations, json!("Running"))), + expected + ); + } + for (mode, expected) in [ + (json!("Running"), OperatingMode::Running), + (json!("Suspended"), OperatingMode::Suspended), + (json!("Paused"), OperatingMode::Other), + (serde_json::Value::Null, OperatingMode::Other), + ] { + assert_eq!(OperatingMode::of(&object(json!({}), mode)), expected); + } +} + +#[test] +fn an_unreadable_ended_reason_is_not_an_absent_one() { + let observe = |annotations: serde_json::Value| { + let sandbox: DynamicObject = serde_json::from_value(json!({ + "apiVersion": "agents.x-k8s.io/v1beta1", "kind": "Sandbox", + "metadata": {"name": "s", "uid": "sandbox-uid", + "labels": {(LABEL_MANAGED_BY): MANAGED_BY, (LABEL_BINDING_VERSION): "2"}, + "annotations": annotations}, + "spec": {"operatingMode": "Suspended"} + })) + .unwrap(); + classify_one(&Observation::from_objects(&sandbox, None, t(0), None)) + }; + let ending = |reason: Option<&str>| { + let mut annotations = json!({(OWNER): OWNER_HEX, (LIFECYCLE): "ending"}); + if let Some(reason) = reason { + annotations[ENDED_REASON] = reason.into(); + } + observe(annotations) + }; + assert_eq!( + ending(None), + State::Ending { + reason: EndedReason::Stopped + } + ); + assert_eq!( + ending(Some("lost")), + State::Ending { + reason: EndedReason::Lost + } + ); + for unreadable in ["Lost", "evicted", ""] { + assert_eq!( + ending(Some(unreadable)), + State::Ignored(Ignored::Inconsistent), + "{unreadable:?}" + ); + } + // A tombstone's reason is informational; an unknown one still retires. + assert!(matches!( + observe(json!({(OWNER): OWNER_HEX, (LIFECYCLE): "ended", (ENDED_REASON): "evicted"})), + State::Ended { .. } + )); +} + +#[test] +fn a_bound_pod_without_a_uid_is_never_replaced() { + let mut missing_uid = bound(10, Container::Running); + missing_uid.pod.as_mut().unwrap().uid.clear(); + assert_eq!( + classify_one(&missing_uid), + State::Ignored(Ignored::Inconsistent) + ); + let pod: Pod = serde_json::from_value(json!({"metadata": {"name": "s"}})).unwrap(); + let sandbox: DynamicObject = serde_json::from_value(json!({ + "apiVersion": "agents.x-k8s.io/v1beta1", "kind": "Sandbox", + "metadata": {"name": "s", "uid": "sandbox-uid"} + })) + .unwrap(); + assert_eq!(PodObservation::from_pod(&pod, &sandbox).uid, ""); +} diff --git a/crates/buzz-backend-kubernetes/src/naming.rs b/crates/buzz-backend-kubernetes/src/naming.rs index 4b9d7ea0355..6bc29165220 100644 --- a/crates/buzz-backend-kubernetes/src/naming.rs +++ b/crates/buzz-backend-kubernetes/src/naming.rs @@ -6,14 +6,12 @@ use nostr::nips::nip19::FromBech32; -/// `app.kubernetes.io/managed-by` value: the management marker's identity half. -pub const MANAGED_BY: &str = "buzz-backend-kubernetes"; - -/// Label key carrying [`MANAGED_BY`]. -pub const LABEL_MANAGED_BY: &str = "app.kubernetes.io/managed-by"; - -/// Label key carrying [`BINDING_VERSION`] — the marker's schema half. -pub const LABEL_BINDING_VERSION: &str = "buzz.block.xyz/binding-version"; +/// The management marker (identity half: `app.kubernetes.io/managed-by`; +/// schema half: `buzz.block.xyz/binding-version`). Spelled once, in the +/// library shared with the in-cluster reaper. +pub use buzz_backend_kubernetes::lifecycle::{ + ANNOTATION_PUBKEY_FULL, LABEL_BINDING_VERSION, LABEL_MANAGED_BY, MANAGED_BY, +}; /// Schema version of the object layout this provider writes. Bumped when the /// pod/Secret shape changes in a way a older provider would mis-handle. @@ -22,10 +20,9 @@ pub const BINDING_VERSION: &str = "1"; /// Label key: truncated pubkey, the reconciliation and GC selector. pub const LABEL_AGENT_PUBKEY: &str = "buzz.block.xyz/agent-pubkey"; -/// Annotation key: full pubkey. Load-bearing — the truncated label is -/// collision-*resistant*, this is what makes it safe (§Deploy State Machine -/// step 1). -pub const ANNOTATION_PUBKEY_FULL: &str = "buzz.block.xyz/agent-pubkey-full"; +// `ANNOTATION_PUBKEY_FULL` (re-exported above) carries the full pubkey. It +// is load-bearing — the truncated label is collision-*resistant*, this is what +// makes it safe (§Deploy State Machine step 1). /// Annotation key: the recorded create-intent fingerprint. pub const ANNOTATION_CREATE_INTENT: &str = "buzz.block.xyz/create-intent";