diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index db00addb..9396a66b 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -21,6 +21,12 @@ jobs: sudo apt-get update sudo apt-get install -y git-lfs openssh-client git lfs install + - name: Report tool versions + run: | + rustc --version --verbose + cargo --version + rustup show active-toolchain + git --version - name: Check formatting run: cargo fmt --all -- --check - name: Check lints diff --git a/Cargo.lock b/Cargo.lock index 35bf2ce2..3f73fc48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2732,6 +2732,7 @@ dependencies = [ "base64 0.22.1", "bytes", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -2751,12 +2752,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.4.2", "web-sys", "webpki-roots", ] @@ -2796,7 +2799,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -4019,6 +4022,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" diff --git a/crates/canopy-git-format/src/pack_index/mod.rs b/crates/canopy-git-format/src/pack_index/mod.rs index f55d87ae..d4c831cd 100644 --- a/crates/canopy-git-format/src/pack_index/mod.rs +++ b/crates/canopy-git-format/src/pack_index/mod.rs @@ -161,6 +161,20 @@ impl PackIndex { self.ids_at(0) } + /// Scan native offsets in hash order using one fixed page. Callers may + /// build an admitted disk index of packed extents without retaining an + /// object-count-sized heap vector or performing a hash lookup per entry. + pub fn offsets(&self) -> IndexOffsets<'_> { + IndexOffsets { + index: self, + next: 0, + buffer: Box::new([0; PAGE]), + start: 0, + end: 0, + failed: false, + } + } + /// Start a bounded sequential read at a checked native ordinal. Immutable /// metadata shards use this to cover contiguous ranges without rescanning /// earlier index entries or materializing all IDs. @@ -268,6 +282,45 @@ pub struct IndexIds<'a> { end: usize, failed: bool, } + +pub struct IndexOffsets<'a> { + index: &'a PackIndex, + next: u32, + buffer: Box<[u8; PAGE]>, + start: usize, + end: usize, + failed: bool, +} +impl Iterator for IndexOffsets<'_> { + type Item = io::Result; + fn next(&mut self) -> Option { + if self.failed || self.next == self.index.count { + return None; + } + if self.start == self.end { + let records = (self.index.count - self.next).min((PAGE / 4) as u32) as usize; + self.end = records * 4; + self.start = 0; + if let Err(error) = read_at( + &self.index.file, + &mut self.buffer[..self.end], + self.index.offsets + u64::from(self.next) * 4, + ) { + self.failed = true; + return Some(Err(error)); + } + } + let mut encoded = [0; 4]; + encoded.copy_from_slice(&self.buffer[self.start..self.start + 4]); + self.start += 4; + self.next += 1; + let result = self.index.offset(u32::from_be_bytes(encoded)); + if result.is_err() { + self.failed = true; + } + Some(result) + } +} impl Iterator for IndexIds<'_> { type Item = io::Result; fn next(&mut self) -> Option { diff --git a/crates/canopy-git-format/src/pack_index/tests.rs b/crates/canopy-git-format/src/pack_index/tests.rs index 4fe1ecca..85259001 100644 --- a/crates/canopy-git-format/src/pack_index/tests.rs +++ b/crates/canopy-git-format/src/pack_index/tests.rs @@ -60,10 +60,11 @@ fn validates_empty_and_multi_page_indexes_for_both_formats() -> io::Result<()> { index.pack_checksum(), ObjectId::try_from(vec![7; format.bytes()]).unwrap() ); - for (n, oid) in index.ids().enumerate() { + for (n, (oid, offset)) in index.ids().zip(index.offsets()).enumerate() { let oid = oid?; assert_eq!(u32::from_be_bytes(oid[..4].try_into().unwrap()), n as u32); assert_eq!(index.find(oid)?.unwrap().offset, 12 + n as u64); + assert_eq!(index.find(oid)?.unwrap().offset, offset?); } // Shards start at a native ordinal, including positions crossing the // iterator's buffer boundary. The end is a valid empty iterator. @@ -97,6 +98,20 @@ fn validates_empty_and_multi_page_indexes_for_both_formats() -> io::Result<()> { Ok(()) } +#[test] +fn offset_pages_cover_native_positions_across_a_page_boundary() -> io::Result<()> { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let index = open(&fixture(format, 20_001), format)?; + let mut count = 0; + for offset in index.offsets() { + assert_eq!(offset?, 12 + count); + count += 1; + } + assert_eq!(count, 20_001); + } + Ok(()) +} + #[test] fn rejects_truncation_and_checksum_tampering() { for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { @@ -159,6 +174,7 @@ fn supports_large_offsets_and_rejects_out_of_range_references() -> io::Result<() rehash(&mut bytes, format); let index = open(&bytes, format)?; assert_eq!(index.find(format.zero())?.unwrap().offset, 0x1_0000_0010); + assert_eq!(index.offsets().next().unwrap()?, 0x1_0000_0010); bytes[offsets..offsets + 4].copy_from_slice(&0x8000_0001_u32.to_be_bytes()); rehash(&mut bytes, format); assert!(open(&bytes, format).is_err()); diff --git a/crates/canopy-object-storage/src/artifact.rs b/crates/canopy-object-storage/src/artifact.rs index 893bcff4..545b3e89 100644 --- a/crates/canopy-object-storage/src/artifact.rs +++ b/crates/canopy-object-storage/src/artifact.rs @@ -142,6 +142,18 @@ impl ArtifactStore { size: u64, digest: [u8; 32], input: &mut (impl AsyncRead + Unpin), + ) -> Result { + self.put_owned(key, size, digest, input, Arc::new(())).await + } + /// Queued hashing retains the caller's physical admission after cancellation. + /// The pin grants no artifact namespace or publication authority. + pub async fn put_owned( + &self, + key: ArtifactKey, + size: u64, + digest: [u8; 32], + input: &mut (impl AsyncRead + Unpin), + owner: Arc, ) -> Result { if size > MAX_ARTIFACT_BYTES { return Err(ArtifactError::TooLarge); @@ -158,7 +170,8 @@ impl ArtifactStore { uuid::Uuid::from_bytes(key.operation), uuid::Uuid::new_v4() )); - let mut upload = external::Upload::new(Arc::clone(&self.store), stage).await?; + let mut upload = + external::Upload::new_owned(Arc::clone(&self.store), stage, owner.clone()).await?; let result = async { let mut hash = blake3::Hasher::new(); let mut remaining = size; @@ -169,7 +182,9 @@ impl ArtifactStore { tokio::time::timeout(Duration::from_secs(120), input.read_exact(&mut bytes)) .await .map_err(|_| ArtifactError::Timeout)??; + let activity = owner.clone(); let (next, part, bytes) = tokio::task::spawn_blocking(move || { + let _activity = activity; hash.update(&bytes); let part = *blake3::hash(&bytes).as_bytes(); (hash, part, Bytes::from(bytes)) @@ -213,6 +228,14 @@ impl ArtifactStore { &self, key: ArtifactKey, descriptor: ArtifactDescriptor, + ) -> Result { + self.read_owned(key, descriptor, Arc::new(())).await + } + pub async fn read_owned( + &self, + key: ArtifactKey, + descriptor: ArtifactDescriptor, + owner: Arc, ) -> Result { if descriptor.size > MAX_ARTIFACT_BYTES { return Err(ArtifactError::TooLarge); @@ -238,7 +261,68 @@ impl ArtifactStore { descriptor, offset: 0, hash: Some(blake3::Hasher::new()), + owner, + }) + } + + /// Authenticate the manifest before reading independently selected parts. + /// Each returned part is checked against that manifest. This capability + /// does not claim to recompute the digest of the entire artifact; callers + /// must already hold its certified descriptor and verify decoded objects. + pub async fn ranges_owned( + &self, + key: ArtifactKey, + descriptor: ArtifactDescriptor, + owner: Arc, + ) -> Result { + Ok(ArtifactRanges { + read: self.read_owned(key, descriptor, owner).await?, + }) + } +} + +/// Independent bounded reads retain the caller's physical owner through +/// provider I/O and detached hashing. Unrequested parts are never fetched. +pub struct ArtifactRanges { + read: ArtifactRead, +} +impl ArtifactRanges { + pub async fn part(&self, index: u64) -> Result { + self.part_owned(index, Arc::new(())).await + } + /// Cached range capabilities retain idle file admission; the active caller + /// supplies its work owner separately so an idle cache cannot pin a lease. + pub async fn part_owned( + &self, + index: u64, + work: Arc, + ) -> Result { + let offset = index + .checked_mul(PART_BYTES as u64) + .filter(|offset| *offset < self.read.descriptor.size) + .ok_or(ArtifactError::Corrupt)?; + let expected = self + .read + .manifest + .part_digest(index) + .ok_or(ArtifactError::Corrupt)?; + let bytes = external::read( + self.read.store.as_ref(), + &self.read.path, + &self.read.manifest, + self.read.descriptor.size, + offset, + ) + .await?; + let owner = (self.read.owner.clone(), work); + tokio::task::spawn_blocking(move || { + let _owner = owner; + if blake3::hash(&bytes).as_bytes() != &expected { + return Err(ArtifactError::Corrupt); + } + Ok(bytes) }) + .await? } } @@ -251,6 +335,7 @@ pub struct ArtifactRead { descriptor: ArtifactDescriptor, offset: u64, hash: Option, + owner: Arc, } impl ArtifactRead { pub fn descriptor(&self) -> ArtifactDescriptor { @@ -273,7 +358,9 @@ impl ArtifactRead { .manifest .part_digest(self.offset / PART_BYTES as u64) .ok_or(ArtifactError::Corrupt)?; + let activity = self.owner.clone(); let (next, valid, bytes) = tokio::task::spawn_blocking(move || { + let _activity = activity; let valid = blake3::hash(&bytes).as_bytes() == &expected; hash.update(&bytes); (hash, valid, bytes) diff --git a/crates/canopy-object-storage/src/artifact/tests.rs b/crates/canopy-object-storage/src/artifact/tests.rs index 516c1202..3fe8ba9f 100644 --- a/crates/canopy-object-storage/src/artifact/tests.rs +++ b/crates/canopy-object-storage/src/artifact/tests.rs @@ -11,6 +11,46 @@ fn key(body: &[u8]) -> ArtifactKey { } } +#[tokio::test] +async fn selected_parts_authenticate_without_fetching_unrequested_bytes() -> Result { + let store: Arc = Arc::new(InMemory::new()); + let artifacts = ArtifactStore::new(store.clone(), [1; 16]); + let mut body = vec![27; PART_BYTES + 31]; + body[PART_BYTES..].fill(42); + let key = key(&body); + let descriptor = artifacts + .put( + key, + body.len() as u64, + key.binding_digest, + &mut body.as_slice(), + ) + .await?; + let path = artifacts.path(key, descriptor.digest)?; + // A missing unrelated part must not affect a selected range read. + store.delete(&external::part(&path, 0)).await?; + let ranges = artifacts + .ranges_owned(key, descriptor, Arc::new(())) + .await?; + assert_eq!(ranges.part(1).await?.as_ref(), &[42; 31]); + assert!(ranges.part(0).await.is_err()); + assert!(ranges.part(2).await.is_err()); + assert!(ranges.part(u64::MAX).await.is_err()); + store + .put(&external::part(&path, 1), vec![43; 31].into()) + .await?; + assert!(matches!(ranges.part(1).await, Err(ArtifactError::Corrupt))); + let mut wrong = descriptor; + wrong.manifest_digest[0] ^= 1; + assert!( + artifacts + .ranges_owned(key, wrong, Arc::new(())) + .await + .is_err() + ); + Ok(()) +} + #[tokio::test] async fn catalog_artifacts_bind_their_own_digest_and_isolate_retired_incarnations() -> Result { let store: Arc = Arc::new(InMemory::new()); @@ -367,3 +407,115 @@ async fn empty_manifest_must_certify_the_empty_part() -> Result { fn key_for_empty() -> ArtifactKey { key(b"") } + +// Release even on assertion failure: runtime shutdown must not strand a thread. +struct Release(Option>); +impl Drop for Release { + fn drop(&mut self) { + if let Some(sender) = self.0.take() { + let _ = sender.send(()); + } + } +} + +#[test] +fn canceled_queued_artifact_hashes_retain_physical_owner_until_actual_drain() -> Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .max_blocking_threads(1) + .build()?; + for phase in 0..3 { + let store: Arc = Arc::new(InMemory::new()); + let artifacts = ArtifactStore::new(store.clone(), [1; 16]); + let body = b"physically retained input"; + let key = key(body); + let descriptor = runtime.block_on(artifacts.put( + key, + body.len() as u64, + key.binding_digest, + &mut body.as_slice(), + ))?; + let owner: Arc = Arc::new(()); + let weak = Arc::downgrade(&owner); + let (entered, running) = std::sync::mpsc::channel(); + let (release, wait) = std::sync::mpsc::channel(); + let release = Release(Some(release)); + let blocker = runtime.spawn_blocking(move || { + let _ = entered.send(()); + let _ = wait.recv(); + }); + running.recv_timeout(Duration::from_secs(5))?; + runtime.block_on(async { + match phase { + 0 => { + let mut reader = artifacts.read_owned(key, descriptor, owner.clone()).await?; + { + let pending = reader.next(); + tokio::pin!(pending); + assert!( + tokio::time::timeout(Duration::from_millis(25), &mut pending) + .await + .is_err() + ); + } + assert!(matches!(reader.next().await, Err(ArtifactError::Corrupt))); + drop(reader); + } + 1 => { + let mut input = body.as_slice(); + let pending = artifacts.put_owned( + key, + body.len() as u64, + key.binding_digest, + &mut input, + owner.clone(), + ); + tokio::pin!(pending); + assert!( + tokio::time::timeout(Duration::from_millis(25), &mut pending) + .await + .is_err() + ); + } + _ => { + let mut upload = external::Upload::new_owned( + store.clone(), + Path::from("stage"), + owner.clone(), + ) + .await?; + upload.write(Bytes::from_static(body)).await?; + let digests = [key.binding_digest]; + let destination = Path::from("destination"); + { + let pending = + upload.publish_hashed(&destination, body.len() as u64, &digests); + tokio::pin!(pending); + assert!( + tokio::time::timeout(Duration::from_millis(25), &mut pending) + .await + .is_err() + ); + } + assert!(matches!( + store.head(&Path::from("destination")).await, + Err(object_store::Error::NotFound { .. }) + )); + drop(upload); + } + } + Ok::<_, Box>(()) + })?; + // All async owners are gone. The confirmed queued hash alone owns it. + drop(owner); + assert!(weak.upgrade().is_some(), "phase {phase}"); + drop(release); + runtime.block_on(async { + blocker.await?; + tokio::task::spawn_blocking(|| ()).await?; + Ok::<_, tokio::task::JoinError>(()) + })?; + assert!(weak.upgrade().is_none(), "phase {phase}"); + } + Ok(()) +} diff --git a/crates/canopy-object-storage/src/external.rs b/crates/canopy-object-storage/src/external.rs index 303c245b..07f06679 100644 --- a/crates/canopy-object-storage/src/external.rs +++ b/crates/canopy-object-storage/src/external.rs @@ -69,16 +69,25 @@ pub struct Upload { stage: Path, parts: u64, active: Option>, + owner: Arc, } impl Upload { pub async fn new(store: Arc, stage: Path) -> object_store::Result { + Self::new_owned(store, stage, Arc::new(())).await + } + pub async fn new_owned( + store: Arc, + stage: Path, + owner: Arc, + ) -> object_store::Result { let active = timed(store.put_multipart(&part(&stage, 0))).await?; Ok(Self { store, stage, parts: 0, active: Some(active), + owner, }) } @@ -137,9 +146,13 @@ impl Upload { length, ) .await?; - let digest = tokio::task::spawn_blocking(move || blake3::hash(&bytes)) - .await - .map_err(|_| invalid())?; + let activity = self.owner.clone(); + let digest = tokio::task::spawn_blocking(move || { + let _activity = activity; + blake3::hash(&bytes) + }) + .await + .map_err(|_| invalid())?; if digest.as_bytes() != expected { return Err(invalid()); } diff --git a/crates/canopy-server/Cargo.toml b/crates/canopy-server/Cargo.toml index 3dbe6650..6fb4d336 100644 --- a/crates/canopy-server/Cargo.toml +++ b/crates/canopy-server/Cargo.toml @@ -9,6 +9,7 @@ name = "canopy" path = "src/main.rs" [dependencies] +async-trait = "0.1" canopy-git-format = { path = "../canopy-git-format" } canopy-object-storage = { path = "../canopy-object-storage" } axum = "0.8.9" @@ -28,7 +29,7 @@ hex = "0.4" http-body = "1" object_store = "0.14.1" percent-encoding = "2" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } rusqlite = { version = "0.34", features = ["bundled", "hooks"] } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -54,4 +55,3 @@ libc = "0.2" rcgen = "0.14.10" tokio-rustls = "0.26.5" tokio = { version = "1.49", features = ["test-util"] } -async-trait = "0.1" diff --git a/crates/canopy-server/src/admission.rs b/crates/canopy-server/src/admission.rs index 6afd514d..b97138d4 100644 --- a/crates/canopy-server/src/admission.rs +++ b/crates/canopy-server/src/admission.rs @@ -4,9 +4,9 @@ use crate::ReadIdentity; use cellule_runtime::Error; use std::{ collections::HashMap, - sync::{Arc, Weak}, + sync::{Arc, Mutex, Weak}, }; -use tokio::sync::{Mutex, OwnedSemaphorePermit, Semaphore}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; /// Node and account admission retained together by work and streamed output. pub struct AdmissionPermit { @@ -47,10 +47,14 @@ impl AccountAdmission { } pub(crate) async fn acquire(&self, actor: ReadIdentity<'_>) -> Result { + self.try_acquire(actor) + } + + pub(crate) fn try_acquire(&self, actor: ReadIdentity<'_>) -> Result { let total = Arc::clone(&self.total) .try_acquire_owned() .map_err(|_| Error::Capacity(self.total_capacity))?; - let semaphore = self.account(actor).await; + let semaphore = self.account(actor); let account = semaphore .try_acquire_owned() .map_err(|_| Error::Capacity(self.account_capacity))?; @@ -67,7 +71,6 @@ impl AccountAdmission { // cannot fill every pending position while another account has work. let _account_waiting = self .waiting_account(actor) - .await .try_acquire_owned() .map_err(|_| Error::Capacity(self.account_capacity))?; let _waiting = self @@ -76,7 +79,6 @@ impl AccountAdmission { .map_err(|_| Error::Capacity(self.total_capacity))?; let account = self .account(actor) - .await .acquire_owned() .await .map_err(|_| Error::Capacity(self.account_capacity))?; @@ -90,15 +92,15 @@ impl AccountAdmission { }) } - async fn account(&self, actor: ReadIdentity<'_>) -> Arc { - Self::semaphore(&self.accounts, actor, self.account_limit).await + fn account(&self, actor: ReadIdentity<'_>) -> Arc { + Self::semaphore(&self.accounts, actor, self.account_limit) } - async fn waiting_account(&self, actor: ReadIdentity<'_>) -> Arc { - Self::semaphore(&self.waiting_accounts, actor, self.account_limit).await + fn waiting_account(&self, actor: ReadIdentity<'_>) -> Arc { + Self::semaphore(&self.waiting_accounts, actor, self.account_limit) } - async fn semaphore( + fn semaphore( entries: &Mutex, Weak>>, actor: ReadIdentity<'_>, limit: usize, @@ -108,7 +110,7 @@ impl AccountAdmission { ReadIdentity::Anonymous => None, }; { - let mut accounts = entries.lock().await; + let mut accounts = entries.lock().expect("account admission"); // Permits retain their semaphore through detached ownership work. // Active or waiting admission bounds this map; expired accounts need no state. accounts.retain(|_, semaphore| semaphore.strong_count() != 0); @@ -234,6 +236,9 @@ mod tests { .unwrap(), ); } - assert_eq!(admission.accounts.lock().await.len(), 1); + assert_eq!( + admission.accounts.lock().expect("account admission").len(), + 1 + ); } } diff --git a/crates/canopy-server/src/branch_rules/mod.rs b/crates/canopy-server/src/branch_rules/mod.rs index cc640007..b4a21490 100644 --- a/crates/canopy-server/src/branch_rules/mod.rs +++ b/crates/canopy-server/src/branch_rules/mod.rs @@ -179,7 +179,14 @@ impl RepositoryCell { .query( None, SqlBatch { - statements: updates.iter().map(policy_statement).collect(), + // Native preparation establishes ancestry in its private + // workspace; final publication supplies catalog-certified + // evidence. Metadata reads must not consult the retired + // mutable commit_ancestry cache. + statements: updates + .iter() + .map(|update| policy_statement_with_ancestry(update, false)) + .collect(), }, ) .await?; @@ -246,6 +253,13 @@ pub(crate) struct Policy { require_pull_request: bool, } impl Policy { + pub(crate) fn allows_reviewed( + &self, + update: &RefUpdate, + reviewed: &crate::pulls::merge::ReviewedMerge, + ) -> bool { + self.allows_ref(update, true) && (!self.require_pull_request || reviewed.authorizes(update)) + } pub(crate) fn allows(&self, update: &RefUpdate, require_ancestry: bool) -> bool { !self.require_pull_request && self.allows_ref(update, require_ancestry) } diff --git a/crates/canopy-server/src/checks/mod.rs b/crates/canopy-server/src/checks/mod.rs index c1bb5d7d..e8453a36 100644 --- a/crates/canopy-server/src/checks/mod.rs +++ b/crates/canopy-server/src/checks/mod.rs @@ -3,6 +3,8 @@ use crate::ReadIdentity; mod mutations; +pub(crate) mod native; +pub use native::NativeCheckError; use crate::{RepositoryCell, directory::validate_component, validate_repository_id}; use cellule_runtime::{ @@ -179,20 +181,33 @@ impl RepositoryCell { actor: impl Into>, oid: crate::ObjectId, after: Option<&str>, - ) -> Result>>, Invocation> { + ) -> Result>>, NativeCheckError> { let actor = actor.into(); - let mut parameters = cursor_parameters(actor, after)?; - parameters.push(SqlValue::Blob(oid.to_vec())); - let result = self.check_rows( - SqlStatement { sql: format!("SELECT ({ACCESS}) AND EXISTS (SELECT 1 FROM objects WHERE oid = ?2 AND kind = 'commit')"), parameters: vec![parameters[0].clone(), parameters[2].clone()] }, - SqlStatement { - sql: format!("SELECT c.name, c.reporter, c.enabled, c.version, {RUN_COLUMNS} FROM check_contexts c LEFT JOIN check_runs r ON r.number = (SELECT number FROM check_runs WHERE oid = ?3 AND context = c.name AND context_version = c.version ORDER BY number DESC LIMIT 1) WHERE c.enabled = 1 AND c.name > ?2 AND ({ACCESS}) ORDER BY c.name LIMIT {CHECK_PAGE_SIZE}"), parameters, - }, - ).await?; + if let Some(cursor) = after { + validate_component(cursor)?; + } + // Keep the actual borrow until the receiver verifies the retained pin. + let (_snapshot, selection) = self.check_selection(actor, oid).await?; + let result = self + .application + .query::( + &self.target, + None, + native::CommitPage { + selection, + after: after.map(str::to_owned), + }, + ) + .await + .map_err(|e| NativeCheckError::Read(Box::new(e)))?; let output = result .output - .map(|rows| { - rows.iter() + .map(|sets| { + let rows = sets + .first() + .ok_or(Error::Command("missing native checks page"))?; + rows.rows + .iter() .map(|row| { if row.len() != 14 { return Err(Error::Command("invalid commit checks row")); @@ -209,7 +224,7 @@ impl RepositoryCell { .collect() }) .transpose() - .map_err(Invocation::NotStarted)?; + .map_err(NativeCheckError::Invalid)?; Ok(Observed { output, receipt: result.receipt, diff --git a/crates/canopy-server/src/checks/mutations.rs b/crates/canopy-server/src/checks/mutations.rs index 74d32c4f..76cbf1df 100644 --- a/crates/canopy-server/src/checks/mutations.rs +++ b/crates/canopy-server/src/checks/mutations.rs @@ -45,34 +45,37 @@ impl RepositoryCell { identity: MutationIdentity, actor: &str, input: NewCheck<'_>, - ) -> Result, Invocation> { - for name in [actor, input.context] { - validate_component(name).map_err(Invocation::NotStarted)?; + ) -> Result, NativeCheckError> { + for value in [actor, input.context] { + validate_component(value)?; } - validate_repository_id(input.id).map_err(Invocation::NotStarted)?; + validate_repository_id(input.id)?; if input.context_version < 1 { - return Err(Invocation::NotStarted(Error::Command( - "invalid check context version", - ))); + return Err(Error::Command("invalid check context version").into()); + } + let (_snapshot, selection) = self + .check_selection(ReadIdentity::Account(actor), input.oid) + .await?; + let result = self + .application + .command::( + &self.target, + identity, + native::CheckStart { + selection, + id: input.id, + context: input.context.into(), + context_version: input.context_version, + }, + ) + .await; + // A recorded policy rejection is a domain outcome with its original + // receipt. Pending/transport failures must never be converted to one. + match result { + Ok(value) => Ok(value), + Err(InvocationError::Rejected(value)) => Ok(*value), + Err(error) => Err(NativeCheckError::Start(Box::new(error))), } - let mut parameters = vec![ - SqlValue::Text(actor.into()), - SqlValue::Blob(input.id.to_vec()), - SqlValue::Blob(input.oid.to_vec()), - SqlValue::Text(input.context.into()), - SqlValue::Integer(input.context_version), - ]; - let decision = format!( - "CASE WHEN NOT ({ACCESS}) OR NOT EXISTS (SELECT 1 FROM objects WHERE oid = ?3 AND kind = 'commit') OR NOT EXISTS (SELECT 1 FROM check_contexts WHERE name = ?4) THEN 'missing' WHEN NOT EXISTS (SELECT 1 FROM check_contexts WHERE name = ?4 AND reporter = ?1) THEN 'forbidden' WHEN NOT EXISTS (SELECT 1 FROM check_contexts WHERE name = ?4 AND version = ?5 AND enabled = 1) OR EXISTS (SELECT 1 FROM check_runs WHERE id = ?2 AND (oid != ?3 OR context != ?4 OR context_version != ?5 OR reporter != ?1)) THEN 'conflict' ELSE 'applied' END" - ); - let check = SqlStatement { - sql: format!("SELECT {decision}"), - parameters: parameters.clone(), - }; - parameters.push(SqlValue::Integer(identity.issued_at_ms)); - self.check_change(identity, vec![check, - SqlStatement { sql: format!("INSERT INTO check_runs (id, oid, context, context_version, reporter, state, version, summary, created_ms, updated_ms) SELECT ?2, ?3, ?4, ?5, ?1, 'queued', 1, '', ?6, ?6 WHERE ({decision}) = 'applied' AND NOT EXISTS (SELECT 1 FROM check_runs WHERE id = ?2)"), parameters }, - ]).await } /// Advances an active attempt for its still-configured reporter and policy version. diff --git a/crates/canopy-server/src/checks/native.rs b/crates/canopy-server/src/checks/native.rs new file mode 100644 index 00000000..8ea0847b --- /dev/null +++ b/crates/canopy-server/src/checks/native.rs @@ -0,0 +1,188 @@ +//! Check metadata receivers consume privately issued, bounded commit membership. +use super::*; +use crate::{ + ObjectId, RepositoryModule, + packs::publication::{ + CommitMembership, MembershipRequest, ServingOwnerError, ServingReadError, + }, +}; +use cellule_runtime::codec::{BoundedDecoder, BoundedEncoder, CodecError, WireValue}; +use cellule_runtime::{ + CellId, CellModule, Command, Query, + registry::{CommandContext, CommandResult, OwnerFence, QueryContext}, +}; +mod codec; + +#[derive(Debug, thiserror::Error)] +pub enum NativeCheckError { + #[error("invalid native check request")] + Invalid(#[from] Error), + #[error("native check membership unavailable")] + Owner(#[from] ServingOwnerError), + #[error("native check membership failed")] + Membership(#[from] ServingReadError), + #[error("native check page failed")] + Read(#[source] Box>>>), + #[error("native check start failed")] + Start(#[source] Box>), +} +#[derive(Clone, Debug)] +pub(crate) struct CommitSelection { + pub(crate) repository: [u8; 16], + pub(crate) actor: Option, + pub(crate) oid: ObjectId, + pub(crate) membership: Option, +} +impl CommitSelection { + fn authorized( + &self, + cell: CellId, + owner: Option, + now: i64, + query: impl FnMut(&SqlBatch) -> cellule_runtime::Result>, + ) -> cellule_runtime::Result { + let Some(proof) = &self.membership else { + return Ok(false); + }; + proof.authorize( + MembershipRequest { + cell, + owner, + repository: self.repository, + actor: &self.actor, + oid: self.oid, + admitted_ms: now, + }, + query, + ) + } +} +#[derive(Clone, Debug)] +pub(crate) struct CommitPage { + pub(crate) selection: CommitSelection, + pub(crate) after: Option, +} +#[derive(Clone, Debug)] +pub(crate) struct CheckStart { + pub(crate) selection: CommitSelection, + pub(crate) id: [u8; 16], + pub(crate) context: String, + pub(crate) context_version: i64, +} + +pub(crate) struct ReadCommitChecks; +impl Query for ReadCommitChecks { + const MODULE: &'static str = RepositoryModule::NAME; + const ID: u32 = 50; + const CODEC_VERSION: u32 = 1; + type Input = CommitPage; + type Output = Option>; + fn execute( + context: &mut QueryContext<'_>, + input: Self::Input, + ) -> cellule_runtime::Result { + input.encode(&mut BoundedEncoder::new(4096)?)?; + if !input + .selection + .authorized(context.cell_id(), None, context.now_ms(), |q| { + context.sql(q) + })? + { + return Ok(None); + } + let actor = input + .selection + .actor + .as_deref() + .map_or(ReadIdentity::Anonymous, ReadIdentity::Account); + Ok(Some(context.sql(&SqlBatch { statements: vec![SqlStatement { + sql: format!("SELECT c.name, c.reporter, c.enabled, c.version, {RUN_COLUMNS} FROM check_contexts c LEFT JOIN check_runs r ON r.number = (SELECT number FROM check_runs WHERE oid = ?3 AND context = c.name AND context_version = c.version ORDER BY number DESC LIMIT 1) WHERE c.enabled = 1 AND c.name > ?2 AND ({ACCESS}) ORDER BY c.name LIMIT {CHECK_PAGE_SIZE}"), + parameters: vec![actor.parameter(), SqlValue::Text(input.after.unwrap_or_default()), SqlValue::Blob(input.selection.oid.to_vec())], + }]})?)) + } +} +pub(crate) struct StartCommitCheck; +impl Command for StartCommitCheck { + const MODULE: &'static str = RepositoryModule::NAME; + const ID: u32 = 49; + const CODEC_VERSION: u32 = 1; + type Input = CheckStart; + type Output = CheckChange; + fn execute( + context: &mut CommandContext<'_, '_>, + input: Self::Input, + ) -> cellule_runtime::Result> { + input.encode(&mut BoundedEncoder::new(4096)?)?; + if !input.selection.authorized( + context.target().cell_id(), + Some(context.owner_fence()), + context.now_ms(), + |q| context.sql(q), + )? { + return Ok(CommandResult::Rejected(CheckChange::NotFound)); + } + let actor = input + .selection + .actor + .ok_or(Error::Command("check reporter missing"))?; + let mut parameters = vec![ + SqlValue::Text(actor), + SqlValue::Blob(input.id.to_vec()), + SqlValue::Blob(input.selection.oid.to_vec()), + SqlValue::Text(input.context), + SqlValue::Integer(input.context_version), + ]; + let decision = format!( + "CASE WHEN NOT ({ACCESS}) OR NOT EXISTS (SELECT 1 FROM check_contexts WHERE name = ?4) THEN 'missing' WHEN NOT EXISTS (SELECT 1 FROM check_contexts WHERE name = ?4 AND reporter = ?1) THEN 'forbidden' WHEN NOT EXISTS (SELECT 1 FROM check_contexts WHERE name = ?4 AND version = ?5 AND enabled = 1) OR EXISTS (SELECT 1 FROM check_runs WHERE id = ?2 AND (oid != ?3 OR context != ?4 OR context_version != ?5 OR reporter != ?1)) THEN 'conflict' ELSE 'applied' END" + ); + let check = SqlStatement { + sql: format!("SELECT {decision}"), + parameters: parameters.clone(), + }; + parameters.push(SqlValue::Integer(context.now_ms())); + let result = context.sql(&SqlBatch { statements:vec![check, SqlStatement { + sql:format!("INSERT INTO check_runs (id, oid, context, context_version, reporter, state, version, summary, created_ms, updated_ms) SELECT ?2, ?3, ?4, ?5, ?1, 'queued', 1, '', ?6, ?6 WHERE ({decision}) = 'applied' AND NOT EXISTS (SELECT 1 FROM check_runs WHERE id = ?2)"), parameters, + }]})?; + let value = result + .first() + .and_then(|s| s.rows.first()) + .map(Vec::as_slice); + match value { + Some([SqlValue::Text(v)]) if v == "applied" => { + Ok(CommandResult::Success(CheckChange::Applied)) + } + Some([SqlValue::Text(v)]) if v == "missing" => { + Ok(CommandResult::Rejected(CheckChange::NotFound)) + } + Some([SqlValue::Text(v)]) if v == "forbidden" => { + Ok(CommandResult::Rejected(CheckChange::Forbidden)) + } + Some([SqlValue::Text(v)]) if v == "conflict" => { + Ok(CommandResult::Rejected(CheckChange::Conflict)) + } + _ => Err(Error::Command("invalid native check outcome")), + } + } +} +impl RepositoryCell { + pub(super) async fn check_selection( + &self, + actor: ReadIdentity<'_>, + oid: ObjectId, + ) -> Result<(crate::packs::publication::ServingSnapshot, CommitSelection), NativeCheckError> + { + actor.validate()?; + let snapshot = self.serving_snapshot(actor).await?; + let membership = snapshot.commit_membership(oid).await?; + let selection = CommitSelection { + repository: self.id, + actor: match actor { + ReadIdentity::Anonymous => None, + ReadIdentity::Account(v) => Some(v.into()), + }, + oid, + membership, + }; + Ok((snapshot, selection)) + } +} diff --git a/crates/canopy-server/src/checks/native/codec.rs b/crates/canopy-server/src/checks/native/codec.rs new file mode 100644 index 00000000..ffee2e4f --- /dev/null +++ b/crates/canopy-server/src/checks/native/codec.rs @@ -0,0 +1,98 @@ +use super::*; +fn fixed(d: &mut BoundedDecoder<'_>) -> Result<[u8; N], CodecError> { + d.read_bytes()?.try_into().map_err(|_| invalid()) +} +fn invalid() -> CodecError { + CodecError::Invalid("invalid native check input") +} +impl WireValue for CommitSelection { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + validate_repository_id(self.repository).map_err(|_| invalid())?; + if self + .actor + .as_deref() + .is_some_and(|a| validate_component(a).is_err()) + { + return Err(invalid()); + } + e.write_bytes(&self.repository)?; + self.actor.encode(e)?; + e.write_bytes(self.oid.as_ref())?; + self.membership.encode(e) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + let value = Self { + repository: fixed(d)?, + actor: Option::::decode(d)?, + oid: ObjectId::try_from(d.read_bytes()?).map_err(|_| invalid())?, + membership: Option::::decode(d)?, + }; + value.encode(&mut BoundedEncoder::new(4096)?)?; + Ok(value) + } +} +impl WireValue for CommitPage { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + if self + .after + .as_deref() + .is_some_and(|a| validate_component(a).is_err()) + { + return Err(invalid()); + } + self.selection.encode(e)?; + self.after.encode(e) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + let value = Self { + selection: CommitSelection::decode(d)?, + after: Option::::decode(d)?, + }; + value.encode(&mut BoundedEncoder::new(4096)?)?; + Ok(value) + } +} +impl WireValue for CheckStart { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + if self.selection.actor.is_none() + || validate_repository_id(self.id).is_err() + || validate_component(&self.context).is_err() + || self.context_version < 1 + { + return Err(invalid()); + } + self.selection.encode(e)?; + e.write_bytes(&self.id)?; + e.write_text(&self.context)?; + e.write_i64(self.context_version) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + let value = Self { + selection: CommitSelection::decode(d)?, + id: fixed(d)?, + context: d.read_text()?.into(), + context_version: d.read_i64()?, + }; + value.encode(&mut BoundedEncoder::new(4096)?)?; + Ok(value) + } +} +impl WireValue for CheckChange { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + e.write_u8(match self { + Self::Applied => 0, + Self::NotFound => 1, + Self::Forbidden => 2, + Self::Conflict => 3, + }) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + match d.read_u8()? { + 0 => Ok(Self::Applied), + 1 => Ok(Self::NotFound), + 2 => Ok(Self::Forbidden), + 3 => Ok(Self::Conflict), + _ => Err(invalid()), + } + } +} diff --git a/crates/canopy-server/src/default_branch.rs b/crates/canopy-server/src/default_branch.rs index d3d1a731..6ed4a026 100644 --- a/crates/canopy-server/src/default_branch.rs +++ b/crates/canopy-server/src/default_branch.rs @@ -6,7 +6,7 @@ use cellule_runtime::{ primitives::sql::SqlValue, }; -use crate::{RepositoryCell, directory::validate_component, refs::valid_ref_name}; +use crate::{ReadIdentity, RepositoryCell, directory::validate_component, refs::valid_ref_name}; /// Repository symbolic HEAD and the ref generation required to change it. #[derive(Clone, Debug, PartialEq, Eq)] @@ -39,13 +39,42 @@ impl RepositoryCell { }) } - /// Changes HEAD only for the owner at the expected ref generation. - /// - /// The target must be a live branch, or there must be no live branches. - /// False means authorization, generation or target existence failed. + /// Reads the constant-size summary with current access in the same query. + /// Native publishers update this row atomically with the immutable snapshot; + /// metadata reads acquire no serving pin and publish no Cell root. + pub async fn default_branch_for( + &self, + actor: ReadIdentity<'_>, + minimum: Option, + ) -> Result>, InvocationError>> { + actor.validate().map_err(InvocationError::NotStarted)?; + let result = self.sql.query(minimum, SqlBatch { + statements: vec![SqlStatement { + sql: format!("SELECT generation, default_branch FROM ref_generation WHERE singleton=1 AND ({}) AND EXISTS (SELECT 1 FROM catalog_state s JOIN catalog_generations g ON g.generation=s.generation WHERE s.singleton=1 AND g.refs IS NOT NULL)", crate::access::READ_ACCESS), + parameters: vec![actor.parameter()], + }], + }).await?; + let output = result + .output + .first() + .ok_or_else(|| { + InvocationError::NotStarted(Error::Command("missing HEAD query result")) + })? + .rows + .first() + .map(|row| decode_head(row)) + .transpose() + .map_err(InvocationError::NotStarted)?; + Ok(Observed { + output, + receipt: result.receipt, + }) + } + + /// Retired SQL writer: HEAD changes require resident native publication. pub async fn set_default_branch( &self, - identity: MutationIdentity, + _identity: MutationIdentity, actor: &str, expected_generation: i64, reference: &str, @@ -56,21 +85,9 @@ impl RepositoryCell { "invalid default branch update", ))); } - // HEAD and the pagination fence change in the same owner-authorized - // statement. A concurrent push or HEAD ABA makes a stale update fail. - let result = self.sql.batch(identity, SqlBatch { - statements: vec![SqlStatement { - sql: "UPDATE ref_generation SET default_branch = ?1, generation = generation + 1 WHERE singleton = 1 AND generation = ?2 AND EXISTS (SELECT 1 FROM repository_identity WHERE owner = ?3) AND (EXISTS (SELECT 1 FROM refs WHERE name = ?1 AND oid IS NOT NULL) OR NOT EXISTS (SELECT 1 FROM refs WHERE name GLOB 'refs/heads/*' AND oid IS NOT NULL))".into(), - parameters: vec![SqlValue::Text(reference.into()), SqlValue::Integer(expected_generation), SqlValue::Text(actor.into())], - }], - }).await?; - Ok(Committed { - output: result - .output - .first() - .is_some_and(|set| set.rows_affected == 1), - receipt: result.receipt, - }) + Err(InvocationError::NotStarted(Error::Command( + "default branch changes require resident native publication", + ))) } } diff --git a/crates/canopy-server/src/deployment/backup/bodies.rs b/crates/canopy-server/src/deployment/backup/bodies.rs index fecb42aa..7b68f3c3 100644 --- a/crates/canopy-server/src/deployment/backup/bodies.rs +++ b/crates/canopy-server/src/deployment/backup/bodies.rs @@ -1,24 +1,9 @@ use super::*; -use crate::{ - blob::{LargeBlobReference, LargeBlobStore, blob_path}, - lfs::{LfsObject, lfs_path, verify_lfs_object}, -}; -use cellule_ltx::rusqlite::{Connection, OpenFlags, OptionalExtension, params}; +use crate::lfs::{LfsObject, lfs_path, verify_lfs_object}; +use cellule_ltx::rusqlite::{Connection, OpenFlags, params}; use cellule_runtime::{NodeLeaseGuard, cell::catalog::CatalogRole}; use object_store::{ObjectStore, prefix::PrefixStore}; -#[derive(Clone, Copy)] -enum BodyKind { - Git, - Pack, - PackIndex, - Lfs, -} -enum Reference { - Git(LargeBlobReference), - Lfs(LfsObject), -} - impl Deployment { pub(super) async fn verify_bodies( &self, @@ -82,7 +67,8 @@ impl Deployment { u64::from(verified.page_size()) * u64::from(verified.database_pages()); let _disk = host.local_disk_budget().try_reserve(database_bytes)?; verified.restore(&database).await?; - let repository_id = read_identity(database.clone()).await?; + let identity = native::identity(database.clone()).await?; + let repository_id = identity.as_ref().map(|value| value.repository); if let Some(id) = repository_id { let target = crate::repository_target( self.identity.tenant(), @@ -95,22 +81,34 @@ impl Deployment { )); } } - for kind in [ - BodyKind::Git, - BodyKind::Pack, - BodyKind::PackIndex, - BodyKind::Lfs, - ] { + count = count + .checked_add( + native::verify( + self, + host, + guard, + &database, + &directory, + identity, + source, + source_store.clone(), + destination_store.clone(), + ) + .await?, + ) + .ok_or(BackupError::Invalid("external object count overflow"))?; + { let mut cursor = Vec::new(); loop { - let page = read_page(database.clone(), kind, cursor).await?; + let page = read_page(database.clone(), cursor).await?; if page.is_empty() { break; } - cursor = match page.last().ok_or(BackupError::Invalid("empty page"))? { - Reference::Git(value) => value.oid.to_vec(), - Reference::Lfs(value) => value.sha256.to_vec(), - }; + cursor = page + .last() + .ok_or(BackupError::Invalid("empty page"))? + .sha256 + .to_vec(); for reference in page { // A crash may leave a provisioned Cell before owner initialization. // That Cell can be empty, but external bytes require a durable UUID. @@ -118,16 +116,10 @@ impl Deployment { "external body has no repository identity", ))?; guard.check()?; - let path = match &reference { - Reference::Git(value) => blob_path(repository_id, &value.sha256), - Reference::Lfs(value) => lfs_path(repository_id, &value.sha256), - }; + let path = lfs_path(repository_id, &reference.sha256); if let (Some(source), Some(source_store)) = (source, &source_store) { verify(source_store.clone(), repository_id, &reference).await?; - let size = match &reference { - Reference::Git(value) => value.size, - Reference::Lfs(value) => value.size, - }; + let size = reference.size; crate::external::copy_parts( self.layout.store().inner().as_ref(), &source.parts().chain(path.parts()).collect(), @@ -161,73 +153,29 @@ impl Deployment { } } -async fn read_identity(database: PathBuf) -> BackupResult> { - tokio::task::spawn_blocking(move || { - let connection = Connection::open_with_flags( - database, - OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, - )?; - let id: Option> = connection - .query_row( - "SELECT repository_id FROM repository_identity WHERE singleton = 1", - [], - |row| row.get(0), - ) - .optional()?; - id.map(|id| { - id.try_into() - .map_err(|_| BackupError::Invalid("invalid repository UUID")) - }) - .transpose() - }) - .await? -} - async fn verify( store: Arc, repository_id: [u8; 16], - reference: &Reference, + reference: &LfsObject, ) -> BackupResult<()> { - match reference { - Reference::Git(value) => { - LargeBlobStore::new(store, repository_id) - .verify(value) - .await?; - } - Reference::Lfs(value) => { - verify_lfs_object(store, repository_id, *value, None).await?; - } - } + verify_lfs_object(store, repository_id, *reference, None).await?; Ok(()) } -async fn read_page( - database: PathBuf, - kind: BodyKind, - cursor: Vec, -) -> BackupResult> { +async fn read_page(database: PathBuf, cursor: Vec) -> BackupResult> { tokio::task::spawn_blocking(move || { let connection = Connection::open_with_flags(database, OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX)?; - let sql = match kind { - BodyKind::Git => "SELECT oid, size, digest, external_sha256 FROM objects WHERE storage = 'external' AND oid > ?1 ORDER BY oid LIMIT 256", - BodyKind::Pack => "SELECT DISTINCT pack_oid, pack_size, pack_digest, sha256 FROM git_packs WHERE pack_oid > ?1 ORDER BY pack_oid LIMIT 256", - BodyKind::PackIndex => "SELECT DISTINCT index_oid, index_size, index_digest, index_sha256 FROM git_packs WHERE index_oid > ?1 ORDER BY index_oid LIMIT 256", - BodyKind::Lfs => "SELECT sha256, size, digest, sha256 FROM lfs_objects WHERE sha256 > ?1 ORDER BY sha256 LIMIT 256", - }; - let mut statement = connection.prepare(sql)?; + let mut statement = connection.prepare("SELECT sha256,size,digest FROM lfs_objects WHERE sha256 > ?1 ORDER BY sha256 LIMIT 256")?; let mut rows = statement.query(params![cursor])?; let mut page = Vec::new(); while let Some(row) = rows.next()? { - let oid: Vec = row.get(0)?; + let sha256: Vec = row.get(0)?; let size: i64 = row.get(1)?; let digest: Vec = row.get(2)?; - let sha256: Vec = row.get(3)?; - let size = u64::try_from(size).map_err(|_| BackupError::Invalid("invalid body size"))?; - let digest = digest.try_into().map_err(|_| BackupError::Invalid("invalid body digest"))?; - let sha256 = sha256.try_into().map_err(|_| BackupError::Invalid("invalid body SHA-256"))?; - page.push(match kind { - BodyKind::Git | BodyKind::Pack | BodyKind::PackIndex => Reference::Git(LargeBlobReference { oid: oid.try_into().map_err(|_| BackupError::Invalid("invalid Git OID"))?, size, blake3: digest, sha256 }), - BodyKind::Lfs => Reference::Lfs(LfsObject { sha256, size, parts_digest: digest }), + page.push(LfsObject { + sha256: sha256.try_into().map_err(|_| BackupError::Invalid("invalid LFS SHA-256"))?, + size: size.try_into().map_err(|_| BackupError::Invalid("invalid LFS size"))?, + parts_digest: digest.try_into().map_err(|_| BackupError::Invalid("invalid LFS digest"))?, }); } Ok(page) diff --git a/crates/canopy-server/src/deployment/backup/mod.rs b/crates/canopy-server/src/deployment/backup/mod.rs index 18453490..76dd6ace 100644 --- a/crates/canopy-server/src/deployment/backup/mod.rs +++ b/crates/canopy-server/src/deployment/backup/mod.rs @@ -17,6 +17,7 @@ use std::path::PathBuf; mod bodies; mod enrollment; +mod native; const MAX_CELLS: usize = 100_000; @@ -42,6 +43,8 @@ pub enum BackupError { Io(#[from] std::io::Error), #[error("backup task failed")] Task(#[from] tokio::task::JoinError), + #[error("backup native artifact graph failed")] + Native(#[source] Box), #[error("backup rejected: {0}")] Invalid(&'static str), } diff --git a/crates/canopy-server/src/deployment/backup/native.rs b/crates/canopy-server/src/deployment/backup/native.rs new file mode 100644 index 00000000..77371c15 --- /dev/null +++ b/crates/canopy-server/src/deployment/backup/native.rs @@ -0,0 +1,366 @@ +//! Pinned native roots, keyset-paged SQL inventory and admitted disk deduplication. +use super::*; +use crate::{ + ObjectFormat, + packs::{ + backup::{ArtifactVisitor, Inventory}, + directory::index::WalkResult, + }, +}; +use canopy_object_storage::artifact::{ArtifactDescriptor, ArtifactKey, ArtifactStore}; +use cellule_ltx::{ + DiskReservation, + rusqlite::{Connection, OpenFlags, OptionalExtension, params, params_from_iter, types::Value}, +}; +use cellule_runtime::NodeLeaseGuard; +use object_store::ObjectStore; +use std::sync::Mutex; + +pub(super) struct Identity { + pub repository: [u8; 16], + format: ObjectFormat, + seed: [u8; 32], +} +pub(super) async fn identity(database: PathBuf) -> BackupResult> { + tokio::task::spawn_blocking(move || { + let c = Connection::open_with_flags(database,OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX)?; + let row = c.query_row("SELECT repository_id,object_format,push_cert_seed FROM repository_identity WHERE singleton=1",[],|row| Ok((row.get::<_,Vec>(0)?,row.get::<_,String>(1)?,row.get::<_,Vec>(2)?))).optional()?; + row.map(|(repository,format,seed)| Ok(Identity { + repository: repository.try_into().map_err(|_| BackupError::Invalid("invalid repository UUID"))?, + format: match format.as_str() { "sha1" => ObjectFormat::Sha1, "sha256" => ObjectFormat::Sha256, _ => return Err(BackupError::Invalid("invalid repository format")) }, + seed: seed.try_into().map_err(|_| BackupError::Invalid("invalid repository seed"))?, + })).transpose() + }).await? +} +struct Purpose { + table: &'static str, + keys: &'static str, + fields: &'static [&'static str], + predicate: &'static str, + cursor: Vec, +} +fn purposes() -> Vec { + [ + ( + "catalog_generations", + "generation", + &["catalog", "refs"][..], + "1", + vec![Value::Integer(-1)], + ), + ( + "pushes", + "id", + &["response_root"][..], + "response_root IS NOT NULL", + vec![Value::Blob(vec![])], + ), + ( + "pull_merges", + "id", + &["publication"][..], + "1", + vec![Value::Blob(vec![])], + ), + ( + "merge_candidates", + "id", + &["native_publication"][..], + "native_publication IS NOT NULL", + vec![Value::Blob(vec![])], + ), + ( + "catalog_head_updates", + "id", + &["fact"][..], + "1", + vec![Value::Blob(vec![])], + ), + ( + "catalog_initialization", + "singleton", + &["result"][..], + "1", + vec![Value::Integer(0)], + ), + ( + "catalog_leases", + "incarnation,admission_sequence", + &[ + "input_checkpoint", + "attestation", + "recovery", + "recovery_phase", + ][..], + "1", + vec![Value::Blob(vec![]), Value::Integer(0)], + ), + ( + "catalog_recovery_receipts", + "incarnation,admission_sequence", + &["recovery", "recovery_phase", "recovery_release"][..], + "1", + vec![Value::Blob(vec![]), Value::Integer(0)], + ), + ] + .into_iter() + .map(|(table, keys, fields, predicate, cursor)| Purpose { + table, + keys, + fields, + predicate, + cursor, + }) + .collect() +} +struct Row { + key: Vec, + columns: Vec>>, +} +async fn page(database: PathBuf, purpose: &Purpose) -> BackupResult> { + // All identifiers and predicates above are compile-time schema declarations. + let n = purpose.cursor.len(); + let parameters = (1..=n) + .map(|i| format!("?{i}")) + .collect::>() + .join(","); + let sql = format!( + "SELECT {},{} FROM {} WHERE {} AND ({}) > ({}) ORDER BY {} LIMIT 32", + purpose.keys, + purpose.fields.join(","), + purpose.table, + purpose.predicate, + purpose.keys, + parameters, + purpose.keys + ); + let cursor = purpose.cursor.clone(); + let fields = purpose.fields.len(); + tokio::task::spawn_blocking(move || { + let c = Connection::open_with_flags( + database, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + let mut stmt = c.prepare(&sql)?; + let mut rows = stmt.query(params_from_iter(cursor))?; + let mut page = Vec::new(); + while let Some(row) = rows.next()? { + let key = (0..n) + .map(|i| row.get(i)) + .collect::, _>>()?; + let columns = (n..n + fields) + .map(|i| row.get(i)) + .collect::>>, _>>()?; + page.push(Row { key, columns }); + } + Ok(page) + }) + .await? +} + +#[expect( + clippy::too_many_arguments, + reason = "one pinned copy carries source and destination capabilities" +)] +pub(super) async fn verify( + deployment: &Deployment, + host: &Host, + guard: &NodeLeaseGuard, + database: &std::path::Path, + directory: &std::path::Path, + identity: Option, + source: Option<&Path>, + source_store: Option>, + destination: Arc, +) -> BackupResult { + let mut purposes = purposes(); + let Some(identity) = identity else { + for purpose in &purposes { + if page(database.into(), purpose) + .await? + .iter() + .any(|row| row.columns.iter().any(Option::is_some)) + { + return Err(BackupError::Invalid( + "native roots have no repository identity", + )); + } + } + return Ok(0); + }; + let target = crate::repository_target( + deployment.identity.tenant(), + deployment.identity.application(), + identity.repository, + )?; + let destination = Arc::new(ArtifactStore::new(destination, identity.repository)); + let store = source_store + .map(|store| Arc::new(ArtifactStore::new(store, identity.repository))) + .unwrap_or_else(|| destination.clone()); + let disk = host.local_disk_budget().try_reserve(128 * 1024)?; + let dedup_root = tempfile::Builder::new() + .prefix("native-backup-") + .tempdir_in(directory)?; + let dedup = dedup_root.path().join("artifacts.sqlite"); + let c = tokio::task::spawn_blocking(move || -> BackupResult { + let c = Connection::open(dedup)?; + c.execute_batch("PRAGMA journal_mode=OFF; PRAGMA synchronous=OFF; PRAGMA cache_size=-256; CREATE TABLE retained(path TEXT PRIMARY KEY,size INTEGER NOT NULL,digest BLOB NOT NULL,manifest BLOB NOT NULL) WITHOUT ROWID;")?; + Ok(c) + }).await??; + let mut copier = Copier { + deployment: deployment.clone(), + source: source.cloned(), + store: store.clone(), + destination, + guard: guard.clone(), + dedup: Arc::new(Dedup { + connection: Mutex::new(c), + _root: dedup_root, + disk, + }), + count: 0, + }; + let mut inventory = + Inventory::new(store, identity.format, target, &mut copier).map_err(BackupError::Native)?; + for (kind, purpose) in purposes.iter_mut().enumerate() { + loop { + guard.check()?; + let rows = page(database.into(), purpose).await?; + if rows.is_empty() { + break; + } + purpose.cursor = rows + .last() + .ok_or(BackupError::Invalid("empty native page"))? + .key + .clone(); + for row in rows { + crate::packs::publication::backup::row( + kind as u8, + &row.columns, + &identity.seed, + &mut inventory, + ) + .await + .map_err(BackupError::Native)?; + } + } + } + Ok(copier.count) +} +struct Copier { + deployment: Deployment, + source: Option, + store: Arc, + destination: Arc, + guard: NodeLeaseGuard, + dedup: Arc, + count: u64, +} +// Field drop order closes SQLite, removes its files, then returns admission. +// Blocking lookups retain this same owner even if their caller is canceled. +struct Dedup { + connection: Mutex, + _root: tempfile::TempDir, + disk: DiskReservation, +} +async fn checked( + store: &ArtifactStore, + key: ArtifactKey, + value: ArtifactDescriptor, +) -> WalkResult<()> { + let mut reader = store.read(key, value).await?; + while reader.next().await?.is_some() {} + Ok(()) +} +#[async_trait::async_trait] +impl ArtifactVisitor for Copier { + async fn artifact(&mut self, key: ArtifactKey, value: ArtifactDescriptor) -> WalkResult { + self.guard.check()?; + let path = self.store.path(key, value.digest)?; + let dedup = self.dedup.clone(); + let name = path.to_string(); + let retained = tokio::task::spawn_blocking(move || -> WalkResult { + let c = dedup + .connection + .lock() + .map_err(|_| BackupError::Invalid("backup dedup poisoned"))?; + let old = c + .query_row( + "SELECT size,digest,manifest FROM retained WHERE path=?1", + params![name], + |row| { + Ok(( + row.get::<_, u64>(0)?, + row.get::<_, Vec>(1)?, + row.get::<_, Vec>(2)?, + )) + }, + ) + .optional()?; + if let Some((size, digest, manifest)) = old { + if size != value.size || digest != value.digest || manifest != value.manifest_digest + { + return Err( + BackupError::Invalid("conflicting native artifact descriptors").into(), + ); + } + return Ok(true); + } + Ok(false) + }) + .await??; + if retained { + return Ok(false); + } + if let Some(source) = &self.source { + checked(&self.store, key, value).await?; + let from = source.parts().chain(path.parts()).collect(); + let to = self.deployment.prefix.parts().chain(path.parts()).collect(); + crate::external::copy_parts( + self.deployment.layout.store().inner().as_ref(), + &from, + &to, + value.size, + ) + .await?; + match self + .deployment + .layout + .store() + .copy_if_not_exists(&from, &to) + .await + { + Ok(()) | Err(StorageError::StateConflict { .. }) => {} + Err(error) => return Err(error.into()), + } + } + checked(&self.destination, key, value).await?; + // Reserve before the insert. 4 KiB per physical artifact conservatively + // covers its bounded key/descriptor and B-tree page overhead. + self.dedup.disk.try_grow(4096)?; + let dedup = self.dedup.clone(); + tokio::task::spawn_blocking(move || -> WalkResult<()> { + dedup + .connection + .lock() + .map_err(|_| BackupError::Invalid("backup dedup poisoned"))? + .execute( + "INSERT INTO retained VALUES(?1,?2,?3,?4)", + params![ + path.to_string(), + value.size, + value.digest.as_slice(), + value.manifest_digest.as_slice() + ], + )?; + Ok(()) + }) + .await??; + self.count = self + .count + .checked_add(1) + .ok_or(BackupError::Invalid("native artifact count overflow"))?; + Ok(true) + } +} diff --git a/crates/canopy-server/src/git_cache/artifacts.rs b/crates/canopy-server/src/git_cache/artifacts.rs index c077aa4a..fee2fd8b 100644 --- a/crates/canopy-server/src/git_cache/artifacts.rs +++ b/crates/canopy-server/src/git_cache/artifacts.rs @@ -9,6 +9,152 @@ struct Writer { _owner: crate::git_objects::ReadOwner, } impl GitCache { + pub(crate) fn native_pack_path(&self, descriptor: NativePackDescriptor) -> PathBuf { + self.git_dir().join(format!( + "objects/pack/pack-{}.pack", + hex::encode(descriptor.git_checksum) + )) + } + + /// Read the original index completely, retaining its manifest and native + /// checksums. The sparse private decoder below grants no object authority. + pub(crate) async fn native_index_owned( + self: &Arc, + store: &ArtifactStore, + descriptor: NativePackDescriptor, + owner: crate::git_objects::ReadOwner, + ) -> Result { + descriptor + .validate(store.repository(), self.object_format) + .map_err(|_| MetadataError::Integrity)?; + let cache = self.clone(); + let held = owner.clone(); + let mut writer = tokio::task::spawn_blocking(move || { + let _owner = held; + cache.reservation()?.try_grow( + descriptor + .index + .size + .checked_add(4096) + .ok_or(MetadataError::Limit)?, + )?; + Ok::<_, MetadataError>(Writer { + file: File::create_new(cache.native_pack_path(descriptor).with_extension("idx"))?, + _cache: cache, + _owner, + }) + }) + .await??; + let mut input = store + .read_owned( + descriptor + .key(ArtifactKind::Index) + .map_err(|_| MetadataError::Integrity)?, + descriptor.index, + owner.clone(), + ) + .await?; + while let Some(bytes) = input.next().await? { + writer = tokio::task::spawn_blocking(move || { + writer.file.write_all(&bytes)?; + Ok::<_, MetadataError>(writer) + }) + .await??; + } + tokio::task::spawn_blocking(move || { + let index = crate::git_format::pack_index::PackIndex::open( + writer + ._cache + .native_pack_path(descriptor) + .with_extension("idx"), + writer._cache.object_format, + )?; + if index.len() != descriptor.object_count + || index.pack_checksum() != descriptor.git_checksum + { + return Err(MetadataError::Integrity); + } + Ok(index) + }) + .await? + } + + /// This is an incomplete private decoder file, never an accepted pack. + /// Its framing comes from the certified descriptor; selected payloads are + /// filled only from authenticated provider parts. Every extracted object + /// still needs canonical verification before leaving this workspace. + pub(crate) async fn sparse_native_owned( + self: &Arc, + descriptor: NativePackDescriptor, + owner: crate::git_objects::ReadOwner, + ) -> Result<(), MetadataError> { + let cache = self.clone(); + tokio::task::spawn_blocking(move || { + use std::io::{Seek, SeekFrom}; + let _owner = owner; + let tail = descriptor + .pack + .size + .checked_sub(descriptor.git_checksum.len() as u64) + .filter(|tail| *tail >= 12) + .ok_or(MetadataError::Integrity)?; + cache.reservation()?.try_grow(8192)?; + let mut file = File::create_new(cache.native_pack_path(descriptor))?; + file.set_len(descriptor.pack.size)?; + file.write_all(b"PACK")?; + file.write_all(&2_u32.to_be_bytes())?; + file.write_all(&descriptor.object_count.to_be_bytes())?; + file.seek(SeekFrom::Start(tail))?; + file.write_all(descriptor.git_checksum.as_ref())?; + Ok::<_, MetadataError>(()) + }) + .await? + } + + pub(crate) async fn sparse_payload_owned( + self: &Arc, + descriptor: NativePackDescriptor, + offset: u64, + bytes: bytes::Bytes, + owner: crate::git_objects::ReadOwner, + ) -> Result<(), MetadataError> { + let cache = self.clone(); + tokio::task::spawn_blocking(move || { + use std::io::{Seek, SeekFrom}; + let _owner = owner; + let end = offset + .checked_add(bytes.len() as u64) + .ok_or(MetadataError::Limit)?; + let tail = descriptor + .pack + .size + .checked_sub(descriptor.git_checksum.len() as u64) + .ok_or(MetadataError::Integrity)?; + if offset < 12 || end > tail { + return Err(MetadataError::Integrity); + } + // Include alignment overhead before allocating sparse file blocks. + let charge = (bytes.len() as u64) + .div_ceil(4096) + .checked_add(1) + .and_then(|pages| pages.checked_mul(4096)) + .ok_or(MetadataError::Limit)?; + cache.reservation()?.try_grow(charge)?; + let mut file = OpenOptions::new() + .write(true) + .open(cache.native_pack_path(descriptor))?; + file.seek(SeekFrom::Start(offset))?; + file.write_all(&bytes)?; + Ok::<_, MetadataError>(()) + }) + .await? + } + + pub(crate) fn reserve_native_spool(&self, bytes: u64) -> Result<(), MetadataError> { + self.reservation()?.try_grow(bytes)?; + Ok(()) + } + /// The isolated verifier calls this exactly once on its fresh private cache. /// Reserve the complete pair before creating files or reading the provider. /// No second pack copy or blob-as-artifact wrapper is involved. @@ -67,9 +213,10 @@ impl GitCache { }) .await??; let mut input = store - .read( + .read_owned( descriptor.key(kind).map_err(|_| MetadataError::Integrity)?, artifact, + owner.clone(), ) .await?; while let Some(bytes) = input.next().await? { diff --git a/crates/canopy-server/src/git_cache/maintenance.rs b/crates/canopy-server/src/git_cache/maintenance.rs index 4193a43a..4b2e3683 100644 --- a/crates/canopy-server/src/git_cache/maintenance.rs +++ b/crates/canopy-server/src/git_cache/maintenance.rs @@ -14,6 +14,7 @@ fn worker_error(error: GitHttpError) -> CacheError { // Follow physical pack order to retain delta-base locality while verifying large // histories. Hash order forces needless repeated decompression of distant bases. +#[cfg(test)] fn index_order(path: &Path, format: crate::ObjectFormat) -> io::Result> { let data = fs::read(path)?; let validated = crate::git_format::pack_index::PackIndex::open(path, format)?; @@ -82,6 +83,7 @@ impl GitCache { .expect("durable inventory poisoned") .insert(sha); } + #[cfg(test)] pub(crate) async fn pack_sources( self: &Arc, ) -> Result)>, CacheError> { diff --git a/crates/canopy-server/src/git_cache/mod.rs b/crates/canopy-server/src/git_cache/mod.rs index 77009cf5..d4badbbe 100644 --- a/crates/canopy-server/src/git_cache/mod.rs +++ b/crates/canopy-server/src/git_cache/mod.rs @@ -9,7 +9,6 @@ use std::{ }; use cellule_ltx::{DiskBudget, DiskReservation, LtxError}; -#[cfg(test)] use flate2::{Compression, write::ZlibEncoder}; #[cfg(test)] use std::collections::BTreeMap; @@ -29,6 +28,7 @@ pub(crate) const CACHE_PREFIX: &str = "canopy-git-"; mod artifacts; mod cleanup; mod serving_refs; +mod verified; #[derive(Debug, thiserror::Error)] pub enum CacheError { @@ -72,7 +72,6 @@ pub(crate) struct GitCache { objects: Option>, // Only durable hydration writes this cache. Stripe by OID so concurrent // fetches share a completed loose object without serializing all objects. - #[cfg(test)] object_writes: OnceLock<[Arc>; 64]>, packed: RwLock>, durable_packs: RwLock>, @@ -141,8 +140,7 @@ impl GitCache { reservation: Some(budget.try_reserve(0)?), cleanup_owner: cleanup, objects, - #[cfg(test)] - object_writes: OnceLock::new(), + object_writes: OnceLock::new(), packed: RwLock::new(Vec::new()), durable_packs: RwLock::new(HashSet::new()), selection: Mutex::new(()), @@ -193,7 +191,7 @@ impl GitCache { Ok(self.reservation()?.bytes()) } - fn writer(self: &Arc, relative: &Path) -> io::Result { + pub(crate) fn writer(self: &Arc, relative: &Path) -> io::Result { let path = self.git_dir().join(relative); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; @@ -226,7 +224,6 @@ impl GitCache { .await? } - #[cfg(test)] fn object_path(&self, oid: crate::ObjectId) -> PathBuf { let hex = hex::encode(oid); self.git_dir() @@ -235,7 +232,6 @@ impl GitCache { .join(&hex[2..]) } - #[cfg(test)] fn object_present(&self, oid: crate::ObjectId) -> io::Result { for index in self .packed @@ -258,7 +254,6 @@ impl GitCache { } } - #[cfg(test)] fn object_write_lock(&self, oid: crate::ObjectId) -> Arc> { let stripes = self .object_writes @@ -266,7 +261,6 @@ impl GitCache { Arc::clone(&stripes[oid[0] as usize % stripes.len()]) } - #[cfg(test)] fn object_writer( self: &Arc, oid: crate::ObjectId, @@ -458,6 +452,7 @@ impl GitCache { } /// Measures native Git's completed writes before the gateway can publish refs. + #[cfg(test)] pub(crate) async fn reconcile(self: &Arc) -> Result<(), CacheError> { self.reconcile_owned(Arc::new(())).await } @@ -562,7 +557,7 @@ impl Drop for GitCache { } } -struct CacheWriter { +pub(crate) struct CacheWriter { file: File, cache: Arc, } diff --git a/crates/canopy-server/src/git_cache/serving_refs.rs b/crates/canopy-server/src/git_cache/serving_refs.rs index 7d7feffb..bf4727ad 100644 --- a/crates/canopy-server/src/git_cache/serving_refs.rs +++ b/crates/canopy-server/src/git_cache/serving_refs.rs @@ -1,24 +1,35 @@ //! Count-bounded ref pages streamed into one unpublished, admitted native cache. use super::*; -use crate::git_objects::ReadOwner; +use crate::{ObjectId, git_objects::ReadOwner}; pub(crate) struct ServingRefsWriter { output: BufWriter, last: String, + replace: bool, _owner: ReadOwner, } impl GitCache { pub(crate) async fn serving_refs( self: &Arc, owner: ReadOwner, + fully_peeled: bool, ) -> Result { let cache = self.clone(); tokio::task::spawn_blocking(move || { - let mut output = BufWriter::new(cache.writer(Path::new("packed-refs"))?); - output.write_all(b"# pack-refs with: sorted\n")?; + let mut output = BufWriter::new(cache.writer(Path::new(if fully_peeled { + "packed-refs.lock" + } else { + "packed-refs" + }))?); + output.write_all(if fully_peeled { + b"# pack-refs with: peeled fully-peeled sorted\n" + } else { + b"# pack-refs with: sorted\n" + })?; Ok(ServingRefsWriter { output, last: String::new(), + replace: fully_peeled, _owner: owner, }) }) @@ -27,14 +38,25 @@ impl GitCache { } impl ServingRefsWriter { pub(crate) async fn append( - mut self, + self, page: Vec<(String, RefExpectation)>, + ) -> Result { + self.append_peeled( + page.into_iter() + .map(|(name, state)| (name, state, None)) + .collect(), + ) + .await + } + pub(crate) async fn append_peeled( + mut self, + page: Vec<(String, RefExpectation, Option)>, ) -> Result { if page.len() > crate::refs::REF_PAGE_SIZE { return Err(CacheError::InvalidHead); } tokio::task::spawn_blocking(move || { - for (name, state) in page { + for (name, state, peeled) in page { let Some(oid) = state.oid else { return Err(CacheError::InvalidHead); }; @@ -46,6 +68,12 @@ impl ServingRefsWriter { return Err(CacheError::InvalidHead); } writeln!(self.output, "{} {name}", hex::encode(oid))?; + if let Some(peeled) = peeled { + if peeled.is_zero() || peeled.format() != oid.format() { + return Err(CacheError::InvalidHead); + } + writeln!(self.output, "^{}", hex::encode(peeled))?; + } self.last = name; } Ok(self) @@ -56,6 +84,10 @@ impl ServingRefsWriter { tokio::task::spawn_blocking(move || { self.output.flush()?; self.output.get_ref().file.sync_all()?; + if self.replace { + let path = self.output.get_ref().cache.git_dir(); + std::fs::rename(path.join("packed-refs.lock"), path.join("packed-refs"))?; + } Ok(()) }) .await? diff --git a/crates/canopy-server/src/git_cache/tests.rs b/crates/canopy-server/src/git_cache/tests.rs index 8fba281e..47118eb4 100644 --- a/crates/canopy-server/src/git_cache/tests.rs +++ b/crates/canopy-server/src/git_cache/tests.rs @@ -1,5 +1,67 @@ use super::*; +#[tokio::test] +async fn streamed_native_extraction_publishes_only_a_verified_complete_object() +-> Result<(), Box> { + use crate::{git_objects::GitObjects, packs::metadata::CanonicalObject}; + for format in [crate::ObjectFormat::Sha1, crate::ObjectFormat::Sha256] { + let root = tempfile::TempDir::new()?; + let budget = DiskBudget::new(32 << 20); + let native = crate::native_resources::NativeResources::default() + .scope(crate::native_resources::NativeClass::Foreground); + let source = GitCache::create( + root.path().into(), + budget.clone(), + "refs/heads/main", + format, + native.clone(), + ) + .await?; + let target = GitCache::create( + root.path().into(), + budget.clone(), + "refs/heads/main", + format, + native.clone(), + ) + .await?; + let mut body = vec![0; 2 << 20]; + blake3::Hasher::new() + .update(b"streamed native extraction") + .finalize_xof() + .fill(&mut body); + let expected = CanonicalObject { + oid: object_id(format, ObjectKind::Blob, &body), + kind: ObjectKind::Blob, + size: body.len() as u64, + digest: *blake3::hash(&body).as_bytes(), + }; + source + .store_object(expected.oid, expected.kind, body.clone()) + .await?; + let mut wrong = expected; + wrong.digest[0] ^= 1; + assert!( + target + .copy_native_owned(source.git_dir(), wrong, source.clone()) + .await + .is_err() + ); + assert!(!target.object_present(expected.oid)?); + target + .copy_native_owned(source.git_dir(), expected, source.clone()) + .await?; + assert!(target.object_present(expected.oid)?); + let mut objects = GitObjects::batch_owned(&target.git_dir(), &native, target.clone())?; + assert_eq!(objects.read_verified(expected, body.len()).await?, body); + objects.finish().await?; + drop(source); + drop(target); + wait_for_cleanup(&budget).await?; + } + Ok(()) +} + async fn wait_for_cleanup(budget: &DiskBudget) -> Result<(), Box> { tokio::time::timeout(std::time::Duration::from_secs(5), async { while budget.used() != 0 { diff --git a/crates/canopy-server/src/git_cache/verified.rs b/crates/canopy-server/src/git_cache/verified.rs new file mode 100644 index 00000000..b8e41264 --- /dev/null +++ b/crates/canopy-server/src/git_cache/verified.rs @@ -0,0 +1,95 @@ +//! Bounded canonical extraction into an unpublished loose-object file. +use super::*; +use crate::{ + git_objects::{BodySink, GitObjects, ObjectReadError, ReadOwner}, + packs::{catalog::NativeReadError, metadata::CanonicalObject}, +}; +use tokio::sync::OwnedMutexGuard; + +struct Sink { + encoder: Option>, + temporary: tempfile::TempPath, + destination: PathBuf, + cache: Arc, + owner: ReadOwner, + _write: OwnedMutexGuard<()>, +} +impl BodySink for Sink { + async fn append(&mut self, bytes: bytes::Bytes) -> Result<(), ObjectReadError> { + let mut encoder = self.encoder.take().ok_or(ObjectReadError::Malformed)?; + let owner = self.owner.clone(); + self.encoder = Some( + tokio::task::spawn_blocking(move || { + let _owner = owner; + encoder.write_all(&bytes)?; + Ok::<_, ObjectReadError>(encoder) + }) + .await??, + ); + Ok(()) + } +} +impl GitCache { + /// The input is an isolated admitted native reader. Only a complete frame + /// matching the certified metadata and a successful child may publish it. + pub(crate) async fn copy_native_owned( + self: &Arc, + input: PathBuf, + expected: CanonicalObject, + owner: ReadOwner, + ) -> Result<(), NativeReadError> { + let write = self.object_write_lock(expected.oid).lock_owned().await; + let cache = self.clone(); + let held = owner.clone(); + let sink = tokio::task::spawn_blocking(move || { + if expected.oid.format() != cache.object_format { + return Err(CacheError::Io(io::Error::new( + io::ErrorKind::InvalidData, + "object format mismatch", + ))); + } + if cache.object_present(expected.oid)? { + return Ok(None); + } + let (mut encoder, temporary, destination) = cache.object_writer(expected.oid)?; + encoder.write_all( + format!("{} {}\0", expected.kind.git_name(), expected.size).as_bytes(), + )?; + Ok::<_, CacheError>(Some(Sink { + encoder: Some(encoder), + temporary, + destination, + cache, + owner: held, + _write: write, + })) + }) + .await??; + let Some(mut sink) = sink else { + return Ok(()); + }; + let mut objects = GitObjects::batch_owned(&input, &self.native, owner)?; + objects.copy_verified(expected, &mut sink).await?; + objects.finish().await?; + tokio::task::spawn_blocking(move || { + let encoder = sink + .encoder + .take() + .ok_or_else(|| io::Error::other("incomplete object writer"))?; + drop(encoder.finish()?); + sink.temporary + .persist_noclobber(&sink.destination) + .map_err(|error| error.error)?; + #[cfg(test)] + sink.cache + .loose_objects + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + sink.cache + .write_generation + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok::<_, CacheError>(()) + }) + .await??; + Ok(()) + } +} diff --git a/crates/canopy-server/src/git_gateway/branch_policy.rs b/crates/canopy-server/src/git_gateway/branch_policy.rs index c6c12352..a55aca17 100644 --- a/crates/canopy-server/src/git_gateway/branch_policy.rs +++ b/crates/canopy-server/src/git_gateway/branch_policy.rs @@ -295,7 +295,7 @@ fn quote(value: &str) -> String { fn commands(bytes: &[u8]) -> Result { commands_in_format(bytes, None) } -fn commands_in_format( +pub(super) fn commands_in_format( mut bytes: &[u8], format: Option, ) -> Result { diff --git a/crates/canopy-server/src/git_gateway/candidates/mod.rs b/crates/canopy-server/src/git_gateway/candidates/mod.rs index 91b65ade..7fd91e2c 100644 --- a/crates/canopy-server/src/git_gateway/candidates/mod.rs +++ b/crates/canopy-server/src/git_gateway/candidates/mod.rs @@ -14,7 +14,9 @@ use cellule_runtime::InvocationError; use std::process::{ExitStatus, Stdio}; use tokio::io::AsyncWriteExt; +mod produce; mod rebase; +pub(crate) use produce::ProducedCandidate; impl GitGateway { pub(crate) async fn prepare_candidate( @@ -23,9 +25,6 @@ impl GitGateway { number: i64, request: CandidateRequest, ) -> Result { - // Serialize native mutations with pushes; each candidate still gets a - // disposable cache. The Cell command rechecks refs and authority later. - let _push = self.push.lock().await; let identity = new_identity()?; let reserved = self .candidate_command(CandidateAction::Reserve { @@ -41,45 +40,7 @@ impl GitGateway { if candidate.result != CandidateResult::Pending { return Ok(CandidateOutcome::Applied(candidate)); } - let policy = self - .repository - .pull_review_policy(actor, number) - .await - .map_err(|error| GatewayError::Cell(Box::new(error)))? - .output; - if policy.is_none_or(|policy| { - !policy.ready || policy.revision.as_ref() != Some(&candidate.request.revision) - }) { - return Ok(CandidateOutcome::Conflict); - } - let cached = self.build_cache(actor, &[]).await?; - let result = prepare_native(&self.repository, &cached.backend, &candidate).await?; - if !valid_result(&result) { - return Err(GitHttpError::TooLarge.into()); - } - cached.backend.cache.reconcile().await?; - if let CandidateResult::Ready { oid, .. } = &result { - let plan = PushPlan { - actor: actor.into(), - updates: vec![RefUpdate { - name: candidate.fetch_ref(), - expected: None, - new_oid: Some(parse_oid(oid)?), - }], - }; - self.persist_objects(&cached.backend, &cached.refs, &plan) - .await?; - self.repository - .prepare_graph(&plan) - .await - .map_err(|error| GatewayError::Cell(Box::new(error)))?; - } - self.candidate_command(CandidateAction::Finish { - actor: actor.into(), - id: candidate.request.id, - result, - }) - .await + self.publish_candidate(*candidate).await } async fn candidate_command( @@ -99,12 +60,13 @@ impl GitGateway { } async fn prepare_native( - repository: &RepositoryCell, + context: &crate::packs::publication::StagingContext, backend: &GitHttpBackend, candidate: &MergeCandidate, ) -> Result { let revision = &candidate.request.revision; let related = run( + context, backend, &["merge-base", &revision.base_oid, &revision.source_oid], b"", @@ -118,12 +80,18 @@ async fn prepare_native( } if candidate.request.strategy == MergeStrategy::Rebase { let common = output_oid(&related.stdout)?; - return rebase::prepare(repository, backend, candidate, &common).await; + return rebase::prepare(context, backend, candidate, &common).await; } // Native merge-tree consolidates multiple merge bases itself. Never select // one merge base or infer a clean result from an empty conflict-path list. - let (tree_oid, conflict) = - merge_tree(backend, &revision.base_oid, &revision.source_oid, None).await?; + let (tree_oid, conflict) = merge_tree( + context, + backend, + &revision.base_oid, + &revision.source_oid, + None, + ) + .await?; if let Some(conflict) = conflict { return Ok(conflict); } @@ -146,7 +114,7 @@ async fn prepare_native( if !message.ends_with('\n') { message.push('\n'); } - let commit = run(backend, &args, message.as_bytes(), &environment).await?; + let commit = run(context, backend, &args, message.as_bytes(), &environment).await?; if !commit.status.success() { return Err(commit.error()); } @@ -166,6 +134,7 @@ fn output_oid(bytes: &[u8]) -> Result { } async fn merge_tree( + context: &crate::packs::publication::StagingContext, backend: &GitHttpBackend, base: &str, source: &str, @@ -184,7 +153,7 @@ async fn merge_tree( args.push(&explicit); } args.extend([base, source]); - let merged = run(backend, &args, b"", &[]).await?; + let merged = run(context, backend, &args, b"", &[]).await?; if !matches!(merged.status.code(), Some(0 | 1)) { return Err(merged.error()); } @@ -235,10 +204,21 @@ impl Output { } } pub(super) async fn run( + context: &crate::packs::publication::StagingContext, + backend: &GitHttpBackend, + args: &[&str], + input: &[u8], + environment: &[(&str, &str)], +) -> Result { + run_owned(backend, args, input, environment, context.physical_owner()).await +} + +pub(super) async fn run_owned( backend: &GitHttpBackend, args: &[&str], input: &[u8], environment: &[(&str, &str)], + owner: crate::git_objects::ReadOwner, ) -> Result { let mut command = crate::native_git::command(&backend.git_dir())?; command @@ -251,7 +231,7 @@ pub(super) async fn run( .stderr(Stdio::piped()); let mut process = GitProcess::spawn( command, - Arc::clone(&backend.cache), + (Arc::clone(&backend.cache), owner), backend .cache .native @@ -281,7 +261,7 @@ pub(super) async fn run( drop(stdin); Ok::<_, GitHttpError>(()) }, - read_bounded(stdout, 128 * 1024), + read_bounded(stdout, 300 * 1024), read_bounded(stderr, 64 * 1024) )?; let status = process.wait().await?; diff --git a/crates/canopy-server/src/git_gateway/candidates/produce.rs b/crates/canopy-server/src/git_gateway/candidates/produce.rs new file mode 100644 index 00000000..b2d85902 --- /dev/null +++ b/crates/canopy-server/src/git_gateway/candidates/produce.rs @@ -0,0 +1,406 @@ +//! A resident producer owns native Git, exact generated inputs and final dispatch. +use super::*; +use crate::git_gateway::push::native::{active, bound, checkpoint, final_publication}; +use crate::packs::{ + catalog::{CatalogFileLimits, CatalogFiles, CatalogIndexes}, + metadata::MetadataLimits, + publication::{ + BeginRequest, CandidatePublicationReply, CatalogPreparation, DEFAULT_LEASE_MS, + PublicationCoordinator, PublicationError, PublicationOutcome, StagingCoordinator, + StagingError, StagingState, StagingTicket, + }, + sources::NativePackDescriptor, + verification::{NativeMetadataLimits, PhysicalLimits, PhysicalVerifier}, +}; +use std::io::Write; + +/// Only the native producer can construct this witness. A client Ready DTO +/// cannot obtain authority to install a server-owned candidate ref. +pub(crate) struct ProducedCandidate { + candidate: MergeCandidate, + operation: [u8; 16], +} +impl ProducedCandidate { + #[cfg(test)] + pub(crate) fn verified_fixture(candidate: MergeCandidate, operation: [u8; 16]) -> Self { + Self { + candidate, + operation, + } + } + pub(crate) fn candidate(&self) -> &MergeCandidate { + &self.candidate + } + pub(crate) fn operation(&self) -> [u8; 16] { + self.operation + } +} +fn work(error: impl StdError + Send + Sync + 'static) -> StagingError { + StagingError::Input(Box::new(error)) +} +fn failed(error: impl StdError + Send + Sync + 'static) -> GatewayError { + GatewayError::Cell(Box::new(error)) +} + +impl GitGateway { + pub(super) async fn publish_candidate( + &self, + candidate: MergeCandidate, + ) -> Result { + let bytes = serde_json::to_vec(&candidate).map_err(failed)?; + let mut digest = blake3::Hasher::new(); + digest.update(b"canopy.generated-candidate-workflow.v1\0"); + digest.update(&self.repository.repository_id()); + digest.update(&bytes); + let staging = self.repository.staging_coordinator().map_err(failed)?; + let request = BeginRequest { + repository: self.repository.repository_id(), + operation: uuid::Uuid::new_v4().into_bytes(), + request_digest: *digest.finalize().as_bytes(), + actor: candidate.actor.clone(), + lease_ms: DEFAULT_LEASE_MS, + }; + // Serialize admission only: observers of the same frozen candidate + // join its original owner and never race independent generated work. + let admission = self.push.lock().await; + let ticket = if let Some(ticket) = + staging.join_generated_candidate(&request).map_err(failed)? + { + ticket + } else { + let ready = staging + .ready_request(request, new_identity()?) + .await + .map_err(failed)?; + let ticket = staging.submit(ready).map_err(|(error, _)| failed(error))?; + let gateway = self.clone(); + let owner = staging.clone(); + let produced = candidate.clone(); + ticket + .drive(move |ticket, publication| async move { + Box::pin(gateway.drive_candidate(owner, ticket, publication, produced)).await + }) + .map_err(failed)?; + ticket + }; + drop(admission); + let number = candidate.number; + let actor = candidate.actor.clone(); + let id = candidate.request.id.clone(); + let reply = match ticket.wait_completion().await { + StagingState::Published(Ok(PublicationOutcome::Candidate(value))) => value.output, + StagingState::Published(Err(error)) => match error.as_ref() { + PublicationError::Candidate(InvocationError::Rejected(value)) => { + value.output.clone() + } + _ => return Err(failed(error)), + }, + StagingState::Uncertain(error) | StagingState::Fenced(error) => { + return Err(failed(error)); + } + _ => return Err(failed(StagingError::NotReady)), + }; + match reply { + CandidatePublicationReply::Applied { + id: selected, + digest, + publication, + } => { + if uuid::Uuid::from_bytes(selected).to_string() != id { + return Err(failed(StagingError::Context)); + } + let Some(candidate) = self + .repository + .merge_candidate(actor.as_str(), number, &id) + .await + .map_err(failed)? + .output + else { + return Ok(CandidateOutcome::NotFound); + }; + let bytes = serde_json::to_vec(&candidate).map_err(failed)?; + if *blake3::hash(&bytes).as_bytes() != digest + || matches!(candidate.result, CandidateResult::Ready { .. }) + != publication.is_some() + { + return Err(failed(StagingError::Context)); + } + Ok(CandidateOutcome::Applied(Box::new(candidate))) + } + CandidatePublicationReply::NotFound => Ok(CandidateOutcome::NotFound), + CandidatePublicationReply::Forbidden => Ok(CandidateOutcome::Forbidden), + CandidatePublicationReply::Conflict => Ok(CandidateOutcome::Conflict), + CandidatePublicationReply::Denied(_) => Ok(CandidateOutcome::Conflict), + } + } + + async fn drive_candidate( + &self, + staging: Arc, + ticket: StagingTicket, + publication: PublicationCoordinator, + mut candidate: MergeCandidate, + ) -> Result<(), StagingError> { + active(&staging, &ticket).await?; + let gateway = self.clone(); + let (produced, packs, certificate) = ticket + .spawn(move |context| async move { + let cached = gateway + .build_cache(&candidate.actor, &[]) + .await + .map_err(work)?; + candidate.result = prepare_native(&context, &cached.backend, &candidate) + .await + .map_err(work)?; + if !valid_result(&candidate.result) { + return Err(work(GitHttpError::TooLarge)); + } + let packs = if matches!(candidate.result, CandidateResult::Ready { .. }) { + generated_pack(&context, &cached.backend, &gateway.artifacts) + .await + .map_err(work)? + } else { + vec![] + }; + let certificate = context + .seal_native_inputs(gateway.artifacts.clone(), packs.iter().copied()) + .await + .map_err(work)?; + let operation = context.token()?.operation; + Ok(( + ProducedCandidate { + candidate, + operation, + }, + packs, + certificate, + )) + })? + .wait() + .await + .map_err(work)?; + checkpoint(&staging, &ticket, certificate).await?; + let mut metadata = Vec::with_capacity(packs.len()); + for pack in packs { + let gateway = self.clone(); + metadata.push( + ticket + .spawn(move |context| async move { + PhysicalVerifier::download_staged( + &context, + &gateway.scratch_root, + gateway.disk_budget.clone(), + &gateway.artifacts, + pack, + PhysicalLimits::default(), + gateway.native.clone(), + ) + .await + .map_err(work)? + .stage_metadata(NativeMetadataLimits::default()) + .await + .map_err(work) + })? + .wait() + .await + .map_err(work)?, + ); + } + ticket.seal()?; + bound(&staging, &ticket).await?; + let format = self.repository.object_format(); + let indexes = Arc::new(CatalogIndexes::new(self.artifacts.clone(), format)); + let files = Arc::new( + CatalogFiles::new( + &self.scratch_root, + self.disk_budget.clone(), + self.artifacts.clone(), + format, + CatalogFileLimits::default(), + ) + .map_err(work)? + .with_native(self.native.clone()), + ); + let base = Arc::new(ticket.open_base(indexes, files).await?); + let gateway = self.clone(); + let ready = ticket + .spawn_bound(move |_, context| async move { + let mut builder = CatalogPreparation::new_staged( + &context, + &gateway.scratch_root, + gateway.disk_budget.clone(), + base, + MetadataLimits::default(), + ) + .await + .map_err(work)?; + for staged in metadata { + builder.add_staged_pack(staged).await.map_err(work)?; + } + let prepared = Arc::new(builder.finish().await.map_err(work)?); + prepared + .ready_native_candidate( + new_identity().map_err(work)?, + &produced, + &gateway.scratch_root, + gateway.disk_budget.clone(), + MetadataLimits::default(), + ) + .await + .map_err(work) + })? + .wait() + .await + .map_err(work)?; + let registered = ready + .persist_recovery(&self.artifacts, new_identity().map_err(work)?) + .await + .map_err(work)?; + let ready = ready + .bind_recovery(registered, &self.artifacts) + .map_err(work)?; + let observer = ticket + .publish_wait(&publication, ready) + .await + .map_err(work)?; + match final_publication(&staging, &ticket, &observer).await { + Ok(PublicationOutcome::Candidate(_)) => Ok(()), + Err(StagingError::Publication(error)) + if matches!( + error.as_ref(), + PublicationError::Candidate(InvocationError::Rejected(_)) + ) => + { + Ok(()) + } + Err(error) => Err(error), + _ => Err(StagingError::Context), + } + } +} + +/// Pack only newly written loose objects. The base is composed of immutable +/// catalog packs, so this work and input size follow generated changes rather +/// than every historical object. Git resolves any deltas against these inputs +/// and emits a non-thin pack. The spool is disk-admitted and memory stays bounded. +async fn generated_pack( + context: &crate::packs::publication::StagingContext, + backend: &GitHttpBackend, + store: &canopy_object_storage::artifact::ArtifactStore, +) -> Result, GatewayError> { + let cache = backend.cache.clone(); + let owner = context.physical_owner(); + let format = context.format(); + let operation = context.token().map_err(failed)?.artifact_operation; + let relative = PathBuf::from(format!("canopy-generated-{}.oids", hex::encode(operation))); + let spool = relative.clone(); + let claim = cache + .native + .try_admit(crate::native_resources::NativeWork::Read)?; + let count = tokio::task::spawn_blocking(move || { + let _owner = owner; + let _claim = claim; + let fence = + crate::native_git::lock_file(&cache.git_dir().join(crate::native_git::WORKER_LOCK))?; + fence.try_lock().map_err(std::io::Error::from)?; + let mut writer = cache.writer(&spool)?; + let mut count = 0u64; + for directory in std::fs::read_dir(cache.git_dir().join("objects"))? { + let directory = directory?; + let prefix = directory.file_name(); + let prefix = prefix + .to_str() + .ok_or_else(|| std::io::Error::other("non-UTF8 loose object directory"))?; + if matches!(prefix, "pack" | "info") { + continue; + } + if prefix.len() != 2 + || !prefix + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + || !directory.file_type()?.is_dir() + { + return Err(std::io::Error::other( + "invalid generated loose object directory", + )); + } + for entry in std::fs::read_dir(directory.path())? { + let entry = entry?; + let name = entry.file_name(); + let name = name + .to_str() + .ok_or_else(|| std::io::Error::other("invalid loose object name"))?; + let oid = format!("{prefix}{name}"); + if !entry.file_type()?.is_file() + || oid.len() != format.bytes() * 2 + || crate::ObjectId::from_hex(&oid).is_err() + { + return Err(std::io::Error::other("invalid generated loose object")); + } + count += 1; + // A fixed ceiling bounds request-private enumeration; disk + // reservation independently enforces the service's byte limit. + if count > 1_000_000 { + return Err(std::io::Error::other("generated object limit")); + } + writeln!(writer, "{oid}")?; + } + } + writer.flush()?; + drop(writer); + fence.unlock()?; + Ok::<_, std::io::Error>(count) + }) + .await + .map_err(failed)??; + if count == 0 { + return Ok(vec![]); + } + context.ensure_live().map_err(failed)?; + let prefix = backend + .git_dir() + .join("objects/pack") + .join(format!("canopy-generated-{}", hex::encode(operation))); + let mut command = crate::native_git::command(&backend.git_dir())?; + command + .arg("--git-dir") + .arg(backend.git_dir()) + .args(["pack-objects", "--no-reuse-object", "--no-reuse-delta"]) + .arg(prefix) + .stdin(std::fs::File::open(backend.git_dir().join(relative))?) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut process = GitProcess::spawn( + command, + (backend.cache.clone(), context.physical_owner()), + backend + .cache + .native + .try_admit(crate::native_resources::NativeWork::Pack)?, + )?; + let stdout = process + .child + .stdout + .take() + .ok_or(GitHttpError::Interrupted)?; + let stderr = process + .child + .stderr + .take() + .ok_or(GitHttpError::Interrupted)?; + let (stdout, stderr) = + tokio::try_join!(read_bounded(stdout, 128), read_bounded(stderr, 64 << 10))?; + let status = process.wait().await?; + if !status.success() { + return Err(GitHttpError::GitExit { + status, + stderr: String::from_utf8_lossy(&stderr).into_owned(), + } + .into()); + } + let checksum = output_oid(&stdout)?; + backend + .stage_generated_pack(context, store, PhysicalLimits::default(), &checksum) + .await + .map_err(failed) +} diff --git a/crates/canopy-server/src/git_gateway/candidates/rebase.rs b/crates/canopy-server/src/git_gateway/candidates/rebase.rs index f6d70565..2443acb3 100644 --- a/crates/canopy-server/src/git_gateway/candidates/rebase.rs +++ b/crates/canopy-server/src/git_gateway/candidates/rebase.rs @@ -5,7 +5,7 @@ use crate::pulls::candidates::{ }; pub(super) async fn prepare( - repository: &RepositoryCell, + context: &crate::packs::publication::StagingContext, backend: &GitHttpBackend, candidate: &MergeCandidate, common: &str, @@ -15,6 +15,7 @@ pub(super) async fn prepare( let range = format!("{}..{}", revision.base_oid, revision.source_oid); let limit = format!("--max-count={}", MAX_COMMITS + 1); let listed = run( + context, backend, &["rev-list", "--reverse", "--topo-order", &limit, &range], b"", @@ -36,7 +37,7 @@ pub(super) async fn prepare( let mut parent = common; for commit in &commits { parse_oid(commit)?; - let size = run(backend, &["cat-file", "-s", commit], b"", &[]).await?; + let size = run(context, backend, &["cat-file", "-s", commit], b"", &[]).await?; if !size.status.success() { return Err(size.error()); } @@ -47,7 +48,7 @@ pub(super) async fn prepare( if size > MAX_COMMIT_BYTES { return unavailable(RebaseUnavailable::Limit); } - let original = run(backend, &["cat-file", "commit", commit], b"", &[]).await?; + let original = run(context, backend, &["cat-file", "commit", commit], b"", &[]).await?; if !original.status.success() { return Err(original.error()); } @@ -55,6 +56,7 @@ pub(super) async fn prepare( // Detect topology from Git rather than treating arbitrary malformed // headers as a merge. Neither case is silently flattened or dropped. let parents = run( + context, backend, &["rev-list", "--parents", "-n", "1", commit], b"", @@ -84,17 +86,16 @@ pub(super) async fn prepare( if parent != revision.source_oid { return Err(GatewayError::MalformedCache); } - repository - .prepare_ancestry(parse_oid(common)?, parse_oid(&revision.base_oid)?) - .await - .map_err(|error| GatewayError::Cell(Box::new(error)))?; + // Native merge-base established this boundary. The private catalog + // verifier checks the generated chain and its base closure before publication. let mut current = revision.base_oid.clone(); let mut tree_oid = String::new(); for (source, bytes) in commits.into_iter().zip(originals) { let original = Commit::parse(&bytes).ok_or(GatewayError::MalformedCache)?; // Replaying one change uses its original parent as the explicit base; // recomputing a merge base would replay the entire branch instead. - let (tree, conflict) = merge_tree(backend, ¤t, source, Some(original.parent)).await?; + let (tree, conflict) = + merge_tree(context, backend, ¤t, source, Some(original.parent)).await?; if let Some(conflict) = conflict { return Ok(conflict); } @@ -103,6 +104,7 @@ pub(super) async fn prepare( return unavailable(RebaseUnavailable::Limit); } let written = run( + context, backend, &["hash-object", "-t", "commit", "-w", "--stdin"], &body, diff --git a/crates/canopy-server/src/git_gateway/fetch.rs b/crates/canopy-server/src/git_gateway/fetch.rs index 85318d67..b27b8ad9 100644 --- a/crates/canopy-server/src/git_gateway/fetch.rs +++ b/crates/canopy-server/src/git_gateway/fetch.rs @@ -3,6 +3,7 @@ use super::*; pub(super) struct FetchRequest { pub(super) wants: BTreeSet, pub(super) filter: Option, + pub(super) needs_blob_sizes: bool, } impl FetchRequest { @@ -14,6 +15,7 @@ impl FetchRequest { return Ok(Self { wants: BTreeSet::new(), filter: None, + needs_blob_sizes: false, }); } Self::parse( @@ -60,18 +62,23 @@ impl FetchRequest { } bytes = &bytes[length..]; } - let filter = filter - .map(|value| { + let (filter, needs_blob_sizes) = match filter { + Some(value) => { let value = std::str::from_utf8(value).map_err(|_| InputError::Fetch)?; - check_filter_policy(value)?; - Ok::<_, InputError>(value.to_owned()) - }) - .transpose()?; - Ok(Self { wants, filter }) + (Some(value.to_owned()), check_filter_policy(value)?) + } + None => (None, false), + }; + Ok(Self { + wants, + filter, + needs_blob_sizes, + }) } } -fn check_filter_policy(value: &str) -> Result<(), InputError> { +fn check_filter_policy(value: &str) -> Result { + let mut needs_blob_sizes = false; // rev-list does not enforce uploadpackfilter.*. Match the transport policy // before traversal, including escaped subfilters, so sparse filters cannot // inspect pattern blobs outside the validated wants. @@ -87,6 +94,8 @@ fn check_filter_policy(value: &str) -> Result<(), InputError> { .map_err(|_| InputError::Fetch)?; pending.push(std::borrow::Cow::Owned(decoded.into_owned())); } + } else if value.starts_with("blob:limit=") { + needs_blob_sizes = true; } else if value != "blob:none" && !value.starts_with("blob:limit=") && !value.starts_with("tree:") @@ -95,7 +104,7 @@ fn check_filter_policy(value: &str) -> Result<(), InputError> { return Err(InputError::Fetch); } } - Ok(()) + Ok(needs_blob_sizes) } impl GitGateway { diff --git a/crates/canopy-server/src/git_gateway/head.rs b/crates/canopy-server/src/git_gateway/head.rs new file mode 100644 index 00000000..30af755d --- /dev/null +++ b/crates/canopy-server/src/git_gateway/head.rs @@ -0,0 +1,164 @@ +//! Symbolic HEAD changes use the same resident-owned staging and exact publication +//! lifecycle as pushes. HTTP observers own no producer or recovery command. +use super::push::native::{active, bound, final_publication}; +use super::*; +use crate::{ + packs::publication::{HeadRequest, PublicationReply}, + packs::{ + catalog::{CatalogFileLimits, CatalogFiles, CatalogIndexes}, + metadata::MetadataLimits, + publication::{ + BeginRequest, CatalogPreparation, DEFAULT_LEASE_MS, PublicationCoordinator, + PublicationError, PublicationOutcome, StagingCoordinator, StagingError, StagingState, + StagingTicket, + }, + }, +}; +use cellule_runtime::{ + InvocationError, + codec::{BoundedEncoder, WireValue}, +}; + +fn work(error: impl StdError + Send + Sync + 'static) -> StagingError { + StagingError::Input(Box::new(error)) +} +fn failed(error: impl StdError + Send + Sync + 'static) -> GatewayError { + GatewayError::Cell(Box::new(error)) +} + +impl GitGateway { + /// Every request owns one exact attempt through resident staging. + /// The final transaction rechecks ownership and the joint generation. + pub async fn set_default_branch( + &self, + identity: MutationIdentity, + actor: &str, + request: HeadRequest, + ) -> Result { + if crate::directory::validate_component(actor).is_err() { + return Err(failed(cellule_runtime::Error::Command( + "invalid HEAD actor", + ))); + } + let mut encoded = + BoundedEncoder::new(crate::packs::publication::NATIVE_HEAD_BYTES).map_err(failed)?; + request.encode(&mut encoded).map_err(failed)?; + let mut digest = blake3::Hasher::new(); + digest.update(b"canopy.symbolic-head-workflow.v1\0"); + digest.update(&self.repository.repository_id()); + digest.update(actor.as_bytes()); + digest.update(&[0]); + digest.update(&encoded.finish()); + let staging = self.repository.staging_coordinator().map_err(failed)?; + let ready = staging + .ready_request( + BeginRequest { + repository: self.repository.repository_id(), + operation: uuid::Uuid::new_v4().into_bytes(), + request_digest: *digest.finalize().as_bytes(), + actor: actor.into(), + lease_ms: DEFAULT_LEASE_MS, + }, + new_identity()?, + ) + .await + .map_err(failed)?; + let ticket = staging.submit(ready).map_err(|(error, _)| failed(error))?; + let gateway = self.clone(); + let owner = staging.clone(); + ticket + .drive(move |ticket, publication| async move { + Box::pin(gateway.drive_head(owner, ticket, publication, identity, request)).await + }) + .map_err(failed)?; + match ticket.wait_completion().await { + StagingState::Published(Ok(PublicationOutcome::Head(value))) => Ok(value.output), + StagingState::Published(Err(error)) => match error.as_ref() { + PublicationError::Head(InvocationError::Rejected(value)) => Ok(value.output), + _ => Err(failed(error)), + }, + StagingState::Uncertain(error) | StagingState::Fenced(error) => Err(failed(error)), + _ => Err(failed(StagingError::NotReady)), + } + } + + async fn drive_head( + &self, + staging: Arc, + ticket: StagingTicket, + publication: PublicationCoordinator, + identity: MutationIdentity, + request: HeadRequest, + ) -> Result<(), StagingError> { + active(&staging, &ticket).await?; + // Ref-only work has no incoming physical inputs. Bind selects the + // current certified joint generation after all staged work drains. + ticket.seal()?; + bound(&staging, &ticket).await?; + let format = self.repository.object_format(); + let indexes = Arc::new(CatalogIndexes::new(self.artifacts.clone(), format)); + let files = Arc::new( + CatalogFiles::new( + &self.scratch_root, + self.disk_budget.clone(), + self.artifacts.clone(), + format, + CatalogFileLimits::default(), + ) + .map_err(work)? + .with_native(self.native.clone()), + ); + let base = Arc::new(ticket.open_base(indexes, files).await?); + let gateway = self.clone(); + let ready = ticket + .spawn_bound(move |_, context| async move { + let prepared = Arc::new( + CatalogPreparation::new_staged( + &context, + &gateway.scratch_root, + gateway.disk_budget.clone(), + base, + MetadataLimits::default(), + ) + .await + .map_err(work)? + .finish() + .await + .map_err(work)?, + ); + prepared + .ready_native_head(identity, request) + .await + .map_err(work) + })? + .wait() + .await + .map_err(work)?; + let registered = ready + .persist_recovery(&self.artifacts, new_identity().map_err(work)?) + .await + .map_err(work)?; + let ready = ready + .bind_recovery(registered, &self.artifacts) + .map_err(work)?; + // Retrieve the producer result before Finishing so it cannot wait for + // its own worker drain. The lifecycle captures the exact ready owner. + let observer = ticket + .publish_wait(&publication, ready) + .await + .map_err(work)?; + match final_publication(&staging, &ticket, &observer).await { + Ok(PublicationOutcome::Head(_)) => Ok(()), + Err(StagingError::Publication(error)) + if matches!( + error.as_ref(), + PublicationError::Head(InvocationError::Rejected(_)) + ) => + { + Ok(()) + } + Err(error) => Err(error), + _ => Err(StagingError::Context), + } + } +} diff --git a/crates/canopy-server/src/git_gateway/merge.rs b/crates/canopy-server/src/git_gateway/merge.rs new file mode 100644 index 00000000..8b642680 --- /dev/null +++ b/crates/canopy-server/src/git_gateway/merge.rs @@ -0,0 +1,196 @@ +//! Reviewed merges use the same resident-owned staging and exact publication +//! lifecycle as pushes. HTTP observers own no producer or recovery command. +use super::push::native::{active, bound, final_publication}; +use super::*; +use crate::{ + packs::{ + catalog::{CatalogFileLimits, CatalogFiles, CatalogIndexes}, + metadata::MetadataLimits, + publication::{ + BeginRequest, CatalogPreparation, DEFAULT_LEASE_MS, PublicationCoordinator, + PublicationError, PublicationOutcome, StagingCoordinator, StagingError, StagingState, + StagingTicket, + }, + }, + pulls::merge::{MergeOutcome, MergeRequest, command::MergeInput, valid_request}, +}; +use cellule_runtime::{ + InvocationError, + codec::{BoundedEncoder, WireValue}, +}; + +fn work(error: impl StdError + Send + Sync + 'static) -> StagingError { + StagingError::Input(Box::new(error)) +} +fn failed(error: impl StdError + Send + Sync + 'static) -> GatewayError { + GatewayError::Cell(Box::new(error)) +} + +impl GitGateway { + /// Admit a fresh attempt for each request observation. Application UUID + /// selection is atomic in the final command; a previously denied SDK + /// identity is never reused with changed policy or proof bytes. + pub async fn merge_pull( + &self, + identity: MutationIdentity, + actor: &str, + number: i64, + request: MergeRequest, + ) -> Result { + if number < 1 + || !valid_request(&request) + || crate::directory::validate_component(actor).is_err() + { + return Err(failed(cellule_runtime::Error::Command( + "invalid merge request", + ))); + } + // This fresh query follows HTTP body ingestion. The final receiver + // independently rechecks authority after queueing and preparation. + let access = self + .repository + .access_level(ReadIdentity::Account(actor), None) + .await + .map_err(failed)? + .output; + match access { + None => return Ok(MergeOutcome::NotFound), + Some(role) if role < TokenScope::Write => return Ok(MergeOutcome::Forbidden), + _ => {} + } + let input = MergeInput { + actor: actor.into(), + number, + request: request.clone(), + issued_at_ms: identity.issued_at_ms, + }; + let mut encoded = BoundedEncoder::new(4096).map_err(failed)?; + input.encode(&mut encoded).map_err(failed)?; + let mut digest = blake3::Hasher::new(); + digest.update(b"canopy.reviewed-merge-workflow.v1\0"); + digest.update(&self.repository.repository_id()); + digest.update(&encoded.finish()); + let staging = self.repository.staging_coordinator().map_err(failed)?; + let ready = staging + .ready_request( + BeginRequest { + repository: self.repository.repository_id(), + operation: uuid::Uuid::new_v4().into_bytes(), + request_digest: *digest.finalize().as_bytes(), + actor: actor.into(), + lease_ms: DEFAULT_LEASE_MS, + }, + new_identity()?, + ) + .await + .map_err(failed)?; + let ticket = staging.submit(ready).map_err(|(error, _)| failed(error))?; + let gateway = self.clone(); + let owner = staging.clone(); + ticket + .drive(move |ticket, publication| async move { + Box::pin(gateway.drive_merge(owner, ticket, publication, identity, number, request)) + .await + }) + .map_err(failed)?; + match ticket.wait_completion().await { + StagingState::Published(Ok(PublicationOutcome::Merge(value))) => Ok(value.output), + StagingState::Published(Err(error)) => match error.as_ref() { + PublicationError::Merge(InvocationError::Rejected(value)) => { + Ok(value.output.clone()) + } + _ => Err(failed(error)), + }, + StagingState::Uncertain(error) | StagingState::Fenced(error) => Err(failed(error)), + _ => Err(failed(StagingError::NotReady)), + } + } + + async fn drive_merge( + &self, + staging: Arc, + ticket: StagingTicket, + publication: PublicationCoordinator, + identity: MutationIdentity, + number: i64, + request: MergeRequest, + ) -> Result<(), StagingError> { + active(&staging, &ticket).await?; + // Ref-only work has no incoming physical inputs. Bind selects the + // current certified joint generation after all staged work drains. + ticket.seal()?; + bound(&staging, &ticket).await?; + let format = self.repository.object_format(); + let indexes = Arc::new(CatalogIndexes::new(self.artifacts.clone(), format)); + let files = Arc::new( + CatalogFiles::new( + &self.scratch_root, + self.disk_budget.clone(), + self.artifacts.clone(), + format, + CatalogFileLimits::default(), + ) + .map_err(work)? + .with_native(self.native.clone()), + ); + let base = Arc::new(ticket.open_base(indexes, files).await?); + let gateway = self.clone(); + let ready = ticket + .spawn_bound(move |_, context| async move { + let prepared = Arc::new( + CatalogPreparation::new_staged( + &context, + &gateway.scratch_root, + gateway.disk_budget.clone(), + base, + MetadataLimits::default(), + ) + .await + .map_err(work)? + .finish() + .await + .map_err(work)?, + ); + prepared + .ready_native_merge( + identity, + number, + request, + &gateway.scratch_root, + gateway.disk_budget.clone(), + MetadataLimits::default(), + ) + .await + .map_err(work) + })? + .wait() + .await + .map_err(work)?; + let registered = ready + .persist_recovery(&self.artifacts, new_identity().map_err(work)?) + .await + .map_err(work)?; + let ready = ready + .bind_recovery(registered, &self.artifacts) + .map_err(work)?; + // Retrieve the producer result before Finishing so it cannot wait for + // its own worker drain. The lifecycle captures the exact ready owner. + let observer = ticket + .publish_wait(&publication, ready) + .await + .map_err(work)?; + match final_publication(&staging, &ticket, &observer).await { + Ok(PublicationOutcome::Merge(_)) => Ok(()), + Err(StagingError::Publication(error)) + if matches!( + error.as_ref(), + PublicationError::Merge(InvocationError::Rejected(_)) + ) => + { + Ok(()) + } + Err(error) => Err(error), + _ => Err(StagingError::Context), + } + } +} diff --git a/crates/canopy-server/src/git_gateway/mod.rs b/crates/canopy-server/src/git_gateway/mod.rs index 600bf224..5069b8ac 100644 --- a/crates/canopy-server/src/git_gateway/mod.rs +++ b/crates/canopy-server/src/git_gateway/mod.rs @@ -1,9 +1,10 @@ //! Durable bridge from Git smart HTTP to one repository's SQLite Cell. +use crate::ObjectKind; use crate::ReadIdentity; use std::{ - collections::{BTreeMap, BTreeSet, HashSet}, + collections::{BTreeMap, BTreeSet}, error::Error as StdError, path::{Path, PathBuf}, sync::Arc, @@ -18,22 +19,22 @@ use object_store::ObjectStore; use tokio::sync::{Mutex, OnceCell}; use crate::{ - INLINE_OBJECT_LIMIT, ObjectBatch, ObjectKind, ObjectStorage, PushPlan, RefExpectation, - RefUpdate, RepositoryCell, StoredObject, - blob::{LargeBlobError, LargeBlobStore}, + PushPlan, RefExpectation, RefUpdate, RepositoryCell, + blob::LargeBlobError, directory::TokenScope, git_cache::CacheError, git_http::{GitHttpBackend, GitHttpError, GitHttpRequest, GitHttpResponse}, git_input::{GitInput, InputError, MAX_FETCH_REQUEST_BYTES}, - git_objects::GitObjects, lfs::LfsService, - push::{PushCompletion, PushError}, + push::PushError, }; mod branch_policy; -mod candidates; +pub(crate) mod candidates; mod discovery; mod fetch; +mod head; +mod merge; pub mod preflight; mod push; mod ssh; @@ -82,17 +83,18 @@ struct CachedRepository { } /// Serves Git requests from a warm, disposable cache of durable Cell state. +#[derive(Clone)] pub struct GitGateway { repository: Arc, signer_directory: Option>, - certificate_seed: OnceCell<[u8; 32]>, - large_blobs: LargeBlobStore, - pack_reader: Arc, - lfs: LfsService, + certificate_seed: Arc>, + _pack_reader: Arc, + lfs: Arc, + artifacts: Arc, scratch_root: PathBuf, disk_budget: DiskBudget, native: crate::native_resources::NativeScope, - push: Mutex<()>, + push: Arc>, } impl GitGateway { @@ -104,7 +106,10 @@ impl GitGateway { native: crate::native_resources::NativeResources, ) -> Self { let native = native.scope(crate::native_resources::NativeClass::Foreground); - let large_blobs = LargeBlobStore::new(Arc::clone(&blob_store), repository.repository_id()); + let artifacts = Arc::new(canopy_object_storage::artifact::ArtifactStore::new( + Arc::clone(&blob_store), + repository.repository_id(), + )); // A reader belongs to this gateway's workspace and disk admission. // Another gateway may use a different root/budget for the same Cell. let pack_reader = Arc::new(crate::pack_store::PackReader::new( @@ -123,18 +128,18 @@ impl GitGateway { readers.retain(|reader| reader.strong_count() > 0); readers.push(Arc::downgrade(&pack_reader)); } - let lfs = LfsService::new(Arc::clone(&repository), blob_store); + let lfs = Arc::new(LfsService::new(Arc::clone(&repository), blob_store)); Self { repository, signer_directory: None, - certificate_seed: OnceCell::new(), - large_blobs, - pack_reader, + certificate_seed: Arc::new(OnceCell::new()), + _pack_reader: pack_reader, lfs, + artifacts, scratch_root, disk_budget, native, - push: Mutex::new(()), + push: Arc::new(Mutex::new(())), } } @@ -205,24 +210,7 @@ impl GitGateway { id, ) .await?; - // Upload spooling uses a private, budgeted scratch file. Serialize - // the push-ID check, decode, native Git work and publication, but - // do not let one slow client block another client's upload. - let _push = self.push.lock().await; - if self - .repository - .begin_push(id, actor, encoded.identity().request_digest) - .await? - { - return Ok(http_body(with_push_id( - self.repository.completed_response(id).await?, - id, - ))); - } - let preflight = encoded - .decode(&self.scratch_root, &self.disk_budget, None) - .await?; - return self.handle_push(preflight).await.map(http_body); + return self.handle_native_push(encoded).await; } let request = self .receive(request, Some(MAX_FETCH_REQUEST_BYTES), admission) @@ -261,10 +249,31 @@ impl GitGateway { .await .map_err(|e| GatewayError::Cell(Box::new(e)))?; Self::validate_wants(&workspace, &fetch.wants).await?; + if discovery { + workspace + .prepare_advertisement() + .await + .map_err(|e| GatewayError::Cell(Box::new(e)))?; + } else { + workspace + .prepare_fetch( + fetch.wants.iter().copied().collect(), + fetch.filter.clone(), + fetch.needs_blob_sizes, + ) + .await + .map_err(|e| GatewayError::Cell(Box::new(e)))?; + } tracing::debug!(discovery,filter=?fetch.filter,generation=workspace.fact().generation, "prepared certified Git transport"); let backend = workspace.backend(self.certificate_nonce().await?); - backend.stream(request, workspace.read_owner()).await? + backend + .stream_command( + request, + workspace.read_owner(), + backend.certified_fetch_command()?, + ) + .await? }; Ok(GitHttpResponse { status: response.status, @@ -356,195 +365,9 @@ impl GitGateway { .with_nonce(self.certificate_nonce().await?); Ok(CachedRepository { backend, refs }) } - - async fn persist_objects( - &self, - backend: &GitHttpBackend, - before: &BTreeMap, - plan: &PushPlan, - ) -> Result<(), GatewayError> { - let included: Vec<_> = plan - .updates - .iter() - .filter_map(|update| update.new_oid) - .collect(); - if included.is_empty() { - return Ok(()); - } - // Published refs already have durable graph closure. Excluding them avoids - // re-reading old history; the final Cell transaction still verifies every new tip. - let excluded = before.values().filter_map(|state| state.oid).collect(); - let started = std::time::Instant::now(); - let mut sources = backend.cache.pack_sources().await?; - let mut archive = None; - let mut packed_ids = None; - if sources.len() == 1 { - let (hash, pack, index, ids) = sources.pop().ok_or(GatewayError::MalformedCache)?; - let record = crate::pack_store::PackRecord { - hash, - pack: self.pack_reader.upload(pack).await?, - index: self.pack_reader.upload(index).await?, - approved: false, - }; - self.repository - .register_pack(new_identity()?, &record) - .await - .map_err(|error| GatewayError::Cell(Box::new(error)))?; - archive = Some(record); - packed_ids = Some(ids); - } - let mut objects = if let Some(ids) = packed_ids { - GitObjects::packed(&backend.git_dir(), ids, &backend.cache.native)? - } else { - GitObjects::start( - &backend.git_dir(), - included, - excluded, - &backend.cache.native, - )? - }; - - let mut batch = ObjectBatch::default(); - let mut verified = HashSet::new(); - let mut logged = std::time::Instant::now(); - loop { - let mut candidates = Vec::with_capacity(crate::object_batch::MAX_BATCH_OBJECTS); - for _ in 0..crate::object_batch::MAX_BATCH_OBJECTS { - let Some(oid) = objects.next().await? else { - break; - }; - candidates.push(oid); - } - if candidates.is_empty() { - break; - } - let present = self - .repository - .canonical_headers(&candidates) - .await - .map_err(|error| GatewayError::Cell(Box::new(error)))?; - for oid in candidates { - if let Some((kind, size, digest)) = present.get(&oid) { - if archive.is_some() { - objects - .read(oid) - .await? - .verify(*kind, *size, *digest) - .await?; - verified.insert(oid); - } - continue; - } - let mut input = objects.read(oid).await?; - let object = if input.kind == ObjectKind::Blob && archive.is_some() { - let size = input.size; - let digest = input.fingerprint().await?; - StoredObject { - oid, - kind: ObjectKind::Blob, - storage: ObjectStorage::Packed { - size, - blake3: digest, - pack: archive - .as_ref() - .ok_or(GatewayError::MalformedCache)? - .pack - .sha256, - }, - } - } else if input.kind == ObjectKind::Blob && input.size > INLINE_OBJECT_LIMIT as u64 - { - let uploaded = self - .large_blobs - .put(oid, input.size, &mut input.reader) - .await?; - input.finish().await?; - StoredObject { - oid, - kind: ObjectKind::Blob, - storage: ObjectStorage::External { - size: uploaded.size, - blake3: uploaded.blake3, - sha256: uploaded.sha256, - }, - } - } else { - let (kind, body) = input.body().await?; - if body.len() > INLINE_OBJECT_LIMIT { - self.repository - .stage_object(new_identity()?, kind, &body) - .await - .map_err(|error| GatewayError::Cell(Box::new(error)))? - } else if let Some(record) = - archive.as_ref().filter(|_| kind == ObjectKind::Blob) - { - StoredObject { - oid, - kind, - storage: ObjectStorage::Packed { - size: body.len() as u64, - blake3: *blake3::hash(&body).as_bytes(), - pack: record.pack.sha256, - }, - } - } else { - StoredObject { - oid, - kind, - storage: ObjectStorage::Inline(body), - } - } - }; - if let Err(object) = batch.try_push(object) { - self.repository - .put_objects(new_identity()?, std::mem::take(&mut batch)) - .await - .map_err(|error| GatewayError::Cell(Box::new(error)))?; - batch - .try_push(object) - .map_err(|_| GatewayError::MalformedCache)?; - } - verified.insert(oid); - } - if logged.elapsed().as_secs() >= 10 { - tracing::info!( - objects = verified.len(), - elapsed_seconds = started.elapsed().as_secs_f64(), - "persisting Git objects" - ); - logged = std::time::Instant::now(); - } - } - objects.finish().await?; - if !batch.is_empty() { - self.repository - .put_objects(new_identity()?, batch) - .await - .map_err(|error| GatewayError::Cell(Box::new(error)))?; - } - if let Some(record) = &archive { - self.repository - .approve_pack(new_identity()?, record.pack.sha256, verified.len()) - .await - .map_err(|error| GatewayError::Cell(Box::new(error)))?; - } - tracing::info!( - elapsed_seconds = started.elapsed().as_secs_f64(), - "persisted Git objects" - ); - Ok(()) - } -} - -fn http_body(response: GitHttpResponse) -> GitHttpResponse { - GitHttpResponse { - status: response.status, - headers: response.headers, - body: Body::from(response.body), - } } -fn with_push_id(mut response: GitHttpResponse, id: [u8; 16]) -> GitHttpResponse { +fn with_push_id(mut response: GitHttpResponse, id: [u8; 16]) -> GitHttpResponse { response.headers.push(( "X-Canopy-Push-Id".into(), uuid::Uuid::from_bytes(id).to_string(), @@ -596,11 +419,12 @@ async fn git_refs( for page in ref_pages(names, 32, 64 << 10) { let mut input = page.join("\n").into_bytes(); input.push(b'\n'); - let output = candidates::run( + let output = candidates::run_owned( backend, &["cat-file", "--batch-check=%(objectname)"], &input, &[], + Arc::new(()), ) .await?; if !output.status.success() { diff --git a/crates/canopy-server/src/git_gateway/push.rs b/crates/canopy-server/src/git_gateway/push.rs index 0fca0d64..2426be67 100644 --- a/crates/canopy-server/src/git_gateway/push.rs +++ b/crates/canopy-server/src/git_gateway/push.rs @@ -1,124 +1,7 @@ +pub(super) mod native; use super::*; impl GitGateway { - pub(super) async fn handle_push( - &self, - preflight: preflight::PushPreflight, - ) -> Result { - let preflight::PushParts { - request, - commands, - identity, - } = preflight.into_parts(); - let actor = identity.actor.as_str(); - let id = identity.operation; - let digest = identity.request_digest; - let option_error = commands.option_error().or_else(|| { - (commands.certificate().is_some() && self.signer_directory.is_none()) - .then_some("Canopy signed pushes are unavailable on this gateway") - }); - let prepared = async { - if let Some(reason) = option_error { - let response = commands - .rejection(reason)? - .ok_or(GatewayError::MalformedCache)?; - return Ok::<_, GatewayError>((response, None, None)); - } - let names = commands.names(); - let cached = self.build_cache(actor, &names).await?; - self.install_branch_policy(&cached, &commands).await?; - let signers = self.install_certificate_policy(&cached, &commands, actor).await?; - let before = cached.refs.clone(); - let backend = signers.map_or_else( - || cached.backend.clone(), - |path| cached.backend.with_signers(path), - ); - let mut response = backend.run(request).await?; - let certificate = self.verified_certificate(&cached, &commands, actor, digest).await?; - // Git may accept some refs and reject others unless atomic was requested. - // Publish its actual changes before returning any successful per-ref report. - let plan = if response.status == 200 { - let after = git_refs(&cached.backend, &names).await?; - let plan = diff_refs(&before, &after, actor); - if plan.updates.is_empty() { - None - } else { - match self.persist_objects(&cached.backend, &before, &plan).await { - Ok(()) => Some(plan), - Err(error) => { - tracing::warn!(push_id = %hex::encode(id), error = ?error, "Git object ingestion failed"); - let reason = match error { - GatewayError::Objects(ObjectReadError::TooLarge) => { - "Canopy object ingestion failed: object exceeds server size limit" - } - _ => "Canopy object ingestion failed; retry push after server recovery", - }; - response = crate::push::report::rejected_report(&response, reason)?; - // Ingestion cannot publish refs. Persist this refusal through - // completion so a concurrent attempt with the same ID can - // win; only the canonical durable response reaches the client. - None - } - } - } - } else { - None - }; - Ok::<_, GatewayError>((response, plan, certificate)) - } - .await; - let (response, plan, certificate) = match prepared { - Ok(prepared) => prepared, - Err(error) => { - let reason = match &error { - GatewayError::Cache(error) | GatewayError::Http(GitHttpError::Cache(error)) - if error.is_admission() => - { - "Canopy push failed before publication: cache disk budget exhausted" - } - GatewayError::Certificate(reason) => reason, - _ => "Canopy push failed before publication; retry after server recovery", - }; - let Some(response) = commands.rejection(reason)? else { - return Err(error); - }; - tracing::warn!(push_id = %hex::encode(id), error = ?error, "Git push failed before publication"); - (response, None, None) - } - }; - let options = if option_error.is_some() { - Vec::new() - } else { - commands.options().to_vec() - }; - // Release parsed command names before staging a potentially large report. - drop(commands); - // Preparation and native Git mutate only disposable refs. Record their - // refusal through completion; a concurrent same-ID winner stays canonical. - // Publication failures below may be uncertain and must never become ng. - let response_id = self.repository.stage_push_response(id, &response).await?; - let result = self - .repository - .complete_push(PushCompletion { - id, - actor: actor.into(), - digest, - response_id, - options, - plan, - certificate, - }) - .await - .map_err(|error| GatewayError::Cell(Box::new(error)))?; - if !result.output { - return Err(PushError::InvalidResponse.into()); - } - Ok(with_push_id( - self.repository.completed_response(id).await?, - id, - )) - } - async fn verified_certificate( &self, cached: &CachedRepository, diff --git a/crates/canopy-server/src/git_gateway/push/native.rs b/crates/canopy-server/src/git_gateway/push/native.rs new file mode 100644 index 00000000..9e4095f1 --- /dev/null +++ b/crates/canopy-server/src/git_gateway/push/native.rs @@ -0,0 +1,564 @@ +//! Production receive-pack is a resident-owned workflow. Request observers +//! never own native work or acknowledge disposable cache refs. +use super::*; +use crate::packs::{ + catalog::{CatalogFileLimits, CatalogFiles, CatalogIndexes}, + metadata::MetadataLimits, + publication::{ + CatalogPreparation, NativeInputCertificate, PublicationCoordinator, PublicationOutcome, + PublicationState, PushCompletionRequest, RegisteredRootRecovery, StagedPublicationTicket, + StagingCoordinator, StagingError, StagingState, StagingTicket, + }, + verification::{NativeMetadataLimits, PhysicalLimits, PhysicalVerifier}, +}; +use canopy_object_storage::artifact::ArtifactRead; + +fn input(error: impl StdError + Send + Sync + 'static) -> StagingError { + StagingError::Input(Box::new(error)) +} +fn observed(error: Arc) -> StagingError { + input(error) +} +fn mutation() -> Result { + new_identity().map_err(input) +} + +impl GitGateway { + pub(in crate::git_gateway) async fn handle_native_push( + &self, + encoded: preflight::EncodedPush, + ) -> Result, GatewayError> { + let staging = self + .repository + .staging_coordinator() + .map_err(|e| GatewayError::Cell(Box::new(e)))?; + let identity = encoded.identity().clone(); + let id = identity.operation; + if let Some(response) = staging + .replay_request(identity.clone(), &self.artifacts) + .await + .map_err(|error| match error { + crate::packs::publication::RootPushReplayError::Denied( + crate::packs::publication::PreparationDenial::Conflict, + ) => GatewayError::Push(crate::PushError::Conflict), + crate::packs::publication::RootPushReplayError::Denied( + crate::packs::publication::PreparationDenial::Unauthorized, + ) => GatewayError::Unauthorized, + error => GatewayError::Cell(Box::new(error)), + })? + { + return Ok(with_push_id(artifact_body(response), id)); + } + // Serialize admission only. Independent pushes execute under bounded + // worker admission and publish against the authoritative bound floor. + let admission = self.push.lock().await; + let ticket = if let Some(ticket) = staging + .join_request(&identity) + .map_err(|e| GatewayError::Cell(Box::new(e)))? + { + ticket + } else { + let ready = staging + .ready_request(identity.clone(), new_identity()?) + .await + .map_err(|e| GatewayError::Cell(Box::new(e)))?; + let ticket = staging + .submit(ready) + .map_err(|(error, _)| GatewayError::Cell(Box::new(error)))?; + let gateway = self.clone(); + let owner = staging.clone(); + // Transfer encoded bytes synchronously before observing any state. + ticket + .drive_receive(move |ticket, publication| async move { + Box::pin(gateway.drive_push(owner, ticket, publication, encoded)).await + }) + .map_err(|e| GatewayError::Cell(Box::new(e)))?; + ticket + }; + drop(admission); + match ticket.wait_completion().await { + StagingState::Published(Ok(PublicationOutcome::RootPush(_))) => { + // Use the known completion's original read capability and + // receipt. Recheck current authorization before streaming. + let publication = ticket + .pending_publication() + .ok_or_else(|| GatewayError::Cell(Box::new(StagingError::Context)))?; + let response = publication + .root_response(&self.artifacts) + .await + .map_err(|e| GatewayError::Cell(Box::new(e)))?; + Ok(with_push_id(artifact_body(response), id)) + } + StagingState::Published(Err(error)) => Err(GatewayError::Cell(Box::new(error))), + StagingState::Uncertain(error) | StagingState::Fenced(error) => { + Err(GatewayError::Cell(Box::new(error))) + } + _ => Err(GatewayError::Cell(Box::new(StagingError::NotReady))), + } + } + + async fn drive_push( + &self, + staging: Arc, + ticket: StagingTicket, + publication: PublicationCoordinator, + encoded: preflight::EncodedPush, + ) -> Result<(), StagingError> { + active(&staging, &ticket).await?; + let store = self.artifacts.clone(); + let (encoded, request) = ticket + .spawn(move |context| async move { + let (encoded, saved) = encoded.retain(&context, &store).await.map_err(input)?; + let checkpoint = context + .seal_push_inputs(store, std::iter::empty(), saved) + .await + .map_err(input)?; + Ok((encoded, checkpoint)) + })? + .wait() + .await + .map_err(observed)?; + checkpoint(&staging, &ticket, request.clone()).await?; + let gateway = self.clone(); + let (packs, certificate, plan) = ticket + .spawn(move |context| async move { + let preflight = encoded + .decode(&gateway.scratch_root, &gateway.disk_budget, None) + .await + .map_err(input)?; + let (completion, packs) = gateway + .native_result(&context, preflight) + .await + .map_err(input)?; + let plan = completion.plan.clone(); + let result = context + .retain_native_result( + &gateway.artifacts, + &request, + completion, + &gateway.scratch_root, + &gateway.disk_budget, + ) + .await + .map_err(input)?; + let certificate = context + .append_native_result( + gateway.artifacts.clone(), + &request, + packs.iter().copied(), + result, + ) + .await + .map_err(input)?; + Ok((packs, certificate, plan)) + })? + .wait() + .await + .map_err(observed)?; + checkpoint(&staging, &ticket, certificate).await?; + // Only bounded descriptor spools survive physical verification. Native + // databases and worker activities drain before Bind can start. + let mut metadata = Vec::with_capacity(packs.len()); + if plan.is_some() { + for pack in packs { + let gateway = self.clone(); + let staged = ticket + .spawn(move |context| async move { + PhysicalVerifier::download_staged( + &context, + &gateway.scratch_root, + gateway.disk_budget.clone(), + &gateway.artifacts, + pack, + PhysicalLimits::default(), + gateway.native.clone(), + ) + .await + .map_err(input)? + .stage_metadata(NativeMetadataLimits::default()) + .await + .map_err(input) + })? + .wait() + .await + .map_err(observed)?; + metadata.push(staged); + } + } + ticket.seal()?; + bound(&staging, &ticket).await?; + let session = ticket.bound_session()?; + let Some(plan) = plan else { + let gateway = self.clone(); + let ready = ticket + .spawn_bound(move |_, _context| async move { + session + .ready_root_outcome( + mutation()?, + &gateway.artifacts, + &gateway.scratch_root, + gateway.disk_budget.clone(), + gateway.signer_directory.as_deref(), + ) + .await + .map_err(input) + })? + .wait() + .await + .map_err(observed)?; + let registered = ready + .persist_recovery(&self.artifacts, mutation()?) + .await + .map_err(input)?; + let ready = ready + .bind_recovery(registered, &self.artifacts) + .map_err(input)?; + let observer = ticket + .publish_wait(&publication, ready) + .await + .map_err(input)?; + final_publication(&staging, &ticket, &observer).await?; + return Ok(()); + }; + let format = self.repository.object_format(); + let indexes = Arc::new(CatalogIndexes::new(self.artifacts.clone(), format)); + let files = Arc::new( + CatalogFiles::new( + &self.scratch_root, + self.disk_budget.clone(), + self.artifacts.clone(), + format, + CatalogFileLimits::default(), + ) + .map_err(input)? + .with_native(self.native.clone()), + ); + let base = Arc::new(ticket.open_base(indexes, files).await?); + let gateway = self.clone(); + let prepared = Arc::new( + ticket + .spawn_bound(move |_, context| async move { + let mut builder = CatalogPreparation::new_staged( + &context, + &gateway.scratch_root, + gateway.disk_budget.clone(), + base, + MetadataLimits::default(), + ) + .await + .map_err(input)?; + for staged in metadata { + builder.add_staged_pack(staged).await.map_err(input)?; + } + builder.finish().await.map_err(input) + })? + .wait() + .await + .map_err(observed)?, + ); + let gateway = self.clone(); + let owner = prepared.clone(); + let (policy, refusal) = ticket + .spawn_bound(move |_, _context| async move { + let policy = Arc::new( + owner + .ref_policy_preparation( + plan, + &gateway.scratch_root, + gateway.disk_budget.clone(), + MetadataLimits::default(), + ) + .await + .map_err(input)?, + ); + let refusal = Arc::new( + session + .ready_root_refusal( + mutation()?, + &gateway.artifacts, + &gateway.scratch_root, + gateway.disk_budget.clone(), + gateway.signer_directory.as_deref(), + ) + .await + .map_err(input)?, + ); + Ok((policy, refusal)) + })? + .wait() + .await + .map_err(observed)?; + let mut offset = 0; + let mut previous: Option = None; + while offset < policy.plan().updates.len() { + let intent = policy.clone(); + let owner = prepared.clone(); + let refusal = refusal.clone(); + let store = self.artifacts.clone(); + let head = previous.clone(); + let (ready, registered, end) = ticket + .spawn_bound(move |_, _context| async move { + let ready = intent + .ready_page(&owner, mutation()?, offset) + .await + .map_err(input)? + .with_refusal(refusal) + .map_err(input)?; + let end = ready.end_offset(); + let registered = ready + .persist_recovery(&store, mutation()?, head.as_ref()) + .await + .map_err(input)?; + let ready = ready + .bind_recovery(registered.clone(), &store) + .map_err(input)?; + Ok((ready, registered, end)) + })? + .wait() + .await + .map_err(observed)?; + let observer = ticket + .register_policy_page_wait(&publication, ready) + .await + .map_err(input)?; + match final_publication(&staging, &ticket, &observer).await? { + PublicationOutcome::PolicyPage(_) => bound(&staging, &ticket).await?, + PublicationOutcome::RootPush(_) => return Ok(()), + _ => return Err(StagingError::Context), + } + previous = Some(registered); + offset = end; + } + let gateway = self.clone(); + let frozen_refusal = refusal.clone(); + let ready = ticket + .spawn_bound(move |_, _context| async move { + let guard = policy.ready(&prepared).await.map_err(input)?; + prepared + .ready_root_push( + mutation()?, + &guard, + &gateway.scratch_root, + gateway.disk_budget.clone(), + MetadataLimits::default(), + gateway.signer_directory.as_deref(), + ) + .await + .map_err(input)? + .with_refusal(frozen_refusal) + .map_err(input) + })? + .wait() + .await + .map_err(observed)?; + let registered = ready + .persist_recovery_after( + &self.artifacts, + mutation()?, + previous.as_ref().ok_or(StagingError::Context)?, + ) + .await + .map_err(input)?; + let ready = ready + .bind_recovery(registered, &self.artifacts) + .map_err(input)?; + let observer = ticket + .publish_wait(&publication, ready) + .await + .map_err(input)?; + final_publication(&staging, &ticket, &observer).await?; + Ok(()) + } + + async fn native_result( + &self, + context: &crate::packs::publication::StagingContext, + preflight: preflight::PushPreflight, + ) -> Result< + ( + PushCompletionRequest, + Vec, + ), + GatewayError, + > { + let preflight::PushParts { + request, + commands, + identity, + } = preflight.into_parts(); + let actor = identity.actor.as_str(); + let option_error = commands.option_error().or_else(|| { + (commands.certificate().is_some() && self.signer_directory.is_none()) + .then_some("Canopy signed pushes are unavailable on this gateway") + }); + let result = async { + if let Some(reason) = option_error { + return Ok(( + commands + .rejection(reason)? + .ok_or(GatewayError::MalformedCache)?, + None, + None, + Vec::new(), + )); + } + let names = commands.names(); + let cached = self.build_cache(actor, &names).await?; + self.install_branch_policy(&cached, &commands).await?; + let signers = self + .install_certificate_policy(&cached, &commands, actor) + .await?; + let backend = signers.map_or_else( + || cached.backend.clone(), + |path| cached.backend.with_signers(path), + ); + let response = backend.run_native_receive(context, request).await?; + let certificate = self + .verified_certificate(&cached, &commands, actor, identity.request_digest) + .await?; + if let Some(verified) = &certificate { + backend + .remove_disposable_certificate( + context, + crate::object_id( + self.repository.object_format(), + ObjectKind::Blob, + &verified.body, + ), + ) + .await + .map_err(|e| GatewayError::Cell(Box::new(e)))?; + } + let plan = if response.status == 200 { + let after = git_refs(&cached.backend, &names).await?; + let plan = diff_refs(&cached.refs, &after, actor); + (!plan.updates.is_empty()).then_some(plan) + } else { + None + }; + let packs = if plan.is_some() { + backend + .stage_native_packs(context, &self.artifacts, PhysicalLimits::default()) + .await + .map_err(|e| GatewayError::Cell(Box::new(e)))? + } else { + Vec::new() + }; + Ok::<_, GatewayError>((response, plan, certificate, packs)) + } + .await; + let (response, plan, certificate, packs) = match result { + Ok(result) => result, + Err(error) => { + let reason = match &error { + GatewayError::Cache(error) | GatewayError::Http(GitHttpError::Cache(error)) + if error.is_admission() => + { + "Canopy push failed before publication: cache disk budget exhausted" + } + GatewayError::Certificate(reason) => reason, + _ => "Canopy push failed before publication; retry after server recovery", + }; + let Some(response) = commands.rejection(reason)? else { + return Err(error); + }; + tracing::warn!(push_id = %hex::encode(identity.operation), ?error, "native push failed before publication"); + (response, None, None, Vec::new()) + } + }; + Ok(( + PushCompletionRequest { + response, + plan, + certificate, + options: if option_error.is_some() { + Vec::new() + } else { + commands.options().to_vec() + }, + }, + packs, + )) + } +} + +pub(in crate::git_gateway) async fn active( + staging: &StagingCoordinator, + ticket: &StagingTicket, +) -> Result<(), StagingError> { + loop { + match ticket.wait().await { + StagingState::Active(_) => return Ok(()), + StagingState::Uncertain(_) => { + staging.recover(ticket)?; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + StagingState::Fenced(error) => return Err(observed(error)), + _ => return Err(StagingError::Inactive), + } + } +} +pub(in crate::git_gateway) async fn bound( + staging: &StagingCoordinator, + ticket: &StagingTicket, +) -> Result<(), StagingError> { + loop { + match ticket.wait_terminal().await { + StagingState::Bound(_) => return Ok(()), + StagingState::Uncertain(_) => { + staging.recover(ticket)?; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + StagingState::Fenced(error) => return Err(observed(error)), + _ => return Err(StagingError::Inactive), + } + } +} +pub(in crate::git_gateway) async fn checkpoint( + staging: &StagingCoordinator, + ticket: &StagingTicket, + proof: NativeInputCertificate, +) -> Result<(), StagingError> { + let registration = ticket + .register_inputs(proof, mutation()?) + .map_err(|(error, _)| error)?; + loop { + match registration.wait_ready().await { + Ok(_) => return Ok(()), + Err(_) if matches!(ticket.state(), StagingState::Uncertain(_)) => { + staging.recover(ticket)?; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + Err(error) => return Err(observed(error)), + } + } +} +pub(in crate::git_gateway) async fn final_publication( + staging: &StagingCoordinator, + ticket: &StagingTicket, + observer: &StagedPublicationTicket, +) -> Result { + loop { + match observer.wait().await { + PublicationState::Finished(Ok(value)) => return Ok(value), + PublicationState::Finished(Err(error)) => return Err(StagingError::Publication(error)), + PublicationState::Uncertain(_) => { + staging.recover(ticket)?; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + _ => return Err(StagingError::Inactive), + } + } +} +fn artifact_body(response: GitHttpResponse) -> GitHttpResponse { + let stream = futures_util::stream::try_unfold(response.body, |mut body| async move { + Ok::<_, canopy_object_storage::artifact::ArtifactError>( + body.next().await?.map(|bytes| (bytes, body)), + ) + }); + GitHttpResponse { + status: response.status, + headers: response.headers, + body: Body::from_stream(stream), + } +} diff --git a/crates/canopy-server/src/git_gateway/ssh.rs b/crates/canopy-server/src/git_gateway/ssh.rs index 217b9c30..eeb409a3 100644 --- a/crates/canopy-server/src/git_gateway/ssh.rs +++ b/crates/canopy-server/src/git_gateway/ssh.rs @@ -29,8 +29,12 @@ impl GitGateway { .ref_workspace(crate::packs::publication::WorkspaceLimits::default()) .await .map_err(|e| GatewayError::Cell(Box::new(e)))?; + workspace + .prepare_advertisement() + .await + .map_err(|e| GatewayError::Cell(Box::new(e)))?; let backend = workspace.backend(self.certificate_nonce().await?); - let mut command = backend.transport_command()?; + let mut command = backend.certified_fetch_command()?; command .arg("upload-pack") .arg(backend.git_dir()) @@ -74,6 +78,14 @@ impl GitGateway { remaining -= group.len(); let request = fetch::FetchRequest::parse(&group)?; Self::validate_wants(&workspace, &request.wants).await?; + workspace + .prepare_fetch( + request.wants.iter().copied().collect(), + request.filter.clone(), + request.needs_blob_sizes, + ) + .await + .map_err(|e| GatewayError::Cell(Box::new(e)))?; stdin.write_all(&group).await?; stdin.flush().await?; if !protocol_v2 { @@ -183,6 +195,11 @@ impl GitGateway { .ok_or(InputError::Commands)?; commands.extend_from_slice(&options); } + // Keep only bounded wire intent for a known pre-publication refusal. + // This report cannot acknowledge a push or alter durable state. + let refusal = + branch_policy::commands_in_format(&commands, Some(self.repository.object_format()))? + .rejection(crate::push::report::REJECTED)?; // Stock send-pack closes its write fd after pack-objects. Delete-only // pushes have no pack and await status without closing stdin. Preserve // their negotiated options group before dispatching the completed body. @@ -193,9 +210,36 @@ impl GitGateway { } else { Body::from(commands) }; - let response = self + let response = match self .handle(rpc("POST", body), actor, None, Some(admission)) - .await?; + .await + { + Ok(response) => response, + Err(error) => { + // Only a known fenced attempt plus a current write downgrade + // can use the original per-ref ng report. Uncertain mutations, + // storage failures and lost replies retain their error path. + let inactive = match &error { + GatewayError::Cell(source) => source + .downcast_ref::>() + .is_some_and(|error| { + matches!(**error, crate::packs::publication::StagingError::Inactive) + }), + _ => false, + }; + if inactive + && self + .access_level(actor) + .await? + .is_some_and(|role| role < TokenScope::Write) + { + let response = refusal.ok_or(error)?; + write_body(Body::from(response.body), &mut writer, b"").await?; + return Ok(()); + } + return Err(error); + } + }; if response.status != 200 { return Err(GitHttpError::Interrupted.into()); } diff --git a/crates/canopy-server/src/git_http/capture.rs b/crates/canopy-server/src/git_http/capture.rs index 1bc10afb..656457b5 100644 --- a/crates/canopy-server/src/git_http/capture.rs +++ b/crates/canopy-server/src/git_http/capture.rs @@ -72,6 +72,51 @@ struct Pair { index: Arc, } impl GitHttpBackend { + /// Git writes its verified push certificate as a request-private loose + /// blob. The immutable native result retains these exact audit bytes; this + /// disposable blob is not an incoming pack or a reachable Git object. + pub(crate) async fn remove_disposable_certificate( + &self, + context: &StagingContext, + oid: crate::ObjectId, + ) -> Result<(), NativeCaptureError> { + context.ensure_live()?; + if oid.format() != context.format() { + return Err(NativeCaptureError::Context); + } + let cache = self.cache.clone(); + let activity = context.physical_owner(); + tokio::task::spawn_blocking(move || { + let _activity = activity; + let fence = crate::native_git::lock_file( + &cache.git_dir().join(crate::native_git::WORKER_LOCK), + )?; + fence.try_lock().map_err(std::io::Error::from)?; + let hex = hex::encode(oid); + let directory = cache.git_dir().join("objects").join(&hex[..2]); + match std::fs::remove_file(directory.join(&hex[2..])) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + match std::fs::remove_dir(directory) { + Ok(()) => {} + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty + ) => {} + Err(error) => return Err(error.into()), + } + fence.unlock()?; + Ok::<_, NativeCaptureError>(()) + }) + .await??; + self.cache.reconcile_owned(context.physical_owner()).await?; + context.ensure_live()?; + Ok(()) + } + /// Run inside a StagingTicket producer after native receive completes. The /// returned inputs establish authenticated bytes, not physical decoding, /// closure, ref authorization or a durable completed network response. @@ -143,6 +188,9 @@ impl GitHttpBackend { { return Err(NativeCaptureError::Limit); } + if NativePackDescriptor::is_empty_pair(format, &path, &index_path)? { + continue; + } let native = NativePackDescriptor::inspect_files( token.repository, token.artifact_operation, @@ -171,37 +219,128 @@ impl GitHttpBackend { }) .await??; context.ensure_live()?; - let mut inputs = Vec::with_capacity(pairs.len()); - for Pair { - mut native, - pack, - index, - } in pairs + upload_pairs(context, store, pairs).await + } + + /// Only the producer's exact newly generated pair is captured. Existing + /// immutable base packs and loose intermediates are never re-ingested. + pub(crate) async fn stage_generated_pack( + &self, + context: &StagingContext, + store: &ArtifactStore, + limits: PhysicalLimits, + checksum: &str, + ) -> Result, NativeCaptureError> { + let token = context.token()?; + let format = context.format(); + if token.repository != store.repository() + || format != self.cache.object_format + || checksum.len() != format.bytes() * 2 + || !checksum + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) { - context.ensure_live()?; - native.pack = upload_file( - pack, - store, - native.key(ArtifactKind::Pack)?, - native.pack.size, - native.pack.digest, - ) - .await?; - context.ensure_live()?; - native.index = upload_file( - index, - store, - native.key(ArtifactKind::Index)?, - native.index.size, - native.index.digest, - ) - .await?; - context.ensure_live()?; - inputs.push(native); + return Err(NativeCaptureError::Context); } + let _selection = self.cache.selection.lock().await; + self.cache.reconcile_owned(context.physical_owner()).await?; + let cache = self.cache.clone(); + let owner = context.physical_owner(); + let checksum = checksum.to_owned(); + let claim = cache + .native + .try_admit(crate::native_resources::NativeWork::Read)?; + let pair = tokio::task::spawn_blocking(move || { + let _claim = claim; + let fence = crate::native_git::lock_file( + &cache.git_dir().join(crate::native_git::WORKER_LOCK), + )?; + fence.try_lock().map_err(std::io::Error::from)?; + let pin = Arc::new(CapturePin { + _fence: fence, + cache, + _owner: owner, + }); + let path = pin.cache.git_dir().join("objects/pack").join(format!( + "canopy-generated-{}-{}.pack", + hex::encode(token.artifact_operation), + checksum + )); + let index_path = path.with_extension("idx"); + if !std::fs::symlink_metadata(&path)?.is_file() + || !std::fs::symlink_metadata(&index_path)?.is_file() + { + return Err(NativeCaptureError::Context); + } + if std::fs::metadata(&path)?.len() > limits.max_pack_bytes + || std::fs::metadata(&index_path)?.len() > limits.max_index_bytes + { + return Err(NativeCaptureError::Limit); + } + let native = NativePackDescriptor::inspect_files( + token.repository, + token.artifact_operation, + format, + &path, + &index_path, + )?; + if hex::encode(native.git_checksum) != checksum + || NativePackDescriptor::is_empty_pair(format, &path, &index_path)? + { + return Err(NativeCaptureError::Context); + } + Ok::<_, NativeCaptureError>(Pair { + native, + pack: Arc::new(InputFile { + path, + _pin: pin.clone(), + }), + index: Arc::new(InputFile { + path: index_path, + _pin: pin, + }), + }) + }) + .await??; + upload_pairs(context, store, vec![pair]).await + } +} + +async fn upload_pairs( + context: &StagingContext, + store: &ArtifactStore, + pairs: Vec, +) -> Result, NativeCaptureError> { + let mut inputs = Vec::with_capacity(pairs.len()); + for Pair { + mut native, + pack, + index, + } in pairs + { + context.ensure_live()?; + native.pack = upload_file( + pack, + store, + native.key(ArtifactKind::Pack)?, + native.pack.size, + native.pack.digest, + ) + .await?; + context.ensure_live()?; + native.index = upload_file( + index, + store, + native.key(ArtifactKind::Index)?, + native.index.size, + native.index.digest, + ) + .await?; context.ensure_live()?; - Ok(inputs) + inputs.push(native); } + context.ensure_live()?; + Ok(inputs) } #[cfg(test)] diff --git a/crates/canopy-server/src/git_http/mod.rs b/crates/canopy-server/src/git_http/mod.rs index 6e79505f..af491569 100644 --- a/crates/canopy-server/src/git_http/mod.rs +++ b/crates/canopy-server/src/git_http/mod.rs @@ -249,6 +249,15 @@ impl GitHttpBackend { Ok(process) } + /// Only gateways validating every want against certified live-ref + /// membership may use this command. Sparse transport workspaces need not + /// contain unrelated ref histories for Git to repeat that authorization. + pub(crate) fn certified_fetch_command(&self) -> Result { + let mut command = self.transport_command()?; + command.args(["-c", "uploadpack.allowAnySHA1InWant=true"]); + Ok(command) + } + /// Streams Git output while retaining the caller's disposable cache owner. pub(crate) async fn stream( &self, @@ -259,7 +268,7 @@ impl GitHttpBackend { .await } - async fn stream_command( + pub(crate) async fn stream_command( &self, request: GitHttpRequest, keep_alive: T, diff --git a/crates/canopy-server/src/git_objects/mod.rs b/crates/canopy-server/src/git_objects/mod.rs index fe593e9f..7bb6278b 100644 --- a/crates/canopy-server/src/git_objects/mod.rs +++ b/crates/canopy-server/src/git_objects/mod.rs @@ -9,7 +9,9 @@ use tokio::{ }; use tokio_util::task::AbortOnDropHandle; -use crate::{INLINE_OBJECT_LIMIT, ObjectKind, object_id}; +#[cfg(test)] +use crate::INLINE_OBJECT_LIMIT; +use crate::{ObjectKind, object_id}; const IO_TIMEOUT: Duration = Duration::from_secs(120); const HEADER_LIMIT: usize = 128; @@ -43,6 +45,7 @@ struct Process { } impl Process { + #[cfg(test)] fn start( git_dir: &Path, args: &[&str], @@ -125,6 +128,16 @@ pub(crate) struct GitObjectWalk { } impl GitObjectWalk { + pub(crate) fn selected_owned( + git_dir: &Path, + included: Vec, + filter: Option<&str>, + native: &crate::native_resources::NativeScope, + owner: ReadOwner, + ) -> Result { + Self::start_owned(git_dir, included, Vec::new(), false, filter, native, owner) + } + #[cfg(test)] pub(crate) fn missing( git_dir: &Path, @@ -135,6 +148,16 @@ impl GitObjectWalk { Self::start(git_dir, included, Vec::new(), true, filter, native) } + pub(crate) fn missing_owned( + git_dir: &Path, + included: Vec, + filter: Option<&str>, + native: &crate::native_resources::NativeScope, + owner: ReadOwner, + ) -> Result { + Self::start_owned(git_dir, included, Vec::new(), true, filter, native, owner) + } + #[cfg(test)] fn start( git_dir: &Path, included: Vec, @@ -142,6 +165,25 @@ impl GitObjectWalk { missing_only: bool, filter: Option<&str>, native: &crate::native_resources::NativeScope, + ) -> Result { + Self::start_owned( + git_dir, + included, + excluded, + missing_only, + filter, + native, + std::sync::Arc::new(()), + ) + } + fn start_owned( + git_dir: &Path, + included: Vec, + excluded: Vec, + missing_only: bool, + filter: Option<&str>, + native: &crate::native_resources::NativeScope, + owner: ReadOwner, ) -> Result { let filter = filter.map(|value| format!("--filter={value}")); let mut args = vec!["rev-list", "--objects", "--no-object-names", "--stdin"]; @@ -151,7 +193,7 @@ impl GitObjectWalk { if let Some(filter) = &filter { args.push(filter); } - let (process, mut input) = Process::start(git_dir, &args, native)?; + let (process, mut input) = Process::start_owned(git_dir, &args, native, owner)?; // Ref lists can exceed argv limits; feed stdin concurrently with stdout consumption. let revisions = AbortOnDropHandle::new(tokio::spawn(async move { for (prefix, roots) in [("", included), ("^", excluded)] { @@ -199,7 +241,9 @@ impl GitObjectWalk { } pub(crate) struct GitObjects { + #[cfg(test)] walk: Option, + #[cfg(test)] inventory: Option>, batch: Process, requests: ChildStdin, @@ -216,7 +260,66 @@ pub trait EdgeSink: Send { ) -> impl std::future::Future> + Send; } +/// Writes remain private until the caller observes canonical verification and +/// the native process's successful completion. +pub(crate) trait BodySink: Send { + fn append( + &mut self, + bytes: bytes::Bytes, + ) -> impl std::future::Future> + Send; +} + impl GitObjects { + pub(crate) async fn copy_verified( + &mut self, + expected: crate::packs::metadata::CanonicalObject, + sink: &mut impl BodySink, + ) -> Result<(), ObjectReadError> { + if self.inspection_failed { + return Err(ObjectReadError::Malformed); + } + self.inspection_failed = true; + let mut object = timeout(IO_TIMEOUT, async { + self.requests + .write_all(format!("{}\n", hex::encode(expected.oid)).as_bytes()) + .await?; + open_object(&mut self.batch.output, expected.oid).await + }) + .await + .map_err(|_| ObjectReadError::Timeout)??; + if object.kind != expected.kind || object.size != expected.size { + return Err(ObjectReadError::Malformed); + } + let mut canonical = crate::git_format::ObjectHasher::new( + expected.oid.format(), + expected.kind, + expected.size, + ); + let mut digest = blake3::Hasher::new(); + loop { + let mut bytes = vec![0; 64 << 10]; + let count = timeout(IO_TIMEOUT, object.reader.read(&mut bytes)) + .await + .map_err(|_| ObjectReadError::Timeout)??; + if count == 0 { + break; + } + bytes.truncate(count); + canonical.update(&bytes); + digest.update(&bytes); + timeout(IO_TIMEOUT, sink.append(bytes.into())) + .await + .map_err(|_| ObjectReadError::Timeout)??; + } + object.finish().await?; + if canonical.finalize() != expected.oid || digest.finalize().as_bytes() != &expected.digest + { + return Err(ObjectReadError::Malformed); + } + self.inspection_failed = false; + Ok(()) + } + #[cfg(test)] pub(crate) fn start( git_dir: &Path, included: Vec, @@ -227,6 +330,7 @@ impl GitObjects { let (batch, requests) = Process::start(git_dir, &["cat-file", "--batch"], native)?; Ok(Self { walk: Some(walk), + #[cfg(test)] inventory: None, batch, requests, @@ -234,6 +338,7 @@ impl GitObjects { }) } + #[cfg(test)] pub(crate) fn packed( git_dir: &Path, ids: Vec, @@ -246,6 +351,7 @@ impl GitObjects { /// Persistent native reader with caller-owned bounded index iteration. /// Verification uses an isolated admitted object directory without alternates. + #[cfg(test)] pub(crate) fn batch( git_dir: &Path, native: &crate::native_resources::NativeScope, @@ -263,7 +369,9 @@ impl GitObjects { let (batch, requests) = Process::start_owned(git_dir, &["cat-file", "--batch"], native, owner)?; Ok(Self { + #[cfg(test)] walk: None, + #[cfg(test)] inventory: None, batch, requests, @@ -324,6 +432,7 @@ impl GitObjects { Ok(canonical) } + #[cfg(test)] pub(crate) async fn next(&mut self) -> Result, ObjectReadError> { if self.inspection_failed { return Err(ObjectReadError::Malformed); @@ -338,6 +447,7 @@ impl GitObjects { .await } + #[cfg(test)] pub(crate) async fn read( &mut self, oid: crate::ObjectId, @@ -360,6 +470,7 @@ impl GitObjects { return Err(ObjectReadError::Malformed); } timeout(IO_TIMEOUT, async move { + #[cfg(test)] if let Some(walk) = self.walk { walk.finish().await?; } @@ -454,60 +565,6 @@ impl GitObject<'_, R> { } Ok(result) } - /// Verify a packed body with constant memory, including oversized blobs. - pub(crate) async fn fingerprint(mut self) -> Result<[u8; 32], ObjectReadError> { - let expected = self.oid; - let mut canonical = - crate::git_format::ObjectHasher::new(expected.format(), self.kind, self.size); - let mut hash = blake3::Hasher::new(); - let mut buffer = vec![0; 64 << 10]; - loop { - let count = timeout(IO_TIMEOUT, self.reader.read(&mut buffer)) - .await - .map_err(|_| ObjectReadError::Timeout)??; - if count == 0 { - break; - } - canonical.update(&buffer[..count]); - hash.update(&buffer[..count]); - } - self.finish().await?; - if canonical.finalize() != expected { - return Err(ObjectReadError::Malformed); - } - Ok(*hash.finalize().as_bytes()) - } - - pub(crate) async fn verify( - mut self, - kind: ObjectKind, - size: u64, - digest: [u8; 32], - ) -> Result<(), ObjectReadError> { - if self.kind != kind || self.size != size { - return Err(ObjectReadError::Malformed); - } - let expected = self.oid; - let mut canonical = crate::git_format::ObjectHasher::new(expected.format(), kind, size); - let mut hash = blake3::Hasher::new(); - let mut buffer = vec![0; 64 << 10]; - loop { - let count = timeout(IO_TIMEOUT, self.reader.read(&mut buffer)) - .await - .map_err(|_| ObjectReadError::Timeout)??; - if count == 0 { - break; - } - canonical.update(&buffer[..count]); - hash.update(&buffer[..count]); - } - self.finish().await?; - if canonical.finalize() != expected || hash.finalize().as_bytes() != &digest { - return Err(ObjectReadError::Malformed); - } - Ok(()) - } - async fn body_verified( mut self, expected: crate::packs::metadata::CanonicalObject, @@ -541,6 +598,7 @@ impl GitObject<'_, R> { .await? } + #[cfg(test)] pub(crate) async fn body(mut self) -> Result<(ObjectKind, Vec), ObjectReadError> { let limit = if self.kind == ObjectKind::Blob { INLINE_OBJECT_LIMIT diff --git a/crates/canopy-server/src/git_read/mod.rs b/crates/canopy-server/src/git_read/mod.rs index d952f5c4..196bfc02 100644 --- a/crates/canopy-server/src/git_read/mod.rs +++ b/crates/canopy-server/src/git_read/mod.rs @@ -45,6 +45,8 @@ pub(crate) enum ReadError { Malformed, #[error("Git Cell read failed")] Cell(#[from] InvocationError>), + #[error("native pull metadata read failed")] + Pull(#[source] Box), #[error("Git read worker failed")] Task(#[from] tokio::task::JoinError), #[error("certified Git snapshot is unavailable")] @@ -52,6 +54,11 @@ pub(crate) enum ReadError { #[error("certified Git snapshot owner is unavailable")] ServingOwner(#[from] crate::packs::publication::ServingOwnerError), } +impl From for ReadError { + fn from(error: crate::pulls::NativePullError) -> Self { + Self::Pull(Box::new(error)) + } +} #[derive(Clone, Copy, PartialEq, Eq)] struct Node { mode: u32, @@ -82,7 +89,7 @@ pub(crate) struct FileChange { before: Option, after: Option, } -#[derive(Clone, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)] pub(crate) enum ComparisonTarget { Current { revision: PullRevision }, diff --git a/crates/canopy-server/src/git_read/patch/mod.rs b/crates/canopy-server/src/git_read/patch/mod.rs index 3f890cd2..4d7ff1dc 100644 --- a/crates/canopy-server/src/git_read/patch/mod.rs +++ b/crates/canopy-server/src/git_read/patch/mod.rs @@ -46,7 +46,8 @@ struct Edit { index: usize, } -#[derive(Serialize)] +#[derive(Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] pub(crate) struct LineAnchor { pub revision: PullRevision, pub merge_base: String, diff --git a/crates/canopy-server/src/lib.rs b/crates/canopy-server/src/lib.rs index 5eadc5fc..78ec22a5 100644 --- a/crates/canopy-server/src/lib.rs +++ b/crates/canopy-server/src/lib.rs @@ -37,6 +37,7 @@ pub mod issues; pub mod blob { //! Verified, immutable Git blob bodies stored outside the Repository Cell. + #[cfg(test)] pub(crate) use canopy_object_storage::blob::blob_path; pub use canopy_object_storage::blob::{ LargeBlobError, LargeBlobRead, LargeBlobReference, LargeBlobStore, @@ -174,6 +175,12 @@ impl CellModule for RepositoryModule { source.update(include_bytes!("lib.rs")); source.update(include_bytes!("deployment/mod.rs")); source.update(include_bytes!("deployment/root.rs")); + source.update(include_bytes!("deployment/backup/bodies.rs")); + source.update(include_bytes!("deployment/backup/native.rs")); + source.update(include_bytes!("packs/backup.rs")); + source.update(include_bytes!("packs/directory/index/visit.rs")); + source.update(include_bytes!("packs/publication/backup.rs")); + source.update(include_bytes!("packs/publication/recovery/backup.rs")); source.update(include_bytes!("server/mod.rs")); source.update(include_bytes!("server/lifecycle.rs")); source.update(include_bytes!("admission.rs")); @@ -195,11 +202,13 @@ impl CellModule for RepositoryModule { source.update(include_bytes!("pack_store.rs")); source.update(include_bytes!("git_objects/mod.rs")); source.update(include_bytes!("git_cache/artifacts.rs")); + source.update(include_bytes!("git_cache/verified.rs")); source.update(include_bytes!("git_cache/serving_refs.rs")); source.update(include_bytes!("git_cache/cleanup.rs")); source.update(include_bytes!("git_cache/mod.rs")); source.update(include_bytes!("git_cache/maintenance.rs")); source.update(include_bytes!("packs/catalog/native.rs")); + source.update(include_bytes!("packs/catalog/sparse.rs")); source.update(include_bytes!("packs/catalog/reader.rs")); source.update(include_bytes!("packs/catalog/graph_spool.rs")); source.update(include_bytes!("packs/catalog/files.rs")); @@ -208,6 +217,8 @@ impl CellModule for RepositoryModule { source.update(include_bytes!("native_git/process.rs")); source.update(include_bytes!("native_git/process/fence.rs")); source.update(include_bytes!("git_gateway/mod.rs")); + source.update(include_bytes!("git_gateway/merge.rs")); + source.update(include_bytes!("git_gateway/head.rs")); source.update(include_bytes!("git_gateway/candidates/mod.rs")); source.update(include_bytes!("git_gateway/fetch.rs")); source.update(include_bytes!("git_gateway/discovery.rs")); @@ -216,11 +227,17 @@ impl CellModule for RepositoryModule { source.update(include_bytes!("git_gateway/preflight/retention.rs")); source.update(include_bytes!("git_gateway/branch_policy.rs")); source.update(include_bytes!("git_gateway/push.rs")); + source.update(include_bytes!("git_gateway/push/native.rs")); source.update(include_bytes!("git_input/mod.rs")); source.update(include_bytes!("git_http/capture.rs")); source.update(include_bytes!("git_http/mod.rs")); source.update(include_bytes!("packs/verification/mod.rs")); source.update(include_bytes!("packs/verification/physical.rs")); + source.update(include_bytes!("packs/verification/physical/staged.rs")); + source.update(include_bytes!("packs/closure/verifier.rs")); + source.update(include_bytes!("packs/directory/mod.rs")); + source.update(include_bytes!("packs/directory/writer.rs")); + source.update(include_bytes!("packs/publication/prepare.rs")); source.update(include_bytes!("packs/verification/spool.rs")); source.update(include_bytes!("packs/wire_request.rs")); source.update(include_bytes!("packs/input_artifact.rs")); @@ -264,6 +281,30 @@ impl CellModule for RepositoryModule { source.update(include_bytes!("packs/publication/completion.rs")); source.update(include_bytes!("packs/publication/ref_proof.rs")); source.update(include_bytes!("packs/publication/ref_snapshot.rs")); + source.update(include_bytes!("packs/publication/native_candidate.rs")); + source.update(include_bytes!("packs/publication/native_merge.rs")); + source.update(include_bytes!("packs/publication/native_head.rs")); + source.update(include_bytes!( + "packs/publication/candidate_publication/mod.rs" + )); + source.update(include_bytes!( + "packs/publication/candidate_publication/publish.rs" + )); + source.update(include_bytes!( + "packs/publication/candidate_publication/audit.rs" + )); + source.update(include_bytes!( + "packs/publication/coordinator/native_candidate.rs" + )); + source.update(include_bytes!("git_gateway/candidates/produce.rs")); + source.update(include_bytes!("packs/publication/native_head/publish.rs")); + source.update(include_bytes!( + "packs/publication/coordinator/native_head.rs" + )); + source.update(include_bytes!("packs/publication/native_merge/audit.rs")); + source.update(include_bytes!( + "packs/publication/coordinator/native_merge.rs" + )); source.update(include_bytes!("packs/publication/initialization.rs")); source.update(include_bytes!( "packs/publication/coordinator/initialization.rs" @@ -280,6 +321,9 @@ impl CellModule for RepositoryModule { "packs/publication/initialization/publish.rs" )); source.update(include_bytes!("packs/publication/staging_service.rs")); + source.update(include_bytes!( + "packs/publication/staging_service/driver.rs" + )); source.update(include_bytes!("packs/publication/staging_receipt.rs")); source.update(include_bytes!("packs/publication/admission_receipt.rs")); source.update(include_bytes!("packs/publication/custody/mod.rs")); @@ -324,6 +368,9 @@ impl CellModule for RepositoryModule { source.update(include_bytes!( "packs/publication/serving/session/workspace.rs" )); + source.update(include_bytes!( + "packs/publication/serving/session/workspace/prepare.rs" + )); source.update(include_bytes!( "packs/publication/serving/session/native_base.rs" )); @@ -340,6 +387,19 @@ impl CellModule for RepositoryModule { )); source.update(include_bytes!("packs/publication/serving/ownership.rs")); source.update(include_bytes!("packs/publication/serving/schema.sql")); + source.update(include_bytes!("packs/publication/ref_observation.rs")); + source.update(include_bytes!( + "packs/publication/ref_observation/selection.rs" + )); + source.update(include_bytes!( + "packs/publication/serving/session/ref_observation.rs" + )); + source.update(include_bytes!("pulls/native/mod.rs")); + source.update(include_bytes!("pulls/native/codec.rs")); + source.update(include_bytes!("pulls/native/client.rs")); + source.update(include_bytes!("pulls/native/threads.rs")); + source.update(include_bytes!("pulls/threads.rs")); + source.update(include_bytes!("pulls/native/reads.rs")); source.update(include_bytes!("packs/publication/registry.rs")); source.update(include_bytes!("server/catalog_initialization.rs")); source.update(include_bytes!("server/residency/mod.rs")); @@ -363,10 +423,15 @@ impl CellModule for RepositoryModule { "../../canopy-object-storage/src/external.rs" )); source.update(include_bytes!("push/mod.rs")); - source.update(include_bytes!("push/plan.rs")); source.update(include_bytes!("push/report.rs")); source.update(include_bytes!("access.rs")); source.update(include_bytes!("visibility.rs")); + source.update(include_bytes!("checks/native.rs")); + source.update(include_bytes!("checks/native/codec.rs")); + source.update(include_bytes!("packs/publication/commit_membership.rs")); + source.update(include_bytes!( + "packs/publication/serving/session/membership.rs" + )); source.update(include_bytes!("checks/mod.rs")); source.update(include_bytes!("checks/mutations.rs")); source.update(include_bytes!("pulls/mod.rs")); @@ -450,6 +515,7 @@ pub struct RepositoryCell { // reference must not retain its original disk budget after gateway eviction. pack_readers: std::sync::Mutex>>, serving: std::sync::Mutex>>, + staging: std::sync::Mutex>>, } impl RepositoryCell { @@ -478,6 +544,7 @@ impl RepositoryCell { target, pack_readers: std::sync::Mutex::new(Vec::new()), serving: std::sync::Mutex::new(None), + staging: std::sync::Mutex::new(None), }) } @@ -485,6 +552,33 @@ impl RepositoryCell { *self.serving.lock().expect("repository serving pool") = Some(std::sync::Arc::downgrade(pool)); } + pub(crate) fn attach_staging( + &self, + staging: &std::sync::Arc, + ) { + *self.staging.lock().expect("repository staging coordinator") = + Some(std::sync::Arc::downgrade(staging)); + } + /// Obtain the resident's owned write lifecycle. Admission still checks live + /// custody and actor limits; retaining this handle cannot keep a Cell resident. + pub fn staging_coordinator( + &self, + ) -> Result< + std::sync::Arc, + packs::publication::StagingError, + > { + let staging = self + .staging + .lock() + .expect("repository staging coordinator") + .as_ref() + .and_then(std::sync::Weak::upgrade) + .ok_or(packs::publication::StagingError::Inactive)?; + if staging.stats().closed { + return Err(packs::publication::StagingError::Closed); + } + Ok(staging) + } /// Borrow the resident's certified joint generation; a detached caller /// cannot abandon its acquisition or extend a released residency. pub async fn serving_snapshot( diff --git a/crates/canopy-server/src/pack_store.rs b/crates/canopy-server/src/pack_store.rs index 368e303b..1cab3aa1 100644 --- a/crates/canopy-server/src/pack_store.rs +++ b/crates/canopy-server/src/pack_store.rs @@ -8,11 +8,11 @@ use crate::{ }; use cellule_ltx::DiskBudget; use cellule_runtime::{ - Error, InvocationError, MutationIdentity, + Error, InvocationError, primitives::sql::{SqlBatch, SqlResultSet, SqlStatement, SqlValue}, }; use object_store::ObjectStore; -use std::{collections::BTreeMap, path::PathBuf, sync::Arc}; +use std::{path::PathBuf, sync::Arc}; use tokio::sync::Mutex; #[derive(Clone)] @@ -178,6 +178,7 @@ impl PackReader { } Ok(body) } + #[cfg(test)] pub(crate) async fn upload(&self, path: PathBuf) -> Result { let hash_path = path.clone(); let (oid, size) = tokio::task::spawn_blocking(move || { @@ -333,98 +334,4 @@ impl RepositoryCell { .ok_or_else(invalid)?, ) } - pub(crate) async fn register_pack( - &self, - identity: MutationIdentity, - record: &PackRecord, - ) -> Result<(), ReadError> { - let values = vec![ - SqlValue::Blob(record.pack.sha256.to_vec()), - SqlValue::Blob(record.hash.to_vec()), - SqlValue::Blob(record.pack.oid.to_vec()), - SqlValue::Integer(record.pack.size.try_into().map_err(|_| invalid())?), - SqlValue::Blob(record.pack.blake3.to_vec()), - SqlValue::Blob(record.index.oid.to_vec()), - SqlValue::Integer(record.index.size.try_into().map_err(|_| invalid())?), - SqlValue::Blob(record.index.blake3.to_vec()), - SqlValue::Blob(record.index.sha256.to_vec()), - ]; - let result = self.sql.batch(identity, SqlBatch { statements: vec![ - SqlStatement { sql: "INSERT INTO git_packs (sha256, pack_hash, pack_oid, pack_size, pack_digest, index_oid, index_size, index_digest, index_sha256) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9) ON CONFLICT DO NOTHING".into(), parameters: values }, - ] }).await?; - drop(result); - let stored = self.pack_record(record.pack.sha256).await?; - if stored.hash != record.hash - || stored.pack.oid != record.pack.oid - || stored.pack.size != record.pack.size - || stored.pack.blake3 != record.pack.blake3 - || stored.index.oid != record.index.oid - || stored.index.size != record.index.size - || stored.index.blake3 != record.index.blake3 - || stored.index.sha256 != record.index.sha256 - { - return Err(invalid()); - } - Ok(()) - } - pub(crate) async fn approve_pack( - &self, - identity: MutationIdentity, - sha: [u8; 32], - verified_count: usize, - ) -> Result<(), ReadError> { - self.sql - .batch( - identity, - SqlBatch { - statements: vec![SqlStatement { - // Every unique index member has a matching canonical SQL row. - // Equal cardinality proves this pack covers the entire immutable - // object table at this transaction, without scanning it on recovery. - sql: "UPDATE git_packs SET approved = 1, covered_through = CASE WHEN ?2 = (SELECT COUNT(oid) FROM objects) THEN (SELECT COALESCE(MAX(sequence), 0) FROM objects) ELSE covered_through END WHERE sha256 = ?1".into(), - parameters: vec![SqlValue::Blob(sha.to_vec()), SqlValue::Integer(verified_count.try_into().map_err(|_| invalid())?)], - }], - }, - ) - .await?; - Ok(()) - } - pub(crate) async fn canonical_headers( - &self, - ids: &[ObjectId], - ) -> Result, ReadError> { - if ids.is_empty() || ids.len() > crate::object_batch::MAX_BATCH_OBJECTS { - return Err(invalid()); - } - let placeholders = vec!["?"; ids.len()].join(","); - let result = self.sql.query(None, SqlBatch { statements: vec![SqlStatement { sql: format!("SELECT oid, kind, size, digest FROM objects WHERE oid IN ({placeholders})"), parameters: ids.iter().map(|oid| SqlValue::Blob(oid.to_vec())).collect() }] }).await?; - let mut headers = BTreeMap::new(); - for row in &result.output.first().ok_or_else(invalid)?.rows { - let [ - SqlValue::Blob(oid), - SqlValue::Text(kind), - SqlValue::Integer(size), - SqlValue::Blob(digest), - ] = row.as_slice() - else { - return Err(invalid()); - }; - let kind = match kind.as_str() { - "blob" => ObjectKind::Blob, - "tree" => ObjectKind::Tree, - "commit" => ObjectKind::Commit, - "tag" => ObjectKind::Tag, - _ => return Err(invalid()), - }; - headers.insert( - oid.as_slice().try_into().map_err(|_| invalid())?, - ( - kind, - (*size).try_into().map_err(|_| invalid())?, - digest.as_slice().try_into().map_err(|_| invalid())?, - ), - ); - } - Ok(headers) - } } diff --git a/crates/canopy-server/src/packs/backup.rs b/crates/canopy-server/src/packs/backup.rs new file mode 100644 index 00000000..5a2e03cb --- /dev/null +++ b/crates/canopy-server/src/packs/backup.rs @@ -0,0 +1,240 @@ +//! Repository-scoped typed artifact inventory for pinned backup snapshots. +use super::{ + catalog::{CatalogIndexes, CatalogSnapshot, StoredCatalog}, + directory::{ + StoredRun, + index::{IndexVisitor, WalkResult}, + snapshot::DirectorySnapshot, + }, + ref_state::{RefStateRecord, RefStateSnapshotRoot}, + sources::{NativeInputRoot, NativePackDescriptor, SourceRecord}, +}; +use crate::ObjectFormat; +use canopy_object_storage::artifact::{ + ArtifactDescriptor, ArtifactKey, ArtifactKind, ArtifactStore, +}; +use cellule_runtime::{ + CellTarget, + codec::{BoundedDecoder, WireValue}, +}; +use std::sync::Arc; + +pub(crate) use super::directory::index::ArtifactVisitor; + +pub(crate) struct Inventory<'a> { + store: Arc, + format: ObjectFormat, + indexes: Arc, + visitor: &'a mut dyn ArtifactVisitor, + target: CellTarget, +} +impl<'a> Inventory<'a> { + pub(crate) fn new( + store: Arc, + format: ObjectFormat, + target: CellTarget, + visitor: &'a mut dyn ArtifactVisitor, + ) -> WalkResult { + if crate::repository_target(target.tenant(), target.application(), store.repository())? + != target + { + return Err(super::directory::index::IndexError::Integrity.into()); + } + Ok(Self { + indexes: Arc::new(CatalogIndexes::new(store.clone(), format)), + store, + format, + visitor, + target, + }) + } + pub(crate) fn target(&self) -> &CellTarget { + &self.target + } + pub(crate) fn store(&self) -> Arc { + self.store.clone() + } + pub(crate) fn format(&self) -> ObjectFormat { + self.format + } + pub(crate) async fn artifact( + &mut self, + key: ArtifactKey, + descriptor: ArtifactDescriptor, + ) -> WalkResult { + self.visitor.artifact(key, descriptor).await + } + pub(crate) async fn input( + &mut self, + operation: [u8; 16], + descriptor: ArtifactDescriptor, + ) -> WalkResult { + self.artifact( + ArtifactKey { + operation, + binding_digest: descriptor.digest, + kind: ArtifactKind::InputRoot, + }, + descriptor, + ) + .await + } + pub(crate) async fn body( + &mut self, + operation: [u8; 16], + descriptor: ArtifactDescriptor, + ) -> WalkResult<()> { + self.artifact( + ArtifactKey { + operation, + binding_digest: descriptor.digest, + kind: ArtifactKind::InputBody, + }, + descriptor, + ) + .await?; + Ok(()) + } + pub(crate) async fn catalog(&mut self, stored: StoredCatalog) -> WalkResult<()> { + if stored.format != self.format { + return Err(super::directory::index::IndexError::Integrity.into()); + } + let snapshot = CatalogSnapshot::download(&self.store, stored).await?; + self.artifact( + ArtifactKey { + operation: stored.operation, + binding_digest: stored.artifact.digest, + kind: ArtifactKind::CatalogNode, + }, + stored.artifact, + ) + .await?; + let directory = DirectorySnapshot::download(&self.store, snapshot.directory).await?; + self.artifact( + ArtifactKey { + operation: snapshot.directory.operation, + binding_digest: snapshot.directory.artifact.digest, + kind: ArtifactKind::CatalogNode, + }, + snapshot.directory.artifact, + ) + .await?; + let indexes = self.indexes.clone(); + for root in directory + .level_zero + .iter() + .chain(directory.levels.iter().flatten()) + { + indexes.ranges().visit(*root, self).await?; + } + if let Some(root) = snapshot.sources { + indexes.sources().visit(root, self).await?; + } + Ok(()) + } + /// Closed audit needs the selected metadata headers, without retaining a + /// superseded catalog's physical object graph as permanent Git history. + pub(crate) async fn catalog_headers(&mut self, stored: StoredCatalog) -> WalkResult<()> { + if stored.format != self.format { + return Err(super::directory::index::IndexError::Integrity.into()); + } + let snapshot = CatalogSnapshot::download(&self.store, stored).await?; + DirectorySnapshot::download(&self.store, snapshot.directory).await?; + self.artifact( + ArtifactKey { + operation: stored.operation, + binding_digest: stored.artifact.digest, + kind: ArtifactKind::CatalogNode, + }, + stored.artifact, + ) + .await?; + self.artifact( + ArtifactKey { + operation: snapshot.directory.operation, + binding_digest: snapshot.directory.artifact.digest, + kind: ArtifactKind::CatalogNode, + }, + snapshot.directory.artifact, + ) + .await?; + Ok(()) + } + pub(crate) async fn refs(&mut self, root: RefStateSnapshotRoot) -> WalkResult<()> { + let snapshot = root.read(&self.store).await?; + if snapshot.format != self.format { + return Err(super::directory::index::IndexError::Integrity.into()); + } + if !self.input(root.operation(), root.artifact()).await? { + return Ok(()); + } + if let Some(root) = snapshot.root { + self.indexes.clone().refs().visit(root, self).await?; + } + Ok(()) + } + pub(crate) async fn native_inputs(&mut self, root: NativeInputRoot) -> WalkResult<()> { + self.indexes.clone().inputs().visit(root, self).await + } + async fn pack(&mut self, pack: NativePackDescriptor) -> WalkResult<()> { + pack.validate(self.store.repository(), self.format)?; + self.artifact(pack.key(ArtifactKind::Pack)?, pack.pack) + .await?; + self.artifact(pack.key(ArtifactKind::Index)?, pack.index) + .await?; + Ok(()) + } +} + +#[async_trait::async_trait] +impl ArtifactVisitor for Inventory<'_> { + async fn artifact( + &mut self, + key: ArtifactKey, + descriptor: ArtifactDescriptor, + ) -> WalkResult { + self.visitor.artifact(key, descriptor).await + } +} +#[async_trait::async_trait] +impl IndexVisitor for Inventory<'_> { + async fn record(&mut self, record: &StoredRun) -> WalkResult<()> { + self.artifact( + ArtifactKey { + operation: record.run.operation, + binding_digest: record.artifact.digest, + kind: ArtifactKind::DirectoryRun, + }, + record.artifact, + ) + .await?; + Ok(()) + } +} +#[async_trait::async_trait] +impl IndexVisitor for Inventory<'_> { + async fn record(&mut self, record: &SourceRecord) -> WalkResult<()> { + self.artifact(record.metadata_key(), record.metadata.artifact) + .await?; + self.pack(record.native()).await + } +} +#[async_trait::async_trait] +impl IndexVisitor for Inventory<'_> { + async fn record(&mut self, record: &NativePackDescriptor) -> WalkResult<()> { + self.pack(*record).await + } +} +#[async_trait::async_trait] +impl IndexVisitor for Inventory<'_> { + async fn record(&mut self, _: &RefStateRecord) -> WalkResult<()> { + Ok(()) + } +} + +pub(crate) fn decode(bytes: &[u8], limit: u32) -> WalkResult { + let mut decoder = BoundedDecoder::new(bytes, limit)?; + let value = T::decode(&mut decoder)?; + decoder.finish()?; + Ok(value) +} diff --git a/crates/canopy-server/src/packs/catalog/files.rs b/crates/canopy-server/src/packs/catalog/files.rs index 76606ce9..47f464fc 100644 --- a/crates/canopy-server/src/packs/catalog/files.rs +++ b/crates/canopy-server/src/packs/catalog/files.rs @@ -173,6 +173,30 @@ impl CatalogFiles { .await } pub(in crate::packs) async fn install_workspace( + &self, + cache: Arc, + source: &ResolvedObject, + owner: crate::git_objects::ReadOwner, + ) -> Result<(), super::native::NativeReadError> { + self.native + .as_ref() + .ok_or(super::native::NativeReadError::Unavailable)? + .install(cache, source, owner, true) + .await + } + pub(in crate::packs) async fn install_transient_workspace( + &self, + cache: Arc, + source: &ResolvedObject, + owner: crate::git_objects::ReadOwner, + ) -> Result<(), super::native::NativeReadError> { + self.native + .as_ref() + .ok_or(super::native::NativeReadError::Unavailable)? + .install(cache, source, owner, false) + .await + } + pub(in crate::packs) async fn install_pack_workspace( &self, cache: Arc, source: super::super::sources::NativePackDescriptor, @@ -181,7 +205,7 @@ impl CatalogFiles { self.native .as_ref() .ok_or(super::native::NativeReadError::Unavailable)? - .install(cache, source, owner) + .install_pack(cache, source, owner) .await } pub(in crate::packs) async fn graph_spool( diff --git a/crates/canopy-server/src/packs/catalog/graph_spool.rs b/crates/canopy-server/src/packs/catalog/graph_spool.rs index da9cad15..4e91a3fd 100644 --- a/crates/canopy-server/src/packs/catalog/graph_spool.rs +++ b/crates/canopy-server/src/packs/catalog/graph_spool.rs @@ -123,6 +123,24 @@ impl GraphSpool { Ok::<_, MetadataError>(()) }) } + /// Reuse this completed selected frontier for native Git's bounded missing + /// object selection. Guessed IDs cannot create new nodes through this path. + pub(in crate::packs) fn retry(&mut self, ids: &[ObjectId]) -> Result<(), MetadataError> { + if ids.len() > 128 { + return Err(MetadataError::Limit); + } + growth::transaction(&mut self.db, &mut self.file, self.maximum, |tx| { + let mut update = tx.prepare_cached( + "UPDATE nodes SET done=0 WHERE oid=?1 AND done=1 AND kind IS NOT NULL", + )?; + for id in ids { + if update.execute([id.as_ref()])? != 1 { + return Err(MetadataError::Integrity); + } + } + Ok::<_, MetadataError>(()) + }) + } pub(in crate::packs) fn pack_seen( &self, p: NativePackDescriptor, diff --git a/crates/canopy-server/src/packs/catalog/mod.rs b/crates/canopy-server/src/packs/catalog/mod.rs index 5d4904aa..c5764670 100644 --- a/crates/canopy-server/src/packs/catalog/mod.rs +++ b/crates/canopy-server/src/packs/catalog/mod.rs @@ -18,6 +18,7 @@ use std::sync::Arc; mod codec; pub(in crate::packs) mod graph_spool; mod native; +mod sparse; pub use native::{NativeFileStats, NativeReadError}; mod files; pub use files::{CatalogFileLimits, CatalogFileStats, CatalogFiles, MAX_OPEN_CATALOG_FILES}; diff --git a/crates/canopy-server/src/packs/catalog/native.rs b/crates/canopy-server/src/packs/catalog/native.rs index 72ed5052..381ba858 100644 --- a/crates/canopy-server/src/packs/catalog/native.rs +++ b/crates/canopy-server/src/packs/catalog/native.rs @@ -58,6 +58,7 @@ struct FileAdmission { } struct PackFile { cache: Arc, + sparse: super::sparse::SparsePack, _admission: Arc, } impl NativeFiles { @@ -153,12 +154,7 @@ impl NativeFiles { if let Some(file) = self.cached(descriptor)? { return Ok(file); } - let bytes = descriptor - .pack - .size - .checked_add(descriptor.index.size) - .and_then(|size| size.checked_add(4096)) - .ok_or(NativeReadError::Capacity)?; + let bytes = super::sparse::SparsePack::index_budget(descriptor)?; if bytes > self.budget.capacity() { return Err(NativeReadError::Capacity); } @@ -177,28 +173,20 @@ impl NativeFiles { }, ) .await?; - cache - .download_native_owned(&self.store, descriptor, Arc::clone(&lifetime)) - .await?; + let sparse = super::sparse::SparsePack::new( + cache.clone(), + &self.store, + descriptor, + self.native.clone(), + lifetime.clone(), + admission.clone(), + ) + .await?; let file = Arc::new(PackFile { cache, + sparse, _admission: admission, }); - let verify = Arc::clone(&file); - let claim = self - .native - .try_admit(crate::native_resources::NativeWork::Read) - .map_err(ObjectReadError::from)?; - tokio::task::spawn_blocking(move || { - let (_owner, _claim) = (lifetime, claim); - let pack = verify.cache.git_dir().join(format!( - "objects/pack/pack-{}.pack", - hex::encode(descriptor.git_checksum) - )); - descriptor.verify_files(&pack, &pack.with_extension("idx"))?; - Ok::<_, IndexError>(()) - }) - .await??; self.downloads .fetch_add(1, std::sync::atomic::Ordering::Relaxed); let evicted = { @@ -251,7 +239,7 @@ impl NativeFiles { ) .await?) } - pub(super) async fn install( + pub(super) async fn install_pack( &self, cache: Arc, descriptor: NativePackDescriptor, @@ -278,6 +266,35 @@ impl NativeFiles { .fetch_add(1, std::sync::atomic::Ordering::Relaxed); Ok(()) } + pub(super) async fn install( + &self, + cache: Arc, + object: &ResolvedObject, + owner: ReadOwner, + retain: bool, + ) -> Result<(), NativeReadError> { + let file = self + .load(object.source.record.native(), owner.clone()) + .await?; + let owner: ReadOwner = Arc::new((owner, file.clone())); + file.sparse + .prepare(object.entry.header.object.oid, owner.clone()) + .await?; + // Retain verified canonical objects in the bounded service cache. + // Request workspaces are disposable and cannot provide warm fetches. + if retain { + file.cache + .copy_native_owned( + file.cache.git_dir(), + object.entry.header.object, + owner.clone(), + ) + .await?; + } + cache + .copy_native_owned(file.cache.git_dir(), object.entry.header.object, owner) + .await + } pub(super) async fn body( &self, object: ResolvedObject, @@ -294,6 +311,9 @@ impl NativeFiles { .load(object.source.record.native(), Arc::clone(&owner)) .await?; let process_owner: ReadOwner = Arc::new((owner, Arc::clone(&file))); + file.sparse + .prepare(expected.oid, process_owner.clone()) + .await?; let mut objects = GitObjects::batch_owned(&file.cache.git_dir(), &self.native, process_owner)?; let body = objects.read_verified(expected, limit).await?; diff --git a/crates/canopy-server/src/packs/catalog/native/tests.rs b/crates/canopy-server/src/packs/catalog/native/tests.rs index 42a654fc..529c92b4 100644 --- a/crates/canopy-server/src/packs/catalog/native/tests.rs +++ b/crates/canopy-server/src/packs/catalog/native/tests.rs @@ -33,7 +33,9 @@ async fn native_pack_cache_evicts_idle_files_for_disk_pressure_and_reuses_verifi let inputs = packs(format, &[300, 301]).await?; let size = inputs .iter() - .map(|p| p.descriptor.pack.size + p.descriptor.index.size) + .map(|p| super::super::sparse::SparsePack::index_budget(p.descriptor)) + .collect::, _>>()? + .into_iter() .max() .ok_or("pair")?; let budget = DiskBudget::new(size + 4096); @@ -96,9 +98,12 @@ async fn native_pack_failure_never_enters_cache_and_file_slot_follows_native_cac assert_eq!(files.stats()?.open_files, 0); assert_eq!(files.stats()?.downloaded_files, 0); let file = files.load(descriptor, Arc::new(())).await?; + let expected = inputs[0].fixture.objects.values().next().ok_or("object")?.0; + file.sparse + .prepare(expected.oid, file.cache.clone()) + .await?; let mut reader = GitObjects::batch_owned(&file.cache.git_dir(), &files.native, file.cache.clone())?; - let expected = inputs[0].fixture.objects.values().next().ok_or("object")?.0; reader.read_verified(expected, 1 << 20).await?; files.cache.lock().map_err(|_| "cache")?.clear(); drop(file); diff --git a/crates/canopy-server/src/packs/catalog/sparse.rs b/crates/canopy-server/src/packs/catalog/sparse.rs new file mode 100644 index 00000000..f2b65212 --- /dev/null +++ b/crates/canopy-server/src/packs/catalog/sparse.rs @@ -0,0 +1,304 @@ +//! Selected authenticated packed extents in a private native decoder workspace. +use super::*; +use crate::{ + git_cache::GitCache, + git_objects::{ObjectReadError, ReadOwner}, + native_resources::{NativeScope, NativeWork}, + packs::{metadata::MetadataError, sources::NativePackDescriptor}, +}; +use bytes::Bytes; +use canopy_object_storage::{artifact::ArtifactRanges, external::PART_BYTES}; +use rusqlite::{Connection, params}; +use std::sync::Mutex; +use tokio::sync::Mutex as AsyncMutex; + +const MAX_DELTA_DEPTH: usize = 128; +struct Offsets { + // Connection closes before the cache can remove the admitted SQLite file. + db: Mutex, + _cache: Arc, +} +pub(super) struct SparsePack { + pub(super) cache: Arc, + index: Arc, + offsets: Arc, + descriptor: NativePackDescriptor, + payload: ArtifactRanges, + native: NativeScope, + preparing: AsyncMutex<()>, + // One bounded provider part, independent of pack/object/repository size. + part: Mutex>, +} +impl SparsePack { + pub(super) fn index_budget(p: NativePackDescriptor) -> Result { + p.index + .size + .checked_add( + u64::from(p.object_count) + .checked_mul(64) + .ok_or(NativeReadError::Capacity)?, + ) + .and_then(|n| n.checked_add(128 * 1024 + 12288)) + .ok_or(NativeReadError::Capacity) + } + pub(super) async fn new( + cache: Arc, + store: &ArtifactStore, + descriptor: NativePackDescriptor, + native: NativeScope, + owner: ReadOwner, + cleanup: ReadOwner, + ) -> Result { + let index = Arc::new( + cache + .native_index_owned(store, descriptor, owner.clone()) + .await?, + ); + let keep = owner.clone(); + let held = cache.clone(); + let scan = index.clone(); + let claim = native + .try_admit(NativeWork::Read) + .map_err(ObjectReadError::from)?; + let tail = descriptor + .pack + .size + .checked_sub(descriptor.git_checksum.len() as u64) + .ok_or(MetadataError::Integrity)?; + let offsets = tokio::task::spawn_blocking(move || { + let (_owner, _claim) = (keep, claim); + held.reserve_native_spool(u64::from(scan.len()).checked_mul(64).and_then(|n| n.checked_add(128 * 1024)).ok_or(MetadataError::Limit)?)?; + let mut db = Connection::open(held.root().join("native-offsets.sqlite"))?; + db.execute_batch("PRAGMA page_size=4096; PRAGMA journal_mode=OFF; PRAGMA synchronous=OFF; PRAGMA cache_size=-256; PRAGMA mmap_size=0; PRAGMA temp_store=MEMORY; CREATE TABLE extents(offset BLOB PRIMARY KEY,ready INTEGER NOT NULL DEFAULT 0) WITHOUT ROWID;")?; + let tx = db.transaction()?; + { + let mut insert = tx.prepare("INSERT INTO extents(offset) VALUES(?1)")?; + for offset in scan.offsets() { + let offset = offset?; + if offset >= tail { return Err(MetadataError::Integrity); } + insert.execute([offset.to_be_bytes().as_slice()])?; + } + } + tx.commit()?; + Ok::<_, MetadataError>(Arc::new(Offsets { db: Mutex::new(db), _cache: held })) + }).await??; + cache.sparse_native_owned(descriptor, owner.clone()).await?; + let payload = store + .ranges_owned( + descriptor.key(ArtifactKind::Pack)?, + descriptor.pack, + cleanup, + ) + .await + .map_err(MetadataError::from)?; + Ok(Self { + cache, + index, + offsets, + descriptor, + payload, + native, + preparing: AsyncMutex::new(()), + part: Mutex::new(None), + }) + } + async fn find(&self, oid: ObjectId, owner: ReadOwner) -> Result { + let index = self.index.clone(); + let claim = self + .native + .try_admit(NativeWork::Read) + .map_err(ObjectReadError::from)?; + Ok(tokio::task::spawn_blocking(move || { + let (_owner, _claim) = (owner, claim); + index + .find(oid)? + .map(|entry| entry.offset) + .ok_or(MetadataError::Integrity) + }) + .await??) + } + async fn span(&self, offset: u64, owner: ReadOwner) -> Result<(bool, u64), NativeReadError> { + let spool = self.offsets.clone(); + let tail = self.descriptor.pack.size - self.descriptor.git_checksum.len() as u64; + let claim = self + .native + .try_admit(NativeWork::Read) + .map_err(ObjectReadError::from)?; + Ok(tokio::task::spawn_blocking(move || { + let (_owner, _claim) = (owner, claim); + let db = spool.db.lock().map_err(|_| MetadataError::Integrity)?; + let (ready, end): (bool, Vec) = db.query_row("SELECT ready,coalesce((SELECT min(offset) FROM extents WHERE offset>?1),?2) FROM extents WHERE offset=?1", params![offset.to_be_bytes().as_slice(), tail.to_be_bytes().as_slice()], |row| Ok((row.get(0)?, row.get(1)?)))?; + let end = u64::from_be_bytes(end.try_into().map_err(|_| MetadataError::Integrity)?); + if end <= offset || end > tail { return Err(MetadataError::Integrity); } + Ok::<_, MetadataError>((ready, end)) + }).await??) + } + async fn part(&self, index: u64, owner: ReadOwner) -> Result { + if let Some((saved, bytes)) = &*self.part.lock().map_err(|_| MetadataError::Integrity)? + && *saved == index + { + return Ok(bytes.clone()); + } + let bytes = self + .payload + .part_owned(index, owner) + .await + .map_err(MetadataError::from)?; + *self.part.lock().map_err(|_| MetadataError::Integrity)? = Some((index, bytes.clone())); + Ok(bytes) + } + async fn prefix( + &self, + offset: u64, + end: u64, + owner: ReadOwner, + ) -> Result, NativeReadError> { + let end = end.min(offset.checked_add(64).ok_or(MetadataError::Integrity)?); + let mut bytes = Vec::with_capacity((end - offset) as usize); + let mut at = offset; + while at < end { + let part = self.part(at / PART_BYTES as u64, owner.clone()).await?; + let start = (at % PART_BYTES as u64) as usize; + let count = (end - at).min( + part.len() + .checked_sub(start) + .ok_or(MetadataError::Integrity)? as u64, + ) as usize; + if count == 0 { + return Err(MetadataError::Integrity.into()); + } + bytes.extend_from_slice(&part[start..start + count]); + at += count as u64; + } + Ok(bytes) + } + pub(super) async fn prepare( + &self, + oid: ObjectId, + owner: ReadOwner, + ) -> Result<(), NativeReadError> { + let _serial = self.preparing.lock().await; + let mut offset = self.find(oid, owner.clone()).await?; + let mut prepared = Vec::with_capacity(MAX_DELTA_DEPTH); + loop { + let (ready, end) = self.span(offset, owner.clone()).await?; + if ready { + break; + } + if prepared.len() == MAX_DELTA_DEPTH { + return Err(NativeReadError::Capacity); + } + let prefix = self.prefix(offset, end, owner.clone()).await?; + let base = base(&prefix, offset, self.descriptor.git_checksum.format())?; + let next = match base { + Base::None => None, + Base::Offset(value) => Some(value), + Base::Object(value) => Some(self.find(value, owner.clone()).await?), + }; + if next.is_some_and(|next| next == offset || prepared.contains(&next)) { + return Err(MetadataError::Integrity.into()); + } + let mut at = offset; + while at < end { + let part = self.part(at / PART_BYTES as u64, owner.clone()).await?; + let start = (at % PART_BYTES as u64) as usize; + let count = (end - at).min( + part.len() + .checked_sub(start) + .ok_or(MetadataError::Integrity)? as u64, + ) as usize; + if count == 0 { + return Err(MetadataError::Integrity.into()); + } + self.cache + .sparse_payload_owned( + self.descriptor, + at, + part.slice(start..start + count), + owner.clone(), + ) + .await?; + at += count as u64; + } + prepared.push(offset); + let Some(next) = next else { + break; + }; + offset = next; + } + let spool = self.offsets.clone(); + let claim = self + .native + .try_admit(NativeWork::Read) + .map_err(ObjectReadError::from)?; + tokio::task::spawn_blocking(move || { + let (_owner, _claim) = (owner, claim); + let mut db = spool.db.lock().map_err(|_| MetadataError::Integrity)?; + let tx = db.transaction()?; + for offset in prepared { + tx.execute( + "UPDATE extents SET ready=1 WHERE offset=?1", + [offset.to_be_bytes().as_slice()], + )?; + } + tx.commit()?; + Ok::<_, MetadataError>(()) + }) + .await??; + Ok(()) + } +} +enum Base { + None, + Offset(u64), + Object(ObjectId), +} +fn base(bytes: &[u8], offset: u64, format: ObjectFormat) -> Result { + let first = *bytes.first().ok_or(MetadataError::Integrity)?; + let kind = (first >> 4) & 7; + let mut at = 1; + let mut size = u64::from(first & 15); + let mut shift = 4; + let mut previous = first; + while previous & 128 != 0 { + previous = *bytes.get(at).ok_or(MetadataError::Integrity)?; + at += 1; + let value = u64::from(previous & 127) + .checked_mul(1_u64.checked_shl(shift).ok_or(MetadataError::Integrity)?) + .ok_or(MetadataError::Integrity)?; + size = size.checked_add(value).ok_or(MetadataError::Integrity)?; + shift += 7; + } + let _size = size; // Native Git validates the complete encoded stream. + match kind { + 1..=4 => Ok(Base::None), + 6 => { + let mut byte = *bytes.get(at).ok_or(MetadataError::Integrity)?; + at += 1; + let mut distance = u64::from(byte & 127); + while byte & 128 != 0 { + byte = *bytes.get(at).ok_or(MetadataError::Integrity)?; + at += 1; + distance = distance + .checked_add(1) + .and_then(|n| n.checked_mul(128)) + .and_then(|n| n.checked_add(u64::from(byte & 127))) + .ok_or(MetadataError::Integrity)?; + } + let base = offset + .checked_sub(distance) + .filter(|base| *base >= 12 && *base < offset) + .ok_or(MetadataError::Integrity)?; + Ok(Base::Offset(base)) + } + 7 => Ok(Base::Object( + ObjectId::try_from( + bytes + .get(at..at + format.bytes()) + .ok_or(MetadataError::Integrity)?, + ) + .map_err(|_| MetadataError::Integrity)?, + )), + _ => Err(MetadataError::Integrity), + } +} diff --git a/crates/canopy-server/src/packs/closure/verifier.rs b/crates/canopy-server/src/packs/closure/verifier.rs index e062053d..e57222f9 100644 --- a/crates/canopy-server/src/packs/closure/verifier.rs +++ b/crates/canopy-server/src/packs/closure/verifier.rs @@ -8,6 +8,7 @@ struct ActivePack { } pub struct ClosureVerifier { spool: Arc>, + activity: crate::git_objects::ReadOwner, context: ClosureContext, canceled: Arc, active: Option, @@ -25,16 +26,17 @@ impl ClosureVerifier { context: ClosureContext, limits: MetadataLimits, ) -> Result { - Self::new_inner(root, budget, context, limits, None).await + Self::new_inner(root, budget, context, limits, None, Arc::new(())).await } - pub(in crate::packs) async fn new_in_workspace( + pub(in crate::packs) async fn new_in_workspace_owned( workspace: Arc, budget: DiskBudget, context: ClosureContext, limits: MetadataLimits, + activity: crate::git_objects::ReadOwner, ) -> Result { let root = workspace.path().to_owned(); - Self::new_inner(&root, budget, context, limits, Some(workspace)).await + Self::new_inner(&root, budget, context, limits, Some(workspace), activity).await } async fn new_inner( root: &Path, @@ -42,12 +44,15 @@ impl ClosureVerifier { context: ClosureContext, limits: MetadataLimits, workspace: Option>, + activity: crate::git_objects::ReadOwner, ) -> Result { let canceled = Arc::new(AtomicBool::new(false)); let mut guard = CancelGuard::new(canceled.clone()); let root = root.to_owned(); let token = canceled.clone(); + let worker_activity = activity.clone(); let spool = tokio::task::spawn_blocking(move || { + let _activity = worker_activity; let mut spool = Spool::new(&root, budget, context, limits, token)?; if let Some(workspace) = workspace { spool._admitted.retain_workspace(workspace); @@ -58,6 +63,7 @@ impl ClosureVerifier { guard.complete(); Ok(Self { spool: Arc::new(Mutex::new(spool)), + activity, context, canceled, active: None, @@ -124,7 +130,9 @@ impl ClosureVerifier { active.partition.add(segment.descriptor())?; let operation = active.native.operation; let spool = self.spool.clone(); + let activity = self.activity.clone(); tokio::task::spawn_blocking(move || { + let _activity = activity; spool .lock() .map_err(|_| ClosureError::Integrity)? @@ -145,7 +153,9 @@ impl ClosureVerifier { } active.partition.finish()?; let spool = self.spool.clone(); + let activity = self.activity.clone(); tokio::task::spawn_blocking(move || { + let _activity = activity; spool .lock() .map_err(|_| ClosureError::Integrity)? @@ -176,7 +186,9 @@ impl ClosureVerifier { } let mut guard = CancelGuard::new(self.canceled.clone()); let spool = self.spool.clone(); + let activity = self.activity.clone(); tokio::task::spawn_blocking(move || { + let _activity = activity; spool .lock() .map_err(|_| ClosureError::Integrity)? @@ -186,7 +198,9 @@ impl ClosureVerifier { if let (Some(base), Some(resolver)) = (self.context.base, resolver) { loop { let spool = self.spool.clone(); + let activity = self.activity.clone(); let ids = tokio::task::spawn_blocking(move || { + let _activity = activity; spool .lock() .map_err(|_| ClosureError::Integrity)? @@ -198,7 +212,9 @@ impl ClosureVerifier { } let batch = resolver.resolve(base, &ids).await?; let spool = self.spool.clone(); + let activity = self.activity.clone(); tokio::task::spawn_blocking(move || { + let _activity = activity; spool .lock() .map_err(|_| ClosureError::Integrity)? @@ -208,7 +224,9 @@ impl ClosureVerifier { } } let spool = self.spool.clone(); + let activity = self.activity.clone(); let witness = tokio::task::spawn_blocking(move || { + let _activity = activity; let mut spool = spool.lock().map_err(|_| ClosureError::Integrity)?; spool.certify_graph()?; spool.witness() diff --git a/crates/canopy-server/src/packs/directory/index/mod.rs b/crates/canopy-server/src/packs/directory/index/mod.rs index 7410100f..b1dc2d88 100644 --- a/crates/canopy-server/src/packs/directory/index/mod.rs +++ b/crates/canopy-server/src/packs/directory/index/mod.rs @@ -18,8 +18,10 @@ mod changes; mod cursor; mod rewrite; mod update; +mod visit; pub use changes::RangeChanges; pub use cursor::RangeCursor; +pub(crate) use visit::{ArtifactVisitor, IndexVisitor, WalkResult}; pub const FANOUT: usize = 128; pub const NODE_BYTES: u32 = 64 << 10; diff --git a/crates/canopy-server/src/packs/directory/index/visit.rs b/crates/canopy-server/src/packs/directory/index/visit.rs new file mode 100644 index 00000000..582e8845 --- /dev/null +++ b/crates/canopy-server/src/packs/directory/index/visit.rs @@ -0,0 +1,61 @@ +//! Typed physical-artifact traversal with bounded depth and shared-node reuse. +use super::*; + +pub(crate) type WalkResult = Result>; + +#[async_trait::async_trait] +pub(crate) trait ArtifactVisitor: Send { + /// False only after this exact key/descriptor was successfully retained. + /// Callers use admitted disk to bound physical-artifact deduplication. + async fn artifact( + &mut self, + key: ArtifactKey, + descriptor: ArtifactDescriptor, + ) -> WalkResult; +} + +#[async_trait::async_trait] +pub(crate) trait IndexVisitor: ArtifactVisitor { + async fn record(&mut self, record: &R) -> WalkResult<()>; +} + +impl RangeIndex { + pub(crate) async fn visit + ?Sized>( + &self, + root: NodeRef, + visitor: &mut V, + ) -> WalkResult<()> { + root.validate(self.format)?; + let limit = (R::FANOUT - 1) * (usize::from(R::MAX_HEIGHT) + 1) + 1; + let mut pending = vec![root]; + while let Some(reference) = pending.pop() { + // Validate this reference's complete bounds even if its physical + // node was previously copied through another generation/root. + let node = self.load(reference.clone()).await?; + if !visitor + .artifact(reference.key(), reference.artifact) + .await? + { + continue; + } + match &node.contents { + Contents::Runs(records) => { + for record in records { + visitor.record(record).await?; + } + } + Contents::Children(children) => { + if pending + .len() + .checked_add(children.len()) + .is_none_or(|n| n > limit) + { + return Err(IndexError::Limit.into()); + } + pending.extend(children.iter().rev().cloned()); + } + } + } + Ok(()) + } +} diff --git a/crates/canopy-server/src/packs/directory/mod.rs b/crates/canopy-server/src/packs/directory/mod.rs index 6a2f9d52..9db7b5b1 100644 --- a/crates/canopy-server/src/packs/directory/mod.rs +++ b/crates/canopy-server/src/packs/directory/mod.rs @@ -250,12 +250,29 @@ impl DirectoryRun { pub async fn upload( self: Arc, store: &ArtifactStore, + ) -> Result { + self.upload_owned(store, Arc::new(())).await + } + pub(in crate::packs) async fn upload_owned( + self: Arc, + store: &ArtifactStore, + activity: crate::git_objects::ReadOwner, ) -> Result { let run = self.descriptor; if run.repository != store.repository() { return Err(MetadataError::Integrity); } - let artifact = upload_file(self, store, run.key(), run.size, run.digest).await?; + let artifact = upload_file( + Arc::new(metadata::transport::OwnedFile { + file: self, + activity, + }), + store, + run.key(), + run.size, + run.digest, + ) + .await?; Ok(StoredRun { run, artifact, diff --git a/crates/canopy-server/src/packs/directory/writer.rs b/crates/canopy-server/src/packs/directory/writer.rs index 13124b60..fe45d16e 100644 --- a/crates/canopy-server/src/packs/directory/writer.rs +++ b/crates/canopy-server/src/packs/directory/writer.rs @@ -12,6 +12,9 @@ pub struct DirectoryBuilder { failed: bool, } impl DirectoryBuilder { + pub(in crate::packs) fn workspace(&self) -> Option> { + self.admitted.workspace() + } pub(in crate::packs) fn retain_workspace(&mut self, workspace: Arc) { self.admitted.retain_workspace(workspace); } diff --git a/crates/canopy-server/src/packs/metadata/transport.rs b/crates/canopy-server/src/packs/metadata/transport.rs index 763dac0f..f33868e2 100644 --- a/crates/canopy-server/src/packs/metadata/transport.rs +++ b/crates/canopy-server/src/packs/metadata/transport.rs @@ -49,13 +49,24 @@ impl MetadataSegment { pub async fn upload( self: Arc, store: &ArtifactStore, + ) -> Result { + self.upload_owned(store, Arc::new(())).await + } + + pub(crate) async fn upload_owned( + self: Arc, + store: &ArtifactStore, + activity: crate::git_objects::ReadOwner, ) -> Result { let segment = self.descriptor(); if segment.identity.repository != store.repository() { return Err(MetadataError::Integrity); } let artifact = upload_file( - self, + Arc::new(OwnedFile { + file: self, + activity, + }), store, key(segment.identity), segment.size, @@ -85,19 +96,32 @@ impl MetadataSegment { stored: StoredSegment, limits: MetadataLimits, reader: Option, + ) -> Result, MetadataError> { + Self::download_owned(root, budget, store, stored, limits, reader, Arc::new(())).await + } + + pub(in crate::packs) async fn download_owned( + root: &Path, + budget: DiskBudget, + store: &ArtifactStore, + stored: StoredSegment, + limits: MetadataLimits, + reader: Option, + activity: crate::git_objects::ReadOwner, ) -> Result, MetadataError> { stored.validate(store)?; - let admitted = download_file_for_reader( + let admitted = download_file_owned( root, budget, store, stored.key(), stored.artifact, limits, - reader, + (reader, activity.clone()), ) .await?; tokio::task::spawn_blocking(move || { + let _activity = activity; Ok(Arc::new(Self::open_admitted( admitted, stored.segment, @@ -117,6 +141,19 @@ impl PinnedFile for MetadataSegment { } } +// Hold worker admission only during physical work. Idle metadata files must +// not keep a creating worker alive across Bind or a bound worker across publish. +pub(crate) struct OwnedFile { + pub(crate) file: Arc, + pub(crate) activity: crate::git_objects::ReadOwner, +} +impl PinnedFile for OwnedFile { + fn open(&self) -> io::Result { + let _activity = &self.activity; + self.file.open() + } +} + pub(crate) async fn upload_file( owner: Arc, store: &ArtifactStore, @@ -136,13 +173,16 @@ pub(crate) async fn upload_file( })) }) .await??; + let physical = source.clone(); let mut input = StreamReader::new(SegmentStream { source, offset: 0, job: None, failed: false, }); - Ok(store.put(key, size, digest, &mut input).await?) + Ok(store + .put_owned(key, size, digest, &mut input, physical) + .await?) } pub(in crate::packs) async fn download_file_for_reader( @@ -154,6 +194,28 @@ pub(in crate::packs) async fn download_file_for_reader( limits: MetadataLimits, reader: Option, ) -> Result { + download_file_owned( + root, + budget, + store, + key, + artifact, + limits, + (reader, Arc::new(())), + ) + .await +} + +pub(in crate::packs) async fn download_file_owned( + root: &Path, + budget: DiskBudget, + store: &ArtifactStore, + key: ArtifactKey, + artifact: ArtifactDescriptor, + limits: MetadataLimits, + admission: (Option, crate::git_objects::ReadOwner), +) -> Result { + let (reader, activity) = admission; if limits.cache_kib == 0 || limits.cache_kib > i32::MAX as u32 || artifact.size > limits.max_file_bytes @@ -174,10 +236,11 @@ pub(in crate::packs) async fn download_file_for_reader( ) .with_reader(reader), ), + _activity: activity, })) }) .await??; - let mut reader = store.read(key, artifact).await?; + let mut reader = store.read_owned(key, artifact, spool.clone()).await?; while let Some(bytes) = reader.next().await? { let spool = Arc::clone(&spool); tokio::task::spawn_blocking(move || { @@ -190,6 +253,9 @@ pub(in crate::packs) async fn download_file_for_reader( }) .await??; } + // The completed reader also owns the spool through its hash jobs. Release + // it before transferring the unique file to the final sync/open stage. + drop(reader); tokio::task::spawn_blocking(move || { let spool = Arc::try_unwrap(spool).map_err(|_| MetadataError::Integrity)?; let admitted = spool @@ -205,6 +271,7 @@ pub(in crate::packs) async fn download_file_for_reader( // Field order closes/unlinks the private file before releasing admission. struct DownloadSpool { admitted: Mutex, + _activity: crate::git_objects::ReadOwner, } // Unlike a bare tokio::fs::File, each pending blocking task owns its admission @@ -283,11 +350,16 @@ mod tests { release_rx.recv().unwrap(); }); ready_rx.recv_timeout(std::time::Duration::from_secs(5))?; + let activity: crate::git_objects::ReadOwner = Arc::new(()); + let weak_activity = Arc::downgrade(&activity); let mut stream = SegmentStream { source: Arc::new(ReadPin { file: Mutex::new(File::open(&path)?), size: segment.descriptor().size, - _owner: segment, + _owner: Arc::new(OwnedFile { + file: segment, + activity, + }), }), offset: 0, job: None, @@ -302,6 +374,7 @@ mod tests { drop(stream); assert_eq!(budget.used(), charged); assert!(weak.upgrade().is_some()); + assert!(weak_activity.upgrade().is_some()); assert!(path.exists()); // Always release before assertions that could unwind: a stopped // blocking worker must not hang runtime shutdown if this test fails. @@ -317,6 +390,7 @@ mod tests { Ok::<_, Box>(()) })?; assert!(weak.upgrade().is_none()); + assert!(weak_activity.upgrade().is_none()); assert!(!path.exists()); Ok(()) } diff --git a/crates/canopy-server/src/packs/mod.rs b/crates/canopy-server/src/packs/mod.rs index 41463d22..bd3cb07f 100644 --- a/crates/canopy-server/src/packs/mod.rs +++ b/crates/canopy-server/src/packs/mod.rs @@ -3,6 +3,7 @@ //! These structures are inputs to trusted catalog verification. Native pack //! membership alone does not certify graph closure or authorize object reads. +pub(crate) mod backup; pub mod catalog; pub mod closure; pub mod directory; diff --git a/crates/canopy-server/src/packs/publication/backup.rs b/crates/canopy-server/src/packs/publication/backup.rs new file mode 100644 index 00000000..ff3facd5 --- /dev/null +++ b/crates/canopy-server/src/packs/publication/backup.rs @@ -0,0 +1,184 @@ +//! Typed graph edges selected exclusively from a pinned repository database. +use super::*; +use crate::packs::{ + backup::{Inventory, decode}, + directory::index::WalkResult, + wire_request::WireRequestRoot, +}; + +pub(super) fn context( + tenant: [u8; 16], + application: [u8; 16], + repository: [u8; 16], + inventory: &Inventory<'_>, +) -> WalkResult<()> { + if tenant != *inventory.target().tenant().as_bytes() + || application != *inventory.target().application().as_bytes() + || repository != inventory.store().repository() + { + return Err(CodecError::Invalid("backup repository context").into()); + } + Ok(()) +} +pub(super) async fn fact(value: GenerationFact, inventory: &mut Inventory<'_>) -> WalkResult<()> { + value.validate()?; + if let Some(root) = value.catalog { + inventory.catalog(root).await?; + } + if let Some(root) = value.refs { + inventory.refs(root).await?; + } + Ok(()) +} +pub(super) async fn catalog_certificate( + value: &CatalogCertificate, + seed: &[u8; 32], + inventory: &mut Inventory<'_>, +) -> WalkResult<()> { + if !value.authenticated(seed) { + return Err(CodecError::Invalid("backup catalog MAC").into()); + } + let data = value.data()?; + context( + data.tenant, + data.application, + data.token.repository, + inventory, + )?; + fact(data.base, inventory).await?; + inventory.catalog(data.catalog).await +} +pub(super) async fn wire(root: WireRequestRoot, inventory: &mut Inventory<'_>) -> WalkResult<()> { + let record = root.read(&inventory.store()).await?; + context( + *record.tenant.as_bytes(), + *record.application.as_bytes(), + record.identity.repository, + inventory, + )?; + if record.format != inventory.format() { + return Err(CodecError::Invalid("backup wire format").into()); + } + inventory.input(root.operation(), root.artifact()).await?; + inventory.body(record.operation, record.request.body).await +} +async fn outcomes(value: RootPushOutcomes, inventory: &mut Inventory<'_>) -> WalkResult<()> { + for root in [value.native, value.rejected, value.replayed] { + root_completion::backup_graph(root, inventory).await?; + } + Ok(()) +} +pub(super) async fn command( + kind: recovery::Kind, + bytes: &[u8], + seed: &[u8; 32], + inventory: &mut Inventory<'_>, +) -> WalkResult<()> { + use recovery::Kind; + match kind { + Kind::Publish => { + let value: RootPushCompletion = decode(bytes, ROOT_COMPLETION_BYTES)?; + catalog_certificate(&value.proof.certificate, seed, inventory).await?; + inventory.refs(value.proof.snapshot).await?; + outcomes(value.outcomes, inventory).await?; + } + Kind::Outcome => { + let value: RootOutcomeCompletion = decode(bytes, ROOT_COMPLETION_BYTES)?; + outcome::backup_graph(&value.proof, seed, inventory).await?; + outcomes(value.outcomes, inventory).await?; + } + Kind::Policy => { + let value: RefPolicyPage = decode(bytes, REF_POLICY_PAGE_BYTES)?; + catalog_certificate(&value.proof.certificate, seed, inventory).await?; + } + Kind::Initialization => { + let value: InitialRefProof = decode(bytes, INITIALIZATION_BYTES)?; + catalog_certificate(&value.certificate, seed, inventory).await?; + inventory.refs(value.refs).await?; + } + Kind::Head => { + let value: NativeHeadProof = decode(bytes, NATIVE_HEAD_BYTES)?; + catalog_certificate(&value.certificate, seed, inventory).await?; + if let Some(root) = value.refs { + inventory.refs(root).await?; + } + } + Kind::Candidate => { + candidate_publication::backup_graph( + &decode(bytes, NATIVE_CANDIDATE_BYTES)?, + seed, + inventory, + ) + .await? + } + Kind::Merge => { + native_merge::backup_graph(&decode(bytes, NATIVE_MERGE_BYTES)?, seed, inventory).await? + } + } + Ok(()) +} + +/// Each row kind uses its declared codec; a blob cannot select its own purpose. +pub(crate) async fn row( + kind: u8, + columns: &[Option>], + seed: &[u8; 32], + inventory: &mut Inventory<'_>, +) -> WalkResult<()> { + let at = |n: usize| { + columns + .get(n) + .and_then(Option::as_deref) + .ok_or(CodecError::Invalid("backup graph row")) + }; + match kind { + 0 => { + if let Some(bytes) = columns.first().and_then(Option::as_deref) { + inventory.catalog(decode(bytes, 256)?).await?; + } + if let Some(bytes) = columns.get(1).and_then(Option::as_deref) { + inventory.refs(decode(bytes, 128)?).await?; + } + } + 1 => root_completion::backup_graph(decode(at(0)?, 128)?, inventory).await?, + 2 => native_merge::audit::backup_graph(decode(at(0)?, 128)?, inventory).await?, + 3 => { + let value: candidate_publication::audit::Selected = decode(at(0)?, 512)?; + if let Some(root) = value.root { + candidate_publication::audit::backup_graph(root, inventory).await?; + } + } + 4 => { + let value: GenerationFact = decode(at(0)?, 512)?; + value.validate()?; + if let Some(root) = value.catalog { + inventory.catalog_headers(root).await?; + } + if let Some(root) = value.refs { + inventory.refs(root).await?; + } + } + 5 => fact(decode(at(0)?, 512)?, inventory).await?, + 6 => { + if let Some(bytes) = columns.first().and_then(Option::as_deref) { + inputs::backup_graph(&decode(bytes, 1024)?, seed, inventory).await?; + } + if let Some(bytes) = columns.get(1).and_then(Option::as_deref) { + catalog_certificate(&decode(bytes, 1024)?, seed, inventory).await?; + } + if let Some(bytes) = columns.get(2).and_then(Option::as_deref) { + recovery::backup::graph( + bytes, + columns.get(3).and_then(Option::as_deref), + None, + seed, + inventory, + ) + .await?; + } + } + 7 => recovery::backup::graph(at(0)?, Some(at(1)?), Some(at(2)?), seed, inventory).await?, + _ => return Err(CodecError::Invalid("backup graph purpose").into()), + } + Ok(()) +} diff --git a/crates/canopy-server/src/packs/publication/candidate_publication/audit.rs b/crates/canopy-server/src/packs/publication/candidate_publication/audit.rs new file mode 100644 index 00000000..4c9bdd70 --- /dev/null +++ b/crates/canopy-server/src/packs/publication/candidate_publication/audit.rs @@ -0,0 +1,319 @@ +//! Permanent selected Ready roots; negative results need only the frozen editorial row. +use super::super::sql::*; +use super::*; +use crate::packs::{ + catalog::CatalogSnapshot, + directory::index::IndexError, + input_artifact::{INPUT_ROOT_BYTES, StoredInputRoot}, + ref_state::{RefSnapshotError, RefStateError, RefStateIndex}, +}; +use canopy_object_storage::artifact::{ArtifactKind, ArtifactStore}; +const DOMAIN: &[u8] = b"canopy.generated-candidate-audit.v1\0"; +pub(in crate::packs::publication) const SAVED: &str = "SELECT binding,pull_number,actor,request,created_ms,result,native_publication FROM merge_candidates WHERE id=?1"; +#[derive(Debug, thiserror::Error)] +pub enum NativeCandidateAuditError { + #[error("candidate audit codec failed")] + Codec(#[from] CodecError), + #[error("candidate audit root failed")] + Root(#[from] crate::packs::InputRootError), + #[error("candidate audit catalog failed")] + Catalog(#[from] IndexError), + #[error("candidate audit refs failed")] + Refs(#[from] RefStateError), + #[error("candidate audit snapshot failed")] + Snapshot(#[from] RefSnapshotError), + #[error("candidate audit context differs")] + Context, +} +#[derive(Clone, Debug)] +pub(in crate::packs::publication) struct Selected { + pub reply: CandidatePublicationReply, + pub root: Option, +} +impl WireValue for Selected { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + self.reply.encode(e)?; + self.root.encode(e) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + Ok(Self { + reply: CandidatePublicationReply::decode(d)?, + root: Option::decode(d)?, + }) + } +} +pub(in crate::packs::publication) struct Audit { + pub candidate: MergeCandidate, + pub catalog: StoredCatalog, + refs: RefStateSnapshotRoot, + generation: u64, + ref_generation: u64, +} +impl WireValue for Audit { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + if !matches!(self.candidate.result, CandidateResult::Ready { .. }) + || self.generation == 0 + || self.ref_generation == 0 + || self.ref_generation > self.generation + { + return Err(CodecError::Invalid("candidate audit scope")); + } + e.write_bytes(DOMAIN)?; + e.write_bytes(&candidate_bytes(&self.candidate)?)?; + self.catalog.encode(e)?; + self.refs.encode(e)?; + e.write_u64(self.generation)?; + e.write_u64(self.ref_generation) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + if d.read_bytes()? != DOMAIN { + return Err(CodecError::Invalid("candidate audit purpose")); + } + let value = Self { + candidate: serde_json::from_slice(d.read_bytes()?) + .map_err(|_| CodecError::Invalid("candidate audit value"))?, + catalog: StoredCatalog::decode(d)?, + refs: RefStateSnapshotRoot::decode(d)?, + generation: d.read_u64()?, + ref_generation: d.read_u64()?, + }; + value.encode(&mut BoundedEncoder::new(INPUT_ROOT_BYTES)?)?; + Ok(value) + } +} +pub(super) async fn prepare( + prepared: &PreparedCatalog, + candidate: &MergeCandidate, + refs: RefStateSnapshotRoot, + ref_generation: u64, +) -> Result { + let value = Audit { + candidate: candidate.clone(), + catalog: prepared.catalog(), + refs, + generation: prepared.base().generation + 1, + ref_generation, + }; + Ok(StoredInputRoot::upload( + &prepared.base.indexes().store(), + prepared.token().artifact_operation, + &value, + INPUT_ROOT_BYTES, + ) + .await?) +} +type CandidateRow = (Vec, MergeCandidate, Option); + +pub(super) fn row(sets: &[SqlResultSet]) -> Result, Error> { + let Some(row) = rows(sets)?.first() else { + return Ok(None); + }; + if row.len() != 7 { + return Err(Error::Command("invalid candidate audit row")); + } + let (binding, candidate) = crate::pulls::candidates::decode(&[SqlResultSet { + columns: vec![], + rows: vec![row[..6].to_vec()], + rows_affected: 0, + }])? + .ok_or(Error::Command("candidate audit row absent"))?; + let selected = match &row[6] { + SqlValue::Null => None, + SqlValue::Blob(bytes) => { + let mut d = BoundedDecoder::new(bytes, 512)?; + let value = Selected::decode(&mut d)?; + d.finish()?; + Some(value) + } + _ => return Err(Error::Command("invalid candidate publication")), + }; + Ok(Some((binding, candidate, selected))) +} +pub(in crate::packs::publication) fn statement(reply: &CandidatePublicationReply) -> SqlStatement { + let id = match reply { + CandidatePublicationReply::Applied { id, .. } => blob(id), + _ => SqlValue::Null, + }; + SqlStatement { + sql: SAVED.into(), + parameters: vec![id], + } +} +pub(in crate::packs::publication) fn selected( + sets: &[SqlResultSet], + reply: &CandidatePublicationReply, + actor: &str, +) -> Result, Error> { + let CandidatePublicationReply::Applied { + id, + digest, + publication, + } = reply + else { + return Ok(None); + }; + let Some((_, candidate, stored)) = row(sets)? else { + return Ok(None); + }; + if candidate.actor != actor + || candidate.request.id != uuid::Uuid::from_bytes(*id).to_string() + || result_digest(&candidate)? != *digest + { + return Ok(None); + } + let ready = matches!(candidate.result, CandidateResult::Ready { .. }); + if ready != publication.is_some() { + return Err(Error::Command("candidate publication result differs")); + } + match stored { + Some(value) if value.reply == *reply && ready == value.root.is_some() => Ok(Some(value)), + None if !ready => Ok(Some(Selected { + reply: reply.clone(), + root: None, + })), + _ => Ok(None), + } +} +pub(in crate::packs::publication) async fn closed_graph( + store: &ArtifactStore, + sets: &[SqlResultSet], + reply: &CandidatePublicationReply, + check: &LeaseCheck, + hash: &mut blake3::Hasher, +) -> Result<(), RootRecoveryError> { + if !reply.applied() { + return Ok(()); + } + let selected = selected(sets, reply, &check.actor)?.ok_or(RootRecoveryError::Context)?; + let Some(root) = selected.root else { + return Ok(()); + }; + let audit: Audit = root + .read(store, INPUT_ROOT_BYTES) + .await + .map_err(NativeCandidateAuditError::from)?; + let CandidatePublicationReply::Applied { + id, + digest, + publication: Some(published), + } = reply + else { + return Err(RootRecoveryError::Context); + }; + if audit.candidate.actor != check.actor + || audit.candidate.request.id != uuid::Uuid::from_bytes(*id).to_string() + || result_digest(&audit.candidate)? != *digest + || audit.catalog.repository != check.token.repository + || audit.refs.operation() != root.operation + || audit.generation != published.generation + || audit.ref_generation != published.ref_generation + { + return Err(RootRecoveryError::Context); + } + let snapshot = CatalogSnapshot::download(store, audit.catalog) + .await + .map_err(NativeCandidateAuditError::from)?; + crate::packs::directory::snapshot::DirectorySnapshot::download(store, snapshot.directory) + .await + .map_err(NativeCandidateAuditError::from)?; + let refs = audit + .refs + .read(store) + .await + .map_err(NativeCandidateAuditError::from)?; + if refs.repository != check.token.repository + || refs.format != audit.catalog.format + || refs.generation != audit.ref_generation + { + return Err(RootRecoveryError::Context); + } + let CandidateResult::Ready { oid, .. } = &audit.candidate.result else { + return Err(RootRecoveryError::Context); + }; + let expected = crate::RefExpectation { + oid: Some(crate::pulls::merge::oid(oid)?), + version: 1, + }; + let found = RefStateIndex::new(std::sync::Arc::new(store.clone()), refs.format) + .read(refs.root, &audit.candidate.fetch_ref()) + .await + .map_err(NativeCandidateAuditError::from)?; + if found != Some(expected) { + return Err(RootRecoveryError::Context); + } + let descriptor = super::super::recovery::archive::descriptor; + descriptor(hash, root.operation, ArtifactKind::InputRoot, root.artifact)?; + descriptor( + hash, + audit.catalog.operation, + ArtifactKind::CatalogNode, + audit.catalog.artifact, + )?; + descriptor( + hash, + snapshot.directory.operation, + ArtifactKind::CatalogNode, + snapshot.directory.artifact, + )?; + descriptor( + hash, + audit.refs.operation(), + ArtifactKind::InputRoot, + audit.refs.artifact(), + )?; + Ok(()) +} + +/// Select only a completed native result matching the requested pull revision +/// and strategy. SQL bytes alone do not establish commit semantics: the private +/// merge factory separately verifies this candidate in its accepted catalog. +pub(in crate::packs::publication) fn ready_for_merge( + sets: &[SqlResultSet], + input: &crate::pulls::merge::command::MergeInput, +) -> Result, Error> { + let Some((binding, candidate, stored)) = row(sets)? else { + return Ok(None); + }; + if candidate.number != input.number + || candidate.request.revision != input.request.revision + || candidate.request.strategy != input.request.strategy + || input.request.candidate_id.as_deref() != Some(&candidate.request.id) + || !matches!(candidate.result, CandidateResult::Ready { .. }) + || binding != crate::pulls::candidates::intent_binding(&candidate)? + { + return Ok(None); + } + let Some(stored) = stored else { + return Ok(None); + }; + if selected(sets, &stored.reply, &candidate.actor)?.is_none() { + return Ok(None); + } + Ok(stored.root.map(|root| (candidate, root))) +} +pub(in crate::packs::publication) async fn verify_ready( + store: &ArtifactStore, + candidate: &MergeCandidate, + root: StoredInputRoot, +) -> Result<(), NativeCandidateAuditError> { + let audit: Audit = root.read(store, INPUT_ROOT_BYTES).await?; + if audit.candidate != *candidate + || audit.catalog.repository != store.repository() + || audit.refs.operation() != root.operation + { + return Err(NativeCandidateAuditError::Context); + } + Ok(()) +} + +pub(in crate::packs::publication) async fn backup_graph( + root: StoredInputRoot, + inventory: &mut crate::packs::backup::Inventory<'_>, +) -> crate::packs::directory::index::WalkResult<()> { + let audit: Audit = root.read(&inventory.store(), INPUT_ROOT_BYTES).await?; + if !inventory.input(root.operation, root.artifact).await? { + return Ok(()); + } + inventory.catalog_headers(audit.catalog).await?; + inventory.refs(audit.refs).await +} diff --git a/crates/canopy-server/src/packs/publication/candidate_publication/mod.rs b/crates/canopy-server/src/packs/publication/candidate_publication/mod.rs new file mode 100644 index 00000000..d8f89b0c --- /dev/null +++ b/crates/canopy-server/src/packs/publication/candidate_publication/mod.rs @@ -0,0 +1,338 @@ +//! Generated candidate publication reuses the editorial intent and exact root journal. +//! Ready never obtains authority from a client result, ref descriptor or SQL OID. +use super::*; +use crate::pulls::candidates::{CandidateResult, MergeCandidate, valid_request, valid_result}; +use cellule_ltx::DiskBudget; +use cellule_runtime::{InvocationError, primitives::sql::SqlCell}; +use std::path::Path; +use tokio::time::timeout_at; + +pub(super) mod audit; +mod publish; +pub use publish::PublishNativeCandidate; +pub const NATIVE_CANDIDATE_BYTES: u32 = 512 << 10; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CandidatePublicationReply { + Applied { + id: [u8; 16], + digest: [u8; 32], + publication: Option, + }, + NotFound, + Forbidden, + Conflict, + Denied(PreparationDenial), +} +impl CandidatePublicationReply { + pub(crate) fn applied(&self) -> bool { + matches!(self, Self::Applied { .. }) + } +} +impl WireValue for CandidatePublicationReply { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + match self { + Self::Applied { + id, + digest, + publication, + } => { + crate::validate_repository_id(*id) + .map_err(|_| CodecError::Invalid("candidate UUID"))?; + e.write_u8(0)?; + e.write_bytes(id)?; + e.write_bytes(digest)?; + publication.encode(e) + } + Self::NotFound => e.write_u8(1), + Self::Forbidden => e.write_u8(2), + Self::Conflict => e.write_u8(3), + Self::Denied(reason) => { + e.write_u8(4)?; + PreparationReply::Denied(*reason).encode(e) + } + } + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + let value = match d.read_u8()? { + 0 => Self::Applied { + id: crate::packs::directory::index::codec::fixed(d)?, + digest: crate::packs::directory::index::codec::fixed(d)?, + publication: Option::decode(d)?, + }, + 1 => Self::NotFound, + 2 => Self::Forbidden, + 3 => Self::Conflict, + 4 => match PreparationReply::decode(d)? { + PreparationReply::Denied(reason) => Self::Denied(reason), + _ => return Err(CodecError::Invalid("candidate denial")), + }, + _ => return Err(CodecError::Invalid("candidate acknowledgement")), + }; + value.encode(&mut BoundedEncoder::new(512)?)?; + Ok(value) + } +} +pub(super) fn candidate_bytes(candidate: &MergeCandidate) -> Result, CodecError> { + if candidate.number <= 0 + || candidate.created_at_ms < 0 + || validate_component(&candidate.actor).is_err() + || !valid_request(&candidate.request) + || !valid_result(&candidate.result) + { + return Err(CodecError::Invalid("generated candidate result")); + } + let bytes = + serde_json::to_vec(candidate).map_err(|_| CodecError::Invalid("candidate encoding"))?; + if bytes.len() > 300 << 10 { + return Err(CodecError::Invalid("candidate result limit")); + } + Ok(bytes) +} +pub(super) fn result_digest(candidate: &MergeCandidate) -> Result<[u8; 32], CodecError> { + Ok(*blake3::hash(&candidate_bytes(candidate)?).as_bytes()) +} +#[derive(Clone, Debug)] +pub struct NativeCandidateProof { + certificate: CatalogCertificate, + candidate: MergeCandidate, + selection: RefSelection, + refs: Option, + ref_generation: Option, + audit: Option, +} +impl NativeCandidateProof { + fn binding(&self) -> Result<[u8; 32], CodecError> { + Self::payload_binding( + &self.candidate, + &self.selection, + self.refs, + self.ref_generation, + self.audit, + ) + } + fn payload_binding( + candidate: &MergeCandidate, + selection: &RefSelection, + refs: Option, + generation: Option, + audit: Option, + ) -> Result<[u8; 32], CodecError> { + let mut e = BoundedEncoder::new(NATIVE_CANDIDATE_BYTES)?; + e.write_bytes(&candidate_bytes(candidate)?)?; + selection.encode(&mut e)?; + refs.encode(&mut e)?; + generation.encode(&mut e)?; + audit.encode(&mut e)?; + let mut h = blake3::Hasher::new(); + h.update(b"canopy.generated-candidate-publication.v1\0"); + h.update(&e.finish()); + Ok(*h.finalize().as_bytes()) + } + fn shape(&self) -> Result<(), CodecError> { + let data = self.certificate.data()?; + candidate_bytes(&self.candidate)?; + let ready = matches!(self.candidate.result, CandidateResult::Ready { .. }); + if data.compaction + || data.base.refs.is_none() + || data.actor != self.candidate.actor + || data.completion_digest.is_some() + || data.refs_digest != Some(self.binding()?) + || self.selection.repository != data.token.repository + || self.selection.actor.as_deref() != Some(&data.actor) + || self.selection.proof.is_some() + || self.selection.facts.len() > 2 + || ready != self.refs.is_some() + || ready != self.ref_generation.is_some() + || ready != self.audit.is_some() + || self + .refs + .is_some_and(|r| r.operation() != data.token.artifact_operation) + || self + .audit + .is_some_and(|r| r.operation != data.token.artifact_operation) + || self + .ref_generation + .is_some_and(|g| g == 0 || g > i64::MAX as u64) + || !ready && (data.input_count != 0 || data.object_count != 0 || data.edge_count != 0) + { + return Err(CodecError::Invalid("candidate proof scope")); + } + Ok(()) + } +} +impl WireValue for NativeCandidateProof { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + self.shape()?; + self.certificate.encode(e)?; + e.write_bytes(&candidate_bytes(&self.candidate)?)?; + self.selection.encode(e)?; + self.refs.encode(e)?; + self.ref_generation.encode(e)?; + self.audit.encode(e) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + let value = Self { + certificate: CatalogCertificate::decode(d)?, + candidate: serde_json::from_slice(d.read_bytes()?) + .map_err(|_| CodecError::Invalid("candidate result"))?, + selection: RefSelection::decode(d)?, + refs: Option::decode(d)?, + ref_generation: Option::decode(d)?, + audit: Option::decode(d)?, + }; + value.shape()?; + Ok(value) + } +} +#[derive(Debug, thiserror::Error)] +pub enum NativeCandidatePublicationError { + #[error("candidate preparation is inactive")] + Base(#[from] PreparationBaseError), + #[error("candidate context differs")] + Context, + #[error("candidate encoding failed")] + Codec(#[from] CodecError), + #[error("candidate native verification failed")] + Verification(#[from] NativeCandidateVerificationError), + #[error("candidate SQL capability failed")] + Capability(#[from] Error), + #[error("candidate ref snapshot failed")] + Snapshot(#[from] crate::packs::ref_state::RefSnapshotError), + #[error("candidate ref tree failed")] + Refs(#[from] crate::packs::ref_state::RefStateError), + #[error("candidate audit failed")] + Root(#[from] crate::packs::InputRootError), + #[error("candidate certificate failed")] + Certificate(#[from] CatalogAttestationError), + #[error("candidate metadata query failed")] + Query(#[source] Box>>), + #[error("candidate command preparation failed")] + Command(#[source] Box>), +} +impl PreparedCatalog { + pub(crate) async fn native_candidate_proof( + &self, + produced: &crate::git_gateway::candidates::ProducedCandidate, + directory: &Path, + budget: DiskBudget, + limits: crate::packs::metadata::MetadataLimits, + ) -> Result { + let (_, deadline) = self.base.live_lease()?; + timeout_at(deadline, async { + let candidate = produced.candidate().clone(); + if candidate.actor != self.base.capability().2.actor + || produced.operation() != self.token().operation + { + return Err(NativeCandidatePublicationError::Context); + } + candidate_bytes(&candidate)?; + let (client, target, _) = self.base.capability(); + let sql = SqlCell::::new(client.clone(), target.clone())?; + let selected = sql + .query( + None, + sql::statement( + "SELECT source_ref,base_ref FROM pull_requests WHERE number=?1", + vec![SqlValue::Integer(candidate.number)], + ), + ) + .await + .map_err(|e| NativeCandidatePublicationError::Query(Box::new(e)))?; + let store = self.base.indexes().store(); + let old = self + .base() + .refs + .ok_or(NativeCandidatePublicationError::Context)? + .read(&store) + .await?; + if old.repository != self.token().repository + || old.format != self.catalog().format + || old.generation > self.base().generation + || old.generation >= i64::MAX as u64 + { + return Err(NativeCandidatePublicationError::Context); + } + let index = crate::packs::ref_state::RefStateIndex::new(store.clone(), old.format); + let mut selection = RefSelection { + repository: self.token().repository, + actor: Some(candidate.actor.clone()), + facts: vec![], + proof: None, + }; + if let Some([SqlValue::Text(source), SqlValue::Text(base)]) = + sql::rows(&selected.output)?.first().map(Vec::as_slice) + { + let mut names = vec![source.clone(), base.clone()]; + names.sort(); + names.dedup(); + for name in names { + selection.facts.push(ref_observation::RefFact { + state: index.read(old.root.clone(), &name).await?, + name, + }); + } + } + let (refs, ref_generation, audit) = + if matches!(candidate.result, CandidateResult::Ready { .. }) { + self.verify_candidate_commit(&candidate, directory, budget, limits) + .await?; + let transition = index + .prepare_candidate(old.root, self.token().artifact_operation, &candidate) + .await?; + let generation = old.generation + 1; + let refs = crate::packs::ref_state::RefStateSnapshotRoot::upload( + &store, + self.token().artifact_operation, + crate::packs::ref_state::RefStateSnapshot { + repository: old.repository, + format: old.format, + generation, + default_branch: old.default_branch, + root: Some(transition.root()), + }, + ) + .await?; + let audit = audit::prepare(self, &candidate, refs, generation).await?; + (Some(refs), Some(generation), Some(audit)) + } else { + (None, None, None) + }; + let binding = NativeCandidateProof::payload_binding( + &candidate, + &selection, + refs, + ref_generation, + audit, + )?; + let proof = NativeCandidateProof { + certificate: self.issue_certificate(Some(binding), None).await?, + candidate, + selection, + refs, + ref_generation, + audit, + }; + proof.shape()?; + self.ensure_live()?; + Ok(proof) + }) + .await + .map_err(|_| PreparationBaseError::Inactive)? + } +} + +pub(super) async fn backup_graph( + proof: &NativeCandidateProof, + seed: &[u8; 32], + inventory: &mut crate::packs::backup::Inventory<'_>, +) -> crate::packs::directory::index::WalkResult<()> { + super::backup::catalog_certificate(&proof.certificate, seed, inventory).await?; + if let Some(root) = proof.refs { + inventory.refs(root).await?; + } + if let Some(root) = proof.audit { + audit::backup_graph(root, inventory).await?; + } + Ok(()) +} diff --git a/crates/canopy-server/src/packs/publication/candidate_publication/publish.rs b/crates/canopy-server/src/packs/publication/candidate_publication/publish.rs new file mode 100644 index 00000000..709c7024 --- /dev/null +++ b/crates/canopy-server/src/packs/publication/candidate_publication/publish.rs @@ -0,0 +1,223 @@ +//! Original authenticated attempt, joint-root CAS and first result share SDK acceptance. +use super::super::{ + commands::{authorized, check_pin, fact, load, matched}, + publish::{authenticate, changed, checkpoint, retention_matches}, + sql::*, +}; +use super::*; +pub struct PublishNativeCandidate; +impl Command for PublishNativeCandidate { + const MODULE: &'static str = RepositoryModule::NAME; + const ID: u32 = 55; + const CODEC_VERSION: u32 = 1; + type Input = NativeCandidateProof; + type Output = CandidatePublicationReply; + fn execute( + context: &mut CommandContext<'_, '_>, + input: Self::Input, + ) -> cellule_runtime::Result> { + let data = input.certificate.data()?; + let check = LeaseCheck { + token: data.token, + actor: data.actor, + }; + recovery::execute(context, &check, recovery::Kind::Candidate, |context| { + publish(context, input) + }) + } +} +fn reject(value: CandidatePublicationReply) -> CommandResult { + CommandResult::Rejected(value) +} +fn denied(reason: PreparationDenial) -> CommandResult { + reject(CandidatePublicationReply::Denied(reason)) +} +fn publish( + context: &mut CommandContext<'_, '_>, + proof: NativeCandidateProof, +) -> cellule_runtime::Result> { + proof.shape()?; + let Some((data, key)) = + authenticate(context, &proof.certificate, Some(proof.binding()?), None)? + else { + return Ok(denied(PreparationDenial::Unauthorized)); + }; + let check = LeaseCheck { + token: data.token, + actor: data.actor.clone(), + }; + let result = authenticated(context, proof, data, key)?; + if check.token.owner == context.owner_fence() + && let Some(row) = load(context, check.token)? + && matched(&row, &check) + { + check_pin(context, &row)?; + changed(context.sql(&statement( + "DELETE FROM catalog_operations WHERE id=?1", + vec![blob(check.token.operation)], + ))?)?; + } + Ok(result) +} +fn authenticated( + context: &mut CommandContext<'_, '_>, + proof: NativeCandidateProof, + data: super::super::certificate::CertificateData, + key: [u8; 32], +) -> cellule_runtime::Result> { + if authorized( + context, + data.token.repository, + &data.actor, + TokenScope::Write, + )? != Some(data.catalog.format) + { + return Ok(reject(CandidatePublicationReply::Forbidden)); + } + let id = uuid::Uuid::parse_str(&proof.candidate.request.id) + .map_err(|_| Error::Command("candidate UUID"))?; + let prior = context.sql(&statement(audit::SAVED, vec![blob(id.as_bytes())]))?; + let Some((binding, old, stored)) = audit::row(&prior)? else { + return Ok(reject(CandidatePublicationReply::NotFound)); + }; + let expected_binding = crate::pulls::candidates::intent_binding(&proof.candidate)?; + if binding != expected_binding + || old.request != proof.candidate.request + || old.actor != data.actor + || old.number != proof.candidate.number + || old.created_at_ms != proof.candidate.created_at_ms + { + return Ok(reject(CandidatePublicationReply::Conflict)); + } + if old.result != CandidateResult::Pending { + let reply = match stored { + Some(value) => value.reply, + None if !matches!(old.result, CandidateResult::Ready { .. }) => { + CandidatePublicationReply::Applied { + id: *id.as_bytes(), + digest: result_digest(&old)?, + publication: None, + } + } + None => return Err(Error::Command("Ready candidate has no native audit")), + }; + if audit::selected(&prior, &reply, &data.actor)?.is_none() { + return Err(Error::Command("candidate prior result differs")); + } + return Ok(CommandResult::Success(reply)); + } + if data.token.owner != context.owner_fence() { + return Ok(denied(PreparationDenial::Stale)); + } + let Some(row) = load(context, data.token)? else { + return Ok(denied(PreparationDenial::Missing)); + }; + if !matched( + &row, + &LeaseCheck { + token: data.token, + actor: data.actor.clone(), + }, + ) { + return Ok(denied(PreparationDenial::Stale)); + } + if row.expires <= now(context.now_ms())? { + return Ok(denied(PreparationDenial::Expired)); + } + check_pin(context, &row)?; + if !retention_matches(context, &data, row.generation, data.catalog.format)? + || fact(context, data.token.repository, data.catalog.format, None)? != data.base + { + return Ok(reject(CandidatePublicationReply::Conflict)); + } + let policy = context.sql(&SqlBatch { + statements: vec![crate::pulls::native::with_refs( + crate::pulls::merge::policy_statement(&data.actor, old.number), + &proof.selection, + )], + })?; + match crate::pulls::merge::candidate_ready(&policy, &old.request.revision)? { + None => return Ok(reject(CandidatePublicationReply::NotFound)), + Some(false) => return Ok(reject(CandidatePublicationReply::Conflict)), + Some(true) => {} + } + let ready = proof.refs.is_some(); + if ready { + let count = context.sql(&statement( + "SELECT count(*) FROM (SELECT generation FROM catalog_generations LIMIT ?1)", + vec![number(MAX_RETAINED_GENERATIONS)?], + ))?; + let Some([count]) = rows(&count)?.first().map(Vec::as_slice) else { + return Err(Error::Command("candidate generation count")); + }; + if unsigned(count)? >= MAX_RETAINED_GENERATIONS { + return Ok(denied(PreparationDenial::Capacity)); + } + } + let Some(missing) = checkpoint(context, &data, &key)? else { + return Ok(reject(CandidatePublicationReply::Conflict)); + }; + let bytes = proof.certificate.bytes()?; + let digest = *blake3::hash(&bytes).as_bytes(); + let publication = if ready { + Some(PublishedRefs { + generation: data + .base + .generation + .checked_add(1) + .filter(|g| *g <= i64::MAX as u64) + .ok_or(Error::Command("candidate generation exhausted"))?, + ref_generation: proof + .ref_generation + .ok_or(Error::Command("candidate ref generation"))?, + certificate_digest: digest, + }) + } else { + None + }; + let reply = CandidatePublicationReply::Applied { + id: *id.as_bytes(), + digest: result_digest(&proof.candidate)?, + publication, + }; + let mut selected = BoundedEncoder::new(512)?; + audit::Selected { + reply: reply.clone(), + root: proof.audit, + } + .encode(&mut selected)?; + let result = serde_json::to_string(&proof.candidate.result) + .map_err(|_| Error::Command("candidate result encoding"))?; + let oid = match &proof.candidate.result { + CandidateResult::Ready { oid, .. } => blob(crate::pulls::merge::oid(oid)?), + _ => SqlValue::Null, + }; + let mut catalog = BoundedEncoder::new(256)?; + data.catalog.encode(&mut catalog)?; + let mut refs = BoundedEncoder::new(128)?; + if let Some(root) = proof.refs { + root.encode(&mut refs)?; + } + if row.expires <= now(context.now_ms())? { + return Ok(denied(PreparationDenial::Expired)); + } + // No later refusal. A late SQL error rolls back roots, summary, first result, + // checkpoint, own attempt closure, journal and SDK acceptance together. + if missing { + changed(context.sql(&statement("UPDATE catalog_operations SET attestation=?1,attestation_digest=?2 WHERE id=?3 AND attestation IS NULL",vec![blob(&bytes),blob(digest),blob(data.token.operation)]))?)?; + changed(context.sql(&statement("UPDATE catalog_leases SET attestation=?1,attestation_digest=?2 WHERE incarnation=?3 AND admission_sequence=?4 AND attestation IS NULL",vec![blob(&bytes),blob(digest),blob(data.token.owner.incarnation.as_bytes()),number(data.token.attempt)?]))?)?; + } + if let Some(value) = publication { + changed(context.sql(&statement("INSERT INTO catalog_generations(generation,catalog,certificate,refs) VALUES(?1,?2,?3,?4)",vec![number(value.generation)?,blob(catalog.finish()),blob(digest),blob(refs.finish())]))?)?; + changed(context.sql(&statement( + "UPDATE catalog_state SET generation=?1 WHERE singleton=1 AND generation=?2", + vec![number(value.generation)?, number(data.base.generation)?], + ))?)?; + changed(context.sql(&statement( + "UPDATE ref_generation SET generation=?1 WHERE singleton=1", + vec![number(value.ref_generation)?], + ))?)?; + } + changed(context.sql(&statement("UPDATE merge_candidates SET result=?2,oid=?3,native_publication=?4 WHERE id=?1 AND json_extract(result,'$.state')='pending'",vec![blob(id.as_bytes()),SqlValue::Text(result),oid,blob(selected.finish())]))?)?; + Ok(CommandResult::Success(reply)) +} diff --git a/crates/canopy-server/src/packs/publication/certificate.rs b/crates/canopy-server/src/packs/publication/certificate.rs index 9d4648dd..38e2dd08 100644 --- a/crates/canopy-server/src/packs/publication/certificate.rs +++ b/crates/canopy-server/src/packs/publication/certificate.rs @@ -79,8 +79,11 @@ impl CertificateData { .any(|value| *value > i64::MAX as u64) || (self.input_count == 0) != (self.object_count == 0) || (self.object_count == 0 && self.edge_count != 0) - || (self.input_checkpoint_digest.is_some() - && (self.input_count == 0 || self.compaction)) + // A delete-only/ref-only push retains authenticated request and + // native outcome custody without adding any physical pack. Its + // checkpoint is still checked by final publication. Compaction + // has no native push checkpoint. + || (self.input_checkpoint_digest.is_some() && self.compaction) || (self.compaction && (self.refs_digest.is_some() || self.completion_digest.is_some())) { return Err(CodecError::Invalid("invalid catalog attestation facts")); diff --git a/crates/canopy-server/src/packs/publication/commit_membership.rs b/crates/canopy-server/src/packs/publication/commit_membership.rs new file mode 100644 index 00000000..0c37c52a --- /dev/null +++ b/crates/canopy-server/src/packs/publication/commit_membership.rs @@ -0,0 +1,164 @@ +//! Bounded historical commit membership under an existing retained serving pin. +//! This proves kind/existence only; callers must recheck their own current policy. +use super::*; +use crate::packs::directory::index::codec::fixed; +use crate::{ObjectId, ReadIdentity}; +use cellule_runtime::{ApplicationId, CellId, TenantId}; +use certificate::CertificateEnvelope; + +const DOMAIN: &[u8] = b"canopy.commit-membership.v1\0"; +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct CommitMembership(CertificateEnvelope); +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct MembershipData { + pub(super) tenant: [u8; 16], + pub(super) application: [u8; 16], + pub(super) token: ServingToken, + pub(super) fact: GenerationFact, + pub(super) actor: Option, + pub(super) oid: ObjectId, +} +impl MembershipData { + fn validate(&self) -> Result<(), CodecError> { + self.token.validate()?; + self.fact.validate()?; + if self.token.generation != self.fact.generation + || self.fact.catalog.is_none_or(|c| { + c.repository != self.token.repository || c.format != self.oid.format() + }) + || self.fact.refs.is_none() + || self.oid.is_zero() + || self + .actor + .as_deref() + .is_some_and(|a| validate_component(a).is_err()) + { + return Err(CodecError::Invalid("invalid commit membership")); + } + Ok(()) + } +} +impl WireValue for MembershipData { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + self.validate()?; + e.write_bytes(DOMAIN)?; + e.write_bytes(&self.tenant)?; + e.write_bytes(&self.application)?; + self.token.encode(e)?; + self.fact.encode(e)?; + self.actor.encode(e)?; + e.write_bytes(self.oid.as_ref()) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + if d.read_bytes()? != DOMAIN { + return Err(CodecError::Invalid("invalid membership purpose")); + } + let value = Self { + tenant: fixed(d)?, + application: fixed(d)?, + token: ServingToken::decode(d)?, + fact: GenerationFact::decode(d)?, + actor: Option::::decode(d)?, + oid: ObjectId::try_from(d.read_bytes()?) + .map_err(|_| CodecError::Invalid("invalid membership OID"))?, + }; + value.validate()?; + Ok(value) + } +} +pub(crate) struct MembershipRequest<'a> { + pub(crate) cell: CellId, + pub(crate) owner: Option, + pub(crate) repository: [u8; 16], + pub(crate) actor: &'a Option, + pub(crate) oid: ObjectId, + pub(crate) admitted_ms: i64, +} +impl CommitMembership { + pub(super) fn seal(data: MembershipData, seed: &[u8; 32]) -> Result { + Ok(Self(CertificateEnvelope::seal(&data, seed)?)) + } + /// The receiver checks the original pin and immutable joint fact rather than + /// the moving current head: unrelated pushes cannot invalidate membership. + pub(crate) fn authorize( + &self, + request: MembershipRequest<'_>, + mut query: impl FnMut(&SqlBatch) -> cellule_runtime::Result>, + ) -> cellule_runtime::Result { + use sql::*; + let MembershipRequest { + cell, + owner, + repository, + actor, + oid, + admitted_ms, + } = request; + let data: MembershipData = self.0.data()?; + let target = crate::repository_target( + TenantId::from_bytes(data.tenant), + ApplicationId::from_bytes(data.application), + repository, + )?; + if target.cell_id() != cell + || data.token.repository != repository + || data.actor != *actor + || data.oid != oid + || owner.is_some_and(|f| data.token.owner != f) + { + return Ok(false); + } + let scope = actor + .as_deref() + .map_or(ReadIdentity::Anonymous, ReadIdentity::Account); + scope.validate()?; + let access = query(&statement( + &format!("SELECT 1 WHERE {}", crate::access::READ_ACCESS), + vec![scope.parameter()], + ))?; + if rows(&access)?.is_empty() { + return Ok(false); + } + let secret = query(&statement( + "SELECT push_cert_seed FROM repository_identity WHERE singleton=1 AND repository_id=?1 AND object_format=?2", + vec![ + blob(repository), + SqlValue::Text(oid.format().as_str().into()), + ], + ))?; + if rows(&secret)?.is_empty() || !self.0.authenticated(&attestation::seed(&secret)?) { + return Ok(false); + } + let token = data.token; + let pin = query(&statement( + "SELECT 1 FROM catalog_serving_pins WHERE reader=?1 AND incarnation=?2 AND admission_sequence=?3 AND owner_epoch=?4 AND generation=?5 AND expires_at_ms>?6", + vec![ + blob(token.reader), + blob(token.owner.incarnation.as_bytes()), + number(token.admission_sequence)?, + blob(token.owner.epoch.to_be_bytes()), + number(token.generation)?, + SqlValue::Integer(now(admitted_ms)?), + ], + ))?; + if rows(&pin)?.is_empty() { + return Ok(false); + } + Ok(generation( + &query(&statement(GENERATION, vec![number(token.generation)?]))?, + repository, + oid.format(), + )? == data.fact) + } +} +impl WireValue for CommitMembership { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + self.0.data::()?; + self.0.encode(e) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + let value = Self(CertificateEnvelope::decode(d)?); + value.0.data::()?; + Ok(value) + } +} diff --git a/crates/canopy-server/src/packs/publication/coordinator.rs b/crates/canopy-server/src/packs/publication/coordinator.rs index 2bf52518..9a2cdf71 100644 --- a/crates/canopy-server/src/packs/publication/coordinator.rs +++ b/crates/canopy-server/src/packs/publication/coordinator.rs @@ -23,6 +23,10 @@ use tokio::{ const COMMAND_RESERVATION: u64 = 8 << 20; const INLINE_BYTES: u32 = 4 << 20; mod initialization; +mod native_head; +pub use native_head::ReadyNativeHead; +mod native_merge; +pub use native_merge::ReadyNativeMerge; mod inputs; pub use initialization::ReadyInitialization; pub use inputs::{NativeInputReadyError, ReadyNativeInputs, RegisteredNativeInputs}; @@ -192,6 +196,8 @@ pub enum PublicationScheduleError { InvalidLimits, #[error("publication coordinator is closed")] Closed, + #[error("publication admission mutex is busy")] + Busy, #[error("publication admission capacity exceeded")] Capacity, #[error("publication belongs to another repository coordinator")] @@ -455,12 +461,20 @@ impl PublicationCoordinator { let ready = ready.into(); let Ok(mut state) = self.inner.state.try_lock() else { return Err(Box::new(PublicationAdmissionFailure { - reason: PublicationScheduleError::Capacity, + reason: PublicationScheduleError::Busy, ready, })); }; self.admit(&mut state, ready, true) } + /// Waiting here reserves no quota and dispatches no command. The caller + /// must capture the held ticket synchronously before yielding again. + pub(in crate::packs::publication) async fn held_admission(&self) -> HeldAdmission<'_> { + HeldAdmission { + coordinator: self, + state: self.inner.state.lock().await, + } + } fn admit( &self, state: &mut State, @@ -797,11 +811,33 @@ impl PublicationCoordinator { ) } #[cfg(test)] + pub(super) async fn with_admission_async_for_test( + &self, + inspect: impl std::future::Future, + ) -> T { + let _state = self.inner.state.lock().await; + inspect.await + } + #[cfg(test)] pub(super) async fn with_admission_for_test(&self, inspect: impl FnOnce() -> T) -> T { let _state = self.inner.state.lock().await; inspect() } } +/// A short synchronous handoff while the fair admission mutex is held. +/// No guard or prepared command may be retained across another await. +pub(in crate::packs::publication) struct HeldAdmission<'a> { + coordinator: &'a PublicationCoordinator, + state: tokio::sync::MutexGuard<'a, State>, +} +impl HeldAdmission<'_> { + pub(in crate::packs::publication) fn reserve( + &mut self, + ready: ReadyPublication, + ) -> Result> { + self.coordinator.admit(&mut self.state, ready, true) + } +} impl PublicationTicket { pub(in crate::packs::publication) fn is_policy_page(&self) -> bool { self.job.policy_page @@ -1273,3 +1309,5 @@ mod fairness { assert_eq!(queue.pop(true, 3), Some(101)); } } + +mod native_candidate; diff --git a/crates/canopy-server/src/packs/publication/coordinator/native_candidate.rs b/crates/canopy-server/src/packs/publication/coordinator/native_candidate.rs new file mode 100644 index 00000000..fae8d7c5 --- /dev/null +++ b/crates/canopy-server/src/packs/publication/coordinator/native_candidate.rs @@ -0,0 +1,76 @@ +//! Generated candidates share the original private owner and exact registered dispatch. +use super::*; +use canopy_object_storage::artifact::ArtifactStore; + +#[must_use] +pub struct ReadyNativeCandidate { + owner: Arc, + command: PreparedCommand, +} +impl PreparedCatalog { + pub(crate) async fn ready_native_candidate( + self: &Arc, + identity: MutationIdentity, + produced: &crate::git_gateway::candidates::ProducedCandidate, + directory: &std::path::Path, + budget: cellule_ltx::DiskBudget, + limits: crate::packs::metadata::MetadataLimits, + ) -> Result { + let proof = self + .native_candidate_proof(produced, directory, budget, limits) + .await?; + self.ensure_live()?; + let (client, target, _) = self.base.capability(); + let command = client + .prepare_command::(target, identity, proof) + .await + .map_err(|e| NativeCandidatePublicationError::Command(Box::new(e)))?; + self.ensure_live()?; + Ok(ReadyNativeCandidate { + owner: self.clone(), + command, + }) + } +} +impl ReadyNativeCandidate { + pub async fn persist_recovery( + &self, + store: &ArtifactStore, + identity: MutationIdentity, + ) -> Result { + super::super::recovery::persist( + &self.owner.base.session, + &self.command, + super::super::recovery::Kind::Candidate, + store, + identity, + 0, + ) + .await + } + pub fn bind_recovery( + self, + registered: RegisteredRootRecovery, + store: &ArtifactStore, + ) -> Result>> { + if !registered.matches_original( + super::super::recovery::Kind::Candidate, + self.command.evidence(), + None, + &self.owner.base.session, + store, + ) { + return Err(Box::new(RecoveryBindingFailure { + original: self, + registered, + })); + } + Ok(ReadyBoundRecovery::new( + PushPreparation::Catalog(self.owner), + None, + false, + registered, + store, + )) + } +} diff --git a/crates/canopy-server/src/packs/publication/coordinator/native_head.rs b/crates/canopy-server/src/packs/publication/coordinator/native_head.rs new file mode 100644 index 00000000..26b97587 --- /dev/null +++ b/crates/canopy-server/src/packs/publication/coordinator/native_head.rs @@ -0,0 +1,71 @@ +//! Symbolic HEAD changes share the original private owner and exact registered dispatch. +use super::*; +use canopy_object_storage::artifact::ArtifactStore; + +#[must_use] +pub struct ReadyNativeHead { + owner: Arc, + command: PreparedCommand, +} +impl PreparedCatalog { + pub async fn ready_native_head( + self: &Arc, + identity: MutationIdentity, + request: HeadRequest, + ) -> Result { + let proof = self.native_head_proof(request).await?; + self.ensure_live()?; + let (client, target, _) = self.base.capability(); + let command = client + .prepare_command::(target, identity, proof) + .await + .map_err(|e| NativeHeadPreparationError::Command(Box::new(e)))?; + self.ensure_live()?; + Ok(ReadyNativeHead { + owner: self.clone(), + command, + }) + } +} +impl ReadyNativeHead { + pub async fn persist_recovery( + &self, + store: &ArtifactStore, + identity: MutationIdentity, + ) -> Result { + super::super::recovery::persist( + &self.owner.base.session, + &self.command, + super::super::recovery::Kind::Head, + store, + identity, + 0, + ) + .await + } + pub fn bind_recovery( + self, + registered: RegisteredRootRecovery, + store: &ArtifactStore, + ) -> Result>> { + if !registered.matches_original( + super::super::recovery::Kind::Head, + self.command.evidence(), + None, + &self.owner.base.session, + store, + ) { + return Err(Box::new(RecoveryBindingFailure { + original: self, + registered, + })); + } + Ok(ReadyBoundRecovery::new( + PushPreparation::Catalog(self.owner), + None, + false, + registered, + store, + )) + } +} diff --git a/crates/canopy-server/src/packs/publication/coordinator/native_merge.rs b/crates/canopy-server/src/packs/publication/coordinator/native_merge.rs new file mode 100644 index 00000000..6a77d494 --- /dev/null +++ b/crates/canopy-server/src/packs/publication/coordinator/native_merge.rs @@ -0,0 +1,84 @@ +//! Native merges share the original private owner and exact registered dispatch. +use super::*; +use crate::pulls::merge::{MergeRequest, command::MergeInput}; +use canopy_object_storage::artifact::ArtifactStore; + +#[must_use] +pub struct ReadyNativeMerge { + owner: Arc, + command: PreparedCommand, +} +impl PreparedCatalog { + pub async fn ready_native_merge( + self: &Arc, + identity: MutationIdentity, + number: i64, + request: MergeRequest, + directory: &Path, + budget: DiskBudget, + limits: MetadataLimits, + ) -> Result { + let input = MergeInput { + actor: self.base.capability().2.actor.clone(), + number, + request, + issued_at_ms: identity.issued_at_ms, + }; + let proof = self + .native_merge_proof(input, directory, budget, limits) + .await?; + self.ensure_live()?; + let (client, target, _) = self.base.capability(); + let command = client + .prepare_command::(target, identity, proof) + .await + .map_err(|e| NativeMergePreparationError::Command(Box::new(e)))?; + self.ensure_live()?; + Ok(ReadyNativeMerge { + owner: self.clone(), + command, + }) + } +} +impl ReadyNativeMerge { + pub async fn persist_recovery( + &self, + store: &ArtifactStore, + identity: MutationIdentity, + ) -> Result { + super::super::recovery::persist( + &self.owner.base.session, + &self.command, + super::super::recovery::Kind::Merge, + store, + identity, + 0, + ) + .await + } + pub fn bind_recovery( + self, + registered: RegisteredRootRecovery, + store: &ArtifactStore, + ) -> Result>> { + if !registered.matches_original( + super::super::recovery::Kind::Merge, + self.command.evidence(), + None, + &self.owner.base.session, + store, + ) { + return Err(Box::new(RecoveryBindingFailure { + original: self, + registered, + })); + } + Ok(ReadyBoundRecovery::new( + PushPreparation::Catalog(self.owner), + None, + false, + registered, + store, + )) + } +} diff --git a/crates/canopy-server/src/packs/publication/coordinator/policy.rs b/crates/canopy-server/src/packs/publication/coordinator/policy.rs index 8ead4277..ed2a9e82 100644 --- a/crates/canopy-server/src/packs/publication/coordinator/policy.rs +++ b/crates/canopy-server/src/packs/publication/coordinator/policy.rs @@ -20,6 +20,7 @@ pub struct ReadyRefPolicyPage { pub(super) prepared: Arc, intent: Arc, command: PreparedCommand, + end_offset: usize, refusal: Option>, #[cfg(test)] refusal_fault: u8, @@ -35,6 +36,7 @@ impl RefPolicyPreparation { .page(prepared, start) .await .map_err(|error| RefPolicyReadyError::Preparation(Box::new(error)))?; + let end_offset = input.offset as usize + input.proof.plan.updates.len(); input.encode(&mut BoundedEncoder::new(REF_POLICY_PAGE_BYTES)?)?; prepared.ensure_live()?; let (client, target, _) = prepared.base.capability(); @@ -47,6 +49,7 @@ impl RefPolicyPreparation { prepared: prepared.clone(), intent: self.clone(), command, + end_offset, refusal: None, #[cfg(test)] refusal_fault: 0, @@ -71,6 +74,12 @@ impl std::fmt::Display for RefPolicyRefusalFailure { } impl std::error::Error for RefPolicyRefusalFailure {} impl ReadyRefPolicyPage { + /// Next offset from this exact byte-bounded page, which may contain fewer + /// than the maximum number of updates. + pub fn end_offset(&self) -> usize { + self.end_offset + } + /// Convert only after this exact original page/refusal bundle is registered. /// The shared session and intent survive admission failure and uncertainty. pub fn bind_recovery( diff --git a/crates/canopy-server/src/packs/publication/coordinator/roots.rs b/crates/canopy-server/src/packs/publication/coordinator/roots.rs index 4fb44f30..b71b1c0a 100644 --- a/crates/canopy-server/src/packs/publication/coordinator/roots.rs +++ b/crates/canopy-server/src/packs/publication/coordinator/roots.rs @@ -22,6 +22,7 @@ pub struct ReadyRootPush { pub(super) owner: PushPreparation, command: RootCommand, pub(super) refusal: bool, + fallback: Option>, } impl PreparedCatalog { pub async fn ready_root_push( @@ -49,6 +50,7 @@ impl PreparedCatalog { owner: PushPreparation::Catalog(self.clone()), command: RootCommand::Publish(command), refusal: false, + fallback: None, }) } } @@ -101,6 +103,7 @@ impl PreparationSession { owner: PushPreparation::Outcome(self.clone()), command: RootCommand::Outcome(command), refusal, + fallback: None, }) } } @@ -117,6 +120,30 @@ impl RootCommand { } } impl ReadyRootPush { + /// Freeze the same-attempt refusal before final publication admission. + /// Recovery executes it only after the original publication is known denied. + pub fn with_refusal(mut self, refusal: Arc) -> Result { + let source = refusal.owner.session(); + let session = self.owner.session(); + if self.fallback.is_some() + || !matches!(self.command, RootCommand::Publish(_)) + || !refusal.refusal + || source.target != session.target + || source.check != session.check + || source.ceiling != session.ceiling + || !Arc::ptr_eq(&source.deadline, &session.deadline) + || !Arc::ptr_eq(&source.fenced, &session.fenced) + { + return Err(PreparationBaseError::Context.into()); + } + self.fallback = Some(refusal); + Ok(self) + } + fn fallback_command(&self) -> Option<&PreparedCommand> { + self.fallback + .as_ref() + .and_then(|value| value.refusal_command()) + } /// Preserve the original factory's shared lifecycle authority while /// dispatching from its exact registered SDK snapshot and body. pub fn bind_recovery( @@ -131,7 +158,7 @@ impl ReadyRootPush { if !registered.matches_original( kind, self.command.evidence(), - None, + self.fallback_command().map(|command| command.evidence()), self.owner.session(), store, ) { @@ -193,7 +220,7 @@ impl ReadyRootPush { session, command, super::super::recovery::Kind::Publish, - None, + self.fallback_command(), Some(previous), store, identity, @@ -245,10 +272,12 @@ impl ReadyRootPush { let session = self.owner.session(); match &self.command { RootCommand::Publish(command) => { - super::super::recovery::persist( + super::super::recovery::persist_full( session, command, super::super::recovery::Kind::Publish, + self.fallback_command(), + None, store, identity, fault, diff --git a/crates/canopy-server/src/packs/publication/coordinator/work.rs b/crates/canopy-server/src/packs/publication/coordinator/work.rs index 7bc2b5fd..13809c93 100644 --- a/crates/canopy-server/src/packs/publication/coordinator/work.rs +++ b/crates/canopy-server/src/packs/publication/coordinator/work.rs @@ -335,6 +335,9 @@ impl ReadyPublication { #[derive(Clone, Debug)] pub enum PublicationOutcome { + Candidate(Committed), + Head(Committed), + Merge(Committed), ServingRelease(Committed), ServingCommand(Committed), Initialization(Committed), @@ -350,6 +353,12 @@ pub enum PublicationOutcome { } #[derive(Debug, thiserror::Error)] pub enum PublicationError { + #[error("native candidate publication: {0}")] + Candidate(#[source] InvocationError), + #[error("symbolic HEAD publication: {0}")] + Head(#[source] InvocationError), + #[error("native reviewed merge publication: {0}")] + Merge(#[source] InvocationError), #[error("serving pin release: {0}")] ServingRelease(#[source] InvocationError), #[error("serving custody command: {0}")] @@ -401,6 +410,9 @@ impl PublicationError { Self::ServingRelease(error) => kind(error), Self::ServingCommand(error) => kind(error), Self::Initialization(error) => kind(error), + Self::Merge(error) => kind(error), + Self::Candidate(error) => kind(error), + Self::Head(error) => kind(error), Self::Push(error) => kind(error), Self::RootPush(error) => kind(error), Self::PolicyPage(error) => kind(error), @@ -424,6 +436,9 @@ impl PublicationError { Self::ServingRelease(error) => unknown(error), Self::ServingCommand(error) => unknown(error), Self::Initialization(error) => unknown(error), + Self::Merge(error) => unknown(error), + Self::Candidate(error) => unknown(error), + Self::Head(error) => unknown(error), Self::Push(error) => unknown(error), Self::RootPush(error) => unknown(error), Self::PolicyPage(error) => unknown(error), diff --git a/crates/canopy-server/src/packs/publication/inputs.rs b/crates/canopy-server/src/packs/publication/inputs.rs index 100b9478..f0428edb 100644 --- a/crates/canopy-server/src/packs/publication/inputs.rs +++ b/crates/canopy-server/src/packs/publication/inputs.rs @@ -977,3 +977,28 @@ pub(super) async fn observe_bound_registration( session.refresh(minimum).await?; Ok(()) } + +pub(super) async fn backup_graph( + value: &NativeInputCertificate, + seed: &[u8; 32], + inventory: &mut crate::packs::backup::Inventory<'_>, +) -> crate::packs::directory::index::WalkResult<()> { + if !value.0.authenticated(seed) { + return Err(CodecError::Invalid("backup input MAC").into()); + } + let data: Inputs = value.0.data()?; + value.scoped_check(inventory.target())?; + if data.format != inventory.format() { + return Err(CodecError::Invalid("backup input format").into()); + } + if let Some(root) = data.root { + inventory.native_inputs(root).await?; + } + if let Some(root) = data.wire_request { + super::backup::wire(root, inventory).await?; + } + if let Some(root) = data.native_result { + super::native_result::backup_graph(root, true, inventory).await?; + } + Ok(()) +} diff --git a/crates/canopy-server/src/packs/publication/mod.rs b/crates/canopy-server/src/packs/publication/mod.rs index ff422d05..ccbc3dd6 100644 --- a/crates/canopy-server/src/packs/publication/mod.rs +++ b/crates/canopy-server/src/packs/publication/mod.rs @@ -33,6 +33,10 @@ pub use session::PreparationSession; mod base; pub use base::{PreparationBaseError, PreparationBaseResolver}; mod certificate; +mod commit_membership; +mod ref_observation; +pub(crate) use commit_membership::{CommitMembership, MembershipRequest}; +pub(crate) use ref_observation::{REF_SELECTION_BYTES, RefSelection}; pub(in crate::packs) mod codec; pub use certificate::{ AttestationOutcome, CERTIFICATE_BYTES, CatalogCertificate, RegisteredCatalog, @@ -51,6 +55,17 @@ pub use initialization::{ }; mod ref_snapshot; pub use ref_snapshot::{PreparedRefSnapshot, RefSnapshotPreparationError}; +mod native_head; +pub use native_head::{ + HeadRequest, NATIVE_HEAD_BYTES, NativeHeadPreparationError, NativeHeadProof, PublishNativeHead, +}; +mod native_candidate; +pub use native_candidate::NativeCandidateVerificationError; +mod native_merge; +pub use native_merge::audit::NativeMergeAuditError; +pub use native_merge::{ + NATIVE_MERGE_BYTES, NativeMergePreparationError, NativeMergeProof, PublishReviewedMerge, +}; mod ref_policy; pub use ref_policy::{ CheckRefPolicyGuard, MAX_REF_POLICY_GUARDS, MAX_REF_POLICY_WATCHES, PreparedRefPolicyGuard, @@ -77,9 +92,10 @@ pub use coordinator::{ PublicationClass, PublicationCoordinator, PublicationError, PublicationLimits, PublicationOutcome, PublicationScheduleError, PublicationState, PublicationStats, PublicationTicket, ReadyBoundRecovery, ReadyCatalogCompaction, ReadyCatalogPush, - ReadyInitialization, ReadyNativeInputs, ReadyPreparation, ReadyPublication, ReadyRefPolicyPage, - ReadyRootPush, RecoveryBindingFailure, RefPolicyReadyError, RefPolicyRefusalFailure, - RegisteredNativeInputs, RootPushReadyError, ServingDrainAdmission, + ReadyInitialization, ReadyNativeHead, ReadyNativeInputs, ReadyNativeMerge, ReadyPreparation, + ReadyPublication, ReadyRefPolicyPage, ReadyRootPush, RecoveryBindingFailure, + RefPolicyReadyError, RefPolicyRefusalFailure, RegisteredNativeInputs, RootPushReadyError, + ServingDrainAdmission, }; pub use scan::{RecoveryScanBudget, RecoveryScanSettings}; mod commands; @@ -98,6 +114,7 @@ pub use recovery::{ TerminalReleaseInput, TerminalReleaseReply, }; mod staging_service; +pub(crate) use staging_service::StagingBudget; pub use staging_service::{ ReadyStaging, StagedInputsTicket, StagedPublicationFailure, StagedPublicationTicket, StagingBound, StagingContext, StagingCoordinator, StagingError, StagingLimits, StagingState, @@ -246,6 +263,17 @@ pub struct MaintenanceRequest { /// Bind the packed production contract. Inline publication/completion adapters /// are deliberately excluded; qualification binds its historical fixtures itself. pub fn register(registry: &mut RegistryBuilder) -> cellule_runtime::Result<()> { + registry.bind_command::()?; + registry.bind_command::()?; + registry.bind_command::()?; + registry.bind_command::()?; + registry.bind_command::()?; + registry.bind_query::()?; + registry.bind_command::()?; + registry.bind_command::()?; + registry.bind_command::()?; + registry.bind_command::()?; + registry.bind_query::()?; registry.bind_command::()?; registry.bind_query::()?; registry.bind_query::()?; @@ -275,3 +303,12 @@ pub fn register(registry: &mut RegistryBuilder) -> cellule_runtime::Result<()> { } #[cfg(test)] mod tests; + +mod candidate_publication; +pub use candidate_publication::audit::NativeCandidateAuditError; +pub use candidate_publication::{ + CandidatePublicationReply, NATIVE_CANDIDATE_BYTES, NativeCandidateProof, + NativeCandidatePublicationError, PublishNativeCandidate, +}; + +pub(crate) mod backup; diff --git a/crates/canopy-server/src/packs/publication/native_candidate.rs b/crates/canopy-server/src/packs/publication/native_candidate.rs new file mode 100644 index 00000000..4d28efa8 --- /dev/null +++ b/crates/canopy-server/src/packs/publication/native_candidate.rs @@ -0,0 +1,224 @@ +//! Generated commit semantics are checked against the private closed catalog. +//! This is preparation, not Ready publication or current editorial authority. +use super::*; +use crate::{ + ObjectId, ObjectKind, + packs::{ + catalog::CatalogReader, + directory::index::IndexError, + metadata::{MetadataError, MetadataLimits, ObjectHeader}, + }, + pulls::{ + candidates::{ + CandidateResult, MergeCandidate, commit_body, + rebase::{Commit, MAX_COMMIT_BYTES, MAX_COMMITS}, + valid_request, + }, + merge::MergeStrategy, + }, +}; +use cellule_ltx::DiskBudget; +use std::path::Path; +use tokio::time::timeout_at; + +#[derive(Debug, thiserror::Error)] +pub enum NativeCandidateVerificationError { + #[error("native candidate preparation is inactive")] + Base(#[from] PreparationBaseError), + #[error("native candidate catalog failed")] + Catalog(#[from] IndexError), + #[error("native candidate metadata failed")] + Metadata(#[from] MetadataError), + #[error("native candidate object read failed")] + Read(#[source] Box), + #[error("native candidate ancestry failed")] + Ancestry(#[source] Box), + #[error("native candidate worker failed")] + Task(#[from] tokio::task::JoinError), + #[error("native candidate intent or commit semantics differ")] + Invalid, +} +impl PreparedCatalog { + /// Verify exact generated merge/squash bytes or every linear rebase rewrite + /// through this privately verified catalog. No SQL object/ancestry mirror or + /// caller-provided body/closure flag is accepted. Future joint publication + /// must bind these facts and recheck current intent, refs, access and owner. + pub async fn verify_candidate_commit( + &self, + candidate: &MergeCandidate, + directory: &Path, + budget: DiskBudget, + limits: MetadataLimits, + ) -> Result<(), NativeCandidateVerificationError> { + let (_, deadline) = self.base.live_lease()?; + timeout_at( + deadline, + self.verify_candidate_commit_inner(candidate, directory, budget, limits), + ) + .await + .map_err(|_| PreparationBaseError::Inactive)? + } + async fn verify_candidate_commit_inner( + &self, + candidate: &MergeCandidate, + directory: &Path, + budget: DiskBudget, + limits: MetadataLimits, + ) -> Result<(), NativeCandidateVerificationError> { + let invalid = NativeCandidateVerificationError::Invalid; + if candidate.actor != self.base.capability().2.actor + || validate_component(&candidate.actor).is_err() + || candidate.number <= 0 + || candidate.created_at_ms < 0 + || !valid_request(&candidate.request) + { + return Err(invalid); + } + let CandidateResult::Ready { + oid: tip, + tree_oid: tree, + } = &candidate.result + else { + return Err(invalid); + }; + let parse = |s: &str| -> Result { + let oid = crate::pulls::merge::oid(s) + .map_err(|_| NativeCandidateVerificationError::Invalid)?; + if oid.format() != self.catalog().format || oid.is_zero() { + return Err(NativeCandidateVerificationError::Invalid); + } + Ok(oid) + }; + let (tip, tree, source, base) = ( + parse(tip)?, + parse(tree)?, + parse(&candidate.request.revision.source_oid)?, + parse(&candidate.request.revision.base_oid)?, + ); + let reader = CatalogReader::open(self.base.indexes(), self.catalog()).await?; + let files = self.base.files(); + let headers = reader + .headers(&[tip, tree, source, base], &*files, &*files) + .await?; + if headers.len() != 4 + || headers + .iter() + .zip([ + ObjectKind::Commit, + ObjectKind::Tree, + ObjectKind::Commit, + ObjectKind::Commit, + ]) + .any(|(h, k)| h.is_none_or(|h| h.object.kind != k)) + { + return Err(invalid); + } + if candidate.request.strategy != MergeStrategy::Rebase { + let body = commit_body(candidate, &hex::encode(tree)); + verify_bytes( + headers[0].ok_or(NativeCandidateVerificationError::Invalid)?, + &body, + )?; + } else { + let mut source = source; + let mut current = tip; + let mut originals = Vec::with_capacity(MAX_COMMITS + 1); + let mut complete = false; + for index in 0..MAX_COMMITS { + self.ensure_live()?; + if originals.contains(&source) { + return Err(NativeCandidateVerificationError::Invalid); + } + originals.push(source); + let original = reader + .lookup(source, &*files, &*files) + .await? + .ok_or(NativeCandidateVerificationError::Invalid)?; + let rewritten = reader + .lookup(current, &*files, &*files) + .await? + .ok_or(NativeCandidateVerificationError::Invalid)?; + if original.entry.header.object.kind != ObjectKind::Commit + || rewritten.entry.header.object.kind != ObjectKind::Commit + { + return Err(NativeCandidateVerificationError::Invalid); + } + let header = rewritten.entry.header; + let metadata = rewritten.source.metadata.clone(); + let edges = + tokio::task::spawn_blocking(move || metadata.edges_after(current, None)) + .await??; + if edges.len() != 2 { + return Err(NativeCandidateVerificationError::Invalid); + } + let tree_edge = edges + .iter() + .find(|e| e.expected_kind == ObjectKind::Tree) + .ok_or(NativeCandidateVerificationError::Invalid)?; + let parent_edge = edges + .iter() + .find(|e| e.expected_kind == ObjectKind::Commit) + .ok_or(NativeCandidateVerificationError::Invalid)?; + if index == 0 && tree_edge.child != tree { + return Err(NativeCandidateVerificationError::Invalid); + } + let owner: crate::git_objects::ReadOwner = self.base.clone(); + let original = files + .body(original, MAX_COMMIT_BYTES, owner) + .await + .map_err(|e| NativeCandidateVerificationError::Read(Box::new(e)))?; + let parsed = + Commit::parse(&original).ok_or(NativeCandidateVerificationError::Invalid)?; + let body = parsed.rewrite( + candidate, + &hex::encode(tree_edge.child), + &hex::encode(parent_edge.child), + ); + verify_bytes(header, &body)?; + source = parse(parsed.parent)?; + current = parent_edge.child; + if current == base { + if originals.contains(&source) { + return Err(NativeCandidateVerificationError::Invalid); + } + originals.push(source); + complete = true; + break; + } + } + if !complete { + return Err(NativeCandidateVerificationError::Invalid); + } + // Walk base history once for this bounded set. Only the remaining + // source anchor may be reachable: accepting an earlier reachable + // source would replay commits that are already on the base branch. + let mut walk = super::ref_proof::ancestry::Walker::new(directory, budget, limits) + .await + .map_err(|e| NativeCandidateVerificationError::Ancestry(Box::new(e)))?; + let reached = walk + .ancestors_within(&reader, &files, &originals, base, &self.base) + .await + .map_err(|e| NativeCandidateVerificationError::Ancestry(Box::new(e)))?; + if reached.last() != Some(&true) || reached[..reached.len() - 1].iter().any(|v| *v) { + return Err(NativeCandidateVerificationError::Invalid); + } + } + self.ensure_live()?; + Ok(()) + } +} +fn verify_bytes(header: ObjectHeader, body: &[u8]) -> Result<(), NativeCandidateVerificationError> { + if body.len() > MAX_COMMIT_BYTES + || header.object.kind != ObjectKind::Commit + || header.object.size != body.len() as u64 + || blake3::hash(body).as_bytes() != &header.object.digest + || crate::object_id(header.object.oid.format(), ObjectKind::Commit, body) + != header.object.oid + { + return Err(NativeCandidateVerificationError::Invalid); + } + // Physical verification bound this OID and canonical header to actual pack + // bytes. Matching both the Git OID and canonical BLAKE3 body digest proves equality without another + // pack download/native subprocess for each rewritten/generated commit. + Ok(()) +} diff --git a/crates/canopy-server/src/packs/publication/native_head.rs b/crates/canopy-server/src/packs/publication/native_head.rs new file mode 100644 index 00000000..c0a3cced --- /dev/null +++ b/crates/canopy-server/src/packs/publication/native_head.rs @@ -0,0 +1,199 @@ +//! Symbolic HEAD changes share the certified ref tree and joint publication CAS. +//! Only the held preparation can certify branch existence or an unborn target. +use super::*; +use crate::packs::ref_state::{RefNameKey, RefStateIndex, RefStateSnapshot}; +use cellule_runtime::InvocationError; +use std::sync::Arc; +use tokio::time::timeout_at; + +pub(in crate::packs::publication) mod publish; +pub use publish::PublishNativeHead; +pub const NATIVE_HEAD_BYTES: u32 = 128 << 10; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HeadRequest { + pub reference: String, + pub expected_generation: i64, +} +impl WireValue for HeadRequest { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + if self.reference.len() > crate::packs::ref_state::MAX_NAME_BYTES + || !crate::default_branch::valid_default_branch(&self.reference) + || !(0..i64::MAX).contains(&self.expected_generation) + { + return Err(CodecError::Invalid("invalid symbolic HEAD request")); + } + e.write_text(&self.reference)?; + e.write_i64(self.expected_generation) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + let value = Self { + reference: d.read_text()?.into(), + expected_generation: d.read_i64()?, + }; + value.encode(&mut BoundedEncoder::new(NATIVE_HEAD_BYTES)?)?; + Ok(value) + } +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NativeHeadProof { + pub certificate: CatalogCertificate, + pub request: HeadRequest, + /// None is a certified refusal, never an empty-ref fallback. + pub refs: Option, +} +impl NativeHeadProof { + fn binding(&self) -> Result<[u8; 32], CodecError> { + Self::payload_binding(&self.request, &self.refs) + } + fn payload_binding( + request: &HeadRequest, + refs: &Option, + ) -> Result<[u8; 32], CodecError> { + let mut e = BoundedEncoder::new(NATIVE_HEAD_BYTES)?; + request.encode(&mut e)?; + refs.encode(&mut e)?; + let mut h = blake3::Hasher::new(); + h.update(b"canopy.symbolic-head-publication.v1\0"); + h.update(&e.finish()); + Ok(*h.finalize().as_bytes()) + } + fn shape(&self) -> Result<(), CodecError> { + let data = self.certificate.data()?; + self.request + .encode(&mut BoundedEncoder::new(NATIVE_HEAD_BYTES)?)?; + if data.compaction + || data.base.refs.is_none() + || data.object_count != 0 + || data.edge_count != 0 + || data.input_count != 0 + || data.input_checkpoint_digest.is_some() + || data.completion_digest.is_some() + || data.refs_digest != Some(self.binding()?) + || self + .refs + .is_some_and(|r| r.operation() != data.token.artifact_operation) + { + return Err(CodecError::Invalid("invalid symbolic HEAD proof")); + } + Ok(()) + } +} +impl WireValue for NativeHeadProof { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + self.shape()?; + self.certificate.encode(e)?; + self.request.encode(e)?; + self.refs.encode(e) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + let value = Self { + certificate: CatalogCertificate::decode(d)?, + request: HeadRequest::decode(d)?, + refs: Option::decode(d)?, + }; + value.shape()?; + Ok(value) + } +} +#[derive(Debug, thiserror::Error)] +pub enum NativeHeadPreparationError { + #[error("symbolic HEAD preparation is inactive")] + Base(#[from] PreparationBaseError), + #[error("symbolic HEAD snapshot failed")] + Snapshot(#[from] crate::packs::ref_state::RefSnapshotError), + #[error("symbolic HEAD ref lookup failed")] + Refs(#[from] crate::packs::ref_state::RefStateError), + #[error("symbolic HEAD range lookup failed")] + Index(#[from] crate::packs::directory::index::IndexError), + #[error("symbolic HEAD encoding failed")] + Codec(#[from] CodecError), + #[error("symbolic HEAD certificate failed")] + Certificate(#[from] CatalogAttestationError), + #[error("symbolic HEAD command preparation failed")] + Command(#[source] Box>), + #[error("symbolic HEAD preparation context differs")] + Context, +} +impl PreparedCatalog { + pub async fn native_head_proof( + &self, + request: HeadRequest, + ) -> Result { + request.encode(&mut BoundedEncoder::new(NATIVE_HEAD_BYTES)?)?; + let (_, deadline) = self.base.live_lease()?; + timeout_at(deadline, async { + if self.object_count() != 0 + || self.edge_count() != 0 + || self.input_count() != 0 + || self.input_checkpoint_digest.is_some() + { + return Err(NativeHeadPreparationError::Context); + } + let base = self.base(); + let store = self.base.indexes().store(); + let old = base + .refs + .ok_or(NativeHeadPreparationError::Context)? + .read(&store) + .await?; + if old.repository != self.token().repository + || old.format != self.catalog().format + || old.generation > base.generation + || old.generation >= i64::MAX as u64 + { + return Err(NativeHeadPreparationError::Context); + } + let mut refs = None; + if old.generation == request.expected_generation as u64 { + let index = RefStateIndex::new(Arc::clone(&store), old.format); + let target = index.read(old.root.clone(), &request.reference).await?; + let live_target = target.is_some_and(|r| r.oid.is_some()); + // The live cursor skips entire tombstoned subtrees. One seek and + // one live record suffice; no scan proportional to repo history. + let mut branches = index.cursor( + old.root.clone(), + Some(RefNameKey::new("refs/heads/")?), + true, + )?; + let has_branches = branches + .next() + .await? + .is_some_and(|r| r.name().starts_with("refs/heads/")); + if live_target || !has_branches { + self.ensure_live()?; + refs = Some( + RefStateSnapshotRoot::upload( + &store, + self.token().artifact_operation, + RefStateSnapshot { + repository: old.repository, + format: old.format, + generation: old.generation + 1, + default_branch: request.reference.clone(), + root: old.root, + }, + ) + .await?, + ); + } + } + let certificate = self + .issue_certificate( + Some(NativeHeadProof::payload_binding(&request, &refs)?), + None, + ) + .await?; + let proof = NativeHeadProof { + certificate, + request, + refs, + }; + proof.shape()?; + self.ensure_live()?; + Ok(proof) + }) + .await + .map_err(|_| PreparationBaseError::Inactive)? + } +} diff --git a/crates/canopy-server/src/packs/publication/native_head/publish.rs b/crates/canopy-server/src/packs/publication/native_head/publish.rs new file mode 100644 index 00000000..d6059c07 --- /dev/null +++ b/crates/canopy-server/src/packs/publication/native_head/publish.rs @@ -0,0 +1,264 @@ +use super::*; +use crate::packs::publication::{ + commands::{authorized, check_pin, fact, load, matched}, + publish::{authenticate, changed, checkpoint, retention_matches}, + sql::*, +}; + +pub(in crate::packs::publication) const SAVED: &str = + "SELECT actor,request_digest,request,result,fact FROM catalog_head_updates WHERE id=?1"; +fn denied(reason: PreparationDenial) -> CommandResult { + CommandResult::Rejected(PublicationReply::Denied(reason)) +} +pub(in crate::packs::publication) fn selected( + sets: &[SqlResultSet], + check: &LeaseCheck, + reply: PublicationReply, +) -> cellule_runtime::Result> { + let Some( + [ + SqlValue::Text(actor), + digest, + SqlValue::Blob(request), + SqlValue::Blob(result), + SqlValue::Blob(fact), + ], + ) = rows(sets)?.first().map(Vec::as_slice) + else { + if rows(sets)?.is_empty() { + return Ok(None); + } + return Err(Error::Command("invalid symbolic HEAD outcome")); + }; + if *actor != check.actor || fixed::<32>(digest)? != check.token.request_digest { + return Ok(None); + } + let mut d = BoundedDecoder::new(result, 512)?; + let saved = PublicationReply::decode(&mut d)?; + d.finish()?; + if saved != reply { + return Ok(None); + } + let PublicationReply::Published(published) = reply else { + return Ok(None); + }; + let mut d = BoundedDecoder::new(request, NATIVE_HEAD_BYTES)?; + let request = HeadRequest::decode(&mut d)?; + d.finish()?; + let mut d = BoundedDecoder::new(fact, 512)?; + let fact = GenerationFact::decode(&mut d)?; + d.finish()?; + if fact + .catalog + .is_none_or(|c| c.repository != check.token.repository) + || fact.refs.is_none() + || fact.generation != published.generation + || fact.certificate != Some(published.certificate_digest) + || published.ref_generation != request.expected_generation as u64 + 1 + { + return Err(Error::Command("symbolic HEAD outcome roots differ")); + } + Ok(Some((request, fact))) +} + +pub struct PublishNativeHead; +impl Command for PublishNativeHead { + const MODULE: &'static str = RepositoryModule::NAME; + const ID: u32 = 54; + const CODEC_VERSION: u32 = 1; + type Input = NativeHeadProof; + type Output = PublicationReply; + fn execute( + context: &mut CommandContext<'_, '_>, + input: Self::Input, + ) -> cellule_runtime::Result> { + let data = input.certificate.data()?; + let check = LeaseCheck { + token: data.token, + actor: data.actor, + }; + recovery::execute(context, &check, recovery::Kind::Head, |context| { + publish(context, input) + }) + } +} +fn publish( + context: &mut CommandContext<'_, '_>, + proof: NativeHeadProof, +) -> cellule_runtime::Result> { + proof.shape()?; + let Some((data, key)) = + authenticate(context, &proof.certificate, Some(proof.binding()?), None)? + else { + return Ok(denied(PreparationDenial::Unauthorized)); + }; + let check = LeaseCheck { + token: data.token, + actor: data.actor.clone(), + }; + let result = publish_authenticated(context, proof, data, key)?; + // Authenticated denial is terminal only for this original bound attempt. + if check.token.owner == context.owner_fence() + && let Some(row) = load(context, check.token)? + && matched(&row, &check) + { + check_pin(context, &row)?; + changed(context.sql(&statement( + "DELETE FROM catalog_operations WHERE id=?1", + vec![blob(check.token.operation)], + ))?)?; + } + Ok(result) +} +fn publish_authenticated( + context: &mut CommandContext<'_, '_>, + proof: NativeHeadProof, + data: super::super::certificate::CertificateData, + key: [u8; 32], +) -> cellule_runtime::Result> { + if authorized( + context, + data.token.repository, + &data.actor, + TokenScope::Admin, + )? != Some(data.catalog.format) + { + return Ok(denied(PreparationDenial::Unauthorized)); + } + let owner = context.sql(&statement( + "SELECT 1 FROM repository_identity WHERE singleton=1 AND owner=?1", + vec![SqlValue::Text(data.actor.clone())], + ))?; + if rows(&owner)?.is_empty() { + return Ok(denied(PreparationDenial::Unauthorized)); + } + let prior = context.sql(&statement(SAVED, vec![blob(data.token.operation)]))?; + if let Some([_, _, SqlValue::Blob(request), SqlValue::Blob(result), _]) = + rows(&prior)?.first().map(Vec::as_slice) + { + let mut e = BoundedEncoder::new(NATIVE_HEAD_BYTES)?; + proof.request.encode(&mut e)?; + if *request != e.finish() { + return Ok(denied(PreparationDenial::Conflict)); + } + let mut d = BoundedDecoder::new(result, 512)?; + let reply = PublicationReply::decode(&mut d)?; + d.finish()?; + if selected( + &prior, + &LeaseCheck { + token: data.token, + actor: data.actor, + }, + reply, + )? + .is_none() + { + return Ok(denied(PreparationDenial::Conflict)); + } + return Ok(CommandResult::Success(reply)); + } + if data.token.owner != context.owner_fence() { + return Ok(denied(PreparationDenial::Stale)); + } + let Some(row) = load(context, data.token)? else { + return Ok(denied(PreparationDenial::Missing)); + }; + if !matched( + &row, + &LeaseCheck { + token: data.token, + actor: data.actor.clone(), + }, + ) { + return Ok(denied(PreparationDenial::Stale)); + } + if row.expires <= now(context.now_ms())? { + return Ok(denied(PreparationDenial::Expired)); + } + check_pin(context, &row)?; + if !retention_matches(context, &data, row.generation, data.catalog.format)? + || fact(context, data.token.repository, data.catalog.format, None)? != data.base + { + return Ok(denied(PreparationDenial::Conflict)); + } + let Some(refs) = proof.refs else { + return Ok(denied(PreparationDenial::Conflict)); + }; + let count = context.sql(&statement( + "SELECT count(*) FROM (SELECT generation FROM catalog_generations LIMIT ?1)", + vec![number(MAX_RETAINED_GENERATIONS)?], + ))?; + let Some([count]) = rows(&count)?.first().map(Vec::as_slice) else { + return Err(Error::Command("missing symbolic HEAD generation count")); + }; + if unsigned(count)? >= MAX_RETAINED_GENERATIONS { + return Ok(denied(PreparationDenial::Capacity)); + } + let Some(missing) = checkpoint(context, &data, &key)? else { + return Ok(denied(PreparationDenial::Conflict)); + }; + let bytes = proof.certificate.bytes()?; + let digest = *blake3::hash(&bytes).as_bytes(); + let generation = data + .base + .generation + .checked_add(1) + .filter(|g| *g <= i64::MAX as u64) + .ok_or(Error::Command("symbolic HEAD generation exhausted"))?; + let reply = PublicationReply::Published(PublishedRefs { + generation, + ref_generation: proof.request.expected_generation as u64 + 1, + certificate_digest: digest, + }); + let fact = GenerationFact { + generation, + catalog: Some(data.catalog), + refs: Some(refs), + certificate: Some(digest), + }; + let mut catalog = BoundedEncoder::new(256)?; + data.catalog.encode(&mut catalog)?; + // Store the descriptor itself, rather than Option framing, in joint roots. + let mut refs_root = BoundedEncoder::new(128)?; + fact.refs + .ok_or(Error::Command("symbolic HEAD root missing"))? + .encode(&mut refs_root)?; + let mut request = BoundedEncoder::new(NATIVE_HEAD_BYTES)?; + proof.request.encode(&mut request)?; + let mut result = BoundedEncoder::new(512)?; + reply.encode(&mut result)?; + let mut result_fact = BoundedEncoder::new(512)?; + fact.encode(&mut result_fact)?; + if row.expires <= now(context.now_ms())? { + return Ok(denied(PreparationDenial::Expired)); + } + // No refusal after the first write. SDK acceptance commits the joint roots, + // original outcome, checkpoint, attempt closure and journal together. + if missing { + changed(context.sql(&statement("UPDATE catalog_operations SET attestation=?1,attestation_digest=?2 WHERE id=?3 AND attestation IS NULL", vec![blob(&bytes),blob(digest),blob(data.token.operation)]))?)?; + changed(context.sql(&statement("UPDATE catalog_leases SET attestation=?1,attestation_digest=?2 WHERE incarnation=?3 AND admission_sequence=?4 AND attestation IS NULL", vec![blob(&bytes),blob(digest),blob(data.token.owner.incarnation.as_bytes()),number(data.token.attempt)?]))?)?; + } + changed(context.sql(&statement( + "INSERT INTO catalog_generations(generation,catalog,certificate,refs) VALUES(?1,?2,?3,?4)", + vec![ + number(generation)?, + blob(catalog.finish()), + blob(digest), + blob(refs_root.finish()), + ], + ))?)?; + changed(context.sql(&statement( + "UPDATE catalog_state SET generation=?1 WHERE singleton=1 AND generation=?2", + vec![number(generation)?, number(data.base.generation)?], + ))?)?; + changed(context.sql(&statement( + "UPDATE ref_generation SET generation=?1,default_branch=?2 WHERE singleton=1", + vec![ + number(proof.request.expected_generation as u64 + 1)?, + SqlValue::Text(proof.request.reference), + ], + ))?)?; + changed(context.sql(&statement("INSERT INTO catalog_head_updates(id,actor,request_digest,request,result,fact) VALUES(?1,?2,?3,?4,?5,?6)", vec![blob(data.token.operation),SqlValue::Text(data.actor),blob(data.token.request_digest),blob(request.finish()),blob(result.finish()),blob(result_fact.finish())]))?)?; + Ok(CommandResult::Success(reply)) +} diff --git a/crates/canopy-server/src/packs/publication/native_merge.rs b/crates/canopy-server/src/packs/publication/native_merge.rs new file mode 100644 index 00000000..40c85fd3 --- /dev/null +++ b/crates/canopy-server/src/packs/publication/native_merge.rs @@ -0,0 +1,700 @@ +//! Private native ref preparation and one owner-fenced reviewed-merge transaction. +//! Transport DTOs grant no authority; the final receiver authenticates every +//! proposed fact before evaluating current editorial and branch predicates. +use super::*; +use super::{ + commands::{check_pin, fact, load, matched}, + publish::{authenticate, changed, checkpoint, retention_matches}, + ref_observation::RefFact, + sql::*, +}; +use crate::{ + PushPlan, + packs::{ + metadata::MetadataLimits, + ref_state::{RefSnapshotError, RefStateError, RefStateIndex}, + }, + pulls::merge::{ + MergeOutcome, MergeRecord, MergeStrategy, + command::{MergeInput, request_binding}, + }, +}; +use cellule_ltx::DiskBudget; +use cellule_runtime::{InvocationError, primitives::sql::SqlCell}; +use std::path::Path; +use tokio::time::timeout_at; + +pub(super) mod audit; + +pub const NATIVE_MERGE_BYTES: u32 = 256 << 10; + +#[derive(Clone, Debug)] +struct Transition { + plan: PushPlan, + ancestry: Vec, + refs: Option, + ref_generation: Option, + audit: Option, + candidate: Option, +} + +/// Exact request, native ref facts and conditional snapshot. Only a privately +/// constructed PreparedCatalog can issue its MAC. No serving-only observation +/// or client-selected root can create write authority. +#[derive(Clone, Debug)] +pub struct NativeMergeProof { + certificate: CatalogCertificate, + input: MergeInput, + selection: RefSelection, + transition: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum NativeMergePreparationError { + #[error("native generated candidate verification failed")] + Candidate(#[from] NativeCandidateVerificationError), + #[error("native candidate audit failed")] + CandidateAudit(#[from] NativeCandidateAuditError), + #[error("native merge audit root failed")] + Root(#[from] crate::packs::InputRootError), + #[error("native merge preparation is inactive")] + Base(#[from] PreparationBaseError), + #[error("native merge encoding failed")] + Codec(#[from] CodecError), + #[error("native merge metadata capability failed")] + Capability(#[from] Error), + #[error("native merge editorial query failed")] + Query(#[source] Box>>), + #[error("native merge refs failed")] + Refs(#[from] RefStateError), + #[error("native merge snapshot failed")] + Snapshot(#[from] RefSnapshotError), + #[error("native merge transition failed")] + Transition(#[from] RefSnapshotPreparationError), + #[error("native merge ancestry failed")] + Ancestry(#[from] RefProofError), + #[error("native merge attestation failed")] + Attestation(#[from] CatalogAttestationError), + #[error("native merge context or strategy differs")] + Context, + #[error("native merge command preparation failed")] + Command(#[source] Box>), +} + +impl NativeMergeProof { + fn shape(&self) -> Result<(), CodecError> { + let data = self.certificate.data()?; + self.input.encode(&mut BoundedEncoder::new(4096)?)?; + if data.compaction + || data.input_count != 0 + || data.input_checkpoint_digest.is_some() + || data.base.refs.is_none() + || data.actor != self.input.actor + || self.selection.repository != data.token.repository + || self.selection.actor.as_deref() != Some(&self.input.actor) + || self.selection.proof.is_some() + || self.selection.facts.len() > 3 + { + return Err(CodecError::Invalid("invalid native merge scope")); + } + self.selection + .encode(&mut BoundedEncoder::new(NATIVE_MERGE_BYTES)?)?; + if self.selection.facts.iter().any(|f| { + f.state + .as_ref() + .and_then(|s| s.oid) + .is_some_and(|o| o.format() != data.catalog.format) + }) { + return Err(CodecError::Invalid("native merge ref format")); + } + if let Some(t) = &self.transition { + if let Some(audit) = t.audit { + audit.validate(crate::packs::input_artifact::INPUT_ROOT_BYTES)?; + } + super::ref_proof::shape(&t.plan, data.catalog.format) + .map_err(|_| CodecError::Invalid("native merge plan"))?; + super::ref_proof::binding(&t.plan, &t.ancestry)?; + if t.candidate.is_some() != (self.input.request.strategy != MergeStrategy::FastForward) + || t.plan.actor != self.input.actor + || t.plan.updates.len() != 1 + || t.plan.updates[0].new_oid.is_none() + || t.plan.updates[0] + .expected + .as_ref() + .and_then(|s| s.oid) + .is_none() + || t.refs + .is_some_and(|r| r.operation() != data.token.artifact_operation) + || t.audit + .is_some_and(|r| r.operation != data.token.artifact_operation) + || t.ref_generation.is_some() != t.refs.is_some() + || t.ref_generation + .is_some_and(|g| g == 0 || g > i64::MAX as u64) + || t.audit.is_some() != t.refs.is_some() + || t.refs.is_some() != super::ref_proof::proven(&t.ancestry, 0) + { + return Err(CodecError::Invalid("invalid native merge transition")); + } + } + Ok(()) + } + fn binding(&self) -> Result<[u8; 32], CodecError> { + Self::payload_binding(&self.input, &self.selection, &self.transition) + } + fn payload_binding( + input: &MergeInput, + selection: &RefSelection, + transition: &Option, + ) -> Result<[u8; 32], CodecError> { + let mut e = BoundedEncoder::new(4096)?; + input.encode(&mut e)?; + let request = *blake3::hash(&e.finish()).as_bytes(); + let mut h = blake3::Hasher::new(); + h.update(b"canopy.native-reviewed-merge.v1\0"); + h.update(&selection.binding(request)?); + h.update(&[u8::from(transition.is_some())]); + if let Some(t) = transition { + h.update(&super::ref_proof::binding(&t.plan, &t.ancestry)?); + let mut e = BoundedEncoder::new(512)?; + t.refs.encode(&mut e)?; + t.ref_generation.encode(&mut e)?; + t.audit.encode(&mut e)?; + t.candidate.encode(&mut e)?; + h.update(&e.finish()); + } + Ok(*h.finalize().as_bytes()) + } +} +impl WireValue for NativeMergeProof { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + self.shape()?; + self.certificate.encode(e)?; + self.input.encode(e)?; + self.selection.encode(e)?; + e.write_bool(self.transition.is_some())?; + if let Some(t) = &self.transition { + t.plan.encode(e)?; + e.write_bytes(&t.ancestry)?; + t.refs.encode(e)?; + t.ref_generation.encode(e)?; + t.audit.encode(e)?; + t.candidate.encode(e)?; + } + Ok(()) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + let value = Self { + certificate: CatalogCertificate::decode(d)?, + input: MergeInput::decode(d)?, + selection: RefSelection::decode(d)?, + transition: if d.read_bool()? { + Some(Transition { + plan: PushPlan::decode(d)?, + ancestry: d.read_bytes()?.to_vec(), + refs: Option::::decode(d)?, + ref_generation: Option::::decode(d)?, + audit: Option::::decode(d)?, + candidate: Option::::decode(d)?, + }) + } else { + None + }, + }; + value.shape()?; + Ok(value) + } +} +impl PreparedCatalog { + pub(crate) async fn native_merge_proof( + &self, + input: MergeInput, + directory: &Path, + budget: DiskBudget, + limits: MetadataLimits, + ) -> Result { + let (_, deadline) = self.base.live_lease()?; + timeout_at( + deadline, + Box::pin(async { + input.encode(&mut BoundedEncoder::new(4096)?)?; + if self.input_count() != 0 + || self.input_checkpoint_digest.is_some() + || input.actor != self.base.capability().2.actor + { + return Err(NativeMergePreparationError::Context); + } + let (client, target, _) = self.base.capability(); + let sql = SqlCell::::new(client.clone(), target.clone())?; + let selected = sql + .query( + None, + statement( + "SELECT source_ref,base_ref FROM pull_requests WHERE number=?1", + vec![SqlValue::Integer(input.number)], + ), + ) + .await + .map_err(|e| NativeMergePreparationError::Query(Box::new(e)))?; + let store = self.base.indexes().store(); + let snapshot = self + .base() + .refs + .ok_or(NativeMergePreparationError::Context)? + .read(&store) + .await?; + if snapshot.repository != self.token().repository + || snapshot.format != self.catalog().format + { + return Err(NativeMergePreparationError::Context); + } + let refs = RefStateIndex::new(store.clone(), snapshot.format); + let mut selection = RefSelection { + repository: self.token().repository, + actor: Some(input.actor.clone()), + facts: Vec::new(), + proof: None, + }; + let names = match rows(&selected.output)?.first().map(Vec::as_slice) { + Some([SqlValue::Text(source), SqlValue::Text(base)]) => { + Some((source.clone(), base.clone())) + } + None => None, + _ => return Err(NativeMergePreparationError::Context), + }; + let mut transition = None; + if let Some((source, base)) = names { + let source_state = refs.read(snapshot.root.clone(), &source).await?; + let base_state = refs.read(snapshot.root.clone(), &base).await?; + selection.facts.push(RefFact { + name: source.clone(), + state: source_state.clone(), + }); + if source != base { + selection.facts.push(RefFact { + name: base.clone(), + state: base_state.clone(), + }); + } + let mut candidate_root = None; + let target_oid = if input.request.strategy == MergeStrategy::FastForward { + source_state.and_then(|s| s.oid) + } else { + let candidate_id = uuid::Uuid::parse_str( + input + .request + .candidate_id + .as_deref() + .ok_or(NativeMergePreparationError::Context)?, + ) + .map_err(|_| NativeMergePreparationError::Context)?; + let saved = sql + .query( + None, + statement( + super::candidate_publication::audit::SAVED, + vec![blob(candidate_id.as_bytes())], + ), + ) + .await + .map_err(|e| NativeMergePreparationError::Query(Box::new(e)))?; + if let Some((candidate, root)) = + super::candidate_publication::audit::ready_for_merge( + &saved.output, + &input, + )? + { + super::candidate_publication::audit::verify_ready( + &store, &candidate, root, + ) + .await?; + self.verify_candidate_commit( + &candidate, + directory, + budget.clone(), + limits, + ) + .await?; + let name = candidate.fetch_ref(); + let state = refs.read(snapshot.root.clone(), &name).await?; + let crate::pulls::candidates::CandidateResult::Ready { oid, .. } = + &candidate.result + else { + return Err(NativeMergePreparationError::Context); + }; + let oid = crate::pulls::merge::oid(oid)?; + selection.facts.push(RefFact { + name, + state: state.clone(), + }); + if state + == Some(crate::RefExpectation { + oid: Some(oid), + version: 1, + }) + { + candidate_root = Some(root); + Some(oid) + } else { + None + } + } else { + None + } + }; + selection.facts.sort_by(|a, b| a.name.cmp(&b.name)); + if let (Some(source_oid), Some(base_state)) = (target_oid, base_state) + && base_state.oid.is_some() + && base_state.oid != Some(source_oid) + { + let plan = PushPlan { + actor: input.actor.clone(), + updates: vec![crate::RefUpdate { + name: base, + expected: Some(base_state), + new_oid: Some(source_oid), + }], + }; + let (plan, ancestry) = self + .required_ref_evidence(plan, directory, budget, limits) + .await?; + let proposed = if super::ref_proof::proven(&ancestry, 0) { + Some(self.prepare_ref_snapshot(&plan).await?.snapshot()) + } else { + None + }; + let (audit, ref_generation) = match proposed { + Some(refs) => { + let (audit, generation) = audit::prepare( + self, + &input, + &plan.updates[0].name, + refs, + plan.updates[0] + .new_oid + .ok_or(NativeMergePreparationError::Context)?, + candidate_root, + ) + .await?; + (Some(audit), Some(generation)) + } + None => (None, None), + }; + transition = Some(Transition { + plan, + ancestry, + refs: proposed, + ref_generation, + audit, + candidate: candidate_root, + }); + } + } + let binding = NativeMergeProof::payload_binding(&input, &selection, &transition)?; + let proof = NativeMergeProof { + certificate: self.issue_certificate(Some(binding), None).await?, + input, + selection, + transition, + }; + proof.encode(&mut BoundedEncoder::new(NATIVE_MERGE_BYTES)?)?; + self.ensure_live()?; + Ok(proof) + }), + ) + .await + .map_err(|_| PreparationBaseError::Inactive)? + } +} + +pub struct PublishReviewedMerge; +impl Command for PublishReviewedMerge { + const MODULE: &'static str = RepositoryModule::NAME; + const ID: u32 = 9; + const CODEC_VERSION: u32 = 8; + type Input = NativeMergeProof; + type Output = MergeOutcome; + fn execute( + context: &mut CommandContext<'_, '_>, + input: Self::Input, + ) -> cellule_runtime::Result> { + let data = input.certificate.data()?; + let check = LeaseCheck { + token: data.token, + actor: data.actor, + }; + recovery::execute(context, &check, recovery::Kind::Merge, |context| { + publish(context, input) + }) + } +} +fn denied(outcome: MergeOutcome) -> CommandResult { + CommandResult::Rejected(outcome) +} + +fn publish( + context: &mut CommandContext<'_, '_>, + proof: NativeMergeProof, +) -> cellule_runtime::Result> { + proof.shape()?; + let Some((data, key)) = + authenticate(context, &proof.certificate, Some(proof.binding()?), None)? + else { + return Ok(denied(MergeOutcome::Conflict)); + }; + let check = LeaseCheck { + token: data.token, + actor: data.actor.clone(), + }; + let result = publish_authenticated(context, proof, data, key)?; + // Every authenticated result is terminal for this exact command. Close its + // own binding atomically with the recovery phase, including denials and + // application UUID replays. Never close a successor or an unauthenticated + // proposal. The independent pin remains until typed terminal retirement. + if check.token.owner == context.owner_fence() + && let Some(row) = load(context, check.token)? + && matched(&row, &check) + { + check_pin(context, &row)?; + changed(context.sql(&statement( + "DELETE FROM catalog_operations WHERE id=?1", + vec![blob(check.token.operation)], + ))?)?; + } + Ok(result) +} +fn publish_authenticated( + context: &mut CommandContext<'_, '_>, + proof: NativeMergeProof, + data: super::certificate::CertificateData, + key: [u8; 32], +) -> cellule_runtime::Result> { + let input = proof.input; + let role = crate::access::decode_access(&context.sql(&SqlBatch { + statements: vec![crate::access::access_statement(&input.actor)], + })?)?; + let Some(role) = role else { + return Ok(denied(MergeOutcome::NotFound)); + }; + if role < TokenScope::Write { + return Ok(denied(MergeOutcome::Forbidden)); + } + let id = uuid::Uuid::parse_str(&input.request.id) + .map_err(|_| Error::Command("invalid merge UUID"))?; + let binding = request_binding(&input); + let previous = context.sql(&statement( + "SELECT binding,id,pull_number,oid,merged_ms,pull_version,source_oid,source_version,base_oid,base_version FROM pull_merges WHERE id=?1", + vec![blob(id.as_bytes())], + ))?; + if let Some(row) = rows(&previous)?.first() { + let Some(SqlValue::Blob(old)) = row.first() else { + return Err(Error::Command("invalid merge binding")); + }; + if *old != binding { + return Ok(denied(MergeOutcome::Conflict)); + } + return Ok(CommandResult::Success(MergeOutcome::Applied { + merge: crate::pulls::merge::record(&row[1..])?, + })); + } + if data.token.owner != context.owner_fence() { + return Ok(denied(MergeOutcome::Conflict)); + } + let Some(row) = load(context, data.token)? else { + return Ok(denied(MergeOutcome::Conflict)); + }; + if !matched( + &row, + &LeaseCheck { + token: data.token, + actor: data.actor.clone(), + }, + ) || row.expires <= now(context.now_ms())? + { + return Ok(denied(MergeOutcome::Conflict)); + } + check_pin(context, &row)?; + if !retention_matches(context, &data, row.generation, data.catalog.format)? + || fact(context, data.token.repository, data.catalog.format, None)? != data.base + { + return Ok(denied(MergeOutcome::Conflict)); + } + let generated = if input.request.strategy != MergeStrategy::FastForward { + let Some(transition) = proof.transition.as_ref() else { + return Ok(denied(MergeOutcome::Conflict)); + }; + let candidate_id = uuid::Uuid::parse_str( + input + .request + .candidate_id + .as_deref() + .ok_or(Error::Command("missing merge candidate"))?, + ) + .map_err(|_| Error::Command("merge candidate UUID"))?; + let saved = context.sql(&statement( + super::candidate_publication::audit::SAVED, + vec![blob(candidate_id.as_bytes())], + ))?; + let Some((candidate, root)) = + super::candidate_publication::audit::ready_for_merge(&saved, &input)? + else { + return Ok(denied(MergeOutcome::Conflict)); + }; + if transition.candidate != Some(root) { + return Ok(denied(MergeOutcome::Conflict)); + } + let crate::pulls::candidates::CandidateResult::Ready { oid, .. } = &candidate.result else { + return Ok(denied(MergeOutcome::Conflict)); + }; + let oid = crate::pulls::merge::oid(oid)?; + if !proof.selection.facts.iter().any(|fact| { + fact.name == candidate.fetch_ref() + && fact.state + == Some(crate::RefExpectation { + oid: Some(oid), + version: 1, + }) + }) { + return Ok(denied(MergeOutcome::Conflict)); + } + Some(oid) + } else { + None + }; + let reviewed = match crate::pulls::merge::reviewed_native_update( + context, + &input, + &proof.selection, + generated, + )? { + Ok(value) => value, + Err(outcome) => return Ok(denied(outcome)), + }; + let Some(transition) = proof.transition else { + return Ok(denied(MergeOutcome::Conflict)); + }; + let update = reviewed.update(); + if !reviewed.authorizes(&transition.plan.updates[0]) { + return Ok(denied(MergeOutcome::Conflict)); + } + if !super::ref_proof::proven(&transition.ancestry, 0) { + return Ok(denied(MergeOutcome::NotFastForward)); + } + let Some(refs) = transition.refs else { + return Ok(denied(MergeOutcome::Conflict)); + }; + let Some(ref_generation) = transition.ref_generation else { + return Ok(denied(MergeOutcome::Conflict)); + }; + let Some(audit) = transition.audit else { + return Ok(denied(MergeOutcome::Conflict)); + }; + let mut encoded_audit = BoundedEncoder::new(128)?; + audit.encode(&mut encoded_audit)?; + let encoded_audit = encoded_audit.finish(); + let policy = context.sql(&SqlBatch { + statements: vec![crate::branch_rules::policy_statement_with_ancestry( + update, true, + )], + })?; + if crate::branch_rules::decode_policy(&policy)? + .is_some_and(|p| !p.allows_reviewed(update, &reviewed)) + { + return Ok(denied(MergeOutcome::BranchPolicy)); + } + let count = context.sql(&statement( + "SELECT count(*) FROM (SELECT generation FROM catalog_generations LIMIT ?1)", + vec![number(MAX_RETAINED_GENERATIONS)?], + ))?; + let Some([count]) = rows(&count)?.first().map(Vec::as_slice) else { + return Err(Error::Command("missing merge generation count")); + }; + if unsigned(count)? >= MAX_RETAINED_GENERATIONS { + return Ok(denied(MergeOutcome::Conflict)); + } + let Some(missing) = checkpoint(context, &data, &key)? else { + return Ok(denied(MergeOutcome::Conflict)); + }; + let bytes = proof.certificate.bytes()?; + let digest = *blake3::hash(&bytes).as_bytes(); + let generation = data + .base + .generation + .checked_add(1) + .filter(|g| *g <= i64::MAX as u64) + .ok_or(Error::Command("merge generation exhausted"))?; + let source = update + .new_oid + .ok_or(Error::Command("missing merge source"))?; + let base = update + .expected + .as_ref() + .and_then(|s| s.oid) + .ok_or(Error::Command("missing merge base"))?; + let result = MergeOutcome::Applied { + merge: MergeRecord { + id: input.request.id.clone(), + number: input.number, + oid: hex::encode(source), + merged_at_ms: input.issued_at_ms, + revision: input.request.revision.clone(), + }, + }; + result.encode(&mut BoundedEncoder::new(512)?)?; + let mut catalog = BoundedEncoder::new(256)?; + data.catalog.encode(&mut catalog)?; + let mut encoded_refs = BoundedEncoder::new(128)?; + refs.encode(&mut encoded_refs)?; + if row.expires <= now(context.now_ms())? { + return Ok(denied(MergeOutcome::Conflict)); + } + // Every later error rolls back roots, pull/UUID state, checkpoint, operation + // consumption and the exact recovery journal together. No later rejection. + if missing { + changed(context.sql(&statement("UPDATE catalog_operations SET attestation=?1,attestation_digest=?2 WHERE id=?3 AND attestation IS NULL", vec![blob(&bytes),blob(digest),blob(data.token.operation)]))?)?; + changed(context.sql(&statement("UPDATE catalog_leases SET attestation=?1,attestation_digest=?2 WHERE incarnation=?3 AND admission_sequence=?4 AND attestation IS NULL", vec![blob(&bytes),blob(digest),blob(data.token.owner.incarnation.as_bytes()),number(data.token.attempt)?]))?)?; + } + changed(context.sql(&statement( + "INSERT INTO catalog_generations(generation,catalog,certificate,refs) VALUES(?1,?2,?3,?4)", + vec![ + number(generation)?, + blob(catalog.finish()), + blob(digest), + blob(encoded_refs.finish()), + ], + ))?)?; + changed(context.sql(&statement( + "UPDATE catalog_state SET generation=?1 WHERE singleton=1 AND generation=?2", + vec![number(generation)?, number(data.base.generation)?], + ))?)?; + changed(context.sql(&statement( + "UPDATE ref_generation SET generation=?1 WHERE singleton=1", + vec![number(ref_generation)?], + ))?)?; + changed(context.sql(&statement("UPDATE pull_requests SET state='merged',version=version+1,updated_ms=max(updated_ms,?2) WHERE number=?1 AND state='open' AND version=?3 AND version<9223372036854775807", vec![SqlValue::Integer(input.number),SqlValue::Integer(input.issued_at_ms),SqlValue::Integer(input.request.revision.pull_version)]))?)?; + changed(context.sql(&statement("INSERT INTO pull_merges(id,binding,pull_number,oid,merged_ms,pull_version,source_oid,source_version,base_oid,base_version,publication,strategy,candidate_id) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)", vec![blob(id.as_bytes()),blob(binding),SqlValue::Integer(input.number),blob(source),SqlValue::Integer(input.issued_at_ms),SqlValue::Integer(input.request.revision.pull_version),blob(crate::pulls::merge::oid(&input.request.revision.source_oid)?),SqlValue::Integer(input.request.revision.source_version),blob(base),SqlValue::Integer(input.request.revision.base_version),blob(encoded_audit), SqlValue::Text(match input.request.strategy {MergeStrategy::FastForward=>"fast_forward",MergeStrategy::MergeCommit=>"merge_commit",MergeStrategy::Squash=>"squash",MergeStrategy::Rebase=>"rebase"}.into()), input.request.candidate_id.as_ref().map(|id|uuid::Uuid::parse_str(id).map(|id|blob(id.as_bytes())).map_err(|_|Error::Command("candidate UUID"))).transpose()?.unwrap_or(SqlValue::Null)]))?)?; + changed(context.sql(&statement( + "DELETE FROM catalog_operations WHERE id=?1", + vec![blob(data.token.operation)], + ))?)?; + Ok(CommandResult::Success(result)) +} + +pub(super) async fn backup_graph( + proof: &NativeMergeProof, + seed: &[u8; 32], + inventory: &mut crate::packs::backup::Inventory<'_>, +) -> crate::packs::directory::index::WalkResult<()> { + super::backup::catalog_certificate(&proof.certificate, seed, inventory).await?; + if let Some(t) = &proof.transition { + if let Some(root) = t.refs { + inventory.refs(root).await?; + } + if let Some(root) = t.audit { + audit::backup_graph(root, inventory).await?; + } + if let Some(root) = t.candidate { + super::candidate_publication::audit::backup_graph(root, inventory).await?; + } + } + Ok(()) +} diff --git a/crates/canopy-server/src/packs/publication/native_merge/audit.rs b/crates/canopy-server/src/packs/publication/native_merge/audit.rs new file mode 100644 index 00000000..c6e7bde6 --- /dev/null +++ b/crates/canopy-server/src/packs/publication/native_merge/audit.rs @@ -0,0 +1,327 @@ +//! Permanent selected merge metadata, independent of transient preparation pins. +//! The existing input root retains typed catalog/ref edges, not an SDK request +//! body. Selection from an immutable UUID row precedes all artifact traversal. +use super::*; +use crate::packs::{ + catalog::{CatalogIndexes, CatalogReader, CatalogSnapshot}, + directory::index::IndexError, + input_artifact::{INPUT_ROOT_BYTES, StoredInputRoot}, +}; +use canopy_object_storage::artifact::{ArtifactKind, ArtifactStore}; +use std::sync::Arc; + +const DOMAIN: &[u8] = b"canopy.native-reviewed-merge-audit.v2\0"; +pub(super) const SAVED: &str = "SELECT binding,id,pull_number,oid,merged_ms,pull_version,source_oid,source_version,base_oid,base_version,publication,strategy,candidate_id FROM pull_merges WHERE id=?1"; + +#[derive(Debug, thiserror::Error)] +pub enum NativeMergeAuditError { + #[error("merge audit codec failed")] + Codec(#[from] CodecError), + #[error("merge audit root failed")] + Root(#[from] crate::packs::InputRootError), + #[error("merge audit catalog failed")] + Catalog(#[from] IndexError), + #[error("merge audit ref snapshot failed")] + Snapshot(#[from] RefSnapshotError), + #[error("merge audit ref lookup failed")] + Refs(#[from] RefStateError), + #[error("selected merge audit context differs")] + Context, +} +struct Audit { + input: MergeInput, + base_ref: String, + catalog: StoredCatalog, + refs: RefStateSnapshotRoot, + ref_generation: u64, + target: crate::ObjectId, + candidate: Option, +} +impl Audit { + fn shape(&self) -> Result<(), CodecError> { + self.input.encode(&mut BoundedEncoder::new(4096)?)?; + if self.candidate.is_some() != (self.input.request.strategy != MergeStrategy::FastForward) + || self.target.format() != self.catalog.format + || (self.input.request.strategy == MergeStrategy::FastForward + && hex::encode(self.target) != self.input.request.revision.source_oid) + || !self.base_ref.starts_with("refs/heads/") + || self.base_ref.len() > crate::packs::ref_state::MAX_NAME_BYTES + || !crate::refs::valid_ref_name(&self.base_ref) + || self.ref_generation == 0 + || self.ref_generation > i64::MAX as u64 + || [ + &self.input.request.revision.source_oid, + &self.input.request.revision.base_oid, + ] + .iter() + .any(|oid| { + crate::pulls::merge::oid(oid).is_err() + || oid.len() != self.catalog.format.bytes() * 2 + }) + { + return Err(CodecError::Invalid("native merge audit scope")); + } + self.catalog.encode(&mut BoundedEncoder::new(256)?)?; + self.refs.encode(&mut BoundedEncoder::new(128)?) + } + fn outcome(&self) -> MergeOutcome { + MergeOutcome::Applied { + merge: MergeRecord { + id: self.input.request.id.clone(), + number: self.input.number, + oid: hex::encode(self.target), + merged_at_ms: self.input.issued_at_ms, + revision: self.input.request.revision.clone(), + }, + } + } +} +impl WireValue for Audit { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + self.shape()?; + e.write_bytes(DOMAIN)?; + self.input.encode(e)?; + e.write_text(&self.base_ref)?; + self.catalog.encode(e)?; + self.refs.encode(e)?; + e.write_u64(self.ref_generation)?; + e.write_bytes(self.target.as_ref())?; + self.candidate.encode(e) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + if d.read_bytes()? != DOMAIN { + return Err(CodecError::Invalid("merge audit purpose")); + } + let value = Self { + input: MergeInput::decode(d)?, + base_ref: d.read_text()?.to_owned(), + catalog: StoredCatalog::decode(d)?, + refs: RefStateSnapshotRoot::decode(d)?, + ref_generation: d.read_u64()?, + target: crate::ObjectId::try_from(d.read_bytes()?) + .map_err(|_| CodecError::Invalid("merge target OID"))?, + candidate: Option::::decode(d)?, + }; + value.shape()?; + Ok(value) + } +} + +pub(super) async fn prepare( + prepared: &PreparedCatalog, + input: &MergeInput, + base_ref: &str, + refs: RefStateSnapshotRoot, + target: crate::ObjectId, + candidate: Option, +) -> Result<(StoredInputRoot, u64), NativeMergePreparationError> { + let store = prepared.base.indexes().store(); + let record = Audit { + input: input.clone(), + base_ref: base_ref.to_owned(), + catalog: prepared.catalog(), + refs, + ref_generation: refs.read(&store).await?.generation, + target, + candidate, + }; + let generation = record.ref_generation; + Ok(( + StoredInputRoot::upload( + &store, + prepared.token().artifact_operation, + &record, + INPUT_ROOT_BYTES, + ) + .await?, + generation, + )) +} + +pub(in crate::packs::publication) fn statement(outcome: &MergeOutcome) -> SqlStatement { + let parameter = match outcome { + MergeOutcome::Applied { merge } => uuid::Uuid::parse_str(&merge.id) + .ok() + .map_or(SqlValue::Null, |id| blob(id.as_bytes())), + _ => SqlValue::Null, + }; + SqlStatement { + sql: SAVED.into(), + parameters: vec![parameter], + } +} +/// Replays may select a prior attempt's permanent root. Never substitute the +/// new attempt's proposal, generation or creating namespace for that result. +pub(in crate::packs::publication) fn selected( + result: &[SqlResultSet], + outcome: &MergeOutcome, + actor: &str, +) -> Result, Error> { + let MergeOutcome::Applied { merge } = outcome else { + return Ok(None); + }; + let Some(row) = rows(result)?.first() else { + return Ok(None); + }; + if row.len() != 13 { + return Err(Error::Command("invalid selected merge audit")); + } + let (SqlValue::Blob(binding), SqlValue::Blob(publication)) = (&row[0], &row[10]) else { + return Err(Error::Command("invalid merge audit binding")); + }; + let expected = MergeInput { + actor: actor.into(), + number: merge.number, + issued_at_ms: merge.merged_at_ms, + request: crate::pulls::merge::MergeRequest { + id: merge.id.clone(), + revision: merge.revision.clone(), + strategy: match &row[11] { + SqlValue::Text(value) => match value.as_str() { + "fast_forward" => MergeStrategy::FastForward, + "merge_commit" => MergeStrategy::MergeCommit, + "squash" => MergeStrategy::Squash, + "rebase" => MergeStrategy::Rebase, + _ => return Err(Error::Command("merge strategy")), + }, + _ => return Err(Error::Command("merge strategy type")), + }, + candidate_id: match &row[12] { + SqlValue::Null => None, + SqlValue::Blob(value) => Some( + uuid::Uuid::from_slice(value) + .map_err(|_| Error::Command("merge candidate UUID"))? + .to_string(), + ), + _ => return Err(Error::Command("merge candidate type")), + }, + }, + }; + if *binding != request_binding(&expected) || crate::pulls::merge::record(&row[1..10])? != *merge + { + return Ok(None); + } + let mut d = BoundedDecoder::new(publication, 128)?; + let root = StoredInputRoot::decode(&mut d)?; + d.finish()?; + root.validate(INPUT_ROOT_BYTES)?; + Ok(Some(root)) +} + +async fn verify( + store: &ArtifactStore, + root: StoredInputRoot, + outcome: &MergeOutcome, + check: &LeaseCheck, +) -> Result<(Audit, CatalogSnapshot), NativeMergeAuditError> { + let audit: Audit = root.read(store, INPUT_ROOT_BYTES).await?; + if audit.input.actor != check.actor + || audit.outcome() != *outcome + || audit.catalog.repository != check.token.repository + || audit.refs.operation() != root.operation + { + return Err(NativeMergeAuditError::Context); + } + let indexes = Arc::new(CatalogIndexes::new( + Arc::new(store.clone()), + audit.catalog.format, + )); + // At most 48 range roots and one source root. Descendant descriptors stay + // reachable through the permanent audit; this performs no provider deletion + // and does not rescan all historical objects or reprove their closure. + let reader = CatalogReader::open(indexes, audit.catalog).await?; + let snapshot = CatalogSnapshot::download(store, reader.stored()).await?; + let refs = audit.refs.read(store).await?; + if refs.format != audit.catalog.format || refs.generation != audit.ref_generation { + return Err(NativeMergeAuditError::Context); + } + let state = RefStateIndex::new(Arc::new(store.clone()), refs.format) + .read(refs.root, &audit.base_ref) + .await?; + if state + != Some(crate::RefExpectation { + oid: Some(audit.target), + version: audit.input.request.revision.base_version + 1, + }) + { + return Err(NativeMergeAuditError::Context); + } + if let Some(candidate) = audit.candidate { + let selected = uuid::Uuid::parse_str( + audit + .input + .request + .candidate_id + .as_deref() + .ok_or(NativeMergeAuditError::Context)?, + ) + .map_err(|_| NativeMergeAuditError::Context)?; + let ready: super::super::candidate_publication::audit::Audit = + candidate.read(store, INPUT_ROOT_BYTES).await?; + if ready.candidate.request.id != selected.to_string() + || ready.candidate.number != audit.input.number + || ready.candidate.request.strategy != audit.input.request.strategy + || ready.candidate.request.revision != audit.input.request.revision + || !matches!(&ready.candidate.result,crate::pulls::candidates::CandidateResult::Ready{oid,..} if *oid==hex::encode(audit.target)) + || ready.catalog.repository != store.repository() + { + return Err(NativeMergeAuditError::Context); + } + } + Ok((audit, snapshot)) +} +pub(in crate::packs::publication) async fn closed_graph( + store: &ArtifactStore, + root: StoredInputRoot, + outcome: &MergeOutcome, + check: &LeaseCheck, + hash: &mut blake3::Hasher, +) -> Result<(), RootRecoveryError> { + let (audit, snapshot) = verify(store, root, outcome, check).await?; + let descriptor = super::super::recovery::archive::descriptor; + if let Some(candidate) = audit.candidate { + descriptor( + hash, + candidate.operation, + ArtifactKind::InputRoot, + candidate.artifact, + )?; + } + + descriptor(hash, root.operation, ArtifactKind::InputRoot, root.artifact)?; + descriptor( + hash, + audit.catalog.operation, + ArtifactKind::CatalogNode, + audit.catalog.artifact, + )?; + descriptor( + hash, + snapshot.directory.operation, + ArtifactKind::CatalogNode, + snapshot.directory.artifact, + )?; + descriptor( + hash, + audit.refs.operation(), + ArtifactKind::InputRoot, + audit.refs.artifact(), + )?; + Ok(()) +} + +pub(in crate::packs::publication) async fn backup_graph( + root: StoredInputRoot, + inventory: &mut crate::packs::backup::Inventory<'_>, +) -> crate::packs::directory::index::WalkResult<()> { + let audit: Audit = root.read(&inventory.store(), INPUT_ROOT_BYTES).await?; + if !inventory.input(root.operation, root.artifact).await? { + return Ok(()); + } + inventory.catalog_headers(audit.catalog).await?; + inventory.refs(audit.refs).await?; + if let Some(root) = audit.candidate { + super::super::candidate_publication::audit::backup_graph(root, inventory).await?; + } + Ok(()) +} diff --git a/crates/canopy-server/src/packs/publication/native_result.rs b/crates/canopy-server/src/packs/publication/native_result.rs index 66f31d8c..fc5d75ac 100644 --- a/crates/canopy-server/src/packs/publication/native_result.rs +++ b/crates/canopy-server/src/packs/publication/native_result.rs @@ -91,7 +91,7 @@ pub(super) struct ResultRecord { request: WireRequestRoot, pub(super) response: GitHttpResponse, plan: Option, - options: Vec, + pub(super) options: Vec, signed: Option>, } impl ResultRecord { @@ -383,3 +383,22 @@ async fn reopen( certificate, }) } + +pub(super) async fn backup_graph( + root: NativeResultRoot, + active: bool, + inventory: &mut crate::packs::backup::Inventory<'_>, +) -> crate::packs::directory::index::WalkResult<()> { + let record = root.read(&inventory.store()).await?; + inventory.input(root.operation(), root.artifact()).await?; + for (key, body) in record.audit_bodies() { + inventory.artifact(key, body).await?; + } + if active { + super::backup::wire(record.request, inventory).await?; + inventory + .body(record.operation, record.response.body) + .await?; + } + Ok(()) +} diff --git a/crates/canopy-server/src/packs/publication/outcome.rs b/crates/canopy-server/src/packs/publication/outcome.rs index a0994b2c..f6687622 100644 --- a/crates/canopy-server/src/packs/publication/outcome.rs +++ b/crates/canopy-server/src/packs/publication/outcome.rs @@ -223,3 +223,24 @@ pub(super) fn current_authority( generation, )? == data.floor) } + +pub(super) async fn backup_graph( + value: &OutcomeCertificate, + seed: &[u8; 32], + inventory: &mut crate::packs::backup::Inventory<'_>, +) -> crate::packs::directory::index::WalkResult<()> { + if !value.0.authenticated(seed) { + return Err(CodecError::Invalid("backup outcome MAC").into()); + } + let data: OutcomeData = value.0.data()?; + super::backup::context( + data.tenant, + data.application, + data.check.token.repository, + inventory, + )?; + if data.format != inventory.format() { + return Err(CodecError::Invalid("backup outcome format").into()); + } + super::backup::fact(data.floor, inventory).await +} diff --git a/crates/canopy-server/src/packs/publication/prepare.rs b/crates/canopy-server/src/packs/publication/prepare.rs index fc04f7be..52ad8951 100644 --- a/crates/canopy-server/src/packs/publication/prepare.rs +++ b/crates/canopy-server/src/packs/publication/prepare.rs @@ -9,9 +9,9 @@ use crate::packs::{ index::{IndexError, NodeRef}, snapshot::DirectorySnapshot, }, - metadata::{MetadataError, MetadataLimits, MetadataSegment}, + metadata::{MetadataError, MetadataLimits, MetadataSegment, StoredSegment}, sources::{NativePackDescriptor, SourceIndex, SourceRecord, SourceRoot}, - verification::{PhysicalError, PhysicalPackWitness}, + verification::{PhysicalError, PhysicalPackWitness, StagedNativeMetadata}, }; use canopy_object_storage::artifact::ArtifactStore; use cellule_ltx::DiskBudget; @@ -162,6 +162,7 @@ async fn merge_sources( /// subtrees are reused; only exact verified incoming shards insert new leaves. /// Failure/cancellation poisons the operation and never yields PreparedCatalog. pub struct CatalogPreparation { + staging: Option, base: Arc, store: Arc, sources: Arc, @@ -170,6 +171,7 @@ pub struct CatalogPreparation { snapshot: DirectorySnapshot, directory: Option, budget: DiskBudget, + input_limits: MetadataLimits, output_limits: MetadataLimits, closure: Option, active: Option, @@ -203,14 +205,58 @@ impl CatalogPreparation { base: Arc, limits: MetadataLimits, output_limits: MetadataLimits, + ) -> Result { + Self::new_inner(root, budget, base, limits, output_limits, None).await + } + /// Production construction retains the admitted worker through every + /// detached assembler job, while the finished private proof owns no worker. + pub async fn new_staged( + context: &StagingContext, + root: &Path, + budget: DiskBudget, + base: Arc, + limits: MetadataLimits, + ) -> Result { + context.ensure_live().map_err(PhysicalError::from)?; + if context.token().map_err(PhysicalError::from)? != base.context_token() + || context.format() != base.context().format + { + return Err(CatalogPreparationError::Integrity); + } + Self::new_inner( + root, + budget, + base, + limits, + MetadataLimits { + max_file_bytes: limits.max_file_bytes.min(RUN_TARGET_BYTES), + ..limits + }, + Some(context.clone()), + ) + .await + } + async fn new_inner( + root: &Path, + budget: DiskBudget, + base: Arc, + limits: MetadataLimits, + output_limits: MetadataLimits, + staging: Option, ) -> Result { DirectoryPartitioner::validate_limits(output_limits)?; let (lease, deadline) = base.live_lease()?; let indexes = base.indexes(); let (snapshot, source_root) = base.catalog_parts(); let root = root.to_owned(); + let activity = staging.as_ref().map_or_else( + || Arc::new(()) as crate::git_objects::ReadOwner, + StagingContext::physical_owner, + ); let work = async { + let workspace_activity = activity.clone(); let workspace = tokio::task::spawn_blocking(move || { + let _activity = workspace_activity; tempfile::Builder::new() .prefix("canopy-catalog-preparation-") .tempdir_in(root) @@ -219,15 +265,17 @@ impl CatalogPreparation { }) .await??; let context = base.context(); - let closure = ClosureVerifier::new_in_workspace( + let closure = ClosureVerifier::new_in_workspace_owned( Arc::clone(&workspace), budget.clone(), context, limits, + activity.clone(), ) .await?; let directory_budget = budget.clone(); let directory = tokio::task::spawn_blocking(move || { + let _activity = activity; let mut builder = DirectoryBuilder::new( workspace.path(), directory_budget, @@ -242,6 +290,7 @@ impl CatalogPreparation { .await??; base.live_lease()?; Ok(Self { + staging, base, store: indexes.store(), sources: indexes.sources(), @@ -250,6 +299,7 @@ impl CatalogPreparation { snapshot, directory: Some(directory), budget, + input_limits: limits, output_limits, closure: Some(closure), active: None, @@ -266,8 +316,17 @@ impl CatalogPreparation { return Err(CatalogPreparationError::Integrity); } self.failed = true; + if let Some(context) = &self.staging { + context.ensure_live().map_err(PhysicalError::from)?; + } Ok(self.base.live_lease()?.1) } + fn physical_owner(&self) -> crate::git_objects::ReadOwner { + self.staging.as_ref().map_or_else( + || Arc::new(()) as crate::git_objects::ReadOwner, + StagingContext::physical_owner, + ) + } pub fn begin_pack( &mut self, witness: PhysicalPackWitness, @@ -326,7 +385,7 @@ impl CatalogPreparation { segment: Arc, ) -> Result<(), CatalogPreparationError> { let deadline = self.start()?; - timeout_at(deadline, self.add_inner(segment)) + timeout_at(deadline, self.add_inner(segment, None)) .await .map_err(|_| PreparationBaseError::Inactive)??; self.base.live_lease()?; @@ -336,6 +395,7 @@ impl CatalogPreparation { async fn add_inner( &mut self, segment: Arc, + stored: Option, ) -> Result<(), CatalogPreparationError> { let native = self.active.ok_or(CatalogPreparationError::Integrity)?; self.closure @@ -348,15 +408,25 @@ impl CatalogPreparation { .take() .ok_or(CatalogPreparationError::Integrity)?; let pinned = Arc::clone(&segment); + let activity = self.physical_owner(); self.directory = Some( tokio::task::spawn_blocking(move || { + let _activity = activity; let mut directory = directory; directory.add_segment(&pinned)?; Ok::<_, MetadataError>(directory) }) .await??, ); - let metadata = segment.upload(&self.store).await?; + let metadata = match stored { + Some(stored) if stored.segment == segment.descriptor() => stored, + Some(_) => return Err(CatalogPreparationError::Integrity), + None => { + segment + .upload_owned(&self.store, self.physical_owner()) + .await? + } + }; let record = SourceRecord { metadata, pack: native.pack, @@ -378,6 +448,61 @@ impl CatalogPreparation { ); Ok(()) } + /// Consume the complete physical witness and its admitted ordinal replay. + /// Download/authenticate/copy one shard at a time; reuse each uploaded + /// SourceRecord instead of uploading metadata again during bound assembly. + pub async fn add_staged_pack( + &mut self, + input: StagedNativeMetadata, + ) -> Result<(), CatalogPreparationError> { + let deadline = self.start()?; + let context = self + .staging + .clone() + .ok_or(CatalogPreparationError::Integrity)?; + // The complete operation is poisoned on cancellation, including a + // failed/absent provider artifact or an incomplete descriptor replay. + let result = timeout_at(deadline, async { + let native = input.witness.native(); + self.failed = false; + self.begin_retained_pack(input.witness).await?; + self.failed = true; + let workspace = self + .directory + .as_ref() + .ok_or(CatalogPreparationError::Integrity)? + .workspace(); + let root = workspace + .as_ref() + .ok_or(CatalogPreparationError::Integrity)? + .path(); + let mut offset = 0; + while let Some((stored, end)) = input.replay.next(&context, native, offset).await? { + context.ensure_live().map_err(PhysicalError::from)?; + let segment = MetadataSegment::download_owned( + root, + self.budget.clone(), + &self.store, + stored, + self.input_limits, + None, + context.physical_owner(), + ) + .await?; + self.add_inner(segment, Some(stored)).await?; + offset = end; + } + context.ensure_live().map_err(PhysicalError::from)?; + self.failed = false; + self.finish_pack().await + }) + .await + .map_err(|_| PreparationBaseError::Inactive)?; + if result.is_err() { + self.failed = true; + } + result + } pub async fn finish_pack(&mut self) -> Result<(), CatalogPreparationError> { let deadline = self.start()?; if self.active.is_none() { @@ -418,9 +543,11 @@ impl CatalogPreparation { .directory .take() .ok_or(CatalogPreparationError::Integrity)?; - let budget = self.budget; + let budget = self.budget.clone(); let limits = self.output_limits; + let activity = self.physical_owner(); let (partitioner, witness) = tokio::task::spawn_blocking(move || { + let _activity = activity; if witness.object_count() == 0 { drop(directory); Ok((None, witness)) @@ -437,7 +564,9 @@ impl CatalogPreparation { let mut incoming_root = None; if let Some(mut partitioner) = partitioner { loop { + let activity = self.physical_owner(); let (next, retained) = tokio::task::spawn_blocking(move || { + let _activity = activity; let next = partitioner.next_run()?; Ok::<_, MetadataError>((next, partitioner)) }) @@ -446,7 +575,7 @@ impl CatalogPreparation { let Some(run) = next else { break; }; - let stored = run.upload(&self.store).await?; + let stored = run.upload_owned(&self.store, self.physical_owner()).await?; incoming_root = Some( index .insert(incoming_root, context.operation, stored) diff --git a/crates/canopy-server/src/packs/publication/recovery/archive.rs b/crates/canopy-server/src/packs/publication/recovery/archive.rs index 6178eb3d..28f06f59 100644 --- a/crates/canopy-server/src/packs/publication/recovery/archive.rs +++ b/crates/canopy-server/src/packs/publication/recovery/archive.rs @@ -121,8 +121,11 @@ impl WireValue for TerminalReleaseReply { } pub(super) enum Terminal { + Candidate(CandidatePublicationReply), Push(Box), Initialization(InitializationReply), + Merge(crate::pulls::merge::MergeOutcome), + Head(PublicationReply), } impl Terminal { fn selected_statement(&self, operation: [u8; 16]) -> SqlStatement { @@ -130,6 +133,13 @@ impl Terminal { sql: match self { Self::Push(_) => super::super::root_completion::read::SAVED, Self::Initialization(_) => super::super::initialization::publish::SAVED, + Self::Candidate(reply) => { + return super::super::candidate_publication::audit::statement(reply); + } + Self::Head(_) => super::super::native_head::publish::SAVED, + Self::Merge(outcome) => { + return super::super::native_merge::audit::statement(outcome); + } } .into(), parameters: vec![blob(operation)], @@ -150,14 +160,101 @@ impl Terminal { // A known negative is the original phase knowledge. A later attempt // may initialize this logical operation, without rewriting that denial. Self::Initialization(InitializationReply::Denied(_)) => true, + Self::Candidate(reply) => { + !reply.applied() + || super::super::candidate_publication::audit::selected( + result, + reply, + &check.actor, + )? + .is_some() + } + Self::Head(reply) => { + matches!(reply, PublicationReply::Denied(_)) + || super::super::native_head::publish::selected(result, check, *reply)? + .is_some() + } + Self::Merge(outcome) => { + !matches!(outcome, crate::pulls::merge::MergeOutcome::Applied { .. }) + || super::super::native_merge::audit::selected(result, outcome, &check.actor)? + .is_some() + } }) } async fn closed_graph( &self, store: &ArtifactStore, + selected: &[SqlResultSet], + check: &LeaseCheck, hash: &mut blake3::Hasher, ) -> Result<(), RootRecoveryError> { match self { + Self::Candidate(reply) => { + hash.update(&encoded(reply, 512)?); + super::super::candidate_publication::audit::closed_graph( + store, selected, reply, check, hash, + ) + .await?; + } + Self::Head(reply) => { + hash.update(&encoded(reply, 512)?); + if let PublicationReply::Published(published) = reply { + let (request, fact) = + super::super::native_head::publish::selected(selected, check, *reply)? + .ok_or(RootRecoveryError::Context)?; + let catalog = fact.catalog.ok_or(RootRecoveryError::Context)?; + let snapshot = + crate::packs::catalog::CatalogSnapshot::download(store, catalog).await?; + crate::packs::directory::snapshot::DirectorySnapshot::download( + store, + snapshot.directory, + ) + .await?; + let refs = fact.refs.ok_or(RootRecoveryError::Context)?; + let state = refs + .read(store) + .await + .map_err(super::super::RefSnapshotPreparationError::from)?; + if state.repository != check.token.repository + || state.format != catalog.format + || state.generation != published.ref_generation + || state.default_branch != request.reference + { + return Err(RootRecoveryError::Context); + } + descriptor( + hash, + catalog.operation, + ArtifactKind::CatalogNode, + catalog.artifact, + )?; + descriptor( + hash, + snapshot.directory.operation, + ArtifactKind::CatalogNode, + snapshot.directory.artifact, + )?; + descriptor( + hash, + refs.operation(), + ArtifactKind::InputRoot, + refs.artifact(), + )?; + } + } + Self::Merge(outcome) => { + hash.update(&encoded(outcome, 512)?); + if let Some(root) = + super::super::native_merge::audit::selected(selected, outcome, &check.actor)? + { + super::super::native_merge::audit::closed_graph( + store, root, outcome, check, hash, + ) + .await?; + } else if matches!(outcome, crate::pulls::merge::MergeOutcome::Applied { .. }) { + return Err(RootRecoveryError::Context); + } + } Self::Push(terminal) => { super::super::root_completion::closed_graph(store, terminal.root, hash).await? } @@ -195,6 +292,36 @@ impl phase::Journal { pub(super) fn terminal(&self, record: &Record) -> Result, CodecError> { // Validation is required even when only a primary result is selected. self.may_advance(record)?; + // A merge has its own typed permanent audit selection. Known denials + // retain their original phase even if a later UUID attempt succeeds. + if record.kind == Kind::Candidate { + return self + .primary + .as_ref() + .map(|v| { + v.decode_reply::() + .map(Terminal::Candidate) + }) + .transpose(); + } + if record.kind == Kind::Head { + return self + .primary + .as_ref() + .map(|v| v.decode_reply::().map(Terminal::Head)) + .transpose(); + } + if record.kind == Kind::Merge { + return self + .primary + .as_ref() + .map(|value| { + value + .decode_reply::() + .map(Terminal::Merge) + }) + .transpose(); + } if record.kind == Kind::Initialization { return self .primary @@ -206,7 +333,7 @@ impl phase::Journal { }) .transpose(); } - let result = if record.kind == Kind::Policy { + let result = if record.kind == Kind::Policy || self.refused(record)? { if !self.refused(record)? { return Ok(None); } @@ -332,7 +459,9 @@ impl RegisteredRootRecovery { )?; record = next; } - terminal.closed_graph(store, &mut hash).await?; + terminal + .closed_graph(store, &row.output, &self.record.check, &mut hash) + .await?; let proof = Proof { recovery: self.certificate.clone(), phase: *blake3::hash(&encoded(&journal, 2048)?).as_bytes(), @@ -361,7 +490,7 @@ impl RegisteredRootRecovery { }) } } -fn validate_bundle( +pub(super) fn validate_bundle( bundle: &Bundle, record: &Record, target: &CellTarget, @@ -661,6 +790,12 @@ impl ReadyTerminalRelease { } } +pub(super) fn backup_release(bytes: &[u8]) -> Result<(), RootRecoveryError> { + let _: ReleaseRecord = + crate::packs::backup::decode(bytes, 1024).map_err(|_| RootRecoveryError::Context)?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/canopy-server/src/packs/publication/recovery/backup.rs b/crates/canopy-server/src/packs/publication/recovery/backup.rs new file mode 100644 index 00000000..96d273f4 --- /dev/null +++ b/crates/canopy-server/src/packs/publication/recovery/backup.rs @@ -0,0 +1,127 @@ +//! Unknown commands retain their original bytes; closed frames retain metadata. +use super::*; +use crate::packs::{ + backup::{Inventory, decode}, + directory::index::WalkResult, +}; + +pub(in crate::packs::publication) async fn graph( + bytes: &[u8], + saved: Option<&[u8]>, + release: Option<&[u8]>, + seed: &[u8; 32], + inventory: &mut Inventory<'_>, +) -> WalkResult<()> { + let certificate: RootRecoveryCertificate = decode(bytes, 1024)?; + if !certificate.0.authenticated(seed) { + return Err(RootRecoveryError::Context.into()); + } + let mut record: Record = certificate.0.data()?; + super::super::backup::context( + record.tenant, + record.application, + record.check.token.repository, + inventory, + )?; + let journal = match saved { + Some(bytes) => phase::journal(&SqlValue::Blob(bytes.to_vec()), &record)?, + None => phase::Journal { + primary: None, + refusal: None, + }, + }; + if let Some(bytes) = release { + archive::backup_release(bytes)?; + journal + .terminal(&record)? + .ok_or(RootRecoveryError::Context)?; + } + let check = record.check.clone(); + let mut first = true; + loop { + let bundle = record + .root + .read::(&inventory.store(), ROOT_BYTES) + .await?; + archive::validate_bundle(&bundle, &record, inventory.target())?; + inventory + .input(record.root.operation, record.root.artifact) + .await?; + if first && release.is_none() { + if journal.primary.is_none() { + command(&record, &bundle.primary, record.kind, seed, inventory).await?; + } + // A frozen future refusal remains necessary while its command is + // unresolved, including before the primary's result is known. + if journal.refusal.is_none() + && (journal.primary.is_none() || journal.refused(&record)?) + && let Some(saved) = &bundle.refusal + { + command(&record, saved, Kind::Outcome, seed, inventory).await?; + } + } + if first && let Some(terminal) = journal.terminal(&record)? { + match terminal { + archive::Terminal::Push(value) => { + super::super::root_completion::backup_graph(value.root, inventory).await? + } + archive::Terminal::Initialization(InitializationReply::Initialized(fact)) => { + super::super::backup::fact(*fact, inventory).await? + } + _ => {} // Permanent selected merge/candidate/HEAD rows are scanned independently. + } + } + first = false; + let Some(previous) = record.previous else { + break; + }; + let frame = previous + .read::(&inventory.store(), ROOT_BYTES) + .await?; + if !frame.certificate.0.authenticated(seed) { + return Err(RootRecoveryError::Context.into()); + } + let next: Record = frame.certificate.0.data()?; + if next.check != check + || next.tenant != record.tenant + || next.application != record.application + || next.step.checked_add(1) != Some(record.step) + { + return Err(RootRecoveryError::Context.into()); + } + if !inventory + .input(previous.operation, previous.artifact) + .await? + { + break; + } + record = next; + } + Ok(()) +} +async fn command( + record: &Record, + saved: &SavedCommand, + kind: Kind, + seed: &[u8; 32], + inventory: &mut Inventory<'_>, +) -> WalkResult<()> { + let limit = kind.body_limit(); + if saved.body.size == 0 || saved.body.size > u64::from(limit) { + return Err(RootRecoveryError::Context.into()); + } + let key = ArtifactKey { + operation: record.check.token.artifact_operation, + binding_digest: saved.body.digest, + kind: ArtifactKind::InputBody, + }; + let mut reader = inventory.store().read(key, saved.body).await?; + let mut bytes = Vec::with_capacity(saved.body.size as usize); + while let Some(part) = reader.next().await? { + bytes.extend_from_slice(&part); + } + // The SDK snapshot's operation digest includes this exact input body. + // Registration already bound the complete frozen contract to its MAC. + inventory.artifact(key, saved.body).await?; + super::super::backup::command(kind, &bytes, seed, inventory).await +} diff --git a/crates/canopy-server/src/packs/publication/recovery/codec.rs b/crates/canopy-server/src/packs/publication/recovery/codec.rs index e0e15658..4b7bb880 100644 --- a/crates/canopy-server/src/packs/publication/recovery/codec.rs +++ b/crates/canopy-server/src/packs/publication/recovery/codec.rs @@ -5,7 +5,9 @@ impl WireValue for Record { if self.root.operation != self.check.token.artifact_operation { return Err(CodecError::Invalid("root recovery namespace")); } - if (self.kind == Kind::Policy) != self.refusal.is_some() { + if (self.kind == Kind::Policy && self.refusal.is_none()) + || (self.refusal.is_some() && !matches!(self.kind, Kind::Policy | Kind::Publish)) + { return Err(CodecError::Invalid( "policy recovery requires frozen refusal", )); @@ -28,6 +30,9 @@ impl WireValue for Record { Kind::Outcome => 1, Kind::Policy => 2, Kind::Initialization => 3, + Kind::Merge => 4, + Kind::Head => 5, + Kind::Candidate => 6, })?; self.primary.encode(e)?; e.write_bool(self.refusal.is_some())?; @@ -54,6 +59,9 @@ impl WireValue for Record { 1 => Kind::Outcome, 2 => Kind::Policy, 3 => Kind::Initialization, + 4 => Kind::Merge, + 5 => Kind::Head, + 6 => Kind::Candidate, _ => return Err(CodecError::Invalid("root recovery command")), }, primary: Stamp::decode(d)?, @@ -77,7 +85,9 @@ impl WireValue for Record { impl WireValue for Bundle { fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { self.primary.validate(self.kind.body_limit())?; - if (self.kind == Kind::Policy) != self.refusal.is_some() { + if (self.kind == Kind::Policy && self.refusal.is_none()) + || (self.refusal.is_some() && !matches!(self.kind, Kind::Policy | Kind::Publish)) + { return Err(CodecError::Invalid("missing frozen refusal")); } if let Some(refusal) = &self.refusal { @@ -89,6 +99,9 @@ impl WireValue for Bundle { Kind::Outcome => 1, Kind::Policy => 2, Kind::Initialization => 3, + Kind::Merge => 4, + Kind::Head => 5, + Kind::Candidate => 6, })?; self.primary.encode(e)?; e.write_bool(self.refusal.is_some())?; @@ -107,6 +120,9 @@ impl WireValue for Bundle { 1 => Kind::Outcome, 2 => Kind::Policy, 3 => Kind::Initialization, + 4 => Kind::Merge, + 5 => Kind::Head, + 6 => Kind::Candidate, _ => return Err(CodecError::Invalid("unknown recovery kind")), }, primary: SavedCommand::decode(d)?, diff --git a/crates/canopy-server/src/packs/publication/recovery/mod.rs b/crates/canopy-server/src/packs/publication/recovery/mod.rs index 918aeccb..92b95dad 100644 --- a/crates/canopy-server/src/packs/publication/recovery/mod.rs +++ b/crates/canopy-server/src/packs/publication/recovery/mod.rs @@ -39,6 +39,14 @@ const DOMAIN: &[u8] = b"canopy.publication-command-recovery.v4\0"; #[derive(Debug, thiserror::Error)] pub enum RootRecoveryError { + #[error("selected candidate audit failed")] + CandidateAudit(#[source] Box), + #[error("symbolic HEAD retirement metadata failed")] + HeadMetadata(#[from] crate::packs::directory::index::IndexError), + #[error("symbolic HEAD retirement snapshot failed")] + HeadSnapshot(#[from] RefSnapshotPreparationError), + #[error("selected native merge audit failed")] + MergeAudit(#[source] Box), #[error("closed initialization graph failed")] Initialization(#[from] super::initialization::InitializationVerificationError), #[error("closed native audit graph failed")] @@ -65,6 +73,12 @@ pub enum RootRecoveryError { Context, } +impl From for RootRecoveryError { + fn from(error: NativeMergeAuditError) -> Self { + Self::MergeAudit(Box::new(error)) + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct RootRecoveryCertificate(CertificateEnvelope); #[derive(Clone, Debug, PartialEq, Eq)] @@ -78,10 +92,19 @@ pub(super) enum Kind { Outcome, Policy, Initialization, + Merge, + Head, + Candidate, } impl Kind { fn body_limit(self) -> u32 { - if self == Self::Initialization { + if self == Self::Candidate { + NATIVE_CANDIDATE_BYTES + } else if self == Self::Head { + NATIVE_HEAD_BYTES + } else if self == Self::Merge { + NATIVE_MERGE_BYTES + } else if self == Self::Initialization { INITIALIZATION_BYTES } else if self == Self::Policy { REF_POLICY_PAGE_BYTES @@ -322,6 +345,25 @@ impl RegisteredRootRecovery { authority: &PreparationAuthority, original: Option<&PreparationSession>, ) -> Result, PublicationError> { + if self.record.kind == Kind::Publish && self.record.refusal.is_some() { + let refusing = std::sync::atomic::AtomicBool::new(false); + let result = Box::pin(self.dispatch_bound( + client, + store, + authority, + &refusing, + original, + #[cfg(test)] + None, + )) + .await?; + return match result { + PublicationOutcome::RootPush(value) => Ok(value), + _ => Err(PublicationError::RootPush(InvocationError::NotStarted( + Error::Command("armed root dispatch outcome differs"), + ))), + }; + } let result = match self.record.kind { Kind::Publish => { self.dispatch_command::(client, store, authority, false, original) @@ -333,7 +375,7 @@ impl RegisteredRootRecovery { ) .await } - Kind::Policy | Kind::Initialization => { + Kind::Policy | Kind::Initialization | Kind::Merge | Kind::Head | Kind::Candidate => { Err(AttemptError::Invocation(InvocationError::NotStarted( Error::Command("recovery kind requires typed phase dispatch"), ))) @@ -377,7 +419,51 @@ impl RegisteredRootRecovery { .await .map(PublicationOutcome::Initialization); } - if self.record.kind != Kind::Policy { + if self.record.kind == Kind::Candidate { + let result = self + .dispatch_command::( + client, store, authority, false, original, + ) + .await + .map_err(|e| e.publication(self.evidence(), PublicationError::Candidate))?; + return if result.output.applied() { + Ok(PublicationOutcome::Candidate(result)) + } else { + Err(PublicationError::Candidate(InvocationError::Rejected( + Box::new(result), + ))) + }; + } + if self.record.kind == Kind::Head { + let result = self + .dispatch_command::(client, store, authority, false, original) + .await + .map_err(|e| e.publication(self.evidence(), PublicationError::Head))?; + return if matches!(result.output, PublicationReply::Published(_)) { + Ok(PublicationOutcome::Head(result)) + } else { + Err(PublicationError::Head(InvocationError::Rejected(Box::new( + result, + )))) + }; + } + if self.record.kind == Kind::Merge { + let result = self + .dispatch_command::(client, store, authority, false, original) + .await + .map_err(|e| e.publication(self.evidence(), PublicationError::Merge))?; + return if matches!( + result.output, + crate::pulls::merge::MergeOutcome::Applied { .. } + ) { + Ok(PublicationOutcome::Merge(result)) + } else { + Err(PublicationError::Merge(InvocationError::Rejected( + Box::new(result), + ))) + }; + } + if self.record.kind != Kind::Policy && self.record.refusal.is_none() { let result = match original { Some(original) => { self.dispatch_root(client, store, authority, Some(original)) @@ -387,16 +473,35 @@ impl RegisteredRootRecovery { }; return result.map(PublicationOutcome::RootPush); } - let result = self - .dispatch_command::(client, store, authority, false, original) - .await; - let refused = match &result { - Ok(value) => { - matches!(value.output, RefPolicyReply::Denied(_)) - || matches!(value.output, RefPolicyReply::Registered(progress) if !progress.valid) - } - Err(AttemptError::Invocation(InvocationError::Rejected(_))) => true, - _ => false, + let mut page = None; + let mut root = None; + let refused = if self.record.kind == Kind::Policy { + let result = self + .dispatch_command::( + client, store, authority, false, original, + ) + .await; + let refused = match &result { + Ok(value) => { + matches!(value.output, RefPolicyReply::Denied(_)) + || matches!(value.output, RefPolicyReply::Registered(progress) if !progress.valid) + } + Err(AttemptError::Invocation(InvocationError::Rejected(_))) => true, + _ => false, + }; + page = Some(result); + refused + } else { + let result = self + .dispatch_command::(client, store, authority, false, original) + .await; + let refused = match &result { + Ok(value) => matches!(value.output, RootCompletionReply::Denied(_)), + Err(AttemptError::Invocation(InvocationError::Rejected(_))) => true, + _ => false, + }; + root = Some(result); + refused }; if refused { let journal = self @@ -413,9 +518,15 @@ impl RegisteredRootRecovery { source: Box::new(RootRecoveryError::Codec(source)), })? { - return Err(PublicationError::PolicyPage(InvocationError::Pending( - Box::new(self.evidence().clone()), - ))); + return Err(if self.record.kind == Kind::Policy { + PublicationError::PolicyPage(InvocationError::Pending(Box::new( + self.evidence().clone(), + ))) + } else { + PublicationError::RootPush(InvocationError::Pending(Box::new( + self.evidence().clone(), + ))) + }); } refusing.store(true, std::sync::atomic::Ordering::Release); let evidence = self @@ -461,9 +572,18 @@ impl RegisteredRootRecovery { Err(error) => Err(error.publication(evidence, PublicationError::RootPush)), }; } - result - .map(PublicationOutcome::PolicyPage) - .map_err(|error| error.publication(self.evidence(), PublicationError::PolicyPage)) + if let Some(result) = page { + result + .map(PublicationOutcome::PolicyPage) + .map_err(|error| error.publication(self.evidence(), PublicationError::PolicyPage)) + } else { + match root.expect("root or policy dispatch") { + Ok(value) => phase::normalize_root(Ok(value)) + .map(PublicationOutcome::RootPush) + .map_err(PublicationError::RootPush), + Err(error) => Err(error.publication(self.evidence(), PublicationError::RootPush)), + } + } } async fn known( &self, @@ -548,33 +668,41 @@ impl RegisteredRootRecovery { // Write query here would hide an expired/revoked attempt before that // original command could record its definitive denial. Bound live // initialization still retains and checks its original local guard. - let session = - if refusal_only || self.record.kind == Kind::Initialization || original.is_some() { - None - } else { - match PreparationSession::open( - client.clone(), - self.evidence().target().clone(), - self.record.check.clone(), - None, - authority.clone(), - ) - .await - { - Ok(session) => Some(session), - Err(error) => { - if let Some(known) = self.known::(client, store, refusal).await? { - return Ok(known); - } - return Err(AttemptError::Invocation(InvocationError::NotStarted( - Error::Facility { - name: "publication recovery custody", - source: Box::new(error), - }, - ))); + // Frozen HEAD metadata also needs no native work or new preparation. + // Its original final receiver must record expired/revoked denials, + // while checking actual owner, original pin and current joint roots. + let session = if refusal_only + || matches!( + self.record.kind, + Kind::Initialization | Kind::Head | Kind::Candidate + ) + || original.is_some() + { + None + } else { + match PreparationSession::open( + client.clone(), + self.evidence().target().clone(), + self.record.check.clone(), + None, + authority.clone(), + ) + .await + { + Ok(session) => Some(session), + Err(error) => { + if let Some(known) = self.known::(client, store, refusal).await? { + return Ok(known); } + return Err(AttemptError::Invocation(InvocationError::NotStarted( + Error::Facility { + name: "publication recovery custody", + source: Box::new(error), + }, + ))); } - }; + } + }; // A command can settle while body I/O or custody acquisition is in flight. if let Some(known) = self.known::(client, store, refusal).await? { return Ok(known); @@ -712,7 +840,8 @@ pub(super) async fn persist_full( || command.evidence().incarnation() != check.token.owner.incarnation || command.input_bytes().is_empty() || command.input_bytes().len() > kind.body_limit() as usize - || (kind == Kind::Policy) != refusal.is_some() + || (kind == Kind::Policy && refusal.is_none()) + || (refusal.is_some() && !matches!(kind, Kind::Policy | Kind::Publish)) { return Err(RootRecoveryError::Context); } @@ -837,3 +966,11 @@ pub(super) async fn persist_full( } Ok(registered) } + +impl From for RootRecoveryError { + fn from(error: NativeCandidateAuditError) -> Self { + Self::CandidateAudit(Box::new(error)) + } +} + +pub(in crate::packs::publication) mod backup; diff --git a/crates/canopy-server/src/packs/publication/recovery/phase.rs b/crates/canopy-server/src/packs/publication/recovery/phase.rs index 90219713..b137e9dd 100644 --- a/crates/canopy-server/src/packs/publication/recovery/phase.rs +++ b/crates/canopy-server/src/packs/publication/recovery/phase.rs @@ -89,6 +89,20 @@ impl Journal { primary.decode_reply::()?, InitializationReply::Denied(_) ) + } else if record.kind == Kind::Candidate { + !primary + .decode_reply::()? + .applied() + } else if record.kind == Kind::Head { + matches!( + primary.decode_reply::()?, + PublicationReply::Denied(_) + ) + } else if record.kind == Kind::Merge { + !matches!( + primary.decode_reply::()?, + crate::pulls::merge::MergeOutcome::Applied { .. } + ) } else if record.kind == Kind::Policy { matches!( primary.decode_reply::()?, @@ -121,6 +135,15 @@ impl Journal { Ok(()) } pub(super) fn refused(&self, record: &Record) -> Result { + if record.kind == Kind::Publish && record.refusal.is_some() { + return Ok(matches!( + self.primary + .as_ref() + .map(Recorded::decode_reply::) + .transpose()?, + Some(RootCompletionReply::Denied(_)) + )); + } if record.kind != Kind::Policy { return Ok(false); } @@ -139,7 +162,7 @@ impl Journal { } pub(super) fn may_advance(&self, record: &Record) -> Result { self.validate(record)?; - if self.refusal.is_some() { + if self.refusal.is_some() || self.refused(record)? { return Ok(false); } let Some(primary) = &self.primary else { @@ -150,6 +173,8 @@ impl Journal { primary.decode_reply::()?, InitializationReply::Denied(_) ) + } else if matches!(record.kind, Kind::Merge | Kind::Head | Kind::Candidate) { + false } else if record.kind == Kind::Policy { matches!(primary.decode_reply::()?, RefPolicyReply::Registered(value) if value.valid) } else { @@ -282,7 +307,9 @@ pub(in crate::packs::publication) fn execute( let stamp = Stamp::of(&evidence); let refusal = if kind == record.kind && stamp == record.primary { false - } else if kind == Kind::Outcome && record.kind == Kind::Policy && record.refusal == Some(stamp) + } else if kind == Kind::Outcome + && matches!(record.kind, Kind::Policy | Kind::Publish) + && record.refusal == Some(stamp) { // Do not consume a pre-frozen refusal identity before a known page // refusal. An execution error leaves the SDK ledger absent. @@ -501,6 +528,37 @@ mod tests { Ok(()) } #[test] + fn denied_publication_keeps_its_frozen_refusal_pinned() -> Result<(), CodecError> { + let mut record = policy_record(); + record.kind = Kind::Publish; + let primary = recorded( + 17, + true, + RootCompletionReply::Denied(PreparationDenial::Conflict), + )?; + let mut journal = Journal { + primary: Some(primary), + refusal: None, + }; + assert!(journal.refused(&record)?); + assert!(!journal.may_advance(&record)?); + let mut unarmed = record.clone(); + unarmed.refusal = None; + assert!(journal.may_advance(&unarmed)?); + journal.refusal = Some(recorded( + 18, + true, + RootCompletionReply::Denied(PreparationDenial::Stale), + )?); + journal.validate(&record)?; + assert!(!journal.may_advance(&record)?); + journal.refusal.as_mut().unwrap().sequence = 17; + assert!(journal.validate(&record).is_err()); + journal.primary = None; + assert!(journal.validate(&record).is_err()); + Ok(()) + } + #[test] fn refusal_requires_a_known_negative_page_and_later_original_sequence() -> Result<(), CodecError> { let record = policy_record(); diff --git a/crates/canopy-server/src/packs/publication/recovery/ready.rs b/crates/canopy-server/src/packs/publication/recovery/ready.rs index ce064874..49e60b46 100644 --- a/crates/canopy-server/src/packs/publication/recovery/ready.rs +++ b/crates/canopy-server/src/packs/publication/recovery/ready.rs @@ -105,6 +105,18 @@ impl ReadyRootRecovery { PublicationError::Initialization(InvocationError::Pending(Box::new( self.recovery.evidence().clone(), ))) + } else if self.recovery.record.kind == Kind::Candidate { + PublicationError::Candidate(InvocationError::Pending(Box::new( + self.recovery.evidence().clone(), + ))) + } else if self.recovery.record.kind == Kind::Head { + PublicationError::Head(InvocationError::Pending(Box::new( + self.recovery.evidence().clone(), + ))) + } else if self.recovery.record.kind == Kind::Merge { + PublicationError::Merge(InvocationError::Pending(Box::new( + self.recovery.evidence().clone(), + ))) } else if self.recovery.record.kind == Kind::Policy { PublicationError::PolicyPage(InvocationError::Pending(Box::new( self.recovery.evidence().clone(), diff --git a/crates/canopy-server/src/packs/publication/recovery/registration.rs b/crates/canopy-server/src/packs/publication/recovery/registration.rs index 37a38a10..93b98a30 100644 --- a/crates/canopy-server/src/packs/publication/recovery/registration.rs +++ b/crates/canopy-server/src/packs/publication/recovery/registration.rs @@ -67,10 +67,10 @@ impl Command for RegisterRootRecovery { if !old.0.authenticated(&seed) || old_record.check != *check { return deny(PreparationDenial::Conflict); } - // The original policy bundle already authorized this exact + // The original armed bundle already authorized this exact // refusal. Advancing to it cannot publish refs or objects and // must remain possible after current Write access is revoked. - let frozen_refusal = old_record.kind == Kind::Policy + let frozen_refusal = matches!(old_record.kind, Kind::Policy | Kind::Publish) && record.kind == Kind::Outcome && old_record.refusal == Some(record.primary); if !permitted && !frozen_refusal { diff --git a/crates/canopy-server/src/packs/publication/recovery/supervisor.rs b/crates/canopy-server/src/packs/publication/recovery/supervisor.rs index b54027d0..ea658006 100644 --- a/crates/canopy-server/src/packs/publication/recovery/supervisor.rs +++ b/crates/canopy-server/src/packs/publication/recovery/supervisor.rs @@ -89,7 +89,7 @@ impl RecoverySupervisor { coordinator, settings, authority, - None, + (None, None), ) } /// The service supplies current repository administration and actual owner @@ -115,7 +115,34 @@ impl RecoverySupervisor { coordinator, settings, authority, - Some(maintenance), + (Some(maintenance), None), + ) + } + /// Resident discovery shares the existing staging owner. A registered + /// recovery head can become visible before its live producer hands off the + /// final command; discovery must leave that exact workflow with its owner. + pub(crate) fn start_resident( + client: CellClient, + target: CellTarget, + store: ArtifactStore, + resident: (PublicationCoordinator, Arc), + settings: RecoveryScanSettings, + authority: PreparationAuthority, + maintenance: MaintenanceRequest, + ) -> Result { + let (coordinator, staging) = resident; + if maintenance.repository != store.repository() || !staging.matches_target(&target) { + return Err(RootRecoveryError::Context); + } + maintenance.encode(&mut BoundedEncoder::new(4096)?)?; + Self::start_inner( + client, + target, + store, + coordinator, + settings, + authority, + (Some(maintenance), Some(staging)), ) } fn start_inner( @@ -125,8 +152,9 @@ impl RecoverySupervisor { coordinator: PublicationCoordinator, settings: RecoveryScanSettings, authority: PreparationAuthority, - maintenance: Option, + ownership: (Option, Option>), ) -> Result { + let (maintenance, staging) = ownership; settings.validate()?; if !authority.matches(&target) || !coordinator.matches_target(&target) @@ -146,6 +174,7 @@ impl RecoverySupervisor { coordinator, authority, maintenance, + staging, }, sql, settings.clone(), @@ -234,6 +263,7 @@ struct Scan { coordinator: PublicationCoordinator, authority: PreparationAuthority, maintenance: Option, + staging: Option>, } impl Scan { async fn visit( @@ -268,6 +298,18 @@ impl Scan { } return Ok(()); } + // Registration precedes the live lifecycle's held handoff. Its exact + // bound owner must retain that gap; cold discovery cannot execute the + // same command early, steal admission, or retire the producer's pin. + // Check the queue first so already admitted cold work still recovers. + if self + .staging + .as_ref() + .is_some_and(|staging| staging.owns_bound(®istered.record.check)) + { + stats.deferred = stats.deferred.saturating_add(1); + return Ok(()); + } // Settled heads must not consume new command slots every scan. Known // intermediate results remain pinned for the retained-input producer; // this worker cannot invent its next page/root or claim new custody. diff --git a/crates/canopy-server/src/packs/publication/ref_observation.rs b/crates/canopy-server/src/packs/publication/ref_observation.rs new file mode 100644 index 00000000..ceebcaab --- /dev/null +++ b/crates/canopy-server/src/packs/publication/ref_observation.rs @@ -0,0 +1,176 @@ +//! Private exact-ref observation under a live serving pin and CURRENT joint fact. +//! Editorial receivers independently recheck current policy in their transaction. +use super::*; +use crate::ReadIdentity; +use crate::packs::directory::index::codec::fixed; +use cellule_runtime::{ApplicationId, CellId, TenantId}; +use certificate::CertificateEnvelope; + +mod selection; +pub(crate) use selection::{REF_SELECTION_BYTES, RefFact, RefSelection}; + +const DOMAIN: &[u8] = b"canopy.ref-observation.v1\0"; +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RefObservation(CertificateEnvelope); +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct ObservationData { + pub(super) tenant: [u8; 16], + pub(super) application: [u8; 16], + pub(super) token: ServingToken, + pub(super) fact: GenerationFact, + pub(super) actor: Option, + pub(super) binding: [u8; 32], +} +impl ObservationData { + fn validate(&self) -> Result<(), CodecError> { + self.token.validate()?; + self.fact.validate()?; + if self.token.generation != self.fact.generation + || self + .fact + .catalog + .is_none_or(|c| c.repository != self.token.repository) + || self.fact.refs.is_none() + || self.binding == [0; 32] + || self + .actor + .as_deref() + .is_some_and(|a| validate_component(a).is_err()) + { + return Err(CodecError::Invalid("invalid ref observation")); + } + Ok(()) + } +} +impl WireValue for ObservationData { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + self.validate()?; + e.write_bytes(DOMAIN)?; + e.write_bytes(&self.tenant)?; + e.write_bytes(&self.application)?; + self.token.encode(e)?; + self.fact.encode(e)?; + self.actor.encode(e)?; + e.write_bytes(&self.binding) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + if d.read_bytes()? != DOMAIN { + return Err(CodecError::Invalid("invalid ref observation purpose")); + } + let value = Self { + tenant: fixed(d)?, + application: fixed(d)?, + token: ServingToken::decode(d)?, + fact: GenerationFact::decode(d)?, + actor: Option::::decode(d)?, + binding: fixed(d)?, + }; + value.validate()?; + Ok(value) + } +} +pub(crate) struct ObservationRequest<'a> { + pub(crate) cell: CellId, + pub(crate) owner: Option, + pub(crate) repository: [u8; 16], + pub(crate) actor: &'a Option, + pub(crate) binding: [u8; 32], + pub(crate) admitted_ms: i64, +} +impl RefObservation { + pub(super) fn seal(data: ObservationData, seed: &[u8; 32]) -> Result { + Ok(Self(CertificateEnvelope::seal(&data, seed)?)) + } + /// This proof requires the original pin and equality with the current joint fact. + pub(crate) fn authorize( + &self, + request: ObservationRequest<'_>, + mut query: impl FnMut(&SqlBatch) -> cellule_runtime::Result>, + ) -> cellule_runtime::Result { + use sql::*; + let ObservationRequest { + cell, + owner, + repository, + actor, + binding, + admitted_ms, + } = request; + let data: ObservationData = self.0.data()?; + let target = crate::repository_target( + TenantId::from_bytes(data.tenant), + ApplicationId::from_bytes(data.application), + repository, + )?; + if target.cell_id() != cell + || data.token.repository != repository + || data.actor != *actor + || data.binding != binding + || owner.is_some_and(|f| data.token.owner != f) + { + return Ok(false); + } + let scope = actor + .as_deref() + .map_or(ReadIdentity::Anonymous, ReadIdentity::Account); + scope.validate()?; + let access = query(&statement( + &format!("SELECT 1 WHERE {}", crate::access::READ_ACCESS), + vec![scope.parameter()], + ))?; + if rows(&access)?.is_empty() { + return Ok(false); + } + let secret = query(&statement( + "SELECT push_cert_seed FROM repository_identity WHERE singleton=1 AND repository_id=?1 AND object_format=?2", + vec![ + blob(repository), + SqlValue::Text( + data.fact + .catalog + .ok_or(Error::Command("ref observation catalog absent"))? + .format + .as_str() + .into(), + ), + ], + ))?; + if rows(&secret)?.is_empty() || !self.0.authenticated(&attestation::seed(&secret)?) { + return Ok(false); + } + let token = data.token; + let pin = query(&statement( + "SELECT 1 FROM catalog_serving_pins WHERE reader=?1 AND incarnation=?2 AND admission_sequence=?3 AND owner_epoch=?4 AND generation=?5 AND expires_at_ms>?6", + vec![ + blob(token.reader), + blob(token.owner.incarnation.as_bytes()), + number(token.admission_sequence)?, + blob(token.owner.epoch.to_be_bytes()), + number(token.generation)?, + SqlValue::Integer(now(admitted_ms)?), + ], + ))?; + if rows(&pin)?.is_empty() { + return Ok(false); + } + let format = data + .fact + .catalog + .ok_or(Error::Command("ref observation catalog absent"))? + .format; + // A retained historical commit proof permits old generations. Ref policy + // must match the moving current joint fact, including HEAD-only changes. + Ok(generation(&query(&statement(CURRENT, vec![]))?, repository, format)? == data.fact) + } +} +impl WireValue for RefObservation { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + self.0.data::()?; + self.0.encode(e) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + let value = Self(CertificateEnvelope::decode(d)?); + value.0.data::()?; + Ok(value) + } +} diff --git a/crates/canopy-server/src/packs/publication/ref_observation/selection.rs b/crates/canopy-server/src/packs/publication/ref_observation/selection.rs new file mode 100644 index 00000000..f6bd672d --- /dev/null +++ b/crates/canopy-server/src/packs/publication/ref_observation/selection.rs @@ -0,0 +1,149 @@ +//! Byte-bounded exact facts reuse the immutable ref state's OID/version shape. +use super::*; +use crate::refs::{RefExpectation, valid_ref_name}; + +pub(crate) const REF_SELECTION_BYTES: u32 = 560 << 10; +const NAME_BYTES: usize = 512 << 10; +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RefFact { + pub(crate) name: String, + pub(crate) state: Option, +} +#[derive(Clone, Debug)] +pub(crate) struct RefSelection { + pub(crate) repository: [u8; 16], + pub(crate) actor: Option, + pub(crate) facts: Vec, + pub(crate) proof: Option, +} +impl RefSelection { + pub(crate) fn binding(&self, request: [u8; 32]) -> Result<[u8; 32], CodecError> { + self.shape()?; + let mut h = blake3::Hasher::new(); + h.update(b"canopy.ref-selection.v1\0"); + h.update(&request); + h.update(&(self.facts.len() as u64).to_le_bytes()); + for fact in &self.facts { + h.update(&(fact.name.len() as u64).to_le_bytes()); + h.update(fact.name.as_bytes()); + h.update(&[u8::from(fact.state.is_some())]); + if let Some(state) = &fact.state { + h.update(&state.version.to_le_bytes()); + h.update(&[u8::from(state.oid.is_some())]); + if let Some(oid) = state.oid { + h.update(&[oid.format().bytes() as u8]); + h.update(oid.as_ref()); + } + } + } + Ok(*h.finalize().as_bytes()) + } + pub(crate) fn authorized( + &self, + cell: cellule_runtime::CellId, + owner: Option, + admitted_ms: i64, + request: [u8; 32], + query: impl FnMut(&SqlBatch) -> cellule_runtime::Result>, + ) -> cellule_runtime::Result { + let Some(proof) = &self.proof else { + return Ok(false); + }; + proof.authorize( + ObservationRequest { + cell, + owner, + repository: self.repository, + actor: &self.actor, + binding: self.binding(request)?, + admitted_ms, + }, + query, + ) + } + fn shape(&self) -> Result<(), CodecError> { + if crate::validate_repository_id(self.repository).is_err() + || self + .actor + .as_deref() + .is_some_and(|a| validate_component(a).is_err()) + || self.facts.len() > 128 + || self.facts.iter().map(|f| f.name.len()).sum::() > NAME_BYTES + || self.facts.windows(2).any(|p| p[0].name >= p[1].name) + || self.facts.iter().any(|f| { + !valid_ref_name(&f.name) + || f.name.len() > crate::packs::ref_state::MAX_NAME_BYTES + || f.state + .as_ref() + .is_some_and(|s| s.version < 1 || s.oid.is_some_and(|o| o.is_zero())) + }) + { + return Err(CodecError::Invalid("invalid exact ref selection")); + } + Ok(()) + } +} +impl WireValue for RefSelection { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + self.shape()?; + e.write_bytes(&self.repository)?; + self.actor.encode(e)?; + e.write_count(self.facts.len())?; + for fact in &self.facts { + e.write_text(&fact.name)?; + e.write_bool(fact.state.is_some())?; + if let Some(state) = &fact.state { + e.write_i64(state.version)?; + e.write_bool(state.oid.is_some())?; + if let Some(oid) = state.oid { + e.write_bytes(oid.as_ref())?; + } + } + } + self.proof.encode(e) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + let repository = fixed(d)?; + let actor = Option::::decode(d)?; + let count = d.read_count()?; + if count > 128 { + return Err(CodecError::Invalid("ref selection count")); + } + let mut facts = Vec::with_capacity(count); + let mut bytes = 0; + for _ in 0..count { + let name = d.read_text()?; + bytes += name.len(); + if bytes > NAME_BYTES { + return Err(CodecError::Invalid("ref selection bytes")); + } + let state = if d.read_bool()? { + Some(RefExpectation { + version: d.read_i64()?, + oid: if d.read_bool()? { + Some( + crate::ObjectId::try_from(d.read_bytes()?) + .map_err(|_| CodecError::Invalid("ref selection OID"))?, + ) + } else { + None + }, + }) + } else { + None + }; + facts.push(RefFact { + name: name.into(), + state, + }); + } + let value = Self { + repository, + actor, + facts, + proof: Option::::decode(d)?, + }; + value.shape()?; + Ok(value) + } +} diff --git a/crates/canopy-server/src/packs/publication/ref_proof.rs b/crates/canopy-server/src/packs/publication/ref_proof.rs index 8f69525b..e436eebc 100644 --- a/crates/canopy-server/src/packs/publication/ref_proof.rs +++ b/crates/canopy-server/src/packs/publication/ref_proof.rs @@ -182,6 +182,11 @@ fn ancestry_policy_page( } Ok((end, SqlBatch { statements })) } +#[derive(Clone, Copy)] +enum AncestryRequirement { + Policy, + Required, +} impl PreparedCatalog { /// Validate targets through this prepared catalog. Ancestry is computed /// only for currently enabled fast-forward rules; final publication checks @@ -194,9 +199,35 @@ impl PreparedCatalog { limits: MetadataLimits, ) -> Result { let (_, deadline) = self.base.live_lease()?; - timeout_at(deadline, self.ref_proof_inner(plan, root, budget, limits)) - .await - .map_err(|_| PreparationBaseError::Inactive)? + timeout_at( + deadline, + self.ref_proof_inner(plan, root, budget, limits, AncestryRequirement::Policy), + ) + .await + .map_err(|_| PreparationBaseError::Inactive)? + } + /// Certify every non-vacuous ancestry predicate through the privately + /// verified native catalog, independent of branch rules. Reviewed merge + /// publication must use this path: even an unprotected branch requires + /// ancestry evidence. False bits remain explicit negative facts. + /// + /// Reuses the ordinary proof/MAC and bounded disk-backed walker. This is + /// preparation only; final publication must still check current refs, + /// reviews/checks/access and its actual owner/lease in one transaction. + pub async fn ref_proof_with_required_ancestry( + &self, + plan: PushPlan, + root: &Path, + budget: DiskBudget, + limits: MetadataLimits, + ) -> Result { + let (_, deadline) = self.base.live_lease()?; + timeout_at( + deadline, + self.ref_proof_inner(plan, root, budget, limits, AncestryRequirement::Required), + ) + .await + .map_err(|_| PreparationBaseError::Inactive)? } async fn ref_proof_inner( &self, @@ -204,8 +235,11 @@ impl PreparedCatalog { root: &Path, budget: DiskBudget, limits: MetadataLimits, + ancestry: AncestryRequirement, ) -> Result { - let (plan, bits) = self.ref_evidence(plan, root, budget, limits).await?; + let (plan, bits) = self + .ref_evidence_with_ancestry(plan, root, budget, limits, ancestry) + .await?; let certificate = self .issue_certificate(Some(binding(&plan, &bits)?), None) .await?; @@ -224,6 +258,27 @@ impl PreparedCatalog { root: &Path, budget: DiskBudget, limits: MetadataLimits, + ) -> Result<(PushPlan, Vec), RefProofError> { + self.ref_evidence_with_ancestry(plan, root, budget, limits, AncestryRequirement::Policy) + .await + } + pub(super) async fn required_ref_evidence( + &self, + plan: PushPlan, + root: &Path, + budget: DiskBudget, + limits: MetadataLimits, + ) -> Result<(PushPlan, Vec), RefProofError> { + self.ref_evidence_with_ancestry(plan, root, budget, limits, AncestryRequirement::Required) + .await + } + async fn ref_evidence_with_ancestry( + &self, + plan: PushPlan, + root: &Path, + budget: DiskBudget, + limits: MetadataLimits, + ancestry: AncestryRequirement, ) -> Result<(PushPlan, Vec), RefProofError> { shape(&plan, self.catalog().format)?; if plan.actor != self.base.capability().2.actor { @@ -256,21 +311,31 @@ impl PreparedCatalog { let mut start = 0; while start < plan.updates.len() { self.ensure_live()?; - let (end, batch) = ancestry_policy_page(&plan.updates, start)?; - let policies = sql - .query(None, batch) - .await - .map_err(|error| RefProofError::Query(Box::new(error)))?; + let (end, policies) = match ancestry { + AncestryRequirement::Required => ((start + 128).min(plan.updates.len()), None), + AncestryRequirement::Policy => { + let (end, batch) = ancestry_policy_page(&plan.updates, start)?; + let policies = sql + .query(None, batch) + .await + .map_err(|error| RefProofError::Query(Box::new(error)))? + .output; + if policies.len() != end - start { + return Err(RefProofError::Invalid); + } + (end, Some(policies)) + } + }; let updates = &plan.updates[start..end]; - if policies.output.len() != updates.len() { - return Err(RefProofError::Invalid); - } - for (at, (update, policy)) in updates.iter().zip(policies.output).enumerate() { + for (at, update) in updates.iter().enumerate() { let index = start + at; - let required = match policy.rows.first().map(Vec::as_slice) { - Some([SqlValue::Integer(0)]) => false, - Some([SqlValue::Integer(1)]) => true, - _ => return Err(RefProofError::Invalid), + let required = match policies.as_ref() { + None => true, + Some(policies) => match policies[at].rows.first().map(Vec::as_slice) { + Some([SqlValue::Integer(0)]) => false, + Some([SqlValue::Integer(1)]) => true, + _ => return Err(RefProofError::Invalid), + }, }; let old = update.expected.as_ref().and_then(|old| old.oid); if old.is_none() || old == update.new_oid || update.new_oid.is_none() { diff --git a/crates/canopy-server/src/packs/publication/ref_proof/ancestry.rs b/crates/canopy-server/src/packs/publication/ref_proof/ancestry.rs index 8c5adbae..843019db 100644 --- a/crates/canopy-server/src/packs/publication/ref_proof/ancestry.rs +++ b/crates/canopy-server/src/packs/publication/ref_proof/ancestry.rs @@ -148,6 +148,131 @@ impl Walker { Ok(result) } + /// Test a bounded set against one descendant traversal. This avoids one + /// full base-history walk per original commit when verifying a rebase. + pub(in crate::packs::publication) async fn ancestors_within( + &mut self, + reader: &CatalogReader, + files: &Arc, + targets: &[ObjectId], + descendant: ObjectId, + base: &PreparationBaseResolver, + ) -> Result, RefProofError> { + if self.failed || self._cancel.0.load(Ordering::Acquire) { + return Err(RefProofError::Canceled); + } + self.failed = true; + let mut guard = WalkCancellation::new(Arc::clone(&self._cancel.0)); + let catalog = reader.stored(); + if targets.is_empty() + || targets.len() > PAGE_OBJECTS + || self.catalog.is_some_and(|c| c != catalog) + || descendant.format() != catalog.format + || targets.iter().any(|o| o.format() != catalog.format) + { + return Err(RefProofError::Invalid); + } + self.catalog = Some(catalog); + base.live_lease()?; + for ids in [targets, std::slice::from_ref(&descendant)] { + let headers = reader.headers(ids, &**files, &**files).await?; + if headers.len() != ids.len() + || headers + .iter() + .any(|h| h.is_none_or(|h| h.object.kind != ObjectKind::Commit)) + { + return Err(RefProofError::Invalid); + } + } + loop { + base.live_lease()?; + if self.call(Scratch::clear_page).await? == 0 { + break; + } + } + self.call(move |scratch| { + scratch.write(|tx| { + tx.execute("INSERT INTO visits(oid) VALUES(?1)", [descendant.as_ref()]) + .map_err(MetadataError::from)?; + Ok(()) + }) + }) + .await?; + let wanted: BTreeSet<_> = targets.iter().copied().collect(); + let mut reached = BTreeSet::new(); + 'walk: loop { + base.live_lease()?; + let page = self + .call(|scratch| { + scratch + .connection + .prepare_cached( + "SELECT oid FROM visits WHERE expanded=0 ORDER BY oid LIMIT ?1", + ) + .map_err(MetadataError::from)? + .query_map([PAGE_OBJECTS as i64], |r| { + crate::packs::metadata::oid(r.get(0)?) + }) + .map_err(MetadataError::from)? + .collect::>>() + .map_err(MetadataError::from) + .map_err(Into::into) + }) + .await?; + if page.is_empty() { + break; + } + for oid in page { + base.live_lease()?; + if wanted.contains(&oid) { + reached.insert(oid); + } + if reached.len() == wanted.len() { + break 'walk; + } + let object = reader + .lookup(oid, &**files, &**files) + .await? + .ok_or(RefProofError::Invalid)?; + if object.entry.header.object.kind != ObjectKind::Commit { + return Err(RefProofError::Invalid); + } + let mut after = None; + loop { + base.live_lease()?; + let metadata = object.source.metadata.clone(); + let edges = + tokio::task::spawn_blocking(move || metadata.edges_after(oid, after)) + .await??; + if edges.is_empty() { + break; + } + after = edges.last().map(|e| e.child); + self.parents(edges).await?; + } + self.call(move |scratch| { + scratch.write(|tx| { + if tx + .execute( + "UPDATE visits SET expanded=1 WHERE oid=?1 AND expanded=0", + [oid.as_ref()], + ) + .map_err(MetadataError::from)? + != 1 + { + return Err(RefProofError::Invalid); + } + Ok(()) + }) + }) + .await?; + } + } + base.live_lease()?; + guard.complete = true; + self.failed = false; + Ok(targets.iter().map(|o| reached.contains(o)).collect()) + } async fn walk( &self, reader: &CatalogReader, diff --git a/crates/canopy-server/src/packs/publication/registry.rs b/crates/canopy-server/src/packs/publication/registry.rs index eaadd40c..26505db1 100644 --- a/crates/canopy-server/src/packs/publication/registry.rs +++ b/crates/canopy-server/src/packs/publication/registry.rs @@ -23,8 +23,14 @@ const fn query(input_limit: u32, output_limit: u32) -> OperationDescri } } -pub(crate) const COMMANDS: [OperationDescriptor; 17] = [ +pub(crate) const COMMANDS: [OperationDescriptor; 26] = [ crate::operation(1), + command::(64 << 10, 64), + command::(NATIVE_MERGE_BYTES, 512), + command::( + crate::pulls::candidates::command::INPUT_BYTES, + crate::pulls::candidates::command::OUTPUT_BYTES, + ), command::(4096, 4096), command::(4096, 4096), command::(4096, 4096), @@ -41,8 +47,14 @@ pub(crate) const COMMANDS: [OperationDescriptor; 17] = [ command::(1024, 512), command::(1024, 128), command::(1024, 128), + command::(4096, 16), + command::(crate::pulls::native::INPUT_BYTES, 16), + command::(crate::pulls::native::INPUT_BYTES, 16), + command::(NATIVE_HEAD_BYTES, 512), + command::(NATIVE_CANDIDATE_BYTES, 512), + command::(crate::pulls::native::INPUT_BYTES, 16), ]; -pub(crate) const QUERIES: [OperationDescriptor; 11] = [ +pub(crate) const QUERIES: [OperationDescriptor; 13] = [ crate::operation(2), query::(4096, 4096), query::(4096, 4096), @@ -54,6 +66,11 @@ pub(crate) const QUERIES: [OperationDescriptor; 11] = [ query::(4096, 512), query::(1024, 1024), query::(1024, 512), + query::(4096, 256 << 10), + query::( + crate::pulls::native::INPUT_BYTES, + crate::pulls::native::OUTPUT_BYTES, + ), ]; #[cfg(test)] @@ -64,7 +81,7 @@ mod tests { use cellule_runtime::CellModule; #[test] - fn production_registers_only_the_packed_command_contract() -> cellule_runtime::Result<()> { + fn production_registers_packed_and_policy_metadata_contracts() -> cellule_runtime::Result<()> { let application = CanopyApplication::compile(build_descriptor( include_bytes!("../../../../../Cargo.lock"), env!("CARGO_PKG_VERSION"), @@ -84,7 +101,8 @@ mod tests { assert_eq!( ids, vec![ - 1, 14, 16, 17, 22, 29, 31, 33, 35, 36, 38, 39, 40, 41, 42, 43, 46 + 1, 8, 9, 10, 14, 16, 17, 22, 29, 31, 33, 35, 36, 38, 39, 40, 41, 42, 43, 46, 49, + 51, 53, 54, 55, 56 ] ); assert_eq!( @@ -93,9 +111,34 @@ mod tests { .iter() .map(|operation| operation.id) .collect::>(), - vec![2, 15, 21, 23, 27, 30, 32, 34, 37, 47, 48] + vec![2, 15, 21, 23, 27, 30, 32, 34, 37, 47, 48, 50, 52] ); for (id, codec, input, output) in [ + ( + 56, + crate::pulls::native::CreateNativeThread::CODEC_VERSION, + crate::pulls::native::INPUT_BYTES, + 16, + ), + ( + 55, + PublishNativeCandidate::CODEC_VERSION, + NATIVE_CANDIDATE_BYTES, + 512, + ), + (54, PublishNativeHead::CODEC_VERSION, NATIVE_HEAD_BYTES, 512), + ( + 10, + crate::pulls::candidates::command::PrepareCandidate::CODEC_VERSION, + crate::pulls::candidates::command::INPUT_BYTES, + crate::pulls::candidates::command::OUTPUT_BYTES, + ), + ( + 9, + PublishReviewedMerge::CODEC_VERSION, + NATIVE_MERGE_BYTES, + 512, + ), ( 33, RegisterRefPolicyPage::CODEC_VERSION, @@ -120,6 +163,24 @@ mod tests { (42, ExecuteCustody::CODEC_VERSION, 1024, 512), (43, StopCustodyIntent::CODEC_VERSION, 1024, 128), (46, ReleaseServingPin::CODEC_VERSION, 1024, 128), + ( + 49, + crate::checks::native::StartCommitCheck::CODEC_VERSION, + 4096, + 16, + ), + ( + 51, + crate::pulls::native::CreateNativePull::CODEC_VERSION, + crate::pulls::native::INPUT_BYTES, + 16, + ), + ( + 53, + crate::pulls::native::ReviewNativePull::CODEC_VERSION, + crate::pulls::native::INPUT_BYTES, + 16, + ), ] { let operation = descriptor .commands diff --git a/crates/canopy-server/src/packs/publication/root_completion/mod.rs b/crates/canopy-server/src/packs/publication/root_completion/mod.rs index 363a8265..4448c3fd 100644 --- a/crates/canopy-server/src/packs/publication/root_completion/mod.rs +++ b/crates/canopy-server/src/packs/publication/root_completion/mod.rs @@ -150,3 +150,5 @@ impl RootPushCompletion { Ok(()) } } + +pub(in crate::packs::publication) use retention::backup_graph; diff --git a/crates/canopy-server/src/packs/publication/root_completion/publish.rs b/crates/canopy-server/src/packs/publication/root_completion/publish.rs index 614a7f50..002ffda4 100644 --- a/crates/canopy-server/src/packs/publication/root_completion/publish.rs +++ b/crates/canopy-server/src/packs/publication/root_completion/publish.rs @@ -187,6 +187,10 @@ impl CompleteRootPush { "UPDATE catalog_state SET generation=?1 WHERE singleton=1 AND generation=?2", vec![number(value.generation)?, number(data.base.generation)?], ))?)?; + changed(context.sql(&statement( + "UPDATE ref_generation SET generation=?1 WHERE singleton=1", + vec![number(value.ref_generation)?], + ))?)?; } Ok(CommandResult::Success(terminal.save(context)?)) } diff --git a/crates/canopy-server/src/packs/publication/root_completion/read.rs b/crates/canopy-server/src/packs/publication/root_completion/read.rs index a8e019b5..39e6505e 100644 --- a/crates/canopy-server/src/packs/publication/root_completion/read.rs +++ b/crates/canopy-server/src/packs/publication/root_completion/read.rs @@ -168,3 +168,14 @@ pub async fn replay_root_push_response( body, })) } + +/// Read the retained native annotation selected by an already-authorized +/// repository audit row. Keep the bounded completion command independent of +/// the potentially large options field; the outcome roots retain it for GC. +pub(in crate::packs::publication) async fn selected_options( + root: NativeOutcomeRoot, + store: &ArtifactStore, +) -> Result, Box> { + let record: OutcomeRecord = root.0.read(store, INPUT_ROOT_BYTES).await?; + Ok(record.native.read(store).await?.options) +} diff --git a/crates/canopy-server/src/packs/publication/root_completion/retention.rs b/crates/canopy-server/src/packs/publication/root_completion/retention.rs index 989d2709..d2136b3d 100644 --- a/crates/canopy-server/src/packs/publication/root_completion/retention.rs +++ b/crates/canopy-server/src/packs/publication/root_completion/retention.rs @@ -45,3 +45,15 @@ async fn verify( while reader.next().await?.is_some() {} super::super::recovery::archive::descriptor(hash, key.operation, key.kind, body) } + +pub(in crate::packs::publication) async fn backup_graph( + root: NativeOutcomeRoot, + inventory: &mut crate::packs::backup::Inventory<'_>, +) -> crate::packs::directory::index::WalkResult<()> { + let record: OutcomeRecord = root.0.read(&inventory.store(), INPUT_ROOT_BYTES).await?; + inventory.input(root.operation(), root.artifact()).await?; + super::super::native_result::backup_graph(record.native, false, inventory).await?; + inventory + .body(record.body_operation, record.response.body) + .await +} diff --git a/crates/canopy-server/src/packs/publication/schema.sql b/crates/canopy-server/src/packs/publication/schema.sql index 6bf84189..c10abbc8 100644 --- a/crates/canopy-server/src/packs/publication/schema.sql +++ b/crates/canopy-server/src/packs/publication/schema.sql @@ -238,8 +238,8 @@ CREATE TABLE pull_requests ( state TEXT NOT NULL CHECK(state IN ('open', 'closed', 'merged')), draft INTEGER NOT NULL CHECK(draft IN (0, 1)), version INTEGER NOT NULL CHECK(typeof(version) = 'integer' AND version > 0), - source_ref TEXT NOT NULL REFERENCES refs(name), - base_ref TEXT NOT NULL REFERENCES refs(name) CHECK(source_ref != base_ref), + source_ref TEXT NOT NULL, + base_ref TEXT NOT NULL CHECK(source_ref != base_ref), initial_source_oid BLOB NOT NULL CHECK(length(initial_source_oid) IN (20, 32)), initial_base_oid BLOB NOT NULL CHECK(length(initial_base_oid) IN (20, 32)), created_ms INTEGER NOT NULL CHECK(created_ms >= 0), @@ -282,8 +282,19 @@ CREATE TABLE pull_merges ( source_oid BLOB NOT NULL CHECK(length(source_oid) IN (20, 32)), source_version INTEGER NOT NULL CHECK(source_version > 0), base_oid BLOB NOT NULL CHECK(length(base_oid) IN (20, 32)), - base_version INTEGER NOT NULL CHECK(base_version > 0) + base_version INTEGER NOT NULL CHECK(base_version > 0), + publication BLOB NOT NULL CHECK(length(publication) BETWEEN 1 AND 128), + strategy TEXT NOT NULL DEFAULT 'fast_forward' CHECK(strategy IN ('fast_forward','merge_commit','squash','rebase')), + candidate_id BLOB REFERENCES merge_candidates(id) CHECK(candidate_id IS NULL OR length(candidate_id)=16), + CHECK((strategy='fast_forward' AND candidate_id IS NULL) OR (strategy!='fast_forward' AND candidate_id IS NOT NULL)) ) WITHOUT ROWID; +CREATE TRIGGER pull_merge_immutable BEFORE UPDATE ON pull_merges +BEGIN SELECT RAISE(ABORT,'merge result immutable'); END; +CREATE TRIGGER pull_merge_not_replaced BEFORE INSERT ON pull_merges +WHEN EXISTS(SELECT 1 FROM pull_merges WHERE id=NEW.id OR pull_number=NEW.pull_number) +BEGIN SELECT RAISE(ABORT,'merge result immutable'); END; +CREATE TRIGGER pull_merge_retained BEFORE DELETE ON pull_merges +BEGIN SELECT RAISE(ABORT,'merge audit retained'); END; CREATE TABLE merge_candidates ( id BLOB PRIMARY KEY CHECK(length(id) = 16), @@ -295,8 +306,20 @@ CREATE TABLE merge_candidates ( result TEXT NOT NULL CHECK(length(CAST(result AS BLOB)) <= 262144), source_oid BLOB NOT NULL CHECK(length(source_oid) IN (20, 32)), base_oid BLOB NOT NULL CHECK(length(base_oid) IN (20, 32)), - oid BLOB CHECK(oid IS NULL OR length(oid) IN (20, 32)) + oid BLOB CHECK(oid IS NULL OR length(oid) IN (20, 32)), + native_publication BLOB CHECK(native_publication IS NULL OR (typeof(native_publication)='blob' AND length(native_publication) BETWEEN 1 AND 512)) ) WITHOUT ROWID; +CREATE TRIGGER candidate_intent_immutable BEFORE UPDATE ON merge_candidates +WHEN NEW.id IS NOT OLD.id OR NEW.binding IS NOT OLD.binding OR NEW.pull_number IS NOT OLD.pull_number OR NEW.actor IS NOT OLD.actor OR NEW.request IS NOT OLD.request OR NEW.created_ms IS NOT OLD.created_ms OR NEW.source_oid IS NOT OLD.source_oid OR NEW.base_oid IS NOT OLD.base_oid +BEGIN SELECT RAISE(ABORT,'candidate intent immutable'); END; +CREATE TRIGGER candidate_completed_immutable BEFORE UPDATE ON merge_candidates +WHEN json_extract(OLD.result,'$.state') IS NOT 'pending' AND (NEW.result IS NOT OLD.result OR NEW.oid IS NOT OLD.oid OR NEW.native_publication IS NOT OLD.native_publication) +BEGIN SELECT RAISE(ABORT,'candidate result immutable'); END; +CREATE TRIGGER candidate_intent_not_replaced BEFORE INSERT ON merge_candidates +WHEN EXISTS(SELECT 1 FROM merge_candidates WHERE id=NEW.id) +BEGIN SELECT RAISE(ABORT,'candidate intent retained'); END; +CREATE TRIGGER candidate_intent_retained BEFORE DELETE ON merge_candidates +BEGIN SELECT RAISE(ABORT,'candidate intent retained'); END; CREATE TABLE pull_threads ( number INTEGER PRIMARY KEY AUTOINCREMENT, @@ -587,3 +610,19 @@ BEGIN SELECT RAISE(ABORT, 'custody command must be retained'); END; CREATE TRIGGER catalog_custody_stop_immutable BEFORE UPDATE OF stopped ON catalog_custody_commands WHEN OLD.stopped IS NOT NULL AND NEW.stopped IS NOT OLD.stopped BEGIN SELECT RAISE(ABORT, 'custody retirement is immutable'); END; + +-- Symbolic HEAD outcomes retain the original joint roots for exact retirement. +CREATE TABLE catalog_head_updates ( + id BLOB PRIMARY KEY CHECK(length(id)=16), + actor TEXT NOT NULL, + request_digest BLOB NOT NULL CHECK(length(request_digest)=32), + request BLOB NOT NULL CHECK(length(request)<=131072), + result BLOB NOT NULL CHECK(length(result)<=512), + fact BLOB NOT NULL CHECK(length(fact)<=512) +) STRICT; +CREATE TRIGGER catalog_head_updates_immutable BEFORE UPDATE ON catalog_head_updates BEGIN + SELECT RAISE(ABORT, 'symbolic HEAD outcome is immutable'); +END; +CREATE TRIGGER catalog_head_updates_retained BEFORE DELETE ON catalog_head_updates BEGIN + SELECT RAISE(ABORT, 'symbolic HEAD outcome is retained'); +END; diff --git a/crates/canopy-server/src/packs/publication/serving/lifecycle.rs b/crates/canopy-server/src/packs/publication/serving/lifecycle.rs index 4cdbe315..2321de21 100644 --- a/crates/canopy-server/src/packs/publication/serving/lifecycle.rs +++ b/crates/canopy-server/src/packs/publication/serving/lifecycle.rs @@ -116,6 +116,23 @@ pub struct ServingSnapshot { _borrow: Arc, } impl ServingSnapshot { + pub(crate) async fn ref_selection( + &self, + request: [u8; 32], + names: &[String], + ) -> Result { + self.pin + .ref_selection(self.actor.clone(), request, names) + .await + } + + pub(crate) async fn commit_membership( + &self, + oid: crate::ObjectId, + ) -> Result, ServingReadError> { + self.pin.commit_membership(self.actor.clone(), oid).await + } + pub(crate) async fn resolve_refs( &self, names: &[String], diff --git a/crates/canopy-server/src/packs/publication/serving/pool.rs b/crates/canopy-server/src/packs/publication/serving/pool.rs index 770d74e0..76e6b6ca 100644 --- a/crates/canopy-server/src/packs/publication/serving/pool.rs +++ b/crates/canopy-server/src/packs/publication/serving/pool.rs @@ -5,10 +5,16 @@ use std::sync::{ Weak, atomic::{AtomicBool, Ordering}, }; -use tokio::sync::Mutex; +use tokio::{ + sync::Mutex, + time::{Duration, Instant, timeout_at}, +}; use tokio_util::{sync::CancellationToken, task::TaskTracker}; pub const MAX_SERVING_GENERATIONS: u8 = 4; +// Bound detached request observers as well as latency. The producer retains its +// slot and exact release after this wait expires; timeout is never reclamation. +const ROLLOVER_WAIT: Duration = Duration::from_secs(2); #[derive(Clone, Copy, Debug)] pub struct ServingPoolLimits { pub generations: u8, @@ -25,6 +31,7 @@ impl Default for ServingPoolLimits { struct Slot { requested_generation: u64, touched: u64, + retiring: bool, owner: ServingOwner, } struct State { @@ -41,6 +48,8 @@ struct Inner { stop: CancellationToken, requests: TaskTracker, drain: TaskTracker, + #[cfg(test)] + selection_started: std::sync::Mutex>>, } struct Lifetime(Weak); impl Drop for Lifetime { @@ -94,6 +103,8 @@ impl ServingPool { stop: CancellationToken::new(), requests: TaskTracker::new(), drain: TaskTracker::new(), + #[cfg(test)] + selection_started: std::sync::Mutex::new(None), }); let work = inner.clone(); inner.drain.spawn(async move { @@ -158,6 +169,18 @@ impl ServingPool { .map_err(ServingReadError::Task)? } #[cfg(test)] + pub(in crate::packs::publication) fn observe_selection_for_test( + &self, + ) -> tokio::sync::oneshot::Receiver<()> { + let (sender, receiver) = tokio::sync::oneshot::channel(); + *self + .inner + .selection_started + .lock() + .expect("selection observer") = Some(sender); + receiver + } + #[cfg(test)] pub(in crate::packs::publication) async fn owners_for_test(&self) -> Vec { self.inner .state @@ -178,77 +201,130 @@ impl Inner { if self.stop.is_cancelled() || self.paused.load(Ordering::Acquire) { return Err(ServingReadError::Inactive.into()); } - let selected = self.context.select(actor.clone()).await?; - let owner = { - let mut state = self.state.lock().await; - if state.closed || self.stop.is_cancelled() || self.paused.load(Ordering::Acquire) { - return Err(ServingReadError::Inactive.into()); + let mut rollover_deadline = None; + // Concurrent viewers may consume a released slot first. Bound retries + // even under continuous publication; admission already bounds waiters. + for _ in 0..=self.limits.generations { + // Permission and current generation can change while release waits. + #[cfg(test)] + if let Some(observer) = self + .selection_started + .lock() + .expect("selection observer") + .take() + { + let _ = observer.send(()); + } + let selected = self.context.select(actor.clone()).await?; + enum Selection { + Borrow(ServingOwner), + Retire(ServingOwner), } - state.slots.retain(|slot| !slot.owner.is_drained()); - state.clock = state.clock.saturating_add(1); - let touched = state.clock; - if let Some(slot) = state.slots.iter_mut().find(|slot| { - let stats = slot.owner.stats(); - match stats.token { - Some(token) => { - stats.phase == ServingOwnerPhase::Ready - && token.generation == selected.generation + let selection = { + let mut state = self.state.lock().await; + if state.closed || self.stop.is_cancelled() || self.paused.load(Ordering::Acquire) { + return Err(ServingReadError::Inactive.into()); + } + state.slots.retain(|slot| !slot.owner.is_drained()); + state.clock = state.clock.saturating_add(1); + let touched = state.clock; + if let Some(slot) = state.slots.iter_mut().find(|slot| { + if slot.retiring { + return false; } - None => { - stats.phase == ServingOwnerPhase::Acquiring - && slot.requested_generation == selected.generation + let stats = slot.owner.stats(); + match stats.token { + Some(token) => { + stats.phase == ServingOwnerPhase::Ready + && token.generation == selected.generation + } + None => { + stats.phase == ServingOwnerPhase::Acquiring + && slot.requested_generation == selected.generation + } } - } - }) { - slot.touched = touched; - slot.owner.clone() - } else { - if state.slots.len() >= usize::from(self.limits.generations) { - // Keep the closing slot until its real producer finishes. - // Retry is explicit; there is no unbounded retired inventory - // or wait behind old provider I/O inside the pool lock. + }) { + slot.touched = touched; + Selection::Borrow(slot.owner.clone()) + } else if state.slots.len() >= usize::from(self.limits.generations) { + // No slot leaves the inventory before its real producer + // exits. Closing is shared; no waiter owns a replacement + // release or a second retired-owner inventory. let mut order: Vec<_> = (0..state.slots.len()).collect(); order.sort_by_key(|i| state.slots[*i].touched); - for i in order { - if state.slots[i].owner.retire_if_idle() { - break; + let retiring = order + .iter() + .copied() + .find(|i| { + let slot = &mut state.slots[*i]; + if !slot.retiring && slot.owner.retire_if_idle() { + slot.retiring = true; + true + } else { + false + } + }) + .or_else(|| order.into_iter().find(|i| state.slots[*i].retiring)); + match retiring { + Some(i) => Selection::Retire(state.slots[i].owner.clone()), + None => return Err(generation_capacity()), + } + } else { + let operation = *uuid::Uuid::new_v4().as_bytes(); + let mut digest = blake3::Hasher::new(); + digest.update(b"canopy.serving-pool.v1"); + digest.update(&self.context.repository()); + digest.update(&operation); + let owner = ServingOwner::start( + self.context.clone(), + self.coordinator.clone(), + BeginRequest { + repository: self.context.repository(), + operation, + request_digest: *digest.finalize().as_bytes(), + actor: self.context.administrator().to_owned(), + lease_ms: self.limits.lease_ms, + }, + crate::server::mutation_identity() + .map_err(|error| ServingOwnerError::Clock(Box::new(error)))?, + ) + .await?; + state.slots.push(Slot { + requested_generation: selected.generation, + touched, + retiring: false, + owner: owner.clone(), + }); + Selection::Borrow(owner) + } + }; + match selection { + Selection::Borrow(owner) => { + // Acquisition may accept a newer fact than selection. + return Ok(owner.snapshot_admitted(actor, permit).await?); + } + Selection::Retire(owner) => { + // Never wait for provider I/O or exact release under the + // pool lock. Shutdown can cancel this bounded observation + // without canceling the independently owned producer. + // Initial Cell selection may queue behind unrelated work. + // Charge the observation budget only once retirement starts; + // subsequent retries share it rather than extending it. + let deadline = + *rollover_deadline.get_or_insert_with(|| Instant::now() + ROLLOVER_WAIT); + let drain = owner.drain_observer(); + tokio::select! { + _ = self.stop.cancelled() => return Err(ServingReadError::Inactive.into()), + result = timeout_at(deadline, drain.wait()) => { + if result.is_err() { + return Err(generation_capacity()); + } } } - return Err(ServingReadError::Capability(Error::Capacity( - "repository serving generations", - )) - .into()); } - let operation = *uuid::Uuid::new_v4().as_bytes(); - let mut digest = blake3::Hasher::new(); - digest.update(b"canopy.serving-pool.v1"); - digest.update(&self.context.repository()); - digest.update(&operation); - let owner = ServingOwner::start( - self.context.clone(), - self.coordinator.clone(), - BeginRequest { - repository: self.context.repository(), - operation, - request_digest: *digest.finalize().as_bytes(), - actor: self.context.administrator().to_owned(), - lease_ms: self.limits.lease_ms, - }, - crate::server::mutation_identity() - .map_err(|error| ServingOwnerError::Clock(Box::new(error)))?, - ) - .await?; - state.slots.push(Slot { - requested_generation: selected.generation, - touched, - owner: owner.clone(), - }); - owner } - }; - // The accepted acquisition may select a newer fact than the observation. - // Return its fact; never label that capability with the requested hint. - Ok(owner.snapshot_admitted(actor, permit).await?) + } + Err(generation_capacity()) } async fn quiesce(self: Arc) -> Result { let state = self.state.lock().await; @@ -295,3 +371,7 @@ impl Inner { Ok(true) } } + +fn generation_capacity() -> ServingOwnerError { + ServingReadError::Capability(Error::Capacity("repository serving generations")).into() +} diff --git a/crates/canopy-server/src/packs/publication/serving/session.rs b/crates/canopy-server/src/packs/publication/serving/session.rs index 2e1a51d6..88e43aae 100644 --- a/crates/canopy-server/src/packs/publication/serving/session.rs +++ b/crates/canopy-server/src/packs/publication/serving/session.rs @@ -13,7 +13,9 @@ use tokio::{sync::Notify, time::Instant}; use tokio_util::{sync::CancellationToken, task::TaskTracker}; mod body; mod edges; +mod membership; mod native_base; +mod ref_observation; mod workspace; pub use edges::{MAX_EDGE_PARENTS, ServingEdgePage}; pub use workspace::{NativeWorkspace, WorkspaceLimits, WorkspaceStats}; diff --git a/crates/canopy-server/src/packs/publication/serving/session/membership.rs b/crates/canopy-server/src/packs/publication/serving/session/membership.rs new file mode 100644 index 00000000..0a7ce137 --- /dev/null +++ b/crates/canopy-server/src/packs/publication/serving/session/membership.rs @@ -0,0 +1,69 @@ +//! Private issuance follows certified header lookup under tracked physical ownership. +use super::super::super::commit_membership::{CommitMembership, MembershipData}; +use super::super::super::{attestation, sql}; +use super::*; + +impl ServingPin { + pub(in crate::packs::publication::serving) async fn commit_membership( + &self, + actor: Option, + oid: crate::ObjectId, + ) -> Result, ServingReadError> { + if oid.is_zero() || oid.format() != self.inner.lease.format { + return Ok(None); + } + self.read_owned(actor.clone(), move |inner, deadline, _permit| async move { + let reader = inner.catalog().await?; + let header = reader + .headers(&[oid], &*inner.context.files, &*inner.context.files) + .await? + .pop() + .flatten(); + if header.is_none_or(|h| h.object.kind != crate::ObjectKind::Commit) { + return Ok(None); + } + if Instant::now() >= deadline { + return Err(ServingReadError::Inactive); + } + let capability = SqlCell::::new( + inner.context.client.clone(), + inner.context.target.clone(), + )?; + let result = capability + .query(None, SqlBatch { + statements: vec![ + SqlStatement { + sql: "SELECT push_cert_seed FROM repository_identity WHERE singleton=1 AND repository_id=?1 AND object_format=?2".into(), + parameters: vec![ + sql::blob(inner.lease.token.repository), + SqlValue::Text(oid.format().as_str().into()), + ], + }, + SqlStatement { + sql: sql::GENERATION.into(), + parameters: vec![sql::number(inner.lease.token.generation)?], + }, + ], + }) + .await + .map_err(|e| ServingReadError::Proof(Box::new(e)))?; + let generation = sql::generation( + result.output.get(1..).ok_or(ServingReadError::Context)?, + inner.lease.token.repository, + oid.format(), + )?; + if generation != inner.lease.fact { + return Err(ServingReadError::Context); + } + let seed = attestation::seed(&result.output)?; + Ok(Some(CommitMembership::seal(MembershipData { + tenant: *inner.context.target.tenant().as_bytes(), + application: *inner.context.target.application().as_bytes(), + token: inner.lease.token, + fact: inner.lease.fact, + actor, + oid, + }, &seed)?)) + }).await + } +} diff --git a/crates/canopy-server/src/packs/publication/serving/session/native_base.rs b/crates/canopy-server/src/packs/publication/serving/session/native_base.rs index 2969b1e7..56ea4f55 100644 --- a/crates/canopy-server/src/packs/publication/serving/session/native_base.rs +++ b/crates/canopy-server/src/packs/publication/serving/session/native_base.rs @@ -53,7 +53,7 @@ impl ServingPin { inner .context .files - .install_workspace(cache.clone(), native, owner.clone()) + .install_pack_workspace(cache.clone(), native, owner.clone()) .await?; observation.refresh(&inner, &actor).await?; job(&spool, owner.clone(), move |s| s.imported(native)).await?; @@ -69,7 +69,7 @@ impl ServingPin { .await?; let mut names = inner.context.indexes.refs().cursor(refs.root, None, true)?; let mut writer = cache - .serving_refs(owner.clone()) + .serving_refs(owner.clone(), false) .await .map_err(crate::packs::catalog::NativeReadError::from)?; loop { diff --git a/crates/canopy-server/src/packs/publication/serving/session/ref_observation.rs b/crates/canopy-server/src/packs/publication/serving/session/ref_observation.rs new file mode 100644 index 00000000..7d6b4a8f --- /dev/null +++ b/crates/canopy-server/src/packs/publication/serving/session/ref_observation.rs @@ -0,0 +1,55 @@ +//! Issuance reads immutable ref facts under the existing tracked physical owner. +use super::super::super::ref_observation::{ + ObservationData, RefFact, RefObservation, RefSelection, +}; +use super::super::super::{attestation, sql}; +use super::*; + +impl ServingPin { + pub(in crate::packs::publication::serving) async fn ref_selection( + &self, + actor: Option, + request: [u8; 32], + names: &[String], + ) -> Result { + if names.len() > 128 + || names.iter().map(String::len).sum::() > 512 << 10 + || names.windows(2).any(|p| p[0] >= p[1]) + || names.iter().any(|n| { + !crate::refs::valid_ref_name(n) || n.len() > crate::packs::ref_state::MAX_NAME_BYTES + }) + { + return Err(ServingReadError::Context); + } + let names = names.to_vec(); + self.read_owned(actor.clone(), move |inner, deadline, _permit| async move { + let snapshot = inner.ref_snapshot().await?; + let mut facts = Vec::with_capacity(names.len()); + for name in names { + if Instant::now() >= deadline { return Err(ServingReadError::Inactive) } + let state = inner.context.indexes.refs().read(snapshot.root.clone(), &name).await?; + facts.push(RefFact { name, state }); + } + let mut selection = RefSelection { repository: inner.lease.token.repository, actor: actor.clone(), facts, proof: None }; + let binding = selection.binding(request)?; + let capability = SqlCell::::new(inner.context.client.clone(), inner.context.target.clone())?; + let result = capability.query(None, SqlBatch { statements: vec![ + SqlStatement { + sql: "SELECT push_cert_seed FROM repository_identity WHERE singleton=1 AND repository_id=?1 AND object_format=?2".into(), + parameters: vec![sql::blob(inner.lease.token.repository), SqlValue::Text(inner.lease.format.as_str().into())], + }, + SqlStatement { sql: sql::GENERATION.into(), parameters: vec![sql::number(inner.lease.token.generation)?] }, + ]}).await.map_err(|e| ServingReadError::Proof(Box::new(e)))?; + if sql::generation(result.output.get(1..).ok_or(ServingReadError::Context)?, inner.lease.token.repository, inner.lease.format)? != inner.lease.fact { + return Err(ServingReadError::Context) + } + let seed = attestation::seed(&result.output)?; + selection.proof = Some(RefObservation::seal(ObservationData { + tenant: *inner.context.target.tenant().as_bytes(), application: *inner.context.target.application().as_bytes(), + token: inner.lease.token, fact: inner.lease.fact, actor, binding, + }, &seed)?); + if Instant::now() >= deadline { return Err(ServingReadError::Inactive) } + Ok(selection) + }).await + } +} diff --git a/crates/canopy-server/src/packs/publication/serving/session/workspace.rs b/crates/canopy-server/src/packs/publication/serving/session/workspace.rs index ab672874..a1f45ea9 100644 --- a/crates/canopy-server/src/packs/publication/serving/session/workspace.rs +++ b/crates/canopy-server/src/packs/publication/serving/session/workspace.rs @@ -3,11 +3,7 @@ //! membership spool authorizes wants; file presence never grants reachability. use super::*; use crate::packs::{catalog::graph_spool::GraphSpool, metadata::MetadataError}; -use crate::{ - ObjectId, - git_cache::GitCache, - git_objects::{GitObjects, ReadOwner}, -}; +use crate::{ObjectId, git_cache::GitCache, git_objects::ReadOwner}; #[derive(Clone, Copy, Debug)] pub struct WorkspaceLimits { @@ -38,6 +34,7 @@ struct Core { pin: ServingPin, actor: Option, stats: WorkspaceStats, + complete_packs: bool, } impl NativeWorkspace { pub(crate) fn backend(&self, nonce_seed: Option<[u8; 32]>) -> crate::git_http::GitHttpBackend { @@ -58,6 +55,7 @@ impl NativeWorkspace { } // Raw paths/owners stay crate-private. A decoded DTO cannot mint a native // read capability; producers must authorize requests and use contains. + #[cfg(test)] pub(crate) fn git_dir(&self) -> std::path::PathBuf { self.core.cache.git_dir() } @@ -129,18 +127,25 @@ impl NativeWorkspace { if expected.size > limit as u64 { return Err(ServingReadError::TooLarge); } - let mut objects = - GitObjects::batch_owned(&workspace.git_dir(), &core.cache.native, owner) - .map_err(crate::packs::catalog::NativeReadError::from)?; - let body = objects - .read_verified(expected, limit) - .await - .map_err(crate::packs::catalog::NativeReadError::from)?; - objects - .finish() - .await + if core.complete_packs { + let mut objects = crate::git_objects::GitObjects::batch_owned( + &core.cache.git_dir(), + &core.cache.native, + owner, + ) .map_err(crate::packs::catalog::NativeReadError::from)?; - Ok(Some(body)) + let body = objects + .read_verified(expected, limit) + .await + .map_err(crate::packs::catalog::NativeReadError::from)?; + objects + .finish() + .await + .map_err(crate::packs::catalog::NativeReadError::from)?; + Ok(Some(body)) + } else { + Ok(Some(inner.context.files.body(object, limit, owner).await?)) + } }, ) .await @@ -173,6 +178,7 @@ impl ServingPin { return Err(ServingReadError::Context); } let roots = roots.map(<[ObjectId]>::to_vec); + let materialize = roots.is_some(); let pin = self.clone(); self.read_session( actor.clone(), @@ -224,7 +230,7 @@ impl ServingPin { .refs() .cursor(refs.root.clone(), None, true)?; let mut writer = cache - .serving_refs(owner.clone()) + .serving_refs(owner.clone(), false) .await .map_err(crate::packs::catalog::NativeReadError::from)?; loop { @@ -282,11 +288,17 @@ impl ServingPin { let source = object.source.record.native(); source.validate(inner.context.repository(), inner.lease.format)?; if !job(&spool, owner.clone(), move |s| s.pack_seen(source)).await? { - inner - .context - .files - .install_workspace(cache.clone(), source, owner.clone()) - .await?; + // Producer workspaces need a complete native + // baseline. Install each certified pair once, rather + // than expanding every object into loose copies. + if materialize { + inner + .context + .files + .install_pack_workspace(cache.clone(), source, owner.clone()) + .await?; + observation.refresh(&inner, &actor).await?; + } observation.refresh(&inner, &actor).await?; job(&spool, owner.clone(), move |s| s.imported(source)).await?; stats.packs = stats @@ -345,6 +357,7 @@ impl ServingPin { pin, actor, stats, + complete_packs: materialize, }), }) }, @@ -389,3 +402,5 @@ impl Observation { Ok(()) } } + +mod prepare; diff --git a/crates/canopy-server/src/packs/publication/serving/session/workspace/prepare.rs b/crates/canopy-server/src/packs/publication/serving/session/workspace/prepare.rs new file mode 100644 index 00000000..89473ba8 --- /dev/null +++ b/crates/canopy-server/src/packs/publication/serving/session/workspace/prepare.rs @@ -0,0 +1,405 @@ +//! Requested structural closure, then native Git's exact blob filter selection. +use super::*; +use crate::{ObjectKind, git_objects::GitObjectWalk}; + +const SELECTION_PAGE: usize = 128; + +// Native size inspection consumes bodies even when the final response omits +// them. Retain only the candidates admitted by the other combined filters. +fn size_candidates(filter: &str, depth: usize) -> Result, ServingReadError> { + if depth > 128 { + return Err(ServingReadError::TooLarge); + } + if filter.starts_with("blob:limit=") { + return Ok(None); + } + let Some(parts) = filter.strip_prefix("combine:") else { + return Ok(Some(filter.into())); + }; + let mut selected = Vec::new(); + for part in parts.split('+') { + let decoded = percent_encoding::percent_decode_str(part) + .decode_utf8() + .map_err(|_| ServingReadError::Context)?; + if let Some(value) = size_candidates(&decoded, depth + 1)? { + selected.push(value); + } + } + match selected.len() { + 0 => Ok(None), + 1 => Ok(selected.pop()), + _ => Ok(Some(format!( + "combine:{}", + selected + .iter() + .map(|value| percent_encoding::utf8_percent_encode( + value, + percent_encoding::NON_ALPHANUMERIC + ) + .to_string()) + .collect::>() + .join("+") + ))), + } +} + +impl NativeWorkspace { + pub(crate) async fn prepare_advertisement(&self) -> Result<(), ServingReadError> { + let core = self.core.clone(); + let actor = core.actor.clone(); + self.core + .pin + .read_session( + actor.clone(), + move |inner, deadline, permit| async move { + let owner: ReadOwner = Arc::new((inner.child(), permit, core.clone())); + let mut observation = Observation { + deadline, + next: Instant::now(), + }; + let refs = inner.ref_snapshot().await?.clone(); + let mut cursor = + inner + .context + .indexes + .refs() + .cursor(refs.root.clone(), None, true)?; + let mut writer = core + .cache + .serving_refs(owner.clone(), true) + .await + .map_err(crate::packs::catalog::NativeReadError::from)?; + let mut page = Vec::with_capacity(crate::refs::REF_PAGE_SIZE); + while let Some(record) = cursor.next().await? { + observation.refresh(&inner, &actor).await?; + let mut id = record.state().oid.ok_or(ServingReadError::Context)?; + let mut finished = false; + let mut peeled = None; + let mut expected = None; + // Certified metadata supplies peeled targets. Native Git can + // advertise fully peeled packed refs without pack bodies. + for _ in 0..128 { + let reader = inner.catalog().await?; + let object = reader + .lookup(id, &*inner.context.files, &*inner.context.files) + .await? + .ok_or(ServingReadError::Context)?; + let kind = object.entry.header.object.kind; + if expected.is_some_and(|expected| expected != kind) { + return Err(ServingReadError::Context); + } + + observation.refresh(&inner, &actor).await?; + if kind != ObjectKind::Tag { + finished = true; + break; + } + let metadata = object.source.metadata; + let held = owner.clone(); + let edges = tokio::task::spawn_blocking(move || { + let _owner = held; + metadata.edges_after(id, None) + }) + .await? + .map_err(crate::packs::directory::index::IndexError::from)?; + if edges.len() != 1 { + return Err(ServingReadError::Context); + } + id = edges[0].child; + peeled = Some(id); + expected = Some(edges[0].expected_kind); + } + if !finished { + return Err(ServingReadError::TooLarge); + } + page.push((record.name().to_owned(), record.state().clone(), peeled)); + if page.len() == crate::refs::REF_PAGE_SIZE { + writer = writer + .append_peeled(std::mem::replace( + &mut page, + Vec::with_capacity(crate::refs::REF_PAGE_SIZE), + )) + .await + .map_err(crate::packs::catalog::NativeReadError::from)?; + } + } + if !page.is_empty() { + writer = writer + .append_peeled(page) + .await + .map_err(crate::packs::catalog::NativeReadError::from)?; + } + writer + .finish() + .await + .map_err(crate::packs::catalog::NativeReadError::from)?; + inner.observe(actor).await?; + Ok(()) + }, + true, + ) + .await + } + + pub(crate) async fn prepare_fetch( + &self, + roots: Vec, + filter: Option, + needs_blob_sizes: bool, + ) -> Result<(), ServingReadError> { + if roots.is_empty() { + return Ok(()); + } + if roots.len() > crate::git_input::MAX_FETCH_REQUEST_BYTES as usize / 40 + || roots + .iter() + .any(|id| id.is_zero() || id.format() != self.object_format()) + { + return Err(ServingReadError::Context); + } + let core = self.core.clone(); + let actor = core.actor.clone(); + self.core + .pin + .read_session( + actor.clone(), + move |inner, deadline, permit| async move { + let owner: ReadOwner = Arc::new((inner.child(), permit, core.clone())); + let limits = WorkspaceLimits::default(); + let spool = inner + .context + .files + .graph_spool( + limits.max_spool_bytes, + limits.cache_kib, + owner.clone(), + owner.clone(), + ) + .await + .map_err(crate::packs::directory::index::IndexError::from)?; + let mut observation = Observation { + deadline, + next: Instant::now(), + }; + let reader = inner.catalog().await?; + for ids in roots.chunks(PAGE_OBJECTS) { + let ids = ids.to_vec(); + if !job(&core.spool, owner.clone(), { + let ids = ids.clone(); + move |s| s.contains(&ids) + }) + .await? + .into_iter() + .all(|present| present) + { + return Err(ServingReadError::Context); + } + job(&spool, owner.clone(), { + let ids = ids.clone(); + move |s| { + s.add(&ids.into_iter().map(|id| (id, None)).collect::>()) + } + }) + .await?; + // Explicit blob wants override a filter. They must exist before + // rev-list, which cannot start from a missing root object. + for id in ids { + observation.refresh(&inner, &actor).await?; + let object = reader + .lookup(id, &*inner.context.files, &*inner.context.files) + .await? + .ok_or(ServingReadError::Context)?; + if object.entry.header.object.kind == ObjectKind::Blob { + inner + .context + .files + .install_workspace(core.cache.clone(), &object, owner.clone()) + .await?; + } + } + } + loop { + observation.refresh(&inner, &actor).await?; + let pending = job(&spool, owner.clone(), |s| s.pending()).await?; + if pending.is_empty() { + break; + } + for (id, expected) in &pending { + observation.refresh(&inner, &actor).await?; + let object = reader + .lookup(*id, &*inner.context.files, &*inner.context.files) + .await? + .ok_or(ServingReadError::Context)?; + let kind = object.entry.header.object.kind; + if expected.is_some_and(|expected| expected != kind) { + return Err(ServingReadError::Context); + } + let id = *id; + job(&spool, owner.clone(), move |s| s.add(&[(id, Some(kind))])).await?; + if kind != ObjectKind::Blob { + inner + .context + .files + .install_workspace(core.cache.clone(), &object, owner.clone()) + .await?; + } else if needs_blob_sizes { + inner + .context + .files + .install_transient_workspace( + core.cache.clone(), + &object, + owner.clone(), + ) + .await?; + } + let metadata = object.source.metadata; + let mut cursor = None; + loop { + observation.refresh(&inner, &actor).await?; + let metadata = metadata.clone(); + let held = owner.clone(); + let edges = tokio::task::spawn_blocking(move || { + let _owner = held; + metadata.edges_after(id, cursor) + }) + .await? + .map_err(crate::packs::directory::index::IndexError::from)?; + if edges.is_empty() { + break; + } + cursor = edges.last().map(|edge| edge.child); + let count = edges.len(); + // An explicit tag naming a blob must remain peelable. + // Only tag roots can introduce tags in this closure. + if kind == ObjectKind::Tag { + for edge in &edges { + if edge.expected_kind == ObjectKind::Blob { + let object = reader + .lookup( + edge.child, + &*inner.context.files, + &*inner.context.files, + ) + .await? + .ok_or(ServingReadError::Context)?; + if object.entry.header.object.kind != ObjectKind::Blob { + return Err(ServingReadError::Context); + } + inner + .context + .files + .install_workspace( + core.cache.clone(), + &object, + owner.clone(), + ) + .await?; + } + } + } + job(&spool, owner.clone(), move |s| { + s.add( + &edges + .into_iter() + .map(|edge| (edge.child, Some(edge.expected_kind))) + .collect::>(), + ) + }) + .await?; + if count < PAGE_OBJECTS { + break; + } + } + } + job(&spool, owner.clone(), move |s| s.done(&pending)).await?; + } + // Git sees all requested structural objects and decides tree/type/ + // combine filters exactly. Size filters require all requested blobs + // first, since native Git cannot inspect an absent blob's size. + let selection_filter = if needs_blob_sizes { + filter + .as_deref() + .map(|value| size_candidates(value, 0)) + .transpose()? + .flatten() + } else { + filter.clone() + }; + let walk = if needs_blob_sizes { + GitObjectWalk::selected_owned + } else { + GitObjectWalk::missing_owned + }; + let mut missing = walk( + &core.cache.git_dir(), + roots, + selection_filter.as_deref(), + &core.cache.native, + owner.clone(), + ) + .map_err(crate::packs::catalog::NativeReadError::from)?; + let mut ids = Vec::with_capacity(SELECTION_PAGE); + while let Some(id) = missing + .next() + .await + .map_err(crate::packs::catalog::NativeReadError::from)? + { + observation.refresh(&inner, &actor).await?; + if needs_blob_sizes { + let selected = reader + .lookup(id, &*inner.context.files, &*inner.context.files) + .await? + .ok_or(ServingReadError::Context)?; + if selected.entry.header.object.kind != ObjectKind::Blob { + continue; + } + } + ids.push(id); + if ids.len() == SELECTION_PAGE { + let page = + std::mem::replace(&mut ids, Vec::with_capacity(SELECTION_PAGE)); + job(&spool, owner.clone(), move |s| s.retry(&page)).await?; + } + } + missing + .finish() + .await + .map_err(crate::packs::catalog::NativeReadError::from)?; + if !ids.is_empty() { + job(&spool, owner.clone(), move |s| s.retry(&ids)).await?; + } + // Release rev-list's native admission before starting extraction; + // a one-slot read budget must not require a nested native child. + loop { + let pending = job(&spool, owner.clone(), |s| s.pending()).await?; + if pending.is_empty() { + break; + } + for (id, expected) in &pending { + observation.refresh(&inner, &actor).await?; + let object = reader + .lookup(*id, &*inner.context.files, &*inner.context.files) + .await? + .ok_or(ServingReadError::Context)?; + if expected != &Some(ObjectKind::Blob) + || object.entry.header.object.kind != ObjectKind::Blob + { + return Err(ServingReadError::Context); + } + inner + .context + .files + .install_workspace(core.cache.clone(), &object, owner.clone()) + .await?; + } + job(&spool, owner.clone(), move |s| s.done(&pending)).await?; + } + inner.observe(actor).await?; + Ok(()) + }, + true, + ) + .await + } +} diff --git a/crates/canopy-server/src/packs/publication/staging_service.rs b/crates/canopy-server/src/packs/publication/staging_service.rs index 011d135a..041e09a5 100644 --- a/crates/canopy-server/src/packs/publication/staging_service.rs +++ b/crates/canopy-server/src/packs/publication/staging_service.rs @@ -20,6 +20,7 @@ use tokio::{ mod bound; use bound::accept_bound; +mod driver; mod publication; mod restore; mod retirement; @@ -97,6 +98,8 @@ pub enum StagingError { Clock, #[error("staging worker panicked")] Worker, + #[error("owned push workflow failed: {0}")] + DriverFailure(Box), #[error("input preparation failed")] Input(#[source] Box), #[error("staging begin failed")] @@ -324,6 +327,7 @@ struct ActorAdmission { #[derive(Default)] struct Admission { closed: bool, + paused: bool, retirement_probe: bool, retirement_probes: u64, retirement_failures: u64, @@ -332,15 +336,28 @@ struct Admission { jobs: HashMap<[u8; 16], Arc>, actors: HashMap, } +#[cfg(test)] +type CheckpointProbeGate = ( + tokio::sync::oneshot::Sender<()>, + tokio::sync::oneshot::Receiver<()>, +); struct Inner { authority: PreparationAuthority, target: CellTarget, limits: StagingLimits, + budget: StagingBudget, + resident: Option<( + CellClient, + PublicationCoordinator, + Arc, + )>, admission: Mutex, workers: Arc, drained: Notify, #[cfg(test)] fault: std::sync::atomic::AtomicU8, + #[cfg(test)] + checkpoint_probe_gate: Mutex>, } struct Local { lease: Option, @@ -360,6 +377,10 @@ struct Local { fenced: bool, recovery: bool, renew: bool, + driver_started: bool, + driver_graceful: bool, + // Diagnostic only: rejected producer values can own physical worker pins. + driver_failure: Option>, } trait RetainedWork: Any + Send + Sync { fn fence_completed(&self); @@ -376,8 +397,12 @@ struct Job { target: CellTarget, actor: String, operation: [u8; 16], + request_digest: [u8; 32], + driver: Mutex>, + driver_stop: tokio_util::sync::CancellationToken, restored_evidence: Option, actor_workers: Arc, + operation_permit: Mutex>, local: Mutex, work: Mutex, exact: Mutex>, @@ -591,11 +616,92 @@ pub struct StagingStats { pub retirement_restarts: u64, pub retirement_running: bool, } +/// Node-wide capacity shared by every resident repository. Physical worker +/// claims are retained by the existing activity owner, including detached work. +#[derive(Clone)] +pub(crate) struct StagingBudget { + operations: Arc, + workers: Arc, +} +impl StagingBudget { + pub(crate) fn new(operations: usize, workers: usize) -> Result { + if operations < 2 || workers < 2 { + return Err(StagingError::InvalidLimits); + } + Ok(Self { + operations: Arc::new(crate::admission::AccountAdmission::new( + operations, + "node staging operations", + "account staging operations", + )), + workers: Arc::new(crate::admission::AccountAdmission::new( + workers, + "node staging workers", + "account staging workers", + )), + }) + } + #[cfg(test)] + pub(crate) fn available(&self) -> (usize, usize) { + (self.operations.available(), self.workers.available()) + } +} +/// Pauses an idle resident while its other services attempt eviction. A busy +/// serving pool or a canceled eviction restores admission through this guard. +pub(crate) struct StagingQuiescence { + inner: Arc, +} +impl StagingQuiescence { + pub(crate) fn commit(self) { + self.inner + .admission + .lock() + .expect("staging admission") + .closed = true; + } +} +impl Drop for StagingQuiescence { + fn drop(&mut self) { + let mut admission = self.inner.admission.lock().expect("staging admission"); + admission.paused = false; + } +} impl StagingCoordinator { + /// Discovery may defer only to this exact bound attempt, including its + /// actor, owner epoch, admission sequence and artifact operation. A reused + /// logical UUID or an unrelated historical pin is insufficient. + pub(in crate::packs::publication) fn owns_bound(&self, check: &LeaseCheck) -> bool { + let Some(ticket) = self.pending(check.token.operation) else { + return false; + }; + let local = ticket.job.local.lock().expect("staging local"); + local + .bound + .as_ref() + .is_some_and(|session| session.check == *check) + } + pub(in crate::packs::publication) fn matches_target(&self, target: &CellTarget) -> bool { + self.inner.target == *target + } pub fn new( target: CellTarget, limits: StagingLimits, authority: PreparationAuthority, + ) -> Result { + limits.validate()?; + // Standalone coordinators keep their original per-repository limits. + // Production residents share a node budget through new_with_budget. + let budget = StagingBudget::new( + limits.operations.max(limits.per_actor * 2), + limits.workers.max(limits.workers_per_actor * 2), + )?; + Self::new_with_budget(target, limits, authority, budget) + } + pub(crate) fn new_with_budget( + target: CellTarget, + limits: StagingLimits, + authority: PreparationAuthority, + budget: StagingBudget, ) -> Result { limits.validate()?; if !authority.matches(&target) { @@ -606,11 +712,15 @@ impl StagingCoordinator { authority, target, limits, + budget, + resident: None, admission: Mutex::new(Admission::default()), workers: Arc::new(Semaphore::new(limits.workers)), drained: Notify::new(), #[cfg(test)] fault: std::sync::atomic::AtomicU8::new(0), + #[cfg(test)] + checkpoint_probe_gate: Mutex::new(None), }), }) } @@ -622,7 +732,7 @@ impl StagingCoordinator { let mut admission = self.inner.admission.lock().expect("staging admission"); let error = if ready.inner.target != self.inner.target { Some(StagingError::Foreign) - } else if admission.closed { + } else if admission.closed || admission.paused { Some(StagingError::Closed) } else if !matches!(ready.inner.command, Exact::Restored(_)) && ready.inner.request.lease_ms != self.inner.limits.lease_ms @@ -645,6 +755,15 @@ impl StagingCoordinator { if let Some(error) = error { return Err((error, ready)); } + let operation_permit = match self + .inner + .budget + .operations + .try_acquire(crate::ReadIdentity::Account(&ready.inner.request.actor)) + { + Ok(permit) => permit, + Err(_) => return Err((StagingError::Capacity, ready)), + }; let actor = admission .actors .entry(ready.inner.request.actor.clone()) @@ -665,8 +784,12 @@ impl StagingCoordinator { target: ready.inner.target, actor: ready.inner.request.actor, operation: ready.inner.request.operation, + request_digest: ready.inner.request.request_digest, + driver: Mutex::new(None), + driver_stop: tokio_util::sync::CancellationToken::new(), restored_evidence, actor_workers, + operation_permit: Mutex::new(Some(operation_permit)), local: Mutex::new(Local { lease: None, bound: None, @@ -687,6 +810,9 @@ impl StagingCoordinator { fenced: false, recovery: false, renew: false, + driver_started: false, + driver_graceful: false, + driver_failure: None, }), work: Mutex::new(WorkSlots::default()), exact: Mutex::new(Some(ready.inner.command)), @@ -754,39 +880,114 @@ impl StagingCoordinator { retirement_running: a.retirement_probe, } } + pub(crate) fn try_quiesce(&self) -> Option { + let mut admission = self.inner.admission.lock().expect("staging admission"); + if admission.paused || !admission.jobs.is_empty() || admission.retirement_probe { + return None; + } + admission.paused = true; + Some(StagingQuiescence { + inner: Arc::clone(&self.inner), + }) + } + /// Seal node admission while already-owned receive workflows finish. Other + /// callback producers retain their existing cancel-and-physical-drain contract. + pub(crate) fn close_admission(&self) { + let mut admission = self.inner.admission.lock().expect("staging admission"); + admission.closed = true; + for job in admission.jobs.values() { + let mut local = job.local.lock().expect("staging local"); + if !local.driver_graceful { + local.stop = true; + job.driver_stop.cancel(); + job.changed.notify_one(); + } + } + } + + pub(crate) async fn finish_receive_workflows(&self) { + let jobs: Vec<_> = self + .inner + .admission + .lock() + .expect("staging admission") + .jobs + .values() + .cloned() + .collect(); + // Timeout abandons only this join observer. Forced close below retains + // and joins every actual controller, worker and uncertain command. + let _ = tokio::time::timeout( + Duration::from_secs(30), + futures_util::future::join_all(jobs.iter().map(|job| driver::drain(job))), + ) + .await; + } + + pub(crate) fn close(&self) { + let mut admission = self.inner.admission.lock().expect("staging admission"); + admission.closed = true; + for job in admission.jobs.values() { + job.local.lock().expect("staging local").stop = true; + job.driver_stop.cancel(); + job.changed.notify_one(); + } + self.inner.drained.notify_waiters(); + } /// Stop admission and renew while accepted workers drain. Uncertain exact /// commands remain charged and returned; explicit recovery remains possible. pub async fn close_and_drain(&self) -> Vec { + self.close(); loop { let wake = self.inner.drained.notified(); tokio::pin!(wake); wake.as_mut().enable(); - { - let mut a = self.inner.admission.lock().expect("staging admission"); - a.closed = true; - for job in a.jobs.values() { - job.local.lock().expect("staging local").stop = true; - job.changed.notify_one(); - } + let pending = { + let a = self.inner.admission.lock().expect("staging admission"); if a.jobs.values().all(|j| { matches!(*j.status.borrow(), StagingState::Uncertain(_)) && j.local.lock().expect("staging local").workers == 0 }) { - return a - .jobs - .values() - .map(|job| StagingTicket { - inner: Arc::clone(&self.inner), - job: Arc::clone(job), - }) - .collect(); + Some( + a.jobs + .values() + .map(|job| StagingTicket { + inner: Arc::clone(&self.inner), + job: Arc::clone(job), + }) + .collect::>(), + ) + } else { + None } + }; + if let Some(pending) = pending { + for ticket in &pending { + driver::drain(&ticket.job).await; + } + return pending; } wake.await; } } #[cfg(test)] - pub(super) fn fault_for_test(&self, fault: u8) { + pub(crate) fn pause_checkpoint_probe_for_test( + &self, + ) -> ( + tokio::sync::oneshot::Receiver<()>, + tokio::sync::oneshot::Sender<()>, + ) { + let (entered, receive) = tokio::sync::oneshot::channel(); + let (release, wait) = tokio::sync::oneshot::channel(); + *self + .inner + .checkpoint_probe_gate + .lock() + .expect("checkpoint gate") = Some((entered, wait)); + (receive, release) + } + #[cfg(test)] + pub(crate) fn fault_for_test(&self, fault: u8) { self.inner .fault .store(fault, std::sync::atomic::Ordering::Release); @@ -821,6 +1022,25 @@ pub struct StagedInputsTicket { registration: Arc, } impl StagedInputsTicket { + /// Order the next producer after the controller has installed its fresh + /// phase. `wait` independently retains the original committed receipt. + pub(crate) async fn wait_ready(&self) -> Result> { + let receipt = self.wait().await?; + let mut state = self.job.status.subscribe(); + loop { + match state.borrow_and_update().clone() { + StagingState::Active(_) | StagingState::Bound(_) => return Ok(receipt), + StagingState::Uncertain(error) | StagingState::Fenced(error) => return Err(error), + StagingState::Stopped | StagingState::Published(_) => { + return Err(Arc::new(StagingError::Inactive)); + } + _ => {} + } + if state.changed().await.is_err() { + return Err(Arc::new(StagingError::Worker)); + } + } + } /// Observe the original durable registration receipt. An uncertain error /// retains the exact command in the coordinator; recover and wait again. /// A receipt is not a fresh authority or lease observation. @@ -844,7 +1064,7 @@ impl StagedInputsTicket { } impl StagingTicket { #[cfg(test)] - pub(super) fn custody_evidence_for_test( + pub(crate) fn custody_evidence_for_test( &self, ) -> Option<( cellule_runtime::PendingMutation, @@ -969,6 +1189,26 @@ impl StagingTicket { } } } + /// Observe final publication without treating the intermediate Bound phase + /// as completion. Cancellation only drops this watch receiver. + pub async fn wait_completion(&self) -> StagingState { + let mut status = self.job.status.subscribe(); + loop { + let state = status.borrow_and_update().clone(); + if matches!( + state, + StagingState::Published(_) + | StagingState::Uncertain(_) + | StagingState::Fenced(_) + | StagingState::Stopped + ) { + return state; + } + if status.changed().await.is_err() { + return status.borrow().clone(); + } + } + } pub async fn wait_terminal(&self) -> StagingState { let mut status = self.job.status.subscribe(); loop { @@ -1000,6 +1240,7 @@ impl StagingTicket { } pub fn stop(&self) { self.job.local.lock().expect("staging local").stop = true; + self.job.driver_stop.cancel(); self.job.changed.notify_one(); } pub async fn open_base( @@ -1097,6 +1338,12 @@ impl StagingTicket { let actor_permit = Arc::clone(&self.job.actor_workers) .try_acquire_owned() .map_err(|_| StagingError::Capacity)?; + let node_permit = self + .inner + .budget + .workers + .try_acquire(crate::ReadIdentity::Account(&self.job.actor)) + .map_err(|_| StagingError::Capacity)?; let (token, format) = { let mut l = self.job.local.lock().expect("staging local"); if (l.seal && !bound) @@ -1131,6 +1378,7 @@ impl StagingTicket { job: Arc::clone(&self.job), permit: Some(permit), actor_permit: Some(actor_permit), + node_permit: Some(node_permit), }); let context = StagingContext { job: Arc::clone(&self.job), @@ -1291,11 +1539,13 @@ struct Activity { job: Arc, permit: Option, actor_permit: Option, + node_permit: Option, } impl Drop for Activity { fn drop(&mut self) { drop(self.actor_permit.take()); drop(self.permit.take()); + drop(self.node_permit.take()); self.job.local.lock().expect("staging local").workers -= 1; self.job.changed.notify_one(); self.inner.drained.notify_waiters(); @@ -1627,6 +1877,8 @@ async fn run(inner: Arc, job: Arc, mut recover: bool) { .clone() .expect("bound checkpoint slot"); registration.finish(Ok(value.receipt)); + #[cfg(test)] + checkpoint_probe_for_test(&inner).await; let matched = matches!(&value.output, StagingReply::Granted(lease) if lease.token == session.lease.token && lease.format == session.lease.format); let result = if matched { @@ -1717,6 +1969,10 @@ async fn run(inner: Arc, job: Arc, mut recover: bool) { l.lease = Some(*lease); } } + #[cfg(test)] + if checkpoint { + checkpoint_probe_for_test(&inner).await; + } match probe(&job, value.receipt).await { Ok((lease, deadline)) => { let differs = job @@ -1904,20 +2160,26 @@ async fn run(inner: Arc, job: Arc, mut recover: bool) { } } Next::Stop => { - let mut local = job.local.lock().expect("staging local"); - local.fenced = true; - if let Some(session) = &local.bound { - session.fence(); + { + let mut local = job.local.lock().expect("staging local"); + local.fenced = true; + if let Some(session) = &local.bound { + session.fence(); + } + // A failed controller must remain a failure before or after + // Bind. Preserve the original receipt as historical evidence; + // reporting Bound here would strand completion observers. + let state = match &local.driver_failure { + Some(error) => StagingState::Fenced(error.clone()), + None => local + .bound_result + .clone() + .map(StagingState::Bound) + .unwrap_or(StagingState::Stopped), + }; + job.status.send_replace(state); } - // Keep the original binding receipt observable after graceful stop. - job.status.send_replace( - local - .bound_result - .clone() - .map(StagingState::Bound) - .unwrap_or(StagingState::Stopped), - ); - drop(local); + driver::drain(&job).await; remove(&inner, &job); return; } @@ -2082,10 +2344,12 @@ async fn finish_fence(inner: &Inner, job: &Job, error: StagingError) { let error = Arc::new(error); job.status .send_replace(StagingState::Fenced(Arc::clone(&error))); + job.driver_stop.cancel(); if let Some(registration) = job.checkpoint.lock().expect("staging checkpoint").as_ref() { registration.finish(Err(error)); } drain_work(job).await; + driver::drain(job).await; remove(inner, job); } async fn drain_work(job: &Job) { @@ -2116,6 +2380,12 @@ fn bound_state(local: &Local) -> StagingState { fn remove(inner: &Inner, job: &Job) { let mut a = inner.admission.lock().expect("staging admission"); a.jobs.remove(&job.operation); + drop( + job.operation_permit + .lock() + .expect("staging operation permit") + .take(), + ); let count = a.actors.get_mut(&job.actor).expect("staging actor"); count.operations -= 1; if count.operations == 0 { @@ -2123,3 +2393,16 @@ fn remove(inner: &Inner, job: &Job) { } inner.drained.notify_waiters(); } + +#[cfg(test)] +async fn checkpoint_probe_for_test(inner: &Inner) { + let gate = inner + .checkpoint_probe_gate + .lock() + .expect("checkpoint gate") + .take(); + if let Some((entered, wait)) = gate { + let _ = entered.send(()); + let _ = wait.await; + } +} diff --git a/crates/canopy-server/src/packs/publication/staging_service/driver.rs b/crates/canopy-server/src/packs/publication/staging_service/driver.rs new file mode 100644 index 00000000..de3ee49d --- /dev/null +++ b/crates/canopy-server/src/packs/publication/staging_service/driver.rs @@ -0,0 +1,294 @@ +//! One workflow controller per admitted operation. Physical work still uses +//! the existing staged/bound worker slots; controllers must not block Bind. +use super::*; +use futures_util::FutureExt; +use std::fmt::Write; +pub(super) type DriverJoin = + futures_util::future::Shared>; + +/// Retain diagnostics without retaining rejected preparation values or their +/// physical credits. Both message bytes and source traversal are bounded. +fn failure(error: &StagingError) -> StagingError { + // A known stopped attempt remains distinguishable from ambiguous command + // evidence. Wire callers may report failure only for this terminal state. + if matches!(error, StagingError::Inactive) { + return StagingError::Inactive; + } + struct Message(String); + impl Write for Message { + fn write_str(&mut self, value: &str) -> std::fmt::Result { + let remaining = 4096 - self.0.len(); + if value.len() <= remaining { + self.0.push_str(value); + return Ok(()); + } + let mut end = remaining; + while !value.is_char_boundary(end) { + end -= 1; + } + self.0.push_str(&value[..end]); + Err(std::fmt::Error) + } + } + let mut message = Message(String::new()); + let mut source: Option<&(dyn std::error::Error + 'static)> = Some(error); + for _ in 0..8 { + let Some(error) = source else { break }; + if !message.0.is_empty() && message.write_str(": ").is_err() { + break; + } + if write!(&mut message, "{error}").is_err() { + break; + } + source = error.source(); + } + StagingError::DriverFailure(message.0.into_boxed_str()) +} + +impl StagingCoordinator { + /// Called only after RepositoryCell selected this root from the completed + /// row under current audit authorization. No client-provided root enters. + pub(crate) async fn completed_options( + &self, + bytes: &[u8], + ) -> Result, StagingError> { + let (_, _, store) = self.inner.resident.as_ref().ok_or(StagingError::Inactive)?; + let mut decoder = + BoundedDecoder::new(bytes, 128).map_err(|e| StagingError::Input(Box::new(e)))?; + let root = NativeOutcomeRoot::decode(&mut decoder) + .map_err(|e| StagingError::Input(Box::new(e)))?; + decoder + .finish() + .map_err(|e| StagingError::Input(Box::new(e)))?; + root_completion::read::selected_options(root, store) + .await + .map_err(StagingError::Input) + } + + /// Select a completed response with current authorization before decoding or + /// admitting another attempt. The resident holds the actual Cell capability. + pub async fn replay_request( + &self, + request: BeginRequest, + store: &canopy_object_storage::artifact::ArtifactStore, + ) -> Result< + Option>, + RootPushReplayError, + > { + let (client, _, _) = self + .inner + .resident + .as_ref() + .ok_or(RootPushReplayError::Context)?; + replay_root_push_response(client, &self.inner.target, request, None, store).await + } + + pub(crate) fn new_resident( + client: CellClient, + target: CellTarget, + limits: StagingLimits, + authority: PreparationAuthority, + budget: StagingBudget, + publication: PublicationCoordinator, + store: Arc, + ) -> Result { + if !publication.matches_target(&target) + || crate::repository_target(target.tenant(), target.application(), store.repository()) + .map_err(|_| StagingError::Foreign)? + != target + { + return Err(StagingError::Foreign); + } + let mut coordinator = Self::new_with_budget(target, limits, authority, budget)?; + Arc::get_mut(&mut coordinator.inner) + .expect("new staging owner") + .resident = Some((client, publication, store)); + Ok(coordinator) + } + + /// Prepare a request with the resident's actual Cell capability. The + /// existing registered custody factory performs the authoritative checks. + pub async fn ready_request( + &self, + request: BeginRequest, + identity: MutationIdentity, + ) -> Result { + { + let admission = self.inner.admission.lock().expect("staging admission"); + if admission.closed || admission.paused { + return Err(StagingError::Closed); + } + } + let (client, _, _) = self.inner.resident.as_ref().ok_or(StagingError::Inactive)?; + ReadyStaging::new(client.clone(), self.inner.target.clone(), request, identity).await + } + + /// Candidate observers join by the digest of the existing frozen editorial + /// intent. Search only the bounded admitted jobs, without a second UUID + /// cache. Uncertain work keeps its original ticket/commands; once a known + /// attempt fully drains, a new operation can retry the same pending intent. + pub(crate) fn join_generated_candidate( + &self, + request: &BeginRequest, + ) -> Result, StagingError> { + if crate::repository_target( + self.inner.target.tenant(), + self.inner.target.application(), + request.repository, + ) + .map_err(|_| StagingError::Context)? + != self.inner.target + { + return Err(StagingError::Foreign); + } + let admitted = self.inner.admission.lock().expect("staging admission"); + Ok(admitted + .jobs + .values() + .find(|job| job.actor == request.actor && job.request_digest == request.request_digest) + .map(|job| StagingTicket { + inner: self.inner.clone(), + job: job.clone(), + })) + } + + /// Joining an operation ID requires the original authenticated context, + /// including while Begin has not yet produced a token. + pub fn join_request( + &self, + request: &BeginRequest, + ) -> Result, StagingError> { + if crate::repository_target( + self.inner.target.tenant(), + self.inner.target.application(), + request.repository, + ) + .map_err(|_| StagingError::Context)? + != self.inner.target + { + return Err(StagingError::Foreign); + } + let Some(ticket) = self.pending(request.operation) else { + return Ok(None); + }; + if ticket.job.actor != request.actor || ticket.job.request_digest != request.request_digest + { + return Err(StagingError::Context); + } + Ok(Some(ticket)) + } +} + +impl StagingTicket { + /// Transfer the entire workflow before the request's next await. An HTTP + /// observer owns neither this task nor its exact command recovery. Only + /// the production resident's publication dispatcher can be supplied here. + /// Run physical work through `spawn`/`spawn_bound`; use this task only to + /// retrieve their outputs and order checkpoint, Bind and publication steps. + pub fn drive(&self, producer: F) -> Result<(), StagingError> + where + F: FnOnce(StagingTicket, PublicationCoordinator) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + { + self.drive_with_drain(producer, false) + } + + /// An authenticated receive-pack keeps its controller during the bounded + /// node shutdown grace. Forced close still cancels it and joins physical + /// workers and exact recovery before releasing the repository. + pub(crate) fn drive_receive(&self, producer: F) -> Result<(), StagingError> + where + F: FnOnce(StagingTicket, PublicationCoordinator) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + { + self.drive_with_drain(producer, true) + } + + fn drive_with_drain(&self, producer: F, graceful: bool) -> Result<(), StagingError> + where + F: FnOnce(StagingTicket, PublicationCoordinator) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + { + let publication = self + .inner + .resident + .as_ref() + .ok_or(StagingError::Inactive)? + .1 + .clone(); + let admission = self.inner.admission.lock().expect("staging admission"); + let mut local = self.job.local.lock().expect("staging local"); + if admission.closed + || admission.paused + || local.stop + || local.fenced + || !admission + .jobs + .get(&self.job.operation) + .is_some_and(|job| Arc::ptr_eq(job, &self.job)) + { + return Err(StagingError::Inactive); + } + if local.driver_started { + return Err(StagingError::Duplicate); + } + local.driver_started = true; + local.driver_graceful = graceful; + let ticket = self.clone(); + let owner = self.job.clone(); + let inner = self.inner.clone(); + let task = tokio::spawn(async move { + let mut task = tokio::spawn(async move { producer(ticket, publication).await }); + let result = tokio::select! { + result = &mut task => result.unwrap_or(Err(StagingError::Worker)), + _ = owner.driver_stop.cancelled() => { + task.abort(); + let _ = task.await; + Err(StagingError::Inactive) + } + }; + if let Err(error) = result { + tracing::warn!(operation = %hex::encode(owner.operation), ?error, "owned push workflow stopped"); + // The lifecycle still owns every admitted exact command and + // physical worker. Never replace an uncertain result with ng. + let diagnostic = Arc::new(failure(&error)); + { + let mut local = owner.local.lock().expect("staging local"); + local.driver_failure = Some(diagnostic); + local.stop = true; + } + // Stop is visible before returning any physical credit. Drop + // outside the lock: an Activity destructor acquires it too. + drop(error); + owner.changed.notify_one(); + } else { + let mut local = owner.local.lock().expect("staging local"); + if !local.finishing && !matches!(*owner.status.borrow(), StagingState::Published(_)) + { + local.stop = true; + } + } + // Wake drain even when an uncertain command has no more workers. + // Its exact owner may be awaiting explicit recovery independently. + owner.changed.notify_one(); + inner.drained.notify_waiters(); + }); + *self.job.driver.lock().expect("staging driver") = Some( + async move { + let _ = task.await; + } + .boxed() + .shared(), + ); + Ok(()) + } +} + +pub(super) async fn drain(job: &Job) { + // Concurrent node/service drains must join the same actual task. Taking a + // JoinHandle would let the second caller mistake its absence for completion. + let task = job.driver.lock().expect("staging driver").clone(); + if let Some(task) = task { + task.await; + } +} diff --git a/crates/canopy-server/src/packs/publication/staging_service/publication.rs b/crates/canopy-server/src/packs/publication/staging_service/publication.rs index 3b8d3a4b..3eb8ab04 100644 --- a/crates/canopy-server/src/packs/publication/staging_service/publication.rs +++ b/crates/canopy-server/src/packs/publication/staging_service/publication.rs @@ -59,7 +59,7 @@ impl StagingTicket { coordinator: &PublicationCoordinator, ready: impl Into, ) -> Result> { - self.handoff(coordinator, ready.into(), false) + self.handoff(ready.into(), false, |ready| coordinator.try_reserve(ready)) } /// Order one intermediate policy page through the same held slot. A known /// successful page resumes Bound; it never terminates or acknowledges a @@ -69,13 +69,55 @@ impl StagingTicket { coordinator: &PublicationCoordinator, ready: impl Into, ) -> Result> { - self.handoff(coordinator, ready.into(), true) + self.handoff(ready.into(), true, |ready| coordinator.try_reserve(ready)) } - fn handoff( + /// Wait only for mutex contention, bounded by the existing custody ceiling. + /// Quota refusal is immediate. Admission and lifecycle capture still happen + /// synchronously; cancellation before this point cannot dispatch a command. + pub async fn publish_wait( + &self, + coordinator: &PublicationCoordinator, + ready: impl Into, + ) -> Result> { + self.handoff_wait(coordinator, ready.into(), false).await + } + /// Intermediate pages use the same bounded handoff as final publication. + pub async fn register_policy_page_wait( &self, coordinator: &PublicationCoordinator, + ready: impl Into, + ) -> Result> { + self.handoff_wait(coordinator, ready.into(), true).await + } + async fn handoff_wait( + &self, + coordinator: &PublicationCoordinator, + ready: ReadyPublication, + policy_page: bool, + ) -> Result> { + let deadline = { + let local = self.job.local.lock().expect("staging local"); + local.deadline.min(local.lifetime) + }; + let Ok(mut admission) = + tokio::time::timeout_at(deadline, coordinator.held_admission()).await + else { + return Err(Box::new(StagedPublicationFailure { + reason: StagingError::Inactive, + ready, + })); + }; + // Recheck live custody after waiting, then retain the exact held ticket + // under the lifecycle lock before either lock is released or we yield. + self.handoff(ready, policy_page, |ready| admission.reserve(ready)) + } + fn handoff( + &self, ready: ReadyPublication, policy_page: bool, + reserve: impl FnOnce( + ReadyPublication, + ) -> Result>, ) -> Result> { let mut local = self.job.local.lock().expect("staging local"); let reason = if ready.is_policy_page() != policy_page { @@ -100,7 +142,7 @@ impl StagingTicket { if let Some(reason) = reason { return Err(Box::new(StagedPublicationFailure { reason, ready })); } - let ticket = coordinator.try_reserve(ready).map_err(|failure| { + let ticket = reserve(ready).map_err(|failure| { Box::new(StagedPublicationFailure { reason: StagingError::PublicationAdmission(failure.reason), ready: failure.ready, @@ -166,6 +208,7 @@ pub(super) async fn observe(inner: &Inner, job: &Job, ticket: &PublicationTicket } drain_work(job).await; job.status.send_replace(StagingState::Published(outcome)); + driver::drain(job).await; remove(inner, job); return true; } @@ -182,6 +225,8 @@ pub(super) async fn observe(inner: &Inner, job: &Job, ticket: &PublicationTicket drain_work(job).await; job.status .send_replace(StagingState::Fenced(Arc::new(StagingError::Inactive))); + job.driver_stop.cancel(); + driver::drain(job).await; remove(inner, job); return true; } diff --git a/crates/canopy-server/src/packs/publication/tests.rs b/crates/canopy-server/src/packs/publication/tests.rs index 821c7ff4..5aeca18b 100644 --- a/crates/canopy-server/src/packs/publication/tests.rs +++ b/crates/canopy-server/src/packs/publication/tests.rs @@ -1,5 +1,6 @@ use super::*; mod attestation; +mod candidate_publication; mod compaction; mod completion; mod coordinator; @@ -14,7 +15,10 @@ mod initialization_retirement; mod inputs; mod mandatory_registration; mod namespaces; +mod native_candidate; mod native_capture; +mod native_head; +mod native_merge; mod policy_dispatch; mod policy_refusal; mod preparation_receipt; @@ -337,6 +341,20 @@ fn identity() -> std::io::Result { expires_at_ms: now + 60_000, }) } +// SDK expiry uses wall-clock milliseconds; Tokio timers are monotonic. +// A wake alone cannot establish that the original identity has expired. +async fn wait_for_sdk_expiry(expires_at_ms: i64) -> Result { + loop { + let now = i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis())?; + if now > expires_at_ms { + return Ok(()); + } + tokio::time::sleep(std::time::Duration::from_millis(u64::try_from( + expires_at_ms - now + 1, + )?)) + .await; + } +} async fn registered_preparation( f: &Fixture, operation: [u8; 16], diff --git a/crates/canopy-server/src/packs/publication/tests/candidate_publication.rs b/crates/canopy-server/src/packs/publication/tests/candidate_publication.rs new file mode 100644 index 00000000..77134e4e --- /dev/null +++ b/crates/canopy-server/src/packs/publication/tests/candidate_publication.rs @@ -0,0 +1,316 @@ +//! Real Git generated bytes, owner-fenced SQL rollback and exact SDK recovery. +use super::*; +use super::{ + native_candidate::{candidate, catalog, initial, ready, write}, + native_merge, + prepare::{cleaned, opened}, + publishing::edit, +}; +use crate::{ + git_gateway::candidates::ProducedCandidate, + packs::verification::physical::tests::prepared_for_store, + pulls::{ + candidates::{CandidateResult, MergeCandidate, commit_body}, + merge::MergeStrategy, + }, +}; +use canopy_object_storage::artifact::{ArtifactKey, ArtifactKind}; +use cellule_runtime::{PreparedCommand, Resolution}; +use object_store::ObjectStoreExt; + +async fn reserve(f: &Fixture, candidate: &MergeCandidate) -> Result { + let candidate = candidate.clone(); + f.handle.execute(identity()?,Digest::from_bytes([218;32]),sql::now(0)?,4096,0,move|tx| { + let id=uuid::Uuid::parse_str(&candidate.request.id).map_err(|_|Error::Command("candidate fixture UUID"))?; + let binding=crate::pulls::candidates::intent_binding(&candidate)?; + let request=serde_json::to_string(&candidate.request).map_err(|_|Error::Command("candidate fixture request"))?; + let result=serde_json::to_string(&CandidateResult::Pending).map_err(|_|Error::Command("candidate fixture result"))?; + tx.execute("INSERT INTO merge_candidates(id,binding,pull_number,actor,request,created_ms,result,source_oid,base_oid) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9)",rusqlite::params![id.as_bytes().as_slice(),binding,candidate.number,candidate.actor,request,candidate.created_at_ms,result,crate::pulls::merge::oid(&candidate.request.revision.source_oid)?.as_ref(),crate::pulls::merge::oid(&candidate.request.revision.base_oid)?.as_ref()])?; + Ok(cellule_runtime::cell::executor::HandlerOutcome::Success(vec![])) + }).await?; + Ok(()) +} +async fn state(f: &Fixture) -> Result> { + let roots = super::publishing::state(&f.handle).await?; + let editorial=f.handle.query(0,65536,|db| { + let candidate:(String,Option>,Option>)=db.query_row("SELECT result,oid,native_publication FROM merge_candidates",[],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?; + let mut query=db.prepare("SELECT recovery_phase,recovery_phase_revision FROM catalog_leases ORDER BY admission_sequence")?; + let phases=query.query_map([],|r|Ok((r.get::<_,Option>>(0)?,r.get::<_,u64>(1)?)))?.collect::>>()?; + serde_json::to_vec(&(candidate,phases)).map_err(|_|Error::Command("candidate fixture state")) + }).await?; + Ok([roots, editorial].concat()) +} +async fn command( + f: &Fixture, + prepared: &PreparedCatalog, + candidate: MergeCandidate, + root: &std::path::Path, + budget: cellule_ltx::DiskBudget, +) -> Result<( + PreparedCommand, + RegisteredRootRecovery, +)> { + let produced = ProducedCandidate::verified_fixture(candidate, prepared.token().operation); + let proof = prepared + .native_candidate_proof( + &produced, + root, + budget, + crate::packs::metadata::tests::limits(), + ) + .await?; + let command = f + .client() + .prepare_command::(&f.target, identity()?, proof) + .await?; + let registered = super::super::recovery::persist( + &prepared.base.session, + &command, + super::super::recovery::Kind::Candidate, + &prepared.base.indexes().store(), + identity()?, + 0, + ) + .await?; + Ok((command, registered)) +} +#[tokio::test] +async fn native_candidate_ready_is_atomic_retained_and_recovers_original_receipt_after_body_retirement() +-> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (f, graph, merge) = native_merge::initial(format, false).await?; + let (base, _, _) = + opened(&f, *uuid::Uuid::new_v4().as_bytes(), graph.store.clone()).await?; + let mut native = prepared_for_store( + format, + 4, + base.context().operation, + graph.provider.clone(), + graph.store.clone(), + ) + .await?; + // Install the real accepted base pack into this isolated fixture so + // stock Git can traverse the generated commit's original parents. + let reader = + crate::packs::catalog::CatalogReader::open(base.indexes(), graph.prepared.catalog()) + .await?; + let files = base.files(); + let pack = reader + .lookup(graph.tip, &*files, &*files) + .await? + .ok_or("base source")? + .source + .record + .native(); + let mut body = graph + .store + .read(pack.key(ArtifactKind::Pack)?, pack.pack) + .await?; + let mut bytes = Vec::new(); + while let Some(chunk) = body.next().await? { + bytes.extend_from_slice(&chunk); + } + crate::packs::verification::physical::tests::independence::git_input( + native.fixture.root.path(), + &["index-pack", "--stdin"], + &bytes, + ) + .await?; + let (first, tree) = initial(&native)?; + assert_eq!(first, graph.initial); + let mut pending = candidate(MergeStrategy::MergeCommit, graph.initial, graph.tip); + pending.request.revision = merge.revision; + reserve(&f, &pending).await?; + let generated = write( + &native, + &commit_body(&pending, &hex::encode(tree)), + "refs/heads/generated", + ) + .await?; + let completed = ready(&pending, generated, tree); + let (prepared, root, budget) = catalog(&f, &mut native, base).await?; + let check = prepared.base.capability().2.clone(); + let (command, saved) = + command(&f, &prepared, completed, root.path(), budget.clone()).await?; + edit(&f,"CREATE TRIGGER abort_candidate BEFORE UPDATE OF result ON merge_candidates BEGIN SELECT RAISE(ABORT,'late candidate failure'); END;").await?; + let before = state(&f).await?; + assert!(command.clone().execute().await.is_err()); + assert_eq!(state(&f).await?, before); + assert!(matches!( + f.client().resolve(command.evidence()).await?, + Resolution::Absent + )); + edit(&f, "DROP TRIGGER abort_candidate").await?; + let original = command.clone().execute().await?; + assert!(matches!( + original.output, + CandidatePublicationReply::Applied { + publication: Some(PublishedRefs { + generation: 2, + ref_generation: 2, + .. + }), + .. + } + )); + assert_eq!(command.execute().await?.receipt, original.receipt); + let selected = f + .handle + .query(0, 1024, |db| { + assert_eq!( + db.query_row("SELECT generation FROM ref_generation", [], |r| r + .get::<_, i64>(0))?, + 2 + ); + assert_eq!( + db.query_row("SELECT count(*) FROM refs", [], |r| r.get::<_, i64>(0))?, + 0 + ); + assert!( + db.execute("UPDATE merge_candidates SET created_ms=created_ms+1", []) + .is_err() + ); + assert!( + db.execute("UPDATE merge_candidates SET result='{}'", []) + .is_err() + ); + assert!(db.execute("DELETE FROM merge_candidates", []).is_err()); + db.query_row("SELECT native_publication FROM merge_candidates", [], |r| { + r.get::<_, Vec>(0) + }) + .map_err(Into::into) + }) + .await?; + let mut decoder = BoundedDecoder::new(&selected, 512)?; + let selected = super::super::candidate_publication::audit::Selected::decode(&mut decoder)?; + decoder.finish()?; + let audit = selected.root.ok_or("candidate audit absent")?; + let path = graph.store.path( + ArtifactKey { + operation: audit.operation, + binding_digest: audit.artifact.digest, + kind: ArtifactKind::InputRoot, + }, + audit.artifact.digest, + )?; + let bytes = graph.provider.get(&path).await?.bytes().await?; + graph.provider.delete(&path).await?; + let admin = super::terminal_retention::maintenance(&f.handle, f.repository).await?; + assert!( + saved + .ready_terminal_release(f.client(), &graph.store, admin.clone(), identity()?) + .await + .is_err() + ); + graph.provider.put(&path, bytes.into()).await?; + assert_eq!( + saved + .ready_terminal_release(f.client(), &graph.store, admin, identity()?) + .await? + .complete() + .await? + .output, + TerminalReleaseReply::Released + ); + for (key, descriptor) in saved.command_bodies_for_test() { + graph + .provider + .delete(&graph.store.path(key, descriptor.digest)?) + .await?; + } + drop(prepared); + cleaned(root.path(), &budget).await?; + let (runtime, handle, client) = super::durable_recovery::restore_owner(&f, &check).await?; + assert!(handle.owner_fence().epoch > check.token.owner.epoch); + let restored = RegisteredRootRecovery::load(&client, &f.target, &graph.store, &check) + .await? + .ok_or("candidate recovery archive absent")?; + let PublicationOutcome::Candidate(replay) = restored + .dispatch_any( + &client, + &graph.store, + &f.authority(), + &std::sync::atomic::AtomicBool::new(false), + ) + .await? + else { + return Err("wrong candidate recovery purpose".into()); + }; + assert_eq!( + (replay.output, replay.receipt), + (original.output, original.receipt) + ); + runtime.shutdown().await?; + drop(graph.prepared); + cleaned(graph.root.path(), &graph.budget).await?; + } + Ok(()) +} + +#[tokio::test] +async fn large_negative_candidate_uses_compact_ack_without_publishing_roots() -> Result { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (f, graph, merge) = native_merge::initial(format, false).await?; + let (prepared, root, budget) = native_merge::preparation(&f, &graph).await?; + let mut pending = candidate(MergeStrategy::MergeCommit, graph.initial, graph.tip); + pending.request.revision = merge.revision; + reserve(&f, &pending).await?; + let mut completed = pending; + completed.result = CandidateResult::Conflicted { + paths_base64: vec![URL_SAFE_NO_PAD.encode(vec![b'x'; 256]); 700], + }; + assert!(crate::pulls::candidates::valid_result(&completed.result)); + assert!(serde_json::to_vec(&completed.result)?.len() > 200 << 10); + let (command, saved) = + command(&f, &prepared, completed, root.path(), budget.clone()).await?; + let before = f + .handle + .query(0, 1024, |db| { + Ok(db + .query_row("SELECT generation FROM catalog_state", [], |r| { + r.get::<_, u64>(0) + })? + .to_le_bytes() + .to_vec()) + }) + .await?; + let original = command.execute().await?; + assert!(matches!( + original.output, + CandidatePublicationReply::Applied { + publication: None, + .. + } + )); + let mut encoded = BoundedEncoder::new(512)?; + original.output.encode(&mut encoded)?; + assert!(encoded.finish().len() < 128); + assert_eq!( + f.handle + .query(0, 1024, |db| Ok(db + .query_row("SELECT generation FROM catalog_state", [], |r| r + .get::<_, u64>(0))? + .to_le_bytes() + .to_vec())) + .await?, + before + ); + let admin = super::terminal_retention::maintenance(&f.handle, f.repository).await?; + assert_eq!( + saved + .ready_terminal_release(f.client(), &graph.store, admin, identity()?) + .await? + .complete() + .await? + .output, + TerminalReleaseReply::Released + ); + drop(prepared); + cleaned(root.path(), &budget).await?; + drop(graph.prepared); + cleaned(graph.root.path(), &graph.budget).await?; + f.runtime.shutdown().await?; + } + Ok(()) +} diff --git a/crates/canopy-server/src/packs/publication/tests/coordinator/held.rs b/crates/canopy-server/src/packs/publication/tests/coordinator/held.rs index 922f9576..3496b397 100644 --- a/crates/canopy-server/src/packs/publication/tests/coordinator/held.rs +++ b/crates/canopy-server/src/packs/publication/tests/coordinator/held.rs @@ -204,7 +204,7 @@ async fn held_admission_uses_existing_account_bytes_and_returns_refused_ready() .await .err() .ok_or("contended synchronous admission unexpectedly accepted")?; - assert_eq!(failure.reason, PublicationScheduleError::Capacity); + assert_eq!(failure.reason, PublicationScheduleError::Busy); attempts[1].3 = Some(failure.ready); // The same logical operation cannot have a second held/executing slot. let duplicate = Box::pin(attempts[0].0.ready_push( diff --git a/crates/canopy-server/src/packs/publication/tests/custody.rs b/crates/canopy-server/src/packs/publication/tests/custody.rs index 193fe140..d315723d 100644 --- a/crates/canopy-server/src/packs/publication/tests/custody.rs +++ b/crates/canopy-server/src/packs/publication/tests/custody.rs @@ -1,7 +1,6 @@ //! Real Cell receipts, pre-admission recovery and the original command's atomic result. use super::{publishing::edit, *}; use cellule_runtime::{Committed, Resolution}; -use tokio::time::Duration; async fn prepare(f: &Fixture, action: CustodyAction) -> Result { Ok(PreparedCustody::prepare(&f.client(), &f.target, action, identity()?).await?) @@ -22,14 +21,7 @@ fn token(output: &CustodyReply) -> Result { } } async fn expire(identity: MutationIdentity) -> Result { - let now = sql::now(0)?; - if now <= identity.expires_at_ms { - tokio::time::sleep(Duration::from_millis(u64::try_from( - identity.expires_at_ms - now + 1, - )?)) - .await; - } - Ok(()) + wait_for_sdk_expiry(identity.expires_at_ms).await } #[tokio::test] @@ -283,7 +275,7 @@ async fn denied_begin_is_original_knowledge_after_sdk_expiry_and_authority_chang let f = Fixture::new(format).await?; let operation = [234; 16]; let mut mutation = identity()?; - mutation.expires_at_ms = mutation.issued_at_ms + 1_000; + mutation.expires_at_ms = mutation.issued_at_ms + 10_000; let original = PreparedCustody::prepare( &f.client(), &f.target, @@ -328,7 +320,7 @@ async fn cold_owner_restoration_recovers_claim_and_renew_receipts_without_revivi let started = execute(&f, CustodyAction::BeginPreparation(f.begin(operation))).await?; let old = token(&started.output)?; let mut mutation = identity()?; - mutation.expires_at_ms = mutation.issued_at_ms + 1_000; + mutation.expires_at_ms = mutation.issued_at_ms + 10_000; let action = if claim { CustodyAction::ClaimPreparation(request(old)) } else { @@ -337,7 +329,12 @@ async fn cold_owner_restoration_recovers_claim_and_renew_receipts_without_revivi let original = PreparedCustody::prepare(&f.client(), &f.target, action, mutation).await?; let registered = original.register(&f.client(), identity()?).await?; - let accepted = registered.recover(&f.client()).await?; + // Allow loaded CI workers to commit before testing actual expiry. + // The post-restore Expired assertion below remains mandatory. + let accepted = registered + .recover(&f.client()) + .await + .map_err(|error| format!("initial acceptance before owner restore: {error:?}"))?; let token = token(&accepted.output)?; let (runtime, handle, client) = super::durable_recovery::restore_owner(&f, &check(token)).await?; @@ -416,7 +413,7 @@ async fn denied_renewal_preserves_knowledge_and_exact_successor_claim_survives_r assert_ne!(prior, original); edit(&f, "UPDATE catalog_operations SET expires_at_ms=0; UPDATE catalog_leases SET expires_at_ms=0").await?; let mut mutation = identity()?; - mutation.expires_at_ms = mutation.issued_at_ms + 1_000; + mutation.expires_at_ms = mutation.issued_at_ms + 10_000; let action = if staging { CustodyAction::RenewStaging(request(prior)) } else { @@ -727,7 +724,7 @@ async fn denied_claim_keeps_its_original_receipt_after_sdk_expiry_and_cold_resto .await?; let successor = token(&accepted.output)?; let mut mutation = identity()?; - mutation.expires_at_ms = mutation.issued_at_ms + 1_000; + mutation.expires_at_ms = mutation.issued_at_ms + 10_000; let action = if staging { CustodyAction::ClaimStaging(request(old)) } else { diff --git a/crates/canopy-server/src/packs/publication/tests/custody_stop.rs b/crates/canopy-server/src/packs/publication/tests/custody_stop.rs index 8ce17155..37a97e35 100644 --- a/crates/canopy-server/src/packs/publication/tests/custody_stop.rs +++ b/crates/canopy-server/src/packs/publication/tests/custody_stop.rs @@ -4,14 +4,7 @@ use cellule_runtime::Resolution; use tokio::time::{Duration, timeout}; async fn expired(value: &cellule_runtime::PendingMutation) -> Result { - let now = sql::now(0)?; - if now <= value.identity().expires_at_ms { - tokio::time::sleep(Duration::from_millis( - (value.identity().expires_at_ms - now + 1) as u64, - )) - .await; - } - Ok(()) + wait_for_sdk_expiry(value.identity().expires_at_ms).await } async fn registered(f: &Fixture, kind: u8) -> Result { Ok( diff --git a/crates/canopy-server/src/packs/publication/tests/durable_policy.rs b/crates/canopy-server/src/packs/publication/tests/durable_policy.rs index 467200c7..962236e1 100644 --- a/crates/canopy-server/src/packs/publication/tests/durable_policy.rs +++ b/crates/canopy-server/src/packs/publication/tests/durable_policy.rs @@ -208,13 +208,7 @@ pub(super) async fn qualify(context: Context<'_>, refusal_case: bool, late_write assert!(staging.close_and_drain().await.is_empty()); let (runtime, handle, client) = super::durable_recovery::restore_owner(f, &check).await?; // Wall-clock expiry is real SDK behavior, not a synthetic transport result. - let now = i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis())?; - if now <= first_identity.expires_at_ms { - tokio::time::sleep(std::time::Duration::from_millis(u64::try_from( - first_identity.expires_at_ms - now + 1, - )?)) - .await; - } + wait_for_sdk_expiry(first_identity.expires_at_ms).await?; assert!(matches!( client.resolve(&first_evidence).await?, Resolution::Expired diff --git a/crates/canopy-server/src/packs/publication/tests/durable_recovery.rs b/crates/canopy-server/src/packs/publication/tests/durable_recovery.rs index 3fa9c7c9..d895b9ce 100644 --- a/crates/canopy-server/src/packs/publication/tests/durable_recovery.rs +++ b/crates/canopy-server/src/packs/publication/tests/durable_recovery.rs @@ -59,6 +59,11 @@ pub(super) async fn qualify_publish( )) .await?; let guard = pending.ready(&prepared).await?; + let refusal = Arc::new( + Arc::new(prepared.base.session.clone()) + .ready_root_refusal(identity()?, store, root, budget.clone(), None) + .await?, + ); let ready = prepared .ready_root_push( identity()?, @@ -68,7 +73,8 @@ pub(super) async fn qualify_publish( crate::packs::metadata::tests::limits(), None, ) - .await?; + .await? + .with_refusal(refusal.clone())?; let loser = prepared .ready_root_push( identity()?, @@ -78,7 +84,8 @@ pub(super) async fn qualify_publish( crate::packs::metadata::tests::limits(), None, ) - .await?; + .await? + .with_refusal(refusal)?; drop(pending); drop(guard); drop(prepared); @@ -248,7 +255,12 @@ async fn qualify_ready( matches!(tokio::time::timeout(std::time::Duration::from_secs(10), observer.wait()).await?, PublicationState::Uncertain(error) if matches!(&*error, PublicationError::RootPush(InvocationError::Pending(evidence)) if **evidence == original)) ); - assert_eq!(queue.stats().await.command_bytes, 32 << 10); + // Publishing bundles retain both the 32 KiB publication and its + // 16 KiB frozen refusal; ref-free outcomes retain one command. + assert_eq!( + queue.stats().await.command_bytes, + if publishing { 48 << 10 } else { 32 << 10 } + ); assert_eq!(queue.close_and_drain().await.len(), 1); observer.recover().await?; assert!( diff --git a/crates/canopy-server/src/packs/publication/tests/initialization_recovery.rs b/crates/canopy-server/src/packs/publication/tests/initialization_recovery.rs index 21b0c942..03ad1640 100644 --- a/crates/canopy-server/src/packs/publication/tests/initialization_recovery.rs +++ b/crates/canopy-server/src/packs/publication/tests/initialization_recovery.rs @@ -270,13 +270,7 @@ async fn original_initialization_receipt_survives_lost_ack_expiry_body_loss_and_ edit(&f, "UPDATE repository_identity SET owner='replacement'").await?; drop(registered); let (runtime, handle, client) = super::durable_recovery::restore_owner(&f, &check).await?; - let now = i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis())?; - if now <= mutation.expires_at_ms { - tokio::time::sleep(Duration::from_millis(u64::try_from( - mutation.expires_at_ms - now + 1, - )?)) - .await; - } + wait_for_sdk_expiry(mutation.expires_at_ms).await?; assert!(matches!( client.resolve(&original).await?, Resolution::Expired diff --git a/crates/canopy-server/src/packs/publication/tests/inputs/requests/results.rs b/crates/canopy-server/src/packs/publication/tests/inputs/requests/results.rs index e572d0a1..103091a9 100644 --- a/crates/canopy-server/src/packs/publication/tests/inputs/requests/results.rs +++ b/crates/canopy-server/src/packs/publication/tests/inputs/requests/results.rs @@ -485,3 +485,101 @@ async fn root_outcome_exact_recovery_preserves_commits_and_refuses_expired_input } Ok(()) } + +#[tokio::test] +async fn resident_discovery_defers_registered_root_until_live_producer_handoff() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let request = Request::new(format, false, false, [249; 16]).await?; + let native = PushCompletionRequest { + plan: None, + response: GitHttpResponse { + status: 503, + headers: Vec::new(), + body: b"native refusal\n".to_vec(), + }, + options: Vec::new(), + certificate: None, + }; + retain(&request, native).await?; + request.ticket.seal()?; + assert!(matches!( + request.ticket.wait_terminal().await, + StagingState::Bound(_) + )); + let session = request.ticket.bound_session()?; + let ready = session + .ready_root_outcome( + identity()?, + &request.store, + request.directory.path(), + request.disk.clone(), + None, + ) + .await?; + assert!(request.coordinator.owns_bound(&session.check)); + let mut other = session.check.clone(); + other.token.artifact_operation[0] ^= 1; + assert!(!request.coordinator.owns_bound(&other)); + other = session.check.clone(); + other.actor = "other".into(); + assert!(!request.coordinator.owns_bound(&other)); + other = session.check.clone(); + other.token.attempt += 1; + assert!(!request.coordinator.owns_bound(&other)); + let evidence = ready.evidence_for_test(); + let registered = ready.persist_recovery(&request.store, identity()?).await?; + let ready = ready.bind_recovery(registered, &request.store)?; + let f = &request.fixture; + let queue = PublicationCoordinator::new( + f.target.clone(), + PublicationLimits::default(), + f.publication_budget.clone(), + )?; + let service = RecoverySupervisor::start_resident( + f.client(), + f.target.clone(), + (*request.store).clone(), + (queue.clone(), Arc::new(request.coordinator.clone())), + f.scans(RecoveryScanLimits { + page: 1, + interval: Duration::from_millis(10), + }), + f.authority(), + super::super::super::terminal_retention::maintenance(&f.handle, f.repository).await?, + )?; + // Keep the producer paused after durable registration. Two full scan + // passes must not execute its original command or steal admission. + timeout(Duration::from_secs(10), async { + while service.stats().passes < 2 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await?; + let stats = service.stats(); + assert_eq!(stats.failures, 0, "{stats:?}"); + assert_eq!( + stats.submitted, 0, + "live producer lost ownership: {stats:?}" + ); + assert!(stats.deferred > 0); + assert_eq!(queue.stats().await.admitted, 0); + assert!(matches!( + f.client().resolve(&evidence).await?, + cellule_runtime::Resolution::Absent + )); + let observer = request.ticket.publish_wait(&queue, ready).await?; + assert!(matches!( + observer.wait().await, + PublicationState::Finished(Ok(PublicationOutcome::RootPush(_))) + )); + assert!(matches!( + request.ticket.wait_terminal().await, + StagingState::Published(_) + )); + service.shutdown().await?; + assert!(queue.close_and_drain().await.is_empty()); + assert!(request.coordinator.close_and_drain().await.is_empty()); + f.runtime.shutdown().await?; + } + Ok(()) +} diff --git a/crates/canopy-server/src/packs/publication/tests/native_candidate.rs b/crates/canopy-server/src/packs/publication/tests/native_candidate.rs new file mode 100644 index 00000000..e56332dc --- /dev/null +++ b/crates/canopy-server/src/packs/publication/tests/native_candidate.rs @@ -0,0 +1,435 @@ +//! Real stock-Git pack bytes and private closed native catalogs. These isolate +//! generated commit verification; joint Ready publication remains separate. +use super::*; +use super::{ + prepare::{opened_native, physical}, + publishing::repack, +}; +use crate::{ + ObjectId, ObjectKind, + packs::{ + catalog::{CatalogFileLimits, CatalogFiles}, + metadata::tests::limits, + verification::physical::tests::{Prepared, independence::git_input}, + }, + pulls::{ + PullRevision, + candidates::{ + CandidateRequest, CandidateResult, MergeCandidate, commit_body, rebase::Commit, + }, + merge::MergeStrategy, + }, +}; +use cellule_ltx::DiskBudget; + +fn oid(bytes: Vec) -> Result { + let s = String::from_utf8(bytes)?; + Ok(crate::pulls::merge::oid(s.trim())?) +} +pub(super) async fn write(native: &Prepared, body: &[u8], name: &str) -> Result { + let o = oid(git_input( + native.fixture.root.path(), + &["hash-object", "-t", "commit", "-w", "--stdin"], + body, + ) + .await?)?; + assert_eq!(o, crate::object_id(o.format(), ObjectKind::Commit, body)); + git_input( + native.fixture.root.path(), + &["update-ref", name, &hex::encode(o)], + b"", + ) + .await?; + Ok(o) +} +fn original(tree: ObjectId, parent: ObjectId, message: &str) -> Vec { + format!("tree {}\nparent {}\nauthor Author 1 +0000\ncommitter Original 2 +0000\nencoding UTF-8\ngpgsig stale signature\n continuation\nmergetag stale tag\n continuation\n\n{message}\n",hex::encode(tree),hex::encode(parent)).into_bytes() +} +pub(super) fn candidate( + strategy: MergeStrategy, + base: ObjectId, + source: ObjectId, +) -> MergeCandidate { + MergeCandidate { + request: CandidateRequest { + id: uuid::Uuid::new_v4().to_string(), + revision: PullRevision { + pull_version: 1, + source_oid: hex::encode(source), + source_version: 1, + base_oid: hex::encode(base), + base_version: 1, + }, + message: if strategy == MergeStrategy::Rebase { + String::new() + } else { + "Exact candidate message".into() + }, + strategy, + }, + number: 1, + actor: "owner".into(), + created_at_ms: 5000, + result: CandidateResult::Pending, + } +} +pub(super) fn ready(c: &MergeCandidate, tip: ObjectId, tree: ObjectId) -> MergeCandidate { + let mut c = c.clone(); + c.result = CandidateResult::Ready { + oid: hex::encode(tip), + tree_oid: hex::encode(tree), + }; + c +} +pub(super) fn initial(native: &Prepared) -> Result<(ObjectId, ObjectId)> { + let (commit, edges) = native + .fixture + .objects + .values() + .find(|(o, _)| o.kind == ObjectKind::Commit) + .ok_or("initial commit")?; + let tree = edges + .iter() + .find(|e| e.expected_kind == ObjectKind::Tree) + .ok_or("initial tree")? + .child; + Ok((commit.oid, tree)) +} + +pub(super) async fn catalog( + f: &Fixture, + native: &mut Prepared, + base: Arc, +) -> Result<(PreparedCatalog, tempfile::TempDir, DiskBudget)> { + repack(native).await?; + let root = tempfile::TempDir::new()?; + let budget = DiskBudget::new(256 << 20); + let files = Arc::new( + CatalogFiles::new( + f.root.path(), + budget.clone(), + native.store.clone(), + f.format, + CatalogFileLimits::default(), + )? + .with_native( + crate::native_resources::NativeResources::default() + .scope(crate::native_resources::NativeClass::Foreground), + ), + ); + let base = Arc::new( + PreparationBaseResolver::from_session(base.session.clone(), base.indexes(), files).await?, + ); + let mut builder = CatalogPreparation::new(root.path(), budget.clone(), base, limits()).await?; + let (witness, segments) = physical(native, root.path(), budget.clone()).await?; + builder.begin_pack(witness)?; + for segment in segments { + builder.add_segment(segment).await?; + } + builder.finish_pack().await?; + Ok((builder.finish().await?, root, budget)) +} +async fn verified( + p: &PreparedCatalog, + c: &MergeCandidate, + root: &tempfile::TempDir, + budget: &DiskBudget, +) -> Result { + p.verify_candidate_commit(c, root.path(), budget.clone(), limits()) + .await?; + Ok(()) +} +async fn refused( + p: &PreparedCatalog, + c: &MergeCandidate, + root: &tempfile::TempDir, + budget: &DiskBudget, +) -> Result { + assert!(matches!( + p.verify_candidate_commit(c, root.path(), budget.clone(), limits()) + .await, + Err(NativeCandidateVerificationError::Invalid) + )); + Ok(()) +} +#[tokio::test] +async fn generated_merge_and_squash_require_exact_verified_bytes_without_native_body_downloads() +-> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let f = Fixture::new(format).await?; + let (mut native, base, _, _) = + opened_native(&f, *uuid::Uuid::new_v4().as_bytes(), 2).await?; + let (initial, tree) = initial(&native)?; + let source = write( + &native, + &original(tree, initial, "source"), + "refs/heads/source", + ) + .await?; + let merge = candidate(MergeStrategy::MergeCommit, initial, source); + let merge_oid = write( + &native, + &commit_body(&merge, &hex::encode(tree)), + "refs/heads/generated-merge", + ) + .await?; + let squash = candidate(MergeStrategy::Squash, initial, source); + let squash_oid = write( + &native, + &commit_body(&squash, &hex::encode(tree)), + "refs/heads/generated-squash", + ) + .await?; + let mut swapped = merge.clone(); + swapped.request.revision.base_oid = hex::encode(source); + swapped.request.revision.source_oid = hex::encode(initial); + let swapped_oid = write( + &native, + &commit_body(&swapped, &hex::encode(tree)), + "refs/heads/swapped", + ) + .await?; + let (p, root, budget) = catalog(&f, &mut native, base).await?; + let good = ready(&merge, merge_oid, tree); + verified(&p, &good, &root, &budget).await?; + verified(&p, &ready(&squash, squash_oid, tree), &root, &budget).await?; + for mut c in [ + good.clone(), + good.clone(), + good.clone(), + good.clone(), + good.clone(), + ] + .into_iter() + .enumerate() + { + match c.0 { + 0 => c.1.request.message.push('!'), + 1 => c.1.created_at_ms += 1000, + 2 => c.1.actor = "another".into(), + 3 => c.1.result = CandidateResult::Pending, + _ => { + c.1.result = CandidateResult::Ready { + oid: hex::encode(merge_oid), + tree_oid: hex::encode(source), + } + } + } + refused(&p, &c.1, &root, &budget).await?; + } + refused(&p, &ready(&merge, swapped_oid, tree), &root, &budget).await?; + refused(&p, &ready(&merge, squash_oid, tree), &root, &budget).await?; + refused( + &p, + &ready( + &merge, + crate::object_id(format, ObjectKind::Commit, b"unpublished"), + tree, + ), + &root, + &budget, + ) + .await?; + assert_eq!( + p.base + .files() + .native_stats()? + .ok_or("native stats")? + .downloaded_files, + 0 + ); + let legacy=f.handle.query(0,4096,|db|Ok(db.query_row("SELECT count(*) FROM sqlite_schema WHERE type='table' AND name IN ('objects','commit_ancestry')",[],|r|r.get::<_,u64>(0))?.to_le_bytes().to_vec())).await?; + assert_eq!( + u64::from_le_bytes(legacy.try_into().map_err(|_| "legacy count")?), + 0 + ); + p.base.session.fence(); + assert!(matches!( + p.verify_candidate_commit(&good, root.path(), budget.clone(), limits()) + .await, + Err(NativeCandidateVerificationError::Base( + PreparationBaseError::Inactive + )) + )); + drop(p); + f.runtime.shutdown().await?; + } + Ok(()) +} +#[tokio::test] +async fn native_rebase_binds_every_original_and_rejects_skips_and_replayed_base_history() -> Result +{ + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let f = Fixture::new(format).await?; + let (mut native, base, _, _) = + opened_native(&f, *uuid::Uuid::new_v4().as_bytes(), 2).await?; + let (initial, tree) = initial(&native)?; + let common_body = original(tree, initial, "common"); + let common = write(&native, &common_body, "refs/heads/common").await?; + let base_oid = write(&native, &original(tree, common, "base"), "refs/heads/base").await?; + let first_body = original(tree, common, "first"); + let first = write(&native, &first_body, "refs/heads/first").await?; + let last_body = original(tree, first, "last"); + let source = write(&native, &last_body, "refs/heads/source").await?; + let c = candidate(MergeStrategy::Rebase, base_oid, source); + let rewrite = |body: &[u8], parent: ObjectId| -> Result> { + Ok(Commit::parse(body).ok_or("original parse")?.rewrite( + &c, + &hex::encode(tree), + &hex::encode(parent), + )) + }; + let rewritten_first = write( + &native, + &rewrite(&first_body, base_oid)?, + "refs/heads/rebase-first", + ) + .await?; + let rewritten_last = write( + &native, + &rewrite(&last_body, rewritten_first)?, + "refs/heads/rebase-last", + ) + .await?; + let skipped = write( + &native, + &rewrite(&last_body, base_oid)?, + "refs/heads/skipped", + ) + .await?; + let extra = write( + &native, + &rewrite(&common_body, base_oid)?, + "refs/heads/extra", + ) + .await?; + let extra_first = write( + &native, + &rewrite(&first_body, extra)?, + "refs/heads/extra-first", + ) + .await?; + let extra_last = write( + &native, + &rewrite(&last_body, extra_first)?, + "refs/heads/extra-last", + ) + .await?; + let mut wrong = rewrite(&last_body, rewritten_first)?; + wrong.extend_from_slice(b"tampered message"); + let wrong = write(&native, &wrong, "refs/heads/wrong").await?; + let (p, root, budget) = catalog(&f, &mut native, base).await?; + verified(&p, &ready(&c, rewritten_last, tree), &root, &budget).await?; + for tip in [skipped, extra_last, wrong] { + refused(&p, &ready(&c, tip, tree), &root, &budget).await?; + } + let stats = p.base.files().native_stats()?.ok_or("native stats")?; + assert_eq!(stats.downloaded_files, 1); + assert!(stats.cache_hits > 0); + let reader = + crate::packs::catalog::CatalogReader::open(p.base.indexes(), p.catalog()).await?; + let files = p.base.files(); + let mut walker = + super::super::ref_proof::ancestry::Walker::new(root.path(), budget.clone(), limits()) + .await?; + assert_eq!( + walker + .ancestors_within( + &reader, + &files, + &[source, common, initial, common, base_oid], + base_oid, + &p.base + ) + .await?, + vec![false, true, true, true, true] + ); + assert_eq!( + walker + .ancestors_within(&reader, &files, &[source, first, common], source, &p.base) + .await?, + vec![true, true, true] + ); + drop(walker); + drop(p); + f.runtime.shutdown().await?; + } + Ok(()) +} + +#[tokio::test] +async fn native_rebase_accepts_128_commits_and_refuses_129_before_publication() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let f = Fixture::new(format).await?; + let (mut native, base, _, _) = + opened_native(&f, *uuid::Uuid::new_v4().as_bytes(), 1).await?; + let (initial, tree) = initial(&native)?; + let count = crate::pulls::candidates::rebase::MAX_COMMITS + 1; + let mut input = Vec::new(); + for rewritten in [false, true] { + for n in 1..=count { + let mark = n + if rewritten { count } else { 0 }; + let parent = if n == 1 { + hex::encode(initial) + } else { + format!(":{}", mark - 1) + }; + let (actor, email, time, reference) = if rewritten { + ( + "owner", + "owner@users.canopy.invalid", + 5, + "refs/heads/rewritten", + ) + } else { + ( + "Original", + "original@example.invalid", + 2, + "refs/heads/original", + ) + }; + input.extend_from_slice(format!("commit {reference}\nmark :{mark}\nauthor Author 1 +0000\ncommitter {actor} <{email}> {time} +0000\ndata 1\nx\nfrom {parent}\n\n").as_bytes()); + } + } + let marks_path = native.fixture.root.path().join("candidate-marks"); + let marks_arg = format!("--export-marks={}", marks_path.display()); + git_input( + native.fixture.root.path(), + &["fast-import", "--quiet", &marks_arg], + &input, + ) + .await?; + let marks = std::fs::read_to_string(marks_path)?; + let selected = |n: usize| -> Result { + let prefix = format!(":{n} "); + Ok(crate::pulls::merge::oid( + marks + .lines() + .find_map(|l| l.strip_prefix(&prefix)) + .ok_or("mark")?, + )?) + }; + let short = candidate(MergeStrategy::Rebase, initial, selected(count - 1)?); + let long = candidate(MergeStrategy::Rebase, initial, selected(count)?); + let short_tip = selected(count * 2 - 1)?; + let long_tip = selected(count * 2)?; + let (p, root, budget) = catalog(&f, &mut native, base).await?; + super::prepare::renewing(&f, &p.base, async { + verified(&p, &ready(&short, short_tip, tree), &root, &budget).await?; + refused(&p, &ready(&long, long_tip, tree), &root, &budget).await + }) + .await?; + assert_eq!( + p.base + .files() + .native_stats()? + .ok_or("native stats")? + .downloaded_files, + 1 + ); + drop(p); + f.runtime.shutdown().await?; + } + Ok(()) +} diff --git a/crates/canopy-server/src/packs/publication/tests/native_capture.rs b/crates/canopy-server/src/packs/publication/tests/native_capture.rs index c0fff050..39b41a83 100644 --- a/crates/canopy-server/src/packs/publication/tests/native_capture.rs +++ b/crates/canopy-server/src/packs/publication/tests/native_capture.rs @@ -13,7 +13,7 @@ use crate::{ catalog::{CatalogFileLimits, CatalogFiles, CatalogIndexes, CatalogReader}, metadata::tests::{fixture as input_fixture, limits}, verification::{ - PhysicalVerifier, + NativeMetadataLimits, PhysicalVerifier, physical::tests::{independence::git_input, physical_limits}, }, }, @@ -437,7 +437,50 @@ async fn native_receive(rooted: bool, mode: CompletionMode) -> Result { } Ok(()) } +#[derive(Clone, Copy, PartialEq, Eq)] +enum MetadataFault { + DescriptorLimit, + TruncatedReplay, + MissingArtifact, +} + +#[tokio::test] +async fn native_receive_metadata_refuses_capacity_corrupt_replay_and_missing_shards_without_publication() +-> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + for fault in [ + MetadataFault::DescriptorLimit, + MetadataFault::TruncatedReplay, + MetadataFault::MissingArtifact, + ] { + Box::pin(native_receive_metadata_case( + format, + true, + CompletionMode::Success, + Some(fault), + )) + .await?; + } + } + Ok(()) +} +async fn publication_state(fixture: &Fixture) -> Result> { + Ok(fixture.handle.query(0, 24, |db| { + let state = db.query_row("SELECT (SELECT generation FROM catalog_state WHERE singleton=1),(SELECT generation FROM ref_generation WHERE singleton=1),(SELECT count(*) FROM pushes WHERE response_id IS NOT NULL)", [], |row| { + Ok((row.get::<_, u64>(0)?, row.get::<_, u64>(1)?, row.get::<_, u64>(2)?)) + })?; + Ok([state.0.to_be_bytes(), state.1.to_be_bytes(), state.2.to_be_bytes()].concat()) + }).await?) +} async fn native_receive_case(format: ObjectFormat, rooted: bool, mode: CompletionMode) -> Result { + Box::pin(native_receive_metadata_case(format, rooted, mode, None)).await +} +async fn native_receive_metadata_case( + format: ObjectFormat, + rooted: bool, + mode: CompletionMode, + metadata_fault: Option, +) -> Result { let ref_free = match mode { CompletionMode::RefFree { kind, .. } => Some(kind), CompletionMode::Durable { .. } @@ -835,32 +878,70 @@ async fn native_receive_case(format: ObjectFormat, rooted: bool, mode: Completio fixture.runtime.shutdown().await?; return Ok(()); } + let before_publication = publication_state(&fixture).await?; let physical_root = Arc::new(tempfile::TempDir::new()?); let physical_disk = DiskBudget::new(256 << 20); let verify_root = physical_root.clone(); let verify_disk = physical_disk.clone(); let verify_store = store.clone(); let verify_native = native.clone(); + let input = inputs[0]; let work = ticket.spawn(move |context| async move { let result = async { - let mut verifier = PhysicalVerifier::download_staged( + let verifier = PhysicalVerifier::download_staged( &context, verify_root.path(), verify_disk, &verify_store, - inputs[0], + input, physical_limits(), verify_native.scope(NativeClass::Foreground), ) .await?; - let segment = verifier.inspect_next_shard(inputs[0].object_count).await?; - let witness = verifier.finish().await?; - Ok::<_, crate::packs::verification::PhysicalError>((witness, segment)) + verifier + .stage_metadata(NativeMetadataLimits { + max_shard_objects: 1, + max_descriptor_bytes: if metadata_fault == Some(MetadataFault::DescriptorLimit) + { + 1 + } else { + NativeMetadataLimits::default().max_descriptor_bytes + }, + }) + .await } .await; result.map_err(|error| StagingError::Input(Box::new(error))) })?; - let (witness, segment) = work.wait().await.map_err(|error| error.to_string())?; + let staged = work.wait().await; + if metadata_fault == Some(MetadataFault::DescriptorLimit) { + let error = match staged { + Err(error) => error, + Ok(_) => return Err("descriptor admission unexpectedly succeeded".into()), + }; + let StagingError::Input(source) = &*error else { + return Err("wrong descriptor refusal".into()); + }; + assert!(matches!( + source.downcast_ref::(), + Some(crate::packs::verification::PhysicalError::Limit) + )); + assert!(coordinator.close_and_drain().await.is_empty()); + assert_eq!(coordinator.stats().workers, 0); + assert_eq!(publication_state(&fixture).await?, before_publication); + cleaned(physical_root.path(), &physical_disk).await?; + fixture.runtime.shutdown().await?; + return Ok(()); + } + let staged = staged.map_err(|error| error.to_string())?; + assert_eq!(staged.shard_count(), input.object_count); + assert!(staged.descriptor_bytes() <= u64::from(staged.shard_count()) * 516); + timeout(Duration::from_secs(10), async { + while physical_disk.used() != staged.descriptor_bytes() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; ticket.seal()?; assert!(matches!( timeout(Duration::from_secs(10), ticket.wait_terminal()).await?, @@ -875,14 +956,64 @@ async fn native_receive_case(format: ObjectFormat, rooted: bool, mode: Completio CatalogFileLimits::default(), )?); let base = Arc::new(ticket.open_base(indexes, files).await?); - if rooted { - let mut builder = - CatalogPreparation::new(physical_root.path(), physical_disk.clone(), base, limits()) + match metadata_fault { + Some(MetadataFault::TruncatedReplay) => staged.truncate_replay_for_test()?, + Some(MetadataFault::MissingArtifact) => { + use object_store::ObjectStoreExt; + let stored = staged.first_metadata_for_test()?; + let path = store.path( + canopy_object_storage::artifact::ArtifactKey { + operation: input.operation, + binding_digest: input.pack.digest, + kind: canopy_object_storage::artifact::ArtifactKind::Metadata, + }, + stored.artifact.digest, + )?; + provider + .delete(&canopy_object_storage::external::part(&path, 0)) .await?; - builder.begin_retained_pack(witness).await?; - builder.add_segment(segment).await?; - builder.finish_pack().await?; - let prepared = builder.finish().await?; + } + _ => {} + } + let producer_root = Arc::clone(&physical_root); + let producer_disk = physical_disk.clone(); + let work = ticket.spawn_bound(move |_, context| async move { + let result = async { + let mut builder = CatalogPreparation::new_staged( + &context, + producer_root.path(), + producer_disk, + base, + limits(), + ) + .await?; + let added = builder.add_staged_pack(staged).await; + if metadata_fault.is_some() { + let error = added.expect_err("invalid staged metadata accepted"); + assert!( + builder.finish().await.is_err(), + "failed builder escaped poison" + ); + return Err(error); + } + added?; + builder.finish().await + } + .await; + result.map_err(|error| StagingError::Input(Box::new(error))) + })?; + let prepared = work.wait().await; + if metadata_fault.is_some() { + assert!(prepared.is_err()); + assert!(coordinator.close_and_drain().await.is_empty()); + assert_eq!(coordinator.stats().workers, 0); + assert_eq!(publication_state(&fixture).await?, before_publication); + cleaned(physical_root.path(), &physical_disk).await?; + fixture.runtime.shutdown().await?; + return Ok(()); + } + let prepared = prepared.map_err(|error| error.to_string())?; + if rooted { if matches!( mode, CompletionMode::MandatoryRegistration @@ -950,17 +1081,7 @@ async fn native_receive_case(format: ObjectFormat, rooted: bool, mode: Completio let publication_identity = identity()?; let work = ticket.spawn_bound(move |_, _context| async move { let result = async { - let mut builder = CatalogPreparation::new( - producer_root.path(), - producer_disk.clone(), - base, - limits(), - ) - .await?; - builder.begin_pack(witness)?; - builder.add_segment(segment).await?; - builder.finish_pack().await?; - let prepared = Arc::new(builder.finish().await?); + let prepared = Arc::new(prepared); let ready = Box::pin(prepared.ready_push( publication_identity, recovered, diff --git a/crates/canopy-server/src/packs/publication/tests/native_head.rs b/crates/canopy-server/src/packs/publication/tests/native_head.rs new file mode 100644 index 00000000..042bdeb5 --- /dev/null +++ b/crates/canopy-server/src/packs/publication/tests/native_head.rs @@ -0,0 +1,353 @@ +//! Same native catalog fixtures as merges, with real SDK phase and retirement. +use super::*; +use super::{ + native_merge::{initial, preparation}, + prepare::cleaned, + publishing::edit, +}; +use crate::packs::ref_state::RefStateIndex; +use canopy_object_storage::artifact::{ArtifactKey, ArtifactKind}; +use cellule_runtime::{PreparedCommand, Resolution}; +use object_store::ObjectStoreExt; + +fn request(reference: &str, generation: i64) -> HeadRequest { + HeadRequest { + reference: reference.into(), + expected_generation: generation, + } +} +async fn command( + f: &Fixture, + prepared: &PreparedCatalog, + request: HeadRequest, +) -> Result<(PreparedCommand, RegisteredRootRecovery)> { + let command = f + .client() + .prepare_command::( + &f.target, + identity()?, + prepared.native_head_proof(request).await?, + ) + .await?; + let saved = super::super::recovery::persist( + &prepared.base.session, + &command, + super::super::recovery::Kind::Head, + &prepared.base.indexes().store(), + identity()?, + 0, + ) + .await?; + Ok((command, saved)) +} +async fn state(f: &Fixture) -> Result> { + let roots = super::publishing::state(&f.handle).await?; + let phases = f.handle.query(0,65536, |db| { + let mut s = db.prepare("SELECT recovery_phase,recovery_phase_revision FROM catalog_leases ORDER BY admission_sequence")?; + let phases = s.query_map([], |r| Ok((r.get::<_,Option>>(0)?,r.get::<_,u64>(1)?)))?.collect::>>()?; + let heads: u64 = db.query_row("SELECT count(*) FROM catalog_head_updates",[],|r|r.get(0))?; + serde_json::to_vec(&(phases,heads)).map_err(|_|Error::Command("HEAD test state")) + }).await?; + Ok([roots, phases].concat()) +} +async fn recover( + f: &Fixture, + saved: &RegisteredRootRecovery, + store: &canopy_object_storage::artifact::ArtifactStore, +) -> Result> { + match saved + .dispatch_any( + &f.client(), + store, + &f.authority(), + &std::sync::atomic::AtomicBool::new(false), + ) + .await + { + Ok(PublicationOutcome::Head(value)) => Ok(value), + Err(PublicationError::Head(InvocationError::Rejected(value))) => Ok(*value), + other => Err(format!("wrong HEAD recovery purpose: {other:?}").into()), + } +} +#[tokio::test] +async fn native_head_reuses_ref_tree_and_preserves_original_receipt_after_typed_retirement() +-> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (f, graph, _) = initial(format, false).await?; + let (prepared, root, budget) = preparation(&f, &graph).await?; + let check = prepared.base.capability().2.clone(); + let old = prepared + .base() + .refs + .ok_or("missing ref snapshot")? + .read(&graph.store) + .await?; + let (command, saved) = command(&f, &prepared, request("refs/heads/feature", 1)).await?; + let original = command.clone().execute().await?; + let PublicationReply::Published(published) = original.output else { + return Err("HEAD refused".into()); + }; + assert_eq!((published.generation, published.ref_generation), (2, 2)); + let audit = f + .handle + .query(0, 1024, |db| { + assert_eq!( + db.query_row("SELECT generation FROM ref_generation", [], |r| r + .get::<_, u64>(0))?, + 2 + ); + assert_eq!( + db.query_row("SELECT default_branch FROM ref_generation", [], |r| r + .get::<_, String>(0))?, + "refs/heads/feature" + ); + assert_eq!( + db.query_row("SELECT count(*) FROM refs", [], |r| r.get::<_, u64>(0))?, + 0 + ); + Ok( + db.query_row("SELECT fact FROM catalog_head_updates", [], |r| { + r.get::<_, Vec>(0) + })?, + ) + }) + .await?; + let mut d = BoundedDecoder::new(&audit, 512)?; + let fact = GenerationFact::decode(&mut d)?; + d.finish()?; + let refs = fact.refs.ok_or("missing HEAD outcome refs")?; + let updated = refs.read(&graph.store).await?; + assert_eq!( + ( + updated.root.clone(), + updated.generation, + updated.default_branch.as_str() + ), + (old.root, 2, "refs/heads/feature") + ); + let index = RefStateIndex::new(graph.store.clone(), format); + assert_eq!( + index + .read(updated.root, "refs/heads/feature") + .await? + .ok_or("missing feature")? + .version, + 1 + ); + assert_eq!(command.execute().await?.receipt, original.receipt); + let admin = super::terminal_retention::maintenance(&f.handle, f.repository).await?; + // Corrupt/missing selected metadata must retain the original independent pin. + let path = graph.store.path( + ArtifactKey { + operation: refs.operation(), + binding_digest: refs.artifact().digest, + kind: ArtifactKind::InputRoot, + }, + refs.artifact().digest, + )?; + let bytes = graph.provider.get(&path).await?.bytes().await?; + graph.provider.delete(&path).await?; + assert!( + saved + .ready_terminal_release(f.client(), &graph.store, admin.clone(), identity()?) + .await + .is_err() + ); + assert_eq!( + recover(&f, &saved, &graph.store).await?.receipt, + original.receipt + ); + graph.provider.put(&path, bytes.into()).await?; + assert_eq!( + saved + .ready_terminal_release(f.client(), &graph.store, admin, identity()?) + .await? + .complete() + .await? + .output, + TerminalReleaseReply::Released + ); + for (key, descriptor) in saved.command_bodies_for_test() { + graph + .provider + .delete(&graph.store.path(key, descriptor.digest)?) + .await?; + } + drop(prepared); + cleaned(root.path(), &budget).await?; + // Permanent outcome selectors cannot be altered or deleted. + let id = check.token.operation; + f.handle + .query(0, 128, move |db| { + assert!( + db.execute( + "UPDATE catalog_head_updates SET actor='replacement' WHERE id=?1", + [id.as_slice()] + ) + .is_err() + ); + assert!( + db.execute( + "DELETE FROM catalog_head_updates WHERE id=?1", + [id.as_slice()] + ) + .is_err() + ); + Ok(Vec::new()) + }) + .await?; + let (runtime, handle, client) = super::durable_recovery::restore_owner(&f, &check).await?; + assert!(handle.owner_fence().epoch > check.token.owner.epoch); + let restored = RegisteredRootRecovery::load(&client, &f.target, &graph.store, &check) + .await? + .ok_or("HEAD archive missing")?; + let PublicationOutcome::Head(replay) = restored + .dispatch_any( + &client, + &graph.store, + &f.authority(), + &std::sync::atomic::AtomicBool::new(false), + ) + .await? + else { + return Err("wrong restored HEAD purpose".into()); + }; + assert_eq!( + (replay.output, replay.receipt), + (original.output, original.receipt) + ); + runtime.shutdown().await?; + drop(graph.prepared); + cleaned(graph.root.path(), &graph.budget).await?; + } + Ok(()) +} +#[tokio::test] +async fn native_head_denials_recheck_current_authority_generation_and_native_branch_existence() +-> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + for mode in 0..5 { + let (f, graph, _) = initial(format, false).await?; + let (prepared, root, budget) = preparation(&f, &graph).await?; + let req = match mode { + 0 => request("refs/heads/absent", 1), + 1 => request("refs/heads/feature", 0), + _ => request("refs/heads/feature", 1), + }; + let (command, saved) = command(&f, &prepared, req).await?; + let reason = match mode { + 2 => { + edit(&f, "UPDATE repository_identity SET owner='replacement'").await?; + PreparationDenial::Unauthorized + } + 3 => { + edit(&f,"INSERT INTO catalog_generations SELECT 2,catalog,certificate,refs FROM catalog_generations WHERE generation=1; UPDATE catalog_state SET generation=2").await?; + PreparationDenial::Conflict + } + 4 => { + edit(&f,"UPDATE catalog_operations SET expires_at_ms=0; UPDATE catalog_leases SET expires_at_ms=0").await?; + PreparationDenial::Expired + } + _ => PreparationDenial::Conflict, + }; + let roots_before = f + .handle + .query(0, 128, |db| { + serde_json::to_vec(&( + db.query_row("SELECT generation FROM catalog_state", [], |r| { + r.get::<_, u64>(0) + })?, + db.query_row("SELECT count(*) FROM catalog_head_updates", [], |r| { + r.get::<_, u64>(0) + })?, + )) + .map_err(|_| Error::Command("HEAD test roots")) + }) + .await?; + let original = if matches!(mode, 2 | 4) { + // Authoritative SDK absence after restart must settle the same + // frozen denial, without a fresh Write observation hiding it. + drop(command); + recover(&f, &saved, &graph.store).await? + } else { + command.execute().await? + }; + assert_eq!(original.output, PublicationReply::Denied(reason)); + let after = f.handle.query(0,128,|db| { + assert_eq!(db.query_row("SELECT count(*) FROM catalog_operations WHERE actor='owner' AND generation IS NOT NULL",[],|r|r.get::<_,u64>(0))?,1); + serde_json::to_vec(&(db.query_row("SELECT generation FROM catalog_state",[],|r|r.get::<_,u64>(0))?,db.query_row("SELECT count(*) FROM catalog_head_updates",[],|r|r.get::<_,u64>(0))?)).map_err(|_|Error::Command("HEAD test roots")) + }).await?; + assert_eq!(after, roots_before); + assert_eq!( + recover(&f, &saved, &graph.store).await?.receipt, + original.receipt + ); + drop(prepared); + cleaned(root.path(), &budget).await?; + drop(graph.prepared); + cleaned(graph.root.path(), &graph.budget).await?; + f.runtime.shutdown().await?; + } + } + Ok(()) +} +#[tokio::test] +async fn native_head_registration_and_late_sql_failure_keep_original_command_and_atomic_roots() +-> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (f, graph, _) = initial(format, false).await?; + let (prepared, root, budget) = preparation(&f, &graph).await?; + let mut proof = prepared + .native_head_proof(request("refs/heads/feature", 1)) + .await?; + let mut e = BoundedEncoder::new(NATIVE_HEAD_BYTES)?; + proof.encode(&mut e)?; + let bytes = e.finish(); + let mut d = BoundedDecoder::new(&bytes, NATIVE_HEAD_BYTES)?; + assert_eq!(NativeHeadProof::decode(&mut d)?, proof); + d.finish()?; + // A request/root mutation cannot borrow an authentic catalog's authority. + proof.request.reference = "refs/heads/main".into(); + assert!( + proof + .encode(&mut BoundedEncoder::new(NATIVE_HEAD_BYTES)?) + .is_err() + ); + let unregistered = f + .client() + .prepare_command::( + &f.target, + identity()?, + prepared + .native_head_proof(request("refs/heads/feature", 1)) + .await?, + ) + .await?; + let before = state(&f).await?; + assert!(matches!( + unregistered.execute().await, + Err(InvocationError::NotStarted(_)) + )); + assert_eq!(state(&f).await?, before); + let (command, _) = command(&f, &prepared, request("refs/heads/feature", 1)).await?; + edit(&f,"CREATE TRIGGER abort_head BEFORE INSERT ON catalog_head_updates BEGIN SELECT RAISE(ABORT,'late HEAD failure'); END").await?; + let before = state(&f).await?; + assert!(command.clone().execute().await.is_err()); + assert_eq!(state(&f).await?, before); + assert!(matches!( + f.client().resolve(command.evidence()).await?, + Resolution::Absent + )); + edit(&f, "DROP TRIGGER abort_head").await?; + assert!(matches!( + command.execute().await?.output, + PublicationReply::Published(_) + )); + drop(prepared); + cleaned(root.path(), &budget).await?; + drop(graph.prepared); + cleaned(graph.root.path(), &graph.budget).await?; + f.runtime.shutdown().await?; + } + Ok(()) +} diff --git a/crates/canopy-server/src/packs/publication/tests/native_merge.rs b/crates/canopy-server/src/packs/publication/tests/native_merge.rs new file mode 100644 index 00000000..da0719e5 --- /dev/null +++ b/crates/canopy-server/src/packs/publication/tests/native_merge.rs @@ -0,0 +1,594 @@ +//! Trusted initial root fixtures isolate the native transaction and exact SDK +//! recovery. Pack/catalog bytes come from verified stock-Git witnesses; this +//! does not qualify the public merge adapter or generated candidate producer. +use super::*; +use super::{ + prepare::{cleaned, opened}, + publishing::{Graph, assembled, edit, plan, update}, +}; +use crate::{ + packs::{ + metadata::tests::limits, + ref_state::{RefStateIndex, RefStateSnapshot}, + }, + pulls::PullRevision, + pulls::merge::{MergeOutcome, MergeRecord, MergeRequest, MergeStrategy, command::MergeInput}, +}; +use cellule_ltx::DiskBudget; +use cellule_runtime::{PreparedCommand, Resolution}; + +pub(super) async fn initial( + format: ObjectFormat, + unrelated: bool, +) -> Result<(Fixture, Graph, MergeRequest)> { + let f = Fixture::new(format).await?; + let graph = assembled(&f, [245; 16], 16).await?; + let source = if unrelated { graph.other } else { graph.tip }; + let store = graph.store.clone(); + let namespace = graph.prepared.token().artifact_operation; + let index = RefStateIndex::new(store.clone(), format); + let refs = index + .prepare( + None, + namespace, + &plan(vec![ + update("refs/heads/main", None, Some(graph.initial)), + update("refs/heads/feature", None, Some(source)), + ]), + ) + .await?; + let refs = RefStateSnapshotRoot::upload( + &store, + namespace, + RefStateSnapshot { + repository: f.repository, + format, + generation: 1, + default_branch: "refs/heads/main".into(), + root: Some(refs.root()), + }, + ) + .await?; + let mut catalog = BoundedEncoder::new(256)?; + graph.prepared.catalog().encode(&mut catalog)?; + let mut encoded_refs = BoundedEncoder::new(128)?; + refs.encode(&mut encoded_refs)?; + let catalog = catalog.finish(); + let refs = encoded_refs.finish(); + let base = graph.initial.to_vec(); + let source_bytes = source.to_vec(); + f.handle.execute(identity()?, Digest::from_bytes([145;32]), sql::now(0)?, 4096, 0, move |tx| { + tx.execute("INSERT INTO catalog_generations(generation,catalog,certificate,refs) VALUES(1,?1,?2,?3)", rusqlite::params![catalog,[4u8;32].as_slice(),refs])?; + tx.execute("UPDATE catalog_state SET generation=1 WHERE singleton=1", [])?; + tx.execute("INSERT INTO pull_requests(number,id,creation_digest,author,title,body,state,draft,version,source_ref,base_ref,initial_source_oid,initial_base_oid,created_ms,updated_ms) VALUES(1,?1,?2,'writer','Merge','', 'open',0,1,'refs/heads/feature','refs/heads/main',?3,?4,0,0)",rusqlite::params![[25u8;16].as_slice(),[26u8;32].as_slice(),source_bytes,base])?; + Ok(cellule_runtime::cell::executor::HandlerOutcome::Success(Vec::new())) + }).await?; + let request = MergeRequest { + id: uuid::Uuid::new_v4().to_string(), + strategy: MergeStrategy::FastForward, + candidate_id: None, + revision: PullRevision { + pull_version: 1, + source_oid: hex::encode(source), + source_version: 1, + base_oid: hex::encode(graph.initial), + base_version: 1, + }, + }; + Ok((f, graph, request)) +} +pub(super) async fn preparation( + f: &Fixture, + graph: &Graph, +) -> Result<(Arc, tempfile::TempDir, DiskBudget)> { + let (base, _, _) = opened(f, *uuid::Uuid::new_v4().as_bytes(), graph.store.clone()).await?; + let root = tempfile::TempDir::new()?; + let budget = DiskBudget::new(256 << 20); + let prepared = Arc::new( + CatalogPreparation::new(root.path(), budget.clone(), base, limits()) + .await? + .finish() + .await?, + ); + assert_eq!(prepared.input_count(), 0); + Ok((prepared, root, budget)) +} +async fn prepared_command( + f: &Fixture, + prepared: &PreparedCatalog, + request: MergeRequest, + root: &std::path::Path, + budget: DiskBudget, +) -> Result<( + PreparedCommand, + RegisteredRootRecovery, +)> { + let mutation = identity()?; + let proof = prepared + .native_merge_proof( + MergeInput { + actor: "owner".into(), + number: 1, + request, + issued_at_ms: mutation.issued_at_ms, + }, + root, + budget, + limits(), + ) + .await?; + let command = f + .client() + .prepare_command::(&f.target, mutation, proof) + .await?; + let registered = super::super::recovery::persist( + &prepared.base.session, + &command, + super::super::recovery::Kind::Merge, + &prepared.base.indexes().store(), + identity()?, + 0, + ) + .await?; + Ok((command, registered)) +} +async fn domain_state(f: &Fixture) -> Result> { + domain_state_except_attempt(f, None).await +} +async fn domain_state_except_attempt(f: &Fixture, operation: Option<[u8; 16]>) -> Result> { + let roots = super::publishing::state_except_operation(&f.handle, operation).await?; + let editorial = f + .handle + .query(0, 4096, |db| { + let pull = db.query_row( + "SELECT state,version,updated_ms FROM pull_requests WHERE number=1", + [], + |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, i64>(1)?, + r.get::<_, i64>(2)?, + )) + }, + )?; + let merges = db.query_row("SELECT count(*) FROM pull_merges", [], |r| { + r.get::<_, u64>(0) + })?; + serde_json::to_vec(&(pull, merges)).map_err(|_| Error::Command("merge fixture state")) + }) + .await?; + Ok([roots, editorial].concat()) +} +async fn merged_roots(f: &Fixture, graph: &Graph) -> Result { + let bytes=f.handle.query(0,4096,|db| { + let result=db.query_row("SELECT s.generation,g.refs,(SELECT count(*) FROM refs),(SELECT generation FROM ref_generation),(SELECT state FROM pull_requests WHERE number=1),(SELECT count(*) FROM pull_merges) FROM catalog_state s JOIN catalog_generations g ON g.generation=s.generation",[],|r|Ok((r.get::<_,u64>(0)?,r.get::<_,Vec>(1)?,r.get::<_,u64>(2)?,r.get::<_,u64>(3)?,r.get::<_,String>(4)?,r.get::<_,u64>(5)?)))?; + serde_json::to_vec(&result).map_err(|_|Error::Command("merge fixture roots")) + }).await?; + let (generation, refs, legacy, summary_generation, pull, merges): ( + u64, + Vec, + u64, + u64, + String, + u64, + ) = serde_json::from_slice(&bytes)?; + assert_eq!( + ( + generation, + legacy, + summary_generation, + pull.as_str(), + merges + ), + (2, 0, 2, "merged", 1) + ); + let mut d = BoundedDecoder::new(&refs, 128)?; + let root = RefStateSnapshotRoot::decode(&mut d)?; + d.finish()?; + let snapshot = root.read(&graph.store).await?; + assert_eq!(snapshot.generation, 2); + let index = RefStateIndex::new(graph.store.clone(), f.format); + let base = index + .read(snapshot.root.clone(), "refs/heads/main") + .await? + .ok_or("merged base")?; + let source = index + .read(snapshot.root, "refs/heads/feature") + .await? + .ok_or("source")?; + assert_eq!( + (base.oid, base.version, source.oid, source.version), + (Some(graph.tip), 2, Some(graph.tip), 1) + ); + Ok(()) +} +#[tokio::test] +async fn native_merge_commits_joint_roots_pull_uuid_and_original_receipt_without_sql_ref_authority() +-> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (f, graph, request) = initial(format, false).await?; + let (prepared, root, budget) = preparation(&f, &graph).await?; + let (command, registered) = + prepared_command(&f, &prepared, request.clone(), root.path(), budget.clone()).await?; + let result = command.clone().execute().await?; + let MergeOutcome::Applied { ref merge } = result.output else { + return Err("merge denied".into()); + }; + assert_eq!( + (merge.id.as_str(), merge.oid.as_str()), + (request.id.as_str(), request.revision.source_oid.as_str()) + ); + merged_roots(&f, &graph).await?; + let recovered = registered + .dispatch_any( + &f.client(), + &graph.store, + &f.authority(), + &std::sync::atomic::AtomicBool::new(false), + ) + .await?; + let PublicationOutcome::Merge(recovered) = recovered else { + return Err("wrong recovered purpose".into()); + }; + assert_eq!(recovered.receipt, result.receipt); + assert_eq!(recovered.output, result.output); + // A fresh SDK identity and privately owned attempt replay the original + // application UUID, even though the pull and base revision have advanced. + let (retry, retry_root, retry_budget) = preparation(&f, &graph).await?; + let (retry_command, _) = + prepared_command(&f, &retry, request, retry_root.path(), retry_budget.clone()).await?; + assert_eq!(retry_command.execute().await?.output, result.output); + merged_roots(&f, &graph).await?; + drop(retry); + cleaned(retry_root.path(), &retry_budget).await?; + drop(prepared); + cleaned(root.path(), &budget).await?; + drop(graph.prepared); + cleaned(graph.root.path(), &graph.budget).await?; + f.runtime.shutdown().await?; + } + Ok(()) +} +#[tokio::test] +async fn native_merge_unprotected_unrelated_history_records_refusal_without_publishing() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (f, graph, request) = initial(format, true).await?; + let (prepared, root, budget) = preparation(&f, &graph).await?; + let (command, registered) = + prepared_command(&f, &prepared, request, root.path(), budget.clone()).await?; + let operation = prepared.token().operation; + let before = domain_state_except_attempt(&f, Some(operation)).await?; + let result = command.execute().await?; + assert_eq!(result.output, MergeOutcome::NotFastForward); + assert_eq!( + domain_state_except_attempt(&f, Some(operation)).await?, + before + ); + assert_operation(&f, operation, 0).await?; + let recovered = registered + .dispatch_any( + &f.client(), + &graph.store, + &f.authority(), + &std::sync::atomic::AtomicBool::new(false), + ) + .await; + assert!( + matches!(recovered,Err(PublicationError::Merge(InvocationError::Rejected(value))) if value.receipt==result.receipt && value.output==MergeOutcome::NotFastForward) + ); + drop(prepared); + cleaned(root.path(), &budget).await?; + drop(graph.prepared); + cleaned(graph.root.path(), &graph.budget).await?; + f.runtime.shutdown().await?; + } + Ok(()) +} +#[tokio::test] +async fn native_merge_late_review_requirement_is_current_and_does_not_bind_a_rejected_application_uuid() +-> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (f, graph, request) = initial(format, false).await?; + let (prepared, root, budget) = preparation(&f, &graph).await?; + let (first, registered) = + prepared_command(&f, &prepared, request.clone(), root.path(), budget.clone()).await?; + edit( + &f, + "INSERT INTO branch_rules VALUES('refs/heads/main',1,1,1,1,1,1)", + ) + .await?; + let operation = prepared.token().operation; + let before = domain_state_except_attempt(&f, Some(operation)).await?; + let result = first.execute().await?; + assert_eq!(result.output, MergeOutcome::ReviewsRequired); + assert_eq!( + domain_state_except_attempt(&f, Some(operation)).await?, + before + ); + assert_operation(&f, operation, 0).await?; + // Remove only the review requirement, retain require-PR, ancestry and + // deletion policy. Only the reviewed command may satisfy this PR gate. + edit(&f,"UPDATE branch_rules SET required_approvals=0,version=2 WHERE reference='refs/heads/main'").await?; + let (retry, retry_root, retry_budget) = preparation(&f, &graph).await?; + let (retry_command, _) = + prepared_command(&f, &retry, request, retry_root.path(), retry_budget.clone()).await?; + assert!(matches!( + retry_command.execute().await?.output, + MergeOutcome::Applied { .. } + )); + merged_roots(&f, &graph).await?; + assert!( + matches!(registered.dispatch_any(&f.client(),&graph.store,&f.authority(),&std::sync::atomic::AtomicBool::new(false)).await, + Err(PublicationError::Merge(InvocationError::Rejected(value))) if value.receipt==result.receipt && value.output==MergeOutcome::ReviewsRequired) + ); + drop(retry); + cleaned(retry_root.path(), &retry_budget).await?; + drop(prepared); + cleaned(root.path(), &budget).await?; + drop(graph.prepared); + cleaned(graph.root.path(), &graph.budget).await?; + f.runtime.shutdown().await?; + } + Ok(()) +} +#[tokio::test] +async fn native_merge_late_sql_abort_rolls_back_roots_pull_uuid_checkpoint_and_recovery_phase() +-> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (f, graph, request) = initial(format, false).await?; + let (prepared, root, budget) = preparation(&f, &graph).await?; + let (command, _registered) = + prepared_command(&f, &prepared, request, root.path(), budget.clone()).await?; + edit(&f,"CREATE TRIGGER abort_merge BEFORE INSERT ON pull_merges BEGIN SELECT RAISE(ABORT,'late merge failure'); END;").await?; + let phase_before = phase_state(&f).await?; + let before = domain_state(&f).await?; + assert!(command.clone().execute().await.is_err()); + assert_eq!(domain_state(&f).await?, before); + assert!(matches!( + f.client().resolve(command.evidence()).await?, + Resolution::Absent + )); + assert_eq!(phase_state(&f).await?, phase_before); + edit(&f, "DROP TRIGGER abort_merge").await?; + assert!(matches!( + command.execute().await?.output, + MergeOutcome::Applied { .. } + )); + merged_roots(&f, &graph).await?; + drop(prepared); + cleaned(root.path(), &budget).await?; + drop(graph.prepared); + cleaned(graph.root.path(), &graph.budget).await?; + f.runtime.shutdown().await?; + } + Ok(()) +} +#[test] +fn maximum_sha256_merge_result_fits_existing_512_byte_recovery_contract() -> Result { + let max = i64::MAX; + let result = MergeOutcome::Applied { + merge: MergeRecord { + id: "12345678-1234-1234-1234-123456789abc".into(), + number: max, + oid: "c".repeat(64), + merged_at_ms: max, + revision: PullRevision { + pull_version: max - 1, + source_oid: "a".repeat(64), + source_version: max - 1, + base_oid: "b".repeat(64), + base_version: max - 1, + }, + }, + }; + let mut e = BoundedEncoder::new(512)?; + result.encode(&mut e)?; + let bytes = e.finish(); + assert_eq!(bytes.len(), 493); + let mut d = BoundedDecoder::new(&bytes, 512)?; + assert_eq!(MergeOutcome::decode(&mut d)?, result); + d.finish()?; + Ok(()) +} + +async fn phase_state(f: &Fixture) -> Result> { + Ok(f.handle.query(0,65536,|db| { + let mut statement=db.prepare("SELECT recovery_phase,recovery_phase_revision FROM catalog_leases ORDER BY admission_sequence")?; + let rows=statement.query_map([],|r|Ok((r.get::<_,Option>>(0)?,r.get::<_,u64>(1)?)))?.collect::>>()?; + serde_json::to_vec(&rows).map_err(|_|Error::Command("merge fixture phase state")) + }).await?) +} + +#[tokio::test] +async fn native_merge_changed_request_and_later_joint_generation_cannot_publish() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + for altered_request in [true, false] { + let (f, graph, request) = initial(format, false).await?; + let (prepared, root, budget) = preparation(&f, &graph).await?; + let mutation = identity()?; + let proof = prepared + .native_merge_proof( + MergeInput { + actor: "owner".into(), + number: 1, + request: request.clone(), + issued_at_ms: mutation.issued_at_ms, + }, + root.path(), + budget.clone(), + limits(), + ) + .await?; + let mut e = BoundedEncoder::new(NATIVE_MERGE_BYTES)?; + proof.encode(&mut e)?; + let mut bytes = e.finish(); + if altered_request { + let locations: Vec<_> = bytes + .windows(request.id.len()) + .enumerate() + .filter(|(_, b)| *b == request.id.as_bytes()) + .map(|(i, _)| i) + .collect(); + assert_eq!(locations.len(), 1); + let last = locations[0] + request.id.len() - 1; + bytes[last] = if bytes[last] == b'a' { b'b' } else { b'a' }; + } + let mut d = BoundedDecoder::new(&bytes, NATIVE_MERGE_BYTES)?; + let proof = NativeMergeProof::decode(&mut d)?; + d.finish()?; + let command = f + .client() + .prepare_command::(&f.target, mutation, proof) + .await?; + super::super::recovery::persist( + &prepared.base.session, + &command, + super::super::recovery::Kind::Merge, + &graph.store, + identity()?, + 0, + ) + .await?; + if !altered_request { + // Model an authoritative unrelated catalog publication with + // identical ref facts. The joint generation alone must fence it. + edit(&f, "INSERT INTO catalog_generations SELECT 2,catalog,certificate,refs FROM catalog_generations WHERE generation=1; UPDATE catalog_state SET generation=2 WHERE singleton=1;").await?; + } + let operation = prepared.token().operation; + let excluded = if altered_request { + None + } else { + Some(operation) + }; + let before = domain_state_except_attempt(&f, excluded).await?; + assert_eq!(command.execute().await?.output, MergeOutcome::Conflict); + assert_eq!(domain_state_except_attempt(&f, excluded).await?, before); + assert_operation(&f, operation, u64::from(altered_request)).await?; + drop(prepared); + cleaned(root.path(), &budget).await?; + drop(graph.prepared); + cleaned(graph.root.path(), &graph.budget).await?; + f.runtime.shutdown().await?; + } + } + Ok(()) +} + +#[tokio::test] +async fn native_merge_requires_registered_original_command_before_its_first_domain_write() -> Result +{ + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (f, graph, request) = initial(format, false).await?; + let (prepared, root, budget) = preparation(&f, &graph).await?; + let mutation = identity()?; + let proof = prepared + .native_merge_proof( + MergeInput { + actor: "owner".into(), + number: 1, + request, + issued_at_ms: mutation.issued_at_ms, + }, + root.path(), + budget.clone(), + limits(), + ) + .await?; + let command = f + .client() + .prepare_command::(&f.target, mutation, proof) + .await?; + let before = domain_state(&f).await?; + assert!(command.clone().execute().await.is_err()); + assert_eq!(domain_state(&f).await?, before); + assert!(matches!( + f.client().resolve(command.evidence()).await?, + Resolution::Absent + )); + super::super::recovery::persist( + &prepared.base.session, + &command, + super::super::recovery::Kind::Merge, + &graph.store, + identity()?, + 0, + ) + .await?; + assert!(matches!( + command.execute().await?.output, + MergeOutcome::Applied { .. } + )); + merged_roots(&f, &graph).await?; + drop(prepared); + cleaned(root.path(), &budget).await?; + drop(graph.prepared); + cleaned(graph.root.path(), &graph.budget).await?; + f.runtime.shutdown().await?; + } + Ok(()) +} + +#[tokio::test] +async fn native_merge_original_result_survives_sqlite_loss_and_actual_owner_restoration() -> Result +{ + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (f, graph, request) = initial(format, false).await?; + let (prepared, root, budget) = preparation(&f, &graph).await?; + let check = prepared.base.capability().2.clone(); + let (command, registered) = + prepared_command(&f, &prepared, request, root.path(), budget.clone()).await?; + let result = command.execute().await?; + assert!(matches!(result.output, MergeOutcome::Applied { .. })); + let evidence = registered.evidence().clone(); + drop(registered); + drop(prepared); + cleaned(root.path(), &budget).await?; + let (runtime, handle, client) = super::durable_recovery::restore_owner(&f, &check).await?; + let loaded = RegisteredRootRecovery::load(&client, &f.target, &graph.store, &check) + .await? + .ok_or("native merge recovery pin missing")?; + assert_eq!(loaded.evidence(), &evidence); + let recovered = loaded + .dispatch_any( + &client, + &graph.store, + &f.authority(), + &std::sync::atomic::AtomicBool::new(false), + ) + .await?; + let PublicationOutcome::Merge(recovered) = recovered else { + return Err("wrong restored result purpose".into()); + }; + assert_eq!(recovered.receipt, result.receipt); + assert_eq!(recovered.output, result.output); + assert!(handle.owner_fence().epoch > check.token.owner.epoch); + let rows = handle.query(0, 128, |db| { + let result: (u64,u64) = db.query_row("SELECT (SELECT generation FROM catalog_state),(SELECT count(*) FROM pull_merges)", [], |r| Ok((r.get(0)?,r.get(1)?)))?; + serde_json::to_vec(&result).map_err(|_| Error::Command("restored merge fixture")) + }).await?; + assert_eq!(serde_json::from_slice::<(u64, u64)>(&rows)?, (2, 1)); + drop(graph.prepared); + cleaned(graph.root.path(), &graph.budget).await?; + runtime.shutdown().await?; + } + Ok(()) +} + +mod retirement; + +async fn assert_operation(f: &Fixture, operation: [u8; 16], expected: u64) -> Result { + f.handle + .query(0, 128, move |db| { + assert_eq!( + db.query_row( + "SELECT count(*) FROM catalog_operations WHERE id=?1", + [operation.as_slice()], + |r| r.get::<_, u64>(0) + )?, + expected + ); + Ok(Vec::new()) + }) + .await?; + Ok(()) +} diff --git a/crates/canopy-server/src/packs/publication/tests/native_merge/retirement.rs b/crates/canopy-server/src/packs/publication/tests/native_merge/retirement.rs new file mode 100644 index 00000000..6a3a3b87 --- /dev/null +++ b/crates/canopy-server/src/packs/publication/tests/native_merge/retirement.rs @@ -0,0 +1,495 @@ +//! Selected audit retention and exact recovery after transient pins are released. +use super::super::{durable_recovery, terminal_retention}; +use super::*; +use canopy_object_storage::artifact::{ArtifactKey, ArtifactKind}; +use object_store::ObjectStoreExt; + +async fn archived_result( + f: &Fixture, + graph: &Graph, + check: &LeaseCheck, +) -> Result> { + let saved = RegisteredRootRecovery::load(&f.client(), &f.target, &graph.store, check) + .await? + .ok_or("merge archive missing")?; + match saved + .dispatch_any( + &f.client(), + &graph.store, + &f.authority(), + &std::sync::atomic::AtomicBool::new(false), + ) + .await + { + Ok(PublicationOutcome::Merge(value)) => Ok(value), + Err(PublicationError::Merge(InvocationError::Rejected(value))) => Ok(*value), + other => Err(format!("unexpected archived merge: {other:?}").into()), + } +} + +async fn retained_pin(f: &Fixture, check: &LeaseCheck, expected: u64) -> Result { + let token = check.token; + f.handle.query(0,128,move |db| { + assert_eq!(db.query_row("SELECT count(*) FROM catalog_leases WHERE incarnation=?1 AND admission_sequence=?2 AND recovery IS NOT NULL",rusqlite::params![token.owner.incarnation.as_bytes().as_slice(),token.attempt],|r|r.get::<_,u64>(0))?,expected); + Ok(Vec::new()) + }).await?; + Ok(()) +} + +#[tokio::test] +async fn native_merge_applied_attempt_releases_pin_without_losing_original_uuid_or_receipt() +-> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (f, graph, request) = initial(format, false).await?; + let (prepared, root, budget) = preparation(&f, &graph).await?; + let check = prepared.base.capability().2.clone(); + let (command, registered) = + prepared_command(&f, &prepared, request.clone(), root.path(), budget.clone()).await?; + let admin = terminal_retention::maintenance(&f.handle, f.repository).await?; + assert!( + registered + .ready_terminal_release(f.client(), &graph.store, admin.clone(), identity()?) + .await + .is_err() + ); + let original = command.execute().await?; + let released = registered + .ready_terminal_release(f.client(), &graph.store, admin, identity()?) + .await? + .complete() + .await?; + assert_eq!(released.output, TerminalReleaseReply::Released); + let token = check.token; + f.handle.query(0,128,move |db| { + assert_eq!(db.query_row("SELECT count(*) FROM catalog_leases WHERE incarnation=?1 AND admission_sequence=?2",rusqlite::params![token.owner.incarnation.as_bytes().as_slice(),token.attempt],|r|r.get::<_,u64>(0))?,0); + assert_eq!(db.query_row("SELECT count(*) FROM catalog_recovery_receipts",[],|r|r.get::<_,u64>(0))?,1); + Ok(Vec::new()) + }).await?; + drop(registered); + drop(prepared); + cleaned(root.path(), &budget).await?; + let restored = RegisteredRootRecovery::load(&f.client(), &f.target, &graph.store, &check) + .await? + .ok_or("merge archive absent")?; + let PublicationOutcome::Merge(recovered) = restored + .dispatch_any( + &f.client(), + &graph.store, + &f.authority(), + &std::sync::atomic::AtomicBool::new(false), + ) + .await? + else { + return Err("merge archive purpose".into()); + }; + assert_eq!( + (recovered.output, recovered.receipt), + (original.output.clone(), original.receipt) + ); + let (retry, retry_root, retry_budget) = preparation(&f, &graph).await?; + let (retry_command, retry_recovery) = + prepared_command(&f, &retry, request, retry_root.path(), retry_budget.clone()).await?; + assert_eq!(retry_command.execute().await?.output, original.output); + // A terminal replay closes only its fresh operation in the same final + // transaction. Its independent pin can then select the original audit. + let admin = terminal_retention::maintenance(&f.handle, f.repository).await?; + assert_eq!( + retry_recovery + .ready_terminal_release(f.client(), &graph.store, admin, identity()?) + .await? + .complete() + .await? + .output, + TerminalReleaseReply::Released + ); + merged_roots(&f, &graph).await?; + drop(retry); + cleaned(retry_root.path(), &retry_budget).await?; + drop(graph.prepared); + cleaned(graph.root.path(), &graph.budget).await?; + f.runtime.shutdown().await?; + } + Ok(()) +} + +#[tokio::test] +async fn negative_merge_archive_keeps_its_denial_after_same_uuid_succeeds() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (f, graph, request) = initial(format, false).await?; + let (prepared, root, budget) = preparation(&f, &graph).await?; + let check = prepared.base.capability().2.clone(); + let (command, saved) = + prepared_command(&f, &prepared, request.clone(), root.path(), budget.clone()).await?; + edit( + &f, + "INSERT INTO branch_rules VALUES('refs/heads/main',1,1,1,1,1,1)", + ) + .await?; + let original = command.execute().await?; + assert_eq!(original.output, MergeOutcome::ReviewsRequired); + let admin = terminal_retention::maintenance(&f.handle, f.repository).await?; + retained_pin(&f, &check, 1).await?; + let release = saved + .ready_terminal_release(f.client(), &graph.store, admin, identity()?) + .await?; + assert_eq!( + release.complete().await?.output, + TerminalReleaseReply::Released + ); + retained_pin(&f, &check, 0).await?; + drop(prepared); + cleaned(root.path(), &budget).await?; + + edit(&f,"UPDATE branch_rules SET required_approvals=0,version=2 WHERE reference='refs/heads/main'").await?; + let (retry, retry_root, retry_budget) = preparation(&f, &graph).await?; + let (command, saved) = + prepared_command(&f, &retry, request, retry_root.path(), retry_budget.clone()).await?; + assert!(matches!( + command.execute().await?.output, + MergeOutcome::Applied { .. } + )); + assert_eq!( + saved + .ready_terminal_release( + f.client(), + &graph.store, + terminal_retention::maintenance(&f.handle, f.repository).await?, + identity()? + ) + .await? + .complete() + .await? + .output, + TerminalReleaseReply::Released + ); + let recovered = archived_result(&f, &graph, &check).await?; + assert_eq!( + (recovered.output, recovered.receipt), + (original.output, original.receipt) + ); + merged_roots(&f, &graph).await?; + drop(retry); + cleaned(retry_root.path(), &retry_budget).await?; + drop(graph.prepared); + cleaned(graph.root.path(), &graph.budget).await?; + f.runtime.shutdown().await?; + } + Ok(()) +} + +#[tokio::test] +async fn missing_or_corrupt_selected_merge_metadata_retains_pin_and_original_result() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (f, graph, request) = initial(format, false).await?; + let (prepared, root, budget) = preparation(&f, &graph).await?; + let check = prepared.base.capability().2.clone(); + let (command, saved) = + prepared_command(&f, &prepared, request, root.path(), budget.clone()).await?; + let original = command.execute().await?; + let publication = f + .handle + .query(0, 128, |db| { + Ok(db.query_row( + "SELECT publication FROM pull_merges WHERE pull_number=1", + [], + |r| r.get::<_, Vec>(0), + )?) + }) + .await?; + let mut d = BoundedDecoder::new(&publication, 128)?; + let audit = crate::packs::input_artifact::StoredInputRoot::decode(&mut d)?; + d.finish()?; + let catalog = prepared.catalog(); + let snapshot = + crate::packs::catalog::CatalogSnapshot::download(&graph.store, catalog).await?; + let encoded = f + .handle + .query(0, 128, |db| { + Ok(db.query_row( + "SELECT refs FROM catalog_generations WHERE generation=2", + [], + |r| r.get::<_, Vec>(0), + )?) + }) + .await?; + let mut d = BoundedDecoder::new(&encoded, 128)?; + let refs = RefStateSnapshotRoot::decode(&mut d)?; + d.finish()?; + let ref_node = refs + .read(&graph.store) + .await? + .root + .ok_or("ref index missing")?; + let edges = [ + (audit.operation, ArtifactKind::InputRoot, audit.artifact), + ( + catalog.operation, + ArtifactKind::CatalogNode, + catalog.artifact, + ), + ( + snapshot.directory.operation, + ArtifactKind::CatalogNode, + snapshot.directory.artifact, + ), + (refs.operation(), ArtifactKind::InputRoot, refs.artifact()), + ( + ref_node.operation, + ArtifactKind::CatalogNode, + ref_node.artifact, + ), + ]; + let admin = terminal_retention::maintenance(&f.handle, f.repository).await?; + for (operation, kind, artifact) in edges { + let path = graph.store.path( + ArtifactKey { + operation, + kind, + binding_digest: artifact.digest, + }, + artifact.digest, + )?; + let bytes = graph.provider.get(&path).await?.bytes().await?; + for corrupt in [false, true] { + graph.provider.delete(&path).await?; + if corrupt { + graph + .provider + .put(&path, vec![0u8; bytes.len()].into()) + .await?; + } + assert!( + saved + .ready_terminal_release( + f.client(), + &graph.store, + admin.clone(), + identity()? + ) + .await + .is_err(), + "accepted missing/corrupt {kind:?}" + ); + retained_pin(&f, &check, 1).await?; + let recovered = archived_result(&f, &graph, &check).await?; + assert_eq!( + (recovered.output, recovered.receipt), + (original.output.clone(), original.receipt) + ); + graph.provider.put(&path, bytes.clone().into()).await?; + } + } + assert_eq!( + saved + .ready_terminal_release(f.client(), &graph.store, admin, identity()?) + .await? + .complete() + .await? + .output, + TerminalReleaseReply::Released + ); + drop(prepared); + cleaned(root.path(), &budget).await?; + drop(graph.prepared); + cleaned(graph.root.path(), &graph.budget).await?; + f.runtime.shutdown().await?; + } + Ok(()) +} + +#[tokio::test] +async fn merge_retirement_fences_authority_and_rolls_back_archive_and_pin_together() -> Result { + let (f, graph, request) = initial(ObjectFormat::Sha256, false).await?; + let (prepared, root, budget) = preparation(&f, &graph).await?; + let check = prepared.base.capability().2.clone(); + let (command, saved) = + prepared_command(&f, &prepared, request, root.path(), budget.clone()).await?; + let original = command.execute().await?; + let admin = terminal_retention::maintenance(&f.handle, f.repository).await?; + for wrong_owner in [true, false] { + let mut wrong = admin.clone(); + if wrong_owner { + wrong.owner.epoch += 1; + } else { + wrong.actor = "outsider".into(); + } + let release = saved + .ready_terminal_release(f.client(), &graph.store, wrong, identity()?) + .await?; + assert!( + matches!(release.complete().await,Err(PublicationError::TerminalRelease(InvocationError::Rejected(value))) if value.output==TerminalReleaseReply::Denied(PreparationDenial::Unauthorized)) + ); + retained_pin(&f, &check, 1).await?; + } + let release = saved + .ready_terminal_release(f.client(), &graph.store, admin, identity()?) + .await?; + edit(&f,"CREATE TRIGGER merge_release_late_fault BEFORE DELETE ON catalog_leases WHEN OLD.recovery IS NOT NULL BEGIN SELECT RAISE(ABORT,'late merge release fault'); END").await?; + let failed = release.clone().complete().await; + assert!( + matches!(failed,Err(PublicationError::TerminalRelease(InvocationError::NotStarted(Error::Sqlite(rusqlite::Error::SqliteFailure(_,Some(ref message)))))) if message=="late merge release fault"), + "{failed:?}" + ); + assert!(matches!( + f.client().resolve(&release.evidence_for_test()).await?, + Resolution::Absent + )); + retained_pin(&f, &check, 1).await?; + f.handle + .query(0, 128, |db| { + assert_eq!( + db.query_row("SELECT count(*) FROM catalog_recovery_receipts", [], |r| { + r.get::<_, u64>(0) + })?, + 0 + ); + Ok(Vec::new()) + }) + .await?; + edit(&f, "DROP TRIGGER merge_release_late_fault").await?; + assert_eq!( + release.complete().await?.output, + TerminalReleaseReply::Released + ); + retained_pin(&f, &check, 0).await?; + for mutation in [ + "UPDATE pull_merges SET publication=zeroblob(32)", + "DELETE FROM pull_merges", + "INSERT OR REPLACE INTO pull_merges SELECT * FROM pull_merges", + ] { + assert!( + edit(&f, mutation).await.is_err(), + "mutable permanent audit: {mutation}" + ); + } + let recovered = archived_result(&f, &graph, &check).await?; + assert_eq!( + (recovered.output, recovered.receipt), + (original.output, original.receipt) + ); + merged_roots(&f, &graph).await?; + drop(prepared); + cleaned(root.path(), &budget).await?; + drop(graph.prepared); + cleaned(graph.root.path(), &graph.budget).await?; + f.runtime.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn archived_merge_and_release_receipts_survive_sqlite_loss_and_owner_restoration() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (f, graph, request) = initial(format, false).await?; + let (prepared, root, budget) = preparation(&f, &graph).await?; + let check = prepared.base.capability().2.clone(); + let (command, saved) = + prepared_command(&f, &prepared, request, root.path(), budget.clone()).await?; + let original = command.execute().await?; + let release = saved + .ready_terminal_release( + f.client(), + &graph.store, + terminal_retention::maintenance(&f.handle, f.repository).await?, + identity()?, + ) + .await?; + let released = release.clone().complete().await?; + retained_pin(&f, &check, 0).await?; + for (key, descriptor) in saved.command_bodies_for_test() { + let path = graph.store.path(key, descriptor.digest)?; + graph.provider.delete(&path).await?; + } + drop(prepared); + cleaned(root.path(), &budget).await?; + let (runtime, handle, client) = durable_recovery::restore_owner(&f, &check).await?; + assert!(handle.owner_fence().epoch > check.token.owner.epoch); + let restored = RegisteredRootRecovery::load(&client, &f.target, &graph.store, &check) + .await? + .ok_or("restored merge archive missing")?; + let PublicationOutcome::Merge(recovered) = restored + .dispatch_any( + &client, + &graph.store, + &f.authority(), + &std::sync::atomic::AtomicBool::new(false), + ) + .await? + else { + return Err("restored merge purpose".into()); + }; + assert_eq!( + (recovered.output, recovered.receipt), + (original.output, original.receipt) + ); + let recovered_release = release.with_client_for_test(client).complete().await?; + assert_eq!( + (recovered_release.output, recovered_release.receipt), + (released.output, released.receipt) + ); + drop(graph.prepared); + cleaned(graph.root.path(), &graph.budget).await?; + runtime.shutdown().await?; + } + Ok(()) +} + +#[tokio::test] +async fn terminal_merge_refusal_closure_rolls_back_with_original_phase_and_sdk_acceptance() -> Result +{ + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (f, graph, request) = initial(format, false).await?; + let (prepared, root, budget) = preparation(&f, &graph).await?; + let (command, saved) = + prepared_command(&f, &prepared, request, root.path(), budget.clone()).await?; + edit( + &f, + "INSERT INTO branch_rules VALUES('refs/heads/main',1,1,1,1,1,1)", + ) + .await?; + edit(&f,"CREATE TRIGGER terminal_merge_close_fault BEFORE DELETE ON catalog_operations BEGIN SELECT RAISE(ABORT,'terminal merge close fault'); END").await?; + let before = phase_state(&f).await?; + let domain = domain_state(&f).await?; + let operation = prepared.token().operation; + let remaining = domain_state_except_attempt(&f, Some(operation)).await?; + assert!(command.clone().execute().await.is_err()); + assert_eq!(phase_state(&f).await?, before); + assert_eq!(domain_state(&f).await?, domain); + assert!(matches!( + f.client().resolve(command.evidence()).await?, + Resolution::Absent + )); + edit(&f, "DROP TRIGGER terminal_merge_close_fault").await?; + let original = command.execute().await?; + assert_eq!(original.output, MergeOutcome::ReviewsRequired); + assert_eq!( + domain_state_except_attempt(&f, Some(operation)).await?, + remaining + ); + assert_operation(&f, operation, 0).await?; + assert_eq!( + saved + .ready_terminal_release( + f.client(), + &graph.store, + terminal_retention::maintenance(&f.handle, f.repository).await?, + identity()? + ) + .await? + .complete() + .await? + .output, + TerminalReleaseReply::Released + ); + let recovered = archived_result(&f, &graph, prepared.base.capability().2).await?; + assert_eq!( + (recovered.output, recovered.receipt), + (original.output, original.receipt) + ); + drop(prepared); + cleaned(root.path(), &budget).await?; + drop(graph.prepared); + cleaned(graph.root.path(), &graph.budget).await?; + f.runtime.shutdown().await?; + } + Ok(()) +} diff --git a/crates/canopy-server/src/packs/publication/tests/preparation_receipt.rs b/crates/canopy-server/src/packs/publication/tests/preparation_receipt.rs index 3de5b8d8..7b68ee9a 100644 --- a/crates/canopy-server/src/packs/publication/tests/preparation_receipt.rs +++ b/crates/canopy-server/src/packs/publication/tests/preparation_receipt.rs @@ -20,14 +20,7 @@ fn denied( } async fn expire(expires_at_ms: i64) -> Result { - let now = sql::now(0)?; - if now <= expires_at_ms { - tokio::time::sleep(Duration::from_millis(u64::try_from( - expires_at_ms - now + 1, - )?)) - .await; - } - Ok(()) + wait_for_sdk_expiry(expires_at_ms).await } #[tokio::test] diff --git a/crates/canopy-server/src/packs/publication/tests/publishing.rs b/crates/canopy-server/src/packs/publication/tests/publishing.rs index f508b4ba..da6ec36b 100644 --- a/crates/canopy-server/src/packs/publication/tests/publishing.rs +++ b/crates/canopy-server/src/packs/publication/tests/publishing.rs @@ -18,6 +18,7 @@ pub(super) struct Graph { pub(super) other: ObjectId, pub(super) blob: ObjectId, pub(super) store: Arc, + pub(super) provider: Arc, } pub(super) fn update(name: &str, old: Option<(ObjectId, i64)>, new: Option) -> RefUpdate { RefUpdate { @@ -82,6 +83,7 @@ pub(super) async fn assembled( other, blob, store: native.store, + provider: native.provider, }) }) .await @@ -130,6 +132,10 @@ async fn history( ) .await?, )?; + repack(native).await?; + Ok((tip, other)) +} +pub(super) async fn repack(native: &mut Prepared) -> Result { git_input(native.fixture.root.path(), &["repack", "-ad"], b"").await?; let path = std::fs::read_dir(native.fixture.root.path().join("objects/pack"))? .find_map(|entry| { @@ -170,7 +176,7 @@ async fn history( native.descriptor.index = artifact_index; native.descriptor.git_checksum = index.pack_checksum(); native.descriptor.object_count = index.len(); - Ok((tip, other)) + Ok(()) } async fn proof(graph: &Graph, updates: Vec) -> Result { Ok(Box::pin(graph.prepared.ref_proof( @@ -182,7 +188,15 @@ async fn proof(graph: &Graph, updates: Vec) -> Result Result> { - Ok(handle.query(0, 64 << 10, |connection| { + state_except_operation(handle, None).await +} +// Terminal merge semantics intentionally close only one exact operation. +// All other operation, root, ref, checkpoint and policy facts remain compared. +pub(super) async fn state_except_operation( + handle: &CellHandle, + exclude: Option<[u8; 16]>, +) -> Result> { + Ok(handle.query(0, 64 << 10, move |connection| { let mut refs = connection.prepare("SELECT name,oid,version FROM refs ORDER BY name")?; let refs = refs.query_map([], |row| Ok((row.get::<_,String>(0)?,row.get::<_,Option>>(1)?,row.get::<_,i64>(2)?)))?.collect::>>()?; let mut catalog = connection.prepare("SELECT generation,catalog,certificate,refs FROM catalog_generations ORDER BY generation")?; @@ -216,8 +230,8 @@ pub(super) async fn state(handle: &CellHandle) -> Result> { let record=(row.get::<_,Vec>(0)?,row.get::<_,String>(1)?,row.get::<_,Vec>(2)?,row.get::<_,Option>>(3)?,row.get::<_,Option>>(4)?,row.get::<_,Option>(5)?,row.get::<_,Option>>(6)?,row.get::<_,Option>>(7)?,row.get::<_,Option>>(8)?,row.get::<_,Option>>(9)?,row.get::<_,Option>>(10)?); hash.update(&serde_json::to_vec(&record).map_err(|_|Error::Command("fixture root outcome hash"))?); } - let mut operations=connection.prepare("SELECT id,actor,request_digest,artifact_operation,generation,attestation,attestation_digest FROM catalog_operations ORDER BY id")?; - let mut rows=operations.query([])?; + let mut operations=connection.prepare("SELECT id,actor,request_digest,artifact_operation,generation,attestation,attestation_digest FROM catalog_operations WHERE ?1 IS NULL OR id!=?1 ORDER BY id")?; + let mut rows=operations.query([exclude.map(|id|id.to_vec())])?; while let Some(row)=rows.next()? { let record=(row.get::<_,Vec>(0)?,row.get::<_,String>(1)?,row.get::<_,Vec>(2)?,row.get::<_,Vec>(3)?,row.get::<_,Option>(4)?,row.get::<_,Option>>(5)?,row.get::<_,Option>>(6)?); hash.update(&serde_json::to_vec(&record).map_err(|_|Error::Command("fixture root operation hash"))?); @@ -300,6 +314,7 @@ pub(super) async fn next_graph( other: old.other, blob: old.blob, store: Arc::clone(&old.store), + provider: Arc::clone(&old.provider), }) } @@ -401,7 +416,7 @@ async fn catalog_ref_membership_kind_and_tampered_bindings_cannot_publish() -> R limits() ) .await, - Err(RefProofError::Invalid) + Err(super::super::RefProofError::Invalid) )); } let input = proof( @@ -573,7 +588,7 @@ async fn ancestry_growth_reuses_pairs_only_in_one_exact_native_catalog() -> Resu &other.prepared.base ) .await, - Err(RefProofError::Invalid) + Err(super::super::RefProofError::Invalid) )); assert!(matches!( walk.is_ancestor( @@ -956,3 +971,110 @@ async fn expired_and_claimed_proofs_and_mutable_publication_facts_fail_closed() fixture.runtime.shutdown().await?; Ok(()) } + +#[tokio::test] +async fn reviewed_merge_requires_native_ancestry_without_a_fast_forward_branch_rule() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let fixture = Fixture::new(format).await?; + let graph = assembled(&fixture, [241; 16], 16).await?; + let changes = plan(vec![ + update( + "refs/heads/merge", + Some((graph.initial, 1)), + Some(graph.tip), + ), + update( + "refs/heads/unrelated", + Some((graph.initial, 1)), + Some(graph.other), + ), + update( + "refs/heads/backwards", + Some((graph.tip, 1)), + Some(graph.initial), + ), + update( + "refs/heads/unchanged", + Some((graph.tip, 1)), + Some(graph.tip), + ), + update("refs/heads/created", None, Some(graph.tip)), + update("refs/heads/deleted", Some((graph.initial, 1)), None), + ]); + let before = state(&fixture.handle).await?; + let proof = graph + .prepared + .ref_proof_with_required_ancestry( + changes.clone(), + graph.root.path(), + graph.budget.clone(), + limits(), + ) + .await?; + assert_eq!( + proof.ancestry, + vec![0b0011_1001], + "merges require native ancestry evidence even without a branch fast-forward rule" + ); + assert_eq!( + proof.certificate.data()?.refs_digest, + Some(super::super::ref_proof::binding(&changes, &proof.ancestry)?) + ); + assert_eq!( + state(&fixture.handle).await?, + before, + "proof construction must not publish or populate SQL refs" + ); + let selective = graph + .prepared + .ref_proof( + changes.clone(), + graph.root.path(), + graph.budget.clone(), + limits(), + ) + .await?; + assert_eq!( + selective.ancestry, + vec![0b0011_1000], + "ordinary pushes must retain policy-driven ancestry work" + ); + assert_ne!( + proof.certificate.data()?.refs_digest, + selective.certificate.data()?.refs_digest + ); + let mut forged = proof.clone(); + forged.ancestry[0] |= 2; + assert_ne!( + forged.certificate.data()?.refs_digest, + Some(super::super::ref_proof::binding( + &forged.plan, + &forged.ancestry + )?), + "an unrelated history cannot become proven by changing transport bits" + ); + let invalid = plan(vec![update( + "refs/heads/not-a-commit", + Some((graph.initial, 1)), + Some(graph.blob), + )]); + assert!(matches!( + graph + .prepared + .ref_proof_with_required_ancestry( + invalid, + graph.root.path(), + graph.budget.clone(), + limits(), + ) + .await, + Err(super::super::RefProofError::Invalid) + )); + assert_eq!(state(&fixture.handle).await?, before); + + drop(graph.prepared); + cleaned(graph.root.path(), &graph.budget).await?; + fixture.runtime.shutdown().await?; + } + Ok(()) +} diff --git a/crates/canopy-server/src/packs/publication/tests/reconcile.rs b/crates/canopy-server/src/packs/publication/tests/reconcile.rs index 2567e0ad..6cfaa51b 100644 --- a/crates/canopy-server/src/packs/publication/tests/reconcile.rs +++ b/crates/canopy-server/src/packs/publication/tests/reconcile.rs @@ -88,6 +88,7 @@ pub(super) async fn graph_with_run_limits( other: initial, blob, store, + provider: native.provider, }) } async fn publish( diff --git a/crates/canopy-server/src/packs/publication/tests/ref_policy/fixture.rs b/crates/canopy-server/src/packs/publication/tests/ref_policy/fixture.rs index 89ec11df..5c6db2b8 100644 --- a/crates/canopy-server/src/packs/publication/tests/ref_policy/fixture.rs +++ b/crates/canopy-server/src/packs/publication/tests/ref_policy/fixture.rs @@ -214,6 +214,7 @@ pub(super) fn attempt<'a>( other: tip, blob, store, + provider: provider.clone(), }, staging, ticket, diff --git a/crates/canopy-server/src/packs/publication/tests/root_completion.rs b/crates/canopy-server/src/packs/publication/tests/root_completion.rs index 36f05fa2..fa6d42d9 100644 --- a/crates/canopy-server/src/packs/publication/tests/root_completion.rs +++ b/crates/canopy-server/src/packs/publication/tests/root_completion.rs @@ -317,6 +317,22 @@ pub(super) async fn qualify( edit(fixture, "INSERT INTO pushes(id,actor,request_digest) VALUES(zeroblob(16),'original',zeroblob(32)); INSERT INTO push_certificates VALUES(X'1111111111111111111111111111111111111111111111111111111111111111',zeroblob(16),'original','original','original key',1234,0)").await?; } } + let prior_summary = fixture + .handle + .query(0, 1024, |db| { + let value: u64 = db.query_row( + "SELECT generation FROM ref_generation WHERE singleton=1", + [], + |r| r.get(0), + )?; + Ok(value.to_le_bytes().to_vec()) + }) + .await?; + let prior_summary = u64::from_le_bytes( + prior_summary + .try_into() + .map_err(|_| "invalid prior summary")?, + ); let mutation = identity()?; let command = fixture .client() @@ -394,6 +410,24 @@ pub(super) async fn qualify( assert_eq!(value.generation, 3); assert_eq!(value.ref_generation, 1); } + let summary = fixture + .handle + .query(0, 1024, |db| { + let value: u64 = db.query_row( + "SELECT generation FROM ref_generation WHERE singleton=1", + [], + |r| r.get(0), + )?; + Ok(value.to_le_bytes().to_vec()) + }) + .await?; + assert_eq!( + u64::from_le_bytes(summary.try_into().map_err(|_| "invalid summary")?), + output + .completion + .publication + .map_or(prior_summary, |value| value.ref_generation) + ); let roots = fixture.handle.query(0, 1024, |db| { let value: (u64, Vec, Vec) = db.query_row("SELECT generation,catalog,refs FROM catalog_generations WHERE generation=(SELECT generation FROM catalog_state)", [], |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?)))?; Ok(serde_json::to_vec(&value).unwrap()) diff --git a/crates/canopy-server/src/packs/publication/tests/serving.rs b/crates/canopy-server/src/packs/publication/tests/serving.rs index b585ddcf..a85ed574 100644 --- a/crates/canopy-server/src/packs/publication/tests/serving.rs +++ b/crates/canopy-server/src/packs/publication/tests/serving.rs @@ -16,6 +16,10 @@ mod refs; mod selection_drain; mod workspace; +// Allow real SQL/provider callbacks under concurrent load. Renewal cases +// retain borrowers beyond this initial lease; expiry cases use their own clocks. +const RENEWAL_LEASE_MS: u64 = 5_000; + async fn initialize(f: &Fixture, store: Arc) -> Result { let (prepared, root, budget) = Box::pin(empty(f, [241; 16], store.clone())).await?; let prepared = Arc::new(prepared); diff --git a/crates/canopy-server/src/packs/publication/tests/serving/custody.rs b/crates/canopy-server/src/packs/publication/tests/serving/custody.rs index e63ee2bf..c393f53a 100644 --- a/crates/canopy-server/src/packs/publication/tests/serving/custody.rs +++ b/crates/canopy-server/src/packs/publication/tests/serving/custody.rs @@ -38,14 +38,7 @@ async fn saved(f: &Fixture, reader: u8) -> Result { .ok_or("serving intent missing")?) } async fn expired(evidence: &PendingMutation) -> Result { - let now = sql::now(0)?; - if now <= evidence.identity().expires_at_ms { - tokio::time::sleep(Duration::from_millis(u64::try_from( - evidence.identity().expires_at_ms - now + 1, - )?)) - .await; - } - Ok(()) + wait_for_sdk_expiry(evidence.identity().expires_at_ms).await } #[tokio::test] diff --git a/crates/canopy-server/src/packs/publication/tests/serving/lifecycle.rs b/crates/canopy-server/src/packs/publication/tests/serving/lifecycle.rs index 0aaab02d..0ece2bd0 100644 --- a/crates/canopy-server/src/packs/publication/tests/serving/lifecycle.rs +++ b/crates/canopy-server/src/packs/publication/tests/serving/lifecycle.rs @@ -48,7 +48,8 @@ async fn ready(owner: &ServingOwner) -> Result { tokio::time::sleep(Duration::from_millis(10)).await; } }) - .await?) + .await + .map_err(|error| format!("serving owner readiness: {error}; {:?}", owner.stats()))?) } async fn zero_pins(f: &Fixture) -> Result { timeout(Duration::from_secs(8), async { @@ -336,7 +337,7 @@ async fn closed_owner_keeps_borrowed_generation_renewing_until_last_snapshot_clo let owner = ServingOwner::start( context(&f, store, &root, tasks.clone())?, q.clone(), - input(&f, 218, "owner", 1_000), + input(&f, 218, "owner", RENEWAL_LEASE_MS), identity()?, ) .await?; @@ -349,7 +350,7 @@ async fn closed_owner_keeps_borrowed_generation_renewing_until_last_snapshot_clo owner.snapshot(Some("owner".into())).await, Err(ServingReadError::Inactive) )); - tokio::time::sleep(Duration::from_millis(1_500)).await; + tokio::time::sleep(Duration::from_millis(RENEWAL_LEASE_MS * 3 / 2)).await; timeout(Duration::from_secs(8), async { while owner.stats().renewals < 2 { assert_eq!( @@ -361,7 +362,13 @@ async fn closed_owner_keeps_borrowed_generation_renewing_until_last_snapshot_clo tokio::time::sleep(Duration::from_millis(10)).await; } }) - .await?; + .await + .map_err(|error| { + format!( + "{format:?}: borrowed-snapshot renewals: {error}; {:?}", + owner.stats() + ) + })?; assert!(owner.stats().renewals >= 2, "{:?}", owner.stats()); assert_eq!(owner.stats().token, Some(token)); assert_eq!(snapshot.fact(), fact); @@ -371,7 +378,11 @@ async fn closed_owner_keeps_borrowed_generation_renewing_until_last_snapshot_clo drop(clone); assert_eq!( timeout(Duration::from_secs(8), owner.close_and_drain()) - .await? + .await + .map_err(|error| format!( + "{format:?}: last snapshot drain: {error}; {:?}", + owner.stats() + ))? .phase, ServingOwnerPhase::Released ); diff --git a/crates/canopy-server/src/packs/publication/tests/serving/pool.rs b/crates/canopy-server/src/packs/publication/tests/serving/pool.rs index bc473428..d6d12421 100644 --- a/crates/canopy-server/src/packs/publication/tests/serving/pool.rs +++ b/crates/canopy-server/src/packs/publication/tests/serving/pool.rs @@ -132,6 +132,152 @@ async fn canceled_cold_observer_and_lost_ack_keep_one_owned_acquisition() -> Res Ok(()) } +#[tokio::test] +async fn sequential_generations_roll_over_idle_slots_without_client_retries() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let f = Fixture::new(format).await?; + let store = Arc::new(ArtifactStore::new(Arc::new(InMemory::new()), f.repository)); + initialize(&f, store.clone()).await?; + let q = queue(&f)?; + let root = tempfile::TempDir::new()?; + let tasks = TaskTracker::new(); + let pool = pooled(&f, store, &root, tasks.clone(), q.clone())?; + for generation in 1..=12 { + if generation > 1 { + advance(&f, generation).await?; + } + let snapshot = + timeout(Duration::from_secs(8), pool.snapshot(Some("owner".into()))).await??; + assert_eq!(snapshot.fact().generation, generation); + assert_eq!(snapshot.headers(&[missing(&f)?]).await?, vec![None]); + assert!(pool.owners_for_test().await.len() <= 4); + assert!(pin_count(&f).await? <= 4); + drop(snapshot); + } + finish(&f, &pool, &q, tasks).await?; + f.runtime.shutdown().await?; + } + Ok(()) +} + +#[tokio::test] +async fn rollover_waiters_share_release_after_observer_cancellation_and_lost_ack() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let f = Fixture::new(format).await?; + let store = Arc::new(ArtifactStore::new(Arc::new(InMemory::new()), f.repository)); + initialize(&f, store.clone()).await?; + let q = queue(&f)?; + let root = tempfile::TempDir::new()?; + let tasks = TaskTracker::new(); + let pool = pooled(&f, store, &root, tasks.clone(), q.clone())?; + let mut old_views = Vec::new(); + for generation in 1..=4 { + if generation > 1 { + advance(&f, generation).await?; + } + old_views.push(pool.snapshot(Some("owner".into())).await?); + } + let old = pool.owners_for_test().await.remove(0); + drop(old_views.remove(0)); + advance(&f, 5).await?; + let (dispatch, entered) = q.pause_for_test().await; + q.fault_for_test(2); + let work = pool.clone(); + let observer = tokio::spawn(async move { work.snapshot(Some("owner".into())).await }); + timeout(Duration::from_secs(8), entered).await??; + observer.abort(); + assert!( + observer + .await + .err() + .ok_or("rollover finished early")? + .is_cancelled() + ); + assert!( + timeout(Duration::from_millis(20), old.drain_observer().wait()) + .await + .is_err() + ); + assert_eq!(pool.owners_for_test().await.len(), 4); + assert_eq!(pin_count(&f).await?, 4); + let work = pool.clone(); + let viewers = tokio::spawn(async move { + futures_util::future::join_all((0..12).map(|_| work.snapshot(Some("owner".into())))) + .await + .into_iter() + .collect::, _>>() + }); + dispatch.send(()).map_err(|_| "release dispatch gone")?; + let snapshots = timeout(Duration::from_secs(8), viewers).await???; + assert!( + snapshots + .iter() + .all(|snapshot| snapshot.fact().generation == 5) + ); + assert_eq!(old.stats().phase, ServingOwnerPhase::Released); + assert_eq!(pool.owners_for_test().await.len(), 4); + assert_eq!(pin_count(&f).await?, 4); + for view in &old_views { + assert_eq!(view.headers(&[missing(&f)?]).await?, vec![None]); + } + drop((old_views, snapshots)); + finish(&f, &pool, &q, tasks).await?; + f.runtime.shutdown().await?; + } + Ok(()) +} + +#[tokio::test] +async fn rollover_timeout_retains_the_slot_and_exact_release_for_retry() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let f = Fixture::new(format).await?; + let store = Arc::new(ArtifactStore::new(Arc::new(InMemory::new()), f.repository)); + initialize(&f, store.clone()).await?; + let q = queue(&f)?; + let root = tempfile::TempDir::new()?; + let tasks = TaskTracker::new(); + let pool = pooled(&f, store, &root, tasks.clone(), q.clone())?; + let mut old_views = Vec::new(); + for generation in 1..=4 { + if generation > 1 { + advance(&f, generation).await?; + } + old_views.push(pool.snapshot(Some("owner".into())).await?); + } + let old = pool.owners_for_test().await.remove(0); + drop(old_views.remove(0)); + advance(&f, 5).await?; + let (dispatch, entered) = q.pause_for_test().await; + let work = pool.clone(); + let observer = tokio::spawn(async move { work.snapshot(Some("owner".into())).await }); + timeout(Duration::from_secs(8), entered).await??; + assert!(matches!( + timeout(Duration::from_secs(8), observer).await??, + Err(ServingOwnerError::Read(ServingReadError::Capability( + Error::Capacity("repository serving generations") + ))) + )); + assert!( + timeout(Duration::from_millis(20), old.drain_observer().wait()) + .await + .is_err() + ); + assert_eq!(pool.owners_for_test().await.len(), 4); + assert_eq!(pin_count(&f).await?, 4); + dispatch.send(()).map_err(|_| "release dispatch gone")?; + let fifth = timeout(Duration::from_secs(8), pool.snapshot(Some("owner".into()))).await??; + assert_eq!(fifth.fact().generation, 5); + assert_eq!(pin_count(&f).await?, 4); + for view in &old_views { + assert_eq!(view.headers(&[missing(&f)?]).await?, vec![None]); + } + drop((old_views, fifth)); + finish(&f, &pool, &q, tasks).await?; + f.runtime.shutdown().await?; + } + Ok(()) +} + #[tokio::test] async fn four_generation_bound_retains_borrows_and_reuses_only_actually_drained_slots() -> Result { for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { @@ -161,14 +307,13 @@ async fn four_generation_bound_retains_borrows_and_reuses_only_actually_drained_ assert_eq!(pin_count(&f).await?, 4); let old = pool.owners_for_test().await.remove(0); drop(snapshots.remove(0)); - assert!(pool.snapshot(Some("owner".into())).await.is_err()); + let fifth = timeout(Duration::from_secs(8), pool.snapshot(Some("owner".into()))).await??; assert_eq!( timeout(Duration::from_secs(8), old.drain_observer().wait()) .await? .phase, ServingOwnerPhase::Released ); - let fifth = pool.snapshot(Some("owner".into())).await?; assert_eq!(fifth.fact().generation, 5); assert_eq!(pin_count(&f).await?, 4); for snapshot in &snapshots { @@ -311,3 +456,74 @@ async fn blocked_old_generation_does_not_block_other_release_or_allow_early_evic } Ok(()) } + +#[tokio::test] +async fn slow_cell_selection_preserves_the_idle_rollover_observation_budget() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let f = Fixture::new(format).await?; + let store = Arc::new(ArtifactStore::new(Arc::new(InMemory::new()), f.repository)); + initialize(&f, store.clone()).await?; + let q = queue(&f)?; + let root = tempfile::TempDir::new()?; + let tasks = TaskTracker::new(); + let pool = pooled(&f, store, &root, tasks.clone(), q.clone())?; + for generation in 1..=4 { + if generation > 1 { + advance(&f, generation).await?; + } + drop(pool.snapshot(Some("owner".into())).await?); + } + advance(&f, 5).await?; + assert_eq!(pool.owners_for_test().await.len(), 4); + // Stall the actual Cell worker, not the pool's timers or a mock reader. + // Selection queues behind this callback; exact release queues afterward. + let handle = f.handle.clone(); + let mutation = identity()?; + let now = sql::now(0)?; + let (entered, started) = tokio::sync::oneshot::channel(); + let (release, waiting) = std::sync::mpsc::channel(); + let blocked = tokio::spawn(async move { + handle + .execute( + mutation, + Digest::from_bytes(*blake3::hash(b"selection-gate").as_bytes()), + now, + b"selection-gate".len(), + 0, + move |_| { + let _ = entered.send(()); + waiting + .recv() + .map_err(|_| Error::Command("selection gate lost"))?; + Ok(cellule_runtime::cell::executor::HandlerOutcome::Success( + Vec::new(), + )) + }, + ) + .await + }); + timeout(Duration::from_secs(8), started).await??; + let selected = pool.observe_selection_for_test(); + let work = pool.clone(); + let viewer = tokio::spawn(async move { work.snapshot(Some("owner".into())).await }); + timeout(Duration::from_secs(8), selected).await??; + tokio::time::sleep(Duration::from_millis(2100)).await; + assert!( + !viewer.is_finished(), + "Cell selection did not wait for the real worker" + ); + release + .send(()) + .map_err(|_| "Cell selection gate disappeared")?; + timeout(Duration::from_secs(8), blocked).await???; + let result = timeout(Duration::from_secs(8), viewer).await??; + // Always join cleanup before asserting, even for the failing baseline. + let fact = result.as_ref().ok().map(|s| s.fact().generation); + let error = result.as_ref().err().map(|e| format!("{e:?}")); + drop(result); + finish(&f, &pool, &q, tasks).await?; + f.runtime.shutdown().await?; + assert_eq!(fact, Some(5), "{error:?}"); + } + Ok(()) +} diff --git a/crates/canopy-server/src/packs/publication/tests/serving/workspace.rs b/crates/canopy-server/src/packs/publication/tests/serving/workspace.rs index d0fe9b7d..4103fbe0 100644 --- a/crates/canopy-server/src/packs/publication/tests/serving/workspace.rs +++ b/crates/canopy-server/src/packs/publication/tests/serving/workspace.rs @@ -394,7 +394,13 @@ async fn closed_producer_renews_during_long_construction_and_returned_workspace_ tokio::time::sleep(Duration::from_millis(10)).await; } }) - .await?; + .await + .map_err(|error| { + format!( + "{format:?}: warm owner readiness: {error}; {:?}", + warm.stats() + ) + })?; let view = warm.snapshot(Some("owner".into())).await?; assert!(view.headers(&[oid]).await?[0].is_some()); drop(view); @@ -403,14 +409,20 @@ async fn closed_producer_renews_during_long_construction_and_returned_workspace_ ServingOwnerPhase::Released ); let mut input = f.begin([119; 16]); - input.lease_ms = 1000; + input.lease_ms = RENEWAL_LEASE_MS; let owner = ServingOwner::start(ctx, q.clone(), input, identity()?).await?; timeout(Duration::from_secs(8), async { while owner.stats().phase != ServingOwnerPhase::Ready { tokio::time::sleep(Duration::from_millis(10)).await; } }) - .await?; + .await + .map_err(|error| { + format!( + "{format:?}: short-lease readiness: {error}; {:?}", + owner.stats() + ) + })?; let view = owner.snapshot(Some("owner".into())).await?; assert!( view.headers(&[oid]) @@ -422,10 +434,16 @@ async fn closed_producer_renews_during_long_construction_and_returned_workspace_ let observer = tokio::spawn(async move { view.workspace(&[oid], WorkspaceLimits::default()).await }); timeout(Duration::from_secs(8), provider.entered.acquire()) - .await?? + .await + .map_err(|error| { + format!( + "{format:?}: constructor provider entry: {error}; {:?}", + owner.stats() + ) + })?? .forget(); owner.close(); - tokio::time::sleep(Duration::from_millis(1500)).await; + tokio::time::sleep(Duration::from_millis(RENEWAL_LEASE_MS * 3 / 2)).await; timeout(Duration::from_secs(8), async { while owner.stats().renewals < 2 { assert_eq!( @@ -437,11 +455,23 @@ async fn closed_producer_renews_during_long_construction_and_returned_workspace_ tokio::time::sleep(Duration::from_millis(10)).await; } }) - .await?; + .await + .map_err(|error| { + format!( + "{format:?}: two closed-owner renewals: {error}; {:?}", + owner.stats() + ) + })?; assert!(owner.stats().renewals >= 2, "{:?}", owner.stats()); provider.proceed.add_permits(1); let workspace = timeout(Duration::from_secs(8), observer) - .await?? + .await + .map_err(|error| { + format!( + "{format:?}: renewed constructor completion: {error}; {:?}", + owner.stats() + ) + })?? .map_err(|e| format!("renewed constructor: {e}"))?; // Construction's final fresh authority check proves the complete result; // short body/membership reads are exercised with their own deadline tests. @@ -458,7 +488,13 @@ async fn closed_producer_renews_during_long_construction_and_returned_workspace_ ); drop(workspace); assert_eq!( - timeout(Duration::from_secs(8), drain).await??.phase, + timeout(Duration::from_secs(8), drain) + .await + .map_err(|error| format!( + "{format:?}: returned workspace drain: {error}; {:?}", + owner.stats() + ))?? + .phase, ServingOwnerPhase::Released ); assert_eq!(pin_count(&f).await?, 0); @@ -622,6 +658,23 @@ async fn expired_lease_does_not_resurrect_or_release_suspended_construction() -> let tasks = TaskTracker::new(); let (ctx, files) = super::body::serving_context(&f, native.store.clone(), &root, tasks.clone())?; + // Warm immutable metadata before the deliberately short expiry lease; + // cold hashing must not consume the setup phase of this gated expiry case. + let oid = *native.fixture.objects.keys().next().ok_or("object")?; + let warm = ServingOwner::start(ctx.clone(), q.clone(), f.begin([122; 16]), identity()?).await?; + timeout(Duration::from_secs(8), async { + while warm.stats().phase != ServingOwnerPhase::Ready { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; + let view = warm.snapshot(Some("owner".into())).await?; + assert!(view.headers(&[oid]).await?[0].is_some()); + drop(view); + assert_eq!( + warm.close_and_drain().await.phase, + ServingOwnerPhase::Released + ); let mut input = f.begin([123; 16]); input.lease_ms = 1000; let owner = ServingOwner::start(ctx, q.clone(), input, identity()?).await?; @@ -632,7 +685,6 @@ async fn expired_lease_does_not_resurrect_or_release_suspended_construction() -> }) .await?; let view = owner.snapshot(Some("owner".into())).await?; - let oid = *native.fixture.objects.keys().next().ok_or("object")?; assert!(view.headers(&[oid]).await?[0].is_some()); let (dispatch, entered) = q.pause_for_test().await; provider.armed.store(true, Ordering::Release); @@ -736,3 +788,151 @@ async fn joint_ref_pages_stream_ten_thousand_names_without_an_object_root_limit( f.runtime.shutdown().await?; Ok(()) } + +#[tokio::test] +async fn fetch_preparation_accepts_exact_renewal_after_initial_deadline() -> Result { + use crate::packs::ref_state::{ + RefStateRecord, RefStateSnapshot, RefStateSnapshotRoot, RefStateTree, + }; + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let f = Fixture::new(format).await?; + let provider = Arc::new(super::blocked::Gate::new()); + let (native, catalog) = super::body::catalog(&f, provider.clone()).await?; + // One reachable native blob isolates renewal from unrelated graph setup. + let oid = *native + .fixture + .objects + .iter() + .find(|(_, (o, _))| o.kind == ObjectKind::Blob) + .ok_or("blob")? + .0; + let refs = RefStateTree::new(native.store.clone(), format) + .build_sorted( + operation(126), + [RefStateRecord::new( + "refs/tags/body", + crate::RefExpectation { + oid: Some(oid), + version: 1, + }, + format, + )], + ) + .await?; + let refs = RefStateSnapshotRoot::upload( + &native.store, + operation(127), + RefStateSnapshot { + repository: f.repository, + format, + generation: 2, + default_branch: "refs/heads/main".into(), + root: refs, + }, + ) + .await?; + f.install_generation(3, catalog, Some(refs)).await?; + let q = super::pool::queue(&f)?; + let root = tempfile::TempDir::new()?; + let tasks = TaskTracker::new(); + let (ctx, _) = + super::body::serving_context(&f, native.store.clone(), &root, tasks.clone())?; + let warm = + ServingOwner::start(ctx.clone(), q.clone(), f.begin([120; 16]), identity()?).await?; + timeout(Duration::from_secs(8), async { + while warm.stats().phase != ServingOwnerPhase::Ready { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .map_err(|e| format!("{format:?}: warm readiness: {e}; {:?}", warm.stats()))?; + let view = warm.snapshot(Some("owner".into())).await?; + let workspace = view + .ref_workspace(WorkspaceLimits::default()) + .await + .map_err(|e| format!("{format:?}: warm workspace: {e}"))?; + assert!(workspace.contains(&[oid]).await?[0]); + drop((workspace, view)); + assert_eq!( + warm.close_and_drain().await.phase, + ServingOwnerPhase::Released + ); + + let mut input = f.begin([121; 16]); + input.lease_ms = RENEWAL_LEASE_MS; + let owner = ServingOwner::start(ctx, q.clone(), input, identity()?).await?; + timeout(Duration::from_secs(8), async { + while owner.stats().phase != ServingOwnerPhase::Ready { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .map_err(|e| { + format!( + "{format:?}: short owner readiness: {e}; {:?}", + owner.stats() + ) + })?; + let view = owner.snapshot(Some("owner".into())).await?; + let workspace = view + .ref_workspace(WorkspaceLimits::default()) + .await + .map_err(|e| format!("{format:?}: short workspace: {e}; {:?}", owner.stats()))?; + provider.armed.store(true, Ordering::Release); + let mut observer = tokio::spawn(async move { + workspace.prepare_fetch(vec![oid], None, false).await?; + Ok::<_, ServingReadError>(workspace) + }); + timeout(Duration::from_secs(8), async { + tokio::select! { + permit = provider.entered.acquire() => { + permit?.forget(); + Ok::<_, Box>(()) + } + result = &mut observer => { + let error = match result { + Ok(Err(error)) => format!("preparation refused before provider entry: {error}"), + Err(error) => format!("preparation worker failed before provider entry: {error}"), + Ok(Ok(_)) => "preparation completed without reaching the armed provider".into(), + }; + Err(error.into()) + }, + } + }).await.map_err(|e| format!("{format:?}: provider entry: {e}; {:?}", owner.stats()))??; + // Physical work must keep the closed owner renewing this same pin. + owner.close(); + tokio::time::sleep(Duration::from_millis(RENEWAL_LEASE_MS * 3 / 2)).await; + timeout(Duration::from_secs(8), async { + while owner.stats().renewals < 2 { + assert_eq!( + owner.stats().phase, + ServingOwnerPhase::Ready, + "{:?}", + owner.stats() + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .map_err(|e| format!("{format:?}: exact renewals: {e}; {:?}", owner.stats()))?; + provider.proceed.add_permits(1); + let workspace = timeout(Duration::from_secs(8), observer) + .await + .map_err(|e| format!("{format:?}: renewed completion: {e}; {:?}", owner.stats()))???; + assert!(workspace.contains(&[oid]).await?[0]); + assert_eq!(pin_count(&f).await?, 1); + drop((workspace, view)); + assert_eq!( + timeout(Duration::from_secs(8), owner.close_and_drain()) + .await? + .phase, + ServingOwnerPhase::Released + ); + assert_eq!(pin_count(&f).await?, 0); + assert!(q.close_and_drain().await.is_empty()); + tasks.close(); + timeout(Duration::from_secs(8), tasks.wait()).await?; + f.runtime.shutdown().await?; + } + Ok(()) +} diff --git a/crates/canopy-server/src/packs/publication/tests/staging_receipt.rs b/crates/canopy-server/src/packs/publication/tests/staging_receipt.rs index 8649459f..4cbcc45c 100644 --- a/crates/canopy-server/src/packs/publication/tests/staging_receipt.rs +++ b/crates/canopy-server/src/packs/publication/tests/staging_receipt.rs @@ -5,14 +5,7 @@ use cellule_runtime::Resolution; use tokio::time::{Duration, timeout}; async fn expire(expires_at_ms: i64) -> Result { - let now = i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis())?; - if now <= expires_at_ms { - tokio::time::sleep(Duration::from_millis(u64::try_from( - expires_at_ms - now + 1, - )?)) - .await; - } - Ok(()) + wait_for_sdk_expiry(expires_at_ms).await } fn denied_stage( result: std::result::Result< diff --git a/crates/canopy-server/src/packs/publication/tests/staging_service.rs b/crates/canopy-server/src/packs/publication/tests/staging_service.rs index 94f00cf3..fc3cf777 100644 --- a/crates/canopy-server/src/packs/publication/tests/staging_service.rs +++ b/crates/canopy-server/src/packs/publication/tests/staging_service.rs @@ -1,4 +1,5 @@ mod bound; +mod budget; mod physical; mod publication; pub(super) mod restore; @@ -246,13 +247,7 @@ async fn staged_service_recovers_original_begin_after_sdk_expiry_before_allowing .output .ok_or("artifact custody expired with the SDK identity")?; assert!(live.expires_at_ms > mutation.expires_at_ms); - let now = i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis())?; - if now <= mutation.expires_at_ms { - tokio::time::sleep(Duration::from_millis(u64::try_from( - mutation.expires_at_ms - now + 1, - )?)) - .await; - } + wait_for_sdk_expiry(mutation.expires_at_ms).await?; assert!(matches!( fixture.client().resolve(&evidence).await?, cellule_runtime::Resolution::Expired @@ -857,3 +852,117 @@ async fn staged_service_revocation_drops_completed_owned_results_before_releasin fixture.runtime.shutdown().await?; Ok(()) } + +#[tokio::test] +async fn generated_candidate_intent_joins_uncertain_original_and_retries_only_after_known_drain() +-> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let f = Fixture::new(format).await?; + let c = StagingCoordinator::new(f.target.clone(), StagingLimits::default(), f.authority())?; + let first_request = f.begin([191; 16]); + c.fault_for_test(4); + let first = submit(&f, &c, first_request.operation, "owner").await?; + assert!(matches!( + terminal(&first).await?, + StagingState::Uncertain(_) + )); + let original = first + .custody_evidence_for_test() + .ok_or("original custody absent")?; + assert!(matches!( + f.client().resolve(&original.0).await?, + cellule_runtime::Resolution::Absent + )); + let mut retry = first_request.clone(); + retry.operation = [192; 16]; + let joined = c + .join_generated_candidate(&retry)? + .ok_or("original uncertain candidate not joined")?; + assert_eq!(joined.custody_evidence_for_test(), Some(original.clone())); + assert_eq!(c.stats().admitted, 1); + let mut wrong = retry.clone(); + wrong.actor = "writer".into(); + assert!(c.join_generated_candidate(&wrong)?.is_none()); + wrong = retry.clone(); + wrong.request_digest = [195; 32]; + assert!(c.join_generated_candidate(&wrong)?.is_none()); + wrong = retry.clone(); + wrong.repository = [196; 16]; + assert!(c.join_generated_candidate(&wrong).is_err()); + c.recover(&first)?; + let old = active(&first).await?; + // Once acceptance is known, transient pending diagnostics are released. + // The durable custody head and SDK still select the exact original. + let saved = RegisteredCustody::load_latest(&f.client(), &f.target, first_request.operation) + .await? + .ok_or("original custody head missing")?; + assert_eq!(saved.evidence(), &original.0); + assert!(matches!( + f.client().resolve(&original.0).await?, + cellule_runtime::Resolution::Committed(_) + )); + assert!(matches!(joined.state(),StagingState::Active(lease) if lease.token==old.token)); + first.stop(); + assert!(matches!(terminal(&first).await?, StagingState::Stopped)); + timeout(Duration::from_secs(10), async { + while c.pending(first_request.operation).is_some() { + tokio::task::yield_now().await; + } + }) + .await?; + assert!(c.join_generated_candidate(&retry)?.is_none()); + let ready = + ReadyStaging::new(f.client(), f.target.clone(), retry.clone(), identity()?).await?; + let fresh = c.submit(ready).map_err(|(error, _)| error)?; + let new = active(&fresh).await?; + assert_eq!(new.token.operation, retry.operation); + assert_ne!(new.token.attempt, old.token.attempt); + assert_ne!(new.token.artifact_operation, old.token.artifact_operation); + assert!(c.close_and_drain().await.is_empty()); + f.runtime.shutdown().await?; + } + Ok(()) +} + +#[tokio::test] +async fn checkpoint_receipt_precedes_custody_probe_but_next_work_waits_for_active_phase() -> Result +{ + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let f = Fixture::new(format).await?; + let c = StagingCoordinator::new(f.target.clone(), StagingLimits::default(), f.authority())?; + let ticket = submit(&f, &c, [236; 16], "owner").await?; + active(&ticket).await?; + let store = Arc::new(canopy_object_storage::artifact::ArtifactStore::new( + Arc::new(InMemory::new()), + f.repository, + )); + let proof = super::inputs::seal(&f, &ticket, store, 0).await?; + let (entered, release) = c.pause_checkpoint_probe_for_test(); + let registration = ticket + .register_inputs(proof, identity()?) + .map_err(|(e, _)| e)?; + timeout(Duration::from_secs(10), entered).await??; + assert!(matches!(ticket.state(), StagingState::RegisteringInputs)); + let original = timeout(Duration::from_secs(10), registration.wait()) + .await? + .map_err(|e| e.to_string())?; + // Dropping the release sender resumes a failed test's held controller. + assert!( + timeout(Duration::from_millis(20), registration.wait_ready()) + .await + .is_err(), + "known checkpoint receipt leaked into the still-registering phase" + ); + let _ = release.send(()); + assert_eq!( + timeout(Duration::from_secs(10), registration.wait_ready()) + .await? + .map_err(|e| e.to_string())?, + original + ); + assert!(matches!(ticket.state(), StagingState::Active(_))); + assert!(c.close_and_drain().await.is_empty()); + f.runtime.shutdown().await?; + } + Ok(()) +} diff --git a/crates/canopy-server/src/packs/publication/tests/staging_service/budget.rs b/crates/canopy-server/src/packs/publication/tests/staging_service/budget.rs new file mode 100644 index 00000000..dde551a9 --- /dev/null +++ b/crates/canopy-server/src/packs/publication/tests/staging_service/budget.rs @@ -0,0 +1,153 @@ +//! A physical owner retains the node share after all async observers leave. +use super::super::super::staging_service::StagingBudget; +use super::*; + +struct Release(Option>); +impl Drop for Release { + fn drop(&mut self) { + if let Some(sender) = self.0.take() { + let _ = sender.send(()); + } + } +} + +#[tokio::test] +async fn shared_staging_worker_budget_survives_detached_work_and_terminal_observers() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let one = Fixture::new(format).await?; + let two = Fixture::new(format).await?; + let budget = StagingBudget::new(4, 2)?; + let a = StagingCoordinator::new_with_budget( + one.target.clone(), + StagingLimits::default(), + one.authority(), + budget.clone(), + )?; + let b = StagingCoordinator::new_with_budget( + two.target.clone(), + StagingLimits::default(), + two.authority(), + budget.clone(), + )?; + let first = submit(&one, &a, [211; 16], "owner").await?; + let second = submit(&two, &b, [212; 16], "owner").await?; + active(&first).await?; + active(&second).await?; + assert_eq!(budget.available(), (2, 2)); + let (release, wait) = std::sync::mpsc::channel(); + let release = Release(Some(release)); + let (entered, running) = oneshot::channel(); + let worker = first + .spawn(move |context| async move { + let owner = context.physical_owner(); + Ok(tokio::task::spawn_blocking(move || { + let _owner = owner; + let _ = entered.send(()); + let _ = wait.recv(); + })) + })? + .wait() + .await + .map_err(|error| error.to_string())?; + timeout(Duration::from_secs(5), running).await??; + assert_eq!(budget.available(), (2, 1)); + assert!(matches!( + second.spawn(|_| async { Ok(()) }), + Err(StagingError::Capacity) + )); + first.stop(); + assert!(!worker.is_finished()); + assert!(a.try_quiesce().is_none()); + drop(release); + timeout(Duration::from_secs(5), worker).await??; + timeout(Duration::from_secs(5), async { + while a.stats().admitted != 0 { + tokio::task::yield_now().await; + } + }) + .await?; + assert_eq!(budget.available(), (3, 2)); + // The old terminal observer remains alive, but neither node credit is + // charged to that observer after the last physical worker exits. + assert!(matches!(first.state(), StagingState::Stopped)); + assert_eq!( + second + .spawn(|_| async { Ok(7) })? + .wait() + .await + .map_err(|e| e.to_string())?, + 7 + ); + let pause = a.try_quiesce().ok_or("idle staging could not pause")?; + let ready = ReadyStaging::new( + one.client(), + one.target.clone(), + one.begin([213; 16]), + identity()?, + ) + .await?; + let (error, ready) = a.submit(ready).err().ok_or("paused admission opened")?; + assert!(matches!(error, StagingError::Closed)); + drop(pause); // Cancellation/failure restores admission without new state. + let retry = a.submit(ready).map_err(|(error, _)| error)?; + active(&retry).await?; + assert_eq!(budget.available(), (2, 2)); + assert!(a.close_and_drain().await.is_empty()); + assert!(b.close_and_drain().await.is_empty()); + assert_eq!(budget.available(), (4, 2)); + one.runtime.shutdown().await?; + two.runtime.shutdown().await?; + } + Ok(()) +} + +#[tokio::test] +async fn shared_staging_operation_budget_retains_exact_uncertainty_until_recovery() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + for fault in [1, 2, 3] { + let fixture = Fixture::new(format).await?; + let budget = StagingBudget::new(2, 2)?; + let coordinator = StagingCoordinator::new_with_budget( + fixture.target.clone(), + StagingLimits::default(), + fixture.authority(), + budget.clone(), + )?; + coordinator.fault_for_test(fault); + let ticket = submit(&fixture, &coordinator, [214; 16], "owner").await?; + assert!(matches!( + terminal(&ticket).await?, + StagingState::Uncertain(_) + )); + let original = ticket + .custody_evidence_for_test() + .ok_or("custody absent")? + .0; + let pending = coordinator.close_and_drain().await; + assert_eq!(pending.len(), 1); + assert_eq!(budget.available(), (1, 2)); + assert!(coordinator.try_quiesce().is_none()); + coordinator.recover(&ticket)?; + assert!( + timeout(Duration::from_secs(10), coordinator.close_and_drain()) + .await? + .is_empty() + ); + assert_eq!(budget.available(), (2, 2)); + assert!(matches!( + fixture.client().resolve(&original).await?, + cellule_runtime::Resolution::Committed(_) + )); + assert_eq!( + RegisteredCustody::load_latest(&fixture.client(), &fixture.target, [214; 16]) + .await? + .ok_or("exact custody lost")? + .evidence() + .clone(), + original + ); + fixture.runtime.shutdown().await?; + } + } + Ok(()) +} diff --git a/crates/canopy-server/src/packs/publication/tests/staging_service/publication.rs b/crates/canopy-server/src/packs/publication/tests/staging_service/publication.rs index 68e1eef6..81379eba 100644 --- a/crates/canopy-server/src/packs/publication/tests/staging_service/publication.rs +++ b/crates/canopy-server/src/packs/publication/tests/staging_service/publication.rs @@ -544,3 +544,136 @@ async fn bound_final_observes_shared_coordinator_recovery_without_losing_lifecyc f.runtime.shutdown().await?; Ok(()) } + +#[tokio::test] +async fn bound_publication_waits_for_contended_admission_without_losing_original_command() -> Result +{ + use std::{ + future::Future, + task::{Context, Poll, Waker}, + }; + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let f = Fixture::new(format).await?; + let c = StagingCoordinator::new(f.target.clone(), StagingLimits::default(), f.authority())?; + let p = PublicationCoordinator::new( + f.target.clone(), + PublicationLimits::default(), + f.publication_budget.clone(), + )?; + let ticket = super::bound::bind(&f, &c, [236; 16], "owner").await?; + let session = ticket.bound_session()?; + let input = ready(&session).await?; + let future = ticket.publish_wait(&p, input); + tokio::pin!(future); + p.with_admission_for_test(|| { + let mut context = Context::from_waker(Waker::noop()); + assert!( + matches!(future.as_mut().poll(&mut context), Poll::Pending), + "mutex contention must wait without consuming the prepared command" + ); + assert!(ticket.pending_publication().is_none()); + assert!(matches!(ticket.state(), StagingState::Bound(_))); + }) + .await; + let observer = timeout(Duration::from_secs(10), future).await??; + finished(timeout(Duration::from_secs(10), observer.wait()).await?)?; + assert_eq!(observer.response().await?, refused()); + assert!(matches!( + terminal(&ticket).await?, + StagingState::Published(Ok(_)) + )); + assert!(c.close_and_drain().await.is_empty()); + assert!(p.close_and_drain().await.is_empty()); + } + Ok(()) +} + +#[tokio::test] +async fn bound_publication_wait_ceiling_never_admits_or_executes_the_retained_command() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let f = Fixture::new(format).await?; + let c = StagingCoordinator::new(f.target.clone(), StagingLimits::default(), f.authority())?; + let p = PublicationCoordinator::new( + f.target.clone(), + PublicationLimits::default(), + f.publication_budget.clone(), + )?; + let ticket = super::bound::bind(&f, &c, [237; 16], "owner").await?; + let session = ticket.bound_session()?; + let input = ready(&session).await?; + ticket.expire_bound_for_test()?; + let failure = timeout( + Duration::from_secs(10), + p.with_admission_async_for_test(ticket.publish_wait(&p, input)), + ) + .await? + .err() + .ok_or("publication admitted under a locked mutex")?; + assert!(matches!(failure.reason, StagingError::Inactive)); + assert!(ticket.pending_publication().is_none()); + assert_eq!(p.stats().await.admitted, 0); + assert_eq!( + super::super::completion::completed_pushes(&f.handle).await?, + 0 + ); + drop(failure); + // Bound is terminal for the staging phase, but expiry completes later. + // Wait for the lifecycle outcome rather than racing its status update. + let state = timeout(Duration::from_secs(10), ticket.wait_completion()).await?; + assert!(matches!(state, StagingState::Fenced(_)), "{state:?}"); + assert!(c.close_and_drain().await.is_empty()); + assert!(p.close_and_drain().await.is_empty()); + f.runtime.shutdown().await?; + } + Ok(()) +} + +#[tokio::test] +async fn bound_publication_wait_keeps_real_quota_refusal_immediate_and_unexecuted() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let f = Fixture::new(format).await?; + let c = StagingCoordinator::new(f.target.clone(), StagingLimits::default(), f.authority())?; + let p = PublicationCoordinator::new( + f.target.clone(), + PublicationLimits { + per_actor: 1, + ..PublicationLimits::default() + }, + f.publication_budget.clone(), + )?; + let first = super::bound::bind(&f, &c, [238; 16], "owner").await?; + let second = super::bound::bind(&f, &c, [239; 16], "owner").await?; + let first_input = ready(&first.bound_session()?).await?; + let second_input = ready(&second.bound_session()?).await?; + let (release, entered) = p.pause_for_test().await; + let observer = first.publish_wait(&p, first_input).await?; + timeout(Duration::from_secs(10), entered).await??; + let failure = timeout( + Duration::from_secs(1), + second.publish_wait(&p, second_input), + ) + .await? + .err() + .ok_or("actor quota exceeded")?; + assert!(matches!( + failure.reason, + StagingError::PublicationAdmission(PublicationScheduleError::Capacity) + )); + assert!(second.pending_publication().is_none()); + assert!(matches!(second.state(), StagingState::Bound(_))); + assert_eq!(p.stats().await.admitted, 1); + drop(failure); + release + .send(()) + .map_err(|_| "held publication disappeared")?; + finished(timeout(Duration::from_secs(10), observer.wait()).await?)?; + assert_eq!( + super::super::completion::completed_pushes(&f.handle).await?, + 1 + ); + assert!(c.close_and_drain().await.is_empty()); + assert!(p.close_and_drain().await.is_empty()); + f.runtime.shutdown().await?; + } + Ok(()) +} diff --git a/crates/canopy-server/src/packs/publication/tests/staging_service/restore.rs b/crates/canopy-server/src/packs/publication/tests/staging_service/restore.rs index f85628af..2a9e9cc5 100644 --- a/crates/canopy-server/src/packs/publication/tests/staging_service/restore.rs +++ b/crates/canopy-server/src/packs/publication/tests/staging_service/restore.rs @@ -57,16 +57,23 @@ pub(in crate::packs::publication::tests) async fn head_expiring( }; let mut mutation = identity()?; if short_expiry { - mutation.expires_at_ms = mutation.issued_at_ms + 1_000; + // Accepted-history tests need time to commit on loaded CI workers; + // they still wait for real SDK expiry before observing cold recovery. + // Intentionally unexecuted expiry cases retain their short window. + mutation.expires_at_ms = + mutation.issued_at_ms + if execute_original { 10_000 } else { 1_000 }; } let command = PreparedCustody::prepare(&f.client(), &f.target, action, mutation).await?; let original = command.evidence().clone(); let registered = command.register(&f.client(), identity()?).await?; - let committed = if execute_original { - Some(registered.recover(&f.client()).await?) - } else { - None - }; + let committed = + if execute_original { + Some(registered.recover(&f.client()).await.map_err(|error| { + format!("initial acceptance for custody kind {kind}: {error:?}") + })?) + } else { + None + }; Ok((original, committed)) } async fn restore( @@ -84,12 +91,7 @@ async fn settle(ticket: &StagingTicket) -> Result { Ok(timeout(Duration::from_secs(10), ticket.wait()).await?) } async fn expired(evidence: &PendingMutation) -> Result { - let until = evidence.identity().expires_at_ms; - let now = i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis())?; - if now <= until { - tokio::time::sleep(Duration::from_millis((until - now + 1) as u64)).await; - } - Ok(()) + wait_for_sdk_expiry(evidence.identity().expires_at_ms).await } #[tokio::test] @@ -159,10 +161,12 @@ async fn cold_staging_keeps_all_original_receipts_after_sdk_expiry_and_actual_ow super::super::durable_recovery::restore_owner(&f, &check(old)).await?; assert_ne!(handle.owner_fence(), old.owner); expired(&evidence).await?; - assert!(matches!( - client.resolve(&evidence).await?, - Resolution::Expired - )); + let resolution = client.resolve(&evidence).await?; + assert!( + matches!(resolution, Resolution::Expired), + "format {format:?}, custody kind {kind}, expiry {}: {resolution:?}", + evidence.identity().expires_at_ms + ); let (service, ticket) = restore(&f, client.clone(), kind, StagingLimits::default()).await?; assert!(matches!(settle(&ticket).await?, StagingState::Fenced(_))); diff --git a/crates/canopy-server/src/packs/publication/tests/staging_service/retirement.rs b/crates/canopy-server/src/packs/publication/tests/staging_service/retirement.rs index 13022519..8e519f2b 100644 --- a/crates/canopy-server/src/packs/publication/tests/staging_service/retirement.rs +++ b/crates/canopy-server/src/packs/publication/tests/staging_service/retirement.rs @@ -5,14 +5,7 @@ use cellule_runtime::{PendingMutation, Resolution}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; async fn expired(evidence: &PendingMutation) -> Result { - let now = crate::packs::publication::sql::now(0)?; - if now <= evidence.identity().expires_at_ms { - tokio::time::sleep(Duration::from_millis( - (evidence.identity().expires_at_ms - now + 1) as u64, - )) - .await; - } - Ok(()) + wait_for_sdk_expiry(evidence.identity().expires_at_ms).await } async fn until(c: &StagingCoordinator, predicate: impl Fn(StagingStats) -> bool) -> Result { timeout(Duration::from_secs(10), async { diff --git a/crates/canopy-server/src/packs/ref_state/mod.rs b/crates/canopy-server/src/packs/ref_state/mod.rs index 51d88879..e06da63f 100644 --- a/crates/canopy-server/src/packs/ref_state/mod.rs +++ b/crates/canopy-server/src/packs/ref_state/mod.rs @@ -56,6 +56,13 @@ impl RefTransition { } } impl RefStateIndex { + pub(crate) async fn visit + ?Sized>( + &self, + root: RefStateRoot, + visitor: &mut V, + ) -> super::directory::index::WalkResult<()> { + self.tree.visit(root, visitor).await + } pub fn new(store: Arc, format: ObjectFormat) -> Self { Self { tree: RefStateTree::new(store, format), diff --git a/crates/canopy-server/src/packs/ref_state/transition.rs b/crates/canopy-server/src/packs/ref_state/transition.rs index bace6e8e..6574d5bb 100644 --- a/crates/canopy-server/src/packs/ref_state/transition.rs +++ b/crates/canopy-server/src/packs/ref_state/transition.rs @@ -33,6 +33,48 @@ impl RefStateIndex { ) -> Result { super::super::publication::codec::artifact_valid(operation)?; super::super::publication::ref_proof::shape(plan, self.format())?; + self.prepare_checked(base, operation, plan).await + } + + /// Private reserved-ref creation. This tree output grants no write authority; + /// the generated publisher must authenticate the verified candidate scope. + pub(crate) async fn prepare_candidate( + &self, + base: Option, + operation: [u8; 16], + candidate: &crate::pulls::candidates::MergeCandidate, + ) -> Result { + use crate::pulls::candidates::{CandidateResult, valid_request}; + let CandidateResult::Ready { oid, .. } = &candidate.result else { + return Err(RefStateError::Changed); + }; + if !valid_request(&candidate.request) + || crate::directory::validate_component(&candidate.actor).is_err() + { + return Err(RefStateError::Changed); + } + let oid = crate::pulls::merge::oid(oid).map_err(|_| RefStateError::Changed)?; + if oid.format() != self.format() || oid.is_zero() { + return Err(RefStateError::Changed); + } + super::super::publication::codec::artifact_valid(operation)?; + let plan = PushPlan { + actor: candidate.actor.clone(), + updates: vec![crate::RefUpdate { + name: candidate.fetch_ref(), + expected: None, + new_oid: Some(oid), + }], + }; + self.prepare_checked(base, operation, &plan).await + } + + async fn prepare_checked( + &self, + base: Option, + operation: [u8; 16], + plan: &PushPlan, + ) -> Result { if let Some(root) = &base { self.tree.validate_root(root.clone()).await?; } diff --git a/crates/canopy-server/src/packs/sources/native.rs b/crates/canopy-server/src/packs/sources/native.rs index 9698647d..b5a18eee 100644 --- a/crates/canopy-server/src/packs/sources/native.rs +++ b/crates/canopy-server/src/packs/sources/native.rs @@ -80,6 +80,25 @@ impl NativePackDescriptor { } /// Local precursor only: manifest digests are filled by authenticated /// upload before this descriptor escapes the capture service. + /// A native receive of refs pointing to existing objects can produce an + /// empty pack. Verify its index/header/trailer before excluding it from the + /// incoming object inventory; zero-object sources remain forbidden. + pub(crate) fn is_empty_pair( + format: ObjectFormat, + pack: &Path, + index: &Path, + ) -> Result { + let index = PackIndex::open(index, format).map_err(MetadataError::from)?; + if !index.is_empty() { + return Ok(false); + } + let size = std::fs::metadata(pack).map_err(MetadataError::from)?.len(); + if size != 12 + format.bytes() as u64 { + return Err(IndexError::Integrity); + } + pack_digest(pack, size, index.pack_checksum(), 0)?; + Ok(true) + } pub(crate) fn inspect_files( repository: [u8; 16], operation: [u8; 16], diff --git a/crates/canopy-server/src/packs/verification/mod.rs b/crates/canopy-server/src/packs/verification/mod.rs index c75ecb23..14608b55 100644 --- a/crates/canopy-server/src/packs/verification/mod.rs +++ b/crates/canopy-server/src/packs/verification/mod.rs @@ -10,7 +10,8 @@ mod spool; pub use spool::VerifiedObject; pub(super) mod physical; pub use physical::{ - PhysicalError, PhysicalLimits, PhysicalPackWitness, PhysicalPartition, PhysicalVerifier, + NativeMetadataLimits, PhysicalError, PhysicalLimits, PhysicalPackWitness, PhysicalPartition, + PhysicalVerifier, StagedNativeMetadata, }; /// The service retains its admitted private native workspace and process diff --git a/crates/canopy-server/src/packs/verification/physical.rs b/crates/canopy-server/src/packs/verification/physical.rs index f793b5b1..dcb07b1f 100644 --- a/crates/canopy-server/src/packs/verification/physical.rs +++ b/crates/canopy-server/src/packs/verification/physical.rs @@ -17,6 +17,8 @@ use canopy_object_storage::{artifact::ArtifactStore, external::MAX_ARTIFACT_BYTE use cellule_ltx::DiskBudget; use std::{path::PathBuf, process::Stdio, sync::Arc, time::Duration}; +mod staged; +pub use staged::{NativeMetadataLimits, StagedNativeMetadata}; mod partition; pub use partition::PhysicalPartition; diff --git a/crates/canopy-server/src/packs/verification/physical/staged.rs b/crates/canopy-server/src/packs/verification/physical/staged.rs new file mode 100644 index 00000000..aaa40f90 --- /dev/null +++ b/crates/canopy-server/src/packs/verification/physical/staged.rs @@ -0,0 +1,242 @@ +//! One resident metadata shard; ordered descriptors live on admitted disk. +//! This private replay is not an input checkpoint or a publication certificate. +use super::*; +use crate::packs::{ + directory::index::IndexRecord, + metadata::{AdmittedFile, StoredSegment}, + publication::StagingContext, + sources::SourceRecord, +}; +use cellule_runtime::codec::{BoundedDecoder, BoundedEncoder}; +use std::{ + io::{Read, Seek, SeekFrom, Write}, + sync::Mutex, +}; + +const RECORD_BYTES: u32 = 512; + +#[derive(Clone, Copy, Debug)] +pub struct NativeMetadataLimits { + pub max_shard_objects: u32, + pub max_descriptor_bytes: u64, +} +impl Default for NativeMetadataLimits { + fn default() -> Self { + Self { + max_shard_objects: 8192, + max_descriptor_bytes: 64 << 20, + } + } +} + +/// Available only after every ordinal has passed isolated physical inspection +/// and all metadata uploads have completed. No local metadata shard or worker +/// activity remains in the result, allowing Creating to drain before Bind. +pub struct StagedNativeMetadata { + pub(in crate::packs) witness: PhysicalPackWitness, + pub(in crate::packs) replay: DescriptorReplay, +} +impl StagedNativeMetadata { + #[cfg(test)] + pub(in crate::packs) fn truncate_replay_for_test(&self) -> Result<(), PhysicalError> { + self.replay + .spool + .lock() + .map_err(|_| PhysicalError::Integrity)? + .file + .file() + .as_file() + .set_len(self.replay.bytes - 1)?; + Ok(()) + } + #[cfg(test)] + pub(in crate::packs) fn first_metadata_for_test(&self) -> Result { + self.replay + .spool + .lock() + .map_err(|_| PhysicalError::Integrity)? + .read(self.native(), 0, self.replay.bytes)? + .map(|(stored, _)| stored) + .ok_or(PhysicalError::Integrity) + } + pub fn native(&self) -> NativePackDescriptor { + self.witness.native() + } + pub fn shard_count(&self) -> u32 { + self.witness.shard_count() + } + pub fn descriptor_bytes(&self) -> u64 { + self.replay.bytes + } +} + +impl PhysicalVerifier { + pub async fn stage_metadata( + mut self, + limits: NativeMetadataLimits, + ) -> Result { + if limits.max_shard_objects == 0 + || limits.max_descriptor_bytes == 0 + || limits.max_descriptor_bytes > MAX_ARTIFACT_BYTES + || self.next_ordinal != 0 + || self.failed + { + return Err(PhysicalError::Limit); + } + let context = self.context.clone().ok_or(PhysicalError::Integrity)?; + context.ensure_live()?; + let root = self.root.clone(); + let budget = self.budget.clone(); + let activity = context.physical_owner(); + let mut spool = tokio::task::spawn_blocking(move || { + let _activity = activity; + let workspace = Arc::new( + tempfile::Builder::new() + .prefix("canopy-native-metadata-") + .tempdir_in(root)?, + ); + let mut file = AdmittedFile::new( + tempfile::NamedTempFile::new_in(workspace.path())?, + budget.try_reserve(0).map_err(MetadataError::from)?, + ); + file.retain_workspace(workspace); + Ok::<_, PhysicalError>(DescriptorSpool { file, bytes: 0 }) + }) + .await??; + while self.next_ordinal < self.descriptor.object_count { + let count = + (self.descriptor.object_count - self.next_ordinal).min(limits.max_shard_objects); + let segment = self.inspect_next_shard(count).await?; + context.ensure_live()?; + let metadata = segment + .upload_owned(&self.store, context.physical_owner()) + .await?; + context.ensure_live()?; + let record = SourceRecord { + metadata, + pack: self.descriptor.pack, + index: self.descriptor.index, + pack_object_count: self.descriptor.object_count, + }; + record.validate(self.descriptor.repository, self.descriptor.format)?; + let activity = context.physical_owner(); + spool = tokio::task::spawn_blocking(move || { + let _activity = activity; + spool.append(record, limits.max_descriptor_bytes)?; + Ok::<_, PhysicalError>(spool) + }) + .await??; + // Upload consumes the last shard pin. The next shard never coexists + // with earlier metadata files; only fixed-size descriptors survive. + } + let witness = self.finish().await?; + let activity = context.physical_owner(); + let replay = tokio::task::spawn_blocking(move || { + let _activity = activity; + spool.file.file().as_file().sync_all()?; + if spool.file.file().as_file().metadata()?.len() != spool.bytes { + return Err(PhysicalError::Integrity); + } + Ok::<_, PhysicalError>(DescriptorReplay { + bytes: spool.bytes, + spool: Arc::new(Mutex::new(spool)), + }) + }) + .await??; + context.ensure_live()?; + Ok(StagedNativeMetadata { witness, replay }) + } +} + +struct DescriptorSpool { + file: AdmittedFile, + bytes: u64, +} +impl DescriptorSpool { + fn append(&mut self, record: SourceRecord, maximum: u64) -> Result<(), PhysicalError> { + let mut encoder = BoundedEncoder::new(RECORD_BYTES).map_err(IndexError::from)?; + record + .encode_record(&mut encoder) + .map_err(IndexError::from)?; + let bytes = encoder.finish(); + let end = self + .bytes + .checked_add(4 + bytes.len() as u64) + .filter(|end| *end <= maximum) + .ok_or(PhysicalError::Limit)?; + // Admit before appending. A partial failed append cannot escape the + // consuming factory and keeps its file charged through cleanup. + self.file + .reservation() + .resize(end) + .map_err(MetadataError::from)?; + self.file + .file_mut() + .write_all(&(bytes.len() as u32).to_be_bytes())?; + self.file.file_mut().write_all(&bytes)?; + self.bytes = end; + Ok(()) + } + fn read( + &mut self, + native: NativePackDescriptor, + offset: u64, + size: u64, + ) -> Result, PhysicalError> { + if self.file.file().as_file().metadata()?.len() != size || offset > size { + return Err(PhysicalError::Integrity); + } + if offset == size { + return Ok(None); + } + let file = self.file.file_mut(); + file.seek(SeekFrom::Start(offset))?; + let mut length = [0; 4]; + file.read_exact(&mut length)?; + let length = u32::from_be_bytes(length); + let end = offset + .checked_add(4 + u64::from(length)) + .filter(|end| *end <= size && length != 0 && length <= RECORD_BYTES) + .ok_or(PhysicalError::Integrity)?; + let mut bytes = vec![0; length as usize]; + file.read_exact(&mut bytes)?; + let mut decoder = BoundedDecoder::new(&bytes, RECORD_BYTES).map_err(IndexError::from)?; + let record = SourceRecord::decode_record(&mut decoder, native.repository, native.format) + .map_err(IndexError::from)?; + decoder.finish().map_err(IndexError::from)?; + record.validate(native.repository, native.format)?; + if record.native() != native { + return Err(PhysicalError::Integrity); + } + Ok(Some((record.metadata, end))) + } +} + +pub(in crate::packs) struct DescriptorReplay { + spool: Arc>, + bytes: u64, +} +impl DescriptorReplay { + pub(in crate::packs) async fn next( + &self, + context: &StagingContext, + native: NativePackDescriptor, + offset: u64, + ) -> Result, PhysicalError> { + context.ensure_live()?; + let spool = self.spool.clone(); + let activity = context.physical_owner(); + let size = self.bytes; + let record = tokio::task::spawn_blocking(move || { + let _activity = activity; + let mut spool = spool.lock().map_err(|_| PhysicalError::Integrity)?; + spool.read(native, offset, size) + }) + .await??; + context.ensure_live()?; + Ok(record) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/canopy-server/src/packs/verification/physical/staged/tests.rs b/crates/canopy-server/src/packs/verification/physical/staged/tests.rs new file mode 100644 index 00000000..50b5e3be --- /dev/null +++ b/crates/canopy-server/src/packs/verification/physical/staged/tests.rs @@ -0,0 +1,86 @@ +use super::*; +use crate::packs::sources::tests::source; +type Result = std::result::Result>; + +fn spool(budget: &DiskBudget) -> Result { + Ok(DescriptorSpool { + file: AdmittedFile::new(tempfile::NamedTempFile::new()?, budget.try_reserve(0)?), + bytes: 0, + }) +} + +#[test] +fn descriptor_replay_reuses_source_codec_and_reserves_before_each_append() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let budget = DiskBudget::new(8 << 20); + let mut spool = spool(&budget)?; + let record = source(1, format); + // Digest order is deliberately irrelevant to this sequential replay. + // Each frame preserves the source codec, including artifact manifests. + for ordinal in 0..5000 { + let mut next = record; + next.metadata.segment.identity.first_ordinal = ordinal; + next.pack_object_count = 5001; + next.index.size = + 8 + 256 * 4 + 5001 * (format.bytes() as u64 + 8) + 2 * format.bytes() as u64; + spool.append(next, 8 << 20)?; + assert_eq!(budget.used(), spool.bytes); + assert_eq!(spool.file.file().as_file().metadata()?.len(), spool.bytes); + } + let size = spool.bytes; + let mut native = record.native(); + native.object_count = 5001; + native.index.size = + 8 + 256 * 4 + 5001 * (format.bytes() as u64 + 8) + 2 * format.bytes() as u64; + let mut offset = 0; + for ordinal in 0..5000 { + let (metadata, end) = spool.read(native, offset, size)?.ok_or("record")?; + assert_eq!(metadata.segment.identity.first_ordinal, ordinal); + assert_eq!(metadata.artifact, record.metadata.artifact); + offset = end; + } + assert_eq!(offset, size); + assert!(spool.read(native, offset, size)?.is_none()); + let path = spool.file.file().path().to_owned(); + drop(spool); + assert_eq!(budget.used(), 0); + assert!(!path.exists()); + } + Ok(()) +} + +#[test] +fn descriptor_replay_rejects_denied_growth_truncation_oversized_frames_and_foreign_binding() +-> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let budget = DiskBudget::new(512); + let mut spool = spool(&budget)?; + let record = source(1, format); + spool.append(record, 1024)?; + let charged = budget.used(); + let size = spool.bytes; + assert!(matches!( + spool.append(record, 1024), + Err(PhysicalError::Metadata(MetadataError::Budget(_))) + )); + assert_eq!(budget.used(), charged); + assert_eq!(spool.bytes, size); + assert_eq!(spool.file.file().as_file().metadata()?.len(), size); + assert!(matches!( + spool.append(record, size), + Err(PhysicalError::Limit) + )); + let mut foreign = record.native(); + foreign.pack.manifest_digest[0] ^= 1; + assert!(spool.read(foreign, 0, size).is_err()); + assert!(spool.read(record.native(), size + 1, size).is_err()); + spool.file.file_mut().seek(SeekFrom::Start(0))?; + spool.file.file_mut().write_all(&513u32.to_be_bytes())?; + assert!(spool.read(record.native(), 0, size).is_err()); + spool.file.file().as_file().set_len(size - 1)?; + assert!(spool.read(record.native(), 0, size).is_err()); + drop(spool); + assert_eq!(budget.used(), 0); + } + Ok(()) +} diff --git a/crates/canopy-server/src/packs/verification/physical/tests.rs b/crates/canopy-server/src/packs/verification/physical/tests.rs index 32babc10..91950c2a 100644 --- a/crates/canopy-server/src/packs/verification/physical/tests.rs +++ b/crates/canopy-server/src/packs/verification/physical/tests.rs @@ -13,7 +13,7 @@ use std::{future::Future, path::Path}; type Result = std::result::Result>; pub(in crate::packs) struct Prepared { pub(in crate::packs) fixture: Fixture, - provider: Arc, + pub(in crate::packs) provider: Arc, pub(in crate::packs) store: Arc, pub(in crate::packs) descriptor: NativePackDescriptor, } diff --git a/crates/canopy-server/src/pulls/candidates/command.rs b/crates/canopy-server/src/pulls/candidates/command.rs index b25f5d1c..aa11ebdc 100644 --- a/crates/canopy-server/src/pulls/candidates/command.rs +++ b/crates/canopy-server/src/pulls/candidates/command.rs @@ -3,10 +3,14 @@ use crate::{ RepositoryModule, access::{access_statement, decode_access}, directory::TokenScope, + packs::publication::{REF_SELECTION_BYTES, RefSelection}, }; use cellule_runtime::{CellModule, Command, registry::CommandResult}; -#[derive(Deserialize, Serialize)] +pub(crate) const INPUT_BYTES: u32 = REF_SELECTION_BYTES + (256 << 10); +pub(crate) const OUTPUT_BYTES: u32 = 1 << 20; + +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(tag = "action", deny_unknown_fields)] pub(crate) enum CandidateAction { Reserve { @@ -22,7 +26,7 @@ pub(crate) enum CandidateAction { }, } impl CandidateAction { - fn valid(&self) -> bool { + pub(super) fn valid(&self) -> bool { match self { Self::Reserve { actor, @@ -44,6 +48,19 @@ impl CandidateAction { } } } + pub(crate) fn actor(&self) -> &str { + match self { + Self::Reserve { actor, .. } | Self::Finish { actor, .. } => actor, + } + } + pub(crate) fn digest(&self) -> Result<[u8; 32], CodecError> { + let mut e = BoundedEncoder::new(256 << 10)?; + self.encode(&mut e)?; + let mut h = blake3::Hasher::new(); + h.update(b"canopy.native-candidate-intent.v1\0"); + h.update(&e.finish()); + Ok(*h.finalize().as_bytes()) + } } impl WireValue for CandidateAction { fn encode(&self, out: &mut BoundedEncoder) -> Result<(), CodecError> { @@ -63,20 +80,56 @@ impl WireValue for CandidateAction { Ok(result) } } + +/// Certified current refs authorize only editorial reservation or a negative +/// preparation result. A Ready result requires joint generated publication; +/// no request DTO or serving observation can grant that write authority. +#[derive(Clone, Debug)] +pub(crate) struct CandidateRefRequest { + pub(crate) selection: RefSelection, + pub(crate) action: CandidateAction, +} +impl WireValue for CandidateRefRequest { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + if self.selection.actor.as_deref() != Some(self.action.actor()) + || self.selection.facts.len() > 2 + || matches!( + &self.action, + CandidateAction::Finish { + result: CandidateResult::Ready { .. }, + .. + } + ) + { + return Err(CodecError::Invalid( + "invalid native candidate editorial scope", + )); + } + self.selection.encode(e)?; + self.action.encode(e) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + let value = Self { + selection: RefSelection::decode(d)?, + action: CandidateAction::decode(d)?, + }; + value.encode(&mut BoundedEncoder::new(INPUT_BYTES)?)?; + Ok(value) + } +} pub(crate) struct PrepareCandidate; impl Command for PrepareCandidate { const MODULE: &'static str = RepositoryModule::NAME; const ID: u32 = 10; - const CODEC_VERSION: u32 = 2; - type Input = CandidateAction; + const CODEC_VERSION: u32 = 3; + type Input = CandidateRefRequest; type Output = CandidateOutcome; fn execute( context: &mut CommandContext<'_, '_>, - action: CandidateAction, + input: CandidateRefRequest, ) -> cellule_runtime::Result> { - let actor = match &action { - CandidateAction::Reserve { actor, .. } | CandidateAction::Finish { actor, .. } => actor, - }; + input.encode(&mut BoundedEncoder::new(INPUT_BYTES)?)?; + let actor = input.action.actor(); let rejected = |value| Ok(CommandResult::Rejected(value)); let role = decode_access(&context.sql(&SqlBatch { statements: vec![access_statement(actor)], @@ -87,6 +140,16 @@ impl Command for PrepareCandidate { if role < TokenScope::Write { return rejected(CandidateOutcome::Forbidden); } + if !input.selection.authorized( + context.target().cell_id(), + Some(context.owner_fence()), + context.now_ms(), + input.action.digest()?, + |q| context.sql(q), + )? { + return rejected(CandidateOutcome::Conflict); + } + let action = input.action; let (candidate, binding) = match action { CandidateAction::Reserve { actor, @@ -132,17 +195,15 @@ impl Command for PrepareCandidate { candidate, )))); } - if let CandidateResult::Ready { oid, tree_oid } = &result - && !certified(context, &candidate, oid, tree_oid)? - { - return rejected(CandidateOutcome::Conflict); - } candidate.result = result; (candidate, None) } }; let Some(state) = policy_state(&context.sql(&SqlBatch { - statements: vec![policy_statement(&candidate.actor, candidate.number)], + statements: vec![super::super::native::with_refs( + policy_statement(&candidate.actor, candidate.number), + &input.selection, + )], })?)? else { return rejected(CandidateOutcome::NotFound); @@ -169,36 +230,17 @@ impl Command for PrepareCandidate { })?; SqlStatement {sql:"INSERT INTO merge_candidates (id,binding,pull_number,actor,request,created_ms,result,source_oid,base_oid) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)".into(),parameters:vec![SqlValue::Blob(id.as_bytes().to_vec()),SqlValue::Blob(binding),SqlValue::Integer(candidate.number),SqlValue::Text(candidate.actor.clone()),SqlValue::Text(request),SqlValue::Integer(candidate.created_at_ms),SqlValue::Text(result),SqlValue::Blob(oid(&candidate.request.revision.source_oid)?.to_vec()),SqlValue::Blob(oid(&candidate.request.revision.base_oid)?.to_vec())]} } else { - let ready = match &candidate.result { - CandidateResult::Ready { oid: commit, .. } => SqlValue::Blob(oid(commit)?.to_vec()), - _ => SqlValue::Null, - }; SqlStatement { - sql: "UPDATE merge_candidates SET result = ?2, oid = ?3 WHERE id = ?1".into(), + sql: "UPDATE merge_candidates SET result = ?2 WHERE id = ?1".into(), parameters: vec![ SqlValue::Blob(id.as_bytes().to_vec()), SqlValue::Text(result), - ready, ], } }; context.sql(&SqlBatch { statements: vec![statement], })?; - if let CandidateResult::Ready { oid: commit, .. } = &candidate.result { - // A candidate becomes fetchable in the same transaction as its ready - // result. The reserved namespace cannot be changed by ordinary pushes. - context.sql(&SqlBatch { - statements: vec![SqlStatement { - sql: "INSERT INTO refs (name, oid, version) VALUES (?1, ?2, 1)".into(), - parameters: vec![ - SqlValue::Text(candidate.fetch_ref()), - SqlValue::Blob(oid(commit)?.to_vec()), - ], - }], - })?; - crate::refs::advance_generation(context)?; - } Ok(CommandResult::Success(CandidateOutcome::Applied(Box::new( candidate, )))) diff --git a/crates/canopy-server/src/pulls/candidates/mod.rs b/crates/canopy-server/src/pulls/candidates/mod.rs index 8905c3f4..a43167d3 100644 --- a/crates/canopy-server/src/pulls/candidates/mod.rs +++ b/crates/canopy-server/src/pulls/candidates/mod.rs @@ -134,16 +134,97 @@ impl RepositoryCell { identity: MutationIdentity, action: command::CandidateAction, ) -> Result, InvocationError> { + let (_snapshot, input) = self + .prepare_candidate_action(action) + .await + .map_err(|source| { + InvocationError::NotStarted(Error::Facility { + name: "native candidate observation", + source: Box::new(source), + }) + })?; self.application - .command::(&self.target, identity, action) + .command::(&self.target, identity, input) .await } + + pub(crate) async fn prepare_candidate_action( + &self, + action: command::CandidateAction, + ) -> Result< + ( + Option, + command::CandidateRefRequest, + ), + super::native::NativePullError, + > { + validate_component(action.actor())?; + if !action.valid() { + return Err(Error::Command("invalid candidate action").into()); + } + if matches!( + &action, + command::CandidateAction::Finish { + result: CandidateResult::Ready { .. }, + .. + } + ) { + return Err( + Error::Command("native generated candidate publication is unavailable").into(), + ); + } + let number = match &action { + command::CandidateAction::Reserve { number, .. } => Some(*number), + command::CandidateAction::Finish { actor, id, .. } => { + let id = uuid::Uuid::parse_str(id) + .map_err(|_| Error::Command("invalid candidate UUID"))?; + let selected = self.sql.query(None, SqlBatch {statements: vec![SqlStatement { + sql: format!("SELECT pull_number FROM merge_candidates WHERE id=?2 AND actor=?1 AND ({ACCESS})"), + parameters: vec![ReadIdentity::Account(actor).parameter(), SqlValue::Blob(id.as_bytes().to_vec())], + }]}).await.map_err(|e| super::native::NativePullError::Metadata(Box::new(e)))?; + match selected + .output + .first() + .and_then(|s| s.rows.first()) + .map(Vec::as_slice) + { + Some([SqlValue::Integer(number)]) if *number > 0 => Some(*number), + None => None, + _ => return Err(Error::Command("invalid candidate number selection").into()), + } + } + }; + let selected = self.sql.query(None, SqlBatch {statements: vec![SqlStatement { + sql: format!("SELECT source_ref,base_ref FROM pull_requests WHERE number=?2 AND ({ACCESS})"), + parameters: vec![ReadIdentity::Account(action.actor()).parameter(), number.map_or(SqlValue::Null, SqlValue::Integer)], + }]}).await.map_err(|e| super::native::NativePullError::Metadata(Box::new(e)))?; + let mut names = match selected + .output + .first() + .and_then(|s| s.rows.first()) + .map(Vec::as_slice) + { + Some([SqlValue::Text(source), SqlValue::Text(base)]) => { + vec![source.clone(), base.clone()] + } + None => Vec::new(), + _ => return Err(Error::Command("invalid candidate ref selection").into()), + }; + names.sort(); + names.dedup(); + let (snapshot, selection) = self + .pull_ref_selection(action.actor(), action.digest()?, &names) + .await?; + Ok((snapshot, command::CandidateRefRequest { selection, action })) + } } -fn query(id: &str) -> cellule_runtime::Result { +pub(crate) fn query(id: &str) -> cellule_runtime::Result { let id = uuid::Uuid::parse_str(id).map_err(|_| Error::Command("invalid candidate UUID"))?; Ok(SqlBatch {statements:vec![SqlStatement {sql:"SELECT binding, pull_number, actor, request, created_ms, result FROM merge_candidates WHERE id = ?1".into(),parameters:vec![SqlValue::Blob(id.as_bytes().to_vec())]}]}) } -fn decode(sets: &[SqlResultSet]) -> cellule_runtime::Result, MergeCandidate)>> { +pub(crate) fn decode( + sets: &[SqlResultSet], +) -> cellule_runtime::Result, MergeCandidate)>> { let set = sets .first() .ok_or(Error::Command("missing candidate result"))?; @@ -247,3 +328,13 @@ fn certified( matches!(rows.first().and_then(|set|set.rows.first()).map(Vec::as_slice),Some([SqlValue::Blob(stored)]) if *stored == body), ) } + +pub(crate) fn intent_binding(candidate: &MergeCandidate) -> cellule_runtime::Result> { + let request = serde_json::to_string(&candidate.request) + .map_err(|_| Error::Command("candidate intent encoding"))?; + Ok(super::mutations::binding(&[ + &candidate.actor, + &candidate.number.to_string(), + &request, + ])) +} diff --git a/crates/canopy-server/src/pulls/merge/command.rs b/crates/canopy-server/src/pulls/merge/command.rs index 492b717f..706ff12b 100644 --- a/crates/canopy-server/src/pulls/merge/command.rs +++ b/crates/canopy-server/src/pulls/merge/command.rs @@ -8,7 +8,7 @@ use cellule_runtime::{ codec::WireValue, registry::CommandContext, registry::CommandResult, }; -#[derive(Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub(crate) struct MergeInput { pub actor: String, @@ -82,22 +82,7 @@ impl Command for MergePull { .map_err(|_| Error::Command("invalid merge UUID"))?; // Bind actor, parent and full intent. Command timestamps are not part of // application retry identity, so a lost reply can use a fresh command. - let binding = super::super::mutations::binding(&[ - &input.actor, - &input.number.to_string(), - &input.request.revision.pull_version.to_string(), - &input.request.revision.source_oid, - &input.request.revision.source_version.to_string(), - &input.request.revision.base_oid, - &input.request.revision.base_version.to_string(), - match input.request.strategy { - MergeStrategy::FastForward => "fast_forward", - MergeStrategy::MergeCommit => "merge_commit", - MergeStrategy::Squash => "squash", - MergeStrategy::Rebase => "rebase", - }, - input.request.candidate_id.as_deref().unwrap_or(""), - ]); + let binding = request_binding(&input); let previous = context.sql(&SqlBatch { statements: vec![SqlStatement { sql: "SELECT binding, id, pull_number, oid, merged_ms, pull_version, source_oid, source_version, base_oid, base_version FROM pull_merges WHERE id = ?1" @@ -193,3 +178,22 @@ impl Command for MergePull { })) } } + +pub(crate) fn request_binding(input: &MergeInput) -> Vec { + super::super::mutations::binding(&[ + &input.actor, + &input.number.to_string(), + &input.request.revision.pull_version.to_string(), + &input.request.revision.source_oid, + &input.request.revision.source_version.to_string(), + &input.request.revision.base_oid, + &input.request.revision.base_version.to_string(), + match input.request.strategy { + MergeStrategy::FastForward => "fast_forward", + MergeStrategy::MergeCommit => "merge_commit", + MergeStrategy::Squash => "squash", + MergeStrategy::Rebase => "rebase", + }, + input.request.candidate_id.as_deref().unwrap_or(""), + ]) +} diff --git a/crates/canopy-server/src/pulls/merge/mod.rs b/crates/canopy-server/src/pulls/merge/mod.rs index 6e28a4df..daf982a9 100644 --- a/crates/canopy-server/src/pulls/merge/mod.rs +++ b/crates/canopy-server/src/pulls/merge/mod.rs @@ -32,7 +32,7 @@ pub struct MergeRecord { pub merged_at_ms: i64, pub revision: PullRevision, } -pub(super) fn record(row: &[SqlValue]) -> cellule_runtime::Result { +pub(crate) fn record(row: &[SqlValue]) -> cellule_runtime::Result { let [ SqlValue::Blob(id), SqlValue::Integer(number), @@ -67,7 +67,7 @@ pub struct ReviewPolicy { pub reviews_satisfied: bool, } /// The final transaction's domain outcome; only Applied moves refs. -#[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] #[serde(tag = "status", rename_all = "snake_case")] pub enum MergeOutcome { Applied { merge: MergeRecord }, @@ -85,10 +85,47 @@ pub(crate) struct ReviewedMerge { update: RefUpdate, } impl ReviewedMerge { + pub(crate) fn update(&self) -> &RefUpdate { + &self.update + } pub(crate) fn authorizes(&self, update: &RefUpdate) -> bool { self.update == *update } } +/// Called only inside the native publisher's authenticated final transaction. +/// This constructs the same exact-update capability as the original merge +/// command; client facts alone cannot satisfy the current review predicate. +pub(crate) fn reviewed_native_update( + context: &cellule_runtime::registry::CommandContext<'_, '_>, + input: &command::MergeInput, + selection: &crate::packs::publication::RefSelection, + generated: Option, +) -> cellule_runtime::Result> { + let statement = + super::native::with_refs(policy_statement(&input.actor, input.number), selection); + let Some(state) = policy_state(&context.sql(&SqlBatch { + statements: vec![statement], + })?)? + else { + return Ok(Err(MergeOutcome::NotFound)); + }; + if !state.policy.ready || state.policy.revision.as_ref() != Some(&input.request.revision) { + return Ok(Err(MergeOutcome::Conflict)); + } + if !state.policy.reviews_satisfied { + return Ok(Err(MergeOutcome::ReviewsRequired)); + } + Ok(Ok(ReviewedMerge { + update: RefUpdate { + name: state.base, + expected: Some(RefExpectation { + oid: Some(oid(&input.request.revision.base_oid)?), + version: input.request.revision.base_version, + }), + new_oid: Some(generated.unwrap_or(oid(&input.request.revision.source_oid)?)), + }, + })) +} pub(super) struct ReviewState { pub(super) policy: ReviewPolicy, base: String, @@ -117,27 +154,36 @@ pub(crate) fn valid_request(request: &MergeRequest) -> bool { } impl RepositoryCell { - /// Reads current review requirements and eligible decisions in one Cell observation. + /// Reads current review requirements against authenticated native ref facts. pub async fn pull_review_policy<'a>( &self, actor: impl Into>, number: i64, - ) -> Result>, Invocation> { - let actor = actor.into(); - actor.validate().map_err(Invocation::NotStarted)?; + ) -> Result>, super::native::NativePullError> { + let result = self.pull_review_state(actor.into(), number).await?; + Ok(Observed { + output: result.output.map(|state| state.policy), + receipt: result.receipt, + }) + } + async fn pull_review_state( + &self, + actor: ReadIdentity<'_>, + number: i64, + ) -> Result>, super::native::NativePullError> { + if number < 1 { + return Err(Error::Command("invalid pull number").into()); + } let result = self - .sql - .query( - None, - SqlBatch { - statements: vec![policy_statement(actor, number)], - }, - ) + .native_pull_rows(actor, super::native::ReadKind::ReviewPolicy(number)) .await?; Ok(Observed { - output: policy_state(&result.output) - .map_err(Invocation::NotStarted)? - .map(|state| state.policy), + output: result + .output + .as_deref() + .map(policy_state) + .transpose()? + .flatten(), receipt: result.receipt, }) } @@ -157,17 +203,11 @@ impl RepositoryCell { "invalid merge request", ))); } - let observed = self - .sql - .query( - None, - SqlBatch { - statements: vec![policy_statement(actor, number)], - }, - ) + let state = self + .pull_review_state(ReadIdentity::Account(actor), number) .await - .map_err(preparation)?; - let state = policy_state(&observed.output).map_err(InvocationError::NotStarted)?; + .map_err(preparation)? + .output; if state.is_some_and(|state| { state.writable && state.policy.ready @@ -223,12 +263,12 @@ fn preparation( source: Box::new(error), }) } -pub(super) fn oid(text: &str) -> cellule_runtime::Result { +pub(crate) fn oid(text: &str) -> cellule_runtime::Result { parse_oid(text) .and_then(|value| value.try_into().ok()) .ok_or(Error::Command("invalid merge object ID")) } -pub(super) fn policy_statement<'a>( +pub(crate) fn policy_statement<'a>( actor: impl Into>, number: i64, ) -> SqlStatement { @@ -300,3 +340,11 @@ pub(super) fn policy_state(sets: &[SqlResultSet]) -> cellule_runtime::Result cellule_runtime::Result> { + Ok(policy_state(sets)? + .map(|state| state.policy.ready && state.policy.revision.as_ref() == Some(revision))) +} diff --git a/crates/canopy-server/src/pulls/mod.rs b/crates/canopy-server/src/pulls/mod.rs index e56f7778..0504bfbc 100644 --- a/crates/canopy-server/src/pulls/mod.rs +++ b/crates/canopy-server/src/pulls/mod.rs @@ -5,6 +5,8 @@ use crate::ReadIdentity; pub mod candidates; pub mod merge; mod mutations; +pub(crate) mod native; +pub use native::NativePullError; pub(crate) mod threads; use crate::{ @@ -184,55 +186,54 @@ pub(crate) fn valid_review(input: &NewReview<'_>) -> bool { } impl RepositoryCell { - /// Lists 32 pull summaries with coherent current branches; missing access returns None. + /// A bounded coherent page from editorial SQL and privately certified native refs. pub async fn pulls<'a>( &self, actor: impl Into>, after: i64, state: Option, - ) -> Result>>, Invocation> { - let actor = actor.into(); + ) -> Result>>, NativePullError> { if after < 0 { - return Err(invalid("invalid pull cursor")); + return Err(Error::Command("invalid pull cursor").into()); } - actor.validate().map_err(Invocation::NotStarted)?; - let mut parameters = vec![actor.parameter(), SqlValue::Integer(after)]; - let filter = if let Some(state) = state { - parameters.push(SqlValue::Text(state.as_str().into())); - "AND p.state = ?3" - } else { - "" - }; - let result = self.pull_rows( - SqlStatement { sql: format!("SELECT ({ACCESS})"), parameters: vec![actor.parameter()] }, - SqlStatement { sql: format!("SELECT {COLUMNS} FROM {JOINS} WHERE p.number > ?2 {filter} AND ({ACCESS}) ORDER BY p.number LIMIT {PULL_PAGE_SIZE}"), parameters }, - ).await?; + let result = self + .native_pull_rows(actor.into(), native::ReadKind::Page { after, state }) + .await?; let output = result .output - .map(|rows| rows.iter().map(|row| summary(row)).collect()) - .transpose() - .map_err(Invocation::NotStarted)?; + .map(|sets| { + sets.first() + .ok_or(Error::Command("missing native pull page"))? + .rows + .iter() + .map(|row| summary(row)) + .collect() + }) + .transpose()?; Ok(Observed { output, receipt: result.receipt, }) } - /// Reads a pull, original tips and live ref state under current read membership. + /// Current branch facts and editorial details share the final typed observation. pub async fn pull<'a>( &self, actor: impl Into>, number: i64, - ) -> Result>, Invocation> { - let actor = actor.into(); - actor.validate().map_err(Invocation::NotStarted)?; - let result = self.sql.query(None, SqlBatch { statements: vec![SqlStatement { - sql: format!("SELECT {COLUMNS}, p.body, p.initial_source_oid, p.initial_base_oid, merged.id, merged.pull_number, merged.oid, merged.merged_ms, merged.pull_version, merged.source_oid, merged.source_version, merged.base_oid, merged.base_version FROM {JOINS} LEFT JOIN pull_merges merged ON merged.pull_number = p.number WHERE p.number = ?2 AND ({ACCESS})"), - parameters: vec![actor.parameter(), SqlValue::Integer(number)], - }] }).await?; - let rows = result - .output - .first() - .ok_or_else(|| invalid("missing pull result"))?; + ) -> Result>, NativePullError> { + if number < 1 { + return Err(Error::Command("invalid pull number").into()); + } + let result = self + .native_pull_rows(actor.into(), native::ReadKind::Detail(number)) + .await?; + let Some(sets) = result.output else { + return Ok(Observed { + output: None, + receipt: result.receipt, + }); + }; + let rows = sets.first().ok_or(Error::Command("missing pull result"))?; let output = rows .rows .first() @@ -265,33 +266,36 @@ impl RepositoryCell { }) }) .transpose() - .map_err(Invocation::NotStarted)?; + .map_err(NativePullError::Invalid)?; Ok(Observed { output, receipt: result.receipt, }) } - /// Reads 16 immutable reviews with current applicability; missing pull/access returns None. + /// Immutable reviews with applicability checked against current exact native refs. pub async fn pull_reviews<'a>( &self, actor: impl Into>, number: i64, after: i64, - ) -> Result>>, Invocation> { - let actor = actor.into(); - if after < 0 { - return Err(invalid("invalid review cursor")); + ) -> Result>>, NativePullError> { + if number < 1 || after < 0 { + return Err(Error::Command("invalid review cursor").into()); } - actor.validate().map_err(Invocation::NotStarted)?; - let result = self.pull_rows( - SqlStatement { sql: format!("SELECT ({ACCESS}) AND EXISTS (SELECT 1 FROM pull_requests WHERE number = ?2)"), parameters: vec![actor.parameter(), SqlValue::Integer(number)] }, - SqlStatement { sql: format!("SELECT r.number, r.id, r.reviewer, r.kind, r.body, r.pull_version, r.source_oid, r.source_version, r.base_oid, r.base_version, coalesce(({APPLICABLE}), 0), r.created_ms FROM pull_reviews r JOIN pull_requests p ON p.number = r.pull_number JOIN refs s ON s.name = p.source_ref JOIN refs b ON b.name = p.base_ref WHERE r.pull_number = ?2 AND r.number > ?3 AND ({ACCESS}) ORDER BY r.number LIMIT {REVIEW_PAGE_SIZE}"), parameters: vec![actor.parameter(), SqlValue::Integer(number), SqlValue::Integer(after)] }, - ).await?; + let result = self + .native_pull_rows(actor.into(), native::ReadKind::Reviews { number, after }) + .await?; let output = result .output - .map(|rows| rows.iter().map(|row| review(row)).collect()) - .transpose() - .map_err(Invocation::NotStarted)?; + .map(|sets| { + sets.first() + .ok_or(Error::Command("missing native review page"))? + .rows + .iter() + .map(|row| review(row)) + .collect() + }) + .transpose()?; Ok(Observed { output, receipt: result.receipt, diff --git a/crates/canopy-server/src/pulls/mutations.rs b/crates/canopy-server/src/pulls/mutations.rs index 8ec4eba6..a3e41ef8 100644 --- a/crates/canopy-server/src/pulls/mutations.rs +++ b/crates/canopy-server/src/pulls/mutations.rs @@ -10,49 +10,8 @@ impl RepositoryCell { identity: MutationIdentity, actor: &str, input: NewPull<'_>, - ) -> Result, Invocation> { - validate_component(actor).map_err(Invocation::NotStarted)?; - validate_repository_id(input.id).map_err(Invocation::NotStarted)?; - if !valid_new(&input) { - return Err(invalid("invalid pull creation")); - } - let mut parameters = vec![ - SqlValue::Text(actor.into()), - SqlValue::Blob(input.id.to_vec()), - SqlValue::Blob(binding(&[ - actor, - input.title, - input.body, - if input.draft { "draft" } else { "ready" }, - input.source_ref, - input.source_oid, - input.base_ref, - input.base_oid, - ])), - SqlValue::Text(input.source_ref.into()), - SqlValue::Blob( - parse_oid(input.source_oid).ok_or_else(|| invalid("invalid source OID"))?, - ), - SqlValue::Text(input.base_ref.into()), - SqlValue::Blob(parse_oid(input.base_oid).ok_or_else(|| invalid("invalid base OID"))?), - ]; - let decision = format!( - "CASE WHEN NOT ({ACCESS}) THEN 'missing' WHEN EXISTS (SELECT 1 FROM pull_requests WHERE id = ?2 AND creation_digest != ?3) THEN 'conflict' WHEN EXISTS (SELECT 1 FROM pull_requests WHERE id = ?2) THEN 'applied' WHEN NOT EXISTS (SELECT 1 FROM refs WHERE name = ?4 AND oid = ?5) OR NOT EXISTS (SELECT 1 FROM refs WHERE name = ?6 AND oid = ?7) OR ?5 = ?7 THEN 'conflict' ELSE 'applied' END" - ); - let check = SqlStatement { - sql: format!("SELECT {decision}"), - parameters: parameters.clone(), - }; - parameters.extend([ - SqlValue::Text(input.title.into()), - SqlValue::Text(input.body.into()), - SqlValue::Integer(i64::from(input.draft)), - SqlValue::Integer(identity.issued_at_ms), - ]); - self.pull_change(identity, vec![check, - SqlStatement { sql: format!("INSERT INTO pull_requests (id, creation_digest, author, title, body, state, draft, version, source_ref, initial_source_oid, base_ref, initial_base_oid, created_ms, updated_ms) SELECT ?2, ?3, ?1, ?8, ?9, 'open', ?10, 1, ?4, ?5, ?6, ?7, ?11, ?11 WHERE ({decision}) = 'applied' AND NOT EXISTS (SELECT 1 FROM pull_requests WHERE id = ?2)"), parameters }, - SqlStatement { sql: "SELECT number FROM pull_requests WHERE id = ?1".into(), parameters: vec![SqlValue::Blob(input.id.to_vec())] }, - ]).await + ) -> Result, native::NativePullError> { + self.native_create_pull(identity, actor, input).await } /// Replaces editorial fields for the author or a current repository writer. /// @@ -107,62 +66,9 @@ impl RepositoryCell { actor: &str, number: i64, input: NewReview<'_>, - ) -> Result, Invocation> { - validate_component(actor).map_err(Invocation::NotStarted)?; - validate_repository_id(input.id).map_err(Invocation::NotStarted)?; - if number < 1 || !valid_review(&input) { - return Err(invalid("invalid pull review")); - } - let revision = input.revision; - let mut parameters = vec![ - SqlValue::Text(actor.into()), - SqlValue::Integer(number), - SqlValue::Blob(input.id.to_vec()), - SqlValue::Blob(binding(&[ - actor, - input.kind.as_str(), - input.body, - &revision.pull_version.to_string(), - &revision.source_oid, - &revision.source_version.to_string(), - &revision.base_oid, - &revision.base_version.to_string(), - ])), - SqlValue::Text(input.kind.as_str().into()), - SqlValue::Integer(revision.pull_version), - SqlValue::Blob( - parse_oid(&revision.source_oid).ok_or_else(|| invalid("invalid source OID"))?, - ), - SqlValue::Integer(revision.source_version), - SqlValue::Blob( - parse_oid(&revision.base_oid).ok_or_else(|| invalid("invalid base OID"))?, - ), - SqlValue::Integer(revision.base_version), - ]; - // Retry identity precedes current revision eligibility, but never access. - // A historical retry can return its review without creating a new decision. - let decision = format!( - "CASE WHEN NOT ({ACCESS}) OR NOT EXISTS (SELECT 1 FROM pull_requests WHERE number = ?2) THEN 'missing' WHEN EXISTS (SELECT 1 FROM pull_reviews WHERE id = ?3 AND (pull_number != ?2 OR creation_digest != ?4)) THEN 'conflict' WHEN EXISTS (SELECT 1 FROM pull_reviews WHERE id = ?3) THEN 'applied' WHEN ?5 != 'comment' AND (NOT ({WRITE}) OR EXISTS (SELECT 1 FROM pull_requests WHERE number = ?2 AND author = ?1)) THEN 'forbidden' WHEN NOT EXISTS (SELECT 1 FROM {JOINS} WHERE p.number = ?2 AND p.version = ?6 AND p.state = 'open' AND (?5 = 'comment' OR p.draft = 0) AND s.oid = ?7 AND s.version = ?8 AND b.oid = ?9 AND b.version = ?10 AND s.oid != b.oid) THEN 'conflict' ELSE 'applied' END" - ); - let check = SqlStatement { - sql: format!("SELECT {decision}"), - parameters: parameters.clone(), - }; - parameters.extend([ - SqlValue::Text(input.body.into()), - SqlValue::Integer(identity.issued_at_ms), - ]); - let review_binding = parameters[3].clone(); - self.pull_change(identity, vec![check, - // Public commenters may have no grant history. Version zero cannot - // authorize an approval: only the owner or a current writer qualifies. - SqlStatement { sql: format!("INSERT INTO pull_reviews (id, creation_digest, pull_number, reviewer, membership_version, kind, body, pull_version, source_oid, source_version, base_oid, base_version, created_ms) SELECT ?3, ?4, ?2, ?1, CASE WHEN EXISTS (SELECT 1 FROM repository_identity WHERE owner = ?1) THEN 0 ELSE coalesce((SELECT version FROM membership_versions WHERE account = ?1), 0) END, ?5, ?11, ?6, ?7, ?8, ?9, ?10, ?12 WHERE ({decision}) = 'applied' AND NOT EXISTS (SELECT 1 FROM pull_reviews WHERE id = ?3)"), parameters }, - // Only an inserted or exact-bound decision can advance its reviewer's - // head. Historical retries cannot replace a newer decision; comments - // never enter this table. - SqlStatement { sql: "INSERT INTO pull_review_heads (pull_number, reviewer, review_number) SELECT pull_number, reviewer, number FROM pull_reviews WHERE id = ?1 AND creation_digest = ?2 AND pull_number = ?3 AND kind != 'comment' AND (EXISTS (SELECT 1 FROM repository_identity WHERE owner = ?4) OR EXISTS (SELECT 1 FROM repository_members WHERE account = ?4)) ON CONFLICT(pull_number, reviewer) DO UPDATE SET review_number = excluded.review_number WHERE excluded.review_number > pull_review_heads.review_number".into(), parameters: vec![SqlValue::Blob(input.id.to_vec()), review_binding, SqlValue::Integer(number), SqlValue::Text(actor.into())] }, - SqlStatement { sql: "SELECT number FROM pull_reviews WHERE id = ?1".into(), parameters: vec![SqlValue::Blob(input.id.to_vec())] }, - ]).await + ) -> Result, native::NativePullError> { + self.native_review_pull(identity, actor, number, input) + .await } pub(super) async fn pull_change( &self, @@ -190,7 +96,7 @@ pub(super) fn binding(fields: &[&str]) -> Vec { } hash.finalize().as_bytes().to_vec() } -fn change(sets: &[SqlResultSet]) -> cellule_runtime::Result { +pub(super) fn change(sets: &[SqlResultSet]) -> cellule_runtime::Result { match sets .first() .and_then(|set| set.rows.first()) @@ -210,3 +116,113 @@ fn change(sets: &[SqlResultSet]) -> cellule_runtime::Result { _ => Err(Error::Command("invalid pull mutation result")), } } + +pub(super) fn create_statements( + actor: &str, + input: NewPull<'_>, + now: i64, +) -> cellule_runtime::Result> { + let mut parameters = vec![ + SqlValue::Text(actor.into()), + SqlValue::Blob(input.id.to_vec()), + SqlValue::Blob(binding(&[ + actor, + input.title, + input.body, + if input.draft { "draft" } else { "ready" }, + input.source_ref, + input.source_oid, + input.base_ref, + input.base_oid, + ])), + SqlValue::Text(input.source_ref.into()), + SqlValue::Blob( + parse_oid(input.source_oid).ok_or_else(|| Error::Command("invalid source OID"))?, + ), + SqlValue::Text(input.base_ref.into()), + SqlValue::Blob( + parse_oid(input.base_oid).ok_or_else(|| Error::Command("invalid base OID"))?, + ), + ]; + let decision = format!( + "CASE WHEN NOT ({ACCESS}) THEN 'missing' WHEN EXISTS (SELECT 1 FROM pull_requests WHERE id = ?2 AND creation_digest != ?3) THEN 'conflict' WHEN EXISTS (SELECT 1 FROM pull_requests WHERE id = ?2) THEN 'applied' WHEN NOT EXISTS (SELECT 1 FROM refs WHERE name = ?4 AND oid = ?5) OR NOT EXISTS (SELECT 1 FROM refs WHERE name = ?6 AND oid = ?7) OR ?5 = ?7 THEN 'conflict' ELSE 'applied' END" + ); + let check = SqlStatement { + sql: format!("SELECT {decision}"), + parameters: parameters.clone(), + }; + parameters.extend([ + SqlValue::Text(input.title.into()), + SqlValue::Text(input.body.into()), + SqlValue::Integer(i64::from(input.draft)), + SqlValue::Integer(now), + ]); + Ok(vec![ + check, + SqlStatement { + sql: format!( + "INSERT INTO pull_requests (id, creation_digest, author, title, body, state, draft, version, source_ref, initial_source_oid, base_ref, initial_base_oid, created_ms, updated_ms) SELECT ?2, ?3, ?1, ?8, ?9, 'open', ?10, 1, ?4, ?5, ?6, ?7, ?11, ?11 WHERE ({decision}) = 'applied' AND NOT EXISTS (SELECT 1 FROM pull_requests WHERE id = ?2)" + ), + parameters, + }, + SqlStatement { + sql: "SELECT number FROM pull_requests WHERE id = ?1".into(), + parameters: vec![SqlValue::Blob(input.id.to_vec())], + }, + ]) +} + +pub(super) fn review_statements( + actor: &str, + number: i64, + input: NewReview<'_>, + now: i64, +) -> cellule_runtime::Result> { + let revision = input.revision; + let mut parameters = vec![ + SqlValue::Text(actor.into()), + SqlValue::Integer(number), + SqlValue::Blob(input.id.to_vec()), + SqlValue::Blob(binding(&[ + actor, + input.kind.as_str(), + input.body, + &revision.pull_version.to_string(), + &revision.source_oid, + &revision.source_version.to_string(), + &revision.base_oid, + &revision.base_version.to_string(), + ])), + SqlValue::Text(input.kind.as_str().into()), + SqlValue::Integer(revision.pull_version), + SqlValue::Blob( + parse_oid(&revision.source_oid).ok_or_else(|| Error::Command("invalid source OID"))?, + ), + SqlValue::Integer(revision.source_version), + SqlValue::Blob( + parse_oid(&revision.base_oid).ok_or_else(|| Error::Command("invalid base OID"))?, + ), + SqlValue::Integer(revision.base_version), + ]; + // Retry identity precedes current revision eligibility, but never access. + // A historical retry can return its review without creating a new decision. + let decision = format!( + "CASE WHEN NOT ({ACCESS}) OR NOT EXISTS (SELECT 1 FROM pull_requests WHERE number = ?2) THEN 'missing' WHEN EXISTS (SELECT 1 FROM pull_reviews WHERE id = ?3 AND (pull_number != ?2 OR creation_digest != ?4)) THEN 'conflict' WHEN EXISTS (SELECT 1 FROM pull_reviews WHERE id = ?3) THEN 'applied' WHEN ?5 != 'comment' AND (NOT ({WRITE}) OR EXISTS (SELECT 1 FROM pull_requests WHERE number = ?2 AND author = ?1)) THEN 'forbidden' WHEN NOT EXISTS (SELECT 1 FROM {JOINS} WHERE p.number = ?2 AND p.version = ?6 AND p.state = 'open' AND (?5 = 'comment' OR p.draft = 0) AND s.oid = ?7 AND s.version = ?8 AND b.oid = ?9 AND b.version = ?10 AND s.oid != b.oid) THEN 'conflict' ELSE 'applied' END" + ); + let check = SqlStatement { + sql: format!("SELECT {decision}"), + parameters: parameters.clone(), + }; + parameters.extend([SqlValue::Text(input.body.into()), SqlValue::Integer(now)]); + let review_binding = parameters[3].clone(); + Ok(vec![check, + // Public commenters may have no grant history. Version zero cannot + // authorize an approval: only the owner or a current writer qualifies. + SqlStatement { sql: format!("INSERT INTO pull_reviews (id, creation_digest, pull_number, reviewer, membership_version, kind, body, pull_version, source_oid, source_version, base_oid, base_version, created_ms) SELECT ?3, ?4, ?2, ?1, CASE WHEN EXISTS (SELECT 1 FROM repository_identity WHERE owner = ?1) THEN 0 ELSE coalesce((SELECT version FROM membership_versions WHERE account = ?1), 0) END, ?5, ?11, ?6, ?7, ?8, ?9, ?10, ?12 WHERE ({decision}) = 'applied' AND NOT EXISTS (SELECT 1 FROM pull_reviews WHERE id = ?3)"), parameters }, + // Only an inserted or exact-bound decision can advance its reviewer's + // head. Historical retries cannot replace a newer decision; comments + // never enter this table. + SqlStatement { sql: "INSERT INTO pull_review_heads (pull_number, reviewer, review_number) SELECT pull_number, reviewer, number FROM pull_reviews WHERE id = ?1 AND creation_digest = ?2 AND pull_number = ?3 AND kind != 'comment' AND (EXISTS (SELECT 1 FROM repository_identity WHERE owner = ?4) OR EXISTS (SELECT 1 FROM repository_members WHERE account = ?4)) ON CONFLICT(pull_number, reviewer) DO UPDATE SET review_number = excluded.review_number WHERE excluded.review_number > pull_review_heads.review_number".into(), parameters: vec![SqlValue::Blob(input.id.to_vec()), review_binding, SqlValue::Integer(number), SqlValue::Text(actor.into())] }, + SqlStatement { sql: "SELECT number FROM pull_reviews WHERE id = ?1".into(), parameters: vec![SqlValue::Blob(input.id.to_vec())] }, + ]) +} diff --git a/crates/canopy-server/src/pulls/native/client.rs b/crates/canopy-server/src/pulls/native/client.rs new file mode 100644 index 00000000..164abf9e --- /dev/null +++ b/crates/canopy-server/src/pulls/native/client.rs @@ -0,0 +1,130 @@ +use super::*; +impl RepositoryCell { + pub(crate) async fn pull_ref_selection( + &self, + actor: &str, + request: [u8; 32], + names: &[String], + ) -> Result< + ( + Option, + RefSelection, + ), + NativePullError, + > { + let access = self + .sql + .query( + None, + SqlBatch { + statements: vec![reads::access(ReadIdentity::Account(actor))], + }, + ) + .await + .map_err(|e| NativePullError::Metadata(Box::new(e)))?; + // A known denied caller still reaches the final typed command so its + // domain refusal has an original durable receipt. No proof is issued. + if !reads::allowed(&access.output)? { + return Ok(( + None, + RefSelection { + repository: self.id, + actor: Some(actor.into()), + facts: Vec::new(), + proof: None, + }, + )); + } + let snapshot = self.serving_snapshot(ReadIdentity::Account(actor)).await?; + let selection = snapshot.ref_selection(request, names).await?; + Ok((Some(snapshot), selection)) + } + + pub(in crate::pulls) async fn native_create_pull( + &self, + identity: MutationIdentity, + actor: &str, + input: NewPull<'_>, + ) -> Result, NativePullError> { + validate_component(actor)?; + validate_repository_id(input.id)?; + if !valid_new(&input) { + return Err(Error::Command("invalid pull creation").into()); + } + let data = CreateData { + id: input.id, + title: input.title.into(), + body: input.body.into(), + draft: input.draft, + source_ref: input.source_ref.into(), + source_oid: input.source_oid.into(), + base_ref: input.base_ref.into(), + base_oid: input.base_oid.into(), + }; + let mut names = vec![data.source_ref.clone(), data.base_ref.clone()]; + names.sort(); + let (_snapshot, selection) = self + .pull_ref_selection(actor, data.digest()?, &names) + .await?; + let result = self + .application + .command::(&self.target, identity, CreateRequest { selection, data }) + .await; + match result { + Ok(v) => Ok(v), + Err(InvocationError::Rejected(v)) => Ok(*v), + Err(e) => Err(NativePullError::Command(Box::new(e))), + } + } + pub(in crate::pulls) async fn native_review_pull( + &self, + identity: MutationIdentity, + actor: &str, + number: i64, + input: NewReview<'_>, + ) -> Result, NativePullError> { + validate_component(actor)?; + validate_repository_id(input.id)?; + if number < 1 || !valid_review(&input) { + return Err(Error::Command("invalid pull review").into()); + } + let data = ReviewData { + number, + id: input.id, + revision: input.revision.clone(), + kind: input.kind, + body: input.body.into(), + }; + let selected = self + .sql + .query( + None, + SqlBatch { + statements: vec![reads::selector( + ReadIdentity::Account(actor), + &ReadKind::Detail(number), + )], + }, + ) + .await + .map_err(|e| NativePullError::Metadata(Box::new(e)))?; + let rows = &selected + .output + .first() + .ok_or(Error::Command("review selection missing"))? + .rows; + let names = reads::selected_names(rows)?; + let (_snapshot, selection) = self + .pull_ref_selection(actor, data.digest()?, &names) + .await?; + let result = self + .application + .command::(&self.target, identity, ReviewRequest { selection, data }) + .await; + match result { + Ok(v) => Ok(v), + Err(InvocationError::Rejected(v)) => Ok(*v), + Err(e) => Err(NativePullError::Command(Box::new(e))), + } + } +} diff --git a/crates/canopy-server/src/pulls/native/codec.rs b/crates/canopy-server/src/pulls/native/codec.rs new file mode 100644 index 00000000..deb66584 --- /dev/null +++ b/crates/canopy-server/src/pulls/native/codec.rs @@ -0,0 +1,302 @@ +use super::*; +fn invalid() -> CodecError { + CodecError::Invalid("invalid native pull input") +} +fn fixed(d: &mut BoundedDecoder<'_>) -> Result<[u8; N], CodecError> { + d.read_bytes()?.try_into().map_err(|_| invalid()) +} +fn digest(value: &impl WireValue) -> Result<[u8; 32], CodecError> { + let mut e = BoundedEncoder::new(INPUT_BYTES)?; + value.encode(&mut e)?; + Ok(*blake3::hash(&e.finish()).as_bytes()) +} +impl CreateData { + pub(crate) fn digest(&self) -> Result<[u8; 32], CodecError> { + digest(self) + } +} +impl ReviewData { + pub(crate) fn digest(&self) -> Result<[u8; 32], CodecError> { + digest(self) + } +} +impl ReadData { + pub(crate) fn digest(&self) -> Result<[u8; 32], CodecError> { + digest(self) + } +} +impl WireValue for CreateData { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + if validate_repository_id(self.id).is_err() + || !valid_new(&self.view()) + || [self.source_ref.len(), self.base_ref.len()] + .into_iter() + .any(|n| n > crate::packs::ref_state::MAX_NAME_BYTES) + { + return Err(invalid()); + } + e.write_u8(51)?; + e.write_bytes(&self.id)?; + e.write_text(&self.title)?; + e.write_text(&self.body)?; + e.write_bool(self.draft)?; + e.write_text(&self.source_ref)?; + e.write_text(&self.source_oid)?; + e.write_text(&self.base_ref)?; + e.write_text(&self.base_oid) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + if d.read_u8()? != 51 { + return Err(invalid()); + } + let v = Self { + id: fixed(d)?, + title: d.read_text()?.into(), + body: d.read_text()?.into(), + draft: d.read_bool()?, + source_ref: d.read_text()?.into(), + source_oid: d.read_text()?.into(), + base_ref: d.read_text()?.into(), + base_oid: d.read_text()?.into(), + }; + v.digest()?; + Ok(v) + } +} +impl WireValue for PullRevision { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + e.write_i64(self.pull_version)?; + e.write_text(&self.source_oid)?; + e.write_i64(self.source_version)?; + e.write_text(&self.base_oid)?; + e.write_i64(self.base_version) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + Ok(Self { + pull_version: d.read_i64()?, + source_oid: d.read_text()?.into(), + source_version: d.read_i64()?, + base_oid: d.read_text()?.into(), + base_version: d.read_i64()?, + }) + } +} +impl WireValue for ReviewData { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + if self.number < 1 + || validate_repository_id(self.id).is_err() + || !valid_review(&self.view()) + { + return Err(invalid()); + } + e.write_u8(53)?; + e.write_i64(self.number)?; + e.write_bytes(&self.id)?; + self.revision.encode(e)?; + e.write_u8(match self.kind { + ReviewKind::Comment => 0, + ReviewKind::Approve => 1, + ReviewKind::RequestChanges => 2, + })?; + e.write_text(&self.body) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + if d.read_u8()? != 53 { + return Err(invalid()); + } + let number = d.read_i64()?; + let id = fixed(d)?; + let revision = PullRevision::decode(d)?; + let kind = match d.read_u8()? { + 0 => ReviewKind::Comment, + 1 => ReviewKind::Approve, + 2 => ReviewKind::RequestChanges, + _ => return Err(invalid()), + }; + let v = Self { + number, + id, + revision, + kind, + body: d.read_text()?.into(), + }; + v.digest()?; + Ok(v) + } +} +impl WireValue for PullState { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + e.write_u8(match self { + Self::Open => 0, + Self::Closed => 1, + Self::Merged => 2, + }) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + match d.read_u8()? { + 0 => Ok(Self::Open), + 1 => Ok(Self::Closed), + 2 => Ok(Self::Merged), + _ => Err(invalid()), + } + } +} +impl WireValue for ReadData { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + e.write_u8(52)?; + match &self.kind { + ReadKind::Page { after, state } => { + if *after < 0 { + return Err(invalid()); + } + e.write_u8(0)?; + e.write_i64(*after)?; + state.encode(e)?; + } + ReadKind::Detail(number) => { + if *number < 1 { + return Err(invalid()); + } + e.write_u8(1)?; + e.write_i64(*number)?; + } + ReadKind::ReviewPolicy(number) => { + if *number < 1 { + return Err(invalid()); + } + e.write_u8(3)?; + e.write_i64(*number)?; + } + ReadKind::Reviews { number, after } => { + if *number < 1 || *after < 0 { + return Err(invalid()); + } + e.write_u8(2)?; + e.write_i64(*number)?; + e.write_i64(*after)?; + } + } + e.write_bytes(&self.metadata) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + if d.read_u8()? != 52 { + return Err(invalid()); + } + let kind = match d.read_u8()? { + 0 => ReadKind::Page { + after: d.read_i64()?, + state: Option::::decode(d)?, + }, + 1 => ReadKind::Detail(d.read_i64()?), + 3 => ReadKind::ReviewPolicy(d.read_i64()?), + 2 => ReadKind::Reviews { + number: d.read_i64()?, + after: d.read_i64()?, + }, + _ => return Err(invalid()), + }; + let v = Self { + kind, + metadata: fixed(d)?, + }; + v.digest()?; + Ok(v) + } +} +impl WireValue for CreateRequest { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + if self.selection.actor.is_none() { + return Err(invalid()); + } + self.selection.encode(e)?; + self.data.encode(e) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + let v = Self { + selection: RefSelection::decode(d)?, + data: CreateData::decode(d)?, + }; + if v.selection.actor.is_none() { + return Err(invalid()); + } + Ok(v) + } +} +impl WireValue for ReviewRequest { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + if self.selection.actor.is_none() { + return Err(invalid()); + } + self.selection.encode(e)?; + self.data.encode(e) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + let v = Self { + selection: RefSelection::decode(d)?, + data: ReviewData::decode(d)?, + }; + if v.selection.actor.is_none() { + return Err(invalid()); + } + Ok(v) + } +} +impl WireValue for ReadRequest { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + self.selection.encode(e)?; + self.data.encode(e) + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + Ok(Self { + selection: RefSelection::decode(d)?, + data: ReadData::decode(d)?, + }) + } +} +impl WireValue for ReadReply { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + match self { + Self::Changed => e.write_u8(0), + Self::Rows(rows) => { + e.write_u8(1)?; + rows.encode(e) + } + } + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + match d.read_u8()? { + 0 => Ok(Self::Changed), + 1 => Ok(Self::Rows(Option::>::decode(d)?)), + _ => Err(invalid()), + } + } +} +impl WireValue for PullChange { + fn encode(&self, e: &mut BoundedEncoder) -> Result<(), CodecError> { + match self { + Self::Applied(n) if *n > 0 => { + e.write_u8(0)?; + e.write_i64(*n) + } + Self::NotFound => e.write_u8(1), + Self::Forbidden => e.write_u8(2), + Self::Conflict => e.write_u8(3), + _ => Err(invalid()), + } + } + fn decode(d: &mut BoundedDecoder<'_>) -> Result { + match d.read_u8()? { + 0 => { + let n = d.read_i64()?; + if n < 1 { + return Err(invalid()); + } + Ok(Self::Applied(n)) + } + 1 => Ok(Self::NotFound), + 2 => Ok(Self::Forbidden), + 3 => Ok(Self::Conflict), + _ => Err(invalid()), + } + } +} diff --git a/crates/canopy-server/src/pulls/native/mod.rs b/crates/canopy-server/src/pulls/native/mod.rs new file mode 100644 index 00000000..033e7caf --- /dev/null +++ b/crates/canopy-server/src/pulls/native/mod.rs @@ -0,0 +1,217 @@ +//! Native ref facts authorize editorial SQL inside one final typed transaction. +use super::*; +use crate::{ + RepositoryModule, + packs::publication::{REF_SELECTION_BYTES, RefSelection}, +}; +use cellule_runtime::codec::{BoundedDecoder, BoundedEncoder, CodecError, WireValue}; +use cellule_runtime::{ + CellModule, Command, Query, + registry::{CommandContext, CommandResult, QueryContext}, +}; +mod codec; +mod reads; +pub(crate) use reads::{ReadData, ReadKind, ReadNativePulls, ReadReply, ReadRequest}; +mod client; +pub(crate) mod threads; +pub(crate) use threads::CreateNativeThread; + +pub(crate) const INPUT_BYTES: u32 = REF_SELECTION_BYTES + (256 << 10); +pub(crate) const OUTPUT_BYTES: u32 = 1 << 20; +#[derive(Debug, thiserror::Error)] +pub enum NativePullError { + #[error("invalid native pull request")] + Invalid(#[from] Error), + #[error("native pull codec failed")] + Codec(#[from] CodecError), + #[error("native pull snapshot unavailable")] + Owner(#[from] crate::packs::publication::ServingOwnerError), + #[error("native pull ref selection failed")] + Serving(#[from] crate::packs::publication::ServingReadError), + #[error("native pull metadata selection failed")] + Metadata(#[source] Box), + #[error("native pull read failed")] + Read(#[source] Box), + #[error("native pull command failed")] + Command(#[source] Box>), + #[error("native pull refs or editorial selection changed")] + Changed, +} +#[derive(Clone, Debug)] +pub(crate) struct CreateData { + pub(crate) id: [u8; 16], + pub(crate) title: String, + pub(crate) body: String, + pub(crate) draft: bool, + pub(crate) source_ref: String, + pub(crate) source_oid: String, + pub(crate) base_ref: String, + pub(crate) base_oid: String, +} +impl CreateData { + pub(crate) fn view(&self) -> NewPull<'_> { + NewPull { + id: self.id, + title: &self.title, + body: &self.body, + draft: self.draft, + source_ref: &self.source_ref, + source_oid: &self.source_oid, + base_ref: &self.base_ref, + base_oid: &self.base_oid, + } + } +} +#[derive(Clone, Debug)] +pub(crate) struct ReviewData { + pub(crate) number: i64, + pub(crate) id: [u8; 16], + pub(crate) revision: PullRevision, + pub(crate) kind: ReviewKind, + pub(crate) body: String, +} +impl ReviewData { + pub(crate) fn view(&self) -> NewReview<'_> { + NewReview { + id: self.id, + revision: &self.revision, + kind: self.kind, + body: &self.body, + } + } +} +#[derive(Clone, Debug)] +pub(crate) struct CreateRequest { + pub(crate) selection: RefSelection, + pub(crate) data: CreateData, +} +#[derive(Clone, Debug)] +pub(crate) struct ReviewRequest { + pub(crate) selection: RefSelection, + pub(crate) data: ReviewData, +} + +/// Shadow the retired table only within this statement with authenticated facts. +/// Names/OIDs/versions are bound parameters, never interpolated client SQL. +pub(crate) fn with_refs(mut statement: SqlStatement, selection: &RefSelection) -> SqlStatement { + if !statement.sql.contains("FROM refs ") && !statement.sql.contains("JOIN refs ") { + return statement; + } + let mut values = Vec::with_capacity(selection.facts.len()); + for fact in &selection.facts { + let n = statement.parameters.len() + 1; + values.push(format!("(?{n},?{},?{})", n + 1, n + 2)); + statement.parameters.extend([ + SqlValue::Text(fact.name.clone()), + fact.state + .as_ref() + .and_then(|s| s.oid) + .map_or(SqlValue::Null, |o| SqlValue::Blob(o.to_vec())), + SqlValue::Integer(fact.state.as_ref().map_or(0, |s| s.version)), + ]); + } + let refs = if values.is_empty() { + "SELECT NULL,NULL,0 WHERE 0".into() + } else { + format!("VALUES {}", values.join(",")) + }; + statement.sql = format!("WITH refs(name,oid,version) AS ({refs}) {}", statement.sql); + statement +} +fn transaction( + context: &mut CommandContext<'_, '_>, + selection: &RefSelection, + statements: Vec, +) -> cellule_runtime::Result> { + let statements = statements + .into_iter() + .map(|s| with_refs(s, selection)) + .collect(); + let change = mutations::change(&context.sql(&SqlBatch { statements })?)?; + Ok(match change { + PullChange::Applied(_) => CommandResult::Success(change), + _ => CommandResult::Rejected(change), + }) +} +fn denial( + context: &mut CommandContext<'_, '_>, + selection: &RefSelection, +) -> cellule_runtime::Result> { + let actor = selection + .actor + .as_deref() + .map_or(ReadIdentity::Anonymous, ReadIdentity::Account); + let permitted = reads::allowed(&context.sql(&SqlBatch { + statements: vec![reads::access(actor)], + })?)?; + Ok(CommandResult::Rejected(if permitted { + PullChange::Conflict + } else { + PullChange::NotFound + })) +} +pub(crate) struct CreateNativePull; +impl Command for CreateNativePull { + const MODULE: &'static str = RepositoryModule::NAME; + const ID: u32 = 51; + const CODEC_VERSION: u32 = 1; + type Input = CreateRequest; + type Output = PullChange; + fn execute( + context: &mut CommandContext<'_, '_>, + input: Self::Input, + ) -> cellule_runtime::Result> { + input.encode(&mut BoundedEncoder::new(INPUT_BYTES)?)?; + if !input.selection.authorized( + context.target().cell_id(), + Some(context.owner_fence()), + context.now_ms(), + input.data.digest()?, + |q| context.sql(q), + )? { + return denial(context, &input.selection); + } + let actor = input + .selection + .actor + .as_deref() + .ok_or(Error::Command("pull author missing"))?; + let statements = mutations::create_statements(actor, input.data.view(), context.now_ms())?; + transaction(context, &input.selection, statements) + } +} +pub(crate) struct ReviewNativePull; +impl Command for ReviewNativePull { + const MODULE: &'static str = RepositoryModule::NAME; + const ID: u32 = 53; + const CODEC_VERSION: u32 = 1; + type Input = ReviewRequest; + type Output = PullChange; + fn execute( + context: &mut CommandContext<'_, '_>, + input: Self::Input, + ) -> cellule_runtime::Result> { + input.encode(&mut BoundedEncoder::new(INPUT_BYTES)?)?; + if !input.selection.authorized( + context.target().cell_id(), + Some(context.owner_fence()), + context.now_ms(), + input.data.digest()?, + |q| context.sql(q), + )? { + return denial(context, &input.selection); + } + let actor = input + .selection + .actor + .as_deref() + .ok_or(Error::Command("reviewer missing"))?; + let statements = mutations::review_statements( + actor, + input.data.number, + input.data.view(), + context.now_ms(), + )?; + transaction(context, &input.selection, statements) + } +} diff --git a/crates/canopy-server/src/pulls/native/reads.rs b/crates/canopy-server/src/pulls/native/reads.rs new file mode 100644 index 00000000..2366fded --- /dev/null +++ b/crates/canopy-server/src/pulls/native/reads.rs @@ -0,0 +1,283 @@ +use super::*; +#[derive(Clone, Debug)] +pub(crate) enum ReadKind { + Page { + after: i64, + state: Option, + }, + Detail(i64), + ReviewPolicy(i64), + Reviews { + number: i64, + after: i64, + }, +} +#[derive(Clone, Debug)] +pub(crate) struct ReadData { + pub(crate) kind: ReadKind, + pub(crate) metadata: [u8; 32], +} +impl ReadData { + /// Bind the bounded editorial selection before privately observing its refs. + pub(crate) fn selected( + kind: ReadKind, + rows: &[Vec], + ) -> cellule_runtime::Result<(Self, Vec)> { + Ok(( + Self { + kind, + metadata: row_binding(rows)?, + }, + selected_names(rows)?, + )) + } +} +#[derive(Clone, Debug)] +pub(crate) struct ReadRequest { + pub(crate) selection: RefSelection, + pub(crate) data: ReadData, +} +#[derive(Debug)] +pub(crate) enum ReadReply { + Changed, + Rows(Option>), +} + +pub(super) fn selector(actor: ReadIdentity<'_>, kind: &ReadKind) -> SqlStatement { + let (filter, mut parameters) = match kind { + ReadKind::Page { after, state } => { + let mut p = vec![actor.parameter(), SqlValue::Integer(*after)]; + let extra = if let Some(state) = state { + p.push(SqlValue::Text(state.as_str().into())); + " AND p.state=?3" + } else { + "" + }; + (format!("p.number>?2{extra}"), p) + } + ReadKind::Detail(number) + | ReadKind::ReviewPolicy(number) + | ReadKind::Reviews { number, .. } => ( + "p.number=?2".into(), + vec![actor.parameter(), SqlValue::Integer(*number)], + ), + }; + SqlStatement { + sql: format!( + "SELECT p.number,p.version,p.source_ref,p.base_ref FROM pull_requests p WHERE {filter} AND ({ACCESS}) ORDER BY p.number LIMIT {PULL_PAGE_SIZE}" + ), + parameters: std::mem::take(&mut parameters), + } +} +fn row_binding(rows: &[Vec]) -> cellule_runtime::Result<[u8; 32]> { + if rows.len() > PULL_PAGE_SIZE { + return Err(Error::Command("pull selection count")); + } + let mut h = blake3::Hasher::new(); + h.update(b"canopy.pull-metadata-selection.v1\0"); + h.update(&(rows.len() as u64).to_le_bytes()); + for row in rows { + let [ + SqlValue::Integer(number), + SqlValue::Integer(version), + SqlValue::Text(source), + SqlValue::Text(base), + ] = row.as_slice() + else { + return Err(Error::Command("pull selection shape")); + }; + if *number < 1 + || *version < 1 + || !valid_default_branch(source) + || !valid_default_branch(base) + { + return Err(Error::Command("pull selection fields")); + } + h.update(&number.to_le_bytes()); + h.update(&version.to_le_bytes()); + for s in [source, base] { + h.update(&(s.len() as u64).to_le_bytes()); + h.update(s.as_bytes()); + } + } + Ok(*h.finalize().as_bytes()) +} +pub(super) fn selected_names(rows: &[Vec]) -> cellule_runtime::Result> { + row_binding(rows)?; + let names: std::collections::BTreeSet<_> = rows + .iter() + .flat_map(|r| r[2..4].iter()) + .map(|v| { + if let SqlValue::Text(s) = v { + Ok(s.clone()) + } else { + Err(Error::Command("pull ref name")) + } + }) + .collect::>()?; + let names: Vec<_> = names.into_iter().collect(); + if names.iter().map(String::len).sum::() > 512 << 10 { + return Err(Error::Capacity("pull ref selection bytes")); + } + Ok(names) +} +pub(super) fn access(actor: ReadIdentity<'_>) -> SqlStatement { + SqlStatement { + sql: format!("SELECT ({ACCESS})"), + parameters: vec![actor.parameter()], + } +} +pub(super) fn allowed(sets: &[SqlResultSet]) -> cellule_runtime::Result { + match sets.first().and_then(|s| s.rows.first()).map(Vec::as_slice) { + Some([SqlValue::Integer(0)]) => Ok(false), + Some([SqlValue::Integer(1)]) => Ok(true), + _ => Err(Error::Command("invalid native pull access")), + } +} +pub(crate) struct ReadNativePulls; +impl Query for ReadNativePulls { + const MODULE: &'static str = RepositoryModule::NAME; + const ID: u32 = 52; + const CODEC_VERSION: u32 = 2; + type Input = ReadRequest; + type Output = ReadReply; + fn execute( + context: &mut QueryContext<'_>, + input: Self::Input, + ) -> cellule_runtime::Result { + input.encode(&mut BoundedEncoder::new(INPUT_BYTES)?)?; + let actor = input + .selection + .actor + .as_deref() + .map_or(ReadIdentity::Anonymous, ReadIdentity::Account); + if !allowed(&context.sql(&SqlBatch { + statements: vec![access(actor)], + })?)? { + return Ok(ReadReply::Rows(None)); + } + if !input.selection.authorized( + context.cell_id(), + None, + context.now_ms(), + input.data.digest()?, + |q| context.sql(q), + )? { + return Ok(ReadReply::Changed); + } + let selected = context.sql(&SqlBatch { + statements: vec![selector(actor, &input.data.kind)], + })?; + let rows = &selected + .first() + .ok_or(Error::Command("pull selection absent"))? + .rows; + if row_binding(rows)? != input.data.metadata + || selected_names(rows)? + != input + .selection + .facts + .iter() + .map(|f| f.name.clone()) + .collect::>() + { + return Ok(ReadReply::Changed); + } + let query = match input.data.kind { + ReadKind::Page { after, state } => { + let mut parameters = vec![actor.parameter(), SqlValue::Integer(after)]; + let filter = if let Some(state) = state { + parameters.push(SqlValue::Text(state.as_str().into())); + "AND p.state=?3" + } else { + "" + }; + SqlStatement { + sql: format!( + "SELECT {COLUMNS} FROM {JOINS} WHERE p.number>?2 {filter} AND ({ACCESS}) ORDER BY p.number LIMIT {PULL_PAGE_SIZE}" + ), + parameters, + } + } + ReadKind::Detail(number) => SqlStatement { + sql: format!( + "SELECT {COLUMNS},p.body,p.initial_source_oid,p.initial_base_oid,merged.id,merged.pull_number,merged.oid,merged.merged_ms,merged.pull_version,merged.source_oid,merged.source_version,merged.base_oid,merged.base_version FROM {JOINS} LEFT JOIN pull_merges merged ON merged.pull_number=p.number WHERE p.number=?2 AND ({ACCESS})" + ), + parameters: vec![actor.parameter(), SqlValue::Integer(number)], + }, + ReadKind::ReviewPolicy(number) => super::super::merge::policy_statement(actor, number), + ReadKind::Reviews { number, after } => { + if rows.is_empty() { + return Ok(ReadReply::Rows(None)); + } + SqlStatement { + sql: format!( + "SELECT r.number,r.id,r.reviewer,r.kind,r.body,r.pull_version,r.source_oid,r.source_version,r.base_oid,r.base_version,coalesce(({APPLICABLE}),0),r.created_ms FROM pull_reviews r JOIN pull_requests p ON p.number=r.pull_number JOIN refs s ON s.name=p.source_ref JOIN refs b ON b.name=p.base_ref WHERE r.pull_number=?2 AND r.number>?3 AND ({ACCESS}) ORDER BY r.number LIMIT {REVIEW_PAGE_SIZE}" + ), + parameters: vec![ + actor.parameter(), + SqlValue::Integer(number), + SqlValue::Integer(after), + ], + } + } + }; + Ok(ReadReply::Rows(Some(context.sql(&SqlBatch { + statements: vec![with_refs(query, &input.selection)], + })?))) + } +} +impl RepositoryCell { + pub(in crate::pulls) async fn native_pull_rows( + &self, + actor: ReadIdentity<'_>, + kind: ReadKind, + ) -> Result>>, NativePullError> { + actor.validate()?; + for _ in 0..3 { + let selected = self + .sql + .query( + None, + SqlBatch { + statements: vec![access(actor), selector(actor, &kind)], + }, + ) + .await + .map_err(|e| NativePullError::Metadata(Box::new(e)))?; + if !allowed(&selected.output)? { + return Ok(Observed { + output: None, + receipt: selected.receipt, + }); + } + let rows = &selected + .output + .get(1) + .ok_or(Error::Command("native pull selection absent"))? + .rows; + let (data, names) = ReadData::selected(kind.clone(), rows)?; + let snapshot = self.serving_snapshot(actor).await?; + let selection = snapshot.ref_selection(data.digest()?, &names).await?; + let result = self + .application + .query::( + &self.target, + Some(selected.receipt), + ReadRequest { selection, data }, + ) + .await + .map_err(|e| NativePullError::Read(Box::new(e)))?; + match result.output { + ReadReply::Changed => continue, + ReadReply::Rows(output) => { + return Ok(Observed { + output, + receipt: result.receipt, + }); + } + } + } + Err(NativePullError::Changed) + } +} diff --git a/crates/canopy-server/src/pulls/native/threads.rs b/crates/canopy-server/src/pulls/native/threads.rs new file mode 100644 index 00000000..456fa35a --- /dev/null +++ b/crates/canopy-server/src/pulls/native/threads.rs @@ -0,0 +1,177 @@ +//! Verified line anchors are purpose-bound to native ref facts at publication. +use super::*; +use crate::git_read::{ComparisonTarget, patch::LineAnchor}; +use crate::pulls::threads::ThreadIntent; + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ThreadData { + pub(crate) number: i64, + pub(crate) intent: ThreadIntent, + pub(crate) anchor: LineAnchor, +} +impl ThreadData { + pub(crate) fn digest(&self) -> Result<[u8; 32], CodecError> { + let mut encoder = BoundedEncoder::new(INPUT_BYTES)?; + self.encode(&mut encoder)?; + Ok(*blake3::hash(&encoder.finish()).as_bytes()) + } +} +impl WireValue for ThreadData { + fn encode(&self, encoder: &mut BoundedEncoder) -> Result<(), CodecError> { + let invalid = || CodecError::Invalid("invalid verified thread"); + if !(1..=20_000).contains(&self.intent.line) + || validate_repository_id(self.intent.id).is_err() + || self.anchor.revision.pull_version < 1 + || self.anchor.revision.source_version < 1 + || self.anchor.revision.base_version < 1 + || parse_oid(&self.anchor.revision.source_oid).is_none() + || parse_oid(&self.anchor.revision.base_oid).is_none() + || match &self.intent.target { + ComparisonTarget::Current { revision } => revision != &self.anchor.revision, + ComparisonTarget::Review { number } => *number < 1, + ComparisonTarget::Merged {} => false, + ComparisonTarget::Thread { .. } => true, + } + { + return Err(invalid()); + } + // Reuse the final statement validator for anchor/intent consistency. + crate::pulls::threads::creation_statements( + "validated", + self.number, + &self.intent, + &self.anchor, + 0, + ) + .map_err(|_| invalid())?; + let bytes = serde_json::to_vec(self).map_err(|_| invalid())?; + if bytes.len() > 256 << 10 { + return Err(invalid()); + } + encoder.write_u8(56)?; + encoder.write_bytes(&bytes) + } + fn decode(decoder: &mut BoundedDecoder<'_>) -> Result { + if decoder.read_u8()? != 56 { + return Err(CodecError::Invalid("invalid verified thread")); + } + let bytes = decoder.read_bytes()?; + if bytes.len() > 256 << 10 { + return Err(CodecError::Invalid("oversized verified thread")); + } + let data: Self = serde_json::from_slice(bytes) + .map_err(|_| CodecError::Invalid("invalid verified thread"))?; + data.digest()?; + Ok(data) + } +} +pub(crate) struct ThreadRequest { + pub(crate) selection: RefSelection, + pub(crate) data: ThreadData, +} +impl WireValue for ThreadRequest { + fn encode(&self, encoder: &mut BoundedEncoder) -> Result<(), CodecError> { + if self.selection.actor.is_none() { + return Err(CodecError::Invalid("thread actor missing")); + } + self.data.encode(encoder)?; + self.selection.encode(encoder) + } + fn decode(decoder: &mut BoundedDecoder<'_>) -> Result { + let value = Self { + data: ThreadData::decode(decoder)?, + selection: RefSelection::decode(decoder)?, + }; + value.encode(&mut BoundedEncoder::new(INPUT_BYTES)?)?; + Ok(value) + } +} +pub(crate) struct CreateNativeThread; +impl Command for CreateNativeThread { + const MODULE: &'static str = RepositoryModule::NAME; + const ID: u32 = 56; + const CODEC_VERSION: u32 = 1; + type Input = ThreadRequest; + type Output = PullChange; + fn execute( + context: &mut CommandContext<'_, '_>, + input: Self::Input, + ) -> cellule_runtime::Result> { + input.encode(&mut BoundedEncoder::new(INPUT_BYTES)?)?; + if !input.selection.authorized( + context.target().cell_id(), + Some(context.owner_fence()), + context.now_ms(), + input.data.digest()?, + |q| context.sql(q), + )? { + return denial(context, &input.selection); + } + let actor = input + .selection + .actor + .as_deref() + .ok_or(Error::Command("thread actor missing"))?; + let statements = crate::pulls::threads::creation_statements( + actor, + input.data.number, + &input.data.intent, + &input.data.anchor, + context.now_ms(), + )?; + transaction(context, &input.selection, statements) + } +} +impl RepositoryCell { + pub(in crate::pulls) async fn native_create_thread( + &self, + identity: MutationIdentity, + actor: &str, + number: i64, + intent: ThreadIntent, + anchor: LineAnchor, + ) -> Result, NativePullError> { + validate_component(actor)?; + let data = ThreadData { + number, + intent, + anchor, + }; + let digest = data.digest()?; + let selected = self + .sql + .query( + None, + SqlBatch { + statements: vec![reads::selector( + ReadIdentity::Account(actor), + &ReadKind::Detail(number), + )], + }, + ) + .await + .map_err(|e| NativePullError::Metadata(Box::new(e)))?; + let rows = &selected + .output + .first() + .ok_or(Error::Command("thread selection missing"))? + .rows; + let names = reads::selected_names(rows)?; + // Keep the admitted snapshot alive until the owner transaction resolves. + let (_snapshot, selection) = self.pull_ref_selection(actor, digest, &names).await?; + match self + .application + .command::( + &self.target, + identity, + ThreadRequest { selection, data }, + ) + .await + { + Ok(v) => Ok(v), + Err(InvocationError::Rejected(v)) => Ok(*v), + Err(e) => Err(NativePullError::Command(Box::new(e))), + } + } +} diff --git a/crates/canopy-server/src/pulls/threads.rs b/crates/canopy-server/src/pulls/threads.rs index 3e461fea..2fd5332e 100644 --- a/crates/canopy-server/src/pulls/threads.rs +++ b/crates/canopy-server/src/pulls/threads.rs @@ -8,6 +8,8 @@ use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; pub(crate) const PAGE: usize = 16; const THREAD_COLUMNS: &str = "number, id, author, body, resolved, version, created_ms, updated_ms, pull_version, source_oid, source_version, base_oid, base_version, merge_base, path, side, line, blob_oid"; +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] pub(crate) struct ThreadIntent { pub id: [u8; 16], pub target: ComparisonTarget, @@ -157,69 +159,11 @@ impl RepositoryCell { identity: MutationIdentity, actor: &str, pull: i64, - input: &ThreadIntent, + input: ThreadIntent, anchor: LineAnchor, - ) -> Result, Invocation> { - validate_component(actor).map_err(Invocation::NotStarted)?; - validate_repository_id(input.id).map_err(Invocation::NotStarted)?; - if pull < 1 - || !valid_body(&input.body) - || input.body.trim().is_empty() - || input.path_base64 != anchor.path_base64 - || input.side != anchor.side - || input.line != anchor.line - { - return Err(invalid("thread intent differs from verified anchor")); - } - let r = &anchor.revision; - let mut parameters = vec![ - SqlValue::Text(actor.into()), - SqlValue::Integer(pull), - SqlValue::Blob(input.id.to_vec()), - SqlValue::Blob(input.digest(actor, pull)), - SqlValue::Integer(r.pull_version), - SqlValue::Blob( - parse_oid(&r.source_oid).ok_or_else(|| invalid("invalid thread source"))?, - ), - SqlValue::Integer(r.source_version), - SqlValue::Blob(parse_oid(&r.base_oid).ok_or_else(|| invalid("invalid thread base"))?), - SqlValue::Integer(r.base_version), - ]; - let eligible = match &input.target { - ComparisonTarget::Current { .. } => format!("EXISTS (SELECT 1 FROM {JOINS} WHERE p.number = ?2 AND p.version = ?5 AND s.oid = ?6 AND s.version = ?7 AND b.oid = ?8 AND b.version = ?9)"), - ComparisonTarget::Review { number } => { parameters.push(SqlValue::Integer(*number)); "EXISTS (SELECT 1 FROM pull_reviews WHERE pull_number = ?2 AND number = ?10 AND pull_version = ?5 AND source_oid = ?6 AND source_version = ?7 AND base_oid = ?8 AND base_version = ?9)".into() }, - ComparisonTarget::Merged {} => "EXISTS (SELECT 1 FROM pull_merges WHERE pull_number = ?2 AND pull_version = ?5 AND source_oid = ?6 AND source_version = ?7 AND base_oid = ?8 AND base_version = ?9)".into(), - ComparisonTarget::Thread { .. } => return Err(invalid("threads are not creation targets")), - }; - let decision = format!( - "CASE WHEN NOT ({ACCESS}) OR NOT EXISTS (SELECT 1 FROM pull_requests WHERE number = ?2) THEN 'missing' WHEN EXISTS (SELECT 1 FROM pull_threads WHERE id = ?3 AND (pull_number != ?2 OR creation_digest != ?4)) THEN 'conflict' WHEN EXISTS (SELECT 1 FROM pull_threads WHERE id = ?3) THEN 'applied' WHEN NOT ({eligible}) THEN 'conflict' ELSE 'applied' END" - ); - let check = SqlStatement { - sql: format!("SELECT {decision}"), - parameters: parameters.clone(), - }; - parameters.resize(10, SqlValue::Null); - parameters.extend([ - SqlValue::Text(input.body.clone()), - SqlValue::Blob( - parse_oid(&anchor.merge_base) - .ok_or_else(|| invalid("invalid thread merge base"))?, - ), - SqlValue::Blob( - URL_SAFE_NO_PAD - .decode(&anchor.path_base64) - .map_err(|_| invalid("invalid verified path"))?, - ), - SqlValue::Text(anchor.side.as_str().into()), - SqlValue::Integer(anchor.line), - SqlValue::Blob( - parse_oid(&anchor.blob_oid).ok_or_else(|| invalid("invalid thread blob"))?, - ), - SqlValue::Integer(identity.issued_at_ms), - ]); - self.pull_change(identity, vec![check, SqlStatement { - sql: format!("INSERT INTO pull_threads (id, creation_digest, pull_number, author, body, resolved, version, pull_version, source_oid, source_version, base_oid, base_version, merge_base, path, side, line, blob_oid, created_ms, updated_ms) SELECT ?3, ?4, ?2, ?1, ?11, 0, 1, ?5, ?6, ?7, ?8, ?9, ?12, ?13, ?14, ?15, ?16, ?17, ?17 WHERE ({decision}) = 'applied' AND NOT EXISTS (SELECT 1 FROM pull_threads WHERE id = ?3)"), parameters, - }, SqlStatement { sql: "SELECT number FROM pull_threads WHERE id = ?1".into(), parameters: vec![SqlValue::Blob(input.id.to_vec())] }]).await + ) -> Result, native::NativePullError> { + self.native_create_thread(identity, actor, pull, input, anchor) + .await } pub(crate) async fn resolve_thread( &self, @@ -394,3 +338,77 @@ fn comment(row: &[SqlValue]) -> cellule_runtime::Result { created_at_ms: *created, }) } + +pub(in crate::pulls) fn creation_statements( + actor: &str, + pull: i64, + input: &ThreadIntent, + anchor: &LineAnchor, + now_ms: i64, +) -> cellule_runtime::Result> { + validate_component(actor)?; + validate_repository_id(input.id)?; + if pull < 1 + || !valid_body(&input.body) + || input.body.trim().is_empty() + || input.path_base64 != anchor.path_base64 + || input.side != anchor.side + || input.line != anchor.line + { + return Err(Error::Command("thread intent differs from verified anchor")); + } + let r = &anchor.revision; + let mut parameters = vec![ + SqlValue::Text(actor.into()), + SqlValue::Integer(pull), + SqlValue::Blob(input.id.to_vec()), + SqlValue::Blob(input.digest(actor, pull)), + SqlValue::Integer(r.pull_version), + SqlValue::Blob(parse_oid(&r.source_oid).ok_or(Error::Command("invalid thread source"))?), + SqlValue::Integer(r.source_version), + SqlValue::Blob(parse_oid(&r.base_oid).ok_or(Error::Command("invalid thread base"))?), + SqlValue::Integer(r.base_version), + ]; + let eligible = match &input.target { + ComparisonTarget::Current { .. } => format!("EXISTS (SELECT 1 FROM {JOINS} WHERE p.number = ?2 AND p.version = ?5 AND s.oid = ?6 AND s.version = ?7 AND b.oid = ?8 AND b.version = ?9)"), + ComparisonTarget::Review { number } => { parameters.push(SqlValue::Integer(*number)); "EXISTS (SELECT 1 FROM pull_reviews WHERE pull_number = ?2 AND number = ?10 AND pull_version = ?5 AND source_oid = ?6 AND source_version = ?7 AND base_oid = ?8 AND base_version = ?9)".into() }, + ComparisonTarget::Merged {} => "EXISTS (SELECT 1 FROM pull_merges WHERE pull_number = ?2 AND pull_version = ?5 AND source_oid = ?6 AND source_version = ?7 AND base_oid = ?8 AND base_version = ?9)".into(), + ComparisonTarget::Thread { .. } => return Err(Error::Command("threads are not creation targets")), + }; + let decision = format!( + "CASE WHEN NOT ({ACCESS}) OR NOT EXISTS (SELECT 1 FROM pull_requests WHERE number = ?2) THEN 'missing' WHEN EXISTS (SELECT 1 FROM pull_threads WHERE id = ?3 AND (pull_number != ?2 OR creation_digest != ?4)) THEN 'conflict' WHEN EXISTS (SELECT 1 FROM pull_threads WHERE id = ?3) THEN 'applied' WHEN NOT ({eligible}) THEN 'conflict' ELSE 'applied' END" + ); + let check = SqlStatement { + sql: format!("SELECT {decision}"), + parameters: parameters.clone(), + }; + parameters.resize(10, SqlValue::Null); + parameters.extend([ + SqlValue::Text(input.body.clone()), + SqlValue::Blob( + parse_oid(&anchor.merge_base).ok_or(Error::Command("invalid thread merge base"))?, + ), + SqlValue::Blob( + URL_SAFE_NO_PAD + .decode(&anchor.path_base64) + .map_err(|_| Error::Command("invalid verified path"))?, + ), + SqlValue::Text(anchor.side.as_str().into()), + SqlValue::Integer(anchor.line), + SqlValue::Blob(parse_oid(&anchor.blob_oid).ok_or(Error::Command("invalid thread blob"))?), + SqlValue::Integer(now_ms), + ]); + Ok(vec![ + check, + SqlStatement { + sql: format!( + "INSERT INTO pull_threads (id, creation_digest, pull_number, author, body, resolved, version, pull_version, source_oid, source_version, base_oid, base_version, merge_base, path, side, line, blob_oid, created_ms, updated_ms) SELECT ?3, ?4, ?2, ?1, ?11, 0, 1, ?5, ?6, ?7, ?8, ?9, ?12, ?13, ?14, ?15, ?16, ?17, ?17 WHERE ({decision}) = 'applied' AND NOT EXISTS (SELECT 1 FROM pull_threads WHERE id = ?3)" + ), + parameters, + }, + SqlStatement { + sql: "SELECT number FROM pull_threads WHERE id = ?1".into(), + parameters: vec![SqlValue::Blob(input.id.to_vec())], + }, + ]) +} diff --git a/crates/canopy-server/src/push/certificate.rs b/crates/canopy-server/src/push/certificate.rs index e5ee3327..618cb9b0 100644 --- a/crates/canopy-server/src/push/certificate.rs +++ b/crates/canopy-server/src/push/certificate.rs @@ -1,5 +1,4 @@ use super::*; -use sha2::{Digest as _, Sha256}; /// Metadata for a verified push certificate retained with its push. #[derive(Debug, serde::Serialize)] @@ -19,13 +18,6 @@ pub struct VerifiedPushCertificate { pub(crate) key: String, } -pub(super) struct CertificateMeta { - pub digest: [u8; 32], - pub size: i64, - pub signer: String, - pub key: String, -} - impl RepositoryCell { pub(crate) async fn push_certificate_seed(&self) -> Result<[u8; 32], PushError> { let result = self @@ -54,84 +46,4 @@ impl RepositoryCell { .try_into() .map_err(|_| PushError::InvalidResponse) } - - pub(super) async fn stage_push_certificate( - &self, - push_id: [u8; 16], - certificate: &VerifiedPushCertificate, - ) -> Result> { - let size = i64::try_from(certificate.body.len()) - .ok() - .filter(|size| *size > 0) - .ok_or(InvocationError::NotStarted(Error::Command( - "invalid push certificate", - )))?; - if certificate.signer.is_empty() || certificate.key.is_empty() { - return Err(InvocationError::NotStarted(Error::Command( - "invalid push certificate identity", - ))); - } - for (part, body) in certificate.body.chunks(CHUNK_BYTES).enumerate() { - self.sql.batch(identity().map_err(|_| InvocationError::NotStarted(Error::Command("push mutation clock failed")))?, SqlBatch { statements: vec![SqlStatement { - sql: "INSERT INTO push_certificate_chunks (push_id, part, body) VALUES (?1, ?2, ?3) ON CONFLICT(push_id, part) DO UPDATE SET body = excluded.body".into(), - parameters: vec![SqlValue::Blob(push_id.to_vec()), SqlValue::Integer(part as i64), SqlValue::Blob(body.to_vec())], - }]}).await.map_err(|source| InvocationError::NotStarted(Error::Facility { - name: "push certificate staging", - source: Box::new(source), - }))?; - } - Ok(CertificateMeta { - digest: Sha256::digest(&certificate.body).into(), - size, - signer: certificate.signer.clone(), - key: certificate.key.clone(), - }) - } -} - -pub(super) fn certificate_complete( - context: &CommandContext<'_, '_>, - push_id: [u8; 16], - certificate: &CertificateMeta, -) -> cellule_runtime::Result { - if certificate.size <= 0 || certificate.signer.is_empty() || certificate.key.is_empty() { - return Ok(false); - } - let parts = (certificate.size - 1) / CHUNK_BYTES as i64 + 1; - let totals = context.sql(&SqlBatch { statements: vec![SqlStatement { - sql: "SELECT count(*), coalesce(sum(length(body)), 0) FROM push_certificate_chunks WHERE push_id = ?1".into(), - parameters: vec![SqlValue::Blob(push_id.to_vec())], - }]})?; - if !matches!( - totals.first().and_then(|set| set.rows.first()).map(Vec::as_slice), - Some([SqlValue::Integer(count), SqlValue::Integer(size)]) if *count == parts && *size == certificate.size - ) { - return Ok(false); - } - let mut digest = Sha256::new(); - let mut size = 0_i64; - for part in 0..parts { - let result = context.sql(&SqlBatch { - statements: vec![SqlStatement { - sql: "SELECT body FROM push_certificate_chunks WHERE push_id = ?1 AND part = ?2" - .into(), - parameters: vec![SqlValue::Blob(push_id.to_vec()), SqlValue::Integer(part)], - }], - })?; - let Some([SqlValue::Blob(body)]) = result - .first() - .and_then(|set| set.rows.first()) - .map(Vec::as_slice) - else { - return Ok(false); - }; - size = size - .checked_add(body.len() as i64) - .ok_or(Error::Command("push certificate size overflow"))?; - if size > certificate.size { - return Ok(false); - } - digest.update(body); - } - Ok(size == certificate.size && digest.finalize().as_slice() == certificate.digest) } diff --git a/crates/canopy-server/src/push/mod.rs b/crates/canopy-server/src/push/mod.rs index 0a8e8d8b..812921fa 100644 --- a/crates/canopy-server/src/push/mod.rs +++ b/crates/canopy-server/src/push/mod.rs @@ -1,21 +1,9 @@ //! Durable request identity and replayable Git responses. -use std::{ - error::Error as StdError, - time::{SystemTime, UNIX_EPOCH}, -}; - -use cellule_runtime::{ - CellModule, Command, Error, InvocationError, MutationIdentity, codec::BoundedDecoder, - codec::BoundedEncoder, codec::CodecError, codec::WireValue, identity::RequestId, - primitives::sql::SqlBatch, primitives::sql::SqlStatement, primitives::sql::SqlValue, - registry::CommandContext, registry::CommandResult, -}; - +use crate::RepositoryCell; use crate::access::READ_ACCESS; -use crate::{ - PushPlan, RepositoryCell, RepositoryModule, git_http::GitHttpResponse, refs::apply_refs, -}; +use cellule_runtime::primitives::sql::{SqlBatch, SqlStatement, SqlValue}; +use std::error::Error as StdError; /// One completed push's authenticated, durable audit annotation. #[derive(Debug, serde::Serialize)] @@ -38,11 +26,8 @@ pub(crate) fn valid_options(options: &[String]) -> bool { } mod certificate; -mod plan; -use certificate::{CertificateMeta, certificate_complete}; pub use certificate::{PushCertificateReceipt, VerifiedPushCertificate}; pub(crate) mod report; -use plan::StagedPlan; pub(crate) const CHUNK_BYTES: usize = 512 * 1024; pub(crate) const MAX_RESPONSE_BYTES: usize = 64 * 1024 * 1024; @@ -76,7 +61,7 @@ impl RepositoryCell { ) -> Result, PushError> { crate::directory::validate_component(actor).map_err(cell)?; let result = self.sql.query(None, SqlBatch { statements: vec![SqlStatement { - sql: format!("SELECT p.actor, p.options, c.digest, c.signer, c.key, c.recorded_at_ms FROM pushes p LEFT JOIN push_certificates c ON c.push_id = p.id WHERE p.id = ?2 AND p.response_id IS NOT NULL AND (p.actor = ?1 OR EXISTS (SELECT 1 FROM repository_identity WHERE owner = ?1)) AND ({READ_ACCESS})"), + sql: format!("SELECT p.actor, p.options, c.digest, c.signer, c.key, c.recorded_at_ms, p.response_root FROM pushes p LEFT JOIN push_certificates c ON c.push_id = p.id WHERE p.id = ?2 AND p.response_id IS NOT NULL AND (p.actor = ?1 OR EXISTS (SELECT 1 FROM repository_identity WHERE owner = ?1)) AND ({READ_ACCESS})"), parameters: vec![SqlValue::Text(actor.into()), SqlValue::Blob(id.to_vec())], }]}).await.map_err(cell)?; let set = result.output.first().ok_or(PushError::InvalidResponse)?; @@ -90,12 +75,23 @@ impl RepositoryCell { signer, key, recorded_at_ms, + root, ] = row.as_slice() else { return Err(PushError::InvalidResponse); }; - let options: Vec = - serde_json::from_str(options).map_err(|_| PushError::InvalidResponse)?; + let options: Vec = match root { + SqlValue::Blob(bytes) => self + .staging_coordinator() + .map_err(cell)? + .completed_options(bytes) + .await + .map_err(cell)?, + SqlValue::Null => { + serde_json::from_str(options).map_err(|_| PushError::InvalidResponse)? + } + _ => return Err(PushError::InvalidResponse), + }; if !valid_options(&options) { return Err(PushError::InvalidResponse); } @@ -121,483 +117,4 @@ impl RepositoryCell { certificate, })) } - - pub(crate) async fn completed_response( - &self, - push_id: [u8; 16], - ) -> Result { - let result = self - .sql - .query( - None, - SqlBatch { - statements: vec![SqlStatement { - sql: "SELECT response_id, rejected, rejection_reason FROM pushes WHERE id = ?1".into(), - parameters: vec![SqlValue::Blob(push_id.to_vec())], - }], - }, - ) - .await - .map_err(cell)?; - let Some([SqlValue::Blob(id), SqlValue::Integer(rejected), reason]) = result - .output - .first() - .and_then(|set| set.rows.first()) - .map(Vec::as_slice) - else { - return Err(PushError::InvalidResponse); - }; - let response = self - .push_response( - id.as_slice() - .try_into() - .map_err(|_| PushError::InvalidResponse)?, - ) - .await?; - match (rejected, reason) { - (0, SqlValue::Null) => Ok(response), - (1, SqlValue::Null) => report::rejected_report(&response, report::REJECTED), - (1, SqlValue::Text(reason)) => report::rejected_report(&response, reason), - _ => Err(PushError::InvalidResponse), - } - } - - pub(crate) async fn begin_push( - &self, - id: [u8; 16], - actor: &str, - digest: [u8; 32], - ) -> Result { - let result = self.sql.batch(identity()?, SqlBatch { statements: vec![ - SqlStatement { - sql: "INSERT INTO pushes (id, actor, request_digest) VALUES (?1, ?2, ?3) ON CONFLICT(id) DO NOTHING".into(), - parameters: vec![SqlValue::Blob(id.to_vec()), SqlValue::Text(actor.into()), SqlValue::Blob(digest.to_vec())], - }, - SqlStatement { - sql: "SELECT actor, request_digest, response_id FROM pushes WHERE id = ?1".into(), - parameters: vec![SqlValue::Blob(id.to_vec())], - }, - ]}).await.map_err(cell)?; - let row = result - .output - .get(1) - .and_then(|set| set.rows.first()) - .ok_or(PushError::InvalidResponse)?; - let [ - SqlValue::Text(stored_actor), - SqlValue::Blob(stored_digest), - response, - ] = row.as_slice() - else { - return Err(PushError::InvalidResponse); - }; - if stored_actor != actor || stored_digest.as_slice() != digest { - return Err(PushError::Conflict); - } - match response { - SqlValue::Null => Ok(false), - SqlValue::Blob(id) if id.len() == 16 => Ok(true), - _ => Err(PushError::InvalidResponse), - } - } - - pub(crate) async fn stage_push_response( - &self, - push_id: [u8; 16], - response: &GitHttpResponse, - ) -> Result<[u8; 16], PushError> { - if response.body.len() > MAX_RESPONSE_BYTES { - return Err(PushError::InvalidResponse); - } - let id = uuid::Uuid::new_v4().into_bytes(); - let headers = serde_json::to_string(&response.headers)?; - if headers.len() > 64 * 1024 { - return Err(PushError::InvalidResponse); - } - let mut statements = vec![SqlStatement { - sql: "INSERT INTO push_responses (id, push_id, status, headers, size, digest) VALUES (?1, ?2, ?3, ?4, ?5, ?6)".into(), - parameters: vec![SqlValue::Blob(id.to_vec()), SqlValue::Blob(push_id.to_vec()), SqlValue::Integer(i64::from(response.status)), SqlValue::Text(headers), SqlValue::Integer(response.body.len() as i64), SqlValue::Blob(blake3::hash(&response.body).as_bytes().to_vec())], - }]; - for (part, body) in response.body.chunks(CHUNK_BYTES).enumerate() { - statements.push(SqlStatement { - sql: - "INSERT INTO push_response_chunks (response_id, part, body) VALUES (?1, ?2, ?3)" - .into(), - parameters: vec![ - SqlValue::Blob(id.to_vec()), - SqlValue::Integer(part as i64), - SqlValue::Blob(body.to_vec()), - ], - }); - self.sql - .batch( - identity()?, - SqlBatch { - statements: std::mem::take(&mut statements), - }, - ) - .await - .map_err(cell)?; - } - if !statements.is_empty() { - self.sql - .batch(identity()?, SqlBatch { statements }) - .await - .map_err(cell)?; - } - Ok(id) - } - - async fn push_response(&self, id: [u8; 16]) -> Result { - let result = - self.sql - .query( - None, - SqlBatch { - statements: vec![SqlStatement { - sql: "SELECT status, headers, size, digest FROM push_responses WHERE id = ?1".into(), - parameters: vec![SqlValue::Blob(id.to_vec())], - }], - }, - ) - .await - .map_err(cell)?; - let row = result - .output - .first() - .and_then(|set| set.rows.first()) - .ok_or(PushError::InvalidResponse)?; - let [ - SqlValue::Integer(status), - SqlValue::Text(headers), - SqlValue::Integer(size), - SqlValue::Blob(digest), - ] = row.as_slice() - else { - return Err(PushError::InvalidResponse); - }; - let size = usize::try_from(*size).map_err(|_| PushError::InvalidResponse)?; - if size > MAX_RESPONSE_BYTES { - return Err(PushError::InvalidResponse); - } - let mut body = Vec::with_capacity(size); - for part in 0..size.div_ceil(CHUNK_BYTES) { - let result = self.sql.query(None, SqlBatch { statements: vec![SqlStatement { - sql: "SELECT body FROM push_response_chunks WHERE response_id = ?1 AND part = ?2".into(), - parameters: vec![SqlValue::Blob(id.to_vec()), SqlValue::Integer(part as i64)], - }]}).await.map_err(cell)?; - let row = result - .output - .first() - .and_then(|set| set.rows.first()) - .ok_or(PushError::InvalidResponse)?; - let [SqlValue::Blob(chunk)] = row.as_slice() else { - return Err(PushError::InvalidResponse); - }; - if body.len() + chunk.len() > size { - return Err(PushError::InvalidResponse); - } - body.extend_from_slice(chunk); - } - if body.len() != size || blake3::hash(&body).as_bytes().as_slice() != digest { - return Err(PushError::InvalidResponse); - } - Ok(GitHttpResponse { - status: u16::try_from(*status).map_err(|_| PushError::InvalidResponse)?, - headers: serde_json::from_str(headers)?, - body, - }) - } - - pub(crate) async fn complete_push( - &self, - input: PushCompletion, - ) -> Result, InvocationError> { - if !valid_options(&input.options) { - return Err(InvocationError::NotStarted(Error::Command( - "invalid push options", - ))); - } - let certificate = if let Some(certificate) = &input.certificate { - if certificate.target != self.target || certificate.request_digest != input.digest { - return Err(InvocationError::NotStarted(Error::Command( - "signed push witness context differs", - ))); - } - Some(self.stage_push_certificate(input.id, certificate).await?) - } else { - None - }; - if let Some(plan) = &input.plan { - self.prepare_graph(plan).await?; - self.prepare_branch_proofs(plan).await?; - } - let plan = match &input.plan { - Some(plan) => { - if plan.actor != input.actor { - return Err(InvocationError::NotStarted(Error::Command( - "push plan actor mismatch", - ))); - } - Some( - self.stage_push_plan(input.response_id, plan) - .await - .map_err(|source| { - InvocationError::NotStarted(Error::Facility { - name: "push ref staging", - source: Box::new(source), - }) - })?, - ) - } - None => None, - }; - let input = CompletePushInput { - id: input.id, - actor: input.actor, - digest: input.digest, - response_id: input.response_id, - options: input.options, - plan, - certificate, - }; - let identity = identity().map_err(|_| { - InvocationError::NotStarted(Error::Command("push mutation clock failed")) - })?; - self.application - .command::(&self.target, identity, input) - .await - } -} - -pub(crate) struct PushCompletion { - pub id: [u8; 16], - pub actor: String, - pub digest: [u8; 32], - pub response_id: [u8; 16], - pub options: Vec, - pub plan: Option, - pub certificate: Option, -} - -pub(crate) struct CompletePushInput { - id: [u8; 16], - actor: String, - digest: [u8; 32], - response_id: [u8; 16], - options: Vec, - plan: Option, - certificate: Option, -} - -impl WireValue for CompletePushInput { - fn encode(&self, encoder: &mut BoundedEncoder) -> Result<(), CodecError> { - encoder.write_bytes(&self.id)?; - encoder.write_text(&self.actor)?; - encoder.write_bytes(&self.digest)?; - encoder.write_bytes(&self.response_id)?; - encoder.write_bytes( - &serde_json::to_vec(&self.options).map_err(|_| CodecError::Invalid("push options"))?, - )?; - encoder.write_bool(self.plan.is_some())?; - if let Some(plan) = &self.plan { - plan.encode(encoder)?; - } - encoder.write_bool(self.certificate.is_some())?; - if let Some(certificate) = &self.certificate { - encoder.write_bytes(&certificate.digest)?; - encoder.write_u64(certificate.size as u64)?; - encoder.write_text(&certificate.signer)?; - encoder.write_text(&certificate.key)?; - } - Ok(()) - } - fn decode(decoder: &mut BoundedDecoder<'_>) -> Result { - Ok(Self { - id: fixed(decoder)?, - actor: decoder.read_text()?.into(), - digest: fixed(decoder)?, - response_id: fixed(decoder)?, - options: serde_json::from_slice(decoder.read_bytes()?) - .map_err(|_| CodecError::Invalid("push options"))?, - plan: if decoder.read_bool()? { - Some(StagedPlan::decode(decoder)?) - } else { - None - }, - certificate: if decoder.read_bool()? { - Some(CertificateMeta { - digest: fixed(decoder)?, - size: i64::try_from(decoder.read_u64()?) - .map_err(|_| CodecError::Invalid("invalid certificate size"))?, - signer: decoder.read_text()?.into(), - key: decoder.read_text()?.into(), - }) - } else { - None - }, - }) - } -} - -fn fixed(decoder: &mut BoundedDecoder<'_>) -> Result<[u8; N], CodecError> { - decoder - .read_bytes()? - .try_into() - .map_err(|_| CodecError::Invalid("invalid push identity length")) -} - -pub(crate) struct CompletePush; - -impl Command for CompletePush { - const MODULE: &'static str = RepositoryModule::NAME; - const ID: u32 = 4; - const CODEC_VERSION: u32 = 6; - type Input = CompletePushInput; - type Output = bool; - - fn execute( - context: &mut CommandContext<'_, '_>, - input: Self::Input, - ) -> cellule_runtime::Result> { - if !valid_options(&input.options) { - return Ok(CommandResult::Rejected(false)); - } - let result = context.sql(&SqlBatch { statements: vec![SqlStatement { - sql: "SELECT response_id FROM pushes WHERE id = ?1 AND actor = ?2 AND request_digest = ?3".into(), - parameters: vec![SqlValue::Blob(input.id.to_vec()), SqlValue::Text(input.actor.clone()), SqlValue::Blob(input.digest.to_vec())], - }]})?; - match result - .first() - .and_then(|set| set.rows.first()) - .map(Vec::as_slice) - { - Some([SqlValue::Blob(_)]) => return Ok(CommandResult::Success(true)), - Some([SqlValue::Null]) => {} - _ => return Ok(CommandResult::Rejected(false)), - } - if !response_complete(context, input.id, input.response_id)? { - return Ok(CommandResult::Rejected(false)); - } - // The final Cell decision must bind the signed principal to the push actor. - if let Some(certificate) = &input.certificate - && (certificate.signer != input.actor - || !certificate_complete(context, input.id, certificate)?) - { - return Ok(CommandResult::Rejected(false)); - } - let replay = if let Some(certificate) = &input.certificate { - let rows = context.sql(&SqlBatch { - statements: vec![SqlStatement { - sql: "SELECT push_id FROM push_certificates WHERE digest = ?1".into(), - parameters: vec![SqlValue::Blob(certificate.digest.to_vec())], - }], - })?; - !rows - .first() - .ok_or(Error::Command("missing certificate replay result"))? - .rows - .is_empty() - } else { - false - }; - let rejected = if replay { - true - } else if let Some(plan) = &input.plan { - let plan = plan.load(context, input.response_id, &input.actor)?; - // apply_refs returns false only before any writes. A policy/CAS/ACL - // refusal records rejection without publishing refs; SQL failures - // still roll back the whole completion transaction. - !apply_refs(context, &plan, None)? - } else { - // Native errors and no-op pushes mutate no refs. Record their - // bound outcome even if write permission was revoked after admission. - false - }; - if let Some(certificate) = &input.certificate - && !replay - { - // Keep the exact signed bytes with the decision. A second push ID - // cannot publish the same certificate, even after ref ABA or takeover. - context.sql(&SqlBatch { statements: vec![SqlStatement { - sql: "INSERT INTO push_certificates (digest, push_id, actor, signer, key, size, recorded_at_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)".into(), - parameters: vec![SqlValue::Blob(certificate.digest.to_vec()), SqlValue::Blob(input.id.to_vec()), SqlValue::Text(input.actor.clone()), SqlValue::Text(certificate.signer.clone()), SqlValue::Text(certificate.key.clone()), SqlValue::Integer(certificate.size), SqlValue::Integer(context.now_ms())], - }]})?; - } - // Publishing the response in the ref transaction makes a lost HTTP reply replayable. - // Staged chunks from interrupted attempts are never returned as completed outcomes. - context.sql(&SqlBatch { - statements: vec![SqlStatement { - sql: "UPDATE pushes SET response_id = ?1, rejected = ?3, options = ?4, rejection_reason = ?5 WHERE id = ?2 AND response_id IS NULL" - .into(), - parameters: vec![ - SqlValue::Blob(input.response_id.to_vec()), - SqlValue::Blob(input.id.to_vec()), - SqlValue::Integer(i64::from(rejected)), - SqlValue::Text(serde_json::to_string(&input.options).map_err(|_| Error::Command("invalid push options"))?), - if replay { SqlValue::Text("Canopy signed push certificate was already used".into()) } else { SqlValue::Null }, - ], - }], - })?; - // Replays need only the saved response. Reclaim the plan atomically - // with publication so rollback keeps every staged chunk available. - context.sql(&SqlBatch { - statements: vec![SqlStatement { - sql: "DELETE FROM push_plan_chunks WHERE response_id = ?1".into(), - parameters: vec![SqlValue::Blob(input.response_id.to_vec())], - }], - })?; - if replay { - context.sql(&SqlBatch { - statements: vec![SqlStatement { - sql: "DELETE FROM push_certificate_chunks WHERE push_id = ?1".into(), - parameters: vec![SqlValue::Blob(input.id.to_vec())], - }], - })?; - } - Ok(CommandResult::Success(true)) - } -} - -fn response_complete( - context: &CommandContext<'_, '_>, - push_id: [u8; 16], - response_id: [u8; 16], -) -> cellule_runtime::Result { - let result = context.sql(&SqlBatch { statements: vec![SqlStatement { - sql: "SELECT r.size, count(c.part), coalesce(sum(length(c.body)), 0), coalesce(min(c.part), 0), coalesce(max(c.part), -1) FROM push_responses r LEFT JOIN push_response_chunks c ON c.response_id = r.id WHERE r.id = ?1 AND r.push_id = ?2 GROUP BY r.id".into(), - parameters: vec![SqlValue::Blob(response_id.to_vec()), SqlValue::Blob(push_id.to_vec())], - }]})?; - let Some( - [ - SqlValue::Integer(size), - SqlValue::Integer(count), - SqlValue::Integer(total), - SqlValue::Integer(first), - SqlValue::Integer(last), - ], - ) = result - .first() - .and_then(|set| set.rows.first()) - .map(Vec::as_slice) - else { - return Ok(false); - }; - let expected = (*size + CHUNK_BYTES as i64 - 1) / CHUNK_BYTES as i64; - Ok(*count == expected && size == total && *first == 0 && *last == expected - 1) -} - -fn identity() -> Result { - let now = i64::try_from( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| PushError::Clock)? - .as_millis(), - ) - .map_err(|_| PushError::Clock)?; - Ok(MutationIdentity { - request_id: RequestId::from_bytes(uuid::Uuid::new_v4().into_bytes()), - issued_at_ms: now, - expires_at_ms: now.checked_add(60_000).ok_or(PushError::Clock)?, - }) } diff --git a/crates/canopy-server/src/push/plan.rs b/crates/canopy-server/src/push/plan.rs deleted file mode 100644 index 301175d4..00000000 --- a/crates/canopy-server/src/push/plan.rs +++ /dev/null @@ -1,113 +0,0 @@ -use super::*; -use crate::refs::MAX_UPDATES; - -const UPDATES_PER_CHUNK: usize = 128; -const CHUNK_LIMIT: u32 = 64 * 1024; - -// Completion carries a small immutable binding, not the potentially large ref -// list. Count, actor and digest checks prevent incomplete or mixed attempts -// from reaching the single authoritative ref transaction. -pub(super) struct StagedPlan { - updates: usize, - digest: [u8; 32], -} - -impl WireValue for StagedPlan { - fn encode(&self, encoder: &mut BoundedEncoder) -> Result<(), CodecError> { - encoder.write_count(self.updates)?; - encoder.write_bytes(&self.digest) - } - - fn decode(decoder: &mut BoundedDecoder<'_>) -> Result { - let updates = decoder.read_count()?; - if !(1..=MAX_UPDATES).contains(&updates) { - return Err(CodecError::Invalid("push update count is outside bounds")); - } - Ok(Self { - updates, - digest: fixed(decoder)?, - }) - } -} - -impl RepositoryCell { - pub(super) async fn stage_push_plan( - &self, - response: [u8; 16], - plan: &PushPlan, - ) -> Result { - if !(1..=MAX_UPDATES).contains(&plan.updates.len()) { - return Err(PushError::InvalidPlan); - } - let mut digest = blake3::Hasher::new(); - for (part, updates) in plan.updates.chunks(UPDATES_PER_CHUNK).enumerate() { - let mut encoder = BoundedEncoder::new(CHUNK_LIMIT).map_err(cell)?; - PushPlan { - actor: plan.actor.clone(), - updates: updates.to_vec(), - } - .encode(&mut encoder) - .map_err(cell)?; - let body = encoder.finish(); - digest.update(&body); - self.sql.batch(identity()?, SqlBatch { statements: vec![SqlStatement { - sql: "INSERT INTO push_plan_chunks (response_id, part, body) VALUES (?1, ?2, ?3)".into(), - parameters: vec![SqlValue::Blob(response.to_vec()), SqlValue::Integer(part as i64), SqlValue::Blob(body)], - }] }).await.map_err(cell)?; - } - Ok(StagedPlan { - updates: plan.updates.len(), - digest: *digest.finalize().as_bytes(), - }) - } -} - -impl StagedPlan { - pub(super) fn load( - &self, - context: &CommandContext<'_, '_>, - response: [u8; 16], - actor: &str, - ) -> cellule_runtime::Result { - let mut digest = blake3::Hasher::new(); - let mut updates = Vec::new(); - let parts = self.updates.div_ceil(UPDATES_PER_CHUNK); - for part in 0..parts { - let result = context.sql(&SqlBatch { - statements: vec![SqlStatement { - sql: "SELECT body FROM push_plan_chunks WHERE response_id = ?1 AND part = ?2" - .into(), - parameters: vec![ - SqlValue::Blob(response.to_vec()), - SqlValue::Integer(part as i64), - ], - }], - })?; - let Some([SqlValue::Blob(body)]) = result - .first() - .and_then(|set| set.rows.first()) - .map(Vec::as_slice) - else { - return Err(Error::Command("push ref plan is incomplete")); - }; - digest.update(body); - let mut decoder = BoundedDecoder::new(body, CHUNK_LIMIT)?; - let chunk = PushPlan::decode(&mut decoder)?; - decoder.finish()?; - let expected = (self.updates - updates.len()).min(UPDATES_PER_CHUNK); - if chunk.actor != actor || chunk.updates.len() != expected { - return Err(Error::Command( - "push ref plan does not match its publication", - )); - } - updates.extend(chunk.updates); - } - if digest.finalize().as_bytes() != &self.digest { - return Err(Error::Command("push ref plan digest mismatch")); - } - Ok(PushPlan { - actor: actor.into(), - updates, - }) - } -} diff --git a/crates/canopy-server/src/push/report.rs b/crates/canopy-server/src/push/report.rs index f1e94542..4bc431f5 100644 --- a/crates/canopy-server/src/push/report.rs +++ b/crates/canopy-server/src/push/report.rs @@ -1,4 +1,5 @@ use super::*; +use crate::{PushPlan, git_http::GitHttpResponse}; pub(crate) const REJECTED: &str = "Canopy publication rejected: refs, permissions or policy changed; fetch and retry"; @@ -82,8 +83,10 @@ pub(crate) fn publication_matches( }; let name = std::str::from_utf8(name).map_err(|_| PushError::InvalidResponse)?; if !crate::refs::valid_ref_name(name) - || !seen.insert(name) - || seen.len() > crate::refs::MAX_UPDATES + // A limit hook can reject more commands than we admit for a + // publication. All-refusal reports need no per-name inventory: + // every successful name still fails against the empty plan below. + || (plan.is_some() && (!seen.insert(name) || seen.len() > crate::refs::MAX_UPDATES)) || (success && (unpack != b"unpack ok\n" || !expected.remove(name))) || (!success && expected.contains(name)) { @@ -308,6 +311,26 @@ mod tests { Ok(()) } + #[test] + fn oversized_all_refusal_report_is_valid_but_cannot_acknowledge_one_ref() + -> Result<(), PushError> { + let mut report = Vec::new(); + write_packet(&mut report, b"unpack ok\n")?; + for n in 0..=crate::refs::MAX_UPDATES { + write_packet( + &mut report, + format!("ng refs/tags/{n} pre-receive hook declined\n").as_bytes(), + )?; + } + report.extend_from_slice(b"0000"); + assert!(publication_matches(&response(report.clone()), None).is_ok()); + report.truncate(report.len() - 4); + write_packet(&mut report, b"ok refs/heads/unvalidated\n")?; + report.extend_from_slice(b"0000"); + assert!(publication_matches(&response(report), None).is_err()); + Ok(()) + } + #[test] fn malformed_reports_cannot_be_rewritten_as_success() { for body in [ diff --git a/crates/canopy-server/src/repository_http/checks.rs b/crates/canopy-server/src/repository_http/checks.rs index 845cfd11..dd886c44 100644 --- a/crates/canopy-server/src/repository_http/checks.rs +++ b/crates/canopy-server/src/repository_http/checks.rs @@ -6,9 +6,7 @@ use crate::{ }, server::{RepositoryRoute, mutation_identity}, }; -use cellule_runtime::{ - Committed, InvocationError, MutationIdentity, primitives::sql::SqlResultSet, -}; +use cellule_runtime::{Committed, MutationIdentity}; use serde::de::DeserializeOwned; use std::time::Duration; @@ -354,9 +352,9 @@ pub(super) async fn update( ) } -fn changed( +fn changed( state: &RepositoryHttp, - result: Result, InvocationError>>, + result: Result, E>, id: Option<[u8; 16]>, ) -> Response { match result { diff --git a/crates/canopy-server/src/repository_http/default_branch.rs b/crates/canopy-server/src/repository_http/default_branch.rs index dc8f45b1..44dac3bf 100644 --- a/crates/canopy-server/src/repository_http/default_branch.rs +++ b/crates/canopy-server/src/repository_http/default_branch.rs @@ -14,17 +14,26 @@ pub(super) async fn read( Path(name): Path, headers: axum::http::HeaderMap, ) -> Response { - let (route, _) = match readable_route(&state, &name, &headers).await { + let (route, actor) = match readable_route(&state, &name, &headers).await { Ok(authorized) => authorized, Err(response) => return response, }; - match route.repository.default_branch(None).await { - Ok(head) if (state.manager.ready)() => response( - route.repository.repository_id(), - &head.output.reference, - head.output.generation, - ), - Ok(_) => plain(StatusCode::SERVICE_UNAVAILABLE, "Canopy node is not ready"), + let head = route + .repository + .default_branch_for(actor.identity(), None) + .await; + match head { + Ok(_) if !(state.manager.ready)() => { + plain(StatusCode::SERVICE_UNAVAILABLE, "Canopy node is not ready") + } + Ok(head) => match head.output { + Some(head) => response( + route.repository.repository_id(), + &head.reference, + head.generation, + ), + None => plain(StatusCode::NOT_FOUND, "Repository not found"), + }, Err(error) => { tracing::error!(error = %error, "default branch read failed"); plain( @@ -82,21 +91,27 @@ pub(super) async fn update( } }; match route - .repository + .gateway .set_default_branch( identity, &principal.account, - input.expected_generation, - &input.reference, + crate::packs::publication::HeadRequest { + expected_generation: input.expected_generation, + reference: input.reference.clone(), + }, ) .await { - Ok(result) if result.output && (state.manager.ready)() => response( - route.repository.repository_id(), - &input.reference, - input.expected_generation + 1, - ), - Ok(result) if !result.output => plain( + Ok(crate::packs::publication::PublicationReply::Published(_)) + if (state.manager.ready)() => + { + response( + route.repository.repository_id(), + &input.reference, + input.expected_generation + 1, + ) + } + Ok(crate::packs::publication::PublicationReply::Denied(_)) => plain( StatusCode::CONFLICT, "Ref generation changed or target branch does not exist", ), diff --git a/crates/canopy-server/src/repository_http/merge.rs b/crates/canopy-server/src/repository_http/merge.rs index 2e46bd52..52bd98e4 100644 --- a/crates/canopy-server/src/repository_http/merge.rs +++ b/crates/canopy-server/src/repository_http/merge.rs @@ -3,7 +3,6 @@ use crate::pulls::{ PullRevision, merge::{MergeOutcome, MergeRequest, MergeStrategy, valid_request}, }; -use cellule_runtime::InvocationError; use std::time::Duration; const WORK_TIMEOUT_MS: u32 = 120_000; @@ -118,14 +117,13 @@ async fn serve( }; identity.expires_at_ms = expires_at_ms; let operation = route - .repository + .gateway .merge_pull(identity, &actor.account, number, request); let outcome = match tokio::time::timeout(Duration::from_millis(u64::from(WORK_TIMEOUT_MS)), operation) .await { - Ok(Ok(result)) if (state.manager.ready)() => result.output, - Ok(Err(InvocationError::Rejected(rejected))) => rejected.output, + Ok(Ok(result)) if (state.manager.ready)() => result, Ok(Err(error)) => return failed(error), Ok(Ok(_)) => return unavailable(), Err(_) => { diff --git a/crates/canopy-server/src/repository_http/pulls.rs b/crates/canopy-server/src/repository_http/pulls.rs index c2d1ee1e..a1c7b504 100644 --- a/crates/canopy-server/src/repository_http/pulls.rs +++ b/crates/canopy-server/src/repository_http/pulls.rs @@ -7,9 +7,7 @@ use crate::{ }, server::{RepositoryRoute, mutation_identity}, }; -use cellule_runtime::{ - Committed, InvocationError, MutationIdentity, primitives::sql::SqlResultSet, -}; +use cellule_runtime::{Committed, MutationIdentity}; use serde::de::DeserializeOwned; use std::time::Duration; @@ -338,7 +336,7 @@ pub(super) async fn review( } fn changed( state: &RepositoryHttp, - result: Result, InvocationError>>, + result: Result, impl std::fmt::Display>, created: bool, ) -> Response { match result { diff --git a/crates/canopy-server/src/repository_http/threads.rs b/crates/canopy-server/src/repository_http/threads.rs index 0c8789ba..fe5231bf 100644 --- a/crates/canopy-server/src/repository_http/threads.rs +++ b/crates/canopy-server/src/repository_http/threads.rs @@ -218,7 +218,7 @@ async fn create_inner( .thread_retry(&actor.account, number, &intent) .await { - Ok(Some(result)) => return changed(state, Ok(result), true), + Ok(Some(result)) => return changed::(state, Ok(result), true), Ok(None) => (), Err(error) => return pulls::failed(error), } @@ -250,18 +250,15 @@ async fn create_inner( state, route .repository - .create_thread(identity, &actor.account, number, &intent, anchor) + .create_thread(identity, &actor.account, number, intent, anchor) .await .map(|result| result.output), true, ) } -fn changed( +fn changed( state: &RepositoryHttp, - result: Result< - PullChange, - cellule_runtime::InvocationError>, - >, + result: Result, created: bool, ) -> Response { match result { diff --git a/crates/canopy-server/src/server/discovery.rs b/crates/canopy-server/src/server/discovery.rs index 1e9c8621..677188eb 100644 --- a/crates/canopy-server/src/server/discovery.rs +++ b/crates/canopy-server/src/server/discovery.rs @@ -63,7 +63,14 @@ impl RepositoryManager { let Some(role) = route.repository.access_level(actor, None).await?.output else { return Ok(None); }; - let head = route.repository.default_branch(None).await?.output; + let Some(head) = route + .repository + .default_branch_for(actor, None) + .await? + .output + else { + return Ok(None); + }; let visibility = route.repository.visibility().await?.output; Ok(Some(RepositoryDetails { entry, diff --git a/crates/canopy-server/src/server/lifecycle.rs b/crates/canopy-server/src/server/lifecycle.rs index 2476c597..f9c1b90c 100644 --- a/crates/canopy-server/src/server/lifecycle.rs +++ b/crates/canopy-server/src/server/lifecycle.rs @@ -1,5 +1,30 @@ use super::*; +type ReadyAddresses = (std::net::SocketAddr, Option); +type StartupSupervisor = JoinHandle>; + +async fn receive_startup( + receive_ready: oneshot::Receiver>, + finished: StartupSupervisor, +) -> Result<(ReadyAddresses, StartupSupervisor), ServerError> { + let addresses = match receive_ready.await { + Ok(Ok(addresses)) => addresses, + Ok(Err(error)) => { + // The readiness channel may wake its caller before the supervisor + // drops task-owned startup resources. An error is a cleanup barrier. + finished.await??; + return Err(error); + } + Err(_) => { + finished.await??; + return Err(ServerError::Repository( + "node supervisor ended before readiness", + )); + } + }; + Ok((addresses, finished)) +} + impl CanopyServer { /// Starts a node only after storage fencing, authority and Git ingress are ready. /// Cancelling startup requests cleanup after admitted initialization settles. @@ -54,15 +79,7 @@ impl CanopyServer { } result }); - let (address, ssh_address) = match receive_ready.await { - Ok(address) => address?, - Err(_) => { - finished.await??; - return Err(ServerError::Repository( - "node supervisor ended before readiness", - )); - } - }; + let ((address, ssh_address), finished) = receive_startup(receive_ready, finished).await?; Ok(Self { address, ssh_address, @@ -112,7 +129,6 @@ impl CanopyServer { impl RunningServer { pub(super) async fn shutdown(mut self) -> Result<(), ServerError> { - self.native.close(); self.maintenance_stop.cancel(); self.repositories.recovery_scans.close(); self.ingress_stop.cancel(); @@ -124,6 +140,7 @@ impl RunningServer { }; self.listeners.stop_ingress(); self.repositories.drain_serving().await; + self.native.close(); self.tasks.close(); self.tasks.wait().await; self.repositories.drain_recovery().await; diff --git a/crates/canopy-server/src/server/lifecycle/tests.rs b/crates/canopy-server/src/server/lifecycle/tests.rs index 2873276d..579406a2 100644 --- a/crates/canopy-server/src/server/lifecycle/tests.rs +++ b/crates/canopy-server/src/server/lifecycle/tests.rs @@ -2,6 +2,55 @@ use super::*; use crate::native_resources::{NativeClass, NativeLimits, NativeWork}; use object_store::memory::InMemory; +#[tokio::test(flavor = "multi_thread")] +async fn failed_startup_waits_for_supervisor_completion_and_owned_listener_release() +-> Result<(), Box> { + let listener = listeners::ReservedListener::new(TcpListener::bind("127.0.0.1:0").await?)?; + let address = listener.local_addr()?; + let (ready, receive_ready) = oneshot::channel(); + let (entered, receive_entered) = oneshot::channel(); + let (release, receive_release) = oneshot::channel(); + let (drained, receive_drained) = oneshot::channel(); + let finished = tokio::spawn(async move { + // The actual supervisor can publish its error before its task-owned + // resources finish dropping. Hold a real reserved listener here. + let _ = ready.send(Err(ServerError::Repository("injected startup failure"))); + let _ = entered.send(()); + let _ = receive_release.await; + drop(listener); + let _ = drained.send(()); + Ok(()) + }); + let mut startup = tokio::spawn(receive_startup(receive_ready, finished)); + tokio::time::timeout(Duration::from_secs(5), receive_entered).await??; + let early = tokio::time::timeout(Duration::from_millis(50), &mut startup) + .await + .ok(); + let returned_before_drain = early.is_some(); + assert!( + TcpListener::bind(address) + .await + .is_err_and(|e| e.kind() == std::io::ErrorKind::AddrInUse) + ); + let _ = release.send(()); + tokio::time::timeout(Duration::from_secs(5), receive_drained).await??; + let result = match early { + Some(result) => result?, + None => tokio::time::timeout(Duration::from_secs(5), startup).await??, + }; + assert!(matches!( + result, + Err(ServerError::Repository("injected startup failure")) + )); + assert!( + !returned_before_drain, + "startup error returned while supervisor still owned the listener" + ); + let rebound = TcpListener::bind(address).await?; + assert_eq!(rebound.local_addr()?, address); + Ok(()) +} + #[tokio::test(flavor = "multi_thread")] async fn native_drain_retains_cell_workspace_and_lease_past_one_lease() -> Result<(), Box> { diff --git a/crates/canopy-server/src/server/mod.rs b/crates/canopy-server/src/server/mod.rs index 3ab1e1e9..22de0916 100644 --- a/crates/canopy-server/src/server/mod.rs +++ b/crates/canopy-server/src/server/mod.rs @@ -203,6 +203,7 @@ pub(crate) struct RepositoryManager { residency_admission: AccountAdmission, transfers: AccountAdmission, tasks: TaskTracker, + staging_budget: crate::packs::publication::StagingBudget, publication_budget: crate::packs::publication::PublicationBudget, recovery_scans: crate::packs::publication::RecoveryScanBudget, serving_reads: crate::packs::publication::ServingReadBudget, @@ -655,6 +656,8 @@ impl RunningServer { "account repository activations", ), tasks: tasks.clone(), + staging_budget: crate::packs::publication::StagingBudget::new(32, 64) + .map_err(|error| ServerError::CatalogRecovery(Box::new(error)))?, publication_budget: crate::packs::publication::PublicationBudget::new( crate::packs::publication::PublicationLimits::default(), ) diff --git a/crates/canopy-server/src/server/peer.rs b/crates/canopy-server/src/server/peer.rs index abd7774a..ad77133b 100644 --- a/crates/canopy-server/src/server/peer.rs +++ b/crates/canopy-server/src/server/peer.rs @@ -478,3 +478,87 @@ fn status(code: StatusCode) -> Response { *response.status_mut() = code; response } + +const FORWARD_HOPS: &str = "canopy-forward-hops"; + +impl NodePeer { + /// Forward transport bytes to the current resident owner. The verified + /// fleet advertisement supplies the TLS endpoint; the original credential + /// is independently authenticated there. Never redirect clients or retry a + /// consumed request body after an ambiguous receive-pack. + pub(crate) async fn forward_repository( + &self, + target: &CellTarget, + request: axum::http::Request, + ) -> Result, ServerError> { + let owner = self.live_owner(target).await?.ok_or(Error::Fenced)?; + if owner.session() == self.0.session { + // Ownership changed after route selection. Let a new request bind + // the local capability; this request must not use the stale gateway. + return Err(Error::Fenced.into()); + } + let (parts, body) = request.into_parts(); + let hops = match parts.headers.get(FORWARD_HOPS) { + None => 0, + Some(value) => value + .to_str() + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|n| *n < 2) + .ok_or(Error::PeerAuthorization("repository forwarding hop limit"))?, + }; + let mut url = endpoint(owner.endpoint())?; + url.set_path(parts.uri.path()); + url.set_query(parts.uri.query()); + let mut headers = parts.headers; + strip_connection_headers(&mut headers); + headers.remove(axum::http::header::HOST); + headers.insert( + FORWARD_HOPS, + axum::http::HeaderValue::from_static(if hops == 0 { "1" } else { "2" }), + ); + let response = self + .0 + .client + .request(parts.method, url) + .headers(headers) + .body(reqwest::Body::wrap_stream(body.into_data_stream())) + .send() + .await + .map_err(|e| transport_error(e, true))?; + let status = response.status(); + let mut headers = response.headers().clone(); + strip_connection_headers(&mut headers); + let mut result = axum::http::Response::new(Body::from_stream(response.bytes_stream())); + *result.status_mut() = status; + *result.headers_mut() = headers; + Ok(result) + } +} + +fn strip_connection_headers(headers: &mut axum::http::HeaderMap) { + // Connection may nominate additional hop-local headers. Copy their names + // before mutation; forwarding credentials never come from extensions. + let named: Vec<_> = headers + .get_all(axum::http::header::CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .filter_map(|name| axum::http::HeaderName::from_bytes(name.trim().as_bytes()).ok()) + .collect(); + for name in named { + headers.remove(name); + } + for name in [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + ] { + headers.remove(name); + } +} diff --git a/crates/canopy-server/src/server/residency/mod.rs b/crates/canopy-server/src/server/residency/mod.rs index 224cca71..9e25fa3b 100644 --- a/crates/canopy-server/src/server/residency/mod.rs +++ b/crates/canopy-server/src/server/residency/mod.rs @@ -67,14 +67,36 @@ pub(crate) struct RepositoryRoute { pub(crate) repository: Arc, pub(crate) gateway: Arc, router: Router, + remote: Option, pin: Arc<()>, } impl RepositoryRoute { pub(crate) async fn dispatch(self, request: Request) -> Response { - let response = match self.router.oneshot(request).await { - Ok(response) => response, - Err(error) => match error {}, + let response = if let Some(peer) = &self.remote { + match peer + .forward_repository(&self.repository.target, request) + .await + { + Ok(response) => response, + Err(error) => { + tracing::warn!(?error, "repository owner forwarding failed"); + let mut response = Response::new(Body::from( + "Repository owner is unavailable; retry the same request", + )); + *response.status_mut() = axum::http::StatusCode::SERVICE_UNAVAILABLE; + response.headers_mut().insert( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response + } + } + } else { + match self.router.oneshot(request).await { + Ok(response) => response, + Err(error) => match error {}, + } }; // A streamed reply outlives the handler. Keep its Cell resident until the // body finishes or is dropped; cache generations have their own worker pins. @@ -382,6 +404,7 @@ impl RepositoryManager { } else if let Some(resident) = loaded.get_mut(&entry.repository_id) { resident.recovery = Some(recovery.clone()); repository.attach_serving(&recovery.serving); + repository.attach_staging(&recovery.staging); None } else { Some(ServerError::Repository("loaded repository is absent")) @@ -416,6 +439,7 @@ impl RepositoryManager { repository: Arc::clone(&existing.repository), gateway: Arc::clone(&existing.gateway), router: existing.router.clone(), + remote: (!existing.local).then(|| self.peer.clone()), pin: Arc::clone(&existing.pin), }) } diff --git a/crates/canopy-server/src/server/residency/recovery.rs b/crates/canopy-server/src/server/residency/recovery.rs index bfba765a..036e972b 100644 --- a/crates/canopy-server/src/server/residency/recovery.rs +++ b/crates/canopy-server/src/server/residency/recovery.rs @@ -4,13 +4,14 @@ use crate::packs::catalog::{CatalogFileLimits, CatalogFiles, CatalogIndexes}; use crate::packs::publication::{ CustodySupervisor, MaintenanceRequest, PreparationAuthority, PublicationCoordinator, PublicationLimits, PublicationState, RecoveryScanLimits, RecoverySupervisor, ServingContext, - ServingPool, ServingPoolLimits, + ServingPool, ServingPoolLimits, StagingCoordinator, StagingLimits, StagingState, }; use canopy_object_storage::artifact::ArtifactStore; pub(super) struct RecoveryServices { pub(super) coordinator: PublicationCoordinator, pub(super) serving: Arc, + pub(super) staging: Arc, workers: Mutex>, } struct Workers { @@ -40,13 +41,25 @@ impl RecoveryServices { manager.publication_budget.clone(), ) .map_err(|error| ServerError::CatalogRecovery(Box::new(error)))?; - let settings = manager - .recovery_scans - .settings(RecoveryScanLimits::default(), &entry.owner); let store = Arc::new(ArtifactStore::new( Arc::clone(&manager.external_store), entry.repository_id, )); + let staging = Arc::new( + StagingCoordinator::new_resident( + client.clone(), + target.clone(), + StagingLimits::default(), + authority.clone(), + manager.staging_budget.clone(), + coordinator.clone(), + store.clone(), + ) + .map_err(|error| ServerError::CatalogRecovery(Box::new(error)))?, + ); + let settings = manager + .recovery_scans + .settings(RecoveryScanLimits::default(), &entry.owner); let serving = Arc::new( ServingPool::new( ServingContext::new( @@ -78,11 +91,11 @@ impl RecoveryServices { ) .map_err(|error| ServerError::CatalogRecovery(Box::new(error)))?, ); - let roots = match RecoverySupervisor::start_retiring( + let roots = match RecoverySupervisor::start_resident( client.clone(), target.clone(), ArtifactStore::new(Arc::clone(&manager.external_store), entry.repository_id), - coordinator.clone(), + (coordinator.clone(), staging.clone()), settings.clone(), authority.clone(), maintenance, @@ -112,11 +125,15 @@ impl RecoveryServices { Ok(Self { coordinator, serving, + staging, workers: Mutex::new(Some(Workers { roots, custody })), }) } pub(super) async fn quiesce(&self) -> bool { + let Some(staging) = self.staging.try_quiesce() else { + return false; + }; let mut workers = self.workers.lock().await; if let Some(active) = workers.as_ref() { tokio::join!(active.roots.pause(), active.custody.pause()); @@ -135,12 +152,33 @@ impl RecoveryServices { } return false; } + staging.commit(); self.serving.close_and_drain().await; join(workers.take()).await; true } + async fn drain_staging(&self) { + loop { + let pending = self.staging.close_and_drain().await; + if pending.is_empty() && !self.staging.stats().retirement_running { + return; + } + // Exact settlement can remove the last job while its read-only + // retirement owner is finishing a round. Keep the Cell and node + // workspace until that independently owned scanner exits too. + for ticket in pending { + if matches!(ticket.state(), StagingState::Uncertain(_)) + && let Err(error) = self.staging.recover(&ticket) + { + tracing::warn!(?error, "exact staging recovery deferred during drain"); + } + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + } pub(super) async fn drain(&self) { + self.drain_staging().await; self.serving.close_and_drain().await; join(self.workers.lock().await.take()).await; loop { @@ -187,6 +225,19 @@ impl RepositoryManager { .filter_map(|repository| repository.recovery.as_ref().map(Arc::clone)) .collect() }; + for service in &services { + service.staging.close_admission(); + } + futures_util::future::join_all( + services + .iter() + .map(|service| service.staging.finish_receive_workflows()), + ) + .await; + // Producer capabilities may retain serving generations and exact held + // publication work. Drain them before closing either lower service. + futures_util::future::join_all(services.iter().map(|service| service.drain_staging())) + .await; for service in &services { service.serving.close(); } diff --git a/crates/canopy-server/src/server/residency/tests.rs b/crates/canopy-server/src/server/residency/tests.rs index c8125c54..1b1c84cf 100644 --- a/crates/canopy-server/src/server/residency/tests.rs +++ b/crates/canopy-server/src/server/residency/tests.rs @@ -3,6 +3,7 @@ use std::{collections::VecDeque, convert::Infallible, future::poll_fn}; use super::*; mod recovery; mod serving; +mod staging; struct Frames(VecDeque>); diff --git a/crates/canopy-server/src/server/residency/tests/serving.rs b/crates/canopy-server/src/server/residency/tests/serving.rs index efa5345c..e2493e5c 100644 --- a/crates/canopy-server/src/server/residency/tests/serving.rs +++ b/crates/canopy-server/src/server/residency/tests/serving.rs @@ -149,6 +149,7 @@ async fn shutdown_refuses_unpublished_serving_constructor_and_joins_it_before_wo .serving_snapshot(ReadIdentity::Account("canopy")) .await; let unavailable = premature.is_err(); + assert!(repository.staging_coordinator().is_err()); drop(premature); let mut shutdown = tokio::spawn(server.shutdown()); timeout(Duration::from_secs(8), manager.serving_stop.cancelled()).await?; @@ -169,6 +170,7 @@ async fn shutdown_refuses_unpublished_serving_constructor_and_joins_it_before_wo unavailable, "unpublished constructor exposed serving before registered ownership" ); + assert!(repository.staging_coordinator().is_err()); assert!(matches!( result, Err(crate::server::ServerError::Runtime( diff --git a/crates/canopy-server/src/server/residency/tests/serving/browser.rs b/crates/canopy-server/src/server/residency/tests/serving/browser.rs index 5cd449d0..ff6a2385 100644 --- a/crates/canopy-server/src/server/residency/tests/serving/browser.rs +++ b/crates/canopy-server/src/server/residency/tests/serving/browser.rs @@ -1,7 +1,10 @@ //! Actual HTTP reads against native, physically verified packs. Only the joint //! catalog fact and editorial pull records are installed by trusted test SQL; -//! this does not qualify the still-unconverted live pull/ref producers. +//! this isolates native reader semantics from generated Git producers. Pull +//! receiver tests use the registered native commands against these real roots. use super::*; +mod checks; +mod pulls; use crate::packs::{ catalog::{ CatalogSnapshot, StoredCatalog, @@ -304,17 +307,63 @@ async fn production_native_browser_refuses_absent_generations_wrong_formats_and_ } async fn editorial_pull( + server: &crate::server::RunningServer, repository: &RepositoryCell, number: i64, source: ObjectId, base: ObjectId, ) -> Result { - // Only editorial metadata remains on legacy refs. The compared bodies and - // ancestry must come from the certified catalog, never objects/parents SQL. + // Trusted joint-root installation isolates native read semantics. Both refs + // and compared bodies come from immutable roots; only editorial rows use SQL. let source_ref = format!("refs/heads/source-{number}"); let base_ref = format!("refs/heads/base-{number}"); + let store = Arc::new(ArtifactStore::new( + server.repositories.external_store.clone(), + repository.repository_id(), + )); + let snapshot = repository + .serving_snapshot(ReadIdentity::Account("canopy")) + .await?; + let fact = snapshot.fact(); + let mut refs = fact.refs.ok_or("joint refs absent")?.read(&store).await?; + let index = + crate::packs::ref_state::RefStateIndex::new(store.clone(), repository.object_format()); + let mut cursor = index.cursor(refs.root.clone(), None, false)?; + let mut records = Vec::new(); + while let Some(record) = cursor.next().await? { + records.push(record); + } + for (name, oid) in [(&source_ref, source), (&base_ref, base)] { + records.push(crate::packs::ref_state::RefStateRecord::new( + name, + crate::refs::RefExpectation { + oid: Some(oid), + version: 1, + }, + repository.object_format(), + )?); + } + records.sort_by(|a, b| a.name().cmp(b.name())); + refs.root = + crate::packs::ref_state::RefStateTree::new(store.clone(), repository.object_format()) + .build_sorted( + operation(300 + 2 * number as u64), + records.into_iter().map(Ok), + ) + .await?; + refs.generation = fact.generation + 1; + let root = + RefStateSnapshotRoot::upload(&store, operation(301 + 2 * number as u64), refs).await?; + drop(snapshot); + install( + repository, + (fact.generation + 1) as i64, + fact.catalog.ok_or("joint catalog absent")?, + root, + ) + .await?; + repository.sql.batch(crate::server::mutation_identity()?,SqlBatch{statements:vec![ - SqlStatement{sql:"INSERT INTO refs(name,oid,version) VALUES(?1,?2,1),(?3,?4,1)".into(),parameters:vec![SqlValue::Text(source_ref.clone()),SqlValue::Blob(source.to_vec()),SqlValue::Text(base_ref.clone()),SqlValue::Blob(base.to_vec())]}, SqlStatement{sql:"INSERT INTO pull_requests(number,id,creation_digest,author,title,body,state,draft,version,source_ref,base_ref,initial_source_oid,initial_base_oid,created_ms,updated_ms) VALUES(?1,?2,?3,'canopy','native comparison','','open',0,1,?4,?5,?6,?7,0,0)".into(),parameters:vec![SqlValue::Integer(number),SqlValue::Blob(uuid::Uuid::new_v4().into_bytes().to_vec()),SqlValue::Blob(vec![42;32]),SqlValue::Text(source_ref),SqlValue::Text(base_ref),SqlValue::Blob(source.to_vec()),SqlValue::Blob(base.to_vec())]}, ]}).await?; Ok( @@ -331,7 +380,7 @@ async fn production_native_comparisons_read_certified_ancestry_patches_and_previ (2, native.main, native.side, native.side), (3, native.main, native.main, native.main), ] { - let target = editorial_pull(&repository, number, source, base).await?; + let target = editorial_pull(&server, &repository, number, source, base).await?; let response = request( &server, &format!("pulls/{number}/comparison"), @@ -389,7 +438,7 @@ async fn production_native_comparisons_read_certified_ancestry_patches_and_previ .find(|edge| edge.expected_kind == crate::ObjectKind::Blob) .ok_or("blob edge")? .child; - let target = editorial_pull(&repository, 4, blob, blob).await?; + let target = editorial_pull(&server, &repository, 4, blob, blob).await?; assert_eq!( request( &server, @@ -400,7 +449,8 @@ async fn production_native_comparisons_read_certified_ancestry_patches_and_previ .status(), reqwest::StatusCode::SERVICE_UNAVAILABLE ); - let target = editorial_pull(&repository, 5, missing(format), missing(format)).await?; + let target = + editorial_pull(&server, &repository, 5, missing(format), missing(format)).await?; assert_eq!( request( &server, @@ -540,7 +590,7 @@ async fn production_certified_edge_pages_cover_wide_trees_and_parent_boundaries( }) .ok_or("last parent")?; assert!(ordinal >= 512); - let target = editorial_pull(&repository, 1, wide, last.child).await?; + let target = editorial_pull(&server, &repository, 1, wide, last.child).await?; let response = request(&server,"pulls/1/comparison",json!({"repository_id":uuid::Uuid::from_bytes(entry.repository_id).to_string(),"target":target,"query":{"kind":"files"}})).await?; let status = response.status(); let body = response.text().await?; diff --git a/crates/canopy-server/src/server/residency/tests/serving/browser/checks.rs b/crates/canopy-server/src/server/residency/tests/serving/browser/checks.rs new file mode 100644 index 00000000..d7ff2ba8 --- /dev/null +++ b/crates/canopy-server/src/server/residency/tests/serving/browser/checks.rs @@ -0,0 +1,300 @@ +//! Receiver qualification uses real resident ownership and physical pack metadata. +//! Trusted generation installation isolates membership, not the writer pipeline. +use super::*; +use crate::checks::{ + CheckChange, CheckContextEdit, + native::{CheckStart, CommitPage, CommitSelection, ReadCommitChecks, StartCommitCheck}, +}; +use crate::packs::publication::CommitMembership; +use cellule_runtime::codec::BoundedDecoder; + +async fn select( + repository: &RepositoryCell, + actor: ReadIdentity<'_>, + oid: ObjectId, +) -> Result<(crate::packs::publication::ServingSnapshot, CommitSelection)> { + let snapshot = repository.serving_snapshot(actor).await?; + let membership = snapshot.commit_membership(oid).await?; + let selection = CommitSelection { + repository: repository.repository_id(), + actor: match actor { + ReadIdentity::Anonymous => None, + ReadIdentity::Account(v) => Some(v.into()), + }, + oid, + membership, + }; + Ok((snapshot, selection)) +} +async fn page(repository: &RepositoryCell, selection: CommitSelection) -> Result { + Ok(repository + .application + .query::( + &repository.target, + None, + CommitPage { + selection, + after: None, + }, + ) + .await? + .output + .is_some()) +} +async fn start( + repository: &RepositoryCell, + selection: CommitSelection, + version: i64, +) -> Result { + let result = repository + .application + .command::( + &repository.target, + crate::server::mutation_identity()?, + CheckStart { + selection, + id: uuid::Uuid::new_v4().into_bytes(), + context: "unit-tests".into(), + context_version: version, + }, + ) + .await; + match result { + Ok(value) => Ok(value.output), + Err(cellule_runtime::InvocationError::Rejected(value)) => Ok(value.output), + Err(error) => Err(error.into()), + } +} +fn damaged(proof: &CommitMembership) -> Result { + let mut e = BoundedEncoder::new(1024)?; + proof.encode(&mut e)?; + let mut bytes = e.finish(); + *bytes.last_mut().ok_or("empty membership")? ^= 1; + let mut d = BoundedDecoder::new(&bytes, 1024)?; + let proof = CommitMembership::decode(&mut d)?; + d.finish()?; + Ok(proof) +} + +#[tokio::test] +async fn native_check_receivers_bind_commit_actor_repository_and_live_retained_pin() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (server, _files) = server().await?; + let (repository, native, _) = fixture(&server, format).await?; + assert_eq!( + repository + .set_check_context( + crate::server::mutation_identity()?, + "canopy", + "unit-tests", + CheckContextEdit { + expected_version: 0, + reporter: "canopy", + enabled: true, + } + ) + .await? + .output, + CheckChange::Applied + ); + let (snapshot, selection) = + select(&repository, ReadIdentity::Account("canopy"), native.main).await?; + let proof = selection.membership.as_ref().ok_or("commit proof absent")?; + assert!(page(&repository, selection.clone()).await?); + assert_eq!( + start(&repository, selection.clone(), 1).await?, + CheckChange::Applied + ); + let mut invalid = Vec::new(); + let mut forged = selection.clone(); + forged.membership = Some(damaged(proof)?); + invalid.push(forged); + let mut wrong_actor = selection.clone(); + wrong_actor.actor = Some("outsider".into()); + invalid.push(wrong_actor); + let mut wrong_repository = selection.clone(); + wrong_repository.repository = uuid::Uuid::new_v4().into_bytes(); + invalid.push(wrong_repository); + let mut other_commit = selection.clone(); + other_commit.oid = native.previous; + invalid.push(other_commit); + let mut no_proof = selection.clone(); + no_proof.membership = None; + invalid.push(no_proof); + for oid in [native.tree, native.tag, missing(format)] { + assert!(snapshot.commit_membership(oid).await?.is_none()); + let mut substitution = selection.clone(); + substitution.oid = oid; + invalid.push(substitution); + } + for selection in invalid { + assert!(!page(&repository, selection.clone()).await?); + assert_eq!( + start(&repository, selection, 1).await?, + CheckChange::NotFound + ); + } + let other = create(&server.repositories, "other-checks", format).await?; + let (other, _, _) = loaded(&server.repositories, other.repository_id).await?; + assert!(!page(&other, selection.clone()).await?); + assert_eq!( + start(&other, selection.clone(), 1).await?, + CheckChange::NotFound + ); + drop(other); + // The exact retained fact remains valid while its original physical pin + // is live, even though current head no longer contains that commit. + let store = ArtifactStore::new( + server.repositories.external_store.clone(), + repository.repository_id(), + ); + let directory = DirectorySnapshot::empty(repository.repository_id(), format) + .upload(&store, operation(200)) + .await?; + let empty = CatalogSnapshot { + directory, + sources: None, + } + .upload(&store, operation(201)) + .await?; + install(&repository, 3, empty, native.refs).await?; + assert!(page(&repository, selection.clone()).await?); + assert_eq!( + start(&repository, selection.clone(), 1).await?, + CheckChange::Applied + ); + assert!( + repository + .commit_checks(ReadIdentity::Account("canopy"), native.main, None) + .await? + .output + .is_none() + ); + // A valid kind proof must not authorize an obsolete context version. + assert_eq!( + repository + .set_check_context( + crate::server::mutation_identity()?, + "canopy", + "unit-tests", + CheckContextEdit { + expected_version: 1, + reporter: "canopy", + enabled: true, + } + ) + .await? + .output, + CheckChange::Applied + ); + assert_eq!( + start(&repository, selection.clone(), 1).await?, + CheckChange::Conflict + ); + assert_eq!( + start(&repository, selection.clone(), 2).await?, + CheckChange::Applied + ); + // Real producer drain removes the lease; deleting SQL rows would not + // qualify the physical lifecycle which protects the proof's metadata. + drop(snapshot); + let (_, _, service) = loaded(&server.repositories, repository.repository_id()).await?; + timeout(Duration::from_secs(8), service.serving.close_and_drain()).await?; + assert_eq!(retained(&repository).await?, 0); + assert!(!page(&repository, selection.clone()).await?); + assert_eq!( + start(&repository, selection, 2).await?, + CheckChange::NotFound + ); + let rows = repository + .sql + .query( + None, + SqlBatch { + statements: vec![SqlStatement { + sql: "SELECT count(*) FROM check_runs".into(), + parameters: vec![], + }], + }, + ) + .await?; + assert_eq!(rows.output[0].rows, vec![vec![SqlValue::Integer(3)]]); + drop((service, repository)); + timeout(Duration::from_secs(15), server.shutdown()).await??; + } + Ok(()) +} + +#[tokio::test] +async fn native_check_receivers_recheck_revoked_membership_and_public_visibility() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (server, _files) = server().await?; + let (repository, native, _) = fixture(&server, format).await?; + repository + .grant_member( + crate::server::mutation_identity()?, + "canopy", + "ci", + crate::server::TokenScope::Read, + ) + .await?; + repository + .set_check_context( + crate::server::mutation_identity()?, + "canopy", + "unit-tests", + CheckContextEdit { + expected_version: 0, + reporter: "ci", + enabled: true, + }, + ) + .await?; + let (snapshot, selection) = + select(&repository, ReadIdentity::Account("ci"), native.main).await?; + assert_eq!( + start(&repository, selection.clone(), 1).await?, + CheckChange::Applied + ); + repository + .revoke_member(crate::server::mutation_identity()?, "canopy", "ci") + .await?; + assert!(!page(&repository, selection.clone()).await?); + assert_eq!( + start(&repository, selection, 1).await?, + CheckChange::NotFound + ); + repository + .sql + .batch( + crate::server::mutation_identity()?, + SqlBatch { + statements: vec![SqlStatement { + sql: "UPDATE ref_generation SET visibility='public' WHERE singleton=1" + .into(), + parameters: vec![], + }], + }, + ) + .await?; + let (public, selection) = select(&repository, ReadIdentity::Anonymous, native.main).await?; + assert!(page(&repository, selection.clone()).await?); + repository + .sql + .batch( + crate::server::mutation_identity()?, + SqlBatch { + statements: vec![SqlStatement { + sql: "UPDATE ref_generation SET visibility='private' WHERE singleton=1" + .into(), + parameters: vec![], + }], + }, + ) + .await?; + assert!(!page(&repository, selection).await?); + drop((snapshot, public, repository)); + timeout(Duration::from_secs(15), server.shutdown()).await??; + } + Ok(()) +} diff --git a/crates/canopy-server/src/server/residency/tests/serving/browser/pulls.rs b/crates/canopy-server/src/server/residency/tests/serving/browser/pulls.rs new file mode 100644 index 00000000..f98ffb70 --- /dev/null +++ b/crates/canopy-server/src/server/residency/tests/serving/browser/pulls.rs @@ -0,0 +1,955 @@ +//! Real resident, immutable ref roots and final typed receiver authorization. +use super::*; +use crate::pulls::{ + NewPull, NewReview, PullChange, PullEdit, PullRevision, PullState, ReviewKind, + native::{ + CreateData, CreateNativePull, CreateRequest, ReadData, ReadKind, ReadNativePulls, + ReadReply, ReadRequest, ReviewData, ReviewNativePull, ReviewRequest, + }, +}; +use cellule_runtime::codec::BoundedDecoder; +use cellule_runtime::{Committed, InvocationError}; + +mod candidates; + +fn data(native: &BrowseFixture) -> CreateData { + CreateData { + id: uuid::Uuid::new_v4().into_bytes(), + title: "Native review".into(), + body: "Immutable refs".into(), + draft: false, + source_ref: "refs/heads/main".into(), + source_oid: hex::encode(native.main), + base_ref: "refs/heads/side".into(), + base_oid: hex::encode(native.side), + } +} +async fn prepare( + repository: &RepositoryCell, + actor: &str, + data: CreateData, +) -> Result<(crate::packs::publication::ServingSnapshot, CreateRequest)> { + let snapshot = repository + .serving_snapshot(ReadIdentity::Account(actor)) + .await?; + let mut names = vec![data.source_ref.clone(), data.base_ref.clone()]; + names.sort(); + let selection = snapshot.ref_selection(data.digest()?, &names).await?; + Ok((snapshot, CreateRequest { selection, data })) +} +async fn execute( + repository: &RepositoryCell, + input: CreateRequest, +) -> Result> { + match repository + .application + .command::( + &repository.target, + crate::server::mutation_identity()?, + input, + ) + .await + { + Ok(value) => Ok(value), + Err(InvocationError::Rejected(value)) => Ok(*value), + Err(error) => Err(error.into()), + } +} +async fn count(repository: &RepositoryCell) -> Result { + let result = repository + .sql + .query( + None, + SqlBatch { + statements: vec![SqlStatement { + sql: "SELECT count(*) FROM pull_requests".into(), + parameters: vec![], + }], + }, + ) + .await?; + let Some([SqlValue::Integer(n)]) = result.output[0].rows.first().map(Vec::as_slice) else { + return Err("pull count missing".into()); + }; + Ok(*n) +} +fn damaged(proof: &T) -> Result { + let mut e = BoundedEncoder::new(1024)?; + proof.encode(&mut e)?; + let mut bytes = e.finish(); + *bytes.last_mut().ok_or("proof empty")? ^= 1; + let mut d = BoundedDecoder::new(&bytes, 1024)?; + let proof = T::decode(&mut d)?; + d.finish()?; + Ok(proof) +} +#[tokio::test] +async fn native_pull_receivers_bind_request_actor_cell_exact_refs_and_current_generation() -> Result +{ + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (server, _files) = server().await?; + let (repository, native, _) = fixture(&server, format).await?; + let (snapshot, input) = prepare(&repository, "canopy", data(&native)).await?; + assert_eq!( + execute(&repository, input.clone()).await?.output, + PullChange::Applied(1) + ); + assert_eq!( + repository + .pull("canopy", 1) + .await? + .output + .ok_or("pull absent")? + .summary + .source + .oid, + Some(hex::encode(native.main)) + ); + let mut invalid = Vec::new(); + let mut forged = input.clone(); + forged.selection.proof = Some(damaged( + forged.selection.proof.as_ref().ok_or("proof absent")?, + )?); + invalid.push(forged); + let mut changed_payload = input.clone(); + changed_payload.data.title.push_str(" substituted"); + invalid.push(changed_payload); + let mut wrong_repo = input.clone(); + wrong_repo.selection.repository = uuid::Uuid::new_v4().into_bytes(); + invalid.push(wrong_repo); + let mut changed_oid = input.clone(); + changed_oid.selection.facts[0] + .state + .as_mut() + .ok_or("ref absent")? + .oid = Some(native.previous); + invalid.push(changed_oid); + let mut changed_version = input.clone(); + changed_version.selection.facts[0] + .state + .as_mut() + .ok_or("ref absent")? + .version += 1; + invalid.push(changed_version); + let mut changed_name = input.clone(); + changed_name.selection.facts[0].name = "refs/heads/other".into(); + invalid.push(changed_name); + let mut missing_proof = input.clone(); + missing_proof.selection.proof = None; + invalid.push(missing_proof); + let mut missing_fact = input.clone(); + missing_fact.selection.facts.remove(0); + invalid.push(missing_fact); + repository + .grant_member( + crate::server::mutation_identity()?, + "canopy", + "reader", + crate::server::TokenScope::Read, + ) + .await?; + let mut wrong_actor = input.clone(); + wrong_actor.selection.actor = Some("reader".into()); + invalid.push(wrong_actor); + for input in invalid { + assert_eq!( + execute(&repository, input).await?.output, + PullChange::Conflict + ); + assert_eq!(count(&repository).await?, 1); + } + let entry = create(&server.repositories, "foreign-pull", format).await?; + let (other, _, _) = loaded(&server.repositories, entry.repository_id).await?; + assert_eq!( + execute(&other, input.clone()).await?.output, + PullChange::Conflict + ); + assert_eq!(count(&other).await?, 0); + drop(other); + let result = repository + .application + .query::( + &repository.target, + None, + ReadRequest { + selection: input.selection.clone(), + data: ReadData { + kind: ReadKind::Detail(1), + metadata: [42; 32], + }, + }, + ) + .await?; + assert!( + matches!(result.output, ReadReply::Changed), + "creation proof cannot authorize another purpose" + ); + // Large valid names increase the admitted request, not certificate size. + let mut long = data(&native); + long.source_ref = format!("refs/heads/{}", "x".repeat(60_000)); + let (long_snapshot, long_input) = prepare(&repository, "canopy", long).await?; + let mut e = BoundedEncoder::new(1024)?; + long_input + .selection + .proof + .as_ref() + .ok_or("long proof absent")? + .encode(&mut e)?; + assert!(e.finish().len() < 1024); + assert_eq!( + execute(&repository, long_input).await?.output, + PullChange::Conflict + ); + drop(long_snapshot); + // Same immutable roots under a later joint fact cannot authorize old ref policy. + install(&repository, 3, native.catalog, native.refs).await?; + assert_eq!( + execute(&repository, input.clone()).await?.output, + PullChange::Conflict + ); + let original = &input.data; + assert_eq!( + repository + .create_pull( + crate::server::mutation_identity()?, + "canopy", + NewPull { + id: original.id, + title: &original.title, + body: &original.body, + draft: original.draft, + source_ref: &original.source_ref, + source_oid: &original.source_oid, + base_ref: &original.base_ref, + base_oid: &original.base_oid + } + ) + .await? + .output, + PullChange::Applied(1) + ); + drop(snapshot); + timeout(Duration::from_secs(15), async { + let pool = repository + .serving + .lock() + .unwrap() + .as_ref() + .and_then(std::sync::Weak::upgrade) + .ok_or("pool absent")?; + pool.close_and_drain().await; + Result::Ok(()) + }) + .await??; + assert_eq!(retained(&repository).await?, 0); + assert_eq!( + execute(&repository, input).await?.output, + PullChange::Conflict + ); + drop(repository); + timeout(Duration::from_secs(15), server.shutdown()).await??; + } + Ok(()) +} +#[tokio::test] +async fn native_pull_receivers_recheck_late_revocation_without_creating_editorial_rows() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (server, _files) = server().await?; + let (repository, native, _) = fixture(&server, format).await?; + repository + .grant_member( + crate::server::mutation_identity()?, + "canopy", + "reader", + crate::server::TokenScope::Read, + ) + .await?; + let (snapshot, input) = prepare(&repository, "reader", data(&native)).await?; + repository + .revoke_member(crate::server::mutation_identity()?, "canopy", "reader") + .await?; + assert_eq!( + execute(&repository, input).await?.output, + PullChange::NotFound + ); + assert_eq!(count(&repository).await?, 0); + assert!(repository.pulls("reader", 0, None).await?.output.is_none()); + drop(snapshot); + drop(repository); + timeout(Duration::from_secs(15), server.shutdown()).await??; + } + Ok(()) +} + +async fn read_request( + repository: &RepositoryCell, + actor: ReadIdentity<'_>, +) -> Result<(crate::packs::publication::ServingSnapshot, ReadRequest)> { + read_kind_request(repository, actor, ReadKind::Detail(1)).await +} +async fn read_kind_request( + repository: &RepositoryCell, + actor: ReadIdentity<'_>, + kind: ReadKind, +) -> Result<(crate::packs::publication::ServingSnapshot, ReadRequest)> { + let selected = repository + .sql + .query( + None, + SqlBatch { + statements: vec![SqlStatement { + sql: "SELECT number,version,source_ref,base_ref FROM pull_requests WHERE number=1".into(), + parameters: vec![], + }], + }, + ) + .await?; + let (data, names) = ReadData::selected(kind, &selected.output[0].rows)?; + let snapshot = repository.serving_snapshot(actor).await?; + let selection = snapshot.ref_selection(data.digest()?, &names).await?; + Ok((snapshot, ReadRequest { selection, data })) +} +async fn read(repository: &RepositoryCell, input: ReadRequest) -> Result { + Ok(repository + .application + .query::(&repository.target, None, input) + .await? + .output) +} +async fn prepared_policy( + repository: &RepositoryCell, + request: &ReadRequest, +) -> Result> { + let ReadReply::Rows(Some(mut sets)) = read(repository, request.clone()).await? else { + return Err("prepared native policy unavailable".into()); + }; + if sets.len() != 1 || sets[0].rows.len() != 1 { + return Err("prepared native policy row count".into()); + } + Ok(sets.remove(0).rows.remove(0)) +} +async fn review(repository: &RepositoryCell, input: ReviewRequest) -> Result { + match repository + .application + .command::( + &repository.target, + crate::server::mutation_identity()?, + input, + ) + .await + { + Ok(value) => Ok(value.output), + Err(InvocationError::Rejected(value)) => Ok(value.output), + Err(error) => Err(error.into()), + } +} + +#[tokio::test] +async fn native_pull_receivers_recheck_editorial_version_and_review_payload_in_final_transaction() +-> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (server, _files) = server().await?; + let (repository, native, _) = fixture(&server, format).await?; + let (creation, input) = prepare(&repository, "canopy", data(&native)).await?; + assert_eq!( + execute(&repository, input).await?.output, + PullChange::Applied(1) + ); + repository + .grant_member( + crate::server::mutation_identity()?, + "canopy", + "reviewer", + crate::server::TokenScope::Write, + ) + .await?; + let (observation, old_read) = + read_request(&repository, ReadIdentity::Account("canopy")).await?; + assert!(matches!( + read(&repository, old_read.clone()).await?, + ReadReply::Rows(Some(_)) + )); + let data = ReviewData { + number: 1, + id: uuid::Uuid::new_v4().into_bytes(), + revision: PullRevision { + pull_version: 1, + source_oid: hex::encode(native.main), + source_version: 1, + base_oid: hex::encode(native.side), + base_version: 1, + }, + kind: ReviewKind::Approve, + body: "Approved exact revision".into(), + }; + let reviewer = repository + .serving_snapshot(ReadIdentity::Account("reviewer")) + .await?; + let selection = reviewer + .ref_selection( + data.digest()?, + &["refs/heads/main".into(), "refs/heads/side".into()], + ) + .await?; + let old_review = ReviewRequest { selection, data }; + let mut altered = old_review.clone(); + altered.data.body.push_str(" substituted"); + assert_eq!(review(&repository, altered).await?, PullChange::Conflict); + assert_eq!( + repository + .edit_pull( + crate::server::mutation_identity()?, + "canopy", + 1, + PullEdit { + expected_version: 1, + title: "Edited after preparation", + body: "", + state: PullState::Open, + draft: false, + } + ) + .await? + .output, + PullChange::Applied(1) + ); + // Git generation is unchanged. The final query must still detect the + // intervening editorial edit; a prepared review cannot approve it. + assert!(matches!( + read(&repository, old_read).await?, + ReadReply::Changed + )); + assert_eq!( + review(&repository, old_review.clone()).await?, + PullChange::Conflict + ); + assert!( + repository + .pull_reviews("canopy", 1, 0) + .await? + .output + .ok_or("reviews absent")? + .is_empty() + ); + let (fresh, request) = read_request(&repository, ReadIdentity::Account("canopy")).await?; + assert!(matches!( + read(&repository, request).await?, + ReadReply::Rows(Some(_)) + )); + repository + .revoke_member(crate::server::mutation_identity()?, "canopy", "reviewer") + .await?; + assert_eq!(review(&repository, old_review).await?, PullChange::NotFound); + assert!( + repository + .pull_reviews("canopy", 1, 0) + .await? + .output + .ok_or("reviews absent")? + .is_empty() + ); + drop((creation, observation, reviewer, fresh, repository)); + timeout(Duration::from_secs(15), server.shutdown()).await??; + } + Ok(()) +} + +#[tokio::test] +async fn native_pull_receivers_recheck_anonymous_visibility_after_proof_preparation() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (server, _files) = server().await?; + let (repository, native, _) = fixture(&server, format).await?; + let (creation, input) = prepare(&repository, "canopy", data(&native)).await?; + assert_eq!( + execute(&repository, input).await?.output, + PullChange::Applied(1) + ); + repository + .sql + .batch( + crate::server::mutation_identity()?, + SqlBatch { + statements: vec![SqlStatement { + sql: "UPDATE ref_generation SET visibility='public' WHERE singleton=1" + .into(), + parameters: vec![], + }], + }, + ) + .await?; + let (snapshot, request) = read_request(&repository, ReadIdentity::Anonymous).await?; + assert!(matches!( + read(&repository, request.clone()).await?, + ReadReply::Rows(Some(_)) + )); + assert_eq!( + repository + .pulls(ReadIdentity::Anonymous, 0, None) + .await? + .output + .ok_or("public list absent")? + .len(), + 1 + ); + repository + .sql + .batch( + crate::server::mutation_identity()?, + SqlBatch { + statements: vec![SqlStatement { + sql: "UPDATE ref_generation SET visibility='private' WHERE singleton=1" + .into(), + parameters: vec![], + }], + }, + ) + .await?; + assert!(matches!( + read(&repository, request).await?, + ReadReply::Rows(None) + )); + assert!( + repository + .pulls(ReadIdentity::Anonymous, 0, None) + .await? + .output + .is_none() + ); + drop((creation, snapshot, repository)); + timeout(Duration::from_secs(15), server.shutdown()).await??; + } + Ok(()) +} + +async fn replace_main_ref( + server: &crate::server::RunningServer, + repository: &RepositoryCell, + oid: Option, + expected: crate::refs::RefExpectation, +) -> Result { + let store = Arc::new(ArtifactStore::new( + server.repositories.external_store.clone(), + repository.repository_id(), + )); + let snapshot = repository + .serving_snapshot(ReadIdentity::Account("canopy")) + .await?; + let fact = snapshot.fact(); + let mut refs = fact.refs.ok_or("refs absent")?.read(&store).await?; + let index = + crate::packs::ref_state::RefStateIndex::new(store.clone(), repository.object_format()); + let transition = index + .prepare( + refs.root.clone(), + operation(500 + 2 * fact.generation), + &crate::PushPlan { + actor: "canopy".into(), + updates: vec![crate::RefUpdate { + name: "refs/heads/main".into(), + expected: Some(expected), + new_oid: oid, + }], + }, + ) + .await?; + refs.root = Some(transition.root()); + refs.generation = fact.generation + 1; + let root = + RefStateSnapshotRoot::upload(&store, operation(501 + 2 * fact.generation), refs).await?; + drop(snapshot); + // Trusted immutable-root fixture; this qualifies the reader, not a merge writer. + install( + repository, + (fact.generation + 1) as i64, + fact.catalog.ok_or("catalog absent")?, + root, + ) + .await +} + +#[tokio::test] +async fn native_review_policy_observes_current_rules_reviews_membership_and_ref_aba() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (server, _files) = server().await?; + let (repository, native, _) = fixture(&server, format).await?; + let (creation, input) = prepare(&repository, "canopy", data(&native)).await?; + assert_eq!( + execute(&repository, input).await?.output, + PullChange::Applied(1) + ); + let initial = repository + .pull_review_policy("canopy", 1) + .await? + .output + .ok_or("native review policy missing")?; + assert!(initial.ready && initial.reviews_satisfied); + assert_eq!(initial.required_approvals, 0); + let revision = initial.revision.ok_or("revision absent")?; + assert_eq!(revision.source_oid, hex::encode(native.main)); + assert_eq!(revision.base_oid, hex::encode(native.side)); + let (policy_snapshot, policy_request) = read_kind_request( + &repository, + ReadIdentity::Account("canopy"), + ReadKind::ReviewPolicy(1), + ) + .await?; + let mut different_purpose = policy_request.clone(); + different_purpose.data.kind = ReadKind::Detail(1); + assert!(matches!( + read(&repository, different_purpose).await?, + ReadReply::Changed + )); + let mut different_number = policy_request.clone(); + different_number.data.kind = ReadKind::ReviewPolicy(2); + assert!(matches!( + read(&repository, different_number).await?, + ReadReply::Changed + )); + repository + .set_branch_rule( + crate::server::mutation_identity()?, + "canopy", + crate::branch_rules::BranchRuleEdit { + reference: "refs/heads/side".into(), + expected_version: 0, + enabled: true, + deny_deletions: false, + fast_forward_only: false, + required_checks: vec![], + require_pull_request: true, + required_approvals: 2, + }, + ) + .await?; + let mut old = None; + for reviewer in ["one", "two"] { + repository + .grant_member( + crate::server::mutation_identity()?, + "canopy", + reviewer, + crate::server::TokenScope::Write, + ) + .await?; + let id = uuid::Uuid::new_v4().into_bytes(); + let value = repository + .review_pull( + crate::server::mutation_identity()?, + reviewer, + 1, + NewReview { + id, + revision: &revision, + kind: ReviewKind::Approve, + body: "Reviewed exact refs", + }, + ) + .await?; + assert!(matches!(value.output, PullChange::Applied(_))); + if reviewer == "two" { + old = Some(id); + } + } + let current = repository + .pull_review_policy("canopy", 1) + .await? + .output + .ok_or("policy absent")?; + assert_eq!( + ( + current.rule_version, + current.required_approvals, + current.approvals + ), + (1, 2, 2) + ); + assert!(current.reviews_satisfied); + // Prepared before the rule and reviews existed: final policy is fresh + // SQL, while the exact native refs and editorial selection stay bound. + let prepared = prepared_policy(&repository, &policy_request).await?; + assert_eq!( + &prepared[9..14], + &[ + SqlValue::Integer(1), + SqlValue::Integer(1), + SqlValue::Integer(2), + SqlValue::Integer(2), + SqlValue::Integer(0), + ] + ); + let (reviewer_snapshot, reviewer_request) = read_kind_request( + &repository, + ReadIdentity::Account("two"), + ReadKind::ReviewPolicy(1), + ) + .await?; + repository + .revoke_member(crate::server::mutation_identity()?, "canopy", "two") + .await?; + assert!(matches!( + read(&repository, reviewer_request).await?, + ReadReply::Rows(None) + )); + repository + .grant_member( + crate::server::mutation_identity()?, + "canopy", + "two", + crate::server::TokenScope::Write, + ) + .await?; + let retry = repository + .review_pull( + crate::server::mutation_identity()?, + "two", + 1, + NewReview { + id: old.ok_or("old review absent")?, + revision: &revision, + kind: ReviewKind::Approve, + body: "Reviewed exact refs", + }, + ) + .await?; + assert!(matches!(retry.output, PullChange::Applied(_))); + let current = repository + .pull_review_policy("canopy", 1) + .await? + .output + .ok_or("policy absent")?; + assert_eq!(current.approvals, 1); + assert!(!current.reviews_satisfied); + assert_eq!( + prepared_policy(&repository, &policy_request).await?[12], + SqlValue::Integer(1) + ); + repository + .review_pull( + crate::server::mutation_identity()?, + "two", + 1, + NewReview { + id: uuid::Uuid::new_v4().into_bytes(), + revision: &revision, + kind: ReviewKind::Approve, + body: "Fresh grant", + }, + ) + .await?; + assert_eq!( + repository + .pull_review_policy("canopy", 1) + .await? + .output + .ok_or("policy absent")? + .approvals, + 2 + ); + for (kind, approvals, changes) in [ + (ReviewKind::RequestChanges, 1, 1), + (ReviewKind::Comment, 1, 1), + (ReviewKind::Approve, 2, 0), + ] { + repository + .review_pull( + crate::server::mutation_identity()?, + "two", + 1, + NewReview { + id: uuid::Uuid::new_v4().into_bytes(), + revision: &revision, + kind, + body: "Current decision", + }, + ) + .await?; + let current = prepared_policy(&repository, &policy_request).await?; + assert_eq!( + ¤t[12..14], + &[SqlValue::Integer(approvals), SqlValue::Integer(changes)] + ); + let public = repository + .pull_review_policy("canopy", 1) + .await? + .output + .ok_or("policy absent")?; + assert_eq!(public.reviews_satisfied, kind == ReviewKind::Approve); + } + replace_main_ref( + &server, + &repository, + None, + crate::refs::RefExpectation { + oid: Some(native.main), + version: 1, + }, + ) + .await?; + let deleted = repository + .pull_review_policy("canopy", 1) + .await? + .output + .ok_or("deleted policy absent")?; + assert!(!deleted.ready && deleted.revision.is_none()); + assert_eq!(deleted.approvals, 0); + assert!(matches!( + read(&repository, policy_request).await?, + ReadReply::Changed + )); + replace_main_ref( + &server, + &repository, + Some(native.main), + crate::refs::RefExpectation { + oid: None, + version: 2, + }, + ) + .await?; + let recreated = repository + .pull_review_policy("canopy", 1) + .await? + .output + .ok_or("recreated policy absent")?; + assert!(recreated.ready); + assert_eq!( + recreated + .revision + .ok_or("recreated revision absent")? + .source_version, + 3 + ); + assert_eq!(recreated.approvals, 0); + assert!(!recreated.reviews_satisfied); + drop((creation, policy_snapshot, reviewer_snapshot, repository)); + timeout(Duration::from_secs(15), server.shutdown()).await??; + } + Ok(()) +} + +#[tokio::test] +async fn native_thread_receiver_binds_verified_anchor_and_rechecks_editorial_revision() -> Result { + use crate::git_read::{ComparisonTarget, Side, patch::LineAnchor}; + use crate::pulls::{ + native::CreateNativeThread, + native::threads::{ThreadData, ThreadRequest}, + threads::ThreadIntent, + }; + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (server, _files) = server().await?; + let (repository, native, _) = fixture(&server, format).await?; + let (creation, input) = prepare(&repository, "canopy", data(&native)).await?; + assert_eq!( + execute(&repository, input).await?.output, + PullChange::Applied(1) + ); + let revision = PullRevision { + pull_version: 1, + source_oid: hex::encode(native.main), + source_version: 1, + base_oid: hex::encode(native.side), + base_version: 1, + }; + let data = ThreadData { + number: 1, + intent: ThreadIntent { + id: uuid::Uuid::new_v4().into_bytes(), + target: ComparisonTarget::Current { + revision: revision.clone(), + }, + path_base64: "ZmlsZQ".into(), + side: Side::After, + line: 1, + body: "Original discussion".into(), + }, + anchor: LineAnchor { + revision, + merge_base: hex::encode(native.side), + path_base64: "ZmlsZQ".into(), + side: Side::After, + line: 1, + blob_oid: hex::encode(native.main), + }, + }; + let snapshot = repository + .serving_snapshot(ReadIdentity::Account("canopy")) + .await?; + let selection = snapshot + .ref_selection( + data.digest()?, + &["refs/heads/main".into(), "refs/heads/side".into()], + ) + .await?; + // Each substitution keeps valid shape but must invalidate its purpose MAC. + let encoded = serde_json::to_vec(&data)?; + for change in ["body", "blob", "line"] { + let mut substituted: ThreadData = serde_json::from_slice(&encoded)?; + match change { + "body" => substituted.intent.body.push_str(" substituted"), + "blob" => substituted.anchor.blob_oid = hex::encode(native.side), + _ => { + substituted.intent.line = 2; + substituted.anchor.line = 2; + } + } + let result = repository + .application + .command::( + &repository.target, + crate::server::mutation_identity()?, + ThreadRequest { + selection: selection.clone(), + data: substituted, + }, + ) + .await; + let Err(InvocationError::Rejected(result)) = result else { + return Err("substituted anchor accepted".into()); + }; + assert_eq!(result.output, PullChange::Conflict); + assert!( + repository + .threads("canopy", 1, 0) + .await? + .ok_or("thread page absent")? + .is_empty() + ); + } + repository + .edit_pull( + crate::server::mutation_identity()?, + "canopy", + 1, + PullEdit { + expected_version: 1, + title: "Edited while anchor was prepared", + body: "", + state: PullState::Open, + draft: false, + }, + ) + .await?; + let result = repository + .application + .command::( + &repository.target, + crate::server::mutation_identity()?, + ThreadRequest { selection, data }, + ) + .await; + let Err(InvocationError::Rejected(result)) = result else { + return Err("stale anchor accepted".into()); + }; + assert_eq!(result.output, PullChange::Conflict); + assert!( + repository + .threads("canopy", 1, 0) + .await? + .ok_or("thread page absent")? + .is_empty() + ); + drop((snapshot, creation, repository)); + timeout(Duration::from_secs(15), server.shutdown()).await??; + } + Ok(()) +} diff --git a/crates/canopy-server/src/server/residency/tests/serving/browser/pulls/candidates.rs b/crates/canopy-server/src/server/residency/tests/serving/browser/pulls/candidates.rs new file mode 100644 index 00000000..5f36c801 --- /dev/null +++ b/crates/canopy-server/src/server/residency/tests/serving/browser/pulls/candidates.rs @@ -0,0 +1,474 @@ +//! Native candidate editorial preparation against actual resident ref facts. +use super::*; +use crate::pulls::{ + candidates::{ + CandidateOutcome, CandidateRequest, CandidateResult, + command::{CandidateAction, CandidateRefRequest, PrepareCandidate}, + }, + merge::MergeStrategy, +}; + +async fn intent(repository: &RepositoryCell, native: &BrowseFixture) -> Result { + let data = data(native); + let created = repository + .create_pull(crate::server::mutation_identity()?, "canopy", data.view()) + .await?; + let PullChange::Applied(number) = created.output else { + return Err("candidate pull refused".into()); + }; + let revision = repository + .pull_review_policy("canopy", number) + .await? + .output + .and_then(|policy| policy.revision) + .ok_or("candidate revision missing")?; + Ok(CandidateAction::Reserve { + actor: "canopy".into(), + number, + request: CandidateRequest { + id: uuid::Uuid::new_v4().to_string(), + revision, + strategy: MergeStrategy::MergeCommit, + message: "Native candidate".into(), + }, + created_ms: crate::server::mutation_identity()?.issued_at_ms, + }) +} + +async fn reserve( + repository: &RepositoryCell, + input: CandidateRefRequest, +) -> Result { + match repository + .application + .command::( + &repository.target, + crate::server::mutation_identity()?, + input, + ) + .await + { + Ok(value) => Ok(value.output), + Err(InvocationError::Rejected(value)) => Ok(value.output), + Err(error) => Err(error.into()), + } +} +async fn candidate_count(repository: &RepositoryCell) -> Result { + let value = repository + .sql + .query( + None, + SqlBatch { + statements: vec![SqlStatement { + sql: "SELECT count(*) FROM merge_candidates".into(), + parameters: vec![], + }], + }, + ) + .await?; + let Some([SqlValue::Integer(count)]) = value.output[0].rows.first().map(Vec::as_slice) else { + return Err("candidate count missing".into()); + }; + Ok(*count) +} + +async fn joint_state(repository: &RepositoryCell) -> Result>> { + let value=repository.sql.query(None,SqlBatch {statements:vec![SqlStatement { + sql:"SELECT s.generation,g.catalog,g.certificate,g.refs FROM catalog_state s JOIN catalog_generations g ON g.generation=s.generation WHERE s.singleton=1".into(),parameters:vec![], + }]}).await?; + Ok(value + .output + .into_iter() + .next() + .ok_or("candidate joint state missing")? + .rows) +} + +async fn fault_sql(handle: &cellule_runtime::cell::actor::CellHandle, sql: &'static str) -> Result { + let identity = crate::server::mutation_identity()?; + handle + .execute( + identity, + cellule_runtime::Digest::from_bytes(*blake3::hash(sql.as_bytes()).as_bytes()), + identity.issued_at_ms, + sql.len(), + 0, + move |tx| { + tx.execute_batch(sql)?; + Ok(cellule_runtime::cell::executor::HandlerOutcome::Success( + Vec::new(), + )) + }, + ) + .await?; + Ok(()) +} + +#[tokio::test] +async fn native_candidate_reservation_uses_certified_refs_without_legacy_ref_authority() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (server, _files) = server().await?; + let (repository, native, _) = fixture(&server, format).await?; + let action = intent(&repository, &native).await?; + let joint = joint_state(&repository).await?; + let CandidateOutcome::Applied(candidate) = repository + .candidate_action(crate::server::mutation_identity()?, action.clone()) + .await? + .output + else { + return Err("native candidate reservation refused".into()); + }; + assert_eq!(candidate.result, CandidateResult::Pending); + let original = (*candidate).clone(); + assert_eq!( + candidate.request.revision.source_oid, + hex::encode(native.main) + ); + assert_eq!( + candidate.request.revision.base_oid, + hex::encode(native.side) + ); + assert_eq!( + repository + .merge_candidate("canopy", candidate.number, &candidate.request.id) + .await? + .output, + Some(*candidate) + ); + let legacy = repository + .sql + .query( + None, + SqlBatch { + statements: vec![SqlStatement { + sql: "SELECT count(*) FROM refs".into(), + parameters: vec![], + }], + }, + ) + .await?; + assert_eq!(legacy.output[0].rows[0], vec![SqlValue::Integer(0)]); + // Logical UUID replay keeps the first creation timestamp and intent. + let CandidateAction::Reserve { + mut created_ms, + actor, + number, + request, + } = action + else { + return Err("reserve intent missing".into()); + }; + created_ms += 100; + let replay = CandidateAction::Reserve { + created_ms, + actor: actor.clone(), + number, + request: request.clone(), + }; + let CandidateOutcome::Applied(replayed) = repository + .candidate_action(crate::server::mutation_identity()?, replay) + .await? + .output + else { + return Err("candidate UUID replay refused".into()); + }; + assert_eq!(*replayed, original); + let mut collision = request.clone(); + collision.message.push_str(" changed"); + assert!( + matches!(repository.candidate_action(crate::server::mutation_identity()?, CandidateAction::Reserve { + actor:actor.clone(), number, request:collision, created_ms, + }).await, Err(InvocationError::Rejected(value)) if matches!(value.output,CandidateOutcome::Conflict)) + ); + assert_eq!(candidate_count(&repository).await?, 1); + + // Negative preparation changes only editorial state. Its first result + // wins; it cannot create a fetch ref or claim generated publication. + let finish = CandidateAction::Finish { + actor: actor.clone(), + id: request.id.clone(), + result: CandidateResult::Unrelated, + }; + let CandidateOutcome::Applied(finished) = repository + .candidate_action(crate::server::mutation_identity()?, finish) + .await? + .output + else { + return Err("negative candidate finish refused".into()); + }; + assert_eq!(finished.result, CandidateResult::Unrelated); + let CandidateOutcome::Applied(replayed) = repository + .candidate_action( + crate::server::mutation_identity()?, + CandidateAction::Finish { + actor: actor.clone(), + id: request.id.clone(), + result: CandidateResult::Conflicted { + paths_base64: vec![], + }, + }, + ) + .await? + .output + else { + return Err("negative result replay refused".into()); + }; + assert_eq!(*finished, *replayed); + let attempted = CandidateAction::Finish { + actor: actor.clone(), + id: request.id.clone(), + result: CandidateResult::Ready { + oid: hex::encode(native.main), + tree_oid: hex::encode(native.tree), + }, + }; + assert!( + repository + .prepare_candidate_action(attempted.clone()) + .await + .is_err() + ); + // Even an authentic current read observation bound to this exact Ready + // payload cannot authorize generated catalog/ref publication. + let snapshot = repository + .serving_snapshot(ReadIdentity::Account(&actor)) + .await?; + let selection = snapshot + .ref_selection( + attempted.digest()?, + &["refs/heads/main".into(), "refs/heads/side".into()], + ) + .await?; + assert!( + reserve( + &repository, + CandidateRefRequest { + selection, + action: attempted + } + ) + .await + .is_err() + ); + assert_eq!( + repository + .merge_candidate("canopy", number, &request.id) + .await? + .output, + Some(*finished) + ); + drop(snapshot); + assert_eq!(candidate_count(&repository).await?, 1); + assert_eq!(joint_state(&repository).await?, joint); + server.shutdown().await?; + } + Ok(()) +} + +#[tokio::test] +async fn native_candidate_receiver_binds_purpose_payload_actor_cell_and_current_generation() +-> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (server, _files) = server().await?; + let (repository, native, _) = fixture(&server, format).await?; + let action = intent(&repository, &native).await?; + let (snapshot, input) = repository.prepare_candidate_action(action).await?; + let mut invalid = Vec::new(); + let mut payload = input.clone(); + let CandidateAction::Reserve { request, .. } = &mut payload.action else { + return Err("reserve missing".into()); + }; + request.message.push_str(" substituted"); + invalid.push(payload); + let mut facts = input.clone(); + facts.selection.facts[0] + .state + .as_mut() + .ok_or("ref absent")? + .version += 1; + invalid.push(facts); + let mut missing = input.clone(); + missing.selection.proof = None; + invalid.push(missing); + let mut forged = input.clone(); + forged.selection.proof = Some(damaged( + forged.selection.proof.as_ref().ok_or("proof absent")?, + )?); + invalid.push(forged); + let mut wrong_repository = input.clone(); + wrong_repository.selection.repository = uuid::Uuid::new_v4().into_bytes(); + invalid.push(wrong_repository); + let (pull_snapshot, pull_input) = prepare(&repository, "canopy", data(&native)).await?; + let mut purpose = input.clone(); + purpose.selection = pull_input.selection; + invalid.push(purpose); + for refused in invalid { + assert!(matches!( + reserve(&repository, refused).await?, + CandidateOutcome::Conflict + )); + assert_eq!(candidate_count(&repository).await?, 0); + } + let mut wrong_actor = input.clone(); + wrong_actor.selection.actor = Some("another-actor".into()); + assert!(reserve(&repository, wrong_actor).await.is_err()); + let foreign = create(&server.repositories, "candidate-foreign", format).await?; + let (other, _, _) = loaded(&server.repositories, foreign.repository_id).await?; + assert!(matches!( + reserve(&other, input.clone()).await?, + CandidateOutcome::Conflict + )); + assert_eq!(candidate_count(&other).await?, 0); + // Retaining identical roots under a new joint generation still fences + // an old serving observation; it must not reserve an editorial row. + install(&repository, 3, native.catalog, native.refs).await?; + assert!(matches!( + reserve(&repository, input).await?, + CandidateOutcome::Conflict + )); + assert_eq!(candidate_count(&repository).await?, 0); + drop((snapshot, pull_snapshot)); + server.shutdown().await?; + } + Ok(()) +} + +#[tokio::test] +async fn native_candidate_receiver_rechecks_late_access_and_editorial_revision() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (server, _files) = server().await?; + let (repository, native, _) = fixture(&server, format).await?; + let action = intent(&repository, &native).await?; + let (snapshot, input) = repository.prepare_candidate_action(action.clone()).await?; + repository + .edit_pull( + crate::server::mutation_identity()?, + "canopy", + 1, + PullEdit { + expected_version: 1, + title: "Changed after preparation", + body: "", + state: PullState::Open, + draft: false, + }, + ) + .await?; + assert!(matches!( + reserve(&repository, input).await?, + CandidateOutcome::Conflict + )); + assert_eq!(candidate_count(&repository).await?, 0); + drop(snapshot); + let CandidateAction::Reserve { + request, + created_ms, + .. + } = action + else { + return Err("reserve missing".into()); + }; + for role in [None, Some(crate::server::TokenScope::Read)] { + repository + .grant_member( + crate::server::mutation_identity()?, + "canopy", + "candidate-writer", + crate::server::TokenScope::Write, + ) + .await?; + let mut request = request.clone(); + request.id = uuid::Uuid::new_v4().to_string(); + request.revision.pull_version = 2; + let (snapshot, input) = repository + .prepare_candidate_action(CandidateAction::Reserve { + actor: "candidate-writer".into(), + number: 1, + request, + created_ms, + }) + .await?; + if let Some(role) = role { + repository + .grant_member( + crate::server::mutation_identity()?, + "canopy", + "candidate-writer", + role, + ) + .await?; + } else { + repository + .revoke_member( + crate::server::mutation_identity()?, + "canopy", + "candidate-writer", + ) + .await?; + } + let result = reserve(&repository, input).await?; + assert!(matches!( + (role, result), + (None, CandidateOutcome::NotFound) | (Some(_), CandidateOutcome::Forbidden) + )); + assert_eq!(candidate_count(&repository).await?, 0); + drop(snapshot); + } + server.shutdown().await?; + } + Ok(()) +} + +#[tokio::test] +async fn native_candidate_reservation_sql_failure_rolls_back_sdk_acceptance_and_retries_original_command() +-> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (server, _files) = server().await?; + let (repository, native, _) = fixture(&server, format).await?; + let action = intent(&repository, &native).await?; + let (snapshot, input) = repository.prepare_candidate_action(action).await?; + let (_, client, _) = loaded(&server.repositories, repository.id).await?; + let command = client + .prepare_command::( + &repository.target, + crate::server::mutation_identity()?, + input, + ) + .await?; + let joint = joint_state(&repository).await?; + let handle = server + .node + .runtime() + .resident_handle( + &repository.target, + cellule_runtime::cell::catalog::CatalogRole::Sql, + ) + .await? + .ok_or("candidate Cell not resident")?; + // Fault installation belongs to the trusted test harness. The product + // SQL primitive correctly refuses statement separators in trigger DDL. + fault_sql(&handle,"CREATE TRIGGER candidate_insert_fault AFTER INSERT ON merge_candidates BEGIN SELECT RAISE(ABORT,'candidate insertion fault'); END").await?; + assert!(command.clone().execute().await.is_err()); + assert_eq!(candidate_count(&repository).await?, 0); + assert_eq!(joint_state(&repository).await?, joint); + assert!(matches!( + client.resolve(command.evidence()).await?, + cellule_runtime::Resolution::Absent + )); + fault_sql(&handle, "DROP TRIGGER candidate_insert_fault").await?; + let applied = command.clone().execute().await?; + let replayed = command.execute().await?; + assert_eq!(applied.receipt, replayed.receipt); + let (CandidateOutcome::Applied(applied), CandidateOutcome::Applied(replayed)) = + (applied.output, replayed.output) + else { + return Err("original candidate command refused".into()); + }; + assert_eq!(applied, replayed); + assert_eq!(candidate_count(&repository).await?, 1); + assert_eq!(joint_state(&repository).await?, joint); + drop(snapshot); + server.shutdown().await?; + } + Ok(()) +} diff --git a/crates/canopy-server/src/server/residency/tests/staging.rs b/crates/canopy-server/src/server/residency/tests/staging.rs new file mode 100644 index 00000000..7b785590 --- /dev/null +++ b/crates/canopy-server/src/server/residency/tests/staging.rs @@ -0,0 +1,659 @@ +//! Production residency owns staging independently of request observers. +use super::recovery::{create, loaded, server}; +use super::*; +use crate::packs::publication::{ + BeginRequest, DEFAULT_LEASE_MS, ReadyStaging, StagingError, StagingState, StagingTicket, +}; +use crate::{ObjectFormat, server::mutation_identity}; +use tokio::{ + sync::oneshot, + time::{Duration, timeout}, +}; +type Result = std::result::Result>; + +async fn ready( + repository: &RepositoryCell, + client: CellClient, + operation: u8, +) -> Result { + Ok(ReadyStaging::new( + client, + repository.target.clone(), + BeginRequest { + repository: repository.id, + operation: [operation; 16], + request_digest: [operation; 32], + actor: "canopy".into(), + lease_ms: DEFAULT_LEASE_MS, + }, + mutation_identity()?, + ) + .await?) +} +async fn active(ticket: &StagingTicket) -> Result { + match timeout(Duration::from_secs(10), ticket.wait()).await? { + StagingState::Active(_) => Ok(()), + other => Err(format!("production staging refused: {other:?}").into()), + } +} + +struct Release(Option>); +impl Drop for Release { + fn drop(&mut self) { + if let Some(sender) = self.0.take() { + let _ = sender.send(()); + } + } +} + +struct DriverDropped(Arc); +impl Drop for DriverDropped { + fn drop(&mut self) { + self.0.store(true, std::sync::atomic::Ordering::Release); + } +} +fn driver_request(repository: &RepositoryCell, operation: u8) -> BeginRequest { + BeginRequest { + repository: repository.id, + operation: [operation; 16], + request_digest: [operation; 32], + actor: "canopy".into(), + lease_ms: DEFAULT_LEASE_MS, + } +} + +struct DriverOwnedFailure { + _pin: crate::git_objects::ReadOwner, + message: String, +} +impl std::fmt::Debug for DriverOwnedFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DriverOwnedFailure").finish_non_exhaustive() + } +} +impl std::fmt::Display for DriverOwnedFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} +impl std::error::Error for DriverOwnedFailure {} + +#[tokio::test] +async fn production_push_driver_survives_observer_loss_binds_and_joins_before_resident_release() +-> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (server, _files) = server().await?; + let manager = server.repositories.clone(); + let entry = create(&manager, "driver-bind", format).await?; + let (repository, _, service) = loaded(&manager, entry.repository_id).await?; + let coordinator = repository.staging_coordinator()?; + let request = driver_request(&repository, 180); + let ready = coordinator + .ready_request(request.clone(), mutation_identity()?) + .await?; + let ticket = coordinator.submit(ready).map_err(|(error, _)| error)?; + // Join identity is checked even before Begin has produced a token. + assert!(coordinator.join_request(&request)?.is_some()); + let mut wrong = request.clone(); + wrong.request_digest[0] ^= 1; + assert!(coordinator.join_request(&wrong).is_err()); + let mut wrong = request.clone(); + wrong.actor = "another-account".into(); + assert!(coordinator.join_request(&wrong).is_err()); + let mut foreign = request.clone(); + foreign.repository = *uuid::Uuid::new_v4().as_bytes(); + assert!(coordinator.join_request(&foreign).is_err()); + let (bound, bound_observer) = oneshot::channel(); + let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let owned = DriverDropped(dropped.clone()); + ticket.drive(move |ticket, publication| async move { + let _owned = owned; + if !matches!(ticket.wait().await, StagingState::Active(_)) { + return Err(StagingError::Context); + } + if publication.stats().await.closed { + return Err(StagingError::Closed); + } + let work = ticket.spawn(|context| async move { context.token() })?; + let _token = work + .wait() + .await + .map_err(|error| StagingError::Input(Box::new(error)))?; + ticket.seal()?; + if !matches!(ticket.wait_terminal().await, StagingState::Bound(_)) { + return Err(StagingError::Context); + } + let _ = bound.send(()); + std::future::pending::>().await + })?; + let called = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let attempt = called.clone(); + assert!(matches!( + ticket.drive(move |_, _| async move { + attempt.store(true, std::sync::atomic::Ordering::Release); + Ok(()) + }), + Err(StagingError::Duplicate) + )); + drop(ticket); + timeout(Duration::from_secs(10), bound_observer).await??; + assert!(!called.load(std::sync::atomic::Ordering::Acquire)); + assert!(!dropped.load(std::sync::atomic::Ordering::Acquire)); + assert_eq!( + coordinator.stats().workers, + 0, + "controller must not prevent its own Bind" + ); + assert_eq!(coordinator.stats().admitted, 1); + assert!(!service.quiesce().await); + timeout(Duration::from_secs(10), server.shutdown()).await??; + assert!(dropped.load(std::sync::atomic::Ordering::Acquire)); + assert_eq!(coordinator.stats().admitted, 0); + assert_eq!(manager.staging_budget.available(), (32, 64)); + assert!(matches!( + coordinator + .ready_request(request.clone(), mutation_identity()?) + .await, + Err(StagingError::Closed) + )); + assert!(coordinator.join_request(&request)?.is_none()); + } + Ok(()) +} + +#[tokio::test] +async fn production_push_driver_cancellation_keeps_detached_physical_worker_and_node_owned_until_drain() +-> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (server, files) = server().await?; + let manager = server.repositories.clone(); + let entry = create(&manager, "driver-physical", format).await?; + let (repository, _, service) = loaded(&manager, entry.repository_id).await?; + let coordinator = repository.staging_coordinator()?; + let ready = coordinator + .ready_request(driver_request(&repository, 181), mutation_identity()?) + .await?; + let ticket = coordinator.submit(ready).map_err(|(error, _)| error)?; + let (release, wait) = std::sync::mpsc::channel(); + let release = Release(Some(release)); + let (entered, running) = oneshot::channel(); + let (transferred, transfer) = oneshot::channel(); + let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let owned = DriverDropped(dropped.clone()); + ticket.drive(move |ticket, _| async move { + let _owned = owned; + if !matches!(ticket.wait().await, StagingState::Active(_)) { + return Err(StagingError::Context); + } + let work = ticket.spawn(move |context| async move { + let owner = context.physical_owner(); + Ok(tokio::task::spawn_blocking(move || { + let _owner = owner; + let _ = entered.send(()); + let _ = wait.recv(); + })) + })?; + let _detached = work + .wait() + .await + .map_err(|error| StagingError::Input(Box::new(error)))?; + let _ = transferred.send(()); + std::future::pending::>().await + })?; + drop(ticket); + timeout(Duration::from_secs(5), running).await??; + timeout(Duration::from_secs(5), transfer).await??; + let node = server.node.clone(); + let mut shutdown = tokio::spawn(server.shutdown()); + timeout(Duration::from_secs(5), async { + while !dropped.load(std::sync::atomic::Ordering::Acquire) { + tokio::task::yield_now().await; + } + }) + .await?; + assert!( + timeout(Duration::from_millis(50), &mut shutdown) + .await + .is_err() + ); + assert!(!node.is_shutting_down()); + assert!(!service.coordinator.stats().await.closed); + assert_eq!(manager.staging_budget.available(), (31, 63)); + assert!( + crate::server::workspace::Workspace::open(&files.path().join("node")) + .is_err_and(|error| error.kind() == std::io::ErrorKind::WouldBlock) + ); + drop(release); + timeout(Duration::from_secs(10), shutdown).await???; + assert_eq!(manager.staging_budget.available(), (32, 64)); + } + Ok(()) +} + +#[tokio::test] +async fn production_push_driver_close_preserves_exact_uncertain_begin_and_panic_returns_credit() +-> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + for fault in [1, 2, 3] { + let (server, _files) = server().await?; + let manager = server.repositories.clone(); + let entry = create(&manager, "driver-exact", format).await?; + let (repository, _, service) = loaded(&manager, entry.repository_id).await?; + let coordinator = repository.staging_coordinator()?; + let ready = coordinator + .ready_request(driver_request(&repository, 182), mutation_identity()?) + .await?; + coordinator.fault_for_test(fault); + let ticket = coordinator.submit(ready).map_err(|(error, _)| error)?; + let (entered, running) = oneshot::channel(); + let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let owned = DriverDropped(dropped.clone()); + ticket.drive(move |_, _| async move { + let _owned = owned; + let _ = entered.send(()); + std::future::pending::>().await + })?; + timeout(Duration::from_secs(5), running).await??; + assert!(matches!( + timeout(Duration::from_secs(10), ticket.wait_terminal()).await?, + StagingState::Uncertain(_) + )); + let original = ticket + .custody_evidence_for_test() + .ok_or("original missing")?; + // Both drains await the same workflow join, without taking its + // handle away from another observer or returning its quota early. + let (first, second) = timeout(Duration::from_secs(5), async { + tokio::join!(coordinator.close_and_drain(), coordinator.close_and_drain()) + }) + .await?; + assert_eq!((first.len(), second.len()), (1, 1)); + assert!(dropped.load(std::sync::atomic::Ordering::Acquire)); + assert_eq!(ticket.custody_evidence_for_test(), Some(original)); + assert_eq!(manager.staging_budget.available(), (31, 64)); + assert!(!service.coordinator.stats().await.closed); + timeout(Duration::from_secs(10), server.shutdown()).await??; + assert_eq!(manager.staging_budget.available(), (32, 64)); + } + let (server, _files) = server().await?; + let manager = server.repositories.clone(); + let entry = create(&manager, "driver-panic", format).await?; + let (repository, _, _) = loaded(&manager, entry.repository_id).await?; + let coordinator = repository.staging_coordinator()?; + let ready = coordinator + .ready_request(driver_request(&repository, 183), mutation_identity()?) + .await?; + let ticket = coordinator.submit(ready).map_err(|(error, _)| error)?; + active(&ticket).await?; + ticket.drive(|_, _| async { panic!("owned workflow panic") })?; + timeout(Duration::from_secs(5), async { + while coordinator.stats().admitted != 0 { + tokio::task::yield_now().await; + } + }) + .await?; + assert!( + matches!(ticket.state(), StagingState::Fenced(error) if matches!(&*error, StagingError::DriverFailure(message) if message.contains("staging worker panicked"))) + ); + assert_eq!(manager.staging_budget.available(), (32, 64)); + timeout(Duration::from_secs(10), server.shutdown()).await??; + } + Ok(()) +} + +#[tokio::test] +async fn production_push_driver_failure_remains_observable_before_and_after_bind() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + for bind in [false, true] { + for owned in [false, true] { + let (server, _files) = server().await?; + let manager = server.repositories.clone(); + let entry = create(&manager, "driver-failure", format).await?; + let (repository, _, service) = loaded(&manager, entry.repository_id).await?; + let coordinator = repository.staging_coordinator()?; + let request = driver_request(&repository, 184); + let ready = coordinator + .ready_request(request.clone(), mutation_identity()?) + .await?; + let ticket = coordinator.submit(ready).map_err(|(error, _)| error)?; + let (entered, running) = oneshot::channel(); + let (release, proceed) = oneshot::channel(); + ticket.drive(move |ticket, _| async move { + if !matches!(ticket.wait().await, StagingState::Active(_)) { + return Err(StagingError::Context); + } + if bind { + ticket.seal()?; + if !matches!(ticket.wait_terminal().await, StagingState::Bound(_)) { + return Err(StagingError::Context); + } + } + let pin = if owned { + let task = if bind { + ticket.spawn_bound(|_, context| async move { + Ok(context.physical_owner()) + })? + } else { + ticket.spawn(|context| async move { Ok(context.physical_owner()) })? + }; + Some( + task.wait() + .await + .map_err(|error| StagingError::Input(Box::new(error)))?, + ) + } else { + None + }; + let _ = entered.send(()); + let _ = proceed.await; + Err(match pin { + Some(pin) => StagingError::Input(Box::new(DriverOwnedFailure { + _pin: pin, + message: format!("worker-owned failure {}", "λ".repeat(4096)), + })), + None => StagingError::Clock, + }) + })?; + timeout(Duration::from_secs(5), running).await??; + let original_bound = ticket.bound_result(); + assert_eq!(original_bound.is_some(), bind); + assert_eq!( + manager.staging_budget.available(), + (31, if owned { 63 } else { 64 }) + ); + drop(ticket); + let observer = coordinator.join_request(&request)?.expect("owned driver"); + let _ = release.send(()); + let outcome = timeout(Duration::from_secs(5), observer.wait_completion()).await?; + let StagingState::Fenced(error) = outcome else { + return Err(format!("controller failure was lost: {outcome:?}").into()); + }; + let StagingError::DriverFailure(message) = &*error else { + return Err(format!("unexpected controller failure: {error:?}").into()); + }; + assert!(message.len() <= 4096); + assert!(message.contains(if owned { + "worker-owned failure" + } else { + "staging clock failed" + })); + assert_eq!(observer.bound_result().is_some(), bind); + if let Some(original) = original_bound { + assert!(Arc::ptr_eq( + &original, + &observer.bound_result().expect("historical Bind") + )); + } + assert!(observer.pending_publication().is_none()); + timeout(Duration::from_secs(5), async { + while coordinator.stats().admitted != 0 { + tokio::task::yield_now().await; + } + }) + .await?; + assert_eq!(manager.staging_budget.available(), (32, 64)); + assert!(!service.coordinator.stats().await.closed); + timeout(Duration::from_secs(10), server.shutdown()).await??; + } + } + } + Ok(()) +} + +#[tokio::test] +async fn production_staging_blocks_eviction_and_shutdown_until_detached_physical_worker_drains() +-> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (server, files) = server().await?; + let manager = server.repositories.clone(); + let entry = create(&manager, "staging-physical", format).await?; + let (repository, client, service) = loaded(&manager, entry.repository_id).await?; + let coordinator = repository.staging_coordinator()?; + assert!(Arc::ptr_eq(&coordinator, &service.staging)); + let ticket = coordinator + .submit(ready(&repository, client.clone(), 91).await?) + .map_err(|(error, _)| error)?; + active(&ticket).await?; + let (release, wait) = std::sync::mpsc::channel(); + let release = Release(Some(release)); + let (entered, running) = oneshot::channel(); + let work = ticket.spawn(move |context| async move { + let owner = context.physical_owner(); + Ok(tokio::task::spawn_blocking(move || { + let _owner = owner; + let _ = entered.send(()); + let _ = wait.recv(); + })) + })?; + let worker = work.wait().await.map_err(|error| error.to_string())?; + timeout(Duration::from_secs(5), running).await??; + assert_eq!(manager.staging_budget.available(), (31, 63)); + assert!(!service.quiesce().await); + assert!(!coordinator.stats().closed); + assert!(!service.coordinator.stats().await.closed); + drop(ticket); + let node = server.node.clone(); + let directory = server.directory.clone(); + let session = server.advertisement.lock().await.advertisement().session(); + let mut shutdown = tokio::spawn(server.shutdown()); + timeout(Duration::from_secs(5), async { + while !coordinator.stats().closed { + tokio::task::yield_now().await; + } + }) + .await?; + assert!( + timeout(Duration::from_millis(50), &mut shutdown) + .await + .is_err() + ); + assert!(repository.staging_coordinator().is_err()); + assert!(!node.is_shutting_down()); + assert!( + directory + .is_live(session, crate::server::unix_now_ms()?) + .await? + ); + assert!(!service.coordinator.stats().await.closed); + assert_eq!(manager.staging_budget.available(), (31, 63)); + assert!( + crate::server::workspace::Workspace::open(&files.path().join("node")) + .is_err_and(|error| error.kind() == std::io::ErrorKind::WouldBlock) + ); + // A cached handle also refuses new admission after the node barrier. + let (error, _) = coordinator + .submit(ready(&repository, client, 92).await?) + .err() + .ok_or("cached coordinator admitted during shutdown")?; + assert!(matches!(error, StagingError::Closed)); + drop(release); + timeout(Duration::from_secs(5), worker).await??; + timeout(Duration::from_secs(10), shutdown).await???; + assert_eq!(manager.staging_budget.available(), (32, 64)); + assert_eq!(coordinator.stats().admitted, 0); + assert!(node.is_shutting_down()); + } + Ok(()) +} + +#[tokio::test] +async fn production_staging_account_capacity_is_shared_across_resident_repositories() -> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (server, _files) = server().await?; + let manager = server.repositories.clone(); + let mut tickets = Vec::new(); + for (repo, name) in ["stage-one", "stage-two", "stage-three"].iter().enumerate() { + let entry = create(&manager, name, format).await?; + let (repository, client, service) = loaded(&manager, entry.repository_id).await?; + let coordinator = repository.staging_coordinator()?; + for index in 0..8 { + let input = + ready(&repository, client.clone(), (100 + repo * 8 + index) as u8).await?; + match coordinator.submit(input) { + Ok(ticket) => { + active(&ticket).await?; + tickets.push(ticket); + } + Err((error, retained)) => { + assert_eq!(repo, 2); + assert!(matches!(error, StagingError::Capacity)); + assert_eq!(service.staging.stats().admitted, 0); + assert_eq!(manager.staging_budget.available(), (16, 64)); + // Quota refusal did not consume the prepared request. The + // exact value can be submitted after an owner drains. + if index == 0 { + let stopped = tickets.remove(0); + stopped.stop(); + timeout(Duration::from_secs(5), async { + while manager.staging_budget.available().0 != 17 { + tokio::task::yield_now().await; + } + }) + .await?; + let retry = coordinator.submit(retained).map_err(|(error, _)| error)?; + active(&retry).await?; + tickets.push(retry); + } + break; + } + } + } + } + assert_eq!(tickets.len(), 16); + drop(tickets); // Observers do not return credits; the resident owns them. + assert_eq!(manager.staging_budget.available(), (16, 64)); + timeout(Duration::from_secs(15), server.shutdown()).await??; + assert_eq!(manager.staging_budget.available(), (32, 64)); + } + Ok(()) +} + +#[tokio::test] +async fn production_staging_eviction_refusal_restores_admission_and_closed_eviction_is_idempotent() +-> Result { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let (server, _files) = server().await?; + let manager = server.repositories.clone(); + let entry = create(&manager, "stage-resume", format).await?; + let (repository, client, service) = loaded(&manager, entry.repository_id).await?; + let snapshot = repository + .serving_snapshot(ReadIdentity::Account("canopy")) + .await?; + assert!(!service.quiesce().await); + let coordinator = repository.staging_coordinator()?; + let ticket = coordinator + .submit(ready(&repository, client, 93).await?) + .map_err(|(error, _)| error)?; + active(&ticket).await?; + assert!(!service.quiesce().await); + ticket.stop(); + drop(snapshot); + timeout(Duration::from_secs(5), async { + while coordinator.stats().admitted != 0 { + tokio::task::yield_now().await; + } + }) + .await?; + assert!(timeout(Duration::from_secs(5), service.quiesce()).await?); + assert!(service.quiesce().await); + assert!(matches!( + repository.staging_coordinator(), + Err(StagingError::Closed) + )); + assert_eq!(manager.staging_budget.available(), (32, 64)); + server.shutdown().await?; + } + Ok(()) +} + +#[tokio::test] +async fn production_shutdown_recovers_exact_staging_while_another_repository_holds_physical_work() +-> Result { + use crate::packs::publication::RegisteredCustody; + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + for fault in [1, 2, 3] { + let (server, _files) = server().await?; + let manager = server.repositories.clone(); + let held = create(&manager, "stage-held", format).await?; + let uncertain = create(&manager, "stage-recovery", format).await?; + let (held_repo, held_client, held_service) = + loaded(&manager, held.repository_id).await?; + let (repository, client, service) = loaded(&manager, uncertain.repository_id).await?; + let ticket = held_service + .staging + .submit(ready(&held_repo, held_client, 94).await?) + .map_err(|(error, _)| error)?; + active(&ticket).await?; + let (release, wait) = std::sync::mpsc::channel(); + let release = Release(Some(release)); + let (entered, running) = oneshot::channel(); + let worker = ticket + .spawn(move |context| async move { + let owner = context.physical_owner(); + Ok(tokio::task::spawn_blocking(move || { + let _owner = owner; + let _ = entered.send(()); + let _ = wait.recv(); + })) + })? + .wait() + .await + .map_err(|error| error.to_string())?; + timeout(Duration::from_secs(5), running).await??; + service.staging.fault_for_test(fault); + let original_ticket = service + .staging + .submit(ready(&repository, client.clone(), 95).await?) + .map_err(|(error, _)| error)?; + assert!(matches!( + timeout(Duration::from_secs(10), original_ticket.wait_terminal()).await?, + StagingState::Uncertain(_) + )); + let original = RegisteredCustody::load_latest(&client, &repository.target, [95; 16]) + .await? + .ok_or("uncertain staging registration absent")? + .evidence() + .clone(); + let prior = match client.resolve(&original).await? { + cellule_runtime::Resolution::Absent => None, + cellule_runtime::Resolution::Committed(outcome) => Some(outcome.commit_sequence()), + other => return Err(format!("unexpected original state: {other:?}").into()), + }; + assert_eq!(prior.is_some(), fault != 1); + drop((ticket, original_ticket)); + let node = server.node.clone(); + let mut shutdown = tokio::spawn(server.shutdown()); + timeout(Duration::from_secs(10), async { + while service.staging.stats().admitted != 0 + || service.staging.stats().retirement_running + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; + assert!( + timeout(Duration::from_millis(30), &mut shutdown) + .await + .is_err() + ); + assert!(!node.is_shutting_down()); + assert_eq!(manager.staging_budget.available(), (31, 63)); + assert!(!service.coordinator.stats().await.closed); + let saved = RegisteredCustody::load_latest(&client, &repository.target, [95; 16]) + .await? + .ok_or("exact registration lost during shutdown")?; + assert_eq!(saved.evidence(), &original); + assert!(saved.settled()); + let replay = saved.recover(&client).await?; + if let Some(sequence) = prior { + assert_eq!(replay.receipt.commit_sequence, sequence); + } + drop(release); + timeout(Duration::from_secs(5), worker).await??; + timeout(Duration::from_secs(10), shutdown).await???; + assert!(!service.staging.stats().retirement_running); + assert_eq!(manager.staging_budget.available(), (32, 64)); + } + } + Ok(()) +} diff --git a/crates/canopy-server/tests/directory_cell/main.rs b/crates/canopy-server/tests/directory_cell/main.rs index 009e7adf..9f029af8 100644 --- a/crates/canopy-server/tests/directory_cell/main.rs +++ b/crates/canopy-server/tests/directory_cell/main.rs @@ -2,8 +2,6 @@ mod accounts; mod capacity; mod compatibility; mod expiry; -#[path = "../support/objects.rs"] -mod objects; #[path = "../support/retained_directory.rs"] mod retained_directory; mod ssh_keys; @@ -15,12 +13,12 @@ use std::{ }; use canopy_server::{ - CanopyApplication, ObjectKind, RepositoryCell, RepositoryModule, build_descriptor, + CanopyApplication, RepositoryCell, RepositoryModule, build_descriptor, directory::{ self, CreateAccountOutcome, DirectoryCell, DirectoryModule, RenameOutcome, RepositoryState, TokenScope, }, - object_id, repository_target, + repository_target, }; use cellule_app::{ApplicationHandle, CellApplication}; use cellule_ltx::{CellReplica, DiskBudget, Host, Limits}; @@ -283,20 +281,24 @@ async fn directory_reservations_recover_two_distinct_repository_cells() .output, renamed ); - let body = b"stored only in alpha"; - let oid = objects::put(&first_repository, identity(6)?, ObjectKind::Blob, body) - .await? - .output; + // Directory candidates identify Cells but do not share their permissions. + // Exercise authoritative repository metadata here; packed object publication + // has its own native receive and cold-restore qualification. + let grant_identity = identity(6)?; + let grant = first_repository + .grant_member(grant_identity, "alice", "bob", TokenScope::Write) + .await?; + assert!(grant.output); assert_eq!( - oid, - object_id(canopy_server::ObjectFormat::Sha1, ObjectKind::Blob, body) - ); - assert!( - second_repository - .existing_objects(&[oid]) + first_repository + .access_level("bob", Some(grant.receipt)) .await? - .output - .is_empty() + .output, + Some(TokenScope::Write) + ); + assert_eq!( + second_repository.access_level("bob", None).await?.output, + None ); first_runtime.shutdown().await?; @@ -384,12 +386,33 @@ async fn directory_reservations_recover_two_distinct_repository_cells() second_id, canopy_server::ObjectFormat::Sha1, )?; + assert!(alpha.identity_matches("alice", None).await?.output); + assert!(beta.identity_matches("alice", None).await?.output); + assert_eq!( + alpha.access_level("bob", Some(grant.receipt)).await?.output, + Some(TokenScope::Write) + ); + assert_eq!(beta.access_level("bob", None).await?.output, None); + let replayed = alpha + .grant_member(grant_identity, "alice", "bob", TokenScope::Write) + .await?; + assert_eq!(replayed.receipt, grant.receipt); + assert!(replayed.output); + // The same logical request ID is scoped to its Cell even after restoration. + let beta_grant = beta + .grant_member(grant_identity, "alice", "bob", TokenScope::Read) + .await?; + assert!(beta_grant.output); + assert_eq!( + beta.access_level("bob", Some(beta_grant.receipt)) + .await? + .output, + Some(TokenScope::Read) + ); assert_eq!( - alpha.object(oid, None).await?.output, - Some((ObjectKind::Blob, body.to_vec())) + alpha.access_level("bob", None).await?.output, + Some(TokenScope::Write) ); - assert!(beta.existing_objects(&[oid]).await?.output.is_empty()); - assert_eq!(alpha.access_level("bob", None).await?.output, None); second_runtime.shutdown().await?; Ok(()) } diff --git a/crates/canopy-server/tests/multi_server/backup.rs b/crates/canopy-server/tests/multi_server/backup.rs index e9996774..a3661daf 100644 --- a/crates/canopy-server/tests/multi_server/backup.rs +++ b/crates/canopy-server/tests/multi_server/backup.rs @@ -97,14 +97,129 @@ async fn backup_restores_git_lfs_and_collaboration_without_original_storage() -> .await .is_err() ); + // Simulate an uploaded creating body whose attempt never registered a + // root. Provider listings must not turn this orphan into a backup root. + use canopy_object_storage::artifact::{ArtifactKey, ArtifactKind, ArtifactStore}; + let orphan_store = ArtifactStore::new( + Arc::new(object_store::prefix::PrefixStore::new( + store.clone(), + source_prefix.clone(), + )), + *uuid::Uuid::parse_str( + repository["repository_id"] + .as_str() + .ok_or("repository UUID missing")?, + )? + .as_bytes(), + ); + let mut orphan = b"unregistered creating input".as_slice(); + let digest = *blake3::hash(orphan).as_bytes(); + orphan_store + .put( + ArtifactKey { + operation: [99; 16], + binding_digest: digest, + kind: ArtifactKind::InputBody, + }, + orphan.len() as u64, + digest, + &mut orphan, + ) + .await?; let report = deployment .create_backup(id, backup.clone(), worker()) .await?; - assert_eq!(report.external_objects, 3); + // Artifact deduplication depends on native Git's packing and exact + // response bytes. Verify the physical inventory below rather than a + // platform-specific number of distinct content-addressed artifacts. assert_eq!(report.cells, 2); deployment .create_backup(id, backup.clone(), worker()) .await?; + // Retired input/command bodies and unselected native responses are not + // permanent audit edges. Remove all this fixture's unretained native bytes + // and prove the same pinned backup remains independently reproducible. + use futures_util::TryStreamExt; + let native_repository = format!( + "repos/{}", + hex::encode( + uuid::Uuid::parse_str( + repository["repository_id"] + .as_str() + .ok_or("missing repository id")? + )? + .as_bytes() + ) + ); + let retained_prefix = StorePath::from(format!("{backup}/{native_repository}")); + let retained = store + .list(Some(&retained_prefix)) + .try_collect::>() + .await? + .into_iter() + .map(|entry| { + entry + .location + .as_ref() + .strip_prefix(backup.as_ref()) + .map(str::to_owned) + .ok_or("backup namespace differs") + }) + .collect::, _>>()?; + let native_manifests = retained + .iter() + .filter(|path| !path.contains(".parts/")) + .count() as u64; + // Repository inventory includes its one retained LFS body. Count + // artifacts, not provider parts, against the report. + assert_eq!(report.external_objects, native_manifests); + assert_eq!( + retained + .iter() + .filter(|path| path.contains("/lfs/") && !path.contains(".parts/")) + .count(), + 1 + ); + for family in ["git-packs", "git-catalogs", "git-inputs"] { + assert!( + retained.iter().any(|path| path.contains(family)), + "missing {family}" + ); + } + assert!( + !retained + .iter() + .any(|path| path.contains(&hex::encode(digest))), + "unregistered creating input must not become a backup root" + ); + let creating_prefix = StorePath::from(format!("{source_prefix}/{native_repository}")); + let creating = store + .list(Some(&creating_prefix)) + .try_collect::>() + .await?; + let mut retired = 0; + for entry in creating { + let relative = entry + .location + .as_ref() + .strip_prefix(source_prefix.as_ref()) + .ok_or("source namespace differs")?; + if !retained.contains(relative) { + store.delete(&entry.location).await?; + retired += 1; + } + } + assert!( + retired > 0, + "fixture must contain unretained native input artifacts" + ); + assert_eq!( + deployment + .create_backup(id, backup.clone(), worker()) + .await? + .external_objects, + report.external_objects + ); let mut forbidden = config( available_address().await?, files.path().join("backup-server"), @@ -130,9 +245,10 @@ async fn backup_restores_git_lfs_and_collaboration_without_original_storage() -> } let emptied = store.list_with_delimiter(Some(&source_prefix)).await?; assert!(emptied.objects.is_empty() && emptied.common_prefixes.is_empty()); - deployment + let verified = deployment .verify_backup(id, backup.clone(), worker()) .await?; + assert_eq!(verified.external_objects, report.external_objects); let mut constrained = worker(); constrained.local_disk_limit_bytes = 1; assert!( @@ -235,6 +351,46 @@ async fn backup_restores_git_lfs_and_collaboration_without_original_storage() -> .as_str() .ok_or("missing repository id")?, )?; + // A complete backup must verify the native parts as well as LFS. Locate + // this fixture's one pack solely to inject provider corruption; production + // traversal derives its authority from the pinned typed graph. + let native_prefix = StorePath::from(format!( + "{backup}/repos/{}/git-packs", + hex::encode(repository_id.as_bytes()) + )); + let parts = store + .list(Some(&native_prefix)) + .try_collect::>() + .await?; + let native_part = parts + .iter() + .find(|entry| { + entry + .location + .as_ref() + .ends_with("/pack.parts/0000000000000000") + }) + .ok_or("backup native pack part absent")? + .location + .clone(); + let original = store.get(&native_part).await?.bytes().await?; + store + .put(&native_part, vec![0; original.len()].into()) + .await?; + assert!( + deployment + .verify_backup(id, backup.clone(), worker()) + .await + .is_err() + ); + store.put(&native_part, original.into()).await?; + assert_eq!( + deployment + .verify_backup(id, backup.clone(), worker()) + .await? + .external_objects, + report.external_objects + ); let lfs_path = StorePath::from(format!( "{backup}/repos/{}/lfs/{}.parts/0000000000000000", hex::encode(repository_id.as_bytes()), diff --git a/crates/canopy-server/tests/multi_server/compatibility.rs b/crates/canopy-server/tests/multi_server/compatibility.rs index b2fa926b..fd4faec3 100644 --- a/crates/canopy-server/tests/multi_server/compatibility.rs +++ b/crates/canopy-server/tests/multi_server/compatibility.rs @@ -9,6 +9,9 @@ async fn refs(path: &Path) -> Result> { #[tokio::test(flavor = "multi_thread")] async fn stock_git_history_refs_and_shallow_fetch_survive_fresh_disk_restore() -> Result { + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .try_init(); let store: Arc = Arc::new(InMemory::new()); let workspace = tempfile::TempDir::new()?; let address = available_address().await?; diff --git a/crates/canopy-server/tests/multi_server/lifecycle/mod.rs b/crates/canopy-server/tests/multi_server/lifecycle/mod.rs index 815ef14e..75d9a4f6 100644 --- a/crates/canopy-server/tests/multi_server/lifecycle/mod.rs +++ b/crates/canopy-server/tests/multi_server/lifecycle/mod.rs @@ -634,11 +634,19 @@ async fn startup_rejects_ignored_conditional_writes_before_enrollment() -> Resul let files = tempfile::TempDir::new()?; let listener = TcpListener::bind("127.0.0.1:0").await?; let address = listener.local_addr()?; + let mut caller_listener = Some(listener); let settings = config(address, files.path().join("server")); let result = if prebound { - CanopyServer::start_with_listener(settings, store.clone(), listener).await + CanopyServer::start_with_listener( + settings, + store.clone(), + caller_listener.take().unwrap(), + ) + .await } else { - drop(listener); + // This failure precedes listener creation. Keep the caller's + // reservation through the probe and verify startup refuses bad + // storage even while its requested address is occupied. CanopyServer::start(settings, store.clone()).await }; assert!(matches!( @@ -649,6 +657,7 @@ async fn startup_rejects_ignored_conditional_writes_before_enrollment() -> Resul )); let remaining = store.list_with_delimiter(None).await?; assert!(remaining.objects.is_empty() && remaining.common_prefixes.is_empty()); + drop(caller_listener); let rebound = TcpListener::bind(address).await?; assert_eq!(rebound.local_addr()?, address); } diff --git a/crates/canopy-server/tests/multi_server/main.rs b/crates/canopy-server/tests/multi_server/main.rs index 5f1be589..9e882b53 100644 --- a/crates/canopy-server/tests/multi_server/main.rs +++ b/crates/canopy-server/tests/multi_server/main.rs @@ -576,6 +576,9 @@ async fn create_repository( } fn config(address: std::net::SocketAddr, data_dir: std::path::PathBuf) -> ServerConfig { + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .try_init(); ServerConfig { tenant: TenantId::from_bytes([51; 16]), application: ApplicationId::from_bytes([52; 16]), diff --git a/crates/canopy-server/tests/multi_server/merge.rs b/crates/canopy-server/tests/multi_server/merge.rs index c49571f7..4e7c5385 100644 --- a/crates/canopy-server/tests/multi_server/merge.rs +++ b/crates/canopy-server/tests/multi_server/merge.rs @@ -557,3 +557,96 @@ async fn merge_rechecks_revisions_authority_and_competing_publications() -> Resu server.shutdown().await?; Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +async fn native_fast_forward_endpoint_replays_original_uuid_and_exposes_joint_refs_for_both_formats() +-> Result { + for format in ["sha1", "sha256"] { + let workspace = tempfile::TempDir::new()?; + let listener = TcpListener::bind("127.0.0.1:0").await?; + let address = listener.local_addr()?; + let server = CanopyServer::start_with_listener( + config(address, workspace.path().join("node")), + Arc::new(InMemory::new()), + listener, + ) + .await?; + let client = Client::new(); + let created = value( + client + .post(format!("http://{address}/api/repositories")) + .bearer_auth(OWNER) + .json(&json!({"name":"native-merge","object_format":format})), + ) + .await?; + let repository = created["repository_id"].clone(); + let url = created["clone_url"].as_str().ok_or("clone URL absent")?; + let repo = format!("http://{address}/api/repositories/native-merge"); + let local = workspace.path().join("local"); + run_git( + None, + &[ + "init", + "-b", + "main", + &format!("--object-format={format}"), + path_str(&local)?, + ], + ) + .await?; + run_git(Some(&local), &["config", "user.name", "Native Merge"]).await?; + run_git( + Some(&local), + &["config", "user.email", "merge@example.invalid"], + ) + .await?; + run_git(Some(&local), &["commit", "--allow-empty", "-m", "Base"]).await?; + let base = oid(&local, "HEAD").await?; + push(&local, url, &["HEAD:refs/heads/main"], true).await?; + tokio::fs::write(local.join("feature.txt"), b"native merge endpoint\n").await?; + run_git(Some(&local), &["add", "."]).await?; + run_git(Some(&local), &["commit", "-m", "Feature"]).await?; + let source = oid(&local, "HEAD").await?; + push(&local, url, &["HEAD:refs/heads/feature"], true).await?; + let api = new_pull( + &client, + &repo, + &repository, + "refs/heads/feature", + &source, + &base, + ) + .await?; + let input = intent(&client, &api, &repository).await?; + let merge_api = format!("{api}/merge"); + let original = value(client.post(&merge_api).bearer_auth(OWNER).json(&input)).await?; + assert_eq!(original["merge"]["oid"], source); + assert_eq!( + value(client.post(&merge_api).bearer_auth(OWNER).json(&input)).await?, + original + ); + let mut collision = input.clone(); + collision["revision"]["source_oid"] = json!(base); + status( + client.post(&merge_api).bearer_auth(OWNER).json(&collision), + StatusCode::CONFLICT, + ) + .await?; + let pull = current(&client, &api).await?; + assert_eq!(pull["state"], "merged"); + assert_eq!(pull["base"]["oid"], source); + assert_eq!(pull["base"]["version"], 2); + let refs = String::from_utf8( + run_git(None, &["-c", AUTH, "ls-remote", url, "refs/heads/main"]).await?, + )?; + assert_eq!(refs.trim(), format!("{source}\trefs/heads/main")); + let clone = workspace.path().join("clone"); + run_git(None, &["-c", AUTH, "clone", url, path_str(&clone)?]).await?; + assert_eq!( + tokio::fs::read(clone.join("feature.txt")).await?, + b"native merge endpoint\n" + ); + server.shutdown().await?; + } + Ok(()) +} diff --git a/crates/canopy-server/tests/multi_server/peers/mod.rs b/crates/canopy-server/tests/multi_server/peers/mod.rs index b6041dfc..6cad8fe2 100644 --- a/crates/canopy-server/tests/multi_server/peers/mod.rs +++ b/crates/canopy-server/tests/multi_server/peers/mod.rs @@ -341,6 +341,7 @@ async fn response_loss_proxy( let lose_reply = Arc::new(std::sync::atomic::AtomicBool::new(false)); let fault = Arc::clone(&lose_reply); let client = Client::new(); + let transport_client = client.clone(); let route = post(move |request: Request| { let client = client.clone(); let fault = Arc::clone(&fault); @@ -374,10 +375,39 @@ async fn response_loss_proxy( result } }); + // The advertised TLS endpoint also carries owner-routed Git/LFS streams. + // Keep the loss injector restricted to the exact Cell RPC above. + let transport = move |request: Request| { + let client = transport_client.clone(); + async move { + let (parts, body) = request.into_parts(); + let url = format!("http://{upstream}{}", parts.uri); + let mut headers = parts.headers; + headers.remove(reqwest::header::HOST); + let response = client + .request(parts.method, url) + .headers(headers) + .body(reqwest::Body::wrap_stream(body.into_data_stream())) + .send() + .await + .unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let mut result = Response::new(Body::from_stream(response.bytes_stream())); + *result.status_mut() = status; + *result.headers_mut() = headers; + result + } + }; let task = tokio::spawn(async move { - axum::serve(listener, Router::new().route("/internal/cell", route)) - .await - .unwrap(); + axum::serve( + listener, + Router::new() + .route("/internal/cell", route) + .fallback(transport), + ) + .await + .unwrap(); }); Ok((address, lose_reply, Proxy(task))) } diff --git a/crates/canopy-server/tests/multi_server/residency/faults/git_discovery.rs b/crates/canopy-server/tests/multi_server/residency/faults/git_discovery.rs index e603618b..5b93ceb9 100644 --- a/crates/canopy-server/tests/multi_server/residency/faults/git_discovery.rs +++ b/crates/canopy-server/tests/multi_server/residency/faults/git_discovery.rs @@ -23,12 +23,15 @@ async fn ref_discovery_does_not_wait_for_a_full_history_restore() -> Result { .await?; let oid = run_git(Some(&source), &["rev-parse", "HEAD"]).await?; let advertised = format!("{} refs/heads/main", std::str::from_utf8(&oid)?.trim()); - let suffix = format!("/git-blobs/{}", hex::encode(Sha256::digest(&body))); + // Pause the physical native pack body rather than the retired loose-blob + // layout. This fixture's incompressible history occupies the unique large + // pack part; manifests and structural metadata remain available. + let suffix = "/pack.parts/0000000000000000"; let mut stored = fixture.store.inner.list(None); let mut blob = None; while let Some(meta) = std::future::poll_fn(|cx| stored.as_mut().poll_next(cx)).await { let meta = meta?; - if meta.location.as_ref().ends_with(&suffix) { + if meta.location.as_ref().ends_with(suffix) && meta.size >= body.len() as u64 { assert!(blob.replace(meta.location).is_none()); } } @@ -37,7 +40,8 @@ async fn ref_discovery_does_not_wait_for_a_full_history_restore() -> Result { // helper as the other cold-restore tests before starting discovery. fixture.make_original_cold().await?; assert!(!fixture.repository_dir.exists()); - *fixture.store.paused_read.lock().unwrap() = Some(blob.ok_or("external Git body missing")?); + *fixture.store.paused_read.lock().unwrap() = + Some(blob.ok_or("external native pack body missing")?); let destination = fixture.workspace.path().join("cold-clone"); let clone = async { run_git( diff --git a/crates/canopy-server/tests/multi_server/residency/mod.rs b/crates/canopy-server/tests/multi_server/residency/mod.rs index 58068f1b..f08089f9 100644 --- a/crates/canopy-server/tests/multi_server/residency/mod.rs +++ b/crates/canopy-server/tests/multi_server/residency/mod.rs @@ -28,7 +28,7 @@ async fn repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_n settings.store_prefix.clone(), *application.as_bytes(), ); - let authority = CellAuthority::new(layout); + let authority = CellAuthority::new(layout.clone()); let server = CanopyServer::start(settings, store).await?; let local_root = workspace.path().join("server/canopy-pack-v1"); let local = workspace.path().join("source"); @@ -92,6 +92,12 @@ async fn repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_n .await? .ok_or("Cell missing")?; let root = before.value().root.clone(); + let before_rows = durable_rows( + &layout, + &target, + &workspace.path().join(format!("before-{index}.sqlite")), + ) + .await?; let clone = workspace.path().join(format!("clone-{index}")); run_git( None, @@ -126,10 +132,21 @@ async fn repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_n .load(target.cell_id()) .await? .ok_or("Cell missing")?; - assert_eq!( - after.value().root, - root, - "read-only restoration published a new root" + let after_rows = durable_rows( + &layout, + &target, + &workspace.path().join(format!("after-{index}.sqlite")), + ) + .await?; + assert_read_only_restore(&before_rows, &after_rows); + assert!( + after + .value() + .root + .as_ref() + .ok_or("restored root absent")? + .commit_sequence + >= root.as_ref().ok_or("original root absent")?.commit_sequence ); } server.shutdown().await?; @@ -305,3 +322,115 @@ async fn invalid_residency_limits_fail_before_creating_local_state() -> Result { } Ok(()) } + +// Inspect authenticated roots through Cellule's VFS; restored files may contain +// sparse placeholders that ordinary SQLite cannot read independently. +type DurableRows = + std::collections::BTreeMap>>; + +fn assert_read_only_restore(before: &DurableRows, after: &DurableRows) { + use cellule_ltx::rusqlite::types::Value; + assert_eq!( + before.keys().collect::>(), + after.keys().collect::>(), + "restoration changed the schema inventory" + ); + for (table, rows) in before { + let restored = &after[table]; + match table.as_str() { + "catalog_custody_commands" => { + // Serving purpose is 1. Existing staging intents (purpose 0) + // remain exact; a read cannot admit publication work. + let staging = |values: &Vec>| { + values + .iter() + .filter(|row| row[0] == Value::Integer(0)) + .cloned() + .collect::>() + }; + assert_eq!(staging(rows), staging(restored)); + for original in rows { + assert!( + restored.iter().any(|row| row[..6] == original[..6]), + "restoration replaced a custody intent" + ); + } + } + "catalog_serving_pins" => { + assert_eq!(restored.len(), 1, "one current generation must be pinned"); + let generation = &after["catalog_state"][0][1]; + for row in restored { + assert_eq!( + &row[4], generation, + "serving pin selected another generation" + ); + } + } + "sys_requests" => { + for row in rows { + assert!( + restored.contains(row), + "restoration replaced a durable receipt" + ); + } + } + "sys_meta" => { + let normalize = |values: &Vec>| { + let mut values = values.clone(); + assert_eq!(values.len(), 1); + // Serving commands advance sequence and logical time. + values[0][3] = Value::Integer(0); + values[0][4] = Value::Integer(0); + values + }; + assert_eq!(normalize(rows), normalize(restored)); + } + _ => assert_eq!(rows, restored, "read-only restoration changed {table}"), + } + } +} + +async fn durable_rows( + layout: &CellStorageLayout, + target: &cellule_runtime::CellTarget, + path: &Path, +) -> Result { + let control = CellAuthority::new(layout.clone()) + .load(target.cell_id()) + .await? + .ok_or("repository absent")?; + let root = control.value().ltx_root().ok_or("root absent")?; + let replica = cellule_ltx::CellReplica::new( + layout.clone(), + *target.cell_id().as_bytes(), + *control.value().incarnation.as_bytes(), + cellule_ltx::Limits::default(), + )?; + let root = replica.open_root(&root).await?.open_read_only(path)?; + let c = root.connection()?; + let mut names = c.prepare("SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name")?; + let names = names + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + let mut result = std::collections::BTreeMap::new(); + for name in names { + let quoted = name.replace('"', "\"\""); + let query = c.prepare(&format!("SELECT * FROM \"{quoted}\""))?; + let count = query.column_count(); + let order = (1..=count) + .map(|i| i.to_string()) + .collect::>() + .join(","); + drop(query); + let mut query = c.prepare(&format!("SELECT * FROM \"{quoted}\" ORDER BY {order}"))?; + let rows = query + .query_map([], |row| { + (0..count) + .map(|i| row.get(i)) + .collect::, _>>() + })? + .collect::, _>>()?; + result.insert(name, rows); + } + Ok(result) +} diff --git a/crates/canopy-server/tests/multi_server/ssh/publication.rs b/crates/canopy-server/tests/multi_server/ssh/publication.rs index aa9c8697..f815223e 100644 --- a/crates/canopy-server/tests/multi_server/ssh/publication.rs +++ b/crates/canopy-server/tests/multi_server/ssh/publication.rs @@ -179,7 +179,7 @@ async fn late_ssh_push_refusals_report_both_refs_and_survive_restore() -> Result let error = String::from_utf8(output.stderr)?; assert!(!output.status.success(), "{change}: {error}"); let reason = if change == "storage" { - "Canopy object ingestion failed" + "Canopy push failed before publication" } else { "Canopy publication rejected" }; @@ -344,10 +344,10 @@ async fn cold_ssh_push_preparation_failure_reports_rejection_before_any_refs_cha store, server, host, + key, source, ssh, url, - .. } = fixture().await?; git(Some(&source), &ssh, &["push", &url, "main"]).await?; server.shutdown().await?; @@ -365,35 +365,84 @@ async fn cold_ssh_push_preparation_failure_reports_rejection_before_any_refs_cha let client = reqwest::Client::new(); let before = generation(&client, address).await?; let original = git(None, &ssh, &["ls-remote", "--refs", &url]).await?; + // Finish discovery before arming the provider fault. It must reject the + // owned push preparation, rather than fail before any command was sent. + let session = connect(ssh_address, &host, &key, "git", true).await?; + let mut channel = session.channel_open_session().await?; + channel + .exec(true, "git-receive-pack 'canopy/publication.git'") + .await?; + tokio::time::timeout(Duration::from_secs(5), async { + let mut advertised = Vec::new(); + loop { + match channel.wait().await.ok_or("SSH advertisement missing")? { + russh::ChannelMsg::Data { data } => { + advertised.extend_from_slice(&data); + if advertised.ends_with(b"0000") { + return Ok::<_, Box>(()); + } + } + russh::ChannelMsg::Close | russh::ChannelMsg::Failure => { + return Err("SSH discovery rejected".into()); + } + _ => {} + } + } + }) + .await??; + let tip = String::from_utf8(git(Some(&source), &ssh, &["rev-parse", "HEAD"]).await?)?; + let mut body = Vec::new(); + for (n, name) in ["preparation", "準備"].iter().enumerate() { + let capabilities = if n == 0 { "\0report-status atomic" } else { "" }; + let command = format!( + "{} {} refs/heads/{name}{capabilities}\n", + "0".repeat(40), + tip.trim() + ); + body.extend_from_slice(format!("{:04x}{command}", command.len() + 4).as_bytes()); + } + body.extend_from_slice(b"0000"); + body.extend(git(Some(&source), &ssh, &["pack-objects", "--all", "--stdout"]).await?); store.read_armed.store(true, Ordering::SeqCst); - let child = git_command( - Some(&source), - &ssh, - &[ - "push", - "--atomic", - &url, - "HEAD:refs/heads/preparation", - "HEAD:refs/heads/準備", - ], - ) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; + channel.data(body.as_slice()).await?; + channel.eof().await?; tokio::time::timeout(Duration::from_secs(15), store.entered.notified()).await?; store.fail.store(true, Ordering::SeqCst); store.proceed.notify_one(); - let output = child.wait_with_output().await?; - let error = String::from_utf8_lossy(&output.stderr); - assert!(!output.status.success(), "{error}"); + let report = tokio::time::timeout(Duration::from_secs(15), async { + let mut report = Vec::new(); + loop { + match channel.wait().await { + Some(russh::ChannelMsg::Data { data }) => report.extend_from_slice(&data), + Some(russh::ChannelMsg::Close) | None => break, + Some(russh::ChannelMsg::Failure) => return Err("SSH report unavailable".into()), + _ => {} + } + } + Ok::<_, Box>(report) + }) + .await??; + let report = String::from_utf8(report)?; + assert!( + report.contains("unpack Canopy push failed before publication"), + "{report}" + ); for name in ["preparation", "準備"] { assert!( - error.lines().any(|line| line.contains("[remote rejected]") - && line.contains(&format!(" -> {name} ")) - && line.contains("Canopy push failed before publication")), - "{error}" + report.contains(&format!( + "ng refs/heads/{name} Canopy push failed before publication" + )), + "{report}" + ); + assert!( + !report.contains(&format!("ok refs/heads/{name}\n")), + "{report}" ); } + drop(channel); + let _ = session + .disconnect(russh::Disconnect::ByApplication, "test finished", "") + .await; assert_eq!( git(None, &ssh, &["ls-remote", "--refs", &url]).await?, original diff --git a/crates/canopy-server/tests/owner_restart.rs b/crates/canopy-server/tests/owner_restart.rs index bb4866c6..5c98dff3 100644 --- a/crates/canopy-server/tests/owner_restart.rs +++ b/crates/canopy-server/tests/owner_restart.rs @@ -1,638 +1,76 @@ -use std::{ - path::Path, - sync::Arc, - time::{SystemTime, UNIX_EPOCH}, -}; - -use canopy_server::{ - CanopyApplication, PushPlan, RefUpdate, RepositoryCell, RepositoryModule, build_descriptor, - git_gateway::GitGateway, http::GitHttpApi, repository_target, -}; -use cellule_app::{CellApplication, CompiledApplication}; -use cellule_host::{CellNode, CellNodeBuilder}; -use cellule_ltx::{CellReplica, DiskBudget, Host, Limits}; -use cellule_runtime::primitives::sql::{SqlBatch, SqlCell, SqlStatement, SqlValue}; -use cellule_runtime::{ - ApplicationId, CellClient, CellModule, Digest, Error, InvocationError, NodeLeaseGuard, - SessionId, TenantId, cell::catalog::CatalogEntry, cell::catalog::CatalogRole, - cell::catalog::CellCatalog, cell::worker::SqlWorkerPool, control::ControlState, control::Owner, - control::authority::CellAuthority, identity::IncarnationId, identity::NodeId, - ltx::CellStorageLayout, node::NodeAdvertisement, node::NodeCapacity, node::NodeDirectory, - node::NodeFailureDomain, node::VersionedNodeAdvertisement, -}; -use cellule_store::Store; -use ed25519_dalek::SigningKey; -use object_store::{ObjectStore, memory::InMemory, path::Path as StorePath}; -use tokio::{net::TcpListener, process::Command, sync::oneshot}; -use tokio_util::sync::CancellationToken; - -mod support; +//! Qualify owner restart through the production resident, not detached handles. +#[path = "support/native_server.rs"] +mod native_server; +use native_server::*; +use object_store::{ObjectStore, memory::InMemory}; +use std::sync::Arc; #[tokio::test(flavor = "multi_thread")] -async fn a_second_node_clones_from_the_published_root_after_local_disk_loss() --> Result<(), Box> { - let application = Arc::new(CanopyApplication::compile(build_descriptor( - include_bytes!("../../../Cargo.lock"), - "owner-restart-test", - ))?); - let tenant = TenantId::from_bytes([31; 16]); - let application_id = ApplicationId::from_bytes([32; 16]); - let mut repository_id = [33; 16]; - repository_id[6] = 0x73; - repository_id[8] = 0x83; - let target = repository_target(tenant, application_id, repository_id)?; - let object_store: Arc = Arc::new(InMemory::new()); - let layout = CellStorageLayout::new( - Store::new(Arc::clone(&object_store)), - StorePath::from("owner-restart-test"), - *application_id.as_bytes(), - ); - let registry = application.registry(); - let code = registry - .module_code(RepositoryModule::NAME) - .ok_or(Error::Registry("repository module missing"))?; - let catalog = CellCatalog::new(layout.clone(), tenant); - let proof = catalog - .provision(CatalogEntry::new(&target, CatalogRole::Sql, code, 1)?) - .await?; - let authority = CellAuthority::new(layout.clone()); - let first_session = SessionId::from_bytes([34; 16]); - let observed = authority - .create_initial( - &proof, - IncarnationId::from_bytes([35; 16]), - Owner { - session: first_session, - endpoint: "https://first.canopy.test".into(), - }, - ) - .await?; - let first_disk = tempfile::TempDir::new()?; - let (first, directory, first_advertisement) = - node(Arc::clone(&application), &layout, first_session).await?; - let first_handle = first - .runtime() - .bootstrap( - proof, - replica( - &application, - &layout, - &target, - *observed.value().incarnation.as_bytes(), - )?, - authority.clone(), - observed, - first_disk.path().join("repository.sqlite"), - |transaction| { - transaction.execute_batch(include_str!("../src/schema.sql"))?; - Ok(()) - }, - ) - .await?; - let app_handle = first.application_handle::( - CellClient::local(Arc::clone(®istry), first_handle), - tenant, - application_id, - )?; - let repository = Arc::new(RepositoryCell::new( - &app_handle, - target.clone(), - repository_id, - canopy_server::ObjectFormat::Sha1, - )?); - let owner_identity = support::identity()?; - repository.ensure_owner(owner_identity, "canopy").await?; - let first_seed = push_cert_seed(&app_handle.sql::(target.clone())?).await?; - assert_eq!(first_seed.len(), 32); - repository.ensure_owner(owner_identity, "canopy").await?; - assert_eq!( - push_cert_seed(&app_handle.sql::(target.clone())?).await?, - first_seed - ); - let first_gateway = Arc::new(GitGateway::new( - Arc::clone(&repository), - first_disk.path().to_path_buf(), - Arc::clone(&object_store), - DiskBudget::new(1 << 30), - canopy_server::native_resources::NativeResources::default(), - )); - let (address, stop, server) = serve(first_gateway).await?; - let first_url = format!("http://{address}/canopy/example.git"); - let local = first_disk.path().join("local"); - run_git(None, &["init", "-b", "main", path_str(&local)?]).await?; - run_git(Some(&local), &["config", "user.name", "Canopy Test"]).await?; - run_git( - Some(&local), - &["config", "user.email", "canopy@example.invalid"], +async fn a_second_node_clones_from_the_published_root_after_local_disk_loss() -> Result { + let workspace = tempfile::TempDir::new()?; + let store: Arc = Arc::new(InMemory::new()); + let first_disk = workspace.path().join("first-owner"); + let first = start(&first_disk, store.clone()).await?; + let url = create(&first, "restart", "sha1").await?; + let source = workspace.path().join("source"); + git(None, &["init", "-b", "main", path(&source)?]).await?; + git(Some(&source), &["config", "user.name", "Restart Test"]).await?; + git( + Some(&source), + &["config", "user.email", "restart@example.invalid"], ) .await?; - tokio::fs::write(local.join("README.md"), b"published Cell root\n").await?; - run_git(Some(&local), &["add", "README.md"]).await?; - let submodule = "74".repeat(20); - run_git( - Some(&local), + std::fs::write(source.join("README.md"), b"published Cell root\n")?; + git(Some(&source), &["add", "README.md"]).await?; + let gitlink = "74".repeat(20); + git( + Some(&source), &[ "update-index", "--add", "--cacheinfo", - &format!("160000,{submodule},submodule"), + &format!("160000,{gitlink},submodule"), ], ) .await?; - run_git(Some(&local), &["commit", "-m", "Initial commit"]).await?; - run_git(Some(&local), &["tag", "-a", "v1", "-m", "Annotated tag"]).await?; - let original_tag = run_git(Some(&local), &["rev-parse", "refs/tags/v1"]).await?; - run_git( - Some(&local), + git(Some(&source), &["commit", "-m", "Durable native graph"]).await?; + git( + Some(&source), + &["tag", "-a", "v1", "-m", "Native annotated tag"], + ) + .await?; + git( + Some(&source), &[ - "-c", - "http.extraHeader=Authorization: Bearer local-test-token", "push", - &first_url, + &url, "HEAD:refs/heads/main", "HEAD:refs/heads/reused", "refs/tags/v1", ], ) .await?; - let original = run_git(Some(&local), &["rev-parse", "HEAD"]).await?; - tokio::fs::write( - local.join("accepted.txt"), - b"accepted ref in a mixed push\n", - ) - .await?; - run_git(Some(&local), &["add", "accepted.txt"]).await?; - run_git(Some(&local), &["commit", "-m", "Mixed push commit"]).await?; - let partial_oid = run_git(Some(&local), &["rev-parse", "HEAD"]).await?; - let blob = run_git(Some(&local), &["rev-parse", "HEAD:accepted.txt"]).await?; - let blob = std::str::from_utf8(&blob)?.trim(); - for (atomic, accepted, rejected) in [ - (false, "partial", "rejected"), - (true, "atomic-accepted", "atomic-rejected"), - ] { - let mut push = Command::new("git"); - push.current_dir(&local) - .env("GIT_TERMINAL_PROMPT", "0") - .args([ - "-c", - "credential.helper=", - "-c", - "http.extraHeader=Authorization: Bearer local-test-token", - "push", - ]); - if atomic { - push.arg("--atomic"); - } - let output = push - .args([ - &first_url, - &format!("HEAD:refs/heads/{accepted}"), - &format!("+{blob}:refs/heads/{rejected}"), - ]) - .output() - .await?; - assert!(!output.status.success()); - assert_eq!( - repository - .ref_state(&format!("refs/heads/{accepted}"), None) - .await? - .output - .is_some(), - !atomic - ); - assert!( - repository - .ref_state(&format!("refs/heads/{rejected}"), None) - .await? - .output - .is_none() - ); - assert!(String::from_utf8_lossy(&output.stderr).contains("[remote rejected]")); - } - let original_ref = repository - .ref_state("refs/heads/reused", None) - .await? - .output; - let command = format!( - "{} {} refs/heads/reused\0report-status side-band-64k\n", - std::str::from_utf8(&original)?.trim(), - "0".repeat(40) - ); - let delete_request = format!("{:04x}{command}0000", command.len() + 4).into_bytes(); - let client = reqwest::Client::new(); - let push_id = uuid::Uuid::new_v4().to_string(); - let retry = |url: &str| { - client - .post(format!("{url}/git-receive-pack")) - .bearer_auth("local-test-token") - .header("Content-Type", "application/x-git-receive-pack-request") - .header("Idempotency-Key", &push_id) - .body(delete_request.clone()) - }; - let discarded = retry(&first_url).send().await?.error_for_status()?; - assert_eq!( - discarded - .headers() - .get("X-Canopy-Push-Id") - .and_then(|id| id.to_str().ok()), - Some(push_id.as_str()) - ); - drop(discarded); - let original_reply = retry(&first_url) - .send() - .await? - .error_for_status()? - .bytes() - .await?; - let deleted_ref = repository - .ref_state("refs/heads/reused", None) - .await? - .output; - assert!( - deleted_ref - .as_ref() - .is_some_and(|state| state.oid.is_none() && state.version == 2) - ); - let refs_generation = repository.refs_page("", None).await?.output.generation; - let _ = stop.send(()); - server.await??; - drop(repository); - drop(app_handle); + let refs = git(None, &["ls-remote", "--refs", &url]).await?; first.shutdown().await?; - directory - .withdraw(&first_advertisement, unix_now_ms()?) - .await?; - drop(first_disk); - - let control = authority - .load(target.cell_id()) - .await? - .ok_or("repository control is missing")?; - assert_eq!(control.value().state, ControlState::Idle); - assert!(control.value().root.is_some()); - let second_session = SessionId::from_bytes([36; 16]); - let second_disk = tempfile::TempDir::new()?; - let (second, directory, second_advertisement) = - node(Arc::clone(&application), &layout, second_session).await?; - let proof = catalog - .lookup(target.cell_id()) - .await? - .ok_or("repository catalog entry is missing")?; - let second_handle = second - .runtime() - .acquire_idle_restored( - proof, - replica( - &application, - &layout, - &target, - *control.value().incarnation.as_bytes(), - )?, - authority, - control, - second_disk.path().join("repository.sqlite"), - Owner { - session: second_session, - endpoint: "https://second.canopy.test".into(), - }, - ) - .await?; - let app_handle = second.application_handle::( - CellClient::local(registry, second_handle), - tenant, - application_id, - )?; - let sql = app_handle.sql::(target.clone())?; - let repository = Arc::new(RepositoryCell::new( - &app_handle, - target, - repository_id, - canopy_server::ObjectFormat::Sha1, - )?); - assert_eq!(push_cert_seed(&sql).await?, first_seed); - assert_eq!( - repository.refs_page("", None).await?.output.generation, - refs_generation - ); + std::fs::remove_dir_all(&first_disk)?; + assert!(!first_disk.exists()); + let second = start(&workspace.path().join("second-owner"), store).await?; + let url = format!("http://{}/canopy/restart.git", second.local_addr()); + assert_eq!(git(None, &["ls-remote", "--refs", &url]).await?, refs); + let clone = workspace.path().join("clone"); + git(None, &["clone", "--bare", &url, path(&clone)?]).await?; + git(Some(&clone), &["fsck", "--strict", "--full"]).await?; assert_eq!( - repository - .ref_state("refs/heads/reused", None) - .await? - .output, - deleted_ref - ); - let second_gateway = Arc::new(GitGateway::new( - Arc::clone(&repository), - second_disk.path().to_path_buf(), - object_store, - DiskBudget::new(1 << 30), - canopy_server::native_resources::NativeResources::default(), - )); - let (address, stop, server) = serve(second_gateway).await?; - let second_url = format!("http://{address}/canopy/example.git"); - let clone = second_disk.path().join("clone"); - run_git( - None, - &[ - "-c", - "http.extraHeader=Authorization: Bearer local-test-token", - "clone", - &second_url, - path_str(&clone)?, - ], - ) - .await?; - assert_eq!( - tokio::fs::read(clone.join("README.md")).await?, + git(Some(&clone), &["show", "main:README.md"]).await?, b"published Cell root\n" ); - assert_eq!( - run_git(Some(&clone), &["rev-parse", "HEAD"]).await?, - original - ); - assert_eq!( - run_git(Some(&clone), &["rev-parse", "refs/tags/v1"]).await?, - original_tag - ); - assert_eq!( - run_git(Some(&clone), &["ls-tree", "HEAD", "submodule"]).await?, - format!("160000 commit {submodule}\tsubmodule\n").as_bytes() - ); - run_git(Some(&clone), &["fsck", "--full"]).await?; - assert_eq!( - run_git(Some(&clone), &["rev-parse", "refs/remotes/origin/partial"]).await?, - partial_oid - ); - assert_eq!( - run_git( - Some(&clone), - &["show", "refs/remotes/origin/partial:accepted.txt"] - ) - .await?, - b"accepted ref in a mixed push\n" - ); - assert!( - repository - .ref_state("refs/heads/rejected", None) - .await? - .output - .is_none() - ); assert!( - repository - .ref_state("refs/heads/atomic-accepted", None) - .await? - .output - .is_none() + String::from_utf8(git(Some(&clone), &["ls-tree", "main", "submodule"]).await?)? + .contains(&gitlink) ); - assert!( - repository - .ref_state("refs/heads/atomic-rejected", None) - .await? - .output - .is_none() - ); - assert!( - run_git( - Some(&clone), - &[ - "-c", - "http.extraHeader=Authorization: Bearer local-test-token", - "ls-remote", - &second_url, - "refs/heads/reused", - ] - ) - .await? - .is_empty() - ); - run_git( - Some(&clone), - &[ - "-c", - "http.extraHeader=Authorization: Bearer local-test-token", - "push", - &second_url, - "HEAD:refs/heads/reused", - ], - ) - .await?; - let recreated = repository - .ref_state("refs/heads/reused", None) - .await? - .output; - assert!( - recreated - .as_ref() - .is_some_and(|state| state.oid.is_some() && state.version == 3) - ); - assert_eq!( - retry(&second_url) - .send() - .await? - .error_for_status()? - .bytes() - .await?, - original_reply - ); - assert_eq!( - repository - .ref_state("refs/heads/reused", None) - .await? - .output, - recreated - ); - assert!(matches!( - repository - .finalize_push( - support::identity()?, - PushPlan { - actor: "canopy".into(), - updates: vec![RefUpdate { - name: "refs/heads/reused".into(), - expected: original_ref, - new_oid: None - }], - } - ) - .await, - Err(InvocationError::Rejected(_)) - )); - assert_eq!( - repository - .ref_state("refs/heads/reused", None) - .await? - .output, - recreated - ); - client - .post(format!("{second_url}/git-receive-pack")) - .bearer_auth("local-test-token") - .header("Content-Type", "application/x-git-receive-pack-request") - .header("Idempotency-Key", uuid::Uuid::new_v4().to_string()) - .body(delete_request.clone()) - .send() - .await? - .error_for_status()?; - assert!( - repository - .ref_state("refs/heads/reused", None) - .await? - .output - .is_some_and(|state| state.oid.is_none() && state.version == 4) - ); - let _ = stop.send(()); - server.await??; + git(Some(&source), &["push", &url, ":refs/heads/reused"]).await?; + git(Some(&source), &["push", &url, "HEAD:refs/heads/reused"]).await?; + assert_eq!(git(None, &["ls-remote", "--refs", &url]).await?, refs); second.shutdown().await?; - directory - .withdraw(&second_advertisement, unix_now_ms()?) - .await?; Ok(()) } - -async fn push_cert_seed( - sql: &SqlCell, -) -> Result, Box> { - let output = sql - .query( - None, - SqlBatch { - statements: vec![SqlStatement { - sql: "SELECT push_cert_seed FROM repository_identity WHERE singleton = 1" - .into(), - parameters: Vec::new(), - }], - }, - ) - .await? - .output; - match output - .first() - .and_then(|set| set.rows.first()) - .map(Vec::as_slice) - { - Some([SqlValue::Blob(seed)]) => Ok(seed.clone()), - _ => Err("missing push certificate seed".into()), - } -} - -async fn node( - application: Arc, - layout: &CellStorageLayout, - session: SessionId, -) -> Result<(CellNode, NodeDirectory, VersionedNodeAdvertisement), Box> { - let fleet = Digest::from_bytes([41; 32]); - let image = Digest::from_bytes([42; 32]); - let registry = application.registry(); - let directory = NodeDirectory::new(layout.clone(), fleet, image, registry.release_digest()); - let now_ms = unix_now_ms()?; - let expires_at_ms = now_ms + 10_000; - let advertisement = NodeAdvertisement::sign( - NodeId::from_bytes(*session.as_bytes()), - session, - "https://canopy.test".into(), - fleet, - Digest::from_bytes([43; 32]), - image, - registry.release_digest(), - &SigningKey::from_bytes(&[44; 32]), - 1, - now_ms, - expires_at_ms, - registry.module_digests(), - vec![1], - NodeFailureDomain::default(), - NodeCapacity::default(), - )?; - let observed = directory.create(advertisement, now_ms).await?; - let node = CellNodeBuilder::new(application) - .with_runtime(SqlWorkerPool::new(1, 4)?, 16 * 1024 * 1024) - .with_replica_host(Host::default().with_local_disk_budget(DiskBudget::new(1 << 30))) - .with_session(session) - .build()?; - node.install_task_group(CancellationToken::new(), CancellationToken::new())?; - node.install_node_lease(NodeLeaseGuard::new(now_ms, expires_at_ms)?)?; - Ok((node, directory, observed)) -} - -fn unix_now_ms() -> Result> { - Ok(i64::try_from( - SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis(), - )?) -} - -fn replica( - application: &CompiledApplication, - layout: &CellStorageLayout, - target: &cellule_runtime::CellTarget, - incarnation: [u8; 16], -) -> Result> { - let cell_type = application - .cell_types() - .iter() - .find(|cell_type| cell_type.namespace() == target.namespace()) - .ok_or("repository Cell declaration missing")?; - Ok(CellReplica::new( - layout.clone(), - *target.cell_id().as_bytes(), - incarnation, - Limits { - max_database_bytes: cell_type.database_limit_bytes(), - max_capture_bytes: cell_type.capture_limit_bytes(), - ..Limits::default() - }, - )?) -} - -async fn serve( - gateway: Arc, -) -> Result< - ( - std::net::SocketAddr, - oneshot::Sender<()>, - tokio::task::JoinHandle>, - ), - Box, -> { - let listener = TcpListener::bind("127.0.0.1:0").await?; - let address = listener.local_addr()?; - let api = Arc::new(GitHttpApi::new( - gateway, - "canopy".into(), - "example", - &format!("http://{address}"), - Arc::new(|| true), - )?); - let (stop, stopped) = oneshot::channel(); - let server = tokio::spawn(async move { - axum::serve(listener, support::git_router(api)) - .with_graceful_shutdown(async move { - let _ = stopped.await; - }) - .await - }); - Ok((address, stop, server)) -} - -fn path_str(path: &Path) -> Result<&str, &'static str> { - path.to_str().ok_or("path is not UTF-8") -} - -async fn run_git(cwd: Option<&Path>, args: &[&str]) -> Result, Box> { - let mut command = Command::new("git"); - command.arg("-c").arg("credential.helper="); - command.env("GIT_TERMINAL_PROMPT", "0"); - if let Some(cwd) = cwd { - command.current_dir(cwd); - } - let output = command.args(args).output().await?; - if !output.status.success() { - return Err(format!( - "git {} failed: {}", - args.join(" "), - String::from_utf8_lossy(&output.stderr) - ) - .into()); - } - Ok(output.stdout) -} diff --git a/crates/canopy-server/tests/repository_cell/main.rs b/crates/canopy-server/tests/repository_cell/main.rs index da98d22c..2c50504d 100644 --- a/crates/canopy-server/tests/repository_cell/main.rs +++ b/crates/canopy-server/tests/repository_cell/main.rs @@ -1,484 +1,119 @@ -mod batches; -mod branch_rules; -mod bulk_refs; -mod checks; -mod chunks; -mod default_branch; -mod graph; -mod issues; -mod merge; -#[path = "../support/objects.rs"] -mod objects; -mod pages; -mod pulls; -mod rebase; -mod visibility; - -use std::{ - sync::Arc, - time::{SystemTime, UNIX_EPOCH}, -}; - -use canopy_server::{ - CanopyApplication, ObjectKind, PushPlan, RefExpectation, RefUpdate, RepositoryCell, - RepositoryModule, build_descriptor, directory::TokenScope, object_id, repository_target, -}; -use cellule_app::{ApplicationHandle, CellApplication}; -use cellule_ltx::{CellReplica, DiskBudget, Host, Limits}; -use cellule_runtime::{ - ApplicationId, CellClient, CellModule, CellRuntime, CellTarget, Error, InvocationError, - MutationIdentity, NamespaceId, SessionId, TenantId, cell::catalog::CatalogEntry, - cell::catalog::CatalogRole, cell::catalog::CellCatalog, cell::worker::SqlWorkerPool, - control::Owner, control::authority::CellAuthority, identity::IncarnationId, - identity::RequestId, ltx::CellStorageLayout, -}; -use cellule_store::Store; -use object_store::{memory::InMemory, path::Path}; +//! Native catalog publication replaces loose-object ingestion and ref commands. +#[path = "../support/native_server.rs"] +mod native_server; +use canopy_server::RepositoryModule; +use cellule_runtime::CellModule; +use native_server::*; +use object_store::{ObjectStore, memory::InMemory}; +use std::sync::Arc; #[tokio::test(flavor = "multi_thread")] -async fn repository_cell_publishes_objects_and_refs_atomically() --> Result<(), Box> { - let application = Arc::new(CanopyApplication::compile(build_descriptor( - include_bytes!("../../Cargo.toml"), - "repository-cell-test", - ))?); - let tenant = TenantId::from_bytes([2; 16]); - let application_id = ApplicationId::from_bytes([3; 16]); - let mut repository_id = [4; 16]; - repository_id[6] = 0x74; - repository_id[8] = 0x84; - let target = repository_target(tenant, application_id, repository_id)?; - let layout = CellStorageLayout::new( - Store::new(Arc::new(InMemory::new())), - Path::from("canopy-test"), - *application_id.as_bytes(), +async fn repository_cell_publishes_objects_and_refs_atomically() -> Result { + // Hard cutover: fixture setup must not resurrect retired PutObjects (5). + assert!( + !RepositoryModule + .descriptor() + .commands + .iter() + .any(|op| op.id == 5) ); - let registry = application.registry(); - let code = registry - .module_code(RepositoryModule::NAME) - .ok_or(Error::Registry("repository module missing"))?; - let proof = CellCatalog::new(layout.clone(), tenant) - .provision(CatalogEntry::new(&target, CatalogRole::Sql, code, 1)?) + let workspace = tempfile::TempDir::new()?; + let store: Arc = Arc::new(InMemory::new()); + let server = start(&workspace.path().join("resident"), store).await?; + for format in ["sha1", "sha256"] { + let name = format!("atomic-{format}"); + let url = create(&server, &name, format).await?; + let source = workspace.path().join(format!("source-{format}")); + git( + None, + &[ + "init", + &format!("--object-format={format}"), + "-b", + "main", + path(&source)?, + ], + ) .await?; - let authority = CellAuthority::new(layout.clone()); - let incarnation = IncarnationId::from_bytes([5; 16]); - let session = SessionId::from_bytes([6; 16]); - let observed = authority - .create_initial( - &proof, - incarnation, - Owner { - session, - endpoint: "https://canopy.test".into(), - }, + git(Some(&source), &["config", "user.name", "Atomic Test"]).await?; + git( + Some(&source), + &["config", "user.email", "atomic@example.invalid"], ) .await?; - let files = tempfile::TempDir::new()?; - let runtime = CellRuntime::new_with_replica_host( - SqlWorkerPool::new(1, 4)?, - 16 * 1024 * 1024, - session, - Host::default().with_local_disk_budget(DiskBudget::new(1 << 30)), - )?; - let outcome: Result<(), Box> = async { - let handle = runtime - .bootstrap( - proof, - CellReplica::new( - layout, - *target.cell_id().as_bytes(), - *incarnation.as_bytes(), - Limits::default(), - )?, - authority, - observed, - files.path().join("repository.sqlite"), - |transaction| { - transaction.execute_batch(include_str!("../../src/schema.sql"))?; - Ok(()) - }, - ) - .await?; - let application_handle = ApplicationHandle::::new( - CellClient::local(registry, handle), - application, - tenant, - application_id, - )?; - assert!( - application_handle - .sql::(CellTarget::new( - tenant, - application_id, - canopy_server::REPOSITORIES, - &[0; 16], - )?) - .is_err() - ); - assert!( - application_handle - .sql::(CellTarget::new( - tenant, - application_id, - canopy_server::REPOSITORIES, - &[0; 4], - )?) - .is_err() - ); - assert!( - application_handle - .sql::(CellTarget::new( - TenantId::from_bytes([99; 16]), - application_id, - canopy_server::REPOSITORIES, - target.partition(), - )?) - .is_err() - ); - assert!( - application_handle - .sql::(CellTarget::new( - tenant, - application_id, - NamespaceId::from_bytes([99; 16]), - target.partition(), - )?) - .is_err() - ); - let mut other_id = repository_id; - other_id[0] ^= 1; - assert!(matches!( - RepositoryCell::new(&application_handle, target.clone(), other_id, canopy_server::ObjectFormat::Sha1), - Err(Error::Identity("repository UUID differs from Cell target")) - )); - let graph_sql = application_handle.sql::(target.clone())?; - let repository = RepositoryCell::new(&application_handle, target.clone(), repository_id, canopy_server::ObjectFormat::Sha1)?; - let empty = repository.refs_page("", None).await?.output; - assert_eq!(empty.generation, 0); - assert!(empty.refs.is_empty()); - let body = b"Canopy stores ordinary Git objects in a Cell"; - let now_ms = i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis())?; - let identity = |byte| MutationIdentity { - request_id: RequestId::from_bytes([byte; 16]), - issued_at_ms: now_ms, - expires_at_ms: now_ms + 60_000, - }; - repository - .ensure_owner( - MutationIdentity { - request_id: RequestId::from_bytes([11; 16]), - issued_at_ms: now_ms, - expires_at_ms: now_ms + 60_000, - }, - "canopy", - ) - .await?; - pages::exercise(&repository, &graph_sql).await?; - batches::exercise(&repository, &graph_sql).await?; - issues::exercise(&repository).await?; - default_branch::empty(&repository).await?; - let committed = objects::put(&repository, - MutationIdentity { - request_id: RequestId::from_bytes([7; 16]), - issued_at_ms: now_ms, - expires_at_ms: now_ms + 60_000, - }, - ObjectKind::Blob, - body, - ) - .await?; - assert_eq!(committed.output, object_id(canopy_server::ObjectFormat::Sha1, ObjectKind::Blob, body)); - assert_eq!( - repository - .object(committed.output, Some(committed.receipt)) - .await? - .output, - Some((ObjectKind::Blob, body.to_vec())) - ); - let tree = objects::put(&repository, identity(26), ObjectKind::Tree, b"") - .await?.output; - let first_commit = format!("tree {}\nauthor Canopy 0 +0000\ncommitter Canopy 0 +0000\n\nFirst\n", hex::encode(tree)); - let committed = objects::put(&repository, identity(27), ObjectKind::Commit, first_commit.as_bytes()) - .await?; - checks::exercise(&repository, committed.output).await?; - let second_commit = format!("tree {}\nparent {}\nauthor Canopy 1 +0000\ncommitter Canopy 1 +0000\n\nSecond\n", hex::encode(tree), hex::encode(committed.output)); - let next = objects::put(&repository, - MutationIdentity { - request_id: RequestId::from_bytes([8; 16]), - issued_at_ms: now_ms, - expires_at_ms: now_ms + 60_000, - }, - ObjectKind::Commit, - second_commit.as_bytes(), - ) - .await?; - // The empty-ref fast path still rejects an ancestor and descendant in - // the same atomic plan, with no generation change or partial writes. - let before = repository.refs_page("", None).await?.output; - assert!(before.refs.is_empty()); - assert!(matches!( - repository - .finalize_push( - identity(28), - PushPlan { - actor: "canopy".into(), - updates: ["refs/tags/conflict", "refs/tags/conflict/child"] - .into_iter() - .map(|name| RefUpdate { - name: name.into(), - expected: None, - new_oid: Some(committed.output), - }) - .collect(), - }, - ) - .await, - Err(InvocationError::Rejected(_)) - )); - assert_eq!( - repository.default_branch(None).await?.output.generation, - before.generation - ); - let published = repository - .finalize_push( - MutationIdentity { - request_id: RequestId::from_bytes([9; 16]), - issued_at_ms: now_ms, - expires_at_ms: now_ms + 60_000, - }, - PushPlan { - actor: "canopy".into(), - updates: vec![ - RefUpdate { - name: "refs/heads/main".into(), - expected: None, - new_oid: Some(committed.output), - }, - RefUpdate { - name: "refs/heads/other".into(), - expected: None, - new_oid: Some(next.output), - }, - ], - }, - ) - .await?; - assert!(published.output); - let expected_main = RefExpectation { - oid: Some(committed.output), - version: 1, - }; - assert_eq!( - repository - .ref_state("refs/heads/main", Some(published.receipt)) - .await? - .output, - Some(expected_main.clone()) - ); - let conflict = repository - .finalize_push( - MutationIdentity { - request_id: RequestId::from_bytes([10; 16]), - issued_at_ms: now_ms, - expires_at_ms: now_ms + 60_000, - }, - PushPlan { - actor: "canopy".into(), - updates: vec![ - RefUpdate { - name: "refs/heads/main".into(), - expected: Some(RefExpectation { - oid: Some(committed.output), - version: 2, - }), - new_oid: Some(next.output), - }, - RefUpdate { - name: "refs/tags/rejected".into(), - expected: None, - new_oid: Some(next.output), - }, - ], - }, - ) - .await; - assert!(matches!(conflict, Err(InvocationError::Rejected(_)))); - assert_eq!( - repository.ref_state("refs/heads/main", None).await?.output, - Some(expected_main) - ); - assert_eq!( - repository - .ref_state("refs/tags/rejected", None) - .await? - .output, - None - ); - assert!( - repository - .grant_member(identity(12), "canopy", "reader", TokenScope::Read) - .await? - .output - ); - assert_eq!(repository.collaborators("canopy", None).await?.output, Some(vec![canopy_server::Collaborator { account: "reader".into(), role: TokenScope::Read }])); - for actor in ["reader", "outsider"] { - assert!(repository.collaborators(actor, None).await?.output.is_none()); + // Cross the selected-object page boundary with native delta candidates. + for n in 0..600_u64 { + let mut body = vec![b'x'; 8192]; + body[..8].copy_from_slice(&n.to_le_bytes()); + std::fs::write(source.join(format!("file-{n:03}")), body)?; } - let reader_plan = PushPlan { - actor: "reader".into(), - updates: vec![RefUpdate { - name: "refs/tags/reader".into(), - expected: None, - new_oid: Some(next.output), - }], - }; - assert!(matches!( - repository - .finalize_push(identity(13), reader_plan.clone()) - .await, - Err(InvocationError::Rejected(_)) - )); - assert!( - repository - .grant_member(identity(14), "canopy", "reader", TokenScope::Write) - .await? - .output - ); - assert!( - repository - .finalize_push(identity(15), reader_plan.clone()) - .await? - .output - ); - assert!( - repository - .revoke_member(identity(16), "canopy", "reader") - .await? - .output - ); - assert_eq!(repository.access_level("reader", None).await?.output, None); - assert_eq!(repository.collaborators("canopy", None).await?.output, Some(Vec::new())); - let after_revoke = PushPlan { - actor: "reader".into(), - updates: vec![RefUpdate { - name: "refs/tags/after-revoke".into(), - expected: None, - new_oid: Some(next.output), - }], - }; - assert!(matches!( - repository.finalize_push(identity(17), after_revoke).await, - Err(InvocationError::Rejected(_)) - )); - assert!( - repository - .ref_state("refs/tags/after-revoke", None) - .await? - .output - .is_none() - ); - let original_state = repository.ref_state("refs/heads/main", None).await?.output; - let plan = |expected, new_oid| PushPlan { - actor: "canopy".into(), - updates: vec![RefUpdate { - name: "refs/heads/main".into(), - expected, - new_oid, - }], - }; - repository - .finalize_push(identity(18), plan(original_state.clone(), None)) - .await?; - let deleted_state = repository.ref_state("refs/heads/main", None).await?.output; - repository - .finalize_push( - identity(19), - plan(deleted_state.clone(), Some(committed.output)), - ) - .await?; - assert!(matches!( - repository - .finalize_push(identity(20), plan(original_state, Some(next.output))) - .await, - Err(InvocationError::Rejected(_)) - )); - let recreated = repository.ref_state("refs/heads/main", None).await?.output; + git(Some(&source), &["add", "."]).await?; + git(Some(&source), &["commit", "-m", "Native paged objects"]).await?; + git( + Some(&source), + &[ + "push", + "--atomic", + &url, + "HEAD:refs/heads/main", + "HEAD:refs/heads/sibling", + ], + ) + .await?; + let refs = git(None, &["ls-remote", "--refs", &url]).await?; + let clone = workspace.path().join(format!("clone-{format}")); + git(None, &["clone", "--bare", &url, path(&clone)?]).await?; + git(Some(&clone), &["fsck", "--strict", "--full"]).await?; assert_eq!( - recreated, - Some(RefExpectation { - oid: Some(committed.output), - version: 3 - }) - ); - repository - .finalize_push(identity(21), plan(recreated, None)) + git(Some(&clone), &["rev-parse", "main"]).await?, + git(Some(&source), &["rev-parse", "HEAD"]).await? + ); + let api = format!("http://{}/api/repositories/{name}", server.local_addr()); + let client = reqwest::Client::new(); + let repository: serde_json::Value = client + .get(&api) + .bearer_auth(TOKEN) + .send() + .await? + .error_for_status()? + .json() .await?; - assert!(matches!( - repository - .finalize_push(identity(22), plan(deleted_state, Some(next.output))) - .await, - Err(InvocationError::Rejected(_)) - )); - let deleted = repository.ref_state("refs/heads/main", None).await?.output; - assert_eq!( - deleted, - Some(RefExpectation { - oid: None, - version: 4 - }) - ); - let child = RefUpdate { - name: "refs/heads/main/topic".into(), - expected: None, - new_oid: Some(next.output), - }; - repository - .finalize_push( - identity(23), - PushPlan { - actor: "canopy".into(), - updates: vec![child], - }, + client + .put(format!("{api}/branch-rules")) + .bearer_auth(TOKEN) + .json( + &serde_json::json!({"repository_id":repository["repository_id"],"rule":{ + "reference":"refs/heads/main","expected_version":0,"enabled":true, + "deny_deletions":false,"fast_forward_only":false,"require_pull_request":true, + "required_approvals":0,"required_checks":[]}}), ) - .await?; - assert!(matches!( - repository - .finalize_push(identity(24), plan(deleted.clone(), Some(committed.output))) - .await, - Err(InvocationError::Rejected(_)) - )); - let child = repository - .ref_state("refs/heads/main/topic", None) + .send() .await? - .output; - let mut replacement = plan(deleted, Some(committed.output)); - replacement.updates.push(RefUpdate { - name: "refs/heads/main/topic".into(), - expected: child, - new_oid: None, - }); - repository.finalize_push(identity(25), replacement).await?; - assert_eq!( - repository.ref_state("refs/heads/main", None).await?.output, - Some(RefExpectation { - oid: Some(committed.output), - version: 5 - }) - ); - default_branch::verify(&repository).await?; - visibility::verify(&repository).await?; - graph::verify(&repository, &graph_sql, &application_handle, &target).await?; - branch_rules::verify(&repository, &graph_sql, &application_handle, &target).await?; - pulls::verify(&repository).await?; - merge::verify(&repository, &graph_sql, &application_handle, &target).await?; - rebase::verify(&repository, &graph_sql).await?; - chunks::exercise(&repository, &graph_sql).await?; - bulk_refs::verify(&repository).await?; - Ok(()) + .error_for_status()?; + std::fs::write(source.join("later"), b"must not publish a sibling alone")?; + git(Some(&source), &["add", "."]).await?; + git( + Some(&source), + &["commit", "-m", "Reject entire native plan"], + ) + .await?; + let rejected = command( + Some(&source), + &[ + "push", + "--atomic", + &url, + "HEAD:refs/heads/main", + "HEAD:refs/heads/new-sibling", + ], + ) + .output() + .await?; + assert!(!rejected.status.success()); + let error = String::from_utf8(rejected.stderr)?; + assert!(error.contains("[remote rejected]"), "{error}"); + assert_eq!(git(None, &["ls-remote", "--refs", &url]).await?, refs); } - .await; - let shutdown = runtime.shutdown().await; - outcome?; - shutdown?; + server.shutdown().await?; Ok(()) } diff --git a/crates/canopy-server/tests/smart_http/main.rs b/crates/canopy-server/tests/smart_http/main.rs index d1c7c3b5..57734286 100644 --- a/crates/canopy-server/tests/smart_http/main.rs +++ b/crates/canopy-server/tests/smart_http/main.rs @@ -1,544 +1,86 @@ -use std::{path::Path, sync::Arc}; - -use canopy_server::{ - CanopyApplication, ObjectStorage, RepositoryCell, RepositoryModule, build_descriptor, - directory::TokenScope, git_gateway::GitGateway, http::GitHttpApi, lfs::LfsError, - repository_target, -}; -use cellule_app::{ApplicationHandle, CellApplication}; -use cellule_ltx::{CellReplica, DiskBudget, Host, Limits}; -use cellule_runtime::{ - ApplicationId, CellClient, CellModule, CellRuntime, Error, SessionId, TenantId, - cell::catalog::CatalogEntry, cell::catalog::CatalogRole, cell::catalog::CellCatalog, - cell::worker::SqlWorkerPool, control::Owner, control::authority::CellAuthority, - identity::IncarnationId, ltx::CellStorageLayout, -}; -use cellule_store::Store; -use object_store::{ObjectStore, memory::InMemory, path::Path as StorePath}; -use sha2::{Digest as _, Sha256}; -use tokio::{net::TcpListener, process::Command, sync::oneshot}; - -#[path = "../support/paused_blobs.rs"] -mod paused_blobs; -#[path = "../support/mod.rs"] -mod support; - -mod cache_admission; -mod cache_reuse; -mod encoded_input; -mod native_resources; -mod publication; -mod push_uploads; -mod ref_snapshots; +//! Stock Git qualification against resident-owned native publication. +#[path = "../support/native_server.rs"] +mod native_server; +use native_server::*; +use object_store::{ObjectStore, memory::InMemory}; +use std::sync::Arc; #[tokio::test(flavor = "multi_thread")] -async fn stock_git_push_and_clone_are_backed_by_one_repository_cell() --> Result<(), Box> { - let _ = tracing_subscriber::fmt() - .with_env_filter("canopy_server=warn") - .with_test_writer() - .try_init(); - let application = Arc::new(CanopyApplication::compile(build_descriptor( - include_bytes!("../../../../Cargo.lock"), - "smart-http-test", - ))?); - let tenant = TenantId::from_bytes([21; 16]); - let application_id = ApplicationId::from_bytes([22; 16]); - let mut repository_id = [23; 16]; - repository_id[6] = 0x73; - repository_id[8] = 0x83; - let target = repository_target(tenant, application_id, repository_id)?; - let layout = CellStorageLayout::new( - Store::new(Arc::new(InMemory::new())), - StorePath::from("smart-http-test"), - *application_id.as_bytes(), - ); - let registry = application.registry(); - let code = registry - .module_code(RepositoryModule::NAME) - .ok_or(Error::Registry("repository module missing"))?; - let proof = CellCatalog::new(layout.clone(), tenant) - .provision(CatalogEntry::new(&target, CatalogRole::Sql, code, 1)?) - .await?; - let authority = CellAuthority::new(layout.clone()); - let incarnation = IncarnationId::from_bytes([24; 16]); - let session = SessionId::from_bytes([25; 16]); - let observed = authority - .create_initial( - &proof, - incarnation, - Owner { - session, - endpoint: "https://canopy.test".into(), - }, - ) - .await?; - let scratch = tempfile::TempDir::new()?; - let runtime = CellRuntime::new_with_replica_host( - SqlWorkerPool::new(1, 4)?, - 16 * 1024 * 1024, - session, - Host::default().with_local_disk_budget(DiskBudget::new(1 << 30)), - )?; - let outcome: Result<(), Box> = async { - let handle = runtime - .bootstrap( - proof, - CellReplica::new( - layout, - *target.cell_id().as_bytes(), - *incarnation.as_bytes(), - Limits::default(), - )?, - authority, - observed, - scratch.path().join("repository.sqlite"), - |transaction| { - transaction.execute_batch(include_str!("../../src/schema.sql"))?; - Ok(()) - }, - ) - .await?; - let application_handle = ApplicationHandle::::new( - CellClient::local(registry, handle), - application, - tenant, - application_id, - )?; - let repository = Arc::new(RepositoryCell::new( - &application_handle, - target, - repository_id, - canopy_server::ObjectFormat::Sha1, - )?); - repository - .ensure_owner(support::identity()?, "canopy") - .await?; - let paused_blobs = Arc::new(paused_blobs::PausedBlobs::default()); - let blob_store: Arc = paused_blobs.clone(); - let disk_budget = DiskBudget::new(1 << 30); - let gateway = Arc::new(GitGateway::new( - Arc::clone(&repository), - scratch.path().to_path_buf(), - Arc::clone(&blob_store), - disk_budget.clone(), - canopy_server::native_resources::NativeResources::default(), - )); - let invalid_oid = [0; 32]; - assert!(matches!( - gateway - .lfs() - .put("canopy", invalid_oid, Body::from("wrong digest"), None) - .await, - Err(LfsError::Corrupt) - )); - assert!(repository.lfs_object(invalid_oid).await?.output.is_none()); - let denied_body = b"reader LFS object"; - let denied_oid: [u8; 32] = Sha256::digest(denied_body).into(); - assert!(matches!( - gateway - .lfs() - .put( - "reader", - denied_oid, - Body::from(denied_body.as_slice()), - None - ) - .await, - Err(LfsError::Forbidden) - )); - assert!(repository.lfs_object(denied_oid).await?.output.is_none()); - repository - .grant_member(support::identity()?, "canopy", "reader", TokenScope::Write) - .await?; - gateway - .lfs() - .put( - "reader", - denied_oid, - Body::from(denied_body.as_slice()), - None, - ) - .await?; - assert!(repository.lfs_object(denied_oid).await?.output.is_some()); - repository - .revoke_member(support::identity()?, "canopy", "reader") - .await?; - let revoked_body = b"revoked LFS object"; - let revoked_oid: [u8; 32] = Sha256::digest(revoked_body).into(); - assert!(matches!( - gateway - .lfs() - .put( - "reader", - revoked_oid, - Body::from(revoked_body.as_slice()), - None - ) - .await, - Err(LfsError::Forbidden) - )); - assert!(repository.lfs_object(revoked_oid).await?.output.is_none()); - push_uploads::verify(&gateway).await?; - let listener = TcpListener::bind("127.0.0.1:0").await?; - let address = listener.local_addr()?; - let teardown_gateway = Arc::clone(&gateway); - let api = Arc::new(GitHttpApi::new( - gateway, - "canopy".into(), - "example", - &format!("http://{address}"), - Arc::new(|| true), - )?); - let (stop_tx, stop_rx) = oneshot::channel::<()>(); - let server = tokio::spawn(async move { - axum::serve(listener, support::git_router(api)) - .with_graceful_shutdown(async move { - let _ = stop_rx.await; - }) - .await - }); - let url = format!("http://{address}/canopy/example.git"); - let unauthenticated = Command::new("git") - .args(["-c", "credential.helper=", "ls-remote", &url]) - .env("GIT_TERMINAL_PROMPT", "0") - .output() - .await?; - assert!(!unauthenticated.status.success()); - let client = reqwest::Client::new(); - let unsupported = client +async fn stock_git_push_and_clone_are_backed_by_one_repository_cell() -> Result { + let workspace = tempfile::TempDir::new()?; + let store: Arc = Arc::new(InMemory::new()); + let server = start(&workspace.path().join("resident"), store).await?; + let url = create(&server, "smart", "sha1").await?; + let client = reqwest::Client::new(); + assert_eq!( + client .post(format!("{url}/git-receive-pack")) - .bearer_auth("local-test-token") + .bearer_auth(TOKEN) .header("Content-Type", "text/plain") - .body(Vec::new()) - .send() - .await?; - assert_eq!( - unsupported.status(), - reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE - ); - let retained = disk_budget.used(); - let occupied = disk_budget.try_reserve(disk_budget.capacity() - retained - 1)?; - let admission_id = uuid::Uuid::new_v4().to_string(); - let admission_request = || { - client - .post(format!("{url}/git-receive-pack")) - .bearer_auth("local-test-token") - .header("Content-Type", "text/plain") - .header("Idempotency-Key", &admission_id) - .body("1234") - }; - assert_eq!( - admission_request().send().await?.status(), - reqwest::StatusCode::INSUFFICIENT_STORAGE - ); - drop(occupied); - assert_eq!(disk_budget.used(), retained); - assert_eq!( - admission_request().send().await?.status(), - reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE - ); - let mut request = Vec::new(); - for index in 0..8000 { - let capabilities = if index == 0 { - "\0report-status side-band-64k" - } else { - "" - }; - let command = format!( - "{} {} refs/heads/bad-pack-{index:04}-{}{capabilities}\n", - "0".repeat(40), - "1".repeat(40), - "x".repeat(48) - ); - request.extend_from_slice(format!("{:04x}{command}", command.len() + 4).as_bytes()); - } - request.extend_from_slice(b"0000"); - request.extend_from_slice(b"not a pack!!"); - let push_id = uuid::Uuid::new_v4().to_string(); - let response = client - .post(format!("{url}/git-receive-pack")) - .bearer_auth("local-test-token") - .header("Content-Type", "application/x-git-receive-pack-request") - .header("Idempotency-Key", &push_id) - .timeout(std::time::Duration::from_secs(5)) - .body(request.clone()) - .send() - .await?; - assert_eq!(response.status(), reqwest::StatusCode::OK); - let report = response.bytes().await?; - assert!(report.len() > 512 * 1024); - let replay = client - .post(format!("{url}/git-receive-pack")) - .bearer_auth("local-test-token") - .header("Content-Type", "application/x-git-receive-pack-request") - .header("Idempotency-Key", &push_id) - .body(request) + .body("invalid media") .send() .await? - .error_for_status()? - .bytes() - .await?; - assert_eq!(replay, report); - assert_eq!( - client - .post(format!("{url}/git-receive-pack")) - .bearer_auth("local-test-token") - .header("Content-Type", "application/x-git-receive-pack-request") - .header("Idempotency-Key", &push_id) - .body(b"0000".to_vec()) - .send() - .await? - .status(), - reqwest::StatusCode::CONFLICT - ); - assert!( - report - .windows(b"ng refs/heads/bad-pack".len()) - .any(|part| part == b"ng refs/heads/bad-pack") - ); - assert!( - repository - .ref_state( - &format!("refs/heads/bad-pack-0000-{}", "x".repeat(48)), - None - ) - .await? - .output - .is_none() - ); - - cache_admission::verify(scratch.path(), &repository, &disk_budget, &client, &url).await?; - - let local = scratch.path().join("local"); - run_git( - None, - &["init", "-b", "main", local.to_str().ok_or("invalid path")?], - ) - .await?; - run_git(Some(&local), &["config", "user.name", "Canopy Test"]).await?; - run_git( - Some(&local), - &["config", "user.email", "canopy@example.invalid"], - ) - .await?; - run_git(Some(&local), &["lfs", "install", "--local"]).await?; - run_git(Some(&local), &["lfs", "track", "*.lfs"]).await?; - tokio::fs::write( - local.join("README.md"), - b"served from the repository Cell\n", - ) - .await?; - let large_body = vec![0x5a; 1_200_000]; - tokio::fs::write(local.join("large.bin"), &large_body).await?; - let lfs_body = vec![0xa5; 1_500_000]; - tokio::fs::write(local.join("tracked.lfs"), &lfs_body).await?; - run_git( - Some(&local), - &[ - "add", - ".gitattributes", - "README.md", - "large.bin", - "tracked.lfs", - ], - ) - .await?; - run_git(Some(&local), &["commit", "-m", "Initial commit"]).await?; - let original = run_git(Some(&local), &["rev-parse", "HEAD"]).await?; - let original = std::str::from_utf8(&original)?.trim(); - run_git( - Some(&local), - &[ - "-c", - "http.extraHeader=Authorization: Bearer local-test-token", - "push", - &url, - "HEAD:refs/heads/main", - ], - ) - .await?; - let expected_oid: canopy_server::ObjectId = hex::decode(original)? - .try_into() - .map_err(|_| "invalid commit ID")?; - assert_eq!( - repository - .ref_state("refs/heads/main", None) - .await? - .output - .and_then(|state| state.oid), - Some(expected_oid) - ); - let mut cursor = None; - let mut external_seen = false; - loop { - let page = repository.object_page(cursor).await?.output; - if page.is_empty() { - break; - } - for object in page { - cursor = Some(object.oid); - external_seen |= matches!(object.storage, ObjectStorage::External { .. }); - } - } - assert!(external_seen); - let lfs_oid: [u8; 32] = Sha256::digest(&lfs_body).into(); - assert_eq!( - repository - .lfs_object(lfs_oid) - .await? - .output - .map(|object| object.size), - Some(lfs_body.len() as u64) - ); - run_git( - Some(&local), - &[ - "-c", - "http.extraHeader=Authorization: Bearer local-test-token", - "push", - &url, - ":refs/heads/main", - ], - ) - .await?; - assert!( - run_git( - Some(&local), - &[ - "-c", - "http.extraHeader=Authorization: Bearer local-test-token", - "ls-remote", - &url, - ] - ) - .await? - .is_empty() - ); - run_git( - Some(&local), - &[ - "-c", - "http.extraHeader=Authorization: Bearer local-test-token", - "push", - &url, - "HEAD:refs/heads/main", - ], - ) - .await?; - assert!( - repository - .ref_state("refs/heads/main", None) - .await? - .output - .is_some_and(|state| state.version == 3) - ); - ref_snapshots::verify(&repository, &client, &url).await?; - let _ = stop_tx.send(()); - server.await??; - // Axum 0.8 signals connection closure before dropping its service. - // Retain the final owner and wait for those service clones, then drop - // the gateway synchronously before checking complete cache teardown. - tokio::time::timeout(std::time::Duration::from_secs(5), async { - while Arc::strong_count(&teardown_gateway) > 1 { - tokio::task::yield_now().await; - } - }) - .await?; - drop(teardown_gateway); - assert_eq!( - disk_budget.used(), - 0, - "gateway teardown releases retained object and snapshot charges" - ); - - let gateway = Arc::new(GitGateway::new( - Arc::clone(&repository), - scratch.path().to_path_buf(), - blob_store, - DiskBudget::new(1 << 30), - canopy_server::native_resources::NativeResources::default(), - )); - let listener = TcpListener::bind("127.0.0.1:0").await?; - let address = listener.local_addr()?; - let api = Arc::new(GitHttpApi::new( - gateway, - "canopy".into(), - "example", - &format!("http://{address}"), - Arc::new(|| true), - )?); - let (stop_tx, stop_rx) = oneshot::channel::<()>(); - let server = tokio::spawn(async move { - axum::serve(listener, support::git_router(api)) - .with_graceful_shutdown(async move { - let _ = stop_rx.await; - }) - .await - }); - let url = format!("http://{address}/canopy/example.git"); - let clone = scratch.path().join("clone"); - run_git( + .status(), + reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE + ); + let source = workspace.path().join("source"); + git(None, &["init", "-b", "main", path(&source)?]).await?; + git(Some(&source), &["config", "user.name", "HTTP Test"]).await?; + git( + Some(&source), + &["config", "user.email", "http@example.invalid"], + ) + .await?; + let first = vec![17_u8; 2 * 1024 * 1024]; + let second = vec![29_u8; 2 * 1024 * 1024]; + std::fs::write(source.join("first"), &first)?; + std::fs::write(source.join("second"), &second)?; + git(Some(&source), &["add", "."]).await?; + git(Some(&source), &["commit", "-m", "Native HTTP body"]).await?; + git( + Some(&source), + &["push", "-o", "canopy.note=resident", &url, "main"], + ) + .await?; + for version in ["0", "2"] { + let clone = workspace.path().join(format!("blobless-{version}")); + git( None, &[ "-c", - "http.extraHeader=Authorization: Bearer local-test-token", + &format!("protocol.version={version}"), "clone", + "--filter=blob:none", + "--no-checkout", &url, - clone.to_str().ok_or("invalid path")?, - ], - ) - .await?; - assert_eq!( - tokio::fs::read(clone.join("README.md")).await?, - b"served from the repository Cell\n" - ); - assert_eq!(tokio::fs::read(clone.join("large.bin")).await?, large_body); - let tags = run_git(Some(&clone), &["tag", "--list", "snapshot-*"]).await?; - assert_eq!(std::str::from_utf8(&tags)?.lines().count(), 300); - run_git(Some(&clone), &["fsck", "--full"]).await?; - run_git(Some(&clone), &["lfs", "install", "--local"]).await?; - run_git( - Some(&clone), - &[ - "-c", - "http.extraHeader=Authorization: Bearer local-test-token", - "lfs", - "pull", + path(&clone)?, ], ) .await?; - assert!(tokio::fs::read(clone.join("tracked.lfs")).await? == lfs_body); - native_resources::verify(scratch.path(), &url).await?; - cache_reuse::verify(scratch.path(), &local, &url).await?; - publication::verify(scratch.path(), &repository, &paused_blobs, &url).await?; - let _ = stop_tx.send(()); - server.await??; - Ok(()) + assert_eq!(git(Some(&clone), &["show", "HEAD:first"]).await?, first); + assert_eq!(git(Some(&clone), &["show", "HEAD:second"]).await?, second); + git(Some(&clone), &["fsck", "--strict"]).await?; } - .await; - let shutdown = runtime.shutdown().await; - outcome?; - shutdown?; + let guess = "12".repeat(20); + let want = format!("want {guess}\n"); + let request = format!("{:04x}{want}00000009done\n", want.len() + 4); + assert_eq!( + client + .post(format!("{url}/git-upload-pack")) + .bearer_auth(TOKEN) + .header("Content-Type", "application/x-git-upload-pack-request") + .body(request) + .send() + .await? + .status(), + reqwest::StatusCode::BAD_REQUEST + ); + git( + Some(&source), + &["push", "-o", "canopy.note=delete", &url, ":main"], + ) + .await?; + assert!(git(None, &["ls-remote", "--refs", &url]).await?.is_empty()); + server.shutdown().await?; Ok(()) } - -async fn run_git(cwd: Option<&Path>, args: &[&str]) -> Result, Box> { - let mut command = Command::new("git"); - command.arg("-c").arg("credential.helper="); - command.env("GIT_TERMINAL_PROMPT", "0"); - if let Some(cwd) = cwd { - command.current_dir(cwd); - } - let output = command.args(args).output().await?; - if !output.status.success() { - return Err(format!( - "git {} failed: {}", - args.join(" "), - String::from_utf8_lossy(&output.stderr) - ) - .into()); - } - Ok(output.stdout) -} -use axum::body::Body; diff --git a/crates/canopy-server/tests/support/native_server.rs b/crates/canopy-server/tests/support/native_server.rs new file mode 100644 index 00000000..e6997cca --- /dev/null +++ b/crates/canopy-server/tests/support/native_server.rs @@ -0,0 +1,81 @@ +//! Production resident fixture shared by standalone transport qualifications. +use canopy_server::server::{CanopyServer, ServerConfig}; +use cellule_runtime::{ApplicationId, Digest, TenantId, identity::NodeId}; +use ed25519_dalek::SigningKey; +use object_store::{ObjectStore, path::Path as StorePath}; +use std::{path::Path, sync::Arc}; +use tokio::{net::TcpListener, process::Command}; +pub type Result = std::result::Result>; +pub const TOKEN: &str = "local-test-token"; + +pub async fn start(directory: &Path, store: Arc) -> Result { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let address = listener.local_addr()?; + let config = ServerConfig { + tenant: TenantId::from_bytes([31; 16]), + application: ApplicationId::from_bytes([32; 16]), + node: NodeId::from_bytes(uuid::Uuid::new_v4().into_bytes()), + fleet: Digest::from_bytes([35; 32]), + image: Digest::from_bytes([36; 32]), + signing_key: SigningKey::from_bytes(&[37; 32]), + owner: "canopy".into(), + token: TOKEN.into(), + public_url: format!("http://{address}"), + peer_endpoint: "https://native-fixture.invalid".into(), + peer_ca_pem: None, + listen: address, + ssh: None, + data_dir: directory.to_owned(), + store_prefix: StorePath::from("native-standalone-fixture"), + native_limits: canopy_server::native_resources::NativeLimits::default(), + local_disk_limit_bytes: 1 << 30, + max_active_repositories: 3, + }; + Ok(CanopyServer::start_with_listener(config, store, listener).await?) +} +pub async fn create(server: &CanopyServer, name: &str, format: &str) -> Result { + let body: serde_json::Value = reqwest::Client::new() + .post(format!("http://{}/api/repositories", server.local_addr())) + .bearer_auth(TOKEN) + .json(&serde_json::json!({"name":name,"object_format":format})) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(body["clone_url"] + .as_str() + .ok_or("clone URL missing")? + .into()) +} +pub fn path(path: &Path) -> Result<&str> { + Ok(path.to_str().ok_or("non-UTF8 fixture path")?) +} +pub fn command(cwd: Option<&Path>, arguments: &[&str]) -> Command { + let mut command = Command::new("git"); + command + .args([ + "-c", + "credential.helper=", + "-c", + "http.extraHeader=Authorization: Bearer local-test-token", + ]) + .env("GIT_TERMINAL_PROMPT", "0"); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + command.args(arguments); + command +} +pub async fn git(cwd: Option<&Path>, arguments: &[&str]) -> Result> { + let output = command(cwd, arguments).output().await?; + if !output.status.success() { + return Err(format!( + "git {} failed: {}", + arguments.join(" "), + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + Ok(output.stdout) +} diff --git a/crates/canopy-server/tests/support/paused_blobs.rs b/crates/canopy-server/tests/support/paused_blobs.rs index 57329463..0a8fd61f 100644 --- a/crates/canopy-server/tests/support/paused_blobs.rs +++ b/crates/canopy-server/tests/support/paused_blobs.rs @@ -44,15 +44,18 @@ impl ObjectStore for PausedBlobs { path: &StorePath, options: PutMultipartOptions, ) -> object_store::Result> { - // Large-blob ingestion starts only after native receive-pack has - // accepted the disposable refs, but before Cell ref publication. - if self.armed.swap(false, Ordering::SeqCst) { + // Native pack capture starts after receive-pack accepted disposable + // refs and before the certified joint root can become visible. + if path.as_ref().contains("/git-packs/") + && path.as_ref().contains("/staging/") + && self.armed.swap(false, Ordering::SeqCst) + { self.entered.notify_one(); self.proceed.notified().await; if self.fail.swap(false, Ordering::SeqCst) { return Err(object_store::Error::Generic { store: "publication-race-store", - source: Box::new(std::io::Error::other("injected blob ingestion failure")), + source: Box::new(std::io::Error::other("injected native pack upload failure")), }); } } @@ -63,7 +66,10 @@ impl ObjectStore for PausedBlobs { path: &StorePath, options: GetOptions, ) -> object_store::Result { - if path.as_ref().contains("/git-blobs/") && self.read_armed.swap(false, Ordering::SeqCst) { + if path.as_ref().contains("/git-packs/") + && (path.as_ref().contains("/pack/") || path.as_ref().ends_with("/pack")) + && self.read_armed.swap(false, Ordering::SeqCst) + { self.entered.notify_one(); self.proceed.notified().await; if self.fail.swap(false, Ordering::SeqCst) { diff --git a/docs/contracts.md b/docs/contracts.md index 6f0ca384..73b14c18 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -103,7 +103,7 @@ These limits bound individual requests and publication work. They do not cap tot | LFS request deadline | Batch: 120 seconds; object PUT: 120-second input idle timeout with no whole-transfer deadline; timeout returns 408 | Git HTTP router / LFS service | | Git request admission | No receive-pack byte quota; 64 MiB for other requests; 120-second input idle deadline | anonymous request spool | | Ref mutation | check actor's write role, compare expected optional OID and monotonic version; retain deletion records and apply all updates in one Cell transaction | `FinalizePush` | -| Symbolic HEAD | `ref_generation.default_branch`, initially `refs/heads/main`; owner-authorized compare-and-set with ref generation | `RepositoryCell::set_default_branch` | +| Symbolic HEAD | Accepted immutable `RefStateSnapshot.default_branch`, initially `refs/heads/main`; owner-authorized joint-root compare-and-set | `PublishNativeHead`, operation 54, codec 1 | | HTTP push identity | repository-local UUID bound to account and BLAKE3 request digest; different IDs identify independent operations | `pushes` | | HTTP push outcome | status, headers and BLAKE3-verified body in SQLite chunks; publish response pointer, rejection decision and ordered push notes atomically with accepted refs | `CompletePush`, codec 5 | | Graph certificates | at most 128 candidates; SQLite verification targets 64 MiB, with larger objects verified individually; typed dependencies must already be certified | `CertifyObjects`, operation 6, codec 1 | @@ -155,26 +155,50 @@ implemented yet. ### Ref snapshots and default branch -The `ref_generation` singleton advances once in the same transaction as each -accepted ref plan or default-branch update, including selecting the same branch. -Typed finalization and HTTP completion share the ref-plan update; rejected plans, -failed HEAD preconditions, completed-request replay and object/ACL writes do not -advance it. Ref queries return at most 256 rows with HEAD and generation in one SQLite -statement, including an empty terminal page. A continuation must supply the -first page's generation. Changes invalidate the scan even when tips return to -their previous OIDs or names are deleted and recreated. The gateway discards -partial scans and tries at most three scans, then returns HTTP 503. -Successful scans therefore describe one coherent ref state. That state can -become older while its disposable cache is hydrated; admitted readers retain -the selected generation, and immutable objects remain readable without GC. - -HEAD must name a valid `refs/heads/` reference under Canopy's UTF-8, -255-byte ref policy. Changing it requires the owner, an expected ref generation, -and either a live target branch or no live branches. Owner authorization, target -existence and generation comparison occur in one Cell SQL update. Concurrent -ref changes and HEAD ABA invalidate the precondition. The SDK's mutation identity -replays its recorded result; the HTTP API uses an explicit generation and requires -a fresh GET after an ambiguous reply. It does not silently retry updates. +The accepted immutable ref snapshot's generation advances once for every +accepted ref plan or HEAD update, including selecting the same branch. Ref +records retain independent monotonic versions and deletion history. HEAD-only +changes reuse the existing ref tree without changing those records. Ref pages +select the same certified joint catalog/ref generation; continuations must use +the unchanged ref generation. A push or HEAD ABA invalidates that precondition. + +HEAD must name a valid UTF-8 `refs/heads/` reference within the ref metadata's +65,535-byte name limit. The HTTP body has a separate 8 KiB limit. Changing HEAD +requires the current repository owner, an admin-scoped token, the expected ref +generation, and either a live target branch or no live branches. The private +preparation reads the held immutable tree. Checking for any live branch uses a +seek and one live cursor result, skipping whole tombstoned subtrees. + +Operation 54 carries a purpose-bound catalog certificate, original request and +conditional new snapshot. The final Cell transaction rechecks current owner, +ACL, actual fence, exact original pin, expiry, retention floor and current joint +roots, then publishes roots, immutable `catalog_head_updates` outcome, checkpoint, +attempt closure and the original recovery phase together. No network observer +owns the command. The resident staging/publication lifecycle retains its original +prepared command and owner through cancellation and uncertain acknowledgements. +Results fit the existing 512-byte recovery phase limit. A known SDK receipt or +journal is resolved before body downloads or new custody. A cold absent HEAD +command performs no new native work; its final receiver can record the original +expiry or revocation denial without first requiring fresh Write authorization. + +Typed retirement checks the immutable selected outcome and bounded catalog, +directory and ref snapshot headers before transferring the original journal to +shared recovery receipts and releasing the transient pin. A missing selected +snapshot retains the pin. The permanent original outcome cannot be updated or +deleted. This authorizes no provider deletion; physical collection and quota +qualification remain separate work. + +The HTTP API uses an explicit generation and requires a fresh GET after an +ambiguous reply. It does not silently retry an update. Repository discovery, +and default-branch GET read the existing constant-size `ref_generation` summary +with current read access in the same query. Every successful native push, +reviewed merge and HEAD publication updates its generation atomically with the +accepted immutable ref snapshot. Push and merge preserve HEAD; operation 54 +changes both fields. The private merge proof binds the generation read from its +prepared immutable snapshot (operation 9 codec 7). Ref-free completions and +refusals leave the summary unchanged. Stock Git reads the accepted immutable +snapshot. Metadata reads perform no artifact I/O, acquire no serving pin and +publish no Cell root. The detached SQL HEAD setter fails closed. `GET /api/repositories//default-branch` requires repository read access, including anonymous public access. It returns `repository_id`, `reference` and `generation`. @@ -1142,8 +1166,10 @@ acknowledged state is recovered from object storage. Normal shutdown may leave local files for the next startup to reclaim. One supervisor owns node startup, the listener, Cell drain and the workspace. -`CanopyServer::start` waits for its readiness result; cancelling that wait closes -the control channel and requests drain after admitted initialization settles. +`CanopyServer::start` waits for its readiness result. A reported startup error +also joins the supervisor before returning, so completion includes release of +its task-owned startup resources. Successful readiness retains the running +supervisor in the handle. Cancelling either wait closes the control channel and requests drain after admitted initialization settles. Dropping a returned handle also requests drain. `shutdown()` waits for completion, but cancelling the wait leaves that same supervisor running. The Tokio runtime must stay alive for cleanup to finish. This does not make runtime destruction, @@ -1618,6 +1644,26 @@ increasing creation number. The OID must identify a stored Git commit; absent objects, trees, tags and blobs are not check targets. A context does not imply branch protection; an enabled branch rule must explicitly require it. +Native commit reads and starts use the resident serving snapshot's authenticated +catalog headers to prove kind and existence. The private `CommitMembership` +factory issues a bounded purpose-specific MAC binding tenant, application, +repository, actor, commit OID, serving token and exact retained catalog/ref fact. +Header lookup runs under the existing tracked physical read owner and admission; +it reads bounded metadata and does not hydrate native pack bodies. The caller +keeps its serving snapshot through the final receiver. + +Repository command 49 starts a check; query 50 reads the bounded latest-check +page. Both verify the MAC, actual Cell identity, current read access, exact live +pin and retained fact. Command 49 also checks the actual admitted owner fence. +Pin expiry uses a refreshed wall clock clamped to runtime logical time. An +unrelated publication may advance current head while the original retained pin +remains authoritative. Producer release, expiry, access loss, or a substituted +actor/OID/repository invalidates that authority. Command policy decisions and +conditional insertion occur in the same transaction. Recorded rejections retain +their receipts and map to domain HTTP outcomes; pending invocation errors remain +ambiguous. Inputs are bounded at 4 KiB and commit pages at 256 KiB / 32 contexts. +The existing check metadata tables, creation order and policy triggers are reused. + Only the configured reporter can start runs. The owner has no implicit reporting bypass and must explicitly configure itself as reporter if desired. Starting requires the enabled context's current version and current repository read access. @@ -1666,7 +1712,8 @@ HTTP routes: Each context contains name, reporter, enabled and version. Each run contains id, OID, context, context_version, reporter, state, version, summary, created_at_ms and updated_at_ms. Mutation UUIDs are canonical lowercase with the supported -RFC variant/version; OIDs use 40 lowercase hexadecimal characters. All writes +RFC variant/version; OIDs use 40 or 64 lowercase hexadecimal characters for +the repository's SHA-1 or SHA-256 format. All writes carry the repository UUID to prevent stale names from targeting another Cell. Missing membership/resources/non-commit targets return 404, authority or scope failure returns 403, identity/version/terminal-state conflicts return 409, and @@ -1706,8 +1753,10 @@ admin-scoped owner token and `{repository_id, rule}`. `rule` contains `reference are 422. Repository identity is a UUID precondition. The body limit is 16 KiB with a 30-second receive deadline. The Cell command rechecks owner authority. -The one authoritative `refs::apply_refs` function applies to typed -`FinalizePush`, HTTP `CompletePush`, and `MergePull`. Before any ref writes, each enabled rule +The historical SQL publisher used `refs::apply_refs` for typed +`FinalizePush`, HTTP `CompletePush`, and `MergePull`. Native publication instead +uses privately certified immutable ref roots and reuses current branch/check +predicates; native reviewed merge operation 9, codec 7 is described below. Before any ref writes, each enabled rule checks deletion policy, ancestry and every required check. For a non-deletion, the selected attempt is the greatest creation number matching the proposed commit, context and current context version. It must have state `success` and @@ -1719,8 +1768,11 @@ ref mutation, including deletion and recreation. Otherwise deletion depends on `deny_deletions`; checks and fast-forward policy govern non-deletions. Branch creation needs checks but has no old ancestry to prove. -Verified commit objects provide `commit_parents(child, parent)` when graph -closure is certified. Only commit-parent edges enter that table. Immutable +In the retired SQL graph contract, verified commit objects provided +`commit_parents(child, parent)` when graph closure was certified. Native +preparation reads verified catalog commit headers and uses MAC-bound ancestry +evidence; it does not populate these retired tables. The historical contract +below does not describe native production authority. Only commit-parent edges enter that table. Immutable `commit_ancestry(ancestor, descendant)` certificates avoid graph traversal in the ref transaction. Operation 7, codec 1 accepts at most 128 child/parent steps. Every step must exist in verified parent links and lead either to the claimed @@ -1800,8 +1852,9 @@ backfill path for old object certificates lacking parent rows. author, editorial content, open/closed state, draft flag, optimistic version, fixed source/base branch names, initial commit OIDs and timestamps. Pull and issue numbers have separate sequences. A pull does not store a mutable copy of current -branch state: reads join the two durable `refs` rows in the same Cell observation. -This avoids fanout writes to every open pull after a push. Retained ref tombstones +branch state: reads join privately authenticated facts from the current immutable +ref snapshot with editorial rows in one final Cell observation. This avoids +fanout writes to every open pull after a push. Retained ref tombstones expose deleted tips as null without losing their versions. Original commit OIDs remain available in details. Pulls may share the same source/base pair. @@ -1846,9 +1899,9 @@ results continue to use their configured context versions, as documented above. A fresh development prefix is required; old memberships have no generation backfill. A future migration must initialize those before enabling these APIs. -All three mutations are bounded guarded SQL batches through the registered Cell -SQL command. Their recorded pre-mutation domain decision, conditional write and -result number share the same transaction. Runtime receipt replay preserves the +Creation and review use typed commands 51 and 53; editorial edits use the existing +registered Cell SQL command. Their recorded pre-mutation domain decision, +conditional write and result number share the same transaction. Runtime receipt replay preserves the original outcome. Application UUID bindings provide HTTP retry semantics across fresh command identities. Failed domain decisions leave pull/review content unchanged. The SDK accepts an authenticated actor assertion; HTTP authenticates @@ -1856,6 +1909,144 @@ before reading the body and the Cell rechecks membership/authority at mutation. As with issues, already admitted token revocation follows the existing admission boundary; repository revocation is checked again in the write. +Native ref observations reuse the MAC envelope, serving token and joint catalog/ref +fact. Issuance derives exact OIDs, versions, tombstones and never-present names +from the immutable ref tree inside the tracked physical read owner. Its +constant-sized certificate binds repository, actor, actual Cell, request purpose +and payload digest, and the complete sorted fact vector digest. Receivers verify +the repository seed/MAC, fresh access, exact unexpired serving pin, admitted command +owner fence and equality with the **current** joint generation before using facts. +A retained historical generation alone cannot authorize current ref policy. +Altered payloads or facts, another Cell, later joint publication and physical pin +release invalidate the observation. Ref lookup requires no native pack bodies. + +Facts shadow `refs` only in a parameterized statement-local CTE. No SQL ref mirror +is populated; the fresh production schema removes the obsolete source/base foreign +keys into that table. Existing editorial rows, UUID digests, member versions and +review-head ordering remain authoritative for collaboration. Inputs admit at most +128 unique sorted refs, 512 KiB of total name bytes and 65,535 bytes per name, within +an 816 KiB operation input bound. Mutation results admit 16 bytes. Unknown names +and deleted tombstones remain distinct authenticated facts; neither supplies a +live tip for a new pull or review. + +Query 52, codec 2, reads lists, details, review applicability and review policy +with a 1 MiB output bound. Policy is a distinct request purpose: a prepared policy +observation cannot authorize a detail query or another pull number. +The initial bounded selection binds pull numbers, editorial versions and ref names. +The final query repeats that selection and rejects a mismatch before joining the +certified refs. The adapter makes at most three attempts with fresh selections; +continued movement returns an explicit error, never a partial or skewed page. +Each final transaction rechecks current access, including anonymous public access +and public-to-private changes after preparation. Known denied mutations still +reach the final command without a proof; its fresh access decision records +NotFound while access remains denied, or Conflict if it has changed. Pending or +transport failures remain errors. Review-policy reads join the authenticated +native tips with current rules, review heads and member generations in the final +query. Changes to rules or decisions after preparation are reflected immediately; +ref/editorial changes invalidate the prepared observation. Tombstones remove the +reviewed revision; recreating the same OID with a later ref version cannot restore +old approvals. Merge ancestry preparation uses this same native policy reader. +The public merge adapter and generated Git producers still need native +publication integration and are not qualified by these read operations. Codec 2 is a hard +cutover of this unreleased query; no old input decoder is retained. + +Native merge preparation has a separate +`PreparedCatalog::ref_proof_with_required_ancestry` factory. Unlike ordinary +`ref_proof`, it walks every non-vacuous base-to-target predicate regardless of +branch rules. It reuses `RefPublicationProof`, the signed plan/evidence digest, +verified native commit headers and the bounded disk-backed walker. False bits +remain negative facts; a decoded bit vector grants no authority. Creation, +deletion and identical tips retain the existing vacuous predicate semantics, +with deletion permissions checked separately at publication. Ordinary pushes +continue to compute ancestry only where their current policy requires it. + +Both factories retain the preparation's live lease, timeout, cancellation and +scratch budget. Neither publishes roots or populates SQL refs/ancestry. A final +native merge receiver must authenticate the exact proof and conditional ref +snapshot, check current access/reviews/checks and ref versions, and commit joint +roots, pull state and UUID result under the actual owner fence and durable +recovery journal. The native receiver and resident fast-forward adapter described below exist. +Typed terminal release is described below. The historical SQL merge description +below is not qualification of generated native merge strategies. + +`PublishReviewedMerge` reuses operation 9 with codec 7 and a 256 KiB input / 512 +byte output contract. It replaces the registered contract rather than decoding +legacy codec 4. Its private factory requires a ref-only `PreparedCatalog`: no +incoming pack or native push-result checkpoint is accepted. It reads at most two +source/base facts from that preparation's immutable ref root, verifies native +ancestry, and prepares the conditional ref snapshot while preserving HEAD. +The existing catalog MAC binds actor, exact request including preparation time, +fact vector, plan/evidence, proposed ref root and permanent audit root under a +distinct merge purpose. +No serving-only proof, arbitrary root or transport bit grants write authority. + +The final command requires its exact registered SDK command and body before +evaluating the domain transition. It authenticates the catalog certificate, +checks current write authority, then replays an exactly bound application UUID +before testing new policy. A new merge requires the actual owner fence, live +matching operation and pin, retained base and equality with the current joint +generation. Authenticated facts join current editorial, review-head and member +metadata in the same statement-local CTE as native pull reads. The exact reviewed +update privately satisfies only its require-PR gate; ancestry and current check +context/reporter results remain mandatory even on unprotected branches. + +Joint catalog/ref roots, pull state/version, immutable UUID result, preparation +checkpoint and operation consumption share the final Cell transaction and its +durable recovery journal. Every authenticated terminal result also closes its +exact matching operation under the actual owner fence, including domain refusals +and applied UUID replays. It cannot close a successor or unauthenticated +proposal; the independent pin remains until typed retirement. Errors after the +first write roll back operation closure, domain changes, phase and SDK acceptance. +Rejected requests do not insert `pull_merges`, so a fresh owned attempt may retry +the same application UUID after policy changes. The older attempt still recovers +its original refusal and receipt. `ReadyNativeMerge` binds its exact original +command and private owner into the existing fair `ReadyBoundRecovery` dispatcher; +Kind `Merge` cannot be restored or decoded as a push outcome. + +The current SHA-256 maximum result encodes to 493 bytes, within the unchanged +512-byte journal cap. Known results survive original factory loss, SQLite loss +and actual owner restoration. An applied UUID row also retains a bounded +`StoredInputRoot` descriptor containing its typed request, catalog and ref +snapshot. The final merge transaction saves that descriptor atomically with the +result; SQL guards prohibit replacing, updating or deleting the row. Terminal +release selects the original UUID audit, authenticates its typed catalog roots +and exact published base-ref path, and transfers the original recovery phase +into the existing immutable receipt archive before deleting the pin. A fresh +UUID replay must close its own operation first and selects the original audit, +not its new proposal. Known denials retain their original phase after later +success and require authoritative operation closure. This authorizes no provider +deletion; complete retained-root inventory and descendant reclamation remain +required. See [terminal retention](design/terminal-publication-retention.md). +The HTTP fast-forward adapter now uses `GitGateway::merge_pull`. It checks +current access after body ingestion, then gives an independent fresh attempt to +the resident staging controller. A bounded, domain-separated intent digest binds +the repository, actor, pull and exact request. A fresh SDK identity is never +substituted for an uncertain original command. Ref-only Bind selects the current +certified joint generation; an admitted bound worker owns catalog preparation +and ancestry verification using the gateway's scratch, disk and native resources. + +Resident root recovery discovery shares the existing staging coordinator. +A newly registered recovery head can precede the producer's held publication +handoff; discovery must not execute or retire it while that exact bound attempt +is still owned. After looking up already admitted cold work, the scanner checks +the full bound `LeaseCheck` against the staging owner and defers a match. Logical +UUID equality alone is insufficient. This creates no additional admission or +SQL mirror. The original lifecycle retains live command recovery, and abandoned +heads without a matching bound owner continue through the cold discovery path. + +The producer result is retrieved before Finishing, then `ReadyNativeMerge` +registers and binds its original command into fair publication. HTTP timeout or +observer cancellation leaves this resident-owned workflow and exact recovery +intact. Known refusals use the normal 404/403/409 mappings; unknown acceptance +remains unavailable and asks for the same application UUID/revision. + +The direct historical `RepositoryCell::merge_pull` API still uses retired codec +4 and is not the product HTTP path. Generated merge/squash/rebase strategies are +not accepted by this factory; their producers and native publication remain +required work. Complete merge-specific cancellation/startup reconstruction, +queued authority/policy races and pre-Bind intent retention qualification remain +required beyond the generic resident lifecycle tests. + Six HTTP operations live under `/api/repositories//pulls`: GET/POST the collection, GET/PUT `/`, GET/POST `//reviews`. Every mutation carries the repository UUID, checked against the resolved Cell. Create/review @@ -2154,7 +2345,11 @@ moves refs. Source must descend from base for this strategy even when the branch rule does not require fast-forward pushes. Non-ancestor or unrelated histories conflict. There is no synthesized commit, implicit rebase or strategy fallback. -Operation 9, codec 4 publishes the merge in one Repository Cell command: +Historical SQL merge contract (operation 9, codec 4; no longer registered in +the native production registry): the old command performed the following +transaction. The native operation 9, codec 7 contract above replaces its storage +authority. Public/resident adapter conversion remains open; this historical +section is not evidence that the current merge endpoint works. 1. Check current write authority. For an existing application UUID, compare its binding to actor, pull number, full requested revision and strategy; an exact @@ -2204,6 +2399,13 @@ remain open. No dependency or lockfile changed. ### Native merge, squash and rebase candidates +In the current packed cutover, operation 10 codec 3 supports native editorial +reservation and negative preparation completion. Generated `ready` publication +and the resident generated producer remain incomplete. The generated-object +workflow below is required delivery scope; retired SQLite object/ref ingestion +does not implement it. This distinction applies even when a pending intent or +negative result is already durable. + Preparation and branch publication are separate actions. A writer POSTs `/api/repositories//pulls//merge-candidates` with `repository_id`, canonical UUID `id`, exact pull `revision`, and `strategy`. `merge_commit` and @@ -2221,7 +2423,44 @@ its original request fields, pull `number`, `actor`, `created_at_ms`, and `resul | `unrelated` | none | Native Git found no common ancestor; cannot publish | | `rebase_unavailable` | `reason` | `merge_history`, `no_commits`, `limit` or `commit_format`; cannot publish | -Operation 10, codec 2 reserves the UUID against actor, pull and complete intent. +Operation 10, codec 3 reserves the UUID against actor, pull and complete intent. +Its input reuses `CandidateAction` and `RefSelection`. A serving observation +authenticates the exact action purpose, actor, selected native OIDs/versions, +actual owner, live pin and current joint generation. The final transaction +checks fresh write access and projects those facts into the existing pull-policy +statement instead of reading the retired SQL ref table. The client keeps its +serving snapshot through dispatch. Known access denials reach the typed command +without a proof, preserving their original durable refusal. + +Reservation and negative completion change only existing candidate editorial +rows. They do not move roots, create fetch refs or authorize physical deletion. +Even an authentic serving observation bound to a `Ready` payload cannot grant +generated write authority: this command's codec rejects that scope, and the +client requires the forthcoming joint generated publisher. There is no codec 2 +compatibility decoder. A SQL failure rolls back both the editorial write and +SDK acceptance, allowing the original prepared command to retry unchanged. + +The private prepared native catalog now provides `verify_candidate_commit`. +It checks the reserved actor, valid intent, repository object format and selected +commit/tree/source/base membership. Merge and squash commits must match exact +parent order, author/committer, reserved UTC second and message bytes. Both the +Git OID and canonical BLAKE3 body digest must match the physically verified +header; verifying these strategies requires no native body/pack download. + +For rebase, at most 128 original commit bodies are read through bounded shared +native-pack custody. Each rewritten commit is verified by canonical headers and +exact expected bytes, preserving original author, encoding and message while +removing stale signatures and replacing tree, parent and committer. The source +and rewritten chains advance together. One admitted, disk-backed traversal of +base ancestry checks the bounded original set: only the final remaining source +anchor may already be reachable from base. Skipping a source commit or replaying +base history is refused. Original body reads reuse the native pack cache; new +commit bodies do not require a second download or subprocess. The shared lease +and timeout cover preparation. This verifier adds no publication authority, +SDK command, schema or compatibility decoder: the forthcoming resident factory +and final transaction must bind these facts to exact intent/ref publication and +recheck fresh policy, access, owner, generation and custody. + It retains the first timestamp. Retrying completed preparation returns the original result, even if the pull later changes or merges; current write access is still required for POST. GET requires current read access. For pending @@ -2253,10 +2492,12 @@ native worker is trusted to compute the merge tree; the Cell validates exact canonical commit bytes and certified graph closure before recording readiness. It does not independently recompute the merge algorithm. -Generated objects use the same verified SQLite/chunk/external-blob ingestion as -pushes. A ready result and `refs/canopy/merge-candidates/` commit in one -transaction, advancing the ref generation for cache invalidation and coherent -pagination. The entire `refs/canopy` namespace, including its root, is reserved. +The cutover's generated publisher must use the same physically verified native +pack/catalog lifecycle as pushes. Its ready result, certified joint roots and +`refs/canopy/merge-candidates/` must commit in one transaction, advancing +the ref generation for cache invalidation and coherent pagination. The retired +SQLite/chunk/external object ingestion and SQL ref insertion are not a fallback. +The entire `refs/canopy` namespace, including its root, is reserved. The authoritative publisher rejects direct creation, replacement and deletion there; native receive hooks give per-ref rejection reports. Mixed pushes may still publish permitted siblings, while atomic pushes reject the group. Ready @@ -2287,8 +2528,10 @@ native peak disk/memory/CPU bounds and crash-left cleanup remain release gates. Candidate input/result objects and their ancestor closure are retention roots. No GC runs today. Abandoned pending rows, ready refs, external orphan objects and historic candidates need quota/retention policy before persistent public use. -Schema 1 remains unreleased: new candidate tables, operation 10 and operation 9 -codec 4 require a fresh development prefix. No dependency or lockfile changes. +Schema 1 remains unreleased and requires a fresh development prefix. The native +editorial increment reuses the existing candidate request/result/table and +ref-observation structures, with operation 10 codec 3; reviewed native merge is +operation 9 codec 7. No dependency or lockfile changes are needed for this step. ## Repository browser @@ -2307,7 +2550,7 @@ A different UUID returns 409; malformed input returns 422. The response is | `{"kind":"history","commit":""}` | `history` | Up to 32 commits following only the first parent | `resolved` contains `reference`, nullable `oid`/`version`, and `generation`. -Default HEAD and its ref tip come from one SQL observation. A missing/deleted +Default HEAD and its ref tip come from the same accepted immutable ref snapshot. A missing/deleted reference returns null OID. Subsequent tree/file/history queries use the returned lowercase SHA-1 object ID, preserving that snapshot through ref changes. Annotated tags are peeled to commits, with at most 16 object visits. Tags to diff --git a/docs/design/certified-serving-pins.md b/docs/design/certified-serving-pins.md index 1e2ff3c3..57166dac 100644 --- a/docs/design/certified-serving-pins.md +++ b/docs/design/certified-serving-pins.md @@ -217,11 +217,23 @@ must resolve their revision/ref through that accepted snapshot. The pool admits each viewer before spawning private request work. Its permit covers selection, acquisition waiting and the returned snapshot borrow. Observer cancellation detaches accepted work, and the owner's original stays retained. -At capacity, the pool initiates closure of one least-recently-used unborrowed -owner and returns an explicit capacity error. Its slot remains charged until the -real producer has exited; there is no unbounded retired-owner list or waiting -behind old provider work under the pool lock. Borrowed old generations remain -immutable. Their independent owners keep renewing while other generations work. +At capacity, the pool initiates closure of a least-recently-used unborrowed +owner and observes its independently owned release outside the pool lock. A +request starts its two-second rollover observation budget when it first needs +to retire an owner; initial authenticated Cell selection does not consume this +budget. Later release observations and selection retries share that deadline. +The request performs at most one retry per configured generation slot. Concurrent waiters can join an already closing +owner. Each retry selects the current generation under current viewer access; +closing owners cannot accept new borrows. Sequential readers can therefore move +through more than four publications without retrying at the client. + +The retiring slot remains charged until the real producer has exited. Timeout, +observer cancellation and a lost release acknowledgement cannot remove its slot +or pin, cancel its exact release, or allocate a fifth owner. All borrowed slots +still produce an immediate capacity error. A slow or uncertain release returns +capacity when the bounded observation expires and remains recoverable. There is +no separate retired-owner inventory. Borrowed old generations remain immutable; +their independent owners keep renewing while other generations work. Eviction pauses acquisition/borrowing and uses a nonwaiting owner handshake. The producer driver must be idle, with no pending original, outstanding borrow diff --git a/docs/design/final-publication-lifecycle.md b/docs/design/final-publication-lifecycle.md index 01c150a4..e8c5025f 100644 --- a/docs/design/final-publication-lifecycle.md +++ b/docs/design/final-publication-lifecycle.md @@ -41,6 +41,53 @@ Final uncertainty appears as StagingState::Uncertain with the original typed Pub The inline response observer is service-internal access to an already admitted result. Root observers use root_response(store), which selects the durable actor/operation/request result under current read authorization and the original receipt before streaming authenticated bytes. Externally requested replay must use the corresponding authenticated replay_push_response or replay_root_push_response preflight. A receipt or caller-selected root grants no artifact access. Compaction results cannot become push responses. A known catalog conflict terminates this local lifecycle; a subsequent Claim and freshly reconciled proof must enter a new admitted lifecycle rather than replacing an ambiguous command. +## Open gate: refusal after pre-bind Write revocation + +The production SSH regression `late_ssh_push_refusals_report_both_refs_and_survive_restore` +still fails when the writer becomes a reader during native pack upload. The +admitted operation preserves both refs and their generation, but Write-dependent +checkpoint registration and fresh staging probes stop before a terminal response +is committed. Post-bound policy-refusal qualification does not cover this case. + +The next implementation must introduce a private **refusal-only capability for +an already admitted staging attempt**. Do not lower Begin, Claim, Bind, catalog +proof issuance or ref publication to Read. Reuse the existing attempt token, +actor/request digest, lease/pin, immutable request/native-result roots, bounded +root command and exact recovery journal. The terminal capability must not open +a catalog base, acquire a generation floor, renew preparation, or produce a +positive completion. Its response must pass current completed-request Read +selection, including after restore. + +Implement and qualify these boundaries in order: + +1. Authenticate the original admission and current owner, remaining expiry and + exact request checkpoint. A reader cannot allocate a fresh write attempt; + terminal preparation cannot replace an uncertain checkpoint command. +2. Add purpose-specific custody for sealing the admitted native result after + revocation. Authenticate the predecessor, original wire request and creating + namespace. Keep generic checkpoint/proof authority unchanged. Drain the + actual upload/native workers before final handoff. +3. Freeze an explicit refusal-only root completion with no publication floor. + Bind that constraint and the selected input checkpoint into the MAC. Reuse + the same immutable outcome layout and exact-command artifacts; an unbound + refusal cannot select the native successful report or publish any pack/ref. +4. Permit first-writer recovery registration only for that authenticated terminal + purpose after Write loss. The Repository Cell must independently verify the + purpose, original actor/token/checkpoint, actual owner, live pin and expiry. + Preserve the same command through ambiguous registration and execution. +5. Integrate terminal handoff with the existing staging worker/result drain and + fair dispatch, without manufacturing a bound lease or retaining a worker + activity in its final ready value. Success or an unrecorded synthetic `ng` + cannot be returned before durable completed-root selection. + +Required negatives include a reader starting a push, a forged purpose/body, +wrong actor/request/checkpoint, expired or replaced attempt, stale owner, ref +publication through terminal custody, and uncertainty followed by cancellation +and restart. Exercise Write-to-Read revocation during upload in SHA-1 and SHA-256, +verify both exact per-ref refusals, unchanged refs/generation, restored replay and +unrelated writers' progress. Full access removal must not grant response replay. +This is a pending implementation contract, not a completed authority change. + ## Terminal recovery retirement After an immutable root outcome and its recovery phase are durably known, the service can start `RecoverySupervisor::start_retiring` with current repository administration and actual owner custody. It prepares a private terminal release through the existing maintenance queue, transferring the same recovery headers/journal to the selected immutable push row and freeing that preparation pin atomically. Original uncertain release commands remain charged and recoverable even after the pin disappears. Live staging uncertainty keeps its original owner. Follow the [terminal retention contract](terminal-publication-retention.md) for exact eligibility, retained edges and receipt recovery; this path grants no provider deletion authority. Production startup must wire the service as part of the mandatory hard cutover. diff --git a/docs/design/native-generated-candidate-publication.md b/docs/design/native-generated-candidate-publication.md new file mode 100644 index 00000000..8a071d9e --- /dev/null +++ b/docs/design/native-generated-candidate-publication.md @@ -0,0 +1,103 @@ +# Native generated candidate publication + +This hard-cutover path reuses the existing candidate, merge, catalog, ref snapshot, +input checkpoint and exact-command recovery structures. It creates no SQL Git +object/ancestry mirror and no second persistent candidate UUID table. + +## Frozen intent and resident admission + +Operation 10 reserves the existing `merge_candidates` row with the first actor, +pull number, full source/base revision, strategy, message and creating timestamp. +The intent is immutable. Completed results are immutable and duplicate inserts +and deletes are refused by the native schema. A completed UUID returns its +original result under current authorization. + +A pending intent enters resident staging with a domain-separated digest of its +canonical candidate value and repository. Admission briefly holds the existing +gateway admission mutex. It searches only the staging service's bounded admitted +job map for the same actor and digest. Concurrent observers share the original +controller, including uncertain original custody or publication commands. A new +operation and creating namespace can be admitted after a known attempt has fully +drained. No observer owns the native process or final recovery command. + +Git `merge-base`, `merge-tree`, `commit-tree` and the bounded rebase producer run +with the staging physical owner retained by their native process owner. Rebase +continues to support at most 128 linear commits, preserving original author, +message and encoding while removing stale signatures. The private catalog +verifier independently checks the original boundary and exact rewritten chain. +Only this native producer constructs `ProducedCandidate`; a client Ready DTO +cannot construct the production witness. + +## Generated inputs + +The accepted native base consists of immutable catalog packs. The producer +streams newly written loose OIDs into a disk-admitted spool with constant +iteration memory and a one-million-object request ceiling. `pack-objects` reads +that explicit spool, disables object/delta reuse and emits a non-thin pair. +Existing base packs are not recaptured as new input. + +Capture reconciles disk usage, fences the private cache and inspects only that +exact generated pack/index pair in the admitted creating namespace. The fence, +cache and physical owner remain pinned through authenticated hash/upload work. +The existing native input checkpoint retains the exact pair inventory. +Independent physical verification downloads it without alternates, checks the +physical partition and canonical identities, then stages bounded metadata. +Catalog preparation checks typed closure against the certified bound base. +The candidate verifier checks the exact generated commit semantics before the +private publication factory can issue a MAC. A generated commit already present +in the certified base can require no new pair. + +## Atomic publication and compact recovery + +Operation 55, codec 1, authenticates the candidate-purpose binding of the frozen +result, exact held-base native ref observations, proposed snapshot, its certified +ref generation, and typed permanent audit. Ready creates the one server-owned +candidate fetch ref with an absent expectation; the ordinary push factory still +rejects server-owned ref updates. + +The final owner transaction selects the immutable intent and any first completed +result, then checks current write authority, actual owner fence, exact operation +and independent pin, lease expiry, retention, current joint roots and full pull +revision. Ready additionally checks generation capacity. It atomically commits +the catalog/ref roots, existing constant-size ref summary (preserving HEAD), +candidate result/OID/audit, attestation checkpoint, original recovery phase and +SDK acceptance. Late SQL failure rolls all of those back. Negative results retain +the frozen editorial result without advancing roots. + +`CandidatePublicationReply` carries the UUID, canonical result digest and optional +`PublishedRefs`; it fits the existing 512-byte recovery contract even when a +conflict result approaches the existing 256-KiB limit. The larger result remains +in the immutable candidate row. Ready additionally stores a typed input-root +audit referencing the accepted catalog and ref snapshot. Recovery kind 6 routes +the exact originally registered operation 55 command. Typed retirement selects +the permanent row before traversing its audit and preserves the original SDK +receipt before releasing the transient pin. Missing audit metadata prevents +retirement. This path performs no provider deletion. + +## Reviewed generated merges + +Operation 9, codec 8, supports fast-forward and generated strategies. The private +factory selects the immutable Ready candidate matching the requested UUID, +strategy, pull number and complete revision. It verifies that candidate's native +audit and commit semantics in the accepted catalog, observes the exact reserved +ref and prepares the ordinary base-branch update to the generated target. Native +ancestry evidence proves the target descends from the requested base. + +The final transaction reselects the candidate result and audit, checks the held +reserved ref, and rechecks current authorization, pull revision, approvals, +changes requested, required checks on the generated target, branch policy, +owner/pin/expiry/retention and joint-root CAS. The reviewed capability authorizes +only that exact update. Existing `pull_merges` now retains strategy and candidate +UUID; its immutable audit retains the actual target and candidate audit root. +Replay reconstructs the original binding from those fields rather than assuming +fast-forward. Merge results retain their existing wire shape and original receipt. + +## Qualification boundaries + +The evidence record distinguishes focused tests, the full workspace diagnostic +and exact-head Linux CI. This work does not qualify full-history cold-base cost, +10,000-engineer throughput, automatic reconstruction of generated work before +final command registration, remote-provider interruption/adoption, continuous +retention/collection, backup export of the complete typed graph, or native +acceleration. Those remain required delivery gates. Passing the generated +workflow does not make the full cutover release qualified. diff --git a/docs/design/native-thread-and-owner-routing.md b/docs/design/native-thread-and-owner-routing.md new file mode 100644 index 00000000..45281937 --- /dev/null +++ b/docs/design/native-thread-and-owner-routing.md @@ -0,0 +1,67 @@ +# Native thread publication and Git owner routing + +This increment repairs native line-thread creation, cross-gateway Git/LFS +transport, reused push UUID conflict classification, and known final catalog +CAS refusal. It does not complete the native cutover or qualify large-team +capacity. See [validation evidence](../evidence/native-ci-routing-20261005.json). + +## Verified line threads + +HTTP verifies the requested hunk and produces its line anchor from a certified +Git snapshot. Command 56, codec 1 binds the complete thread intent and anchor +into the existing purpose MAC. The owner transaction checks current access, +owner fence, selected native refs and the pull revision before executing the +existing editorial statements under the authenticated native-ref CTE. Body, +blob, line or revision substitution cannot reuse that proof. No per-object SQL +mirror or writable compatibility ref table is added. + +## Resident owner transport + +A remote ingress gateway has no resident native staging or serving capability. +Git and LFS HTTP streams therefore go to the current live owner selected through +the verified fleet advertisement. The existing TLS client validates that +endpoint, forwards the original credential, and lets the owner authenticate and +authorize it independently. Request and response bodies stream through existing +transfer admission. Hop-local headers are stripped and forwarding is bounded to +two hops. A consumed request is never retried automatically after an ambiguous +receive-pack. Stale ownership or transport failure returns an unavailable +response so a new request can bind the current route. + +This covers HTTP Git/LFS transport. Remote SSH principals and editorial API +writer routing require their own qualification. + +## Final publication refusal + +The final publishing bundle retains the original publication command and the +same frozen refusal used by ref-policy pages. Recovery executes the refusal only +when the authenticated journal records the original publication's denial. +Pending or unknown publication cannot select it. A denied armed publication +cannot advance to another bundle while its refusal remains unsettled. Original +SDK identities, command bodies, phase order and receipts remain authoritative; +archive selection retains the terminal refusal receipt. + +The publishing bundle reserves 48 KiB: 32 KiB for publication and 16 KiB for the +refusal. The cold-owner fixture now includes both commands and checks that exact +reservation. This path does not repair revocation before the native result +checkpoint; that requires a separately restricted negative staging capability. + +## Validation and remaining work + +The full workspace diagnostic passed 754 server library cases, with one obsolete +32 KiB fixture assertion failing. The corrected 48 KiB assertion passed its +cold-owner regression. Registry, refusal-ordering and purpose-bound thread +regressions pass on the final source. All-target Clippy with warnings denied, +formatting, the production build and 96 Python harness tests pass. + +The full multi-server run remains red: 97 passed, 10 failed, 9 ignored. Line +threads, visibility, both peer-routing scenarios, and push UUID conflict now +pass. Native discovery pauses the physical pack part and passes alone, retaining +the two-second metadata checks, exact clone bytes and strict Git integrity +checks. It timed out under full local contention; a listener rebinding fixture +also encountered address reuse and passed alone. Their full-run failures remain +in the evidence rather than being hidden by weaker assertions. + +Native backup, selective fetch/cache materialization, read-only restoration, +late SSH refusal, and the three detached standalone fixtures remain open. Three +option-error cases from Linux passed locally and need new-head Linux validation. +No provider or large-team workload qualification is claimed. diff --git a/docs/design/staging-service-lifecycle.md b/docs/design/staging-service-lifecycle.md index 86867440..29fd0d41 100644 --- a/docs/design/staging-service-lifecycle.md +++ b/docs/design/staging-service-lifecycle.md @@ -1,6 +1,6 @@ # Service owned staging lifecycle -`StagingCoordinator` now owns admitted Begin/Claim/Renew/RegisterStagedInputs/Bind commands, input and bound tasks and completed results through observer cancellation and exact outcome recovery. It composes the [stored staging phases](staged-input-retention.md) with fresh deadline observations and the existing private catalog pipeline. Production HTTP/SSH/mirror/generated producer selection, durable takeover reconstruction, full process admission and large-team qualification remain required. +`StagingCoordinator` now owns admitted Begin/Claim/Renew/RegisterStagedInputs/Bind commands, input and bound tasks and completed results through observer cancellation and exact outcome recovery. It composes the [stored staging phases](staged-input-retention.md) with fresh deadline observations and the existing private catalog pipeline. Production HTTP/SSH receive-pack now selects this lifecycle. Generated producers, durable takeover reconstruction, complete physical admission and large-team qualification remain required. ## Admission and ownership @@ -50,6 +50,11 @@ A known registration stores its original receipt before a fresh CheckStaging que Call seal when the input phase should finish. It prevents new producer admission and enters Draining. Existing producers and retained completed results continue under renewed staging custody. Bind does not begin until all input slots have drained through handoff or failure. This prevents a canceled observer from silently losing a physical witness while the service advances to catalog preparation. +`wait_terminal()` observes staging handoff, including the intermediate Bound +state. Callers waiting for expiry or final publication must use +`wait_completion()`, which waits for Published, Uncertain, Fenced or Stopped. +Observing Bound does not establish an expiry result or completed publication. + Bind uses a newly prepared original command 42 and registrar 41 under the shared registered custody protocol. Known binding preserves the token, creating namespace and artifact expiry, and adds only the current catalog floor. Bound records that durable result and its original receipt; its recorded timestamps are not a fresh live-lease observation. Stage contexts become inactive after handoff. The operation remains admitted through bound preparation. `ticket.open_base` refreshes at the binding receipt and uses the existing PreparationBaseResolver with the supervisor's shared session, validating current access and expiry while inheriting automatic renewal, shutdown fencing and the bound residence ceiling. A producer can physically verify a native pack and return its private PhysicalPackWitness and sealed metadata segments. Take that result, seal, observe Bound, open the base, and feed the witness/segments to CatalogPreparation. The existing assembler rechecks store, namespace, partition completeness, canonical overlap and closure. Its private factories issue the publication proof. Bind and a generic producer result do not grant canonical or publication authority. @@ -99,3 +104,51 @@ StagingStats exposes completed probe queries, failed observations/fingerprint co Seven additional regression families exercise all seven original custody actions in SHA-1/SHA-256, closed coordinators and dropped observers, stopped staging/bound renewals with live callbacks and retained completed resources, unavailable private queries without absent-command execution or known-phase retries, an old ordinal after an explicit successor, malformed stop rejection, a corrupt head followed by a valid head and later repair, exclusion of input checkpoints despite an older stop, and 130 admitted operations spanning multiple probe pages plus restart at an earlier key after idle. The native/domain codecs reject the all-zero operation ID; the multi-page fixture uses valid nonzero IDs rather than weakening that invariant. The initial warm fixture observed the preceding binding before the renewal; it now waits for the actual uncertain renewal. The checkpoint fixture now registers an explicit successor instead of using the fresh-operation factory against an existing journal. Final frozen-source evidence is recorded in the implementation status. + +## Bounded physical metadata handoff + +The admitted native verifier now uploads/releases one metadata shard per step +and retains ordered `SourceRecord` descriptors on admitted disk. The creating +result contains a complete witness and descriptor replay; it contains no worker +context or open metadata database, so transferring it cannot block Bind. +The source tree's digest order is distinct from physical ordinal order. + +A bound catalog builder checks its staging context, selects the exact retained +native input checkpoint, and authenticates/copies one stored shard at a time. +It reuses the stored metadata artifact instead of uploading it again. Blocking +closure/directory jobs, file reads/writes and artifact hash jobs retain the same +activity pin. A failure or cancellation cannot yield a private prepared catalog. +Completed private proofs do not retain worker activity across final publication. + +This API composition is a prerequisite, not live transport conversion or a +full-history deadline/throughput result. Resident service drain, adopted older +input verification, remaining request/policy work and actual producer wiring +remain release work. Current qualification and limits are tracked in the +[implementation status](../large-repository-implementation-status.md). + + +## Production receive workflow and shutdown grace + +HTTP and SSH transfer one authenticated encoded receive request to `drive_receive` +before awaiting status. Its controller registers wire custody, retrieves staged +native/result/descriptor outputs, registers checkpoints, drains physical workers, +binds once, and orders the existing policy/ref/completed-root protocol. Each +byte-bounded page advances by its actual minted end offset. Returned success is +selected from the durable completed root under current read authorization. + +Node shutdown first closes ingress and staging admission. Already-owned receive +controllers get a shared 30-second grace while serving, native admission, Cell +heartbeat and workspace ownership remain available. The grace is a controller +finish window, not a deadline for draining physical jobs or uncertain mutations. +After it expires, ordinary forced close cancels/joins controllers and drains the +existing worker/exact recovery owners before the lower services can close. +Generic `drive` callback producers continue to cancel immediately. Public stop, +lease/authority fencing and forced close retain their previous semantics. + +Stock SSH qualification disconnects the request after real native pack upload is +paused, starts shutdown, proves release remains blocked, resumes upload, and +checks the new commit and blob through cold clone and strict fsck. Late Write-to-Read +revocation before Bind still requires a separately authorized durable refusal +path. No current lease, completed receipt or generic callback may bypass that +missing authority transition. Request/result/policy physical pins and authenticated +older-owner adoption remain unfinished release gates. diff --git a/docs/design/terminal-publication-retention.md b/docs/design/terminal-publication-retention.md index 6fe01b40..d8a98243 100644 --- a/docs/design/terminal-publication-retention.md +++ b/docs/design/terminal-publication-retention.md @@ -1,20 +1,48 @@ # Terminal publication recovery retention -Completed pushes and closed initialization attempts must release their independent preparation pins without losing original command receipts. Leaving successful pins forever eventually exhausts the 4,096-pin admission cap and retains unnecessary catalog floors. This protocol transfers the same authenticated recovery certificate and phase journal into the immutable shared `catalog_recovery_receipts` table, then deletes the matching pin in one transaction. It uses the fresh publication schema and existing certificate, journal, SDK receipt and artifact structures; it adds no durable queue or per-object rows. The archive is keyed by original incarnation/admission sequence, with an operation index for typed enumeration. Different attempts of the same logical initialization retain independent original receipts. +Completed pushes and closed initialization or reviewed-merge attempts must release their independent preparation pins without losing original command receipts. Leaving successful pins forever eventually exhausts the 4,096-pin admission cap and retains unnecessary catalog floors. This protocol transfers the same authenticated recovery certificate and phase journal into the immutable shared `catalog_recovery_receipts` table, then deletes the matching pin in one transaction. It uses the fresh publication schema and existing certificate, journal, SDK receipt and artifact structures; it adds no durable queue or per-object rows. The archive is keyed by original incarnation/admission sequence, with an operation index for typed enumeration. Different attempts of the same logical initialization retain independent original receipts. The protocol releases a preparation pin. It does not authorize provider deletion. Complete retained-root enumeration, reader and worker drain, backup, isolated restore, and repository-scoped collection remain required before any production artifact deletion. ## Release eligibility and authority -`RegisteredRootRecovery::ready_terminal_release` accepts only the current canonical recovery head with a recorded completed root outcome. A policy head is terminal only when its original refusal is recorded and its pre-frozen fallback root command has completed. A known initialization result is terminal after its exact attempt is closed. A positive must match the immutable selected initialization fact; a known denial may retire only after Claim or bounded operation reaping has removed that exact active binding. A successor of the same logical operation keeps its own pin. Unknown acceptance, passed intermediate pages, denied push root commands, historical heads, and a refused page without completed fallback cannot produce a release proof. +`RegisteredRootRecovery::ready_terminal_release` accepts only the current canonical recovery head with a recorded completed root outcome. A policy head is terminal only when its original refusal is recorded and its pre-frozen fallback root command has completed. A known initialization result is terminal after its exact attempt is closed. A positive must match the immutable selected initialization fact; a known denial may retire only after Claim or bounded operation reaping has removed that exact active binding. A successor of the same logical operation keeps its own pin. A typed merge +result is terminal only after its exact attempt closes. An applied result must +match the permanent UUID row and selected native audit; a denial retains its +original phase even if a later fresh command succeeds with the same UUID. +Unknown acceptance, passed intermediate pages, denied push root commands, historical heads, and a refused page without completed fallback cannot produce a release proof. The private factory checks the saved actor and request selection against that exact completed outcome. It authenticates the current bundle and each predecessor frame, verifying repository MACs, tenant/application, lease identity, original SDK stamps, and strictly decreasing phase steps. Each header is bounded by 8 KiB. It then authenticates the selected outcome/native metadata and streams the selected response and native audit bodies to verified EOF. Missing or corrupt required bytes prevent proof creation. For positive initialization, the same verifier used by route activation downloads the catalog, directory and ref snapshot and checks their complete typed empty graph. The graph transcript includes the original fact and all three authenticated descriptors. A known negative has no positive graph edges; its original typed denial remains bound by the phase digest. +For native fast-forward merges, `pull_merges.publication` reuses the existing +bounded `StoredInputRoot` representation. The private factory uploads logical +merge intent, base ref, catalog descriptor, ref snapshot and ref generation; +the catalog MAC binds this audit descriptor alongside the conditional ref +proposal. Operation 9 codec 6 saves it atomically with the immutable UUID result. +The authenticated final merge command closes only its exact matching operation +when recording any terminal result, including a refusal or application replay. +This shares the original recovery/SDK transaction and avoids an independent +abort command. Closure failure rolls back the original phase and acceptance; +unauthenticated proposals and successor operations stay protected. +SQL guards reject result mutation, deletion and replacement. Fresh deployments +use this schema directly; no backward decoder or SQL ref mirror is introduced. + +Terminal verification reconstructs the exact actor/request binding and original +result from that UUID row, then reads the purpose-specific audit. It checks the +repository, original result, at most 48 directory range roots and one source +root through a fresh bounded `CatalogReader`, and the exact published base-ref +path/version through the selected immutable ref snapshot. Replays select the +first applied attempt's creating namespace, even when the new attempt proposed +no ref update. Verification does not enumerate historical objects or prove +physical closure again. The permanent audit retains its catalog/ref descendant +edges; the future complete collector must walk these edges as retained roots +before any deletion. Missing or corrupt selected metadata prevents pin release. + The purpose-specific MAC proof binds the original recovery certificate, phase-journal digest, and verified closed-graph digest. `ReleaseTerminalRecovery` is command 40, codec version 2, using purpose `canopy.terminal-recovery-release.v2\0`, with input limited to 4 KiB and output to 128 bytes. The actual command receiver separately requires current repository Admin authorization and its admitted owner fence. A proof prepared under an old owner or by a subsequently unauthorized administrator cannot bypass those checks. ## Atomic transfer and original receipts -Before its first write, the receiver verifies the proof and embedded original certificate, exact current pin identity/head/phase, terminal selection, the immutable selected push or positive initialization outcome, and absence of an active binding for that exact incarnation/admission sequence. All semantic refusals precede writes. +Before its first write, the receiver verifies the proof and embedded original certificate, exact current pin identity/head/phase, terminal selection, the immutable selected push, positive initialization or applied merge outcome, and absence of an active binding for that exact incarnation/admission sequence. All semantic refusals precede writes. The receiver obtains its release command's actual SDK mutation evidence and sequence from `CommandContext`. It inserts one shared archive row containing the original pin key and logical operation, and three bounded values: the original recovery certificate, original phase journal, and release identity/result/sequence. An exact CAS then deletes the matching preparation pin. Any later SQL failure rolls back both writes and SDK acceptance. SQL guards prohibit archive replacement, mutation or deletion; the lease deletion guard requires the same certificate and phase in the exact shared archive row. @@ -32,6 +60,7 @@ An owner SQL query is not an arbitrary local SQLite read. The pinned Cellule exe | Selected response body | Required; its creating namespace can belong to a prior admitted attempt | | Native plan and signed certificate bodies | Required native audit edges, when present | | Original wire request, original command bodies, unselected responses and unpublished candidate artifacts | No permanent edge from this closed audit role; other retained snapshots, unknown attempts, readers or backups can still require them | +| Applied merge audit input root | Retained by the immutable UUID result; includes exact intent and typed catalog/ref descendants, independently of SQL generation reaping | | Initial empty catalog, directory and ref snapshot | Retained through the immutable initialization fact; its original pin identity also names receipt recovery | | Published catalog, refs, packs and indexes | Retained through their own certified catalog/generation and reader/backup roots | @@ -45,7 +74,7 @@ Only eligible closed attempts transfer into the shared archive. An older denied Before each bounded keyset scan, the supervisor also recovers uncertain factory-owned terminal-release jobs from that same coordinator. This is necessary after a committed release removes its pin but loses its acknowledgement: a pin-only scan would no longer discover the still-charged command. Recovery retains the original SDK command, identity and receipt, including after coordinator close or scanner stop/restart. It does not retry compaction or replace a live producer's uncertain command. -An active denied initialization is deferred before release preparation or SDK admission. Successful repository startup retires its initial pin before exposing the route, and recovered positive startup discovers the original pin by the immutable initialization outcome’s exact incarnation/admission sequence. Pending startup retires an original known denial only after a successful Claim. Current maintenance fencing comes from the validated durable Cell Control and its live node advertisement; the receiver still independently checks its actual admitted fence and current Admin role. The existing tracked transition owns this constant-size work through cancellation. +An active denied initialization or merge is deferred before release preparation or SDK admission. Successful repository startup retires its initial pin before exposing the route, and recovered positive startup discovers the original pin by the immutable initialization outcome’s exact incarnation/admission sequence. Pending startup retires an original known denial only after a successful Claim. Current maintenance fencing comes from the validated durable Cell Control and its live node advertisement; the receiver still independently checks its actual admitted fence and current Admin role. The existing tracked transition owns this constant-size work through cancellation. Stopping the scanner requests stop between scans and joins its current work. It does not cancel an admitted release. Close and drain the publication coordinator separately. On owner succession, restart the service with current maintenance authority; original receipt lookup remains independent of that fresh authority. Production routing must use the SDK's ownership-aware transport rather than keeping a stopped owner's fixed local handle. @@ -55,4 +84,17 @@ Native SHA-1/SHA-256 tests exercise quota release at the existing 4,096-pin cap, Six typed initialization families additionally qualify both formats, immutable shared archives, denied-old/successful-new receipt separation, missing typed empty metadata, actual Admin/owner checks, last-write rollback, automatic release recovery after pin disappearance, SDK expiry, saved-body loss and fresh-owner restoration. The complete frozen-source publication suite passes 264 tests in 175.94 seconds with four threads and standard stacks. +Five native merge retirement families qualify both formats where applicable: +original applied UUID replay selects the first audit after releasing its pin; +a denied attempt closes atomically and keeps its original refusal after a fresh attempt +succeeds; missing and corrupt audit, catalog, directory, ref snapshot and ref +index metadata prevent release without losing the result; current Admin/owner +checks and a fault at the final delete preserve atomic archive/pin rollback; +and archive plus release receipts survive original command-body removal, SQLite +loss and actual owner restoration. Permanent merge rows reject update, delete +and replacement. These use verified stock-Git pack/catalog bytes and a trusted +synthetic initial certificate, isolating transaction and recovery invariants. +They do not qualify the public endpoint, actual automatic merge retirement, +generation reaping under a merge workload or complete provider garbage collection. + These fixtures prove the covered transaction, receipt and lifecycle invariants. They do not establish throughput for 10,000 developers. Proof preparation is currently serialized by the repository scanner; node-wide fair verification admission, provider I/O budgets and full-history measurements remain mandatory. Complete production producer/reader and background-service wiring, initial Begin/Claim/Renew/pre-registration uncertainty, retained-input Claim/adoption/repreparation, complete typed collection and isolated restore, file-backed intents/reports, OS containment, accelerated reads, physical rewriting and continuous hot-root maintenance remain open under the [implementation plan](../large-repository-implementation-plan.md) and [large-team requirements](../large-team-scalability.md). diff --git a/docs/evidence/native-backup-readiness-20261006.json b/docs/evidence/native-backup-readiness-20261006.json new file mode 100644 index 00000000..b752d0dc --- /dev/null +++ b/docs/evidence/native-backup-readiness-20261006.json @@ -0,0 +1,2143 @@ +{ + "source_parent": "aa86f944907b64816c366a55bdca2f465e25e00e", + "source_manifest_sha256": "4aa711e91ae7830335432a5a8c954014b5a5531964cb2c8a025bffb20173d215", + "sources": [ + { + "path": "Cargo.lock", + "sha256": "ea876788c7c48d013f588730c28c719d3b88283921be3f42418f186421fb954d" + }, + { + "path": "Cargo.toml", + "sha256": "577b251e8bb21314d338c7dccfb42429de3cfdb2191be3b854786bb394de507c" + }, + { + "path": "crates/canopy-git-format/Cargo.toml", + "sha256": "60b0e162738fb11e55a1eb7e02b21add376b0f19c6e5119171c2b7d11359738d" + }, + { + "path": "crates/canopy-git-format/src/lib.rs", + "sha256": "5b5a8674b1804564e1195ba6b79231b3024b00f202fb087c9012214dd67d5b73" + }, + { + "path": "crates/canopy-git-format/src/pack_index/mod.rs", + "sha256": "c3d086276a6d64d0f79afd1feef2f0a65e172ae5865f11f1d9a6c63154aeea44" + }, + { + "path": "crates/canopy-git-format/src/pack_index/tests.rs", + "sha256": "c5930d97ecb1923a0364b54fef733dcd3001c84af54a79090c94a49e34a2ed4f" + }, + { + "path": "crates/canopy-object-storage/Cargo.toml", + "sha256": "f7b22c443cfc6beb1abe41542d22af6002442f4a43b9c651e34e95aed717c826" + }, + { + "path": "crates/canopy-object-storage/src/artifact.rs", + "sha256": "767506bf5d48ccd1d454139538361d99e76952bafb8bb0c761f89e97958a0d4a" + }, + { + "path": "crates/canopy-object-storage/src/artifact/tests.rs", + "sha256": "75b3ce3def1b32f29b0ede0e86a6f5ea6dcd0b164a57920874a08e9abb311829" + }, + { + "path": "crates/canopy-object-storage/src/blob/mod.rs", + "sha256": "fa2a21fd07cb56bc314de7b134ab669c1caa0b8b59dedcf3049ca3cd882cc99a" + }, + { + "path": "crates/canopy-object-storage/src/blob/read.rs", + "sha256": "e2994c3398fdf41fcf43acab00a0ccbf26606ce401183c5a9e9f691c1f5726b3" + }, + { + "path": "crates/canopy-object-storage/src/blob/tests.rs", + "sha256": "d49b9ea9bd9e017adae3bebc773d5fe4b52ce83757dd7b13bd126af68a6dc0a7" + }, + { + "path": "crates/canopy-object-storage/src/external.rs", + "sha256": "28b5178ef81d72489db7201d81ba3c21e42a25c030262dae733f86664a75339c" + }, + { + "path": "crates/canopy-object-storage/src/lib.rs", + "sha256": "377b85fd4124e42d177f290b8ac7c8149f9223c2bec0b07c7dd5fbca936571db" + }, + { + "path": "crates/canopy-server/Cargo.toml", + "sha256": "cbfe82768a262d1e49c5442534dfa0811cf31cba886992068e480ce92a14ad0f" + }, + { + "path": "crates/canopy-server/examples/benchmark.rs", + "sha256": "2a1ae5437fec4a402e74fea55643c265a85bfc153ea7f03df76a5b8863a62c9b" + }, + { + "path": "crates/canopy-server/examples/support/mod.rs", + "sha256": "e2d78a6c801bd113bcdd0214a91712455b58e620f85f3f2a3ba32fe7368eb24a" + }, + { + "path": "crates/canopy-server/src/access.rs", + "sha256": "cd9378763fea7f81c87b201a506dd3d9f9c235a061158e6cbeae434390ec9570" + }, + { + "path": "crates/canopy-server/src/admission.rs", + "sha256": "5d0ef8cde0c4d31b3da9b301d37529b8c38d24cb4bf4a156510fe8ba9ea132c0" + }, + { + "path": "crates/canopy-server/src/ancestry.rs", + "sha256": "61342aa4d982de2fbfae1591fe994be77482bcdce2aa75c511d879457b5861ef" + }, + { + "path": "crates/canopy-server/src/branch_rules/command.rs", + "sha256": "abd39aae1d4400b678c23b4a0cfb21691affef3f6803a6d721e90d53541464ec" + }, + { + "path": "crates/canopy-server/src/branch_rules/mod.rs", + "sha256": "26f588ccffde24b83cf42c0e34f3ea2f2d1ba87af64c4631ea1126d9975f320f" + }, + { + "path": "crates/canopy-server/src/checks/mod.rs", + "sha256": "f7550d692056fb0046a296778e22e7f9152acfef646bfd359cd654d423d0e0ba" + }, + { + "path": "crates/canopy-server/src/checks/mutations.rs", + "sha256": "c4522f0ada18a414b3da8de99ae8813c58a28350a4659765d674c57394fa0e9a" + }, + { + "path": "crates/canopy-server/src/checks/native.rs", + "sha256": "7f4c12d84f322dc4dc38478eee2f3d34e29e6ee0537b2e832c41253efc18c591" + }, + { + "path": "crates/canopy-server/src/checks/native/codec.rs", + "sha256": "1df0d06bfe77a3d62d713f666e9ceb27b0981810567d97ddb80e6f0530d535de" + }, + { + "path": "crates/canopy-server/src/default_branch.rs", + "sha256": "32d424a454f791a9b42c48e72a252ebaf9f3eea332d25265c366cb4ee7070c14" + }, + { + "path": "crates/canopy-server/src/deployment/backup/bodies.rs", + "sha256": "2e53f959411301c36a774f1e3fb652a576f2264318c0f5f0380df02120a8fd58" + }, + { + "path": "crates/canopy-server/src/deployment/backup/enrollment.rs", + "sha256": "a3cf53c74a8c101d61c443154aeec8eb3610aabb860c43ffd46144eca17302e7" + }, + { + "path": "crates/canopy-server/src/deployment/backup/mod.rs", + "sha256": "7172ee744beb899f898cefad0d8aea75a8f30dbd6bab61010206b1a3caefd9a0" + }, + { + "path": "crates/canopy-server/src/deployment/backup/native.rs", + "sha256": "4ba572997a014d3837058b16a7b1e73beca90bb81e307c74e2221292597b1668" + }, + { + "path": "crates/canopy-server/src/deployment/mod.rs", + "sha256": "253de4f08871587fb456a54c51da7305f186cbc16dd6b06ddf7e26e59349199c" + }, + { + "path": "crates/canopy-server/src/deployment/recovery.rs", + "sha256": "6e9922b30c01ef6dff61358aab565921285feca1fa67388dc07a8d0029b8e07c" + }, + { + "path": "crates/canopy-server/src/deployment/root.rs", + "sha256": "109308ed8013362532179505dfb7cb7e2941a7e8deb7fd2879a66aede58d8b22" + }, + { + "path": "crates/canopy-server/src/deployment/tests.rs", + "sha256": "d5379b84c34bfe460ece1a5a18c8ca338882ebb3a1397c0697d9ded46c8270cf" + }, + { + "path": "crates/canopy-server/src/deployment/tests/retained_maintenance.rs", + "sha256": "7d349038bccc8918775750ba8e098049f5aa0132d1bca5e47705457871400c79" + }, + { + "path": "crates/canopy-server/src/directory/accounts.rs", + "sha256": "baa4b36abede25ef4c68b67fe953c90eee6057879a9445429169e9b56607cc7d" + }, + { + "path": "crates/canopy-server/src/directory/audit.rs", + "sha256": "a34dd0649778a8bd1f56b5320c9c41d73efc5f45813c0f24723cb79d691532dc" + }, + { + "path": "crates/canopy-server/src/directory/lfs_auth.rs", + "sha256": "b26f7845cfff1a8265331ece9bbfbdba8c26d17fbbfc2198cb7b444d18855b41" + }, + { + "path": "crates/canopy-server/src/directory/mod.rs", + "sha256": "050579edfc80a6558089ae2cfb52a061a54b798e3c93c006a96dbef4cc8be6d3" + }, + { + "path": "crates/canopy-server/src/directory/ssh_keys.rs", + "sha256": "28ce2c0f6d6111d88968684ee8049792341d5064fb4e15a0d25224bea6fec246" + }, + { + "path": "crates/canopy-server/src/directory/timed_sql.rs", + "sha256": "6bef4ea0c8c22e6f26ebd2369a071108489959382388399dc9879822f0c2a0f6" + }, + { + "path": "crates/canopy-server/src/directory/tokens.rs", + "sha256": "5c4521a715cc1be01e2cab811f78a26ba55eb12a62bc288f8c1a6a20da801415" + }, + { + "path": "crates/canopy-server/src/git_cache/artifacts.rs", + "sha256": "0f6c5de936ed8ad3b15e3a86030b845d7e90e0d9baebdf66cb0250ce308bc091" + }, + { + "path": "crates/canopy-server/src/git_cache/cleanup.rs", + "sha256": "8b693536daa4585d8f93b49f97ed1d0edb9ff4a3433ff665af18999332ce9260" + }, + { + "path": "crates/canopy-server/src/git_cache/maintenance.rs", + "sha256": "b2d9a6fac5c27a201ed1c3845505ed19be70ba0c86b59ee7c6d291aec0def2f8" + }, + { + "path": "crates/canopy-server/src/git_cache/mod.rs", + "sha256": "e8a75eebd96174a248de4919f0f5c59ac12d2c613f50d6ed697aa617a3521297" + }, + { + "path": "crates/canopy-server/src/git_cache/serving_refs.rs", + "sha256": "3089ce8b3b3e45ee4bc6db54affd2755f3b66b7ab3f082d9d13a2c1fa4db5b57" + }, + { + "path": "crates/canopy-server/src/git_cache/tests.rs", + "sha256": "f3120de4cacea738497895c38921693f1c5190714e32e7b7208d494e21e3ca12" + }, + { + "path": "crates/canopy-server/src/git_gateway/branch_policy.rs", + "sha256": "59054d18711f292bd1175d4007ba0fbf36f55d5b08945b2304e0970973fb0b00" + }, + { + "path": "crates/canopy-server/src/git_gateway/candidates/mod.rs", + "sha256": "17ec718c07bade59eabfe1b1a36edb891e7b17b1ad0f516bcdf1f2c6c7f0e2b2" + }, + { + "path": "crates/canopy-server/src/git_gateway/candidates/produce.rs", + "sha256": "d9832a5717df96c58d9f9559dccecd8ce9ed23abeb0c7e428c974d4dbc5fbeb9" + }, + { + "path": "crates/canopy-server/src/git_gateway/candidates/rebase.rs", + "sha256": "84f83e62c70f3608a8f53c59e2bc62964be4021439d9ae70538276ea8f46d550" + }, + { + "path": "crates/canopy-server/src/git_gateway/discovery.rs", + "sha256": "8666944df14e09482837d959297049fd28474ce692a24c837f3f9eb2ea4511f5" + }, + { + "path": "crates/canopy-server/src/git_gateway/fetch.rs", + "sha256": "2513866510850a4ac95cb123429664f0332bdd81c86528a136679b2a4dee04e8" + }, + { + "path": "crates/canopy-server/src/git_gateway/head.rs", + "sha256": "f5b95ee17bacf9bbaf62b33ec02543b894afb7ddda2348d8541b3fa5bf03daf2" + }, + { + "path": "crates/canopy-server/src/git_gateway/merge.rs", + "sha256": "bee44b53b02eadfae5a6beb5bbdaae63677534344697b14bea65f1d58cbccf32" + }, + { + "path": "crates/canopy-server/src/git_gateway/mod.rs", + "sha256": "2126cdd9216e56a16420ff6d20de7d8c66d4b55a8f33bb7dd8e96225895c3a3e" + }, + { + "path": "crates/canopy-server/src/git_gateway/preflight.rs", + "sha256": "336c40e3ac3e9aefa37a1dd238dd5810b810726898730e7075b5d6da05ecc322" + }, + { + "path": "crates/canopy-server/src/git_gateway/preflight/retention.rs", + "sha256": "c66d79c5fae991827ee8314ef78a4e0f208cc8be64672ddca268dea66bebef53" + }, + { + "path": "crates/canopy-server/src/git_gateway/preflight/tests.rs", + "sha256": "07edb88ab4c7e2dc149306f8281211ec9e5bc658402683849ee8585780385699" + }, + { + "path": "crates/canopy-server/src/git_gateway/push.rs", + "sha256": "f89a94a262eecd72b8f97b3b81611f57225638212ee8228860148c00edaa135c" + }, + { + "path": "crates/canopy-server/src/git_gateway/push/native.rs", + "sha256": "7a82d4ec5480241dc98aacf0eece4bb7dcbb8a09dcc63e76f7c19105dd215b13" + }, + { + "path": "crates/canopy-server/src/git_gateway/ssh.rs", + "sha256": "171679a84f1efcbc7bda16343c096175e4b281312fb9267f6b601de608786f4c" + }, + { + "path": "crates/canopy-server/src/git_http/capture.rs", + "sha256": "f6cc7e63f61d02b9416f376d1a90080cbeac97cf5c2dec1673f88fe6b3cd1b1d" + }, + { + "path": "crates/canopy-server/src/git_http/mod.rs", + "sha256": "7a925f1621606e8e94c7a0384f2d8dffacabfa5f357d7343dc860aef9eef3129" + }, + { + "path": "crates/canopy-server/src/git_http/stream_tests.rs", + "sha256": "8f9ee6a67b996840b9f4f4f6ca9300645033698c8860f6ae3fc8c0f3836ebe63" + }, + { + "path": "crates/canopy-server/src/git_input/mod.rs", + "sha256": "7923a390a20f22630d71c53ff5ec785e97e380cc4d86c10e532b866bf98b18c0" + }, + { + "path": "crates/canopy-server/src/git_input/tests.rs", + "sha256": "39edbe9765186bca88162a6dfa6f0520964a896d6265f956449dcd14e1f5daf4" + }, + { + "path": "crates/canopy-server/src/git_objects/mod.rs", + "sha256": "2f6def3f82b1797aec9792381f043557a35f623e20e685eddd77ce5878c2cc64" + }, + { + "path": "crates/canopy-server/src/git_objects/tests.rs", + "sha256": "1ef594ea8c8751138a2946558d802c849e0783ea44da0a1cd57350c0d37fd2b2" + }, + { + "path": "crates/canopy-server/src/git_read/browse.rs", + "sha256": "eb95b3f65b86511062e42e0731ae98a0bc949ba696ef2e0339219f8fa75d485d" + }, + { + "path": "crates/canopy-server/src/git_read/graph.rs", + "sha256": "472bd76c8d61f85e425b05deed2bf14f0506342311948fabeaf50404e7efde70" + }, + { + "path": "crates/canopy-server/src/git_read/mod.rs", + "sha256": "7f10eda407af801aa82c1c0fdf2941ba3aa5c586b40eae0bd879020fdb947832" + }, + { + "path": "crates/canopy-server/src/git_read/patch/mod.rs", + "sha256": "9eebb7e66e678680fbdc80dfe5edd60318b4fbd766f57e127a14f429fbb134bb" + }, + { + "path": "crates/canopy-server/src/git_read/patch/tests.rs", + "sha256": "228be85ffd003755a3a061c452f3ecfc20392cf72155a084f723d5db2caa5e6b" + }, + { + "path": "crates/canopy-server/src/git_read/trees.rs", + "sha256": "38b78d05e44fa792831e548e75fa81651bc7f56d9d5bed17c3fda4c0ca0b276e" + }, + { + "path": "crates/canopy-server/src/graph/mod.rs", + "sha256": "cb6cb2ea01a8262d0df04b7a33620b1f053918b376375d860760e81c7be66862" + }, + { + "path": "crates/canopy-server/src/graph/preparation.rs", + "sha256": "56a3c91d6857942ce927895c3c74dd7715ae674d998c0c86bfc1db6e0c487d81" + }, + { + "path": "crates/canopy-server/src/graph/stream.rs", + "sha256": "1d6dd023572d606134a084f16b929c509e611c2fb4901830f91e9d0f40a3e8f7" + }, + { + "path": "crates/canopy-server/src/graph/stream/tests.rs", + "sha256": "5c847aab9a0e3b79038f8cf7958ab7ed40e97c0a227382f7b4d5d157051b4a37" + }, + { + "path": "crates/canopy-server/src/graph/tests.rs", + "sha256": "685ef8995614d7f4e67e08670907ea4ff1a94860ab9869ee7aece141f0fb569c" + }, + { + "path": "crates/canopy-server/src/http/lfs_locks.rs", + "sha256": "73f82df87b221795e6531ea968ab8630ce5e5fe769d69ea0d3c7a2f8c5c80d82" + }, + { + "path": "crates/canopy-server/src/http/mod.rs", + "sha256": "b200ac8fe6a62d22946e95491e72f2f731ed766238b2d355b8fc841f5353416c" + }, + { + "path": "crates/canopy-server/src/issues/mod.rs", + "sha256": "0bb4c4e5cae85be3714e485fb4bbd93052e1902fbc4c140d972c059ba36004ac" + }, + { + "path": "crates/canopy-server/src/issues/mutations.rs", + "sha256": "725f0936b2f8622c6cefe9c52ea6349b61631ead7d49fbd453ee558085609014" + }, + { + "path": "crates/canopy-server/src/lfs/locks.rs", + "sha256": "710844a9f4edeec5a83bc22e4200827b9288d29ff0263ef5c904822e00e7b1e8" + }, + { + "path": "crates/canopy-server/src/lfs/mod.rs", + "sha256": "8ca41399d63aba98020b317bcd96ed965cf70d5328d953f1280d7abde3de6be2" + }, + { + "path": "crates/canopy-server/src/lfs/read.rs", + "sha256": "6286b9e235ff6435db778854f131602823261404bfc9fc7642e9a7f471251d36" + }, + { + "path": "crates/canopy-server/src/lfs/tests.rs", + "sha256": "4aa4b2dd43cb742df80b83407cf1cbfa4c8637c671bf2b1b7271ea5297a910f1" + }, + { + "path": "crates/canopy-server/src/lfs/upload.rs", + "sha256": "5018ea81930c481ceaa2bf8829af1709d56821086ff2aba1e172c368990074a3" + }, + { + "path": "crates/canopy-server/src/lib.rs", + "sha256": "bf650db6f31b945b43e1aa70159b83d3c17173622ac340161944525e02ac4890" + }, + { + "path": "crates/canopy-server/src/main.rs", + "sha256": "1e6954a8d42e8c45dc26d216684773c72758fd962191d38140cda3814be6a10f" + }, + { + "path": "crates/canopy-server/src/native_git.rs", + "sha256": "3c2cc2520e1da5bc14191835624ea1b794cf385b3c2154065da29bbb09802c29" + }, + { + "path": "crates/canopy-server/src/native_git/process.rs", + "sha256": "58abb05aa26e31f679a5a17378f4847b8ad08f1c77e0d8e554d4bcc00c925734" + }, + { + "path": "crates/canopy-server/src/native_git/process/fence.rs", + "sha256": "b62b004c41a5c2610ea50d77bb7dccb420d0ac92f9b085bdec866de5d22d5da9" + }, + { + "path": "crates/canopy-server/src/native_git/process/tests.rs", + "sha256": "98df7fb19f03448b37d24cb705ba6ee351aafc303b418cb99b1aecb375ba4e55" + }, + { + "path": "crates/canopy-server/src/native_resources.rs", + "sha256": "488d2c527d61a73dd2698e3a4b5be7a7e922ad7c449ea2a22b58d67a59121113" + }, + { + "path": "crates/canopy-server/src/native_resources/tests.rs", + "sha256": "075e86dadc5f517a9f22aa1c26eb5445b1f74beed031eba5f8b279e4874778d7" + }, + { + "path": "crates/canopy-server/src/object_batch/mod.rs", + "sha256": "13d00cae66d61c3eeee9b287973ad16d5b52341a8c7d422f48bc45131ac8c557" + }, + { + "path": "crates/canopy-server/src/object_batch/tests.rs", + "sha256": "f37acb996c1ce8dd1cdcb90049244de714585fa5f2781e602fcd49b94d4b94f5" + }, + { + "path": "crates/canopy-server/src/object_chunks/mod.rs", + "sha256": "71961b2bf93518169f12eadfe5d927a9ba193a36406400d03a8fb0c89c5c8abb" + }, + { + "path": "crates/canopy-server/src/object_chunks/tests.rs", + "sha256": "8fd4431cac7079485e0e0d514a1074e925f5b58c8f985cb6f8d6b940698a89dc" + }, + { + "path": "crates/canopy-server/src/object_reads/mod.rs", + "sha256": "6559a5077717e876892ff33ed5551c9b5a9397fe488beb65a73daaa18b1cfa38" + }, + { + "path": "crates/canopy-server/src/pack_store.rs", + "sha256": "3e1ff0991afbc57e0ebb6b0da2fc8d17e8d116f3a595822c37d511525738773d" + }, + { + "path": "crates/canopy-server/src/packs/backup.rs", + "sha256": "061bc8a3fd847de54b5d186e551c82d803889b303c52d4cb58c86c595572f25d" + }, + { + "path": "crates/canopy-server/src/packs/catalog/codec.rs", + "sha256": "fb7d384cef71f7be93bc586a0e79badd915dcc89b2f9853a9c034ca40587b62f" + }, + { + "path": "crates/canopy-server/src/packs/catalog/files.rs", + "sha256": "faf185a7aed6bc85deaf17abe5152bb06850f05f9b28843e5a3f9dc2be09b14e" + }, + { + "path": "crates/canopy-server/src/packs/catalog/graph_spool.rs", + "sha256": "f1cf72be8a36d6adc94e11a1c3a5d57be1fa9c3ed813bb531055ba1cc2c69d97" + }, + { + "path": "crates/canopy-server/src/packs/catalog/graph_spool/tests.rs", + "sha256": "7bcc7eb29daef7a9385fb15d674f612a16e43bc1a444bd061da93ebbb5141053" + }, + { + "path": "crates/canopy-server/src/packs/catalog/mod.rs", + "sha256": "2d762f30991a826aedd2e4796800258520e953d1fc2f8cd0e07a8a63ff851477" + }, + { + "path": "crates/canopy-server/src/packs/catalog/native.rs", + "sha256": "a6f50f40a9599b04214187b50ddac9a6df0cd9dbeb3c0eb1667c0becf64895af" + }, + { + "path": "crates/canopy-server/src/packs/catalog/native/tests.rs", + "sha256": "159ae4101c65b0d3a6a2eb040af9b71c12329af6e0ea9f0c460c6140d0b34816" + }, + { + "path": "crates/canopy-server/src/packs/catalog/reader.rs", + "sha256": "8f1774ef6840f73e37da188899dabdce6ae31b656475f5e8ee6b8dc87464f73b" + }, + { + "path": "crates/canopy-server/src/packs/catalog/serving_fixture.rs", + "sha256": "9f32d456083fea3895a8f96ff62c3df670635c636988b484b9e74267c07ea448" + }, + { + "path": "crates/canopy-server/src/packs/catalog/tests.rs", + "sha256": "322652548da8be5a472214b9d571300913e031851d3db244d1d99107f4082750" + }, + { + "path": "crates/canopy-server/src/packs/catalog/tests/files.rs", + "sha256": "d88fe635fec473c6788c5a01a4059472881a4444507ac7a16a69333e2db4eeb0" + }, + { + "path": "crates/canopy-server/src/packs/closure/copy.rs", + "sha256": "83e2cdfca589d2f9ebaa32d9120ef8d8f893cbd01ad3c6458fa035e5a44ac9fb" + }, + { + "path": "crates/canopy-server/src/packs/closure/graph.rs", + "sha256": "a0e270769bd0b3f7d2c33c933197928ecb84368e5531b2861b08f0de66c0300d" + }, + { + "path": "crates/canopy-server/src/packs/closure/mod.rs", + "sha256": "622eb0dd3358ffdaa097b45f10c03dd252306453e65284da696446c641af4391" + }, + { + "path": "crates/canopy-server/src/packs/closure/retained.rs", + "sha256": "07b8da1838af56db5b418e28cc2516ca60c54c95e5ad58411f37515181940bcd" + }, + { + "path": "crates/canopy-server/src/packs/closure/spool.rs", + "sha256": "32a102501cc02a20d1be6ce4a8f26182f809a5204b8890e6964dc3ba692c3e69" + }, + { + "path": "crates/canopy-server/src/packs/closure/tests.rs", + "sha256": "61087054d068316cc398173a7fcd3fba8e2c9220cc501575cafc56a0cd73140e" + }, + { + "path": "crates/canopy-server/src/packs/closure/tests/graph.rs", + "sha256": "c6df580a117db5731cb7d9b74556f3770e8e100781bc1d8f394bab60db4168c9" + }, + { + "path": "crates/canopy-server/src/packs/closure/tests/resources.rs", + "sha256": "c285d66f9ef8be7dd80411c6dad239c1f88e8c0ff31c3da6f8153d134b5ce795" + }, + { + "path": "crates/canopy-server/src/packs/closure/verifier.rs", + "sha256": "021788bf15327410a67b1f4ea29af6ff99d015171fd53c3707fad37131b5e3b8" + }, + { + "path": "crates/canopy-server/src/packs/closure/witness.rs", + "sha256": "e62f1c0a02d7c11008290b814bb416971bfddab49ca52e464050a7c8a6463572" + }, + { + "path": "crates/canopy-server/src/packs/directory/coverage.rs", + "sha256": "d9912fdb2d6d47714e1e428c718cc841ef54d369a9f502731078eca93bb3e546" + }, + { + "path": "crates/canopy-server/src/packs/directory/index/bulk.rs", + "sha256": "5ca4f597f8ed66ea335ed9b05376736e21d683c4e023f6ff37ecd281cec60346" + }, + { + "path": "crates/canopy-server/src/packs/directory/index/changes.rs", + "sha256": "d707f098a9361b63d384b80dbf9341f14abadfacf3018f4fdb025d1d402c8929" + }, + { + "path": "crates/canopy-server/src/packs/directory/index/codec.rs", + "sha256": "1449ebf119023aa6760dd9a2608f7090f35dd0f16c6d2d0e5bc00e04c2ad4e3a" + }, + { + "path": "crates/canopy-server/src/packs/directory/index/cursor.rs", + "sha256": "b0f46cda70163ed19313f0d6d8dcc7bf6fed1706b81c8986aa25ac1bd324e20d" + }, + { + "path": "crates/canopy-server/src/packs/directory/index/mod.rs", + "sha256": "aaaa8c49b35818f058a3cd88aabb57e429841a3d4160497f7c57b62bc53d3488" + }, + { + "path": "crates/canopy-server/src/packs/directory/index/record.rs", + "sha256": "a50df6a8b76bc30d935bd8d7bc380a54840efdcc7d0ee05848e8b19f3f61b482" + }, + { + "path": "crates/canopy-server/src/packs/directory/index/rewrite.rs", + "sha256": "0abed8b2621d92bba8431514ad77c009cca210d08da5964aa16952ea1f19b7bf" + }, + { + "path": "crates/canopy-server/src/packs/directory/index/tests.rs", + "sha256": "14e82703358df5b69c0e7d1b857d6d853e37787d56fb7f6c2a39619872352abd" + }, + { + "path": "crates/canopy-server/src/packs/directory/index/update.rs", + "sha256": "a3a0d8d2bbc87d49f314d29fedbd14afbb66fd322f94b61f54b8aef5b5916e77" + }, + { + "path": "crates/canopy-server/src/packs/directory/index/visit.rs", + "sha256": "249cddbef4a4f541e19c7af35f828e119b66d003e70c757c15273caa6087df33" + }, + { + "path": "crates/canopy-server/src/packs/directory/mod.rs", + "sha256": "cbb8bea03ca7578da4b1902b5aa57ddc2b1a0d0c12c18352a86743bb82082108" + }, + { + "path": "crates/canopy-server/src/packs/directory/partition.rs", + "sha256": "b66fde6b96e4aff4a215b5a8be66311987dfc918323cbd65e426cf402956b178" + }, + { + "path": "crates/canopy-server/src/packs/directory/snapshot.rs", + "sha256": "31effd077751267730844b55a2a0c09f104212523975392f332094efd566b896" + }, + { + "path": "crates/canopy-server/src/packs/directory/snapshot/codec.rs", + "sha256": "c4bf30e83f912f4c69f44113495869b0abc08b00ce63c301c547de5b42ea3500" + }, + { + "path": "crates/canopy-server/src/packs/directory/tests.rs", + "sha256": "109dcada74b316a14727f40a738ff280d378050765082da65ab0ec21513e1e69" + }, + { + "path": "crates/canopy-server/src/packs/directory/tests/compaction_inventory.rs", + "sha256": "61f21c445cbe3aa66a78c74f808515aadce97943d1841d706f803f0b629a392a" + }, + { + "path": "crates/canopy-server/src/packs/directory/tests/coverage.rs", + "sha256": "821ff1b16bd0434cd49bd8c0f75e319ae690df72e13e1ae79f7d6ae64c6b8f06" + }, + { + "path": "crates/canopy-server/src/packs/directory/tests/partition.rs", + "sha256": "b439cac8a8d0cda00920eff596a8be29883d406d3510c10121a864edc7ae783b" + }, + { + "path": "crates/canopy-server/src/packs/directory/tests/partition_lifetime.rs", + "sha256": "ac6a9ab5635ee16bc9b8bb0309861f41c65e4ec178c824874cbff981d8866d0e" + }, + { + "path": "crates/canopy-server/src/packs/directory/writer.rs", + "sha256": "ef0260442eb0d9cd397a8f7735077d99e03940489815b1ad143004bb183f2ab8" + }, + { + "path": "crates/canopy-server/src/packs/input_artifact.rs", + "sha256": "028c8cee3f92a897836c62a8fa362465e924bdec52c2ae5cb48aa2db6103b5ea" + }, + { + "path": "crates/canopy-server/src/packs/metadata/growth.rs", + "sha256": "adf24b9a2833ac5ba8fd7ba75a484b3b3687f1a6005f0f8788e0ffb4115868af" + }, + { + "path": "crates/canopy-server/src/packs/metadata/mod.rs", + "sha256": "fd5f7d946155a2fb6ca2d2175b2bc58ef11ffaf621bb155a06bf459667a660e1" + }, + { + "path": "crates/canopy-server/src/packs/metadata/tests.rs", + "sha256": "234b77dc39eecc3bc6b7715b73e1b7ab8456a60e04753bfd71e559176ae41d05" + }, + { + "path": "crates/canopy-server/src/packs/metadata/transport.rs", + "sha256": "594b33d6ec656cb3701461fa6ac0e7b37f2497048ea48383d109386bdd34d062" + }, + { + "path": "crates/canopy-server/src/packs/metadata/writer.rs", + "sha256": "34b1bc202818d310694d103ecfd437824f9e6f97e90e84b06e9f870e8dde793b" + }, + { + "path": "crates/canopy-server/src/packs/mod.rs", + "sha256": "62796e522c496ae2c962822872aa0183370e7c29abf2eb091955c8054e66b6e9" + }, + { + "path": "crates/canopy-server/src/packs/publication/admission_receipt.rs", + "sha256": "853a2f275b382cbfe4a8c14fd6e1d1fadbd98e86e829442e3e6522a64c45f00b" + }, + { + "path": "crates/canopy-server/src/packs/publication/attestation.rs", + "sha256": "2f542b7076a6aaabdedd1da3a93e60eff64040b83061f92cddddb98313d47c71" + }, + { + "path": "crates/canopy-server/src/packs/publication/backup.rs", + "sha256": "43591258f92d0abaecb201c4b8968d5752d6df30f7350195b9e59bee916d9ced" + }, + { + "path": "crates/canopy-server/src/packs/publication/base.rs", + "sha256": "d363f6ce5ea211ab26aca6b98ec869f61e2f875b5110c247085496520a0d64fb" + }, + { + "path": "crates/canopy-server/src/packs/publication/candidate_publication/audit.rs", + "sha256": "1de82267ac55f9c21b8f7bbe7ae01725f2f120ebe8d0d3cc3c0592b8f40953bb" + }, + { + "path": "crates/canopy-server/src/packs/publication/candidate_publication/mod.rs", + "sha256": "6db2a153228bad4a772c7ce125923eff33cc2bc496d9f45b8c640bc3f118add4" + }, + { + "path": "crates/canopy-server/src/packs/publication/candidate_publication/publish.rs", + "sha256": "121c6bab8bd4f4eec01939af0ae3786ddf4f1abdd771e22664ac1e17faae2ade" + }, + { + "path": "crates/canopy-server/src/packs/publication/certificate.rs", + "sha256": "4fddfc005a6c0434054a69619d9605a79ae59813698e3482af76ccb57968382e" + }, + { + "path": "crates/canopy-server/src/packs/publication/codec.rs", + "sha256": "edb6f140e9a18047ee7c6f7e11849a7cad2620dcaa479b53ab04bf0b30fa8453" + }, + { + "path": "crates/canopy-server/src/packs/publication/commands.rs", + "sha256": "5873ec8a53dfa0952636f3c7516769e678e69742b794db5a93b804f933b98bc0" + }, + { + "path": "crates/canopy-server/src/packs/publication/commit_membership.rs", + "sha256": "0e45acfcd01d071fb23b88daa5e332a798130a6eaf6f3562f9df75018d2056c4" + }, + { + "path": "crates/canopy-server/src/packs/publication/compaction.rs", + "sha256": "002cbf8d7982cf781e9d9774c19d7813405f7eeb7690ecfdcd5c968b119f6e99" + }, + { + "path": "crates/canopy-server/src/packs/publication/compaction/prepare.rs", + "sha256": "e5d28aa35dfad62ea54601e97e6f0b893efc710aa93f129a2de7855405b092cb" + }, + { + "path": "crates/canopy-server/src/packs/publication/compaction/publish.rs", + "sha256": "03d588e5706462c05fdbda81fee79d257cfbd7a67708d9cc738ec599f42962d3" + }, + { + "path": "crates/canopy-server/src/packs/publication/compaction/range.rs", + "sha256": "7897baf94150939daa0edbe5c1ebb711e6d1c236c80560071c238fe1e3bea8da" + }, + { + "path": "crates/canopy-server/src/packs/publication/compaction/schedule.rs", + "sha256": "897a16e193135c4984802b3b63d67cbab4b3d6a24ac6fe1debbbaa9cf68e4669" + }, + { + "path": "crates/canopy-server/src/packs/publication/compaction/schedule/tests.rs", + "sha256": "14d839203d6b1e4cedb968b4e3b7ea3323338e9020d000653b74602dd995a197" + }, + { + "path": "crates/canopy-server/src/packs/publication/completion.rs", + "sha256": "cfd215d215b695ba07f63b9fa3050442dc1761e7e18804f2586734aea8016ff3" + }, + { + "path": "crates/canopy-server/src/packs/publication/coordinator.rs", + "sha256": "fcac106eec0eb655a8e6188d154f5e98b342477dfe10e3eed2437fea436b2551" + }, + { + "path": "crates/canopy-server/src/packs/publication/coordinator/budget.rs", + "sha256": "182a7035e72c12d80b157f05a7390bd6534c28f5230a5112daf7dc6a130ffff5" + }, + { + "path": "crates/canopy-server/src/packs/publication/coordinator/budget/tests.rs", + "sha256": "7994b1ff59478dc13215c1dc92316c8d1a07e840d595e063bd8ff876ddad4a33" + }, + { + "path": "crates/canopy-server/src/packs/publication/coordinator/initialization.rs", + "sha256": "e08beb2e36f94203e648c47e3cb3bdf847a669e700d1681dd2c0d83cf66c6e36" + }, + { + "path": "crates/canopy-server/src/packs/publication/coordinator/inputs.rs", + "sha256": "871d23d2a227dd6e84971c6115a2e24fc80e1a43a332b8aba6b673cf953c5d85" + }, + { + "path": "crates/canopy-server/src/packs/publication/coordinator/native_candidate.rs", + "sha256": "f46a442a2dfc4107dc872f86e040e9e56ec8caeea3a5d84431ab79d45ddfdd76" + }, + { + "path": "crates/canopy-server/src/packs/publication/coordinator/native_head.rs", + "sha256": "086b82da54699264e763ea21c13368fbb3cecbc33452e2a10c5a196cd2d04e51" + }, + { + "path": "crates/canopy-server/src/packs/publication/coordinator/native_merge.rs", + "sha256": "4c0eedf1275f193499006041bcf7c8f91d3a8c05398b32ce9686a4251ecd6948" + }, + { + "path": "crates/canopy-server/src/packs/publication/coordinator/policy.rs", + "sha256": "0aa684ed7a784f4cf6c0e69eeb19be318eed66d1ced803595553aaef267c8cf3" + }, + { + "path": "crates/canopy-server/src/packs/publication/coordinator/preparation.rs", + "sha256": "20e6e33970eb1c069a25cf5121ec5efe29ede36cae2b39090d14d82f2687257a" + }, + { + "path": "crates/canopy-server/src/packs/publication/coordinator/recovery.rs", + "sha256": "21a2445d9749ab4f9c6cf157dc8b4221ae5e20208cadd2b09b1999e1a5716427" + }, + { + "path": "crates/canopy-server/src/packs/publication/coordinator/roots.rs", + "sha256": "df2cd3bc3627c9ee718f2dd54072c911a62358d97fb6e6eb210fed186d30ec56" + }, + { + "path": "crates/canopy-server/src/packs/publication/coordinator/serving_drain.rs", + "sha256": "1c9c5d800932fd6dac1c0ec03d05a919c406f1cc8236cc1a301846e562f4a544" + }, + { + "path": "crates/canopy-server/src/packs/publication/coordinator/work.rs", + "sha256": "a1beaa1d190b157e2b6e9c916953494eec706aeb5550d3f2455f97c54efbbb36" + }, + { + "path": "crates/canopy-server/src/packs/publication/custody/codec.rs", + "sha256": "9fa386277a41473d32100d6156e2a21fba071180ca838d8da055400d59a0c79e" + }, + { + "path": "crates/canopy-server/src/packs/publication/custody/commands.rs", + "sha256": "f3900da141bd1b7ff93beb97f8e596dc2105ac6870d4bfaf7631e2cf012efd03" + }, + { + "path": "crates/canopy-server/src/packs/publication/custody/dispatch.rs", + "sha256": "02952eab2f46096f8306ff0924255d6aee5f4aaa48bb235f5f198937baba5b66" + }, + { + "path": "crates/canopy-server/src/packs/publication/custody/mod.rs", + "sha256": "a7eb484936670b58e99371a55c2ef931ed889e23ed2efc8ee58249bd664c73f2" + }, + { + "path": "crates/canopy-server/src/packs/publication/custody/scan.rs", + "sha256": "c5e39c9ae07a31523a66c905e94135d8afbdc4513b1c480d86a1ab28e7f64f04" + }, + { + "path": "crates/canopy-server/src/packs/publication/custody/stop.rs", + "sha256": "0725424880cfeb285489f0958a85f2b41b07417a1ed4baa0a5fb80906040e8e4" + }, + { + "path": "crates/canopy-server/src/packs/publication/exact.rs", + "sha256": "e486a4b7878323492bf4a5bf017a4f0d6398fab75859354525bb6376062d6b68" + }, + { + "path": "crates/canopy-server/src/packs/publication/initialization.rs", + "sha256": "2612099bb024ab078f7c65e939334578ca59208df3ef189b791e766f7bb4e595" + }, + { + "path": "crates/canopy-server/src/packs/publication/initialization/publish.rs", + "sha256": "072663cbbbac08b4b537a463da2901b7f1473fadea90d462491b5b78cd36025c" + }, + { + "path": "crates/canopy-server/src/packs/publication/inputs.rs", + "sha256": "d170786ad21555f5e2692825b23b543f38f1e33dfa26f37c6af3c196db50ab14" + }, + { + "path": "crates/canopy-server/src/packs/publication/inputs/custody.rs", + "sha256": "a7330cbe522f13dc4687d9a400e131742993033611992b5adf1b0e81f11d8d81" + }, + { + "path": "crates/canopy-server/src/packs/publication/inputs/limits_tests.rs", + "sha256": "1e1ddc8c5a514e7184054b77a47ebdbbcf7ccaa3d5616637f198cff7eb5b0064" + }, + { + "path": "crates/canopy-server/src/packs/publication/mod.rs", + "sha256": "d96142365f9b3aa276af5637b8407d540f4d557217027d0667c30b829dadef37" + }, + { + "path": "crates/canopy-server/src/packs/publication/native_candidate.rs", + "sha256": "3c5a52ed9cbb4d83fb24453ce1d36a3b64cefb0ef4f3d0ae8d1b08ca708ef07d" + }, + { + "path": "crates/canopy-server/src/packs/publication/native_head.rs", + "sha256": "ac1a8e33d737964854b9f57d22ec5495aaa42ed33733851e1438d264e1082308" + }, + { + "path": "crates/canopy-server/src/packs/publication/native_head/publish.rs", + "sha256": "a624b7bc3969b9b88f31f6515e8d7fe9cb4a7d3d8261c2112d5d870cd062f9d8" + }, + { + "path": "crates/canopy-server/src/packs/publication/native_merge.rs", + "sha256": "41753bbd0f9d61f959c9dd0c338ec88143a110965ed1321792be5518bd1a9c97" + }, + { + "path": "crates/canopy-server/src/packs/publication/native_merge/audit.rs", + "sha256": "817568743fb85fa2cc6b1dfc9b3b96f3f44c824cbc1385c942324e0bb35ffae1" + }, + { + "path": "crates/canopy-server/src/packs/publication/native_result.rs", + "sha256": "e922b3dcdbf7d23fbbf9f73e359b1bc207df7e7fe1fc040ffbeaa1407b4e613c" + }, + { + "path": "crates/canopy-server/src/packs/publication/native_result/codec.rs", + "sha256": "1feafe06d85ed11bb9fe5b9a82c3baf436bd6d8d9a868c766fd50456104615fd" + }, + { + "path": "crates/canopy-server/src/packs/publication/native_result/plan.rs", + "sha256": "2ae3202244a62d9db96a6c0a0b0e5fbcaa8cc641f3139b675eb29ca4b983bad6" + }, + { + "path": "crates/canopy-server/src/packs/publication/native_result/plan/tests.rs", + "sha256": "0f715c006257db00402a24e59310f360d0fac5aaae0b30a0c16a080e23e50e91" + }, + { + "path": "crates/canopy-server/src/packs/publication/outcome.rs", + "sha256": "b043a053a93d7f7c74c3c6469c66b4770cf79bcd33b2df5ef6b1edd039d28327" + }, + { + "path": "crates/canopy-server/src/packs/publication/owner.rs", + "sha256": "78a98de316e19bcaffe10439b7cd9edf187af2c55e00bef12db9a4cf5ed9a878" + }, + { + "path": "crates/canopy-server/src/packs/publication/preparation_receipt.rs", + "sha256": "a7df0fb19ef0107dc4da8f0719975ee53d04eaf8c1fff1d3a93230960843067e" + }, + { + "path": "crates/canopy-server/src/packs/publication/prepare.rs", + "sha256": "4d11017e2f5ce092ba7d63827740ad761fc0913fb806cd0f1ab5895e4355c153" + }, + { + "path": "crates/canopy-server/src/packs/publication/publish.rs", + "sha256": "02438ea8b529a69915897c55a93dce68a3e479801f9d04a7fba38af69d6844d1" + }, + { + "path": "crates/canopy-server/src/packs/publication/recovery/archive.rs", + "sha256": "6acd4cf545934f7e4f136042d4cc7cf3c71e26a2b3c2ccdb82abc406bb7e3bd0" + }, + { + "path": "crates/canopy-server/src/packs/publication/recovery/backup.rs", + "sha256": "01a21e8aa99dd1f2abd396d4b24cc086bf6316621f5813e70cb75576b16da01d" + }, + { + "path": "crates/canopy-server/src/packs/publication/recovery/codec.rs", + "sha256": "d201599b14e5102fa2ea6ddce27c5116c1eb6f4b178c6c47d38742746012a03d" + }, + { + "path": "crates/canopy-server/src/packs/publication/recovery/initialization.rs", + "sha256": "902d875e49573c0f024516ce4a411302d0e53875517f340120f759d7565e059a" + }, + { + "path": "crates/canopy-server/src/packs/publication/recovery/mod.rs", + "sha256": "6c7c4d5716773e2a6d3002212e217a331942ff898510aa66e2b018180d889322" + }, + { + "path": "crates/canopy-server/src/packs/publication/recovery/phase.rs", + "sha256": "6eb95cb63c96002c9d0f80cf13eef0b54bce20ad91e7e1b1129a74b08c827559" + }, + { + "path": "crates/canopy-server/src/packs/publication/recovery/ready.rs", + "sha256": "dcc010dc3096b8db881535846f6fc3715848d8b6936a8ed85803b7f5e6b3285f" + }, + { + "path": "crates/canopy-server/src/packs/publication/recovery/registration.rs", + "sha256": "9365df807689d02a841e1d9bee8d0a187c1c6c81a6b29c750133f0a2ff9c74fe" + }, + { + "path": "crates/canopy-server/src/packs/publication/recovery/supervisor.rs", + "sha256": "cd19249f899e7ffa21a60c1a3189f249f30933af7a67db939b153dcd43548645" + }, + { + "path": "crates/canopy-server/src/packs/publication/recovery/tests.rs", + "sha256": "fb5e4cf523fe385a32ba962b40cbd89a6c2e9edeef71c566572b9b495c9c04d8" + }, + { + "path": "crates/canopy-server/src/packs/publication/ref_observation.rs", + "sha256": "e669d1cb0562003a796c47be824108d4a80a8d57234d293f5e310e4ac5d29789" + }, + { + "path": "crates/canopy-server/src/packs/publication/ref_observation/selection.rs", + "sha256": "20702f14f9b9458e9ea83131f2f22114a5612e7dfa357a7a2129d545d6e1d66f" + }, + { + "path": "crates/canopy-server/src/packs/publication/ref_policy/codec.rs", + "sha256": "0c37d1d8ccd3ab74c253e08d4d86a5c8f3230a7e128ed0a11718ee5e666209d4" + }, + { + "path": "crates/canopy-server/src/packs/publication/ref_policy/commands.rs", + "sha256": "f7e0f63db8c51f8c7fde9bbc14b108caea2d35a6305547b756cc36fb2375e897" + }, + { + "path": "crates/canopy-server/src/packs/publication/ref_policy/mod.rs", + "sha256": "5554daaaee47984d51cebe20ad5037970fa61cc4e09663c3d4358581864adf9f" + }, + { + "path": "crates/canopy-server/src/packs/publication/ref_policy/prepare.rs", + "sha256": "195de5ece8888b4457a56fdfe8b1f1dabc30f8684f90e8a4f019b5d1c59743c4" + }, + { + "path": "crates/canopy-server/src/packs/publication/ref_proof.rs", + "sha256": "8bd48176fd2f5e42673c5fcdcf1e2d4eb0f95d6db8fce4a6dd251cb7b5bb2458" + }, + { + "path": "crates/canopy-server/src/packs/publication/ref_proof/ancestry.rs", + "sha256": "a9653535785221d35f667a73f9072b608e60f2ac44daaf04d7f0809937305665" + }, + { + "path": "crates/canopy-server/src/packs/publication/ref_proof/ancestry/tests.rs", + "sha256": "cc67e987b1a73c732dc157e4fbb23f966894eb027b296e7c895dc908cd262786" + }, + { + "path": "crates/canopy-server/src/packs/publication/ref_proof/tests.rs", + "sha256": "180fb28b58f8a0874bb8a6703b78754950d5ade6caf94e3449d9c8cf102a7100" + }, + { + "path": "crates/canopy-server/src/packs/publication/ref_snapshot.rs", + "sha256": "9e866e2523cfeebe7f56b88377a52e3f3ed41a2bac2931ec47a7f0080611091c" + }, + { + "path": "crates/canopy-server/src/packs/publication/registry.rs", + "sha256": "7bd735a021c3c8bcbb7d614aa6323a8f56ef1580d7edc613905a261bb90cbb98" + }, + { + "path": "crates/canopy-server/src/packs/publication/root_completion/codec.rs", + "sha256": "2957177a85ccaccf04d1344e81be696ca55d9a0f5fb4d5ba620c4daccf80e629" + }, + { + "path": "crates/canopy-server/src/packs/publication/root_completion/mod.rs", + "sha256": "0639981a833493a515b0bb53fdc2c501529f53a3433a58dd32e2eb3b6ae4797c" + }, + { + "path": "crates/canopy-server/src/packs/publication/root_completion/outcome.rs", + "sha256": "3d0a4c7acb6ac5ee06d1c99ec0e394118ea1d875ff82ffa2804a1445bb6189d4" + }, + { + "path": "crates/canopy-server/src/packs/publication/root_completion/prepare.rs", + "sha256": "7dbd745790166926f3274d99c856af25b996ecbd6bc6d9f3c815091f27b4a5a8" + }, + { + "path": "crates/canopy-server/src/packs/publication/root_completion/publish.rs", + "sha256": "e450c466521ef0c6ba38d8d7465197aeadb259c703ecd56575366ce5e32997e6" + }, + { + "path": "crates/canopy-server/src/packs/publication/root_completion/read.rs", + "sha256": "db5a23dd942eb39b87b750fbf4115d49d60fbb082eb34a8cf1eb74dfdf89b076" + }, + { + "path": "crates/canopy-server/src/packs/publication/root_completion/ref_free.rs", + "sha256": "c0e13d9453941ef8cc112bf48039f4ada115fab55d21a53dff3409c5b46da7ba" + }, + { + "path": "crates/canopy-server/src/packs/publication/root_completion/result.rs", + "sha256": "219ca8b4443c65cdf16c62a1bacd4e7c2b7555417977ee11941ae6ec551b19fe" + }, + { + "path": "crates/canopy-server/src/packs/publication/root_completion/retention.rs", + "sha256": "42db60cf1db5956a3a10417f7096a58719affbda1a42cddc4336cb4fa3bd7346" + }, + { + "path": "crates/canopy-server/src/packs/publication/root_completion/tests.rs", + "sha256": "069a39e4009d0b700803f158bbaedff2172de0c77e21eb3c62b52a0e3371bd0a" + }, + { + "path": "crates/canopy-server/src/packs/publication/scan.rs", + "sha256": "0ec626f85bc8fb8aea33cdd3ecb935afe8b34428a6b1f48587b8a2d9f80d54f6" + }, + { + "path": "crates/canopy-server/src/packs/publication/scan/tests.rs", + "sha256": "3cc3ee92ba5436652f4024bd5edeff7400fd7c199260857e5886e96a417ad840" + }, + { + "path": "crates/canopy-server/src/packs/publication/serving.rs", + "sha256": "af6e2d42f1d192a30f3aa1853eeeafdc3a0f12ed6365490c9c763b6e55623ffd" + }, + { + "path": "crates/canopy-server/src/packs/publication/serving/codec.rs", + "sha256": "ad49731468721723a5733dee5d1288c5351b328ef179e470988eb6f18c30d64b" + }, + { + "path": "crates/canopy-server/src/packs/publication/serving/command_owner.rs", + "sha256": "edf8a7c5c98df0054b27bb2af2890bb84d55f6921f57f058677962674014f96d" + }, + { + "path": "crates/canopy-server/src/packs/publication/serving/commands.rs", + "sha256": "925a8fafa2a80f81bc8bf897006bba5ec959eabfc47e2796539af6972cbd01dd" + }, + { + "path": "crates/canopy-server/src/packs/publication/serving/lifecycle.rs", + "sha256": "819f4360fddc64e746cef0c0167337519798324f01b4d8e610c4184bc34292a6" + }, + { + "path": "crates/canopy-server/src/packs/publication/serving/ownership.rs", + "sha256": "49332ddd4b616293a1195a8a2284eede89f9e2d3a74c19715053cf5875bb6653" + }, + { + "path": "crates/canopy-server/src/packs/publication/serving/pool.rs", + "sha256": "1ab9411bcc7e55f75c0098ac549bf1ee378082ec68925d1c75a36edb3bd67d23" + }, + { + "path": "crates/canopy-server/src/packs/publication/serving/session.rs", + "sha256": "8d98101d6da7a9e137c7caeb46f33103df6bc53348a2b72cfe5a8a7e77357bb0" + }, + { + "path": "crates/canopy-server/src/packs/publication/serving/session/body.rs", + "sha256": "2eec6c39ca50cfb7827b20a003cb45c98c1e81cf99c19c1d8f729e5b810748e6" + }, + { + "path": "crates/canopy-server/src/packs/publication/serving/session/edges.rs", + "sha256": "5cf07e6fa1c02b829295195d7e9c76f39c006d1d6aae0e877594c37621c7bb10" + }, + { + "path": "crates/canopy-server/src/packs/publication/serving/session/handoff.rs", + "sha256": "7635151d254fb4fa5b344630cda3604a57eb409967bd2f372cd03f0ccae7ed0c" + }, + { + "path": "crates/canopy-server/src/packs/publication/serving/session/membership.rs", + "sha256": "6ea3c5d3344f0f7fdf8d4feeded9ac879469d16a8f548fbcc1c3879af640e821" + }, + { + "path": "crates/canopy-server/src/packs/publication/serving/session/native_base.rs", + "sha256": "b5c02892acec7bfac119cb027e3fb7ad518978e46d551b83d2b5e6bfe380f2a9" + }, + { + "path": "crates/canopy-server/src/packs/publication/serving/session/reads.rs", + "sha256": "34e7d1bd70fb99da1699abc4d4fc286de8e70be3b5f1ee4d07100c1c0d4ec179" + }, + { + "path": "crates/canopy-server/src/packs/publication/serving/session/ref_observation.rs", + "sha256": "e25d0e5b52db5f9a3d9fe6c603f9bf00f631d90b41b7d6e64c50a2961af34094" + }, + { + "path": "crates/canopy-server/src/packs/publication/serving/session/refs.rs", + "sha256": "4a3323df513ee8043de070435623d492afc10dab45851c26ba87185a5398b029" + }, + { + "path": "crates/canopy-server/src/packs/publication/serving/session/workspace.rs", + "sha256": "9e2529f7fad4ca6227d5f6416669cd254a23e60e5b5b8418ae9f648cae4ec7ab" + }, + { + "path": "crates/canopy-server/src/packs/publication/session.rs", + "sha256": "8e78d9b77cd9ea3fb83bdd9413df237cbecd73c71bde8d66c64dd9393f934d5a" + }, + { + "path": "crates/canopy-server/src/packs/publication/sql.rs", + "sha256": "550b74680fd9321bbc87eead9fd7a97fd1aac5f871bfe20daa0fd76c5bd76691" + }, + { + "path": "crates/canopy-server/src/packs/publication/staging.rs", + "sha256": "4a1adee4036816c35eaf3c47aeef87ad1fa5219d7fcf2925e486c55603742896" + }, + { + "path": "crates/canopy-server/src/packs/publication/staging_receipt.rs", + "sha256": "e81841b2d6279887475284cdc05878ee57b85cc9c98fbb3a77dbf4693a900d79" + }, + { + "path": "crates/canopy-server/src/packs/publication/staging_service.rs", + "sha256": "31784da153daa126c6b6b1bae1d7ec63ec05079dc3d9781c46cf19ee03e2cfb2" + }, + { + "path": "crates/canopy-server/src/packs/publication/staging_service/bound.rs", + "sha256": "34252e371e893ece236ee467ce4beda4fe67b66fe41280f1949dd41876cfdee4" + }, + { + "path": "crates/canopy-server/src/packs/publication/staging_service/driver.rs", + "sha256": "689cf2dda06d75c4dbf83bd52f7ad3fe08b8026e27953d3ff8fc461cb589845b" + }, + { + "path": "crates/canopy-server/src/packs/publication/staging_service/publication.rs", + "sha256": "f27b93ffc26d947c911a5878ba91daca836e0afe451b1fbeeb1182d3f1b18051" + }, + { + "path": "crates/canopy-server/src/packs/publication/staging_service/restore.rs", + "sha256": "64557e0c96871042cbb189fbc7f6d8d10fdbd25f0926b05cb706dc512cf1c28b" + }, + { + "path": "crates/canopy-server/src/packs/publication/staging_service/retirement.rs", + "sha256": "9bcf81192841b4e18af815057d1ff78f504fc33e91a29fc0eabb5eb2c3094e09" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests.rs", + "sha256": "c9bcbb356e83cb32e3c2b44aa3af038b45daf0b5f79c868bf4b2c9907fc212f2" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/attestation.rs", + "sha256": "5abaefe62a42ccacd1e7eb8a5fba5878b89c07ed5e829cf69f0b189656a63e04" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/candidate_publication.rs", + "sha256": "497f149e734c9aeae3321c1452d1775e701188abcbd9839abfb58aa8ea1fed02" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/compaction.rs", + "sha256": "af38c1ca3b9d1e2a4e9aca806a14cbd247149ced9a95e185ffdf0ceeabb00d41" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/compaction/coordinator.rs", + "sha256": "7f0991a6f5402a23f6c15036a3c0f1247c33eff3718de4cb49bd98314aee071f" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/compaction/range.rs", + "sha256": "53d350883acda875ea5f627d5e0c87d375e52b3c98cea5d33df05cf570ad1226" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/compaction/recovery.rs", + "sha256": "00bd4d3fd1e10c86bd42853e22da77cc714538931d34eb822d69bcf5ef7ed004" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/compaction/schedule.rs", + "sha256": "e0b325ed57ed59ee0160d65ad5d6a3f624dd2557f1316f7e252fb0c9dc29444c" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/completion.rs", + "sha256": "6bdfa78c607b8bbe917c72c4eec4039500cbcb739a996581588adfbe00c9043c" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/completion/outcome.rs", + "sha256": "c791f27aa1ca8998a0a0edefb9392f678a363bb836fb4b5288e4ffbea1131e13" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/coordinator.rs", + "sha256": "dcde91ab16f212fec306990d544e699a0987077122faba5c59192c47f0e1db8f" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/coordinator/budget.rs", + "sha256": "f8d9107a7bbd0c42c700b27055ef0362dfc9b642172f388d7670db63dcaa0c8a" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/coordinator/held.rs", + "sha256": "8d5361446c88fc189d4c9b1f97348cdb1d66e364b298482b30798ac0ac8ce2bc" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/coordinator/preparation.rs", + "sha256": "d11e296ffd59d61cba3b7aa7f912e4d8f90a7eb70e67f85d828ea50749499641" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/custody.rs", + "sha256": "b92d931dc3af0be158d3ae7fd6fc98fb9efde1b726f2cbeb8b9d9ead887c56a9" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/custody_stop.rs", + "sha256": "d0a9f91817b2bf3304e5788ee3ff619097306819d01a462263426751e081c91f" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/durable_policy.rs", + "sha256": "278120a30e9922f493b5766d00cde4c60f6ac81ac23b545fe709e44f8a9badc8" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/durable_recovery.rs", + "sha256": "6a1c4aacb7c04c0c6c999a08174eef468455dec5d18375a0f1eef8f91051ff22" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/frontier.rs", + "sha256": "bde9906e106a600781e43114a3ed2a52f2cc9596eddaceb40f4a8d2421d7cb25" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/initialization.rs", + "sha256": "a8990c949e117c398c5f48bff66b7cfa1a595dac94c4a3df45284a499b8a2df6" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/initialization_recovery.rs", + "sha256": "6282169f15f22a5d329c3cac507b7b98f7aeb20244e7da7071fcbe09a0cda091" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/initialization_retirement.rs", + "sha256": "419df2e898cc04826597a660bc101eb1f88f0d60994beae23ee3b37584b1e5c3" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/inputs.rs", + "sha256": "4d525d99be4815e8bc194157d0d6005b52ebe3f46d34e974ce53ecaba178a669" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/inputs/bound.rs", + "sha256": "06aedc222fba530bd443299648ab7d6da44d73101698482f9ed3460d22abd8a2" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/inputs/custody.rs", + "sha256": "b0240837a59f6713f1db428ac82eeece47fa906428244aa4e5acd67b246b5389" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/inputs/requests.rs", + "sha256": "30676fde880087809efe5e96dc71afcabc356d08b359b4169562d3da0d0d6250" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/inputs/requests/results.rs", + "sha256": "9499fce0dbf7836c170d4265e0e1fe24cf6e09d8663791481b0f1cbb4f9d9bc6" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/inputs/requests/results/signed.rs", + "sha256": "89ce2f7bd6343df03def6a2f9991b4f9a017e007572b7df3af03a452d559b428" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/mandatory_registration.rs", + "sha256": "1134588791dcf2b89781b532c1b13aa362e28416696d1bf7776eebe0cf29e2f6" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/namespaces.rs", + "sha256": "305ea1ebbc813441780e37e097b4bc4bc2f602225379200e9e87ec6f6c455cad" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/native_candidate.rs", + "sha256": "6cf33f32770d23ee2a7bf4e571bf0795b42b62f5f5b8e42d7927bfbae2ce8fc9" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/native_capture.rs", + "sha256": "794b63e6bcb11b6447b21caff491007f2e33f0a81c5045c1fab89d163d784364" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/native_head.rs", + "sha256": "f19cfe5e9bc32ecebdb7eb0951ea0acfa126a1e7b3d0d22fc89d17c0a11044f2" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/native_merge.rs", + "sha256": "b99c0b6da67a33107f191674a22fcc7885481ccc3cb32240d5499514f5ac3284" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/native_merge/retirement.rs", + "sha256": "71e2b969aa7c7b3778290961bd39d852a8d4d61d962d127b4c2e29561a51bbac" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/policy_dispatch.rs", + "sha256": "0ac49afd97502d167d965aa5da2fe569b778d4391d695cbf018470a457cae869" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/policy_refusal.rs", + "sha256": "4bc84b7fe7c3d801bebb8f11d763c7d14b0960fd0b458def55b900367b1b7238" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/preparation_receipt.rs", + "sha256": "a9173028cbb75a77792b6b81f94fd94c59d3f553346c44a6b4673253a8ef6bd0" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/prepare.rs", + "sha256": "07e92a7180f2e91eadcad16bec6706d2f2dc52cc83887be60eacaeaa705eeb61" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/publishing.rs", + "sha256": "e6381a6b08b687f8c551984df12673c98d9576525ebc3df4a23489abb5a66183" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/reconcile.rs", + "sha256": "5a98610905fad39777c6d1f28259e257b7c65836cf77284685f588d65c135446" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/recovery_discovery.rs", + "sha256": "84ba4e59589ddf48f01f235e88e1675d059c05021def9e6801e37c7df34338e4" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/ref_policy.rs", + "sha256": "4c1b1c43b5c53ff80c69175ca011de7f70fd4d49413cc97ec2e4676441c2da74" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/ref_policy/cleanup.rs", + "sha256": "ad85a9468d6a2b32627a0697b6080701d3cefc7e43491623815ea7ce991ca728" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/ref_policy/fixture.rs", + "sha256": "36124def6b4919bc35102997364566645d4a05aaf3b4ecb261d852ae6139005b" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/ref_policy/freshness.rs", + "sha256": "0fc05e45d2cda9455ce5e4e8991c65d9eb04bfddeb9839a46efca84ba250a50a" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/ref_policy/pages.rs", + "sha256": "65a2f8abd9801777c2e5064b2d8c852f73919d2cf55f44062b0a8825090643e1" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/ref_snapshot.rs", + "sha256": "30dc48306354c340a827b3d77547e0a98d2b837c27d07ab79dab7e14de35218d" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/refs.rs", + "sha256": "6aa86153f3d5e9ffeb7a555c263eafd34679e4c2db738d2ea591e9b220afa5cf" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/root_completion.rs", + "sha256": "d08626c6a4d43438c2ea3ebe8a98f8913727d6488259e8abcbdb328fef654ba8" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/root_dispatch.rs", + "sha256": "2d76a771dcd441024465d3b5a9bb96a79d84b7863ef4c5fb5fe4f9c1b7fc20de" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/root_outcome.rs", + "sha256": "f332bb90e407c09b130c068216dfb734fdedee79b06f7a9b646c0afc8d04c23c" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/serving.rs", + "sha256": "7c994b993f92417c58516e9c4332b35cc6b9cc98450694a89d73c52850e3bc42" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/serving/blocked.rs", + "sha256": "729ee569bbc1b11343946033a8aaf0dd326f2ca54c8986df17c9c6191d3c7e24" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/serving/body.rs", + "sha256": "08a21be58e1b7cd14829422c8fec2e9f6b006592d0f849aeadb7de51646df160" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/serving/custody.rs", + "sha256": "f3539f3a063fa141edbb9533d3c5b3582f2c2d0821ed880b44d123b85c18f139" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/serving/edges.rs", + "sha256": "df0141d94987d6f2c3a8f88d11e58b7409874e0140ffc006d1e867da3213f46e" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/serving/lifecycle.rs", + "sha256": "c502b7ee59f0fd2eb6aaf950ae896d77a44f2addd9f17b048c0accf82aab3395" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/serving/lifecycle/restarts.rs", + "sha256": "3b6432badcfebbfb5d9f24b67962f97719475dd301b56e252a9114650e35f036" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/serving/pool.rs", + "sha256": "32ca15fe71a43474599da7dd59acef17450c332cfdb71df9595c8f5724956c86" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/serving/refs.rs", + "sha256": "d20b42f78a26b3be3c08547a4652f8ed14acc5b31d32b8f8fb8018dbc8de55b5" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/serving/selection_drain.rs", + "sha256": "eb1fc5049aacf6eaa49f33f4791ddd4be6fea1bc31a51b4213c5c78541d3eea2" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/serving/workspace.rs", + "sha256": "0e92817ac8df5a48152352396ae63912def7439ab0a919fcd5fdbaf7a4fdf21c" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/staged_durable.rs", + "sha256": "af865b638c57e18136d01554aea81378418b36f9334d2a7cdd01a87ab37d0db3" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/staging.rs", + "sha256": "7ae5a651ee0b8208a4cfa540713b9bb8e5e505479900112819403ba269314fcd" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/staging_receipt.rs", + "sha256": "0068520761bf0de573cb6c440cdbfe68748c6e5d067c09c9ba553e44b8bfe765" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/staging_service.rs", + "sha256": "f6f6e39a4457f4d6f658b0cef37687e4defa7f965848cdbe019e446eeb36df1c" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/staging_service/bound.rs", + "sha256": "e96afc48ef6db71e2ebcf23b3c3ab8c9cc646d7c97383c1ede80db0fdfcd7997" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/staging_service/budget.rs", + "sha256": "9744dc0ec00111b8a7838914a894357893e2f84e697c3baaa9435ea377618ec8" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/staging_service/physical.rs", + "sha256": "d8799cef5f5c92ac0798a517fb60d15d910dc4f4ed84bd8cd43280063e65d9ab" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/staging_service/publication.rs", + "sha256": "3cbbeae4a2dd4e6515c114996028429ca7966e70fcdd1b405cb4638dea48faff" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/staging_service/restore.rs", + "sha256": "01194fe2d728d5768215304f2ff971526bfebb5eb286bb60cf24fbf32b5afe85" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/staging_service/retirement.rs", + "sha256": "60fc3ddd18458730d8867655492bafc0689f395eb0c15628fa882a15f2a08a00" + }, + { + "path": "crates/canopy-server/src/packs/publication/tests/terminal_retention.rs", + "sha256": "d9ecbeb70ea470e50508e27b158a62c5fa904c8743a21d6b8a84a9ec0ef6aa4d" + }, + { + "path": "crates/canopy-server/src/packs/ref_state/mod.rs", + "sha256": "b19c1b77637ceb49c268878165ee9f782f118d23374daefa415d2e45d9705c94" + }, + { + "path": "crates/canopy-server/src/packs/ref_state/record.rs", + "sha256": "ad4df7d06930b0e0455763532465fcf6a63e0eb96efdbea0ebc373115c90c348" + }, + { + "path": "crates/canopy-server/src/packs/ref_state/snapshot.rs", + "sha256": "3a8c117feeb5bab67bba0183e299cdf61c9794b8d154dbae50f6d44c8254e710" + }, + { + "path": "crates/canopy-server/src/packs/ref_state/tests.rs", + "sha256": "94cd171a6f9d82d00239d2cb5c3c814553d93bd2bb12b34a9c990630903b3387" + }, + { + "path": "crates/canopy-server/src/packs/ref_state/transition.rs", + "sha256": "dd2e05a762943bb77c92b636e0a3026058cdd286c4dae08407d6ecdc72ec1c30" + }, + { + "path": "crates/canopy-server/src/packs/sources/codec.rs", + "sha256": "9c4cc37f78743adbd89a70d8cde27139e1447524f44a56b7e746e40b95b69fd4" + }, + { + "path": "crates/canopy-server/src/packs/sources/inputs.rs", + "sha256": "24bc235354aec1cdf9828ed44214e9378cecddbb5e6f9060424da89849464a5d" + }, + { + "path": "crates/canopy-server/src/packs/sources/mod.rs", + "sha256": "616a99f5e0a989ef35cf0cc4305756171026727904b15a71e69a2b17b919eca9" + }, + { + "path": "crates/canopy-server/src/packs/sources/native.rs", + "sha256": "006362684fffa7ce2560374bc360b73b361f01866da510db92f8284ce96d1387" + }, + { + "path": "crates/canopy-server/src/packs/sources/resolve.rs", + "sha256": "987d0875307a4df8cb8936b5372a91ce820daa7ed31303ac093e2921c5fc81cb" + }, + { + "path": "crates/canopy-server/src/packs/sources/tests.rs", + "sha256": "88bc9ce33a50900498d67303280f90ce8325c91e51e7e56a91c72564f7ecbcd1" + }, + { + "path": "crates/canopy-server/src/packs/sources/tests/changes.rs", + "sha256": "ab60c729a4b33b6031fcd84e7ef0ddec30bf0c62aacad7d300fdde77751b4692" + }, + { + "path": "crates/canopy-server/src/packs/sources/verification.rs", + "sha256": "7899af543f21c0e2fda126883bd4b8d69a70e4116ea819ae5de4d1efe1388b64" + }, + { + "path": "crates/canopy-server/src/packs/verification/mod.rs", + "sha256": "901e0f3d216bd443009361636eac0ccf43f0b3350c84557c9a06178025d8e275" + }, + { + "path": "crates/canopy-server/src/packs/verification/physical.rs", + "sha256": "6ea01d9253170daf3ee9b6cc575214417a4d480f8f4df51f28a304b3391be56f" + }, + { + "path": "crates/canopy-server/src/packs/verification/physical/partition.rs", + "sha256": "99314756f75cf01e50742b31a704c4b6020818ad2b90175b572345d50b8ffe22" + }, + { + "path": "crates/canopy-server/src/packs/verification/physical/staged.rs", + "sha256": "e778841ec760f8651c6c58f4a86b305da3848f4a29dd9efca95afe953be37419" + }, + { + "path": "crates/canopy-server/src/packs/verification/physical/staged/tests.rs", + "sha256": "f95bda28402334586884d227c20ac55f4b379e4811c3691fafd95d04b4dba112" + }, + { + "path": "crates/canopy-server/src/packs/verification/physical/tests.rs", + "sha256": "59d191aebbf36d1178252d3b94145283c4b0d9b5305f5bb34b0ce3cc13976d73" + }, + { + "path": "crates/canopy-server/src/packs/verification/physical/tests/independence.rs", + "sha256": "4d7012c1b5c783fa199ffc03ce7956d2b6eb083ca6b290ff97faac16b6c08618" + }, + { + "path": "crates/canopy-server/src/packs/verification/spool.rs", + "sha256": "ef78b2ff1f57ee40f7fab5863c0113f730fb6516d3aeb3a48c5d0411fb8d5524" + }, + { + "path": "crates/canopy-server/src/packs/verification/spool/tests.rs", + "sha256": "bd68135bbdc74dab25e074a9e23c71424d9da0f306dad979853a268ae9bfe4db" + }, + { + "path": "crates/canopy-server/src/packs/verification/tests.rs", + "sha256": "887f09d2f9d5fddacbca1e6d74aac2e893768d45f2dad4375d008d9d0fe37fc4" + }, + { + "path": "crates/canopy-server/src/packs/wire_request.rs", + "sha256": "d37771c383d9ae171cdfb469e3c7c63aff597fde2f698ae0c5ceadba1122d172" + }, + { + "path": "crates/canopy-server/src/pulls/candidates/command.rs", + "sha256": "ecb8a985eb7433743d8094bdc7978554515ca52f4c234e9348fa2139cfc3642c" + }, + { + "path": "crates/canopy-server/src/pulls/candidates/mod.rs", + "sha256": "05a40bd4b4e473fcd10b67361bbb95567545ea63832f99ec2ad990407e5e92bc" + }, + { + "path": "crates/canopy-server/src/pulls/candidates/rebase.rs", + "sha256": "e01080d2dd6c06f311329cc91aa34d369eda959799b7ce92119ea0b89057bbaf" + }, + { + "path": "crates/canopy-server/src/pulls/merge/command.rs", + "sha256": "5f640f3627222189281842605e3edd1f1287cc8a5426c787c184f5dacd0c0b36" + }, + { + "path": "crates/canopy-server/src/pulls/merge/mod.rs", + "sha256": "cb3128d2bdcd06fd248f114db6038fd5890e85c07a2b1818956dde941a722889" + }, + { + "path": "crates/canopy-server/src/pulls/mod.rs", + "sha256": "c361d2550b0dbc35b83f4ba334469bbbb2811f43e06e32a7ab9954f7e12e1122" + }, + { + "path": "crates/canopy-server/src/pulls/mutations.rs", + "sha256": "e2842cbeb241ef6a4d1616cc7d2f36e915b0dad7605d0a7988be34a2672a36b6" + }, + { + "path": "crates/canopy-server/src/pulls/native/client.rs", + "sha256": "f726dee079725e0df74211d268e7cfffdfb152852121910703d7a46133af05fe" + }, + { + "path": "crates/canopy-server/src/pulls/native/codec.rs", + "sha256": "38b3462d08077b15d2090547b8982668349e3fac50a8c1ded893b96dad693154" + }, + { + "path": "crates/canopy-server/src/pulls/native/mod.rs", + "sha256": "ebe3cc02ffee4de5cfb25ed7fb36efa2533cd7c982e193c33b126b9d91044fb8" + }, + { + "path": "crates/canopy-server/src/pulls/native/reads.rs", + "sha256": "71ce40bdb9d2b18edf87d9bdeed086ec6e70f5c9d3ced4d0b02e64041c29b427" + }, + { + "path": "crates/canopy-server/src/pulls/native/threads.rs", + "sha256": "30e9baf91149ad28ac9e8d5eac6cc79fa2b6c160c8f73399c3547f393ad5f5a1" + }, + { + "path": "crates/canopy-server/src/pulls/threads.rs", + "sha256": "b4a59e2d1d77f82fead62b540de60612466fe45eb48520e41f0d8d6b791b5793" + }, + { + "path": "crates/canopy-server/src/push/certificate.rs", + "sha256": "01c0d3634fcec05d7832d23a7b42256b3a9425417ccdedb8ac7d1384122e47a8" + }, + { + "path": "crates/canopy-server/src/push/mod.rs", + "sha256": "bb126cd9c0d78c4d5147b5f22aa0a1df4b93af05ecabbe3906910a9670b7fff9" + }, + { + "path": "crates/canopy-server/src/push/report.rs", + "sha256": "0a26f9407710126e38321b91bfe8eda808d9f30b7d8cd20cbb8af10d30f591d1" + }, + { + "path": "crates/canopy-server/src/refs.rs", + "sha256": "d0307212fad83f43f4fc00156f9382ef9f6ea915227e4e50b892a39d82b71537" + }, + { + "path": "crates/canopy-server/src/repository_http/accounts.rs", + "sha256": "87af39ed1c7ec1b0d0e77153848d52d62f7637542838df2db7e5e2c7d10aa2e6" + }, + { + "path": "crates/canopy-server/src/repository_http/authorization.rs", + "sha256": "6e1c0c6878336a7026641c5782ae4dcf7a0433542a414c5157a4be8111e3d407" + }, + { + "path": "crates/canopy-server/src/repository_http/branch_rules.rs", + "sha256": "66a591d88d779d100e9a3862e96372df1b6132b3643f3d118bc784059f26c829" + }, + { + "path": "crates/canopy-server/src/repository_http/browse.rs", + "sha256": "fa411fc2f31bc2b8efe1015be970d36dc1a78c8fc7928a5e1f6ef6e74a82632e" + }, + { + "path": "crates/canopy-server/src/repository_http/candidates.rs", + "sha256": "f8731c09c6554e6ff7cfc0c31d1fadab0561a07ed4085af4bc10bb842345d02e" + }, + { + "path": "crates/canopy-server/src/repository_http/checks.rs", + "sha256": "d606ca47ef429f9fc15e0b47c2a35e38970f7c5a43636cb1450db45fd91034cb" + }, + { + "path": "crates/canopy-server/src/repository_http/collaborators.rs", + "sha256": "f1a40c9211d6698a4df699a576d21bd6c0d8b7e7ee938534946022709e20ccba" + }, + { + "path": "crates/canopy-server/src/repository_http/comparison.rs", + "sha256": "ee16dc2d6012e985206d18228e0826fe7a078ebfadc143ada3d9fe087e1cf2c2" + }, + { + "path": "crates/canopy-server/src/repository_http/default_branch.rs", + "sha256": "2278ae157b16975f0180377cd1f3a727a57af165d1e0b8dcaa99887f97628c7d" + }, + { + "path": "crates/canopy-server/src/repository_http/issues.rs", + "sha256": "267b9620d4acc43740aea18282e7e9c387968fbf1f673a2b1d04c3f853527b10" + }, + { + "path": "crates/canopy-server/src/repository_http/merge.rs", + "sha256": "2a23852beb3ee4af078006a73a10c3671b583a92c1c267bd2c91a9aa8f5205ee" + }, + { + "path": "crates/canopy-server/src/repository_http/mod.rs", + "sha256": "1a6bffd1203ef780c80513cdfdd76d617e980d0ee7c3ac8e1d1a096437084e62" + }, + { + "path": "crates/canopy-server/src/repository_http/pulls.rs", + "sha256": "391974ba50b813e6043b61fa2d289986406aa29f1bd52609793c77f6512ee52f" + }, + { + "path": "crates/canopy-server/src/repository_http/pushes.rs", + "sha256": "da2efe9bf8141aa897227c9132d4e4400ddac73822dac62775e0f88339c509cc" + }, + { + "path": "crates/canopy-server/src/repository_http/ssh_keys.rs", + "sha256": "44f1df9e9f46cef9aa832f1d7208a2afb6938cd5f90d3b7cb483af2b25f9695e" + }, + { + "path": "crates/canopy-server/src/repository_http/threads.rs", + "sha256": "8c48158c2bb545b6fb10b684b8fcf1bf514d0fd759e7b99fe797882cf2dc76a4" + }, + { + "path": "crates/canopy-server/src/repository_http/tokens.rs", + "sha256": "5a7b5a3307e035b5bc820a566f8b54bca68fa02e9376315023db84ec313c9561" + }, + { + "path": "crates/canopy-server/src/repository_http/transport.rs", + "sha256": "b4d5398919d1456287d6905903312b404c64a0d4688a693335414ffb03b71366" + }, + { + "path": "crates/canopy-server/src/repository_http/visibility.rs", + "sha256": "78c70641e85aa869f16d7efa6c4fc45a57e0d84fe5797b878871084dcda94f07" + }, + { + "path": "crates/canopy-server/src/server/catalog_admission.rs", + "sha256": "d0ccfd3a6663c0d7be1de113a287d76530007e9ec0b7f22d1a1e2761347b1f08" + }, + { + "path": "crates/canopy-server/src/server/catalog_admission/tests.rs", + "sha256": "f8c55dd0da6f2fe85a7303843687dfa97476927e1cff426efa2c28d0570b6f39" + }, + { + "path": "crates/canopy-server/src/server/catalog_initialization.rs", + "sha256": "b9b2ed27d87e437dcfc81ef68c7f5bc9aaecf831f9ef947fd58f90ec68cfe86b" + }, + { + "path": "crates/canopy-server/src/server/catalog_initialization/tests.rs", + "sha256": "19b48f3e57e7a0cab2e1a70604e2590918667a930787e736f82fa38b0418d5a5" + }, + { + "path": "crates/canopy-server/src/server/discovery.rs", + "sha256": "76a68f28b052d7e76e338f50cea877a07bec9ac60fcc4ff78b7d8e7a8007c320" + }, + { + "path": "crates/canopy-server/src/server/lifecycle.rs", + "sha256": "0c8a908e772c2747c732463cb0a942557569108df1217a380e33b721b056cbe5" + }, + { + "path": "crates/canopy-server/src/server/lifecycle/tests.rs", + "sha256": "adde3500c84d4e17fb5bd98e8a59618c3cbfa8287c7210c8ec919cb689a52489" + }, + { + "path": "crates/canopy-server/src/server/listeners.rs", + "sha256": "01abcaef27d49a009ed4df5e1a2843637da8ee057725123f92409abb7c82f534" + }, + { + "path": "crates/canopy-server/src/server/mod.rs", + "sha256": "f6cc7a3a30e8572d84f75f1255a2f5705240d8e9c22ad552eb79a76070c16fce" + }, + { + "path": "crates/canopy-server/src/server/peer.rs", + "sha256": "d3388a448809680a383310d5f26687049b02a2b65d2c319e50bb8590672a2175" + }, + { + "path": "crates/canopy-server/src/server/request_trace.rs", + "sha256": "126cb6f7ddc313b2e1e3eab7b07bc7190730b6c18aaa5f57b105ee7e3f7c2bda" + }, + { + "path": "crates/canopy-server/src/server/residency/mod.rs", + "sha256": "11474f670ce1b12fab28faffe36ced6438c119fe2035fe449f6a8bf2886569f2" + }, + { + "path": "crates/canopy-server/src/server/residency/recovery.rs", + "sha256": "9cd845589293808869831ef68429a52a5230be182abc9f70f1cb071788740853" + }, + { + "path": "crates/canopy-server/src/server/residency/tests.rs", + "sha256": "8f6ad39a1aafa28e1f2f674252472e3ee0b9da4604b25cdcfb708d65458ef570" + }, + { + "path": "crates/canopy-server/src/server/residency/tests/recovery.rs", + "sha256": "23f781b71762ff0d648bfb3a783af5a9b96b89718ce008380a54e555d9f7cbf9" + }, + { + "path": "crates/canopy-server/src/server/residency/tests/serving.rs", + "sha256": "bf7bc70914aba49e5372c230f69419250c23d8d3a153b0edb384fb05199ef78a" + }, + { + "path": "crates/canopy-server/src/server/residency/tests/serving/browser.rs", + "sha256": "52d5e68fa321f951050c8224beec774291d2a0530f71d664f0da9b5b619fc286" + }, + { + "path": "crates/canopy-server/src/server/residency/tests/serving/browser/checks.rs", + "sha256": "755bcd70e0f5f6af5b58e45566073f44e6a4a41fea361a2def5997492c6428d5" + }, + { + "path": "crates/canopy-server/src/server/residency/tests/serving/browser/pulls.rs", + "sha256": "8288d13567d908720815ae46e4e60f085ef003ef100ee6c28543464d178d3232" + }, + { + "path": "crates/canopy-server/src/server/residency/tests/serving/browser/pulls/candidates.rs", + "sha256": "1cd851040c126a904cc7d1571c30f8f7a7979d2a84ba575bf94f752e321baf5d" + }, + { + "path": "crates/canopy-server/src/server/residency/tests/staging.rs", + "sha256": "cf61fbfd579744eafdb4f64533cd047b8f7081db5317d59b1bb4d038bea64a07" + }, + { + "path": "crates/canopy-server/src/server/storage.rs", + "sha256": "780a50bcc9676b6a4a85bf2d97213da182e7ab940e0de89b83b06456b0e46100" + }, + { + "path": "crates/canopy-server/src/server/tokens.rs", + "sha256": "2d853e04134d6354dc322a1d1e8293c933d9185d2603f9534c90c9d601d7656a" + }, + { + "path": "crates/canopy-server/src/server/workspace/mod.rs", + "sha256": "2ce836de9402b3518a95320d1099f71cefea4febecb5d952382173d180f5adda" + }, + { + "path": "crates/canopy-server/src/server/workspace/tests.rs", + "sha256": "a256a4e91a9262e32b950d3b0cd4fda885a032fe8b0d17095aacc69a04fc212d" + }, + { + "path": "crates/canopy-server/src/ssh.rs", + "sha256": "2381428f1cdfed2d384c4a80910e89a7032ecacc086d3a8b2d14754774692aff" + }, + { + "path": "crates/canopy-server/src/transfer.rs", + "sha256": "059140411b105ef66db14412f959d2411c47674d2b3ce404049e6820fbade526" + }, + { + "path": "crates/canopy-server/src/visibility.rs", + "sha256": "33da635eb46483ed01f54363a3e3f1edcd16c401e1069ac672d13a79467aaec5" + }, + { + "path": "crates/canopy-server/src/web.rs", + "sha256": "4612e8505c58bedf37a76f87d249163c028625e429b65968870a6105c2773ec7" + }, + { + "path": "crates/canopy-server/tests/directory_cell/accounts.rs", + "sha256": "b58dc29e8a643258b1420389e1ccaccc8371ac48276db904c7ec148691ba30d9" + }, + { + "path": "crates/canopy-server/tests/directory_cell/capacity.rs", + "sha256": "7e84df7b6ac84dff5a10dbdc30f67e629df59a557b9e38e7445faacc0f98b081" + }, + { + "path": "crates/canopy-server/tests/directory_cell/compatibility.rs", + "sha256": "ec6ce5fa0b65f96a52613077dd660e06b93ac703f7dfd7f0bc9aa822e698fcce" + }, + { + "path": "crates/canopy-server/tests/directory_cell/expiry.rs", + "sha256": "fb0df4bfbfd808af0fcf4afb5a99196c156779a592594bb3e5802580b5384404" + }, + { + "path": "crates/canopy-server/tests/directory_cell/main.rs", + "sha256": "fdf62f903fdebd0588f0076607b225e7e53ed5bc1bbde569ec9b23fa3b6bff02" + }, + { + "path": "crates/canopy-server/tests/directory_cell/ssh_keys.rs", + "sha256": "80b24299e5d0f8e3841c194c87b721cbab90262af0b0714d85764c65197be274" + }, + { + "path": "crates/canopy-server/tests/git_http.rs", + "sha256": "0aac63148f766387ead058dd3e786b90db3639e83b6ae27dd39623fd7e70a2c4" + }, + { + "path": "crates/canopy-server/tests/multi_server/account_admin.rs", + "sha256": "1077a611e4c0f1daa90f3941ca1c972696a93f6cedba1a8b89a299a0d9792560" + }, + { + "path": "crates/canopy-server/tests/multi_server/account_audit.rs", + "sha256": "421c8cac840561db25fd8759e980fd76631d363999c497a7823937ae61d8d7be" + }, + { + "path": "crates/canopy-server/tests/multi_server/accounts.rs", + "sha256": "042fb25082907112c9bc0a4d83463f02c97e6b365b5f07c8a2a1605a093469a8" + }, + { + "path": "crates/canopy-server/tests/multi_server/backup.rs", + "sha256": "cbc3b6a87516f8484860953655b975bb395f789d313f546fb0662bfa7bbabd1e" + }, + { + "path": "crates/canopy-server/tests/multi_server/branch_rules.rs", + "sha256": "4d02419340248dc585a2dad0b9250e590b89636f89a9c5cd801b36b30f3c98e2" + }, + { + "path": "crates/canopy-server/tests/multi_server/browse.rs", + "sha256": "3d8294dc3a12ebf661ab87bf2615e9da9a2f3c6c80c180ed7c89e8bf777f6f76" + }, + { + "path": "crates/canopy-server/tests/multi_server/bulk_refs.rs", + "sha256": "d50f7c66ff69b2ac8e11c5e771219776256c22a2d598ca15bf3bd11f74c20f9e" + }, + { + "path": "crates/canopy-server/tests/multi_server/candidates.rs", + "sha256": "b2a385f2dc132f8f1d47eda3cac1e247a63e601ad72f7f2554ca39d40c789c9c" + }, + { + "path": "crates/canopy-server/tests/multi_server/checks.rs", + "sha256": "3ea687fd2b6d93b9989ad243703bf3ce402eafaa62fe85a9c7229880fdc928f2" + }, + { + "path": "crates/canopy-server/tests/multi_server/collaborators.rs", + "sha256": "2e4f16d446de002db4bef4aee887947ec5e9e22f7517dca42e71df50b09ed4e0" + }, + { + "path": "crates/canopy-server/tests/multi_server/comparison/history.rs", + "sha256": "5db6d2c8b6b4dd2e4d63486854895e993c89e075778663cb60cf557654a510d6" + }, + { + "path": "crates/canopy-server/tests/multi_server/comparison/mod.rs", + "sha256": "bee76ba171fd7f5a1b0e093fad8862552648cbffc74e6d8967761f07bee09793" + }, + { + "path": "crates/canopy-server/tests/multi_server/comparison/patches.rs", + "sha256": "9943cd8b2cd26c6400856416b1f9a8db0b91a8e9a90a8a426a64656e24725354" + }, + { + "path": "crates/canopy-server/tests/multi_server/comparison/threads.rs", + "sha256": "348a76fdc0eaa7427fd1ed0f7f450952fb7219cb5b8786cac0c285bd43c81a42" + }, + { + "path": "crates/canopy-server/tests/multi_server/compatibility.rs", + "sha256": "c2aa24acc5361aa3d4656078fb6bc4e05598416fae39643a0feddc7c722dc9b7" + }, + { + "path": "crates/canopy-server/tests/multi_server/default_branch.rs", + "sha256": "8c089f74cf9aec71f179b986f63ffd87c893d7cc42958fd6919da73cb6c4bc10" + }, + { + "path": "crates/canopy-server/tests/multi_server/deployment.rs", + "sha256": "79fde24ff8809a3ad509f583a926c722dcdb196a1f4269dcf571db2546a0a7ef" + }, + { + "path": "crates/canopy-server/tests/multi_server/discovery.rs", + "sha256": "791936ceaf3475772111002c421ffa82b340a68e44b9d8a2fbc2f2e8f156dae5" + }, + { + "path": "crates/canopy-server/tests/multi_server/issues.rs", + "sha256": "4ab2fc8ee471296bb4e5e226f2641aaf475f182b3847accbeba58877a2366850" + }, + { + "path": "crates/canopy-server/tests/multi_server/large_objects.rs", + "sha256": "7aaae9075a61365b01d31a337f5f87b6748c0a805d44cc0af33cf18d7c302743" + }, + { + "path": "crates/canopy-server/tests/multi_server/lfs_locks.rs", + "sha256": "862bb8a6b1b9e0b0fc29017b4d7f47c488458e81e7fa4fbdc8abdb39f3deaa07" + }, + { + "path": "crates/canopy-server/tests/multi_server/lifecycle/fork.rs", + "sha256": "3d64e4155771d4b9e72a8f93ac5a750e651036737048a254d9bc8159c9901992" + }, + { + "path": "crates/canopy-server/tests/multi_server/lifecycle/lease.rs", + "sha256": "ee825b88b88f9231321255ff2afede6f13cbf2ae1e9ea503a48f0c0a0e82a768" + }, + { + "path": "crates/canopy-server/tests/multi_server/lifecycle/mod.rs", + "sha256": "63d54dc7f5ecbf48c102d0dd83519f0dbb857b2cae35cce5fb50652795e77cd3" + }, + { + "path": "crates/canopy-server/tests/multi_server/listener_handoff.rs", + "sha256": "bc0a56596a0f1910b94e04639d6aa7a23589028bf5b05cb12dc443b5248c21ec" + }, + { + "path": "crates/canopy-server/tests/multi_server/main.rs", + "sha256": "c991ddc98a1e2f5aa597252e084a332581a07e3fff9b4e9a487935e3c9fdbdda" + }, + { + "path": "crates/canopy-server/tests/multi_server/merge.rs", + "sha256": "f3d0d7f9bb8e45a304eb30d166488babfb61157b16bbfa69d0934b58f1ccb449" + }, + { + "path": "crates/canopy-server/tests/multi_server/partial_clone.rs", + "sha256": "5ad3c870f546c341be0e2e529ab7058a4c04768fb3068fd4fb4c02308cf275fd" + }, + { + "path": "crates/canopy-server/tests/multi_server/peers/cold_activation.rs", + "sha256": "6fa5fc02db45476b5cf453bf2e398a3283cfe105f978ce9dfa4e5f5bb3a31be8" + }, + { + "path": "crates/canopy-server/tests/multi_server/peers/mod.rs", + "sha256": "2ac306aabdbd23b0adb5e9dcf873a0673775d74b6d6ef5a755f3fce2242966c4" + }, + { + "path": "crates/canopy-server/tests/multi_server/pulls.rs", + "sha256": "b8641ad44851ea3dc2a0777d7f43d652f354289272100da30564953053d861ce" + }, + { + "path": "crates/canopy-server/tests/multi_server/push_options.rs", + "sha256": "56015e1a5844de23ae940d6ac958f5577b7c13b45c72f2e18672c665302d48b0" + }, + { + "path": "crates/canopy-server/tests/multi_server/rebase.rs", + "sha256": "4cda05f2118f5fe014dddee33bb1789aaa81ccc866c03c9ade40173a4c095cae" + }, + { + "path": "crates/canopy-server/tests/multi_server/residency/faults/admission.rs", + "sha256": "de463c985f74630546b60f9cb6d64dd9e71494dcfa6aba90dfe77ad4e613681a" + }, + { + "path": "crates/canopy-server/tests/multi_server/residency/faults/git_discovery.rs", + "sha256": "bf366e30ede6ccb090d0c8ff595e8ae0731f6d0b59cd32753ba37d5f69751ead" + }, + { + "path": "crates/canopy-server/tests/multi_server/residency/faults/mod.rs", + "sha256": "58c0e7ad096d923ad9e83e3b81ccc268a493bc9119c2d519811f8b5239fb835a" + }, + { + "path": "crates/canopy-server/tests/multi_server/residency/mod.rs", + "sha256": "5385e48807e47ea29bd62a5fc21c9733c0f70c16503563f7680c171a99b7d322" + }, + { + "path": "crates/canopy-server/tests/multi_server/retained_catalog.rs", + "sha256": "0d3050a1377dae4e539862527931a8d778615f0126e9f2b422e706a78dfb447e" + }, + { + "path": "crates/canopy-server/tests/multi_server/sha256.rs", + "sha256": "8475d53b4abb1aac8aeab951cd2232681aac81869bf058ffe08664d39ae9ceb3" + }, + { + "path": "crates/canopy-server/tests/multi_server/size.rs", + "sha256": "36dd44784bfbba1b5bf98015d0c68afa8d75384d2819524e187aef5c2458f64d" + }, + { + "path": "crates/canopy-server/tests/multi_server/ssh/fetch.rs", + "sha256": "7b71ff5132228409e5c2bdc8e97cb243cff69e51f8a10b7ee952abd701e618d3" + }, + { + "path": "crates/canopy-server/tests/multi_server/ssh/filtered_preparation.rs", + "sha256": "ea2bf58aac22046f09709d69e0da9bd9f0f517ecf57c8d5ee916d0f91991aa98" + }, + { + "path": "crates/canopy-server/tests/multi_server/ssh/lfs.rs", + "sha256": "3a45615da9d498d167ca4d14b7efeef81b5650309e5e99dc7300536aea9c151b" + }, + { + "path": "crates/canopy-server/tests/multi_server/ssh/mod.rs", + "sha256": "05e32ec5efc1d85e9b993bc51f5cbc243a994292483f0f69fa5a386b9b7a8441" + }, + { + "path": "crates/canopy-server/tests/multi_server/ssh/publication.rs", + "sha256": "a43bb4563bc7d06a28cd9ce1717c69dc297a22ed41b062602dc2ce435d34cf88" + }, + { + "path": "crates/canopy-server/tests/multi_server/ssh_keys.rs", + "sha256": "dee0f7d857ce06a27e4a6188543ddb23f293be642ee3ecdc2292d2020e81158d" + }, + { + "path": "crates/canopy-server/tests/multi_server/tokens/mod.rs", + "sha256": "12906439734dfee69727f3f8741638182c5467b39c997bdd38312c63390884a5" + }, + { + "path": "crates/canopy-server/tests/multi_server/tokens/token_quotas.rs", + "sha256": "867adc7c3dea35fefb79e176f774a027ad4825720cac6acf5f5275bffdb9ad34" + }, + { + "path": "crates/canopy-server/tests/multi_server/transfers.rs", + "sha256": "71cde977cb4cd861b6cf1d4bf9c3e47f4c2a452b101efe2bd0e0da18d7fef3a6" + }, + { + "path": "crates/canopy-server/tests/multi_server/visibility.rs", + "sha256": "03722d5df40e7bb069d25a648d1b52cb0dee06bab8c6d71ef39df84e764b50bd" + }, + { + "path": "crates/canopy-server/tests/multi_server/workspace.rs", + "sha256": "6045d31be02106df17b41d317dc2ec625662061b3aabf055b0085b769b9f86ea" + }, + { + "path": "crates/canopy-server/tests/owner_restart.rs", + "sha256": "a48eb12f4d8b3246bf3eb67bbd6b680f34396b619d5105682122b639913c03a0" + }, + { + "path": "crates/canopy-server/tests/repository_cell/batches.rs", + "sha256": "33073182ca181eaa859bb7745fcd2791f31c7f02b44fd57206943dc8400bd6af" + }, + { + "path": "crates/canopy-server/tests/repository_cell/branch_rules.rs", + "sha256": "2f8bf6eaf1d4dd6851442c5e8eef91d84d7d4cb681645d2f9819ffc7884407c0" + }, + { + "path": "crates/canopy-server/tests/repository_cell/bulk_refs.rs", + "sha256": "9223c138a0f94a52d709e00c4db53d407a3f03b37ee07eefd08a83ed02371407" + }, + { + "path": "crates/canopy-server/tests/repository_cell/checks.rs", + "sha256": "e736205b3f6b5da05c3ef9c2fcf1edc38c9743d9fcf3decc5d492090b8af3b22" + }, + { + "path": "crates/canopy-server/tests/repository_cell/chunks.rs", + "sha256": "4d0ba1b064b3ba0da7187ce22a54926b8c50fe4965325c6f33f53904705d713a" + }, + { + "path": "crates/canopy-server/tests/repository_cell/default_branch.rs", + "sha256": "6f901bb2f07e9a83ae6033ec8c633b53db9dc865bf8f387a08c78a964f83148c" + }, + { + "path": "crates/canopy-server/tests/repository_cell/graph.rs", + "sha256": "e9a9c00ec4aa3c0ea8c445ea08af801d3e51eee93198798f185d4e6f31d1f404" + }, + { + "path": "crates/canopy-server/tests/repository_cell/issues.rs", + "sha256": "d5b3367b6e62327b61b1b689c4712cfc1be1f87fb4d0e613855ec34692bb2fa1" + }, + { + "path": "crates/canopy-server/tests/repository_cell/main.rs", + "sha256": "6595a32386e7077d86c3a2d458996c24251f78cfbf0258e714bfcd2ec4087c62" + }, + { + "path": "crates/canopy-server/tests/repository_cell/merge.rs", + "sha256": "b154dce0f308ddbb3661de3ef2b577458f079a4fdf432df5ad8cc88b1db738e2" + }, + { + "path": "crates/canopy-server/tests/repository_cell/pages.rs", + "sha256": "782f8058103b1b621d2a4145761250fd527cdf5b7998ef7640ba866fec424847" + }, + { + "path": "crates/canopy-server/tests/repository_cell/pulls.rs", + "sha256": "d5fc6426c4a9caac0b57caac56f9378bb7ea6b0b34b3ceca673acf26d3a33e2f" + }, + { + "path": "crates/canopy-server/tests/repository_cell/rebase.rs", + "sha256": "288f51d8876620385473e3428adb28d672ef45b8135e73b25b0e0431d7d35124" + }, + { + "path": "crates/canopy-server/tests/repository_cell/visibility.rs", + "sha256": "4a7f1f1e7b1a97064a6b31dac2d14bc6dbc41f8c3bad47a277a7564a0fa5060f" + }, + { + "path": "crates/canopy-server/tests/smart_http/cache_admission.rs", + "sha256": "721c05836efc6ee6db3424c41118b6e380097a202e404bfb04b939217ce47e1d" + }, + { + "path": "crates/canopy-server/tests/smart_http/cache_reuse.rs", + "sha256": "001f1fc9a67c48b54ab7d999b26da58696df25d843148fc05fd531ef4538d1f6" + }, + { + "path": "crates/canopy-server/tests/smart_http/encoded_input.rs", + "sha256": "5a47511b31c8a6753fc2c43cdde6189e1216819e3bb5b6e583c36b7ac67c3e37" + }, + { + "path": "crates/canopy-server/tests/smart_http/main.rs", + "sha256": "949aa477e98c604bd5a5ba2b4cf83e4c28184859c016cd9cfd352fd86c857145" + }, + { + "path": "crates/canopy-server/tests/smart_http/native_resources.rs", + "sha256": "fa4299bca19049a8038b96b31e978f6b0ce95841a600b24f229359470ee5fa22" + }, + { + "path": "crates/canopy-server/tests/smart_http/publication.rs", + "sha256": "900e1f8acd082541b327d084da139702581f4cdf86a6bddc48a011b6b336ef05" + }, + { + "path": "crates/canopy-server/tests/smart_http/push_uploads.rs", + "sha256": "d64ecd6480177d4c40271866382f540b58245809bbb5e23817fcef5bed8e85d9" + }, + { + "path": "crates/canopy-server/tests/smart_http/ref_snapshots.rs", + "sha256": "9d46ae59c25f646cb5ec57cd72868c42e13082330154d66a684e6e17506c694c" + }, + { + "path": "crates/canopy-server/tests/support/mod.rs", + "sha256": "04d62438601197007ba0b813813e1326a6c820bb214c23c4778cf61cc13a1f6a" + }, + { + "path": "crates/canopy-server/tests/support/objects.rs", + "sha256": "85b142a97480bfe46e9c7331ffdca6b6d137c109dced696ed29f191160c2c1ae" + }, + { + "path": "crates/canopy-server/tests/support/paused_blobs.rs", + "sha256": "14d8954c785737eb4776ca9234aabf6224adf1e29fdbb98210514c4ad5007b59" + }, + { + "path": "crates/canopy-server/tests/support/retained_directory.rs", + "sha256": "499db26adc781b702c770badf7f87d4941b604f41c3db51d0252b1e84ca6411d" + } + ], + "validation": { + "backup": { + "result": "passed", + "seconds": 100.81, + "log": "/tmp/canopy-native-backup-final2.log" + }, + "checkpoint_race": { + "before": "failed deterministically: receipt observed in RegisteringInputs", + "after": "passed both SHA-1 and SHA-256", + "log": "/tmp/canopy-checkpoint-race-after.log" + }, + "push_options": { + "passed": 4, + "ignored": 1, + "failed": 0, + "seconds": 3.51, + "log": "/tmp/canopy-checkpoint-push-options.log" + }, + "clippy": { + "result": "passed", + "command": "cargo +1.98.0 clippy --workspace --all-targets --locked -- -D warnings", + "log": "/tmp/canopy-native-backup-clippy2.log" + }, + "qualification_note": "Tests preceded moving one decoder helper above its test module; no behavior changed. Clippy checks the final placement. Full workspace and new-head Linux remain required." + }, + "preceding_linux": { + "head": "aa86f944907b64816c366a55bdca2f465e25e00e", + "runs": [ + 37411479759, + 37411617637 + ], + "multi_server": { + "passed": 100, + "failed": 11, + "ignored": 9 + } + }, + "remaining": [ + "selective native fetch/cache preparation", + "late pre-bind SSH rejection", + "read-only residency restoration assertion", + "detached standalone fixtures", + "full workspace and new-head Linux qualification" + ], + "full_ci_green": false +} diff --git a/docs/evidence/native-candidate-intent-ci-20261005.json b/docs/evidence/native-candidate-intent-ci-20261005.json new file mode 100644 index 00000000..21836cb2 --- /dev/null +++ b/docs/evidence/native-candidate-intent-ci-20261005.json @@ -0,0 +1,746 @@ +{ + "recorded_at_utc": "2026-10-05T21:45:48.805014+00:00", + "base_head": "c8c49e8ff3e9467e2b0ca9f1610ab67245c48c58", + "host": "macOS, Rust 1.98.0; exact-head Linux qualification remains required", + "source_files": 517, + "rust_files": 499, + "source_hash_digest": "67608ddeea86178381fdc9cc3521f6c4362cd9648c0734fc8453c079a28c4926", + "source_manifest": "/tmp/canopy-native-candidate-intent-final-source.json", + "source_digest_algorithm": "SHA256 of compact sorted-key JSON mapping each Rust/SQL/TOML/lock/YAML path to its file SHA256", + "source_unchanged_during_validation": true, + "release_qualified": false, + "baseline": { + "source_digest": "7ab9a27474d208b86522094022daeb05a51aa8b036cc6b2597dda51d6a5b1495", + "reason": "Actual resident/native-ref candidate reservation compiles and fails because operation 10 is absent from the production registry. Legacy ref table is not populated.", + "path": "/tmp/canopy-native-candidate-intent-baseline.log", + "sha256": "2866ba8c2fb74d63915b1e7d9831d760ad41c8bf80e23fe7a71759e27dd3b801", + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 739 filtered out; finished in 0.86s" + ] + }, + "intermediate_failed_runs": [ + { + "source_digest": "85c4b6dc74c7b45647c11a97242bd99ba2506dc96942e1b0a61ee7e030d6bdd9", + "reason": "Three families pass; rollback fixture tries CREATE TRIGGER through the public SQL primitive, which correctly rejects statement separators. Product validation and assertions remain unchanged.", + "path": "/tmp/canopy-native-candidate-intent-focused-first.log", + "sha256": "a4a05258216e7f72655a759c2684fcf97907ea770f7251c565848e128e6e767d", + "summaries": [ + "test result: FAILED. 3 passed; 1 failed; 0 ignored; 0 measured; 739 filtered out; finished in 2.20s" + ] + }, + { + "source_digest": "b75150e738976a6d116cc4dd21e994e9b5b7b2207c6a76f8a61b268a0b6a848f", + "reason": "Three families pass; rollback fixture uses the local Cell query API, which correctly refuses writes to its read-only connection. Fault installation moved to the trusted test-only Cell mutation API; no read/SQL guard was weakened.", + "path": "/tmp/canopy-native-candidate-intent-second-focused-final.log", + "sha256": "1db47e6480227c8bbea69891a35fc08592e229d025e0e051b36ac7f52513bf7d", + "summaries": [ + "test result: FAILED. 3 passed; 1 failed; 0 ignored; 0 measured; 739 filtered out; finished in 2.27s" + ] + } + ], + "utility_failure": "An initial validator-script transformation produced a Python SyntaxError before any compiler started. The script was corrected, syntax checked, and then executed; no qualification is claimed for that launch.", + "validation": { + "source_digest": "67608ddeea86178381fdc9cc3521f6c4362cd9648c0734fc8453c079a28c4926", + "phases": [ + { + "name": "focused", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "server::residency::tests::serving::browser::pulls::candidates::", + "--locked", + "--", + "--nocapture" + ], + "exit_code": 0, + "seconds": 83.34, + "log": "/tmp/canopy-native-candidate-intent-final-focused-final.log", + "log_sha256": "0354d5f63d916c7eebcd43c8c3f211d1c9200bb0a53bbdd6d04e6d2cf611f1ab", + "summaries": [ + "test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 739 filtered out; finished in 3.95s" + ], + "failed_cases": [] + }, + { + "name": "registry", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "packs::publication::registry::tests::production_registers_packed_and_policy_metadata_contracts", + "--locked", + "--", + "--exact" + ], + "exit_code": 0, + "seconds": 0.78, + "log": "/tmp/canopy-native-candidate-intent-final-registry-final.log", + "log_sha256": "9631879411f5511ea5c00d9a14da36fbd9b3531f6b5c452543b986cc702442d9", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 742 filtered out; finished in 0.05s" + ], + "failed_cases": [] + }, + { + "name": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 31.7, + "log": "/tmp/canopy-native-candidate-intent-final-clippy-final.log", + "log_sha256": "476fa92a305fc1c5ef53bac68be8abc5f17ee872054cb5cd90ab6aba963b86b9", + "summaries": [], + "failed_cases": [] + }, + { + "name": "workspace", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--locked", + "--no-fail-fast" + ], + "exit_code": 101, + "seconds": 708.24, + "log": "/tmp/canopy-native-candidate-intent-final-workspace-final.log", + "log_sha256": "47679cd51fa28f067aaa2f0b232db8818cb934161fccb289836a66430ce01b6a", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.71s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.32s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 742 filtered out; finished in 0.00s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 742 filtered out; finished in 0.08s", + "test result: ok. 743 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 296.64s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.20s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s", + "test result: FAILED. 87 passed; 20 failed; 9 ignored; 0 measured; 0 filtered out; finished in 351.45s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.33s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.16s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.27s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s" + ], + "failed_cases": [ + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery", + "a_second_node_clones_from_the_published_root_after_local_disk_loss", + "repository_cell_publishes_objects_and_refs_atomically", + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ] + }, + { + "name": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 38.06, + "log": "/tmp/canopy-native-candidate-intent-final-build-final.log", + "log_sha256": "5cc1f6ca415c48867244d685575227a3d31b7d1a48277026417833e5caed7c4f", + "summaries": [], + "failed_cases": [] + }, + { + "name": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.23, + "log": "/tmp/canopy-native-candidate-intent-final-fmt-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "name": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 40.79, + "log": "/tmp/canopy-native-candidate-intent-final-harness-final.log", + "log_sha256": "1bb99a476c31b11299eb39ee4e728c28531110fab271887b89529a11526535fc", + "summaries": [], + "failed_cases": [] + }, + { + "name": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.04, + "log": "/tmp/canopy-native-candidate-intent-final-diff-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + } + ], + "complete": true, + "release_qualified": false, + "source_unchanged": true + }, + "workspace_terminal_inventory": [ + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_git_format-cdf8cea2f92fe6a4)", + "passed": 6, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_object_storage-4a0661c5c4765140)", + "passed": 15, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_server-d080aca381ae9ba9)", + "passed": 743, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy-16a4bf977c56198e)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/directory_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/directory_cell-31a0f4eea5beeae3)", + "passed": 13, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/git_http.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/git_http-afa4d1d9a2db0179)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/multi_server/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/multi_server-3e08ee00a07d7bde)", + "passed": 87, + "failed": 20, + "ignored": 9 + }, + { + "binary": "tests/owner_restart.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/owner_restart-be32ecc90c554a17)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "tests/repository_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/repository_cell-322afb5848ed5e86)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "tests/smart_http/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/smart_http-18ac06122d3c394f)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "canopy_git_format", + "passed": 0, + "failed": 0, + "ignored": 0 + }, + { + "binary": "canopy_object_storage", + "passed": 0, + "failed": 0, + "ignored": 0 + }, + { + "binary": "canopy_server", + "passed": 0, + "failed": 0, + "ignored": 0 + } + ], + "workspace_unique_totals": { + "passed": 868, + "failed": 23, + "ignored": 9, + "executed": 891, + "total": 900 + }, + "counting": "Last summary per Cargo Running/Doc-tests section; nested child summaries and focused reruns excluded. Ignored cases remain unexecuted.", + "workspace_failure_delta": { + "added": [], + "removed": [] + }, + "parent_ci": { + "headRefOid": "c8c49e8ff3e9467e2b0ca9f1610ab67245c48c58", + "mergeable": "MERGEABLE", + "statusCheckRollup": [ + { + "__typename": "CheckRun", + "completedAt": "2026-10-05T21:13:04Z", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/crabbuild/canopy/actions/runs/37373904793/job/111977522885", + "name": "harness", + "startedAt": "2026-10-05T21:12:20Z", + "status": "COMPLETED", + "workflowName": "Verify" + }, + { + "__typename": "CheckRun", + "completedAt": "2026-10-05T21:12:55Z", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/crabbuild/canopy/actions/runs/37373898679/job/111977504016", + "name": "harness", + "startedAt": "2026-10-05T21:12:14Z", + "status": "COMPLETED", + "workflowName": "Verify" + }, + { + "__typename": "CheckRun", + "completedAt": "2026-10-05T21:33:31Z", + "conclusion": "FAILURE", + "detailsUrl": "https://github.com/crabbuild/canopy/actions/runs/37373904793/job/111977523019", + "name": "rust", + "startedAt": "2026-10-05T21:07:29Z", + "status": "COMPLETED", + "workflowName": "Verify" + }, + { + "__typename": "CheckRun", + "completedAt": "2026-10-05T21:28:07Z", + "conclusion": "FAILURE", + "detailsUrl": "https://github.com/crabbuild/canopy/actions/runs/37373898679/job/111977503964", + "name": "rust", + "startedAt": "2026-10-05T21:07:31Z", + "status": "COMPLETED", + "workflowName": "Verify" + } + ] + }, + "parent_linux_runs": [ + { + "run_id": 37373904793, + "metadata": { + "conclusion": "failure", + "headSha": "c8c49e8ff3e9467e2b0ca9f1610ab67245c48c58", + "jobs": [ + { + "completedAt": "2026-10-05T21:13:04Z", + "conclusion": "success", + "databaseId": 111977522885, + "name": "harness", + "startedAt": "2026-10-05T21:12:20Z", + "status": "completed", + "steps": [ + { + "completedAt": "2026-10-05T21:12:22Z", + "conclusion": "success", + "name": "Set up job", + "number": 1, + "startedAt": "2026-10-05T21:12:21Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:12:24Z", + "conclusion": "success", + "name": "Run actions/checkout@v4", + "number": 2, + "startedAt": "2026-10-05T21:12:22Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:13:02Z", + "conclusion": "success", + "name": "Check Python qualification harness", + "number": 3, + "startedAt": "2026-10-05T21:12:24Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:13:02Z", + "conclusion": "success", + "name": "Post Run actions/checkout@v4", + "number": 6, + "startedAt": "2026-10-05T21:13:02Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:13:02Z", + "conclusion": "success", + "name": "Complete job", + "number": 7, + "startedAt": "2026-10-05T21:13:02Z", + "status": "completed" + } + ], + "url": "https://github.com/crabbuild/canopy/actions/runs/37373904793/job/111977522885" + }, + { + "completedAt": "2026-10-05T21:33:31Z", + "conclusion": "failure", + "databaseId": 111977523019, + "name": "rust", + "startedAt": "2026-10-05T21:07:29Z", + "status": "completed", + "steps": [ + { + "completedAt": "2026-10-05T21:07:30Z", + "conclusion": "success", + "name": "Set up job", + "number": 1, + "startedAt": "2026-10-05T21:07:30Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:07:31Z", + "conclusion": "success", + "name": "Run actions/checkout@v4", + "number": 2, + "startedAt": "2026-10-05T21:07:30Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:07:41Z", + "conclusion": "success", + "name": "Install tools", + "number": 3, + "startedAt": "2026-10-05T21:07:31Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:07:43Z", + "conclusion": "success", + "name": "Report tool versions", + "number": 4, + "startedAt": "2026-10-05T21:07:41Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:07:49Z", + "conclusion": "success", + "name": "Check formatting", + "number": 5, + "startedAt": "2026-10-05T21:07:43Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:10:09Z", + "conclusion": "success", + "name": "Check lints", + "number": 6, + "startedAt": "2026-10-05T21:07:49Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:33:29Z", + "conclusion": "failure", + "name": "Test", + "number": 7, + "startedAt": "2026-10-05T21:10:09Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:33:29Z", + "conclusion": "skipped", + "name": "Qualify Git compatibility against RustFS", + "number": 8, + "startedAt": "2026-10-05T21:33:29Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:33:29Z", + "conclusion": "skipped", + "name": "Build server", + "number": 9, + "startedAt": "2026-10-05T21:33:29Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:33:30Z", + "conclusion": "success", + "name": "Post Run actions/checkout@v4", + "number": 18, + "startedAt": "2026-10-05T21:33:29Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:33:30Z", + "conclusion": "success", + "name": "Complete job", + "number": 19, + "startedAt": "2026-10-05T21:33:30Z", + "status": "completed" + } + ], + "url": "https://github.com/crabbuild/canopy/actions/runs/37373904793/job/111977523019" + } + ], + "status": "completed" + }, + "log": { + "path": "/tmp/canopy-native-candidate-intent-parent-pr-full.log", + "sha256": "97329983199101ae876929f444547b89ea0092958ad902f171a8140063630bb4", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.42s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 9.17s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 738 filtered out; finished in 0.00s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 738 filtered out; finished in 0.01s", + "test result: ok. 739 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 474.31s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.75s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: FAILED. 88 passed; 23 failed; 9 ignored; 0 measured; 0 filtered out; finished in 647.90s" + ] + } + }, + { + "run_id": 37373898679, + "metadata": { + "conclusion": "failure", + "headSha": "c8c49e8ff3e9467e2b0ca9f1610ab67245c48c58", + "jobs": [ + { + "completedAt": "2026-10-05T21:28:07Z", + "conclusion": "failure", + "databaseId": 111977503964, + "name": "rust", + "startedAt": "2026-10-05T21:07:31Z", + "status": "completed", + "steps": [ + { + "completedAt": "2026-10-05T21:07:32Z", + "conclusion": "success", + "name": "Set up job", + "number": 1, + "startedAt": "2026-10-05T21:07:31Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:07:34Z", + "conclusion": "success", + "name": "Run actions/checkout@v4", + "number": 2, + "startedAt": "2026-10-05T21:07:32Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:07:41Z", + "conclusion": "success", + "name": "Install tools", + "number": 3, + "startedAt": "2026-10-05T21:07:34Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:07:43Z", + "conclusion": "success", + "name": "Report tool versions", + "number": 4, + "startedAt": "2026-10-05T21:07:41Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:07:46Z", + "conclusion": "success", + "name": "Check formatting", + "number": 5, + "startedAt": "2026-10-05T21:07:43Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:09:24Z", + "conclusion": "success", + "name": "Check lints", + "number": 6, + "startedAt": "2026-10-05T21:07:46Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:28:04Z", + "conclusion": "failure", + "name": "Test", + "number": 7, + "startedAt": "2026-10-05T21:09:24Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:28:04Z", + "conclusion": "skipped", + "name": "Qualify Git compatibility against RustFS", + "number": 8, + "startedAt": "2026-10-05T21:28:04Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:28:04Z", + "conclusion": "skipped", + "name": "Build server", + "number": 9, + "startedAt": "2026-10-05T21:28:04Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:28:04Z", + "conclusion": "success", + "name": "Post Run actions/checkout@v4", + "number": 18, + "startedAt": "2026-10-05T21:28:04Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:28:04Z", + "conclusion": "success", + "name": "Complete job", + "number": 19, + "startedAt": "2026-10-05T21:28:04Z", + "status": "completed" + } + ], + "url": "https://github.com/crabbuild/canopy/actions/runs/37373898679/job/111977503964" + }, + { + "completedAt": "2026-10-05T21:12:55Z", + "conclusion": "success", + "databaseId": 111977504016, + "name": "harness", + "startedAt": "2026-10-05T21:12:14Z", + "status": "completed", + "steps": [ + { + "completedAt": "2026-10-05T21:12:15Z", + "conclusion": "success", + "name": "Set up job", + "number": 1, + "startedAt": "2026-10-05T21:12:14Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:12:16Z", + "conclusion": "success", + "name": "Run actions/checkout@v4", + "number": 2, + "startedAt": "2026-10-05T21:12:15Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:12:54Z", + "conclusion": "success", + "name": "Check Python qualification harness", + "number": 3, + "startedAt": "2026-10-05T21:12:16Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:12:54Z", + "conclusion": "success", + "name": "Post Run actions/checkout@v4", + "number": 6, + "startedAt": "2026-10-05T21:12:54Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:12:54Z", + "conclusion": "success", + "name": "Complete job", + "number": 7, + "startedAt": "2026-10-05T21:12:54Z", + "status": "completed" + } + ], + "url": "https://github.com/crabbuild/canopy/actions/runs/37373898679/job/111977504016" + } + ], + "status": "completed" + }, + "log": { + "path": "/tmp/canopy-native-candidate-intent-parent-push-full.log", + "sha256": "34c208c02f4edb957680a12ee568baf66bbc1264f859438f01f8dd7c72049a64", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.28s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.01s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 738 filtered out; finished in 0.00s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 738 filtered out; finished in 0.02s", + "test result: ok. 739 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 378.54s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.27s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: FAILED. 88 passed; 23 failed; 9 ignored; 0 measured; 0 filtered out; finished in 535.74s" + ] + } + } + ], + "parent_ci_interpretation": "Actual observed parent c8c49e8 statuses and available terminal Rust logs are retained. They do not qualify this increment; pending, in-progress and cancelled checks do not prove success. RustFS/build skipped after test failure remain unqualified.", + "changes": [ + "Operation 10 codec 3 is registered with bounded native editorial input. It reuses CandidateAction, CandidateRequest, CandidateResult, existing merge_candidates rows and RefSelection; no ref/ancestry mirror, table, decoder or provider deletion is added.", + "The client selects pull ref names under fresh access, issues an exact-purpose ref observation and retains its serving snapshot through dispatch. Final execution checks fresh write role, actual Cell/owner, live pin, exact action/facts and current joint generation before evaluating native projected pull policy.", + "Only Pending reservation or first negative result may mutate editorial rows. Generated Ready payloads are refused even with an authentic read observation. The retired SQL generated-object certification/ref insertion path is removed from this command.", + "Logical UUID replay preserves first intent, creation timestamp and completed negative result; changed intent is a collision. Source/base/editorial versions are rechecked before a new reservation or negative completion.", + "Four actual-resident families exercise SHA-1 and SHA-256: reservation/negative finish/replay, authentic-read Ready refusal and unchanged joint roots; purpose/payload/fact/actor/cell/current-generation binding; late access and editorial changes; late SQL insert failure with SDK absence, unchanged roots and exact original-command receipt replay." + ], + "qualification_limits": [ + "Fixtures contain genuine physically verified stock-Git native metadata and actual resident/registered receiver/serving observations, but install their initial joint fact using trusted synthetic certificate SQL. They do not qualify initial-root generation, public generated producer or full workload capacity.", + "Generated Ready publication and merge/squash/rebase remain incomplete. The existing gateway candidate producer still performs old persistence after native Git generation; no complete successful public candidate endpoint is claimed.", + "Candidate-specific producer cancellation, pre-Bind intent/checkpoint recovery, original registered generated command, automatic retention and cold/owner restoration require qualification with the completed resident publisher.", + "Default-branch and thread writers, full CI including Linux durable refusals, backup/peer recovery, selective fetch, complete physical retention/GC/final DDL, fair maintenance/accelerators, asynchronous file attribution and large-history/10k-engineer capacity remain open.", + "macOS lib-test linker reports oversized __eh_frame compact-unwind warning. All-target Clippy, server build and Linux validation are independent checks." + ] +} diff --git a/docs/evidence/native-candidate-verification-ci-20261005.json b/docs/evidence/native-candidate-verification-ci-20261005.json new file mode 100644 index 00000000..c9fd3774 --- /dev/null +++ b/docs/evidence/native-candidate-verification-ci-20261005.json @@ -0,0 +1,757 @@ +{ + "recorded_at_utc": "2026-10-05T22:24:38.331143+00:00", + "base_head": "823092222fd043c2a0e3ce7deab8cdab40cd097c", + "host": "macOS, Rust 1.98.0; exact new-head Linux qualification required", + "release_qualified": false, + "source_files": 519, + "rust_files": 501, + "source_hash_digest": "e06fe0f8168246e9e9bf731b16d720bb97ad49c4bebb627db10ffe5a84d1eb7a", + "source_manifest": "/tmp/canopy-native-candidate-verification-final-source.json", + "source_digest_algorithm": "SHA256 of compact sorted-key JSON mapping each Rust/SQL/TOML/lock/YAML path to its file SHA256", + "source_unchanged_during_validation": true, + "public_baseline": { + "source_digest": "67608ddeea86178381fdc9cc3521f6c4362cd9648c0734fc8453c079a28c4926", + "reason": "Actual production stock-Git candidate endpoint fails at 503 instead of 200 on the parent. This private semantic increment does not claim to resolve that public failure; complete joint generated publication is next.", + "path": "/tmp/canopy-native-candidate-verification-public-baseline.log", + "sha256": "745cee89934b6426810fa32d8128b3f476d944e00be8e95508507ac2a6798c90", + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 115 filtered out; finished in 2.16s" + ] + }, + "intermediate_failed_runs": [ + { + "reason": "Compile failure: used the wrong location for the shared OID parser and treated the native fixture typed-edge inventory as raw body bytes. No tests executed and no qualification claimed.", + "path": "/tmp/canopy-native-candidate-verification-focused-first.log", + "sha256": "419e2596ef483a6a50ee347424fdd5e66e472197dd4f172df4d93f78f5af05a4", + "summaries": [] + }, + { + "reason": "Compile failure: shared pull parser returns bytes; switched to existing typed merge OID parser. No tests executed and no qualification claimed.", + "path": "/tmp/canopy-native-candidate-verification-focused-second.log", + "sha256": "e8ba5d7c26bc1618350a25c1dd0f93e8d7525e38558669fbc7188ffe55e82c33", + "summaries": [] + }, + { + "reason": "Two both-format rebase families pass, merge/squash fixture hits absent legacy objects table. The packed schema has removed objects/commit_ancestry; final assertion proves table absence rather than counting nonexistent rows. Final verifier also matches canonical BLAKE3 body digest beside Git OID.", + "path": "/tmp/canopy-native-candidate-verification-focused-third.log", + "sha256": "e802c4cca5fdbca597a6063f662e70b8fbfd71d09c2f5f3e481ab47a8232e313", + "summaries": [ + "test result: FAILED. 2 passed; 1 failed; 0 ignored; 0 measured; 743 filtered out; finished in 16.05s" + ] + } + ], + "validation": { + "source_digest": "e06fe0f8168246e9e9bf731b16d720bb97ad49c4bebb627db10ffe5a84d1eb7a", + "phases": [ + { + "name": "focused", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "packs::publication::tests::native_candidate::", + "--locked", + "--", + "--nocapture" + ], + "exit_code": 0, + "seconds": 148.68, + "log": "/tmp/canopy-native-candidate-verification-final-focused-final.log", + "log_sha256": "6e6bdd6242aaeaaea5b79883d1847e8085d938228a10f041e8a879517c2566fc", + "summaries": [ + "test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 743 filtered out; finished in 12.71s" + ], + "failed_cases": [] + }, + { + "name": "merge", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "packs::publication::tests::native_merge::", + "--locked" + ], + "exit_code": 0, + "seconds": 10.35, + "log": "/tmp/canopy-native-candidate-verification-final-merge-final.log", + "log_sha256": "2a4fe6a30fa702d639f156995873143c30c71fec1bd044aaf3abd133bd47c70f", + "summaries": [ + "test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 732 filtered out; finished in 5.75s" + ], + "failed_cases": [] + }, + { + "name": "registry", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "packs::publication::registry::tests::production_registers_packed_and_policy_metadata_contracts", + "--locked", + "--", + "--exact" + ], + "exit_code": 0, + "seconds": 1.06, + "log": "/tmp/canopy-native-candidate-verification-final-registry-final.log", + "log_sha256": "743852031baea3d9a1cbaff5b31019a53ffd48067e9011f8d7e6c712447a2077", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 745 filtered out; finished in 0.10s" + ], + "failed_cases": [] + }, + { + "name": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 52.0, + "log": "/tmp/canopy-native-candidate-verification-final-clippy-final.log", + "log_sha256": "cf63691c426f069d6d0bb771e09d5520aca5de9892d837a2bd28980e51a68352", + "summaries": [], + "failed_cases": [] + }, + { + "name": "workspace", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--locked", + "--no-fail-fast" + ], + "exit_code": 101, + "seconds": 815.55, + "log": "/tmp/canopy-native-candidate-verification-final-workspace-final.log", + "log_sha256": "215a5e42f4a1fdc8f928033cc3a4c91e27813c23d6be9430e32c3e18826ce8d1", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.52s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.86s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 745 filtered out; finished in 0.01s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 745 filtered out; finished in 0.08s", + "test result: ok. 746 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 341.05s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 8.71s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s", + "test result: FAILED. 87 passed; 20 failed; 9 ignored; 0 measured; 0 filtered out; finished in 369.62s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.77s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.29s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.37s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s" + ], + "failed_cases": [ + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery", + "a_second_node_clones_from_the_published_root_after_local_disk_loss", + "repository_cell_publishes_objects_and_refs_atomically", + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ] + }, + { + "name": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 61.27, + "log": "/tmp/canopy-native-candidate-verification-final-build-final.log", + "log_sha256": "a9c739d365564f2d759cb47f51f3db6f1201f1f069f63d4ff3d40de9c66e412f", + "summaries": [], + "failed_cases": [] + }, + { + "name": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.59, + "log": "/tmp/canopy-native-candidate-verification-final-fmt-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "name": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 43.39, + "log": "/tmp/canopy-native-candidate-verification-final-harness-final.log", + "log_sha256": "6f5949b48bd3a8896ed98da4b39bce1f6ec5b8b87825c5dca9e13c455a725c28", + "summaries": [], + "failed_cases": [] + }, + { + "name": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.05, + "log": "/tmp/canopy-native-candidate-verification-final-diff-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + } + ], + "complete": true, + "release_qualified": false, + "source_unchanged": true + }, + "workspace_terminal_inventory": [ + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_git_format-cdf8cea2f92fe6a4)", + "passed": 6, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_object_storage-4a0661c5c4765140)", + "passed": 15, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_server-d080aca381ae9ba9)", + "passed": 746, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy-16a4bf977c56198e)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/directory_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/directory_cell-31a0f4eea5beeae3)", + "passed": 13, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/git_http.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/git_http-afa4d1d9a2db0179)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/multi_server/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/multi_server-3e08ee00a07d7bde)", + "passed": 87, + "failed": 20, + "ignored": 9 + }, + { + "binary": "tests/owner_restart.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/owner_restart-be32ecc90c554a17)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "tests/repository_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/repository_cell-322afb5848ed5e86)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "tests/smart_http/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/smart_http-18ac06122d3c394f)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "canopy_git_format", + "passed": 0, + "failed": 0, + "ignored": 0 + }, + { + "binary": "canopy_object_storage", + "passed": 0, + "failed": 0, + "ignored": 0 + }, + { + "binary": "canopy_server", + "passed": 0, + "failed": 0, + "ignored": 0 + } + ], + "workspace_unique_totals": { + "passed": 871, + "failed": 23, + "ignored": 9, + "executed": 894, + "total": 903 + }, + "counting": "Last summary per Cargo Running/Doc-tests section; focused runs and nested child summaries excluded. Ignored cases remain unexecuted.", + "workspace_failure_delta": { + "added": [], + "removed": [] + }, + "parent_ci": { + "headRefOid": "823092222fd043c2a0e3ce7deab8cdab40cd097c", + "mergeable": "MERGEABLE", + "statusCheckRollup": [ + { + "__typename": "CheckRun", + "completedAt": "2026-10-05T21:47:34Z", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/crabbuild/canopy/actions/runs/37378141563/job/111992656152", + "name": "harness", + "startedAt": "2026-10-05T21:46:50Z", + "status": "COMPLETED", + "workflowName": "Verify" + }, + { + "__typename": "CheckRun", + "completedAt": "2026-10-05T21:47:28Z", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/crabbuild/canopy/actions/runs/37378136899/job/111992639205", + "name": "harness", + "startedAt": "2026-10-05T21:46:47Z", + "status": "COMPLETED", + "workflowName": "Verify" + }, + { + "__typename": "CheckRun", + "completedAt": "2026-10-05T22:12:57Z", + "conclusion": "FAILURE", + "detailsUrl": "https://github.com/crabbuild/canopy/actions/runs/37378141563/job/111992655728", + "name": "rust", + "startedAt": "2026-10-05T21:46:49Z", + "status": "COMPLETED", + "workflowName": "Verify" + }, + { + "__typename": "CheckRun", + "completedAt": "2026-10-05T22:10:29Z", + "conclusion": "FAILURE", + "detailsUrl": "https://github.com/crabbuild/canopy/actions/runs/37378136899/job/111992639458", + "name": "rust", + "startedAt": "2026-10-05T21:46:49Z", + "status": "COMPLETED", + "workflowName": "Verify" + } + ] + }, + "parent_linux_runs": [ + { + "run_id": 37378141563, + "metadata": { + "conclusion": "failure", + "headSha": "823092222fd043c2a0e3ce7deab8cdab40cd097c", + "jobs": [ + { + "completedAt": "2026-10-05T22:12:57Z", + "conclusion": "failure", + "databaseId": 111992655728, + "name": "rust", + "startedAt": "2026-10-05T21:46:49Z", + "status": "completed", + "steps": [ + { + "completedAt": "2026-10-05T21:46:51Z", + "conclusion": "success", + "name": "Set up job", + "number": 1, + "startedAt": "2026-10-05T21:46:50Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:46:52Z", + "conclusion": "success", + "name": "Run actions/checkout@v4", + "number": 2, + "startedAt": "2026-10-05T21:46:51Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:47:01Z", + "conclusion": "success", + "name": "Install tools", + "number": 3, + "startedAt": "2026-10-05T21:46:52Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:47:05Z", + "conclusion": "success", + "name": "Report tool versions", + "number": 4, + "startedAt": "2026-10-05T21:47:01Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:47:13Z", + "conclusion": "success", + "name": "Check formatting", + "number": 5, + "startedAt": "2026-10-05T21:47:05Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:49:47Z", + "conclusion": "success", + "name": "Check lints", + "number": 6, + "startedAt": "2026-10-05T21:47:13Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:12:55Z", + "conclusion": "failure", + "name": "Test", + "number": 7, + "startedAt": "2026-10-05T21:49:47Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:12:55Z", + "conclusion": "skipped", + "name": "Qualify Git compatibility against RustFS", + "number": 8, + "startedAt": "2026-10-05T22:12:55Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:12:55Z", + "conclusion": "skipped", + "name": "Build server", + "number": 9, + "startedAt": "2026-10-05T22:12:55Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:12:55Z", + "conclusion": "success", + "name": "Post Run actions/checkout@v4", + "number": 18, + "startedAt": "2026-10-05T22:12:55Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:12:56Z", + "conclusion": "success", + "name": "Complete job", + "number": 19, + "startedAt": "2026-10-05T22:12:55Z", + "status": "completed" + } + ], + "url": "https://github.com/crabbuild/canopy/actions/runs/37378141563/job/111992655728" + }, + { + "completedAt": "2026-10-05T21:47:34Z", + "conclusion": "success", + "databaseId": 111992656152, + "name": "harness", + "startedAt": "2026-10-05T21:46:50Z", + "status": "completed", + "steps": [ + { + "completedAt": "2026-10-05T21:46:52Z", + "conclusion": "success", + "name": "Set up job", + "number": 1, + "startedAt": "2026-10-05T21:46:51Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:46:53Z", + "conclusion": "success", + "name": "Run actions/checkout@v4", + "number": 2, + "startedAt": "2026-10-05T21:46:52Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:47:31Z", + "conclusion": "success", + "name": "Check Python qualification harness", + "number": 3, + "startedAt": "2026-10-05T21:46:53Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:47:31Z", + "conclusion": "success", + "name": "Post Run actions/checkout@v4", + "number": 6, + "startedAt": "2026-10-05T21:47:31Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:47:31Z", + "conclusion": "success", + "name": "Complete job", + "number": 7, + "startedAt": "2026-10-05T21:47:31Z", + "status": "completed" + } + ], + "url": "https://github.com/crabbuild/canopy/actions/runs/37378141563/job/111992656152" + } + ], + "status": "completed" + }, + "rust_log": { + "path": "/tmp/canopy-native-candidate-verification-parent-pr-full.log", + "sha256": "67cace992f4b6eff56848b2641764ec5443730fc711176d14bb80ee4415b812b", + "summaries": [] + } + }, + { + "run_id": 37378136899, + "metadata": { + "conclusion": "failure", + "headSha": "823092222fd043c2a0e3ce7deab8cdab40cd097c", + "jobs": [ + { + "completedAt": "2026-10-05T21:47:28Z", + "conclusion": "success", + "databaseId": 111992639205, + "name": "harness", + "startedAt": "2026-10-05T21:46:47Z", + "status": "completed", + "steps": [ + { + "completedAt": "2026-10-05T21:46:48Z", + "conclusion": "success", + "name": "Set up job", + "number": 1, + "startedAt": "2026-10-05T21:46:47Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:46:49Z", + "conclusion": "success", + "name": "Run actions/checkout@v4", + "number": 2, + "startedAt": "2026-10-05T21:46:48Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:47:26Z", + "conclusion": "success", + "name": "Check Python qualification harness", + "number": 3, + "startedAt": "2026-10-05T21:46:49Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:47:27Z", + "conclusion": "success", + "name": "Post Run actions/checkout@v4", + "number": 6, + "startedAt": "2026-10-05T21:47:26Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:47:27Z", + "conclusion": "success", + "name": "Complete job", + "number": 7, + "startedAt": "2026-10-05T21:47:27Z", + "status": "completed" + } + ], + "url": "https://github.com/crabbuild/canopy/actions/runs/37378136899/job/111992639205" + }, + { + "completedAt": "2026-10-05T22:10:29Z", + "conclusion": "failure", + "databaseId": 111992639458, + "name": "rust", + "startedAt": "2026-10-05T21:46:49Z", + "status": "completed", + "steps": [ + { + "completedAt": "2026-10-05T21:46:50Z", + "conclusion": "success", + "name": "Set up job", + "number": 1, + "startedAt": "2026-10-05T21:46:50Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:46:52Z", + "conclusion": "success", + "name": "Run actions/checkout@v4", + "number": 2, + "startedAt": "2026-10-05T21:46:50Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:47:05Z", + "conclusion": "success", + "name": "Install tools", + "number": 3, + "startedAt": "2026-10-05T21:46:52Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:47:05Z", + "conclusion": "success", + "name": "Report tool versions", + "number": 4, + "startedAt": "2026-10-05T21:47:05Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:47:08Z", + "conclusion": "success", + "name": "Check formatting", + "number": 5, + "startedAt": "2026-10-05T21:47:05Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:49:10Z", + "conclusion": "success", + "name": "Check lints", + "number": 6, + "startedAt": "2026-10-05T21:47:08Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:10:27Z", + "conclusion": "failure", + "name": "Test", + "number": 7, + "startedAt": "2026-10-05T21:49:10Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:10:27Z", + "conclusion": "skipped", + "name": "Qualify Git compatibility against RustFS", + "number": 8, + "startedAt": "2026-10-05T22:10:27Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:10:27Z", + "conclusion": "skipped", + "name": "Build server", + "number": 9, + "startedAt": "2026-10-05T22:10:27Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:10:28Z", + "conclusion": "success", + "name": "Post Run actions/checkout@v4", + "number": 18, + "startedAt": "2026-10-05T22:10:27Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:10:28Z", + "conclusion": "success", + "name": "Complete job", + "number": 19, + "startedAt": "2026-10-05T22:10:28Z", + "status": "completed" + } + ], + "url": "https://github.com/crabbuild/canopy/actions/runs/37378136899/job/111992639458" + } + ], + "status": "completed" + }, + "rust_log": { + "path": "/tmp/canopy-native-candidate-verification-parent-push-full.log", + "sha256": "725c4a8cba2e4e77e4a2297710c8da558f1b34c9a500256fcea820a889321f0b", + "summaries": [] + } + } + ], + "changes": [ + "PreparedCatalog.verify_candidate_commit reuses existing candidate model, closed physically verified native catalog, canonical headers, typed edges and shared native file custody. No SQL object/ancestry/ref mirror, schema, registry change, backward decoder or physical deletion is added.", + "Merge/squash exact parent order, author/committer, original reserved time, message and verified tree/source/base kind are matched using canonical Git OID, size and BLAKE3 body digest. No native pack/body download is needed for these strategies.", + "Rebase advances source and rewritten linear chains together; it reads one bounded original body at a time and verifies exact rewritten bytes from verified header/tree/parent metadata. Original author/encoding/message are preserved and obsolete signatures stripped. At most 128 commits are accepted.", + "A shared bounded, admitted disk ancestry queue checks all original targets with one base-history traversal; only final source anchor may already be reachable. Skipped commits and replayed base history are refused, without rebuilding authoritative SQL ancestry.", + "Three physically verified stock-Git families exercise both object formats: valid merge/squash with exact intent/actor/time/parent/kind/member/fence refusal and zero native downloads; valid rebase, skipped/tampered/extra-base history refusal and one shared pack download; 128-commit acceptance and 129 refusal. Existing 14 native merge cases remain passing." + ], + "qualification_limits": [ + "This is semantic preparation, not generated Ready publication, a write capability or current editorial authorization. The public generated endpoint still calls retired ingestion; its failure remains open.", + "Tests construct a private closed catalog from real physical stock-Git pack witnesses with actual preparation capability. They do not qualify public candidate preparation/fetch/merge, generated producer cancellation, exact final command/recovery, initial-root production, lost acknowledgement, startup adoption or retirement.", + "Shared pack cache counts prove bounded native download reuse for these fixtures, not large-history selective fetch or real object-store byte throughput. At most 128 original bodies are read, but base ancestry traversal can still cover full base history on admitted disk. Acceleration, shared/selected workspaces and workload capacity remain required.", + "Fresh policy/access/actual owner/pin/current generation/input checkpoint and immutable UUID/audit selection must be verified by the forthcoming joint publisher. Existing 512-byte phase result cap must be preserved using a compact acknowledgement rather than CandidateOutcome with potentially large messages/conflict paths.", + "Remaining full CI, generated reviewed merge/squash/rebase, default-branch/thread writers, peer/backup recovery, selective fetch, physical retention/GC/final DDL, fair maintenance/accelerators, asynchronous file attribution and 10,000-engineer capacity remain incomplete.", + "macOS lib-test linker retains oversized __eh_frame compact-unwind warning. All-target Clippy, production build and Linux CI are independent checks." + ], + "parent_linux_discrepancy": { + "newly_observed_case": "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "observation": "Both parent 8230922 Linux jobs fail the delete push with HTTP500 and a directly adjacent logical-publication-already-admitted driver failure. Current macOS full run passes that family. Pending creating custody/inputs and final work share JobKind::Publication; reproduce the actual collision before changing guards. Not classified as a flake or attributed to this semantic increment.", + "logs": [ + "/tmp/canopy-native-candidate-verification-parent-pr-full.log", + "/tmp/canopy-native-candidate-verification-parent-push-full.log" + ] + }, + "next_executable_plan": "/tmp/canopy-native-generated-publisher-plan.md" +} diff --git a/docs/evidence/native-checks-ci-20261005.json b/docs/evidence/native-checks-ci-20261005.json new file mode 100644 index 00000000..dd0ac1c8 --- /dev/null +++ b/docs/evidence/native-checks-ci-20261005.json @@ -0,0 +1,460 @@ +{ + "recorded_at_utc": "2026-10-05T07:25:13.004826+00:00", + "base_head": "3e40f19ef40694d5f34709b963c7c4f924b01314", + "source_files": 502, + "rust_files": 484, + "source_hash_digest": "d13027792e8b7354fb58c7a152f7d38534083d5389ddffebc35be54bf133eda3", + "source_digest_algorithm": "SHA256 of compact sorted-key JSON path-to-file-SHA256 map, including Rust/SQL/TOML/lock/YAML sources", + "source_manifest": "/tmp/canopy-native-checks-final-source.json", + "toolchain": "Rust 1.98.0, macOS ARM64, isolated target, RUSTC_WRAPPER empty", + "release_qualified": false, + "ci_pass_claim": false, + "reproductions": { + "removed_object_table_http": { + "log": "/tmp/canopy-native-checks-repro1.log", + "sha256": "950353be002daf6f176827cfb10846e71c36e74ef9dc30668fd1b9dadcc759fc", + "exit_code": 101, + "source_note": "Base commit 3e40f19; original HTTP and stock-Git production integration test before native checks conversion.", + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 114 filtered out; finished in 3.47s" + ], + "failed_cases": [ + "checks::commit_checks_bind_reporters_versions_and_reruns_across_recovery" + ] + }, + "initial_adapter_compile": { + "log": "/tmp/canopy-native-checks-check1.log", + "sha256": "ac4296ba570e3cad197f69a30edc89eb9af04e1d8cdc63bdd7bab72b912a790b", + "exit_code": 101, + "source_note": "Intermediate source; private codec helper and missing SQL client accessor. Not final-source validation.", + "summaries": [], + "failed_cases": [] + }, + "test_fixture_compile": { + "log": "/tmp/canopy-native-checks-negative1.log", + "sha256": "7c5eccef3dde7599010dc85e1d9a5073095730f86452c06de3e90c055e6e1f43", + "exit_code": 101, + "source_note": "Intermediate source; private adapter and incorrect TokenScope namespace corrected in the test without widening the production API.", + "summaries": [], + "failed_cases": [] + }, + "durable_rejection_incorrectly_treated_as_service_failure": { + "log": "/tmp/canopy-native-checks-e2e1.log", + "sha256": "a56370d557b3a2c4ca7be810a454d0f8e6c5b23fc5524e9dde2ef9335443d2ff", + "exit_code": 101, + "source_note": "Intermediate source: 503 instead of expected 403. Final adapter preserves recorded domain rejection receipt.", + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 114 filtered out; finished in 3.00s" + ], + "failed_cases": [ + "checks::commit_checks_bind_reporters_versions_and_reruns_across_recovery" + ] + }, + "receiver_rejection_contract": { + "log": "/tmp/canopy-native-checks-negative2.log", + "sha256": "a474e3a9b5df2dccb3391397339c57716fdb0939defba249b8b4ad187d705313", + "exit_code": 101, + "source_note": "Intermediate tests treated InvocationError::Rejected as transport failure; receiver kept the correct durable rejection. Final tests explicitly inspect its recorded outcome.", + "summaries": [ + "test result: FAILED. 0 passed; 2 failed; 0 ignored; 0 measured; 715 filtered out; finished in 0.98s" + ], + "failed_cases": [ + "server::residency::tests::serving::browser::checks::native_check_receivers_recheck_revoked_membership_and_public_visibility", + "server::residency::tests::serving::browser::checks::native_check_receivers_bind_commit_actor_repository_and_live_retained_pin" + ] + } + }, + "isolated_timing_probes": { + "source_digest": "d13027792e8b7354fb58c7a152f7d38534083d5389ddffebc35be54bf133eda3", + "binary": "/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_server-d080aca381ae9ba9", + "rollover": { + "log": "/tmp/canopy-native-checks-rollover-focused.log", + "sha256": "78390c538af17cc2fbdf448226ea418eb631c9741066ef68bfc3502f359306b2", + "exit_code": 0, + "source_note": null, + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 716 filtered out; finished in 9.47s" + ], + "failed_cases": [] + }, + "ceiling": { + "log": "/tmp/canopy-native-checks-ceiling-focused.log", + "sha256": "7676d4ed53c4d44226d66f8c3ec00a0a255225594d3fff4c82ed04394540cfac", + "exit_code": 0, + "source_note": null, + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 716 filtered out; finished in 4.87s" + ], + "failed_cases": [] + }, + "interpretation": "Both pass in isolation. This does not establish root cause or supersede the two failed library cases in the full workload." + }, + "final_validation": { + "source_digest": "d13027792e8b7354fb58c7a152f7d38534083d5389ddffebc35be54bf133eda3", + "phases": [ + { + "name": "negative", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "native_check_receivers", + "--locked", + "--", + "--nocapture" + ], + "exit_code": 0, + "seconds": 110.13, + "log": "/tmp/canopy-native-checks-negative-final.log", + "log_sha256": "9e262997db3be0a33dec01cd157dd05e7dfcf3e07c418ce82c3b1c3cf8756785", + "summaries": [ + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 715 filtered out; finished in 3.05s" + ], + "failed_cases": [] + }, + { + "name": "e2e", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--test", + "multi_server", + "checks::commit_checks_bind_reporters_versions_and_reruns_across_recovery", + "--locked", + "--", + "--exact", + "--nocapture" + ], + "exit_code": 0, + "seconds": 62.37, + "log": "/tmp/canopy-native-checks-e2e-final.log", + "log_sha256": "658ed7ebcc41e00e3a4a1cf70553f565ca8f3c0a8ae4ddb51b38be03b924201b", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 114 filtered out; finished in 3.62s" + ], + "failed_cases": [] + }, + { + "name": "branch-rules", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--test", + "multi_server", + "branch_rules::", + "--locked", + "--", + "--nocapture" + ], + "exit_code": 0, + "seconds": 18.38, + "log": "/tmp/canopy-native-checks-branch-rules-final.log", + "log_sha256": "d5b19071e749da1c1cf60eeccd77e2f2be13a6b081fc1920ae543905e9973af2", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 114 filtered out; finished in 17.28s" + ], + "failed_cases": [] + }, + { + "name": "workspace", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--locked", + "--no-fail-fast" + ], + "exit_code": 101, + "seconds": 796.8, + "log": "/tmp/canopy-native-checks-workspace-final.log", + "log_sha256": "0ba7361ecb050ff5c785abd5d6cfa073113803e307d708206c8f380187b009e7", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.51s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.71s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 716 filtered out; finished in 0.01s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 716 filtered out; finished in 0.10s", + "test result: FAILED. 715 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; finished in 414.61s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 9.72s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s", + "test result: FAILED. 77 passed; 29 failed; 9 ignored; 0 measured; 0 filtered out; finished in 349.32s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.51s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.29s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.41s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s" + ], + "failed_cases": [ + "packs::publication::tests::serving::pool::sequential_generations_roll_over_idle_slots_without_client_retries", + "packs::publication::tests::staging_service::publication::bound_publication_wait_ceiling_never_admits_or_executes_the_retained_command", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "comparison::comparison_matches_git_and_preserves_exact_views_across_recovery", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "comparison::comparison_rejects_oversized_change_sets_without_partial_results", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "comparison::patches::patches_apply_with_stock_git_and_reject_excess_work_without_truncation", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "comparison::comparison_merge_bases_match_git_for_wide_unrelated_and_crisscross_histories", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "pulls::pull_reviews_follow_exact_revisions_and_membership_across_recovery", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery", + "a_second_node_clones_from_the_published_root_after_local_disk_loss", + "repository_cell_publishes_objects_and_refs_atomically", + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ] + }, + { + "name": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 54.11, + "log": "/tmp/canopy-native-checks-clippy-final.log", + "log_sha256": "cd809d40250466bc8b0688fd8832cce9186a7e01f758313a640246b24e476e6e", + "summaries": [], + "failed_cases": [] + }, + { + "name": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 101.06, + "log": "/tmp/canopy-native-checks-build-final.log", + "log_sha256": "4ea2d32201c07d9398ffdb4082cd6931713215605e743b272634faec758da981", + "summaries": [], + "failed_cases": [] + }, + { + "name": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.72, + "log": "/tmp/canopy-native-checks-fmt-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "name": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 42.25, + "log": "/tmp/canopy-native-checks-harness-final.log", + "log_sha256": "3f424359086bead86badfb20ba3cc66c89e9d9c21cb191190676941f11d85f23", + "summaries": [], + "failed_cases": [] + }, + { + "name": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.05, + "log": "/tmp/canopy-native-checks-diff-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + } + ], + "complete": true, + "release_qualified": false, + "source_unchanged": true + }, + "unique_workspace_inventory": { + "targets": [ + { + "target": "Running unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_git_format-cdf8cea2f92fe6a4)", + "passed": 6, + "failed": 0, + "ignored": 0 + }, + { + "target": "Running unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_object_storage-4a0661c5c4765140)", + "passed": 15, + "failed": 0, + "ignored": 0 + }, + { + "target": "Running unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_server-d080aca381ae9ba9)", + "passed": 715, + "failed": 2, + "ignored": 0 + }, + { + "target": "Running unittests src/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy-16a4bf977c56198e)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "target": "Running tests/directory_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/directory_cell-31a0f4eea5beeae3)", + "passed": 13, + "failed": 0, + "ignored": 0 + }, + { + "target": "Running tests/git_http.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/git_http-afa4d1d9a2db0179)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "target": "Running tests/multi_server/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/multi_server-3e08ee00a07d7bde)", + "passed": 77, + "failed": 29, + "ignored": 9 + }, + { + "target": "Running tests/owner_restart.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/owner_restart-be32ecc90c554a17)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "target": "Running tests/repository_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/repository_cell-322afb5848ed5e86)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "target": "Running tests/smart_http/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/smart_http-18ac06122d3c394f)", + "passed": 0, + "failed": 1, + "ignored": 0 + } + ], + "totals": { + "passed": 830, + "failed": 34, + "ignored": 9 + }, + "counting_note": "Largest aggregate by case count per Cargo Running target; nested child summaries and focused reruns excluded. Existing ignored cases were not executed." + }, + "parent_linux_ci": [ + { + "head": "3e40f19ef40694d5f34709b963c7c4f924b01314", + "run": 37272804247, + "rust_job": 111643212719, + "url": "https://github.com/crabbuild/canopy/actions/runs/37272804247/job/111643212719", + "conclusion": "FAILURE", + "rustc": "1.98.1 (48a229cea 2026-09-01)", + "cargo": "1.98.1 (797e8a9bc 2026-08-05)", + "git": "2.55.0", + "server_library": { + "passed": 715, + "failed": 0 + }, + "multi_server": { + "passed": 75, + "failed": 35, + "ignored": 9 + }, + "log": "/tmp/canopy-native-checks-parent-push-full.log", + "log_sha256": "c1457999fa277df1820c299458a73b6c97f5cd473ac726da5ebc7a66dd7ad574" + }, + { + "head": "3e40f19ef40694d5f34709b963c7c4f924b01314", + "run": 37272808437, + "rust_job": 111643224703, + "url": "https://github.com/crabbuild/canopy/actions/runs/37272808437/job/111643224703", + "conclusion": "FAILURE", + "rustc": "1.98.1 (48a229cea 2026-09-01)", + "cargo": "1.98.1 (797e8a9bc 2026-08-05)", + "git": "2.55.0", + "server_library": { + "passed": 715, + "failed": 0 + }, + "multi_server": { + "passed": 76, + "failed": 34, + "ignored": 9 + }, + "log": "/tmp/canopy-native-checks-parent-pr-full.log", + "log_sha256": "16683781287eda2c9660e719a6e12f4852c50d71f9fcf0472521d590030b61b1" + } + ], + "coverage": [ + "Actual HTTP and stock-Git push/check integration: reporter permissions, concurrent logical UUID retries, ordering, policy-version changes, terminal updates, pagination, rename, and independent owner recovery.", + "Actual resident production registry and physically verified SHA-1/SHA-256 metadata: MAC corruption, actor/repository/OID substitution, another actual Cell receiver, missing proof, noncommit targets, a retained generation after current-head advancement, fresh context version, actual producer drain, and rejected insert count.", + "Actual read-only reporter grant/revocation and anonymous public-to-private visibility change before final receiver.", + "Proof issuance uses existing tracked read_owned ownership and authenticated metadata headers; final receiver uses current access, exact retained fact, live pin, and actual command owner fence. Existing metadata structure and immutable MAC envelope reused." + ], + "remaining_high_priority": [ + "Diagnose workload-dependent serving rollover Capacity and bound expiry terminal-state assertion; both failed in the full library suite but pass isolated.", + "Complete native pull creation/list/detail/review/ref authority and default-branch mutation; generated merge/rebase/candidate writers still use legacy metadata.", + "Resolve Linux native rejected-push/bulk admission or custody expiry and pre-Bind late Write-revocation terminal refusal without weakening final authorization.", + "Complete peer ownership/residency, source-independent backup, reachable-only cold/filtered fetch, and standalone real-resident integrations.", + "Complete physical ownership/recovery, final DDL hard cutover, full-history Linux/Kubernetes/Chromium qualification and 10,000-developer capacity gates." + ], + "sdk": { + "path": "/Users/haipingfu/.codex/worktrees/durable-command-recovery/cellule", + "head": "161067f5a21703b3e257024bcb64e565fd9657b4", + "clean_when_checked": true + } +} diff --git a/docs/evidence/native-ci-fixture-migration-20261006.md b/docs/evidence/native-ci-fixture-migration-20261006.md new file mode 100644 index 00000000..4218372b --- /dev/null +++ b/docs/evidence/native-ci-fixture-migration-20261006.md @@ -0,0 +1,59 @@ +# Native CI fixture migration + +The repository cutover registers native catalog publication, staging custody, +and serving owners. A detached `RepositoryCell` is not a serving owner, and the +old `PutObjects` command (5) is deliberately not registered. Standalone tests +must create repositories through a leased `CanopyServer`, just as production +requests do. The workflow continues to run every integration test target. + +The standalone entry points now qualify these production paths: + +- `owner_restart`: publish commit/tree/blob/tag and gitlink graphs; stop the + first owner, delete its entire data directory, restore on a new node, clone + and run strict Git fsck, and delete/recreate a branch after restoration. +- `repository_cell`: require the retired ingestion command to stay absent; + publish and clone 600 delta candidates across selection-page boundaries in + SHA-1 and SHA-256 repositories; reject an atomic two-ref update after a policy + change and prove that neither ref changes. +- `smart_http`: preserve unsupported-media handling; exercise stock push + options, both Git protocol versions, blobless clone and explicit lazy fetch, + reject a guessed unreachable object, and publish a delete-only push. + +The old helper modules under `tests/repository_cell` and `tests/smart_http` +remain available as historical fixture references. Their loose-object SQL, +chunk uploads, detached gateway and graph-certificate setup are not fixtures +for the supported storage model. Native coverage lives at the following seams: + +| Former fixture area | Active coverage | +| --- | --- | +| Object batch/page/chunk bounds and rollback | `src/packs/metadata/tests.rs`, `src/packs/catalog/graph_spool/tests.rs`, `src/packs/verification/spool/tests.rs`, and the 600-object standalone case | +| Native object format, index binding and canonical bytes | `canopy-git-format/src/pack_index/tests.rs`, `src/packs/catalog/native/tests.rs`, `src/git_cache/tests.rs`, `src/packs/verification/tests.rs` | +| Graph/frontier preparation and all-or-none refs | `src/packs/publication/tests/frontier.rs`, `refs.rs`, `ref_policy`, and standalone atomic publication | +| Cache reuse, pressure and physical ownership | `src/packs/catalog/native/tests.rs`, `src/server/residency/tests`, `tests/multi_server/partial_clone.rs`, `ssh/fetch.rs`, `ssh/filtered_preparation.rs` | +| Encoded input, uploads and completion | `src/git_input/tests.rs`, `src/packs/publication/tests/native_capture.rs`, `tests/multi_server/push_options.rs`, `ssh/publication.rs` | +| Issues, checks, pulls, reviews, merge, rebase, visibility, default branch | Corresponding `tests/multi_server` modules and native publication unit tests | + +Selective reads verify each original provider part against its authenticated +manifest and verify the extracted canonical object against its certified kind, +size, Git OID and BLAKE3 digest. Their private sparse pack is an input to native +Git decoding; it is not represented as a newly verified complete pack. Incoming +pack verification and writer preparation retain their complete-pack checks. + +Size filters inspect bodies in a disposable workspace. The persistent cache +retains size-inspection candidates permitted by the other combined filters; +Git still applies the original size threshold to the response. Omitted bodies +outside the permitted tree/type selection are not retained. + +A late SSH access downgrade can emit the original per-ref failure report only +when the staging owner reports a known inactive terminal attempt and current +access is below write. This is a wire rejection, not a durable publication or +success acknowledgement. Uncertain mutations and lost replies keep their +original error/recovery paths. + +Explicit producer-root workspaces install each certified complete pack/index +pair once and read selected bodies from that workspace. This keeps native +baselines packed, avoids per-object child processes during construction, and +preserves the original cancellation/expiry/revocation file-admission tests. +Ref-based fetch workspaces continue to use selective extraction. The producer +correction changes no test assertions or admission limits; all 11 workspace +unit tests pass at this revision. diff --git a/docs/evidence/native-ci-routing-20261005.json b/docs/evidence/native-ci-routing-20261005.json new file mode 100644 index 00000000..4f36f992 --- /dev/null +++ b/docs/evidence/native-ci-routing-20261005.json @@ -0,0 +1,293 @@ +{ + "recorded_at_utc": "2026-10-06T03:57:58.668291+00:00", + "base_head": "68dad78c6bf125c95fdb13d289553e7a8ac9d485", + "host": "macOS, Rust 1.98.0", + "release_qualified": false, + "ci_run": "https://github.com/crabbuild/canopy/actions/runs/37397787531", + "sdk_pin": "161067f5a21703b3e257024bcb64e565fd9657b4", + "source_digest_algorithm": "SHA256 of compact sorted-key JSON mapping each Rust/SQL/TOML/lock/YAML source path to its SHA256", + "full_workspace_source_digest": "164030e0480e3b113d323bf91c4ea4a98458f7d87d887d41487ed059bd00994e", + "full_workspace_source_manifest": "/tmp/canopy-routing-final-source.json", + "final_source_digest": "7869f6a211217221da60792d2a2283235629733ef0bfc203588c8dfaaec601ec", + "final_source_manifest": "/tmp/canopy-routing-corrected-source.json", + "source_files": 531, + "source_delta_after_full_workspace": [ + "crates/canopy-server/src/packs/publication/tests/durable_recovery.rs" + ], + "delta_description": "Only the durable-recovery fixture admission assertion changed from 32 KiB to 48 KiB for the newly armed publication bundle. No production code changed after the full diagnostic. The corrected fixture was rerun and passed.", + "full_workspace": { + "source_digest": "164030e0480e3b113d323bf91c4ea4a98458f7d87d887d41487ed059bd00994e", + "complete": true, + "phases": [ + { + "name": "workspace", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--locked", + "--no-fail-fast" + ], + "exit_code": 101, + "seconds": 942.45, + "log": "/tmp/canopy-routing-final-workspace.log", + "log_sha256": "7f555ec9dca349e2b62a9ecc48845126a866a1bccf1a4eb46dfde231ce7b7666", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.98s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.44s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 754 filtered out; finished in 0.01s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 754 filtered out; finished in 0.10s", + "test result: FAILED. 754 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 406.28s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 11.88s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s", + "test result: FAILED. 97 passed; 10 failed; 9 ignored; 0 measured; 0 filtered out; finished in 419.99s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.60s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.29s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.43s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s" + ], + "failed_cases": [ + "packs::publication::tests::native_capture::native_receive_durable_root_recovery_restores_joint_publication_and_fences_absent_old_owner", + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "listener_handoff::mismatched_listener_is_rejected_before_workspace_and_storage_writes", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "a_second_node_clones_from_the_published_root_after_local_disk_loss", + "repository_cell_publishes_objects_and_refs_atomically", + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ] + }, + { + "name": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 78.45, + "log": "/tmp/canopy-routing-final-build.log", + "log_sha256": "97748165ff048af9ca6d46e7fcd3ba6594771b12e99cc7d63cdf627d79f3d91e", + "summaries": [], + "failed_cases": [] + }, + { + "name": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.52, + "log": "/tmp/canopy-routing-final-fmt.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "name": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.05, + "log": "/tmp/canopy-routing-final-diff.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + } + ], + "source_unchanged": true, + "finished_at_utc": "2026-10-06T03:53:54.205561+00:00" + }, + "final_focused": { + "source_digest": "7869f6a211217221da60792d2a2283235629733ef0bfc203588c8dfaaec601ec", + "complete": true, + "phases": [ + { + "name": "phase", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--locked", + "--lib", + "packs::publication::recovery::phase::" + ], + "exit_code": 0, + "seconds": 1.16, + "log": "/tmp/canopy-routing-corrected-phase.log", + "log_sha256": "38b72abb32e39250aedbb642143525009647416f9bce3ab4fedc20d15c0e199c", + "summaries": [ + "test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 752 filtered out; finished in 0.02s" + ], + "failed_cases": [] + }, + { + "name": "registry", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--locked", + "--lib", + "production_registers_packed_and_policy_metadata_contracts" + ], + "exit_code": 0, + "seconds": 0.57, + "log": "/tmp/canopy-routing-corrected-registry.log", + "log_sha256": "e61fea3524a74e4c6c9b80fcb9daf643065a900b846b8ac90f943b0718280862", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 754 filtered out; finished in 0.16s" + ], + "failed_cases": [] + }, + { + "name": "thread", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--locked", + "--lib", + "native_thread_receiver_binds_verified_anchor_and_rechecks_editorial_revision" + ], + "exit_code": 0, + "seconds": 3.1, + "log": "/tmp/canopy-routing-corrected-thread.log", + "log_sha256": "04f78821111c11b5eadf2701fdc11c69adc1ca9ff0f7efc172f5d8ed714b3842", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 754 filtered out; finished in 2.70s" + ], + "failed_cases": [] + }, + { + "name": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 36.13, + "log": "/tmp/canopy-routing-corrected-clippy.log", + "log_sha256": "5bbde0b9daf654bf2893c82e0ca225ecba76fdd98a85b8cae636df83b197c3c8", + "summaries": [], + "failed_cases": [] + }, + { + "name": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.45, + "log": "/tmp/canopy-routing-corrected-fmt.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "name": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.04, + "log": "/tmp/canopy-routing-corrected-diff.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + } + ], + "source_unchanged": true, + "finished_at_utc": "2026-10-06T03:56:59.917436+00:00" + }, + "additional_checks": [ + { + "name": "cold-owner", + "log": "/tmp/canopy-armed-cold-fixed.log", + "exit_code": 0, + "log_sha256": "6d831a537071030013e200ed3379512c827f48ddfda5f87279ecfd77ee28dc96", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 754 filtered out; finished in 5.06s" + ], + "failed_cases": [] + }, + { + "name": "discovery-focused", + "log": "/tmp/canopy-discovery-native-repro.log", + "exit_code": 0, + "log_sha256": "ffec81ddc1b0452bedd75465553ea3a1e2a1dd4548ac50ff59af03151334da1a", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 115 filtered out; finished in 6.07s" + ], + "failed_cases": [] + }, + { + "name": "listener-focused", + "log": "/tmp/canopy-listener-repro.log", + "exit_code": 0, + "log_sha256": "a19dcea73055908eac0e766f749d7fc70e3efd94900f41538d118bd91f7c0d22", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 115 filtered out; finished in 0.00s" + ], + "failed_cases": [] + }, + { + "name": "harness", + "log": "/tmp/canopy-routing-harness.log", + "exit_code": 0, + "log_sha256": "6e7fb525f14fde752af6b0614c73f13530f9e177082866daff05ad3cbc9b9f49", + "summaries": [], + "failed_cases": [] + } + ], + "remaining": [ + "native backup artifact graph (retired objects/git_packs queries)", + "selective native provider reads and retained object cache", + "read-only residency restoration root advancement", + "late pre-bind SSH ACL revocation needs a purpose-restricted negative staging path", + "standalone owner_restart/repository_cell/smart_http fixtures use detached or retired APIs", + "discovery latency and listener rebinding fail under full local contention; focused tests pass", + "three option-error failures from the referenced Linux run did not reproduce on macOS; new-head Linux qualification required" + ] +} diff --git a/docs/evidence/native-discovery-ci-20261006.json b/docs/evidence/native-discovery-ci-20261006.json new file mode 100644 index 00000000..0c986821 --- /dev/null +++ b/docs/evidence/native-discovery-ci-20261006.json @@ -0,0 +1,30 @@ +{ + "base_commit": "8ec0aefdea0b99910cdcd755001648e1c5ddd941", + "problem": "Both Linux runs at 772c6d0 passed all 757 server unit tests and 110 multi-server cases, failing only ref discovery while a large native pack part was deliberately paused. Advertised commit or tag bodies can share that physical part.", + "fix": "Stream fully peeled packed refs from certified ref and catalog metadata in bounded pages. Native Git owns v0/v2 discovery and capability formatting; discovery does not fetch native pack bodies. Validate tag-edge kinds and retain depth bound and fresh authority checks. The admitted replacement uses packed-refs.lock then atomic rename, with conservative disk charging.", + "qualification": { + "paused_pack_discovery": { + "result": "PASS with unchanged two-second requests", + "seconds": 10.99 + }, + "stock_git_tags_history_shallow_and_cold_clone": { + "result": "PASS", + "seconds": 20.64 + }, + "default_branch_fresh_restore_discovery": { + "result": "PASS", + "seconds": 9.01 + }, + "clippy": "PASS: workspace, all targets, locked, warnings denied", + "format": "PASS", + "diff_check": "PASS", + "full_workspace": "pending", + "linux_provider": "pending" + }, + "sources": { + "crates/canopy-server/src/git_cache/serving_refs.rs": "d080e0e19a179ca2bd3fa668c54145499e6fdc29d38f7932643cb4e828e3b626", + "crates/canopy-server/src/packs/publication/serving/session/native_base.rs": "96c9358ac1b2e1255bd9b97840eac1b00d464605a2248d91c41c7e1a20a6655a", + "crates/canopy-server/src/packs/publication/serving/session/workspace.rs": "4d17485a9155d6c0e90d92fdad2ca08d28f699a52d5f326720958e139514caf8", + "crates/canopy-server/src/packs/publication/serving/session/workspace/prepare.rs": "af01cc2468948f4a6d9fc3442d19c782908de67103da56b1259efe0d4b08550a" + } +} diff --git a/docs/evidence/native-fetch-renewal-ci-20261006.json b/docs/evidence/native-fetch-renewal-ci-20261006.json new file mode 100644 index 00000000..707b69f9 --- /dev/null +++ b/docs/evidence/native-fetch-renewal-ci-20261006.json @@ -0,0 +1,28 @@ +{ + "base_commit": "772c6d00aee5ee6201a2175a35d41d3b0e65e891", + "problem": "Preparation refreshed the same serving pin but read_owned rejected completion after its original deadline. Full local run: 756 server unit tests passed, one certified HTTP clone returned HTTP 500; isolated clone passed.", + "reproduction": { + "log": "/tmp/canopy-fetch-renewal-before.log", + "result": "FAIL: Inactive after the gated provider spans the original lease and the exact owner renews twice" + }, + "fix": "Fetch and advertisement preparation use existing renewable read_session and fresh final authority observation; no lease duration or resource/test limits changed.", + "qualification": { + "workspace_tests": { + "passed": 12, + "seconds": 30.5, + "log": "/tmp/canopy-fetch-renewal-after.log" + }, + "clippy": { + "result": "PASS: workspace, all targets, locked, warnings denied", + "seconds": 49.27 + }, + "format": "PASS", + "diff_check": "PASS", + "full_workspace": "pending", + "linux_provider": "pending" + }, + "sources": { + "crates/canopy-server/src/packs/publication/serving/session/workspace/prepare.rs": "6f77f31a111737c4ed87490889a5d0f66ab317266c7cc75a18531bd55b709fe8", + "crates/canopy-server/src/packs/publication/tests/serving/workspace.rs": "009a0c43a035e5c309641b04a4ea44b52ddd6e71cf9baecf0ca8d43c6f54bde5" + } +} diff --git a/docs/evidence/native-generated-publication-ci-20261005.json b/docs/evidence/native-generated-publication-ci-20261005.json new file mode 100644 index 00000000..a9917e8b --- /dev/null +++ b/docs/evidence/native-generated-publication-ci-20261005.json @@ -0,0 +1,319 @@ +{ + "recorded_at_utc": "2026-10-06T01:09:35.971053+00:00", + "base_head": "1ebb2786a3f15622d59a9b0d6420c1e7d0517e1b", + "host": "macOS, Rust 1.98.0; exact new-head Linux qualification required", + "release_qualified": false, + "source_files": 530, + "rust_files": 512, + "source_hash_digest": "d17d143258c8fab52eac31f699e42cd54259be4af8ec48d5b7c2d43c5caabb8f", + "source_manifest": "/tmp/canopy-generated-final-source.json", + "source_digest_algorithm": "SHA256 of compact sorted-key JSON mapping each Rust/SQL/TOML/lock/YAML path to its file SHA256", + "source_unchanged_during_validation": true, + "validation": { + "source_digest": "d17d143258c8fab52eac31f699e42cd54259be4af8ec48d5b7c2d43c5caabb8f", + "complete": true, + "phases": [ + { + "name": "retry", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--locked", + "--lib", + "generated_candidate_intent_joins_uncertain_original_and_retries_only_after_known_drain" + ], + "exit_code": 0, + "seconds": 102.49, + "log": "/tmp/canopy-generated-final-retry.log", + "log_sha256": "b378b5a93e34d18298ee8e47c0a1c53e430d21458110f96e96810366e5ee46fc", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 752 filtered out; finished in 0.24s" + ], + "failed_cases": [] + }, + { + "name": "transactions", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--locked", + "--lib", + "packs::publication::tests::candidate_publication::" + ], + "exit_code": 0, + "seconds": 2.17, + "log": "/tmp/canopy-generated-final-transactions.log", + "log_sha256": "b809ea8f3975213a973df8535e78a0bcea6b70b235458fa72d93f88d5ddb26e7", + "summaries": [ + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 751 filtered out; finished in 1.42s" + ], + "failed_cases": [] + }, + { + "name": "merge", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--locked", + "--lib", + "native_merge" + ], + "exit_code": 0, + "seconds": 7.56, + "log": "/tmp/canopy-generated-final-merge.log", + "log_sha256": "728e3b19d0f616ba7af2f07690e16ebcd7254a6851811ca9a3425aba5ad51b40", + "summaries": [ + "test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 739 filtered out; finished in 7.22s" + ], + "failed_cases": [] + }, + { + "name": "head", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--locked", + "--lib", + "native_head" + ], + "exit_code": 0, + "seconds": 5.56, + "log": "/tmp/canopy-generated-final-head.log", + "log_sha256": "10423fb77d9fc8ddb61e71a28289b9db777045a42a19cca7d1fbb93b84649959", + "summaries": [ + "test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 750 filtered out; finished in 5.20s" + ], + "failed_cases": [] + }, + { + "name": "candidates", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--locked", + "--test", + "multi_server", + "candidates::" + ], + "exit_code": 0, + "seconds": 104.03, + "log": "/tmp/canopy-generated-final-candidates.log", + "log_sha256": "59ef6ae7977bff2631f7df3113d9a9959283453d69922e5dfc01ac74ebcbbdcf", + "summaries": [ + "test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 113 filtered out; finished in 26.74s" + ], + "failed_cases": [] + }, + { + "name": "rebase", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--locked", + "--test", + "multi_server", + "rebase::" + ], + "exit_code": 0, + "seconds": 18.21, + "log": "/tmp/canopy-generated-final-rebase.log", + "log_sha256": "520654d5b43656b0a93a69d87a6a677b66a62f6a80ade9b5e82504d51d166323", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 115 filtered out; finished in 16.83s" + ], + "failed_cases": [] + }, + { + "name": "sha256", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--locked", + "--test", + "multi_server", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "--", + "--exact" + ], + "exit_code": 0, + "seconds": 9.4, + "log": "/tmp/canopy-generated-final-sha256.log", + "log_sha256": "49ad196cf211f56c42d786e4f38ed83d20efeeace75ffde990ab2819cca37b9b", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 115 filtered out; finished in 8.50s" + ], + "failed_cases": [] + }, + { + "name": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 44.11, + "log": "/tmp/canopy-generated-final-clippy.log", + "log_sha256": "8016738f3743ff7a6344dd03d636804e776627d3c4c34cc31ac7fa0e056d1c2d", + "summaries": [], + "failed_cases": [] + }, + { + "name": "workspace", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--locked", + "--no-fail-fast" + ], + "exit_code": 101, + "seconds": 797.82, + "log": "/tmp/canopy-generated-final-workspace.log", + "log_sha256": "b8e6d143a85892184d5f99ba559ebb427134d453a145e2864eeb7183f61ea549", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.40s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.05s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 752 filtered out; finished in 0.01s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 752 filtered out; finished in 0.09s", + "test result: ok. 753 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 360.54s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 14.43s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.16s", + "test result: FAILED. 93 passed; 14 failed; 9 ignored; 0 measured; 0 filtered out; finished in 398.62s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.50s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.39s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.65s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s" + ], + "failed_cases": [ + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery", + "a_second_node_clones_from_the_published_root_after_local_disk_loss", + "repository_cell_publishes_objects_and_refs_atomically", + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ] + }, + { + "name": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 64.78, + "log": "/tmp/canopy-generated-final-build.log", + "log_sha256": "0900ec22ab155c16e1caea5a43d4c5f9d9cd5d0898632803a9736b4a4740f671", + "summaries": [], + "failed_cases": [] + }, + { + "name": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.59, + "log": "/tmp/canopy-generated-final-fmt.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "name": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 43.9, + "log": "/tmp/canopy-generated-final-harness.log", + "log_sha256": "651f2b86f1312d3534d475dea692087fad2ce3d2d223302def85f1913d1a501a", + "summaries": [], + "failed_cases": [] + }, + { + "name": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.15, + "log": "/tmp/canopy-generated-final-diff.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + } + ], + "source_unchanged": true, + "release_qualified": false + }, + "historical_linux": { + "head": "1ebb2786a3f15622d59a9b0d6420c1e7d0517e1b", + "run": "https://github.com/crabbuild/canopy/actions/runs/37391496693", + "result": "FAILURE; multi-server 89 passed, 22 failed, 9 ignored", + "log_sha256": "58862df442dc9b52bc73ce6818b54f2afea78bb45e56e3e18128d8b796afaedd", + "qualification_of_current_source": false + }, + "qualification_note": "Current-source full workspace remains failed. Nested child-process library summaries are not additional independent passing cases. No tests or assertions were disabled; no large-team throughput or remote-provider qualification is claimed." +} diff --git a/docs/evidence/native-head-ci-20261005.json b/docs/evidence/native-head-ci-20261005.json new file mode 100644 index 00000000..a6e37b1a --- /dev/null +++ b/docs/evidence/native-head-ci-20261005.json @@ -0,0 +1,760 @@ +{ + "recorded_at_utc": "2026-10-05T23:57:21.753158+00:00", + "base_head": "7c232ea0d4d384c0ac2c5cf0dc401ce454e1a00f", + "host": "macOS, Rust 1.98.0; exact new-head Linux qualification required", + "release_qualified": false, + "source_files": 524, + "rust_files": 506, + "source_hash_digest": "d7b56a0f6e789648dda2a9ebe3dfeb30ad5d747a855bdecd400ca9d05069e8ea", + "source_manifest": "/tmp/canopy-head-summary-source.json", + "source_digest_algorithm": "SHA256 of compact sorted-key JSON mapping each Rust/SQL/TOML/lock/YAML path to its file SHA256", + "source_unchanged_during_validation": true, + "validation": { + "source_digest": "d7b56a0f6e789648dda2a9ebe3dfeb30ad5d747a855bdecd400ca9d05069e8ea", + "complete": true, + "phases": [ + { + "name": "head", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--locked", + "--lib", + "native_head" + ], + "exit_code": 0, + "seconds": 134.59, + "log": "/tmp/canopy-head-summary-head.log", + "log_sha256": "e0e3a3bd4c022f22bf5aeca9688d960d47adc7a983876a82ab0f44fa7aefd35d", + "summaries": [ + "test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 747 filtered out; finished in 6.08s" + ], + "failed_cases": [] + }, + { + "name": "merge", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--locked", + "--lib", + "native_merge" + ], + "exit_code": 0, + "seconds": 6.97, + "log": "/tmp/canopy-head-summary-merge.log", + "log_sha256": "4d34eed2c82a7d19921d9052d649c3c7d2cd442bf97399de7b74000b8a2336ad", + "summaries": [ + "test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 736 filtered out; finished in 5.80s" + ], + "failed_cases": [] + }, + { + "name": "public", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--locked", + "--test", + "multi_server", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "--", + "--exact" + ], + "exit_code": 0, + "seconds": 88.02, + "log": "/tmp/canopy-head-summary-public.log", + "log_sha256": "756652712b533832935ef2281d52d5d0d7dab8f09309a6f180729aa9f203880a", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 115 filtered out; finished in 7.59s" + ], + "failed_cases": [] + }, + { + "name": "cold", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--locked", + "--test", + "multi_server", + "peers::cold_activation::" + ], + "exit_code": 0, + "seconds": 4.04, + "log": "/tmp/canopy-head-summary-cold.log", + "log_sha256": "5f928564e8c881d28aa4e4bc969e5ad90260342cc1938c7d9949827e1af11e3a", + "summaries": [ + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 114 filtered out; finished in 2.54s" + ], + "failed_cases": [] + }, + { + "name": "bootstrap", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--locked", + "--test", + "multi_server", + "workspace::new_repositories_bootstrap_the_production_packed_catalog_before_becoming_ready", + "--", + "--exact" + ], + "exit_code": 0, + "seconds": 3.85, + "log": "/tmp/canopy-head-summary-bootstrap.log", + "log_sha256": "94df72fea51923e65570f0d31adae86829f292a60f4345556a00ff922c46ea6b", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 115 filtered out; finished in 3.37s" + ], + "failed_cases": [] + }, + { + "name": "oversize", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--locked", + "--test", + "multi_server", + "comparison::comparison_rejects_oversized_change_sets_without_partial_results", + "--", + "--exact" + ], + "exit_code": 0, + "seconds": 3.46, + "log": "/tmp/canopy-head-summary-oversize.log", + "log_sha256": "23884c775d6f7881e59108944a126d597d41de4ec683833504f5c14485bcaa74", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 115 filtered out; finished in 3.01s" + ], + "failed_cases": [] + }, + { + "name": "discovery", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--locked", + "--test", + "multi_server", + "discovery::repository_discovery_filters_current_acl_and_pages_past_revoked_grants_after_restore", + "--", + "--exact" + ], + "exit_code": 0, + "seconds": 17.48, + "log": "/tmp/canopy-head-summary-discovery.log", + "log_sha256": "df9a69863e3a37d28bc2cd69527e8cc693409e2496e2ef61bc728a75bade3dc5", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 115 filtered out; finished in 16.82s" + ], + "failed_cases": [] + }, + { + "name": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 49.73, + "log": "/tmp/canopy-head-summary-clippy.log", + "log_sha256": "4bb7f80a2815bf59e3f974d5dfb1de1bf100921e2447b23d3711499424ca8f98", + "summaries": [], + "failed_cases": [] + }, + { + "name": "workspace", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--locked", + "--no-fail-fast" + ], + "exit_code": 101, + "seconds": 752.49, + "log": "/tmp/canopy-head-summary-workspace.log", + "log_sha256": "19f36a54bae4605e03eaae1589326930e1a9e489a53a5f86ab908bb5dd898311", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.24s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.33s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 749 filtered out; finished in 0.02s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 749 filtered out; finished in 0.08s", + "test result: ok. 750 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 368.86s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.86s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s", + "test result: FAILED. 88 passed; 19 failed; 9 ignored; 0 measured; 0 filtered out; finished in 356.93s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.28s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.17s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.27s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s" + ], + "failed_cases": [ + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery", + "a_second_node_clones_from_the_published_root_after_local_disk_loss", + "repository_cell_publishes_objects_and_refs_atomically", + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ] + }, + { + "name": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 37.81, + "log": "/tmp/canopy-head-summary-build.log", + "log_sha256": "11cf025f5bfca56d93bd55b71e03567bca9d33b71f423acf7a41267601acb381", + "summaries": [], + "failed_cases": [] + }, + { + "name": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.24, + "log": "/tmp/canopy-head-summary-fmt.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "name": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 40.72, + "log": "/tmp/canopy-head-summary-harness.log", + "log_sha256": "a7ed82283e322c3e506901e2d379125e1be1f3822667d0f370332df1766b8fcd", + "summaries": [], + "failed_cases": [] + }, + { + "name": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.05, + "log": "/tmp/canopy-head-summary-diff.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + } + ], + "source_unchanged": true, + "release_qualified": false + }, + "failing_first": { + "clean_parent_public_default_branch": { + "log": "/tmp/canopy-native-head-public-before.log", + "log_sha256": "ba806d827d81625e54d412e81a3fd7f88b474850093d75f983faf55a462000b6", + "exit_code": 101 + }, + "intermediate_reader_root_mutation": { + "source_digest": "339f988252be032ec41515870febc1b6be3e523e4fc273eee4fd0d959cf41491", + "workspace": { + "name": "workspace", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--locked", + "--no-fail-fast" + ], + "exit_code": 101, + "seconds": 680.59, + "log": "/tmp/canopy-native-head-final-workspace-final.log", + "log_sha256": "9137bc5e321ad9f8f7346088998ab91224de47be89b44fd9a490f02e9b5cbc70", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.67s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.24s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 749 filtered out; finished in 0.01s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 749 filtered out; finished in 0.09s", + "test result: ok. 750 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 291.00s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.54s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s", + "test result: FAILED. 84 passed; 23 failed; 9 ignored; 0 measured; 0 filtered out; finished in 367.95s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.45s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.24s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.38s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s" + ], + "failed_cases": [ + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "comparison::comparison_rejects_oversized_change_sets_without_partial_results", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::cold_activation::competing_cold_gateway_waits_for_the_winners_serving_handle", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::cold_activation::simultaneous_cold_gateways_follow_the_winning_live_owner", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "workspace::new_repositories_bootstrap_the_production_packed_catalog_before_becoming_ready", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery", + "a_second_node_clones_from_the_published_root_after_local_disk_loss", + "repository_cell_publishes_objects_and_refs_atomically", + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ] + }, + "new_regressions": [ + "peers::cold_activation::competing_cold_gateway_waits_for_the_winners_serving_handle", + "peers::cold_activation::simultaneous_cold_gateways_follow_the_winning_live_owner", + "workspace::new_repositories_bootstrap_the_production_packed_catalog_before_becoming_ready" + ], + "current_assertions_unchanged": true + } + }, + "parent_linux": { + "pr": { + "status": { + "conclusion": "failure", + "headSha": "7c232ea0d4d384c0ac2c5cf0dc401ce454e1a00f", + "jobs": [ + { + "completedAt": "2026-10-05T23:03:59Z", + "conclusion": "failure", + "databaseId": 112013399366, + "name": "rust", + "startedAt": "2026-10-05T22:43:06Z", + "status": "completed", + "steps": [ + { + "completedAt": "2026-10-05T22:43:08Z", + "conclusion": "success", + "name": "Set up job", + "number": 1, + "startedAt": "2026-10-05T22:43:07Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:43:09Z", + "conclusion": "success", + "name": "Run actions/checkout@v4", + "number": 2, + "startedAt": "2026-10-05T22:43:08Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:43:17Z", + "conclusion": "success", + "name": "Install tools", + "number": 3, + "startedAt": "2026-10-05T22:43:09Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:43:18Z", + "conclusion": "success", + "name": "Report tool versions", + "number": 4, + "startedAt": "2026-10-05T22:43:17Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:43:21Z", + "conclusion": "success", + "name": "Check formatting", + "number": 5, + "startedAt": "2026-10-05T22:43:18Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:45:07Z", + "conclusion": "success", + "name": "Check lints", + "number": 6, + "startedAt": "2026-10-05T22:43:21Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T23:03:56Z", + "conclusion": "failure", + "name": "Test", + "number": 7, + "startedAt": "2026-10-05T22:45:07Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T23:03:56Z", + "conclusion": "skipped", + "name": "Qualify Git compatibility against RustFS", + "number": 8, + "startedAt": "2026-10-05T23:03:56Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T23:03:56Z", + "conclusion": "skipped", + "name": "Build server", + "number": 9, + "startedAt": "2026-10-05T23:03:56Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T23:03:57Z", + "conclusion": "success", + "name": "Post Run actions/checkout@v4", + "number": 18, + "startedAt": "2026-10-05T23:03:56Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T23:03:57Z", + "conclusion": "success", + "name": "Complete job", + "number": 19, + "startedAt": "2026-10-05T23:03:57Z", + "status": "completed" + } + ], + "url": "https://github.com/crabbuild/canopy/actions/runs/37384217476/job/112013399366" + }, + { + "completedAt": "2026-10-05T22:43:48Z", + "conclusion": "success", + "databaseId": 112013399624, + "name": "harness", + "startedAt": "2026-10-05T22:43:06Z", + "status": "completed", + "steps": [ + { + "completedAt": "2026-10-05T22:43:08Z", + "conclusion": "success", + "name": "Set up job", + "number": 1, + "startedAt": "2026-10-05T22:43:07Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:43:09Z", + "conclusion": "success", + "name": "Run actions/checkout@v4", + "number": 2, + "startedAt": "2026-10-05T22:43:08Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:43:46Z", + "conclusion": "success", + "name": "Check Python qualification harness", + "number": 3, + "startedAt": "2026-10-05T22:43:09Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:43:46Z", + "conclusion": "success", + "name": "Post Run actions/checkout@v4", + "number": 6, + "startedAt": "2026-10-05T22:43:46Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:43:46Z", + "conclusion": "success", + "name": "Complete job", + "number": 7, + "startedAt": "2026-10-05T22:43:46Z", + "status": "completed" + } + ], + "url": "https://github.com/crabbuild/canopy/actions/runs/37384217476/job/112013399624" + } + ], + "status": "completed" + }, + "log": "/tmp/canopy-native-head-parent-pr-full.log", + "log_sha256": "76a03be5961d7cbda6d6a8078f3a1fa58ad2d6483b81222b1c6e2647878b7668" + }, + "push": { + "status": { + "conclusion": "failure", + "headSha": "7c232ea0d4d384c0ac2c5cf0dc401ce454e1a00f", + "jobs": [ + { + "completedAt": "2026-10-05T23:09:18Z", + "conclusion": "failure", + "databaseId": 112013378631, + "name": "rust", + "startedAt": "2026-10-05T22:43:02Z", + "status": "completed", + "steps": [ + { + "completedAt": "2026-10-05T22:43:03Z", + "conclusion": "success", + "name": "Set up job", + "number": 1, + "startedAt": "2026-10-05T22:43:03Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:43:05Z", + "conclusion": "success", + "name": "Run actions/checkout@v4", + "number": 2, + "startedAt": "2026-10-05T22:43:03Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:43:11Z", + "conclusion": "success", + "name": "Install tools", + "number": 3, + "startedAt": "2026-10-05T22:43:05Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:43:12Z", + "conclusion": "success", + "name": "Report tool versions", + "number": 4, + "startedAt": "2026-10-05T22:43:11Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:43:16Z", + "conclusion": "success", + "name": "Check formatting", + "number": 5, + "startedAt": "2026-10-05T22:43:12Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:45:32Z", + "conclusion": "success", + "name": "Check lints", + "number": 6, + "startedAt": "2026-10-05T22:43:16Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T23:09:16Z", + "conclusion": "failure", + "name": "Test", + "number": 7, + "startedAt": "2026-10-05T22:45:32Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T23:09:16Z", + "conclusion": "skipped", + "name": "Qualify Git compatibility against RustFS", + "number": 8, + "startedAt": "2026-10-05T23:09:16Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T23:09:16Z", + "conclusion": "skipped", + "name": "Build server", + "number": 9, + "startedAt": "2026-10-05T23:09:16Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T23:09:17Z", + "conclusion": "success", + "name": "Post Run actions/checkout@v4", + "number": 18, + "startedAt": "2026-10-05T23:09:16Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T23:09:17Z", + "conclusion": "success", + "name": "Complete job", + "number": 19, + "startedAt": "2026-10-05T23:09:17Z", + "status": "completed" + } + ], + "url": "https://github.com/crabbuild/canopy/actions/runs/37384211402/job/112013378631" + }, + { + "completedAt": "2026-10-05T22:43:46Z", + "conclusion": "success", + "databaseId": 112013378871, + "name": "harness", + "startedAt": "2026-10-05T22:43:03Z", + "status": "completed", + "steps": [ + { + "completedAt": "2026-10-05T22:43:05Z", + "conclusion": "success", + "name": "Set up job", + "number": 1, + "startedAt": "2026-10-05T22:43:04Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:43:06Z", + "conclusion": "success", + "name": "Run actions/checkout@v4", + "number": 2, + "startedAt": "2026-10-05T22:43:05Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:43:44Z", + "conclusion": "success", + "name": "Check Python qualification harness", + "number": 3, + "startedAt": "2026-10-05T22:43:06Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:43:44Z", + "conclusion": "success", + "name": "Post Run actions/checkout@v4", + "number": 6, + "startedAt": "2026-10-05T22:43:44Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T22:43:44Z", + "conclusion": "success", + "name": "Complete job", + "number": 7, + "startedAt": "2026-10-05T22:43:44Z", + "status": "completed" + } + ], + "url": "https://github.com/crabbuild/canopy/actions/runs/37384211402/job/112013378871" + } + ], + "status": "completed" + }, + "log": "/tmp/canopy-native-head-parent-push-full.log", + "log_sha256": "f26b54c4c98260645dd196a9929491fa66b0fa6965a8790e9c31057b61d15597" + } + }, + "remaining_failed_cases": [ + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery", + "a_second_node_clones_from_the_published_root_after_local_disk_loss", + "repository_cell_publishes_objects_and_refs_atomically", + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ], + "fixed_from_intermediate_inventory": [ + "comparison::comparison_rejects_oversized_change_sets_without_partial_results", + "peers::cold_activation::competing_cold_gateway_waits_for_the_winners_serving_handle", + "peers::cold_activation::simultaneous_cold_gateways_follow_the_winning_live_owner", + "workspace::new_repositories_bootstrap_the_production_packed_catalog_before_becoming_ready" + ], + "new_failed_from_intermediate_inventory": [], + "limits": [ + "Full CI remains incomplete if any workspace target fails. No workflow, test, ignored-case status or root-mutation assertion was weakened.", + "No provider deletion, final retained-root inventory or large-team capacity qualification.", + "SDK revision, protected original index and historical progress archive remain unchanged." + ] +} diff --git a/docs/evidence/native-lease-fixture-ci-20261006.json b/docs/evidence/native-lease-fixture-ci-20261006.json new file mode 100644 index 00000000..65c61862 --- /dev/null +++ b/docs/evidence/native-lease-fixture-ci-20261006.json @@ -0,0 +1,19 @@ +{ + "base_commit": "54ed25636ade52ac6bcbebc1a2fe52cf00256aec", + "observed_full_run": "756 server unit tests passed; short-lease expiry setup returned Inactive and initial renewal regression returned Elapsed. HTTP clone passed.", + "change": "Warm immutable expiry metadata before starting the unchanged one-second lease. Minimize renewal regression to one reachable native blob; same five-second lease, delayed provider, two renewals, final membership, original pin and full drain. Distinguish premature worker refusal from provider-gate timeout with stage-specific diagnostics.", + "validation": { + "workspace_tests": { + "passed": 12, + "seconds": 18.16 + }, + "clippy": "PASS: workspace, all targets, locked, warnings denied", + "format": "PASS", + "diff_check": "PASS", + "full_workspace": "pending", + "linux_provider": "pending" + }, + "sha256": { + "crates/canopy-server/src/packs/publication/tests/serving/workspace.rs": "6fa05ccfe00b15e98ee897df54afc7a95f0c2e0ddddcf6061b770bef10410c35" + } +} diff --git a/docs/evidence/native-merge-ancestry-ci-20261005.json b/docs/evidence/native-merge-ancestry-ci-20261005.json new file mode 100644 index 00000000..b077ff01 --- /dev/null +++ b/docs/evidence/native-merge-ancestry-ci-20261005.json @@ -0,0 +1,394 @@ +{ + "recorded_at_utc": "2026-10-05T18:45:22.065095+00:00", + "base_head": "6fc9483570423b9674e9f4e3d5f70d151b47b668", + "host": "macOS, Rust 1.98.0; exact-head Linux qualification remains required", + "source_files": 510, + "rust_files": 492, + "source_hash_digest": "8c242912a383fbfa5ef5c2023163b2491bf0013eb9c0d16b1256a7b3f89897d8", + "source_manifest": "/tmp/canopy-native-merge-ancestry-final-source.json", + "source_digest_algorithm": "SHA256 of compact sorted-key JSON mapping each Rust/SQL/TOML/lock/YAML path to its file SHA256", + "source_unchanged_during_validation": true, + "release_qualified": false, + "reproduction": { + "result": "Ordinary policy-selective proof omits the true base-to-descendant bit when the branch has no fast-forward rule. The new mandatory-ancestry regression fails before the factory is added; actual 56, expected 57. This reproduces a missing preparation primitive, not an implemented or fixed native merge endpoint.", + "source_manifest": "/tmp/canopy-native-merge-ancestry-baseline-source.json", + "source_digest": "c93ec7158e511405fe9ee3c9d847659fc4574dfbbb0c4dd79477e8d4608b3c78", + "path": "/tmp/canopy-native-merge-ancestry-baseline.log", + "sha256": "e775e5f90bd0692682b5d0d49596d08c9bd90b6fbb0343ee0a6f0d0998e2d070", + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 724 filtered out; finished in 0.52s" + ] + }, + "validation": { + "source_digest": "8c242912a383fbfa5ef5c2023163b2491bf0013eb9c0d16b1256a7b3f89897d8", + "phases": [ + { + "name": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 52.93, + "log": "/tmp/canopy-native-merge-ancestry-final-clippy-final.log", + "log_sha256": "cc51973db5cf58ad335560712df6fc48048f3b4662a843e3421d5451c044ed5f", + "summaries": [], + "failed_cases": [] + }, + { + "name": "ancestry", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "reviewed_merge_requires_native_ancestry_without_a_fast_forward_branch_rule", + "--locked", + "--", + "--nocapture" + ], + "exit_code": 0, + "seconds": 2.52, + "log": "/tmp/canopy-native-merge-ancestry-final-ancestry-final.log", + "log_sha256": "0b599018f93bdb039509685f01524a685992e8f40cd1acdf1feca7a2c2cca092", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 724 filtered out; finished in 1.41s" + ], + "failed_cases": [] + }, + { + "name": "workspace", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--locked", + "--no-fail-fast" + ], + "exit_code": 101, + "seconds": 790.87, + "log": "/tmp/canopy-native-merge-ancestry-final-workspace-final.log", + "log_sha256": "4ef8f2613c196cd046b3262281f5be6aa9c82433992b5bdf2b52083a8dfa9f44", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.15s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.11s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 724 filtered out; finished in 0.01s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 724 filtered out; finished in 0.07s", + "test result: ok. 725 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 354.95s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.62s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s", + "test result: FAILED. 82 passed; 24 failed; 9 ignored; 0 measured; 0 filtered out; finished in 348.75s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.14s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.28s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s" + ], + "failed_cases": [ + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery", + "a_second_node_clones_from_the_published_root_after_local_disk_loss", + "repository_cell_publishes_objects_and_refs_atomically", + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ] + }, + { + "name": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 41.81, + "log": "/tmp/canopy-native-merge-ancestry-final-build-final.log", + "log_sha256": "da2722ffab0462a401624f58b0464533063e4ac8668ca2037d13b4b9944aa49e", + "summaries": [], + "failed_cases": [] + }, + { + "name": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.34, + "log": "/tmp/canopy-native-merge-ancestry-final-fmt-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "name": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 41.06, + "log": "/tmp/canopy-native-merge-ancestry-final-harness-final.log", + "log_sha256": "977ba385ed294539a83621a1949a741f34d62bf24bb1edcdeb0a4e9414e628a2", + "summaries": [], + "failed_cases": [] + }, + { + "name": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.06, + "log": "/tmp/canopy-native-merge-ancestry-final-diff-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + } + ], + "complete": true, + "release_qualified": false, + "source_unchanged": true + }, + "workspace_terminal_inventory": [ + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_git_format-cdf8cea2f92fe6a4)", + "passed": 6, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_object_storage-4a0661c5c4765140)", + "passed": 15, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_server-d080aca381ae9ba9)", + "passed": 725, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy-16a4bf977c56198e)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/directory_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/directory_cell-31a0f4eea5beeae3)", + "passed": 13, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/git_http.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/git_http-afa4d1d9a2db0179)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/multi_server/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/multi_server-3e08ee00a07d7bde)", + "passed": 82, + "failed": 24, + "ignored": 9 + }, + { + "binary": "tests/owner_restart.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/owner_restart-be32ecc90c554a17)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "tests/repository_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/repository_cell-322afb5848ed5e86)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "tests/smart_http/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/smart_http-18ac06122d3c394f)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "canopy_git_format", + "passed": 0, + "failed": 0, + "ignored": 0 + }, + { + "binary": "canopy_object_storage", + "passed": 0, + "failed": 0, + "ignored": 0 + }, + { + "binary": "canopy_server", + "passed": 0, + "failed": 0, + "ignored": 0 + } + ], + "workspace_unique_totals": { + "passed": 845, + "failed": 27, + "ignored": 9, + "executed": 872, + "total": 881 + }, + "counting": "Last summary per Cargo Running/Doc-tests section; nested child summaries and focused reruns excluded. Ordinary ignored cases remain unexecuted.", + "parent_linux_ci": [ + { + "head": "6fc9483570423b9674e9f4e3d5f70d151b47b668", + "run": 37353990783, + "job": 111911623913, + "conclusion": "failure", + "path": "/tmp/canopy-native-merge-parent-pr-full.log", + "sha256": "5a9157513dcd053d93beba7ad78c1063cc7f1d205dee44453a39cc6239cdf0d4", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.48s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.69s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 723 filtered out; finished in 0.00s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 723 filtered out; finished in 0.02s", + "test result: ok. 724 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 368.32s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.99s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: FAILED. 83 passed; 27 failed; 9 ignored; 0 measured; 0 filtered out; finished in 525.36s" + ], + "failed_cases": [ + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "push_options::mismatched_signed_push_options_return_a_durable_git_rejection", + "push_options::stock_git_push_options_are_validated_recorded_and_recovered", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::ssh_push_options_cover_pack_and_delete_only_requests", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery" + ] + }, + { + "head": "6fc9483570423b9674e9f4e3d5f70d151b47b668", + "run": 37353984202, + "job": 111911602108, + "conclusion": "failure", + "path": "/tmp/canopy-native-merge-parent-push-full.log", + "sha256": "5d6554918f30d27f8dbbfeaa4b189bc26f8d227ce5f7ba86f514c3ee0e3f2509", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.41s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 9.01s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 723 filtered out; finished in 0.00s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 723 filtered out; finished in 0.02s", + "test result: ok. 724 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 476.75s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.05s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: FAILED. 83 passed; 27 failed; 9 ignored; 0 measured; 0 filtered out; finished in 620.95s" + ], + "failed_cases": [ + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "push_options::mismatched_signed_push_options_return_a_durable_git_rejection", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "push_options::stock_git_push_options_are_validated_recorded_and_recovered", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::ssh_push_options_cover_pack_and_delete_only_requests", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery" + ] + } + ], + "changes": [ + "PreparedCatalog::ref_proof_with_required_ancestry reuses the existing native header verification, MAC-bound plan/evidence, bounded disk-backed walker, live lease, timeout and scratch custody. It computes every non-vacuous predicate independently of SQL branch rules.", + "Ordinary pushes retain policy-selective ancestry work. No new proof format, table, decoder or SQL ref/ancestry mirror is introduced.", + "Genuine native catalog regression covers SHA-1/SHA-256 descendant, unrelated and backwards histories, vacuous predicates, negative evidence digest binding, invalid branch tip kind and unchanged persistent publication state. It is not qualification of the immutable-root merge receiver, resident merge adapter or generated candidate writer." + ], + "remaining": "Integrate mandatory evidence into a private conditional native merge snapshot and typed owner-fenced transaction, durable exact-command recovery/terminal release, and actual resident/public merge adapter. Native candidates/rebase, thread revisions, default branch, rejected pushes, peer/backup recovery, selective fetch, physical ownership/retention/GC/final DDL, accelerators/fair maintenance and full-history/10k-engineer qualification remain open." +} diff --git a/docs/evidence/native-merge-atomic-ci-20261005.json b/docs/evidence/native-merge-atomic-ci-20261005.json new file mode 100644 index 00000000..ede62eac --- /dev/null +++ b/docs/evidence/native-merge-atomic-ci-20261005.json @@ -0,0 +1,852 @@ +{ + "recorded_at_utc": "2026-10-05T19:54:15.723235+00:00", + "base_head": "0b8d3b5c397fb11532f09b8a71e023e604d7a6c1", + "host": "macOS, Rust 1.98.0; exact-head Linux qualification remains required", + "source_files": 513, + "rust_files": 495, + "source_hash_digest": "214b64e819382822ffa9bac28434d2ea2bd42a4247421e69025569188b9cf198", + "source_manifest": "/tmp/canopy-native-merge-atomic-final-source.json", + "source_digest_algorithm": "SHA256 of compact sorted-key JSON mapping each Rust/SQL/TOML/lock/YAML path to its file SHA256", + "source_unchanged_during_validation": true, + "release_qualified": false, + "baseline": { + "result": "Compiled parent HTTP merge workflow returns 503 where 409 is required. This remains an unresolved public adapter failure; the new private receiver tests do not fix or qualify that endpoint.", + "source_digest": "8c242912a383fbfa5ef5c2023163b2491bf0013eb9c0d16b1256a7b3f89897d8", + "path": "/tmp/canopy-native-merge-atomic-baseline.log", + "sha256": "590de417ee8531b6c2d6300983c4b4311408ed7970a9a9ec3b468a0599764437", + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 114 filtered out; finished in 1.80s" + ] + }, + "intermediate_failed_runs": [ + { + "reason": "First check rejected a moved ref snapshot root and unused import; corrected before regression validation.", + "path": "/tmp/canopy-native-merge-atomic-check-first.log", + "sha256": "9a976c57c17378c76d3aab61d5d0baa98cbd1aeea25fa3990f0cea5b4198dae3", + "summaries": [] + }, + { + "reason": "First focused compile rejected a wrong PullRevision import; corrected to crate::pulls::PullRevision.", + "path": "/tmp/canopy-native-merge-atomic-focused-first.log", + "sha256": "634445258d3969468d51ca1691876ae477ea5081f78ff7a904b02c9cbb57eff0", + "summaries": [] + }, + { + "reason": "Focused fixture used literal clock zero and failed mutation identity lifetime validation before the factory ran. Changed fixture to actual sql::now(0); no production checks or assertions relaxed.", + "path": "/tmp/canopy-native-merge-atomic-focused-second.log", + "sha256": "0f5b526a1206c8b4107efaf2cefe3b92c0818d3077a5ea57596e45044dd2cace", + "summaries": [ + "test result: FAILED. 1 passed; 4 failed; 0 ignored; 0 measured; 725 filtered out; finished in 0.73s" + ] + } + ], + "focused": { + "source_digest": "214b64e819382822ffa9bac28434d2ea2bd42a4247421e69025569188b9cf198", + "result": "8 passed, 0 failed; cases exercise both SHA-1 and SHA-256 where applicable", + "path": "/tmp/canopy-native-merge-atomic-final-focused-final.log", + "sha256": "070c51bc934daed1f82e76dfa0f320324e5779e8c4dbc1f84a23f8071ea1cb79", + "summaries": [ + "test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 725 filtered out; finished in 3.73s" + ] + }, + "source_fingerprint_correction": { + "recorded_at_utc": "2026-10-05T19:32:52.974698+00:00", + "baseline_source_digest": "f9ba7219fc0d96193935bb46454b975b1071e0dfdef92843b06fcbd805b6ce38", + "lib_sha256": "11c9d5461a82e43b5b8d81f99b8de259015841ada0d249885229d28afcb23378", + "missing_repository_module_source_inputs": [ + "packs/publication/native_merge.rs", + "packs/publication/coordinator/native_merge.rs" + ], + "impact": "Explicit RepositoryModule source digest omits new private merge command/ready factory implementation bytes. Add these inputs after current validator is terminal, and revalidate changed source before commit.", + "corrected_lib_sha256": "ebaffa1560562db8fd863299c66e2288d72acbf02e3a8ce4d779181ce06156b6", + "corrected_source_digest": "214b64e819382822ffa9bac28434d2ea2bd42a4247421e69025569188b9cf198", + "missing_after_correction": [] + }, + "pre_correction_validation": { + "recorded_at_utc": "2026-10-05T19:36:46.613092+00:00", + "base_head": "0b8d3b5c397fb11532f09b8a71e023e604d7a6c1", + "host": "macOS, Rust 1.98.0; exact-head Linux qualification remains required", + "source_files": 513, + "rust_files": 495, + "source_hash_digest": "f9ba7219fc0d96193935bb46454b975b1071e0dfdef92843b06fcbd805b6ce38", + "source_manifest": "/tmp/canopy-native-merge-atomic-before-fingerprint-source.json", + "source_digest_algorithm": "SHA256 of compact sorted-key JSON mapping each Rust/SQL/TOML/lock/YAML path to its file SHA256", + "source_unchanged_during_validation": true, + "release_qualified": false, + "baseline": { + "result": "Compiled parent HTTP merge workflow returns 503 where 409 is required. This remains an unresolved public adapter failure; the new private receiver tests do not fix or qualify that endpoint.", + "source_digest": "8c242912a383fbfa5ef5c2023163b2491bf0013eb9c0d16b1256a7b3f89897d8", + "path": "/tmp/canopy-native-merge-atomic-baseline.log", + "sha256": "590de417ee8531b6c2d6300983c4b4311408ed7970a9a9ec3b468a0599764437", + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 114 filtered out; finished in 1.80s" + ] + }, + "intermediate_failed_runs": [ + { + "reason": "First check rejected a moved ref snapshot root and unused import; corrected before regression validation.", + "path": "/tmp/canopy-native-merge-atomic-check-first.log", + "sha256": "9a976c57c17378c76d3aab61d5d0baa98cbd1aeea25fa3990f0cea5b4198dae3", + "summaries": [] + }, + { + "reason": "First focused compile rejected a wrong PullRevision import; corrected to crate::pulls::PullRevision.", + "path": "/tmp/canopy-native-merge-atomic-focused-first.log", + "sha256": "634445258d3969468d51ca1691876ae477ea5081f78ff7a904b02c9cbb57eff0", + "summaries": [] + }, + { + "reason": "Focused fixture used literal clock zero and failed mutation identity lifetime validation before the factory ran. Changed fixture to actual sql::now(0); no production checks or assertions relaxed.", + "path": "/tmp/canopy-native-merge-atomic-focused-second.log", + "sha256": "0f5b526a1206c8b4107efaf2cefe3b92c0818d3077a5ea57596e45044dd2cace", + "summaries": [ + "test result: FAILED. 1 passed; 4 failed; 0 ignored; 0 measured; 725 filtered out; finished in 0.73s" + ] + } + ], + "focused": { + "source_digest": "f9ba7219fc0d96193935bb46454b975b1071e0dfdef92843b06fcbd805b6ce38", + "result": "8 passed, 0 failed; cases exercise both SHA-1 and SHA-256 where applicable", + "path": "/tmp/canopy-native-merge-atomic-focused-third.log", + "sha256": "ec6705946bb702f874f1b6a45e4fcdd81491ccc86a40bc859ff2e7e4b7a656e1", + "summaries": [ + "test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 725 filtered out; finished in 4.68s" + ] + }, + "validation": { + "source_digest": "f9ba7219fc0d96193935bb46454b975b1071e0dfdef92843b06fcbd805b6ce38", + "phases": [ + { + "name": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 44.47, + "log": "/tmp/canopy-native-merge-atomic-before-fingerprint-clippy.log", + "log_sha256": "87a7146810d65889efebb29c068638b400da9412b132caaca0edab70edf739a3", + "summaries": [], + "failed_cases": [] + }, + { + "name": "workspace", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--locked", + "--no-fail-fast" + ], + "exit_code": 101, + "seconds": 739.36, + "log": "/tmp/canopy-native-merge-atomic-before-fingerprint-workspace.log", + "log_sha256": "163f0a12d22e7ae9eb2410ce13b8f356fecd1e03db149e1aa3d705b21236feb5", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.77s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.45s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 732 filtered out; finished in 0.00s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 732 filtered out; finished in 0.08s", + "test result: ok. 733 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 314.83s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.52s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s", + "test result: FAILED. 82 passed; 24 failed; 9 ignored; 0 measured; 0 filtered out; finished in 353.22s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.43s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.23s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.35s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s" + ], + "failed_cases": [ + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery", + "a_second_node_clones_from_the_published_root_after_local_disk_loss", + "repository_cell_publishes_objects_and_refs_atomically", + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ] + }, + { + "name": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 60.04, + "log": "/tmp/canopy-native-merge-atomic-before-fingerprint-build.log", + "log_sha256": "34566228b3affe91123c22f0f35627811f697d606ca025fe8dbef9c302e6ecf1", + "summaries": [], + "failed_cases": [] + }, + { + "name": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.47, + "log": "/tmp/canopy-native-merge-atomic-before-fingerprint-fmt.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "name": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 44.79, + "log": "/tmp/canopy-native-merge-atomic-before-fingerprint-harness.log", + "log_sha256": "cb01e415cf1f4b7710be7e4585b058921656c8fb9a6b883d961641f128abd933", + "summaries": [], + "failed_cases": [] + }, + { + "name": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.12, + "log": "/tmp/canopy-native-merge-atomic-before-fingerprint-diff.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + } + ], + "complete": true, + "release_qualified": false, + "source_unchanged": true + }, + "workspace_terminal_inventory": [ + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_git_format-cdf8cea2f92fe6a4)", + "passed": 6, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_object_storage-4a0661c5c4765140)", + "passed": 15, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_server-d080aca381ae9ba9)", + "passed": 733, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy-16a4bf977c56198e)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/directory_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/directory_cell-31a0f4eea5beeae3)", + "passed": 13, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/git_http.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/git_http-afa4d1d9a2db0179)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/multi_server/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/multi_server-3e08ee00a07d7bde)", + "passed": 82, + "failed": 24, + "ignored": 9 + }, + { + "binary": "tests/owner_restart.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/owner_restart-be32ecc90c554a17)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "tests/repository_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/repository_cell-322afb5848ed5e86)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "tests/smart_http/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/smart_http-18ac06122d3c394f)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "canopy_git_format", + "passed": 0, + "failed": 0, + "ignored": 0 + }, + { + "binary": "canopy_object_storage", + "passed": 0, + "failed": 0, + "ignored": 0 + }, + { + "binary": "canopy_server", + "passed": 0, + "failed": 0, + "ignored": 0 + } + ], + "workspace_unique_totals": { + "passed": 853, + "failed": 27, + "ignored": 9, + "executed": 880, + "total": 889 + }, + "counting": "Last summary per Cargo Running/Doc-tests section; nested child summaries and focused reruns excluded. Ordinary ignored cases remain unexecuted.", + "parent_linux_ci": [ + { + "head": "0b8d3b5c397fb11532f09b8a71e023e604d7a6c1", + "run": 37358483334, + "job": 111926817450, + "conclusion": "failure", + "path": "/tmp/canopy-native-merge-atomic-parent-pr-full.log", + "sha256": "07ba5e1cd626f38fe91accf43dc8a1d4b320009b8f2c63ec467d6f516cf5b34d", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.79s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.87s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 724 filtered out; finished in 0.00s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 724 filtered out; finished in 0.01s", + "test result: ok. 725 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 359.44s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.57s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: FAILED. 83 passed; 27 failed; 9 ignored; 0 measured; 0 filtered out; finished in 512.80s" + ], + "failed_cases": [ + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "push_options::mismatched_signed_push_options_return_a_durable_git_rejection", + "push_options::stock_git_push_options_are_validated_recorded_and_recovered", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::ssh_push_options_cover_pack_and_delete_only_requests", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery" + ] + }, + { + "head": "0b8d3b5c397fb11532f09b8a71e023e604d7a6c1", + "run": 37358473334, + "job": 111926783807, + "conclusion": "failure", + "path": "/tmp/canopy-native-merge-atomic-parent-push-full.log", + "sha256": "cbbe193b62d24f69d566b1da47b3715430e77b8ebbd51438325347ab492a430c", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.46s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 8.93s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 724 filtered out; finished in 0.00s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 724 filtered out; finished in 0.02s", + "test result: ok. 725 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 471.27s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.34s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s", + "test result: FAILED. 83 passed; 27 failed; 9 ignored; 0 measured; 0 filtered out; finished in 620.69s" + ], + "failed_cases": [ + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "push_options::mismatched_signed_push_options_return_a_durable_git_rejection", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "push_options::stock_git_push_options_are_validated_recorded_and_recovered", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::ssh_push_options_cover_pack_and_delete_only_requests", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery" + ] + } + ], + "changes": [ + "Private ref-only fast-forward preparation binds exact actor/request/native ref versions, mandatory verified ancestry and conditional COW snapshot in the existing CatalogCertificate envelope.", + "Native operation 9 codec 5 commits fresh owner/access/review/check/ref predicates, joint generation, pull state, applied application UUID, checkpoint/operation consumption and exact-command phase atomically. No SQL ref/ancestry mirror or backward decoder is introduced.", + "Typed ReadyNativeMerge retains actual preparation ownership and reuses ReadyBoundRecovery, fair dispatch and original registered SDK command recovery. Denials do not bind application UUID; applied retries preserve original result.", + "Eight focused cases cover native publication, unrelated ancestry, late review changes, mandatory registration, payload/generation conflicts, late SQL rollback, UUID replay, real owner restoration after SQLite loss and actual Rust maximum SHA-256 result of 493 wire bytes under existing 512 cap." + ], + "qualification_limits": [ + "Native merge fixture installs a genuine verified stock-Git catalog and immutable ref tree using a synthetic initial catalog certificate. This isolates private preparation/receiver/recovery, not initial-root production or HTTP/resident integration.", + "Public merge adapter still invokes retired codec 4 and legacy SQL ancestry. No endpoint fix or full CI success is claimed.", + "Merge terminal release currently deliberately retains physical pins pending selected native graph and exact UUID outcome certification. Retention/capacity are not qualified.", + "Generated merge/squash/rebase producers, native thread/default-branch writers, rejected-push workload failures, replication/backup, selective fetch, physical recovery/GC/final schema, accelerators/fair maintenance and full-history/10k-engineer qualification remain open." + ], + "validation_manifest": "/tmp/canopy-native-merge-atomic-before-fingerprint-validation.json" + }, + "validation": { + "source_digest": "214b64e819382822ffa9bac28434d2ea2bd42a4247421e69025569188b9cf198", + "phases": [ + { + "name": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 54.49, + "log": "/tmp/canopy-native-merge-atomic-final-clippy-final.log", + "log_sha256": "abd2b8f7dcf558a21949ae0db1ca7f9b2720edf9820fc920e0f0a81fb17ebed8", + "summaries": [], + "failed_cases": [] + }, + { + "name": "focused", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "packs::publication::tests::native_merge::", + "--locked", + "--", + "--nocapture" + ], + "exit_code": 0, + "seconds": 134.15, + "log": "/tmp/canopy-native-merge-atomic-final-focused-final.log", + "log_sha256": "070c51bc934daed1f82e76dfa0f320324e5779e8c4dbc1f84a23f8071ea1cb79", + "summaries": [ + "test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 725 filtered out; finished in 3.73s" + ], + "failed_cases": [] + }, + { + "name": "workspace", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--locked", + "--no-fail-fast" + ], + "exit_code": 101, + "seconds": 735.77, + "log": "/tmp/canopy-native-merge-atomic-final-workspace-final.log", + "log_sha256": "12bc5be366ffddb500febeb08540c3ba966e3358fc7a8429b26c0998c92d2b2c", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.22s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.26s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 732 filtered out; finished in 0.01s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 732 filtered out; finished in 0.10s", + "test result: ok. 733 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 299.05s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.05s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s", + "test result: FAILED. 82 passed; 24 failed; 9 ignored; 0 measured; 0 filtered out; finished in 345.61s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.31s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.18s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.26s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s" + ], + "failed_cases": [ + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "visibility::public_reads_and_private_revocation_survive_cell_recovery", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "a_second_node_clones_from_the_published_root_after_local_disk_loss", + "repository_cell_publishes_objects_and_refs_atomically", + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ] + }, + { + "name": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 42.65, + "log": "/tmp/canopy-native-merge-atomic-final-build-final.log", + "log_sha256": "b038fbceddb7b684c15b344b2d4c7ab7ab6e43efd9ef7a98716d9af24bf00137", + "summaries": [], + "failed_cases": [] + }, + { + "name": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.28, + "log": "/tmp/canopy-native-merge-atomic-final-fmt-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "name": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 41.83, + "log": "/tmp/canopy-native-merge-atomic-final-harness-final.log", + "log_sha256": "6561098ae5cb3a98d96b5561170ea706ebc24954e072b199241a0639845fd6dc", + "summaries": [], + "failed_cases": [] + }, + { + "name": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.06, + "log": "/tmp/canopy-native-merge-atomic-final-diff-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + } + ], + "complete": true, + "release_qualified": false, + "source_unchanged": true + }, + "workspace_terminal_inventory": [ + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_git_format-cdf8cea2f92fe6a4)", + "passed": 6, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_object_storage-4a0661c5c4765140)", + "passed": 15, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_server-d080aca381ae9ba9)", + "passed": 733, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy-16a4bf977c56198e)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/directory_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/directory_cell-31a0f4eea5beeae3)", + "passed": 13, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/git_http.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/git_http-afa4d1d9a2db0179)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/multi_server/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/multi_server-3e08ee00a07d7bde)", + "passed": 82, + "failed": 24, + "ignored": 9 + }, + { + "binary": "tests/owner_restart.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/owner_restart-be32ecc90c554a17)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "tests/repository_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/repository_cell-322afb5848ed5e86)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "tests/smart_http/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/smart_http-18ac06122d3c394f)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "canopy_git_format", + "passed": 0, + "failed": 0, + "ignored": 0 + }, + { + "binary": "canopy_object_storage", + "passed": 0, + "failed": 0, + "ignored": 0 + }, + { + "binary": "canopy_server", + "passed": 0, + "failed": 0, + "ignored": 0 + } + ], + "workspace_unique_totals": { + "passed": 853, + "failed": 27, + "ignored": 9, + "executed": 880, + "total": 889 + }, + "counting": "Last summary per Cargo Running/Doc-tests section; nested child summaries and focused reruns excluded. Ordinary ignored cases remain unexecuted.", + "parent_linux_ci": [ + { + "head": "0b8d3b5c397fb11532f09b8a71e023e604d7a6c1", + "run": 37358483334, + "job": 111926817450, + "conclusion": "failure", + "path": "/tmp/canopy-native-merge-atomic-parent-pr-full.log", + "sha256": "07ba5e1cd626f38fe91accf43dc8a1d4b320009b8f2c63ec467d6f516cf5b34d", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.79s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.87s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 724 filtered out; finished in 0.00s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 724 filtered out; finished in 0.01s", + "test result: ok. 725 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 359.44s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.57s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: FAILED. 83 passed; 27 failed; 9 ignored; 0 measured; 0 filtered out; finished in 512.80s" + ], + "failed_cases": [ + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "push_options::mismatched_signed_push_options_return_a_durable_git_rejection", + "push_options::stock_git_push_options_are_validated_recorded_and_recovered", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::ssh_push_options_cover_pack_and_delete_only_requests", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery" + ] + }, + { + "head": "0b8d3b5c397fb11532f09b8a71e023e604d7a6c1", + "run": 37358473334, + "job": 111926783807, + "conclusion": "failure", + "path": "/tmp/canopy-native-merge-atomic-parent-push-full.log", + "sha256": "cbbe193b62d24f69d566b1da47b3715430e77b8ebbd51438325347ab492a430c", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.46s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 8.93s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 724 filtered out; finished in 0.00s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 724 filtered out; finished in 0.02s", + "test result: ok. 725 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 471.27s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.34s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s", + "test result: FAILED. 83 passed; 27 failed; 9 ignored; 0 measured; 0 filtered out; finished in 620.69s" + ], + "failed_cases": [ + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "push_options::mismatched_signed_push_options_return_a_durable_git_rejection", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "push_options::stock_git_push_options_are_validated_recorded_and_recovered", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::ssh_push_options_cover_pack_and_delete_only_requests", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery" + ] + } + ], + "changes": [ + "Private ref-only fast-forward preparation binds exact actor/request/native ref versions, mandatory verified ancestry and conditional COW snapshot in the existing CatalogCertificate envelope.", + "Native operation 9 codec 5 commits fresh owner/access/review/check/ref predicates, joint generation, pull state, applied application UUID, checkpoint/operation consumption and exact-command phase atomically. No SQL ref/ancestry mirror or backward decoder is introduced.", + "RepositoryModule explicit source fingerprint includes both new native merge implementation files; prior incomplete-fingerprint validation remains separately attributed.", + "Typed ReadyNativeMerge retains actual preparation ownership and reuses ReadyBoundRecovery, fair dispatch and original registered SDK command recovery. Denials do not bind application UUID; applied retries preserve original result.", + "Eight focused cases cover native publication, unrelated ancestry, late review changes, mandatory registration, payload/generation conflicts, late SQL rollback, UUID replay, real owner restoration after SQLite loss and actual Rust maximum SHA-256 result of 493 wire bytes under existing 512 cap." + ], + "qualification_limits": [ + "Native merge fixture installs a genuine verified stock-Git catalog and immutable ref tree using a synthetic initial catalog certificate. This isolates private preparation/receiver/recovery, not initial-root production or HTTP/resident integration.", + "Public merge adapter still invokes retired codec 4 and legacy SQL ancestry. No endpoint fix or full CI success is claimed.", + "Merge terminal release currently deliberately retains physical pins pending selected native graph and exact UUID outcome certification. Retention/capacity are not qualified.", + "Generated merge/squash/rebase producers, native thread/default-branch writers, rejected-push workload failures, replication/backup, selective fetch, physical recovery/GC/final schema, accelerators/fair maintenance and full-history/10k-engineer qualification remain open." + ] +} diff --git a/docs/evidence/native-merge-endpoint-ci-20261005.json b/docs/evidence/native-merge-endpoint-ci-20261005.json new file mode 100644 index 00000000..b12ca76c --- /dev/null +++ b/docs/evidence/native-merge-endpoint-ci-20261005.json @@ -0,0 +1,736 @@ +{ + "recorded_at_utc": "2026-10-05T21:05:40.578599+00:00", + "base_head": "75814b4c692364b4476e57456d439001bd5abbf2", + "host": "macOS, Rust 1.98.0; exact-head Linux qualification remains required", + "source_files": 516, + "rust_files": 498, + "source_hash_digest": "d1aa115a3c9e7ff266c3d71e50fc3d2054648af58e52d70c845ef817e2acd53a", + "source_manifest": "/tmp/canopy-native-merge-endpoint-final-source.json", + "source_digest_algorithm": "SHA256 of compact sorted-key JSON mapping each Rust/SQL/TOML/lock/YAML path to its file SHA256", + "source_unchanged_during_validation": true, + "release_qualified": false, + "baselines": [ + { + "source_digest": "ec37049731e41eb420dac90a67c20fa49c2efc603a6ff206c38da674ce9fa2eb", + "reason": "Unmodified parent HTTP merge race test returns 503 where 409 is required because the product route invokes retired SQL preparation.", + "path": "/tmp/canopy-native-merge-endpoint-baseline.log", + "sha256": "58df66f0c8a59197631eb92e5c2a4f4902beba2d211e89aa44b2a13c67edf574", + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 114 filtered out; finished in 4.17s" + ] + }, + { + "source_digest": "6a178aef7aedc91828a06d28614f1e17015e7b2b0d9d9da9766a44e0ecd46f77", + "reason": "Compiled failing-first immediate terminal release after native merge denial returns Error::Context because the authenticated operation remains open.", + "path": "/tmp/canopy-native-merge-endpoint-closure-baseline.log", + "sha256": "f26f66b4112a38557c25cc36cb087e3c1f67a342a7a80de69d3419078983807f", + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 737 filtered out; finished in 0.65s" + ] + } + ], + "intermediate_failed_runs": [ + { + "source_digest": "89bb2bd7e2fcdbcc98bce9a8ed81b20d7753668a37278f7b860531498f038292", + "reason": "Ten focused cases passed and four failed after intended atomic terminal operation closure. Existing state oracles forbade the expected deletion of the actual terminal operation. Corrected only that projection: all other domain state and other operations remain compared, explicit own-operation counts distinguish authenticated closure from unauthenticated refusal, and rollback still compares complete state and SDK absence.", + "path": "/tmp/canopy-native-merge-endpoint-focused-first.log", + "sha256": "6fed7d1bd32a45a0f4a873d4c535beca1c5c96dc6870e5774338d5764c06b6dd", + "summaries": [ + "test result: FAILED. 10 passed; 4 failed; 0 ignored; 0 measured; 725 filtered out; finished in 4.09s" + ] + } + ], + "validation": { + "source_digest": "d1aa115a3c9e7ff266c3d71e50fc3d2054648af58e52d70c845ef817e2acd53a", + "phases": [ + { + "name": "focused", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "packs::publication::tests::native_merge::", + "--locked", + "--", + "--nocapture" + ], + "exit_code": 0, + "seconds": 104.18, + "log": "/tmp/canopy-native-merge-endpoint-final-focused-final.log", + "log_sha256": "786e3a27e6a7c16f402d4d11d956cbf2a79648d4e497e3e732b9dd9553423f24", + "summaries": [ + "test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 725 filtered out; finished in 5.30s" + ], + "failed_cases": [] + }, + { + "name": "endpoint", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--test", + "multi_server", + "merge::native_fast_forward_endpoint_replays_original_uuid_and_exposes_joint_refs_for_both_formats", + "--locked", + "--", + "--exact", + "--nocapture" + ], + "exit_code": 0, + "seconds": 82.55, + "log": "/tmp/canopy-native-merge-endpoint-final-endpoint-final.log", + "log_sha256": "36dc4d70a890361c43ca979a2072a338cfa1dd7479d2155c1e113153fca23292", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 115 filtered out; finished in 9.69s" + ], + "failed_cases": [] + }, + { + "name": "races", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--test", + "multi_server", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "--locked", + "--", + "--exact", + "--nocapture" + ], + "exit_code": 0, + "seconds": 8.66, + "log": "/tmp/canopy-native-merge-endpoint-final-races-final.log", + "log_sha256": "6fdd39ecf86689c0e6cfea4193c1cdbfad7c23345e366262a84300e8227f8262", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 115 filtered out; finished in 7.67s" + ], + "failed_cases": [] + }, + { + "name": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 57.79, + "log": "/tmp/canopy-native-merge-endpoint-final-clippy-final.log", + "log_sha256": "566ebe7bd72ab2948999391bccf1e518235fbd63a5df89bd2f9eaa6c49f446a6", + "summaries": [], + "failed_cases": [] + }, + { + "name": "workspace", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--locked", + "--no-fail-fast" + ], + "exit_code": 101, + "seconds": 747.83, + "log": "/tmp/canopy-native-merge-endpoint-final-workspace-final.log", + "log_sha256": "7ffa3b5bec87507c39d3d718d5e3801ec9dc62d4aacb0456c5e6ebdd6e612367", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.33s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.43s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 738 filtered out; finished in 0.01s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 738 filtered out; finished in 0.09s", + "test result: ok. 739 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 346.33s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 9.97s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.09s", + "test result: FAILED. 87 passed; 20 failed; 9 ignored; 0 measured; 0 filtered out; finished in 371.83s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.42s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.54s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.36s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s" + ], + "failed_cases": [ + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery", + "a_second_node_clones_from_the_published_root_after_local_disk_loss", + "repository_cell_publishes_objects_and_refs_atomically", + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ] + }, + { + "name": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 44.99, + "log": "/tmp/canopy-native-merge-endpoint-final-build-final.log", + "log_sha256": "690dfa3c8b4fe81dc1f8417f9291a3ab3f59527cf8ade3f2c3216d0c96b8ff38", + "summaries": [], + "failed_cases": [] + }, + { + "name": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.22, + "log": "/tmp/canopy-native-merge-endpoint-final-fmt-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "name": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 41.57, + "log": "/tmp/canopy-native-merge-endpoint-final-harness-final.log", + "log_sha256": "d3474fb327b2a19d198e57c275dcfbaee1acd9883374de1778e90a29f6182047", + "summaries": [], + "failed_cases": [] + }, + { + "name": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.11, + "log": "/tmp/canopy-native-merge-endpoint-final-diff-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + } + ], + "complete": true, + "release_qualified": false, + "source_unchanged": true + }, + "workspace_terminal_inventory": [ + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_git_format-cdf8cea2f92fe6a4)", + "passed": 6, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_object_storage-4a0661c5c4765140)", + "passed": 15, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_server-d080aca381ae9ba9)", + "passed": 739, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy-16a4bf977c56198e)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/directory_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/directory_cell-31a0f4eea5beeae3)", + "passed": 13, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/git_http.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/git_http-afa4d1d9a2db0179)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/multi_server/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/multi_server-3e08ee00a07d7bde)", + "passed": 87, + "failed": 20, + "ignored": 9 + }, + { + "binary": "tests/owner_restart.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/owner_restart-be32ecc90c554a17)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "tests/repository_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/repository_cell-322afb5848ed5e86)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "tests/smart_http/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/smart_http-18ac06122d3c394f)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "canopy_git_format", + "passed": 0, + "failed": 0, + "ignored": 0 + }, + { + "binary": "canopy_object_storage", + "passed": 0, + "failed": 0, + "ignored": 0 + }, + { + "binary": "canopy_server", + "passed": 0, + "failed": 0, + "ignored": 0 + } + ], + "workspace_unique_totals": { + "passed": 864, + "failed": 23, + "ignored": 9, + "executed": 887, + "total": 896 + }, + "counting": "Last summary per Cargo Running/Doc-tests section; nested child summaries and focused reruns excluded. Ignored cases remain unexecuted.", + "workspace_failure_delta": { + "added": [], + "removed": [ + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "sha256::sha256_checks_reviews_and_merge_survive_restore" + ] + }, + "parent_ci": { + "headRefOid": "75814b4c692364b4476e57456d439001bd5abbf2", + "mergeable": "MERGEABLE", + "statusCheckRollup": [ + { + "__typename": "CheckRun", + "completedAt": "2026-10-05T20:45:38Z", + "conclusion": "CANCELLED", + "detailsUrl": "https://github.com/crabbuild/canopy/actions/runs/37370177305/job/111965091142", + "name": "harness", + "startedAt": "2026-10-05T20:30:37Z", + "status": "COMPLETED", + "workflowName": "Verify" + }, + { + "__typename": "CheckRun", + "completedAt": "2026-10-05T20:41:42Z", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/crabbuild/canopy/actions/runs/37370182670/job/111965108638", + "name": "harness", + "startedAt": "2026-10-05T20:40:54Z", + "status": "COMPLETED", + "workflowName": "Verify" + }, + { + "__typename": "CheckRun", + "completedAt": "2026-10-05T21:00:22Z", + "conclusion": "FAILURE", + "detailsUrl": "https://github.com/crabbuild/canopy/actions/runs/37370182670/job/111965108917", + "name": "rust", + "startedAt": "2026-10-05T20:40:57Z", + "status": "COMPLETED", + "workflowName": "Verify" + }, + { + "__typename": "CheckRun", + "completedAt": "2026-10-05T21:01:47Z", + "conclusion": "FAILURE", + "detailsUrl": "https://github.com/crabbuild/canopy/actions/runs/37370177305/job/111965091486", + "name": "rust", + "startedAt": "2026-10-05T20:35:59Z", + "status": "COMPLETED", + "workflowName": "Verify" + } + ] + }, + "parent_linux_runs": [ + { + "run_id": 37370182670, + "metadata": { + "conclusion": "failure", + "headSha": "75814b4c692364b4476e57456d439001bd5abbf2", + "jobs": [ + { + "completedAt": "2026-10-05T20:41:42Z", + "conclusion": "success", + "databaseId": 111965108638, + "name": "harness", + "startedAt": "2026-10-05T20:40:54Z", + "status": "completed", + "steps": [ + { + "completedAt": "2026-10-05T20:40:56Z", + "conclusion": "success", + "name": "Set up job", + "number": 1, + "startedAt": "2026-10-05T20:40:54Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T20:41:00Z", + "conclusion": "success", + "name": "Run actions/checkout@v4", + "number": 2, + "startedAt": "2026-10-05T20:40:56Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T20:41:39Z", + "conclusion": "success", + "name": "Check Python qualification harness", + "number": 3, + "startedAt": "2026-10-05T20:41:00Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T20:41:39Z", + "conclusion": "success", + "name": "Post Run actions/checkout@v4", + "number": 6, + "startedAt": "2026-10-05T20:41:39Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T20:41:39Z", + "conclusion": "success", + "name": "Complete job", + "number": 7, + "startedAt": "2026-10-05T20:41:39Z", + "status": "completed" + } + ], + "url": "https://github.com/crabbuild/canopy/actions/runs/37370182670/job/111965108638" + }, + { + "completedAt": "2026-10-05T21:00:22Z", + "conclusion": "failure", + "databaseId": 111965108917, + "name": "rust", + "startedAt": "2026-10-05T20:40:57Z", + "status": "completed", + "steps": [ + { + "completedAt": "2026-10-05T20:40:58Z", + "conclusion": "success", + "name": "Set up job", + "number": 1, + "startedAt": "2026-10-05T20:40:58Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T20:41:00Z", + "conclusion": "success", + "name": "Run actions/checkout@v4", + "number": 2, + "startedAt": "2026-10-05T20:40:58Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T20:41:13Z", + "conclusion": "success", + "name": "Install tools", + "number": 3, + "startedAt": "2026-10-05T20:41:00Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T20:41:17Z", + "conclusion": "success", + "name": "Report tool versions", + "number": 4, + "startedAt": "2026-10-05T20:41:13Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T20:41:22Z", + "conclusion": "success", + "name": "Check formatting", + "number": 5, + "startedAt": "2026-10-05T20:41:17Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T20:43:02Z", + "conclusion": "success", + "name": "Check lints", + "number": 6, + "startedAt": "2026-10-05T20:41:22Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:00:20Z", + "conclusion": "failure", + "name": "Test", + "number": 7, + "startedAt": "2026-10-05T20:43:02Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:00:20Z", + "conclusion": "skipped", + "name": "Qualify Git compatibility against RustFS", + "number": 8, + "startedAt": "2026-10-05T21:00:20Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:00:20Z", + "conclusion": "skipped", + "name": "Build server", + "number": 9, + "startedAt": "2026-10-05T21:00:20Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:00:20Z", + "conclusion": "success", + "name": "Post Run actions/checkout@v4", + "number": 18, + "startedAt": "2026-10-05T21:00:20Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:00:20Z", + "conclusion": "success", + "name": "Complete job", + "number": 19, + "startedAt": "2026-10-05T21:00:20Z", + "status": "completed" + } + ], + "url": "https://github.com/crabbuild/canopy/actions/runs/37370182670/job/111965108917" + } + ], + "status": "completed" + }, + "path": "/tmp/canopy-native-merge-endpoint-parent-pr-full.log", + "sha256": "829f11c999f17c6961513deca8ed49e74f273c71872273f1dba8d28909e2d9c8", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.83s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.66s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 737 filtered out; finished in 0.00s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 737 filtered out; finished in 0.01s", + "test result: ok. 738 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 357.70s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.31s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: FAILED. 83 passed; 27 failed; 9 ignored; 0 measured; 0 filtered out; finished in 507.55s" + ] + }, + { + "run_id": 37370177305, + "metadata": { + "conclusion": "failure", + "headSha": "75814b4c692364b4476e57456d439001bd5abbf2", + "jobs": [ + { + "completedAt": "2026-10-05T20:45:38Z", + "conclusion": "cancelled", + "databaseId": 111965091142, + "name": "harness", + "startedAt": "2026-10-05T20:30:37Z", + "status": "completed", + "steps": [], + "url": "https://github.com/crabbuild/canopy/actions/runs/37370177305/job/111965091142" + }, + { + "completedAt": "2026-10-05T21:01:47Z", + "conclusion": "failure", + "databaseId": 111965091486, + "name": "rust", + "startedAt": "2026-10-05T20:35:59Z", + "status": "completed", + "steps": [ + { + "completedAt": "2026-10-05T20:36:00Z", + "conclusion": "success", + "name": "Set up job", + "number": 1, + "startedAt": "2026-10-05T20:36:00Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T20:36:02Z", + "conclusion": "success", + "name": "Run actions/checkout@v4", + "number": 2, + "startedAt": "2026-10-05T20:36:00Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T20:36:08Z", + "conclusion": "success", + "name": "Install tools", + "number": 3, + "startedAt": "2026-10-05T20:36:02Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T20:36:10Z", + "conclusion": "success", + "name": "Report tool versions", + "number": 4, + "startedAt": "2026-10-05T20:36:08Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T20:36:15Z", + "conclusion": "success", + "name": "Check formatting", + "number": 5, + "startedAt": "2026-10-05T20:36:10Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T20:38:35Z", + "conclusion": "success", + "name": "Check lints", + "number": 6, + "startedAt": "2026-10-05T20:36:15Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:01:44Z", + "conclusion": "failure", + "name": "Test", + "number": 7, + "startedAt": "2026-10-05T20:38:35Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:01:44Z", + "conclusion": "skipped", + "name": "Qualify Git compatibility against RustFS", + "number": 8, + "startedAt": "2026-10-05T21:01:44Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:01:44Z", + "conclusion": "skipped", + "name": "Build server", + "number": 9, + "startedAt": "2026-10-05T21:01:44Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:01:44Z", + "conclusion": "success", + "name": "Post Run actions/checkout@v4", + "number": 18, + "startedAt": "2026-10-05T21:01:44Z", + "status": "completed" + }, + { + "completedAt": "2026-10-05T21:01:44Z", + "conclusion": "success", + "name": "Complete job", + "number": 19, + "startedAt": "2026-10-05T21:01:44Z", + "status": "completed" + } + ], + "url": "https://github.com/crabbuild/canopy/actions/runs/37370177305/job/111965091486" + } + ], + "status": "completed" + }, + "path": "/tmp/canopy-native-merge-endpoint-parent-push-full.log", + "sha256": "0ecd67d73aca631a9b1d1c664106db818cb1cdab5dc524a74fc5985868bec5b8", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.43s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 8.92s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 737 filtered out; finished in 0.00s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 737 filtered out; finished in 0.01s", + "test result: ok. 738 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 476.67s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.91s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s", + "test result: FAILED. 83 passed; 27 failed; 9 ignored; 0 measured; 0 filtered out; finished in 618.68s" + ] + } + ], + "parent_ci_interpretation": "Parent 75814b4 Linux PR and push Rust jobs both completed FAILURE. Both passed 738 library cases and failed multi-server at 83 passed/27 failed/9 ignored. PR harness passed; push harness was cancelled. PR workflow log and push Rust job log plus exact-head metadata are preserved; push full-run log retrieval reported the cancelled harness log unavailable, so its actual Rust job was fetched directly. RustFS and Linux server build were skipped after Test failed. These are parent results, not qualification of this increment.", + "changes": [ + "Production HTTP fast-forward merge now uses GitGateway resident-owned staging and exact registered native publication; it no longer invokes retired RepositoryCell SQL merge preparation.", + "The adapter checks fresh access after body ingestion, transfers the intent and producer synchronously to the resident driver before awaiting observation, binds native base and admitted work, persists the original command and dispatches through the existing fair publication lifecycle.", + "Each request observation gets a fresh preparation operation. The final application UUID transaction selects original results or refuses collisions; denied SDK identities are not reused with new proof or policy bytes.", + "Authenticated terminal success, denial and UUID replay atomically close only the exact matching admitted operation. Late closure errors roll back all domain changes, recovery phase and SDK acceptance; unauthenticated or successor operations remain protected.", + "Fourteen private native merge tests pass. New late-closure SQL fault coverage proves full rollback, same-command retry, original known denial and immediate typed pin retirement for both formats.", + "Real stock-Git public endpoint coverage creates production roots, merges and replays UUIDs, refuses changed revisions, discovers refs and clones exact feature bytes for SHA-1 and SHA-256. Existing unrelated-history, stale revision, late body-ingestion revocation and competing publication coverage passes." + ], + "qualification_limits": [ + "New endpoint tests use genuine production startup and initial roots. Private proof/retirement fixtures still use verified stock-Git bytes with a synthetic initial certificate; neither proves full large-history capacity.", + "Merge-specific observer cancellation, lost acknowledgement, startup adoption, queued policy changes, pre-Bind intent reconstruction and automatic retirement after generation reaping require dedicated qualification; generic resident push lifecycle coverage is not sufficient proof for this producer.", + "Generated merge-commit, squash and rebase remain unavailable in this adapter. The historical direct RepositoryCell::merge_pull API still uses retired preparation and is not the production HTTP caller. No compatibility decoder is added.", + "No provider deletion is authorized. Complete physical ownership, retained-root inventory including permanent audits, cold/filtered fetch, backup/restore, peer recovery and final DDL remain required.", + "macOS lib-test linker reports an oversized __eh_frame compact-unwind warning. All-target Clippy, production server build and exact-head Linux results are independent checks.", + "Full CI, durable refusals, generated candidates/thread/default-branch writers, accelerators/fair maintenance, asynchronous file attribution and full-history/10k-engineer capacity gates remain open." + ] +} diff --git a/docs/evidence/native-merge-retirement-ci-20261005.json b/docs/evidence/native-merge-retirement-ci-20261005.json new file mode 100644 index 00000000..8a7807ef --- /dev/null +++ b/docs/evidence/native-merge-retirement-ci-20261005.json @@ -0,0 +1,377 @@ +{ + "recorded_at_utc": "2026-10-05T20:29:32.209793+00:00", + "base_head": "2ed3d58f9a3ee1b3020f6100555febaca4e01330", + "host": "macOS, Rust 1.98.0; exact-head Linux qualification remains required", + "source_files": 515, + "rust_files": 497, + "source_hash_digest": "ec37049731e41eb420dac90a67c20fa49c2efc603a6ff206c38da674ce9fa2eb", + "source_manifest": "/tmp/canopy-native-merge-retirement-final-source.json", + "source_digest_algorithm": "SHA256 of compact sorted-key JSON mapping each Rust/SQL/TOML/lock/YAML path to its file SHA256", + "source_unchanged_during_validation": true, + "release_qualified": false, + "baseline": { + "source_digest": "82e7d1c7014e1431125b5a9af0e0072d1e39311311e147328ec2337daaf8a9c5", + "result": "Compiled regression fails Error::Context when an applied merge attempts terminal pin release; original implementation deliberately provided no typed Merge terminal.", + "path": "/tmp/canopy-native-merge-retirement-baseline.log", + "sha256": "a0f048d817a9879318885f8ea67b0b15894c1d2a997ae85a7d5a33fda0147a9e", + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 733 filtered out; finished in 0.52s" + ] + }, + "intermediate_failed_runs": [ + { + "reason": "CatalogReader::open arguments were reversed. Corrected before focused validation. No frozen source manifest was captured for this intermediate compile; no exact-source qualification is claimed.", + "path": "/tmp/canopy-native-merge-retirement-check-first.log", + "sha256": "e11041d36cb9ce2f2c964e3dc9f70335d4da1329bd789cdca937f9fbf628d363", + "summaries": [] + }, + { + "reason": "New test helper needed to unbox Rejected result and two additional Graph fixture constructors needed the provider handle. Corrected without changing production authorization or assertions.", + "source_digest": "80cb311d59467cc883ab48b7f81cd05dde6fbbd14b931874324ea8011a24f32f", + "path": "/tmp/canopy-native-merge-retirement-focused-first.log", + "sha256": "6704639364b4cb603ae2d558e85360d702716bdad7789ed47bdce6a27a9d305e", + "summaries": [] + } + ], + "focused": { + "result": "13 passed, 0 failed; five new retirement families cover both SHA-1/SHA-256 where applicable", + "path": "/tmp/canopy-native-merge-retirement-final-focused-final.log", + "sha256": "63bd04b359b514fc40358e5ed80baa45507b8d1180716d8044ffee7ef2a92cb3", + "summaries": [ + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 725 filtered out; finished in 4.13s" + ] + }, + "validation": { + "source_digest": "ec37049731e41eb420dac90a67c20fa49c2efc603a6ff206c38da674ce9fa2eb", + "phases": [ + { + "name": "focused", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "packs::publication::tests::native_merge::", + "--locked", + "--", + "--nocapture" + ], + "exit_code": 0, + "seconds": 83.03, + "log": "/tmp/canopy-native-merge-retirement-final-focused-final.log", + "log_sha256": "63bd04b359b514fc40358e5ed80baa45507b8d1180716d8044ffee7ef2a92cb3", + "summaries": [ + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 725 filtered out; finished in 4.13s" + ], + "failed_cases": [] + }, + { + "name": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 34.62, + "log": "/tmp/canopy-native-merge-retirement-final-clippy-final.log", + "log_sha256": "6dc013c5421e14d5bf81bb68c6ba2263e86afb401c45cebf5b5639f691873586", + "summaries": [], + "failed_cases": [] + }, + { + "name": "workspace", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--locked", + "--no-fail-fast" + ], + "exit_code": 101, + "seconds": 696.39, + "log": "/tmp/canopy-native-merge-retirement-final-workspace-final.log", + "log_sha256": "f2da3c4d0382ea8a604b7fe52127042f71e55f0789333b3c8a49c064fa825757", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.65s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.23s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 737 filtered out; finished in 0.00s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 737 filtered out; finished in 0.06s", + "test result: ok. 738 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 287.28s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.71s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s", + "test result: FAILED. 82 passed; 24 failed; 9 ignored; 0 measured; 0 filtered out; finished in 348.96s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.16s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.22s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s" + ], + "failed_cases": [ + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery", + "a_second_node_clones_from_the_published_root_after_local_disk_loss", + "repository_cell_publishes_objects_and_refs_atomically", + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ] + }, + { + "name": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 38.51, + "log": "/tmp/canopy-native-merge-retirement-final-build-final.log", + "log_sha256": "696a3334cc2e62576200113f74213b1e3d1e78a14665704cdf04e76fdcab0e7a", + "summaries": [], + "failed_cases": [] + }, + { + "name": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.25, + "log": "/tmp/canopy-native-merge-retirement-final-fmt-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "name": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 41.98, + "log": "/tmp/canopy-native-merge-retirement-final-harness-final.log", + "log_sha256": "5eef478c1e991f316ac5a6e1c49d19adbfff7e6d7a05f2b0edeaea4d9cec0a2e", + "summaries": [], + "failed_cases": [] + }, + { + "name": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.05, + "log": "/tmp/canopy-native-merge-retirement-final-diff-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + } + ], + "complete": true, + "release_qualified": false, + "source_unchanged": true + }, + "workspace_terminal_inventory": [ + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_git_format-cdf8cea2f92fe6a4)", + "passed": 6, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_object_storage-4a0661c5c4765140)", + "passed": 15, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_server-d080aca381ae9ba9)", + "passed": 738, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy-16a4bf977c56198e)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/directory_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/directory_cell-31a0f4eea5beeae3)", + "passed": 13, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/git_http.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/git_http-afa4d1d9a2db0179)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/multi_server/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/multi_server-3e08ee00a07d7bde)", + "passed": 82, + "failed": 24, + "ignored": 9 + }, + { + "binary": "tests/owner_restart.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/owner_restart-be32ecc90c554a17)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "tests/repository_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/repository_cell-322afb5848ed5e86)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "tests/smart_http/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/smart_http-18ac06122d3c394f)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "canopy_git_format", + "passed": 0, + "failed": 0, + "ignored": 0 + }, + { + "binary": "canopy_object_storage", + "passed": 0, + "failed": 0, + "ignored": 0 + }, + { + "binary": "canopy_server", + "passed": 0, + "failed": 0, + "ignored": 0 + } + ], + "workspace_unique_totals": { + "passed": 858, + "failed": 27, + "ignored": 9, + "executed": 885, + "total": 894 + }, + "counting": "Last summary per Cargo Running/Doc-tests section; nested child summaries and focused reruns excluded. Ignored cases remain unexecuted.", + "workspace_failure_delta": { + "added": [], + "removed": [] + }, + "parent_ci": { + "headRefOid": "2ed3d58f9a3ee1b3020f6100555febaca4e01330", + "statusCheckRollup": [ + { + "__typename": "CheckRun", + "completedAt": "2026-10-05T20:09:41Z", + "conclusion": "CANCELLED", + "detailsUrl": "https://github.com/crabbuild/canopy/actions/runs/37366430531/job/111952638748", + "name": "harness", + "startedAt": "2026-10-05T19:54:39Z", + "status": "COMPLETED", + "workflowName": "Verify" + }, + { + "__typename": "CheckRun", + "completedAt": "2026-10-05T20:05:27Z", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/crabbuild/canopy/actions/runs/37366434267/job/111952651018", + "name": "harness", + "startedAt": "2026-10-05T20:04:46Z", + "status": "COMPLETED", + "workflowName": "Verify" + }, + { + "__typename": "CheckRun", + "completedAt": "2026-10-05T20:09:43Z", + "conclusion": "CANCELLED", + "detailsUrl": "https://github.com/crabbuild/canopy/actions/runs/37366434267/job/111952650597", + "name": "rust", + "startedAt": "2026-10-05T19:54:41Z", + "status": "COMPLETED", + "workflowName": "Verify" + }, + { + "__typename": "CheckRun", + "completedAt": "2026-10-05T20:09:41Z", + "conclusion": "CANCELLED", + "detailsUrl": "https://github.com/crabbuild/canopy/actions/runs/37366430531/job/111952638834", + "name": "rust", + "startedAt": "2026-10-05T19:54:39Z", + "status": "COMPLETED", + "workflowName": "Verify" + } + ] + }, + "parent_ci_interpretation": "Parent 2ed3d58 Rust checks were observed terminal CANCELLED, not successful. One PR harness completed SUCCESS. No Linux Rust qualification is inferred.", + "changes": [ + "Operation 9 codec 6 binds a permanent audit descriptor in the existing catalog certificate and atomically saves it with the applied UUID result. It reuses StoredInputRoot and pull_merges rather than SQL ref/ancestry mirrors or a compatibility decoder.", + "Fresh schema guards prevent UUID result/audit update, deletion or replacement. Selected typed audit contains logical request, base ref, catalog, ref snapshot and ref generation.", + "Merge terminal release selects the original applied UUID audit, checks actor/request/result and native catalog top roots plus exact published base-ref path, then atomically archives original recovery and release receipt before deleting its pin.", + "New UUID replay attempts and negative attempts require their own actual operation closure. Archived negative results remain original after a later fresh command succeeds.", + "Five new families cover both-format applied/replayed pin release, denied-then-successful original receipt separation, missing/corrupt five metadata roles, actual authority and last-write rollback, immutable UUID rows, and saved-command-body loss/SQLite loss/owner restoration of merge and release receipts." + ], + "qualification_limits": [ + "Native fixture installs genuine verified stock-Git catalog/ref bytes using a synthetic initial certificate. Tests isolate private publication/retirement/recovery and do not qualify public HTTP/resident integration.", + "Verification is bounded top-metadata and selected-ref-path verification, not exhaustive historical object/closure traversal. Permanent audit retains typed descendants. No provider deletion is authorized; complete root inventory, backup/restore and physical GC remain required.", + "Actual automatic merge retirement and generation-reaping workload coverage remain required. Existing supervisor uses shared typed terminal protocol but no merge-specific service test is claimed.", + "Public merge endpoint still invokes retired codec4/SQL preparation and remains unqualified. Generated strategies and native thread/default-branch writers remain open.", + "macOS lib-test linker reports an oversized __eh_frame compact-unwind warning; all-target Clippy is a separate check. Production server build and exact-head Linux results must be assessed independently.", + "Full CI, pre-Bind/late-ACL refusal, peer recovery/backup, selective fetch, final DDL/physical retention, accelerators/fair maintenance, asynchronous file attribution and full-history/10k-engineer capacity gates remain open." + ] +} diff --git a/docs/evidence/native-metadata-replay-20261004.json b/docs/evidence/native-metadata-replay-20261004.json new file mode 100644 index 00000000..6e4b43bd --- /dev/null +++ b/docs/evidence/native-metadata-replay-20261004.json @@ -0,0 +1,381 @@ +{ + "checkpoint": "bounded owned native metadata replay", + "previous_head": "d86f3152e450eb97b1b8db6d5968322f49703b0d", + "main": "d559e5635e002ee3f885c780a418cf861a5197fc", + "main_merge_before_publication": "d0aaaae940651670a6e553e4e8b37ba9379a1135", + "pr34_merged_at": "2026-10-04T23:57:49Z", + "rust_source_unchanged_across_main_merge": true, + "validation": { + "source_files": 490, + "rust_files": 476, + "source_hash_digest": "0c3751fde6bcf8ed1c20a4edde45cf829de54090bcf95caa4abdfebe3ed6c800", + "release_qualified": false, + "execution_complete": true, + "phases": [ + { + "label": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 41.475, + "log": "/tmp/canopy-metadata-replay-clippy-final.log", + "log_sha256": "8f331ada4aa4c0c4403de8b0232cdae96cf197c294dec01c8d9c6bcd821a829a", + "summaries": [], + "failed_cases": [] + }, + { + "label": "focused", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "--locked", + "--", + "physical::staged::tests::", + "native_receive_stages_verifies_and_publishes_then_clones_after_cache_loss", + "native_receive_prepares_bounded_immutable_root_completion_from_registered_custody", + "native_receive_metadata_refuses_capacity_corrupt_replay_and_missing_shards_without_publication", + "canceled_queued_read_keeps_file_and_admission_until_the_worker_finishes", + "--test-threads=4" + ], + "exit_code": 0, + "seconds": 89.785, + "log": "/tmp/canopy-metadata-replay-focused-final.log", + "log_sha256": "1fedfdcf9474fa6248d30e175d313b02a0e399e05fe45e2588e8d8837b326fe7", + "summaries": [ + [ + 6, + 0, + 0, + 0, + 692 + ] + ], + "failed_cases": [] + }, + { + "label": "artifact-owner", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-object-storage", + "--lib", + "--locked", + "canceled_queued_artifact_hashes_retain_physical_owner_until_actual_drain" + ], + "exit_code": 0, + "seconds": 6.884, + "log": "/tmp/canopy-metadata-replay-artifact-owner-final.log", + "log_sha256": "1acc1a0a9b167682b0d55c0d2c55c519442bf18a3d7c6645b1e265486231782f", + "summaries": [ + [ + 1, + 0, + 0, + 0, + 14 + ] + ], + "failed_cases": [] + }, + { + "label": "workspace", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--locked" + ], + "exit_code": 101, + "seconds": 280.965, + "log": "/tmp/canopy-metadata-replay-workspace-final.log", + "log_sha256": "dd0956ce61eb9dc11c5325611c46e63c935d95058b64600a911b6352976f8173", + "summaries": [ + [ + 6, + 0, + 0, + 0, + 0 + ], + [ + 15, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + 697 + ], + [ + 1, + 0, + 0, + 0, + 697 + ], + [ + 698, + 0, + 0, + 0, + 0 + ], + [ + 2, + 0, + 0, + 0, + 0 + ], + [ + 12, + 1, + 0, + 0, + 0 + ] + ], + "failed_cases": [ + "directory_reservations_recover_two_distinct_repository_cells" + ] + }, + { + "label": "binary", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--bin", + "canopy", + "--locked" + ], + "exit_code": 0, + "seconds": 0.734, + "log": "/tmp/canopy-metadata-replay-binary-final.log", + "log_sha256": "40b603a2b0e2e1134e228a8a2ae0abc6171efa1a9a1ddd0385f5c2bd56083b6a", + "summaries": [ + [ + 2, + 0, + 0, + 0, + 0 + ] + ], + "failed_cases": [] + }, + { + "label": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 51.439, + "log": "/tmp/canopy-metadata-replay-build-final.log", + "log_sha256": "19683af99cd6f5c236c26b491f657169681ec380c18339fdc6a40d62671fe7c5", + "summaries": [], + "failed_cases": [] + }, + { + "label": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.268, + "log": "/tmp/canopy-metadata-replay-fmt-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "label": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.062, + "log": "/tmp/canopy-metadata-replay-diff-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "label": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 41.884, + "log": "/tmp/canopy-metadata-replay-harness-final.log", + "log_sha256": "ac71d80010338328a551c19b3481da7555337d0d445822fd34e2c48e098fc4fe", + "summaries": [], + "failed_cases": [] + } + ] + }, + "rust_unique_executed": 734, + "rust_unique_passed": 733, + "rust_unique_failed": 1, + "library_unique_passed": 719, + "server_library_passed": 698, + "publication_unique_passed": 401, + "focused_server_passed": 6, + "focused_artifact_owner_passed": 1, + "binary_passed": 2, + "directory_passed": 12, + "directory_failed": 1, + "python_passed": 96, + "counts_exclude": "focused and binary reruns, and two nested subprocess summaries; interleaved child stdout is parsed without losing the parent case", + "limits": { + "default_max_shard_objects": 8192, + "default_max_descriptor_bytes": 67108864, + "descriptor_record_bytes": 512, + "frame_prefix_bytes": 4, + "physical_inspection_page_objects": 512, + "local_resident_metadata_shards": 1 + }, + "qualification_scope": [ + "SHA-1/SHA-256 actual native receive, retained checkpoint registration, multi-shard physical inspection/upload, Creating drain before Bind, bound replay/closure/catalog preparation, joint-root completion and cold clone composition", + "denied descriptor growth, malformed/truncated bounded frames and mismatched native bindings", + "descriptor-capacity refusal, truncated replay and missing authenticated metadata parts poison preparation and leave catalog/ref generations and completed responses unchanged in both formats", + "canceled confirmed queued metadata reads and all three artifact hash phases retain physical ownership until actual drain" + ], + "source_hashes_file": { + "path": "/tmp/canopy-metadata-replay-source-hashes.json", + "sha256": "249b16c28bc2426c3ba2cefeec3e63907bb7e1ab39b13cfd547320e802612844" + }, + "static_audit": { + "source_unchanged": true, + "source_files": 490, + "source_digest": "0c3751fde6bcf8ed1c20a4edde45cf829de54090bcf95caa4abdfebe3ed6c800", + "manifest_pins": 5, + "lock_pins": 6, + "sdk_clean": true, + "doc_links": 66, + "protected": [ + { + "path": "/Users/haipingfu/Github/canopy/.git/worktrees/canopy5/index", + "sha256": "bef77b0a83f80518f232060828e83797174b1863b8ed9147bffa65850af59798" + }, + { + "path": "docs/archive/pr20-progress-through-8bb0ee7.md", + "sha256": "c7494d679abed5e1e55a5b2d605d80e786cb4de86406d77f0c7a37c71c79437e" + } + ], + "main": "d559e5635e002ee3f885c780a418cf861a5197fc" + }, + "remote_previous_head_ci": [ + { + "head": "d86f3152e450eb97b1b8db6d5968322f49703b0d", + "run": 37244954415, + "rust_job": 111560885573, + "server_library_passed": 695, + "binary_passed": 2, + "directory_passed": 12, + "directory_failed": 1, + "failure": "retired ingestion operation descriptor is unavailable", + "log": { + "path": "/tmp/canopy-pr34-d86f315-failed.log", + "sha256": "7ae471264b95a73ecd98f40162a3afcba00b00125db001583580e106175a82b8" + } + }, + { + "head": "d86f3152e450eb97b1b8db6d5968322f49703b0d", + "run": 37244950312, + "rust_job": 111560875098, + "server_library_passed": 695, + "binary_passed": 2, + "directory_passed": 12, + "directory_failed": 1, + "failure": "retired ingestion operation descriptor is unavailable", + "log": { + "path": "/tmp/canopy-pr34-d86f315-secondary-failed.log", + "sha256": "8bd0ae82dc8399f09145c48bb0699bee33cc87ef82daa1b6974261c5b04f3a7e" + } + } + ], + "pre_qualification_diagnostics": [ + { + "path": "/tmp/canopy-metadata-replay-clippy-draft.log", + "sha256": "604fb1a07782e4f012951432c96c549b3585d4c25daa3776ac69a06d6a8721ca" + }, + { + "path": "/tmp/canopy-metadata-replay-before-artifact-owner-clippy-final.log", + "sha256": "53d1fae2ac76ea9f4ab8a7c9a93d4b6e3194bf58d825875e01457747d957e80e" + }, + { + "path": "/tmp/canopy-metadata-replay-before-reader-release-clippy-final.log", + "sha256": "d748adbb4a98c983f5cb30d16f18d225a19456a83cb4345e9834d2a2b3b9a0e4" + } + ], + "diagnostic_scope": "moved fixture value, missing ObjectStoreExt import and temporary path lifetime; no final-source passing qualification is attributed to drafts", + "draft_validation_files": [ + { + "path": "/tmp/canopy-metadata-replay-before-artifact-owner-validation.json", + "sha256": "48d082047ec5aa954da613590df52b0244d84120ed58d0fd6abe5180d1a07d44" + }, + { + "path": "/tmp/canopy-metadata-replay-before-reader-release-validation.json", + "sha256": "39cd2bd368b361df71e8a8c83327b59f69334a406c908d02321225a6a37c92c1" + } + ], + "release_qualified": false, + "remaining_failure": "Actual writers and the directory integration caller still invoke retired ingestion. Convert those callers; do not restore the command/schema or skip the case.", + "unrun": [ + "remaining integration binaries/doctests beyond the first failed integration binary", + "complete new-source Linux/RustFS workflow", + "full native histories, OS containment, fair maintenance/hot-root progress and 10000-engineer mixed-load/recovery capacity", + "adopted earlier-owner input physical verification via authenticated retained selection" + ], + "next_priorities": [ + "resident staging ownership and real owned HTTP/SSH/generated producers", + "remaining request/policy physical ownership and authenticated old-owner physical input selection", + "mandatory joint-root completion and real integration caller conversion, then complete Linux/provider CI", + "remaining authority consumers, custody history/rollover and final schema removal", + "typed GC/backup/restore, OS containment, fair native maintenance/shared hot workspaces, full-history/team capacity and file attribution" + ] +} diff --git a/docs/evidence/native-policy-startup-ci-20261005.json b/docs/evidence/native-policy-startup-ci-20261005.json new file mode 100644 index 00000000..332617a9 --- /dev/null +++ b/docs/evidence/native-policy-startup-ci-20261005.json @@ -0,0 +1,560 @@ +{ + "recorded_at_utc": "2026-10-05T18:10:26.030001+00:00", + "base_head": "ef765d449ab09488af1712d72dc39aa1894dc555", + "host": "macOS, Rust 1.98.0; exact-head Linux qualification remains required", + "source_files": 510, + "rust_files": 492, + "source_hash_digest": "08ef5b30a9be622dc4ebccc592cd66d7cbf20a72e6912aeddb364e2f8ddcbd39", + "source_manifest": "/tmp/canopy-native-policy-final-source.json", + "source_digest_algorithm": "SHA256 of compact sorted-key JSON mapping each Rust/SQL/TOML/lock/YAML path to its file SHA256", + "source_unchanged_during_validation": true, + "release_qualified": false, + "reproductions": [ + { + "result": "Shared public startup completion seam returns error before the actual supervisor releases its held reserved listener. Regression fails before join fix.", + "source_manifest": "/tmp/canopy-native-policy-startup-baseline-source.json", + "source_digest": "c519e829fcb5e781cc75b8a91cf0eaa78407a0ae8b361e7b214fa24df72656eb", + "path": "/tmp/canopy-native-policy-startup-baseline.log", + "sha256": "9ab02b5dd3436c2cddebb9770be2b613a2be14618d99bbb79e1030f2b644eabf", + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 722 filtered out; finished in 0.03s" + ] + }, + { + "result": "Actual resident review-policy returns None because it joins retired SQL refs after a native fixture creates a pull. Fails before reader conversion.", + "source_manifest": "/tmp/canopy-native-policy-reader-baseline-source.json", + "source_digest": "1a5ed060f279f159e023eb4c7516a55a7d64665335cccecd0572c32f04c27d61", + "path": "/tmp/canopy-native-policy-reader-baseline.log", + "sha256": "6561575e8b5ee1f75fae6db68e546390090eedabbeea8352c917f77467066177", + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 723 filtered out; finished in 0.72s" + ] + }, + { + "result": "Previously compiled ef765d4 stock-Git HTTP merge family fails at its initial review-policy GET with 404. This binary reproduction is not validation of uncommitted source at execution.", + "compiled_source_manifest": "/tmp/canopy-native-pulls-final-source.json", + "path": "/tmp/canopy-native-policy-http-baseline.log", + "sha256": "5c38801db6586a3508862cfcb66e3fe3df4cd703551b3c398f612df10dfbfed7", + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 114 filtered out; finished in 2.28s" + ] + } + ], + "intermediate_attempts": [ + { + "result": "Initial reader conversion passes the first native-policy regression before expanded prepared-query checks. Not final-source qualification.", + "path": "/tmp/canopy-native-policy-reader-fixed.log", + "sha256": "a2c7854608136eb8f84605c2c09c5dd01bf07df43c762586a28c3c66a5432512", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 723 filtered out; finished in 2.42s" + ] + }, + { + "result": "Expanded regression incorrectly expected a comment to clear a prior request-changes decision. Production behavior matches documented non-comment review-head semantics; correct expected changes=1. Our partial workspace compiler was terminated before source correction; no foreign process stopped.", + "validation": { + "source_digest": "83c4f50c16d5927ed457fc9e35c9953b3ecf11002e5825c76331637ee50271c8", + "phases": [ + { + "name": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 33.31, + "log": "/tmp/canopy-native-policy-first-clippy-final.log", + "log_sha256": "af9d5539cad7c82e3d2d31390d2bea14957b4492dd3feed01db68eff5d74f1ca", + "summaries": [], + "failed_cases": [] + }, + { + "name": "policy", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "native_review_policy_observes_current_rules_reviews_membership_and_ref_aba", + "--locked", + "--", + "--nocapture" + ], + "exit_code": 101, + "seconds": 77.39, + "log": "/tmp/canopy-native-policy-first-policy-first.log", + "log_sha256": "3bd733eac119f91dbc72a46ce05ec8646da157aec7d7a57b3157b318af312b14", + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 723 filtered out; finished in 0.86s" + ], + "failed_cases": [ + "server::residency::tests::serving::browser::pulls::native_review_policy_observes_current_rules_reviews_membership_and_ref_aba" + ] + }, + { + "name": "startup", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "failed_startup_waits_for_supervisor_completion_and_owned_listener_release", + "--locked", + "--", + "--nocapture" + ], + "exit_code": 0, + "seconds": 0.74, + "log": "/tmp/canopy-native-policy-first-startup-final.log", + "log_sha256": "26612f455fbaa6f0f78a049bf091b2227fb7c5dc9ccf49ab6f4eb7eb84fd2d8d", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 723 filtered out; finished in 0.05s" + ], + "failed_cases": [] + } + ], + "complete": false, + "release_qualified": false, + "interrupted": true, + "interruption_record": "/tmp/canopy-native-policy-first-interruption.json" + }, + "source_manifest": "/tmp/canopy-native-policy-first-source.json", + "interruption": { + "reason": "Stop our validation after regression exposes incorrect comment expectation; preserve all completed phases and partial workspace log before correcting test.", + "processes": { + "57223": [ + 30109, + "/opt/homebrew/Cellar/python@3.14/3.14.6/Frameworks/Python.framework/Versions/3.14/Resources/Python.app/Contents/MacOS/Python -u /tmp/canopy_native_policy_validate.py" + ], + "57958": [ + 57223, + "/Users/haipingfu/.rustup/toolchains/1.98.0-aarch64-apple-darwin/bin/cargo test --workspace --locked --no-fail-fast" + ], + "58144": [ + 57958, + "/Users/haipingfu/.rustup/toolchains/1.98.0-aarch64-apple-darwin/bin/rustc --crate-name multi_server --edition=2024 crates/canopy-server/tests/multi_server/main.rs --error-format=json --json=diagnostic-rendered-ansi,artifacts,future-incompat --emit=dep-info,link -C embed-bitcode=no -C debuginfo=2 -C split-debuginfo=unpacked --test --check-cfg cfg(docsrs,test) --check-cfg cfg(feature, values()) -C metadata=181874f3bf65c835 -C extra-filename=-3e08ee00a07d7bde --out-dir /Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps -L dependency=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps --extern async_trait=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libasync_trait-264adbdf16f2d6bb.dylib --extern axum=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libaxum-64e276c868a1333d.rlib --extern base64=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libbase64-b543fed2f0293468.rlib --extern blake3=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libblake3-ccf2879731550d36.rlib --extern bytes=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libbytes-258f462c5e906be1.rlib --extern canopy_git_format=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libcanopy_git_format-47331bdc9c9e8b2d.rlib --extern canopy_object_storage=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libcanopy_object_storage-b50a50d07ced2114.rlib --extern canopy_server=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libcanopy_server-737729dd7418b4df.rlib --extern cellule_app=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libcellule_app-37270f04fe254f2c.rlib --extern cellule_host=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libcellule_host-d934a2c86b6ded1f.rlib --extern cellule_ltx=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libcellule_ltx-c03e13c2108bfd42.rlib --extern cellule_runtime=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libcellule_runtime-0cbcd9ec23d93de9.rlib --extern cellule_store=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libcellule_store-ff151c59722e759d.rlib --extern ed25519_dalek=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libed25519_dalek-8cb1fbe14113f307.rlib --extern flate2=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libflate2-4c659b2504bcde03.rlib --extern futures_core=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libfutures_core-eb34df332aa694e8.rlib --extern futures_util=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libfutures_util-e0a1ab9539c3c127.rlib --extern hex=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libhex-d8b113cf0abdd414.rlib --extern http_body=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libhttp_body-15944d5a62cbd2dd.rlib --extern libc=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/liblibc-1bdbc5d94638008d.rlib --extern object_store=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libobject_store-24b6fc0ca49d1c41.rlib --extern percent_encoding=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libpercent_encoding-76226f1a5c7d4040.rlib --extern rcgen=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/librcgen-e34af303a837c49c.rlib --extern reqwest=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libreqwest-1843dd4e2c5ea820.rlib --extern rusqlite=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/librusqlite-1255a978c6680481.rlib --extern russh=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/librussh-284e98d7a0ec41b7.rlib --extern serde=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libserde-718e4da2dae12fe2.rlib --extern serde_json=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libserde_json-29d6a1871be8ccb4.rlib --extern sha1=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libsha1-a587e3b4251863cb.rlib --extern sha2=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libsha2-7c643b52fee5e057.rlib --extern ssh_key=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libssh_key-3cb8b3f90f07f4cc.rlib --extern tempfile=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libtempfile-2a7959d794f25bf9.rlib --extern thiserror=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libthiserror-d2acbff21d4177b1.rlib --extern tokio=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libtokio-4b19c8c8af5c60af.rlib --extern tokio_rustls=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libtokio_rustls-51d53d321968bcee.rlib --extern tokio_util=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libtokio_util-5cfdd31275f97490.rlib --extern tower=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libtower-c8013d21ed30a066.rlib --extern tracing=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libtracing-a9cf8244c3c83909.rlib --extern tracing_appender=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libtracing_appender-164fcb77ccb03877.rlib --extern tracing_subscriber=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libtracing_subscriber-98f6a9959a9e3652.rlib --extern url=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/liburl-269058e59acc5dd8.rlib --extern uuid=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/libuuid-f99bd28e327e2af1.rlib -L native=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/build/blake3-afb1f6c10ed84bad/out -L native=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/build/aws-lc-sys-2bdb6e07e75aa555/out -L native=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/build/ring-8ea5519ffb1e7330/out -L native=/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/build/libsqlite3-sys-c4209bb4f57153f5/out" + ] + } + }, + "partial_workspace_log": { + "path": "/tmp/canopy-native-policy-first-workspace-final.log", + "sha256": "c5eb0cf3885ca7b9fd71752200e85c3d1290e5a3173eeec3a40b1dd0760860cd", + "summaries": [] + } + } + ], + "validation": { + "source_digest": "08ef5b30a9be622dc4ebccc592cd66d7cbf20a72e6912aeddb364e2f8ddcbd39", + "phases": [ + { + "name": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 30.66, + "log": "/tmp/canopy-native-policy-final-clippy-final.log", + "log_sha256": "e0ab0a2ed6a1eff099473dc983c8e7b042844d340a327cf4f489494ddb206258", + "summaries": [], + "failed_cases": [] + }, + { + "name": "policy", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "native_review_policy_observes_current_rules_reviews_membership_and_ref_aba", + "--locked", + "--", + "--nocapture" + ], + "exit_code": 0, + "seconds": 85.49, + "log": "/tmp/canopy-native-policy-final-policy-final.log", + "log_sha256": "1cc4b79822f0894caf82b980b81cabf130b91c58c57ccfa0092de09132c7578f", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 723 filtered out; finished in 2.60s" + ], + "failed_cases": [] + }, + { + "name": "startup", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "failed_startup_waits_for_supervisor_completion_and_owned_listener_release", + "--locked", + "--", + "--nocapture" + ], + "exit_code": 0, + "seconds": 0.76, + "log": "/tmp/canopy-native-policy-final-startup-final.log", + "log_sha256": "d797bf59919151ea4431f9bdf585ac190a2d145315081695a6372a72d148db39", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 723 filtered out; finished in 0.05s" + ], + "failed_cases": [] + }, + { + "name": "workspace", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--locked", + "--no-fail-fast" + ], + "exit_code": 101, + "seconds": 669.8, + "log": "/tmp/canopy-native-policy-final-workspace-final.log", + "log_sha256": "210f69b7cc2988bbd04477ad90eabb03145c6cffa5a94d98aa854fc89273ccf5", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.71s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.27s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 723 filtered out; finished in 0.01s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 723 filtered out; finished in 0.08s", + "test result: ok. 724 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 287.29s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 8.57s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s", + "test result: FAILED. 82 passed; 24 failed; 9 ignored; 0 measured; 0 filtered out; finished in 352.72s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.38s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.15s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.26s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s" + ], + "failed_cases": [ + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery", + "a_second_node_clones_from_the_published_root_after_local_disk_loss", + "repository_cell_publishes_objects_and_refs_atomically", + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ] + }, + { + "name": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 42.23, + "log": "/tmp/canopy-native-policy-final-build-final.log", + "log_sha256": "55b2c9b98406e7846b9bdf62055ecb1261f3028de4299ca852a0853e8ec496d1", + "summaries": [], + "failed_cases": [] + }, + { + "name": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.24, + "log": "/tmp/canopy-native-policy-final-fmt-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "name": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 42.2, + "log": "/tmp/canopy-native-policy-final-harness-final.log", + "log_sha256": "788897927721b6e97f074403bc69ca4d1360d4b877e2c09089d257dfd0bf611f", + "summaries": [], + "failed_cases": [] + }, + { + "name": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.05, + "log": "/tmp/canopy-native-policy-final-diff-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + } + ], + "complete": true, + "release_qualified": false, + "source_unchanged": true + }, + "workspace_terminal_inventory": [ + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_git_format-cdf8cea2f92fe6a4)", + "passed": 6, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_object_storage-4a0661c5c4765140)", + "passed": 15, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_server-d080aca381ae9ba9)", + "passed": 724, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy-16a4bf977c56198e)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/directory_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/directory_cell-31a0f4eea5beeae3)", + "passed": 13, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/git_http.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/git_http-afa4d1d9a2db0179)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/multi_server/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/multi_server-3e08ee00a07d7bde)", + "passed": 82, + "failed": 24, + "ignored": 9 + }, + { + "binary": "tests/owner_restart.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/owner_restart-be32ecc90c554a17)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "tests/repository_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/repository_cell-322afb5848ed5e86)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "tests/smart_http/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/smart_http-18ac06122d3c394f)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "canopy_git_format", + "passed": 0, + "failed": 0, + "ignored": 0 + }, + { + "binary": "canopy_object_storage", + "passed": 0, + "failed": 0, + "ignored": 0 + }, + { + "binary": "canopy_server", + "passed": 0, + "failed": 0, + "ignored": 0 + } + ], + "workspace_unique_totals": { + "passed": 844, + "failed": 27, + "ignored": 9, + "executed": 871, + "total": 880 + }, + "counting": "Last summary per Cargo Running section; nested child summaries and focused reruns excluded. Ordinary ignored cases remain unexecuted.", + "parent_linux_ci": [ + { + "head": "ef765d449ab09488af1712d72dc39aa1894dc555", + "run": 37348401382, + "job": 111892756665, + "conclusion": "failure", + "log": "/tmp/canopy-policy-parent-pr-full.log", + "sha256": "ed2700812c12e18ab37de67fe6ce663153195a534a73684d9737da197afd400d", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.53s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 11.87s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 721 filtered out; finished in 0.00s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 721 filtered out; finished in 0.03s", + "test result: ok. 722 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 499.48s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.12s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: FAILED. 83 passed; 27 failed; 9 ignored; 0 measured; 0 filtered out; finished in 657.90s" + ], + "failed_cases": [ + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "push_options::mismatched_signed_push_options_return_a_durable_git_rejection", + "push_options::stock_git_push_options_are_validated_recorded_and_recovered", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::ssh_push_options_cover_pack_and_delete_only_requests", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery" + ] + }, + { + "head": "ef765d449ab09488af1712d72dc39aa1894dc555", + "run": 37348395260, + "job": 111892735724, + "conclusion": "failure", + "log": "/tmp/canopy-policy-parent-push-full.log", + "sha256": "69b061e52d575e3d8ea78da60ee4f649deabcc42714dd1df2b6aff88a508dbc4", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.68s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.03s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 721 filtered out; finished in 0.00s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 721 filtered out; finished in 0.01s", + "test result: ok. 722 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 366.32s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.31s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: FAILED. 83 passed; 27 failed; 9 ignored; 0 measured; 0 filtered out; finished in 515.12s" + ], + "failed_cases": [ + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "push_options::mismatched_signed_push_options_return_a_durable_git_rejection", + "push_options::stock_git_push_options_are_validated_recorded_and_recovered", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::ssh_push_options_cover_pack_and_delete_only_requests", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery" + ] + } + ], + "changes": [ + "Review-policy query 52 codec 2 reuses private native ref observations, bounded metadata selection and final fresh SQL rules/heads/membership/access. Merge ancestry preflight uses this reader; final merge writer remains legacy and unqualified.", + "Actual-resident regression for both formats covers prepared query freshness, purpose/number substitution, delayed access revocation, membership ABA, comment-preserved decisions, tombstones and same-OID ref ABA.", + "Startup error completion joins its actual supervisor before returning; successful readiness and cancellation ownership stay unchanged.", + "Conditional-storage fixture retains caller port reservation through the probe while preserving exact domain/empty-store/rebind assertions. This does not establish the cause of every earlier AddrInUse failure." + ], + "limits": { + "facts": 128, + "total_name_bytes": 524288, + "single_name_bytes": 65535, + "input_bytes": 835584, + "query_output_bytes": 1048576, + "read_attempts": 3 + }, + "remaining": "Native atomic merge/rebase/candidate publication and thread revisions, default-branch joint mutation, Linux rejected-push workloads and pre-Bind durable refusal, real peers/source-independent backup, reachable-only cold/filtered fetch and standalone actual residents, physical ownership/recovery/retention/final DDL, accelerators/fair maintenance and complete large-history/10k-engineer qualification." +} diff --git a/docs/evidence/native-producer-workspace-ci-20261006.json b/docs/evidence/native-producer-workspace-ci-20261006.json new file mode 100644 index 00000000..21fc71a1 --- /dev/null +++ b/docs/evidence/native-producer-workspace-ci-20261006.json @@ -0,0 +1,27 @@ +{ + "base_head": "759d4778f2ae9a04d426470f5354618c1e904f62", + "sources": { + "crates/canopy-server/src/packs/publication/serving/session/workspace.rs": "b5ef94e49c7f941eb63289f47dd4f41ac8a9d3ec78413df9cb911aa50c1bd9ff" + }, + "change": "Explicit producer roots install each certified complete pack/index pair once and verify selected bodies from that workspace. Ref-based transport workspaces retain selective extraction. No admission limits or test assertions changed.", + "validation": { + "workspace_tests": { + "passed": 11, + "seconds": 18.69, + "log": "/tmp/canopy-native-producer-workspace-fixed.log" + }, + "clippy": { + "exit_code": 0, + "seconds": 43.12, + "log": "/tmp/canopy-native-producer-clippy.log" + }, + "format_and_diff": { + "exit_code": 0 + }, + "full_workspace": { + "state": "pending", + "log": "/tmp/canopy-native-workspace-after-producer.log" + } + }, + "prior_full_failure": "752 pass / 5 fail at759d477: three extra-file/admission assertions, wide workspace timeout, and contended ancestry lease expiry. The ancestry case passed isolated at the same source in37.85s; it still requires full-suite validation." +} diff --git a/docs/evidence/native-pulls-ci-20261005.json b/docs/evidence/native-pulls-ci-20261005.json new file mode 100644 index 00000000..ebaa33ca --- /dev/null +++ b/docs/evidence/native-pulls-ci-20261005.json @@ -0,0 +1,632 @@ +{ + "recorded_at_utc": "2026-10-05T17:24:03.277163+00:00", + "base_head": "4eb4fd0b6594ec71d6e7f7cec36ce63705556c2b", + "host": "macOS, Rust 1.98.0; final Linux qualification remains required", + "source_files": 510, + "rust_files": 492, + "source_hash_digest": "98f83b2614325e981544abf21949ed9e96c779c7aeff702178a2dbbf44f2b477", + "source_digest_algorithm": "SHA256 of compact sorted-key JSON mapping each Rust/SQL/TOML/lock/YAML path to its file SHA256; source manifest /tmp/canopy-native-pulls-final-source.json", + "source_unchanged_during_validation": true, + "release_qualified": false, + "reproduction": { + "exit_code": 101, + "path": "/tmp/canopy-native-pulls-baseline.log", + "sha256": "297188583bce3eb5af8275037e18bc1cd05f9f764b39865a7fda8eaea83509c7", + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 114 filtered out; finished in 2.62s" + ], + "baseline_head": "4eb4fd0b6594ec71d6e7f7cec36ce63705556c2b", + "baseline_source_hash_digest": "57004d2198c0001dfed9c02c60d4e979f1905a2e887607b48f3d52d7f28138d0", + "result": "Original compiled HTTP pull family returns 409 after real native push because creation checks the retired SQL ref table." + }, + "intermediate_attempts": [ + { + "result": "Compile failure from helper visibility, missing codec conversion and boxed recorded rejection handling; not a runtime reproduction.", + "path": "/tmp/canopy-native-pulls-check1.log", + "sha256": "b7421e5c4c4c0f716d907147001279eec49b6a6ba589b54a927a0e2697628338", + "summaries": [] + }, + { + "result": "HTTP family reaches delayed revocation and returns 503 instead of 404. Known denied adapters now route to a final typed command with no proof, retaining its original durable NotFound receipt.", + "path": "/tmp/canopy-native-pulls-e2e1.log", + "sha256": "cbc3dee324e06e247af9758b3dc1ab9618a12562f8c3a559299118ca69b1da23", + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 114 filtered out; finished in 5.09s" + ] + }, + { + "result": "HTTP family passes before the final added receiver tests/fixture correction. Not counted as final frozen-source workspace evidence.", + "path": "/tmp/canopy-native-pulls-e2e2.log", + "sha256": "1153f24598518106b10a36cb11bbcf9a61bec2ff133ef906185c6113ef971bad", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 114 filtered out; finished in 11.19s" + ] + }, + { + "provenance": "Recorded from tool output for the first broader browser invocation; its raw log path was reused by the final validator. This is a result transcription, not a full original log.", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "server::residency::tests::serving::browser::", + "--locked", + "--", + "--nocapture" + ], + "exit_code": 101, + "summary": "test result: FAILED. 9 passed; 2 failed; 0 ignored; 0 measured; 711 filtered out; finished in 9.56s", + "failed_cases": [ + "server::residency::tests::serving::browser::production_certified_edge_pages_cover_wide_trees_and_parent_boundaries", + "server::residency::tests::serving::browser::production_native_comparisons_read_certified_ancestry_patches_and_previews" + ], + "error": "Root(Codec(Invalid(\"invalid preparation context\")))", + "cause": "Trusted native ref fixture used UUID artifact operation identifiers instead of the validated CANOPY01 sequence. Fixed fixture identifiers; no production validation relaxed." + } + ], + "pre_box_validation": { + "source_hash_digest": "8fe11937310c76559e3a1277fd0ec7a7ace7d77ee9e6f20c552cd50bf844fb47", + "manifest": "/tmp/canopy-native-pulls-source.json", + "validation": { + "source_digest": "8fe11937310c76559e3a1277fd0ec7a7ace7d77ee9e6f20c552cd50bf844fb47", + "phases": [ + { + "name": "browser", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "server::residency::tests::serving::browser::", + "--locked", + "--", + "--nocapture" + ], + "exit_code": 0, + "seconds": 123.52, + "log": "/tmp/canopy-native-pulls-browser-final.log", + "log_sha256": "400d67ee0a7ab13ad203e6c799b04dc2450622a242aaa2533eb45ea569e4452b", + "summaries": [ + "test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 711 filtered out; finished in 14.51s" + ], + "failed_cases": [] + }, + { + "name": "workspace", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--locked", + "--no-fail-fast" + ], + "exit_code": 101, + "seconds": 794.45, + "log": "/tmp/canopy-native-pulls-workspace-final.log", + "log_sha256": "f97e5520e5c33f850d139607dabd27e4182f6060cdb2ab855a7245efc35712c7", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.00s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.57s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 721 filtered out; finished in 0.01s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 721 filtered out; finished in 0.10s", + "test result: ok. 722 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 343.77s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 8.95s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s", + "test result: FAILED. 82 passed; 24 failed; 9 ignored; 0 measured; 0 filtered out; finished in 360.36s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.26s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.31s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.46s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s" + ], + "failed_cases": [ + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery", + "a_second_node_clones_from_the_published_root_after_local_disk_loss", + "repository_cell_publishes_objects_and_refs_atomically", + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ] + }, + { + "name": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 101, + "seconds": 42.68, + "log": "/tmp/canopy-native-pulls-clippy-final.log", + "log_sha256": "94eb8f2d2b926cedfceba1ffecccba9aba1df671751b9380848080257347835f", + "summaries": [], + "failed_cases": [] + }, + { + "name": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 64.68, + "log": "/tmp/canopy-native-pulls-build-final.log", + "log_sha256": "0900ec22ab155c16e1caea5a43d4c5f9d9cd5d0898632803a9736b4a4740f671", + "summaries": [], + "failed_cases": [] + }, + { + "name": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.59, + "log": "/tmp/canopy-native-pulls-fmt-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "name": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 42.44, + "log": "/tmp/canopy-native-pulls-harness-final.log", + "log_sha256": "1134e53160dd26d782712c1313bdad2f19d8bd6dca4b21c7ffefdfba7a14d860", + "summaries": [], + "failed_cases": [] + }, + { + "name": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.06, + "log": "/tmp/canopy-native-pulls-diff-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + } + ], + "complete": true, + "release_qualified": false, + "source_unchanged": true + }, + "result": "All 722 server libraries pass, matrix 82/24/9, three standalone failures. Clippy rejects the oversized new git_read::ReadError variant; fixed by boxing only its NativePullError source. This earlier source is not final-source qualification." + }, + "validation": { + "source_digest": "98f83b2614325e981544abf21949ed9e96c779c7aeff702178a2dbbf44f2b477", + "phases": [ + { + "name": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 48.79, + "log": "/tmp/canopy-native-pulls-final-clippy-final.log", + "log_sha256": "8b679661943726b3f0b75740583640f6585e5b3c5cb37d46c271ee6ab612069e", + "summaries": [], + "failed_cases": [] + }, + { + "name": "workspace", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--locked", + "--no-fail-fast" + ], + "exit_code": 101, + "seconds": 847.12, + "log": "/tmp/canopy-native-pulls-final-workspace-final.log", + "log_sha256": "70cc1cecd6868272abd9dbfa4157daade7d96dcd8fd0f713f8226eeb26b3ed22", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.19s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.07s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 721 filtered out; finished in 0.01s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 721 filtered out; finished in 0.09s", + "test result: ok. 722 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 324.15s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.35s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s", + "test result: FAILED. 81 passed; 25 failed; 9 ignored; 0 measured; 0 filtered out; finished in 350.74s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.31s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.19s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.25s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s" + ], + "failed_cases": [ + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "lifecycle::startup_rejects_ignored_conditional_writes_before_enrollment", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery", + "a_second_node_clones_from_the_published_root_after_local_disk_loss", + "repository_cell_publishes_objects_and_refs_atomically", + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ] + }, + { + "name": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 42.04, + "log": "/tmp/canopy-native-pulls-final-build-final.log", + "log_sha256": "fd2a411ad01d8c18877f2e7bec595dca45a4521e5cf1a7dfcaca2ef57e71649e", + "summaries": [], + "failed_cases": [] + }, + { + "name": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.24, + "log": "/tmp/canopy-native-pulls-final-fmt-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "name": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 42.19, + "log": "/tmp/canopy-native-pulls-final-harness-final.log", + "log_sha256": "c463ce564853df7c65609655034b8c355aa09c4f6a1e50301d8fe13d3c55941f", + "summaries": [], + "failed_cases": [] + }, + { + "name": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.04, + "log": "/tmp/canopy-native-pulls-final-diff-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + } + ], + "complete": true, + "release_qualified": false, + "source_unchanged": true + }, + "workspace_terminal_inventory": [ + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_git_format-cdf8cea2f92fe6a4)", + "passed": 6, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_object_storage-4a0661c5c4765140)", + "passed": 15, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_server-d080aca381ae9ba9)", + "passed": 722, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy-16a4bf977c56198e)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/directory_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/directory_cell-31a0f4eea5beeae3)", + "passed": 13, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/git_http.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/git_http-afa4d1d9a2db0179)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/multi_server/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/multi_server-3e08ee00a07d7bde)", + "passed": 81, + "failed": 25, + "ignored": 9 + }, + { + "binary": "tests/owner_restart.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/owner_restart-be32ecc90c554a17)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "tests/repository_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/repository_cell-322afb5848ed5e86)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "tests/smart_http/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/smart_http-18ac06122d3c394f)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "canopy_git_format", + "passed": 0, + "failed": 0, + "ignored": 0 + }, + { + "binary": "canopy_object_storage", + "passed": 0, + "failed": 0, + "ignored": 0 + }, + { + "binary": "canopy_server", + "passed": 0, + "failed": 0, + "ignored": 0 + } + ], + "workspace_unique_totals": { + "passed": 841, + "failed": 28, + "ignored": 9, + "executed": 869, + "total": 878 + }, + "counting": "Last summary in each Cargo Running section; nested child summaries and focused reruns excluded. Ordinary ignored cases remain unexecuted.", + "parent_linux_ci": [ + { + "head": "4eb4fd0b6594ec71d6e7f7cec36ce63705556c2b", + "run": 37338375281, + "job": 111858847192, + "conclusion": "failure", + "log": "/tmp/canopy-native-pulls-parent-pr-full.log", + "sha256": "f29b90ef2d9e9cf8071195e82f98b0090796be1044708df25d3ff85cf3bbd417", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.43s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 9.85s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 717 filtered out; finished in 0.00s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 717 filtered out; finished in 0.03s", + "test result: ok. 718 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 470.27s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.83s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: FAILED. 78 passed; 32 failed; 9 ignored; 0 measured; 0 filtered out; finished in 551.42s" + ], + "failed_cases": [ + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "comparison::comparison_matches_git_and_preserves_exact_views_across_recovery", + "comparison::comparison_rejects_oversized_change_sets_without_partial_results", + "comparison::comparison_merge_bases_match_git_for_wide_unrelated_and_crisscross_histories", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::patches::patches_apply_with_stock_git_and_reject_excess_work_without_truncation", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "push_options::mismatched_signed_push_options_return_a_durable_git_rejection", + "pulls::pull_reviews_follow_exact_revisions_and_membership_across_recovery", + "push_options::stock_git_push_options_are_validated_recorded_and_recovered", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "ssh::ssh_push_options_cover_pack_and_delete_only_requests", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery" + ] + }, + { + "head": "4eb4fd0b6594ec71d6e7f7cec36ce63705556c2b", + "run": 37338371454, + "job": 111858834134, + "conclusion": "failure", + "log": "/tmp/canopy-native-pulls-parent-push-full.log", + "sha256": "c889611ad759c14c703da2d0706cf0879eeb138d9029cc51eecd069385d45218", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.43s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 9.91s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 717 filtered out; finished in 0.00s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 717 filtered out; finished in 0.01s", + "test result: ok. 718 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 453.72s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.75s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: FAILED. 77 passed; 33 failed; 9 ignored; 0 measured; 0 filtered out; finished in 540.63s" + ], + "failed_cases": [ + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "comparison::comparison_matches_git_and_preserves_exact_views_across_recovery", + "comparison::comparison_rejects_oversized_change_sets_without_partial_results", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::comparison_merge_bases_match_git_for_wide_unrelated_and_crisscross_histories", + "comparison::patches::patches_apply_with_stock_git_and_reject_excess_work_without_truncation", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "bulk_refs::bulk_mirror_publication_is_atomic_and_survives_restart", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "push_options::mismatched_signed_push_options_return_a_durable_git_rejection", + "pulls::pull_reviews_follow_exact_revisions_and_membership_across_recovery", + "push_options::stock_git_push_options_are_validated_recorded_and_recovered", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "ssh::ssh_push_options_cover_pack_and_delete_only_requests", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery" + ] + } + ], + "changes": [ + "Core pull creation/review and list/detail/applicability consume privately issued exact native ref facts in final typed transactions.", + "Reuses editorial rows, UUID bindings, membership versions, review heads, immutable ref state, MAC envelope, serving token and joint fact; no SQL ref mirror.", + "Current-generation and live-pin proof checks bind request purpose/payload/facts, actor, actual Cell and command owner, with fresh ACL.", + "Bounded editorial selection is rechecked before final reads; at most three fresh attempts prevent skew.", + "Fresh production DDL removes obsolete ref foreign keys; trusted reader fixtures install native roots using validated artifact operation identifiers.", + "Build fingerprint now includes native pull/ref and previously omitted native check/membership files." + ], + "limits": { + "facts": 128, + "total_name_bytes": 524288, + "single_name_bytes": 65535, + "input_bytes": 835584, + "query_output_bytes": 1048576, + "command_output_bytes": 16, + "read_attempts": 3 + }, + "remaining": "Merge-policy/current-thread ref consumers, default-branch joint mutation, generated merge/rebase/candidate producers, Linux rejected-push workload and pre-Bind terminal refusal, real peers/residency and source-independent backup, reachable-only cold/filtered fetch, standalone real residents, physical custody/owner adoption/startup/retention/final DDL and full Linux/K8s/Chromium + 10k-engineer capacity qualification.", + "additional_final_failure": { + "case": "lifecycle::startup_rejects_ignored_conditional_writes_before_enrollment", + "error": "Os { code: 48, kind: AddrInUse, message: \"Address already in use\" }", + "qualification": "Passed in pre-box full run; failed in final full run. The port allocation/rebind test needs actual port/descriptor ownership diagnosis. No assertion, cleanup requirement or concurrency limit was weakened." + } +} diff --git a/docs/evidence/native-selective-ci-20261006.json b/docs/evidence/native-selective-ci-20261006.json new file mode 100644 index 00000000..402b50c2 --- /dev/null +++ b/docs/evidence/native-selective-ci-20261006.json @@ -0,0 +1,102 @@ +{ + "base_head": "195e96d59f469ddf49bf54b0eb3206e3fdedb760", + "sources": { + "crates/canopy-git-format/src/pack_index/mod.rs": "f7f380bc9de6da545e2cfcaf11a2b710a54644420abd8e2d3d3e70888462b8de", + "crates/canopy-git-format/src/pack_index/tests.rs": "8d7c9556af3053d7d83773e1c060b7180df86c93885a4de3979f9b2e32a116c4", + "crates/canopy-object-storage/src/artifact.rs": "0a6fd498b6ef036fb7322fd414c87a75e2ca36e1711d4acbd20970dcfab91b2f", + "crates/canopy-object-storage/src/artifact/tests.rs": "c3bed92e00d4ed8d5f9ce0c55bd96f4c05d04500fb98ae7bc941a1c846a2993b", + "crates/canopy-server/src/git_cache/artifacts.rs": "e65788302f8c56eaba2d4f8748e5c7218370675ecfa5525927f4904c44d55ff8", + "crates/canopy-server/src/git_cache/mod.rs": "3ec2f04c075dbc3f025fb042a9e152a51a6c0cff18953eb2dea9b4c858603402", + "crates/canopy-server/src/git_cache/tests.rs": "03c49c42592b8f7f4a0368da46b803f56fc11347ab28a089fd2a0344046eff7a", + "crates/canopy-server/src/git_cache/verified.rs": "aeecdf57d51ed3230ed30f06693ed754aad29530fe4721aef5a50882d990989a", + "crates/canopy-server/src/git_gateway/branch_policy.rs": "b1735efd5736876871b2ee4fd23fc96857d76cf68db42fb86f661dbcb00e47a4", + "crates/canopy-server/src/git_gateway/fetch.rs": "e82c562fc32abc0f9cdb05ca0963fea9ddda4a9609eceb1ded504b4c5b44745e", + "crates/canopy-server/src/git_gateway/mod.rs": "993d600ca80b885dcb6c341c56071838efa41eb5fd8908365f6f65f6c261a294", + "crates/canopy-server/src/git_gateway/ssh.rs": "1cf860d7a6ff0a3110d245f810bb8320be6ff36554307c21daccf3f9c9dcd811", + "crates/canopy-server/src/git_http/mod.rs": "3b54b2e8d37d36829867a98c8b6733cc554f7e42c0df810dd2af687c4683dd64", + "crates/canopy-server/src/git_objects/mod.rs": "e7e0f9c467f99fb9c2c7676ceacb6bf1e5215b1791b9df165b6bd6729aeb880c", + "crates/canopy-server/src/lib.rs": "b9540acc22460674058a71d98fe8d80426eca8cb2febdc8e60414030cfe87f2b", + "crates/canopy-server/src/packs/catalog/files.rs": "d7257387fe68c2466c53b6c4f6245b6e5b2f671de8a3a5ba13dfced41e87b1c8", + "crates/canopy-server/src/packs/catalog/graph_spool.rs": "e690ef4f14cb21070817748ad9072dc119b794ec0b09a799e602be3e3755300c", + "crates/canopy-server/src/packs/catalog/mod.rs": "e7f14fd586b16f05491784457843e807aa3bbf446ffa83112513480df0c7a97d", + "crates/canopy-server/src/packs/catalog/native.rs": "4e2b4b6a21d97f695815aba1b87f11dad03519bc406718b36f09880237872e13", + "crates/canopy-server/src/packs/catalog/native/tests.rs": "c3fe6c17ca3e520cd8b396e85cbb28fd907845ebf7097715c6789ad8019aefd7", + "crates/canopy-server/src/packs/catalog/sparse.rs": "51ff764a5031a333e83f2f12691632f2f50b1af92b262d5a7dd0f2b2503de0b3", + "crates/canopy-server/src/packs/publication/serving/session/native_base.rs": "984656c2fc770f9eb2e870f00c95b476fbbef54810203a33a55427e4e9908a4c", + "crates/canopy-server/src/packs/publication/serving/session/workspace.rs": "7babc46e519d6962722aac97dd1ad92c3dc46f83e4c5b68cabeded9100941444", + "crates/canopy-server/src/packs/publication/serving/session/workspace/prepare.rs": "7ae137c7705979f4dbed56c6405443f2ef89180f56ff20181e31b71f089ebbe9", + "crates/canopy-server/src/packs/publication/staging_service/driver.rs": "f9ea042bbdb577f6f8073fc99fb7bc51476062cd46a9e4aa0dac81674e8fd348", + "crates/canopy-server/tests/multi_server/backup.rs": "f558588d5f96337540195d2c01c9bc1dd2f6525b39f817fb0c3747c1cf10694d", + "crates/canopy-server/tests/owner_restart.rs": "094a48e95baf2a7d896dbdf9cbd7b1bfa6c310c7d363d3e209cca028d184fda5", + "crates/canopy-server/tests/repository_cell/main.rs": "4d5627ee20e0594aea08641a76caf19de6a4a67c88ca7b8e795b4792f375e210", + "crates/canopy-server/tests/smart_http/main.rs": "78ba45f37fda1fc0932642721d8a148dbdc34d9df05af5dce01ff6f14732a2ee", + "crates/canopy-server/tests/support/native_server.rs": "8a40a8d316899c91d736b22fc93073f62a693f284677acdf199ae70bc07a22ee" + }, + "manifest_digest": "267ae60c739ff0e553375405fdbb23363bfd7bf63f99b280c34930be68bef049", + "validation": { + "clippy": { + "command": "cargo +1.98.0 clippy --workspace --all-targets --locked -- -D warnings", + "exit_code": 0, + "log": "/tmp/canopy-native-selective-clippy.log" + }, + "harness": { + "tests": 96, + "exit_code": 0, + "log": "/tmp/canopy-native-harness-final.log" + }, + "http_partial_clone": { + "passed": 2, + "existing_ignored": 1, + "log": "/tmp/canopy-selective-http-fixed.log" + }, + "http_ssh_filter_matrix": { + "passed": 1, + "seconds": 127.01, + "log": "/tmp/canopy-selective-filters-fixed3.log" + }, + "late_ssh_refusals": { + "passed": 1, + "seconds": 17.39, + "log": "/tmp/canopy-late-ssh-known-refusal.log" + }, + "backup": { + "passed": 1, + "seconds": 109.51, + "log": "/tmp/canopy-backup-inventory2.log" + }, + "native_standalones": { + "owner_restart_seconds": 9.39, + "atomic_both_formats_seconds": 60.55, + "smart_http_seconds": 15.24, + "logs": [ + "/tmp/canopy-native-standalone-fixtures.log", + "/tmp/canopy-native-smart-http-fixed.log" + ] + }, + "full_workspace": { + "state": "failed", + "head": "759d4778f2ae9a04d426470f5354618c1e904f62", + "library_passed": 752, + "library_failed": 5, + "seconds": 445.3, + "command": "cargo +1.98.0 test --workspace --locked", + "log": "/tmp/canopy-native-workspace-final.log", + "failures": [ + "ancestry_growth_reuses_pairs_only_in_one_exact_native_catalog", + "cancelled_construction_keeps_pin_and_admission_until_suspended_provider_drains", + "expired_lease_does_not_resurrect_or_release_suspended_construction", + "suspended_construction_refuses_revoked_access_after_real_transfer_finishes", + "complete_native_history_crosses_shards_and_wide_parent_pages_without_loose_copies" + ], + "follow_up": "Producer workspace correction keeps each complete certified pair in one workspace and reads it without extra sparse-cache admission. Targeted 11 workspace tests pass without assertion or budget changes. Ancestry test passes isolated; full-suite resource contention still requires qualification." + } + }, + "limitations": [ + "Linux CI on this source has not completed.", + "Provider qualification has not run locally.", + "Size filters inspect candidate bodies before native response selection.", + "Serving membership still builds the full certified metadata closure; large-team capacity is not qualified.", + "Native writer preparation still installs complete inherited packs." + ], + "cutover_note": "Standalone fixture setup now uses the production resident. Retired ingestion operation 5 stays absent. No workflow or test target is disabled." +} diff --git a/docs/evidence/native-writer-ci-20261005.json b/docs/evidence/native-writer-ci-20261005.json new file mode 100644 index 00000000..7951c028 --- /dev/null +++ b/docs/evidence/native-writer-ci-20261005.json @@ -0,0 +1,172 @@ +{ + "recorded_at_utc": "2026-10-05T04:13:28.009295+00:00", + "base_head": "1ab3853d7411876c9f2c8fcc410e3bf409be35a1", + "source_files": 493, + "rust_files": 479, + "source_hash_digest": "3fde41e1f521f09f087f24a212fb9741ce095089e681c8b742ee7b08fc9db439", + "source_digest_algorithm": "SHA256 of sorted path + NUL + file SHA256 + newline for tracked/nonignored Rust, SQL, Cargo manifests/lock/toolchain files", + "manifest_log": "/tmp/canopy-native-writer-final-source.json", + "toolchain": "Rust 1.98.0", + "host": "macOS; isolated RustFS in Docker; Linux PR CI remains required", + "unique_cases": { + "passed": 821, + "failed": 36, + "ignored": 7, + "total": 864 + }, + "runs": { + "workspace": { + "log": "/tmp/canopy-native-writer-workspace2.log", + "sha256": "3791d77e95dc911a5b335519508fe6b1487fe14da9c2ed3c4fdb58f2468abf24", + "exit_code": 101, + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.78s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.53s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 707 filtered out; finished in 0.02s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 707 filtered out; finished in 0.09s", + "test result: ok. 708 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 319.23s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.02s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s", + "test result: FAILED. 74 passed; 32 failed; 9 ignored; 0 measured; 0 filtered out; finished in 345.37s" + ], + "failed_cases": [ + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "checks::commit_checks_bind_reporters_versions_and_reruns_across_recovery", + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "branch_rules::protected_pushes_preserve_native_reports_and_policy_across_recovery", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::comparison_matches_git_and_preserves_exact_views_across_recovery", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "comparison::patches::patches_apply_with_stock_git_and_reject_excess_work_without_truncation", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "comparison::comparison_rejects_oversized_change_sets_without_partial_results", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "comparison::comparison_merge_bases_match_git_for_wide_unrelated_and_crisscross_histories", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "pulls::pull_reviews_follow_exact_revisions_and_membership_across_recovery", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery", + "ssh::stock_ssh_clone_push_fetch_filters_and_revocation_survive_disk_loss" + ] + }, + "owner_restart": { + "log": "/tmp/canopy-native-writer-owner_restart1.log", + "sha256": "34742c00418a92c8b521a56932dfcf50feef59d0db00cc29009db672470fa5ac", + "exit_code": 101, + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.50s" + ], + "failed_cases": [ + "a_second_node_clones_from_the_published_root_after_local_disk_loss" + ] + }, + "repository_cell": { + "log": "/tmp/canopy-native-writer-repository_cell1.log", + "sha256": "a924a8381e434434a3b3b2dc581c9b17fea832df06e3969b43c35738c3467a70", + "exit_code": 101, + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.20s" + ], + "failed_cases": [ + "repository_cell_publishes_objects_and_refs_atomically" + ] + }, + "smart_http": { + "log": "/tmp/canopy-native-writer-smart_http1.log", + "sha256": "1df241873fd064a2951293eb61e424524772c3ba2738bb8ff65757bd7edcadf3", + "exit_code": 101, + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.40s" + ], + "failed_cases": [ + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ] + }, + "rustfs": { + "log": "/tmp/canopy-native-writer-rustfs1.log", + "sha256": "45be385b5c2f1118d440e508a075bca9d0dbed79a785759647fada3640a7d6dc", + "exit_code": 1, + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 114 filtered out; finished in 14.79s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 114 filtered out; finished in 4.99s" + ], + "failed_cases": [ + "sha256::sha256_real_provider_native_merge_candidates" + ] + }, + "clippy": { + "log": "/tmp/canopy-native-writer-clippy3.log", + "sha256": "3cf4d8ef44cab40cbf914c13c0fcf671f0df1f6c19dc109981f21c6ad55bf69e", + "exit_code": 0, + "summaries": [], + "failed_cases": [] + }, + "build": { + "log": "/tmp/canopy-native-writer-build1.log", + "sha256": "148bd156b917f79c7a616eae627abb60604dee7f29ff18db30c1abfd6bf6f5c8", + "exit_code": 0, + "summaries": [], + "failed_cases": [] + }, + "harness": { + "log": "/tmp/canopy-native-writer-harness1.log", + "sha256": "3463cc7ddce2f982e04d14f65a1ed36b99232ed29bb398de8f1df8eda44a0e4b", + "exit_code": 0, + "summaries": [], + "failed_cases": [] + } + }, + "source_changed_during_final_validation": false, + "release_qualified": false, + "remaining_high_priorities": [ + "Authenticated durable per-ref refusal when Write is revoked during native pack capture, before Bind; refs currently remain unchanged but transport closes.", + "Convert generated candidate/merge/rebase writers and authoritative pull/check/default-branch metadata to certified catalog/ref facts.", + "Owner-aware routing to actual resident serving/staging capabilities; complete standalone integration fixture conversion.", + "Native-aware backup/restore root enumeration, generation retirement, filtered serving and read-only restore boundaries.", + "Complete request/result/policy detached physical pins, older-owner input adoption, full Linux/RustFS workflow and large-team/full-history capacity gates." + ], + "previous_evidence": "push-workflow-ci-20261004.json", + "retained_diagnostic_runs": [ + { + "log": "/tmp/canopy-native-writer-matrix1.log", + "source_hash_digest": "e8ceddc67dfdd3b852c3d1c96aff0edc2e3165ed926ec0293255908e4840081b", + "result": "68 passed, 38 failed, 9 ignored; preceding source" + }, + { + "log": "/tmp/canopy-native-writer-workspace1.log", + "source_hash_digest": "5348ba16c16f89086b2458f8da3659e95ea01092bdb75622110f79c5694de9af", + "result": "349 server library passes, 359 registry-binding failures; fixed on final source" + }, + { + "log": "/tmp/canopy-native-writer-focused4.log", + "source_hash_digest": "5348ba16c16f89086b2458f8da3659e95ea01092bdb75622110f79c5694de9af", + "result": "3 passes, 1 late-revocation failure; preceding source" + } + ], + "unique_case_identity": "Test binary plus case name, checked against terminal per-binary summaries; nested child repetitions/provider reruns are not additional cases.", + "interleaved_output": { + "binary": "canopy_server", + "cases": [ + "git_http::stream_tests::failed_spawn_releases_parent_fence_before_cache_cleanup", + "git_http::stream_tests::disconnect_kills_the_process_group_and_releases_cache" + ], + "detail": "Parent and subprocess test announcements interleave on one line. Both actual cases are counted once using the terminal 708-pass server summary and the matching 708-case --list inventory; the combined pseudo-name is excluded.", + "inventory_log": "/tmp/canopy-native-writer-lib-list.log" + } +} diff --git a/docs/evidence/push-admission-ci-20261005.json b/docs/evidence/push-admission-ci-20261005.json new file mode 100644 index 00000000..5a05b7df --- /dev/null +++ b/docs/evidence/push-admission-ci-20261005.json @@ -0,0 +1,632 @@ +{ + "recorded_at_utc": "2026-10-05T06:29:55.815708+00:00", + "base_head": "79e2a3c7c8f6f094d4056ba1a106511619b2a351", + "source_files": 493, + "rust_files": 479, + "source_hash_digest": "e27224d4325fe884709cddc6d45a2809de1914a9b4c0a5b79f88520cc4e6d024", + "source_digest_algorithm": "SHA256 of sorted path + NUL + file SHA256 + newline for Rust, SQL, Cargo manifests/lock/toolchain files", + "source_manifest": "/tmp/canopy-admission-final-source.json", + "workflow_sha256": "8a891045792649c21d7728bc29341fcf7827050510a898b4d4365df48a1a8649", + "toolchain": "Rust 1.98.0, macOS ARM64, isolated Cargo target, RUSTC_WRAPPER empty", + "release_qualified": false, + "ci_pass_claim": false, + "regressions": { + "controller_failure_before_bind": { + "log": "/tmp/canopy-driver-failure-repro1.log", + "sha256": "15a48e82470800f891722fb16a7fe2d71fe1bc5df3b64afaa87de84208470b50", + "exit_code": 101, + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 711 filtered out; finished in 0.26s" + ], + "failed_cases": [ + "server::residency::tests::staging::production_push_driver_failure_remains_observable_before_and_after_bind" + ], + "source_manifest": "/tmp/canopy-driver-failure-repro1-source.json", + "source_digest": "414e41ed3e799c15a8bc31b187864e68b12f8aab20f35b270ecbc1a77d26e7f9" + }, + "retaining_error_that_owns_physical_worker": { + "log": "/tmp/canopy-driver-failure-owner-repro1.log", + "sha256": "f91918b056057df5e0bb737a58834ad70b78e17d834004b275b55cd64b9e887a", + "exit_code": 101, + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 711 filtered out; finished in 5.47s" + ], + "failed_cases": [ + "server::residency::tests::staging::production_push_driver_failure_remains_observable_before_and_after_bind" + ], + "source_manifest": "/tmp/canopy-driver-failure-owner-repro1-source.json", + "source_digest": "b43ba82a2a059fbf34a1ad521b5e1d1369f801962ec3030b7edc73cb53bc40a7" + }, + "bounded_driver_diagnostic": { + "log": "/tmp/canopy-driver-failure-focused-final.log", + "sha256": "f8269d4034cf7e2d8910f411983afad846de35b575b16156915bf7d77709d43d", + "exit_code": 0, + "summaries": [ + "test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 708 filtered out; finished in 14.24s" + ], + "failed_cases": [], + "source_manifest": "/tmp/canopy-driver-failure-final-source.json", + "source_digest": "f02f74b4665dbc6629b881235ed56395a7ecfa49135bbf54e33ea612582aca51" + }, + "contended_real_bound_handoff_before_fix": { + "log": "/tmp/canopy-admission-contention-repro.log", + "sha256": "f282d674392acbbc2de1d755d6c8251d47958c826805d73812aad26554419e63", + "exit_code": 101, + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 712 filtered out; finished in 0.16s" + ], + "failed_cases": [ + "packs::publication::tests::staging_service::publication::bound_publication_waits_for_contended_admission_without_losing_original_command" + ] + }, + "contention_quota_and_deadline_after_fix": { + "log": "/tmp/canopy-admission-contention-focused-final.log", + "sha256": "bce62f49db9f49e11fa12d1feb398b3a2842d1fe894fb309a49200c635b0c0de", + "exit_code": 0, + "summaries": [ + "test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 712 filtered out; finished in 0.55s" + ], + "failed_cases": [], + "source_manifest": "/tmp/canopy-admission-final-source.json", + "source_digest": "e27224d4325fe884709cddc6d45a2809de1914a9b4c0a5b79f88520cc4e6d024" + } + }, + "intermediate_driver_source": { + "validation": { + "source_digest": "f02f74b4665dbc6629b881235ed56395a7ecfa49135bbf54e33ea612582aca51", + "phases": [ + { + "name": "focused", + "exit_code": 0, + "seconds": 134.01, + "log": "/tmp/canopy-driver-failure-focused-final.log", + "log_sha256": "f8269d4034cf7e2d8910f411983afad846de35b575b16156915bf7d77709d43d", + "summaries": [ + "test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 708 filtered out; finished in 14.24s" + ], + "failed_cases": [] + }, + { + "name": "workspace", + "exit_code": 101, + "seconds": 43.46, + "log": "/tmp/canopy-driver-failure-workspace-final.log", + "log_sha256": "eaf4c8970d796a1072e7fd5a62811b0a7de63baead83f02081a0ab1db7cd2d42", + "summaries": [], + "failed_cases": [] + }, + { + "name": "clippy", + "exit_code": 0, + "seconds": 64.98, + "log": "/tmp/canopy-driver-failure-clippy-final.log", + "log_sha256": "ac704f42b4ed890ab4654f0415890ca3debf5498f7d6d129539c127d029edd61", + "summaries": [], + "failed_cases": [] + }, + { + "name": "build", + "exit_code": 0, + "seconds": 68.41, + "log": "/tmp/canopy-driver-failure-build-final.log", + "log_sha256": "edd67e4a5978392bcd3db1f83b7eb6420506ed92b103d1692c312e247cc098f1", + "summaries": [], + "failed_cases": [] + }, + { + "name": "fmt", + "exit_code": 0, + "seconds": 1.33, + "log": "/tmp/canopy-driver-failure-fmt-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "name": "diff", + "exit_code": 0, + "seconds": 0.03, + "log": "/tmp/canopy-driver-failure-diff-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + } + ], + "complete": true, + "release_qualified": false, + "source_unchanged": true + }, + "workspace_retry": { + "log": "/tmp/canopy-driver-failure-workspace-retry1.log", + "sha256": "0981246f9b74acd389d7db58fc9dbcb760f72cc04895d145ccd933d518e51280", + "exit_code": 101, + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.80s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.31s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 711 filtered out; finished in 0.01s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 711 filtered out; finished in 0.09s", + "test result: FAILED. 708 passed; 4 failed; 0 ignored; 0 measured; 0 filtered out; finished in 353.45s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 10.77s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s", + "test result: FAILED. 73 passed; 33 failed; 9 ignored; 0 measured; 0 filtered out; finished in 370.55s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.44s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.22s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.48s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s" + ], + "failed_cases": [ + "packs::publication::tests::serving::lifecycle::restarts::producer_restarts_preserve_factory_identity_held_ticket_capture_renewal_and_release", + "server::residency::tests::serving::production_shutdown_keeps_publication_cell_heartbeat_and_workspace_until_last_borrow", + "server::residency::tests::serving::production_resident_shares_generation_and_busy_eviction_resumes_before_exact_drain", + "server::residency::tests::recovery::production_shutdown_recovers_other_repositories_while_one_producer_holds_its_command", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "checks::commit_checks_bind_reporters_versions_and_reruns_across_recovery", + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "branch_rules::protected_pushes_preserve_native_reports_and_policy_across_recovery", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "comparison::comparison_matches_git_and_preserves_exact_views_across_recovery", + "comparison::comparison_rejects_oversized_change_sets_without_partial_results", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "comparison::patches::patches_apply_with_stock_git_and_reject_excess_work_without_truncation", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "comparison::comparison_merge_bases_match_git_for_wide_unrelated_and_crisscross_histories", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "pulls::pull_reviews_follow_exact_revisions_and_membership_across_recovery", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "ssh::ssh_push_options_cover_pack_and_delete_only_requests", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery", + "bulk_refs::bulk_mirror_publication_is_atomic_and_survives_restart", + "a_second_node_clones_from_the_published_root_after_local_disk_loss", + "repository_cell_publishes_objects_and_refs_atomically", + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ], + "source_manifest": "/tmp/canopy-driver-failure-final-source.json", + "source_digest": "f02f74b4665dbc6629b881235ed56395a7ecfa49135bbf54e33ea612582aca51" + }, + "workspace_retry_note": "Isolated target and disabled sccache compiled after a missing rcgu.o failure in the previous shared target. Full run still failed: 708/4 server libraries, 73/33/9 multi-server, three standalone failures. Four timeouts passed focused only after concurrent build jobs drained; final full-suite results supersede neither historical failure nor its source fingerprint." + }, + "final_validation": { + "source_digest": "e27224d4325fe884709cddc6d45a2809de1914a9b4c0a5b79f88520cc4e6d024", + "phases": [ + { + "name": "restart", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "producer_restarts_preserve_factory_identity_held_ticket_capture_renewal_and_release", + "--locked" + ], + "exit_code": 0, + "seconds": 6.78, + "log": "/tmp/canopy-admission-restart-final.log", + "log_sha256": "2dde1a4c3b94532c7925333bd55c23860d3784a63ca865ee119c9edca4777bdc", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 714 filtered out; finished in 5.54s" + ], + "failed_cases": [] + }, + { + "name": "shutdown", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "production_shutdown_keeps_publication_cell_heartbeat_and_workspace_until_last_borrow", + "--locked" + ], + "exit_code": 0, + "seconds": 1.64, + "log": "/tmp/canopy-admission-shutdown-final.log", + "log_sha256": "0fad96827df24c581ad5df0781d355379b554b3a39174735ed3c2d2ae090bfa3", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 714 filtered out; finished in 1.00s" + ], + "failed_cases": [] + }, + { + "name": "eviction", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "production_resident_shares_generation_and_busy_eviction_resumes_before_exact_drain", + "--locked" + ], + "exit_code": 0, + "seconds": 1.23, + "log": "/tmp/canopy-admission-eviction-final.log", + "log_sha256": "27392eb0b377815b6b8b8ac32e139dfd1bdd3cf6c943ca9e98697f41c66c7a1a", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 714 filtered out; finished in 0.82s" + ], + "failed_cases": [] + }, + { + "name": "recovery", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "production_shutdown_recovers_other_repositories_while_one_producer_holds_its_command", + "--locked" + ], + "exit_code": 0, + "seconds": 8.65, + "log": "/tmp/canopy-admission-recovery-final.log", + "log_sha256": "eb25bf09a8cd57f248f6da9e92c1ea4de65c92587437eca20c6cd1ae1a966480", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 714 filtered out; finished in 8.30s" + ], + "failed_cases": [] + }, + { + "name": "bulk", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--test", + "multi_server", + "bulk_mirror_publication_is_atomic_and_survives_restart", + "--locked", + "--", + "--nocapture" + ], + "exit_code": 0, + "seconds": 146.72, + "log": "/tmp/canopy-admission-bulk-final.log", + "log_sha256": "3a77ea2bfd517cfd5a2c05b0a1f6db6665a6724b2b36af16ec385764007f1ece", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 114 filtered out; finished in 74.99s" + ], + "failed_cases": [] + }, + { + "name": "options", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--test", + "multi_server", + "push_options::", + "--locked", + "--", + "--nocapture" + ], + "exit_code": 0, + "seconds": 7.73, + "log": "/tmp/canopy-admission-options-final.log", + "log_sha256": "0b811e31fc9c583a89703f4cbabbac9b42363c3decc1411065d4f032ab3febec", + "summaries": [ + "test result: ok. 3 passed; 0 failed; 1 ignored; 0 measured; 111 filtered out; finished in 5.22s" + ], + "failed_cases": [] + }, + { + "name": "workspace", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--locked", + "--no-fail-fast" + ], + "exit_code": 101, + "seconds": 722.6, + "log": "/tmp/canopy-admission-workspace-final.log", + "log_sha256": "e3e6fa071cf518d66b65144702a7399a04f37ebc74d59b5e2b674aa5e0a599dc", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.09s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.70s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 714 filtered out; finished in 0.01s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 714 filtered out; finished in 0.20s", + "test result: ok. 715 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 327.22s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 11.94s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s", + "test result: FAILED. 75 passed; 31 failed; 9 ignored; 0 measured; 0 filtered out; finished in 358.04s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.42s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.63s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.21s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s" + ], + "failed_cases": [ + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "checks::commit_checks_bind_reporters_versions_and_reruns_across_recovery", + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "branch_rules::protected_pushes_preserve_native_reports_and_policy_across_recovery", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "comparison::comparison_matches_git_and_preserves_exact_views_across_recovery", + "comparison::patches::patches_apply_with_stock_git_and_reject_excess_work_without_truncation", + "comparison::comparison_rejects_oversized_change_sets_without_partial_results", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "comparison::comparison_merge_bases_match_git_for_wide_unrelated_and_crisscross_histories", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "pulls::pull_reviews_follow_exact_revisions_and_membership_across_recovery", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery", + "a_second_node_clones_from_the_published_root_after_local_disk_loss", + "repository_cell_publishes_objects_and_refs_atomically", + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ] + }, + { + "name": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 46.74, + "log": "/tmp/canopy-admission-clippy-final.log", + "log_sha256": "b33de76a1243507d0a4378b4ba042da68b08296e5f56d7585f4f4417fac77c70", + "summaries": [], + "failed_cases": [] + }, + { + "name": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 59.76, + "log": "/tmp/canopy-admission-build-final.log", + "log_sha256": "9aeec9c34c760a400ef390e410cc12ab6c4eb1d3de6b883e47e3e28190508573", + "summaries": [], + "failed_cases": [] + }, + { + "name": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.29, + "log": "/tmp/canopy-admission-fmt-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "name": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 42.59, + "log": "/tmp/canopy-admission-harness-final.log", + "log_sha256": "46d58ee2d7dc831ae0e69d4cd0d8a1d424173487af3937adf80887a5215b74ed", + "summaries": [], + "failed_cases": [] + }, + { + "name": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.04, + "log": "/tmp/canopy-admission-diff-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + } + ], + "complete": true, + "release_qualified": false, + "source_unchanged": true + }, + "parent_linux_ci": { + "head": "79e2a3c7c8f6f094d4056ba1a106511619b2a351", + "pr_run": 37264946662, + "rust_job": 111619651832, + "conclusion": "FAILURE", + "server_library": { + "passed": 711, + "failed": 0 + }, + "multi_server": { + "passed": 75, + "failed": 35, + "ignored": 9 + }, + "log": { + "log": "/tmp/canopy-remote-79e2a3c-pr-full.log", + "sha256": "c4544a19463e7dd4ecd2e1c495b957254c3599df345dff90c97502bccae00b4c", + "exit_code": 101, + "summaries": [], + "failed_cases": [] + }, + "toolchain_note": "Git 2.55.0 is printed; exact Rust version was not printed. Workflow now reports rustc/cargo/active-toolchain/Git versions without changing toolchain selection." + }, + "prior_linux_arm_probes": { + "pre_admission_source_note": "These are diagnostic probes, not final-source Linux qualification.", + "rust197_options": { + "log": "/tmp/canopy-options-linux-group1.log", + "sha256": "e168702f061766ac4ee50d7acd21364c7719072cda5c850f8fd9270172932852", + "exit_code": 0, + "summaries": [ + "test result: ok. 4 passed; 0 failed; 1 ignored; 0 measured; 114 filtered out; finished in 5.96s" + ], + "failed_cases": [] + }, + "rust197_matrix": { + "log": "/tmp/canopy-options-linux-matrix1.log", + "sha256": "7d83f869d1db6d82b312cb1421b275892dbf1adae0a9846a3df523e3e4e1cc75", + "exit_code": 101, + "summaries": [ + "test result: FAILED. 77 passed; 33 failed; 9 ignored; 0 measured; 0 filtered out; finished in 504.00s" + ], + "failed_cases": [ + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "branch_rules::protected_pushes_preserve_native_reports_and_policy_across_recovery", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "checks::commit_checks_bind_reporters_versions_and_reruns_across_recovery", + "comparison::comparison_matches_git_and_preserves_exact_views_across_recovery", + "comparison::comparison_merge_bases_match_git_for_wide_unrelated_and_crisscross_histories", + "comparison::comparison_rejects_oversized_change_sets_without_partial_results", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::patches::patches_apply_with_stock_git_and_reject_excess_work_without_truncation", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "lfs_locks::stock_lfs_locks_block_conflicting_pushes_and_unlock_allows_retry", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "pulls::pull_reviews_follow_exact_revisions_and_membership_across_recovery", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::failed_local_cleanup_retries_before_restoring_the_released_repository", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery" + ] + }, + "rust198_options": { + "log": "/tmp/canopy-options-linux-rust198-group1.log", + "sha256": "2f61d9ee6386d15739908a6474f9c6b716980772f65625403d8f4d5539d4785d", + "exit_code": 0, + "summaries": [ + "test result: ok. 4 passed; 0 failed; 1 ignored; 0 measured; 114 filtered out; finished in 14.93s" + ], + "failed_cases": [] + }, + "bounded_driver_source_options": { + "log": "/tmp/canopy-driver-failure-linux-options-final.log", + "sha256": "ee7b1b8d95110bd4a48a025ab4727ecb0e5a32029510321b228667106a6dbc79", + "exit_code": 0, + "summaries": [ + "test result: ok. 4 passed; 0 failed; 1 ignored; 0 measured; 114 filtered out; finished in 3.92s" + ], + "failed_cases": [], + "source_manifest": "/tmp/canopy-driver-failure-final-source.json", + "source_digest": "f02f74b4665dbc6629b881235ed56395a7ecfa49135bbf54e33ea612582aca51" + }, + "bounded_driver_source_controllers": { + "log": "/tmp/canopy-driver-failure-linux-controllers-final.log", + "sha256": "eecbf2792285151aad36fd76847465d1cf5bfb89a32a8c723f90b081567841ab", + "exit_code": 0, + "summaries": [ + "test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 708 filtered out; finished in 14.30s" + ], + "failed_cases": [], + "source_manifest": "/tmp/canopy-driver-failure-final-source.json", + "source_digest": "f02f74b4665dbc6629b881235ed56395a7ecfa49135bbf54e33ea612582aca51" + } + }, + "scope_and_limits": [ + "A contended mutex now has a distinct Busy classification. Native push waits fairly for the mutex under the existing custody deadline and keeps the original prepared command. Actual operation/account/byte quota refusal remains Capacity and is never retried.", + "Held reservation, custody recheck and lifecycle ticket capture are synchronous under the same admission guard. No admitted command exists while awaiting that guard. Timeout returns the original ready value; requester cancellation does not own the resident controller.", + "Controller diagnostics retain at most 4096 valid UTF-8 bytes and eight error sources. Rejected prepared values are dropped outside the local lock after stop is recorded, before physical credits can be reused. Historical Bind evidence remains separate.", + "Known completion responses use the existing publication ticket read capability, completion receipt watermark and fresh authorization. Missing request replay alone was not proven causal.", + "Local bulk failure exposed PublicationAdmission(Capacity); deterministic contention repro proves the busy-mutex defect. Final-head GitHub option failures require actual CI observation, not extrapolation from ARM64/local passes.", + "No tests disabled, no quotas raised, no authorization weakened, no retired object/ref SQL tables restored.", + "Remaining native metadata/writer conversion, refusal-only pre-Bind terminal semantics, peer routing, backup/filtered reads, standalone resident fixtures, physical custody completion and large-history/team capacity gates remain open." + ], + "sdk": { + "path": "/Users/haipingfu/.codex/worktrees/durable-command-recovery/cellule", + "head": "161067f5a21703b3e257024bcb64e565fd9657b4", + "clean_when_checked": true + }, + "unique_workspace_inventory": { + "passed": 828, + "failed": 34, + "ignored_unexecuted": 9, + "total": 871 + } +} diff --git a/docs/evidence/push-workflow-ci-20261004.json b/docs/evidence/push-workflow-ci-20261004.json new file mode 100644 index 00000000..6c4b074e --- /dev/null +++ b/docs/evidence/push-workflow-ci-20261004.json @@ -0,0 +1,1909 @@ +{ + "date": "2026-10-04 America/Vancouver", + "base_head": "bd8d819315f0e438d9b857a481dfcd294ddd0bd9", + "goal_status": "active", + "release_qualified": false, + "source": { + "files": 493, + "rust_files": 479, + "digest": "1d3f47aa052dcc8cef1f59aac6d6c8e466165a9f1e013dfffd656daf14b50f37", + "algorithm": "SHA256 of JSON sort_keys=true path-to-SHA256 mapping of tracked and nonignored untracked rs/sql/toml/Cargo.lock files", + "manifest": "/tmp/canopy-ci-driver-renewal-source-hashes.json" + }, + "validation": { + "source_files": 493, + "rust_files": 479, + "source_hash_digest": "1d3f47aa052dcc8cef1f59aac6d6c8e466165a9f1e013dfffd656daf14b50f37", + "release_qualified": false, + "execution_complete": true, + "phases": [ + { + "label": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 50.962, + "log": "/tmp/canopy-ci-driver-renewal-clippy-final.log", + "log_sha256": "cf55fabaa3448490b93baad25aea0f932e5a604d82c820010d5f690a3c2390fa", + "summaries": [], + "failed_cases": [] + }, + { + "label": "focused", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "--locked", + "--", + "server::residency::tests::staging::", + "shared_staging_", + "cold_staging_keeps_all_original_receipts_after_sdk_expiry_and_actual_owner_restore", + "closed_producer_renews_during_long_construction_and_returned_workspace_lifetime", + "closed_owner_keeps_borrowed_generation_renewing_until_last_snapshot_clone_drops", + "packs::publication::tests::custody::", + "packs::sources::tests::changes::", + "reachability_stops_at_live_refs_without_scanning_other_history", + "--test-threads=4" + ], + "exit_code": 0, + "seconds": 307.121, + "log": "/tmp/canopy-ci-driver-renewal-focused-final.log", + "log_sha256": "8f9331a75b00215c6f1f3a44823e692c1444fb6c590ca88c0617343473fcf6e2", + "summaries": [ + [ + 31, + 0, + 0, + 0, + 676 + ] + ], + "failed_cases": [] + }, + { + "label": "libraries", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--lib", + "--locked", + "--no-fail-fast" + ], + "exit_code": 0, + "seconds": 334.677, + "log": "/tmp/canopy-ci-driver-renewal-libraries-final.log", + "log_sha256": "cc999e7f5313acf9b45926963450ee3e77a2d900382ee7f84a5bee11cd081014", + "summaries": [ + [ + 6, + 0, + 0, + 0, + 0 + ], + [ + 15, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + 706 + ], + [ + 1, + 0, + 0, + 0, + 706 + ], + [ + 707, + 0, + 0, + 0, + 0 + ] + ], + "failed_cases": [] + }, + { + "label": "read_integration", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--test", + "directory_cell", + "--test", + "git_http", + "--locked", + "--no-fail-fast" + ], + "exit_code": 0, + "seconds": 12.26, + "log": "/tmp/canopy-ci-driver-renewal-read_integration-final.log", + "log_sha256": "0b4e671c32b93eb2ccf545acc7f73b446afa985830bc28f39670da5fb52194ea", + "summaries": [ + [ + 13, + 0, + 0, + 0, + 0 + ], + [ + 2, + 0, + 0, + 0, + 0 + ] + ], + "failed_cases": [] + }, + { + "label": "binary", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--bin", + "canopy", + "--locked" + ], + "exit_code": 0, + "seconds": 0.78, + "log": "/tmp/canopy-ci-driver-renewal-binary-final.log", + "log_sha256": "edce003cec44c858eb11ba1c9ba5f5d58d5c0f1c4d5afddc909866aa7b25c6ed", + "summaries": [ + [ + 2, + 0, + 0, + 0, + 0 + ] + ], + "failed_cases": [] + }, + { + "label": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 0.311, + "log": "/tmp/canopy-ci-driver-renewal-build-final.log", + "log_sha256": "f2066ca264b9a8598ecd436c70919e1efe1acea4098ad2a13b5d82d67bc049ec", + "summaries": [], + "failed_cases": [] + }, + { + "label": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.565, + "log": "/tmp/canopy-ci-driver-renewal-fmt-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "label": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.075, + "log": "/tmp/canopy-ci-driver-renewal-diff-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "label": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 41.185, + "log": "/tmp/canopy-ci-driver-renewal-harness-final.log", + "log_sha256": "855582c553c0344e2740f28f2facec35bd81a974c52c5d0006ba9af661e1c5c4", + "summaries": [], + "failed_cases": [] + } + ] + }, + "final_source_case_inventory": { + "case_counts": { + "canopy_git_format": { + "passed": 6, + "failed": 0, + "ignored": 0, + "publication_passed": 0 + }, + "canopy_object_storage": { + "passed": 15, + "failed": 0, + "ignored": 0, + "publication_passed": 0 + }, + "canopy_server": { + "passed": 707, + "failed": 0, + "ignored": 0, + "publication_passed": 403 + }, + "directory_cell": { + "passed": 13, + "failed": 0, + "ignored": 0, + "publication_passed": 0 + }, + "git_http": { + "passed": 2, + "failed": 0, + "ignored": 0, + "publication_passed": 0 + }, + "canopy": { + "passed": 2, + "failed": 0, + "ignored": 0, + "publication_passed": 0 + } + }, + "unique_executed": 745, + "unique_passed": 745, + "unique_failed": 0, + "failed_cases": [] + }, + "full_workspace_before_renewal_diagnostics": { + "validation": { + "source_files": 493, + "rust_files": 479, + "source_hash_digest": "d34dcdd214989f6407a75c1e2a3761c59e4357d370c77cb9ee6ba05a1e46e980", + "release_qualified": false, + "execution_complete": true, + "phases": [ + { + "label": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 45.222, + "log": "/tmp/canopy-ci-driver-expiry-clippy-final.log", + "log_sha256": "3d43e631c2091fb07b3f611450617052dd92cfdbb6ad56b490e8358ca84030b9", + "summaries": [], + "failed_cases": [] + }, + { + "label": "focused", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "--locked", + "--", + "server::residency::tests::staging::", + "shared_staging_", + "cold_staging_keeps_all_original_receipts_after_sdk_expiry_and_actual_owner_restore", + "--test-threads=4" + ], + "exit_code": 0, + "seconds": 250.971, + "log": "/tmp/canopy-ci-driver-expiry-focused-final.log", + "log_sha256": "8c876b206a051000482fe406940480eaffa0f5f1cdb0e3ca8a1e6b58df9fc2c8", + "summaries": [ + [ + 10, + 0, + 0, + 0, + 697 + ] + ], + "failed_cases": [] + }, + { + "label": "workspace", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--locked", + "--no-fail-fast" + ], + "exit_code": 101, + "seconds": 701.724, + "log": "/tmp/canopy-ci-driver-expiry-workspace-final.log", + "log_sha256": "14c9b7efe248564a11539bf94eb8dc95601efe4ab7c9245be3a7b820d2f50f0b", + "summaries": [ + [ + 6, + 0, + 0, + 0, + 0 + ], + [ + 15, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + 706 + ], + [ + 1, + 0, + 0, + 0, + 706 + ], + [ + 706, + 1, + 0, + 0, + 0 + ], + [ + 2, + 0, + 0, + 0, + 0 + ], + [ + 13, + 0, + 0, + 0, + 0 + ], + [ + 2, + 0, + 0, + 0, + 0 + ], + [ + 46, + 60, + 9, + 0, + 0 + ], + [ + 0, + 1, + 0, + 0, + 0 + ], + [ + 0, + 1, + 0, + 0, + 0 + ], + [ + 0, + 1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0 + ] + ], + "failed_cases": [ + "packs::publication::tests::serving::workspace::closed_producer_renews_during_long_construction_and_returned_workspace_lifetime", + "bulk_refs::bulk_mirror_publication_is_atomic_and_survives_restart", + "branch_rules::protected_pushes_preserve_native_reports_and_policy_across_recovery", + "accounts::disabled_account_loses_all_credentials_and_stays_disabled_after_restore", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "comparison::comparison_matches_git_and_preserves_exact_views_across_recovery", + "checks::commit_checks_bind_reporters_versions_and_reruns_across_recovery", + "compatibility::stock_git_history_refs_and_shallow_fetch_survive_fresh_disk_restore", + "comparison::comparison_rejects_oversized_change_sets_without_partial_results", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "comparison::patches::patches_apply_with_stock_git_and_reject_excess_work_without_truncation", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "deployment::maintenance_drains_two_nodes_and_fences_startup_until_exact_resume", + "browse::browser_reads_exact_git_snapshots_with_pages_modes_and_recovery", + "lfs_locks::stock_lfs_locks_block_conflicting_pushes_and_unlock_allows_retry", + "large_objects::large_tree_commit_and_tag_restore_from_sqlite_after_owner_restart", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "push_options::mismatched_signed_push_options_return_a_durable_git_rejection", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "pulls::pull_reviews_follow_exact_revisions_and_membership_across_recovery", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "push_options::stock_git_push_options_are_validated_recorded_and_recovered", + "push_options::signed_push_binds_registered_key_and_preserves_audit_after_restore", + "residency::faults::admission::cold_admission_stays_bounded_after_clients_disconnect", + "residency::faults::admission::one_accounts_cold_requests_leave_capacity_for_another_account", + "residency::faults::cancelled_cold_activation_retains_its_reserved_slot", + "residency::faults::denied_release_retains_local_state_and_recovers_after_node_restart", + "residency::faults::disconnected_admission_finishes_release_and_allows_a_later_restore", + "residency::faults::failed_local_cleanup_retries_before_restoring_the_released_repository", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::lost_release_reply_is_resolved_before_local_cleanup", + "residency::faults::paused_cold_activation_keeps_other_warm_repositories_available", + "residency::faults::paused_cold_repository_does_not_serialize_other_cold_activations", + "residency::faults::paused_release_keeps_other_warm_repositories_available", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "comparison::comparison_merge_bases_match_git_for_wide_unrelated_and_crisscross_histories", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_repository_push_clone_fetch_and_restore", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::publication::cold_ssh_push_preparation_failure_reports_rejection_before_any_refs_change", + "ssh::publication::disconnected_ssh_push_finishes_publication_before_shutdown_releases_cells", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "ssh::sha256_ssh_push_and_clone", + "ssh::signed_sha256_ssh_push_survives_fresh_disk_restore", + "ssh::ssh_push_options_cover_pack_and_delete_only_requests", + "ssh::lfs::stock_lfs_uses_ssh_identity_for_push_pull_and_locks_after_restore", + "tokens::token_rotation_revocation_and_last_admin_survive_owner_restore", + "ssh::stock_ssh_clone_push_fetch_filters_and_revocation_survive_disk_loss", + "visibility::public_reads_and_private_revocation_survive_cell_recovery", + "a_second_node_clones_from_the_published_root_after_local_disk_loss", + "repository_cell_publishes_objects_and_refs_atomically", + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ] + }, + { + "label": "binary", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--bin", + "canopy", + "--locked" + ], + "exit_code": 0, + "seconds": 0.742, + "log": "/tmp/canopy-ci-driver-expiry-binary-final.log", + "log_sha256": "600ea71c8e4db447e31be9ba7e603a51bf12dbfd83d024ae3db30f2d3b7ae862", + "summaries": [ + [ + 2, + 0, + 0, + 0, + 0 + ] + ], + "failed_cases": [] + }, + { + "label": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 0.343, + "log": "/tmp/canopy-ci-driver-expiry-build-final.log", + "log_sha256": "f591e097f22f91dd5c31fbbf7e587d80c0a4e0ed594f9c9012a544f38c52d926", + "summaries": [], + "failed_cases": [] + }, + { + "label": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.462, + "log": "/tmp/canopy-ci-driver-expiry-fmt-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "label": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.063, + "log": "/tmp/canopy-ci-driver-expiry-diff-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "label": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 42.194, + "log": "/tmp/canopy-ci-driver-expiry-harness-final.log", + "log_sha256": "9528b23766928ed852ecae4239f27fecdf4471656195757c969233ec23aec079", + "summaries": [], + "failed_cases": [] + } + ] + }, + "inventory": { + "case_counts": { + "canopy_git_format": { + "passed": 6, + "failed": 0, + "ignored": 0, + "publication_passed": 0 + }, + "canopy_object_storage": { + "passed": 15, + "failed": 0, + "ignored": 0, + "publication_passed": 0 + }, + "canopy_server": { + "passed": 706, + "failed": 1, + "ignored": 0, + "publication_passed": 402 + }, + "canopy": { + "passed": 2, + "failed": 0, + "ignored": 0, + "publication_passed": 0 + }, + "directory_cell": { + "passed": 13, + "failed": 0, + "ignored": 0, + "publication_passed": 0 + }, + "git_http": { + "passed": 2, + "failed": 0, + "ignored": 0, + "publication_passed": 0 + }, + "multi_server": { + "passed": 46, + "failed": 60, + "ignored": 9, + "publication_passed": 0 + }, + "owner_restart": { + "passed": 0, + "failed": 1, + "ignored": 0, + "publication_passed": 0 + }, + "repository_cell": { + "passed": 0, + "failed": 1, + "ignored": 0, + "publication_passed": 0 + }, + "smart_http": { + "passed": 0, + "failed": 1, + "ignored": 0, + "publication_passed": 0 + } + }, + "unique_executed": 854, + "unique_passed": 790, + "unique_failed": 64, + "failed_cases": [ + { + "runner": "canopy_server", + "case": "packs::publication::tests::serving::workspace::closed_producer_renews_during_long_construction_and_returned_workspace_lifetime" + }, + { + "runner": "multi_server", + "case": "bulk_refs::bulk_mirror_publication_is_atomic_and_survives_restart" + }, + { + "runner": "multi_server", + "case": "branch_rules::protected_pushes_preserve_native_reports_and_policy_across_recovery" + }, + { + "runner": "multi_server", + "case": "accounts::disabled_account_loses_all_credentials_and_stays_disabled_after_restore" + }, + { + "runner": "multi_server", + "case": "candidates::candidate_native_graph_and_paths_preserve_git_semantics" + }, + { + "runner": "multi_server", + "case": "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge" + }, + { + "runner": "multi_server", + "case": "comparison::comparison_matches_git_and_preserves_exact_views_across_recovery" + }, + { + "runner": "multi_server", + "case": "checks::commit_checks_bind_reporters_versions_and_reruns_across_recovery" + }, + { + "runner": "multi_server", + "case": "compatibility::stock_git_history_refs_and_shallow_fetch_survive_fresh_disk_restore" + }, + { + "runner": "multi_server", + "case": "comparison::comparison_rejects_oversized_change_sets_without_partial_results" + }, + { + "runner": "multi_server", + "case": "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access" + }, + { + "runner": "multi_server", + "case": "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore" + }, + { + "runner": "multi_server", + "case": "comparison::patches::patches_apply_with_stock_git_and_reject_excess_work_without_truncation" + }, + { + "runner": "multi_server", + "case": "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries" + }, + { + "runner": "multi_server", + "case": "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge" + }, + { + "runner": "multi_server", + "case": "backup::backup_restores_git_lfs_and_collaboration_without_original_storage" + }, + { + "runner": "multi_server", + "case": "deployment::maintenance_drains_two_nodes_and_fences_startup_until_exact_resume" + }, + { + "runner": "multi_server", + "case": "browse::browser_reads_exact_git_snapshots_with_pages_modes_and_recovery" + }, + { + "runner": "multi_server", + "case": "lfs_locks::stock_lfs_locks_block_conflicting_pushes_and_unlock_allows_retry" + }, + { + "runner": "multi_server", + "case": "large_objects::large_tree_commit_and_tag_restore_from_sqlite_after_owner_restart" + }, + { + "runner": "multi_server", + "case": "leased_server_recovers_two_repositories_with_git_and_lfs" + }, + { + "runner": "multi_server", + "case": "merge::merge_rechecks_revisions_authority_and_competing_publications" + }, + { + "runner": "multi_server", + "case": "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery" + }, + { + "runner": "multi_server", + "case": "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history" + }, + { + "runner": "multi_server", + "case": "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs" + }, + { + "runner": "multi_server", + "case": "push_options::mismatched_signed_push_options_return_a_durable_git_rejection" + }, + { + "runner": "multi_server", + "case": "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes" + }, + { + "runner": "multi_server", + "case": "pulls::pull_reviews_follow_exact_revisions_and_membership_across_recovery" + }, + { + "runner": "multi_server", + "case": "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory" + }, + { + "runner": "multi_server", + "case": "push_options::stock_git_push_options_are_validated_recorded_and_recovered" + }, + { + "runner": "multi_server", + "case": "push_options::signed_push_binds_registered_key_and_preserves_audit_after_restore" + }, + { + "runner": "multi_server", + "case": "residency::faults::admission::cold_admission_stays_bounded_after_clients_disconnect" + }, + { + "runner": "multi_server", + "case": "residency::faults::admission::one_accounts_cold_requests_leave_capacity_for_another_account" + }, + { + "runner": "multi_server", + "case": "residency::faults::cancelled_cold_activation_retains_its_reserved_slot" + }, + { + "runner": "multi_server", + "case": "residency::faults::denied_release_retains_local_state_and_recovers_after_node_restart" + }, + { + "runner": "multi_server", + "case": "residency::faults::disconnected_admission_finishes_release_and_allows_a_later_restore" + }, + { + "runner": "multi_server", + "case": "residency::faults::failed_local_cleanup_retries_before_restoring_the_released_repository" + }, + { + "runner": "multi_server", + "case": "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore" + }, + { + "runner": "multi_server", + "case": "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged" + }, + { + "runner": "multi_server", + "case": "residency::faults::lost_release_reply_is_resolved_before_local_cleanup" + }, + { + "runner": "multi_server", + "case": "residency::faults::paused_cold_activation_keeps_other_warm_repositories_available" + }, + { + "runner": "multi_server", + "case": "residency::faults::paused_cold_repository_does_not_serialize_other_cold_activations" + }, + { + "runner": "multi_server", + "case": "residency::faults::paused_release_keeps_other_warm_repositories_available" + }, + { + "runner": "multi_server", + "case": "sha256::sha256_checks_reviews_and_merge_survive_restore" + }, + { + "runner": "multi_server", + "case": "comparison::comparison_merge_bases_match_git_for_wide_unrelated_and_crisscross_histories" + }, + { + "runner": "multi_server", + "case": "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node" + }, + { + "runner": "multi_server", + "case": "sha256::sha256_native_merge_candidates_survive_restore_and_publish" + }, + { + "runner": "multi_server", + "case": "sha256::sha256_repository_push_clone_fetch_and_restore" + }, + { + "runner": "multi_server", + "case": "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache" + }, + { + "runner": "multi_server", + "case": "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips" + }, + { + "runner": "multi_server", + "case": "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants" + }, + { + "runner": "multi_server", + "case": "ssh::publication::cold_ssh_push_preparation_failure_reports_rejection_before_any_refs_change" + }, + { + "runner": "multi_server", + "case": "ssh::publication::disconnected_ssh_push_finishes_publication_before_shutdown_releases_cells" + }, + { + "runner": "multi_server", + "case": "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore" + }, + { + "runner": "multi_server", + "case": "ssh::sha256_ssh_push_and_clone" + }, + { + "runner": "multi_server", + "case": "ssh::signed_sha256_ssh_push_survives_fresh_disk_restore" + }, + { + "runner": "multi_server", + "case": "ssh::ssh_push_options_cover_pack_and_delete_only_requests" + }, + { + "runner": "multi_server", + "case": "ssh::lfs::stock_lfs_uses_ssh_identity_for_push_pull_and_locks_after_restore" + }, + { + "runner": "multi_server", + "case": "tokens::token_rotation_revocation_and_last_admin_survive_owner_restore" + }, + { + "runner": "multi_server", + "case": "ssh::stock_ssh_clone_push_fetch_filters_and_revocation_survive_disk_loss" + }, + { + "runner": "multi_server", + "case": "visibility::public_reads_and_private_revocation_survive_cell_recovery" + }, + { + "runner": "owner_restart", + "case": "a_second_node_clones_from_the_published_root_after_local_disk_loss" + }, + { + "runner": "repository_cell", + "case": "repository_cell_publishes_objects_and_refs_atomically" + }, + { + "runner": "smart_http", + "case": "stock_git_push_and_clone_are_backed_by_one_repository_cell" + } + ] + }, + "final_source_qualification": false + }, + "before_expiry_repair": { + "source_files": 493, + "rust_files": 479, + "source_hash_digest": "6dfcc1f677c343d8c0a12eec3d310ee516325ec85c8134303c3f02283dc60ec6", + "release_qualified": false, + "execution_complete": true, + "phases": [ + { + "label": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 49.618, + "log": "/tmp/canopy-ci-driver-before-expiry-clock/canopy-ci-driver-clippy-final.log", + "log_sha256": "52c732cbda6a7a90a3a9316cd03889363259ce40605a947219f7f6a48cff71f7", + "summaries": [], + "failed_cases": [] + }, + { + "label": "focused", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "--locked", + "--", + "server::residency::tests::staging::", + "shared_staging_", + "--test-threads=4" + ], + "exit_code": 0, + "seconds": 113.091, + "log": "/tmp/canopy-ci-driver-before-expiry-clock/canopy-ci-driver-focused-final.log", + "log_sha256": "45761900523b66acc11ba9c200a546d58f3ce53cc8bb488a4d9a362955d9a492", + "summaries": [ + [ + 9, + 0, + 0, + 0, + 698 + ] + ], + "failed_cases": [] + }, + { + "label": "workspace", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--locked", + "--no-fail-fast" + ], + "exit_code": 101, + "seconds": 673.119, + "log": "/tmp/canopy-ci-driver-before-expiry-clock/canopy-ci-driver-workspace-final.log", + "log_sha256": "bf5e1a13fdcc50c6329ac1969480ef92bd113d77727e753ae659015bb41ccb46", + "summaries": [ + [ + 6, + 0, + 0, + 0, + 0 + ], + [ + 15, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + 706 + ], + [ + 1, + 0, + 0, + 0, + 706 + ], + [ + 706, + 1, + 0, + 0, + 0 + ], + [ + 2, + 0, + 0, + 0, + 0 + ], + [ + 13, + 0, + 0, + 0, + 0 + ], + [ + 2, + 0, + 0, + 0, + 0 + ], + [ + 46, + 60, + 9, + 0, + 0 + ], + [ + 0, + 1, + 0, + 0, + 0 + ], + [ + 0, + 1, + 0, + 0, + 0 + ], + [ + 0, + 1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0 + ] + ], + "failed_cases": [ + "packs::publication::tests::staging_service::restore::cold_staging_keeps_all_original_receipts_after_sdk_expiry_and_actual_owner_restore", + "bulk_refs::bulk_mirror_publication_is_atomic_and_survives_restart", + "accounts::disabled_account_loses_all_credentials_and_stays_disabled_after_restore", + "branch_rules::protected_pushes_preserve_native_reports_and_policy_across_recovery", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "comparison::comparison_matches_git_and_preserves_exact_views_across_recovery", + "checks::commit_checks_bind_reporters_versions_and_reruns_across_recovery", + "comparison::comparison_rejects_oversized_change_sets_without_partial_results", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::patches::patches_apply_with_stock_git_and_reject_excess_work_without_truncation", + "compatibility::stock_git_history_refs_and_shallow_fetch_survive_fresh_disk_restore", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "browse::browser_reads_exact_git_snapshots_with_pages_modes_and_recovery", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "deployment::maintenance_drains_two_nodes_and_fences_startup_until_exact_resume", + "lfs_locks::stock_lfs_locks_block_conflicting_pushes_and_unlock_allows_retry", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "large_objects::large_tree_commit_and_tag_restore_from_sqlite_after_owner_restart", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "push_options::mismatched_signed_push_options_return_a_durable_git_rejection", + "pulls::pull_reviews_follow_exact_revisions_and_membership_across_recovery", + "push_options::stock_git_push_options_are_validated_recorded_and_recovered", + "push_options::signed_push_binds_registered_key_and_preserves_audit_after_restore", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "residency::faults::admission::cold_admission_stays_bounded_after_clients_disconnect", + "residency::faults::admission::one_accounts_cold_requests_leave_capacity_for_another_account", + "residency::faults::cancelled_cold_activation_retains_its_reserved_slot", + "residency::faults::denied_release_retains_local_state_and_recovers_after_node_restart", + "residency::faults::disconnected_admission_finishes_release_and_allows_a_later_restore", + "residency::faults::failed_local_cleanup_retries_before_restoring_the_released_repository", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "residency::faults::lost_release_reply_is_resolved_before_local_cleanup", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::paused_cold_repository_does_not_serialize_other_cold_activations", + "residency::faults::paused_release_keeps_other_warm_repositories_available", + "residency::faults::paused_cold_activation_keeps_other_warm_repositories_available", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "comparison::comparison_merge_bases_match_git_for_wide_unrelated_and_crisscross_histories", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "sha256::sha256_repository_push_clone_fetch_and_restore", + "ssh::publication::cold_ssh_push_preparation_failure_reports_rejection_before_any_refs_change", + "ssh::publication::disconnected_ssh_push_finishes_publication_before_shutdown_releases_cells", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "ssh::sha256_ssh_push_and_clone", + "ssh::lfs::stock_lfs_uses_ssh_identity_for_push_pull_and_locks_after_restore", + "ssh::signed_sha256_ssh_push_survives_fresh_disk_restore", + "ssh::ssh_push_options_cover_pack_and_delete_only_requests", + "tokens::token_rotation_revocation_and_last_admin_survive_owner_restore", + "ssh::stock_ssh_clone_push_fetch_filters_and_revocation_survive_disk_loss", + "visibility::public_reads_and_private_revocation_survive_cell_recovery", + "a_second_node_clones_from_the_published_root_after_local_disk_loss", + "repository_cell_publishes_objects_and_refs_atomically", + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ] + }, + { + "label": "binary", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--bin", + "canopy", + "--locked" + ], + "exit_code": 0, + "seconds": 0.798, + "log": "/tmp/canopy-ci-driver-before-expiry-clock/canopy-ci-driver-binary-final.log", + "log_sha256": "edce003cec44c858eb11ba1c9ba5f5d58d5c0f1c4d5afddc909866aa7b25c6ed", + "summaries": [ + [ + 2, + 0, + 0, + 0, + 0 + ] + ], + "failed_cases": [] + }, + { + "label": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 53.553, + "log": "/tmp/canopy-ci-driver-before-expiry-clock/canopy-ci-driver-build-final.log", + "log_sha256": "881a218543f81532c3c8a4f6011bc86c2889ad9c48345756f46f7341845f6a7f", + "summaries": [], + "failed_cases": [] + }, + { + "label": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.434, + "log": "/tmp/canopy-ci-driver-before-expiry-clock/canopy-ci-driver-fmt-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "label": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.086, + "log": "/tmp/canopy-ci-driver-before-expiry-clock/canopy-ci-driver-diff-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "label": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 47.892, + "log": "/tmp/canopy-ci-driver-before-expiry-clock/canopy-ci-driver-harness-final.log", + "log_sha256": "70e4a93b039360334cbef4c050b3dcd3d882bf08f4f2886d61753733fa0fd3ac", + "summaries": [], + "failed_cases": [] + } + ] + }, + "before_shared_clock_and_accepted_denial_windows": { + "source_files": 493, + "rust_files": 479, + "source_hash_digest": "d05adfe14c1bafba443cb20669b75b63b581019d133d96c04e2102600ded67b7", + "release_qualified": false, + "execution_complete": true, + "phases": [ + { + "label": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 42.159, + "log": "/tmp/canopy-ci-driver-final-clippy-final.log", + "log_sha256": "7750d4d8317880d577fd051623bbad968e897468333cadd66fb4b9127c4526fd", + "summaries": [], + "failed_cases": [] + }, + { + "label": "focused", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "--locked", + "--", + "server::residency::tests::staging::", + "shared_staging_", + "cold_staging_keeps_all_original_receipts_after_sdk_expiry_and_actual_owner_restore", + "closed_producer_renews_during_long_construction_and_returned_workspace_lifetime", + "object_reads::tests::", + "reachability_stops_at_live_refs_without_scanning_other_history", + "--test-threads=4" + ], + "exit_code": 0, + "seconds": 268.339, + "log": "/tmp/canopy-ci-driver-final-focused-final.log", + "log_sha256": "35ee411d8b5ef3e557060e2399b91eb5175649e41aa7b6359b828b74027e6178", + "summaries": [ + [ + 12, + 0, + 0, + 0, + 695 + ] + ], + "failed_cases": [] + }, + { + "label": "libraries", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--lib", + "--locked", + "--no-fail-fast" + ], + "exit_code": 101, + "seconds": 383.954, + "log": "/tmp/canopy-ci-driver-final-libraries-final.log", + "log_sha256": "90a98aed4e0ce433f84b1fae681ab1b6bcfc2c4bc6ac17fe938e4c081089b77e", + "summaries": [ + [ + 6, + 0, + 0, + 0, + 0 + ], + [ + 15, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + 706 + ], + [ + 1, + 0, + 0, + 0, + 706 + ], + [ + 706, + 1, + 0, + 0, + 0 + ] + ], + "failed_cases": [ + "packs::publication::tests::custody::denied_begin_is_original_knowledge_after_sdk_expiry_and_authority_changes" + ] + }, + { + "label": "read_integration", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--test", + "directory_cell", + "--test", + "git_http", + "--locked", + "--no-fail-fast" + ], + "exit_code": 0, + "seconds": 10.454, + "log": "/tmp/canopy-ci-driver-final-read_integration-final.log", + "log_sha256": "f02f066ca8799c5f385827774ad70aadb2fd265ad83c78b60a01198b2b290f72", + "summaries": [ + [ + 13, + 0, + 0, + 0, + 0 + ], + [ + 2, + 0, + 0, + 0, + 0 + ] + ], + "failed_cases": [] + }, + { + "label": "binary", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--bin", + "canopy", + "--locked" + ], + "exit_code": 0, + "seconds": 1.516, + "log": "/tmp/canopy-ci-driver-final-binary-final.log", + "log_sha256": "f167dd7dcf179604c65e33e0f831317e7d9c46f8b8c685cc2a0725442e0abb38", + "summaries": [ + [ + 2, + 0, + 0, + 0, + 0 + ] + ], + "failed_cases": [] + }, + { + "label": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 0.371, + "log": "/tmp/canopy-ci-driver-final-build-final.log", + "log_sha256": "8a2933ae25e59188be988ea6218274cb341f13d8bc4b533f57b12ececd4be8b3", + "summaries": [], + "failed_cases": [] + }, + { + "label": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.454, + "log": "/tmp/canopy-ci-driver-final-fmt-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "label": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.077, + "log": "/tmp/canopy-ci-driver-final-diff-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "label": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 42.406, + "log": "/tmp/canopy-ci-driver-final-harness-final.log", + "log_sha256": "b979edbb903488f026dea0d71e1c910237c8d6b36a80c9b79dcf29aed8ba9221", + "summaries": [], + "failed_cases": [] + } + ] + }, + "before_renewal_profiles": { + "source_files": 493, + "rust_files": 479, + "source_hash_digest": "8962d75ad2899f66b23f62e1ed9607fc932704dc6cf8a7001d738e57e50702e4", + "release_qualified": false, + "execution_complete": true, + "phases": [ + { + "label": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 50.418, + "log": "/tmp/canopy-ci-driver-sdk-clock-clippy-final.log", + "log_sha256": "12b99542b944cc9fe99cb9fff248cbfb8553a5f05c65f364cf042a5331620a9f", + "summaries": [], + "failed_cases": [] + }, + { + "label": "focused", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "--locked", + "--", + "server::residency::tests::staging::", + "shared_staging_", + "cold_staging_keeps_all_original_receipts_after_sdk_expiry_and_actual_owner_restore", + "closed_producer_renews_during_long_construction_and_returned_workspace_lifetime", + "packs::publication::tests::custody::", + "packs::sources::tests::changes::", + "reachability_stops_at_live_refs_without_scanning_other_history", + "--test-threads=4" + ], + "exit_code": 0, + "seconds": 283.371, + "log": "/tmp/canopy-ci-driver-sdk-clock-focused-final.log", + "log_sha256": "e780c4bf9fc1e9f1655e42693b29e86a46bd6bb9fbc4cf673f3e444cb8488ebb", + "summaries": [ + [ + 30, + 0, + 0, + 0, + 677 + ] + ], + "failed_cases": [] + }, + { + "label": "libraries", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--lib", + "--locked", + "--no-fail-fast" + ], + "exit_code": 101, + "seconds": 365.122, + "log": "/tmp/canopy-ci-driver-sdk-clock-libraries-final.log", + "log_sha256": "7187e3581964a5dddc8a244d3f39c87cda3c4e3a9936d3a2e5956150dbf76c70", + "summaries": [ + [ + 6, + 0, + 0, + 0, + 0 + ], + [ + 15, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + 706 + ], + [ + 1, + 0, + 0, + 0, + 706 + ], + [ + 706, + 1, + 0, + 0, + 0 + ] + ], + "failed_cases": [ + "packs::publication::tests::serving::lifecycle::closed_owner_keeps_borrowed_generation_renewing_until_last_snapshot_clone_drops" + ] + }, + { + "label": "read_integration", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--test", + "directory_cell", + "--test", + "git_http", + "--locked", + "--no-fail-fast" + ], + "exit_code": 0, + "seconds": 7.631, + "log": "/tmp/canopy-ci-driver-sdk-clock-read_integration-final.log", + "log_sha256": "cf9d9fbcbde66a73deb5001f1dc1204bbbd1bca3e7c165d75ffe32b27683d2bd", + "summaries": [ + [ + 13, + 0, + 0, + 0, + 0 + ], + [ + 2, + 0, + 0, + 0, + 0 + ] + ], + "failed_cases": [] + }, + { + "label": "binary", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--bin", + "canopy", + "--locked" + ], + "exit_code": 0, + "seconds": 0.619, + "log": "/tmp/canopy-ci-driver-sdk-clock-binary-final.log", + "log_sha256": "15cf2b821d663bfd31fa79d2cb8f5c7bb58693aecc85cde6c899ff24322e49d4", + "summaries": [ + [ + 2, + 0, + 0, + 0, + 0 + ] + ], + "failed_cases": [] + }, + { + "label": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 0.31, + "log": "/tmp/canopy-ci-driver-sdk-clock-build-final.log", + "log_sha256": "5604fc8f0c276397a55db4bd7008236712075c0ad37d3b3a0f1fe362b1b85a36", + "summaries": [], + "failed_cases": [] + }, + { + "label": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.422, + "log": "/tmp/canopy-ci-driver-sdk-clock-fmt-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "label": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.061, + "log": "/tmp/canopy-ci-driver-sdk-clock-diff-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "label": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 42.579, + "log": "/tmp/canopy-ci-driver-sdk-clock-harness-final.log", + "log_sha256": "b817b0984849c80aa01cbc0115b85631aa1acf0d5bfc9ae5268f0fc5491dad72", + "summaries": [], + "failed_cases": [] + } + ] + }, + "retained_diagnostics": [ + { + "path": "/tmp/canopy-ci-driver-focused.log", + "sha256": "26a99c58671d0e3383228d61bf94a7ae972c028e16d61f1d009ec545e424af82", + "final_source_qualification": false + }, + { + "path": "/tmp/canopy-ci-driver-focused-repair.log", + "sha256": "c353a89aac180817dbe96c041cf56d517e0037c7c3f18f53077f98be112cf10e", + "final_source_qualification": false + }, + { + "path": "/tmp/canopy-ci-driver-clippy-draft.log", + "sha256": "0616323379bedae77d7caf2d3182ecca493b6e97b8ee1f94231db99df8501d8f", + "final_source_qualification": false + }, + { + "path": "/tmp/canopy-ci-integration-inventory.log", + "sha256": "43b2be4b00aaac1684670240b94118bc2842c6f8fb3087229c3e084d9106130e", + "final_source_qualification": false + }, + { + "path": "/tmp/canopy-ci-directory-domain.log", + "sha256": "89699e85afc7f9db48ff430860a64352acb27df5d4215c2c4f1747ae005a3338", + "final_source_qualification": false + }, + { + "path": "/tmp/canopy-ci-bd8d819-linux-failed.log", + "sha256": "d1e930d1de92df14c0d9b3106eb7c9ea46cd0c5e3021886ce15b17782ea77b80", + "final_source_qualification": false + }, + { + "path": "/tmp/canopy-pr34-reported-rust-failed.log", + "sha256": "0d45c1f7145930e379d62f4c82ca68f9b7c4d067f122f2221e5507abb90005e9", + "final_source_qualification": false + } + ], + "qualification_limits": [ + "Production native HTTP/SSH/generated writers remain on retired ingestion/completion. Complete integration and Linux/RustFS CI remain release gates.", + "The retired SQL hydration regressions were transferred to native SourceIndex change cursor tests and pinned serving reachability. Final complete library validation includes the replacement native cursor cases.", + "Final library/read/binary qualification is distinct from the earlier source-frozen full workspace inventory. That inventory contains 63 integration failures and one unlabelled serving-renewal timeout.", + "Two serving-renewal fixtures use five-second test leases and retain borrowers for 7.5 seconds, still crossing the original expiry and requiring multiple real renewals, unchanged pin identity and last-borrower release. Expiration-only cases and production profiles remain unchanged; timeout diagnostics report phase and owner state.", + "The pre-controller integration diagnostic lacks a full pre-run manifest; nested child summaries and focused reruns are excluded from unique counts.", + "Generic workflow callback cases qualify resident ownership, not actual native network writer publication.", + "Portable macOS tests do not qualify Linux-only forks or RustFS/provider behavior.", + "No full Linux/Kubernetes/Chromium history or 10000-engineer capacity qualification." + ], + "reported_ci": { + "run": 37232379168, + "job": 111524630647, + "head": "cc4a963c750d03c22483d6a9f12099a525114385", + "state": "FAILURE", + "server_library_passed": 670, + "server_library_failed": 5, + "failure": "retired objects SQL reads" + }, + "previous_ci": [ + { + "run": 37250865851, + "job": 111578008697, + "head": "bd8d819315f0e438d9b857a481dfcd294ddd0bd9", + "state": "FAILURE", + "server_library_passed": 704, + "directory_passed": 12, + "directory_failed": 1 + }, + { + "run": 37250862165, + "job": 111577998825, + "head": "bd8d819315f0e438d9b857a481dfcd294ddd0bd9", + "state": "FAILURE" + } + ], + "preservation": { + "protected_index": "/Users/haipingfu/Github/canopy/.git/worktrees/canopy5/index", + "archive": "docs/archive/pr20-progress-through-8bb0ee7.md", + "cellule_revision": "161067f5a21703b3e257024bcb64e565fd9657b4", + "protected_index_sha256": "bef77b0a83f80518f232060828e83797174b1863b8ed9147bffa65850af59798", + "archive_sha256": "c7494d679abed5e1e55a5b2d605d80e786cb4de86406d77f0c7a37c71c79437e" + }, + "next_priorities": [ + "Wire actual native HTTP/SSH/generated producers through the resident controller and mandatory registered root policy/completion with exact original recovery.", + "Complete integration conversion and full Linux/RustFS workflow; investigate any recurrent one-second serving-renewal failure using its phase diagnostics.", + "Finish request/policy physical ownership, authenticated old-owner selection, remaining authority/readers, custody rollover/final DDL, GC/backup/isolated restore, containment, fair maintenance/coalesced workspaces, full-history mixed-load capacity and attribution." + ] +} diff --git a/docs/evidence/resident-recovery-discovery-ci-20261005.json b/docs/evidence/resident-recovery-discovery-ci-20261005.json new file mode 100644 index 00000000..b8b2c0fc --- /dev/null +++ b/docs/evidence/resident-recovery-discovery-ci-20261005.json @@ -0,0 +1,274 @@ +{ + "recorded_at_utc": "2026-10-05T22:42:15.834260+00:00", + "base_head": "823092222fd043c2a0e3ce7deab8cdab40cd097c", + "host": "macOS, Rust 1.98.0; current-head Linux qualification required", + "release_qualified": false, + "source_files": 519, + "rust_files": 501, + "source_hash_digest": "923f13824b06e38897b342cbd837e716ba5771966efbe56b50b833b927605f45", + "source_manifest": "/tmp/canopy-resident-recovery-race-final-source.json", + "source_digest_algorithm": "SHA256 of compact sorted-key JSON mapping each Rust/SQL/TOML/lock/YAML path to its file SHA256", + "source_unchanged_during_validation": true, + "validation": { + "source_digest": "923f13824b06e38897b342cbd837e716ba5771966efbe56b50b833b927605f45", + "phases": [ + { + "name": "focused", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "resident_discovery_defers_registered_root_until_live_producer_handoff", + "--locked", + "--", + "--nocapture" + ], + "exit_code": 0, + "seconds": 1.39, + "log": "/tmp/canopy-resident-recovery-race-final-focused-final.log", + "log_sha256": "9f57a1c5372fee91183e774492c577ba6d0e3e4c30ce870bb6ca14c14519ccb5", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 746 filtered out; finished in 0.43s" + ], + "failed_cases": [] + }, + { + "name": "discovery", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "packs::publication::tests::recovery_discovery::", + "--locked" + ], + "exit_code": 0, + "seconds": 2.23, + "log": "/tmp/canopy-resident-recovery-race-final-discovery-final.log", + "log_sha256": "ed024e9f2276f1a8edfcfac38ec19d54509cb2f8bbebee62a27c2fb58c74bc6b", + "summaries": [ + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 745 filtered out; finished in 1.84s" + ], + "failed_cases": [] + }, + { + "name": "staged", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "packs::publication::tests::staging_service::publication::", + "--locked" + ], + "exit_code": 0, + "seconds": 3.43, + "log": "/tmp/canopy-resident-recovery-race-final-staged-final.log", + "log_sha256": "2b39b293a537301fb9fb536ce7f6dabdbd4cc785ec232979ead16f5b75a3fef9", + "summaries": [ + "test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 737 filtered out; finished in 3.04s" + ], + "failed_cases": [] + }, + { + "name": "registry", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "packs::publication::registry::tests::production_registers_packed_and_policy_metadata_contracts", + "--locked", + "--", + "--exact" + ], + "exit_code": 0, + "seconds": 0.43, + "log": "/tmp/canopy-resident-recovery-race-final-registry-final.log", + "log_sha256": "36163c43a9d9d49855aaa365245f0989c5412865912f931f86f90f430e13a8f9", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 746 filtered out; finished in 0.07s" + ], + "failed_cases": [] + }, + { + "name": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 40.67, + "log": "/tmp/canopy-resident-recovery-race-final-clippy-final.log", + "log_sha256": "1f2390b5b69a58446bab65b3c146a59cc5d75161ce06c5ad478670688fff4523", + "summaries": [], + "failed_cases": [] + }, + { + "name": "library", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "--locked" + ], + "exit_code": 0, + "seconds": 357.74, + "log": "/tmp/canopy-resident-recovery-race-final-library-final.log", + "log_sha256": "cd0b306e13443439550a017d7b060ec820225369475a98f3e2af44be7cbc8062", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 746 filtered out; finished in 0.01s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 746 filtered out; finished in 0.09s", + "test result: ok. 747 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 356.78s" + ], + "failed_cases": [] + }, + { + "name": "history", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--test", + "multi_server", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "--locked", + "--", + "--exact" + ], + "exit_code": 0, + "seconds": 64.53, + "log": "/tmp/canopy-resident-recovery-race-final-history-final.log", + "log_sha256": "1080f3b7b8a0486c4ec412fc5a6df1c7daae8d6e7b0fcd7c2fcf696161b0e7ef", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 115 filtered out; finished in 8.76s" + ], + "failed_cases": [] + }, + { + "name": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 41.99, + "log": "/tmp/canopy-resident-recovery-race-final-build-final.log", + "log_sha256": "2fb5ee3e0c93f986baf18411573eb6a8eb0ab49ad1d68f8032148b56d7e7a923", + "summaries": [], + "failed_cases": [] + }, + { + "name": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.3, + "log": "/tmp/canopy-resident-recovery-race-final-fmt-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "name": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 42.08, + "log": "/tmp/canopy-resident-recovery-race-final-harness-final.log", + "log_sha256": "461ca7bc24683d2d817838e6659f3190da63952e09fee985385bce03f1ee6a6e", + "summaries": [], + "failed_cases": [] + }, + { + "name": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.04, + "log": "/tmp/canopy-resident-recovery-race-final-diff-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + } + ], + "complete": true, + "release_qualified": false, + "source_unchanged": true + }, + "failing_first": { + "reason": "Actual Cell root recovery discovery submits and retires a newly registered original while its live producer is paused before held handoff; submitted=1, release_submitted=1, expected no submission. The new resident constructor carried but did not consult staging ownership in this failing-first run.", + "path": "/tmp/canopy-resident-recovery-race-before.log", + "sha256": "627868a835cbeec9efa7cc60e8cda1f02193707c0ac343db35d6b84f14544443", + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 746 filtered out; finished in 0.23s" + ] + }, + "first_fixed": { + "path": "/tmp/canopy-resident-recovery-race-after.log", + "sha256": "be6a9c0de778b148b815d1262d18d234e8901a68aefb2c485eabe94de550ad0a", + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 746 filtered out; finished in 0.65s" + ] + }, + "causal_fix": "Production root discovery shares the existing staging coordinator. An already admitted cold ticket is checked first; otherwise only exact bound LeaseCheck ownership defers discovery. No duplicate guard, SDK identity, recovery journal or schema is weakened.", + "regression_scope": [ + "Both SHA-1 and SHA-256; producer paused after actual durable registration; two complete discovery passes leave original SDK acceptance absent and queue admissions zero; the same registered command then publishes through its live staging owner.", + "Equal logical UUID with another artifact operation, actor or admission sequence cannot defer discovery. Existing cold recovery, uncertain command and held-publication tests retain their original assertions." + ], + "historical_full_workspace": "docs/evidence/native-candidate-verification-ci-20261005.json", + "historical_full_totals": { + "passed": 871, + "failed": 23, + "ignored": 9 + }, + "qualification_limits": [ + "Focused current-source gates are not a full green workspace claim. The previous complete macOS inventory and its source digest remain historical evidence, not a run of this source.", + "Parent Linux PR/push jobs both fail the branch-deletion family with HTTP500/logical publication already admitted. Exact current-head Linux jobs and full release gates still required.", + "Generated candidate Ready/catalog/fetch-ref publication, generated reviewed merge, default-branch/thread writers, peer/backup recovery, selective fetch, complete retention/GC/final DDL, acceleration/fair maintenance, attribution and large-team load proof remain open.", + "No test was ignored or assertion weakened, and no Verify workflow was changed. Existing macOS oversized compact-unwind linker warning remains recorded." + ] +} diff --git a/docs/evidence/resident-staging-20261004.json b/docs/evidence/resident-staging-20261004.json new file mode 100644 index 00000000..e40eba88 --- /dev/null +++ b/docs/evidence/resident-staging-20261004.json @@ -0,0 +1,310 @@ +{ + "checkpoint": "node-owned resident staging lifecycle and shared admission", + "previous_head": "6357f4149fe7aee6df675fc1c3a50fe5bb45216e", + "main": "d559e5635e002ee3f885c780a418cf861a5197fc", + "validation": { + "source_files": 492, + "rust_files": 478, + "source_hash_digest": "3d83c7cb2432207af2f58a667a562e82624734c8c9c7ad58faf20f2f22ec15bd", + "release_qualified": false, + "execution_complete": true, + "phases": [ + { + "label": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 42.621, + "log": "/tmp/canopy-resident-staging-clippy-final.log", + "log_sha256": "02cd00b79c2761d7d23109cee9099682bf498300e0665b88da5a0f28495eb4a7", + "summaries": [], + "failed_cases": [] + }, + { + "label": "focused", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--lib", + "--locked", + "--", + "server::residency::tests::staging::", + "shared_staging_", + "cold_owner_restoration_recovers_claim_and_renew_receipts_without_reviving_custody", + "cold_staging_keeps_all_original_receipts_after_sdk_expiry_and_actual_owner_restore", + "--test-threads=4" + ], + "exit_code": 0, + "seconds": 230.974, + "log": "/tmp/canopy-resident-staging-focused-final.log", + "log_sha256": "f43fe37b3467a8648ecb850ff53e5ad9dc778835bdaeb92a327f36cb29093a8e", + "summaries": [ + [ + 8, + 0, + 0, + 0, + 696 + ] + ], + "failed_cases": [] + }, + { + "label": "workspace", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--locked" + ], + "exit_code": 101, + "seconds": 453.441, + "log": "/tmp/canopy-resident-staging-workspace-final.log", + "log_sha256": "669667303e6c5330ef3c1c468b8b4ea3ca53d8113f173bd280a6ae7cd172dede", + "summaries": [ + [ + 6, + 0, + 0, + 0, + 0 + ], + [ + 15, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + 703 + ], + [ + 1, + 0, + 0, + 0, + 703 + ], + [ + 704, + 0, + 0, + 0, + 0 + ], + [ + 2, + 0, + 0, + 0, + 0 + ], + [ + 12, + 1, + 0, + 0, + 0 + ] + ], + "failed_cases": [ + "directory_reservations_recover_two_distinct_repository_cells" + ] + }, + { + "label": "binary", + "command": [ + "cargo", + "+1.98.0", + "test", + "-p", + "canopy-server", + "--bin", + "canopy", + "--locked" + ], + "exit_code": 0, + "seconds": 0.916, + "log": "/tmp/canopy-resident-staging-binary-final.log", + "log_sha256": "cae37e49f262f366983ce63e0d06965fa91627b732eb813ae6f09909ac3f08f4", + "summaries": [ + [ + 2, + 0, + 0, + 0, + 0 + ] + ], + "failed_cases": [] + }, + { + "label": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 52.197, + "log": "/tmp/canopy-resident-staging-build-final.log", + "log_sha256": "7b7596109c3ec3a52853199a057533dd544995fcdff0f102f4610abcb8df6e57", + "summaries": [], + "failed_cases": [] + }, + { + "label": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.274, + "log": "/tmp/canopy-resident-staging-fmt-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "label": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.064, + "log": "/tmp/canopy-resident-staging-diff-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "label": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 40.786, + "log": "/tmp/canopy-resident-staging-harness-final.log", + "log_sha256": "02044d964939bf02c29c246653dcbd0ecbc66410efbb35185fc91735ed8c985a", + "summaries": [], + "failed_cases": [] + } + ] + }, + "rust_unique_executed": 740, + "rust_unique_passed": 739, + "rust_unique_failed": 1, + "library_unique_passed": 725, + "server_library_passed": 704, + "publication_unique_passed": 403, + "focused_passed": 8, + "binary_passed": 2, + "directory_passed": 12, + "directory_failed": 1, + "python_passed": 96, + "counts_exclude": "focused and binary reruns, and two nested subprocess summaries; interleaved child stdout is parsed without losing the parent case", + "limits": { + "node_operations": 32, + "node_physical_workers": 64, + "node_operations_per_account": 16, + "node_physical_workers_per_account": 32, + "repository_operations": 32, + "repository_operations_per_actor": 8, + "repository_workers": 64, + "repository_workers_per_actor": 8 + }, + "scope": [ + "Actual resident constructor/registration barrier owns one StagingCoordinator, with a weak RepositoryCell capability.", + "Node and account quotas reuse existing AccountAdmission and bounded weak maps. Exact uncertainty retains operation admission; terminal removal releases it despite retained observers.", + "Physical activity owns shared node worker capacity through async result transfer, abort and observer cancellation.", + "Reversible idle staging pause before serving quiescence. Busy staging blocks eviction. Successful eviction closes admission; retry is idempotent.", + "Shutdown closes all staging owners and drains repositories concurrently before serving/publication closure, preserving Cell authority, heartbeat and workspace.", + "Drain waits for the independent read-only retirement owner after exact job settlement.", + "Production mixed shutdown recovers absent, lost-reply and panicked original staging commands in both formats while another repository holds detached physical work.", + "Accepted-history fixture windows permit initial commit under load; tests still wait for actual SDK expiry and preserve original receipts after genuine owner restoration." + ], + "source_hashes_file": { + "path": "/tmp/canopy-resident-staging-source-hashes.json", + "sha256": "2293b2f59c8843e46781cea2b1fd5b1c6d10864c07aeefcf974b41e9bbb24518" + }, + "protected": [ + { + "path": "/Users/haipingfu/Github/canopy/.git/worktrees/canopy5/index", + "sha256": "bef77b0a83f80518f232060828e83797174b1863b8ed9147bffa65850af59798" + }, + { + "path": "/Users/haipingfu/.codex/worktrees/packed-catalog-publication-pr/canopy/docs/archive/pr20-progress-through-8bb0ee7.md", + "sha256": "c7494d679abed5e1e55a5b2d605d80e786cb4de86406d77f0c7a37c71c79437e" + } + ], + "preceding_remote_ci": { + "head": "6357f4149fe7aee6df675fc1c3a50fe5bb45216e", + "run": 37247847774, + "job": 111569198375, + "library_passed": 719, + "server_library_passed": 698, + "failure": "retired ingestion operation descriptor unavailable in directory integration", + "log": { + "path": "/tmp/canopy-pr36-6357f414-failed.log", + "sha256": "b7e0b6fc2673708e4b6180fb95813d1cd79c76a0a53f5c75fed9e6ba6b6eb855" + } + }, + "preserved_initial_validation": { + "path": "/tmp/canopy-resident-staging-before-final-drain/validation.json", + "sha256": "420532880b059928276a6cdd719ff637178d69e29b67c7aeb94a7e873a76b451" + }, + "initial_diagnostics": { + "path": "/tmp/canopy-resident-staging-clippy.log", + "sha256": "d625f63446be4896b12a577a270f1a42a4763c4202e7e1451d8108bd1a0da504" + }, + "initial_failures": "The first full run passed 701/703 server tests and exposed two one-second accepted-history command windows expiring under parallel load. Its source/log hashes remain attributed to that run. Final source also joins the retirement scanner and adds a production mixed-drain regression.", + "release_qualified": false, + "remaining_failure": "Actual HTTP/SSH/generated writers and directory integration still invoke retired ingestion. Convert actual writers and callers without restoring the removed table/command or skipping failing cases.", + "unrun": [ + "later integration binaries and doctests beyond failed directory integration", + "complete new-source Linux/RustFS workflow", + "full native histories and 10000-engineer mixed-load/recovery capacity", + "adopted earlier-owner physical input selection and remaining production hard-cutover scope" + ], + "next_priorities": [ + "owned HTTP/SSH/generated writer pipeline on resident staging and registered root policy/completion", + "actual integration caller conversion, then complete workspace/Linux/provider CI", + "remaining read/policy authority, authenticated old-owner input verification, custody history/rollover and final schema cutover", + "GC/backup/restore, OS containment, fair maintenance/shared hot workspaces, full-history/team capacity and attribution" + ] +} diff --git a/docs/evidence/serving-rollover-ci-20261005.json b/docs/evidence/serving-rollover-ci-20261005.json new file mode 100644 index 00000000..b8f0f0b1 --- /dev/null +++ b/docs/evidence/serving-rollover-ci-20261005.json @@ -0,0 +1,278 @@ +{ + "recorded_at_utc": "2026-10-05T04:44:48.534404+00:00", + "base_head": "64481d15b6d1f210006d137ca481b8b95abcb721", + "source_files": 493, + "rust_files": 479, + "source_hash_digest": "07634d72100e79ba93581796920a5391fdfd33df80d819e99d39a3c7c164e040", + "source_digest_algorithm": "SHA256 of sorted path + NUL + file SHA256 + newline for tracked/nonignored Rust, SQL, Cargo manifests/lock/toolchain files", + "manifest_log": "/tmp/canopy-serving-rollover-final-source.json", + "toolchain": "Rust 1.98.0", + "host": "macOS; isolated RustFS in Docker; current-source Linux CI required", + "source_changed_during_validation": false, + "runs": { + "workspace": { + "log": "/tmp/canopy-serving-rollover-workspace1.log", + "sha256": "c88d4453a8f057a014106928b14e13099dcd8930ad9bd6be61fb99133f1552e7", + "exit_code": 101, + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.82s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.60s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 710 filtered out; finished in 0.01s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 710 filtered out; finished in 0.08s", + "test result: ok. 711 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 285.64s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 19.50s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s", + "test result: FAILED. 75 passed; 31 failed; 9 ignored; 0 measured; 0 filtered out; finished in 370.96s" + ], + "failed_cases": [ + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "branch_rules::protected_pushes_preserve_native_reports_and_policy_across_recovery", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "checks::commit_checks_bind_reporters_versions_and_reruns_across_recovery", + "comparison::comparison_matches_git_and_preserves_exact_views_across_recovery", + "comparison::comparison_merge_bases_match_git_for_wide_unrelated_and_crisscross_histories", + "comparison::comparison_rejects_oversized_change_sets_without_partial_results", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::patches::patches_apply_with_stock_git_and_reject_excess_work_without_truncation", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "pulls::pull_reviews_follow_exact_revisions_and_membership_across_recovery", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery" + ] + }, + "focused_pool": { + "log": "/tmp/canopy-serving-rollover-focused1.log", + "sha256": "d55c6d220fb7bc525415fc67e9aa05c6cff359a8dad3ea2d1249491a07841f02", + "exit_code": 0, + "summaries": [ + "test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 702 filtered out; finished in 6.25s" + ], + "failed_cases": [] + }, + "clippy": { + "log": "/tmp/canopy-serving-rollover-clippy1.log", + "sha256": "abeaf69f56824dc0bde93466f05c7532e950c9d70cb231f4bd39b1d9753dc342", + "exit_code": 0, + "summaries": [], + "failed_cases": [] + }, + "harness": { + "log": "/tmp/canopy-serving-rollover-harness1.log", + "sha256": "00eaa78805d5d40de3c6be44db3b0f2dde8200aaed54fc0934b9b77baff2440e", + "exit_code": 0, + "summaries": [], + "failed_cases": [] + }, + "owner_restart": { + "log": "/tmp/canopy-serving-rollover-owner_restart1.log", + "sha256": "d0112cd478f8a30dfb390e81ae1ebf69b50bbe863778de7272b7e5dec12b73b2", + "exit_code": 101, + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.42s" + ], + "failed_cases": [ + "a_second_node_clones_from_the_published_root_after_local_disk_loss" + ] + }, + "repository_cell": { + "log": "/tmp/canopy-serving-rollover-repository_cell1.log", + "sha256": "63d7e15dc76ad9b6e6965da6a426eb5e24b64fe3618430b12c90ebabe78ceec2", + "exit_code": 101, + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.21s" + ], + "failed_cases": [ + "repository_cell_publishes_objects_and_refs_atomically" + ] + }, + "smart_http": { + "log": "/tmp/canopy-serving-rollover-smart_http1.log", + "sha256": "3ff9409a7a1a3f1a817e69ab2a5e1648c8abf2ce26dff296997670c812ffb653", + "exit_code": 101, + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s" + ], + "failed_cases": [ + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ] + }, + "build": { + "log": "/tmp/canopy-serving-rollover-build1.log", + "sha256": "0b83a7a1488f68a48846a2c431afc5219cce3ef451d99c4767370888ef96bf8c", + "exit_code": 0, + "summaries": [], + "failed_cases": [] + }, + "rustfs": { + "log": "/tmp/canopy-serving-rollover-rustfs1.log", + "sha256": "0975d46acf8b6d74d76949763ddfea5d48ecbe0a6736146936c8e034f7202877", + "exit_code": 1, + "summaries": [ + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 114 filtered out; finished in 8.70s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 114 filtered out; finished in 2.41s" + ], + "failed_cases": [ + "sha256::sha256_real_provider_native_merge_candidates" + ] + } + }, + "unique_cases": { + "passed": 825, + "failed": 35, + "ignored": 7, + "executed": 860, + "total": 867 + }, + "unique_case_identity": "Binary plus case name; terminal per-binary summaries are authoritative. Excludes nested child repeats, focused reruns and ordinary ignored listings of the two separately executed provider cases. Parent/subprocess stream-test announcements can interleave; the two actual cases are counted once, not as a combined pseudo-name.", + "reproduction": { + "log": "/tmp/canopy-serving-rollover-repro1.log", + "exit_code": 101, + "source_hash_digest": "689bae1c71ce409743750ff35881bdd2f0263ced039849beba708f2519a102c0", + "result": "The new sequential-generation regression fails with repository serving generations capacity before the production fix." + }, + "resolved_multi_server_failure": "ssh::stock_ssh_clone_push_fetch_filters_and_revocation_survive_disk_loss", + "new_pool_cases": [ + "sequential_generations_roll_over_idle_slots_without_client_retries", + "rollover_waiters_share_release_after_observer_cancellation_and_lost_ack", + "rollover_timeout_retains_the_slot_and_exact_release_for_retry" + ], + "previous_head_linux_ci": [ + { + "head": "64481d15b6d1f210006d137ca481b8b95abcb721", + "event": "push", + "run_id": 37262723806, + "job_id": 111613132781, + "state": "FAILURE", + "log": "/tmp/canopy-remote-64481d1-push-failed.log", + "log_sha256": "6fbf73db98661281801112b72aa359646f0dae0d1f0aad4a56e6040aec79b8db", + "server_library_passes": 708, + "multi_server": { + "passed": 75, + "failed": 35, + "ignored": 9 + }, + "failed_cases": [ + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "branch_rules::protected_pushes_preserve_native_reports_and_policy_across_recovery", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "checks::commit_checks_bind_reporters_versions_and_reruns_across_recovery", + "comparison::comparison_matches_git_and_preserves_exact_views_across_recovery", + "comparison::comparison_merge_bases_match_git_for_wide_unrelated_and_crisscross_histories", + "comparison::comparison_rejects_oversized_change_sets_without_partial_results", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::patches::patches_apply_with_stock_git_and_reject_excess_work_without_truncation", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "pulls::pull_reviews_follow_exact_revisions_and_membership_across_recovery", + "push_options::mismatched_signed_push_options_return_a_durable_git_rejection", + "push_options::stock_git_push_options_are_validated_recorded_and_recovered", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "ssh::ssh_push_options_cover_pack_and_delete_only_requests", + "ssh::stock_ssh_clone_push_fetch_filters_and_revocation_survive_disk_loss", + "visibility::public_reads_and_private_revocation_survive_cell_recovery" + ] + }, + { + "head": "64481d15b6d1f210006d137ca481b8b95abcb721", + "event": "pull_request", + "run_id": 37262727328, + "job_id": 111613143050, + "state": "FAILURE", + "log": "/tmp/canopy-remote-64481d1-pr-failed.log", + "log_sha256": "fc5f7860708aef82d696666ca1405850d15f7a07d2ea2616327fdafc62d49705", + "server_library_passes": 708, + "multi_server": { + "passed": 75, + "failed": 35, + "ignored": 9 + }, + "failed_cases": [ + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "branch_rules::protected_pushes_preserve_native_reports_and_policy_across_recovery", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "checks::commit_checks_bind_reporters_versions_and_reruns_across_recovery", + "comparison::comparison_matches_git_and_preserves_exact_views_across_recovery", + "comparison::comparison_merge_bases_match_git_for_wide_unrelated_and_crisscross_histories", + "comparison::comparison_rejects_oversized_change_sets_without_partial_results", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::patches::patches_apply_with_stock_git_and_reject_excess_work_without_truncation", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "pulls::pull_reviews_follow_exact_revisions_and_membership_across_recovery", + "push_options::mismatched_signed_push_options_return_a_durable_git_rejection", + "push_options::stock_git_push_options_are_validated_recorded_and_recovered", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "ssh::ssh_push_options_cover_pack_and_delete_only_requests", + "ssh::stock_ssh_clone_push_fetch_filters_and_revocation_survive_disk_loss", + "visibility::public_reads_and_private_revocation_survive_cell_recovery" + ] + } + ], + "current_source_linux_ci": "Unexecuted locally; a new PR/push run is required after publication. Previous-head failures do not constitute current-source qualification.", + "isolated_provider_cleanup": "No canopy-size- container or volume remains; unrelated containers were preserved.", + "provider_not_executed": [ + "remaining six provider cases after native merge-candidate failure" + ], + "release_qualified": false, + "remaining_high_priorities": [ + "Durable rejection for invalid/mismatched push options: previous-head Linux has three additional transport/reason failures.", + "Purpose-specific durable refusal for Write-to-Read revocation during native upload before Bind; preserve write publication authorization.", + "Convert generated candidate/merge/rebase and remaining pull/check/default-branch metadata to certified roots.", + "Complete peer resident routing, native backup/isolated restore, filtered serving and standalone resident fixtures.", + "Complete detached physical ownership, older-owner adoption, Linux/RustFS full gates and full-history/10,000-engineer capacity qualification." + ], + "previous_evidence": "native-writer-ci-20261005.json" +} diff --git a/docs/evidence/serving-selection-budget-ci-20261005.json b/docs/evidence/serving-selection-budget-ci-20261005.json new file mode 100644 index 00000000..a99fcfa0 --- /dev/null +++ b/docs/evidence/serving-selection-budget-ci-20261005.json @@ -0,0 +1,403 @@ +{ + "recorded_at_utc": "2026-10-05T16:06:50.539325+00:00", + "base_head": "c86057c441ca86e856bc5be0fdeef01a6782b142", + "host": "macOS, Rust 1.98.0; Linux qualification required", + "source_files": 502, + "rust_files": 484, + "source_hash_digest": "57004d2198c0001dfed9c02c60d4e979f1905a2e887607b48f3d52d7f28138d0", + "source_digest_algorithm": "SHA256 of compact sorted-key JSON mapping each Rust/SQL/TOML/lock/YAML path to its file SHA256; source manifest /tmp/canopy-lifecycle-source.json", + "source_unchanged_during_validation": true, + "release_qualified": false, + "reproduction": { + "exit_code": 101, + "path": "/tmp/canopy-rollover-selection-repro3.log", + "sha256": "08d8305ce91adbee8540f55ed994a866a5f04aecd7f514df18b91a94d7fae176", + "summaries": [ + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 717 filtered out; finished in 2.87s" + ], + "baseline_source_hash_digest": "e77e101432456257c5228137ed7b872032a8e9635e35ec5e643bd07e1417e626", + "baseline_manifest_reconstructed": true, + "result": "After queuing initial selection on the actual Cell worker for 2.1 seconds, the unmodified timer returns repository serving generations Capacity before idle release can use its observation budget. This proves one trigger; it does not attribute every historical Capacity failure." + }, + "discarded_fixture_attempts": [ + { + "log": "/tmp/canopy-rollover-selection-repro1.log", + "result": "Compiler borrow error; not a bug reproduction." + }, + { + "log": "/tmp/canopy-rollover-selection-repro2.log", + "result": "Zero-byte SDK mailbox reservation rejected test setup before its callback; RecvError is not the bug reproduction." + } + ], + "publication_family": { + "exit_code": 0, + "path": "/tmp/canopy-lifecycle-publication-tests.log", + "sha256": "44fb1ac6e0ad872e6f36c4636107a906d7a018e449603d93cfd1ab9109d59dde", + "summaries": [ + "test result: ok. 376 passed; 0 failed; 0 ignored; 0 measured; 342 filtered out; finished in 282.02s" + ] + }, + "validation": { + "source_digest": "57004d2198c0001dfed9c02c60d4e979f1905a2e887607b48f3d52d7f28138d0", + "phases": [ + { + "name": "workspace", + "command": [ + "cargo", + "+1.98.0", + "test", + "--workspace", + "--locked", + "--no-fail-fast" + ], + "exit_code": 101, + "seconds": 689.92, + "log": "/tmp/canopy-lifecycle-workspace-final.log", + "log_sha256": "09a9b2dce024cb9982885d3239be7c987827e0b2181b8fdac3917b5e06caf607", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.67s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.17s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 717 filtered out; finished in 0.00s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 717 filtered out; finished in 0.06s", + "test result: ok. 718 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 293.38s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.03s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s", + "test result: FAILED. 77 passed; 29 failed; 9 ignored; 0 measured; 0 filtered out; finished in 337.03s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.30s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.15s", + "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.23s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s" + ], + "failed_cases": [ + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::comparison_matches_git_and_preserves_exact_views_across_recovery", + "comparison::comparison_rejects_oversized_change_sets_without_partial_results", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "comparison::patches::patches_apply_with_stock_git_and_reject_excess_work_without_truncation", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "comparison::comparison_merge_bases_match_git_for_wide_unrelated_and_crisscross_histories", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "pulls::pull_reviews_follow_exact_revisions_and_membership_across_recovery", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery", + "a_second_node_clones_from_the_published_root_after_local_disk_loss", + "repository_cell_publishes_objects_and_refs_atomically", + "stock_git_push_and_clone_are_backed_by_one_repository_cell" + ] + }, + { + "name": "clippy", + "command": [ + "cargo", + "+1.98.0", + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 31.04, + "log": "/tmp/canopy-lifecycle-clippy-final.log", + "log_sha256": "a32ee79f7dec1470a9cd949d0b11b083e6bc7803bc864b9763c092de04bd8cb1", + "summaries": [], + "failed_cases": [] + }, + { + "name": "build", + "command": [ + "cargo", + "+1.98.0", + "build", + "--locked", + "--bin", + "canopy" + ], + "exit_code": 0, + "seconds": 36.43, + "log": "/tmp/canopy-lifecycle-build-final.log", + "log_sha256": "9ba3258f8b439288f542274aef9a4259625fa1660318406cacac82642d6bdcf1", + "summaries": [], + "failed_cases": [] + }, + { + "name": "fmt", + "command": [ + "cargo", + "+1.98.0", + "fmt", + "--all", + "--", + "--check" + ], + "exit_code": 0, + "seconds": 1.19, + "log": "/tmp/canopy-lifecycle-fmt-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + }, + { + "name": "harness", + "command": [ + "python3", + "-B", + "-m", + "unittest", + "discover", + "-s", + "scripts", + "-p", + "test_*.py" + ], + "exit_code": 0, + "seconds": 41.18, + "log": "/tmp/canopy-lifecycle-harness-final.log", + "log_sha256": "3e6bc8df29a2990de483f893523bdf539da691ca691465268045a9eb63916449", + "summaries": [], + "failed_cases": [] + }, + { + "name": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "exit_code": 0, + "seconds": 0.04, + "log": "/tmp/canopy-lifecycle-diff-final.log", + "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "summaries": [], + "failed_cases": [] + } + ], + "complete": true, + "release_qualified": false, + "source_unchanged": true + }, + "workspace_terminal_inventory": [ + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_git_format-cdf8cea2f92fe6a4)", + "passed": 6, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_object_storage-4a0661c5c4765140)", + "passed": 15, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/lib.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy_server-d080aca381ae9ba9)", + "passed": 718, + "failed": 0, + "ignored": 0 + }, + { + "binary": "unittests src/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/canopy-16a4bf977c56198e)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/directory_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/directory_cell-31a0f4eea5beeae3)", + "passed": 13, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/git_http.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/git_http-afa4d1d9a2db0179)", + "passed": 2, + "failed": 0, + "ignored": 0 + }, + { + "binary": "tests/multi_server/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/multi_server-3e08ee00a07d7bde)", + "passed": 77, + "failed": 29, + "ignored": 9 + }, + { + "binary": "tests/owner_restart.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/owner_restart-be32ecc90c554a17)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "tests/repository_cell/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/repository_cell-322afb5848ed5e86)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "tests/smart_http/main.rs (/Users/haipingfu/.codex/tmp/canopy-driver-native-target-zedhqen9/debug/deps/smart_http-18ac06122d3c394f)", + "passed": 0, + "failed": 1, + "ignored": 0 + }, + { + "binary": "canopy_git_format", + "passed": 0, + "failed": 0, + "ignored": 0 + }, + { + "binary": "canopy_object_storage", + "passed": 0, + "failed": 0, + "ignored": 0 + }, + { + "binary": "canopy_server", + "passed": 0, + "failed": 0, + "ignored": 0 + } + ], + "workspace_unique_totals": { + "passed": 833, + "failed": 32, + "ignored": 9, + "executed": 865, + "total": 874 + }, + "counting": "Uses the last summary for each Cargo Running section; nested subprocess case summaries and focused reruns are excluded. Ordinary ignored cases remain unexecuted.", + "parent_linux_ci": [ + { + "head": "c86057c441ca86e856bc5be0fdeef01a6782b142", + "run": 37277708867, + "job": 111658341938, + "conclusion": "failure", + "log": "/tmp/canopy-lifecycle-parent-push-full.log", + "sha256": "11ca55f6f9794664043b00c911162344609997fa218bd539663d1cb258166c78", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.28s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.46s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 716 filtered out; finished in 0.00s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 716 filtered out; finished in 0.01s", + "test result: ok. 717 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 410.28s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.01s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s", + "test result: FAILED. 78 passed; 32 failed; 9 ignored; 0 measured; 0 filtered out; finished in 512.38s" + ], + "failed_cases": [ + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "comparison::comparison_matches_git_and_preserves_exact_views_across_recovery", + "comparison::comparison_rejects_oversized_change_sets_without_partial_results", + "comparison::comparison_merge_bases_match_git_for_wide_unrelated_and_crisscross_histories", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::patches::patches_apply_with_stock_git_and_reject_excess_work_without_truncation", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "push_options::mismatched_signed_push_options_return_a_durable_git_rejection", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "pulls::pull_reviews_follow_exact_revisions_and_membership_across_recovery", + "push_options::stock_git_push_options_are_validated_recorded_and_recovered", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "ssh::ssh_push_options_cover_pack_and_delete_only_requests", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery" + ] + }, + { + "head": "c86057c441ca86e856bc5be0fdeef01a6782b142", + "run": 37277713775, + "job": 111658357649, + "conclusion": "failure", + "log": "/tmp/canopy-lifecycle-parent-pr-full.log", + "sha256": "b266e10b7e6288bac66e64cea43665181374b6b4106b4ce3c2c9effcaaabc3c8", + "summaries": [ + "test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.44s", + "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 8.83s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 716 filtered out; finished in 0.00s", + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 716 filtered out; finished in 0.01s", + "test result: ok. 717 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 456.85s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s", + "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.81s", + "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s", + "test result: FAILED. 78 passed; 32 failed; 9 ignored; 0 measured; 0 filtered out; finished in 543.29s" + ], + "failed_cases": [ + "backup::backup_restores_git_lfs_and_collaboration_without_original_storage", + "candidates::candidate_native_graph_and_paths_preserve_git_semantics", + "candidates::conflicts_and_unrelated_histories_never_publish_and_stale_candidates_cannot_merge", + "candidates::native_candidates_are_fetchable_checked_and_recover_before_atomic_merge", + "comparison::comparison_matches_git_and_preserves_exact_views_across_recovery", + "comparison::comparison_rejects_oversized_change_sets_without_partial_results", + "comparison::comparison_merge_bases_match_git_for_wide_unrelated_and_crisscross_histories", + "comparison::history::historical_comparisons_survive_branch_deletion_and_recheck_current_access", + "comparison::patches::patches_apply_with_stock_git_and_reject_excess_work_without_truncation", + "default_branch::default_branch_controls_discovery_and_clone_after_fresh_owner_restore", + "comparison::threads::line_threads_bind_verified_hunks_and_survive_ref_loss_with_authorized_retries", + "leased_server_recovers_two_repositories_with_git_and_lfs", + "merge::merge_rechecks_revisions_authority_and_competing_publications", + "merge::reviewed_merge_is_atomic_replayable_and_visible_to_git_after_recovery", + "partial_clone::cold_single_branch_fetch_skips_unrelated_commit_and_tree_history", + "peers::moving_repositories_preserve_history_and_serialize_cross_gateway_pushes", + "partial_clone::filtered_clones_lazy_fetch_reachable_objects_without_hydrating_other_blobs", + "push_options::mismatched_signed_push_options_return_a_durable_git_rejection", + "peers::two_live_nodes_route_git_to_distinct_cell_owners_and_recover_the_directory", + "pulls::pull_reviews_follow_exact_revisions_and_membership_across_recovery", + "push_options::stock_git_push_options_are_validated_recorded_and_recovered", + "rebase::rebase_conflicts_limits_and_stale_publication_keep_branches_unchanged", + "residency::faults::git_discovery::ref_discovery_does_not_wait_for_a_full_history_restore", + "sha256::sha256_native_merge_candidates_survive_restore_and_publish", + "sha256::sha256_checks_reviews_and_merge_survive_restore", + "ssh::fetch::cold_full_fetches_only_hydrate_blobs_reachable_from_requested_tips", + "ssh::fetch::cold_ssh_blobless_clones_hydrate_only_explicit_blob_wants", + "residency::repositories_beyond_resident_capacity_restore_git_and_lfs_on_the_same_node", + "ssh::filtered_preparation::cold_filters_keep_omitted_blobs_out_of_server_cache", + "ssh::ssh_push_options_cover_pack_and_delete_only_requests", + "ssh::publication::late_ssh_push_refusals_report_both_refs_and_survive_restore", + "visibility::public_reads_and_private_revocation_survive_cell_recovery" + ] + } + ], + "changes": [ + "Start the unchanged two-second rollover observation deadline only at the first retirement; share it across later retries and preserve charged slots until actual physical drain.", + "Observe bound expiry via wait_completion rather than intermediate staging Bound; retain Fenced, zero-admission and no-execution assertions." + ], + "remaining": "Linux rejected-push workload failures; native pull/ref product receivers and default-branch mutation; generated merge/rebase/candidate writers; peers/recovery/backup; reachability and cache evidence; standalone real-resident fixtures; full physical custody/owner adoption/final DDL and large-history/team capacity qualification." +} diff --git a/docs/large-repository-implementation-status.md b/docs/large-repository-implementation-status.md index 7a5d14b2..8c3adb30 100644 --- a/docs/large-repository-implementation-status.md +++ b/docs/large-repository-implementation-status.md @@ -1,19 +1,1006 @@ # Large-repository implementation status -Updated during implementation on 2026-10-04. **The full implementation and capacity goal remains open.** The [large-team amendment](large-team-scalability.md) is mandatory scope alongside the original storage design. Passing primitive tests is not completion of the hard cutover or proof of capacity. - -Current cutover review: [PR #34](https://github.com/crabbuild/canopy/pull/34), -directly against `main`. The original SQL hydration failures have been resolved by -converting their real read/cache callers. Full CI remains open: the directory -integration fixture still invokes the retired loose-object ingestion command. -The physical worker ownership change below supplies a prerequisite for the native -write replacement; it does not complete that replacement. The PR remains for -review and is not ready to merge or deploy. Older checkpoint notes describe -historical states. +Updated during implementation on 2026-10-05. **The full implementation and capacity goal remains open.** The [large-team amendment](large-team-scalability.md) is mandatory scope alongside the original storage design. Passing primitive tests is not completion of the hard cutover or proof of capacity. + +Packed cutover [PR #34](https://github.com/crabbuild/canopy/pull/34) was merged into +`main` at `d559e5635e002ee3f885c780a418cf861a5197fc` while its checks still failed. +The native metadata follow-up is based on that main revision. The original SQL +hydration failures have been resolved by converting real read/cache callers. +The directory recovery fixture now uses repository-local permission metadata +to verify separate Cells and replay after restoration. Full CI remains open because remaining product callers and fixtures still +invoke retired ingestion/ref metadata. Generated candidate and reviewed-merge +publication now use certified native roots. The production HTTP/SSH native writer is +now wired through the resident lifecycle, with the remaining correctness and +qualification gates described below. This cutover is not release qualified. +Older checkpoint notes describe historical states. Implementation is isolated in the PR worktree. The original checkout contains an unrelated, extensive staged workspace merge; its workspace, benchmark and runtime work has been preserved. Canopy is split into Git-format, object-storage and server crates. Main now contains all completed PR #20–#30 changes through [PR #31](https://github.com/crabbuild/canopy/pull/31), merged at `db80fd836db94fff894030f02d736fe92840748c`. The PR #31 checkpoint audit verifies each directly merged PR's exact merge tree and main ancestry; that checkpoint's entire tree is identical to completed PR #30 (`5bf48677857e3d1dd769aa7f1d73eb5db00db30f`). PRs #28–#30 originally merged into stack branches and reached main through #31. Both #31 Verify runs, [37132349361](https://github.com/crabbuild/canopy/actions/runs/37132349361) and [37132329706](https://github.com/crabbuild/canopy/actions/runs/37132329706), pass harness and Rust. The merged main revision also passes [Verify 37132672371](https://github.com/crabbuild/canopy/actions/runs/37132672371). -All five Cellule dependency declarations and six lockfile entries pin `161067f5a21703b3e257024bcb64e565fd9657b4` from [Cellule PR #50](https://github.com/crabbuild/cellule/pull/50), including the admitted owner fence, exact-command snapshot and admitted-mutation APIs. Historical validation below remains attributed to its original source revisions. Trusted ref-plan certification, typed catalog/ref publication, immutable exact-response completion and the class/account-fair dispatcher exist. The local production cutover now selects their registry/schema and initializes new repositories through certified empty roots. Production HTTP/SSH/generated producers and authoritative readers, complete startup recovery and the final schema hard cutover remain open. +All five Cellule dependency declarations and six lockfile entries pin `161067f5a21703b3e257024bcb64e565fd9657b4` from [Cellule PR #50](https://github.com/crabbuild/cellule/pull/50), including the admitted owner fence, exact-command snapshot and admitted-mutation APIs. Historical validation below remains attributed to its original source revisions. Trusted ref-plan certification, typed catalog/ref publication, immutable exact-response completion and the class/account-fair dispatcher exist. The local production cutover now selects their registry/schema and initializes new repositories through certified empty roots. Production HTTP/SSH receive-pack now uses the resident native pipeline. Generated producers and reviewed merges now use native publication. Remaining authoritative readers, complete startup recovery and the final schema hard cutover remain open. + +## Read-only residency restoration (2026-10-06 checkpoint) + +The failing physical-root equality assertion was diagnosed using authenticated +Cellule VFS snapshots. A cold clone changes only `catalog_custody_commands`, +`catalog_serving_pins`, `sys_meta` and `sys_requests`: serving acquisitions +register exact command recovery and pin the selected generation. Product rows +are unchanged. A physical root comparison therefore conflicts with durable +serving custody. + +The integration now compares every table and requires identical repository +identity/allocation, catalog/ref, LFS, collaboration and other product rows. +It separately preserves staging intents, existing custody identities and exact +receipts, permits runtime sequence/time advancement, and requires one serving +pin for the current generation. All six repositories restore, clone, fsck and +retrieve LFS successfully in the focused test (18.11 seconds). Workspace +all-target Clippy with warnings denied passes. New-head Linux and the remaining +selective-fetch, late SSH refusal and standalone fixture work remain open. + +## Native backup and staging readiness (2026-10-06 checkpoint) + +Backup inventory now follows the authenticated native catalog, ref, input, +outcome, audit and recovery graphs from the pinned Cell snapshot. SQL inventory +uses keyset pages; traversal uses bounded depth and disk-admitted deduplication. +Copies preserve the original creating namespaces and authenticate source and +destination parts. Closed recovery metadata does not demand retired command +bodies or responses that can no longer be selected. LFS remains paged and +verified. Provider listing is used only for fault injection in the test. + +The strengthened backup integration passes after deleting original storage, +with strict Git fsck, LFS and collaboration restoration, orphan exclusion, +repeat restore and native/LFS corruption rejection. The fixture retains 25 +physical artifacts including LFS. This is a fixture count, not a capacity claim. + +A deterministic gate reproduced the push-option race: the original checkpoint +receipt became available before the controller restored its active phase. +The production pipeline now waits for readiness separately from the immutable +receipt. The regression passes for both object formats; all four runnable +HTTP/SSH push-option integrations pass locally. The provider case remains +ignored under its existing qualification rule. + +Linux runs 37411479759 and 37411617637 at aa86f94 both report 100 passing, +11 failing and 9 ignored multi-server tests. These repairs require a new-head +Linux run. Selective fetch, late pre-bind SSH rejection, read-only restoration +and detached standalone fixtures remain open. Full CI is not green. + +## Native line threads and owner routing (2026-10-05 checkpoint) + +The [native thread and owner routing contract](design/native-thread-and-owner-routing.md) +repairs purpose-bound line-thread publication, HTTP Git/LFS forwarding to the +live resident owner, push UUID conflict classification, and durable frozen +refusal after a known final catalog CAS denial. Both peer tests, line-thread and +visibility integrations, and the leased push UUID test now pass. The final +cold-owner fixture includes the publication and refusal command reservations. + +[The evidence](evidence/native-ci-routing-20261005.json) distinguishes the full +workspace source from its one assertion-only test correction and final focused +checks. The full diagnostic had 754 passing library cases and the corrected +admission assertion; multi-server had 97 passed / 10 failed / 9 ignored. All +three detached standalone fixtures still fail. Native discovery and listener +rebinding pass alone but fail under full local contention. Clippy, build, +formatting and 96 harness tests pass. Full CI, native backup/selective fetch, +late pre-bind SSH refusal, read-only residency restoration, and new-head Linux +qualification remain open. The capacity goal remains paused and incomplete. + +## Native generated candidates and reviewed merges (2026-10-05 checkpoint) + +The public candidate, rebase and SHA-256 integration failures were caused by +writers targeting retired SQL Git metadata. Generated work now runs under the +resident staging owner and emits only its request-private generated objects. +Concurrent observations join the original frozen intent and uncertain command; +a fresh operation is admitted only after a known attempt fully drains. The +independent physical verifier and private commit verifier prepare operation 55 +codec 1. Its owner transaction atomically commits native roots, the reserved +fetch ref, immutable candidate result, typed audit and exact SDK acceptance. +Large conflict results stay in the existing row behind a compact recovery reply. + +Reviewed merges now support generated strategies through operation 9 codec 8. +They select the immutable Ready result, verify its audit and reserved ref, and +recheck current full pull revision, reviews, checks on the generated target, +branch policy, authorization, owner fence, pin, expiry and joint roots. The +existing merge row and typed audit retain the actual generated target and +candidate identity. Late SQL failures roll back publication and acceptance; +retirement retains the first receipt and refuses missing audit metadata. + +The [technical design](design/native-generated-candidate-publication.md) explains +these boundaries and reused structures. Focused public candidate, rebase and +SHA-256 cases pass, along with transaction rollback, large negative outcomes, +archived receipt recovery, uncertain intent joining and reviewed merges. The +full frozen-source diagnostic passes all 753 server library cases. Multi-server +finishes with 93 passed, 14 failed and nine ignored; owner-restart, repository-cell +and smart-HTTP each retain one failure. All-target Clippy, production build, +formatting and all 96 Python harness tests pass. Compared with the preceding +local checkpoint, five candidate/rebase/SHA-256 failures are resolved and no new +failed names were introduced. The results are recorded in +[evidence](evidence/native-generated-publication-ci-20261005.json). Full CI remains +open. Native line-thread creation, complete backup/peer restoration, selective +fetch, detached legacy fixtures and recovery qualification remain required. +These results do not qualify 10,000-engineer throughput or the full hard cutover. + +## Native symbolic HEAD publication (2026-10-05 checkpoint) + +The stock-Git default-branch regression failed before this change: the API +reported success but an unborn Unicode clone still selected `refs/heads/main`. +The public writer had changed legacy SQL HEAD while native Git read the accepted +immutable snapshot. The HTTP writer now uses resident staging and operation 54 +codec 1. Discovery and HEAD GET read the existing constant-size summary with +current ACL in the same observation. Native pushes, reviewed merges and HEAD +updates maintain that summary atomically with their immutable ref snapshots; +the merge proof binds the prepared snapshot generation in operation 9 codec 7. +Metadata reads acquire no pin and publish no root. The detached SQL setter fails +closed. + +The private factory retains the existing ref tree and creates a new snapshot +header. It checks the target through an exact tree lookup; one live cursor seek +checks whether any branch exists, skipping deleted subtrees. The final owner +transaction checks current ACL, actual fence, original pin, expiry, retention +and joint-root CAS, then commits roots, immutable original outcome, checkpoint, +attempt closure and its compact recovery journal atomically. The new permanent +outcome selector reuses `GenerationFact` and the existing 512-byte phase limit. +Typed retirement verifies selected snapshot headers and preserves the original +SDK receipt before releasing the transient pin; it grants no provider deletion. + +Three actual Cell regression families pass in both Git formats. They cover +unchanged tree/ref versions, request-purpose binding, absent registration, late +SQL rollback with absent SDK acceptance, current owner/expiry/generation/target +denials, cold absent recovery after revocation or expiry, missing metadata +retaining a pin, immutable outcome rows, and receipt recovery on a fresh owner +after deleting command bodies. The original public stock-Git case, repository +metadata/ACL/restore case, branch-deletion case, registry and all-target Clippy +also pass. The complete current-source validation results are recorded in +[evidence](evidence/native-head-ci-20261005.json). + +The corrected source's full workspace diagnostic passes all 750 server library +cases. Multi-server finishes with 88 passed, 19 failed and nine ignored; the +three detached owner-restart, repository-cell and smart-HTTP targets each fail. +Clippy, production build, formatting and all 96 Python harness cases pass. +Both cold-gateway root-equality cases, pristine-bootstrap custody-count case and +oversized-comparison case now pass in the full run with unchanged assertions. +The intermediate serving-pin reader failed those cases and was corrected before +commit. This is a completed diagnostic inventory, not a green CI result. + +Both Linux Verify runs on parent `7c232ea` completed with 747 passing library +cases and the branch-deletion regression passing; their multi-server inventories +were 88 passed, 23 failed and nine ignored. They confirm the previous recovery +race fix but are not qualification of this HEAD source. Full CI and the capacity +goal remain open. Generated candidates/merges, line threads, detached legacy +Cell fixtures/writers, selective fetch, peer/backup restore, retention/collection, +final DDL, acceleration and workload qualification remain required. + +## Resident recovery discovery race (2026-10-05 checkpoint) + +The two Linux Verify jobs on `8230922` add a branch-deletion failure with HTTP +500 and `logical publication already admitted`. Restart discovery can observe a +new recovery registration before the live producer reserves final publication. +It can execute that original command and retire its pin during this gap. +A deterministic failing-first actual Cell regression reproduces premature +submission and retirement while the producer is paused after registration. + +Production residency now supplies the existing staging coordinator to root +recovery discovery. After checking already admitted cold work, discovery defers +only when that coordinator still owns the exact bound `LeaseCheck`: actor, +request digest, operation, owner incarnation/epoch, admission sequence and artifact +operation. This leaves the original producer in charge of handoff. The queue's +original duplicate guard and cold exact-command recovery remain in place. +Historical pins and reused logical UUIDs cannot satisfy the exact owner test. +There is no additional command, SQL table or retry identity. + +The regression passes in SHA-1 and SHA-256 and verifies absent SDK acceptance, +zero queue admissions and no scanner submission before producer handoff, then +successful publication through the same registered command. Frozen current-source +validation passes all 747 server library cases, the branch-deletion integration +case, both recovery-discovery tests, all 10 staging-publication cases, registry, +all-target Clippy, formatting, production build and all 96 Python harness tests. +[Discovery evidence](evidence/resident-recovery-discovery-ci-20261005.json) +records actual terminal logs and source digest +`923f13824b06e38897b342cbd837e716ba5771966efbe56b50b833b927605f45`. +The previous full diagnostic inventory is historical, not a run of this source. +Exact new-head Linux qualification remains required. Full CI remains open; generated +publication, metadata writers, selective-fetch and restoration failures are +separate required work, not flakes or skipped tests. + +## Native generated commit semantic verification (2026-10-05 checkpoint) + +A private preparation verifier reuses the candidate model and physically verified +catalog headers/edges. Merge and squash check the exact canonical Git OID, size +and BLAKE3 body digest without native body downloads. Rebase checks up to 128 +original commits, exact rewritten parent order and bytes, author/message/encoding +preservation and removal of stale signatures. One admitted disk ancestry walk +rejects skipped source commits and replayed base history. The fixture accepts +128 and refuses 129 commits in both Git formats, with shared pack-cache reuse. +The verifier does not publish a Ready candidate or authorize a SQL write. + +Frozen semantic verification passed three new both-format families, 14 existing +native merge cases, all 746 library cases, registry, Clippy, formatting, production +build and all 96 Python harness tests. The full diagnostic inventory was 871 +passed, 23 failed and nine ignored; the failed-case set was unchanged from the +preceding macOS checkpoint. Its historical source digest is +`e06fe0f8168246e9e9bf731b16d720bb97ad49c4bebb627db10ffe5a84d1eb7a`. +[Semantic evidence](evidence/native-candidate-verification-ci-20261005.json) +retains the public candidate baseline (HTTP 503), intermediate failed runs, +terminal log hashes and actual Linux failures. Generated joint publication, +startup adoption, selective workspaces and workload capacity remain required. + +## Native candidate reservation and negative completion (2026-10-05 checkpoint) + +Operation 10 codec 3 is now registered for native editorial candidate intent. +It reuses the existing request/action/result, candidate rows and certified ref +selection. The client retains its serving snapshot through dispatch. The final +receiver checks fresh write access, actual Cell/owner, exact action purpose and +facts, live pin and current joint generation, then evaluates pull policy against +native refs. UUID replay preserves the first request, timestamp and negative +result; changed intent is a collision. Pending reservation and first negative +completion modify only editorial metadata. Generated Ready results require a +separate joint publication and are refused even with authentic read evidence; +the old SQL object certification/ref insertion path is removed from this command. + +The failing-first actual resident reservation compiled and failed because the +operation was absent from the production registry. Four regression families now +pass in both Git formats: reservation/negative completion/replay and unchanged +roots; purpose/payload/fact/actor/Cell/current-generation binding; late access and +editorial revision changes; and SQL rollback with absent SDK acceptance followed +by exact-command retry and receipt replay. Two intermediate rollback-fixture +attempts hit the public SQL separator guard and read-only query guard. The fault +now uses a trusted test-only Cell mutation; neither production guard was weakened. +Fixtures use verified stock-Git metadata but synthetic initial certificate SQL. +They do not qualify the public generated producer or initial-root creation. + +Frozen validation passes all 743 server library cases, the registry contract, +all-target Clippy with warnings denied, production build, formatting/diff checks +and all 96 Python harness cases. Full Rust results are 868 passed /23 failed /9 +ignored, with the exact failed-case set unchanged from the preceding checkpoint. +The unchanged digest across 517 source files (499 Rust) is +`67608ddeea86178381fdc9cc3521f6c4362cd9648c0734fc8453c079a28c4926`. +[Candidate evidence](evidence/native-candidate-intent-ci-20261005.json) records +complete results, failing-first/intermediate runs and parent Linux CI. Both +parent c8c49e8 Linux Rust jobs failed; Python harness jobs passed. Their RustFS +and production-build phases were skipped, and exact new-head Linux validation +remains required. + +Next: complete resident generated candidate production and atomic Ready/catalog/ +fetch-ref publication, then native merge/squash/rebase and thread +writers. Qualify cancellation, lost acknowledgements, startup adoption and +retention, resolve remaining full CI failures, and complete peer/backup recovery, +selective fetch, physical collection/final DDL, acceleration/fair maintenance, +asynchronous attribution and full-history/10,000-engineer capacity gates. The +full goal remains active and this increment is not release qualified. + +## Public native fast-forward merge endpoint (2026-10-05 checkpoint) + +Production HTTP merge now calls the resident `GitGateway` native driver. The +adapter checks access again after body ingestion, transfers the request and +producer to resident ownership before awaiting observation, binds the current +native catalog/ref base, retrieves admitted preparation before Finishing, +persists the original command and uses the existing fair publication/recovery +coordinator. Each observation gets an independent preparation attempt; the +final transaction selects the original application UUID result or refuses a +collision. Generated strategies remain unavailable rather than receiving a +different merge strategy. The historical direct `RepositoryCell::merge_pull` +API remains a retired-preparation caller outside this product route. + +Authenticated terminal denials and UUID replays now close only their matching +admitted operation atomically with the original recovery phase and SDK +acceptance. The independent pin remains until typed terminal retirement. A +late closure SQL fault rolls back complete domain state, phase and acceptance; +the same exact command can retry and its known denial can retire immediately. +Unauthenticated proposals and successor operations do not gain closure rights. +This reuses the existing operation/recovery structures and codec 6 payload. + +The original HTTP regression failed at 503 instead of 409. The failing-first +immediate-denial-retirement regression failed with `Error::Context`. Fourteen +private merge tests now pass. Real stock-Git endpoint coverage creates genuine +production roots for SHA-1 and SHA-256, merges and replays an exact UUID, +refuses changed intent, discovers the joint ref and clones the exact new bytes. +Existing coverage for unrelated history, stale revisions, body-ingestion access +revocation and competing publications also passes. A first intermediate run +had four state-oracle failures after the intentional operation deletion; the +corrected oracle compares every other operation/domain fact and asserts the +exact own-operation count, while rollback still compares the full state. + +Frozen validation passes all 739 server library cases, all-target Clippy with +warnings denied, production server build, formatting/diff checks and all 96 +Python harness cases. Full Rust results are 864 passed /23 failed /9 ignored, +with no added failures and four removed: both reviewed merge families, +SHA-256 checks/reviews/merge recovery, and historical comparisons after branch +deletion. Multi-server finishes at 87 passed /20 failed /9 ignored; three +standalone aggregates still fail. The exact unchanged source digest over +516 files (498 Rust) is +`d1aa115a3c9e7ff266c3d71e50fc3d2054648af58e52d70c845ef817e2acd53a`. +Baselines, the intermediate failures and complete validation are preserved in +[endpoint evidence](evidence/native-merge-endpoint-ci-20261005.json). +Parent `75814b4` Linux PR and push Rust jobs both failed: 738 library cases pass, +then multi-server fails at 83 passed /27 failed /9 ignored. RustFS and server +build were skipped. Those parent logs do not qualify this increment on Linux. + +Next: qualify this producer's observer cancellation, lost acknowledgement, +restart adoption, queued policy/access changes, pre-Bind intent recovery and +automatic audit retirement after generation pruning. Generic push lifecycle +tests are not sufficient merge-specific evidence. Implement native generated +candidate and merge/squash/rebase publication, thread/default-branch writers +and remaining full CI failures. Physical retention/collection/final DDL, +backup/restore, peer recovery, reachable-only cold/filtered fetch, +accelerators/fair maintenance, asynchronous file attribution and +full-history/10,000-engineer capacity gates remain required. The full goal is +active and this cutover remains unqualified for release. + +## Native merge audit and terminal retirement (2026-10-05 checkpoint) + +Operation 9 codec 6 now binds a permanent merge audit in the existing catalog +certificate. The fresh `pull_merges` schema stores a bounded `StoredInputRoot` +descriptor beside the immutable UUID result; SQL guards reject result/audit +updates, deletion and replacement. It reuses existing request, catalog, ref +snapshot and recovery structures, without a SQL ref mirror or backward decoder. +The final transaction saves the audit descriptor with joint roots and result. + +Typed terminal retirement selects the first applied UUID's permanent audit, +checks its exact actor/request/result and native catalog top roots plus published +base-ref path, and atomically archives original recovery and release receipts +before deleting the closed preparation pin. A fresh replay must close its own +operation and selects the old audit rather than its new proposal. Known denials +remain their original phase after a later fresh attempt succeeds. Missing or +corrupt selected metadata prevents release. This grants no provider deletion; +the complete retained-root collector must include the audit's typed descendants. + +The failing-first applied-retirement regression compiled and returned +`Error::Context` on the parent implementation. Thirteen focused merge tests now +pass, including five new retirement families for applied/replayed release, +denied-then-successful receipt separation, missing/corrupt metadata, actual +Admin/owner checks, last-write rollback, immutable result rows, original body +removal and SQLite-loss/owner restoration of both merge and release receipts. +Both formats are exercised where applicable. Fixtures use verified stock-Git +bytes with a trusted synthetic initial certificate; they do not qualify the +public merge adapter or initial-root production path. + +Frozen validation passes all 738 server library tests, all-target Clippy with +warnings denied, the production server build, formatting/diff checks and all +96 Python harness tests. Full Rust results are 858 passed /27 failed /9 ignored; +the exact failed-case set is unchanged from the prior atomic-merge run. +Exact source digest across +515 files (497 Rust) is +`ec37049731e41eb420dac90a67c20fa49c2efc603a6ff206c38da674ce9fa2eb`. +The final results and preserved baseline/intermediate failures are recorded +in [merge retirement evidence](evidence/native-merge-retirement-ci-20261005.json). +Parent `2ed3d58` Rust checks were cancelled, not passed; one PR harness completed +successfully. Exact new-head Linux qualification remains required. + +Next: qualify actual automatic merge retirement and selected audits after SQL +generation reaping; connect the public/resident fast-forward endpoint through +owned staging and exact registered dispatch; then generated merge/squash/rebase, +thread/default-branch writers and the remaining full CI failures. Physical +collection/backup/restore, peer recovery, selective fetch, fair maintenance and +accelerators, asynchronous file attribution and full-history/10,000-engineer +capacity gates remain open. The historical checkpoints below describe earlier +revisions; the current full goal is not achieved or release qualified. + +## Atomic native reviewed-merge transaction (2026-10-05 checkpoint) + +The private factory now binds the exact merge request, actor, source/base ref +versions, mandatory ancestry evidence and conditional immutable ref snapshot to +the existing catalog certificate. It reads verified native metadata, without a +SQL ref or ancestry mirror. This increment supports fast-forward intent; +generated merge, squash and rebase producers remain open. + +Operation 9, codec 5 replaces the production registration of the retired SQL +merge command. Its final owner-fenced transaction checks current access, pull +revision, applicable reviews, branch rules and required checks, then atomically +commits the joint catalog/ref generation, pull state, application UUID result, +checkpoint, operation consumption and original-command recovery journal. Errors +after the first domain write roll back the whole transaction. An applied UUID +replays its original record; a rejected review request can be retried under a +fresh admitted command after policy changes. + +The ready capability retains the actual preparation owner and reuses typed +`ReadyBoundRecovery` dispatch. Eight focused regressions pass for SHA-1 and +SHA-256, including native roots without SQL ref authority, unrelated history, +late review changes, payload substitution, generation conflict, mandatory +original-command registration, late SQL rollback, UUID replay and original +receipt recovery after SQLite loss and actual owner restoration. The maximum +SHA-256 response is 493 wire bytes within the unchanged 512-byte recovery cap. +Both new implementation files are included in the Repository Cell source +fingerprint, so future implementation changes alter the runtime contract. +The fixtures install a trusted initial native root with a synthetic certificate; +they qualify the private factory/receiver/recovery, not the initial-root producer +or public endpoint. Frozen-source validation passes all 733 server library tests, all-target +Clippy with warnings denied, server build, formatting/diff checks and all 96 +Python harness cases. Multi-server remains at 82 passed /24 failed /9 ignored; +three standalone aggregates also fail. Unique totals are 853 passed /27 failed +/9 unexecuted ignores, excluding nested child summaries and focused reruns. +The exact 513-file corrected source digest is +`214b64e819382822ffa9bac28434d2ea2bd42a4247421e69025569188b9cf198`. +The complete run, separately attributed pre-fingerprint run, intermediate +compile/fixture failures and source-fingerprint correction are preserved in +[atomic merge evidence](evidence/native-merge-atomic-ci-20261005.json). +Parent `0b8d3b5` Linux PR/push runs each pass 725 server library cases and fail +the matrix at 83 passed /27 failed /9 ignored. Exact new-head Linux and RustFS +qualification remain required; this PR is not green or release qualified. + +The public merge adapter still calls the retired command and fails. Merge +terminal release deliberately retains its preparation pin until the selected +native graph and exact UUID outcome can be certified. These are explicit +integration and retention gaps, not completed endpoint or capacity work. The +next priority is safe terminal graph certification and actual resident/public +merge dispatch, followed by generated writers and full CI. + +## Mandatory native merge ancestry (2026-10-05 checkpoint) + +The ordinary ref-proof factory computes ancestry only for enabled fast-forward +branch rules. Reviewed merges must prove base-to-target ancestry even on an +unprotected branch. A new native catalog regression fails before the mandatory +factory exists: the valid descendant bit is absent (56 instead of 57). This is a +reproduction of missing preparation evidence, not a fixed merge endpoint. + +`PreparedCatalog::ref_proof_with_required_ancestry` now verifies every +non-vacuous predicate independently of branch rules. It reuses the existing +proof, signed plan/evidence binding, verified commit headers, disk-backed walker, +lease/deadline, cancellation and scratch budget. Ordinary pushes retain selective +ancestry work. No proof format, compatibility decoder, table or SQL ref/ancestry +mirror is added. + +The genuine native catalog regression passes for SHA-1 and SHA-256. It covers +descendants, unrelated and backwards histories, vacuous predicates, negative +evidence binding, invalid branch tip kind and unchanged publication state. It +does not qualify an immutable-root merge receiver or resident merge adapter. + +Frozen-source validation passes all 725 server library tests, all-target Clippy +with warnings denied, server build, formatting/diff checks and all 96 Python +harness cases. Multi-server remains at 82 passed / 24 failed / 9 ignored, and +three standalone aggregates fail. Unique workspace totals are 845 passed / +27 failed / 9 unexecuted ignores, excluding nested child summaries and focused +reruns. The 510-file source digest is +`8c242912a383fbfa5ef5c2023163b2491bf0013eb9c0d16b1256a7b3f89897d8`. +The full failed run and failing-first regression are preserved in +[ancestry evidence](evidence/native-merge-ancestry-ci-20261005.json). + +Parent `6fc9483` Linux PR and push Verify runs pass all 724 server library cases, +formatting and Clippy; each fails multi-server at 83 passed / 27 failed / +9 ignored. Three additional Linux failures involve durable HTTP/SSH push-option +refusals. Both full logs are preserved in the ancestry evidence. Exact new-head +Linux and RustFS qualification remain required; this PR is not green or release +qualified. + +Atomic native merge publication remains the next implementation priority: bind +these facts to the private conditional ref snapshot, commit joint roots, pull +state and UUID result with fresh reviews/checks/access under the actual owner +fence, and retain exact command recovery through cancellation and restart. +Generated candidates/rebase, other writers and the full capacity goal remain +open. + +## Native review-policy and failed-startup supervision (2026-10-05 checkpoint) + +Review-policy still joined retired SQL refs after core pulls were converted. An +existing stock-Git HTTP merge family reproducibly returned 404 at its first +policy read. A new actual-resident regression also returned no policy for both +native refs. Query 52, codec 2, now adds a distinct policy purpose and reuses the +same authenticated ref observation and bounded editorial selection. Current +branch rules, review heads, account generations and access are read in its final +Cell transaction. Merge ancestry preparation uses that reader; the final legacy +merge writer remains unfinished. + +The actual-resident regression covers SHA-1 and SHA-256, preparations before +rule/review changes, substituted purposes and pull numbers, delayed access +revocation, revoked/regranted reviewer decisions, request-changes decisions and comments +that preserve the latest decision, +and ref deletion/recreation with the same OID. It does not populate legacy refs +or qualify a generated writer. + +Public startup formerly returned a reported readiness error before joining its +supervisor. A regression holds a real reserved listener in the supervisor after +reporting an error: before the fix startup returns early; afterward it waits for +release and preserves the original error. The existing conditional-storage +startup fixture now retains its caller reservation through the storage probe, +then checks exact rebind after release. Domain rejection and empty-store checks +remain intact. These prove supervision and remove a fixture's unowned probe +window; they do not establish the cause of every historical `AddrInUse` failure. + +Frozen-source validation passes all 724 server library cases, all-target Clippy +with warnings denied, server build, formatting/diff checks and all 96 Python +harness cases. The existing failed-storage startup case passes in the full +workload. Multi-server finishes at 82 passed / 24 failed / 9 ignored; the three +standalone aggregates still fail. Unique totals are 844 passed / 27 failed / +9 unexecuted ignores, excluding nested summaries and focused reruns. The HTTP +merge reproduction now proceeds past policy reads and fails later at legacy +merge preparation/publication (503); the merge workflow is not qualified. + +The expanded regression initially expected a comment to clear request-changes; +that contradicted the documented decision-head contract. Its failed run and the +interrupted partial compiler run are preserved alongside the corrected regression +and complete final run in [policy/startup evidence](evidence/native-policy-startup-ci-20261005.json). +The 510-file frozen-source digest is +`08ef5b30a9be622dc4ebccc592cd66d7cbf20a72e6912aeddb364e2f8ddcbd39`. +Parent `ef765d4` Linux PR and push Verify pass all 722 server libraries, formatting +and Clippy but each fail multi-server at 83 passed / 27 failed / 9 ignored. Those +results remain failures; exact new-head Linux validation is still required. + +Native atomic merge/candidate/rebase publication, thread revisions, default-branch +publication, rejected-push workload failures, peer/backup recovery and selective +fetch remain priority work. The full implementation/capacity goal stays active. + +## Native pull/ref metadata conversion (2026-10-05 checkpoint) + +Pull creation still selected the retired SQL `refs` table after native push, +reproducibly returning HTTP 409. Pull list/detail/review applicability joined the +same retired authority, and the fresh schema required obsolete foreign keys into +it. Core pull operations now derive bounded exact facts from the immutable native +ref tree under the actual resident serving snapshot. Typed commands 51/53 and +query 52 consume a purpose-specific private certificate that reuses the MAC +envelope, serving token and joint fact. The final transaction independently checks +actual Cell/command owner, current access, the exact unexpired pin, payload/fact +digests and equality with the current joint generation. Historical retained +generations alone cannot authorize moving ref policy. + +Editorial rows, UUID bindings, membership versions and review-head ordering are +reused. Authenticated facts form a parameterized statement-local CTE; no SQL ref +mirror is written. The fresh schema removes only the obsolete pull source/base +ref foreign keys. Lists/details/reviews bind and recheck the initial bounded +editorial row selection before joining refs, with at most three fresh attempts. +Inputs admit 128 sorted facts and 512 KiB total ref-name bytes within an 816 KiB +wire limit; query output is capped at 1 MiB and mutation output at 16 bytes. The +certificate remains constant-sized even with long names. Ref issuance reads +metadata without hydrating native pack bodies. + +The stock-Git HTTP pull/review/recovery family passes, including UUID retries, +ref/editorial ABA, membership changes and delayed repository revocation. Four new +actual-resident receiver families exercise both object formats: proof/payload, +actor/Cell/ref substitution; pin drain and current-generation advance; late +revocation; prepared editorial-version/review conflicts; and anonymous +public-to-private visibility. A broader fixture run caught invalid UUID artifact +operation IDs in the trusted native ref fixture; it now uses validated CANOPY01 +operations without relaxing production validation. Clippy also caught the larger +Git read error variant; its native-pull error source is now boxed. + +Final frozen-source validation passes all 722 server library cases, all-target +Clippy with warnings denied, server build, formatting/diff checks and all 96 +Python harness cases. The five repaired HTTP pull/comparison/patch integrations +pass in the full workload. Multi-server finishes at 81 passed / 25 failed / +9 ignored; the three standalone aggregates also fail. Unique workspace totals +are 841 passed / 28 failed / 9 unexecuted ignores. One additional startup test +returns `AddrInUse`; port ownership/rebind causality remains unresolved. The +pre-box run had 842/27/9 but failed Clippy and does not supersede final-source +results. Fingerprints, both full runs and intermediate failures are recorded in +[native pull evidence](evidence/native-pulls-ci-20261005.json). New-head Linux +qualification remains required; this is not a green-CI or release claim. + +Parent `4eb4fd0` Linux PR Verify passes all 718 server library cases and fails +multi-server at 78 passed / 32 failed / 9 ignored. Its push run passes the same +libraries and fails at 77/33/9, with an additional bulk mirror failure. These +parent results do not qualify this new source. Merge-policy readers, line-thread +current-revision checks, generated merge/rebase/candidate writers, default-branch +mutation, peer residency, source-independent backup and reachable-only filtered +fetch remain open. Complete physical custody/recovery/final DDL and full +large-history/team capacity gates are still required. The full goal remains active. + +## Serving rollover and expiry observation (2026-10-05 checkpoint) + +Initial authenticated Cell selection used to consume the pool's two-second idle +release observation budget. A regression fills all four slots, queues the actual +Cell selection behind an admitted worker for 2.1 seconds, and then requests the +next generation. Before the fix it fails with repository serving-generation +capacity; after the fix it succeeds in both object formats and drains all pins. +The budget now begins at the first retirement observation and stays shared across +later retries. Its duration, generation cap and physical-drain requirements are +unchanged. This reproduces one cause of the earlier workload-dependent capacity +failure; it does not prove that every historical capacity failure had that cause. + +The bound-expiry test was calling `wait_terminal()`, which accepts intermediate +Bound as staging handoff. It now calls `wait_completion()` to observe the expiry +outcome and retains the Fenced, zero-admission and no-execution assertions. No +production expiry classification or authorization semantics were relaxed. +All 376 publication cases pass on the new frozen source. Full workspace tests +pass all 718 server library cases, including both previously failing cases and +the new Cell-contention regression. Multi-server remains at 77 passed / 29 +failed / 9 ignored; the three standalone aggregate fixtures also fail. The unique +workspace inventory is 833 passed / 32 failed / 9 unexecuted ignores. All-target +Clippy with warnings denied, server build, formatting, diff checks and all 96 +Python harness cases pass. Source fingerprints and complete results are in +[evidence/serving-selection-budget-ci-20261005.json](evidence/serving-selection-budget-ci-20261005.json). +Current-head Linux qualification remains required. + +Both `c86057c` Linux Verify runs pass all 717 server library tests and fail +multi-server at 78 passed / 32 failed / 9 ignored: the +[push run](https://github.com/crabbuild/canopy/actions/runs/37277708867) and +[PR run](https://github.com/crabbuild/canopy/actions/runs/37277713775). +Native commit checks, branch protection and bulk mirror cases pass there. +Three rejected-push option cases still fail in the full Linux workload despite +isolated local and earlier isolated Linux passes. Pull/ref product callers, +default-branch mutation, generated writers, recovery/backup and filtered/cache +expectations remain open. This follow-up is not a green-CI or release claim. + +## Native commit checks conversion (2026-10-05 checkpoint) + +Commit-check reads and starts were still querying the removed `objects` table, +returning HTTP 503 after a native push. They now use authenticated bounded +catalog headers under the actual resident serving snapshot. A private, +purpose-specific membership certificate reuses the existing MAC envelope, +serving token and retained joint fact. The final typed receivers verify actual +Cell identity, fresh read access, the exact live pin/fact, and the command's +admitted owner fence. Check policy/version and guarded insertion share one +transaction. Unrelated current-head advancement preserves a live historical pin; +actor, OID or repository substitution, tampering, revocation and real producer +drain invalidate its authority. Existing check metadata and ordering are reused. +No native pack-body hydration is needed for membership issuance. + +Production command 49 and query 50 have 4 KiB input bounds, a 16-byte command +outcome bound and a 256 KiB / 32-context query bound. Recorded policy rejections +retain their original receipts and produce the existing HTTP domain statuses; +pending/transport failures remain errors. The stock-Git HTTP checks family and +branch-protection family pass. Two new actual-resident receiver families cover +both object formats, forged/substituted proofs, noncommit targets, another Cell, +retained generations, current policy, revocation, visibility and physical drain. + +Frozen-source full-workspace validation still fails: server libraries finish +715 passed / 2 failed; multi-server finishes 77 passed / 29 failed / 9 existing +ignores; the three standalone integration aggregates each fail. The two library +failures are sequential serving rollover capacity and bound-expiry terminal-state +classification. Both pass isolated from the same compiled source, which does not +establish their cause or supersede the full-workload failure. The unique workspace +inventory is 830 passed / 34 failed / 9 unexecuted ignores; child summaries and +focused reruns are excluded. All-target Clippy with warnings denied, server build, +formatting, diff checks and all 96 Python harness cases pass. +The complete validation and build/format/harness results are recorded in +[evidence/native-checks-ci-20261005.json](evidence/native-checks-ci-20261005.json). + +The parent `3e40f19` Linux push Verify run +[37272804247](https://github.com/crabbuild/canopy/actions/runs/37272804247) +fails multi-server at 75/35/9; its PR run +[37272808437](https://github.com/crabbuild/canopy/actions/runs/37272808437) +fails at 76/34/9. Both pass all 715 server library cases. Reported tools are +Rust/Cargo 1.98.1 and Git 2.55.0. Those results belong to that parent, not this +increment. New-head Linux qualification is required. + +Highest priorities are the two workload-dependent library failures, Linux native +bulk/rejected-push custody and pre-Bind terminal refusal, then native pull/ref +creation/list/detail/reviews and default-branch mutation. Generated writers, peer +residency, source-independent backup, reachable-only cold/filtered fetch, +standalone resident fixtures, physical custody completion, final DDL and full +large-history/team capacity gates remain open. The full goal is active and this +cutover remains unqualified for release. + +## Native HTTP/SSH writer cutover (in progress) + +The actual receive-pack path now transfers its authenticated encoded request to +its resident staging controller before waiting. It registers original request +custody, runs native Git in a disposable cache, registers the exact native result +and creating pack descriptors, verifies native metadata, binds a publication +floor, and constructs the certified catalog/ref replacement. Byte-bounded policy +pages advance by their exact minted offsets. Every page and final outcome uses +registered exact recovery; ambiguous dispatch never becomes a new success or +refusal. Success responses stream only after authorized completed-root selection. +The legacy whole-push mutex and SQL object/push-response writer are removed from +this path. Generated candidate writers remain outstanding. + +Signed certificate bytes remain in immutable audit artifacts; the verified +request-private loose certificate blob is removed under the native worker fence +before capturing incoming packs. Authenticated empty native pack/index pairs are +excluded from the nonempty catalog source inventory. Ref-only/delete-only pushes +still carry their registered request/result checkpoint through final publication. +Audit option reads follow the authorized completed outcome root instead of the +retired SQL payload. The default-branch GET uses the certified ref snapshot; +its mutation still requires conversion to joint publication. + +Node shutdown seals staging admission and gives already-owned receive workflows +30 seconds to finish while their Cell, serving generations and native admission +remain available. Forced close after that grace cancels the controller and joins +its physical work and exact recovery before releasing the lower services. Generic +callback producers preserve their cancel-and-drain contract. The provider-fault +fixtures now pause native staging uploads; cold preparation failure is armed only +after SSH discovery, so the test reaches an actual admitted push. + +Current diagnostic qualification confirms signed pushes/audit restore, SHA-1 and +SHA-256 history/push/clone/fetch/restore, the 4,096-ref mirror and oversized exact +rejection replay, cold SSH preparation refusal, and disconnected SSH publication +through shutdown. A late Write-to-Read revocation still fences staging before a +completed per-ref refusal exists. Refs remain unchanged, but the transport closes. +This remains a failing correctness/UX gate; authorization is not weakened to hide +it. Full-workspace, Linux/RustFS and final-source evidence remain release gates. +Request/result/policy detached physical pins, authenticated older-owner adoption, +remaining product metadata writers/readers, backup and capacity qualification are +still required. No large-team throughput or release claim follows from this slice. + +Final frozen-source validation passes all 729 library cases (6 Git-format, +15 object-storage and 708 server), 13 directory cases, two Git backend cases and +two binary cases. Multi-server completes with 74 passes, 32 failures and nine +existing ignores. The three standalone aggregate integrations each fail. Isolated +RustFS passes SHA-256 push/clone/restore and then fails its merge-candidate gate; +remaining provider cases are unexecuted. Combined unique inventory is 821 passes, +36 failures and seven unexecuted ignores, including the two provider executions +without double-counting their ordinary ignored listings. All-target Clippy, +server build, formatting/diff checks and 96 Python harness cases pass. +These results do not qualify the full workflow or Linux. Exact fingerprints and +remaining priorities are in [native writer evidence](evidence/native-writer-ci-20261005.json). + +## Serving generation rollover and CI repair (2026-10-05) + +A sequential reader could exhaust the four-generation serving pool even after +all old snapshots were dropped. The pool started one idle owner's release and +immediately returned capacity. It now observes independently owned retirement +outside the pool lock and retries current authorized selection within a shared +two-second rollover budget and a slot-count retry limit. Closing owners remain +charged and cannot accept new borrows. Four borrowed generations still refuse +capacity immediately; timeout, observer cancellation and lost acknowledgement +preserve the exact release and its retained slot/pin. Limits are unchanged. + +Three new pool families cover twelve sequential generations, concurrent waiters +with cancellation/lost acknowledgement, and timed-out release followed by retry, +in both object formats. All nine pool families pass. The complete frozen-source +workspace passes 732 library cases (6 Git-format, 15 object-storage and 711 server), +13 directory cases, two Git backend cases and two binary cases. Multi-server +finishes with 75 passes, 31 failures and nine existing ignores. The stock SSH +clone/push/fetch/revocation family now passes; its previous serving-generation +capacity failure is resolved. The three standalone aggregates still fail. +Isolated RustFS passes SHA-256 push/clone/restore and fails native merge candidates; +its later six cases remain unexecuted. Combined unique inventory is 825 passes, +35 failures and seven unexecuted ignores. Clippy with warnings denied, server +build, formatting/diff checks and 96 harness cases pass. + +Both Linux Verify runs at previous head `64481d15b6d1f210006d137ca481b8b95abcb721` +pass 708 server library cases but fail multi-server with 75 passes, 35 failures +and nine ignores. In addition to the macOS failures, three push-option rejection +families fail with a transport error or missing exact reason. Those logs are +captured; they are not current-source Linux qualification. New PR/push CI must +run after this increment. The [rollover evidence](evidence/serving-rollover-ci-20261005.json) +separates source digests, preserved reproduction, local results and previous-head +Linux failures. The [final publication contract](design/final-publication-lifecycle.md#open-gate-refusal-after-pre-bind-write-revocation) +defines the pending refusal-only authority boundary without weakening Write for +admission or publication. Full CI, generated metadata/writers, backup/routing, +physical ownership/recovery and large-team capacity remain open. + +## Push failure observation and admission contention (2026-10-05) + +A controller failure before Bind previously became `Stopped`, so receive-pack +reported `MalformedCache` instead of the actual failure. After Bind, the same +path reported the historical `Bound` state and could strand a completion +observer. Failed controllers now finish as `Fenced` with a diagnostic bounded to +4,096 UTF-8 bytes and eight error sources. The original Bind receipt remains +available as historical evidence. Clean stop behavior is unchanged. + +Errors can contain rejected preparation values that retain physical worker +credits. The controller records stop before dropping those values outside its +local lock; only the bounded diagnostic survives. Regression coverage uses a +real resident, observer loss/rejoin, both object formats, both Bind phases, +ordinary failures and an error carrying an actual worker pin. Retaining the +whole error reproduced a drain timeout; the bounded representation removes that +ownership cycle. Existing cancellation, panic and exact uncertain-Begin recovery +families remain required checks. + +Known completed HTTP/SSH responses now use the retained publication ticket's +existing response API, including its completion receipt as the query watermark +and a fresh read-authorization check. Initial request replay remains a separate +authorized lookup. Neither diagnostics nor receipts grant artifact authority. + +The diagnostic exposed a bulk-mirror failure during final publication admission. +Synchronous admission classified a busy mutex as `Capacity`; receive-pack treated +that refusal as terminal. This can happen when a completed page wakes its +observer before the coordinator releases its mutex. Contention now has a +separate `Busy` classification. Native final commands and policy pages wait for +the fair mutex under their existing custody ceiling, retaining the original +prepared command and registered recovery. After the wait, the lifecycle checks +current custody and captures the held ticket synchronously under the admission +guard. Waiting reserves no quota and dispatches no command. Operation, account +and byte quota refusals remain immediate; no limit is increased or retried. + +A deterministic regression polls a real bound publication while the mutex is +held, then verifies the original command completes after release. Separate cases +verify that quota exhaustion refuses without execution and that a custody +ceiling ends the wait without admission. All three families pass for SHA-1 and +SHA-256. The stock Git bulk mirror and HTTP push-option group also pass locally. + +Linux Verify at head `79e2a3c7c8f6f094d4056ba1a106511619b2a351` passes all 711 +server library cases and the stock SSH workflow, but multi-server still fails +with 75 passes, 35 failures and nine ignores. Three rejected-option families +continue to fail on GitHub; a bulk-mirror failure also appeared under load. +Isolated Linux ARM64 option groups pass with Rust 1.97 and 1.98, with and without +warning-level logging. These observations do not establish the cause of those +GitHub option failures or qualify the complete CI workflow. Verify now prints +Rust, Cargo, active-toolchain and Git versions without changing toolchain choice. + +The final frozen-source macOS run passes all 736 library cases (715 server), +13 directory cases, two Git backend cases and two binary cases. Multi-server +finishes with 75 passes, 31 failures and nine existing ignores; bulk mirror, +HTTP/SSH options and stock SSH pass under concurrent load. The three standalone +integration aggregates still fail. All-target Clippy with warnings denied, +formatting and the server build pass. The unique workspace inventory is 828 +passes, 34 failures and nine unexecuted ignores; focused reruns and nested child +summaries are not counted again. No final-source RustFS claim is made. + +Exact source/log fingerprints, both controller-failure reproductions, the +contended-admission reproduction, intermediate failed validation and final +validation are retained in +[`push-admission-ci-20261005.json`](evidence/push-admission-ci-20261005.json). +Current-head Linux CI remains required. Native collaboration metadata and +candidate writers, pre-Bind refusal semantics, peer routing, backup/filtered +reads and actual standalone resident fixtures remain the highest release gates. +The complete storage/team-capacity goal remains open. + +## Whole-workflow ownership and CI repair + +The production resident supplies its actual Cell client and publication +dispatcher to staging. A resident can prepare a request through the existing +registered custody factory and join an admitted request only when repository, +actor, operation and request digest match, including before Begin yields a token. +Closed or paused admission refuses new request preparation. + +Each existing staging job can own one workflow controller. Its callback orders +the staged/bound worker slots, retrieves their private outputs, and then orders +checkpoint registration, Bind, policy pages and final publication. The controller +does not consume a physical-worker slot, so its own existence cannot block Bind +or held publication. Actual physical work must use the existing worker APIs. +No additional durable queue, schema or operation inventory is introduced. + +Dropping a request observer leaves that controller owned by the existing job. +Stop and shutdown cancel and join its actual callback. Concurrent drains clone +one shared join rather than taking a handle away from another drain. Final +publication and fencing join the controller before removing operation credit. +Native/SQL/provider work detached from a worker retains its existing physical +activity and still blocks lower serving/publication, Cell, heartbeat and workspace +release. A callback error or panic stops the workflow without replacing any +uncertain exact command with a native refusal. + +Regression families exercise actual production residents in both object formats: +observer loss through Bind, duplicate controllers and mismatched join contexts, +busy eviction, callback cleanup before shutdown, detached physical work through +shutdown, concurrent drains of uncertain Begin after absent/lost/panicked replies, +the original command identities and admission credits, and controller panic. +These qualify controller ownership, not completed HTTP/SSH/generated writer wiring. + +The directory recovery test previously entered a removed object-ingestion command. +It now grants Write in one repository and leaves the other ungranted, restores +both from durable Cell storage, replays the original grant receipt, and uses the +same request ID to grant Read in the other Cell without changing the first grant. +This preserves the directory test's distinct-Cell, permissions and recovery +contract. Packed object publication and cold object restoration remain covered +by the native publication qualification; their failures are not skipped. + +SDK-expiry fixtures share one helper that rechecks wall-clock milliseconds after +every Tokio timer wake. The SDK uses wall time for expiry, so a single monotonic +sleep does not establish that boundary. Prepared identities, deadlines and receipt +assertions remain unchanged. Accepted-denial fixtures give initial execution ten +seconds, matching the existing cold acceptance fixtures, and still require real +SDK expiry before historical recovery. Intentionally unexecuted fixtures retain +their short window. The full run exposed a Begin denial whose one-second SDK +identity expired before initial acceptance; its Pending evidence is retained. +The cold expiry assertion reports the actual resolution, object format, custody +kind and original deadline. Earlier failed source fingerprints and terminal logs +remain attributed separately from repaired runs. + +Preceding library runs exposed unlabelled timeouts in two serving renewal tests +using one-second leases. They now use a shared five-second test lease and hold +actual borrowers for 7.5 seconds, still crossing the original expiry and requiring +multiple renewals, unchanged pin identity and release after the last borrower. +Production profiles and expiration-only cases remain unchanged. The waits report +object format, phase and owner state. Earlier timed-out runs remain failed evidence; +any recurrent failure must be investigated rather than counted as successful. + +The broader pre-controller diagnostic inventory reached previously unrun tests: +Git backend 2 passed; multi-server 46 passed, 60 failed and 9 ignored; owner restart, +repository Cell and smart HTTP each failed their one aggregate case. This is not +final-source qualification. Actual HTTP pushes still call `persist_objects` and +legacy push completion, encountering removed `objects`/`git_packs` tables or +unregistered ingestion descriptors. Some standalone fixtures also lack a registered +resident serving capability. The full workflow remains a release gate. + +Final qualification of the repaired source passes 745 unique Rust cases: 728 +library cases (6 Git-format, 15 object-storage and 707 server), 13 directory cases, +2 Git backend cases and 2 binary cases. All 31 focused ownership, custody, cursor, +reachability and renewal regressions pass. Clippy with warnings denied, build, +formatting/diff checks and 96 Python harness cases pass. These results exclude +focused/binary reruns and nested child summaries from unique counts. + +The preceding frozen full workspace run executed 854 unique Rust cases: 790 +passed, 64 failed and 9 were ignored. Its 63 integration failures still require +production writer/fixture conversion; its additional serving-renewal timeout is +retained as failed evidence preceding the revised test profile. Final library/read +qualification is separate from that earlier complete inventory. Linux/RustFS and +full production integrations are still required before release. + +Current qualification and exact source/log fingerprints are recorded in +[workflow/CI evidence](evidence/push-workflow-ci-20261004.json). The immediately +required next step is to connect the actual native HTTP/SSH writer to this owned +controller and mandatory registered root policy/completion, then convert remaining +integration fixtures and run the complete Linux/RustFS workflow. Request/policy +physical ownership, adopted old-owner inputs and the remaining large-team design +requirements are still mandatory; this increment does not establish capacity. + +## Resident staging ownership + +The production resident now constructs one `StagingCoordinator` alongside its +serving pool and publication coordinator. Both capabilities are attached under +the existing constructor/shutdown barrier; `RepositoryCell` holds weak references. +A retained or detached caller cannot invent an unregistered resident service. +Remote repository routes still require owner-aware write forwarding. + +All resident coordinators share node admission for 32 operations and 64 physical +workers, with half-node account shares and the existing repository/account caps. +These are admission defaults, not measured capacity. The implementation reuses +`AccountAdmission`, its bounded weak account map, semaphore permits and the +existing staging activity owner. Operation credit survives exact uncertainty +and is returned on terminal removal even if a caller retains its old observer. +Worker credit stays with the last physical activity, including queued jobs after +an async result is transferred. Rejected admission returns the original prepared +request. No additional durable queue, SQL schema or operation inventory is added. + +Idle eviction pauses staging admission through a reversible guard before +quiescing serving. Busy staging prevents eviction. A serving refusal or canceled +pause releases the guard; a committed eviction closes admission. Shutdown closes +all inventoried staging services first and drains them concurrently while their +serving/publication resources remain available. Each service resolves its exact +uncertain originals and waits for the read-only retirement owner to exit before +releasing the lower services, Cell ownership, heartbeat or node workspace. +Late constructors are refused by the same registration barrier and drained. + +Regression coverage exercises both object formats: production eviction and +shutdown with detached physical work, shared cross-repository account capacity, +retry of the same refused request, busy-serving admission restoration, idempotent +closed eviction, exact staging recovery while another repository remains busy, +and physical/operation credits after observer cancellation or retained terminal +observers. Accepted-history expiry fixtures give initial command execution ten +seconds under loaded CI and still wait for actual SDK expiry before recovery. +Unexecuted expiry fixtures retain their one-second window. + +This increment supplies the resident write lifecycle; live HTTP/SSH/generated +writers and their integration fixtures still require conversion to it. The full +CI and release/capacity gates remain open. The preceding resident-only increment's +frozen-source qualification passes 725 library cases, including 704 +server cases and 403 publication cases. All eight focused regressions, Clippy +with warnings denied, build, formatting/diff checks and 96 Python harness cases +pass. The workspace command executes 740 unique Rust cases: 739 pass and the +retired-ingestion directory case fails; later integrations/doctests are unrun. +See [resident staging evidence](evidence/resident-staging-20261004.json) for exact +commands, source/log fingerprints, preserved earlier failures and remaining gates. + +## Bounded native metadata preparation + +`PhysicalVerifier::stage_metadata` now consumes a live admitted verifier and +uploads/releases one metadata shard at a time. Defaults admit at most 8,192 +objects per shard and 64 MiB of descriptor replay; the existing physical metadata +file and edge ceilings still apply. Native inspection uses pages of at most 512 +objects. A large structural object or exhausted file/disk limit fails preparation +rather than escaping admission. These limits are configurable runtime inputs, +not measured large-history throughput or fleet configuration. + +The private replay uses the existing `SourceRecord` codec, with a maximum 512-byte +record and four-byte framing, inside one `AdmittedFile`. Append reserves bytes +before writes; reading retains one record. Ordinal order is preserved explicitly: +the source index sorts by incarnation/digest and cannot serve as ordinal replay. +The result owns admitted descriptor storage and a complete physical witness; +it retains neither SQLite metadata connections nor creating worker activity. +Creating can therefore drain before Bind without collecting every shard in a Vec. + +`CatalogPreparation::new_staged` checks the bound context and carries worker +admission through queued closure/directory operations and immutable output +uploads. `add_staged_pack` authenticates retained native checkpoint membership, +reopens/hash-checks one uploaded shard at a time and reuses its stored descriptor +without uploading it twice. Exact witness partition, native artifact bindings, +typed closure and the final fenced publication gates remain mandatory. A failed +or canceled replay poisons the builder. The finished private catalog does not +retain the worker activity that publication must drain. + +Artifact uploads/downloads now offer owned entry points. Source hashing, +download hashing and destination-part validation retain the physical owner in +each blocking job. Metadata transfers pass their existing pinned file/spool; +native downloads pass their existing read owner. This closes the lower hash-job +cancellation gap as well as owning the SQL/file work. Other unconverted request +and policy paths still need their caller ownership integration. + +Final frozen-source qualification passes all 719 unique library cases (6 +Git-format, 15 object-storage, 698 server), including all 401 publication cases. +All six focused server cases and the artifact hash ownership case pass. The full +workspace command passes the two binary cases and 12 directory cases, then fails +the retired-ingestion directory case: 734 unique Rust cases executed, 733 pass, +one fails. Focused/binary reruns and nested subprocess summaries are excluded. +All-target workspace Clippy with warnings denied, build, formatting/diff checks +and 96 Python harness cases pass. Source remains unchanged across the main merge, +which adds only documentation/gallery files. Exact commands, source/log +fingerprints, preceding CI results, draft diagnostics, scope and remaining gates +are recorded in [metadata replay evidence](evidence/native-metadata-replay-20261004.json). +Both preceding `d86f315` Linux CI runs pass 695 server library cases and then fail +at the same directory integration case. Live HTTP/SSH/generated +write wiring, resident staging service ownership, authenticated adopted-input +physical verification and mandatory joint-root completion remain open. Bound +assembly still reopens incoming metadata and runs incoming closure checks; no +claim is made that cold full-history work fits the current bound deadline or that +this establishes 10,000-engineer capacity. Final Linux/provider qualification, +cache sharing/hot-root progress and the rest of the hard-cutover goal remain +required. ## Staging physical worker ownership