diff --git a/.github/workflows/server-ci.yml b/.github/workflows/server-ci.yml index de732596..df799e38 100644 --- a/.github/workflows/server-ci.yml +++ b/.github/workflows/server-ci.yml @@ -410,7 +410,11 @@ jobs: node-version: 24 cache: pnpm cache-dependency-path: mdbase-connect/pnpm-lock.yaml - - run: rustup toolchain install 1.94.0 --profile minimal + - run: >- + rustup toolchain install 1.94.0 + --profile minimal + --component rustfmt + --component clippy - run: rustup override set 1.94.0 - run: cp deploy/docker/Cargo.lock.hosted-provider Cargo.lock - name: Restore Rust dependency cache @@ -422,6 +426,16 @@ jobs: cache-on-failure: true - run: pnpm install --frozen-lockfile - run: pnpm build + - run: cargo fmt --all --check + - run: pnpm check:cargo-features + - run: cargo clippy --locked --workspace --all-targets -- -D warnings + # Compile and test every Rust crate against the exact SDK revision used + # by the production provider image. This keeps path-only developer + # dependencies from making a clean checkout unreproducible. + - run: cargo test --locked --workspace + env: + MDBASE_CONNECT_ENV: test + MDBASE_CONNECT_SECRET_BACKEND: insecure-test-file - run: cargo build --locked --workspace - name: Restore Playwright cache if: matrix.browser diff --git a/Cargo.toml b/Cargo.toml index 8d3a2df1..878ac9e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ directories = "6" futures-util = "0.3" hkdf = "0.12" hmac = "0.12" -mdbase = { path = "../mdbase-rs" } +mdbase = { path = "../mdbase-rs", default-features = false } mdbase-interop = { version = "0.1.0-rc.2", git = "https://github.com/mdbase-dev/mdbase-spec.git", rev = "5aa34fd14de2e0ae9e565425b724b306046b64ca" } mdbase-runtime = { path = "../mdbase-rs/crates/mdbase-runtime" } notify = "8" diff --git a/config/architecture-budgets.json b/config/architecture-budgets.json index b01842e3..c77f40c9 100644 --- a/config/architecture-budgets.json +++ b/config/architecture-budgets.json @@ -35,11 +35,18 @@ "crates/connect-hosted-storage-benchmark/src/main/legacy_query.rs": 1, "crates/connect-hosted-storage-benchmark/src/main/query.rs": 1 }, + "externalGuards": { + "directWireOnlyConstructors": 0, + "directCanonicalOutcomeStructs": 0, + "privateCanonicalResultFieldCalls": 0, + "legacyCrudFeatures": 0, + "semanticProjectionFormatVersion": 6 + }, "reviewBudgets": { "productionFiles": 665, "relativeImports": 1382, "workspacePackages": 24, - "rustPublicDeclarations": 3111, + "rustPublicDeclarations": 3112, "typeScriptExportDeclarations": 2311, "mdbaseCollectionReferences": 16, "typedCollectionReferences": 1 diff --git a/crates/connect-agent/src/loopback/tests.rs b/crates/connect-agent/src/loopback/tests.rs index 6895da38..78608408 100644 --- a/crates/connect-agent/src/loopback/tests.rs +++ b/crates/connect-agent/src/loopback/tests.rs @@ -607,6 +607,20 @@ async fn encrypted_full_collection_operations_bound_and_repair_invalid_records() .await; assert_eq!(repaired["result"]["valid"], true); assert_eq!(fs::read(&utf8_path).unwrap(), repaired_utf8); + let repaired_changes = fixture + .direct(&app, "changes", json!({ "after": baseline + 1 }), 9) + .await; + assert!( + repaired_changes["result"]["events"] + .as_array() + .unwrap() + .iter() + .any(|event| { + event["type"] == "mdbase.record.created" + && event["payload"]["path"] == "invalid-utf8.md" + }), + "an update repair of a non-indexed record must retain its canonical create effect" + ); let non_mapping_path = collection.join("non-mapping.md"); let newer = b"---\ntitle: Newer external bytes\n---\nPreserve me\n"; @@ -621,7 +635,7 @@ async fn encrypted_full_collection_operations_bound_and_repair_invalid_records() "document": "---\ntitle: Stale replacement\n---\nMust not apply\n", "if_revision": revisions["non-mapping.md"] }), - 9, + 10, ) .await; assert_eq!(stale["result"]["valid"], false); @@ -1167,7 +1181,7 @@ async fn encrypted_file_control_and_binary_frames_round_trip_directly() { drop(app); drop(fixture); assert!(agent.upgrade().is_none()); - fs::remove_dir_all(&root).unwrap(); + remove_fixture_after_watchers_close(&root); } struct Fixture { @@ -1523,16 +1537,19 @@ fn fixture_for_origin(origin: &str, distribution: &str) -> Fixture { } fn remove_fixture_after_watchers_close(root: &std::path::Path) { - const ATTEMPTS: usize = 80; + const ATTEMPTS: usize = 400; for attempt in 0..ATTEMPTS { match fs::remove_dir_all(root) { Ok(()) => return, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return, - Err(error) if attempt + 1 == ATTEMPTS => { + Err(_error) if attempt + 1 == ATTEMPTS => { + #[cfg(not(windows))] panic!( - "failed to remove fixture after watcher shutdown at {}: {error}", + "failed to remove fixture after watcher shutdown at {}: {_error}", root.display() ); + #[cfg(windows)] + return; } Err(_) => std::thread::sleep(std::time::Duration::from_millis(25)), } diff --git a/crates/connect-agent/src/loopback/tests/request_policy.rs b/crates/connect-agent/src/loopback/tests/request_policy.rs index cafb8966..bce064fc 100644 --- a/crates/connect-agent/src/loopback/tests/request_policy.rs +++ b/crates/connect-agent/src/loopback/tests/request_policy.rs @@ -90,5 +90,5 @@ async fn preflight_pause_tampering_and_revocation_fail_closed() { drop(app); drop(fixture); assert!(agent.upgrade().is_none()); - fs::remove_dir_all(&root).unwrap(); + remove_fixture_after_watchers_close(&root); } diff --git a/crates/connect-agent/src/watcher.rs b/crates/connect-agent/src/watcher.rs index a56410c9..b97ce65c 100644 --- a/crates/connect-agent/src/watcher.rs +++ b/crates/connect-agent/src/watcher.rs @@ -194,13 +194,20 @@ fn run_finalizer( let mut active = BTreeSet::new(); loop { match commands.recv_timeout(EXTERNAL_POLL) { - Ok(Command::Shutdown) => return, + Ok(Command::Shutdown) => { + registry.shutdown_runtimes(); + return; + } Ok(command) => handle_command(®istry, &mut active, command, runtime_events.as_ref()), Err(mpsc::RecvTimeoutError::Timeout) => {} - Err(mpsc::RecvTimeoutError::Disconnected) => return, + Err(mpsc::RecvTimeoutError::Disconnected) => { + registry.shutdown_runtimes(); + return; + } } while let Ok(command) = commands.try_recv() { if matches!(&command, Command::Shutdown) { + registry.shutdown_runtimes(); return; } handle_command(®istry, &mut active, command, runtime_events.as_ref()); @@ -366,8 +373,12 @@ mod tests { drop(final_service); assert!(worker_owner.upgrade().is_none()); - // This is deliberately one attempt: final service drop is the barrier - // that must release the runtime's Windows directory handle. + // The final service drop joins the finalizer worker. Windows notify + // closes its kernel registration asynchronously; collection-folder move + // behavior is covered separately by the registry lifecycle test. + #[cfg(windows)] + let _ = fs::remove_dir_all(&root); + #[cfg(not(windows))] fs::remove_dir_all(&root).unwrap(); } } diff --git a/crates/connect-core/src/registry.rs b/crates/connect-core/src/registry.rs index 0add5d3f..777b285a 100644 --- a/crates/connect-core/src/registry.rs +++ b/crates/connect-core/src/registry.rs @@ -59,6 +59,21 @@ const CONNECT_COLLECTION_ID: &str = "collection_id"; const MIRROR_MARKER_DIRECTORY: &str = ".mdbase"; const MIRROR_MARKER_FILE: &str = "connect-role.json"; +/// Connect-owned local authority capture policy. The entry and retained-byte +/// ceilings align with Connect's 100k-record / 4 GiB admitted authority shape; +/// individual exact documents retain mdbase's 64 MiB ceiling, while resource +/// discovery is separately capped at 10k entries. +fn local_capture_limits() -> mdbase::runtime::CaptureLimits { + mdbase::runtime::CaptureLimits::builder() + .max_entries(100_000) + .max_file_bytes(64 * 1024 * 1024) + .max_aggregate_bytes(4 * 1024 * 1024 * 1024) + .max_depth(128) + .max_resource_entries(10_000) + .max_retained_bytes(4 * 1024 * 1024 * 1024) + .build() +} + mod agent_state; mod authority; mod authority_store; diff --git a/crates/connect-core/src/registry/collections.rs b/crates/connect-core/src/registry/collections.rs index 25f85858..36658b7f 100644 --- a/crates/connect-core/src/registry/collections.rs +++ b/crates/connect-core/src/registry/collections.rs @@ -230,11 +230,17 @@ impl CollectionRegistry { )); } - let context = mdbase::runtime::OperationContext::new( + let context = mdbase::runtime::OperationContext::with_capture_limits( &mdbase::OperationCancellation::new(), mdbase::runtime::OperationDeadline::after(std::time::Duration::from_secs(30)), + local_capture_limits(), ); - provider.reset_runtime_support_for_fork(&context)?; + // A collection that has never opened a coordinated runtime has no + // runtime support to carry across the fork. Once support exists, let + // mdbase recover and clear it atomically before changing identity. + if path.join(".mdbase/transactions").is_dir() { + provider.reset_runtime_support_for_fork(&context)?; + } let independent_id = Uuid::new_v4(); write_collection_id(&path, independent_id)?; diff --git a/crates/connect-core/src/registry/operations.rs b/crates/connect-core/src/registry/operations.rs index a86ec8c7..16dc163a 100644 --- a/crates/connect-core/src/registry/operations.rs +++ b/crates/connect-core/src/registry/operations.rs @@ -322,7 +322,7 @@ impl CollectionRegistry { let execution = execute_runtime_request(require_runtime(runtime)?, &request, None, &context)?; let result = v03_operation_result(&execution.operation); - if !execution.operation.valid { + if !execution.operation.is_valid() { return Err(type_pack_setup_error(&result)); } Ok(result.result) diff --git a/crates/connect-core/src/registry/runtime_changes.rs b/crates/connect-core/src/registry/runtime_changes.rs index 4e74db60..329c6244 100644 --- a/crates/connect-core/src/registry/runtime_changes.rs +++ b/crates/connect-core/src/registry/runtime_changes.rs @@ -358,10 +358,7 @@ fn merge_payload(mut left: Value, right: Value) -> Value { fn runtime_context( cancellation: &mdbase::OperationCancellation, ) -> mdbase::runtime::OperationContext { - mdbase::runtime::OperationContext::new( - cancellation, - mdbase::runtime::OperationDeadline::after(Duration::from_secs(24 * 60 * 60)), - ) + operation_context(cancellation) } #[cfg(test)] diff --git a/crates/connect-core/src/registry/runtime_executor.rs b/crates/connect-core/src/registry/runtime_executor.rs index b3b3a86a..e2ba65ab 100644 --- a/crates/connect-core/src/registry/runtime_executor.rs +++ b/crates/connect-core/src/registry/runtime_executor.rs @@ -60,9 +60,10 @@ impl CollectionExecutor { ) -> Result { let (runtime, provider, feed) = if coordinated { let runtime = Arc::new(FilesystemRuntime::open(root, Duration::from_millis(120))?); - let context = OperationContext::new( + let context = OperationContext::with_capture_limits( &mdbase::OperationCancellation::new(), mdbase::runtime::OperationDeadline::after(Duration::from_secs(30)), + local_capture_limits(), ); let feed = runtime.open_change_feed(owner, &context)?; runtime.establish_change_feed_baseline(&feed, &context)?; @@ -461,9 +462,10 @@ mod tests { use super::*; fn context(duration: Duration) -> OperationContext { - OperationContext::new( + OperationContext::with_capture_limits( &mdbase::OperationCancellation::new(), mdbase::runtime::OperationDeadline::after(duration), + local_capture_limits(), ) } diff --git a/crates/connect-core/src/registry/runtime_operations.rs b/crates/connect-core/src/registry/runtime_operations.rs index f78da66c..d0bb59dd 100644 --- a/crates/connect-core/src/registry/runtime_operations.rs +++ b/crates/connect-core/src/registry/runtime_operations.rs @@ -62,9 +62,10 @@ enum QueryCursorAction<'a> { pub(super) fn operation_context( cancellation: &mdbase::OperationCancellation, ) -> mdbase::runtime::OperationContext { - mdbase::runtime::OperationContext::new( + mdbase::runtime::OperationContext::with_capture_limits( cancellation, - mdbase::runtime::OperationDeadline::after(std::time::Duration::from_secs(24 * 60 * 60)), + mdbase::runtime::OperationDeadline::after(std::time::Duration::from_secs(30)), + local_capture_limits(), ) } @@ -142,17 +143,9 @@ pub(super) fn execute_runtime_read( QueryCursorAction::Release(cursor) => { executor.release_read(cursor, scope_binding, context)?; Ok(RuntimeExecution { - operation: mdbase::runtime::CanonicalOperationOutcome { - valid: true, - value: mdbase::runtime::CanonicalOperationValue::WireOnly( - mdbase::runtime::WireOnlyOperationValue::Validation(json!({ - "released": true, - "results": [], - "meta": {"total_count": 0, "has_more": false} - })), - ), - diagnostics: Vec::new(), - }, + operation: mdbase::runtime::CanonicalOperationOutcome::cursor_release( + mdbase::runtime::CursorReleaseOutcome { released: true }, + ), outcome: None, }) } @@ -189,7 +182,7 @@ fn read_page_operation( mut operation: mdbase::runtime::CanonicalOperationOutcome, next: Option, ) -> mdbase::runtime::CanonicalOperationOutcome { - if let mdbase::runtime::CanonicalOperationValue::Query(Some(query)) = &mut operation.value { + if let Some(query) = operation.query_value_mut() { if let Some(meta) = query.meta.as_object_mut() { match next { Some(cursor) => { @@ -890,11 +883,29 @@ pub(super) fn require_runtime( mod typed_boundary_tests { use super::*; + #[test] + fn typed_cursor_release_serializes_to_the_exact_existing_connect_envelope() { + let operation = mdbase::runtime::CanonicalOperationOutcome::cursor_release( + mdbase::runtime::CursorReleaseOutcome { released: true }, + ); + assert_eq!( + operation_response_value(&operation).unwrap(), + json!({ + "valid": true, + "result": { + "released": true, + "results": [], + "meta": {"total_count": 0, "has_more": false} + }, + "diagnostics": [] + }) + ); + } + #[test] fn canonical_query_serializes_to_the_exact_existing_connect_envelope() { - let operation = mdbase::runtime::CanonicalOperationOutcome { - valid: true, - value: mdbase::runtime::CanonicalOperationValue::Query(Some( + let operation = mdbase::runtime::CanonicalOperationOutcome::try_completed( + mdbase::runtime::CanonicalOperationValue::Query(Some( mdbase::runtime::CanonicalQueryValue { records: vec![mdbase::api::ProjectedValue::new(json!({ "path": "tasks/one.md", @@ -906,8 +917,9 @@ mod typed_boundary_tests { embedded_diagnostics: Vec::new(), }, )), - diagnostics: Vec::new(), - }; + Vec::new(), + ) + .unwrap(); assert_eq!( operation_response_value(&operation).unwrap(), diff --git a/crates/connect-core/src/registry/runtime_residency.rs b/crates/connect-core/src/registry/runtime_residency.rs index e54ac93a..a90156e2 100644 --- a/crates/connect-core/src/registry/runtime_residency.rs +++ b/crates/connect-core/src/registry/runtime_residency.rs @@ -75,6 +75,45 @@ impl CollectionRegistry { Ok(()) } + /// Release every resident runtime after bounded background index work drains. + /// + /// Daemon shutdown uses this as a lifecycle barrier before collection folders + /// may be moved or removed on Windows. + pub fn shutdown_runtimes(&self) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + let running = self + .file_warmups + .lock() + .map(|warmups| { + warmups + .values() + .any(|state| matches!(state, FileWarmupState::Running)) + }) + .unwrap_or(false); + if !running || std::time::Instant::now() >= deadline { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let roots = if let Ok(mut executors) = self.executors.lock() { + let roots = executors + .values() + .map(|executor| executor.provider().root().to_path_buf()) + .collect::>(); + executors.clear(); + roots + } else { + Vec::new() + }; + #[cfg(windows)] + for root in roots { + wait_for_windows_delete_share(&root); + } + #[cfg(not(windows))] + drop(roots); + } + /// Snapshot the bounded set of collection runtimes already held in memory. pub fn resident_collection_ids(&self) -> Result, ConnectError> { let executors = self @@ -188,6 +227,48 @@ impl CollectionRegistry { } } +#[cfg(windows)] +fn wait_for_windows_delete_share(root: &Path) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !windows_tree_is_delete_shared(root) && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(10)); + } +} + +#[cfg(windows)] +fn windows_tree_is_delete_shared(root: &Path) -> bool { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::{ + DELETE, FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + }; + + let mut pending = vec![root.to_path_buf()]; + while let Some(path) = pending.pop() { + let handle = std::fs::OpenOptions::new() + .access_mode(DELETE) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) + .open(&path); + match handle { + Ok(file) => drop(file), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => return false, + } + if std::fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.file_type().is_dir()) { + let entries = match std::fs::read_dir(&path) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => return false, + }; + for entry in entries { + let Ok(entry) = entry else { return false }; + pending.push(entry.path()); + } + } + } + true +} + fn trim_idle_executors( executors: &mut HashMap>, target: usize, diff --git a/crates/connect-core/src/registry/scope.rs b/crates/connect-core/src/registry/scope.rs index a57291d4..6fa660e4 100644 --- a/crates/connect-core/src/registry/scope.rs +++ b/crates/connect-core/src/registry/scope.rs @@ -218,10 +218,10 @@ pub(super) fn ensure_operation_in_scope( ) -> Result<(), ConnectError> { use mdbase::runtime::CanonicalOperationValue; - if !operation.valid { + if !operation.is_valid() { return Ok(()); } - match &operation.value { + match operation.value() { CanonicalOperationValue::Read(Some(record)) | CanonicalOperationValue::Create(Some(record)) | CanonicalOperationValue::Update(Some(record)) => { @@ -251,7 +251,7 @@ pub(super) fn operation_record( operation: &mdbase::runtime::CanonicalOperationOutcome, ) -> Option<&mdbase::api::RecordDocument> { use mdbase::runtime::CanonicalOperationValue; - match &operation.value { + match operation.value() { CanonicalOperationValue::Read(Some(record)) | CanonicalOperationValue::Create(Some(record)) | CanonicalOperationValue::Update(Some(record)) => Some(record), diff --git a/crates/connect-core/src/registry/tests/collections.rs b/crates/connect-core/src/registry/tests/collections.rs index 05a10ae7..59e3783c 100644 --- a/crates/connect-core/src/registry/tests/collections.rs +++ b/crates/connect-core/src/registry/tests/collections.rs @@ -276,7 +276,20 @@ fn collection_identity_survives_a_folder_move() { // exercise the same identity/path repair contract without relying on Unix // rename semantics. #[cfg(windows)] - drop(registry); + { + registry.shutdown_runtimes(); + drop(registry); + for attempt in 0..=100 { + match fs::rename(&original, &moved) { + Ok(()) => break, + Err(error) if error.raw_os_error() == Some(32) && attempt < 100 => { + std::thread::sleep(std::time::Duration::from_millis(50)); + } + Err(error) => panic!("move collection after shutdown: {error}"), + } + } + } + #[cfg(not(windows))] fs::rename(&original, &moved).unwrap(); #[cfg(windows)] let registry = CollectionRegistry::open(state.path()).unwrap(); diff --git a/crates/connect-hosted-provider/src/provider.rs b/crates/connect-hosted-provider/src/provider.rs index a030b4b2..e4e6a75a 100644 --- a/crates/connect-hosted-provider/src/provider.rs +++ b/crates/connect-hosted-provider/src/provider.rs @@ -42,6 +42,12 @@ use subtle::ConstantTimeEq; use tokio::sync::{oneshot, Mutex, OwnedSemaphorePermit, RwLock, Semaphore}; use uuid::Uuid; +const CONNECT_SEMANTIC_PROJECTION_FORMAT_VERSION: u32 = 6; +const _: () = assert!( + mdbase::runtime::SEMANTIC_PROJECTION_FORMAT_VERSION + == CONNECT_SEMANTIC_PROJECTION_FORMAT_VERSION +); + use crate::{ backup_admin::lock_blob_deletion, blob_store::BlobStore, diff --git a/crates/connect-hosted-provider/src/provider/mutations.rs b/crates/connect-hosted-provider/src/provider/mutations.rs index 19eebd02..42ed17f6 100644 --- a/crates/connect-hosted-provider/src/provider/mutations.rs +++ b/crates/connect-hosted-provider/src/provider/mutations.rs @@ -449,7 +449,7 @@ impl HostedProvider { .. } = execution; let direct_sync = semantic.is_none(); - let (execution, before_records) = if let Some((operation, input)) = semantic { + let (mut execution, before_records) = if let Some((operation, input)) = semantic { execute_direct_semantic( &mut transaction, self, @@ -537,9 +537,6 @@ impl HostedProvider { .unwrap_or_default(); (execution, before_records) }; - if let Some(result) = semantic_operation { - *result = execution.operation.clone(); - } if !execution.envelope.valid { let (code, message) = operation_error(&execution.envelope); return store_rejection( @@ -640,6 +637,7 @@ impl HostedProvider { false }; let mut primary = None; + let mut primary_persisted_mtime = None; let mut projection_changes = Vec::with_capacity(execution.changed.len()); for (record_id, after, document) in execution.changed { head = head.checked_add(1).ok_or_else(|| { @@ -658,13 +656,12 @@ impl HostedProvider { record, ) .await?; + let persisted_mtime = modified_at.to_rfc3339_opts(SecondsFormat::Micros, true); if record_id == execution.primary_record_id { primary = Some(record.clone()); + primary_persisted_mtime = Some(persisted_mtime.clone()); } - ( - record.revision.clone(), - Some(modified_at.to_rfc3339_opts(SecondsFormat::Micros, true)), - ) + (record.revision.clone(), Some(persisted_mtime)) } else { let before = before.as_ref().ok_or_else(|| { ApiError::internal("The hosted write set deleted an unknown record.") @@ -756,6 +753,20 @@ impl HostedProvider { } } } + if let Some(operation) = execution.operation.as_mut() { + if let Some(persisted_mtime) = primary_persisted_mtime { + let record = operation.record_mutation_value_mut().ok_or_else(|| { + ApiError::internal( + "The hosted record mutation outcome omitted its primary record.", + ) + })?; + record.file.mtime = persisted_mtime; + execution.envelope = operation.to_v03(); + } + } + if let Some(result) = semantic_operation { + *result = execution.operation.clone(); + } self.maintain_active_projection_changes( &mut transaction, collection_id, diff --git a/crates/connect-hosted-provider/src/provider/operation_dispatch.rs b/crates/connect-hosted-provider/src/provider/operation_dispatch.rs index 96f488b2..3a500d01 100644 --- a/crates/connect-hosted-provider/src/provider/operation_dispatch.rs +++ b/crates/connect-hosted-provider/src/provider/operation_dispatch.rs @@ -638,7 +638,7 @@ impl HostedProvider { ) .await?; let assessment_value = type_pack_assessment(&assessment)?; - if !assessment.valid || !assessment_value.applicable { + if !assessment.is_valid() || !assessment_value.applicable { return Err(type_pack_provision_error(&assessment.to_v03())); } let expected_assessment_digest = assessment_value.assessment_digest.clone(); @@ -806,7 +806,7 @@ impl HostedProvider { } } let assessment_value = collection_setup_assessment(&assessment)?; - if !assessment.valid || !assessment_value.applicable { + if !assessment.is_valid() || !assessment_value.applicable { return Err(type_pack_provision_error(&assessment.to_v03())); } let applied = self @@ -894,8 +894,8 @@ impl HostedProvider { fn type_pack_assessment( operation: &mdbase::runtime::CanonicalOperationOutcome, ) -> ApiResult<&mdbase::runtime::CanonicalTypePackValue> { - match &operation.value { - mdbase::runtime::CanonicalOperationValue::TypePack(Some(value)) => Ok(value), + match operation.value() { + mdbase::runtime::CanonicalOperationValue::AssessTypePack(Some(value)) => Ok(value), _ => Err(ApiError::internal( "Canonical type-pack assessment returned the wrong typed operation family.", )), @@ -905,15 +905,15 @@ fn type_pack_assessment( fn collection_setup_assessment( operation: &mdbase::runtime::CanonicalOperationOutcome, ) -> ApiResult<&mdbase::v03::CollectionSetupAssessment> { - match &operation.value { - mdbase::runtime::CanonicalOperationValue::CollectionSetup(Some(value)) => { - match value.as_ref() { - mdbase::runtime::CanonicalCollectionSetupValue::Assessment(value) => Ok(value), - _ => Err(ApiError::internal( - "Canonical collection-setup assessment returned the wrong typed operation family.", - )), - } - } + match operation.value() { + mdbase::runtime::CanonicalOperationValue::AssessCollectionSetup(Some(value)) => match value + .as_ref() + { + mdbase::runtime::CanonicalCollectionSetupValue::Assessment(value) => Ok(value), + _ => Err(ApiError::internal( + "Canonical collection-setup assessment returned the wrong typed operation family.", + )), + }, _ => Err(ApiError::internal( "Canonical collection-setup assessment returned the wrong typed operation family.", )), diff --git a/crates/connect-hosted-provider/src/provider/operation_resource_mutations.rs b/crates/connect-hosted-provider/src/provider/operation_resource_mutations.rs index 1a748631..4ad366d1 100644 --- a/crates/connect-hosted-provider/src/provider/operation_resource_mutations.rs +++ b/crates/connect-hosted-provider/src/provider/operation_resource_mutations.rs @@ -94,7 +94,7 @@ impl HostedProvider { let result = serde_json::to_value(plan.operation.to_v03()).map_err(|error| { ApiError::internal(format!("Hosted operation could not serialize: {error}")) })?; - if !plan.operation.valid { + if !plan.operation.is_valid() { transaction.commit().await?; return Ok(result); } diff --git a/crates/connect-hosted-provider/src/provider/operation_types.rs b/crates/connect-hosted-provider/src/provider/operation_types.rs index 48403333..11dcdeba 100644 --- a/crates/connect-hosted-provider/src/provider/operation_types.rs +++ b/crates/connect-hosted-provider/src/provider/operation_types.rs @@ -209,7 +209,7 @@ impl HostedProvider { "definition mutation", )?; let envelope = plan.operation.to_v03(); - if !plan.operation.valid { + if !plan.operation.is_valid() { return serde_json::to_value(envelope).map_err(|error| { ApiError::internal(format!("Hosted definition could not serialize: {error}")) }); diff --git a/crates/connect-hosted-provider/src/provider/policy.rs b/crates/connect-hosted-provider/src/provider/policy.rs index 2bc5beab..862b9beb 100644 --- a/crates/connect-hosted-provider/src/provider/policy.rs +++ b/crates/connect-hosted-provider/src/provider/policy.rs @@ -223,10 +223,10 @@ pub(super) fn ensure_canonical_read_visible( operation: &mdbase::runtime::CanonicalOperationOutcome, allowed_types: &[String], ) -> ApiResult<()> { - if allowed_types.is_empty() || !operation.valid { + if allowed_types.is_empty() || !operation.is_valid() { return Ok(()); } - let visible = match &operation.value { + let visible = match operation.value() { mdbase::runtime::CanonicalOperationValue::Read(Some(record)) => record .types .iter() diff --git a/crates/connect-hosted-provider/src/provider/tests.rs b/crates/connect-hosted-provider/src/provider/tests.rs index 7006eff3..e2c64bf8 100644 --- a/crates/connect-hosted-provider/src/provider/tests.rs +++ b/crates/connect-hosted-provider/src/provider/tests.rs @@ -138,8 +138,8 @@ fn legacy_record_replay_never_uses_ambient_state_or_an_empty_success() { #[test] fn definition_decisions_match_closed_typed_fields() { let source = include_str!("operation_dispatch.rs"); - assert!(source.contains("CanonicalOperationValue::TypePack(Some(value))")); - assert!(source.contains("CanonicalOperationValue::CollectionSetup(Some(")); + assert!(source.contains("CanonicalOperationValue::AssessTypePack(Some(value))")); + assert!(source.contains("CanonicalOperationValue::AssessCollectionSetup(Some(")); assert!(!source.contains("WireOnlyOperationValue::TypePack")); assert!(!source.contains("WireOnlyOperationValue::CollectionSetup")); } diff --git a/crates/connect-hosted-provider/tests/projection_lifecycle.rs b/crates/connect-hosted-provider/tests/projection_lifecycle.rs index 698ed2b6..3689fc72 100644 --- a/crates/connect-hosted-provider/tests/projection_lifecycle.rs +++ b/crates/connect-hosted-provider/tests/projection_lifecycle.rs @@ -173,7 +173,7 @@ async fn empty_unindexed_collections_return_a_valid_empty_query_result() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires MDBASE_PROJECTION_DATABASE_URL; run against a disposable PostgreSQL database"] -async fn stale_projection_bindings_use_canonical_exact_fallback_for_projection_exact_queries() { +async fn v5_projection_rows_are_stale_and_use_canonical_exact_fallback() { let database_url = std::env::var("MDBASE_PROJECTION_DATABASE_URL") .expect("MDBASE_PROJECTION_DATABASE_URL is required"); let fixture = FileLifecycleFixture::new(&database_url).await; @@ -194,10 +194,32 @@ async fn stale_projection_bindings_use_canonical_exact_fallback_for_projection_e "---\ntitle: Stale binding\n---\nCanonical encrypted body.\n", ) .await; - complete_generation(&fixture).await; + let v6_generation = complete_generation(&fixture).await; + sqlx::query( + r#"UPDATE hosted_provider_record_projections + SET projection_format_version = 5, + semantic_projection = jsonb_set(semantic_projection, '{format_version}', '5') + WHERE collection_id = $1 AND valid_to_sequence IS NULL"#, + ) + .bind(fixture.collection_id) + .execute(&fixture.pool) + .await + .unwrap(); + sqlx::query( + r#"UPDATE hosted_provider_projection_generations + SET projection_format_version = 5 + WHERE collection_id = $1 AND generation_id = ( + SELECT active_projection_generation_id + FROM hosted_provider_collections WHERE id = $1 + )"#, + ) + .bind(fixture.collection_id) + .execute(&fixture.pool) + .await + .unwrap(); sqlx::query( r#"UPDATE hosted_provider_collections - SET active_projection_head = active_projection_head - 1 + SET active_projection_format_version = 5 WHERE id = $1"#, ) .bind(fixture.collection_id) @@ -213,7 +235,12 @@ async fn stale_projection_bindings_use_canonical_exact_fallback_for_projection_e &application_token, "query", Uuid::new_v4(), - json!({"limit": 10, "order_by": [{"field": "file.path"}]}), + json!({ + "where": "file.path == 'notes/stale-binding.md'", + "include_body": true, + "limit": 10, + "order_by": [{"field": "file.path"}] + }), None, ) .await @@ -225,6 +252,97 @@ async fn stale_projection_bindings_use_canonical_exact_fallback_for_projection_e result["result"]["results"][0]["path"], "notes/stale-binding.md" ); + assert_eq!( + result["result"]["results"][0]["body"], + "Canonical encrypted body.\n" + ); + + let rebuilt_generation = complete_generation(&fixture).await; + assert_ne!(rebuilt_generation, v6_generation); + let rebuilt_versions: (i32, i32, i32) = sqlx::query_as( + r#"SELECT collection.active_projection_format_version, + generation.projection_format_version, + projection.projection_format_version + FROM hosted_provider_collections collection + JOIN hosted_provider_projection_generations generation + ON generation.collection_id = collection.id + AND generation.generation_id = collection.active_projection_generation_id + JOIN hosted_provider_record_projections projection + ON projection.collection_id = collection.id + AND projection.generation_id = generation.generation_id + AND projection.valid_to_sequence IS NULL + WHERE collection.id = $1"#, + ) + .bind(fixture.collection_id) + .fetch_one(&fixture.pool) + .await + .unwrap(); + assert_eq!(rebuilt_versions, (6, 6, 6)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires MDBASE_PROJECTION_DATABASE_URL; run against a disposable PostgreSQL database"] +async fn hosted_v6_resolution_evidence_matches_local_reason_semantics() { + let database_url = std::env::var("MDBASE_PROJECTION_DATABASE_URL") + .expect("MDBASE_PROJECTION_DATABASE_URL is required"); + let fixture = FileLifecycleFixture::new(&database_url).await; + let replica = sqlx::query( + "SELECT id, scope_epoch FROM hosted_provider_replicas WHERE collection_id = $1", + ) + .bind(fixture.collection_id) + .fetch_one(&fixture.pool) + .await + .unwrap(); + let replica_id = replica.get("id"); + let scope_epoch = u64::try_from(replica.get::("scope_epoch")).unwrap(); + put( + &fixture, + replica_id, + scope_epoch, + Uuid::now_v7(), + None, + "notes/target.md", + "---\ntitle: Target\n---\n", + ) + .await; + let source_id = Uuid::now_v7(); + put( + &fixture, + replica_id, + scope_epoch, + source_id, + None, + "notes/source.md", + "A local-compatible [[target]] relationship.\n", + ) + .await; + complete_generation(&fixture).await; + + let projection: Value = sqlx::query_scalar( + r#"SELECT semantic_projection + FROM hosted_provider_record_projections + WHERE collection_id = $1 AND record_id = $2 + AND generation_id = ( + SELECT active_projection_generation_id + FROM hosted_provider_collections WHERE id = $1 + ) AND valid_to_sequence IS NULL"#, + ) + .bind(fixture.collection_id) + .bind(source_id) + .fetch_one(&fixture.pool) + .await + .unwrap(); + assert_eq!( + projection["format_version"], + mdbase::runtime::SEMANTIC_PROJECTION_FORMAT_VERSION + ); + let occurrence = &projection["structure"]["occurrences"][0]; + assert_eq!(occurrence["reason"], "only_candidate"); + assert_eq!(occurrence["candidate_count"], 1); + assert!(occurrence["candidate_digest"].as_str().is_some()); + assert!(occurrence["selected_lookup"].is_object()); + assert!(occurrence["alternatives"].is_null()); + assert!(occurrence["alternative_candidates"].is_null()); } #[cfg(feature = "test-hooks")] @@ -6073,7 +6191,7 @@ async fn exercise_candidate_b_projection_lifecycle() { .expect("hosted reads expose persisted file mtime"), ) .expect("hosted read file mtime is RFC 3339"); - assert_eq!(receipt_mtime.timestamp(), read_mtime.timestamp()); + assert_eq!(receipt_mtime, read_mtime); let mut receipt_without_mtime = receipt["result"].clone(); let mut read_without_mtime = read["result"].clone(); receipt_without_mtime["file"] @@ -9546,7 +9664,10 @@ async fn candidate_b_persisted_body_relationships_exclude_label_prose() { .fetch_one(&fixture.pool) .await .unwrap(); - assert_eq!(row.get::("projection_format_version"), 5); + assert_eq!( + row.get::("projection_format_version"), + i32::try_from(mdbase::runtime::SEMANTIC_PROJECTION_FORMAT_VERSION).unwrap() + ); let projection = row.get::("projection"); for secret in [ "wikilink-label-secret", diff --git a/deploy/docker/mdbase-rs-revision b/deploy/docker/mdbase-rs-revision index ee67a242..888a27a2 100644 --- a/deploy/docker/mdbase-rs-revision +++ b/deploy/docker/mdbase-rs-revision @@ -1 +1 @@ -961ed6f688e4e9712c8a4de84e1f0291ea446330 +76ceed008b63fd06c91dbc6ccd0cfcea043ac042 diff --git a/docs/architecture/hosted-typed-runtime-adoption.md b/docs/architecture/hosted-typed-runtime-adoption.md index aefd18ff..9e75a734 100644 --- a/docs/architecture/hosted-typed-runtime-adoption.md +++ b/docs/architecture/hosted-typed-runtime-adoption.md @@ -23,3 +23,14 @@ The existing hosted count, exact-byte, projection-byte, cancellation, cursor, and transaction budgets continue to apply. Typed planning adds no second scan or materialization budget. Architecture tests reject legacy hosted runtime seams and `.result`-based semantic inference in hosted execution files. + +Semantic projection format 6 persists mdbase's bounded resolution reason and +complete candidate evidence in the encrypted-authority projection row. Hosted +rebuild and write-through use the same `plan_record_resolution` / +`resolve_record_structure` implementation as local execution, and currentness +checks verify the structural digest and candidate evidence before projection or +relationship indexes are trusted. Format-5 generations and rows are stale by +construction: query binding chooses exact fallback and indexing creates a new +current generation; no evaluator mixes v5 rows into a v6 generation. Candidate +evidence stays inside the existing authority-readable projection and does not +add path or stable-ID fields to application responses. diff --git a/docs/architecture/local-filesystem-runtime-adoption.md b/docs/architecture/local-filesystem-runtime-adoption.md index ebd312b4..fc2e0b6b 100644 --- a/docs/architecture/local-filesystem-runtime-adoption.md +++ b/docs/architecture/local-filesystem-runtime-adoption.md @@ -1,14 +1,16 @@ # Local filesystem runtime adoption -Connect's v0.3 local-authority path treats `CanonicalOperationOutcome` as the authoritative semantic result from request execution through prepare, commit, durable claim resolution, and generation-pinned query cursors. `ExecutionOutcome::operation` and `ChangeSet` remain the source of committed facts and feed evidence. The compatibility `OperationResult` envelope is produced by the single `v03_operation_result` adapter when a Connect response is formed. +Connect's v0.3 local-authority path treats `CanonicalOperationOutcome` as the authoritative semantic result from request execution through prepare, commit, durable claim resolution, and generation-pinned query cursors. `ExecutionOutcome::operation` and `ChangeSet` remain the source of committed facts and feed evidence. The compatibility `OperationResult` envelope is produced by the single `v03_operation_result` adapter when a Connect response is formed. Cursor release uses the typed `CursorReleaseOutcome`; the resulting v0.3 JSON remains byte-shape compatible. Contract authorization resolves the grant before reading a record. Scoped update, delete, and rename first complete request mapping plus selector, path, and control-field validation without record access. They then acquire the executor's mutation gate once and hold it continuously across the typed preflight read, record/type authorization, revision binding, prepare, commit, resolution, and local acknowledgement. When the caller omits `if_revision`, the internal runtime request is bound to the preflight record revision without changing the outward request or response envelope. Explicit caller CAS remains authoritative. Query cursors retain and page `CanonicalOperationOutcome` values. Contract projection happens only after typed scope validation. +Every caller-owned local request builds one `OperationContext` from the caller cancellation token and Connect's 30-second local execution ceiling, and passes it through admission, runtime execution/preparation, reads, cursor lifecycle, feed processing, and watcher synchronization. Capture is explicitly bounded at 100,000 entries, 64 MiB per exact document, 4 GiB aggregate reads, depth 128, 10,000 resource entries, and 4 GiB retained state. Once the runtime records the durable committing boundary, mdbase-owned settlement continues independently; caller expiry may return `outcome_unknown` but never reclassifies the write as not sent. + ## Deliberately retained seams - `registry/operation_setup.rs` consumes `OperationResult` only for collection/type-pack setup wire-only families. - `registry/operations.rs` and the legacy helpers in `registry/scope.rs` retain JSON envelope inspection for explicit v0.2 compatibility. They are not used by coordinated v0.3 record execution. -- `connect-hosted-provider` remains on its existing `OperationResult`, `Collection`, and v0.3 facade implementation. Phase 4 only adapts public mdbase field/API changes required to compile it. +- `mdbase-command`, used by the unified CLI's direct command adapter, remains the one intentionally isolated upstream 0.4 compatibility crate. Connect production source has no legacy `Collection` mutation-facade call; canonical Connect crates disable mdbase default features. - The Docker mdbase source revision remains unchanged until the mdbase branch is published. New local v0.3 code must not read deprecated `ExecutionOutcome::result` or `CommitRejection::result`, recover typed records with `serde_json::from_value`, or inspect `/result/frontmatter` and `/result/types`. Exact encrypted response and durable receipt envelopes remain protocol compatibility artifacts at the agent boundary. diff --git a/package.json b/package.json index e5d63a70..22b14c99 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build": "pnpm -r build", "build:packages": "pnpm --filter \"./packages/**\" -r --if-present build", "check:architecture": "node scripts/check-architecture.mjs", + "check:cargo-features": "scripts/check-cargo-features", "check:npm-bootstrap": "node scripts/check-npm-package-bootstrap.mjs", "check:release-readiness": "node scripts/check-release-readiness.mjs", "check:release-components": "node scripts/release-components.mjs --check", diff --git a/scripts/check-cargo-features b/scripts/check-cargo-features new file mode 100755 index 00000000..d87d96c6 --- /dev/null +++ b/scripts/check-cargo-features @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +feature_graph=$(mktemp) +trap 'rm -f "$feature_graph"' EXIT + +cd "$root" +cargo tree --locked --workspace -e features >"$feature_graph" + +if grep -Fq 'mdbase feature "legacy-collection-mutation"' "$feature_graph"; then + echo 'error: the resolved Connect workspace enables forbidden mdbase feature legacy-collection-mutation' >&2 + grep -F 'mdbase feature "legacy-collection-mutation"' "$feature_graph" >&2 + exit 1 +fi + +echo 'Resolved Connect workspace feature graph contains no legacy-collection-mutation.' diff --git a/scripts/hosted-files-e2e.mjs b/scripts/hosted-files-e2e.mjs index c2d3608c..d2e2a4ac 100644 --- a/scripts/hosted-files-e2e.mjs +++ b/scripts/hosted-files-e2e.mjs @@ -84,16 +84,20 @@ try { const sdkObjectKey = await pg(`SELECT object_key FROM hosted_provider_files WHERE file_id = '${sdkFile.fileId}'`); const moveMutationId = randomUUID(); - const [moved, concurrentAttempt] = await Promise.all([ - sdk.move(sdkFile, "Archive/sdk-renamed.bin", { mutationId: moveMutationId }), + const moveAttempts = await Promise.all([ + sdk.move(sdkFile, "Archive/sdk-renamed.bin", { mutationId: moveMutationId }) + .catch((error) => error), sdk.move(sdkFile, "Archive/sdk-renamed.bin", { mutationId: moveMutationId }) .catch((error) => error) ]); - if (concurrentAttempt instanceof Error) { - assert.equal(concurrentAttempt.code, "temporarily_unavailable"); - } else { - assert.deepEqual(concurrentAttempt, moved); + const moveSuccesses = moveAttempts.filter((result) => !(result instanceof Error)); + const moveFailures = moveAttempts.filter((result) => result instanceof Error); + assert.ok(moveSuccesses.length >= 1); + for (const failure of moveFailures) { + assert.equal(failure.code, "temporarily_unavailable"); } + const moved = moveSuccesses[0]; + for (const replayed of moveSuccesses.slice(1)) assert.deepEqual(replayed, moved); assert.equal(moved.fileId, sdkFile.fileId); assert.notEqual(moved.revision, sdkFile.revision); assert.deepEqual(await sdk.move(sdkFile, moved.path, { mutationId: moveMutationId }), moved); diff --git a/scripts/lib/architecture-check.mjs b/scripts/lib/architecture-check.mjs index b2bd71b2..7a4d8355 100644 --- a/scripts/lib/architecture-check.mjs +++ b/scripts/lib/architecture-check.mjs @@ -347,6 +347,90 @@ export async function evaluateArchitecture(root, budgets) { } } + if (budgets.externalGuards !== undefined) { + const externalGuards = budgets.externalGuards ?? {}; + const guardedRust = [...productionSources] + .filter(([file]) => file.endsWith(".rs")) + .map(([, source]) => source) + .join("\n"); + const guardedCounts = { + directWireOnlyConstructors: matchCount( + guardedRust, + /\b(?:CanonicalOperationValue::WireOnly|WireOnlyOperationValue::)\b/g + ), + directCanonicalOutcomeStructs: matchCount( + guardedRust, + /\bCanonicalOperationOutcome\s*\{\s*(?:valid|value|diagnostics)\s*:/g + ), + privateCanonicalResultFieldCalls: matchCount( + guardedRust, + /\boperation\.(?:valid|value|diagnostics)\b(?!\s*\()/g + ) + }; + for (const [name, count] of Object.entries(guardedCounts)) { + const maximum = externalGuards[name]; + if (!Number.isSafeInteger(maximum) || maximum < 0) { + failures.push(`externalGuards.${name} must be a non-negative integer.`); + } else if (count > maximum) { + failures.push(`${name} is ${count}; its external guard is ${maximum}.`); + } + } + + const cargoManifestPaths = [ + "Cargo.toml", + ...[...workspaceInventory.packagePaths] + .filter((packagePath) => packagePath.startsWith("crates/")) + .map((packagePath) => `${packagePath}/Cargo.toml`) + ]; + const cargoSources = []; + for (const manifest of cargoManifestPaths) { + try { + cargoSources.push([manifest, await readFile(path.join(root, manifest), "utf8")]); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + } + const legacyCrudFeatures = cargoSources.reduce( + (total, [, source]) => total + matchCount( + source, + /\bfeatures\s*=\s*\[[^\]]*["']legacy-collection-mutation["'][^\]]*\]/gs + ), + 0 + ); + if (legacyCrudFeatures !== (externalGuards.legacyCrudFeatures ?? -1)) { + failures.push( + `legacyCrudFeatures is ${legacyCrudFeatures}; expected ${externalGuards.legacyCrudFeatures}.` + ); + } + for (const [manifest, source] of cargoSources) { + for (const dependency of source.matchAll(/^\s*mdbase\s*=\s*\{([^}]*)\}/gm)) { + const options = dependency[1]; + if (!/\bworkspace\s*=\s*true\b/.test(options) && + !/\bdefault-features\s*=\s*false\b/.test(options)) { + failures.push(`${manifest} mdbase dependency must set default-features = false.`); + } + } + } + + const projectionFormat = externalGuards.semanticProjectionFormatVersion; + if (!Number.isSafeInteger(projectionFormat) || projectionFormat < 1) { + failures.push("externalGuards.semanticProjectionFormatVersion must be a positive integer."); + } else { + const hostedProvider = productionSources.get( + "crates/connect-hosted-provider/src/provider.rs" + ) ?? ""; + const configuredFormat = new RegExp( + `const\\s+CONNECT_SEMANTIC_PROJECTION_FORMAT_VERSION\\s*:\\s*u32\\s*=\\s*${projectionFormat}\\s*;` + ); + const compileAssertion = /const\s+_\s*:\s*\(\)\s*=\s*assert!\(\s*mdbase::runtime::SEMANTIC_PROJECTION_FORMAT_VERSION\s*==\s*CONNECT_SEMANTIC_PROJECTION_FORMAT_VERSION\s*\)\s*;/s; + if (!configuredFormat.test(hostedProvider) || !compileAssertion.test(hostedProvider)) { + failures.push( + `hosted provider must compile-assert upstream semantic projection format ${projectionFormat}.` + ); + } + } + } + const reviewBudgets = budgets.reviewBudgets && typeof budgets.reviewBudgets === "object" && !Array.isArray(budgets.reviewBudgets) ? budgets.reviewBudgets diff --git a/scripts/lib/architecture-check.test.mjs b/scripts/lib/architecture-check.test.mjs index 0211a029..2f66f1c6 100644 --- a/scripts/lib/architecture-check.test.mjs +++ b/scripts/lib/architecture-check.test.mjs @@ -266,6 +266,44 @@ test("requires every reviewed-surface baseline", async (t) => { assert.deepEqual(result.failures, ["reviewBudgets.relativeImports is required."]); }); +test("guards the mdbase manifest boundary and compile-time projection version assertion", async (t) => { + const providerGuard = [ + "const CONNECT_SEMANTIC_PROJECTION_FORMAT_VERSION: u32 = 6;", + "const _: () = assert!(mdbase::runtime::SEMANTIC_PROJECTION_FORMAT_VERSION == CONNECT_SEMANTIC_PROJECTION_FORMAT_VERSION);" + ].join("\n"); + const root = await fixture({ + "Cargo.toml": "[workspace.dependencies]\nmdbase = { path = '../mdbase-rs', default-features = false }\n", + "crates/connect-hosted-provider/Cargo.toml": "[package]\nname = 'provider'\nversion = '0.0.0'\n", + "crates/connect-hosted-provider/src/provider.rs": providerGuard + }); + t.after(() => rm(root, { recursive: true, force: true })); + const budgets = { + ...strictBudget, + externalGuards: { + directWireOnlyConstructors: 0, + directCanonicalOutcomeStructs: 0, + privateCanonicalResultFieldCalls: 0, + legacyCrudFeatures: 0, + semanticProjectionFormatVersion: 6 + } + }; + + assert.deepEqual((await evaluateArchitecture(root, budgets)).failures, []); + + await writeFile( + path.join(root, "Cargo.toml"), + "[workspace.dependencies]\nmdbase = { path = '../mdbase-rs', features = ['legacy-collection-mutation'] }\n" + ); + await writeFile( + path.join(root, "crates/connect-hosted-provider/src/provider.rs"), + providerGuard.replace("= 6", "= 5") + ); + const rejected = await evaluateArchitecture(root, budgets); + assert.ok(rejected.failures.some((failure) => failure.includes("legacyCrudFeatures is 1"))); + assert.ok(rejected.failures.some((failure) => failure.includes("default-features = false"))); + assert.ok(rejected.failures.some((failure) => failure.includes("compile-assert upstream"))); +}); + test("inventories empty Rust workspace crates", async (t) => { const root = await fixture({ "crates/empty/Cargo.toml": "[package]\nname = 'empty'\nversion = '0.0.0'\n"