From 6372790f65f13b9b3630d7fdf1284b6aa56f8e6c Mon Sep 17 00:00:00 2001 From: wchwawa Date: Mon, 26 Oct 2026 17:37:43 +1100 Subject: [PATCH] chore: retire the multipart upload token from the staged ledger StagedObject.multipart_token and StagedObjectRecord.multipart_upload_id were reserved for a provider multipart path that never existed: every producer set them to None, the lifecycle cleaner refused any request carrying one as ambiguous because no provider exposes a multipart abort, and the Uploading provider state was never written. Now that the owner writes every block with one create, the token has no possible future either. Remove the wire field and its limit, the durable field and its limit, the Uploading state from the registry and the transition tables, the multipart branch from the lifecycle cleanup request, its proof digest and its refusal, and the row key from the Python inspection result; reword the docs. The staged-object record moves to its own value version 8 while the shared publish value version stays at 7: clients seal manifest rows against the shared encoding, and the manifest row did not change. A version 7 staged-object record is refused instead of read with its bytes shifted. Signed-off-by: wchwawa --- .../src/append_operations_tests.rs | 1 - crates/nokv-client/src/artifact.rs | 1 - crates/nokv-meta/src/workspace/mod.rs | 4 +- crates/nokv-meta/src/workspace/publication.rs | 14 +-- .../workspace/publish_operation_records.rs | 101 ++---------------- crates/nokv-meta/src/workspace/restore.rs | 2 - crates/nokv-protocol/src/artifact_publish.rs | 1 - crates/nokv-protocol/src/codec.rs | 5 +- crates/nokv-protocol/src/request.rs | 1 - crates/nokv-protocol/src/types.rs | 13 --- crates/nokv-python/README.md | 4 +- crates/nokv-python/src/client.rs | 1 - crates/nokv-server/src/executor.rs | 6 -- crates/nokv-server/src/lifecycle.rs | 63 ++--------- crates/nokv-types/src/workspace.rs | 2 - crates/nokv/src/main.rs | 1 - docs/architecture.md | 2 +- docs/metadata-schema.md | 15 +-- docs/product-design.md | 4 +- .../append_operations_acceptance_gate.py | 3 +- 20 files changed, 37 insertions(+), 207 deletions(-) diff --git a/crates/nokv-client/src/append_operations_tests.rs b/crates/nokv-client/src/append_operations_tests.rs index b6130d310..0451a24e5 100644 --- a/crates/nokv-client/src/append_operations_tests.rs +++ b/crates/nokv-client/src/append_operations_tests.rs @@ -170,7 +170,6 @@ fn inspection(observation: OperationStatus, start: u32, end: u32) -> AppendClean object_identity: ObjectIdentity::new(format!("object-{sequence}")).unwrap(), expected_length: 4, expected_digest: sha256_digest_uri(Digest([7; 32])), - multipart_token: None, }) .collect(), next_after: (end < 5).then(|| end - 1), diff --git a/crates/nokv-client/src/artifact.rs b/crates/nokv-client/src/artifact.rs index e7d21979b..9eccd1995 100644 --- a/crates/nokv-client/src/artifact.rs +++ b/crates/nokv-client/src/artifact.rs @@ -3181,7 +3181,6 @@ fn publication_rows( object_identity: object_identity.clone(), expected_length: block.len, expected_digest: digest.clone(), - multipart_token: None, }); manifest_rows.push(ArtifactManifestRow { object_index: block.object_index, diff --git a/crates/nokv-meta/src/workspace/mod.rs b/crates/nokv-meta/src/workspace/mod.rs index 73097ad47..55d97e6b3 100644 --- a/crates/nokv-meta/src/workspace/mod.rs +++ b/crates/nokv-meta/src/workspace/mod.rs @@ -167,8 +167,8 @@ pub use publish_operation_records::{ ArtifactManifestRow, ManifestPosition, PublishAuthority, PublishClaim, PublishOperationRecord, PublishRecordError, PublishResult, PublishTerminalError, PublishTerminalErrorKind, PublishTransition, StagedObjectRecord, MAX_APPEND_SEGMENTS, MAX_MANIFEST_ROWS, - MAX_MULTIPART_ID_BYTES, MAX_STAGED_OBJECTS, MAX_TERMINAL_ERROR_BYTES, - PUBLISH_OPERATION_VALUE_FORMAT_VERSION, PUBLISH_VALUE_FORMAT_VERSION, + MAX_STAGED_OBJECTS, MAX_TERMINAL_ERROR_BYTES, PUBLISH_OPERATION_VALUE_FORMAT_VERSION, + PUBLISH_VALUE_FORMAT_VERSION, STAGED_OBJECT_VALUE_FORMAT_VERSION, }; pub use query::{ aggregate_paths_at, catalog_fields_at, find_workspaces_at, get_current_workspace, diff --git a/crates/nokv-meta/src/workspace/publication.rs b/crates/nokv-meta/src/workspace/publication.rs index 4f01dc7d2..249a37893 100644 --- a/crates/nokv-meta/src/workspace/publication.rs +++ b/crates/nokv-meta/src/workspace/publication.rs @@ -1014,7 +1014,6 @@ fn validate_cleanup_update( if expected.artifact_revision_id != next.artifact_revision_id || expected.object_sequence != next.object_sequence || expected.object_key != next.object_key - || expected.multipart_upload_id != next.multipart_upload_id || expected.expected_length != next.expected_length || expected.expected_digest_uri != next.expected_digest_uri || next.provider_state != StagedProviderState::Aborted @@ -1044,10 +1043,6 @@ fn validate_staged_update( || expected.object_key != next.object_key || expected.expected_length != next.expected_length || expected.expected_digest_uri != next.expected_digest_uri - || expected - .multipart_upload_id - .as_ref() - .is_some_and(|id| next.multipart_upload_id.as_ref() != Some(id)) || !valid_provider_transition(expected.provider_state, next.provider_state) || !valid_cleanup_transition(expected.cleanup_state, next.cleanup_state) { @@ -1064,12 +1059,6 @@ fn valid_provider_transition(expected: StagedProviderState, next: StagedProvider (expected, next), ( StagedProviderState::Planned, - StagedProviderState::Uploading - | StagedProviderState::Uploaded - | StagedProviderState::AbortPending - | StagedProviderState::Ambiguous - ) | ( - StagedProviderState::Uploading, StagedProviderState::Uploaded | StagedProviderState::AbortPending | StagedProviderState::Ambiguous @@ -5324,7 +5313,6 @@ mod tests { artifact_revision_id, u64::from(sequence), ), - multipart_upload_id: None, expected_length: 1, expected_digest_uri: format!("sha256:{sequence:064x}"), provider_state: StagedProviderState::Planned, @@ -8058,7 +8046,7 @@ mod tests { for next in [ StagedObjectRecord { - provider_state: StagedProviderState::Uploading, + provider_state: StagedProviderState::Planned, ..persisted.clone() }, StagedObjectRecord { diff --git a/crates/nokv-meta/src/workspace/publish_operation_records.rs b/crates/nokv-meta/src/workspace/publish_operation_records.rs index 115a126f8..055c06c62 100644 --- a/crates/nokv-meta/src/workspace/publish_operation_records.rs +++ b/crates/nokv-meta/src/workspace/publish_operation_records.rs @@ -18,8 +18,14 @@ use nokv_types::{ WorkspaceRevision, FIXED_ID_BYTES, SHA256_BYTES, }; -/// Durable value format for publication-owned payloads. +/// Durable value format for publication-owned payloads. Clients seal manifest +/// rows against this encoding, so it moves only with the manifest row. pub const PUBLISH_VALUE_FORMAT_VERSION: u8 = 7; +/// Durable value format for the staged-object record. Version 8 dropped the +/// multipart upload id that no provider path ever set; a version 7 record is +/// refused rather than read with the field's bytes shifted into its +/// neighbours. +pub const STAGED_OBJECT_VALUE_FORMAT_VERSION: u8 = 8; /// Durable value format for the publish operation record. Version 9 records /// the initiating principal; version 8 added the sealed digest of a body /// published inline. @@ -36,7 +42,6 @@ pub const MAX_DEPENDENCY_DEPTH: u8 = 8; /// Maximum append-segment sequence admitted by the durable publication codec. pub const MAX_APPEND_SEGMENTS: u32 = 1_048_576; -pub const MAX_MULTIPART_ID_BYTES: usize = 1_024; pub const MAX_DIGEST_URI_BYTES: usize = 256; pub const MAX_TERMINAL_ERROR_BYTES: usize = 4_096; @@ -351,7 +356,6 @@ pub struct StagedObjectRecord { pub artifact_revision_id: ArtifactRevisionId, pub object_sequence: u32, pub object_key: String, - pub multipart_upload_id: Option>, pub expected_length: u64, pub expected_digest_uri: String, pub provider_state: StagedProviderState, @@ -1300,9 +1304,6 @@ impl StagedObjectRecord { }); } validate_object_key(&self.object_key, self.artifact_revision_id)?; - if let Some(upload_id) = &self.multipart_upload_id { - validate_opaque_bytes("multipart_upload_id", upload_id, MAX_MULTIPART_ID_BYTES)?; - } if self.expected_length == 0 { return Err(PublishRecordError::ZeroScalar { field: "expected_length", @@ -1315,15 +1316,10 @@ impl StagedObjectRecord { pub fn encode(&self) -> Result, PublishRecordError> { self.validate()?; let mut encoded = Vec::with_capacity(256); - encoded.push(PUBLISH_VALUE_FORMAT_VERSION); + encoded.push(STAGED_OBJECT_VALUE_FORMAT_VERSION); encoded.extend_from_slice(self.artifact_revision_id.as_bytes()); encoded.extend_from_slice(&self.object_sequence.to_be_bytes()); push_string(&mut encoded, "object_key", &self.object_key)?; - push_optional_bytes( - &mut encoded, - "multipart_upload_id", - self.multipart_upload_id.as_deref(), - )?; encoded.extend_from_slice(&self.expected_length.to_be_bytes()); push_string( &mut encoded, @@ -1347,13 +1343,11 @@ impl StagedObjectRecord { MAX_STAGED_OBJECT_RECORD_BYTES, )?; let mut decoder = Decoder::new(encoded); - decoder.require_value_version()?; + decoder.require_version(STAGED_OBJECT_VALUE_FORMAT_VERSION)?; let artifact_revision_id = ArtifactRevisionId::from_bytes(decoder.fixed("artifact_revision_id")?); let object_sequence = decoder.u32("object_sequence")?; let object_key = decoder.string("object_key", OBJECT_KEY_BYTES)?; - let multipart_upload_id = - decoder.optional_bytes("multipart_upload_id", MAX_MULTIPART_ID_BYTES)?; let expected_length = decoder.u64("expected_length")?; let expected_digest_uri = decoder.string("expected_digest_uri", MAX_DIGEST_URI_BYTES)?; let provider_state = decode_provider_state(decoder.u8("provider_state")?)?; @@ -1363,7 +1357,6 @@ impl StagedObjectRecord { artifact_revision_id, object_sequence, object_key, - multipart_upload_id, expected_length, expected_digest_uri, provider_state, @@ -1576,13 +1569,10 @@ fn validate_staged_state_pair( let valid = matches!( (provider, cleanup), ( - StagedProviderState::Planned - | StagedProviderState::Uploading - | StagedProviderState::Uploaded, + StagedProviderState::Planned | StagedProviderState::Uploaded, StagedCleanupState::Owned ) | ( StagedProviderState::Planned - | StagedProviderState::Uploading | StagedProviderState::Uploaded | StagedProviderState::AbortPending, StagedCleanupState::DeletePending @@ -1692,24 +1682,6 @@ fn validate_text(field: &'static str, value: &str, max: usize) -> Result<(), Pub Ok(()) } -fn validate_opaque_bytes( - field: &'static str, - value: &[u8], - max: usize, -) -> Result<(), PublishRecordError> { - if value.is_empty() { - return Err(PublishRecordError::EmptyField { field }); - } - if value.len() > max { - return Err(PublishRecordError::FieldTooLong { - field, - length: value.len(), - max, - }); - } - Ok(()) -} - fn require_record_size( record: &'static str, length: usize, @@ -1880,21 +1852,6 @@ fn push_bytes( Ok(()) } -fn push_optional_bytes( - target: &mut Vec, - field: &'static str, - value: Option<&[u8]>, -) -> Result<(), PublishRecordError> { - match value { - None => target.push(0), - Some(value) => { - target.push(1); - push_bytes(target, field, value)?; - } - } - Ok(()) -} - fn push_optional_fixed(target: &mut Vec, value: &Option<[u8; N]>) { match value { None => target.push(0), @@ -1966,18 +1923,6 @@ impl<'a> Decoder<'a> { .map_err(|_| PublishRecordError::InvalidUtf8 { field }) } - fn optional_bytes( - &mut self, - field: &'static str, - max: usize, - ) -> Result>, PublishRecordError> { - match self.u8(field)? { - 0 => Ok(None), - 1 => self.bytes(field, max).map(Some), - value => Err(PublishRecordError::InvalidOptionalTag { field, value }), - } - } - fn optional_fixed( &mut self, field: &'static str, @@ -2288,7 +2233,6 @@ mod tests { artifact_revision_id: revision(0x22), object_sequence: 7, object_key: object_key(0x22), - multipart_upload_id: Some(b"upload-1".to_vec()), expected_length: 4_096, expected_digest_uri: "sha256:block".to_owned(), provider_state: StagedProviderState::Uploaded, @@ -2296,14 +2240,11 @@ mod tests { }; let key = object_key(0x22); let expected = [ - &[PUBLISH_VALUE_FORMAT_VERSION][..], + &[STAGED_OBJECT_VALUE_FORMAT_VERSION][..], &[0x22; 16], &7_u32.to_be_bytes(), &(key.len() as u32).to_be_bytes(), key.as_bytes(), - &[1], - &8_u32.to_be_bytes(), - b"upload-1", &4_096_u64.to_be_bytes(), &12_u32.to_be_bytes(), b"sha256:block", @@ -2649,7 +2590,6 @@ mod tests { artifact_revision_id: revision(0x22), object_sequence: 0, object_key: object_key(0x22), - multipart_upload_id: None, expected_length: 1, expected_digest_uri: "sha256:a".to_owned(), provider_state: StagedProviderState::Uploaded, @@ -2760,7 +2700,6 @@ mod tests { artifact_revision_id: revision(0x22), object_sequence: 0, object_key: object_key(0x22), - multipart_upload_id: None, expected_length: 1, expected_digest_uri: "sha256:a".to_owned(), provider_state: StagedProviderState::Uploaded, @@ -2857,24 +2796,6 @@ mod tests { #[test] fn oversized_fields_and_records_fail_before_allocation_or_acceptance() { - let staged = StagedObjectRecord { - artifact_revision_id: revision(0x22), - object_sequence: 0, - object_key: object_key(0x22), - multipart_upload_id: Some(vec![0; MAX_MULTIPART_ID_BYTES + 1]), - expected_length: 1, - expected_digest_uri: "sha256:a".to_owned(), - provider_state: StagedProviderState::Uploading, - cleanup_state: StagedCleanupState::Owned, - }; - assert!(matches!( - staged.encode(), - Err(PublishRecordError::FieldTooLong { - field: "multipart_upload_id", - .. - }) - )); - let oversized = vec![0; MAX_PUBLISH_OPERATION_RECORD_BYTES + 1]; assert_eq!( PublishOperationRecord::decode(&oversized).unwrap_err(), diff --git a/crates/nokv-meta/src/workspace/restore.rs b/crates/nokv-meta/src/workspace/restore.rs index 322e0d1ed..c1f9f8488 100644 --- a/crates/nokv-meta/src/workspace/restore.rs +++ b/crates/nokv-meta/src/workspace/restore.rs @@ -10794,7 +10794,6 @@ mod tests { artifact_revision_id, object_sequence: 0, object_key: object_block_key(shard(), root(), artifact_revision_id, 0), - multipart_upload_id: None, expected_length: body.len() as u64, expected_digest_uri: body_digest_uri(body), provider_state: StagedProviderState::Planned, @@ -10836,7 +10835,6 @@ mod tests { artifact_revision_id, object_sequence: object_index, object_key: object_key.clone(), - multipart_upload_id: None, expected_length: block_size, expected_digest_uri: digest_uri.clone(), provider_state: StagedProviderState::Planned, diff --git a/crates/nokv-protocol/src/artifact_publish.rs b/crates/nokv-protocol/src/artifact_publish.rs index 36fb492fa..d0f0c830d 100644 --- a/crates/nokv-protocol/src/artifact_publish.rs +++ b/crates/nokv-protocol/src/artifact_publish.rs @@ -313,7 +313,6 @@ mod tests { object_identity: ObjectIdentity::new(key).unwrap(), expected_length: 4, expected_digest: digest("sha256:abcd"), - multipart_token: None, } } diff --git a/crates/nokv-protocol/src/codec.rs b/crates/nokv-protocol/src/codec.rs index 47f15ba6f..2940c0b88 100644 --- a/crates/nokv-protocol/src/codec.rs +++ b/crates/nokv-protocol/src/codec.rs @@ -385,7 +385,6 @@ mod tests { object_identity: object(), expected_length: 1, expected_digest: digest.clone(), - multipart_token: None, }], }, ), @@ -445,8 +444,8 @@ mod tests { assert_eq!( <[u8; 32]>::from(golden.finalize()), [ - 95, 157, 43, 219, 156, 245, 126, 182, 88, 13, 148, 128, 96, 253, 190, 238, 237, - 127, 173, 41, 108, 173, 150, 72, 236, 15, 70, 203, 2, 183, 100, 149, + 108, 207, 191, 121, 68, 172, 63, 22, 194, 9, 128, 128, 137, 110, 101, 239, 11, 98, + 201, 199, 14, 192, 40, 140, 37, 233, 60, 50, 125, 247, 177, 184, ], "update only for an intentional fused publication v15 wire change" ); diff --git a/crates/nokv-protocol/src/request.rs b/crates/nokv-protocol/src/request.rs index 8b7dcd27e..c94568f1d 100644 --- a/crates/nokv-protocol/src/request.rs +++ b/crates/nokv-protocol/src/request.rs @@ -2373,7 +2373,6 @@ mod tests { object_identity: crate::ObjectIdentity::new(format!("objects/{sequence}")).unwrap(), expected_length: 1, expected_digest: crate::sha256_digest_uri(Digest([5; 32])), - multipart_token: None, } } diff --git a/crates/nokv-protocol/src/types.rs b/crates/nokv-protocol/src/types.rs index 20d042c61..e39894032 100644 --- a/crates/nokv-protocol/src/types.rs +++ b/crates/nokv-protocol/src/types.rs @@ -539,12 +539,9 @@ pub struct StagedObject { pub object_identity: ObjectIdentity, pub expected_length: u64, pub expected_digest: DigestUri, - pub multipart_token: Option>, } impl StagedObject { - pub const MAX_MULTIPART_TOKEN_BYTES: usize = 4_096; - pub fn validate(&self) -> Result<(), ProtocolError> { if self.expected_length == 0 { return Err(ProtocolError::invalid( @@ -552,16 +549,6 @@ impl StagedObject { "must be greater than zero", )); } - if self - .multipart_token - .as_ref() - .is_some_and(|token| token.len() > Self::MAX_MULTIPART_TOKEN_BYTES) - { - return Err(ProtocolError::invalid( - "staged_object.multipart_token", - format!("exceeds {} bytes", Self::MAX_MULTIPART_TOKEN_BYTES), - )); - } Ok(()) } } diff --git a/crates/nokv-python/README.md b/crates/nokv-python/README.md index af0d0377f..4b2d9bcb9 100644 --- a/crates/nokv-python/README.md +++ b/crates/nokv-python/README.md @@ -135,8 +135,8 @@ not for installing the SDK. same status plus logical `operation_token`, child `publication_token`, object namespace, `registered_count`, `cleanup_cursor`, `remaining_count`, and a page of retained staged `entries`. Each entry has `sequence`, `object_identity`, - `expected_length`, `expected_digest`, and optional `multipart_token`. Retired - keys are not a historical inventory. The limit is 1 to 192; `next_cursor` is + `expected_length`, and `expected_digest`. Retired keys are not a historical + inventory. The limit is 1 to 192; `next_cursor` is opaque `bytes` or `None`. Continue with the unchanged cursor. A changed state returns `Conflict`; restart inspection rather than combining different pages. - `Client.operation_recover(operation_id, expected_state_digest=None)` asks the diff --git a/crates/nokv-python/src/client.rs b/crates/nokv-python/src/client.rs index 6d2ec41cb..f832b7925 100644 --- a/crates/nokv-python/src/client.rs +++ b/crates/nokv-python/src/client.rs @@ -739,7 +739,6 @@ impl PythonWorkspaceClient { row.set_item("object_identity", entry.object_identity.as_str())?; row.set_item("expected_length", entry.expected_length)?; row.set_item("expected_digest", entry.expected_digest.as_str())?; - row.set_item("multipart_token", entry.multipart_token.clone())?; entries.append(row)?; } dict.set_item("entries", entries)?; diff --git a/crates/nokv-server/src/executor.rs b/crates/nokv-server/src/executor.rs index f589468a8..683de5a83 100644 --- a/crates/nokv-server/src/executor.rs +++ b/crates/nokv-server/src/executor.rs @@ -2332,7 +2332,6 @@ impl MetadataWorkspaceRequestExecutor { artifact_revision_id: operation.artifact_revision_id, object_sequence: object.sequence, object_key: object.object_identity.as_str().to_owned(), - multipart_upload_id: object.multipart_token.clone(), expected_length: object.expected_length, expected_digest_uri: object.expected_digest.as_str().to_owned(), provider_state: types::StagedProviderState::Planned, @@ -3176,7 +3175,6 @@ impl MetadataWorkspaceRequestExecutor { expected_length: row.expected_length, expected_digest: protocol::DigestUri::new(row.expected_digest_uri) .map_err(|e| internal(e.to_string()))?, - multipart_token: row.multipart_upload_id, }); } Ok(ExecutedRequest { @@ -9127,7 +9125,6 @@ mod tests { object_identity: protocol::ObjectIdentity::new(object_key.clone()).unwrap(), expected_length: length, expected_digest: body_digest.clone(), - multipart_token: None, }]; let manifest_rows = vec![protocol::ArtifactManifestRow { object_index: 0, @@ -12598,7 +12595,6 @@ mod tests { object_identity: protocol::ObjectIdentity::new(object_key.clone()).unwrap(), expected_length: 1, expected_digest: body_digest.clone(), - multipart_token: None, }]; let manifest_rows = vec![protocol::ArtifactManifestRow { object_index: 0, @@ -12857,7 +12853,6 @@ mod tests { expected_digest: protocol::sha256_digest_uri(protocol::Digest( [sequence as u8; 32], )), - multipart_token: None, }) .collect(); let seals = @@ -13756,7 +13751,6 @@ mod tests { object_identity: protocol::ObjectIdentity::new(object_key.clone()).unwrap(), expected_length: 1, expected_digest: body_digest.clone(), - multipart_token: None, }]; let manifest_rows = vec![protocol::ArtifactManifestRow { object_index: 0, diff --git a/crates/nokv-server/src/lifecycle.rs b/crates/nokv-server/src/lifecycle.rs index a699b8f5c..cedbfffea 100644 --- a/crates/nokv-server/src/lifecycle.rs +++ b/crates/nokv-server/src/lifecycle.rs @@ -41,13 +41,11 @@ pub enum LifecycleCleanupPurpose { } /// Provider-neutral cleanup request. Failed append keys must be permanently -/// sealed; ordinary publication and revision GC require absence. Multipart -/// cleanup must also fence the named upload before returning success. +/// sealed; ordinary publication and revision GC require absence. #[derive(Clone, Debug, PartialEq, Eq)] pub struct LifecycleCleanupRequest { pub purpose: LifecycleCleanupPurpose, pub object_key: String, - pub multipart_upload_id: Option>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -75,7 +73,7 @@ impl LifecycleCleanupProof { /// Build stable provider evidence for one authoritative cleanup request. /// The caller must pass the canonical object key received from the /// lifecycle worker; the digest binds the metadata schema, proof domain, - /// cleanup purpose, exact key, optional multipart id, and disposition. + /// cleanup purpose, exact key, and disposition. pub fn from_cleanup_request( request: &LifecycleCleanupRequest, disposition: LifecycleCleanupDisposition, @@ -89,13 +87,6 @@ impl LifecycleCleanupProof { LifecycleCleanupPurpose::AbortedAppend => 3, }]); hash_part(&mut hasher, request.object_key.as_bytes()); - match request.multipart_upload_id.as_deref() { - None => hasher.update([0]), - Some(multipart_upload_id) => { - hasher.update([1]); - hash_part(&mut hasher, multipart_upload_id); - } - } hasher.update([match disposition { LifecycleCleanupDisposition::Deleted => 1, LifecycleCleanupDisposition::AlreadyAbsent => 2, @@ -144,9 +135,8 @@ impl LifecycleDurabilityBarrier for RecoveryPublisher { /// ordinary publication and revision GC retain physical deletion semantics. /// /// The object boundary cannot prove that a failed destructive call was not -/// dispatched, and it currently exposes no multipart-abort operation. Both -/// cases therefore fail closed as ambiguous so the metadata operation is -/// quarantined instead of retrying an uncertain deletion. +/// dispatched, so that case fails closed as ambiguous and the metadata +/// operation is quarantined instead of retrying an uncertain deletion. #[derive(Clone)] pub struct ArtifactLifecycleCleaner { store: Arc, @@ -166,11 +156,6 @@ where &self, request: &LifecycleCleanupRequest, ) -> Result { - if request.multipart_upload_id.is_some() { - return Err(LifecycleCleanupError::Ambiguous { - evidence: b"configured object provider has no multipart-abort boundary".to_vec(), - }); - } let key = ObjectKey::new(request.object_key.clone()).map_err(|error| { LifecycleCleanupError::Ambiguous { evidence: format!("authoritative lifecycle object key is invalid: {error}") @@ -794,7 +779,6 @@ impl LifecycleRunner { LifecycleCleanupPurpose::AbortedPublication }, object_key: expected.object_key.clone(), - multipart_upload_id: expected.multipart_upload_id.clone(), }; match self.objects.cleanup(&request) { Ok(proof) if proof.matches_request(&request) => { @@ -1511,7 +1495,6 @@ impl LifecycleRunner { let request = LifecycleCleanupRequest { purpose: LifecycleCleanupPurpose::RevisionGarbageCollection, object_key: entry.row.object_key.clone(), - multipart_upload_id: None, }; match self.objects.cleanup(&request) { Ok(proof) if proof.matches_request(&request) => { @@ -2011,7 +1994,6 @@ mod tests { LifecycleCleanupRequest { purpose: LifecycleCleanupPurpose::RevisionGarbageCollection, object_key: "nokv/artifacts/object".to_owned(), - multipart_upload_id: None, } } @@ -2027,7 +2009,6 @@ mod tests { let request = LifecycleCleanupRequest { purpose: LifecycleCleanupPurpose::AbortedAppend, object_key: key.as_str().to_owned(), - multipart_upload_id: None, }; for _ in 0..2 { let proof = cleaner.cleanup(&request).unwrap(); @@ -2095,22 +2076,6 @@ mod tests { assert_eq!(store.delete_calls.load(Ordering::SeqCst), 1); } - #[test] - fn artifact_lifecycle_deleter_quarantines_multipart_before_dispatch() { - let store = Arc::new(FakeArtifactStore { - delete_result: Ok(ObjectDeleteOutcome::Deleted), - delete_calls: AtomicUsize::new(0), - }); - let deleter = ArtifactLifecycleCleaner::new(Arc::clone(&store)); - let mut request = lifecycle_delete_request(); - request.multipart_upload_id = Some(b"upload-id".to_vec()); - - let error = deleter.cleanup(&request).unwrap_err(); - - assert!(matches!(error, LifecycleCleanupError::Ambiguous { .. })); - assert_eq!(store.delete_calls.load(Ordering::SeqCst), 0); - } - #[test] fn artifact_lifecycle_deleter_quarantines_provider_error() { let key = ObjectKey::new("nokv/artifacts/object").unwrap(); @@ -2539,7 +2504,6 @@ mod tests { let request = LifecycleCleanupRequest { purpose: LifecycleCleanupPurpose::AbortedPublication, object_key: "nokv/artifacts/01010101010101010101010101010101/02020202020202020202020202020202/03030303030303030303030303030303/blocks/0000000000000007".to_owned(), - multipart_upload_id: Some(b"multipart-7".to_vec()), }; let proof = LifecycleCleanupProof::from_cleanup_request( &request, @@ -2548,9 +2512,9 @@ mod tests { assert_eq!( proof.digest, [ - 0xdd, 0xcf, 0x71, 0xa4, 0x41, 0x4e, 0x6c, 0x96, 0xc9, 0x0a, 0x30, 0xf1, 0xc2, 0x28, - 0x78, 0x54, 0x50, 0x2e, 0x2c, 0x3f, 0x15, 0x56, 0x2c, 0xf8, 0xbd, 0xed, 0xd2, 0x69, - 0xd0, 0x33, 0x7c, 0xe1 + 0xb3, 0x1c, 0x58, 0xdf, 0x86, 0x18, 0x29, 0x4f, 0xec, 0xae, 0x2d, 0xa7, 0x5f, 0x06, + 0x68, 0x23, 0x20, 0x23, 0xd3, 0x8b, 0x78, 0x84, 0xe9, 0x35, 0x36, 0x1f, 0x34, 0xab, + 0x8c, 0xb7, 0xdf, 0x80 ] ); assert_eq!( @@ -2574,16 +2538,6 @@ mod tests { ) .digest ); - changed = request.clone(); - changed.multipart_upload_id = None; - assert_ne!( - proof.digest, - LifecycleCleanupProof::from_cleanup_request( - &changed, - LifecycleCleanupDisposition::Deleted, - ) - .digest - ); assert_ne!( proof.digest, LifecycleCleanupProof::from_cleanup_request( @@ -3110,7 +3064,6 @@ mod tests { artifact_revision_id: revision, object_sequence: 0, object_key: meta::object_block_key(shard(), root(), revision, 0), - multipart_upload_id: None, expected_length: 8, expected_digest_uri: sha256_uri([0x73; SHA256_BYTES]), provider_state: StagedProviderState::Planned, @@ -3705,7 +3658,6 @@ mod tests { artifact_revision_id: shared_revision, object_sequence: 0, object_key: shared_object_key.clone(), - multipart_upload_id: None, expected_length: block_bytes.len() as u64, expected_digest_uri: sha256_uri([0x61; SHA256_BYTES]), provider_state: StagedProviderState::Planned, @@ -4008,7 +3960,6 @@ mod tests { artifact_revision_id: shared_revision, object_sequence: 0, object_key: shared_object_key.clone(), - multipart_upload_id: None, expected_length: block_bytes.len() as u64, expected_digest_uri: sha256_uri([0x61; SHA256_BYTES]), provider_state: StagedProviderState::Planned, diff --git a/crates/nokv-types/src/workspace.rs b/crates/nokv-types/src/workspace.rs index 5ba7a75cf..5f01d6458 100644 --- a/crates/nokv-types/src/workspace.rs +++ b/crates/nokv-types/src/workspace.rs @@ -843,7 +843,6 @@ durable_enum! { /// Provider-observed state for one staged immutable object. pub enum StagedProviderState { Planned = 1, - Uploading = 2, Uploaded = 3, AbortPending = 4, Aborted = 5, @@ -1477,7 +1476,6 @@ mod tests { ]); assert_durable_registry(&[ (StagedProviderState::Planned, 1), - (StagedProviderState::Uploading, 2), (StagedProviderState::Uploaded, 3), (StagedProviderState::AbortPending, 4), (StagedProviderState::Aborted, 5), diff --git a/crates/nokv/src/main.rs b/crates/nokv/src/main.rs index 54158f40e..cce3b9024 100644 --- a/crates/nokv/src/main.rs +++ b/crates/nokv/src/main.rs @@ -2495,7 +2495,6 @@ mod tests { expected_digest: nokv_protocol::sha256_digest_uri(nokv_protocol::Digest( [0x44; 32], )), - multipart_token: None, }], next_after: Some(1), }, diff --git a/docs/architecture.md b/docs/architecture.md index 97bb038bf..4e67d0640 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -484,7 +484,7 @@ is therefore an opt-in, not the resting state. Durable ledgers, not object listing, recover: -- staged/multipart uploads; +- staged uploads; - commit construction; - restore staging and cleanup; - GC claims and ambiguous deletes. diff --git a/docs/metadata-schema.md b/docs/metadata-schema.md index b41f542df..eb850c9f4 100644 --- a/docs/metadata-schema.md +++ b/docs/metadata-schema.md @@ -133,8 +133,9 @@ fail-closed and unchanged. Durable codecs are independently versioned: the publish operation record uses value version `9` and rejects version `8`; -staged-object and manifest-row records use value version -`7` and reject version `6`; an append publication binds both the full 256-bit +manifest-row records use value version `7` and reject version `6`; the +staged-object record uses value version `8`, which dropped the multipart +upload id, and rejects version `7`; an append publication binds both the full 256-bit intent and logical parent/attempt, with a cleanup retry counter that prevents repeated quarantine from recreating an earlier state token. `AppendOperationRecord` uses value version `2` and retains the current attempt, optional compact receipt, and initiator. An @@ -242,7 +243,7 @@ RestorePhase: Preparing=1, Copying=2, SourceSealed=3, Ready=4, DestinationSealing=11 CommitRetirePhase: Claiming=1, Releasing=2, Complete=3, Quarantined=4 GcPhase: Queued=1, Claimed=2, Deleting=3, Deleted=4, Quarantined=5 -StagedProviderState: Planned=1, Uploading=2, Uploaded=3, AbortPending=4, +StagedProviderState: Planned=1, Uploaded=3, AbortPending=4, Aborted=5, Ambiguous=6 StagedCleanupState: Owned=1, DeletePending=2, Deleted=3, Quarantined=4 GcClaimState: Candidate=1, Claimed=2, Complete=3, Quarantined=4 @@ -485,8 +486,8 @@ RestoreMember StagedObject key: root_id | publish_operation_id | object_sequence - val: artifact revision, object key, multipart/upload id, - expected length/digest, provider state, cleanup state + val: artifact revision, object key, expected length/digest, + provider state, cleanup state CommandDedupe key: root_id | request_id @@ -759,7 +760,7 @@ the old candidate stale. Publication is object-first and metadata-last. Before upload, a `PublishOperation` and exact `StagedObject` ledger own all object keys, -multipart ids, lengths, and digests. +lengths, and digests. The mutually exclusive operation transitions are: @@ -797,7 +798,7 @@ retained staged rows, bound to the exact current logical/child state. Generic pu revision GC retain their existing deletion semantics. A late upload completion must observe the operation state; after abort it joins -cleanup instead of publishing. Ambiguous multipart completion, late PUT, or +cleanup instead of publishing. An ambiguous create, late PUT, or DELETE remains ledger-owned and `Quarantined` until reconciled. Object listing is never used to discover staged ownership. diff --git a/docs/product-design.md b/docs/product-design.md index 6ac3c3a87..d0ab944f5 100644 --- a/docs/product-design.md +++ b/docs/product-design.md @@ -172,8 +172,8 @@ with the same request id. Failed or abandoned uploads remain invisible and are recovered from a durable staged-object ledger. Whole-artifact replacement is the generic write primitive. Logs use immutable -append segments plus a conditional stream-head advance. Range reads and -multipart uploads remain first-class SDK operations. +append segments plus a conditional stream-head advance. Range reads remain a +first-class SDK operation. ## Discovery And Provenance diff --git a/scripts/workbench/append_operations_acceptance_gate.py b/scripts/workbench/append_operations_acceptance_gate.py index be180a95d..75cedcb9d 100644 --- a/scripts/workbench/append_operations_acceptance_gate.py +++ b/scripts/workbench/append_operations_acceptance_gate.py @@ -418,8 +418,7 @@ def inspect_all( require( row["expected_length"] == len(block) and row["expected_digest"] == "sha256:" + hashlib.sha256(block).hexdigest() - and row["object_identity"].endswith(f"/blocks/{row['sequence']:016x}") - and row["multipart_token"] is None, + and row["object_identity"].endswith(f"/blocks/{row['sequence']:016x}"), "public ledger preserves exact expected object identity, bytes, and digest", row, )