From 2640670e9eeb0bd83ee49621f25dec426a6e2c7a Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:10:00 +0000 Subject: [PATCH 1/6] feat(core): add the bulk operation types, ARNs and limits The new types are the requests and responses of StartBulkPutItems, DescribeBulkOperation, ListBulkOperations and CancelBulkOperation, with the member names of the API model. The arn module parses a bulk operation ARN and answers a TableName written as a table ARN. One parser serves the server's authorization layer and the engine's handlers, so the two read each ARN the same way. LimitsConfig gains the bulk limits, each with a serde default. They cap what one account and one operation may use, such as concurrent operations, input bytes, rows read and report records. Others set the retention period, the worker slots, the runner's lease, the batch sizes and the write rate. MetricName gains BulkOperationsStarted, BulkOperationsFinished and BulkPutItemsWritten, and QuerySource gains BulkClaim and BulkRetention for the two bulk workers. --- crates/core/src/arn.rs | 15 + crates/core/src/arn/bulk.rs | 503 ++++++++++++++++ crates/core/src/arn/table.rs | 199 +++++++ crates/core/src/limits/mod.rs | 159 +++++ crates/core/src/metrics/types.rs | 18 + crates/core/src/types/bulk_operation.rs | 546 ++++++++++++++++++ .../types/bulk_operation_tests/description.rs | 290 ++++++++++ .../src/types/bulk_operation_tests/mod.rs | 149 +++++ .../src/types/bulk_operation_tests/wire.rs | 305 ++++++++++ crates/core/src/types/mod.rs | 10 + 10 files changed, 2194 insertions(+) create mode 100644 crates/core/src/arn/bulk.rs create mode 100644 crates/core/src/arn/table.rs create mode 100644 crates/core/src/types/bulk_operation.rs create mode 100644 crates/core/src/types/bulk_operation_tests/description.rs create mode 100644 crates/core/src/types/bulk_operation_tests/mod.rs create mode 100644 crates/core/src/types/bulk_operation_tests/wire.rs diff --git a/crates/core/src/arn.rs b/crates/core/src/arn.rs index 408d368cc..962ad6895 100644 --- a/crates/core/src/arn.rs +++ b/crates/core/src/arn.rs @@ -17,6 +17,21 @@ //! to any value: a caller that pins them to its deployment does so itself, and a //! caller that ignores them (authorization canonicalizes every supplied ARN to //! its own deployment) reads only the table and sub-resource. +//! +//! The bulk operations read their ARNs through the `bulk` and `table` +//! submodules: a bulk operation ARN, and a `TableName` written as a table ARN. +//! The server's authorization layer and the engine's bulk handlers call the +//! same functions. The engine keeps ARN construction (operation ids, +//! formatting) beside its handlers. + +mod bulk; +mod table; +pub use bulk::{ + BulkOperationArn, BulkOperationArnError, TableArnError, bulk_operation_arn_id_error, + bulk_operation_arn_shape_error, bulk_operation_does_not_exist, own_bulk_operation_arn, + own_table_arn_name, parse_bulk_operation_arn, +}; +pub use table::{check_table_arn_name, table_arn_refusal}; /// A table sub-resource: a secondary index, a stream, or a backup. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/core/src/arn/bulk.rs b/crates/core/src/arn/bulk.rs new file mode 100644 index 000000000..53228b563 --- /dev/null +++ b/crates/core/src/arn/bulk.rs @@ -0,0 +1,503 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Bulk operation ARN parsing and the own-table ARN resolution. + +use crate::error::DynamoDbError; + +/// Layer 1 of the bulk operation ARN validation: the string is not a bulk +/// operation ARN at all. +const ARN_SHAPE_MESSAGE: &str = "Invalid Bulk operation ARN"; + +/// Layer 2: a bulk operation ARN whose partition is not `aws`, whose +/// service is not `dynamodb`, or whose operation id has the wrong shape. +const ARN_ID_MESSAGE: &str = "Invalid Request: Bulk operation ARN is invalid."; + +/// Why a bulk operation ARN did not resolve for the caller who sent it. +/// +/// The variants are the layers in the order the hosted service applies +/// them (measured on the service): `Shape` first, then the account +/// and region comparisons, and only then the partition, service, and +/// operation id. A foreign-account ARN carrying an id of the wrong shape +/// is answered as a foreign account, not as a malformed id, so the caller +/// learns nothing about the shape of another account's ids. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BulkOperationArnError { + /// The string is not a bulk operation ARN at all. + Shape, + /// A bulk operation ARN naming another account. + ForeignAccount, + /// The caller's account, another region. + ForeignRegion, + /// The caller's account and region, with a partition, a service, or an + /// operation id the parse refuses. + InvalidId, +} + +/// Why a table ARN spelling did not resolve to a bare name. +/// +/// `ForeignAccount` is a well-formed table ARN naming another account: +/// both callers answer it `AccessDeniedException`, `Access is denied` +/// (measured), before any policy is fetched and before the handler runs. +/// `ForeignRegion` is a well-formed table ARN in the caller's account +/// naming another region: it answers `ValidationException`, `1 validation +/// error detected: Invalid AWS region in '{arn}'` (measured), at +/// validation step 2. The account check wins when both differ, matching +/// the authorization layer running first. `Invalid` is every other +/// non-resolving spelling, answered with the `Invalid table ARN` message. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TableArnError { + ForeignAccount, + ForeignRegion, + Invalid, +} + +/// The bare table name of a value spelled as a table ARN in the caller's +/// own account and region. +/// +/// A string resolves only when every one of these holds: it starts with +/// `arn:`, has exactly six colon-separated parts, the partition is `aws`, +/// the service is `dynamodb`, the region and account equal the caller's, +/// the resource starts with `table/`, and the remainder is a non-empty +/// name containing neither `/` nor `:`. The `/` rule rejects index and +/// backup ARNs; the `:` rule rejects an ARN with a seventh +/// colon-separated field, which `splitn` would otherwise fold into the +/// name. A name never resolves across account, region, or partition +/// boundaries. +/// +/// A non-resolving spelling is classified by [`TableArnError`]. Both +/// callers read the accept rules and the classification from this one +/// function, so the name a policy is evaluated against, the name the +/// handler serves, and the error a rejected spelling answers cannot +/// drift apart. +/// +/// # Errors +/// +/// Returns the [`TableArnError`] classification; the callers map it to +/// their wire errors. +pub fn own_table_arn_name( + value: &str, + region: &str, + account: &str, +) -> Result { + if !value.starts_with("arn:") { + return Err(TableArnError::Invalid); + } + let parts: Vec<&str> = value.splitn(6, ':').collect(); + if parts.len() < 6 || parts[1] != "aws" || parts[2] != "dynamodb" { + return Err(TableArnError::Invalid); + } + let Some(name) = parts[5].strip_prefix("table/") else { + return Err(TableArnError::Invalid); + }; + if name.is_empty() || name.contains('/') || name.contains(':') { + return Err(TableArnError::Invalid); + } + if parts[4] != account { + return Err(TableArnError::ForeignAccount); + } + if parts[3] != region { + return Err(TableArnError::ForeignRegion); + } + Ok(name.to_owned()) +} + +/// The parsed parts of a bulk operation ARN. +/// +/// [`parse_bulk_operation_arn`] reads a string with no caller to compare +/// against, so a foreign ARN parses successfully there and the caller +/// decides what to answer. [`own_bulk_operation_arn`] takes the caller's +/// region and account and classifies a foreign ARN itself, which is what +/// puts the account comparison ahead of the operation id check. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BulkOperationArn { + pub region: String, + pub account: String, + pub operation_id: String, +} + +/// The first-layer answer for a bulk operation ARN that is not an ARN at +/// all, including an absent one (measured on the service: +/// `DescribeBulkOperation` and `CancelBulkOperation` with no +/// `BulkOperationArn` answer this). +#[must_use] +pub fn bulk_operation_arn_shape_error() -> DynamoDbError { + DynamoDbError::ValidationException(ARN_SHAPE_MESSAGE.to_owned()) +} + +/// The layer 2 answer: a bulk operation ARN whose partition, service, or +/// operation id the parse refuses. +#[must_use] +pub fn bulk_operation_arn_id_error() -> DynamoDbError { + DynamoDbError::ValidationException(ARN_ID_MESSAGE.to_owned()) +} + +/// The colon-separated fields of a string that passed layer 1, with +/// nothing read yet. One home for the split, so the two entry points +/// below cannot disagree about what a bulk operation ARN is made of. +struct BulkArnFields<'a> { + partition: &'a str, + service: &'a str, + region: &'a str, + account: &'a str, + operation_id: &'a str, +} + +impl BulkArnFields<'_> { + /// Whether the partition and service are the two this server serves + /// (both measured). Policy resource matching is literal, so a + /// respelling no Deny on the canonical spelling could match is + /// refused rather than served. + fn names_this_service(&self) -> bool { + self.partition == "aws" && self.service == "dynamodb" + } + + /// Whether the operation id has the shape ids are minted with. + fn id_is_well_formed(&self) -> bool { + is_valid_operation_id(self.operation_id) + } + + fn into_arn(self) -> BulkOperationArn { + BulkOperationArn { + region: self.region.to_owned(), + account: self.account.to_owned(), + operation_id: self.operation_id.to_owned(), + } + } +} + +/// Layer 1: split a string that is a bulk operation ARN into its fields. +/// `None` for a string that does not start with `arn:`, falls outside the +/// model's length bounds, has fewer than six colon-separated parts, or +/// whose resource part is not `bulk-operation/{id}`. Nothing else is read +/// here. +fn split_bulk_operation_arn(arn: &str) -> Option> { + if !arn.starts_with("arn:") || arn.len() < 37 || arn.len() > 1024 { + return None; + } + let parts: Vec<&str> = arn.splitn(6, ':').collect(); + if parts.len() < 6 { + return None; + } + let operation_id = parts[5].strip_prefix("bulk-operation/")?; + Some(BulkArnFields { + partition: parts[1], + service: parts[2], + region: parts[3], + account: parts[4], + operation_id, + }) +} + +/// Read a bulk operation ARN on behalf of the caller who sent it, in the +/// order the hosted service applies the layers. +/// +/// Layer 1 is the shape. The account and region comparisons come next, and +/// the partition, service, and operation id last: a foreign-account ARN +/// whose id is malformed is `ForeignAccount` (measured on the service: +/// a foreign account with the id `nope` is denied, while the caller's +/// own account with the same id answers the layer 2 validation message). +/// Both foreign answers are decided here, from the string alone, so +/// neither depends on whether the foreign operation exists. +/// +/// The engine's handlers and the server's authorization layer both read an +/// ARN through this function, so the resource a policy is evaluated +/// against and the resource a handler serves cannot drift apart, and +/// neither can the order in which a request wrong at two layers is +/// answered. +/// +/// # Errors +/// +/// Returns the [`BulkOperationArnError`] classification; the callers map +/// it to their wire errors. +pub fn own_bulk_operation_arn( + arn: &str, + region: &str, + account: &str, +) -> Result { + let fields = split_bulk_operation_arn(arn).ok_or(BulkOperationArnError::Shape)?; + if fields.account != account { + return Err(BulkOperationArnError::ForeignAccount); + } + if fields.region != region { + return Err(BulkOperationArnError::ForeignRegion); + } + if !fields.names_this_service() || !fields.id_is_well_formed() { + return Err(BulkOperationArnError::InvalidId); + } + Ok(fields.into_arn()) +} + +/// Validate and split a bulk operation ARN without a caller's account or +/// region to compare against: layer 1, then the partition, service, and +/// operation id of layer 2. A caller that knows its own account reads an +/// ARN through [`own_bulk_operation_arn`] instead, because the hosted +/// service compares the account before it reads the id. +/// +/// Layer 1 rejects a string that is not a bulk operation ARN: it does not +/// start with `arn:`, has fewer than six colon-separated parts, is shorter +/// than 37 or longer than 1024 characters (the model's bounds), or its +/// resource part is not `bulk-operation/{id}`. Layer 2 rejects a partition +/// that is not `aws`, a service that is not `dynamodb` (both measured), +/// or an id that does not match `[0-9]{14}-[0-9a-f]{8}`. +/// `own_table_arn_name` requires the same partition and service of a table +/// ARN. +/// +/// # Errors +/// +/// Returns `ValidationException` with the layer's own message. +pub fn parse_bulk_operation_arn(arn: &str) -> Result { + let fields = split_bulk_operation_arn(arn).ok_or_else(bulk_operation_arn_shape_error)?; + if !fields.names_this_service() || !fields.id_is_well_formed() { + return Err(bulk_operation_arn_id_error()); + } + Ok(fields.into_arn()) +} + +/// Layer 3 of the bulk operation ARN validation: a well-formed ARN that +/// names no operation in the caller's account, or an operation in another +/// region. The engine's handlers and the server's authorization layer +/// answer a foreign-region ARN with this one error, decided without a +/// storage read, so the caller cannot learn whether the foreign operation +/// exists. A foreign-account ARN is denied instead (measured), also +/// without a storage read. +#[must_use] +pub fn bulk_operation_does_not_exist(arn: &str) -> DynamoDbError { + DynamoDbError::ResourceNotFoundException(format!( + "Bulk operation with arn: {arn} does not exist" + )) +} + +/// True when `id` matches `[0-9]{14}-[0-9a-f]{8}` exactly. +fn is_valid_operation_id(id: &str) -> bool { + let bytes = id.as_bytes(); + bytes.len() == 23 + && bytes[..14].iter().all(u8::is_ascii_digit) + && bytes[14] == b'-' + && bytes[15..] + .iter() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) +} + +#[cfg(test)] +mod bulk_arn_tests { + use super::*; + + const REGION: &str = "us-east-1"; + const ACCOUNT: &str = "123456789012"; + + #[test] + fn only_an_own_account_and_region_table_arn_resolves() { + assert_eq!( + own_table_arn_name( + &format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/orders"), + REGION, + ACCOUNT + ), + Ok("orders".to_owned()) + ); + // A well-formed table ARN of another account is denied; one of the + // caller's account in another region is a region validation + // failure. The account check wins when both differ, matching the + // authorization layer running first. + assert_eq!( + own_table_arn_name( + &format!("arn:aws:dynamodb:{REGION}:999999999999:table/orders"), + REGION, + ACCOUNT + ), + Err(TableArnError::ForeignAccount), + ); + assert_eq!( + own_table_arn_name( + &format!("arn:aws:dynamodb:eu-west-1:{ACCOUNT}:table/orders"), + REGION, + ACCOUNT + ), + Err(TableArnError::ForeignRegion), + ); + assert_eq!( + own_table_arn_name( + "arn:aws:dynamodb:eu-west-1:999999999999:table/orders", + REGION, + ACCOUNT + ), + Err(TableArnError::ForeignAccount), + ); + // Everything else is Invalid: bare names, another partition, + // another service, another resource type, an index path, an empty + // name, and a name that keeps ARN separators. + for invalid in [ + "orders".to_owned(), + format!("arn:aws-cn:dynamodb:{REGION}:{ACCOUNT}:table/orders"), + format!("arn:aws:s3:{REGION}:{ACCOUNT}:table/orders"), + format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:backup/orders"), + format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/orders/index/gsi1"), + format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/"), + format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/orders:extra"), + format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}"), + ] { + assert_eq!( + own_table_arn_name(&invalid, REGION, ACCOUNT), + Err(TableArnError::Invalid), + "{invalid} must classify as invalid" + ); + } + } + + fn parse_message(input: &str) -> String { + match parse_bulk_operation_arn(input).expect_err("this ARN must be rejected") { + DynamoDbError::ValidationException(message) => message, + other => panic!("expected ValidationException, got {other:?}"), + } + } + + #[test] + fn each_parse_layer_answers_its_own_message() { + // Layer 1: not a bulk operation ARN at all. + assert_eq!(parse_message("orders"), ARN_SHAPE_MESSAGE); + assert_eq!( + parse_message("arn:aws:dynamodb:us-east-1:123456789012:table/x"), + ARN_SHAPE_MESSAGE + ); + // Layer 2: a partition or service respelling (both measured). + // Policy resource matching is literal, so no Deny written against + // the documented ARN shape could match either string; accepting + // one would let it evade an explicit Deny on the canonical + // spelling. The parse still refuses both, with the layer 2 + // message. + assert_eq!( + parse_message( + "arn:aws-cn:dynamodb:us-east-1:123456789012:bulk-operation/01789060589767-da9ddd4f" + ), + ARN_ID_MESSAGE + ); + assert_eq!( + parse_message( + "arn:aws:s3:us-east-1:123456789012:bulk-operation/01789060589767-da9ddd4f" + ), + ARN_ID_MESSAGE + ); + // Layer 2: the right resource type with the wrong id shape. + assert_eq!( + parse_message( + "arn:aws:dynamodb:us-east-1:123456789012:bulk-operation/not-a-valid-id-here" + ), + ARN_ID_MESSAGE + ); + } + + #[test] + fn a_valid_arn_parses_into_its_parts() { + let parsed = parse_bulk_operation_arn( + "arn:aws:dynamodb:us-east-1:123456789012:bulk-operation/01789060589767-da9ddd4f", + ) + .expect("this ARN parses"); + assert_eq!( + parsed, + BulkOperationArn { + region: "us-east-1".to_owned(), + account: "123456789012".to_owned(), + operation_id: "01789060589767-da9ddd4f".to_owned(), + } + ); + } + + #[test] + fn the_caller_aware_read_compares_the_account_before_the_id() { + let valid_id = "01789060589767-da9ddd4f"; + // Measured on the service: with a malformed id, a foreign + // account is denied and the caller's own account answers the + // layer two validation message. + assert_eq!( + own_bulk_operation_arn( + "arn:aws:dynamodb:us-east-1:999999999999:bulk-operation/nope", + REGION, + ACCOUNT + ), + Err(BulkOperationArnError::ForeignAccount) + ); + assert_eq!( + own_bulk_operation_arn( + &format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:bulk-operation/nope"), + REGION, + ACCOUNT + ), + Err(BulkOperationArnError::InvalidId) + ); + // The account comparison outranks the region comparison, and both + // outrank the id, so a foreign account in a foreign region with a + // malformed id is still the account answer. + assert_eq!( + own_bulk_operation_arn( + "arn:aws:dynamodb:eu-west-1:999999999999:bulk-operation/nope", + REGION, + ACCOUNT + ), + Err(BulkOperationArnError::ForeignAccount) + ); + assert_eq!( + own_bulk_operation_arn( + &format!("arn:aws:dynamodb:eu-west-1:{ACCOUNT}:bulk-operation/nope"), + REGION, + ACCOUNT + ), + Err(BulkOperationArnError::ForeignRegion) + ); + // Layer 1 still runs first: a string that is not a bulk operation + // ARN is the shape answer whatever account it names. + assert_eq!( + own_bulk_operation_arn("orders", REGION, ACCOUNT), + Err(BulkOperationArnError::Shape) + ); + assert_eq!( + own_bulk_operation_arn( + "arn:aws:dynamodb:us-east-1:999999999999:table/orders-with-a-long-name", + REGION, + ACCOUNT + ), + Err(BulkOperationArnError::Shape) + ); + // A partition or service respelling of the caller's own ARN is the + // id answer, as it is for the no-caller-context parse. + for respelled in [ + format!("arn:aws-cn:dynamodb:{REGION}:{ACCOUNT}:bulk-operation/{valid_id}"), + format!("arn:aws:s3:{REGION}:{ACCOUNT}:bulk-operation/{valid_id}"), + ] { + assert_eq!( + own_bulk_operation_arn(&respelled, REGION, ACCOUNT), + Err(BulkOperationArnError::InvalidId), + "{respelled} must answer the id layer" + ); + } + // The caller's own canonical ARN resolves to the same parts the + // no-caller-context parse gives. + let canonical = format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:bulk-operation/{valid_id}"); + assert_eq!( + own_bulk_operation_arn(&canonical, REGION, ACCOUNT), + Ok(BulkOperationArn { + region: REGION.to_owned(), + account: ACCOUNT.to_owned(), + operation_id: valid_id.to_owned(), + }) + ); + assert_eq!( + parse_bulk_operation_arn(&canonical).ok(), + own_bulk_operation_arn(&canonical, REGION, ACCOUNT).ok() + ); + } + + #[test] + fn the_not_found_error_names_the_arn() { + let arn = "arn:aws:dynamodb:us-east-1:123456789012:bulk-operation/01789060589767-da9ddd4f"; + match bulk_operation_does_not_exist(arn) { + DynamoDbError::ResourceNotFoundException(message) => { + assert_eq!( + message, + format!("Bulk operation with arn: {arn} does not exist") + ); + } + other => panic!("expected ResourceNotFoundException, got {other:?}"), + } + } +} diff --git a/crates/core/src/arn/table.rs b/crates/core/src/arn/table.rs new file mode 100644 index 000000000..55ae3a4d9 --- /dev/null +++ b/crates/core/src/arn/table.rs @@ -0,0 +1,199 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! The wire answers for a `TableName` spelled as a table ARN, shared by the +//! server's authorization layer and the bulk put handler so the two cannot +//! answer the same spelling differently. The server layer runs first and +//! answers a request over HTTP; the handler answers the same spelling when +//! it is reached without that layer (dev mode and the engine tests). + +use super::TableArnError; +use crate::error::DynamoDbError; + +/// The longest table name the table-name rule admits. +const MAX_NAME_CHARS: usize = 255; +/// The shortest table name the table-name rule admits. +const MIN_NAME_CHARS: usize = 3; + +/// The wire error for a table ARN that [`super::own_table_arn_name`] refused. +/// +/// A well-formed table ARN of another account answers `Access is denied`, +/// and one of the caller's account in another region answers the region +/// message (both measured). A table ARN in the caller's partition, service, +/// region and account whose resource name is a sub-resource path (an index +/// ARN, for example) answers the service's resource-name format message +/// (measured beside an enum fault, which it is answered without). Every +/// other refused spelling keeps the project's `Invalid table ARN` text. +#[must_use] +pub fn table_arn_refusal( + value: &str, + region: &str, + account: &str, + error: TableArnError, +) -> DynamoDbError { + match error { + TableArnError::ForeignAccount => { + DynamoDbError::AccessDeniedException("Access is denied".to_owned()) + } + TableArnError::ForeignRegion => DynamoDbError::ValidationException(format!( + "1 validation error detected: Invalid AWS region in '{value}'" + )), + TableArnError::Invalid => { + let own_table_prefix = format!("arn:aws:dynamodb:{region}:{account}:table/"); + if let Some(name) = value.strip_prefix(&own_table_prefix) + && name.contains('/') + { + DynamoDbError::ValidationException(format!( + "1 validation error detected: Value '{value}' at 'tableName' failed to \ + satisfy constraint: Valid ARN format is \ + 'arn:::::resourceType/resourceName', \ + where Resource name must have length less than or equal to 255, Resource \ + name must have length greater than or equal to 3, Resource name must \ + satisfy regular expression pattern: [a-zA-Z0-9_.-]+" + )) + } else { + DynamoDbError::ValidationException(format!("Invalid table ARN: {value}")) + } + } + } +} + +/// Hold the name a table ARN of the caller's own account resolved to, to +/// the table-name rule. The service answers one clause naming the whole ARN, +/// the length bounds before the pattern (measured with `ab`, `..`, `a*`, a +/// 256-character name, `deni*`, `den ied`, and a 252-character name ending +/// in ` *`), where a bare name answers every failing rule in one envelope. +/// +/// # Errors +/// +/// Returns `ValidationException` with the measured clause. +pub fn check_table_arn_name(value: &str, name: &str) -> Result<(), DynamoDbError> { + let chars = name.chars().count(); + let rule = if chars < MIN_NAME_CHARS { + format!("must have length greater than or equal to {MIN_NAME_CHARS}") + } else if chars > MAX_NAME_CHARS { + format!("must have length less than or equal to {MAX_NAME_CHARS}") + } else if !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')) + { + "must satisfy regular expression pattern: [a-zA-Z0-9_.-]+".to_owned() + } else { + return Ok(()); + }; + Err(DynamoDbError::ValidationException(format!( + "1 validation error detected: Value '{value}' at 'tableName' failed to satisfy \ + constraint: Table name of ARN {rule}" + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const REGION: &str = "us-east-1"; + const ACCOUNT: &str = "123456789012"; + + fn message(error: &DynamoDbError) -> &str { + match error { + DynamoDbError::ValidationException(message) + | DynamoDbError::AccessDeniedException(message) => message, + other => panic!("unexpected error {other:?}"), + } + } + + fn arn(name: &str) -> String { + format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/{name}") + } + + #[test] + fn a_name_under_three_characters_answers_the_minimum() { + for name in ["ab", "..", "a*"] { + let value = arn(name); + let error = check_table_arn_name(&value, name).unwrap_err(); + assert_eq!( + message(&error), + format!( + "1 validation error detected: Value '{value}' at 'tableName' failed to \ + satisfy constraint: Table name of ARN must have length greater than or \ + equal to 3" + ), + "{name}" + ); + } + } + + #[test] + fn a_name_over_255_characters_answers_the_maximum() { + let name = "a".repeat(256); + let value = arn(&name); + let error = check_table_arn_name(&value, &name).unwrap_err(); + assert!( + message(&error) + .ends_with("Table name of ARN must have length less than or equal to 255"), + "{}", + message(&error) + ); + } + + #[test] + fn a_name_outside_the_pattern_answers_the_pattern() { + let long = format!("{} *", "b".repeat(250)); + for name in ["deni*", "den ied", long.as_str()] { + let value = arn(name); + let error = check_table_arn_name(&value, name).unwrap_err(); + assert_eq!( + message(&error), + format!( + "1 validation error detected: Value '{value}' at 'tableName' failed to \ + satisfy constraint: Table name of ARN must satisfy regular expression \ + pattern: [a-zA-Z0-9_.-]+" + ), + "{name}" + ); + } + } + + #[test] + fn a_name_inside_the_rule_passes() { + for name in ["abc", "a.b-c_d", &"z".repeat(255)] { + check_table_arn_name(&arn(name), name).unwrap(); + } + } + + #[test] + fn an_index_arn_answers_the_resource_name_format() { + let value = arn("x/index/gsi"); + let error = table_arn_refusal(&value, REGION, ACCOUNT, TableArnError::Invalid); + assert!( + message(&error) + .contains("at 'tableName' failed to satisfy constraint: Valid ARN format is"), + "{}", + message(&error) + ); + } + + #[test] + fn other_refusals_keep_their_texts() { + let foreign = "arn:aws:dynamodb:us-east-1:111111111111:table/xyz"; + assert_eq!( + message(&table_arn_refusal( + foreign, + REGION, + ACCOUNT, + TableArnError::ForeignAccount + )), + "Access is denied" + ); + let shape = "arn:aws:s3:::orders"; + assert_eq!( + message(&table_arn_refusal( + shape, + REGION, + ACCOUNT, + TableArnError::Invalid + )), + format!("Invalid table ARN: {shape}") + ); + } +} diff --git a/crates/core/src/limits/mod.rs b/crates/core/src/limits/mod.rs index a894f1b83..df52443b3 100755 --- a/crates/core/src/limits/mod.rs +++ b/crates/core/src/limits/mod.rs @@ -65,6 +65,88 @@ pub struct LimitsConfig { /// Reject bare reserved keywords in expressions (DynamoDB-compatible default: true). #[serde(default = "default_enforce_reserved_keywords")] pub enforce_reserved_keywords: bool, + /// Maximum non-terminal bulk operations per account. `StartBulkPutItems` + /// rejects above it. + #[serde(default = "default_max_concurrent_bulk_operations")] + pub max_concurrent_bulk_operations: u32, + /// Maximum enumerated (compressed) input bytes per bulk operation. + /// `StartBulkPutItems` rejects above it. + #[serde(default = "default_max_bulk_input_bytes")] + pub max_bulk_input_bytes: u64, + /// Maximum enumerated input object count per bulk operation. Above it + /// the Start is accepted and the operation ends `FAILED` with + /// `S3ObjectsCountLimitExceeded` (the hosted service's shape; its quota + /// is 50,000). + #[serde(default = "default_max_bulk_input_files")] + pub max_bulk_input_files: u64, + /// Maximum size of one enumerated input object. Above it the Start is + /// accepted and the operation ends `FAILED` with `InputObjectTooLarge` + /// (measured on the service; the hosted service's cap is 5 GiB, rendered + /// as `5.0 GB` in its message). + #[serde(default = "default_max_bulk_input_object_bytes")] + pub max_bulk_input_object_bytes: u64, + /// Maximum `ClientToken` length in bytes on `StartBulkPutItems`. The API + /// model puts no length cap on the token; this bounds what one request + /// can ask the catalog to store. + #[serde(default = "default_max_bulk_client_token_bytes")] + pub max_bulk_client_token_bytes: usize, + /// Days a terminal bulk operation record is kept before the retention + /// worker purges it, its report records, and its ledger rows. + #[serde(default = "default_bulk_operation_retention_days")] + pub bulk_operation_retention_days: u32, + /// Bulk operations one process runs at once (count of runner tasks). + #[serde(default = "default_bulk_operation_worker_concurrency")] + pub bulk_operation_worker_concurrency: usize, + /// Of the concurrent runner tasks, at most this many per account + /// (count), so one account cannot hold every slot. + #[serde(default = "default_bulk_operation_worker_per_account")] + pub bulk_operation_worker_per_account: usize, + /// Length in seconds of the lease a runner holds on its operation; + /// renewed at every checkpoint. + #[serde(default = "default_bulk_operation_lease_seconds")] + pub bulk_operation_lease_seconds: u64, + /// Rows per checkpoint (count): the runner persists cursor, counters, + /// and report records after this many consumed lines. + #[serde(default = "default_bulk_put_batch_size")] + pub bulk_put_batch_size: usize, + /// Rows per storage transaction (count): a bulk runner hands this many + /// admitted rows to storage in one batch. Capped at 100 when read, and + /// bounded by `bulk_put_batch_size` because the batch is flushed at every + /// checkpoint; raising it alone above the checkpoint size has no effect + /// and is logged. A batch holds its row locks (and on SQLite the process + /// write lock) for its whole transaction. + #[serde(default = "default_bulk_put_write_batch_rows")] + pub bulk_put_write_batch_rows: usize, + /// Longest accepted input line in bytes; a longer line abandons the + /// rest of its file. + #[serde(default = "default_max_bulk_line_bytes")] + pub max_bulk_line_bytes: usize, + /// Physical lines one bulk operation may consume (count, blank lines + /// included); exceeding it fails the operation. + #[serde(default = "default_max_bulk_item_count")] + pub max_bulk_item_count: u64, + /// Report records kept per bulk operation (count); records beyond it + /// are dropped and the drop is logged. + #[serde(default = "default_max_bulk_report_records")] + pub max_bulk_report_records: u64, + /// Serialized bytes of report records kept per bulk operation; records + /// beyond it are dropped and the drop is logged. + #[serde(default = "default_max_bulk_report_bytes")] + pub max_bulk_report_bytes: u64, + /// Terminal-born bulk operation starts per account per hour (rows + /// inserted already `FAILED`, from requests naming an absent bucket). At + /// or above it `StartBulkPutItems` answers `ThrottlingException` and + /// inserts nothing; the handler treats values below 1 as 1. + #[serde(default = "default_max_bulk_failed_starts_per_hour")] + pub max_bulk_failed_starts_per_hour: u64, + /// Ceiling on bulk put writes in this process, in write request units + /// per second (1 KB of item, rounded up, per unit; the unit + /// `BulkPutBytes` charges). Shared by every operation the process runs, + /// so bulk loads leave the rest of the storage write path to request + /// traffic on the same server. `0` disables the ceiling. A request's + /// own `MaxWriteRequestUnits` paces that operation on top of this. + #[serde(default = "default_bulk_put_write_units_per_second")] + pub bulk_put_write_units_per_second: u64, } impl Default for LimitsConfig { @@ -93,6 +175,23 @@ impl Default for LimitsConfig { max_import_item_count: default_max_import_item_count(), max_export_item_count: default_max_export_item_count(), enforce_reserved_keywords: default_enforce_reserved_keywords(), + max_concurrent_bulk_operations: default_max_concurrent_bulk_operations(), + max_bulk_input_bytes: default_max_bulk_input_bytes(), + max_bulk_input_files: default_max_bulk_input_files(), + max_bulk_input_object_bytes: default_max_bulk_input_object_bytes(), + max_bulk_client_token_bytes: default_max_bulk_client_token_bytes(), + bulk_operation_retention_days: default_bulk_operation_retention_days(), + bulk_operation_worker_concurrency: default_bulk_operation_worker_concurrency(), + bulk_operation_worker_per_account: default_bulk_operation_worker_per_account(), + bulk_operation_lease_seconds: default_bulk_operation_lease_seconds(), + bulk_put_batch_size: default_bulk_put_batch_size(), + bulk_put_write_batch_rows: default_bulk_put_write_batch_rows(), + max_bulk_line_bytes: default_max_bulk_line_bytes(), + max_bulk_item_count: default_max_bulk_item_count(), + max_bulk_report_records: default_max_bulk_report_records(), + max_bulk_report_bytes: default_max_bulk_report_bytes(), + max_bulk_failed_starts_per_hour: default_max_bulk_failed_starts_per_hour(), + bulk_put_write_units_per_second: default_bulk_put_write_units_per_second(), } } } @@ -165,3 +264,63 @@ fn default_max_export_item_count() -> u64 { fn default_enforce_reserved_keywords() -> bool { true } +fn default_max_concurrent_bulk_operations() -> u32 { + 50 +} +fn default_max_bulk_input_bytes() -> u64 { + // 10 GB, matching the import file cap. + 10_737_418_240 +} +fn default_max_bulk_input_files() -> u64 { + // The hosted service's per-operation object quota. + 50_000 +} +fn default_max_bulk_input_object_bytes() -> u64 { + // 5 GiB, the hosted service's per-object cap. + 5_368_709_120 +} +fn default_max_bulk_client_token_bytes() -> usize { + 1024 +} +fn default_bulk_operation_retention_days() -> u32 { + 30 +} +fn default_bulk_operation_worker_concurrency() -> usize { + 4 +} +fn default_bulk_operation_worker_per_account() -> usize { + 2 +} +fn default_bulk_operation_lease_seconds() -> u64 { + 60 +} +fn default_bulk_put_batch_size() -> usize { + 25 +} +fn default_bulk_put_write_batch_rows() -> usize { + 25 +} +fn default_max_bulk_line_bytes() -> usize { + // 4 MiB. + 4_194_304 +} +fn default_max_bulk_item_count() -> u64 { + 100_000_000 +} +fn default_max_bulk_report_records() -> u64 { + 1_000_000 +} +fn default_max_bulk_report_bytes() -> u64 { + // 1 GiB. + 1_073_741_824 +} +fn default_max_bulk_failed_starts_per_hour() -> u64 { + 100 +} +fn default_bulk_put_write_units_per_second() -> u64 { + // The per-account write ceiling the request path enforces + // (`per_account_max_wcu`): bulk writes in one process are held to the + // same rate one account's request traffic is. Far above what one runner + // sustains, so it binds only when many operations saturate a server. + default_per_account_max_wcu() +} diff --git a/crates/core/src/metrics/types.rs b/crates/core/src/metrics/types.rs index 85811deaa..8bd5ae246 100755 --- a/crates/core/src/metrics/types.rs +++ b/crates/core/src/metrics/types.rs @@ -55,6 +55,15 @@ pub enum MetricName { WorkerCycleLatency, /// P120e: Worker error count. WorkerErrorCount, + /// Bulk operations created by `StartBulkPutItems` (dimensions: table). + BulkOperationsStarted, + /// Bulk operations that reached a terminal status (dimensions: the + /// operation dimension carries the terminal status). + BulkOperationsFinished, + /// Items a bulk operation wrote to its table, counted in the process + /// whose write committed; replayed rows and condition failures are not + /// counted (dimensions: table). + BulkPutItemsWritten, } impl std::fmt::Display for MetricName { @@ -86,6 +95,9 @@ impl std::fmt::Display for MetricName { Self::WorkerLastSuccess => f.write_str("WorkerLastSuccess"), Self::WorkerCycleLatency => f.write_str("WorkerCycleLatency"), Self::WorkerErrorCount => f.write_str("WorkerErrorCount"), + Self::BulkOperationsStarted => f.write_str("BulkOperationsStarted"), + Self::BulkOperationsFinished => f.write_str("BulkOperationsFinished"), + Self::BulkPutItemsWritten => f.write_str("BulkPutItemsWritten"), } } } @@ -131,6 +143,10 @@ pub enum QuerySource { LoginAttemptCleanup, /// Background: the backup reconciler. BackupReconciler, + /// Background: bulk operation claim loop. + BulkClaim, + /// Background: bulk operation retention purge. + BulkRetention, /// Management CLI commands. Management, } @@ -152,6 +168,8 @@ impl std::fmt::Display for QuerySource { Self::ControlPlane => f.write_str("ControlPlane"), Self::LoginAttemptCleanup => f.write_str("LoginAttemptCleanup"), Self::BackupReconciler => f.write_str("BackupReconciler"), + Self::BulkClaim => f.write_str("BulkClaim"), + Self::BulkRetention => f.write_str("BulkRetention"), Self::Management => f.write_str("Management"), } } diff --git a/crates/core/src/types/bulk_operation.rs b/crates/core/src/types/bulk_operation.rs new file mode 100644 index 000000000..b72e9eb0d --- /dev/null +++ b/crates/core/src/types/bulk_operation.rs @@ -0,0 +1,546 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Types for bulk operations: `StartBulkPutItems`, `DescribeBulkOperation`, +//! `ListBulkOperations`, and `CancelBulkOperation`. +//! +//! Member names follow the API model. On the S3 store `S3Bucket` and +//! `S3KeyPrefix` name a bucket and a key prefix; on the filesystem store +//! they name a directory under the caller's account subtree of the root and +//! a path prefix within it. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// Input format for bulk put operations. +/// +/// Unlike `ImportTable`'s `InputFormat`, this enum has no CSV member; the +/// model defines a separate two-member set for bulk operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum BulkOperationInputFormat { + /// `DynamoDB` JSON format (`{"Item": {"pk": {"S": "val"}, ...}}`). + #[serde(rename = "DYNAMODB_JSON")] + DynamodbJson, + /// Amazon Ion text format. + #[serde(rename = "ION")] + Ion, +} + +impl<'de> Deserialize<'de> for BulkOperationInputFormat { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "DYNAMODB_JSON" => Ok(Self::DynamodbJson), + "ION" => Ok(Self::Ion), + other => Err(serde::de::Error::custom(format!( + "1 validation error detected: Value '{other}' at 'inputFormat' \ + failed to satisfy constraint: Member must satisfy enum value set: \ + [DYNAMODB_JSON, ION]" + ))), + } + } +} + +/// Compression applied to bulk input files. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum InputCompressionType { + /// Gzip-compressed input files. + #[serde(rename = "GZIP")] + Gzip, + /// Zstandard-compressed input files, decoded while reading. + #[serde(rename = "ZSTD")] + Zstd, + /// Uncompressed input files. An absent `InputCompressionType` is read as + /// this value. + #[serde(rename = "NONE")] + None, +} + +impl<'de> Deserialize<'de> for InputCompressionType { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "GZIP" => Ok(Self::Gzip), + "ZSTD" => Ok(Self::Zstd), + "NONE" => Ok(Self::None), + other => Err(serde::de::Error::custom(format!( + "1 validation error detected: Value '{other}' at 'inputCompressionType' \ + failed to satisfy constraint: Member must satisfy enum value set: \ + [GZIP, ZSTD, NONE]" + ))), + } + } +} + +/// Performance tier for a bulk operation. Recorded and returned; a single +/// node makes no behavioural distinction between the two. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum BulkOperationPerformanceTier { + /// Default tier. + #[serde(rename = "STANDARD")] + Standard, + /// Accelerated tier. + #[serde(rename = "ACCELERATED")] + Accelerated, +} + +impl<'de> Deserialize<'de> for BulkOperationPerformanceTier { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "STANDARD" => Ok(Self::Standard), + "ACCELERATED" => Ok(Self::Accelerated), + other => Err(serde::de::Error::custom(format!( + "1 validation error detected: Value '{other}' at 'performanceTier' \ + failed to satisfy constraint: Member must satisfy enum value set: \ + [STANDARD, ACCELERATED]" + ))), + } + } +} + +/// Report record kinds an operation writes to its output location. +/// +/// The model names this shape `OutputTypeEnum` and the list member +/// `OutputType`; the wire member name is what clients see, so the type is +/// named after it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum OutputType { + /// Rows that failed a parse-level or item-level check. + #[serde(rename = "ERROR")] + Error, + /// Rows whose condition evaluated false at the write. + #[serde(rename = "CONDITION_CHECK_FAILURE")] + ConditionCheckFailure, +} + +impl<'de> Deserialize<'de> for OutputType { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "ERROR" => Ok(Self::Error), + "CONDITION_CHECK_FAILURE" => Ok(Self::ConditionCheckFailure), + // The value sits inside a list, so the service would report a + // positional path here; a serde deserializer cannot know its + // index. Same limitation as `DistanceFunction` in table.rs. + other => Err(serde::de::Error::custom(format!( + "1 validation error detected: Value '{other}' at 'outputType' \ + failed to satisfy constraint: Member must satisfy enum value set: \ + [ERROR, CONDITION_CHECK_FAILURE]" + ))), + } + } +} + +/// Server-side encryption algorithm for the report location. Recorded, no +/// effect. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum S3SseAlgorithm { + /// AES-256 encryption. + #[serde(rename = "AES256")] + Aes256, + /// KMS-managed key encryption. + #[serde(rename = "KMS")] + Kms, +} + +impl<'de> Deserialize<'de> for S3SseAlgorithm { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "AES256" => Ok(Self::Aes256), + "KMS" => Ok(Self::Kms), + other => Err(serde::de::Error::custom(format!( + "1 validation error detected: Value '{other}' at 's3SseAlgorithm' \ + failed to satisfy constraint: Member must satisfy enum value set: \ + [AES256, KMS]" + ))), + } + } +} + +/// Status of a bulk operation. +/// +/// The transitions are `IN_PROGRESS` to any of `COMPLETED`, +/// `COMPLETED_WITH_ERRORS`, `FAILED`, or `CANCELLING`, and `CANCELLING` to +/// `CANCELLED` only. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum BulkOperationStatus { + /// The operation is running. + #[serde(rename = "IN_PROGRESS")] + InProgress, + /// Every row processed, no failed items. Condition failures do not + /// count as failed items. + #[serde(rename = "COMPLETED")] + Completed, + /// Every row processed, one or more failed items. + #[serde(rename = "COMPLETED_WITH_ERRORS")] + CompletedWithErrors, + /// A cancel was accepted and the operation is stopping. + #[serde(rename = "CANCELLING")] + Cancelling, + /// The operation stopped after a cancel. + #[serde(rename = "CANCELLED")] + Cancelled, + /// The operation could not continue. + #[serde(rename = "FAILED")] + Failed, +} + +impl<'de> Deserialize<'de> for BulkOperationStatus { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "IN_PROGRESS" => Ok(Self::InProgress), + "COMPLETED" => Ok(Self::Completed), + "COMPLETED_WITH_ERRORS" => Ok(Self::CompletedWithErrors), + "CANCELLING" => Ok(Self::Cancelling), + "CANCELLED" => Ok(Self::Cancelled), + "FAILED" => Ok(Self::Failed), + other => Err(serde::de::Error::custom(format!( + "1 validation error detected: Value '{other}' at 'bulkOperationStatus' \ + failed to satisfy constraint: Member must satisfy enum value set: \ + [IN_PROGRESS, COMPLETED, COMPLETED_WITH_ERRORS, CANCELLING, CANCELLED, FAILED]" + ))), + } + } +} + +impl BulkOperationStatus { + /// Whether this status is terminal. + /// + /// Every poll loop and every state check must treat all four terminal + /// statuses alike, including `COMPLETED_WITH_ERRORS`. + #[must_use] + pub fn is_terminal(self) -> bool { + matches!( + self, + Self::Completed | Self::CompletedWithErrors | Self::Failed | Self::Cancelled + ) + } +} + +/// Input file location and format for `StartBulkPutItems`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct InputS3Configuration { + /// Directory name under the configured import root, resolved per + /// account. + #[serde(rename = "S3Bucket")] + pub s3_bucket: String, + /// Prefix over bucket-relative file paths. Empty or absent means every + /// file in the bucket directory. + #[serde(rename = "S3KeyPrefix", skip_serializing_if = "Option::is_none")] + pub s3_key_prefix: Option, + /// Format of the input files. + #[serde(rename = "InputFormat")] + pub input_format: BulkOperationInputFormat, + /// Compression applied to the input files; absent is read as `NONE`. + #[serde( + rename = "InputCompressionType", + skip_serializing_if = "Option::is_none" + )] + pub input_compression_type: Option, + /// Owning account id; when present it must equal the caller's account. + #[serde(rename = "S3BucketOwner", skip_serializing_if = "Option::is_none")] + pub s3_bucket_owner: Option, +} + +/// Report location and record selection for `StartBulkPutItems`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct OutputS3Configuration { + /// Which report record kinds are written. The model requires the member; + /// the handler rejects an empty list. + #[serde(rename = "OutputType")] + pub output_type: Vec, + /// Directory name under the configured export root. Absent means no + /// report is written; counters are still maintained. + #[serde(rename = "S3Bucket", skip_serializing_if = "Option::is_none")] + pub s3_bucket: Option, + /// Prefix under which the report tree is written. + #[serde(rename = "S3KeyPrefix", skip_serializing_if = "Option::is_none")] + pub s3_key_prefix: Option, + /// Owning account id; when present it must equal the caller's account. + #[serde(rename = "S3BucketOwner", skip_serializing_if = "Option::is_none")] + pub s3_bucket_owner: Option, + /// Server-side encryption algorithm. Recorded, no effect. + #[serde(rename = "S3SseAlgorithm", skip_serializing_if = "Option::is_none")] + pub s3_sse_algorithm: Option, + /// KMS key for server-side encryption. Recorded, no effect. + #[serde(rename = "S3SseKmsKeyId", skip_serializing_if = "Option::is_none")] + pub s3_sse_kms_key_id: Option, +} + +/// Input for `StartBulkPutItems`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct StartBulkPutItemsInput { + /// A bare table name or a table ARN in the caller's account and region. + #[serde(rename = "TableName")] + pub table_name: String, + /// Idempotency token. A repeated token with the same parameters replays + /// the existing operation. + #[serde(rename = "ClientToken", skip_serializing_if = "Option::is_none")] + pub client_token: Option, + /// Input file location and format. + #[serde(rename = "InputS3Configuration")] + pub input_s3_configuration: InputS3Configuration, + /// Report location and record selection. + #[serde(rename = "OutputS3Configuration")] + pub output_s3_configuration: OutputS3Configuration, + /// Write rate cap for the operation. The model constrains it to + /// `>= 1`; the Start handler enforces the range in a later phase, so + /// the wire type is signed and an out-of-range value reaches the + /// handler as a value rather than dying in deserialization, like + /// `Limit` on `QueryInput`. Recorded and returned. + #[serde( + rename = "MaxWriteRequestUnits", + skip_serializing_if = "Option::is_none" + )] + pub max_write_request_units: Option, + /// Performance tier; absent means `STANDARD`. + #[serde(rename = "PerformanceTier", skip_serializing_if = "Option::is_none")] + pub performance_tier: Option, +} + +/// Bytes written to the table or to one secondary index. +/// +/// The wire value is a structure holding one member, not a bare number. +#[derive(Debug, Clone, Copy, Serialize)] +pub struct BulkOperationBytes { + /// Bytes written, rounded per item to the next 1 KB with a 1 KB minimum. + #[serde(rename = "BulkPutBytes")] + pub bulk_put_bytes: u64, +} + +/// Bytes written to one vector index. +/// +/// The wire value is a structure holding one member, not a bare number. +/// Vector index bytes are metered in their own unit and are not included in +/// the top-level `BulkPutBytes`. +#[derive(Debug, Clone, Copy, Serialize)] +pub struct BulkOperationVectorBytes { + /// Vector write bytes for the index. + #[serde(rename = "BulkPutVectorBytes")] + pub bulk_put_vector_bytes: u64, +} + +/// Usage entry for one table in a terminal bulk operation description. +#[derive(Debug, Clone, Serialize)] +pub struct BulkOperationUsage { + /// Bare name of the table. The model types the member `TableArn`, but + /// the service emits the bare table name here (measured on the + /// service), not the ARN. + #[serde(rename = "TableName")] + pub table_name: String, + /// Total bytes written to the base table. + #[serde(rename = "BulkPutBytes")] + pub bulk_put_bytes: u64, + /// Base table bytes as a nested structure; the same figure as + /// `BulkPutBytes`. + #[serde(rename = "Table")] + pub table: BulkOperationBytes, + /// Bytes written to each global secondary index. An index no row + /// projects into has no entry, so a map with no entries is omitted + /// (measured on the service). + #[serde( + rename = "GlobalSecondaryIndexes", + skip_serializing_if = "HashMap::is_empty" + )] + pub global_secondary_indexes: HashMap, + /// Bytes written to each local secondary index. An empty map is omitted + /// (measured on the service). + #[serde( + rename = "LocalSecondaryIndexes", + skip_serializing_if = "HashMap::is_empty" + )] + pub local_secondary_indexes: HashMap, + /// Vector write bytes for each vector index. An empty map is omitted + /// (measured on the service). + #[serde(rename = "VectorIndexes", skip_serializing_if = "HashMap::is_empty")] + pub vector_indexes: HashMap, +} + +/// One entry of a description's `TableDetails`: the target table's name +/// and id. +#[derive(Debug, Clone, Serialize)] +pub struct BulkOperationTableDetail { + /// Bare name of the target table. + #[serde(rename = "TableName")] + pub table_name: String, + /// Id of the target table. + #[serde(rename = "TableId")] + pub table_id: String, +} + +/// Description of a bulk operation. +#[derive(Debug, Clone, Serialize)] +pub struct BulkOperationDescription { + /// ARN of the operation. + #[serde(rename = "BulkOperationArn")] + pub bulk_operation_arn: String, + /// Current status. + #[serde(rename = "BulkOperationStatus")] + pub bulk_operation_status: BulkOperationStatus, + /// Name and id of the target table. One entry; the model types the + /// member as a list. Present on the Start response and on every + /// Describe (measured on the service). + #[serde(rename = "TableDetails")] + pub table_details: Vec, + /// ARN of the target table. Modelled, never emitted by the service + /// (measured on the service), so always `None` on the wire. + #[serde(rename = "TableArn", skip_serializing_if = "Option::is_none")] + pub table_arn: Option, + /// Id of the target table. Modelled, never emitted by the service + /// (measured on the service); the id travels in `TableDetails`. + #[serde(rename = "TableId", skip_serializing_if = "Option::is_none")] + pub table_id: Option, + /// Client token the operation was started with. + #[serde(rename = "ClientToken", skip_serializing_if = "Option::is_none")] + pub client_token: Option, + /// Input configuration as submitted. + #[serde( + rename = "InputS3Configuration", + skip_serializing_if = "Option::is_none" + )] + pub input_s3_configuration: Option, + /// Output configuration as submitted. + #[serde( + rename = "OutputS3Configuration", + skip_serializing_if = "Option::is_none" + )] + pub output_s3_configuration: Option, + /// Start time (epoch seconds). + #[serde(rename = "StartTime", skip_serializing_if = "Option::is_none")] + pub start_time: Option, + /// End time (epoch seconds), present once terminal. + #[serde(rename = "EndTime", skip_serializing_if = "Option::is_none")] + pub end_time: Option, + /// Rows read and attempted, including failed rows and condition + /// failures. + #[serde(rename = "ProcessedItemCount", skip_serializing_if = "Option::is_none")] + pub processed_item_count: Option, + /// Bytes of input consumed so far. + #[serde( + rename = "ProcessedInputBytes", + skip_serializing_if = "Option::is_none" + )] + pub processed_input_bytes: Option, + /// Sum of the enumerated sizes of all matched input files, fixed at + /// start. + #[serde(rename = "TotalInputBytes", skip_serializing_if = "Option::is_none")] + pub total_input_bytes: Option, + /// Usage entries, present once terminal; one entry for the table. + #[serde(rename = "BulkOperationUsage", skip_serializing_if = "Option::is_none")] + pub bulk_operation_usage: Option>, + /// Rows that failed a parse-level or item-level check. + #[serde(rename = "FailedItemCount", skip_serializing_if = "Option::is_none")] + pub failed_item_count: Option, + /// Exception type name explaining a `FAILED` status. + #[serde(rename = "FailureCode", skip_serializing_if = "Option::is_none")] + pub failure_code: Option, + /// Message explaining a `FAILED` status. + #[serde(rename = "FailureMessage", skip_serializing_if = "Option::is_none")] + pub failure_message: Option, + /// Write rate cap the operation was started with. Signed like the + /// input field it echoes; the model types both as the same `long`. + #[serde( + rename = "MaxWriteRequestUnits", + skip_serializing_if = "Option::is_none" + )] + pub max_write_request_units: Option, + /// Performance tier the operation runs at. + #[serde(rename = "PerformanceTier", skip_serializing_if = "Option::is_none")] + pub performance_tier: Option, +} + +/// Output for `StartBulkPutItems`. +#[derive(Debug, Clone, Serialize)] +pub struct StartBulkPutItemsOutput { + /// Description of the started operation; status is always + /// `IN_PROGRESS`. + #[serde(rename = "BulkOperationDescription")] + pub bulk_operation_description: BulkOperationDescription, +} + +/// Input for `DescribeBulkOperation`. +#[derive(Debug, Clone, Deserialize)] +pub struct DescribeBulkOperationInput { + /// ARN of the operation to describe. + #[serde(rename = "BulkOperationArn")] + pub bulk_operation_arn: String, +} + +/// Output for `DescribeBulkOperation`. +#[derive(Debug, Clone, Serialize)] +pub struct DescribeBulkOperationOutput { + /// Description of the operation. + #[serde(rename = "BulkOperationDescription")] + pub bulk_operation_description: BulkOperationDescription, +} + +/// Input for `ListBulkOperations`. +#[derive(Debug, Clone, Deserialize)] +pub struct ListBulkOperationsInput { + /// Page size. The model constrains it to 1 to 25 inclusive; absent + /// means 10 (measured). The List handler enforces the range, so the + /// wire type is signed and an out-of-range value reaches the handler as + /// a value rather than dying in deserialization, like `Limit` on + /// `QueryInput`. + #[serde(rename = "MaxResults")] + pub max_results: Option, + /// Pagination cursor from a previous response. + #[serde(rename = "NextToken")] + pub next_token: Option, +} + +/// Summary of one operation in a `ListBulkOperations` page. +#[derive(Debug, Clone, Serialize)] +pub struct BulkOperationSummary { + /// ARN of the operation. + #[serde(rename = "BulkOperationArn")] + pub bulk_operation_arn: String, + /// Current status. + #[serde(rename = "BulkOperationStatus")] + pub bulk_operation_status: BulkOperationStatus, + /// Start time (epoch seconds). + #[serde(rename = "StartTime")] + pub start_time: f64, + /// End time (epoch seconds), present once terminal. + #[serde(rename = "EndTime", skip_serializing_if = "Option::is_none")] + pub end_time: Option, +} + +/// Output for `ListBulkOperations`. +#[derive(Debug, Clone, Serialize)] +pub struct ListBulkOperationsOutput { + /// One page of summaries, newest first. + #[serde(rename = "BulkOperationsSummaryList")] + pub bulk_operations_summary_list: Vec, + /// Cursor for the next page; absent on the last page. + #[serde(rename = "NextToken", skip_serializing_if = "Option::is_none")] + pub next_token: Option, +} + +/// Input for `CancelBulkOperation`. +#[derive(Debug, Clone, Deserialize)] +pub struct CancelBulkOperationInput { + /// ARN of the operation to cancel. + #[serde(rename = "BulkOperationArn")] + pub bulk_operation_arn: String, +} + +/// Output for `CancelBulkOperation`. The model requires both members and +/// defines no others. +#[derive(Debug, Clone, Serialize)] +pub struct CancelBulkOperationOutput { + /// ARN of the operation. + #[serde(rename = "BulkOperationArn")] + pub bulk_operation_arn: String, + /// Status after the cancel, normally `CANCELLING`. + #[serde(rename = "BulkOperationStatus")] + pub bulk_operation_status: BulkOperationStatus, +} + +#[cfg(test)] +#[path = "bulk_operation_tests/mod.rs"] +mod bulk_operation_tests; diff --git a/crates/core/src/types/bulk_operation_tests/description.rs b/crates/core/src/types/bulk_operation_tests/description.rs new file mode 100644 index 000000000..f2459ea59 --- /dev/null +++ b/crates/core/src/types/bulk_operation_tests/description.rs @@ -0,0 +1,290 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Serialization of the bulk operation description, usage, summary and outputs. + +use super::*; + +#[test] +fn description_start_time_serializes_as_a_json_number() { + let mut description = minimal_description(); + description.start_time = Some(1_789_060_589.767); + description.end_time = Some(1_789_060_650.634); + let value = serde_json::to_value(&description).unwrap(); + assert!( + value["StartTime"].is_number(), + "StartTime must be a JSON number, got: {}", + value["StartTime"] + ); + assert_eq!(value["StartTime"], json!(1_789_060_589.767)); + assert!( + value["EndTime"].is_number(), + "EndTime must be a JSON number, got: {}", + value["EndTime"] + ); + assert_eq!(value["EndTime"], json!(1_789_060_650.634)); +} + +#[test] +fn description_with_only_required_fields_serializes_the_three_always_present_keys() { + let value = serde_json::to_value(minimal_description()).unwrap(); + let object = value.as_object().unwrap(); + assert_eq!( + object.keys().map(String::as_str).collect::>(), + vec!["BulkOperationArn", "BulkOperationStatus", "TableDetails"], + "absent optional members must be omitted, not serialized as null" + ); + assert_eq!(value["BulkOperationArn"], json!(ARN)); + assert_eq!(value["BulkOperationStatus"], json!("IN_PROGRESS")); +} + +#[test] +fn fully_populated_description_uses_the_model_wire_names() { + let value = serde_json::to_value(full_description()).unwrap(); + let object = value.as_object().unwrap(); + for key in [ + "BulkOperationArn", + "BulkOperationStatus", + "TableDetails", + "TableArn", + "TableId", + "ClientToken", + "InputS3Configuration", + "OutputS3Configuration", + "StartTime", + "EndTime", + "ProcessedItemCount", + "FailedItemCount", + "ProcessedInputBytes", + "TotalInputBytes", + "BulkOperationUsage", + "FailureCode", + "FailureMessage", + "MaxWriteRequestUnits", + "PerformanceTier", + ] { + assert!(object.contains_key(key), "missing wire member {key}"); + } + assert!(value["BulkOperationUsage"].is_array()); + assert_eq!(value["PerformanceTier"], json!("STANDARD")); +} + +#[test] +fn table_details_carries_one_entry_of_the_table_name_and_id() { + // Measured on the service: every description carries + // TableDetails as a one-entry list of the bare table name and the + // table id. + let value = serde_json::to_value(full_description()).unwrap(); + assert_eq!( + value["TableDetails"], + json!([{ + "TableName": "orders", + "TableId": "0ec72c3d-0000-0000-0000-000000000000", + }]) + ); +} + +#[test] +fn input_configuration_round_trips_through_the_wire_names() { + let value = serde_json::to_value(input_configuration()).unwrap(); + assert_eq!(value, full_start_body()["InputS3Configuration"]); +} + +#[test] +fn output_configuration_round_trips_through_the_wire_names() { + let value = serde_json::to_value(output_configuration()).unwrap(); + assert_eq!(value, full_start_body()["OutputS3Configuration"]); +} + +#[test] +fn output_configuration_absent_optionals_are_omitted() { + let config: OutputS3Configuration = + serde_json::from_value(json!({ "OutputType": ["ERROR"] })).unwrap(); + let value = serde_json::to_value(&config).unwrap(); + assert_eq!( + value + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect::>(), + vec!["OutputType"], + "absent optional members must be omitted, not serialized as null" + ); +} + +#[test] +fn usage_table_member_is_an_object_carrying_bulk_put_bytes() { + let value = serde_json::to_value(usage()).unwrap(); + assert!( + value["Table"].is_object(), + "Table must be a nested object, not a scalar, got: {}", + value["Table"] + ); + assert_eq!(value["Table"], json!({ "BulkPutBytes": 25600 })); +} + +#[test] +fn usage_gsi_map_values_are_objects_carrying_bulk_put_bytes() { + let value = serde_json::to_value(usage()).unwrap(); + assert!( + value["GlobalSecondaryIndexes"]["gsi1"].is_object(), + "index map values must be nested objects, not scalars, got: {}", + value["GlobalSecondaryIndexes"]["gsi1"] + ); + assert_eq!( + value["GlobalSecondaryIndexes"], + json!({ "gsi1": { "BulkPutBytes": 4096 } }) + ); + assert!( + value.get("LocalSecondaryIndexes").is_none(), + "an empty index map is omitted, not serialized as {{}}" + ); +} + +#[test] +fn usage_vector_map_values_are_objects_carrying_bulk_put_vector_bytes() { + let value = serde_json::to_value(usage()).unwrap(); + assert!( + value["VectorIndexes"]["vi1"].is_object(), + "vector map values must be nested objects, not scalars, got: {}", + value["VectorIndexes"]["vi1"] + ); + assert_eq!( + value["VectorIndexes"], + json!({ "vi1": { "BulkPutVectorBytes": 8192 } }) + ); +} + +#[test] +fn usage_serializes_the_top_level_table_name_and_bulk_put_bytes() { + let value = serde_json::to_value(usage()).unwrap(); + assert_eq!(value["TableName"], json!("orders")); + assert_eq!(value["BulkPutBytes"], json!(25600)); +} + +#[test] +fn summary_without_end_time_serializes_three_keys() { + let summary = BulkOperationSummary { + bulk_operation_arn: ARN.to_owned(), + bulk_operation_status: BulkOperationStatus::InProgress, + start_time: 1_789_060_589.767, + end_time: None, + }; + let value = serde_json::to_value(&summary).unwrap(); + assert_eq!( + value + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect::>(), + vec!["BulkOperationArn", "BulkOperationStatus", "StartTime"] + ); + assert!(value["StartTime"].is_number()); +} + +#[test] +fn summary_with_end_time_serializes_it_as_a_json_number() { + let summary = BulkOperationSummary { + bulk_operation_arn: ARN.to_owned(), + bulk_operation_status: BulkOperationStatus::Completed, + start_time: 1_789_060_589.767, + end_time: Some(1_789_060_650.634), + }; + let value = serde_json::to_value(&summary).unwrap(); + assert_eq!(value["EndTime"], json!(1_789_060_650.634)); +} + +#[test] +fn list_output_omits_next_token_when_absent() { + let output = ListBulkOperationsOutput { + bulk_operations_summary_list: vec![], + next_token: None, + }; + let value = serde_json::to_value(&output).unwrap(); + assert_eq!( + value + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect::>(), + vec!["BulkOperationsSummaryList"] + ); + assert_eq!(value["BulkOperationsSummaryList"], json!([])); +} + +#[test] +fn cancel_output_serializes_exactly_two_keys() { + let output = CancelBulkOperationOutput { + bulk_operation_arn: ARN.to_owned(), + bulk_operation_status: BulkOperationStatus::Cancelling, + }; + let value = serde_json::to_value(&output).unwrap(); + assert_eq!( + value + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect::>(), + vec!["BulkOperationArn", "BulkOperationStatus"] + ); + assert_eq!(value["BulkOperationStatus"], json!("CANCELLING")); +} + +#[test] +fn start_output_wraps_the_description() { + let output = StartBulkPutItemsOutput { + bulk_operation_description: minimal_description(), + }; + let value = serde_json::to_value(&output).unwrap(); + assert_eq!( + value + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect::>(), + vec!["BulkOperationDescription"] + ); +} + +#[test] +fn describe_output_wraps_the_description() { + let output = DescribeBulkOperationOutput { + bulk_operation_description: minimal_description(), + }; + let value = serde_json::to_value(&output).unwrap(); + assert_eq!( + value + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect::>(), + vec!["BulkOperationDescription"] + ); +} + +#[test] +fn usage_lsi_map_values_are_objects_carrying_bulk_put_bytes() { + let mut with_lsi = usage(); + with_lsi.local_secondary_indexes.insert( + "lsi1".to_owned(), + BulkOperationBytes { + bulk_put_bytes: 2048, + }, + ); + let value = serde_json::to_value(with_lsi).unwrap(); + assert!( + value["LocalSecondaryIndexes"]["lsi1"].is_object(), + "index map values must be nested objects, not scalars, got: {}", + value["LocalSecondaryIndexes"]["lsi1"] + ); + assert_eq!( + value["LocalSecondaryIndexes"], + json!({ "lsi1": { "BulkPutBytes": 2048 } }) + ); +} diff --git a/crates/core/src/types/bulk_operation_tests/mod.rs b/crates/core/src/types/bulk_operation_tests/mod.rs new file mode 100644 index 000000000..2e7e94f69 --- /dev/null +++ b/crates/core/src/types/bulk_operation_tests/mod.rs @@ -0,0 +1,149 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Shared fixtures for the bulk operation type tests; the cases live in +//! `wire` (enums, inputs, status) and `description` (description, usage, +//! summary and output serialization). + +use super::*; +use serde_json::json; +use std::collections::HashMap; + +mod description; +mod wire; + +pub(super) const ARN: &str = + "arn:aws:dynamodb:us-east-1:000000000000:bulk-operation/01789060589767-da9ddd4f"; + +pub(super) const TABLE_ARN: &str = "arn:aws:dynamodb:us-east-1:000000000000:table/orders"; + +pub(super) fn full_start_body() -> serde_json::Value { + json!({ + "TableName": "orders", + "ClientToken": "load-2026-09-10-a", + "InputS3Configuration": { + "S3Bucket": "loads", + "S3KeyPrefix": "orders/2026-09-10/", + "InputFormat": "DYNAMODB_JSON", + "InputCompressionType": "GZIP", + "S3BucketOwner": "000000000000" + }, + "OutputS3Configuration": { + "OutputType": ["ERROR", "CONDITION_CHECK_FAILURE"], + "S3Bucket": "loads", + "S3KeyPrefix": "reports/", + "S3BucketOwner": "000000000000", + "S3SseAlgorithm": "AES256", + "S3SseKmsKeyId": "alias/example" + }, + "MaxWriteRequestUnits": 1000, + "PerformanceTier": "STANDARD" + }) +} + +pub(super) fn input_configuration() -> InputS3Configuration { + serde_json::from_value(full_start_body()["InputS3Configuration"].clone()).unwrap() +} + +pub(super) fn output_configuration() -> OutputS3Configuration { + serde_json::from_value(full_start_body()["OutputS3Configuration"].clone()).unwrap() +} + +pub(super) fn minimal_description() -> BulkOperationDescription { + BulkOperationDescription { + bulk_operation_arn: ARN.to_owned(), + bulk_operation_status: BulkOperationStatus::InProgress, + table_details: Vec::new(), + table_arn: None, + table_id: None, + client_token: None, + input_s3_configuration: None, + output_s3_configuration: None, + start_time: None, + end_time: None, + processed_item_count: None, + failed_item_count: None, + processed_input_bytes: None, + total_input_bytes: None, + bulk_operation_usage: None, + failure_code: None, + failure_message: None, + max_write_request_units: None, + performance_tier: None, + } +} + +pub(super) fn full_description() -> BulkOperationDescription { + BulkOperationDescription { + bulk_operation_arn: ARN.to_owned(), + bulk_operation_status: BulkOperationStatus::Completed, + table_details: vec![BulkOperationTableDetail { + table_name: "orders".to_owned(), + table_id: "0ec72c3d-0000-0000-0000-000000000000".to_owned(), + }], + table_arn: Some(TABLE_ARN.to_owned()), + table_id: Some("0ec72c3d-0000-0000-0000-000000000000".to_owned()), + client_token: Some("load-2026-09-10-a".to_owned()), + input_s3_configuration: Some(input_configuration()), + output_s3_configuration: Some(output_configuration()), + start_time: Some(1_789_060_589.767), + end_time: Some(1_789_060_650.634), + processed_item_count: Some(25), + failed_item_count: Some(0), + processed_input_bytes: Some(4096), + total_input_bytes: Some(4096), + bulk_operation_usage: Some(vec![usage()]), + failure_code: Some("ValidationException".to_owned()), + failure_message: Some("Operation exceeded max_bulk_item_count (100 rows)".to_owned()), + max_write_request_units: Some(1000), + performance_tier: Some(BulkOperationPerformanceTier::Standard), + } +} + +pub(super) fn usage() -> BulkOperationUsage { + let mut gsis = HashMap::new(); + gsis.insert( + "gsi1".to_owned(), + BulkOperationBytes { + bulk_put_bytes: 4096, + }, + ); + let mut vectors = HashMap::new(); + vectors.insert( + "vi1".to_owned(), + BulkOperationVectorBytes { + bulk_put_vector_bytes: 8192, + }, + ); + BulkOperationUsage { + table_name: "orders".to_owned(), + bulk_put_bytes: 25600, + table: BulkOperationBytes { + bulk_put_bytes: 25600, + }, + global_secondary_indexes: gsis, + local_secondary_indexes: HashMap::new(), + vector_indexes: vectors, + } +} + +/// The message pattern `deserialize_error` in the engine keys on to classify +/// an enum violation as a `ValidationException` rather than a +/// `SerializationException`. A derive-produced "unknown variant" message does +/// not carry it. +pub(super) fn enum_rejection_message( + value: serde_json::Value, +) -> String { + let message = serde_json::from_value::(value) + .expect_err("a value outside the enum set must be rejected") + .to_string(); + assert!( + message.contains("validation error detected"), + "enum rejection must carry the validation-error marker, got: {message}" + ); + assert!( + message.contains("Member must satisfy enum value set"), + "enum rejection must name the enum value set, got: {message}" + ); + message +} diff --git a/crates/core/src/types/bulk_operation_tests/wire.rs b/crates/core/src/types/bulk_operation_tests/wire.rs new file mode 100644 index 000000000..95e60690d --- /dev/null +++ b/crates/core/src/types/bulk_operation_tests/wire.rs @@ -0,0 +1,305 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Wire names and enum acceptance for the bulk operation request and status types. + +use super::*; + +#[test] +fn start_input_deserializes_every_field_from_the_wire_names() { + let input: StartBulkPutItemsInput = serde_json::from_value(full_start_body()).unwrap(); + assert_eq!(input.table_name, "orders"); + assert_eq!(input.client_token.as_deref(), Some("load-2026-09-10-a")); + assert_eq!(input.max_write_request_units, Some(1000)); + assert_eq!( + input.performance_tier, + Some(BulkOperationPerformanceTier::Standard) + ); + + let in_cfg = &input.input_s3_configuration; + assert_eq!(in_cfg.s3_bucket, "loads"); + assert_eq!(in_cfg.s3_key_prefix.as_deref(), Some("orders/2026-09-10/")); + assert_eq!(in_cfg.input_format, BulkOperationInputFormat::DynamodbJson); + assert_eq!( + in_cfg.input_compression_type, + Some(InputCompressionType::Gzip) + ); + assert_eq!(in_cfg.s3_bucket_owner.as_deref(), Some("000000000000")); + + let out_cfg = &input.output_s3_configuration; + assert_eq!( + out_cfg.output_type, + vec![OutputType::Error, OutputType::ConditionCheckFailure] + ); + assert_eq!(out_cfg.s3_bucket.as_deref(), Some("loads")); + assert_eq!(out_cfg.s3_key_prefix.as_deref(), Some("reports/")); + assert_eq!(out_cfg.s3_bucket_owner.as_deref(), Some("000000000000")); + assert_eq!(out_cfg.s3_sse_algorithm, Some(S3SseAlgorithm::Aes256)); + assert_eq!(out_cfg.s3_sse_kms_key_id.as_deref(), Some("alias/example")); +} + +#[test] +fn start_input_without_optional_fields_deserializes_with_none() { + let input: StartBulkPutItemsInput = serde_json::from_value(json!({ + "TableName": "orders", + "InputS3Configuration": { "S3Bucket": "loads", "InputFormat": "DYNAMODB_JSON" }, + "OutputS3Configuration": { "OutputType": ["ERROR"] } + })) + .unwrap(); + assert_eq!(input.client_token, None); + assert_eq!(input.max_write_request_units, None); + assert_eq!(input.performance_tier, None); + assert_eq!(input.input_s3_configuration.s3_key_prefix, None); + assert_eq!(input.input_s3_configuration.input_compression_type, None); + assert_eq!(input.input_s3_configuration.s3_bucket_owner, None); + assert_eq!(input.output_s3_configuration.s3_bucket, None); + assert_eq!(input.output_s3_configuration.s3_key_prefix, None); + assert_eq!(input.output_s3_configuration.s3_bucket_owner, None); + assert_eq!(input.output_s3_configuration.s3_sse_algorithm, None); + assert_eq!(input.output_s3_configuration.s3_sse_kms_key_id, None); +} + +#[test] +fn input_format_accepts_the_two_model_values() { + assert_eq!( + serde_json::from_value::(json!("DYNAMODB_JSON")).unwrap(), + BulkOperationInputFormat::DynamodbJson + ); + assert_eq!( + serde_json::from_value::(json!("ION")).unwrap(), + BulkOperationInputFormat::Ion + ); +} + +#[test] +fn input_format_rejects_a_value_outside_the_enum() { + enum_rejection_message::(json!("CSV")); +} + +#[test] +fn compression_type_accepts_the_three_model_values() { + assert_eq!( + serde_json::from_value::(json!("GZIP")).unwrap(), + InputCompressionType::Gzip + ); + assert_eq!( + serde_json::from_value::(json!("ZSTD")).unwrap(), + InputCompressionType::Zstd + ); + assert_eq!( + serde_json::from_value::(json!("NONE")).unwrap(), + InputCompressionType::None + ); +} + +#[test] +fn compression_type_rejects_a_value_outside_the_enum() { + enum_rejection_message::(json!("BROTLI")); +} + +#[test] +fn performance_tier_accepts_the_two_model_values() { + assert_eq!( + serde_json::from_value::(json!("STANDARD")).unwrap(), + BulkOperationPerformanceTier::Standard + ); + assert_eq!( + serde_json::from_value::(json!("ACCELERATED")).unwrap(), + BulkOperationPerformanceTier::Accelerated + ); +} + +#[test] +fn performance_tier_rejects_a_value_outside_the_enum() { + enum_rejection_message::(json!("TURBO")); +} + +#[test] +fn output_type_accepts_the_two_model_values() { + assert_eq!( + serde_json::from_value::(json!("ERROR")).unwrap(), + OutputType::Error + ); + assert_eq!( + serde_json::from_value::(json!("CONDITION_CHECK_FAILURE")).unwrap(), + OutputType::ConditionCheckFailure + ); +} + +#[test] +fn output_type_rejects_a_value_outside_the_enum() { + enum_rejection_message::(json!("WARNING")); +} + +#[test] +fn sse_algorithm_accepts_the_two_model_values() { + assert_eq!( + serde_json::from_value::(json!("AES256")).unwrap(), + S3SseAlgorithm::Aes256 + ); + assert_eq!( + serde_json::from_value::(json!("KMS")).unwrap(), + S3SseAlgorithm::Kms + ); +} + +#[test] +fn sse_algorithm_rejects_a_value_outside_the_enum() { + enum_rejection_message::(json!("AES128")); +} + +#[test] +fn is_terminal_is_true_for_exactly_the_four_terminal_statuses() { + assert!(!BulkOperationStatus::InProgress.is_terminal()); + assert!(!BulkOperationStatus::Cancelling.is_terminal()); + assert!(BulkOperationStatus::Completed.is_terminal()); + assert!(BulkOperationStatus::CompletedWithErrors.is_terminal()); + assert!(BulkOperationStatus::Failed.is_terminal()); + assert!(BulkOperationStatus::Cancelled.is_terminal()); +} + +#[test] +fn status_serializes_to_the_six_wire_names() { + let cases = [ + (BulkOperationStatus::InProgress, "IN_PROGRESS"), + (BulkOperationStatus::Completed, "COMPLETED"), + ( + BulkOperationStatus::CompletedWithErrors, + "COMPLETED_WITH_ERRORS", + ), + (BulkOperationStatus::Cancelling, "CANCELLING"), + (BulkOperationStatus::Cancelled, "CANCELLED"), + (BulkOperationStatus::Failed, "FAILED"), + ]; + for (status, wire) in cases { + assert_eq!(serde_json::to_value(status).unwrap(), json!(wire)); + } +} + +#[test] +fn list_input_deserializes_max_results_and_next_token() { + let input: ListBulkOperationsInput = serde_json::from_value(json!({ + "MaxResults": 25, + "NextToken": "0000018f6f7a2f47" + })) + .unwrap(); + assert_eq!(input.max_results, Some(25)); + assert_eq!(input.next_token.as_deref(), Some("0000018f6f7a2f47")); + + let empty: ListBulkOperationsInput = serde_json::from_value(json!({})).unwrap(); + assert_eq!(empty.max_results, None); + assert_eq!(empty.next_token, None); +} + +#[test] +fn describe_input_deserializes_the_arn() { + let input: DescribeBulkOperationInput = + serde_json::from_value(json!({ "BulkOperationArn": ARN })).unwrap(); + assert_eq!(input.bulk_operation_arn, ARN); +} + +#[test] +fn cancel_input_deserializes_the_arn() { + let input: CancelBulkOperationInput = + serde_json::from_value(json!({ "BulkOperationArn": ARN })).unwrap(); + assert_eq!(input.bulk_operation_arn, ARN); +} + +#[test] +fn status_accepts_the_six_wire_names() { + let cases = [ + ("IN_PROGRESS", BulkOperationStatus::InProgress), + ("COMPLETED", BulkOperationStatus::Completed), + ( + "COMPLETED_WITH_ERRORS", + BulkOperationStatus::CompletedWithErrors, + ), + ("CANCELLING", BulkOperationStatus::Cancelling), + ("CANCELLED", BulkOperationStatus::Cancelled), + ("FAILED", BulkOperationStatus::Failed), + ]; + for (wire, status) in cases { + assert_eq!( + serde_json::from_value::(json!(wire)).unwrap(), + status + ); + } +} + +#[test] +fn status_rejects_a_value_outside_the_enum() { + enum_rejection_message::(json!("RUNNING")); +} + +#[test] +fn negative_max_write_request_units_deserializes_for_the_handler_to_range_check() { + // The range check (>= 1) is the handler's, at validation step 2, so the + // wire field must accept a negative integer; an unsigned field would + // reject it inside serde as a SerializationException instead of the + // ValidationException the range rule produces. + let mut body = full_start_body(); + body["MaxWriteRequestUnits"] = json!(-5); + let input: StartBulkPutItemsInput = serde_json::from_value(body).unwrap(); + assert_eq!(input.max_write_request_units, Some(-5)); +} + +#[test] +fn negative_max_results_deserializes_for_the_handler_to_range_check() { + // Same boundary as MaxWriteRequestUnits: 1 to 25 is enforced by the + // handler, so serde must carry the negative value through. + let input: ListBulkOperationsInput = + serde_json::from_value(json!({ "MaxResults": -1 })).unwrap(); + assert_eq!(input.max_results, Some(-1)); +} + +#[test] +fn compression_type_serializes_to_the_wire_names() { + assert_eq!( + serde_json::to_value(InputCompressionType::Gzip).unwrap(), + json!("GZIP") + ); + assert_eq!( + serde_json::to_value(InputCompressionType::Zstd).unwrap(), + json!("ZSTD") + ); + assert_eq!( + serde_json::to_value(InputCompressionType::None).unwrap(), + json!("NONE") + ); +} + +#[test] +fn sse_algorithm_serializes_to_the_wire_names() { + assert_eq!( + serde_json::to_value(S3SseAlgorithm::Aes256).unwrap(), + json!("AES256") + ); + assert_eq!( + serde_json::to_value(S3SseAlgorithm::Kms).unwrap(), + json!("KMS") + ); +} + +#[test] +fn input_format_serializes_to_the_wire_names() { + assert_eq!( + serde_json::to_value(BulkOperationInputFormat::DynamodbJson).unwrap(), + json!("DYNAMODB_JSON") + ); + assert_eq!( + serde_json::to_value(BulkOperationInputFormat::Ion).unwrap(), + json!("ION") + ); +} + +#[test] +fn performance_tier_serializes_to_the_wire_names() { + assert_eq!( + serde_json::to_value(BulkOperationPerformanceTier::Standard).unwrap(), + json!("STANDARD") + ); + assert_eq!( + serde_json::to_value(BulkOperationPerformanceTier::Accelerated).unwrap(), + json!("ACCELERATED") + ); +} diff --git a/crates/core/src/types/mod.rs b/crates/core/src/types/mod.rs index e21f75110..5c35e15e5 100755 --- a/crates/core/src/types/mod.rs +++ b/crates/core/src/types/mod.rs @@ -3,6 +3,7 @@ mod attribute_value; mod backup; mod batch; +mod bulk_operation; mod capacity; mod import_export; mod item; @@ -22,6 +23,15 @@ pub use batch::{ BatchGetItemInput, BatchGetItemOutput, BatchWriteItemInput, BatchWriteItemOutput, DeleteRequest, KeysAndAttributes, PutRequest, WriteRequest, }; +pub use bulk_operation::{ + BulkOperationBytes, BulkOperationDescription, BulkOperationInputFormat, + BulkOperationPerformanceTier, BulkOperationStatus, BulkOperationSummary, + BulkOperationTableDetail, BulkOperationUsage, BulkOperationVectorBytes, + CancelBulkOperationInput, CancelBulkOperationOutput, DescribeBulkOperationInput, + DescribeBulkOperationOutput, InputCompressionType, InputS3Configuration, + ListBulkOperationsInput, ListBulkOperationsOutput, OutputS3Configuration, OutputType, + S3SseAlgorithm, StartBulkPutItemsInput, StartBulkPutItemsOutput, +}; pub use capacity::{ Capacity, ConsumedCapacity, ItemCollectionMetrics, ReturnConsumedCapacity, ReturnItemCollectionMetrics, ReturnValuesOnConditionCheckFailure, VectorCapacity, From d0fd782dd6156f0fb22accc337c947441787d50a Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:10:01 +0000 Subject: [PATCH 2/6] feat(s3,config): add the S3 client crate and the [bulk] configuration The extenddb-s3 crate opens one store's S3 clients from its region, endpoint_url and force_path_style settings. The clients sign with the standard AWS credential chain, which the crate resolves once at startup, logging the outcome under the store's name. They follow the region redirects that AWS S3 sends, and they retry transient failures within one time bound. Without an endpoint_url, the crate also sends one STS GetCallerIdentity request at startup and logs the identity the credentials sign as. The config crate reads [bulk], whose store key selects the filesystem store or the S3 store. It refuses credential keys in [bulk.s3], and a plain http endpoint_url off loopback unless the section sets allow_insecure_endpoint. The settings display lists the bulk store's settings and the bulk limits. The workspace manifest adds the crate and the credentials-login feature of aws-config, so that the credential chain resolves a profile written by aws login. It also enables serde_json's unbounded_depth feature for the engine's row parser, and Cargo.lock and the license notices change with the manifest. --- Cargo.lock | 337 ++++- Cargo.toml | 23 +- SOFTWARE-LICENSE-NOTICES-CASSANDRA.html | 1681 +++++++++++++++++++++- SOFTWARE-LICENSE-NOTICES-DEV.html | 1685 ++++++++++++++++++++++- SOFTWARE-LICENSE-NOTICES-MONGODB.html | 1437 ++++++++++++++++++- SOFTWARE-LICENSE-NOTICES.html | 1684 +++++++++++++++++++++- crates/config/src/bulk.rs | 247 ++++ crates/config/src/bulk_s3.rs | 399 ++++++ crates/config/src/display.rs | 136 +- crates/config/src/lib.rs | 12 + crates/config/src/s3_client.rs | 358 +++++ crates/s3/Cargo.toml | 37 + crates/s3/src/client.rs | 146 ++ crates/s3/src/clients.rs | 264 ++++ crates/s3/src/codes.rs | 43 + crates/s3/src/credentials.rs | 98 ++ crates/s3/src/identity.rs | 179 +++ crates/s3/src/lib.rs | 104 ++ crates/s3/src/retry.rs | 151 ++ crates/s3/src/tests/child.rs | 69 + crates/s3/src/tests/codes.rs | 53 + crates/s3/src/tests/deadlines.rs | 33 + crates/s3/src/tests/endpoints.rs | 255 ++++ crates/s3/src/tests/identity.rs | 326 +++++ crates/s3/src/tests/logs.rs | 100 ++ crates/s3/src/tests/mod.rs | 133 ++ crates/s3/src/tests/redirect.rs | 125 ++ crates/s3/src/tests/settings.rs | 20 + crates/s3/src/tests/temporary.rs | 203 +++ 29 files changed, 10107 insertions(+), 231 deletions(-) create mode 100644 crates/config/src/bulk.rs create mode 100644 crates/config/src/bulk_s3.rs create mode 100644 crates/config/src/s3_client.rs create mode 100644 crates/s3/Cargo.toml create mode 100644 crates/s3/src/client.rs create mode 100644 crates/s3/src/clients.rs create mode 100644 crates/s3/src/codes.rs create mode 100644 crates/s3/src/credentials.rs create mode 100644 crates/s3/src/identity.rs create mode 100644 crates/s3/src/lib.rs create mode 100644 crates/s3/src/retry.rs create mode 100644 crates/s3/src/tests/child.rs create mode 100644 crates/s3/src/tests/codes.rs create mode 100644 crates/s3/src/tests/deadlines.rs create mode 100644 crates/s3/src/tests/endpoints.rs create mode 100644 crates/s3/src/tests/identity.rs create mode 100644 crates/s3/src/tests/logs.rs create mode 100644 crates/s3/src/tests/mod.rs create mode 100644 crates/s3/src/tests/redirect.rs create mode 100644 crates/s3/src/tests/settings.rs create mode 100644 crates/s3/src/tests/temporary.rs diff --git a/Cargo.lock b/Cargo.lock index c033bcd24..b785a660a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,6 +155,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-compression" version = "0.4.42" @@ -227,6 +237,7 @@ checksum = "c456581cb3c77fafcc8c67204a70680d40b61112d6da78c77bd31d945b65f1b5" dependencies = [ "aws-credential-types", "aws-runtime", + "aws-sdk-signin", "aws-sdk-sso", "aws-sdk-ssooidc", "aws-sdk-sts", @@ -237,15 +248,20 @@ dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", "aws-types", + "base64-simd", "bytes", "fastrand", "hex", "http 1.4.0", + "p256", + "rand 0.8.6", "ring", + "sha2 0.10.9", "time", "tokio", "tracing", "url", + "uuid", "zeroize", ] @@ -347,6 +363,30 @@ dependencies = [ "url", ] +[[package]] +name = "aws-sdk-signin" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9b967a27bdd38b1e462f52b24fa91aaf61cd8823e78b8825895a720fbe26087" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + [[package]] name = "aws-sdk-sso" version = "1.93.0" @@ -515,17 +555,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12fb0abf49ff0cab20fd31ac1215ed7ce0ea92286ba09e2854b42ba5cabe7525" dependencies = [ "aws-smithy-async", + "aws-smithy-protocol-test", "aws-smithy-runtime-api", "aws-smithy-types", + "bytes", "h2", "http 1.4.0", + "http-body 1.0.1", "hyper", "hyper-rustls", "hyper-util", + "indexmap", "pin-project-lite", "rustls", "rustls-native-certs", "rustls-pki-types", + "serde", + "serde_json", "tokio", "tokio-rustls", "tower", @@ -550,6 +596,25 @@ dependencies = [ "aws-smithy-runtime-api", ] +[[package]] +name = "aws-smithy-protocol-test" +version = "0.63.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7090f3a3657e7c2c0331604d62baa20a9a9f765c3f4bf63ccf48ccba6b8b7240" +dependencies = [ + "assert-json-diff", + "aws-smithy-runtime-api", + "base64-simd", + "cbor-diag", + "ciborium", + "http 0.2.12", + "pretty_assertions", + "regex-lite", + "roxmltree", + "serde_json", + "thiserror", +] + [[package]] name = "aws-smithy-query" version = "0.60.13" @@ -737,6 +802,12 @@ dependencies = [ "tower-service", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -865,6 +936,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bson" version = "2.15.0" @@ -960,6 +1040,25 @@ dependencies = [ "uuid", ] +[[package]] +name = "cbor-diag" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc245b6ecd09b23901a4fbad1ad975701fd5061ceaef6afa93a2d70605a64429" +dependencies = [ + "bs58", + "chrono", + "data-encoding", + "half", + "nom", + "num-bigint", + "num-rational", + "num-traits", + "separator", + "url", + "uuid", +] + [[package]] name = "cc" version = "1.2.62" @@ -1038,6 +1137,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1330,6 +1456,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1512,6 +1650,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1568,6 +1712,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "either" version = "1.16.0" @@ -1577,6 +1735,26 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1767,17 +1945,44 @@ dependencies = [ name = "extenddb-engine" version = "0.1.14" dependencies = [ + "aws-config", + "aws-sdk-s3", + "aws-smithy-http-client", "base64 0.22.1", "crc32fast", "extenddb-auth", "extenddb-core", + "extenddb-s3", "extenddb-storage", + "extenddb-storage-postgres", + "extenddb-storage-sqlite", + "flate2", "futures", "hex", "hmac 0.12.1", + "md-5 0.10.6", + "rand 0.9.4", + "rustix", + "ruzstd", "serde", "serde_json", "sha2 0.10.9", + "sqlx", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "extenddb-s3" +version = "0.1.14" +dependencies = [ + "aws-config", + "aws-credential-types", + "aws-sdk-s3", + "aws-sdk-sts", + "aws-smithy-http-client", + "aws-types", "tokio", "tracing", "uuid", @@ -1800,6 +2005,7 @@ dependencies = [ "extenddb-core", "extenddb-engine", "extenddb-storage", + "extenddb-storage-sqlite", "futures", "hyper", "libc", @@ -1968,6 +2174,16 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2166,6 +2382,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2209,6 +2426,17 @@ dependencies = [ "wasip3", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.16" @@ -2228,6 +2456,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -3239,6 +3478,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3296,6 +3546,18 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "parking" version = "2.2.1" @@ -3503,6 +3765,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -3513,6 +3785,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3729,6 +4010,16 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -3755,6 +4046,15 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "roxmltree" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "921904a62e410e37e215c40381b7117f830d9d89ba60ab5236170541dd25646b" +dependencies = [ + "xmlparser", +] + [[package]] name = "rsa" version = "0.9.10" @@ -3893,6 +4193,15 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "ruzstd" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5" +dependencies = [ + "twox-hash", +] + [[package]] name = "ryu" version = "1.0.23" @@ -3923,6 +4232,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3952,6 +4275,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "separator" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f97841a747eef040fcd2e7b3b9a220a7205926e60488e673d9e4926d27772ce5" + [[package]] name = "serde" version = "1.0.228" @@ -5226,7 +5555,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -5600,6 +5929,12 @@ dependencies = [ "hashlink 0.8.4", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yasna" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index 97e64e9e0..5f8973d5e 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/engine", "crates/storage", "crates/config", + "crates/s3", "crates/storage-postgres", "crates/storage-cassandra", "crates/storage-mongodb", @@ -33,6 +34,7 @@ extenddb-backup-store = { path = "crates/backup-store" } extenddb-engine = { path = "crates/engine" } extenddb-storage = { path = "crates/storage" } extenddb-config = { path = "crates/config" } +extenddb-s3 = { path = "crates/s3" } extenddb-storage-cassandra = { path = "crates/storage-cassandra" } extenddb-storage-mongodb = { path = "crates/storage-mongodb" } extenddb-storage-postgres = { path = "crates/storage-postgres" } @@ -43,7 +45,7 @@ extenddb-app = { path = "crates/app" } # Serialization serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = { version = "1", features = ["unbounded_depth"] } toml = "0.8" # Error handling @@ -99,10 +101,12 @@ dashmap = "6" # them past that, and the 1.88 leg of .github/workflows/test.yml fails if # they drift. The feature set is that of tests/rust/Cargo.toml plus `sso` on # aws-config, so a server whose credentials come from an SSO profile -# (`sso_session` in the shared config) can reach the backup bucket. -# tests/rust does not enable `sso`: its client takes explicit credentials -# from the environment, falling back to the default chain without SSO. -aws-config = { version = "=1.8.13", default-features = false, features = ["behavior-version-latest", "rt-tokio", "default-https-client", "credentials-process", "sso"] } +# (`sso_session` in the shared config) can reach the backup bucket, and +# `credentials-login`, so a profile written by `aws login` resolves instead +# of stopping the credential chain with FeatureNotEnabled. tests/rust +# enables neither: its client takes explicit credentials from the +# environment, falling back to the default chain without SSO. +aws-config = { version = "=1.8.13", default-features = false, features = ["behavior-version-latest", "rt-tokio", "default-https-client", "credentials-process", "sso", "credentials-login"] } aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = ["behavior-version-latest", "rt-tokio", "default-https-client"] } # Already in the graph through aws-config; named directly so the S3 store can # read the S3-specific endpoint setting the way aws-sdk-s3 does. @@ -118,6 +122,15 @@ aws-smithy-http-client = { version = "=1.1.9", default-features = false, feature # Already in the graph through aws-config and aws-sdk-s3; named directly so # the S3 store refuses at open the endpoints the SDK's URL parser refuses. url = { version = "=2.5.8" } +# The bulk store's S3 clients (extenddb-s3) use the aws-config and aws-sdk-s3 +# above. Their startup identity check calls STS: aws-sdk-sts is already in +# the graph through aws-config, and is named directly at the version +# Cargo.lock holds, with the features aws-sdk-s3 uses. +aws-sdk-sts = { version = "=1.97.0", default-features = false, features = ["rt-tokio", "default-https-client"] } +# The credential features the bulk store's temporary-keys warning reads +# (`AwsCredentialFeature`). Already in the graph through aws-config; this is +# the version Cargo.lock holds. +aws-credential-types = { version = "=1.2.11", default-features = false } bytes = "1" # Crypto & checksums diff --git a/SOFTWARE-LICENSE-NOTICES-CASSANDRA.html b/SOFTWARE-LICENSE-NOTICES-CASSANDRA.html index 80174e5eb..269f2f91b 100644 --- a/SOFTWARE-LICENSE-NOTICES-CASSANDRA.html +++ b/SOFTWARE-LICENSE-NOTICES-CASSANDRA.html @@ -49,14 +49,14 @@

ExtendDB Software License Notices

Overview of licenses:

All license text:

@@ -1747,25 +1747,216 @@

Used by:

Apache License 2.0

Used by:

+
                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+ +
  • +

    Apache License 2.0

    +

    Used by:

    +
                                     Apache License
                                Version 2.0, January 2004
    @@ -1955,30 +2146,898 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright {yyyy} {name of copyright owner} + Copyright 2017 Juniper Networks, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                     Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "{}"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright {yyyy} {name of copyright owner}
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                    Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "{}"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright 2018-2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0 January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
     
    -   Licensed under the Apache License, Version 2.0 (the "License");
    -   you may not use this file except in compliance with the License.
    -   You may obtain a copy of the License at
    +APPENDIX: How to apply the Apache License to your work.
     
    -       http://www.apache.org/licenses/LICENSE-2.0
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
     
    -   Unless required by applicable law or agreed to in writing, software
    -   distributed under the License is distributed on an "AS IS" BASIS,
    -   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    -   See the License for the specific language governing permissions and
    -   limitations under the License.
    +Copyright (c) 2016 Alex Crichton
    +Copyright (c) 2017 The Tokio Authors
     
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
     
  • Apache License 2.0

    Used by:

                                  Apache License
    -                        Version 2.0 January 2004
    +                        Version 2.0, January 2004
                          http://www.apache.org/licenses/
     
     TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    @@ -2165,7 +3224,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright [yyyy] [name of copyright owner] +Copyright (c) 2019 Matthias Einwag Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2184,15 +3243,7 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -2382,8 +3433,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright (c) 2016 Alex Crichton -Copyright (c) 2017 The Tokio Authors +Copyright 2014 Paho Lurie-Gregg Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2395,14 +3445,13 @@

    Used by:

    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and -limitations under the License. -
    +limitations under the License.
  • Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -2592,7 +3641,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright (c) 2019 Matthias Einwag +Copyright 2017 The Rust Project Developers Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2611,7 +3660,8 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -2801,7 +3851,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2014 Paho Lurie-Gregg +Copyright 2017 http-rs authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2813,13 +3863,14 @@

    Used by:

    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and -limitations under the License.
    +limitations under the License. +
  • Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -3009,7 +4060,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2017 The Rust Project Developers +Copyright 2017 quininer kel Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -3028,7 +4079,7 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -3218,7 +4269,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2017 http-rs authors +Copyright 2018 The pin-utils authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -3237,7 +4288,8 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -3427,7 +4479,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2017 quininer kel +Copyright 2018-2022 RustCrypto Developers Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -4503,6 +5555,7 @@

    Used by:

  • base64 0.21.7
  • base64 0.22.1
  • bitflags 2.11.1
  • +
  • bytes-utils 0.1.4
  • cc 1.2.62
  • cfg-if 1.0.4
  • cmake 0.1.58
  • @@ -4521,15 +5574,19 @@

    Used by:

  • errno 0.3.14
  • event-listener-strategy 0.5.4
  • event-listener 5.4.2
  • +
  • fastrand 2.5.0
  • find-msvc-tools 0.1.9
  • flate2 1.1.9
  • fnv 1.0.7
  • form_urlencoded 1.2.2
  • +
  • group 0.13.0
  • hashbrown 0.14.5
  • hashbrown 0.15.5
  • +
  • hashbrown 0.16.1
  • hashbrown 0.17.1
  • heck 0.5.0
  • httparse 1.10.1
  • +
  • hyper-rustls 0.27.10
  • idna 1.1.0
  • idna_adapter 1.2.2
  • indexmap 2.14.0
  • @@ -4537,6 +5594,7 @@

    Used by:

  • itertools 0.14.0
  • jobserver 0.1.34
  • lazy_static 1.5.0
  • +
  • linux-raw-sys 0.12.1
  • lock_api 0.4.14
  • log 0.4.29
  • mime 0.3.17
  • @@ -4544,6 +5602,7 @@

    Used by:

  • num-integer 0.1.46
  • num-traits 0.2.19
  • once_cell 1.21.4
  • +
  • openssl-probe 0.2.1
  • parking 2.2.1
  • parking_lot 0.12.5
  • parking_lot_core 0.9.12
  • @@ -4554,9 +5613,13 @@

    Used by:

  • pest_meta 2.8.6
  • pkg-config 0.3.33
  • regex-automata 0.4.14
  • +
  • regex-lite 0.1.9
  • regex-syntax 0.8.10
  • +
  • ring 0.17.14
  • ron 0.8.1
  • rustc_version 0.4.1
  • +
  • rustix 1.1.4
  • +
  • rustls-native-certs 0.8.4
  • rustls 0.23.46
  • scopeguard 1.2.0
  • signal-hook-registry 1.4.8
  • @@ -4574,6 +5637,7 @@

    Used by:

  • url 2.5.8
  • uuid 1.23.1
  • version_check 0.9.5
  • +
  • xmlparser 0.13.6
  • yasna 0.6.0
  •                               Apache License
    @@ -4777,6 +5841,216 @@ 

    Used by:

    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
     
  • @@ -5409,18 +6683,32 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -6955,6 +8243,7 @@ 

    Used by:

  • extenddb-config 0.1.14
  • extenddb-core 0.1.14
  • extenddb-engine 0.1.14
  • +
  • extenddb-s3 0.1.14
  • extenddb-server 0.1.14
  • extenddb-storage 0.1.14
  • extenddb-storage-cassandra 0.1.14
  • @@ -6962,7 +8251,24 @@

    Used by:

  • anyhow 1.0.103
  • arraydeque 0.5.1
  • async-trait 0.1.89
  • +
  • aws-config 1.8.13
  • +
  • aws-credential-types 1.2.11
  • aws-lc-sys 0.46.0
  • +
  • aws-runtime 1.6.0
  • +
  • aws-sigv4 1.3.8
  • +
  • aws-smithy-async 1.2.11
  • +
  • aws-smithy-checksums 0.64.3
  • +
  • aws-smithy-eventstream 0.60.18
  • +
  • aws-smithy-http-client 1.1.9
  • +
  • aws-smithy-http 0.63.3
  • +
  • aws-smithy-json 0.62.3
  • +
  • aws-smithy-observability 0.2.4
  • +
  • aws-smithy-query 0.60.13
  • +
  • aws-smithy-runtime-api 1.11.3
  • +
  • aws-smithy-runtime 1.10.0
  • +
  • aws-smithy-types 1.4.3
  • +
  • aws-smithy-xml 0.60.13
  • +
  • aws-types 1.3.11
  • cassandra-protocol 4.0.1
  • cdrs-tokio-helpers-derive 5.0.3
  • cdrs-tokio 9.0.2
  • @@ -7710,6 +9016,27 @@

    Used by:

    // WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN // ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF // OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +
    +
  • +
  • +

    ISC License

    +

    Used by:

    + +
    Copyright 2015-2025 Brian Smith.
    +
    +Permission to use, copy, modify, and/or distribute this software for any
    +purpose with or without fee is hereby granted, provided that the above
    +copyright notice and this permission notice appear in all copies.
    +
    +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
    +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
    +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
    +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
    +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
    +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
    +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
     
  • @@ -7951,6 +9278,63 @@

    Used by:

    OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2018-2019 Sean McArthur
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2018-2023 Sean McArthur
    +Copyright (c) 2016 Alex Crichton
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
     
  • @@ -8038,6 +9422,39 @@

    Used by:

    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2019 Hyper Contributors
    +
    +Permission is hereby granted, free of charge, to any
    +person obtaining a copy of this software and associated
    +documentation files (the "Software"), to deal in the
    +Software without restriction, including without
    +limitation the rights to use, copy, modify, merge,
    +publish, distribute, sublicense, and/or sell copies of
    +the Software, and to permit persons to whom the Software
    +is furnished to do so, subject to the following
    +conditions:
    +
    +The above copyright notice and this permission notice
    +shall be included in all copies or substantial portions
    +of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
    +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
    +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
    +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
    +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    +DEALINGS IN THE SOFTWARE.
     
  • @@ -8355,6 +9772,34 @@

    Used by:

    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2016 Jerome Froelich
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
  • MIT License

    @@ -8434,6 +9879,35 @@

    Used by:

    The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2019 Moritz Borcherding
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
     THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
     IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
     FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    @@ -8564,6 +10038,34 @@ 

    Used by:

    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2022 Nugine
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
  • MIT License

    @@ -8597,7 +10099,9 @@

    Used by:

    MIT License

    Used by:

    MIT License
     
    @@ -8736,6 +10240,34 @@ 

    Used by:

    OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2014 Mathijs van de Nes
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
  • MIT License

    @@ -9107,6 +10639,34 @@

    Used by:

    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    © 2016 Bertram Truong
    +© 2021 Kornel Lesiński
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
  • Unicode License v3

    @@ -9231,6 +10791,7 @@

    zlib License

    Used by:

    Copyright (c) 2024 Orson Peters
     
    diff --git a/SOFTWARE-LICENSE-NOTICES-DEV.html b/SOFTWARE-LICENSE-NOTICES-DEV.html
    index 9ff5a037d..3525d430c 100644
    --- a/SOFTWARE-LICENSE-NOTICES-DEV.html
    +++ b/SOFTWARE-LICENSE-NOTICES-DEV.html
    @@ -49,14 +49,14 @@ 

    ExtendDB Software License Notices

    Overview of licenses:

    All license text:

    @@ -1537,25 +1537,216 @@

    Used by:

    Apache License 2.0

    Used by:

    +
                                     Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "[]"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright [yyyy] [name of copyright owner]
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    +
                                     Apache License
                                Version 2.0, January 2004
    @@ -1745,30 +1936,898 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright {yyyy} {name of copyright owner} + Copyright 2017 Juniper Networks, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                     Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "{}"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright {yyyy} {name of copyright owner}
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                    Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "{}"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright 2018-2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0 January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
     
    -   Licensed under the Apache License, Version 2.0 (the "License");
    -   you may not use this file except in compliance with the License.
    -   You may obtain a copy of the License at
    +APPENDIX: How to apply the Apache License to your work.
     
    -       http://www.apache.org/licenses/LICENSE-2.0
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
     
    -   Unless required by applicable law or agreed to in writing, software
    -   distributed under the License is distributed on an "AS IS" BASIS,
    -   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    -   See the License for the specific language governing permissions and
    -   limitations under the License.
    +Copyright (c) 2016 Alex Crichton
    +Copyright (c) 2017 The Tokio Authors
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
     
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
     
  • Apache License 2.0

    Used by:

                                  Apache License
    -                        Version 2.0 January 2004
    +                        Version 2.0, January 2004
                          http://www.apache.org/licenses/
     
     TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    @@ -1955,7 +3014,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright [yyyy] [name of copyright owner] +Copyright (c) 2019 Matthias Einwag Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -1974,15 +3033,7 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -2172,8 +3223,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright (c) 2016 Alex Crichton -Copyright (c) 2017 The Tokio Authors +Copyright 2014 Paho Lurie-Gregg Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2185,14 +3235,13 @@

    Used by:

    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and -limitations under the License. -
    +limitations under the License.
  • Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -2382,7 +3431,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright (c) 2019 Matthias Einwag +Copyright 2017 The Rust Project Developers Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2401,7 +3450,8 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -2591,7 +3641,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2014 Paho Lurie-Gregg +Copyright 2017 http-rs authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2603,13 +3653,14 @@

    Used by:

    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and -limitations under the License.
    +limitations under the License. +
  • Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -2799,7 +3850,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2017 The Rust Project Developers +Copyright 2017 quininer kel Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2818,7 +3869,7 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -3008,7 +4059,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2017 http-rs authors +Copyright 2018 The pin-utils authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -3027,7 +4078,8 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -3217,7 +4269,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2017 quininer kel +Copyright 2018-2022 RustCrypto Developers Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -4083,6 +5135,7 @@

    Used by:

  • base64 0.21.7
  • base64 0.22.1
  • bitflags 2.11.1
  • +
  • bytes-utils 0.1.4
  • cc 1.2.62
  • cfg-if 1.0.4
  • cmake 0.1.58
  • @@ -4100,20 +5153,25 @@

    Used by:

  • errno 0.3.14
  • event-listener-strategy 0.5.4
  • event-listener 5.4.2
  • +
  • fastrand 2.5.0
  • find-msvc-tools 0.1.9
  • flate2 1.1.9
  • fnv 1.0.7
  • form_urlencoded 1.2.2
  • +
  • group 0.13.0
  • hashbrown 0.14.5
  • hashbrown 0.15.5
  • +
  • hashbrown 0.16.1
  • hashbrown 0.17.1
  • heck 0.5.0
  • httparse 1.10.1
  • +
  • hyper-rustls 0.27.10
  • idna 1.1.0
  • idna_adapter 1.2.2
  • indexmap 2.14.0
  • jobserver 0.1.34
  • lazy_static 1.5.0
  • +
  • linux-raw-sys 0.12.1
  • lock_api 0.4.14
  • log 0.4.29
  • mime 0.3.17
  • @@ -4121,6 +5179,7 @@

    Used by:

  • num-integer 0.1.46
  • num-traits 0.2.19
  • once_cell 1.21.4
  • +
  • openssl-probe 0.2.1
  • parking 2.2.1
  • parking_lot 0.12.5
  • parking_lot_core 0.9.12
  • @@ -4131,8 +5190,13 @@

    Used by:

  • pest_meta 2.8.6
  • pkg-config 0.3.33
  • regex-automata 0.4.14
  • +
  • regex-lite 0.1.9
  • regex-syntax 0.8.10
  • +
  • ring 0.17.14
  • ron 0.8.1
  • +
  • rustc_version 0.4.1
  • +
  • rustix 1.1.4
  • +
  • rustls-native-certs 0.8.4
  • rustls 0.23.46
  • scopeguard 1.2.0
  • signal-hook-registry 1.4.8
  • @@ -4148,6 +5212,7 @@

    Used by:

  • url 2.5.8
  • uuid 1.23.1
  • version_check 0.9.5
  • +
  • xmlparser 0.13.6
  • yasna 0.6.0
  •                               Apache License
    @@ -4351,6 +5416,216 @@ 

    Used by:

    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
     
  • @@ -4983,16 +6258,30 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -6875,6 +8164,7 @@ 

    Used by:

  • extenddb-config 0.1.14
  • extenddb-core 0.1.14
  • extenddb-engine 0.1.14
  • +
  • extenddb-s3 0.1.14
  • extenddb-server 0.1.14
  • extenddb-storage 0.1.14
  • extenddb-storage-sqlite 0.1.14
  • @@ -6882,7 +8172,24 @@

    Used by:

  • anyhow 1.0.103
  • arraydeque 0.5.1
  • async-trait 0.1.89
  • +
  • aws-config 1.8.13
  • +
  • aws-credential-types 1.2.11
  • aws-lc-sys 0.46.0
  • +
  • aws-runtime 1.6.0
  • +
  • aws-sigv4 1.3.8
  • +
  • aws-smithy-async 1.2.11
  • +
  • aws-smithy-checksums 0.64.3
  • +
  • aws-smithy-eventstream 0.60.18
  • +
  • aws-smithy-http-client 1.1.9
  • +
  • aws-smithy-http 0.63.3
  • +
  • aws-smithy-json 0.62.3
  • +
  • aws-smithy-observability 0.2.4
  • +
  • aws-smithy-query 0.60.13
  • +
  • aws-smithy-runtime-api 1.11.3
  • +
  • aws-smithy-runtime 1.10.0
  • +
  • aws-smithy-types 1.4.3
  • +
  • aws-smithy-xml 0.60.13
  • +
  • aws-types 1.3.11
  • daemonize 0.5.0
  • dlv-list 0.5.2
  • dunce 1.0.5
  • @@ -6900,6 +8207,7 @@

    Used by:

  • rcgen 0.14.8
  • rustversion 1.0.22
  • ryu 1.0.23
  • +
  • semver 1.0.28
  • serde 1.0.228
  • serde_core 1.0.228
  • serde_derive 1.0.228
  • @@ -7340,6 +8648,27 @@

    Used by:

    // WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN // ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF // OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +
    +
  • +
  • +

    ISC License

    +

    Used by:

    + +
    Copyright 2015-2025 Brian Smith.
    +
    +Permission to use, copy, modify, and/or distribute this software for any
    +purpose with or without fee is hereby granted, provided that the above
    +copyright notice and this permission notice appear in all copies.
    +
    +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
    +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
    +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
    +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
    +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
    +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
    +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
     
  • @@ -7608,6 +8937,63 @@

    Used by:

    OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2018-2019 Sean McArthur
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2018-2023 Sean McArthur
    +Copyright (c) 2016 Alex Crichton
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
     
  • @@ -7695,6 +9081,39 @@

    Used by:

    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2019 Hyper Contributors
    +
    +Permission is hereby granted, free of charge, to any
    +person obtaining a copy of this software and associated
    +documentation files (the "Software"), to deal in the
    +Software without restriction, including without
    +limitation the rights to use, copy, modify, merge,
    +publish, distribute, sublicense, and/or sell copies of
    +the Software, and to permit persons to whom the Software
    +is furnished to do so, subject to the following
    +conditions:
    +
    +The above copyright notice and this permission notice
    +shall be included in all copies or substantial portions
    +of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
    +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
    +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
    +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
    +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    +DEALINGS IN THE SOFTWARE.
     
  • @@ -8012,6 +9431,34 @@

    Used by:

    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2016 Jerome Froelich
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
  • MIT License

    @@ -8091,6 +9538,35 @@

    Used by:

    The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2019 Moritz Borcherding
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
     THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
     IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
     FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    @@ -8226,7 +9702,37 @@ 

    Used by:

    MIT License

    Used by:

    +
    MIT License
    +
    +Copyright (c) 2022 Nugine
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
  • +
  • +

    MIT License

    +

    Used by:

    +
    MIT License
     
    @@ -8370,6 +9876,7 @@ 

    Used by:

    MIT License

    Used by:

    The MIT License (MIT)
    @@ -8463,6 +9970,35 @@ 

    Used by:

    The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2015 Jake Goulding
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
     THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
     IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
     FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    @@ -8646,6 +10182,34 @@ 

    Used by:

    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    © 2016 Bertram Truong
    +© 2021 Kornel Lesiński
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
  • Unicode License v3

    @@ -8770,6 +10334,7 @@

    zlib License

    Used by:

    Copyright (c) 2024 Orson Peters
     
    diff --git a/SOFTWARE-LICENSE-NOTICES-MONGODB.html b/SOFTWARE-LICENSE-NOTICES-MONGODB.html
    index 6f1570169..ed920dd17 100644
    --- a/SOFTWARE-LICENSE-NOTICES-MONGODB.html
    +++ b/SOFTWARE-LICENSE-NOTICES-MONGODB.html
    @@ -49,14 +49,14 @@ 

    ExtendDB Software License Notices

    Overview of licenses:

    All license text:

    @@ -1534,6 +1534,215 @@

    Used by:

    See the License for the specific language governing permissions and limitations under the License. +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                     Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "[]"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright [yyyy] [name of copyright owner]
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
     
  • @@ -2173,28 +2382,669 @@

    Used by:

    Copyright {yyyy} {name of copyright owner} - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                    Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "{}"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright 2018-2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0 January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright (c) 2016 Alex Crichton
    +Copyright (c) 2017 The Tokio Authors
     
    -       https://www.apache.org/licenses/LICENSE-2.0
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
     
    -   Unless required by applicable law or agreed to in writing, software
    -   distributed under the License is distributed on an "AS IS" BASIS,
    -   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    -   See the License for the specific language governing permissions and
    -   limitations under the License.
    +	http://www.apache.org/licenses/LICENSE-2.0
     
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
     
  • Apache License 2.0

    Used by:

                                  Apache License
    -                        Version 2.0 January 2004
    +                        Version 2.0, January 2004
                          http://www.apache.org/licenses/
     
     TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    @@ -2381,7 +3231,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright [yyyy] [name of copyright owner] +Copyright (c) 2019 Matthias Einwag Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2400,15 +3250,7 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -2598,8 +3440,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright (c) 2016 Alex Crichton -Copyright (c) 2017 The Tokio Authors +Copyright 2014 Paho Lurie-Gregg Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2611,14 +3452,13 @@

    Used by:

    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and -limitations under the License. -
    +limitations under the License.
  • Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -2808,7 +3648,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright (c) 2019 Matthias Einwag +Copyright 2017 The Rust Project Developers Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2827,7 +3667,8 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -3017,7 +3858,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2014 Paho Lurie-Gregg +Copyright 2017 http-rs authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -3029,13 +3870,14 @@

    Used by:

    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and -limitations under the License.
    +limitations under the License. +
  • Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -3225,7 +4067,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2017 The Rust Project Developers +Copyright 2017 quininer kel Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -3244,7 +4086,7 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -3434,7 +4276,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2017 http-rs authors +Copyright 2018 The pin-utils authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -3453,7 +4295,8 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -3643,7 +4486,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2017 quininer kel +Copyright 2018-2022 RustCrypto Developers Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -4509,6 +5352,7 @@

    Used by:

  • base64 0.21.7
  • base64 0.22.1
  • bitflags 2.11.1
  • +
  • bytes-utils 0.1.4
  • cc 1.2.62
  • cfg-if 1.0.4
  • cmake 0.1.58
  • @@ -4528,20 +5372,25 @@

    Used by:

  • errno 0.3.14
  • event-listener-strategy 0.5.4
  • event-listener 5.4.2
  • +
  • fastrand 2.5.0
  • find-msvc-tools 0.1.9
  • flate2 1.1.9
  • fnv 1.0.7
  • form_urlencoded 1.2.2
  • +
  • group 0.13.0
  • hashbrown 0.14.5
  • hashbrown 0.15.5
  • +
  • hashbrown 0.16.1
  • hashbrown 0.17.1
  • heck 0.5.0
  • httparse 1.10.1
  • +
  • hyper-rustls 0.27.10
  • idna 1.1.0
  • idna_adapter 1.2.2
  • indexmap 2.14.0
  • jobserver 0.1.34
  • lazy_static 1.5.0
  • +
  • linux-raw-sys 0.12.1
  • lock_api 0.4.14
  • log 0.4.29
  • mime 0.3.17
  • @@ -4549,6 +5398,7 @@

    Used by:

  • num-integer 0.1.46
  • num-traits 0.2.19
  • once_cell 1.21.4
  • +
  • openssl-probe 0.2.1
  • parking 2.2.1
  • parking_lot 0.12.5
  • parking_lot_core 0.9.12
  • @@ -4559,10 +5409,13 @@

    Used by:

  • pest_meta 2.8.6
  • pkg-config 0.3.33
  • regex-automata 0.4.14
  • +
  • regex-lite 0.1.9
  • regex-syntax 0.8.10
  • ring 0.17.14
  • ron 0.8.1
  • rustc_version 0.4.1
  • +
  • rustix 1.1.4
  • +
  • rustls-native-certs 0.8.4
  • rustls 0.23.46
  • scopeguard 1.2.0
  • serde_with 3.21.0
  • @@ -4583,6 +5436,7 @@

    Used by:

  • url 2.5.8
  • uuid 1.23.1
  • version_check 0.9.5
  • +
  • xmlparser 0.13.6
  • yasna 0.6.0
  •                               Apache License
    @@ -4786,6 +5640,216 @@ 

    Used by:

    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
     
  • @@ -5418,28 +6482,42 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -6904,6 +7982,7 @@ 

    Used by:

  • extenddb-config 0.1.14
  • extenddb-core 0.1.14
  • extenddb-engine 0.1.14
  • +
  • extenddb-s3 0.1.14
  • extenddb-server 0.1.14
  • extenddb-storage 0.1.14
  • extenddb-storage-mongodb 0.1.14
  • @@ -6911,7 +7990,24 @@

    Used by:

  • anyhow 1.0.103
  • arraydeque 0.5.1
  • async-trait 0.1.89
  • +
  • aws-config 1.8.13
  • +
  • aws-credential-types 1.2.11
  • aws-lc-sys 0.46.0
  • +
  • aws-runtime 1.6.0
  • +
  • aws-sigv4 1.3.8
  • +
  • aws-smithy-async 1.2.11
  • +
  • aws-smithy-checksums 0.64.3
  • +
  • aws-smithy-eventstream 0.60.18
  • +
  • aws-smithy-http-client 1.1.9
  • +
  • aws-smithy-http 0.63.3
  • +
  • aws-smithy-json 0.62.3
  • +
  • aws-smithy-observability 0.2.4
  • +
  • aws-smithy-query 0.60.13
  • +
  • aws-smithy-runtime-api 1.11.3
  • +
  • aws-smithy-runtime 1.10.0
  • +
  • aws-smithy-types 1.4.3
  • +
  • aws-smithy-xml 0.60.13
  • +
  • aws-types 1.3.11
  • daemonize 0.5.0
  • dlv-list 0.5.2
  • dunce 1.0.5
  • @@ -7637,6 +8733,63 @@

    Used by:

    OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2018-2019 Sean McArthur
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2018-2023 Sean McArthur
    +Copyright (c) 2016 Alex Crichton
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
     
  • @@ -7724,6 +8877,39 @@

    Used by:

    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2019 Hyper Contributors
    +
    +Permission is hereby granted, free of charge, to any
    +person obtaining a copy of this software and associated
    +documentation files (the "Software"), to deal in the
    +Software without restriction, including without
    +limitation the rights to use, copy, modify, merge,
    +publish, distribute, sublicense, and/or sell copies of
    +the Software, and to permit persons to whom the Software
    +is furnished to do so, subject to the following
    +conditions:
    +
    +The above copyright notice and this permission notice
    +shall be included in all copies or substantial portions
    +of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
    +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
    +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
    +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
    +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    +DEALINGS IN THE SOFTWARE.
     
  • @@ -8068,6 +9254,34 @@

    Used by:

    DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2016 Jerome Froelich
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
  • MIT License

    @@ -8266,6 +9480,35 @@

    Used by:

    The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2019 Moritz Borcherding
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
     THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
     IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
     FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    @@ -8454,6 +9697,34 @@ 

    Used by:

    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2022 Nugine
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
  • MIT License

    @@ -8487,7 +9758,9 @@

    Used by:

    MIT License

    Used by:

    MIT License
     
    @@ -8626,6 +9899,34 @@ 

    Used by:

    OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2014 Mathijs van de Nes
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
  • MIT License

    @@ -8696,6 +9997,35 @@

    Used by:

    The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2015 Jake Goulding
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
     THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
     IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
     FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    @@ -9027,6 +10357,34 @@ 

    Used by:

    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    © 2016 Bertram Truong
    +© 2021 Kornel Lesiński
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
     
  • @@ -9152,6 +10510,7 @@

    zlib License

    Used by:

    Copyright (c) 2024 Orson Peters
     
    diff --git a/SOFTWARE-LICENSE-NOTICES.html b/SOFTWARE-LICENSE-NOTICES.html
    index 47ee4fe1c..d8164710b 100644
    --- a/SOFTWARE-LICENSE-NOTICES.html
    +++ b/SOFTWARE-LICENSE-NOTICES.html
    @@ -49,14 +49,14 @@ 

    ExtendDB Software License Notices

    Overview of licenses:

    All license text:

    @@ -1538,25 +1538,216 @@

    Used by:

    Apache License 2.0

    Used by:

    +
                                     Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "[]"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright [yyyy] [name of copyright owner]
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    +
                                     Apache License
                                Version 2.0, January 2004
    @@ -1746,30 +1937,898 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright {yyyy} {name of copyright owner} + Copyright 2017 Juniper Networks, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                     Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "{}"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright {yyyy} {name of copyright owner}
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                    Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "{}"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright 2018-2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0 January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
     
    -   Licensed under the Apache License, Version 2.0 (the "License");
    -   you may not use this file except in compliance with the License.
    -   You may obtain a copy of the License at
    +APPENDIX: How to apply the Apache License to your work.
     
    -       http://www.apache.org/licenses/LICENSE-2.0
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
     
    -   Unless required by applicable law or agreed to in writing, software
    -   distributed under the License is distributed on an "AS IS" BASIS,
    -   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    -   See the License for the specific language governing permissions and
    -   limitations under the License.
    +Copyright (c) 2016 Alex Crichton
    +Copyright (c) 2017 The Tokio Authors
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
     
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
     
  • Apache License 2.0

    Used by:

                                  Apache License
    -                        Version 2.0 January 2004
    +                        Version 2.0, January 2004
                          http://www.apache.org/licenses/
     
     TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    @@ -1956,7 +3015,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright [yyyy] [name of copyright owner] +Copyright (c) 2019 Matthias Einwag Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -1975,15 +3034,7 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -2173,8 +3224,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright (c) 2016 Alex Crichton -Copyright (c) 2017 The Tokio Authors +Copyright 2014 Paho Lurie-Gregg Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2186,14 +3236,13 @@

    Used by:

    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and -limitations under the License. -
    +limitations under the License.
  • Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -2383,7 +3432,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright (c) 2019 Matthias Einwag +Copyright 2017 The Rust Project Developers Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2402,7 +3451,8 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -2592,7 +3642,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2014 Paho Lurie-Gregg +Copyright 2017 http-rs authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2604,13 +3654,14 @@

    Used by:

    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and -limitations under the License.
    +limitations under the License. +
  • Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -2800,7 +3851,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2017 The Rust Project Developers +Copyright 2017 quininer kel Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2819,7 +3870,7 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -3009,7 +4060,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2017 http-rs authors +Copyright 2018 The pin-utils authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -3028,7 +4079,8 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -3218,7 +4270,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2017 quininer kel +Copyright 2018-2022 RustCrypto Developers Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -4084,6 +5136,7 @@

    Used by:

  • base64 0.21.7
  • base64 0.22.1
  • bitflags 2.11.1
  • +
  • bytes-utils 0.1.4
  • cc 1.2.62
  • cfg-if 1.0.4
  • cmake 0.1.58
  • @@ -4101,20 +5154,25 @@

    Used by:

  • errno 0.3.14
  • event-listener-strategy 0.5.4
  • event-listener 5.4.2
  • +
  • fastrand 2.5.0
  • find-msvc-tools 0.1.9
  • flate2 1.1.9
  • fnv 1.0.7
  • form_urlencoded 1.2.2
  • +
  • group 0.13.0
  • hashbrown 0.14.5
  • hashbrown 0.15.5
  • +
  • hashbrown 0.16.1
  • hashbrown 0.17.1
  • heck 0.5.0
  • httparse 1.10.1
  • +
  • hyper-rustls 0.27.10
  • idna 1.1.0
  • idna_adapter 1.2.2
  • indexmap 2.14.0
  • jobserver 0.1.34
  • lazy_static 1.5.0
  • +
  • linux-raw-sys 0.12.1
  • lock_api 0.4.14
  • log 0.4.29
  • mime 0.3.17
  • @@ -4122,6 +5180,7 @@

    Used by:

  • num-integer 0.1.46
  • num-traits 0.2.19
  • once_cell 1.21.4
  • +
  • openssl-probe 0.2.1
  • parking 2.2.1
  • parking_lot 0.12.5
  • parking_lot_core 0.9.12
  • @@ -4132,8 +5191,13 @@

    Used by:

  • pest_meta 2.8.6
  • pkg-config 0.3.33
  • regex-automata 0.4.14
  • +
  • regex-lite 0.1.9
  • regex-syntax 0.8.10
  • +
  • ring 0.17.14
  • ron 0.8.1
  • +
  • rustc_version 0.4.1
  • +
  • rustix 1.1.4
  • +
  • rustls-native-certs 0.8.4
  • rustls 0.23.46
  • scopeguard 1.2.0
  • signal-hook-registry 1.4.8
  • @@ -4149,6 +5213,7 @@

    Used by:

  • url 2.5.8
  • uuid 1.23.1
  • version_check 0.9.5
  • +
  • xmlparser 0.13.6
  • yasna 0.6.0
  •                               Apache License
    @@ -4352,6 +5417,216 @@ 

    Used by:

    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
     
  • @@ -4984,16 +6259,30 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -6457,6 +7746,7 @@ 

    Used by:

  • extenddb-config 0.1.14
  • extenddb-core 0.1.14
  • extenddb-engine 0.1.14
  • +
  • extenddb-s3 0.1.14
  • extenddb-server 0.1.14
  • extenddb-storage 0.1.14
  • extenddb-storage-postgres 0.1.14
  • @@ -6464,7 +7754,24 @@

    Used by:

  • anyhow 1.0.103
  • arraydeque 0.5.1
  • async-trait 0.1.89
  • +
  • aws-config 1.8.13
  • +
  • aws-credential-types 1.2.11
  • aws-lc-sys 0.46.0
  • +
  • aws-runtime 1.6.0
  • +
  • aws-sigv4 1.3.8
  • +
  • aws-smithy-async 1.2.11
  • +
  • aws-smithy-checksums 0.64.3
  • +
  • aws-smithy-eventstream 0.60.18
  • +
  • aws-smithy-http-client 1.1.9
  • +
  • aws-smithy-http 0.63.3
  • +
  • aws-smithy-json 0.62.3
  • +
  • aws-smithy-observability 0.2.4
  • +
  • aws-smithy-query 0.60.13
  • +
  • aws-smithy-runtime-api 1.11.3
  • +
  • aws-smithy-runtime 1.10.0
  • +
  • aws-smithy-types 1.4.3
  • +
  • aws-smithy-xml 0.60.13
  • +
  • aws-types 1.3.11
  • daemonize 0.5.0
  • dlv-list 0.5.2
  • dunce 1.0.5
  • @@ -6482,6 +7789,7 @@

    Used by:

  • rcgen 0.14.8
  • rustversion 1.0.22
  • ryu 1.0.23
  • +
  • semver 1.0.28
  • serde 1.0.228
  • serde_core 1.0.228
  • serde_derive 1.0.228
  • @@ -6922,6 +8230,27 @@

    Used by:

    // WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN // ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF // OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +
    +
  • +
  • +

    ISC License

    +

    Used by:

    + +
    Copyright 2015-2025 Brian Smith.
    +
    +Permission to use, copy, modify, and/or distribute this software for any
    +purpose with or without fee is hereby granted, provided that the above
    +copyright notice and this permission notice appear in all copies.
    +
    +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
    +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
    +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
    +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
    +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
    +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
    +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
     
  • @@ -7163,6 +8492,63 @@

    Used by:

    OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2018-2019 Sean McArthur
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2018-2023 Sean McArthur
    +Copyright (c) 2016 Alex Crichton
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
     
  • @@ -7250,6 +8636,39 @@

    Used by:

    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2019 Hyper Contributors
    +
    +Permission is hereby granted, free of charge, to any
    +person obtaining a copy of this software and associated
    +documentation files (the "Software"), to deal in the
    +Software without restriction, including without
    +limitation the rights to use, copy, modify, merge,
    +publish, distribute, sublicense, and/or sell copies of
    +the Software, and to permit persons to whom the Software
    +is furnished to do so, subject to the following
    +conditions:
    +
    +The above copyright notice and this permission notice
    +shall be included in all copies or substantial portions
    +of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
    +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
    +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
    +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
    +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    +DEALINGS IN THE SOFTWARE.
     
  • @@ -7567,6 +8986,34 @@

    Used by:

    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2016 Jerome Froelich
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
  • MIT License

    @@ -7646,6 +9093,35 @@

    Used by:

    The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2019 Moritz Borcherding
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
     THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
     IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
     FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    @@ -7781,7 +9257,37 @@ 

    Used by:

    MIT License

    Used by:

    +
    MIT License
    +
    +Copyright (c) 2022 Nugine
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
  • +
  • +

    MIT License

    +

    Used by:

    +
    MIT License
     
    @@ -7920,6 +9426,34 @@ 

    Used by:

    OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2014 Mathijs van de Nes
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
  • MIT License

    @@ -7990,6 +9524,35 @@

    Used by:

    The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2015 Jake Goulding
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
     THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
     IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
     FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    @@ -8325,6 +9888,7 @@ 

    zlib License

    Used by:

    Copyright (c) 2024 Orson Peters
     
    diff --git a/crates/config/src/bulk.rs b/crates/config/src/bulk.rs
    new file mode 100644
    index 000000000..c89ed653f
    --- /dev/null
    +++ b/crates/config/src/bulk.rs
    @@ -0,0 +1,247 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +//! Bulk operation store configuration (`[bulk]` section).
    +
    +use serde::Deserialize;
    +
    +use crate::BulkS3Config;
    +
    +/// Store root dev mode substitutes when the `[bulk]` section is absent,
    +/// relative to the process working directory. Named in the dev-mode banner
    +/// and resolved to its canonical form by the server at startup.
    +pub const DEV_DEFAULT_BULK_PATH: &str = "./.extenddb/buckets";
    +
    +/// Configuration for the bulk operation store (`[bulk]` section).
    +///
    +/// Bulk put reads its input files and writes its error reports through one
    +/// store: the filesystem store beneath one root, namespaced per account
    +/// (`{path}/{account id}/{S3Bucket}/...`), or the S3 store, which reads and
    +/// writes the buckets the requests name in AWS S3 or an S3-compatible
    +/// service. The store is independent of the `[import]` and `[export]`
    +/// sections.
    +#[derive(Debug, Clone, Deserialize)]
    +#[serde(deny_unknown_fields)]
    +pub struct BulkConfig {
    +    /// Store implementation, `filesystem` (the default) or `s3`.
    +    #[serde(default)]
    +    pub store: BulkStoreKind,
    +    /// Filesystem store settings. Required when `store = "filesystem"`;
    +    /// enforced at startup rather than by serde so the error names the key.
    +    pub filesystem: Option,
    +    /// S3 store settings. Optional when `store = "s3"`: an absent section
    +    /// takes every default.
    +    pub s3: Option,
    +}
    +
    +/// Store implementation selector for `[bulk] store`.
    +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
    +#[serde(rename_all = "lowercase")]
    +pub enum BulkStoreKind {
    +    /// Local filesystem store rooted at `[bulk.filesystem] path`.
    +    #[default]
    +    Filesystem,
    +    /// The S3 store configured by `[bulk.s3]`.
    +    S3,
    +}
    +
    +/// Settings for the filesystem bulk store (`[bulk.filesystem]` section).
    +#[derive(Debug, Clone, Deserialize)]
    +#[serde(deny_unknown_fields)]
    +pub struct BulkFilesystemConfig {
    +    /// The store root. Inputs are read and reports are written beneath
    +    /// `{path}/{account id}`. Created at startup if missing.
    +    pub path: String,
    +}
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::*;
    +
    +    /// Narrow view for the `[bulk]` tests. `AppConfig` cannot be
    +    /// deserialized here because `StorageConfig` requires an installed
    +    /// backend, which the config crate's unit tests do not have; the
    +    /// `[bulk]` section deserializes independently of storage, so this
    +    /// struct exercises the same field the full config carries.
    +    #[derive(Debug, serde::Deserialize)]
    +    struct BulkSection {
    +        bulk: Option,
    +    }
    +
    +    fn parse_bulk(toml_text: &str) -> Result {
    +        config::Config::builder()
    +            .add_source(config::File::from_str(toml_text, config::FileFormat::Toml))
    +            .build()?
    +            .try_deserialize()
    +    }
    +
    +    #[test]
    +    fn bulk_section_absent_is_none() {
    +        let parsed = parse_bulk("").unwrap();
    +        assert!(parsed.bulk.is_none());
    +    }
    +
    +    #[test]
    +    fn bulk_store_defaults_to_filesystem() {
    +        let parsed =
    +            parse_bulk("[bulk]\n[bulk.filesystem]\npath = \"/var/lib/extenddb/buckets\"\n")
    +                .unwrap();
    +        let bulk = parsed.bulk.unwrap();
    +        assert_eq!(bulk.store, BulkStoreKind::Filesystem);
    +        assert_eq!(bulk.filesystem.unwrap().path, "/var/lib/extenddb/buckets");
    +    }
    +
    +    #[test]
    +    fn bulk_store_s3_deserializes() {
    +        // `s3` selects the S3 store; its section is optional.
    +        let parsed = parse_bulk("[bulk]\nstore = \"s3\"\n").unwrap();
    +        let bulk = parsed.bulk.unwrap();
    +        assert_eq!(bulk.store, BulkStoreKind::S3);
    +        assert!(bulk.filesystem.is_none());
    +    }
    +
    +    #[test]
    +    fn bulk_store_unknown_value_is_rejected() {
    +        assert!(parse_bulk("[bulk]\nstore = \"tape\"\n").is_err());
    +    }
    +
    +    /// The configuration layer matches enum values case-insensitively, so
    +    /// every spelling of `s3` deserializes to the one `S3` variant, every
    +    /// spelling of `filesystem` to `Filesystem`, and anything else is a
    +    /// parse error: no spelling reaches the resolution code as a third
    +    /// value.
    +    #[test]
    +    fn bulk_store_case_spellings_land_on_the_two_variants() {
    +        for spelling in ["s3", "S3"] {
    +            let parsed = parse_bulk(&format!("[bulk]\nstore = \"{spelling}\"\n")).unwrap();
    +            assert_eq!(
    +                parsed.bulk.unwrap().store,
    +                BulkStoreKind::S3,
    +                "store = {spelling:?} must land on the S3 variant"
    +            );
    +        }
    +        // Case spellings of filesystem land on Filesystem.
    +        for spelling in ["filesystem", "Filesystem", "FILESYSTEM"] {
    +            let parsed = parse_bulk(&format!("[bulk]\nstore = \"{spelling}\"\n")).unwrap();
    +            assert_eq!(parsed.bulk.unwrap().store, BulkStoreKind::Filesystem);
    +        }
    +        // Anything else, whitespace-padded s3 included, is a parse error.
    +        for spelling in ["s3 ", " s3", "sthree", "s-3"] {
    +            assert!(
    +                parse_bulk(&format!("[bulk]\nstore = \"{spelling}\"\n")).is_err(),
    +                "store = {spelling:?} must be refused by the parser"
    +            );
    +        }
    +    }
    +
    +    #[test]
    +    fn bulk_unknown_key_is_rejected() {
    +        assert!(parse_bulk("[bulk]\nroot = \"/somewhere\"\n").is_err());
    +        assert!(parse_bulk("[bulk]\n[bulk.filesystem]\npaths = [\"/a\"]\n").is_err());
    +        assert!(parse_bulk("[bulk]\nstore = \"s3\"\n[bulk.s3]\nprofile = \"loads\"\n").is_err());
    +        assert!(
    +            parse_bulk("[bulk]\nstore = \"s3\"\n[bulk.s3]\nendpoint = \"https://a\"\n").is_err()
    +        );
    +    }
    +
    +    #[test]
    +    fn bulk_environment_overrides_apply() {
    +        // The same Environment source `load` uses, fed a fixed map instead of
    +        // the process environment so the test is deterministic and parallel
    +        // safe. `EXTENDDB__BULK__STORE`, `EXTENDDB__BULK__FILESYSTEM__PATH`
    +        // and the four `EXTENDDB__BULK__S3__*` variables must override the
    +        // file values; the booleans arrive as strings and are converted.
    +        let vars = std::collections::HashMap::from([
    +            ("EXTENDDB__BULK__STORE".to_owned(), "s3".to_owned()),
    +            (
    +                "EXTENDDB__BULK__FILESYSTEM__PATH".to_owned(),
    +                "/from-env/buckets".to_owned(),
    +            ),
    +            (
    +                "EXTENDDB__BULK__S3__REGION".to_owned(),
    +                "eu-central-1".to_owned(),
    +            ),
    +            (
    +                "EXTENDDB__BULK__S3__ENDPOINT_URL".to_owned(),
    +                "http://127.0.0.1:7070".to_owned(),
    +            ),
    +            (
    +                "EXTENDDB__BULK__S3__ALLOW_INSECURE_ENDPOINT".to_owned(),
    +                "true".to_owned(),
    +            ),
    +            (
    +                "EXTENDDB__BULK__S3__FORCE_PATH_STYLE".to_owned(),
    +                "true".to_owned(),
    +            ),
    +        ]);
    +        let cfg = config::Config::builder()
    +            .add_source(config::File::from_str(
    +                "[bulk]\nstore = \"filesystem\"\n[bulk.filesystem]\npath = \"/from-file\"\n\
    +                 [bulk.s3]\nregion = \"us-east-1\"\nendpoint_url = \"https://from-file\"\n\
    +                 allow_insecure_endpoint = false\nforce_path_style = false\n",
    +                config::FileFormat::Toml,
    +            ))
    +            .add_source(
    +                config::Environment::with_prefix("EXTENDDB")
    +                    .separator("__")
    +                    .source(Some(vars)),
    +            )
    +            .build()
    +            .unwrap();
    +        let parsed: BulkSection = cfg.try_deserialize().unwrap();
    +        let bulk = parsed.bulk.unwrap();
    +        assert_eq!(bulk.store, BulkStoreKind::S3);
    +        assert_eq!(bulk.filesystem.unwrap().path, "/from-env/buckets");
    +        let s3 = bulk.s3.unwrap();
    +        assert_eq!(s3.region.as_deref(), Some("eu-central-1"));
    +        assert_eq!(s3.endpoint_url.as_deref(), Some("http://127.0.0.1:7070"));
    +        assert!(s3.allow_insecure_endpoint);
    +        assert!(s3.force_path_style);
    +    }
    +
    +    #[test]
    +    fn bulk_s3_environment_alone_selects_and_configures_the_store() {
    +        let vars = std::collections::HashMap::from([
    +            ("EXTENDDB__BULK__STORE".to_owned(), "s3".to_owned()),
    +            (
    +                "EXTENDDB__BULK__S3__FORCE_PATH_STYLE".to_owned(),
    +                "false".to_owned(),
    +            ),
    +        ]);
    +        let cfg = config::Config::builder()
    +            .add_source(
    +                config::Environment::with_prefix("EXTENDDB")
    +                    .separator("__")
    +                    .source(Some(vars)),
    +            )
    +            .build()
    +            .unwrap();
    +        let parsed: BulkSection = cfg.try_deserialize().unwrap();
    +        let bulk = parsed.bulk.unwrap();
    +        assert_eq!(bulk.store, BulkStoreKind::S3);
    +        let s3 = bulk.s3.unwrap();
    +        assert!(!s3.force_path_style);
    +        assert!(s3.region.is_none() && s3.endpoint_url.is_none());
    +    }
    +
    +    #[test]
    +    fn bulk_environment_alone_creates_the_section() {
    +        // A deployment can enable the store with environment variables and no
    +        // `[bulk]` section in the file.
    +        let vars = std::collections::HashMap::from([(
    +            "EXTENDDB__BULK__FILESYSTEM__PATH".to_owned(),
    +            "/env-only/buckets".to_owned(),
    +        )]);
    +        let cfg = config::Config::builder()
    +            .add_source(
    +                config::Environment::with_prefix("EXTENDDB")
    +                    .separator("__")
    +                    .source(Some(vars)),
    +            )
    +            .build()
    +            .unwrap();
    +        let parsed: BulkSection = cfg.try_deserialize().unwrap();
    +        let bulk = parsed.bulk.unwrap();
    +        assert_eq!(bulk.store, BulkStoreKind::Filesystem);
    +        assert_eq!(bulk.filesystem.unwrap().path, "/env-only/buckets");
    +    }
    +}
    diff --git a/crates/config/src/bulk_s3.rs b/crates/config/src/bulk_s3.rs
    new file mode 100644
    index 000000000..e4541155e
    --- /dev/null
    +++ b/crates/config/src/bulk_s3.rs
    @@ -0,0 +1,399 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +//! S3 bulk store configuration (`[bulk.s3]` section) and its startup
    +//! checks, which are the shared S3 client checks (`s3_client`) under the
    +//! section's name.
    +
    +use serde::Deserialize;
    +use serde::de::IgnoredAny;
    +
    +use crate::s3_client::{S3_CREDENTIAL_KEYS, S3ClientSection};
    +
    +/// Settings for the S3 bulk store (`[bulk.s3]` section). Every key is
    +/// optional. Credentials never come from this section: the store reads the
    +/// standard AWS credential chain, and the credential keys below are
    +/// accepted by the parser only so [`BulkS3Config::validate`] can refuse
    +/// them with a message that names the chain instead of the generic
    +/// unknown-key error.
    +#[derive(Debug, Clone, Default, Deserialize)]
    +#[serde(deny_unknown_fields)]
    +pub struct BulkS3Config {
    +    /// The signing region and the first region a bucket is tried in.
    +    /// Default: `[server] region`.
    +    pub region: Option,
    +    /// An S3-compatible endpoint, an absolute `http` or `https` URL.
    +    /// Default: AWS S3 for the region.
    +    pub endpoint_url: Option,
    +    /// Accept a plain `http` `endpoint_url` whose host is not a loopback
    +    /// form (`127.x.y.z`, `::1` or `localhost`), which otherwise refuses
    +    /// startup; the server then logs a warning. Such an endpoint carries the
    +    /// S3 requests and responses, input objects and reports included, across
    +    /// the network unencrypted. Default: false.
    +    #[serde(default)]
    +    pub allow_insecure_endpoint: bool,
    +    /// Address buckets in the path rather than the host name. Most
    +    /// S3-compatible services need it. Default: false.
    +    #[serde(default)]
    +    pub force_path_style: bool,
    +    #[serde(default)]
    +    access_key_id: Option,
    +    #[serde(default)]
    +    secret_access_key: Option,
    +    #[serde(default)]
    +    session_token: Option,
    +    #[serde(default)]
    +    aws_access_key_id: Option,
    +    #[serde(default)]
    +    aws_secret_access_key: Option,
    +    #[serde(default)]
    +    aws_session_token: Option,
    +}
    +
    +impl BulkS3Config {
    +    /// Check the section against the startup rules
    +    /// ([`S3ClientSection::validate`]). Returns the notices the server logs
    +    /// at warn, or the startup refusal.
    +    ///
    +    /// # Errors
    +    ///
    +    /// A credential key, a region that is not one host label, or an
    +    /// `endpoint_url` that is not an absolute `http` or `https` URL with a
    +    /// host, that carries user information, a query or a fragment, or that
    +    /// is plain `http` to a host that is not loopback while
    +    /// `allow_insecure_endpoint` is false.
    +    pub fn validate(&self) -> Result, String> {
    +        self.section().validate()
    +    }
    +
    +    /// The store as the startup log line and the dev-mode banner name it
    +    /// ([`S3ClientSection::describe`]). `default_region` is `[server]
    +    /// region`. The console settings page shows `endpoint_url` as
    +    /// configured, after `validate` has refused user information, a query
    +    /// and a fragment.
    +    #[must_use]
    +    pub fn describe(&self, default_region: &str) -> String {
    +        self.section().describe(default_region)
    +    }
    +
    +    /// The section as the shared S3 client checks read it.
    +    fn section(&self) -> S3ClientSection<'_> {
    +        let set = [
    +            self.access_key_id.is_some(),
    +            self.secret_access_key.is_some(),
    +            self.session_token.is_some(),
    +            self.aws_access_key_id.is_some(),
    +            self.aws_secret_access_key.is_some(),
    +            self.aws_session_token.is_some(),
    +        ];
    +        S3ClientSection {
    +            name: "[bulk.s3]",
    +            region: self.region.as_deref(),
    +            endpoint_url: self.endpoint_url.as_deref(),
    +            allow_insecure_endpoint: self.allow_insecure_endpoint,
    +            force_path_style: self.force_path_style,
    +            credential_key: S3_CREDENTIAL_KEYS
    +                .into_iter()
    +                .zip(set)
    +                .find_map(|(key, set)| set.then_some(key)),
    +        }
    +    }
    +}
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::*;
    +
    +    /// Narrow view for the `[bulk.s3]` tests, as in the `[bulk]` tests:
    +    /// the section deserializes independently of storage.
    +    #[derive(Debug, serde::Deserialize)]
    +    struct BulkSection {
    +        bulk: Option,
    +    }
    +
    +    fn parse_bulk(toml_text: &str) -> Result {
    +        config::Config::builder()
    +            .add_source(config::File::from_str(toml_text, config::FileFormat::Toml))
    +            .build()?
    +            .try_deserialize()
    +    }
    +
    +    fn s3_section(toml_text: &str) -> BulkS3Config {
    +        parse_bulk(toml_text)
    +            .unwrap()
    +            .bulk
    +            .unwrap()
    +            .s3
    +            .expect("[bulk.s3] present")
    +    }
    +
    +    #[test]
    +    fn bulk_s3_section_parses_every_key() {
    +        let s3 = s3_section(
    +            "[bulk]\nstore = \"s3\"\n[bulk.s3]\nregion = \"us-west-2\"\n\
    +             endpoint_url = \"https://objects.example.internal:9000\"\n\
    +             allow_insecure_endpoint = true\nforce_path_style = true\n",
    +        );
    +        assert_eq!(s3.region.as_deref(), Some("us-west-2"));
    +        assert_eq!(
    +            s3.endpoint_url.as_deref(),
    +            Some("https://objects.example.internal:9000")
    +        );
    +        assert!(s3.allow_insecure_endpoint);
    +        assert!(s3.force_path_style);
    +        assert_eq!(s3.validate(), Ok(Vec::new()));
    +        assert_eq!(
    +            s3.describe("us-east-1"),
    +            "s3 (region us-west-2, endpoint https://objects.example.internal:9000, path-style)"
    +        );
    +    }
    +
    +    #[test]
    +    fn bulk_s3_without_its_section_takes_every_default() {
    +        let bulk = parse_bulk("[bulk]\nstore = \"s3\"\n")
    +            .unwrap()
    +            .bulk
    +            .unwrap();
    +        assert!(bulk.s3.is_none());
    +        let defaults = BulkS3Config::default();
    +        assert_eq!(defaults.validate(), Ok(Vec::new()));
    +        assert!(!defaults.allow_insecure_endpoint);
    +        assert!(!defaults.force_path_style);
    +        assert_eq!(
    +            defaults.describe("eu-west-1"),
    +            "s3 (region eu-west-1, endpoint AWS S3, virtual-hosted-style)"
    +        );
    +        let empty = s3_section("[bulk]\nstore = \"s3\"\n[bulk.s3]\n");
    +        assert_eq!(empty.validate(), Ok(Vec::new()));
    +        assert!(!empty.allow_insecure_endpoint);
    +    }
    +
    +    #[test]
    +    fn bulk_s3_credential_keys_are_refused_naming_the_credential_chain() {
    +        for key in [
    +            "access_key_id",
    +            "secret_access_key",
    +            "session_token",
    +            "aws_access_key_id",
    +            "aws_secret_access_key",
    +            "aws_session_token",
    +        ] {
    +            let s3 = s3_section(&format!(
    +                "[bulk]\nstore = \"s3\"\n[bulk.s3]\n{key} = \"value-not-echoed\"\n"
    +            ));
    +            let refusal = s3.validate().expect_err(key);
    +            assert!(
    +                refusal.starts_with(&format!("[bulk.s3] {key} is not accepted")),
    +                "{refusal}"
    +            );
    +            assert!(
    +                refusal.contains("standard AWS credential chain"),
    +                "{refusal}"
    +            );
    +            assert!(!refusal.contains("value-not-echoed"), "{refusal}");
    +        }
    +    }
    +
    +    #[test]
    +    fn bulk_s3_region_must_be_non_empty_without_whitespace() {
    +        let empty = s3_section("[bulk]\n[bulk.s3]\nregion = \"\"\n");
    +        assert_eq!(
    +            empty.validate(),
    +            Err("[bulk.s3] region must not be empty".to_owned())
    +        );
    +        for region in [" us-east-1", "us-east-1 ", "us east 1", "us-east-1\t"] {
    +            let s3 = BulkS3Config {
    +                region: Some(region.to_owned()),
    +                ..BulkS3Config::default()
    +            };
    +            assert_eq!(
    +                s3.validate(),
    +                Err("[bulk.s3] region must not contain whitespace".to_owned()),
    +                "{region:?}"
    +            );
    +        }
    +    }
    +
    +    #[test]
    +    fn bulk_s3_region_must_be_one_host_label() {
    +        for region in ["us-east-1", "eu-west-3", "local1", "a"] {
    +            let s3 = BulkS3Config {
    +                region: Some(region.to_owned()),
    +                ..BulkS3Config::default()
    +            };
    +            assert_eq!(s3.validate(), Ok(Vec::new()), "{region}");
    +        }
    +        for region in [
    +            "us-east-1/../x",
    +            "US-EAST-1",
    +            "-us",
    +            "us-",
    +            "us_east_1",
    +            "us.east",
    +        ] {
    +            let s3 = BulkS3Config {
    +                region: Some(region.to_owned()),
    +                ..BulkS3Config::default()
    +            };
    +            let refusal = s3.validate().expect_err(region);
    +            assert_eq!(
    +                refusal,
    +                "[bulk.s3] region must be lowercase letters, digits and hyphens, beginning and \
    +                 ending with a letter or a digit",
    +                "{region}"
    +            );
    +            assert!(!refusal.contains(region));
    +        }
    +    }
    +
    +    #[test]
    +    fn describe_names_the_endpoint_by_scheme_host_and_port() {
    +        for (url, named) in [
    +            (
    +                "https://objects.example:9000/base",
    +                "https://objects.example:9000",
    +            ),
    +            ("HTTP://Objects.Example/x/y", "http://objects.example"),
    +            ("http://[::1]:7070", "http://[::1]:7070"),
    +            ("http://AKID:SECRET@127.0.0.1:17070", "not a valid URL"),
    +            ("http://127.0.0.1:17070/?token=SECRET", "not a valid URL"),
    +        ] {
    +            let s3 = BulkS3Config {
    +                endpoint_url: Some(url.to_owned()),
    +                ..BulkS3Config::default()
    +            };
    +            let described = s3.describe("us-east-1");
    +            assert_eq!(
    +                described,
    +                format!("s3 (region us-east-1, endpoint {named}, virtual-hosted-style)")
    +            );
    +            assert!(!described.contains("SECRET") && !described.contains("AKID"));
    +        }
    +    }
    +
    +    fn endpoint(url: &str) -> Result, String> {
    +        BulkS3Config {
    +            endpoint_url: Some(url.to_owned()),
    +            ..BulkS3Config::default()
    +        }
    +        .validate()
    +    }
    +
    +    #[test]
    +    fn bulk_s3_endpoint_url_accepts_absolute_http_and_https_urls() {
    +        for url in [
    +            "https://objects.example.internal:9000",
    +            "https://objects.example.internal",
    +            "HTTPS://Objects.Example.Internal/base/path",
    +            "http://127.0.0.1:7070",
    +            "http://localhost:9000/",
    +            "http://[::1]:7070",
    +            "https://[2001:db8::1]:443",
    +            "https://s3_compat.internal:65535",
    +        ] {
    +            assert_eq!(endpoint(url), Ok(Vec::new()), "{url}");
    +        }
    +    }
    +
    +    #[test]
    +    fn bulk_s3_endpoint_url_refusals() {
    +        let shape = "[bulk.s3] endpoint_url must be an absolute http or https URL with a host";
    +        for url in [
    +            "objects.example.internal:9000",
    +            "/relative/path",
    +            "ftp://objects.example.internal",
    +            "s3://bucket",
    +            "https://",
    +            "https:///path",
    +            "https://:9000",
    +            "https://objects:0",
    +            "https://objects:65536",
    +            "https://objects:port",
    +            "https://objects:",
    +            "https://obj ects",
    +            "https://objects/\npath",
    +            "https://[::1",
    +            "https://[]:9000",
    +            "https://ob!ects",
    +        ] {
    +            assert_eq!(endpoint(url), Err(shape.to_owned()), "{url:?}");
    +        }
    +        let userinfo = endpoint("https://AKIDEXAMPLE:secretvalue@objects:9000").unwrap_err();
    +        assert_eq!(
    +            userinfo,
    +            "[bulk.s3] endpoint_url must not carry user information"
    +        );
    +        assert!(!userinfo.contains("secretvalue"));
    +        assert_eq!(
    +            endpoint("https://user@objects"),
    +            Err("[bulk.s3] endpoint_url must not carry user information".to_owned())
    +        );
    +        for url in [
    +            "https://objects:9000/?x-id=1",
    +            "https://objects:9000#part",
    +            "https://objects?",
    +        ] {
    +            assert_eq!(
    +                endpoint(url),
    +                Err("[bulk.s3] endpoint_url must not carry a query or a fragment".to_owned()),
    +                "{url}"
    +            );
    +        }
    +    }
    +
    +    fn insecure_endpoint(url: &str) -> Result, String> {
    +        BulkS3Config {
    +            endpoint_url: Some(url.to_owned()),
    +            allow_insecure_endpoint: true,
    +            ..BulkS3Config::default()
    +        }
    +        .validate()
    +    }
    +
    +    /// The backup store's rule: a plain `http` endpoint must name a loopback
    +    /// form unless `allow_insecure_endpoint = true`, which accepts it with
    +    /// the notice. The refusal does not repeat the URL.
    +    #[test]
    +    fn bulk_s3_plain_http_to_a_host_that_is_not_loopback_is_refused() {
    +        let refusal = "[bulk.s3] endpoint_url uses http:// to a host that is not a recognised \
    +                       loopback form (use 127.x.y.z, ::1 or localhost); S3 requests and \
    +                       responses would cross the network unencrypted. Use https://, or set \
    +                       allow_insecure_endpoint = true to accept it";
    +        let notice = "[bulk.s3] endpoint_url uses plain http to a host that is not loopback; S3 \
    +                      requests and responses cross the network unencrypted";
    +        for url in [
    +            "http://objects.example.internal:9000",
    +            "http://s3.example.com",
    +            "http://s3.example.com:9000/",
    +            "HTTP://10.0.0.5:9000",
    +            "http://128.0.0.1:9000",
    +            "http://[2001:db8::1]:9000",
    +            "http://localhost.example.com:9000",
    +            // Only a host that parses as an address is an address.
    +            "http://127.0.0.1.evil.example",
    +            "http://127.example.internal",
    +            "http://127.0.0.256",
    +            // Other spellings of a loopback address.
    +            "http://127.1",
    +            "http://127.0.0.01:9000",
    +            "http://localhost.:9000",
    +            "http://[::ffff:127.0.0.1]:9000",
    +        ] {
    +            assert_eq!(endpoint(url), Err(refusal.to_owned()), "{url}");
    +            assert_eq!(insecure_endpoint(url), Ok(vec![notice.to_owned()]), "{url}");
    +        }
    +        for url in [
    +            "http://127.0.0.1:9000",
    +            "http://127.255.0.9",
    +            "http://127.10.20.30",
    +            "http://localhost:9000/",
    +            "http://LOCALHOST",
    +            "http://[::1]:9000",
    +            "http://[0:0:0:0:0:0:0:1]:9000",
    +            "https://s3.example.com",
    +            "https://10.0.0.5:9000",
    +        ] {
    +            assert_eq!(endpoint(url), Ok(Vec::new()), "{url}");
    +            assert_eq!(insecure_endpoint(url), Ok(Vec::new()), "{url}");
    +        }
    +    }
    +}
    diff --git a/crates/config/src/display.rs b/crates/config/src/display.rs
    index f626b75a2..60897b888 100644
    --- a/crates/config/src/display.rs
    +++ b/crates/config/src/display.rs
    @@ -3,6 +3,8 @@
     //! Configuration display and redaction helpers for the console settings page,
     //! the management settings API and the `extenddb settings` CLI.
     
    +use extenddb_core::limits::LimitsConfig;
    +
     use crate::AppConfig;
     
     /// Keys whose values must be redacted in configuration displays.
    @@ -61,6 +63,14 @@ pub fn build_config_entries(cfg: &AppConfig) -> Vec<(String, String)> {
         let backend = &cfg.storage.backend;
         let data_pool_size = display_pool_limit(cfg.storage.max_connections_override());
         let catalog_pool_size = display_pool_limit(cfg.storage.max_catalog_connections_override());
    +    // The S3 store's settings when it is the selected store, an absent
    +    // `[bulk.s3]` section taking every default; `none` otherwise.
    +    let default_s3 = crate::BulkS3Config::default();
    +    let bulk_s3 = cfg
    +        .bulk
    +        .as_ref()
    +        .filter(|b| b.store == crate::BulkStoreKind::S3)
    +        .map(|b| b.s3.as_ref().unwrap_or(&default_s3));
         let mut entries = vec![
             ("server.bind_addr".into(), cfg.server.bind_addr.clone()),
             ("server.port".into(), cfg.server.port.to_string()),
    @@ -113,11 +123,63 @@ pub fn build_config_entries(cfg: &AppConfig) -> Vec<(String, String)> {
                     cfg.export_config.paths.join(", ")
                 },
             ),
    +        (
    +            "bulk.store".into(),
    +            cfg.bulk.as_ref().map_or_else(
    +                || "disabled".into(),
    +                |b| match b.store {
    +                    crate::BulkStoreKind::Filesystem => "filesystem".to_owned(),
    +                    crate::BulkStoreKind::S3 => "s3".to_owned(),
    +                },
    +            ),
    +        ),
    +        (
    +            "bulk.filesystem.path".into(),
    +            cfg.bulk
    +                .as_ref()
    +                .and_then(|b| b.filesystem.as_ref())
    +                .map_or_else(|| "none".into(), |f| f.path.clone()),
    +        ),
    +        (
    +            "bulk.s3.region".into(),
    +            bulk_s3.map_or_else(
    +                || "none".into(),
    +                |s3| {
    +                    s3.region
    +                        .clone()
    +                        .unwrap_or_else(|| cfg.server.region.clone())
    +                },
    +            ),
    +        ),
    +        (
    +            "bulk.s3.endpoint_url".into(),
    +            bulk_s3.map_or_else(
    +                || "none".into(),
    +                |s3| s3.endpoint_url.clone().unwrap_or_else(|| "AWS S3".into()),
    +            ),
    +        ),
    +        (
    +            "bulk.s3.allow_insecure_endpoint".into(),
    +            bulk_s3.map_or_else(
    +                || "none".into(),
    +                |s3| s3.allow_insecure_endpoint.to_string(),
    +            ),
    +        ),
    +        (
    +            "bulk.s3.force_path_style".into(),
    +            bulk_s3.map_or_else(|| "none".into(), |s3| s3.force_path_style.to_string()),
    +        ),
         ];
     
    -    // Commonly adjusted limits (full list in [limits] section of extenddb.sample.toml).
    -    let lim = &cfg.limits;
    -    entries.extend([
    +    entries.extend(limits_entries(&cfg.limits));
    +
    +    entries
    +}
    +
    +/// Commonly adjusted `[limits]` keys (full list in the `[limits]` section of
    +/// extenddb.sample.toml).
    +fn limits_entries(lim: &LimitsConfig) -> Vec<(String, String)> {
    +    vec![
             (
                 "limits.max_item_size_bytes".into(),
                 lim.max_item_size_bytes.to_string(),
    @@ -138,9 +200,71 @@ pub fn build_config_entries(cfg: &AppConfig) -> Vec<(String, String)> {
                 "limits.max_import_file_bytes".into(),
                 lim.max_import_file_bytes.to_string(),
             ),
    -    ]);
    -
    -    entries
    +        (
    +            "limits.max_concurrent_bulk_operations".into(),
    +            lim.max_concurrent_bulk_operations.to_string(),
    +        ),
    +        (
    +            "limits.bulk_operation_retention_days".into(),
    +            lim.bulk_operation_retention_days.to_string(),
    +        ),
    +        (
    +            "limits.bulk_operation_worker_concurrency".into(),
    +            lim.bulk_operation_worker_concurrency.to_string(),
    +        ),
    +        (
    +            "limits.bulk_operation_worker_per_account".into(),
    +            lim.bulk_operation_worker_per_account.to_string(),
    +        ),
    +        (
    +            "limits.bulk_operation_lease_seconds".into(),
    +            lim.bulk_operation_lease_seconds.to_string(),
    +        ),
    +        (
    +            "limits.bulk_put_batch_size".into(),
    +            lim.bulk_put_batch_size.to_string(),
    +        ),
    +        (
    +            "limits.bulk_put_write_batch_rows".into(),
    +            lim.bulk_put_write_batch_rows.to_string(),
    +        ),
    +        (
    +            "limits.max_bulk_input_bytes".into(),
    +            lim.max_bulk_input_bytes.to_string(),
    +        ),
    +        (
    +            "limits.max_bulk_input_files".into(),
    +            lim.max_bulk_input_files.to_string(),
    +        ),
    +        (
    +            "limits.max_bulk_input_object_bytes".into(),
    +            lim.max_bulk_input_object_bytes.to_string(),
    +        ),
    +        (
    +            "limits.max_bulk_line_bytes".into(),
    +            lim.max_bulk_line_bytes.to_string(),
    +        ),
    +        (
    +            "limits.max_bulk_item_count".into(),
    +            lim.max_bulk_item_count.to_string(),
    +        ),
    +        (
    +            "limits.max_bulk_report_records".into(),
    +            lim.max_bulk_report_records.to_string(),
    +        ),
    +        (
    +            "limits.max_bulk_report_bytes".into(),
    +            lim.max_bulk_report_bytes.to_string(),
    +        ),
    +        (
    +            "limits.max_bulk_failed_starts_per_hour".into(),
    +            lim.max_bulk_failed_starts_per_hour.to_string(),
    +        ),
    +        (
    +            "limits.bulk_put_write_units_per_second".into(),
    +            lim.bulk_put_write_units_per_second.to_string(),
    +        ),
    +    ]
     }
     
     #[cfg(test)]
    diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs
    index 5b1cb603a..c1b791617 100644
    --- a/crates/config/src/lib.rs
    +++ b/crates/config/src/lib.rs
    @@ -40,6 +40,11 @@ pub struct AppConfig {
         /// operations. If empty or absent, exports are denied (secure default).
         #[serde(default, rename = "export")]
         pub export_config: ImportExportPathConfig,
    +    /// Bulk operation store configuration (`[bulk]` section). Absent means
    +    /// bulk operations are disabled (secure default); dev mode substitutes a
    +    /// filesystem store at `./.extenddb/buckets` when the section is absent.
    +    #[serde(rename = "bulk")]
    +    pub bulk: Option,
         /// Maximum import file size in bytes. Defaults to 10 GB.
         pub max_import_bytes: Option,
         /// Path to the rendered documentation directory (`/console/docs`).
    @@ -60,6 +65,13 @@ pub struct ImportExportPathConfig {
         pub paths: Vec,
     }
     
    +mod bulk;
    +mod bulk_s3;
    +mod s3_client;
    +pub use bulk::{BulkConfig, BulkFilesystemConfig, BulkStoreKind, DEV_DEFAULT_BULK_PATH};
    +pub use bulk_s3::BulkS3Config;
    +pub use s3_client::{S3_CREDENTIAL_KEYS, S3ClientSection};
    +
     #[derive(Debug, Clone, Deserialize)]
     #[serde(deny_unknown_fields)]
     pub struct ServerConfig {
    diff --git a/crates/config/src/s3_client.rs b/crates/config/src/s3_client.rs
    new file mode 100644
    index 000000000..402f286ef
    --- /dev/null
    +++ b/crates/config/src/s3_client.rs
    @@ -0,0 +1,358 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +//! The keys and startup checks every S3 client section shares, such as
    +//! `[bulk.s3]`: `region`, `endpoint_url`, `allow_insecure_endpoint` and
    +//! `force_path_style`, the credential keys the section refuses, and the
    +//! checks, with the section's name in every message. A section declares its
    +//! own keys, so its unknown-key check stays its own, and hands what it read
    +//! to [`S3ClientSection`].
    +
    +/// The credential keys an S3 client section refuses, in the order the
    +/// check names them: the store reads its credentials from the standard
    +/// AWS credential chain, never from the configuration file.
    +pub const S3_CREDENTIAL_KEYS: [&str; 6] = [
    +    "access_key_id",
    +    "secret_access_key",
    +    "session_token",
    +    "aws_access_key_id",
    +    "aws_secret_access_key",
    +    "aws_session_token",
    +];
    +
    +/// One S3 client section as it was read.
    +#[derive(Debug, Clone, Copy)]
    +pub struct S3ClientSection<'a> {
    +    /// The section as its messages name it, brackets included: `[bulk.s3]`.
    +    pub name: &'a str,
    +    /// The signing region and the first region a bucket is tried in.
    +    pub region: Option<&'a str>,
    +    /// An S3-compatible endpoint, an absolute `http` or `https` URL.
    +    pub endpoint_url: Option<&'a str>,
    +    /// Accept a plain `http` endpoint whose host is not a loopback form,
    +    /// with a notice, where the check otherwise refuses it.
    +    pub allow_insecure_endpoint: bool,
    +    /// Address buckets in the path rather than the host name.
    +    pub force_path_style: bool,
    +    /// The first of [`S3_CREDENTIAL_KEYS`] the section sets.
    +    pub credential_key: Option<&'a str>,
    +}
    +
    +impl S3ClientSection<'_> {
    +    /// Check the section against the startup rules. Returns the notices
    +    /// the server logs at warn (a plain `http` endpoint on a host that is
    +    /// not loopback, accepted with `allow_insecure_endpoint`), or the
    +    /// startup refusal.
    +    ///
    +    /// # Errors
    +    ///
    +    /// A credential key, a region that is empty, holds whitespace or is not
    +    /// a host label of lowercase letters, digits and hyphens (the form the S3
    +    /// endpoint rules accept), or an `endpoint_url` that is not an absolute
    +    /// `http` or `https` URL with a host, that carries user information, a
    +    /// query or a fragment, or that is plain `http` to a host that is not a
    +    /// loopback form while `allow_insecure_endpoint` is false. The messages
    +    /// never repeat the value: the URL may hold a secret.
    +    pub fn validate(&self) -> Result, String> {
    +        let name = self.name;
    +        if let Some(key) = self.credential_key {
    +            return Err(format!(
    +                "{name} {key} is not accepted: the S3 store reads its credentials from the \
    +                 standard AWS credential chain (environment variables, the shared config and \
    +                 credentials files, web identity, container and instance credentials), never \
    +                 from the configuration file"
    +            ));
    +        }
    +        if let Some(region) = self.region {
    +            if region.is_empty() {
    +                return Err(format!("{name} region must not be empty"));
    +            }
    +            if region.chars().any(char::is_whitespace) {
    +                return Err(format!("{name} region must not contain whitespace"));
    +            }
    +            if !is_region_label(region) {
    +                return Err(format!(
    +                    "{name} region must be lowercase letters, digits and hyphens, beginning \
    +                     and ending with a letter or a digit"
    +                ));
    +            }
    +        }
    +        let mut notices = Vec::new();
    +        if let Some(url) = self.endpoint_url {
    +            let endpoint = parse_endpoint(name, url)?;
    +            if !endpoint.tls && !endpoint.loopback {
    +                if !self.allow_insecure_endpoint {
    +                    return Err(format!(
    +                        "{name} endpoint_url uses http:// to a host that is not a recognised \
    +                         loopback form (use 127.x.y.z, ::1 or localhost); S3 requests and \
    +                         responses would cross the network unencrypted. Use https://, or set \
    +                         allow_insecure_endpoint = true to accept it"
    +                    ));
    +                }
    +                notices.push(format!(
    +                    "{name} endpoint_url uses plain http to a host that is not loopback; S3 \
    +                     requests and responses cross the network unencrypted"
    +                ));
    +            }
    +        }
    +        Ok(notices)
    +    }
    +
    +    /// The store as a startup log line and the dev-mode banner name it, for
    +    /// example `s3 (region us-east-1, endpoint https://objects:9000,
    +    /// path-style)`. `default_region` is the region the store takes when
    +    /// the section names none. The endpoint is named by its scheme, host
    +    /// and port only, so a path, user information, a query or a fragment
    +    /// never reaches that log line or the banner, whether or not the
    +    /// section was checked first.
    +    #[must_use]
    +    pub fn describe(&self, default_region: &str) -> String {
    +        let region = self.region.unwrap_or(default_region);
    +        let endpoint = match self.endpoint_url {
    +            None => "AWS S3".to_owned(),
    +            Some(url) => parse_endpoint(self.name, url)
    +                .map_or_else(|_| "not a valid URL".to_owned(), |endpoint| endpoint.origin),
    +        };
    +        let style = if self.force_path_style {
    +            "path-style"
    +        } else {
    +            "virtual-hosted-style"
    +        };
    +        format!("s3 (region {region}, endpoint {endpoint}, {style})")
    +    }
    +}
    +
    +/// Whether `region` is one host label: lowercase letters, digits and
    +/// hyphens, beginning and ending with a letter or a digit.
    +fn is_region_label(region: &str) -> bool {
    +    let allowed = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit();
    +    region.chars().all(|c| allowed(c) || c == '-')
    +        && region.starts_with(allowed)
    +        && region.ends_with(allowed)
    +}
    +
    +/// What the startup check reads from an `endpoint_url`.
    +struct Endpoint {
    +    tls: bool,
    +    /// The host is a loopback form ([`is_loopback_host`]).
    +    loopback: bool,
    +    /// The scheme, host and port, lowercased, as `describe` names them.
    +    origin: String,
    +}
    +
    +/// Parse the `endpoint_url` of the section `name`: `http` or `https` (any
    +/// case), `://`, an authority with a host and an optional port, and an
    +/// optional path. User information, a query and a fragment are refused, as
    +/// is whitespace.
    +fn parse_endpoint(name: &str, url: &str) -> Result {
    +    let shape = || format!("{name} endpoint_url must be an absolute http or https URL with a host");
    +    if url.chars().any(|c| c.is_whitespace() || c.is_control()) {
    +        return Err(shape());
    +    }
    +    let (scheme, rest) = url.split_once("://").ok_or_else(shape)?;
    +    let tls = if scheme.eq_ignore_ascii_case("https") {
    +        true
    +    } else if scheme.eq_ignore_ascii_case("http") {
    +        false
    +    } else {
    +        return Err(shape());
    +    };
    +    if rest.contains(['?', '#']) {
    +        return Err(format!(
    +            "{name} endpoint_url must not carry a query or a fragment"
    +        ));
    +    }
    +    let authority = rest.split('/').next().unwrap_or_default();
    +    if authority.contains('@') {
    +        return Err(format!(
    +            "{name} endpoint_url must not carry user information"
    +        ));
    +    }
    +    let (host, port) = if let Some(bracketed) = authority.strip_prefix('[') {
    +        let (literal, after) = bracketed.split_once(']').ok_or_else(shape)?;
    +        if literal.is_empty()
    +            || !literal
    +                .chars()
    +                .all(|c| c.is_ascii_hexdigit() || c == ':' || c == '.')
    +        {
    +            return Err(shape());
    +        }
    +        let port = match after {
    +            "" => None,
    +            other => Some(other.strip_prefix(':').ok_or_else(shape)?),
    +        };
    +        (format!("[{literal}]"), port)
    +    } else {
    +        let (host, port) = match authority.rsplit_once(':') {
    +            Some((host, port)) => (host, Some(port)),
    +            None => (authority, None),
    +        };
    +        if host.is_empty()
    +            || !host
    +                .chars()
    +                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
    +        {
    +            return Err(shape());
    +        }
    +        (host.to_ascii_lowercase(), port)
    +    };
    +    if let Some(port) = port
    +        && (port.is_empty()
    +            || !port.chars().all(|c| c.is_ascii_digit())
    +            || port.parse::().map_or(true, |p| p == 0))
    +    {
    +        return Err(shape());
    +    }
    +    let loopback = is_loopback_host(
    +        host.strip_prefix('[')
    +            .and_then(|literal| literal.strip_suffix(']'))
    +            .unwrap_or(&host),
    +    );
    +    let scheme = if tls { "https" } else { "http" };
    +    let origin = match port {
    +        Some(port) => format!("{scheme}://{host}:{port}"),
    +        None => format!("{scheme}://{host}"),
    +    };
    +    Ok(Endpoint {
    +        tls,
    +        loopback,
    +        origin,
    +    })
    +}
    +
    +/// `127.0.0.0/8`, `::1` and `localhost`, in the forms the backup store's
    +/// endpoint check recognises (`crates/backup-store`, the `config` module
    +/// doc): `127.x.y.z` in dotted-quad form, an IPv6 address equal to `::1`
    +/// (`host` holds it without its brackets), and `localhost` in any case.
    +/// Other spellings of a loopback address (`127.1`, `127.0.0.01`,
    +/// `::ffff:127.0.0.1`, a trailing dot) are not recognised.
    +fn is_loopback_host(host: &str) -> bool {
    +    host.eq_ignore_ascii_case("localhost")
    +        || host
    +            .parse::()
    +            .is_ok_and(|ip| ip.is_loopback())
    +        || host
    +            .parse::()
    +            .is_ok_and(|ip| ip.is_loopback())
    +}
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::*;
    +
    +    /// A section of another name than `[bulk.s3]`.
    +    fn backup(endpoint_url: Option<&'static str>) -> S3ClientSection<'static> {
    +        S3ClientSection {
    +            name: "[backup.s3]",
    +            region: None,
    +            endpoint_url,
    +            allow_insecure_endpoint: false,
    +            force_path_style: false,
    +            credential_key: None,
    +        }
    +    }
    +
    +    #[test]
    +    fn every_message_names_the_section_it_checks() {
    +        let refusal = S3ClientSection {
    +            credential_key: Some("aws_session_token"),
    +            ..backup(None)
    +        }
    +        .validate()
    +        .unwrap_err();
    +        assert!(
    +            refusal.starts_with("[backup.s3] aws_session_token is not accepted: the S3 store"),
    +            "{refusal}"
    +        );
    +        for (region, refusal) in [
    +            ("", "[backup.s3] region must not be empty"),
    +            ("us east", "[backup.s3] region must not contain whitespace"),
    +            (
    +                "US-EAST-1",
    +                "[backup.s3] region must be lowercase letters, digits and hyphens, beginning \
    +                 and ending with a letter or a digit",
    +            ),
    +        ] {
    +            let section = S3ClientSection {
    +                region: Some(region),
    +                ..backup(None)
    +            };
    +            assert_eq!(section.validate(), Err(refusal.to_owned()), "{region:?}");
    +        }
    +        for (url, refusal) in [
    +            (
    +                "ftp://objects",
    +                "[backup.s3] endpoint_url must be an absolute http or https URL with a host",
    +            ),
    +            (
    +                "https://user:secret@objects",
    +                "[backup.s3] endpoint_url must not carry user information",
    +            ),
    +            (
    +                "https://objects/?x=1",
    +                "[backup.s3] endpoint_url must not carry a query or a fragment",
    +            ),
    +        ] {
    +            assert_eq!(
    +                backup(Some(url)).validate(),
    +                Err(refusal.to_owned()),
    +                "{url}"
    +            );
    +        }
    +        assert_eq!(
    +            backup(Some("http://objects.example.internal")).validate(),
    +            Err(
    +                "[backup.s3] endpoint_url uses http:// to a host that is not a recognised \
    +                 loopback form (use 127.x.y.z, ::1 or localhost); S3 requests and responses \
    +                 would cross the network unencrypted. Use https://, or set \
    +                 allow_insecure_endpoint = true to accept it"
    +                    .to_owned()
    +            )
    +        );
    +        assert_eq!(
    +            S3ClientSection {
    +                allow_insecure_endpoint: true,
    +                ..backup(Some("http://objects.example.internal"))
    +            }
    +            .validate(),
    +            Ok(vec![
    +                "[backup.s3] endpoint_url uses plain http to a host that is not loopback; S3 \
    +                 requests and responses cross the network unencrypted"
    +                    .to_owned()
    +            ])
    +        );
    +        assert_eq!(
    +            backup(Some("HTTPS://Objects:9000/base")).describe("eu-west-1"),
    +            "s3 (region eu-west-1, endpoint https://objects:9000, virtual-hosted-style)"
    +        );
    +    }
    +
    +    /// The backup store's loopback forms, and the other spellings of a
    +    /// loopback address it does not recognise.
    +    #[test]
    +    fn loopback_hosts() {
    +        for host in [
    +            "127.0.0.1",
    +            "127.1.2.3",
    +            "localhost",
    +            "LocalHost",
    +            "::1",
    +            "0:0:0:0:0:0:0:1",
    +        ] {
    +            assert!(is_loopback_host(host), "{host}");
    +        }
    +        for host in [
    +            "128.0.0.1",
    +            "10.0.0.1",
    +            "::2",
    +            "example.com",
    +            "localhost.example",
    +            "",
    +            "127.1",
    +            "127.0.0.01",
    +            "localhost.",
    +            "::ffff:127.0.0.1",
    +        ] {
    +            assert!(!is_loopback_host(host), "{host}");
    +        }
    +    }
    +}
    diff --git a/crates/s3/Cargo.toml b/crates/s3/Cargo.toml
    new file mode 100644
    index 000000000..13a5d2de4
    --- /dev/null
    +++ b/crates/s3/Cargo.toml
    @@ -0,0 +1,37 @@
    +# Copyright 2026 ExtendDB contributors
    +# SPDX-License-Identifier: Apache-2.0
    +[package]
    +name = "extenddb-s3"
    +version.workspace = true
    +edition.workspace = true
    +rust-version.workspace = true
    +license.workspace = true
    +
    +[features]
    +# The hooks a store's tests build clients with: `S3Clients::new` over a
    +# configuration of the test's own, `S3Clients::with_retry`,
    +# `S3Clients::config` and `retry::RetryPolicy::NONE`. A store enables it in
    +# its dev-dependencies, so production code cannot reach them.
    +test-util = []
    +
    +[dependencies]
    +# The S3 client and the standard AWS credential chain.
    +aws-config = { workspace = true }
    +aws-sdk-s3 = { workspace = true }
    +# The startup identity check (GetCallerIdentity), and the key it reads an
    +# endpoint set for STS alone with.
    +aws-sdk-sts = { workspace = true }
    +aws-types = { workspace = true }
    +# The credential features the temporary-keys warning reads.
    +aws-credential-types = { workspace = true }
    +tokio = { workspace = true }
    +tracing = { workspace = true }
    +
    +[dev-dependencies]
    +# The client tests answer canned S3 and STS responses through the SDK's
    +# replay HTTP client.
    +aws-smithy-http-client = { workspace = true, features = ["test-util"] }
    +# `test-util` gives the identity check tests a paused clock, so its
    +# five-second bound is passed without a five-second sleep.
    +tokio = { workspace = true, features = ["test-util"] }
    +uuid = { workspace = true }
    diff --git a/crates/s3/src/client.rs b/crates/s3/src/client.rs
    new file mode 100644
    index 000000000..f1472fb4c
    --- /dev/null
    +++ b/crates/s3/src/client.rs
    @@ -0,0 +1,146 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Building one store's clients: the pinned behaviour version, the
    +//! deadlines, the region and endpoint of the settings, and the startup
    +//! credential check.
    +
    +use std::time::Duration;
    +
    +use aws_config::{ConfigLoader, SdkConfig};
    +use aws_sdk_s3::config::timeout::TimeoutConfig;
    +use aws_sdk_s3::config::{
    +    BehaviorVersion, Region, RequestChecksumCalculation, ResponseChecksumValidation,
    +};
    +
    +use crate::S3StoreSettings;
    +use crate::clients::S3Clients;
    +use crate::credentials::{Startup, check_credentials, warn_of_temporary_keys};
    +use crate::identity;
    +
    +/// The SDK behaviour version every store client is built and tested with.
    +/// It is pinned rather than `BehaviorVersion::latest()`, so an SDK update
    +/// cannot change the clients' retry, timeout or stalled-stream defaults
    +/// without a change here: a store may render what they decide, as the bulk
    +/// put store states one attempt for every mapped 4xx of a report write.
    +#[must_use]
    +pub fn behavior_version() -> BehaviorVersion {
    +    BehaviorVersion::v2026_01_12()
    +}
    +
    +/// The connect timeout of each attempt: the SDK's default for the pinned
    +/// behaviour version, stated here so an SDK update cannot change it.
    +pub const CONNECT_TIMEOUT: Duration = Duration::from_millis(3100);
    +
    +/// The wait for a response's head on each attempt, from the request's
    +/// dispatch. A body is not under this deadline: one that stops is ended by
    +/// the SDK's stalled-stream protection, on by default for the pinned
    +/// behaviour version (`aws-smithy-runtime` `client::defaults`, a 5-second
    +/// grace period for uploads and downloads).
    +pub const READ_TIMEOUT: Duration = Duration::from_secs(60);
    +
    +/// The deadlines every store client is built with.
    +#[must_use]
    +pub fn timeouts() -> TimeoutConfig {
    +    TimeoutConfig::builder()
    +        .connect_timeout(CONNECT_TIMEOUT)
    +        .read_timeout(READ_TIMEOUT)
    +        .build()
    +}
    +
    +/// Build a store's clients from its settings and the standard AWS
    +/// credential chain. The region is set on the loader, so `AWS_REGION` does
    +/// not change it. Credentials are resolved once, within five seconds, and
    +/// the outcome logged under `startup`'s label; a failure is a warning, so a
    +/// slow instance metadata service does not stop the server. Resolved
    +/// temporary keys the SDK cannot renew are a warning too. Without an
    +/// `endpoint_url`, one STS GetCallerIdentity request is sent at startup to
    +/// log the identity the credentials sign as, in a task of its own that
    +/// never delays the return.
    +pub async fn connect(settings: &S3StoreSettings, startup: &Startup<'_>) -> S3Clients {
    +    connect_with(settings, startup, |builder| builder).await
    +}
    +
    +/// [`connect`], with `adjust` applied to the S3 client configuration
    +/// before it is built, so a store's contract tests run on the clients the
    +/// settings describe with an interceptor of their own. Without an
    +/// `endpoint_url`, the startup GetCallerIdentity request goes over the
    +/// HTTP client of the built S3 clients, so an HTTP client `adjust` sets
    +/// carries it too: a test that gives the clients an in-process client
    +/// keeps that request in the process.
    +pub async fn connect_with(
    +    settings: &S3StoreSettings,
    +    startup: &Startup<'_>,
    +    adjust: impl FnOnce(aws_sdk_s3::config::Builder) -> aws_sdk_s3::config::Builder,
    +) -> S3Clients {
    +    let sdk = loader(settings).load().await;
    +    connect_from(settings, &sdk, startup, adjust).await
    +}
    +
    +/// The loader of the SDK configuration [`connect_with`] builds the clients
    +/// over: the pinned behaviour version and the store's region, which
    +/// `AWS_REGION` and a profile's region do not change, with the credential
    +/// chain and everything else read from the environment and the AWS config
    +/// files.
    +pub(crate) fn loader(settings: &S3StoreSettings) -> ConfigLoader {
    +    aws_config::defaults(behavior_version()).region(Region::new(settings.region.clone()))
    +}
    +
    +/// [`connect_with`] over a loaded SDK configuration: the startup checks
    +/// of the credentials the configuration resolves, then the clients, which
    +/// keep the resolved access key ID. The identity check runs against AWS S3
    +/// only, in a task of its own, over the HTTP client of the clients.
    +pub(crate) async fn connect_from(
    +    settings: &S3StoreSettings,
    +    sdk: &SdkConfig,
    +    startup: &Startup<'_>,
    +    adjust: impl FnOnce(aws_sdk_s3::config::Builder) -> aws_sdk_s3::config::Builder,
    +) -> S3Clients {
    +    let resolved = check_credentials(sdk, startup).await;
    +    if let Some(credentials) = &resolved {
    +        warn_of_temporary_keys(credentials, startup.label);
    +    }
    +    let access_key_id = resolved
    +        .as_ref()
    +        .map(|credentials| credentials.access_key_id().to_owned());
    +    let clients = clients_from(settings, sdk, adjust).with_access_key_id(access_key_id);
    +    if let (Some(credentials), None) = (resolved, &settings.endpoint_url) {
    +        identity::spawn_check(
    +            sdk,
    +            clients.http_client(),
    +            &settings.region,
    +            credentials,
    +            startup.label,
    +        );
    +    }
    +    clients
    +}
    +
    +/// A store's clients over a loaded SDK configuration: the settings'
    +/// addressing style, the deadlines of [`timeouts`], and the endpoint of
    +/// `endpoint_url` alone. Against a configured endpoint checksums are sent
    +/// and checked only when an operation requires them, since several
    +/// S3-compatible services refuse the trailing checksums the SDK otherwise
    +/// sends, and no region redirects are followed. Without one, an endpoint
    +/// the SDK read from `AWS_ENDPOINT_URL`, `AWS_ENDPOINT_URL_S3` or a
    +/// profile (`endpoint_url`, a `services` section) is cleared, so requests
    +/// go to AWS S3 for the region, as a store's startup log line says.
    +pub fn clients_from(
    +    settings: &S3StoreSettings,
    +    sdk: &SdkConfig,
    +    adjust: impl FnOnce(aws_sdk_s3::config::Builder) -> aws_sdk_s3::config::Builder,
    +) -> S3Clients {
    +    let mut builder = aws_sdk_s3::config::Builder::from(sdk)
    +        .force_path_style(settings.force_path_style)
    +        .timeout_config(timeouts());
    +    let endpoint = settings.endpoint_url.is_some();
    +    if let Some(url) = &settings.endpoint_url {
    +        builder = builder
    +            .endpoint_url(url)
    +            .request_checksum_calculation(RequestChecksumCalculation::WhenRequired)
    +            .response_checksum_validation(ResponseChecksumValidation::WhenRequired);
    +    } else {
    +        builder.set_endpoint_url(None);
    +    }
    +    S3Clients::from_config(adjust(builder).build(), !endpoint, endpoint)
    +}
    diff --git a/crates/s3/src/clients.rs b/crates/s3/src/clients.rs
    new file mode 100644
    index 000000000..39ce1bec6
    --- /dev/null
    +++ b/crates/s3/src/clients.rs
    @@ -0,0 +1,264 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The S3 clients of one store: the client for the configured region, and
    +//! one per region a bucket was found in. AWS S3 answers a request signed
    +//! for the wrong region with a 301 `PermanentRedirect` (or a 400
    +//! `AuthorizationHeaderMalformed`) naming the bucket's region in
    +//! `x-amz-bucket-region`; the clients remember that region for the bucket
    +//! and send the request once more there (measured on the service: an input
    +//! bucket and an output bucket in another region both work).
    +
    +use std::collections::HashMap;
    +use std::future::Future;
    +use std::sync::{Mutex, MutexGuard, PoisonError};
    +
    +use aws_sdk_s3::Client;
    +use aws_sdk_s3::config::http::HttpResponse;
    +use aws_sdk_s3::config::{Region, SharedHttpClient};
    +use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
    +
    +use crate::retry::{Backoff, RetryPolicy, is_transient};
    +
    +/// Bucket regions remembered at most. Past it the map starts again empty,
    +/// so a caller naming many buckets cannot grow it without bound.
    +const MAX_REMEMBERED_BUCKETS: usize = 1024;
    +
    +/// The clients of one S3 store.
    +pub struct S3Clients {
    +    config: aws_sdk_s3::Config,
    +    default: Client,
    +    /// Whether region redirects are followed: on AWS S3, not against a
    +    /// configured endpoint.
    +    follow_redirects: bool,
    +    /// Whether the client sends checksums only when an operation requires
    +    /// them, the setting for an S3-compatible endpoint.
    +    checksums_when_required: bool,
    +    bucket_regions: Mutex>,
    +    regional: Mutex>,
    +    /// The bound on each call's retries of transient failures.
    +    retry: RetryPolicy,
    +    /// The access key ID of the credentials the startup check resolved.
    +    access_key_id: Option,
    +}
    +
    +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> {
    +    mutex.lock().unwrap_or_else(PoisonError::into_inner)
    +}
    +
    +impl S3Clients {
    +    /// The clients over `config`, the configuration of the store's region.
    +    pub(crate) fn from_config(
    +        config: aws_sdk_s3::Config,
    +        follow_redirects: bool,
    +        checksums_when_required: bool,
    +    ) -> Self {
    +        Self {
    +            default: Client::from_conf(config.clone()),
    +            config,
    +            follow_redirects,
    +            checksums_when_required,
    +            bucket_regions: Mutex::new(HashMap::new()),
    +            regional: Mutex::new(HashMap::new()),
    +            retry: RetryPolicy::STORE,
    +            access_key_id: None,
    +        }
    +    }
    +
    +    /// The clients with the access key ID the startup check resolved.
    +    pub(crate) fn with_access_key_id(mut self, access_key_id: Option) -> Self {
    +        self.access_key_id = access_key_id;
    +        self
    +    }
    +
    +    /// The access key ID of the credentials the startup check resolved, or
    +    /// `None` when they did not resolve, for a caller that compares it with
    +    /// keys of its own. Nothing in this crate logs it.
    +    #[must_use]
    +    pub fn access_key_id(&self) -> Option<&str> {
    +        self.access_key_id.as_deref()
    +    }
    +
    +    /// The retries of one call, from now under the store's bound.
    +    #[must_use]
    +    pub fn backoff(&self) -> Backoff {
    +        self.retry.start()
    +    }
    +
    +    /// The HTTP client the clients send over, when one was set on their
    +    /// configuration rather than left to the SDK's default.
    +    pub(crate) fn http_client(&self) -> Option {
    +        self.config.http_client()
    +    }
    +
    +    /// Whether the client sends checksums only when an operation requires
    +    /// them; multipart uploads then declare no checksum algorithm.
    +    #[must_use]
    +    pub fn checksums_when_required(&self) -> bool {
    +        self.checksums_when_required
    +    }
    +
    +    /// The client of the region `bucket` was found in, or of the store's
    +    /// region for a bucket no redirect has named. The region is learned by
    +    /// [`Self::call`] and [`Self::call_within`], which follow a redirect
    +    /// once; a request sent on this client alone gets the redirect as its
    +    /// answer.
    +    #[must_use]
    +    pub fn client_for(&self, bucket: &str) -> Client {
    +        let region = lock(&self.bucket_regions).get(bucket).cloned();
    +        match region {
    +            Some(region) => self.regional_client(region),
    +            None => self.default.clone(),
    +        }
    +    }
    +
    +    fn regional_client(&self, region: Region) -> Client {
    +        if self.config.region() == Some(®ion) {
    +            return self.default.clone();
    +        }
    +        lock(&self.regional)
    +            .entry(region.to_string())
    +            .or_insert_with(|| Client::from_conf(self.config.to_builder().region(region).build()))
    +            .clone()
    +    }
    +
    +    /// Run one S3 call for `bucket` on the client of the bucket's region.
    +    /// A redirect naming another region is remembered for the bucket, and
    +    /// the call runs once more on that region's client. A transient
    +    /// failure is sent again under the store's bound (`retry`).
    +    pub async fn call(
    +        &self,
    +        bucket: &str,
    +        call: F,
    +    ) -> Result>
    +    where
    +        F: Fn(Client) -> Fut,
    +        Fut: Future>>,
    +        E: ProvideErrorMetadata + std::error::Error + Send + Sync + 'static,
    +    {
    +        self.call_within(bucket, &mut self.backoff(), &call).await
    +    }
    +
    +    /// [`Self::call`] with the retries of a backoff the caller holds, so a
    +    /// ranged GET that is requested again after a cut body stays under one
    +    /// bound with the retries of its requests.
    +    pub async fn call_within(
    +        &self,
    +        bucket: &str,
    +        backoff: &mut Backoff,
    +        call: &F,
    +    ) -> Result>
    +    where
    +        F: Fn(Client) -> Fut,
    +        Fut: Future>>,
    +        E: ProvideErrorMetadata + std::error::Error + Send + Sync + 'static,
    +    {
    +        loop {
    +            let result = self.call_once(bucket, call).await;
    +            let Err(error) = &result else {
    +                return result;
    +            };
    +            if !is_transient(error) {
    +                return result;
    +            }
    +            let Some(wait) = backoff.next_wait() else {
    +                return result;
    +            };
    +            tracing::warn!(
    +                bucket,
    +                retry = backoff.retries(),
    +                wait_ms = u64::try_from(wait.as_millis()).unwrap_or(u64::MAX),
    +                error = %aws_sdk_s3::error::DisplayErrorContext(error),
    +                "S3 call failed with a transient error; it is sent again after the wait"
    +            );
    +            tokio::time::sleep(wait).await;
    +        }
    +    }
    +
    +    async fn call_once(
    +        &self,
    +        bucket: &str,
    +        call: &F,
    +    ) -> Result>
    +    where
    +        F: Fn(Client) -> Fut,
    +        Fut: Future>>,
    +        E: ProvideErrorMetadata,
    +    {
    +        let client = self.client_for(bucket);
    +        let used = client.config().region().cloned();
    +        let first = call(client).await;
    +        let Err(error) = &first else {
    +            return first;
    +        };
    +        let Some(region) = self
    +            .redirect_region(error)
    +            .filter(|r| Some(r) != used.as_ref())
    +        else {
    +            return first;
    +        };
    +        tracing::info!(
    +            bucket,
    +            region = %region,
    +            "S3 bucket is in another region; the request is sent there"
    +        );
    +        {
    +            let mut regions = lock(&self.bucket_regions);
    +            if regions.len() >= MAX_REMEMBERED_BUCKETS && !regions.contains_key(bucket) {
    +                regions.clear();
    +            }
    +            regions.insert(bucket.to_owned(), region.clone());
    +        }
    +        call(self.regional_client(region)).await
    +    }
    +
    +    fn redirect_region(
    +        &self,
    +        error: &SdkError,
    +    ) -> Option {
    +        if !self.follow_redirects {
    +            return None;
    +        }
    +        let response = error.raw_response()?;
    +        let status = response.status().as_u16();
    +        let redirect = matches!(status, 301 | 307)
    +            || (status == 400 && error.code() == Some("AuthorizationHeaderMalformed"));
    +        if !redirect {
    +            return None;
    +        }
    +        let region = response.headers().get("x-amz-bucket-region")?.trim();
    +        (!region.is_empty()).then(|| Region::new(region.to_owned()))
    +    }
    +}
    +
    +/// The hooks a store's tests build clients with, behind the `test-util`
    +/// feature: production builds its clients through [`crate::clients_from`]
    +/// under the store's retry bound.
    +#[cfg(any(test, feature = "test-util"))]
    +impl S3Clients {
    +    /// The clients over `config`, the configuration of the store's region,
    +    /// for a test that builds its own configuration.
    +    #[must_use]
    +    pub fn new(
    +        config: aws_sdk_s3::Config,
    +        follow_redirects: bool,
    +        checksums_when_required: bool,
    +    ) -> Self {
    +        Self::from_config(config, follow_redirects, checksums_when_required)
    +    }
    +
    +    /// Another bound on each call's retries, so a test sees retries without
    +    /// waiting seconds, or none at all.
    +    #[must_use]
    +    pub fn with_retry(mut self, retry: RetryPolicy) -> Self {
    +        self.retry = retry;
    +        self
    +    }
    +
    +    /// The configuration of the store's region, for the tests that check
    +    /// what the clients are built with.
    +    #[must_use]
    +    pub fn config(&self) -> &aws_sdk_s3::Config {
    +        &self.config
    +    }
    +}
    diff --git a/crates/s3/src/codes.rs b/crates/s3/src/codes.rs
    new file mode 100644
    index 000000000..bfafadb03
    --- /dev/null
    +++ b/crates/s3/src/codes.rs
    @@ -0,0 +1,43 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The S3 error codes about the server's own signing identity, account or
    +//! clock, and the check of a failed call against them.
    +
    +use aws_sdk_s3::config::http::HttpResponse;
    +use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
    +
    +/// The S3 codes that describe the server's own signing identity, account
    +/// or clock, never the caller's request: a store signs every request with
    +/// the server's credentials, so a caller cannot cause them and cannot fix
    +/// them, and the operator reads S3's code and message in the store's log.
    +/// `AllAccessDisabled` names the bucket's account and stays the caller's.
    +pub const SERVER_CREDENTIAL_CODES: &[&str] = &[
    +    "SignatureDoesNotMatch",
    +    "InvalidAccessKeyId",
    +    "ExpiredToken",
    +    "InvalidToken",
    +    "TokenRefreshRequired",
    +    "RequestTimeTooSkewed",
    +    "CredentialsNotSupported",
    +    "InvalidSecurity",
    +    "MissingSecurityHeader",
    +    "AccountProblem",
    +    "NotSignedUp",
    +];
    +
    +/// Whether an S3 error code is one of [`SERVER_CREDENTIAL_CODES`].
    +#[must_use]
    +pub fn is_server_credential_code(code: Option<&str>) -> bool {
    +    code.is_some_and(|code| SERVER_CREDENTIAL_CODES.contains(&code))
    +}
    +
    +/// Whether a failed call is S3's answer with one of
    +/// [`SERVER_CREDENTIAL_CODES`]. A failure S3 did not answer (a connection
    +/// failure, a timeout) carries no code and is not.
    +#[must_use]
    +pub fn is_server_credential_error(
    +    error: &SdkError,
    +) -> bool {
    +    is_server_credential_code(error.code())
    +}
    diff --git a/crates/s3/src/credentials.rs b/crates/s3/src/credentials.rs
    new file mode 100644
    index 000000000..d23566d87
    --- /dev/null
    +++ b/crates/s3/src/credentials.rs
    @@ -0,0 +1,98 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The startup credential check: the standard AWS credential chain asked
    +//! once, within five seconds, and the outcome logged under the store's
    +//! name, with a warning for resolved keys the SDK cannot renew.
    +
    +use std::time::Duration;
    +
    +use aws_config::SdkConfig;
    +use aws_credential_types::credential_feature::AwsCredentialFeature;
    +use aws_sdk_s3::config::{Credentials, ProvideCredentials};
    +
    +/// How long the startup credential check waits for the credential chain.
    +const CREDENTIAL_CHECK: Duration = Duration::from_secs(5);
    +
    +/// What the startup warning says about credentials that resolve later.
    +/// The SDK parses the shared config and credentials files once, when the
    +/// chain first reads them, and keeps the result. Until credentials
    +/// resolve, the other providers of the chain are asked again each time a
    +/// request needs credentials; once they resolve, the SDK's identity cache
    +/// keeps them until they expire, or for 15 minutes when they carry no
    +/// expiry.
    +pub const CREDENTIALS_LATER: &str = "The shared config and credentials files are read \
    +     once, at startup, so a change to them needs a restart; until credentials resolve, the \
    +     environment, web identity, container and instance metadata providers are asked again for \
    +     each request that needs them";
    +
    +/// How one store names itself in its startup lines.
    +#[derive(Clone, Copy, Debug)]
    +pub struct Startup<'a> {
    +    /// The store's name, as its log lines give it, such as `S3 bulk store`.
    +    pub label: &'a str,
    +    /// What the store's callers meet while its credentials do not resolve,
    +    /// as one clause, such as `Start answers InternalServerError until
    +    /// they resolve`.
    +    pub until_resolved: &'a str,
    +}
    +
    +/// Resolve the credentials once, log the outcome, and return the resolved
    +/// credentials.
    +pub(crate) async fn check_credentials(
    +    sdk: &SdkConfig,
    +    startup: &Startup<'_>,
    +) -> Option {
    +    let Startup {
    +        label,
    +        until_resolved,
    +    } = *startup;
    +    let Some(provider) = sdk.credentials_provider() else {
    +        tracing::warn!("{label} has no credential provider");
    +        return None;
    +    };
    +    match tokio::time::timeout(CREDENTIAL_CHECK, provider.provide_credentials()).await {
    +        Ok(Ok(credentials)) => {
    +            tracing::info!("{label} credentials resolved");
    +            Some(credentials)
    +        }
    +        Ok(Err(error)) => {
    +            tracing::warn!(
    +                error = %aws_sdk_s3::error::DisplayErrorContext(&error),
    +                "{label} credentials cannot be resolved; {until_resolved}. {CREDENTIALS_LATER}"
    +            );
    +            None
    +        }
    +        Err(_) => {
    +            tracing::warn!(
    +                "{label} credentials were not resolved within five seconds; {until_resolved}. \
    +                 {CREDENTIALS_LATER}"
    +            );
    +            None
    +        }
    +    }
    +}
    +
    +/// Warn when the resolved credentials are temporary keys the SDK cannot
    +/// renew: a session token and no expiry, as environment variables or keys
    +/// written into a shared file give them. The SDK asks such a source again
    +/// and gets the same keys, so once they expire every request fails until
    +/// the server restarts. Credentials with an expiry (a role, web identity,
    +/// IAM Identity Center, a `credential_process` that prints one) are renewed
    +/// before they expire, and long-term keys carry no session token. Keys a
    +/// `credential_process` printed without an expiry are no warning either:
    +/// the SDK's identity cache keeps them for 15 minutes and then runs the
    +/// process again.
    +pub(crate) fn warn_of_temporary_keys(credentials: &Credentials, label: &str) {
    +    let from_a_process = credentials
    +        .get_property::>()
    +        .is_some_and(|features| features.contains(&AwsCredentialFeature::CredentialsProcess));
    +    if credentials.session_token().is_some() && credentials.expiry().is_none() && !from_a_process {
    +        tracing::warn!(
    +            "The {label} credentials are temporary keys the AWS SDK cannot renew. When they \
    +             expire, its S3 requests fail until the server restarts with new keys. Give the \
    +             server a role, web identity, IAM Identity Center or a credential_process profile \
    +             instead"
    +        );
    +    }
    +}
    diff --git a/crates/s3/src/identity.rs b/crates/s3/src/identity.rs
    new file mode 100644
    index 000000000..eefade5cd
    --- /dev/null
    +++ b/crates/s3/src/identity.rs
    @@ -0,0 +1,179 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The startup identity check: STS GetCallerIdentity in the store's region,
    +//! signed with the credentials the startup check resolved and sent over
    +//! the HTTP client of the store's S3 clients. It runs in a task of its own
    +//! under a bound of its own, so it never delays startup, and only against
    +//! AWS S3: an S3-compatible service has no STS. The outcome is a log line
    +//! under the store's label, and no line carries the access key ID or the
    +//! secret.
    +
    +use std::time::Duration;
    +
    +use aws_config::SdkConfig;
    +use aws_sdk_sts::config::http::HttpResponse;
    +use aws_sdk_sts::config::{Credentials, Region, SharedHttpClient};
    +use aws_sdk_sts::error::{DisplayErrorContext, ProvideErrorMetadata, SdkError};
    +use aws_sdk_sts::operation::get_caller_identity::{
    +    GetCallerIdentityError, GetCallerIdentityOutput,
    +};
    +use aws_types::service_config::ServiceConfigKey;
    +
    +/// How long the identity check waits for STS.
    +const IDENTITY_CHECK: Duration = Duration::from_secs(5);
    +
    +/// The codes STS refuses credentials with, as measured on AWS STS:
    +/// `InvalidClientTokenId` for an access key ID AWS does not know and for a
    +/// session token altered or left out, `SignatureDoesNotMatch` for a wrong
    +/// secret key, and `ExpiredToken` for a session token past its expiry. Every
    +/// other answer goes to the line that the credentials could not be checked,
    +/// with its code.
    +const REFUSED: &[&str] = &[
    +    "InvalidClientTokenId",
    +    "SignatureDoesNotMatch",
    +    "ExpiredToken",
    +];
    +
    +/// How STS's `SignatureDoesNotMatch` message starts for a request signed
    +/// more than 15 minutes before or after STS's clock, as measured: the clock
    +/// is off, not the credentials.
    +const CLOCK_OFF: &[&str] = &["Signature expired:", "Signature not yet current:"];
    +
    +/// What the logged text says in place of the access key ID, should an
    +/// answer repeat it.
    +const KEY_ELIDED: &str = "";
    +
    +/// Start the identity check of `credentials` in `region` in a task of its
    +/// own, over `http_client`, the S3 clients' HTTP client (the SDK's default
    +/// when `None`); the outcome is logged under `label`. The call goes to the
    +/// endpoint set for STS alone ([`sts_endpoint`]), or to AWS STS for the
    +/// region: the global endpoint the SDK read from the environment or a
    +/// profile is cleared, as it is for the store's S3 clients.
    +pub(crate) fn spawn_check(
    +    sdk: &SdkConfig,
    +    http_client: Option,
    +    region: &str,
    +    credentials: Credentials,
    +    label: &str,
    +) {
    +    let key = credentials.access_key_id().to_owned();
    +    let mut builder = aws_sdk_sts::config::Builder::from(sdk)
    +        .region(Region::new(region.to_owned()))
    +        .credentials_provider(credentials);
    +    builder.set_http_client(http_client);
    +    builder.set_endpoint_url(sts_endpoint(sdk));
    +    let client = aws_sdk_sts::Client::from_conf(builder.build());
    +    let label = label.to_owned();
    +    tokio::spawn(async move {
    +        let answer =
    +            tokio::time::timeout(IDENTITY_CHECK, client.get_caller_identity().send()).await;
    +        log_identity(&label, &key, answer.ok());
    +    });
    +}
    +
    +/// The endpoint the configuration sets for STS alone: `AWS_ENDPOINT_URL_STS`,
    +/// or the `sts` entry of the profile's `services` section. The SDK answers
    +/// a service's endpoint with that service's own setting when there is one
    +/// and with the global one otherwise (`AWS_ENDPOINT_URL`, then the
    +/// profile's `endpoint_url`), which clients set to ExtendDB; an answer
    +/// equal to the global endpoint is that one, and is not used. The SDK reads
    +/// `AWS_ENDPOINT_URL` before the profile, so with it set a profile's `sts`
    +/// entry is not seen.
    +fn sts_endpoint(sdk: &SdkConfig) -> Option {
    +    let key = ServiceConfigKey::builder()
    +        .service_id("STS")
    +        .env("AWS_ENDPOINT_URL")
    +        .profile("endpoint_url")
    +        .build()
    +        .ok()?;
    +    let endpoint = sdk.service_config()?.load_config(key)?;
    +    (Some(endpoint.as_str()) != sdk.endpoint_url()).then_some(endpoint)
    +}
    +
    +/// Log the outcome of one identity check: STS's answer, or `None` when no
    +/// answer came within the bound.
    +fn log_identity(
    +    label: &str,
    +    key: &str,
    +    answer: Option>>,
    +) {
    +    let elide = |text: &str| {
    +        if key.is_empty() {
    +            text.to_owned()
    +        } else {
    +            text.replace(key, KEY_ELIDED)
    +        }
    +    };
    +    match answer {
    +        Some(Ok(identity)) => tracing::info!(
    +            "{label} signs as {arn} (AWS account {account})",
    +            arn = elide(identity.arn().unwrap_or_default()),
    +            account = identity.account().unwrap_or_default(),
    +        ),
    +        Some(Err(error)) => match reading(&error) {
    +            Reading::Refused(code) => {
    +                // STS ends its message with a full stop; the line goes on
    +                // with a sentence of its own.
    +                let message = elide(error.message().unwrap_or_default());
    +                let message = message.trim_end().trim_end_matches('.');
    +                tracing::warn!(
    +                    "AWS refused the {label} credentials ({code}): {message}. Its S3 requests \
    +                     fail until the server restarts with credentials AWS accepts"
    +                );
    +            }
    +            Reading::ClockOff(code) => {
    +                let message = elide(error.message().unwrap_or_default());
    +                tracing::warn!(
    +                    "{label} credentials could not be checked with AWS STS: the server clock is \
    +                     more than 15 minutes off ({code}): {message}"
    +                );
    +            }
    +            Reading::Unchecked => {
    +                let cause = match (error.code(), error.raw_response()) {
    +                    (Some(code), Some(response)) => format!(
    +                        "{code} (HTTP {status}): {message}",
    +                        status = response.status().as_u16(),
    +                        message = error.message().unwrap_or_default(),
    +                    ),
    +                    _ => DisplayErrorContext(&error).to_string(),
    +                };
    +                let cause = elide(&cause);
    +                tracing::warn!("{label} credentials could not be checked with AWS STS: {cause}");
    +            }
    +        },
    +        None => tracing::warn!(
    +            "{label} credentials could not be checked with AWS STS: no answer within five seconds"
    +        ),
    +    }
    +}
    +
    +/// What an STS error says of the credentials.
    +enum Reading<'a> {
    +    /// STS refused them, with this code.
    +    Refused(&'a str),
    +    /// STS refused the request's time, with this code: the server clock is
    +    /// off, which says nothing of the credentials.
    +    ClockOff(&'a str),
    +    /// Anything else, an answer STS did not give included.
    +    Unchecked,
    +}
    +
    +/// Read an STS error: one of [`REFUSED`], a [`CLOCK_OFF`] message, or
    +/// neither.
    +fn reading(error: &SdkError) -> Reading<'_> {
    +    let SdkError::ServiceError(_) = error else {
    +        return Reading::Unchecked;
    +    };
    +    let Some(code) = error.code() else {
    +        return Reading::Unchecked;
    +    };
    +    let message = error.message().unwrap_or_default();
    +    if code == "SignatureDoesNotMatch" && CLOCK_OFF.iter().any(|start| message.starts_with(start)) {
    +        Reading::ClockOff(code)
    +    } else if REFUSED.contains(&code) {
    +        Reading::Refused(code)
    +    } else {
    +        Reading::Unchecked
    +    }
    +}
    diff --git a/crates/s3/src/lib.rs b/crates/s3/src/lib.rs
    new file mode 100644
    index 000000000..76216b496
    --- /dev/null
    +++ b/crates/s3/src/lib.rs
    @@ -0,0 +1,104 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The S3 clients of an ExtendDB store and the server's AWS credentials
    +//! they sign with. [`connect`] opens one store's clients from its
    +//! [`S3StoreSettings`]: the region from the settings, the endpoint from
    +//! `endpoint_url` alone, the pinned behaviour version and deadlines, and
    +//! the standard AWS credential chain, resolved once at startup with the
    +//! outcome logged under the store's name ([`Startup`]). Resolved temporary
    +//! keys the SDK cannot renew are a warning, and without an `endpoint_url`
    +//! one STS GetCallerIdentity request is sent at startup, in a task of its
    +//! own and over the S3 clients' HTTP client, to log the identity the
    +//! credentials sign as.
    +//! [`S3Clients`] follows the region redirects AWS S3 answers and sends a
    +//! transient failure again under one bound ([`retry`]).
    +//! [`is_server_credential_error`] says whether a failure is about the
    +//! server's own credentials, account or clock, which no caller of the
    +//! store can fix.
    +//!
    +//! A store with one configured bucket uses the crate as it is:
    +//!
    +//! ```no_run
    +//! use extenddb_s3::{S3StoreSettings, Startup, connect, is_server_credential_error};
    +//!
    +//! # async fn example() {
    +//! let settings = S3StoreSettings {
    +//!     region: "us-east-1".to_owned(),
    +//!     endpoint_url: None,
    +//!     force_path_style: false,
    +//! };
    +//! let startup = Startup {
    +//!     label: "S3 example store",
    +//!     until_resolved: "its requests fail until they resolve",
    +//! };
    +//! let clients = connect(&settings, &startup).await;
    +//!
    +//! // One call on the client of the bucket's region: a redirect to another
    +//! // region is followed once and remembered, and a transient failure is
    +//! // sent again under the retry bound.
    +//! let listed = clients
    +//!     .call("example-bucket", |client| {
    +//!         client.list_objects_v2().bucket("example-bucket").send()
    +//!     })
    +//!     .await;
    +//! if let Err(error) = &listed
    +//!     && is_server_credential_error(error)
    +//! {
    +//!     // The server's own credentials, account or clock: the operator's
    +//!     // fix, never the caller's.
    +//! }
    +//!
    +//! // Then the client of the region the call above found the bucket in,
    +//! // with the retries held by the caller under the same bound. Only `call`
    +//! // and `call_within` learn a bucket's region: a request sent on this
    +//! // client alone gets a redirect as its answer.
    +//! let client = clients.client_for("example-bucket");
    +//! let mut backoff = clients.backoff();
    +//! loop {
    +//!     let Err(error) = client.head_bucket().bucket("example-bucket").send().await else {
    +//!         break;
    +//!     };
    +//!     if !extenddb_s3::retry::is_transient(&error) {
    +//!         break;
    +//!     }
    +//!     let Some(wait) = backoff.next_wait() else {
    +//!         break;
    +//!     };
    +//!     tokio::time::sleep(wait).await;
    +//! }
    +//! # }
    +//! ```
    +//!
    +//! The `test-util` feature adds the hooks a store's tests build clients
    +//! with: `S3Clients::new` over a configuration of the test's own,
    +//! `S3Clients::with_retry`, `S3Clients::config` and
    +//! `retry::RetryPolicy::NONE`. A store enables it in its dev-dependencies.
    +
    +mod client;
    +mod clients;
    +mod codes;
    +mod credentials;
    +mod identity;
    +pub mod retry;
    +
    +#[cfg(test)]
    +mod tests;
    +
    +pub use client::{
    +    CONNECT_TIMEOUT, READ_TIMEOUT, behavior_version, clients_from, connect, connect_with, timeouts,
    +};
    +pub use clients::S3Clients;
    +pub use codes::{SERVER_CREDENTIAL_CODES, is_server_credential_code, is_server_credential_error};
    +pub use credentials::{CREDENTIALS_LATER, Startup};
    +
    +/// The settings of one S3 store's clients.
    +#[derive(Debug, Clone)]
    +pub struct S3StoreSettings {
    +    /// The signing region and the first region a bucket is tried in.
    +    pub region: String,
    +    /// An S3-compatible endpoint; `None` for AWS S3.
    +    pub endpoint_url: Option,
    +    /// Address buckets in the path rather than the host name.
    +    pub force_path_style: bool,
    +}
    diff --git a/crates/s3/src/retry.rs b/crates/s3/src/retry.rs
    new file mode 100644
    index 000000000..9e8e9186e
    --- /dev/null
    +++ b/crates/s3/src/retry.rs
    @@ -0,0 +1,151 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Transient S3 failures, retried by a store after the SDK's own attempts.
    +//! A call that fails with a connection failure, a timeout, a response that
    +//! could not be read, an HTTP 429, 500, 502, 503 or 504, or the codes
    +//! `SlowDown`, `RequestTimeout`, `InternalError` or `ServiceUnavailable` is
    +//! sent again after a wait that starts at [`FIRST_WAIT`] and doubles up to
    +//! [`MAX_WAIT`]; no new try starts once [`TOTAL`] has passed since the call
    +//! began, and the last failure then goes to the caller. Credentials that
    +//! cannot be resolved and every other client error are answered at once.
    +
    +use std::time::{Duration, Instant};
    +
    +use aws_sdk_s3::config::http::HttpResponse;
    +use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
    +
    +/// The first wait before a call is sent again.
    +pub const FIRST_WAIT: Duration = Duration::from_secs(1);
    +/// The longest wait between two tries of a call.
    +pub const MAX_WAIT: Duration = Duration::from_secs(30);
    +/// No try of a call starts once this long has passed since it began.
    +pub const TOTAL: Duration = Duration::from_secs(120);
    +
    +/// The bound on one call's retries.
    +#[derive(Clone, Copy, Debug, PartialEq, Eq)]
    +pub struct RetryPolicy {
    +    /// The wait before the first retry.
    +    pub first: Duration,
    +    /// The longest wait between two tries.
    +    pub max: Duration,
    +    /// No try starts once this long has passed since the call began.
    +    pub total: Duration,
    +}
    +
    +impl RetryPolicy {
    +    /// The store's bound: [`FIRST_WAIT`], [`MAX_WAIT`] and [`TOTAL`].
    +    pub const STORE: Self = Self {
    +        first: FIRST_WAIT,
    +        max: MAX_WAIT,
    +        total: TOTAL,
    +    };
    +
    +    /// No retry: the first failure surfaces. A test hook, behind the
    +    /// `test-util` feature.
    +    #[cfg(any(test, feature = "test-util"))]
    +    pub const NONE: Self = Self {
    +        first: Duration::ZERO,
    +        max: Duration::ZERO,
    +        total: Duration::ZERO,
    +    };
    +
    +    /// The waits of one call, starting now.
    +    pub fn start(self) -> Backoff {
    +        Backoff {
    +            policy: self,
    +            started: Instant::now(),
    +            retries: 0,
    +        }
    +    }
    +
    +    /// The wait before retry number `retries` (from 0) of a call that has
    +    /// run for `elapsed`, or `None` once the call has spent its total: the
    +    /// first wait doubled per earlier retry, capped at the longest wait and
    +    /// at what is left of the total.
    +    pub fn wait(self, retries: u32, elapsed: Duration) -> Option {
    +        let left = self
    +            .total
    +            .checked_sub(elapsed)
    +            .filter(|left| !left.is_zero())?;
    +        let doubled = self
    +            .first
    +            .checked_mul(1u32.checked_shl(retries).unwrap_or(u32::MAX))
    +            .unwrap_or(self.max);
    +        Some(doubled.min(self.max).min(left))
    +    }
    +}
    +
    +/// The retries of one call.
    +pub struct Backoff {
    +    policy: RetryPolicy,
    +    started: Instant,
    +    retries: u32,
    +}
    +
    +impl Backoff {
    +    /// The wait before the next try, or `None` once the call has spent its
    +    /// total; each `Some` counts one retry.
    +    pub fn next_wait(&mut self) -> Option {
    +        let wait = self.policy.wait(self.retries, self.started.elapsed())?;
    +        self.retries += 1;
    +        Some(wait)
    +    }
    +
    +    /// Retries granted so far.
    +    pub fn retries(&self) -> u32 {
    +        self.retries
    +    }
    +}
    +
    +/// Whether a failed call is transient after the SDK's own attempts.
    +pub fn is_transient(error: &SdkError) -> bool {
    +    match error {
    +        // A connection failure, a timeout, or a failure the HTTP client
    +        // marks as retryable, such as a response that ended before its
    +        // end. The SDK reports credentials it cannot resolve as a dispatch
    +        // failure of no kind; that one, and a request the SDK could not
    +        // build or send as asked, are answered at once.
    +        SdkError::DispatchFailure(failure) => {
    +            failure.is_io() || failure.is_timeout() || failure.as_other().is_some()
    +        }
    +        SdkError::TimeoutError(_) | SdkError::ResponseError(_) => true,
    +        SdkError::ServiceError(_) => {
    +            // 429 is how some S3-compatible services throttle; AWS S3
    +            // answers 503 SlowDown.
    +            let status = error.raw_response().map(|r| r.status().as_u16());
    +            matches!(status, Some(429 | 500 | 502 | 503 | 504))
    +                || matches!(
    +                    error.code(),
    +                    Some("SlowDown" | "RequestTimeout" | "InternalError" | "ServiceUnavailable")
    +                )
    +        }
    +        _ => false,
    +    }
    +}
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::*;
    +
    +    #[test]
    +    fn the_waits_double_from_one_second_to_thirty_within_two_minutes() {
    +        let policy = RetryPolicy::STORE;
    +        let mut elapsed = Duration::ZERO;
    +        let mut waits = Vec::new();
    +        // At most 64 retries are asked for, so a bound that never ends
    +        // fails the comparison below instead of looping.
    +        for retries in 0..64 {
    +            let Some(wait) = policy.wait(retries, elapsed) else {
    +                break;
    +            };
    +            waits.push(wait.as_secs());
    +            elapsed += wait;
    +        }
    +        assert_eq!(waits, [1, 2, 4, 8, 16, 30, 30, 29]);
    +        assert_eq!(elapsed, TOTAL);
    +        assert_eq!(policy.wait(0, TOTAL), None);
    +        assert_eq!(policy.wait(40, Duration::ZERO), Some(MAX_WAIT));
    +        assert_eq!(RetryPolicy::NONE.wait(0, Duration::ZERO), None);
    +    }
    +}
    diff --git a/crates/s3/src/tests/child.rs b/crates/s3/src/tests/child.rs
    new file mode 100644
    index 000000000..19dbd4fa5
    --- /dev/null
    +++ b/crates/s3/src/tests/child.rs
    @@ -0,0 +1,69 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! A child process of this test binary with a fixed environment, for the
    +//! tests that load the SDK's configuration from the process environment
    +//! and the AWS config files. The host's variables, profiles and instance
    +//! metadata stay out of the child: its environment holds a scratch `HOME`,
    +//! a config file of the test's own, a credentials file that does not
    +//! exist, instance metadata off, and the variables the test names.
    +
    +use std::future::Future;
    +use std::process::{Command, Stdio};
    +
    +/// The variable that tells a child process of this test binary to run the
    +/// test's body.
    +const CHILD: &str = "EXTENDDB_TEST_S3_STORE_CHILD";
    +
    +/// Whether this process is a child [`run`] started.
    +pub(super) fn is_child() -> bool {
    +    std::env::var_os(CHILD).is_some()
    +}
    +
    +/// The case a child [`run`] started runs, as `run` named it.
    +pub(super) fn case() -> String {
    +    std::env::var(CHILD).unwrap_or_default()
    +}
    +
    +/// Drive `body` to its end on a current-thread runtime, so the events it
    +/// logs reach a subscriber installed on this thread.
    +pub(super) fn block_on(body: F) -> F::Output {
    +    tokio::runtime::Builder::new_current_thread()
    +        .enable_all()
    +        .build()
    +        .unwrap()
    +        .block_on(body)
    +}
    +
    +/// Run the test `test` of the module `module` (its `module_path!()`) in a
    +/// child process with `variables` and the AWS config file `config`, and
    +/// assert that it ran and passed. `case` names the run in a failure.
    +pub(super) fn run(module: &str, test: &str, case: &str, variables: &[(&str, &str)], config: &str) {
    +    let module = module.split_once("::").map_or("", |(_, rest)| rest);
    +    let dir = std::env::temp_dir().join(format!("eb-s3-child-{}", uuid::Uuid::new_v4()));
    +    std::fs::create_dir_all(&dir).unwrap();
    +    std::fs::write(dir.join("config"), config).unwrap();
    +    let output = Command::new(std::env::current_exe().unwrap())
    +        .args([
    +            format!("{module}::{test}").as_str(),
    +            "--exact",
    +            "--nocapture",
    +            "--test-threads",
    +            "1",
    +        ])
    +        .env_clear()
    +        .env(CHILD, case)
    +        .env("HOME", &dir)
    +        .env("AWS_CONFIG_FILE", dir.join("config"))
    +        .env("AWS_SHARED_CREDENTIALS_FILE", dir.join("no-credentials"))
    +        .env("AWS_EC2_METADATA_DISABLED", "true")
    +        .envs(variables.iter().copied())
    +        .stdin(Stdio::null())
    +        .output()
    +        .unwrap();
    +    let _ = std::fs::remove_dir_all(&dir);
    +    let stdout = String::from_utf8_lossy(&output.stdout);
    +    let stderr = String::from_utf8_lossy(&output.stderr);
    +    assert!(output.status.success(), "{case}:\n{stdout}\n{stderr}");
    +    assert!(stdout.contains("1 passed"), "{case}:\n{stdout}\n{stderr}");
    +}
    diff --git a/crates/s3/src/tests/codes.rs b/crates/s3/src/tests/codes.rs
    new file mode 100644
    index 000000000..387fe5726
    --- /dev/null
    +++ b/crates/s3/src/tests/codes.rs
    @@ -0,0 +1,53 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The S3 codes about the server's own signing identity, account or clock,
    +//! read from the failed call S3 answers.
    +
    +use super::*;
    +use crate::{SERVER_CREDENTIAL_CODES, is_server_credential_code, is_server_credential_error};
    +
    +/// Each code with the status S3 answers it with.
    +const SERVER_FAULTS: &[(u16, &str)] = &[
    +    (403, "SignatureDoesNotMatch"),
    +    (403, "InvalidAccessKeyId"),
    +    (400, "ExpiredToken"),
    +    (400, "InvalidToken"),
    +    (400, "TokenRefreshRequired"),
    +    (403, "RequestTimeTooSkewed"),
    +    (400, "CredentialsNotSupported"),
    +    (403, "InvalidSecurity"),
    +    (400, "MissingSecurityHeader"),
    +    (403, "AccountProblem"),
    +    (403, "NotSignedUp"),
    +];
    +
    +#[test]
    +fn the_server_credential_codes_are_the_listed_ones() {
    +    let listed: Vec<_> = SERVER_FAULTS.iter().map(|(_, code)| *code).collect();
    +    assert_eq!(listed, SERVER_CREDENTIAL_CODES);
    +    for code in SERVER_CREDENTIAL_CODES {
    +        assert!(is_server_credential_code(Some(code)), "{code}");
    +    }
    +    assert!(!is_server_credential_code(Some("AllAccessDisabled")));
    +    assert!(!is_server_credential_code(Some("AccessDenied")));
    +    assert!(!is_server_credential_code(None));
    +}
    +
    +#[tokio::test]
    +async fn a_failed_call_is_read_by_the_code_s3_answered() {
    +    for &(status, code) in SERVER_FAULTS {
    +        let (clients, _replay) = clients(vec![s3_error(status, code, "S3 text.")]);
    +        let error = list_one(&clients, "loads").await.unwrap_err();
    +        assert!(is_server_credential_error(&error), "{code}");
    +    }
    +    for (status, code) in [(403, "AllAccessDisabled"), (403, "AccessDenied")] {
    +        let (clients, _replay) = clients(vec![s3_error(status, code, "S3 text.")]);
    +        let error = list_one(&clients, "loads").await.unwrap_err();
    +        assert!(!is_server_credential_error(&error), "{code}");
    +    }
    +    // No answer at all: the replay client has nothing to answer with.
    +    let (clients, _replay) = clients(Vec::new());
    +    let error = list_one(&clients, "loads").await.unwrap_err();
    +    assert!(!is_server_credential_error(&error), "{error:?}");
    +}
    diff --git a/crates/s3/src/tests/deadlines.rs b/crates/s3/src/tests/deadlines.rs
    new file mode 100644
    index 000000000..809f3d487
    --- /dev/null
    +++ b/crates/s3/src/tests/deadlines.rs
    @@ -0,0 +1,33 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The deadlines of a store's clients: the connect and read timeouts every
    +//! client is built with.
    +
    +use std::time::Duration;
    +
    +use super::*;
    +use crate::{CONNECT_TIMEOUT, READ_TIMEOUT, clients_from};
    +
    +#[test]
    +fn the_store_clients_carry_the_connect_and_read_deadlines() {
    +    assert_eq!(CONNECT_TIMEOUT, Duration::from_millis(3100));
    +    assert_eq!(READ_TIMEOUT, Duration::from_secs(60));
    +    for endpoint in [None, Some("http://127.0.0.1:9")] {
    +        let clients = clients_from(&settings(endpoint), &sdk_config(), |builder| builder);
    +        let deadlines = clients
    +            .config()
    +            .timeout_config()
    +            .expect("deadlines are set");
    +        assert_eq!(
    +            deadlines.connect_timeout(),
    +            Some(CONNECT_TIMEOUT),
    +            "{endpoint:?}"
    +        );
    +        assert_eq!(deadlines.read_timeout(), Some(READ_TIMEOUT), "{endpoint:?}");
    +        // A body is bounded by stalled-stream protection, not by a wall
    +        // clock over the whole call.
    +        assert_eq!(deadlines.operation_timeout(), None, "{endpoint:?}");
    +        assert_eq!(deadlines.operation_attempt_timeout(), None, "{endpoint:?}");
    +    }
    +}
    diff --git a/crates/s3/src/tests/endpoints.rs b/crates/s3/src/tests/endpoints.rs
    new file mode 100644
    index 000000000..27895b427
    --- /dev/null
    +++ b/crates/s3/src/tests/endpoints.rs
    @@ -0,0 +1,255 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! A store's endpoint comes from its `endpoint_url` setting alone, and its
    +//! region from its settings. Each case runs in a child process of this
    +//! test binary (`child::run`) whose environment holds static keys, a region
    +//! that is not the store's, and the case's endpoint variables or profile,
    +//! and nothing of the host's. The configuration is loaded by the loader
    +//! `connect_with` uses, with an HTTP client that never answers; the store's
    +//! S3 requests and the identity check's GetCallerIdentity are answered in
    +//! the process by the client the test gives the store's clients.
    +
    +use std::sync::{Arc, Mutex};
    +use std::time::Duration;
    +
    +use aws_sdk_s3::config::http::{HttpRequest, HttpResponse};
    +use aws_sdk_s3::config::retry::RetryConfig;
    +use aws_sdk_s3::config::{Region, SharedHttpClient};
    +use aws_sdk_s3::primitives::SdkBody;
    +use aws_smithy_http_client::test_util::{
    +    NeverClient, ReplayEvent, StaticReplayClient, infallible_client_fn,
    +};
    +
    +use super::child;
    +use super::*;
    +use crate::client::{connect_from, loader};
    +
    +/// The endpoint each case names. Nothing is sent to it: an in-process
    +/// client answers every request.
    +const ELSEWHERE: &str = "http://127.0.0.1:9";
    +
    +/// AWS S3's endpoint for a virtual-hosted bucket `loads` in us-east-1, the
    +/// store's region.
    +const AWS_S3: &str = "https://loads.s3.us-east-1.amazonaws.com/";
    +
    +/// AWS STS in the store's region.
    +const AWS_STS: &str = "https://sts.us-east-1.amazonaws.com/";
    +
    +/// Static keys, so the credential chain resolves from the environment, and
    +/// a region that is not the store's.
    +const KEYS: [(&str, &str); 3] = [
    +    ("AWS_ACCESS_KEY_ID", "AKIDENDPOINTTEST"),
    +    ("AWS_SECRET_ACCESS_KEY", "endpoint-test-secret"),
    +    ("AWS_REGION", "eu-west-1"),
    +];
    +
    +/// One way the environment names an endpoint: the variables it sets, the
    +/// AWS config file it writes, and where the identity check then sends
    +/// GetCallerIdentity.
    +struct Case {
    +    name: &'static str,
    +    variables: &'static [(&'static str, &'static str)],
    +    config: &'static str,
    +    sts: &'static str,
    +}
    +
    +const CASES: &[Case] = &[
    +    Case {
    +        name: "AWS_ENDPOINT_URL",
    +        variables: &[("AWS_ENDPOINT_URL", "http://127.0.0.1:9/all")],
    +        config: "",
    +        sts: AWS_STS,
    +    },
    +    Case {
    +        name: "AWS_ENDPOINT_URL_S3",
    +        variables: &[("AWS_ENDPOINT_URL_S3", "http://127.0.0.1:9/s3")],
    +        config: "",
    +        sts: AWS_STS,
    +    },
    +    Case {
    +        name: "profile endpoint_url",
    +        variables: &[],
    +        config: "[default]\nendpoint_url = http://127.0.0.1:9/profile\n",
    +        sts: AWS_STS,
    +    },
    +    Case {
    +        name: "profile services",
    +        variables: &[],
    +        config: "[default]\nservices = local\n\n[services local]\ns3 =\n  endpoint_url = \
    +                 http://127.0.0.1:9/services\n",
    +        sts: AWS_STS,
    +    },
    +    Case {
    +        name: "every source",
    +        variables: &[
    +            ("AWS_ENDPOINT_URL", "http://127.0.0.1:9/all"),
    +            ("AWS_ENDPOINT_URL_S3", "http://127.0.0.1:9/s3"),
    +        ],
    +        config: "[default]\nendpoint_url = http://127.0.0.1:9/profile\nservices = local\n\n\
    +                 [services local]\ns3 =\n  endpoint_url = http://127.0.0.1:9/services\n",
    +        sts: AWS_STS,
    +    },
    +    // An endpoint set for STS alone is honoured by the identity check.
    +    Case {
    +        name: "AWS_ENDPOINT_URL_STS",
    +        variables: &[
    +            ("AWS_ENDPOINT_URL", "http://127.0.0.1:9/all"),
    +            ("AWS_ENDPOINT_URL_STS", "http://127.0.0.1:9/sts"),
    +        ],
    +        config: "",
    +        sts: "http://127.0.0.1:9/sts",
    +    },
    +    Case {
    +        name: "profile services sts",
    +        variables: &[],
    +        config: "[default]\nendpoint_url = http://127.0.0.1:9/profile\nservices = local\n\n\
    +                 [services local]\nsts =\n  endpoint_url = http://127.0.0.1:9/profile-sts\n",
    +        sts: "http://127.0.0.1:9/profile-sts",
    +    },
    +    // The SDK reads AWS_ENDPOINT_URL before a profile's services section,
    +    // so STS's endpoint is then the global one, which the check ignores.
    +    Case {
    +        name: "AWS_ENDPOINT_URL beside profile services sts",
    +        variables: &[("AWS_ENDPOINT_URL", "http://127.0.0.1:9/all")],
    +        config: "[default]\nservices = local\n\n[services local]\nsts =\n  endpoint_url = \
    +                 http://127.0.0.1:9/profile-sts\n",
    +        sts: AWS_STS,
    +    },
    +];
    +
    +/// An empty listing of `loads`.
    +fn listing() -> HttpResponse {
    +    HttpResponse::new(
    +        200.try_into().unwrap(),
    +        SdkBody::from(
    +            "\nloads\
    +             false",
    +        ),
    +    )
    +}
    +
    +/// STS's answer to GetCallerIdentity.
    +const IDENTITY: &str = "\
    +     arn:aws:iam::123456789012:user/extenddb-storeAIDAENDPOINTTEST\
    +     123456789012";
    +
    +/// The URI of each request the routing client answered, in order.
    +#[derive(Debug, Default)]
    +struct Asked {
    +    s3: Vec,
    +    sts: Vec,
    +}
    +
    +/// An HTTP client that answers in the process: GetCallerIdentity with an
    +/// identity, any other request with an empty listing.
    +fn routing(asked: &Arc>) -> SharedHttpClient {
    +    let asked = asked.clone();
    +    infallible_client_fn(move |request| {
    +        let uri = request.uri().to_string();
    +        let identity = request
    +            .body()
    +            .bytes()
    +            .is_some_and(|body| String::from_utf8_lossy(body).contains("Action=GetCallerIdentity"));
    +        let mut asked = asked.lock().unwrap();
    +        let response = if identity {
    +            asked.sts.push(uri);
    +            HttpResponse::new(200.try_into().unwrap(), SdkBody::from(IDENTITY))
    +        } else {
    +            asked.s3.push(uri);
    +            listing()
    +        };
    +        response.try_into_http1x().unwrap()
    +    })
    +}
    +
    +#[test]
    +fn without_endpoint_url_the_store_sends_to_aws_s3_whatever_the_environment_names() {
    +    if child::is_child() {
    +        child::block_on(in_child());
    +        return;
    +    }
    +    for case in CASES {
    +        let variables: Vec<_> = KEYS.iter().chain(case.variables).copied().collect();
    +        child::run(
    +            module_path!(),
    +            "without_endpoint_url_the_store_sends_to_aws_s3_whatever_the_environment_names",
    +            case.name,
    +            &variables,
    +            case.config,
    +        );
    +    }
    +}
    +
    +/// In the child: the store's clients built from this process's environment
    +/// with no `endpoint_url` send to AWS S3 in the store's region, the
    +/// identity check sends to the case's STS endpoint over the same client,
    +/// and the SDK's own client over a configuration loaded from the same
    +/// environment sends to the endpoint the case names, which shows the case
    +/// reaches the SDK.
    +async fn in_child() {
    +    let case = CASES
    +        .iter()
    +        .find(|case| case.name == child::case())
    +        .expect("the child runs a case of CASES");
    +    let never = NeverClient::new();
    +    let loaded = loader(&settings(None))
    +        .http_client(never.clone())
    +        .load()
    +        .await;
    +    let asked = Arc::new(Mutex::new(Asked::default()));
    +    let http = routing(&asked);
    +    let clients = connect_from(&settings(None), &loaded, &STARTUP, move |builder| {
    +        builder
    +            .http_client(http)
    +            .retry_config(RetryConfig::disabled())
    +    })
    +    .await
    +    .with_retry(RetryPolicy::NONE);
    +    list_one(&clients, "loads").await.unwrap();
    +    // The identity check runs in a task of its own.
    +    for _ in 0..500 {
    +        if !asked.lock().unwrap().sts.is_empty() {
    +            break;
    +        }
    +        tokio::time::sleep(Duration::from_millis(10)).await;
    +    }
    +    {
    +        let asked = asked.lock().unwrap();
    +        assert_eq!(asked.s3.len(), 1, "{asked:?}");
    +        assert!(
    +            asked.s3[0].starts_with(AWS_S3),
    +            "the store sent to {asked:?}"
    +        );
    +        assert_eq!(asked.sts.len(), 1, "{asked:?}");
    +        assert!(
    +            asked.sts[0].starts_with(case.sts),
    +            "the identity check sent to {asked:?}"
    +        );
    +    }
    +    assert_eq!(never.num_calls(), 0);
    +
    +    let sdk = aws_config::defaults(behavior_version())
    +        .region(Region::new("us-east-1"))
    +        .load()
    +        .await;
    +    let plain = StaticReplayClient::new(vec![ReplayEvent::new(
    +        HttpRequest::new(SdkBody::empty()),
    +        listing(),
    +    )]);
    +    let client = aws_sdk_s3::Client::from_conf(
    +        aws_sdk_s3::config::Builder::from(&sdk)
    +            .http_client(plain.clone())
    +            .retry_config(RetryConfig::disabled())
    +            .build(),
    +    );
    +    client
    +        .list_objects_v2()
    +        .bucket("loads")
    +        .send()
    +        .await
    +        .unwrap();
    +    let sdk_uri = uri(&plain, 0);
    +    assert!(sdk_uri.starts_with(ELSEWHERE), "the SDK sent to {sdk_uri}");
    +}
    diff --git a/crates/s3/src/tests/identity.rs b/crates/s3/src/tests/identity.rs
    new file mode 100644
    index 000000000..26d8a18e4
    --- /dev/null
    +++ b/crates/s3/src/tests/identity.rs
    @@ -0,0 +1,326 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The startup identity check. Each case builds the clients over an SDK
    +//! configuration with static keys whose HTTP client answers in-process (a
    +//! replay client, or one that never answers), so no request leaves the
    +//! test. The clock is paused: the five-second bound passes without a
    +//! five-second sleep, and a startup that waited for the check would show
    +//! it in the paused clock.
    +
    +use std::time::Duration;
    +
    +use aws_sdk_s3::config::retry::RetryConfig;
    +use aws_sdk_s3::config::{Credentials, HttpClient, Region, SharedCredentialsProvider};
    +use aws_smithy_http_client::test_util::NeverClient;
    +use tracing::Level;
    +
    +use super::logs::{Capture, Logged};
    +use super::*;
    +use crate::client::connect_from;
    +
    +const KEY: &str = "AKIDIDENTITYTEST";
    +const SECRET: &str = "identity-test-secret";
    +
    +/// The store's region, which is not the SDK configuration's.
    +const STORE_REGION: &str = "eu-central-1";
    +
    +/// An SDK configuration with static keys, region us-east-1 and an endpoint
    +/// as `AWS_ENDPOINT_URL` would give it, over `http`.
    +fn sdk_over(http: impl HttpClient + 'static) -> SdkConfig {
    +    SdkConfig::builder()
    +        .behavior_version(behavior_version())
    +        .region(Region::new("us-east-1"))
    +        .endpoint_url("http://127.0.0.1:9/environment")
    +        .credentials_provider(SharedCredentialsProvider::new(Credentials::new(
    +            KEY, SECRET, None, None, "test",
    +        )))
    +        .http_client(http)
    +        .retry_config(RetryConfig::disabled())
    +        .build()
    +}
    +
    +/// The settings of a store on AWS S3 in [`STORE_REGION`].
    +fn on_aws_s3() -> S3StoreSettings {
    +    S3StoreSettings {
    +        region: STORE_REGION.to_owned(),
    +        endpoint_url: None,
    +        force_path_style: false,
    +    }
    +}
    +
    +/// Build the clients, then let the identity check run to its end, and
    +/// return this crate's lines past the credential check's own. No line
    +/// carries the access key ID or the secret.
    +async fn check(settings: &S3StoreSettings, http: impl HttpClient + 'static) -> Vec {
    +    let (capture, _guard) = Capture::install();
    +    let _clients = connect_from(settings, &sdk_over(http), &STARTUP, |builder| builder).await;
    +    tokio::time::sleep(Duration::from_secs(30)).await;
    +    identity_lines(&capture)
    +}
    +
    +fn identity_lines(capture: &Capture) -> Vec {
    +    let lines: Vec<_> = capture
    +        .ours()
    +        .into_iter()
    +        .filter(|line| line.message() != "S3 test store credentials resolved")
    +        .collect();
    +    for line in &lines {
    +        let text = format!("{line:?}");
    +        assert!(!text.contains(KEY) && !text.contains(SECRET), "{text}");
    +    }
    +    lines
    +}
    +
    +fn sts(status: u16, body: &str) -> ReplayEvent {
    +    ReplayEvent::new(
    +        HttpRequest::new(SdkBody::empty()),
    +        HttpResponse::new(status.try_into().unwrap(), SdkBody::from(body.to_owned())),
    +    )
    +}
    +
    +fn sts_error(status: u16, code: &str, message: &str) -> ReplayEvent {
    +    sts(
    +        status,
    +        &format!(
    +            "\
    +             Sender{code}{message}\
    +             0f6c2a1e-identity-test"
    +        ),
    +    )
    +}
    +
    +fn only(lines: &[Logged], level: Level) -> &str {
    +    assert_eq!(lines.len(), 1, "{lines:?}");
    +    assert_eq!(lines[0].level, level, "{lines:?}");
    +    lines[0].message()
    +}
    +
    +/// STS's answer to GetCallerIdentity for the store's IAM user.
    +fn signed_as() -> ReplayEvent {
    +    sts(
    +        200,
    +        "\
    +         arn:aws:iam::123456789012:user/extenddb-store\
    +         AIDAIDENTITYTESTUSER123456789012\
    +         0f6c2a1e-identity-test\
    +         ",
    +    )
    +}
    +
    +/// The line logged for [`signed_as`].
    +const SIGNS_AS: &str = "S3 test store signs as arn:aws:iam::123456789012:user/extenddb-store \
    +                        (AWS account 123456789012)";
    +
    +#[tokio::test(start_paused = true)]
    +async fn the_identity_the_credentials_sign_as_is_logged() {
    +    let replay = StaticReplayClient::new(vec![signed_as()]);
    +    let lines = check(&on_aws_s3(), replay.clone()).await;
    +    assert_eq!(only(&lines, Level::INFO), SIGNS_AS);
    +    // One GetCallerIdentity to AWS STS in the store's region, past the
    +    // environment's endpoint, signed with the resolved keys.
    +    let requests: Vec<_> = replay.actual_requests().collect();
    +    assert_eq!(requests.len(), 1);
    +    let request = requests[0];
    +    assert!(
    +        request
    +            .uri()
    +            .starts_with("https://sts.eu-central-1.amazonaws.com"),
    +        "{}",
    +        request.uri()
    +    );
    +    let body = String::from_utf8_lossy(request.body().bytes().unwrap_or_default()).into_owned();
    +    assert!(body.contains("Action=GetCallerIdentity"), "{body}");
    +    let signed = request.headers().get("authorization").unwrap_or_default();
    +    assert!(
    +        signed.contains(&format!("Credential={KEY}/")) && signed.contains("/eu-central-1/sts/"),
    +        "{signed}"
    +    );
    +}
    +
    +/// STS's answers for credentials it refuses, as measured on AWS STS: an
    +/// access key ID AWS does not know, or a session token altered or left out
    +/// (`InvalidClientTokenId`), a wrong secret key (`SignatureDoesNotMatch`),
    +/// and a session token past its expiry (`ExpiredToken`). The last case
    +/// repeats the access key ID.
    +#[tokio::test(start_paused = true)]
    +async fn credentials_sts_refuses_are_a_warning_with_its_code() {
    +    for (code, message, logged) in [
    +        (
    +            "InvalidClientTokenId",
    +            "The security token included in the request is invalid.",
    +            "The security token included in the request is invalid",
    +        ),
    +        (
    +            "SignatureDoesNotMatch",
    +            "The request signature we calculated does not match the signature you provided. \
    +             Check your AWS Secret Access Key and signing method. Consult the service \
    +             documentation for details.",
    +            "The request signature we calculated does not match the signature you provided. \
    +             Check your AWS Secret Access Key and signing method. Consult the service \
    +             documentation for details",
    +        ),
    +        (
    +            "ExpiredToken",
    +            "The security token included in the request is expired",
    +            "The security token included in the request is expired",
    +        ),
    +        (
    +            "InvalidClientTokenId",
    +            "The security token included in the request is invalid: AKIDIDENTITYTEST.",
    +            "The security token included in the request is invalid: ",
    +        ),
    +    ] {
    +        let replay = StaticReplayClient::new(vec![sts_error(403, code, message)]);
    +        let lines = check(&on_aws_s3(), replay).await;
    +        assert_eq!(
    +            only(&lines, Level::WARN),
    +            format!(
    +                "AWS refused the S3 test store credentials ({code}): {logged}. Its S3 requests \
    +                 fail until the server restarts with credentials AWS accepts"
    +            )
    +        );
    +    }
    +}
    +
    +/// STS's answers for a request signed more than 15 minutes before or after
    +/// its clock, as measured on AWS STS: `SignatureDoesNotMatch`, with a
    +/// message that says so.
    +#[tokio::test(start_paused = true)]
    +async fn a_clock_more_than_15_minutes_off_is_a_warning_of_the_clock() {
    +    for message in [
    +        "Signature expired: 20261027T113503Z is now earlier than 20261027T114003Z \
    +         (20261027T115503Z - 15 min.)",
    +        "Signature not yet current: 20261027T121502Z is still later than 20261027T121002Z \
    +         (20261027T115502Z + 15 min.)",
    +    ] {
    +        let replay =
    +            StaticReplayClient::new(vec![sts_error(403, "SignatureDoesNotMatch", message)]);
    +        let lines = check(&on_aws_s3(), replay).await;
    +        assert_eq!(
    +            only(&lines, Level::WARN),
    +            format!(
    +                "S3 test store credentials could not be checked with AWS STS: the server clock \
    +                 is more than 15 minutes off (SignatureDoesNotMatch): {message}"
    +            )
    +        );
    +    }
    +}
    +
    +/// Every other answer, a malformed signature included, says nothing measured
    +/// about the credentials. The last case repeats the access key ID.
    +#[tokio::test(start_paused = true)]
    +async fn an_answer_that_says_nothing_of_the_credentials_is_a_warning_that_they_were_not_checked() {
    +    for (status, code, message, logged) in [
    +        (400, "Throttling", "Rate exceeded", "Rate exceeded"),
    +        (429, "Throttling", "Rate exceeded", "Rate exceeded"),
    +        (
    +            500,
    +            "InternalFailure",
    +            "Try again later.",
    +            "Try again later.",
    +        ),
    +        (
    +            400,
    +            "IncompleteSignature",
    +            "Credential=AKIDIDENTITYTEST/20261027/eu-central-1/sts is incomplete.",
    +            "Credential=/20261027/eu-central-1/sts is incomplete.",
    +        ),
    +    ] {
    +        let replay = StaticReplayClient::new(vec![sts_error(status, code, message)]);
    +        let lines = check(&on_aws_s3(), replay).await;
    +        assert_eq!(
    +            only(&lines, Level::WARN),
    +            format!(
    +                "S3 test store credentials could not be checked with AWS STS: {code} (HTTP \
    +                 {status}): {logged}"
    +            )
    +        );
    +    }
    +    // A transport failure: the replay client has nothing to answer with.
    +    let lines = check(&on_aws_s3(), StaticReplayClient::new(Vec::new())).await;
    +    let line = only(&lines, Level::WARN);
    +    assert!(
    +        line.starts_with("S3 test store credentials could not be checked with AWS STS: "),
    +        "{line}"
    +    );
    +}
    +
    +#[tokio::test(start_paused = true)]
    +async fn no_answer_within_five_seconds_is_a_warning_and_startup_does_not_wait_for_it() {
    +    let never = NeverClient::new();
    +    let (capture, _guard) = Capture::install();
    +    let started = tokio::time::Instant::now();
    +    let _clients = connect_from(
    +        &on_aws_s3(),
    +        &sdk_over(never.clone()),
    +        &STARTUP,
    +        |builder| builder,
    +    )
    +    .await;
    +    assert!(
    +        started.elapsed() < Duration::from_secs(1),
    +        "{:?}",
    +        started.elapsed()
    +    );
    +    tokio::time::sleep(Duration::from_millis(4900)).await;
    +    assert_eq!(identity_lines(&capture).len(), 0);
    +    tokio::time::sleep(Duration::from_millis(200)).await;
    +    assert_eq!(
    +        only(&identity_lines(&capture), Level::WARN),
    +        "S3 test store credentials could not be checked with AWS STS: no answer within five \
    +         seconds"
    +    );
    +    assert_eq!(never.num_calls(), 1);
    +}
    +
    +/// An identity that repeats the access key ID, as a role session named
    +/// after it does, is logged without it.
    +#[tokio::test(start_paused = true)]
    +async fn an_access_key_id_in_the_identity_is_elided() {
    +    let replay = StaticReplayClient::new(vec![sts(
    +        200,
    +        &format!(
    +            "\
    +             \
    +             arn:aws:sts::123456789012:assumed-role/loader/{KEY}\
    +             AROAIDENTITYTESTROLE:{KEY}123456789012\
    +             "
    +        ),
    +    )]);
    +    let lines = check(&on_aws_s3(), replay).await;
    +    assert_eq!(
    +        only(&lines, Level::INFO),
    +        "S3 test store signs as arn:aws:sts::123456789012:assumed-role/loader/ \
    +         (AWS account 123456789012)"
    +    );
    +}
    +
    +#[tokio::test(start_paused = true)]
    +async fn the_check_goes_over_the_http_client_the_s3_clients_are_given() {
    +    // The configuration's own client never answers; the one the caller
    +    // gives the S3 clients answers the identity.
    +    let never = NeverClient::new();
    +    let given = StaticReplayClient::new(vec![signed_as()]);
    +    let http = given.clone();
    +    let (capture, _guard) = Capture::install();
    +    let _clients = connect_from(
    +        &on_aws_s3(),
    +        &sdk_over(never.clone()),
    +        &STARTUP,
    +        move |builder| builder.http_client(http),
    +    )
    +    .await;
    +    tokio::time::sleep(Duration::from_secs(30)).await;
    +    assert_eq!(only(&identity_lines(&capture), Level::INFO), SIGNS_AS);
    +    assert_eq!(given.actual_requests().count(), 1);
    +    assert_eq!(never.num_calls(), 0);
    +}
    +
    +#[tokio::test(start_paused = true)]
    +async fn with_an_endpoint_url_no_sts_request_is_sent() {
    +    let never = NeverClient::new();
    +    let lines = check(&settings(Some("http://127.0.0.1:9")), never.clone()).await;
    +    assert_eq!(lines.len(), 0, "{lines:?}");
    +    assert_eq!(never.num_calls(), 0);
    +}
    diff --git a/crates/s3/src/tests/logs.rs b/crates/s3/src/tests/logs.rs
    new file mode 100644
    index 000000000..e2e9cc9a7
    --- /dev/null
    +++ b/crates/s3/src/tests/logs.rs
    @@ -0,0 +1,100 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! A subscriber that keeps the events logged on the current thread, for
    +//! the tests that assert a log line's level, target and fields.
    +
    +use std::sync::{Arc, Mutex};
    +
    +use tracing::field::{Field, Visit};
    +use tracing::span::{Attributes, Id, Record};
    +use tracing::{Event, Level, Metadata, Subscriber};
    +
    +/// One logged event: its level, its target and its fields, `message`
    +/// included.
    +#[derive(Clone, Debug)]
    +pub(super) struct Logged {
    +    pub(super) level: Level,
    +    pub(super) target: String,
    +    fields: Vec<(String, String)>,
    +}
    +
    +impl Logged {
    +    pub(super) fn field(&self, name: &str) -> Option<&str> {
    +        self.fields
    +            .iter()
    +            .find(|(field, _)| field == name)
    +            .map(|(_, value)| value.as_str())
    +    }
    +
    +    pub(super) fn message(&self) -> &str {
    +        self.field("message").unwrap_or_default()
    +    }
    +}
    +
    +/// The events logged while the guard [`Capture::install`] returns lives.
    +#[derive(Clone, Default)]
    +pub(super) struct Capture(Arc>>);
    +
    +impl Capture {
    +    /// Install on the current thread until the guard drops. A test that
    +    /// uses it runs on a current-thread runtime, so the store's calls log
    +    /// on this thread.
    +    pub(super) fn install() -> (Self, tracing::dispatcher::DefaultGuard) {
    +        let capture = Self::default();
    +        let guard = tracing::dispatcher::set_default(&tracing::Dispatch::new(capture.clone()));
    +        (capture, guard)
    +    }
    +
    +    /// The events this crate logged, the SDK's own left out.
    +    pub(super) fn ours(&self) -> Vec {
    +        self.events()
    +            .into_iter()
    +            .filter(|event| event.target.starts_with("extenddb_s3"))
    +            .collect()
    +    }
    +
    +    pub(super) fn events(&self) -> Vec {
    +        self.0.lock().unwrap().clone()
    +    }
    +}
    +
    +struct Fields<'a>(&'a mut Vec<(String, String)>);
    +
    +impl Visit for Fields<'_> {
    +    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
    +        self.0.push((field.name().to_owned(), format!("{value:?}")));
    +    }
    +
    +    fn record_str(&mut self, field: &Field, value: &str) {
    +        self.0.push((field.name().to_owned(), value.to_owned()));
    +    }
    +}
    +
    +impl Subscriber for Capture {
    +    fn enabled(&self, _: &Metadata<'_>) -> bool {
    +        true
    +    }
    +
    +    fn new_span(&self, _: &Attributes<'_>) -> Id {
    +        Id::from_u64(1)
    +    }
    +
    +    fn record(&self, _: &Id, _: &Record<'_>) {}
    +
    +    fn record_follows_from(&self, _: &Id, _: &Id) {}
    +
    +    fn event(&self, event: &Event<'_>) {
    +        let mut fields = Vec::new();
    +        event.record(&mut Fields(&mut fields));
    +        self.0.lock().unwrap().push(Logged {
    +            level: *event.metadata().level(),
    +            target: event.metadata().target().to_owned(),
    +            fields,
    +        });
    +    }
    +
    +    fn enter(&self, _: &Id) {}
    +
    +    fn exit(&self, _: &Id) {}
    +}
    diff --git a/crates/s3/src/tests/mod.rs b/crates/s3/src/tests/mod.rs
    new file mode 100644
    index 000000000..b5f0d95b2
    --- /dev/null
    +++ b/crates/s3/src/tests/mod.rs
    @@ -0,0 +1,133 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Tests of the shared clients. Each canned S3 response goes through the
    +//! SDK's replay HTTP client, which records each request and sends nothing.
    +
    +mod child;
    +mod codes;
    +mod deadlines;
    +mod endpoints;
    +mod identity;
    +mod logs;
    +mod redirect;
    +mod settings;
    +mod temporary;
    +
    +use aws_config::SdkConfig;
    +use aws_sdk_s3::config::http::{HttpRequest, HttpResponse};
    +use aws_sdk_s3::config::retry::RetryConfig;
    +use aws_sdk_s3::config::{Credentials, Region, SharedCredentialsProvider};
    +use aws_sdk_s3::error::SdkError;
    +use aws_sdk_s3::operation::list_objects_v2::{ListObjectsV2Error, ListObjectsV2Output};
    +use aws_sdk_s3::primitives::SdkBody;
    +use aws_smithy_http_client::test_util::{NeverClient, ReplayEvent, StaticReplayClient};
    +
    +use crate::retry::RetryPolicy;
    +use crate::{S3Clients, S3StoreSettings, Startup, behavior_version};
    +
    +pub(super) const REQUEST_ID: &str = "4KQS8G3V7XJ2M1PA";
    +pub(super) const EXTENDED_ID: &str = "pQ8x1T0z+7mY/2abcdEF3ghIJ4klMN5opQR6stUV7wxYZ8=";
    +
    +/// How the test store names itself in its startup lines.
    +pub(super) const STARTUP: Startup<'static> = Startup {
    +    label: "S3 test store",
    +    until_resolved: "its calls fail until they resolve",
    +};
    +
    +/// A store's clients over a replay client answering `responses` in order,
    +/// region us-east-1, virtual-hosted addressing, redirects followed, no
    +/// retries by the SDK or by the store.
    +pub(super) fn clients(responses: Vec) -> (S3Clients, StaticReplayClient) {
    +    let replay = StaticReplayClient::new(
    +        responses
    +            .into_iter()
    +            .map(|response| ReplayEvent::new(HttpRequest::new(SdkBody::empty()), response))
    +            .collect(),
    +    );
    +    let config = aws_sdk_s3::Config::builder()
    +        .behavior_version(behavior_version())
    +        .region(Region::new("us-east-1"))
    +        .credentials_provider(Credentials::new("AKIDTEST", "secret", None, None, "test"))
    +        .http_client(replay.clone())
    +        .retry_config(RetryConfig::disabled())
    +        .build();
    +    (
    +        S3Clients::new(config, true, false).with_retry(RetryPolicy::NONE),
    +        replay,
    +    )
    +}
    +
    +pub(super) fn with_headers(mut response: HttpResponse, headers: &[(&str, &str)]) -> HttpResponse {
    +    for (name, value) in headers {
    +        response
    +            .headers_mut()
    +            .insert((*name).to_owned(), (*value).to_owned());
    +    }
    +    response
    +}
    +
    +/// An S3 error response with its XML body and both request ids.
    +pub(super) fn s3_error(status: u16, code: &str, message: &str) -> HttpResponse {
    +    let body = format!(
    +        "\n{code}\
    +         {message}{REQUEST_ID}\
    +         {EXTENDED_ID}"
    +    );
    +    with_headers(
    +        HttpResponse::new(status.try_into().unwrap(), SdkBody::from(body)),
    +        &[
    +            ("content-type", "application/xml"),
    +            ("x-amz-request-id", REQUEST_ID),
    +            ("x-amz-id-2", EXTENDED_ID),
    +        ],
    +    )
    +}
    +
    +pub(super) fn uri(replay: &StaticReplayClient, index: usize) -> String {
    +    replay
    +        .actual_requests()
    +        .nth(index)
    +        .map(|request| request.uri().to_owned())
    +        .unwrap_or_default()
    +}
    +
    +/// One ListObjectsV2 of at most one key under `in/` in `bucket`, through
    +/// the clients' own call.
    +pub(super) async fn list_one(
    +    clients: &S3Clients,
    +    bucket: &str,
    +) -> Result> {
    +    clients
    +        .call(bucket, |client| {
    +            client
    +                .list_objects_v2()
    +                .bucket(bucket)
    +                .prefix("in/")
    +                .max_keys(1)
    +                .send()
    +        })
    +        .await
    +}
    +
    +/// An SDK configuration as the loader would give it, with static keys,
    +/// built without reading the environment, over an HTTP client that never
    +/// answers, so a request a test does not expect stays in the process.
    +pub(super) fn sdk_config() -> SdkConfig {
    +    SdkConfig::builder()
    +        .behavior_version(behavior_version())
    +        .region(Region::new("us-east-1"))
    +        .credentials_provider(SharedCredentialsProvider::new(Credentials::new(
    +            "AKIDTEST", "secret", None, None, "test",
    +        )))
    +        .http_client(NeverClient::new())
    +        .build()
    +}
    +
    +pub(super) fn settings(endpoint_url: Option<&str>) -> S3StoreSettings {
    +    S3StoreSettings {
    +        region: "us-east-1".to_owned(),
    +        endpoint_url: endpoint_url.map(str::to_owned),
    +        force_path_style: endpoint_url.is_some(),
    +    }
    +}
    diff --git a/crates/s3/src/tests/redirect.rs b/crates/s3/src/tests/redirect.rs
    new file mode 100644
    index 000000000..0eb8a8ba0
    --- /dev/null
    +++ b/crates/s3/src/tests/redirect.rs
    @@ -0,0 +1,125 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Region redirects: a bucket in another region is found through the
    +//! redirect AWS S3 answers, once, and remembered.
    +
    +use aws_sdk_s3::config::http::HttpResponse;
    +use aws_sdk_s3::config::retry::RetryConfig;
    +use aws_sdk_s3::config::{Credentials, Region};
    +use aws_sdk_s3::error::ProvideErrorMetadata;
    +use aws_sdk_s3::primitives::SdkBody;
    +use aws_smithy_http_client::test_util::{ReplayEvent, StaticReplayClient};
    +
    +use super::*;
    +
    +fn empty_listing() -> HttpResponse {
    +    HttpResponse::new(
    +        200.try_into().unwrap(),
    +        SdkBody::from(
    +            "\nloads\
    +             false",
    +        ),
    +    )
    +}
    +
    +fn redirect(status: u16, code: &str, region: &str) -> HttpResponse {
    +    with_headers(
    +        s3_error(status, code, "The bucket is in another region."),
    +        &[("x-amz-bucket-region", region)],
    +    )
    +}
    +
    +#[tokio::test]
    +async fn a_redirect_is_followed_once_and_the_bucket_region_remembered() {
    +    let (clients, replay) = clients(vec![
    +        redirect(301, "PermanentRedirect", "us-west-2"),
    +        empty_listing(),
    +        empty_listing(),
    +    ]);
    +    list_one(&clients, "loads").await.unwrap();
    +    list_one(&clients, "loads").await.unwrap();
    +    assert!(
    +        uri(&replay, 0).contains("s3.us-east-1.amazonaws.com"),
    +        "{}",
    +        uri(&replay, 0)
    +    );
    +    assert!(
    +        uri(&replay, 1).contains("s3.us-west-2.amazonaws.com"),
    +        "{}",
    +        uri(&replay, 1)
    +    );
    +    assert!(
    +        uri(&replay, 2).contains("s3.us-west-2.amazonaws.com"),
    +        "{}",
    +        uri(&replay, 2)
    +    );
    +    assert_eq!(replay.actual_requests().count(), 3);
    +}
    +
    +#[tokio::test]
    +async fn client_for_gives_the_client_of_the_remembered_region() {
    +    let (clients, _replay) = clients(vec![
    +        redirect(301, "PermanentRedirect", "us-west-2"),
    +        empty_listing(),
    +    ]);
    +    let region = |client: aws_sdk_s3::Client| client.config().region().cloned();
    +    assert_eq!(
    +        region(clients.client_for("loads")),
    +        Some(Region::new("us-east-1"))
    +    );
    +    list_one(&clients, "loads").await.unwrap();
    +    assert_eq!(
    +        region(clients.client_for("loads")),
    +        Some(Region::new("us-west-2"))
    +    );
    +    assert_eq!(
    +        region(clients.client_for("reports")),
    +        Some(Region::new("us-east-1"))
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_authorization_header_naming_the_wrong_region_is_followed() {
    +    let (clients, replay) = clients(vec![
    +        redirect(400, "AuthorizationHeaderMalformed", "eu-west-1"),
    +        empty_listing(),
    +    ]);
    +    list_one(&clients, "loads").await.unwrap();
    +    assert!(
    +        uri(&replay, 1).contains("s3.eu-west-1.amazonaws.com"),
    +        "{}",
    +        uri(&replay, 1)
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_redirect_is_the_answer_against_a_configured_endpoint_and_after_one_retry() {
    +    // Against an S3-compatible endpoint redirects are not followed.
    +    let replay = StaticReplayClient::new(vec![ReplayEvent::new(
    +        HttpRequest::new(SdkBody::empty()),
    +        redirect(301, "PermanentRedirect", "us-west-2"),
    +    )]);
    +    let config = aws_sdk_s3::Config::builder()
    +        .behavior_version(behavior_version())
    +        .region(Region::new("us-east-1"))
    +        .endpoint_url("http://127.0.0.1:9")
    +        .force_path_style(true)
    +        .credentials_provider(Credentials::new("AKIDTEST", "secret", None, None, "test"))
    +        .http_client(replay.clone())
    +        .retry_config(RetryConfig::disabled())
    +        .build();
    +    let endpoint_clients = S3Clients::new(config, false, true).with_retry(RetryPolicy::NONE);
    +    let refused = list_one(&endpoint_clients, "loads").await.unwrap_err();
    +    assert_eq!(refused.code(), Some("PermanentRedirect"));
    +    assert_eq!(replay.actual_requests().count(), 1);
    +
    +    // On AWS S3 a second redirect after the retry is the answer.
    +    let (aws, replay) = clients(vec![
    +        redirect(301, "PermanentRedirect", "us-west-2"),
    +        redirect(301, "PermanentRedirect", "eu-west-1"),
    +    ]);
    +    let again = list_one(&aws, "loads").await.unwrap_err();
    +    assert_eq!(again.code(), Some("PermanentRedirect"));
    +    assert_eq!(replay.actual_requests().count(), 2);
    +}
    diff --git a/crates/s3/src/tests/settings.rs b/crates/s3/src/tests/settings.rs
    new file mode 100644
    index 000000000..58f71cc74
    --- /dev/null
    +++ b/crates/s3/src/tests/settings.rs
    @@ -0,0 +1,20 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The settings the store's clients are built with: the pinned behaviour
    +//! version.
    +
    +use crate::behavior_version;
    +use crate::client::loader;
    +
    +/// The SDK configuration the store's clients are built from carries the
    +/// pinned behaviour version. The workspace enables the SDK's
    +/// `behavior-version-latest` feature, which gives a client built without a
    +/// version the newest one, so the pin is checked on the store's own loader:
    +/// a loader that stopped setting it would float with the SDK release once
    +/// the SDK's newest version moves past the pinned one.
    +#[tokio::test]
    +async fn the_store_configuration_carries_the_pinned_behaviour_version() {
    +    let sdk = loader(&super::settings(None)).load().await;
    +    assert_eq!(sdk.behavior_version(), Some(behavior_version()));
    +}
    diff --git a/crates/s3/src/tests/temporary.rs b/crates/s3/src/tests/temporary.rs
    new file mode 100644
    index 000000000..fe3e388bc
    --- /dev/null
    +++ b/crates/s3/src/tests/temporary.rs
    @@ -0,0 +1,203 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The warning for resolved temporary keys the SDK cannot renew: a session
    +//! token and no expiry, from any source but a `credential_process`. The
    +//! in-process cases build the clients over an SDK configuration that
    +//! resolves the case's keys, against a configured endpoint, so no request
    +//! is sent; the configuration's HTTP client never answers and records each
    +//! call, and none is made. The profile cases run the SDK's own chain in a
    +//! child process, against a configured endpoint too.
    +
    +use std::time::{Duration, SystemTime};
    +
    +use aws_credential_types::credential_feature::AwsCredentialFeature;
    +use aws_sdk_s3::config::{Credentials, Region, SharedCredentialsProvider};
    +use aws_smithy_http_client::test_util::NeverClient;
    +use tracing::Level;
    +
    +use super::child;
    +use super::logs::{Capture, Logged};
    +use super::*;
    +use crate::client::connect_from;
    +use crate::connect_with;
    +
    +const SECRET: &str = "temporary-test-secret";
    +
    +const CANNOT_RENEW: &str = "The S3 test store credentials are temporary keys the AWS SDK cannot \
    +     renew. When they expire, its S3 requests fail until the server restarts with new keys. Give \
    +     the server a role, web identity, IAM Identity Center or a credential_process profile instead";
    +
    +/// An SDK configuration that resolves `credentials` (none when `None`),
    +/// over `never`.
    +fn resolving(credentials: Option, never: &NeverClient) -> SdkConfig {
    +    let mut builder = SdkConfig::builder()
    +        .behavior_version(behavior_version())
    +        .region(Region::new("us-east-1"))
    +        .http_client(never.clone());
    +    if let Some(credentials) = credentials {
    +        builder = builder.credentials_provider(SharedCredentialsProvider::new(credentials));
    +    }
    +    builder.build()
    +}
    +
    +/// The lines this crate logs while the clients are built over a
    +/// configuration that resolves `credentials`. No line carries the access
    +/// key ID or the secret, and no request is sent.
    +async fn startup_lines(credentials: Credentials) -> Vec {
    +    let key = credentials.access_key_id().to_owned();
    +    let never = NeverClient::new();
    +    let sdk = resolving(Some(credentials), &never);
    +    let (capture, _guard) = Capture::install();
    +    let _clients = connect_from(
    +        &settings(Some("http://127.0.0.1:9")),
    +        &sdk,
    +        &STARTUP,
    +        |builder| builder,
    +    )
    +    .await;
    +    // Past the identity check's own bound, had one started.
    +    tokio::time::sleep(Duration::from_secs(30)).await;
    +    assert_eq!(never.num_calls(), 0);
    +    let lines = capture.ours();
    +    for line in &lines {
    +        let text = format!("{line:?}");
    +        assert!(!text.contains(&key) && !text.contains(SECRET), "{text}");
    +    }
    +    lines
    +}
    +
    +fn warnings(lines: &[Logged]) -> Vec<&str> {
    +    lines
    +        .iter()
    +        .filter(|line| line.level == Level::WARN)
    +        .map(Logged::message)
    +        .collect()
    +}
    +
    +#[tokio::test(start_paused = true)]
    +async fn a_session_token_without_an_expiry_is_a_warning() {
    +    let lines = startup_lines(Credentials::new(
    +        "ASIATEMPORARYTEST",
    +        SECRET,
    +        Some("session-token".to_owned()),
    +        None,
    +        "test",
    +    ))
    +    .await;
    +    assert_eq!(warnings(&lines), [CANNOT_RENEW]);
    +}
    +
    +#[tokio::test(start_paused = true)]
    +async fn keys_with_an_expiry_or_without_a_session_token_are_no_warning() {
    +    let in_an_hour = SystemTime::now() + Duration::from_secs(3600);
    +    for credentials in [
    +        Credentials::new(
    +            "ASIAEXPIRINGTEST",
    +            SECRET,
    +            Some("session-token".to_owned()),
    +            Some(in_an_hour),
    +            "test",
    +        ),
    +        Credentials::new("AKIALONGTERMTEST", SECRET, None, None, "test"),
    +    ] {
    +        let lines = startup_lines(credentials).await;
    +        assert_eq!(warnings(&lines), Vec::<&str>::new());
    +        let resolved: Vec<_> = lines.iter().map(Logged::message).collect();
    +        assert_eq!(resolved, ["S3 test store credentials resolved"]);
    +    }
    +}
    +
    +/// Keys from a `credential_process` are run for again when the SDK's cache
    +/// lets them go, so a session token without an expiry from one is no
    +/// warning.
    +#[tokio::test(start_paused = true)]
    +async fn keys_a_credential_process_gives_without_an_expiry_are_no_warning() {
    +    let mut credentials = Credentials::new(
    +        "ASIAPROCESSTEST",
    +        SECRET,
    +        Some("session-token".to_owned()),
    +        None,
    +        "test",
    +    );
    +    credentials.set_property(vec![AwsCredentialFeature::CredentialsProcess]);
    +    let lines = startup_lines(credentials).await;
    +    assert_eq!(warnings(&lines), Vec::<&str>::new());
    +}
    +
    +/// The same through the SDK's own credential chain, in a child process of
    +/// this test binary (`child::run`) whose AWS config file is the case's: a
    +/// `credential_process` that prints a session token and no `Expiration`,
    +/// and the same kind of keys written into the profile.
    +const PROFILES: [(&str, &str); 2] = [
    +    (
    +        "credential_process",
    +        "[default]\ncredential_process = printf '{\"Version\": 1, \"AccessKeyId\": \
    +         \"ASIAPROCESSTEST\", \"SecretAccessKey\": \"temporary-test-secret\", \"SessionToken\": \
    +         \"process-token\"}'\n",
    +    ),
    +    (
    +        "keys in the profile",
    +        "[default]\naws_access_key_id = ASIAPROFILETEST\naws_secret_access_key = \
    +         temporary-test-secret\naws_session_token = profile-token\n",
    +    ),
    +];
    +
    +#[test]
    +fn a_credential_process_profile_is_no_warning_and_keys_in_the_profile_are() {
    +    if child::is_child() {
    +        child::block_on(from_a_profile());
    +        return;
    +    }
    +    for (case, config) in PROFILES {
    +        child::run(
    +            module_path!(),
    +            "a_credential_process_profile_is_no_warning_and_keys_in_the_profile_are",
    +            case,
    +            &[],
    +            config,
    +        );
    +    }
    +}
    +
    +/// In the child: the lines of a store built from this process's profile,
    +/// against a configured endpoint, so nothing is sent.
    +async fn from_a_profile() {
    +    let (capture, _guard) = Capture::install();
    +    let _clients = connect_with(&settings(Some("http://127.0.0.1:9")), &STARTUP, |builder| {
    +        builder
    +    })
    +    .await;
    +    let lines = capture.ours();
    +    let messages: Vec<_> = lines.iter().map(Logged::message).collect();
    +    let resolved = "S3 test store credentials resolved";
    +    if child::case() == "credential_process" {
    +        assert_eq!(messages, [resolved]);
    +    } else {
    +        assert_eq!(messages, [resolved, CANNOT_RENEW]);
    +    }
    +    for line in &lines {
    +        let text = format!("{line:?}");
    +        assert!(!text.contains("ASIA") && !text.contains(SECRET), "{text}");
    +    }
    +}
    +
    +#[tokio::test]
    +async fn the_clients_keep_the_resolved_access_key_id_for_the_caller() {
    +    let endpoint = settings(Some("http://127.0.0.1:9"));
    +    let never = NeverClient::new();
    +    let resolved = Credentials::new("AKIARESOLVEDTEST", SECRET, None, None, "test");
    +    let clients = connect_from(
    +        &endpoint,
    +        &resolving(Some(resolved), &never),
    +        &STARTUP,
    +        |builder| builder,
    +    )
    +    .await;
    +    assert_eq!(clients.access_key_id(), Some("AKIARESOLVEDTEST"));
    +    // Credentials that do not resolve leave none.
    +    let unresolved = resolving(None, &never);
    +    let clients = connect_from(&endpoint, &unresolved, &STARTUP, |builder| builder).await;
    +    assert_eq!(clients.access_key_id(), None);
    +    assert_eq!(never.num_calls(), 0);
    +}
    
    From dca4c96947b62f13de013a3ac10f43b09a7d5da6 Mon Sep 17 00:00:00 2001
    From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com>
    Date: Thu, 5 Nov 2026 22:10:02 +0000
    Subject: [PATCH 3/6] feat(storage): add the bulk operation store and the
     batched put
    
    Migration 8 adds the bulk put items tables on SQLite and PostgreSQL:
    operations, input files, report records, row outcomes and write owners.
    The BulkOperationStore trait declares the storage steps of an
    operation's lifecycle, which the engine calls: start, cancel, claim,
    checkpoint, finish and the retention purge. DataEngine gains
    put_items_batch, which writes rows and records their outcomes in one
    transaction, so a resumed run writes no row twice. DataEngine::put_item
    takes an optional row marker, and a put that carries one records its
    outcome in the ledger. The engine's put path moves to
    put_item/payload.rs, which the bulk runner shares, and core's
    validate_put_item moves into it. PutItem now checks the nesting depth of
    ExpressionAttributeValues with its condition checks, before the table
    lookup. MongoDB and Cassandra have no bulk operation store: a bulk
    request that reaches the store gets ValidationException, and migration
    8 does not apply to them. The contract suite runs one set of store
    cases on both SQLite and PostgreSQL, and docs/schema holds the new
    dumps. The engine's manifest is in this commit because the put helper's
    tests need the SQLite engine as a dev-dependency.
    ---
     crates/core/src/validation/mod.rs             | 101 +--
     crates/engine/Cargo.toml                      |  39 +
     crates/engine/src/batch_write_item.rs         |   1 +
     crates/engine/src/import_export.rs            |   2 +-
     crates/engine/src/put_item.rs                 |  94 +-
     crates/engine/src/put_item/payload.rs         | 248 +++++
     crates/engine/src/put_item/payload/tests.rs   | 419 +++++++++
     .../src/bulk_operation_store.rs               | 136 +++
     .../storage-cassandra/src/data/data_engine.rs |  48 +
     crates/storage-cassandra/src/lib.rs           |   1 +
     .../storage-cassandra/src/migrations/mod.rs   |   4 +
     crates/storage-cassandra/tests/common/mod.rs  |   1 +
     .../tests/direct/backup_engine.rs             |   4 +-
     .../tests/direct/delete_item.rs               |  10 +
     .../storage-cassandra/tests/direct/index.rs   |  32 +-
     .../tests/direct/put_get_item.rs              |  14 +
     .../storage-cassandra/tests/direct/query.rs   |  19 +-
     crates/storage-cassandra/tests/direct/scan.rs |   9 +
     .../tests/direct/stream_ownership.rs          |   1 +
     .../storage-cassandra/tests/direct/streams.rs |  10 +
     .../tests/direct/transact_get_items.rs        |   7 +
     .../tests/direct/transact_write_items.rs      |  41 +-
     .../transact_write_stream_batch_limit.rs      |   2 +-
     .../direct/transact_write_stream_images.rs    |   3 +
     .../direct/transact_write_stream_records.rs   |  12 +-
     .../direct/transact_write_stream_sequence.rs  |   2 +-
     .../tests/direct/transaction_ledger.rs        |   8 +-
     crates/storage-cassandra/tests/ttl_bench.rs   |   3 +-
     .../tests/ttl_integration.rs                  |  46 +-
     .../storage-mongodb/src/backup_live_tests.rs  |   2 +-
     .../src/bulk_operation_store.rs               | 136 +++
     crates/storage-mongodb/src/data_engine.rs     |  48 +
     crates/storage-mongodb/src/lib.rs             |   1 +
     crates/storage-mongodb/src/migrations/mod.rs  |   4 +
     crates/storage-mongodb/src/snapshot_engine.rs |   1 +
     .../storage-mongodb/tests/migrations_fresh.rs |  18 +-
     .../storage-mongodb/tests/migrations_locks.rs |   2 +-
     .../tests/migrations_released.rs              |   2 +-
     crates/storage-postgres/Cargo.toml            |   3 +-
     .../data_migrations/0008_bulk_operations.sql  |  53 ++
     .../migrations/0008_bulk_operations.sql       | 119 +++
     .../src/bulk_operation_store/claim.rs         | 359 ++++++++
     .../src/bulk_operation_store/files.rs         | 197 ++++
     .../src/bulk_operation_store/index_guard.rs   | 218 +++++
     .../src/bulk_operation_store/mod.rs           | 340 +++++++
     .../src/bulk_operation_store/pg_tests.rs      | 392 ++++++++
     .../src/bulk_operation_store/query.rs         | 286 ++++++
     .../src/bulk_operation_store/start.rs         | 420 +++++++++
     .../src/bulk_operation_store/unreadable.rs    | 103 +++
     .../src/data/bulk_row_outcomes.rs             | 355 ++++++++
     .../storage-postgres/src/data/data_engine.rs  | 112 ++-
     crates/storage-postgres/src/data/index.rs     |   7 +
     crates/storage-postgres/src/data/mod.rs       |   1 +
     crates/storage-postgres/src/data/put_item.rs  | 847 +++++++++---------
     .../src/data/put_item/batch.rs                | 232 +++++
     crates/storage-postgres/src/lib.rs            |   1 +
     crates/storage-postgres/src/migrations/mod.rs |  15 +-
     crates/storage-postgres/src/update_table.rs   |  25 +
     .../storage-postgres/tests/backup_restore.rs  |   1 +
     .../tests/bulk_operation_control_plane.rs     | 428 +++++++++
     .../tests/bulk_operation_store.rs             | 411 +++++++++
     .../tests/bulk_operation_store/locks.rs       |  95 ++
     .../tests/bulk_operation_store/row_faults.rs  | 236 +++++
     .../tests/doc_version_literals.rs             |   2 +-
     .../storage-postgres/tests/gsi_bulk_batch.rs  | 564 ++++++++++++
     .../tests/gsi_bulk_batch/ledger.rs            | 195 ++++
     .../tests/gsi_online_build.rs                 |  23 +-
     .../tests/gsi_stale_writer.rs                 |  23 +-
     .../storage-postgres/tests/key_collation.rs   |   2 +-
     .../storage-postgres/tests/put_create_race.rs |  56 +-
     .../tests/stream_commit_order.rs              |   1 +
     .../tests/stream_record_clock.rs              |   2 +
     .../storage-postgres/tests/table_snapshot.rs  |  10 +-
     crates/storage-postgres/tests/twi_conflict.rs |  24 +-
     .../tests/vector_control_plane.rs             |   3 +-
     crates/storage-sqlite/Cargo.toml              |   4 +
     .../migrations/0008_bulk_operations.sql       | 126 +++
     crates/storage-sqlite/src/backup.rs           |   4 +
     .../src/bulk_operation_store/claim.rs         | 424 +++++++++
     .../src/bulk_operation_store/files.rs         | 208 +++++
     .../src/bulk_operation_store/index_guard.rs   | 161 ++++
     .../src/bulk_operation_store/mod.rs           | 315 +++++++
     .../src/bulk_operation_store/query.rs         | 278 ++++++
     .../src/bulk_operation_store/stall_tests.rs   | 210 +++++
     .../src/bulk_operation_store/start.rs         | 352 ++++++++
     .../src/bulk_operation_store/tests.rs         | 323 +++++++
     .../src/bulk_operation_store/unreadable.rs    | 108 +++
     crates/storage-sqlite/src/data/data_engine.rs | 160 ++++
     crates/storage-sqlite/src/data/mod.rs         |   2 +
     crates/storage-sqlite/src/data/put_item.rs    | 493 ++++++++--
     .../storage-sqlite/src/data/put_item_tests.rs | 119 +++
     crates/storage-sqlite/src/data/tx_helpers.rs  |   2 +-
     .../src/data/update_item_tests.rs             |   2 +-
     crates/storage-sqlite/src/lib.rs              |   1 +
     crates/storage-sqlite/src/migrations/mod.rs   |   9 +
     crates/storage-sqlite/src/snapshot_engine.rs  |  10 +-
     crates/storage-sqlite/src/update_table.rs     |  25 +
     crates/storage-sqlite/src/vector_bench.rs     |   2 +-
     .../tests/bulk_operation_store.rs             | 347 +++++++
     crates/storage/Cargo.toml                     |   4 +-
     crates/storage/src/bulk_operation_flags.rs    |  41 +
     crates/storage/src/bulk_operation_store.rs    | 531 +++++++++++
     .../src/bulk_operation_store/abandon.rs       |  86 ++
     .../bulk_operation_store_contract/batch.rs    | 328 +++++++
     .../checkpoint.rs                             | 513 +++++++++++
     .../bulk_operation_store_contract/counters.rs | 180 ++++
     .../bulk_operation_store_contract/finish.rs   | 253 ++++++
     .../index_crossing.rs                         | 282 ++++++
     .../initial_failure.rs                        | 517 +++++++++++
     .../bulk_operation_store_contract/lease.rs    | 398 ++++++++
     .../bulk_operation_store_contract/ledger.rs   | 293 ++++++
     .../src/bulk_operation_store_contract/list.rs | 275 ++++++
     .../lock_waits.rs                             | 169 ++++
     .../many_files.rs                             | 190 ++++
     .../src/bulk_operation_store_contract/mod.rs  | 371 ++++++++
     .../bulk_operation_store_contract/purge.rs    | 374 ++++++++
     .../refused_index_creation.rs                 | 222 +++++
     .../bulk_operation_store_contract/report.rs   | 311 +++++++
     .../run_context.rs                            | 100 +++
     .../bulk_operation_store_contract/start.rs    | 568 ++++++++++++
     .../table_deleting.rs                         | 139 +++
     .../unreadable.rs                             | 283 ++++++
     .../write_owner.rs                            | 448 +++++++++
     crates/storage/src/error.rs                   |  51 ++
     crates/storage/src/lib.rs                     | 250 +++++-
     crates/storage/src/migrations/registry.rs     |   7 +-
     crates/storage/src/snapshot_conformance.rs    |   2 +
     docs/schema/postgres.txt                      |  77 ++
     docs/schema/sqlite.txt                        |   9 +
     129 files changed, 17935 insertions(+), 714 deletions(-)
     create mode 100644 crates/engine/src/put_item/payload.rs
     create mode 100644 crates/engine/src/put_item/payload/tests.rs
     create mode 100644 crates/storage-cassandra/src/bulk_operation_store.rs
     create mode 100644 crates/storage-mongodb/src/bulk_operation_store.rs
     create mode 100644 crates/storage-postgres/data_migrations/0008_bulk_operations.sql
     create mode 100644 crates/storage-postgres/migrations/0008_bulk_operations.sql
     create mode 100644 crates/storage-postgres/src/bulk_operation_store/claim.rs
     create mode 100644 crates/storage-postgres/src/bulk_operation_store/files.rs
     create mode 100644 crates/storage-postgres/src/bulk_operation_store/index_guard.rs
     create mode 100644 crates/storage-postgres/src/bulk_operation_store/mod.rs
     create mode 100644 crates/storage-postgres/src/bulk_operation_store/pg_tests.rs
     create mode 100644 crates/storage-postgres/src/bulk_operation_store/query.rs
     create mode 100644 crates/storage-postgres/src/bulk_operation_store/start.rs
     create mode 100644 crates/storage-postgres/src/bulk_operation_store/unreadable.rs
     create mode 100644 crates/storage-postgres/src/data/bulk_row_outcomes.rs
     create mode 100644 crates/storage-postgres/src/data/put_item/batch.rs
     create mode 100644 crates/storage-postgres/tests/bulk_operation_control_plane.rs
     create mode 100644 crates/storage-postgres/tests/bulk_operation_store.rs
     create mode 100644 crates/storage-postgres/tests/bulk_operation_store/locks.rs
     create mode 100644 crates/storage-postgres/tests/bulk_operation_store/row_faults.rs
     create mode 100644 crates/storage-postgres/tests/gsi_bulk_batch.rs
     create mode 100644 crates/storage-postgres/tests/gsi_bulk_batch/ledger.rs
     create mode 100644 crates/storage-sqlite/migrations/0008_bulk_operations.sql
     create mode 100644 crates/storage-sqlite/src/bulk_operation_store/claim.rs
     create mode 100644 crates/storage-sqlite/src/bulk_operation_store/files.rs
     create mode 100644 crates/storage-sqlite/src/bulk_operation_store/index_guard.rs
     create mode 100644 crates/storage-sqlite/src/bulk_operation_store/mod.rs
     create mode 100644 crates/storage-sqlite/src/bulk_operation_store/query.rs
     create mode 100644 crates/storage-sqlite/src/bulk_operation_store/stall_tests.rs
     create mode 100644 crates/storage-sqlite/src/bulk_operation_store/start.rs
     create mode 100644 crates/storage-sqlite/src/bulk_operation_store/tests.rs
     create mode 100644 crates/storage-sqlite/src/bulk_operation_store/unreadable.rs
     create mode 100644 crates/storage-sqlite/src/data/put_item_tests.rs
     create mode 100644 crates/storage-sqlite/tests/bulk_operation_store.rs
     create mode 100644 crates/storage/src/bulk_operation_flags.rs
     create mode 100644 crates/storage/src/bulk_operation_store.rs
     create mode 100644 crates/storage/src/bulk_operation_store/abandon.rs
     create mode 100644 crates/storage/src/bulk_operation_store_contract/batch.rs
     create mode 100644 crates/storage/src/bulk_operation_store_contract/checkpoint.rs
     create mode 100644 crates/storage/src/bulk_operation_store_contract/counters.rs
     create mode 100644 crates/storage/src/bulk_operation_store_contract/finish.rs
     create mode 100644 crates/storage/src/bulk_operation_store_contract/index_crossing.rs
     create mode 100644 crates/storage/src/bulk_operation_store_contract/initial_failure.rs
     create mode 100644 crates/storage/src/bulk_operation_store_contract/lease.rs
     create mode 100644 crates/storage/src/bulk_operation_store_contract/ledger.rs
     create mode 100644 crates/storage/src/bulk_operation_store_contract/list.rs
     create mode 100644 crates/storage/src/bulk_operation_store_contract/lock_waits.rs
     create mode 100644 crates/storage/src/bulk_operation_store_contract/many_files.rs
     create mode 100644 crates/storage/src/bulk_operation_store_contract/mod.rs
     create mode 100644 crates/storage/src/bulk_operation_store_contract/purge.rs
     create mode 100644 crates/storage/src/bulk_operation_store_contract/refused_index_creation.rs
     create mode 100644 crates/storage/src/bulk_operation_store_contract/report.rs
     create mode 100644 crates/storage/src/bulk_operation_store_contract/run_context.rs
     create mode 100644 crates/storage/src/bulk_operation_store_contract/start.rs
     create mode 100644 crates/storage/src/bulk_operation_store_contract/table_deleting.rs
     create mode 100644 crates/storage/src/bulk_operation_store_contract/unreadable.rs
     create mode 100644 crates/storage/src/bulk_operation_store_contract/write_owner.rs
    
    diff --git a/crates/core/src/validation/mod.rs b/crates/core/src/validation/mod.rs
    index a849f2c95..7681f67f5 100755
    --- a/crates/core/src/validation/mod.rs
    +++ b/crates/core/src/validation/mod.rs
    @@ -12,8 +12,8 @@ use crate::error::{DynamoDbError, ErrorMessageKey, error_message};
     use crate::limits::LimitsConfig;
     use crate::types::{
         AttributeDefinition, AttributeValue, BillingMode, CreateTableInput, DeleteItemInput,
    -    GetItemInput, Item, KeySchemaElement, KeyType, MAX_VECTOR_INDEXES_PER_TABLE, PutItemInput,
    -    ReturnValues, ScalarAttributeType, Select, UpdateItemInput, VECTOR_INDEX_COUNT_LIMIT_CREATE,
    +    GetItemInput, Item, KeySchemaElement, KeyType, MAX_VECTOR_INDEXES_PER_TABLE, ReturnValues,
    +    ScalarAttributeType, Select, UpdateItemInput, VECTOR_INDEX_COUNT_LIMIT_CREATE,
         VECTOR_INDEX_REQUIRES_PAY_PER_REQUEST, item_size_bytes,
     };
     
    @@ -1059,49 +1059,6 @@ fn validate_lsi_count(
         Ok(())
     }
     
    -/// Validate a `PutItem` request.
    -///
    -/// Checks table name, item size, key presence/types, and `ReturnValues`.
    -///
    -/// # Errors
    -///
    -/// Returns `DynamoDbError::ValidationException` for invalid input.
    -pub fn validate_put_item(
    -    input: &PutItemInput,
    -    limits: &LimitsConfig,
    -    key_schema: &[KeySchemaElement],
    -    attr_defs: &[AttributeDefinition],
    -) -> Result<(), DynamoDbError> {
    -    validate_table_name(&input.table_name, limits)?;
    -
    -    // REQ-DATA-001: PutItem only supports NONE and ALL_OLD
    -    if !matches!(
    -        input.return_values,
    -        ReturnValues::None | ReturnValues::AllOld
    -    ) {
    -        return Err(DynamoDbError::ValidationException(
    -            "Return values set to invalid value".to_owned(),
    -        ));
    -    }
    -
    -    // The attribute-name size check is not here: the service reports it
    -    // ahead of the table lookup, so the handler runs it before this.
    -    validate_item_keys(&input.item, key_schema, attr_defs)?;
    -    validate_item_numbers(&input.item)?;
    -    validate_item_nesting_depth(&input.item)?;
    -
    -    let size = item_size_bytes(&input.item);
    -    if size > limits.max_item_size_bytes {
    -        return Err(DynamoDbError::ValidationException(
    -            "Item size has exceeded the maximum allowed size".to_owned(),
    -        ));
    -    }
    -
    -    validate_key_sizes(&input.item, key_schema, limits)?;
    -
    -    Ok(())
    -}
    -
     /// Validate a `GetItem` request.
     ///
     /// Checks table name and key presence/types.
    @@ -1446,6 +1403,49 @@ fn nested_attribute_name_too_large(value: &AttributeValue, limits: &LimitsConfig
         }
     }
     
    +/// Which key check of [`validate_item_keys`] fired for a rejected item.
    +///
    +/// Callers that decide behaviour by the fired check (the bulk put error
    +/// report embeds the submitted image only for the missing-key class) match
    +/// on this instead of reconstructing and comparing the message text, so a
    +/// wording change here fails a compile-time match or a kind assertion
    +/// rather than silently flipping the caller's rule.
    +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
    +pub enum ItemKeyFault {
    +    /// A key attribute is absent from the item.
    +    MissingKey,
    +    /// A key attribute is present with the wrong type.
    +    TypeMismatch,
    +}
    +
    +/// Validate that an item contains all required key attributes with correct
    +/// types, reporting which check fired on rejection.
    +///
    +/// # Errors
    +///
    +/// Returns the fired [`ItemKeyFault`] and the `DynamoDbError::ValidationException`
    +/// if a key attribute is missing or has the wrong type.
    +pub fn validate_item_keys_reporting(
    +    item: &Item,
    +    key_schema: &[KeySchemaElement],
    +    attr_defs: &[AttributeDefinition],
    +) -> Result<(), (ItemKeyFault, DynamoDbError)> {
    +    for ks in key_schema {
    +        let Some(value) = item.get(&ks.attribute_name) else {
    +            return Err((
    +                ItemKeyFault::MissingKey,
    +                DynamoDbError::ValidationException(format!(
    +                    "One or more parameter values were invalid: Missing the key {} in the item",
    +                    ks.attribute_name
    +                )),
    +            ));
    +        };
    +        validate_key_attribute_type(&ks.attribute_name, value, attr_defs)
    +            .map_err(|error| (ItemKeyFault::TypeMismatch, error))?;
    +    }
    +    Ok(())
    +}
    +
     /// Validate that an item contains all required key attributes with correct types.
     ///
     /// # Errors
    @@ -1456,16 +1456,7 @@ pub fn validate_item_keys(
         key_schema: &[KeySchemaElement],
         attr_defs: &[AttributeDefinition],
     ) -> Result<(), DynamoDbError> {
    -    for ks in key_schema {
    -        let value = item.get(&ks.attribute_name).ok_or_else(|| {
    -            DynamoDbError::ValidationException(format!(
    -                "One or more parameter values were invalid: Missing the key {} in the item",
    -                ks.attribute_name
    -            ))
    -        })?;
    -        validate_key_attribute_type(&ks.attribute_name, value, attr_defs)?;
    -    }
    -    Ok(())
    +    validate_item_keys_reporting(item, key_schema, attr_defs).map_err(|(_, error)| error)
     }
     
     /// Validate that a key map contains exactly the key attributes and nothing else.
    diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
    index 9f58bea6e..3ed414b3e 100755
    --- a/crates/engine/Cargo.toml
    +++ b/crates/engine/Cargo.toml
    @@ -17,8 +17,47 @@ tracing = { workspace = true }
     base64 = { workspace = true }
     uuid = { workspace = true }
     crc32fast = { workspace = true }
    +flate2 = "1"
    +ruzstd = "0.9.1"
     hmac = "0.12"
     sha2 = "0.10"
    +md-5 = "0.10"
     hex = "0.4"
    +rand = { workspace = true }
     tokio = { workspace = true }
     futures = { workspace = true }
    +# The bulk put S3 store: the shared S3 clients and the server's AWS
    +# credentials, and the S3 client types the store's calls use.
    +extenddb-s3 = { workspace = true }
    +aws-sdk-s3 = { workspace = true }
    +
    +# The bulk input and report layout is POSIX-only: the component walk and
    +# handle-relative write discipline need openat/mkdirat/renameat/fcntl with
    +# O_NOFOLLOW/O_NONBLOCK, all through rustix without unsafe. Pinned exactly
    +# to the version already in Cargo.lock.
    +[target.'cfg(unix)'.dependencies]
    +rustix = { version = "=1.1.4", features = ["fs"] }
    +
    +[dev-dependencies]
    +# The runner behaviour tests drive a real storage engine. The engine crate
    +# is backend-agnostic at runtime (it receives Arc), so a
    +# concrete backend appears here only as a test dependency; the sqlite
    +# in-memory database gives each test an isolated engine with no file on
    +# disk.
    +extenddb-storage-sqlite = { workspace = true, features = ["memory"] }
    +# The runner cases that must hold on both backends also run on a throwaway
    +# PostgreSQL database when EXTENDDB_TEST_PG_CONNECTION_STRING names a
    +# server; sqlx creates and drops that database.
    +extenddb-storage-postgres = { workspace = true }
    +sqlx = { workspace = true }
    +# `test-util` gives the write pacer tests a paused clock, so a one-second
    +# bucket is measured without a one-second sleep.
    +tokio = { workspace = true, features = ["test-util"] }
    +# The S3 store's fault tests answer canned S3 responses through the SDK's
    +# replay HTTP client, and build SDK configurations without reading the
    +# environment.
    +aws-smithy-http-client = { workspace = true, features = ["test-util"] }
    +aws-config = { workspace = true }
    +# The test hooks of the shared S3 clients (`S3Clients::new`, `with_retry`,
    +# `RetryPolicy::NONE`).
    +extenddb-s3 = { workspace = true, features = ["test-util"] }
    diff --git a/crates/engine/src/batch_write_item.rs b/crates/engine/src/batch_write_item.rs
    index 71cd354f8..940fb1877 100755
    --- a/crates/engine/src/batch_write_item.rs
    +++ b/crates/engine/src/batch_write_item.rs
    @@ -146,6 +146,7 @@ pub async fn handle_batch_write_item(
                             None,
                             &empty_maps,
                             stream.as_ref(),
    +                        None,
                         )
                         .await
                         .map_err(storage_err_to_dynamo)?;
    diff --git a/crates/engine/src/import_export.rs b/crates/engine/src/import_export.rs
    index a67892a9d..563c9bae1 100755
    --- a/crates/engine/src/import_export.rs
    +++ b/crates/engine/src/import_export.rs
    @@ -156,7 +156,7 @@ pub async fn handle_import_table(
     
             let maps = extenddb_core::expression::ExpressionMaps::default();
             ctx.storage
    -            .put_item(&key_info, item, false, None, &maps, None)
    +            .put_item(&key_info, item, false, None, &maps, None, None)
                 .await
                 .map_err(storage_err_to_dynamo)?;
             imported_count += 1;
    diff --git a/crates/engine/src/put_item.rs b/crates/engine/src/put_item.rs
    index 15efcc9b3..1779daf1a 100755
    --- a/crates/engine/src/put_item.rs
    +++ b/crates/engine/src/put_item.rs
    @@ -13,11 +13,14 @@ use extenddb_core::types::{PutItemInput, PutItemOutput, ReturnValues, item_size_
     use crate::OperationContext;
     use crate::capacity_helpers;
     use crate::create_table::{storage_err_to_dynamo, storage_err_to_dynamo_with_ccf};
    -use crate::expression_helpers::resolve_condition;
     use crate::serialize_output;
    -use crate::stream_capture;
     use crate::{DispatchMetrics, DispatchResult};
     
    +mod payload;
    +pub(crate) use payload::{
    +    PutPayloadFault, apply_put, resolve_put_condition, stream_capture_for, validate_put_payload,
    +};
    +
     /// Handle a `PutItem` request.
     ///
     /// Validates the input, writes the item, and returns the old item if requested.
    @@ -147,7 +150,7 @@ pub async fn handle_put_item(
         // Validate expressions/`Expected` before the existence check,
         // so a malformed request to a missing table returns ValidationException,
         // not ResourceNotFoundException. Key/item-content checks stay after.
    -    let (condition, maps) = resolve_condition(
    +    let (condition, maps) = resolve_put_condition(
             input.condition_expression.as_deref(),
             input.expression_attribute_names.as_ref(),
             input.expression_attribute_values.as_ref(),
    @@ -156,51 +159,12 @@ pub async fn handle_put_item(
             &ctx.limits,
         )?;
     
    -    if input.expected.is_none()
    -        || input
    -            .expected
    -            .as_ref()
    -            .is_some_and(std::collections::HashMap::is_empty)
    -    {
    -        let exprs: Vec<&extenddb_core::expression::Expr> = condition.iter().collect();
    -        extenddb_core::expression::validate_unused_attributes(
    -            &maps.names,
    -            &maps.values,
    -            &exprs,
    -            &[],
    -            &std::collections::HashSet::new(),
    -            &std::collections::HashSet::new(),
    -        )?;
    -    }
    -
         let key_info = ctx
             .table_key_info(&input.table_name)
             .await
             .map_err(storage_err_to_dynamo)?;
     
    -    extenddb_core::validation::validate_put_item(
    -        &input,
    -        &ctx.limits,
    -        &key_info.key_schema,
    -        &key_info.attribute_definitions,
    -    )?;
    -
    -    // The nesting-depth limit applies to every ExpressionAttributeValue on the
    -    // single-item write paths, condition-only placeholders included: measured
    -    // 2026-08-24 (us-east-1), a 32-level value referenced solely by a
    -    // ConditionExpression is rejected on PutItem, DeleteItem, and UpdateItem
    -    // alike, while 31 levels is accepted. TransactWriteItems deliberately does
    -    // NOT get this check: the same 32-level condition-only value is accepted
    -    // inside a transaction on every sub-op shape (also measured).
    -    if let Some(values) = &input.expression_attribute_values {
    -        extenddb_core::validation::validate_attribute_values_nesting_depth(values.values())?;
    -    }
    -
    -    extenddb_core::validation::validate_vector_write(
    -        &input.item,
    -        &key_info.vector_indexes,
    -        &key_info.attribute_definitions,
    -    )?;
    +    validate_put_payload(&input.item, &key_info, &ctx.limits).map_err(|fault| fault.error)?;
     
         let return_old = input.return_values == ReturnValues::AllOld;
         let capacity_requested =
    @@ -223,36 +187,26 @@ pub async fn handle_put_item(
             key_info.has_lsi,
         );
     
    -    // Check if streams are enabled (need old item for stream record).
    -    let view_type = stream_capture::stream_view_type(&key_info);
    -
    -    let stream = view_type.map(|vt| extenddb_storage::StreamCapture {
    -        view_type: vt,
    -        user_identity: None,
    -        region: ctx.region.clone(),
    -    });
         // Capacity uses the larger old/new base item and must include index entries
         // deleted by an overwrite, so request the old item whenever capacity is
    -    // requested. Indexed writes already read it inside the storage transaction.
    -    let need_old_for_stream = stream.is_some();
    -    let need_old_for_capacity = capacity_requested;
    -
    -    let old_item = ctx
    -        .storage
    -        .put_item(
    -            &key_info,
    -            input.item,
    -            return_old || need_old_for_stream || need_old_for_capacity,
    -            condition.as_ref(),
    -            &maps,
    -            stream.as_ref(),
    -        )
    -        .await
    -        .map_err(|e| {
    -            storage_err_to_dynamo_with_ccf(e, input.return_values_on_condition_check_failure)
    -        })?;
    +    // requested. Indexed writes already read it inside the storage transaction;
    +    // the stream-record fetch is `apply_put`'s own.
    +    let old_item = apply_put(
    +        ctx.storage.as_ref(),
    +        &ctx.region,
    +        &key_info,
    +        input.item,
    +        condition.as_ref(),
    +        &maps,
    +        return_old || capacity_requested,
    +        None,
    +    )
    +    .await
    +    .map_err(|e| {
    +        storage_err_to_dynamo_with_ccf(e, input.return_values_on_condition_check_failure)
    +    })?;
     
    -    // Stream records are now captured atomically within the storage transaction.
    +    // Stream records are captured atomically within the storage transaction.
     
         let old_item_bytes = old_item.as_ref().map_or(0, item_size_bytes);
         let wcu = capacity_helpers::write_capacity_units(new_item_bytes.max(old_item_bytes));
    diff --git a/crates/engine/src/put_item/payload.rs b/crates/engine/src/put_item/payload.rs
    new file mode 100644
    index 000000000..79cce9d0f
    --- /dev/null
    +++ b/crates/engine/src/put_item/payload.rs
    @@ -0,0 +1,248 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Payload validation and single-item write helpers shared by
    +//! `handle_put_item` and the bulk put runner.
    +
    +use std::collections::{HashMap, HashSet};
    +use std::sync::Arc;
    +
    +use extenddb_core::error::DynamoDbError;
    +use extenddb_core::expression::{Expr, ExpressionMaps, validate_unused_attributes};
    +use extenddb_core::limits::LimitsConfig;
    +use extenddb_core::types::{
    +    AttributeValue, ConditionalOperator, ExpectedAttributeValue, Item, TableKeyInfo,
    +    item_size_bytes,
    +};
    +use extenddb_storage::error::StorageError;
    +use extenddb_storage::{BulkRowMarker, StorageEngine, StreamCapture};
    +
    +use crate::expression_helpers::resolve_condition;
    +use crate::stream_capture;
    +
    +/// Which check of [`validate_put_payload`] fired for a rejected item.
    +///
    +/// `handle_put_item` discards it; the bulk put error report decides by it
    +/// whether the record embeds the submitted image. Matching on the kind
    +/// instead of the message text means a wording change in the validation
    +/// layer fails a kind assertion rather than silently flipping the rule.
    +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
    +pub(crate) enum PutPayloadFault {
    +    /// A key attribute is absent from the item.
    +    MissingKey,
    +    /// A key attribute is present with the wrong type.
    +    KeyTypeMismatch,
    +    /// A secondary index key attribute has the wrong type or is an empty
    +    /// string. Reported by the bulk admission step only; `handle_put_item`
    +    /// meets these in storage.
    +    IndexKey,
    +    /// A primary-key attribute holds an empty string or an empty binary
    +    /// value. Told apart from the other key rules because the bulk record
    +    /// for it carries the submitted image (measured on the service),
    +    /// where the base-key type mismatch does not.
    +    KeyEmptyValue,
    +    /// The item is over the item size limit.
    +    Oversized,
    +    /// An attribute value nests deeper than the item nesting limit.
    +    NestingDepth,
    +    /// Any other item check.
    +    Other,
    +}
    +
    +/// A rejected put payload: the check that fired and the error `PutItem`
    +/// answers for the same item.
    +#[derive(Debug)]
    +pub(crate) struct PutPayloadError {
    +    pub kind: PutPayloadFault,
    +    pub error: DynamoDbError,
    +}
    +
    +impl PutPayloadError {
    +    fn other(error: DynamoDbError) -> Self {
    +        Self {
    +            kind: PutPayloadFault::Other,
    +            error,
    +        }
    +    }
    +}
    +
    +/// The fault a primary-key value check reports: the empty-value rule is
    +/// named so the bulk record can attach the submitted image to it, which the
    +/// byte-size rule does not get. Both rules share one validator, so the rule
    +/// is read from the message it produced; the bulk row test for the
    +/// empty-string key asserts that message, so a reword of the
    +/// validator fails there rather than silently dropping the image.
    +fn key_fault(error: DynamoDbError) -> PutPayloadError {
    +    if error.to_string().contains("cannot contain an empty") {
    +        PutPayloadError {
    +            kind: PutPayloadFault::KeyEmptyValue,
    +            error,
    +        }
    +    } else {
    +        PutPayloadError::other(error)
    +    }
    +}
    +
    +/// Resolve the condition fields of a put: `resolve_condition`, the
    +/// unused-attribute check (skipped when a non-empty `Expected` supplies the
    +/// condition, whose placeholders are merged rather than referenced), then
    +/// the nesting-depth check on the expression attribute values. One sequence
    +/// for `handle_put_item` and the bulk put runner, so a row's condition
    +/// faults carry the messages `PutItem` answers.
    +///
    +/// The nesting-depth limit applies to every `ExpressionAttributeValue` on
    +/// the single-item write paths, condition-only placeholders included:
    +/// measured on the service (us-east-1), a 32-level value referenced solely by a
    +/// `ConditionExpression` is rejected on `PutItem`, `DeleteItem`, and
    +/// `UpdateItem` alike, while 31 levels is accepted. `TransactWriteItems`
    +/// deliberately does NOT get this check: the same 32-level condition-only
    +/// value is accepted inside a transaction on every sub-op shape (also
    +/// measured).
    +///
    +/// # Errors
    +///
    +/// Returns `DynamoDbError::ValidationException` for expression syntax,
    +/// unused-attribute, and nesting-depth faults.
    +pub(crate) fn resolve_put_condition(
    +    expression: Option<&str>,
    +    names: Option<&HashMap>,
    +    values: Option<&HashMap>,
    +    expected: Option<&HashMap>,
    +    conditional_operator: Option,
    +    limits: &LimitsConfig,
    +) -> Result<(Option, ExpressionMaps), DynamoDbError> {
    +    let (condition, maps) = resolve_condition(
    +        expression,
    +        names,
    +        values,
    +        expected,
    +        conditional_operator,
    +        limits,
    +    )?;
    +    if expected.is_none_or(HashMap::is_empty) {
    +        let exprs: Vec<&Expr> = condition.iter().collect();
    +        validate_unused_attributes(
    +            &maps.names,
    +            &maps.values,
    +            &exprs,
    +            &[],
    +            &HashSet::new(),
    +            &HashSet::new(),
    +        )?;
    +    }
    +    if let Some(values) = values {
    +        extenddb_core::validation::validate_attribute_values_nesting_depth(values.values())?;
    +    }
    +    Ok((condition, maps))
    +}
    +
    +/// Validate one item for a put: attribute name sizes, keys, numbers,
    +/// nesting depth, item size, and key sizes, in that order, then the vector
    +/// write check, reporting which check fired. One sequence for
    +/// `handle_put_item` and the bulk put runner, so a row's item faults carry
    +/// the messages `PutItem` answers. The name sizes come first because
    +/// `PutItem` refuses a long name ahead of its table lookup, with the
    +/// standalone request's wording; `handle_put_item` has run that check
    +/// before it calls this.
    +///
    +/// # Errors
    +///
    +/// Returns the fired [`PutPayloadFault`] and the error `PutItem` answers
    +/// for the same item.
    +pub(crate) fn validate_put_payload(
    +    item: &Item,
    +    key_info: &TableKeyInfo,
    +    limits: &LimitsConfig,
    +) -> Result<(), PutPayloadError> {
    +    use extenddb_core::validation::{self, AttributeNameRequestForm, ItemKeyFault};
    +    validation::validate_attribute_name_sizes(item, limits, AttributeNameRequestForm::SingleItem)
    +        .map_err(PutPayloadError::other)?;
    +    validation::validate_item_keys_reporting(
    +        item,
    +        &key_info.key_schema,
    +        &key_info.attribute_definitions,
    +    )
    +    .map_err(|(fault, error)| PutPayloadError {
    +        kind: match fault {
    +            ItemKeyFault::MissingKey => PutPayloadFault::MissingKey,
    +            ItemKeyFault::TypeMismatch => PutPayloadFault::KeyTypeMismatch,
    +        },
    +        error,
    +    })?;
    +    validation::validate_item_numbers(item).map_err(PutPayloadError::other)?;
    +    validation::validate_item_nesting_depth(item).map_err(|error| PutPayloadError {
    +        kind: PutPayloadFault::NestingDepth,
    +        error,
    +    })?;
    +    if item_size_bytes(item) > limits.max_item_size_bytes {
    +        return Err(PutPayloadError {
    +            kind: PutPayloadFault::Oversized,
    +            error: DynamoDbError::ValidationException(
    +                "Item size has exceeded the maximum allowed size".to_owned(),
    +            ),
    +        });
    +    }
    +    validation::validate_key_sizes(item, &key_info.key_schema, limits).map_err(key_fault)?;
    +    validation::validate_vector_write(
    +        item,
    +        &key_info.vector_indexes,
    +        &key_info.attribute_definitions,
    +    )
    +    .map_err(PutPayloadError::other)?;
    +    Ok(())
    +}
    +
    +/// The stream capture a write to this table records, when the table's
    +/// stream is enabled: the view type from the key info, no caller identity,
    +/// the server's region.
    +pub(crate) fn stream_capture_for(
    +    key_info: &TableKeyInfo,
    +    region: &Arc,
    +) -> Option {
    +    stream_capture::stream_view_type(key_info).map(|vt| StreamCapture {
    +        view_type: vt,
    +        user_identity: None,
    +        region: Arc::clone(region),
    +    })
    +}
    +
    +/// Apply one put to storage the way `handle_put_item` does: the stream
    +/// capture is built from the table's stream specification and the region,
    +/// with no user identity, and inserted in the same transaction as the data
    +/// write; the bulk row marker is passed through to the row-outcome ledger.
    +/// The replaced item is fetched when the caller asks for it or a stream
    +/// record needs it.
    +///
    +/// # Errors
    +///
    +/// Returns the storage error unmapped; each caller owns its own mapping
    +/// (`handle_put_item` maps to `DynamoDbError`, the bulk runner matches the
    +/// variants directly).
    +#[allow(clippy::too_many_arguments)] // the write's inputs, per the shared core contract
    +pub(crate) async fn apply_put(
    +    storage: &dyn StorageEngine,
    +    region: &Arc,
    +    key_info: &TableKeyInfo,
    +    item: Item,
    +    condition: Option<&Expr>,
    +    maps: &ExpressionMaps,
    +    return_old: bool,
    +    marker: Option<&BulkRowMarker>,
    +) -> Result, StorageError> {
    +    let stream = stream_capture_for(key_info, region);
    +    let fetch_old = return_old || stream.is_some();
    +    storage
    +        .put_item(
    +            key_info,
    +            item,
    +            fetch_old,
    +            condition,
    +            maps,
    +            stream.as_ref(),
    +            marker,
    +        )
    +        .await
    +}
    +
    +#[cfg(test)]
    +mod tests;
    diff --git a/crates/engine/src/put_item/payload/tests.rs b/crates/engine/src/put_item/payload/tests.rs
    new file mode 100644
    index 000000000..af627fd1a
    --- /dev/null
    +++ b/crates/engine/src/put_item/payload/tests.rs
    @@ -0,0 +1,419 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +use extenddb_core::types::{
    +    AttributeDefinition, CreateTableInput, DescribeStreamInput, KeySchemaElement, KeyType,
    +    ScalarAttributeType, StreamEventName,
    +};
    +use extenddb_storage::server_components::ServerComponentsOptions;
    +use extenddb_storage::{BulkRowMarker, RowOutcome, StorageEngine};
    +use serde_json::json;
    +
    +const ACCOUNT: &str = "123456789012";
    +const REGION: &str = "us-east-1";
    +
    +fn limits() -> LimitsConfig {
    +    LimitsConfig::default()
    +}
    +
    +fn key_info() -> TableKeyInfo {
    +    TableKeyInfo {
    +        key_schema: vec![
    +            KeySchemaElement {
    +                attribute_name: "pk".to_owned(),
    +                key_type: KeyType::Hash,
    +            },
    +            KeySchemaElement {
    +                attribute_name: "sk".to_owned(),
    +                key_type: KeyType::Range,
    +            },
    +        ],
    +        attribute_definitions: vec![
    +            AttributeDefinition {
    +                attribute_name: "pk".to_owned(),
    +                attribute_type: ScalarAttributeType::S,
    +            },
    +            AttributeDefinition {
    +                attribute_name: "sk".to_owned(),
    +                attribute_type: ScalarAttributeType::S,
    +            },
    +        ],
    +        ..TableKeyInfo::default()
    +    }
    +}
    +
    +fn string_item(entries: &[(&str, &str)]) -> Item {
    +    let mut item = Item::new();
    +    for (name, value) in entries {
    +        item.insert((*name).to_owned(), AttributeValue::S((*value).to_owned()));
    +    }
    +    item
    +}
    +
    +/// An attribute value nested `depth` levels deep in lists.
    +fn nested_value(depth: usize) -> AttributeValue {
    +    let mut value = AttributeValue::S("leaf".to_owned());
    +    for _ in 0..depth {
    +        value = AttributeValue::L(vec![value]);
    +    }
    +    value
    +}
    +
    +// resolve_put_condition: the resolve, unused-attribute, nesting order.
    +
    +#[test]
    +fn a_valid_condition_resolves_to_an_expression() {
    +    let mut values = HashMap::new();
    +    values.insert(":v".to_owned(), AttributeValue::S("a".to_owned()));
    +    let (condition, _maps) =
    +        resolve_put_condition(Some("pk = :v"), None, Some(&values), None, None, &limits())
    +            .expect("the condition resolves");
    +    assert!(condition.is_some());
    +}
    +
    +#[test]
    +fn an_unused_expression_value_is_rejected() {
    +    let mut values = HashMap::new();
    +    values.insert(":v".to_owned(), AttributeValue::S("a".to_owned()));
    +    let error = resolve_put_condition(
    +        Some("attribute_not_exists(pk)"),
    +        None,
    +        Some(&values),
    +        None,
    +        None,
    +        &limits(),
    +    )
    +    .expect_err("the unused value is rejected");
    +    assert!(
    +        error.to_string().contains("unused"),
    +        "unexpected message: {error}"
    +    );
    +}
    +
    +#[test]
    +fn an_expression_value_past_the_nesting_limit_is_rejected() {
    +    let mut values = HashMap::new();
    +    values.insert(":v".to_owned(), nested_value(32));
    +    let error = resolve_put_condition(Some("v = :v"), None, Some(&values), None, None, &limits())
    +        .expect_err("the over-deep value is rejected");
    +    assert!(
    +        error.to_string().contains("nested"),
    +        "unexpected message: {error}"
    +    );
    +}
    +
    +#[test]
    +fn an_unused_value_is_reported_before_its_nesting_depth() {
    +    // The unused-attribute check runs before the nesting-depth check,
    +    // so a value that is both unused and over-deep reports the unused
    +    // message.
    +    let mut values = HashMap::new();
    +    values.insert(":v".to_owned(), nested_value(32));
    +    let error = resolve_put_condition(
    +        Some("attribute_not_exists(pk)"),
    +        None,
    +        Some(&values),
    +        None,
    +        None,
    +        &limits(),
    +    )
    +    .expect_err("the row is rejected");
    +    assert!(
    +        error.to_string().contains("unused"),
    +        "unexpected message: {error}"
    +    );
    +}
    +
    +// validate_put_payload: the fired kinds and their messages.
    +
    +#[test]
    +fn a_missing_key_fires_the_missing_key_kind() {
    +    let fault = validate_put_payload(&string_item(&[("pk", "a")]), &key_info(), &limits())
    +        .expect_err("the item is rejected");
    +    assert_eq!(fault.kind, PutPayloadFault::MissingKey);
    +    assert_eq!(
    +        fault.error.to_string(),
    +        "One or more parameter values were invalid: Missing the key sk in the item"
    +    );
    +}
    +
    +#[test]
    +fn a_key_type_mismatch_fires_the_mismatch_kind() {
    +    let mut item = string_item(&[("sk", "b")]);
    +    item.insert("pk".to_owned(), AttributeValue::N("1".to_owned()));
    +    let fault =
    +        validate_put_payload(&item, &key_info(), &limits()).expect_err("the item is rejected");
    +    assert_eq!(fault.kind, PutPayloadFault::KeyTypeMismatch);
    +    assert_eq!(
    +        fault.error.to_string(),
    +        "One or more parameter values were invalid: Type mismatch for key pk expected: S \
    +         actual: N"
    +    );
    +}
    +
    +#[test]
    +fn a_type_mismatch_fires_before_a_later_missing_key() {
    +    // The schema walk reaches the mismatched first key before the
    +    // absent second key, so the fired kind is the mismatch; the bulk
    +    // error report attaches no image for it.
    +    let mut item = Item::new();
    +    item.insert("pk".to_owned(), AttributeValue::N("1".to_owned()));
    +    let fault =
    +        validate_put_payload(&item, &key_info(), &limits()).expect_err("the item is rejected");
    +    assert_eq!(fault.kind, PutPayloadFault::KeyTypeMismatch);
    +}
    +
    +#[test]
    +fn an_oversized_item_fires_the_oversized_kind() {
    +    let tight = LimitsConfig {
    +        max_item_size_bytes: 8,
    +        ..LimitsConfig::default()
    +    };
    +    let fault = validate_put_payload(
    +        &string_item(&[("pk", "a"), ("sk", "b"), ("v", "cccccccccc")]),
    +        &key_info(),
    +        &tight,
    +    )
    +    .expect_err("the item is rejected");
    +    assert_eq!(fault.kind, PutPayloadFault::Oversized);
    +    assert_eq!(
    +        fault.error.to_string(),
    +        "Item size has exceeded the maximum allowed size"
    +    );
    +}
    +
    +#[test]
    +fn an_overdeep_item_fires_the_nesting_depth_kind() {
    +    let mut item = string_item(&[("pk", "a"), ("sk", "b")]);
    +    item.insert("deep".to_owned(), nested_value(33));
    +    let fault =
    +        validate_put_payload(&item, &key_info(), &limits()).expect_err("the item is rejected");
    +    assert_eq!(fault.kind, PutPayloadFault::NestingDepth);
    +}
    +
    +#[test]
    +fn a_non_key_fault_fires_the_other_kind() {
    +    let mut item = string_item(&[("pk", "a"), ("sk", "b")]);
    +    item.insert("n".to_owned(), AttributeValue::N("not-a-number".to_owned()));
    +    let fault =
    +        validate_put_payload(&item, &key_info(), &limits()).expect_err("the item is rejected");
    +    assert_eq!(fault.kind, PutPayloadFault::Other);
    +}
    +
    +#[test]
    +fn a_valid_item_passes() {
    +    assert!(
    +        validate_put_payload(
    +            &string_item(&[("pk", "a"), ("sk", "b")]),
    +            &key_info(),
    +            &limits()
    +        )
    +        .is_ok()
    +    );
    +}
    +
    +// apply_put: stream capture and marker passthrough, against the
    +// in-memory SQLite engine.
    +
    +async fn engine() -> Arc {
    +    let config = extenddb_storage_sqlite::SqliteConfig {
    +        path: ":memory:".to_owned(),
    +        pool_size: 2,
    +    };
    +    let components = (extenddb_storage_sqlite::backend().server_components)(
    +        &config,
    +        REGION,
    +        ServerComponentsOptions::default(),
    +    )
    +    .await
    +    .expect("server components");
    +    components
    +        .catalog_store
    +        .set_setting("control_plane_delay_seconds", "0")
    +        .await
    +        .expect("set control plane delay");
    +    components
    +        .catalog_store
    +        .create_account(ACCOUNT, "put-item-tests")
    +        .await
    +        .expect("create account");
    +    components.engine
    +}
    +
    +async fn create_table(storage: &Arc, name: &str, stream: bool) {
    +    let mut body = json!({
    +        "TableName": name,
    +        "KeySchema": [
    +            {"AttributeName": "pk", "KeyType": "HASH"},
    +            {"AttributeName": "sk", "KeyType": "RANGE"}
    +        ],
    +        "AttributeDefinitions": [
    +            {"AttributeName": "pk", "AttributeType": "S"},
    +            {"AttributeName": "sk", "AttributeType": "S"}
    +        ],
    +        "BillingMode": "PAY_PER_REQUEST"
    +    });
    +    if stream {
    +        body["StreamSpecification"] = json!({
    +            "StreamEnabled": true,
    +            "StreamViewType": "NEW_AND_OLD_IMAGES"
    +        });
    +    }
    +    let input: CreateTableInput = serde_json::from_value(body).expect("create table input");
    +    storage
    +        .create_table(ACCOUNT, input)
    +        .await
    +        .expect("create table");
    +}
    +
    +#[tokio::test]
    +async fn apply_put_captures_a_stream_record_the_way_handle_put_item_does() {
    +    let storage = engine().await;
    +    create_table(&storage, "streamed", true).await;
    +    let key_info = storage
    +        .table_key_info(ACCOUNT, "streamed")
    +        .await
    +        .expect("key info");
    +    let region: Arc = Arc::from(REGION);
    +
    +    apply_put(
    +        storage.as_ref(),
    +        ®ion,
    +        &key_info,
    +        string_item(&[("pk", "a"), ("sk", "b")]),
    +        None,
    +        &ExpressionMaps::default(),
    +        false,
    +        None,
    +    )
    +    .await
    +    .expect("the write applies");
    +
    +    let (summaries, _) = storage
    +        .list_streams(ACCOUNT, Some("streamed"), 10, None)
    +        .await
    +        .expect("list streams");
    +    let stream_arn = summaries.first().expect("one stream").stream_arn.clone();
    +    let description = storage
    +        .describe_stream(
    +            ACCOUNT,
    +            &DescribeStreamInput {
    +                stream_arn,
    +                limit: None,
    +                exclusive_start_shard_id: None,
    +            },
    +        )
    +        .await
    +        .expect("describe stream");
    +    // The record lands on the shard the partition key hashes to, so
    +    // collect across every shard.
    +    let mut records = Vec::new();
    +    for shard in &description.shards {
    +        let (shard_records, _) = storage
    +            .get_stream_records(ACCOUNT, &description.stream_arn, &shard.shard_id, None, 100)
    +            .await
    +            .expect("get records");
    +        records.extend(shard_records);
    +    }
    +
    +    assert_eq!(records.len(), 1, "one record for one write");
    +    let record = &records[0];
    +    assert_eq!(record.event_name, StreamEventName::Insert);
    +    assert_eq!(record.aws_region, REGION);
    +    assert!(record.user_identity.is_none(), "no user identity");
    +    assert!(record.dynamodb.new_image.is_some(), "NewImage populated");
    +    assert!(
    +        record.dynamodb.old_image.is_none(),
    +        "fresh key, no old image"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn apply_put_records_the_marker_in_the_row_outcome_ledger() {
    +    let storage = engine().await;
    +    create_table(&storage, "marked", false).await;
    +    let key_info = storage
    +        .table_key_info(ACCOUNT, "marked")
    +        .await
    +        .expect("key info");
    +    let region: Arc = Arc::from(REGION);
    +    let (condition, maps) = resolve_put_condition(
    +        Some("attribute_not_exists(pk)"),
    +        None,
    +        None,
    +        None,
    +        None,
    +        &limits(),
    +    )
    +    .expect("the condition resolves");
    +    let marker = BulkRowMarker {
    +        account_id: ACCOUNT.to_owned(),
    +        operation_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV".to_owned(),
    +        file_index: 0,
    +        row_index: 3,
    +        owner: String::new(),
    +        keep_old: false,
    +        settled: (0, 0),
    +    };
    +
    +    apply_put(
    +        storage.as_ref(),
    +        ®ion,
    +        &key_info,
    +        string_item(&[("pk", "a"), ("sk", "b")]),
    +        condition.as_ref(),
    +        &maps,
    +        false,
    +        Some(&marker),
    +    )
    +    .await
    +    .expect("the write applies");
    +
    +    let outcome = storage
    +        .bulk_row_outcome(ACCOUNT, &marker.operation_id, 0, 3)
    +        .await
    +        .expect("ledger read");
    +    assert_eq!(outcome, Some(RowOutcome::Applied));
    +}
    +
    +#[tokio::test]
    +async fn apply_put_returns_the_old_item_only_when_asked() {
    +    let storage = engine().await;
    +    create_table(&storage, "plain", false).await;
    +    let key_info = storage
    +        .table_key_info(ACCOUNT, "plain")
    +        .await
    +        .expect("key info");
    +    let region: Arc = Arc::from(REGION);
    +    let maps = ExpressionMaps::default();
    +
    +    let first = apply_put(
    +        storage.as_ref(),
    +        ®ion,
    +        &key_info,
    +        string_item(&[("pk", "a"), ("sk", "b"), ("v", "1")]),
    +        None,
    +        &maps,
    +        false,
    +        None,
    +    )
    +    .await
    +    .expect("the first write applies");
    +    assert!(first.is_none(), "no stream, not asked: no old item fetched");
    +
    +    let second = apply_put(
    +        storage.as_ref(),
    +        ®ion,
    +        &key_info,
    +        string_item(&[("pk", "a"), ("sk", "b"), ("v", "2")]),
    +        None,
    +        &maps,
    +        true,
    +        None,
    +    )
    +    .await
    +    .expect("the second write applies");
    +    let old = second.expect("the replaced item is returned when asked");
    +    assert_eq!(old.get("v"), Some(&AttributeValue::S("1".to_owned())));
    +}
    diff --git a/crates/storage-cassandra/src/bulk_operation_store.rs b/crates/storage-cassandra/src/bulk_operation_store.rs
    new file mode 100644
    index 000000000..20f3f36d5
    --- /dev/null
    +++ b/crates/storage-cassandra/src/bulk_operation_store.rs
    @@ -0,0 +1,136 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! `BulkOperationStore` for the Cassandra backend.
    +//!
    +//! Bulk operations are not implemented on this backend in this version.
    +//! Every method refuses with the unsupported-feature message, which the
    +//! engine surfaces to the client as a `ValidationException`, matching how
    +//! this backend reports other capabilities it does not carry.
    +
    +use std::time::{Duration, SystemTime};
    +
    +use futures::future::BoxFuture;
    +
    +use extenddb_storage::error::StorageError;
    +use extenddb_storage::{
    +    BulkOperationListResult, BulkOperationRecord, BulkOperationStore, CancelOutcome, FinishState,
    +    LeaseState, ListCursor, NewBulkOperation, Progress, ReportKind, ReportRecord, StartOutcome,
    +    Terminal,
    +};
    +
    +use crate::CassandraEngine;
    +
    +fn unsupported() -> BoxFuture<'static, Result> {
    +    Box::pin(async {
    +        Err(StorageError::Validation(
    +            "Bulk operations are not supported on this storage backend".to_owned(),
    +        ))
    +    })
    +}
    +
    +impl BulkOperationStore for CassandraEngine {
    +    fn start_bulk_operation(
    +        &self,
    +        _account_id: &str,
    +        _req: NewBulkOperation,
    +        _account_limit: u32,
    +    ) -> BoxFuture<'_, Result> {
    +        unsupported()
    +    }
    +
    +    fn get_bulk_operation(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        unsupported()
    +    }
    +
    +    fn count_non_terminal(&self, _account_id: &str) -> BoxFuture<'_, Result> {
    +        unsupported()
    +    }
    +
    +    fn count_terminal_born_since(
    +        &self,
    +        _account_id: &str,
    +        _since_ms: i64,
    +    ) -> BoxFuture<'_, Result> {
    +        unsupported()
    +    }
    +
    +    fn list_bulk_operations(
    +        &self,
    +        _account_id: &str,
    +        _limit: u32,
    +        _after: Option,
    +    ) -> BoxFuture<'_, BulkOperationListResult> {
    +        unsupported()
    +    }
    +
    +    fn request_cancel(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        unsupported()
    +    }
    +
    +    fn claim_runnable(
    +        &self,
    +        _owner: &str,
    +        _lease: Duration,
    +        _max: usize,
    +        _exclude_accounts: &[String],
    +        _exclude_operations: &[String],
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        unsupported()
    +    }
    +
    +    fn checkpoint(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +        _owner: &str,
    +        _lease: Duration,
    +        _progress: Progress,
    +    ) -> BoxFuture<'_, Result> {
    +        unsupported()
    +    }
    +
    +    fn report_records_page(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +        _kind: ReportKind,
    +        _after: Option<(i64, i64)>,
    +        _limit: usize,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        unsupported()
    +    }
    +
    +    fn finish(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +        _owner: &str,
    +        _outcome: Terminal,
    +    ) -> BoxFuture<'_, Result> {
    +        unsupported()
    +    }
    +
    +    fn list_purgeable_before(
    +        &self,
    +        _cutoff: SystemTime,
    +        _limit: usize,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        unsupported()
    +    }
    +
    +    fn purge_operations(
    +        &self,
    +        _pairs: &[(String, String)],
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        unsupported()
    +    }
    +}
    diff --git a/crates/storage-cassandra/src/data/data_engine.rs b/crates/storage-cassandra/src/data/data_engine.rs
    index f75cb6a93..1567f9288 100644
    --- a/crates/storage-cassandra/src/data/data_engine.rs
    +++ b/crates/storage-cassandra/src/data/data_engine.rs
    @@ -25,7 +25,12 @@ impl DataEngine for CassandraEngine {
             condition: Option<&Expr>,
             maps: &ExpressionMaps,
             stream: Option<&StreamCapture>,
    +        marker: Option<&extenddb_storage::BulkRowMarker>,
         ) -> BoxFuture<'_, Result, StorageError>> {
    +        // Bulk operations are not implemented on this backend, so no caller
    +        // can reach this method with a marker; the engine refuses the bulk
    +        // request first.
    +        let _ = marker;
             let key_info = key_info.clone();
             let condition = condition.cloned();
             let maps = maps.clone();
    @@ -43,6 +48,49 @@ impl DataEngine for CassandraEngine {
             })
         }
     
    +    fn put_items_batch(
    +        &self,
    +        _key_info: &TableKeyInfo,
    +        _rows: Vec,
    +        _stream: Option<&StreamCapture>,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        // Bulk operations are not supported on this backend; the engine
    +        // refuses the request at start, so no batch reaches storage. The
    +        // refusal here keeps the contract explicit now that the trait method
    +        // is required.
    +        Box::pin(async {
    +            Err(StorageError::Validation(
    +                "Bulk operations are not supported on this storage backend".to_owned(),
    +            ))
    +        })
    +    }
    +
    +    fn bulk_row_outcome(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +        _file_index: i64,
    +        _row_index: i64,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        Box::pin(async {
    +            Err(StorageError::Validation(
    +                "Bulk operations are not supported on this storage backend".to_owned(),
    +            ))
    +        })
    +    }
    +
    +    fn delete_bulk_row_outcomes(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        Box::pin(async {
    +            Err(StorageError::Validation(
    +                "Bulk operations are not supported on this storage backend".to_owned(),
    +            ))
    +        })
    +    }
    +
         fn get_item(
             &self,
             key_info: &TableKeyInfo,
    diff --git a/crates/storage-cassandra/src/lib.rs b/crates/storage-cassandra/src/lib.rs
    index 3f0489402..e6dcf5ac5 100644
    --- a/crates/storage-cassandra/src/lib.rs
    +++ b/crates/storage-cassandra/src/lib.rs
    @@ -17,6 +17,7 @@ mod authorization_store;
     mod backup_catalog;
     mod backup_engine;
     pub mod bootstrapper;
    +mod bulk_operation_store;
     pub mod cassandra_util;
     pub mod catalog_store;
     pub mod config;
    diff --git a/crates/storage-cassandra/src/migrations/mod.rs b/crates/storage-cassandra/src/migrations/mod.rs
    index fc3352c61..2eaf17d51 100644
    --- a/crates/storage-cassandra/src/migrations/mod.rs
    +++ b/crates/storage-cassandra/src/migrations/mod.rs
    @@ -154,6 +154,10 @@ pub fn migration(id: MigrationId) -> Migration {
                 "the stream_pending table belongs to the PostgreSQL backend, and Cassandra numbers its \
                  stream records with the server's clock (#660)",
             ),
    +        MigrationId::BulkOperations => Migration::NotApplicable(
    +            "the Cassandra backend has no bulk operation store: a bulk operation request that \
    +             reaches the store is refused with ValidationException",
    +        ),
         }
     }
     
    diff --git a/crates/storage-cassandra/tests/common/mod.rs b/crates/storage-cassandra/tests/common/mod.rs
    index ea97c57ad..50f58b5ca 100644
    --- a/crates/storage-cassandra/tests/common/mod.rs
    +++ b/crates/storage-cassandra/tests/common/mod.rs
    @@ -669,6 +669,7 @@ pub async fn put_item_then_lock(
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("Initial put_item should succeed");
    diff --git a/crates/storage-cassandra/tests/direct/backup_engine.rs b/crates/storage-cassandra/tests/direct/backup_engine.rs
    index 15c1381d5..25e1d8b83 100644
    --- a/crates/storage-cassandra/tests/direct/backup_engine.rs
    +++ b/crates/storage-cassandra/tests/direct/backup_engine.rs
    @@ -113,7 +113,7 @@ async fn make_gsis_synchronous(engine: &CassandraEngine, table_id: &str, names:
     
     async fn put_all(engine: &CassandraEngine, key_info: &TableKeyInfo, items: Vec) {
         let mut writes = futures::stream::iter(items)
    -        .map(|item| engine.put_item(key_info, item, false, None, &Default::default(), None))
    +        .map(|item| engine.put_item(key_info, item, false, None, &Default::default(), None, None))
             .buffer_unordered(32);
         while let Some(result) = writes.next().await {
             result.expect("seed item");
    @@ -300,6 +300,7 @@ async fn test_backup_describe_list_delete_and_account_scope() {
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("seed item");
    @@ -533,6 +534,7 @@ async fn test_table_name_filter_spans_table_recreation() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("seed original table");
    diff --git a/crates/storage-cassandra/tests/direct/delete_item.rs b/crates/storage-cassandra/tests/direct/delete_item.rs
    index bb52adce9..a75905d70 100644
    --- a/crates/storage-cassandra/tests/direct/delete_item.rs
    +++ b/crates/storage-cassandra/tests/direct/delete_item.rs
    @@ -35,6 +35,7 @@ async fn test_delete_item_pk_only_exists() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("put_item failed");
    @@ -120,6 +121,7 @@ async fn test_delete_item_with_sk_exists() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("put_item failed");
    @@ -195,6 +197,7 @@ async fn test_delete_item_return_old_false() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("put_item failed");
    @@ -350,6 +353,7 @@ async fn test_delete_item_sets_partition_max_delete_timestamp_pk_only() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("put_item should succeed");
    @@ -413,6 +417,7 @@ async fn test_delete_item_sets_partition_max_delete_timestamp_with_sk() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("put_item 1 should succeed");
    @@ -424,6 +429,7 @@ async fn test_delete_item_sets_partition_max_delete_timestamp_with_sk() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("put_item 2 should succeed");
    @@ -511,6 +517,7 @@ async fn test_delete_item_partition_max_timestamp_increases_on_subsequent_delete
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("put_item 1");
    @@ -522,6 +529,7 @@ async fn test_delete_item_partition_max_timestamp_increases_on_subsequent_delete
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("put_item 2");
    @@ -605,6 +613,7 @@ async fn test_delete_item_partition_max_timestamp_increases_on_subsequent_delete
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("put_item 3");
    @@ -658,6 +667,7 @@ async fn test_transaction_put_rejected_by_partition_max_delete_timestamp() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("put_item anchor should succeed");
    diff --git a/crates/storage-cassandra/tests/direct/index.rs b/crates/storage-cassandra/tests/direct/index.rs
    index 42fd7455e..278d6b022 100644
    --- a/crates/storage-cassandra/tests/direct/index.rs
    +++ b/crates/storage-cassandra/tests/direct/index.rs
    @@ -677,7 +677,7 @@ async fn test_async_gsi_enqueues_row_atomically() {
         item.insert("gpk".to_string(), AttributeValue::S("g1".to_string()));
     
         engine
    -        .put_item(&table.key_info, item, false, None, &maps, None)
    +        .put_item(&table.key_info, item, false, None, &maps, None, None)
             .await
             .expect("put_item");
     
    @@ -719,7 +719,7 @@ async fn test_async_gsi_worker_convergence() {
             item.insert("id".to_string(), AttributeValue::S("X".to_string()));
             item.insert("gpk".to_string(), AttributeValue::S(v.to_string()));
             engine
    -            .put_item(&table.key_info, item, false, None, &maps, None)
    +            .put_item(&table.key_info, item, false, None, &maps, None, None)
                 .await
                 .expect("put_item");
         }
    @@ -800,7 +800,7 @@ async fn test_async_gsi_worker_skips_dropped_index() {
             item.insert("id".to_string(), AttributeValue::S(format!("pk{i}")));
             item.insert("gpk".to_string(), AttributeValue::S(format!("g{i}")));
             engine
    -            .put_item(&table.key_info, item, false, None, &maps, None)
    +            .put_item(&table.key_info, item, false, None, &maps, None, None)
                 .await
                 .expect("put_item");
         }
    @@ -1165,11 +1165,11 @@ async fn test_async_gsi_worker_keeps_the_row_across_a_same_key_update() {
         v2.insert("data".to_string(), AttributeValue::S("v2".to_string()));
         let maps = ExpressionMaps::default();
         engine
    -        .put_item(&table.key_info, v1, false, None, &maps, None)
    +        .put_item(&table.key_info, v1, false, None, &maps, None, None)
             .await
             .expect("put v1");
         engine
    -        .put_item(&table.key_info, v2, false, None, &maps, None)
    +        .put_item(&table.key_info, v2, false, None, &maps, None, None)
             .await
             .expect("put v2");
         assert_eq!(gsi_pending_count(&engine, &account_keyspace).await, 2);
    @@ -1319,6 +1319,7 @@ async fn test_every_write_path_stamps_the_index_row_with_the_stored_version() {
                 None,
                 &maps,
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -1340,6 +1341,7 @@ async fn test_every_write_path_stamps_the_index_row_with_the_stored_version() {
                 Some(¬_exists),
                 &maps,
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -1357,6 +1359,7 @@ async fn test_every_write_path_stamps_the_index_row_with_the_stored_version() {
                 None,
                 &maps,
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -1462,6 +1465,7 @@ async fn test_every_write_path_stamps_the_index_row_with_the_stored_version() {
                 None,
                 &maps,
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -1599,6 +1603,7 @@ async fn a_backfill_the_recovery_worker_started_ends_before_the_worker_returns()
                         None,
                         &ExpressionMaps::default(),
                         None,
    +                    None,
                     )
                     .await
                     .expect("put_item");
    @@ -1770,6 +1775,7 @@ async fn test_a_delete_stamps_its_tombstones_one_above_the_deleted_version() {
                     None,
                     &maps,
                     None,
    +                None,
                 )
                 .await
                 .unwrap();
    @@ -1849,7 +1855,7 @@ async fn test_a_delete_removes_the_index_row_a_backfill_wrote() {
         item.insert("id".to_owned(), AttributeValue::S("x".to_owned()));
         item.insert("status".to_owned(), AttributeValue::S("s".to_owned()));
         engine
    -        .put_item(&table.key_info, item, false, None, &maps, None)
    +        .put_item(&table.key_info, item, false, None, &maps, None, None)
             .await
             .unwrap();
         let keyspace = engine.account_keyspace(&table.key_info.account_id);
    @@ -1992,7 +1998,15 @@ async fn test_a_queue_entry_written_over_a_version_ahead_of_the_clock_is_drained
         item.insert("gpk".to_string(), AttributeValue::S("g".to_string()));
         item.insert("data".to_string(), AttributeValue::S("v1".to_string()));
         engine
    -        .put_item(&table.key_info, item.clone(), false, None, &maps, None)
    +        .put_item(
    +            &table.key_info,
    +            item.clone(),
    +            false,
    +            None,
    +            &maps,
    +            None,
    +            None,
    +        )
             .await
             .expect("put v1");
         let ahead = extenddb_storage_cassandra::data::version::now_micros() + 3_600_000_000;
    @@ -2007,7 +2021,7 @@ async fn test_a_queue_entry_written_over_a_version_ahead_of_the_clock_is_drained
         .unwrap();
         item.insert("data".to_string(), AttributeValue::S("v2".to_string()));
         engine
    -        .put_item(&table.key_info, item, false, None, &maps, None)
    +        .put_item(&table.key_info, item, false, None, &maps, None, None)
             .await
             .expect("put v2");
         assert_eq!(gsi_pending_count(&engine, &account_keyspace).await, 2);
    @@ -2052,7 +2066,7 @@ async fn test_the_gsi_worker_resolves_two_entries_for_one_item_in_version_order(
             item.insert("id".to_string(), AttributeValue::S("X".to_string()));
             item.insert("gpk".to_string(), AttributeValue::S(gpk.to_string()));
             engine
    -            .put_item(&table.key_info, item, false, None, &maps, None)
    +            .put_item(&table.key_info, item, false, None, &maps, None, None)
                 .await
                 .expect("put");
         }
    diff --git a/crates/storage-cassandra/tests/direct/put_get_item.rs b/crates/storage-cassandra/tests/direct/put_get_item.rs
    index df9186548..58353abb9 100644
    --- a/crates/storage-cassandra/tests/direct/put_get_item.rs
    +++ b/crates/storage-cassandra/tests/direct/put_get_item.rs
    @@ -33,6 +33,7 @@ async fn test_put_and_get_item_pk_only() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("Put failed");
    @@ -78,6 +79,7 @@ async fn test_put_and_get_item_with_string_sk() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("Put failed");
    @@ -123,6 +125,7 @@ async fn test_put_and_get_item_with_number_sk() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("Put failed");
    @@ -180,6 +183,7 @@ async fn test_put_and_get_item_with_decimal_sk() {
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("put_item with decimal sort key should succeed");
    @@ -237,6 +241,7 @@ async fn test_put_and_get_item_with_binary_sk() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("Put failed");
    @@ -277,6 +282,7 @@ async fn test_put_item_update_existing() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("First put failed");
    @@ -291,6 +297,7 @@ async fn test_put_item_update_existing() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("Second put failed");
    @@ -358,6 +365,7 @@ async fn test_put_item_with_return_old() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("First put failed");
    @@ -374,6 +382,7 @@ async fn test_put_item_with_return_old() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("Second put failed");
    @@ -431,6 +440,7 @@ async fn test_put_item_with_sync_gsi() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("put_item with GSI failed");
    @@ -504,6 +514,7 @@ async fn test_put_item_rejects_when_prepared_txn_id_set_pk_only() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await;
     
    @@ -546,6 +557,7 @@ async fn test_put_item_rejects_when_prepared_txn_id_set_with_sk() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await;
     
    @@ -577,6 +589,7 @@ async fn test_put_item_succeeds_when_prepared_txn_id_is_null() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("First put should succeed");
    @@ -594,6 +607,7 @@ async fn test_put_item_succeeds_when_prepared_txn_id_is_null() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("Overwrite of unlocked item should succeed");
    diff --git a/crates/storage-cassandra/tests/direct/query.rs b/crates/storage-cassandra/tests/direct/query.rs
    index 05baa9e95..4c14c0eaa 100644
    --- a/crates/storage-cassandra/tests/direct/query.rs
    +++ b/crates/storage-cassandra/tests/direct/query.rs
    @@ -29,7 +29,7 @@ async fn test_query_pk_only() {
     
             let maps = ExpressionMaps::default();
             engine
    -            .put_item(&table.key_info, item, false, None, &maps, None)
    +            .put_item(&table.key_info, item, false, None, &maps, None, None)
                 .await
                 .expect("put_item should succeed");
         }
    @@ -109,6 +109,7 @@ async fn test_query_with_sk_equals() {
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("put_item should succeed");
    @@ -190,6 +191,7 @@ async fn test_query_with_sk_comparison() {
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("put_item should succeed");
    @@ -292,6 +294,7 @@ async fn test_query_with_numeric_sk() {
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("put_item should succeed");
    @@ -393,6 +396,7 @@ async fn test_query_with_decimal_sk_range_and_order() {
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("put_item should succeed");
    @@ -484,6 +488,7 @@ async fn test_query_ordering() {
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("put_item should succeed");
    @@ -597,6 +602,7 @@ async fn test_query_limit() {
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("put_item should succeed");
    @@ -680,6 +686,7 @@ async fn test_query_pagination() {
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("put_item should succeed");
    @@ -835,6 +842,7 @@ async fn test_query_between() {
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("put_item should succeed");
    @@ -935,6 +943,7 @@ async fn test_query_begins_with() {
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("put_item should succeed");
    @@ -1044,7 +1053,7 @@ async fn test_query_gsi() {
             );
     
             engine
    -            .put_item(&table.key_info, item, false, None, &maps, None)
    +            .put_item(&table.key_info, item, false, None, &maps, None, None)
                 .await
                 .expect("put_item should succeed");
     
    @@ -1139,7 +1148,7 @@ async fn test_query_gsi_pagination() {
             item.insert("data".to_string(), AttributeValue::N(i.to_string()));
     
             engine
    -            .put_item(&table.key_info, item, false, None, &maps, None)
    +            .put_item(&table.key_info, item, false, None, &maps, None, None)
                 .await
                 .expect("put_item should succeed");
         }
    @@ -1288,7 +1297,7 @@ async fn test_query_lsi() {
             );
     
             engine
    -            .put_item(&table.key_info, item, false, None, &maps, None)
    +            .put_item(&table.key_info, item, false, None, &maps, None, None)
                 .await
                 .expect("put_item should succeed");
         }
    @@ -1401,7 +1410,7 @@ async fn test_query_gsi_with_sort_key() {
             );
     
             engine
    -            .put_item(&table.key_info, item, false, None, &maps, None)
    +            .put_item(&table.key_info, item, false, None, &maps, None, None)
                 .await
                 .expect("put_item should succeed");
         }
    diff --git a/crates/storage-cassandra/tests/direct/scan.rs b/crates/storage-cassandra/tests/direct/scan.rs
    index cb5d2a6c3..818acaa6d 100644
    --- a/crates/storage-cassandra/tests/direct/scan.rs
    +++ b/crates/storage-cassandra/tests/direct/scan.rs
    @@ -39,6 +39,7 @@ async fn put_pk_only_items(
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("put_item should succeed");
    @@ -127,6 +128,7 @@ async fn test_scan_composite_key_all() {
                         None,
                         &Default::default(),
                         None,
    +                    None,
                     )
                     .await
                     .expect("put_item should succeed");
    @@ -257,6 +259,7 @@ async fn test_scan_pagination_composite_key() {
                         None,
                         &Default::default(),
                         None,
    +                    None,
                     )
                     .await
                     .expect("put_item should succeed");
    @@ -397,6 +400,7 @@ async fn test_scan_gsi() {
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("put_item should succeed");
    @@ -464,6 +468,7 @@ async fn test_scan_gsi_pagination() {
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("put_item should succeed");
    @@ -552,6 +557,7 @@ async fn test_scan_numeric_sort_key_all() {
                         None,
                         &Default::default(),
                         None,
    +                    None,
                     )
                     .await
                     .expect("put_item should succeed");
    @@ -611,6 +617,7 @@ async fn test_scan_numeric_sort_key_pagination() {
                         None,
                         &Default::default(),
                         None,
    +                    None,
                     )
                     .await
                     .expect("put_item should succeed");
    @@ -691,6 +698,7 @@ async fn test_scan_binary_sort_key_all() {
                         None,
                         &Default::default(),
                         None,
    +                    None,
                     )
                     .await
                     .expect("put_item should succeed");
    @@ -754,6 +762,7 @@ async fn test_scan_lsi() {
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("put_item should succeed");
    diff --git a/crates/storage-cassandra/tests/direct/stream_ownership.rs b/crates/storage-cassandra/tests/direct/stream_ownership.rs
    index fa1170c59..0d6ea1195 100644
    --- a/crates/storage-cassandra/tests/direct/stream_ownership.rs
    +++ b/crates/storage-cassandra/tests/direct/stream_ownership.rs
    @@ -115,6 +115,7 @@ async fn put(engine: &CassandraEngine, key_info: &TableKeyInfo, pk: &str, val: u
                 None,
                 &Default::default(),
                 Some(&capture),
    +            None,
             )
             .await
             .expect("put_item");
    diff --git a/crates/storage-cassandra/tests/direct/streams.rs b/crates/storage-cassandra/tests/direct/streams.rs
    index bc0efa206..57a735a00 100644
    --- a/crates/storage-cassandra/tests/direct/streams.rs
    +++ b/crates/storage-cassandra/tests/direct/streams.rs
    @@ -163,6 +163,7 @@ async fn test_put_item_insert_writes_stream_record() {
                 None,
                 &Default::default(),
                 Some(&capture(StreamViewType::NewAndOldImages)),
    +            None,
             )
             .await
             .unwrap();
    @@ -199,6 +200,7 @@ async fn test_put_item_overwrite_writes_modify_stream_record() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -213,6 +215,7 @@ async fn test_put_item_overwrite_writes_modify_stream_record() {
                 None,
                 &Default::default(),
                 Some(&capture(StreamViewType::NewAndOldImages)),
    +            None,
             )
             .await
             .unwrap();
    @@ -251,6 +254,7 @@ async fn test_delete_item_writes_remove_stream_record() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -328,6 +332,7 @@ async fn test_keys_only_view_type_omits_images() {
                 None,
                 &Default::default(),
                 Some(&capture(StreamViewType::KeysOnly)),
    +            None,
             )
             .await
             .unwrap();
    @@ -362,6 +367,7 @@ async fn test_same_pk_records_land_in_same_shard_with_ordered_sequences() {
                     None,
                     &Default::default(),
                     Some(&capture(StreamViewType::NewImage)),
    +                None,
                 )
                 .await
                 .unwrap();
    @@ -474,6 +480,7 @@ async fn test_latest_sequence_number_after_write() {
                 None,
                 &Default::default(),
                 Some(&capture(StreamViewType::NewImage)),
    +            None,
             )
             .await
             .unwrap();
    @@ -512,6 +519,7 @@ async fn test_get_stream_records_returns_written_records() {
                     None,
                     &Default::default(),
                     Some(&capture(StreamViewType::NewImage)),
    +                None,
                 )
                 .await
                 .unwrap();
    @@ -559,6 +567,7 @@ async fn test_get_stream_records_after_sequence_paginates() {
                     None,
                     &Default::default(),
                     Some(&capture(StreamViewType::NewImage)),
    +                None,
                 )
                 .await
                 .unwrap();
    @@ -680,6 +689,7 @@ async fn test_stream_record_created_at_matches_the_record_and_sequence() {
                     None,
                     &Default::default(),
                     Some(&capture(view)),
    +                None,
                 )
                 .await
                 .unwrap();
    diff --git a/crates/storage-cassandra/tests/direct/transact_get_items.rs b/crates/storage-cassandra/tests/direct/transact_get_items.rs
    index 77ea0dd0a..f1f09e353 100644
    --- a/crates/storage-cassandra/tests/direct/transact_get_items.rs
    +++ b/crates/storage-cassandra/tests/direct/transact_get_items.rs
    @@ -35,6 +35,7 @@ async fn test_transact_get_items_single_item() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("Put item failed");
    @@ -82,6 +83,7 @@ async fn test_transact_get_items_multiple_items() {
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("Put item failed");
    @@ -143,6 +145,7 @@ async fn test_transact_get_items_nonexistent_item() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -204,6 +207,7 @@ async fn test_transact_get_items_with_sort_key() {
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .unwrap();
    @@ -287,6 +291,7 @@ async fn test_transact_get_items_cross_table() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -299,6 +304,7 @@ async fn test_transact_get_items_cross_table() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -398,6 +404,7 @@ async fn test_transact_get_items_max_items() {
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .unwrap();
    diff --git a/crates/storage-cassandra/tests/direct/transact_write_items.rs b/crates/storage-cassandra/tests/direct/transact_write_items.rs
    index 1e1a8b122..fc390c39c 100644
    --- a/crates/storage-cassandra/tests/direct/transact_write_items.rs
    +++ b/crates/storage-cassandra/tests/direct/transact_write_items.rs
    @@ -80,7 +80,15 @@ async fn test_transact_write_put_and_delete() {
         );
     
         engine
    -        .put_item(&table.key_info, item1.clone(), false, None, &maps, None)
    +        .put_item(
    +            &table.key_info,
    +            item1.clone(),
    +            false,
    +            None,
    +            &maps,
    +            None,
    +            None,
    +        )
             .await
             .expect("Initial put_item should succeed");
     
    @@ -161,7 +169,15 @@ async fn test_transact_write_rollback_on_condition_failure() {
         let maps = ExpressionMaps::default();
     
         engine
    -        .put_item(&table.key_info, item1.clone(), false, None, &maps, None)
    +        .put_item(
    +            &table.key_info,
    +            item1.clone(),
    +            false,
    +            None,
    +            &maps,
    +            None,
    +            None,
    +        )
             .await
             .expect("Initial put_item should succeed");
     
    @@ -267,6 +283,7 @@ async fn test_transact_write_rollback_update_existing_item() {
                 None,
                 &maps_empty,
                 None,
    +            None,
             )
             .await
             .expect("setup put_item");
    @@ -356,7 +373,15 @@ async fn test_transact_write_rollback_delete_existing_item() {
             AttributeValue::S("keep_me".to_string()),
         );
         engine
    -        .put_item(&table.key_info, existing.clone(), false, None, &maps, None)
    +        .put_item(
    +            &table.key_info,
    +            existing.clone(),
    +            false,
    +            None,
    +            &maps,
    +            None,
    +            None,
    +        )
             .await
             .expect("setup put_item");
     
    @@ -417,7 +442,15 @@ async fn test_transact_write_rollback_delete_existing_item() {
     
         // Verify item is no longer locked (a subsequent write should succeed)
         engine
    -        .put_item(&table.key_info, existing.clone(), false, None, &maps, None)
    +        .put_item(
    +            &table.key_info,
    +            existing.clone(),
    +            false,
    +            None,
    +            &maps,
    +            None,
    +            None,
    +        )
             .await
             .expect("put_item after rollback should succeed (item not locked)");
     }
    diff --git a/crates/storage-cassandra/tests/direct/transact_write_stream_batch_limit.rs b/crates/storage-cassandra/tests/direct/transact_write_stream_batch_limit.rs
    index 025599b84..b741e853d 100644
    --- a/crates/storage-cassandra/tests/direct/transact_write_stream_batch_limit.rs
    +++ b/crates/storage-cassandra/tests/direct/transact_write_stream_batch_limit.rs
    @@ -52,7 +52,7 @@ pub(crate) async fn seed(
         let maps = ExpressionMaps::default();
         let item = big_item(id, bytes, val);
         engine
    -        .put_item(key_info, item, false, None, &maps, None)
    +        .put_item(key_info, item, false, None, &maps, None, None)
             .await
             .unwrap();
     }
    diff --git a/crates/storage-cassandra/tests/direct/transact_write_stream_images.rs b/crates/storage-cassandra/tests/direct/transact_write_stream_images.rs
    index 892b64c8b..9026c3688 100644
    --- a/crates/storage-cassandra/tests/direct/transact_write_stream_images.rs
    +++ b/crates/storage-cassandra/tests/direct/transact_write_stream_images.rs
    @@ -82,6 +82,7 @@ async fn test_transact_write_put_over_existing_records_modify() {
                 None,
                 &maps,
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -216,6 +217,7 @@ async fn test_transacted_records_match_the_single_item_records() {
                         None,
                         &maps,
                         None,
    +                    None,
                     )
                     .await
                     .unwrap();
    @@ -233,6 +235,7 @@ async fn test_transacted_records_match_the_single_item_records() {
                 None,
                 &maps,
                 Some(&view),
    +            None,
             )
             .await
             .unwrap();
    diff --git a/crates/storage-cassandra/tests/direct/transact_write_stream_records.rs b/crates/storage-cassandra/tests/direct/transact_write_stream_records.rs
    index 1c0623f00..0bd6c4a84 100644
    --- a/crates/storage-cassandra/tests/direct/transact_write_stream_records.rs
    +++ b/crates/storage-cassandra/tests/direct/transact_write_stream_records.rs
    @@ -140,7 +140,15 @@ pub(crate) async fn one_stream_record_per_mutation(
         // only records in the table are the transaction's.
         for (id, val) in [("upd-1", "before"), ("del-1", "doomed")] {
             engine
    -            .put_item(&table.key_info, item(id, val), false, None, &maps, None)
    +            .put_item(
    +                &table.key_info,
    +                item(id, val),
    +                false,
    +                None,
    +                &maps,
    +                None,
    +                None,
    +            )
                 .await
                 .unwrap();
         }
    @@ -386,6 +394,7 @@ async fn test_transact_write_condition_check_records_nothing() {
                 None,
                 &maps,
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -443,6 +452,7 @@ async fn test_cancelled_transaction_records_nothing() {
                 None,
                 &maps,
                 None,
    +            None,
             )
             .await
             .unwrap();
    diff --git a/crates/storage-cassandra/tests/direct/transact_write_stream_sequence.rs b/crates/storage-cassandra/tests/direct/transact_write_stream_sequence.rs
    index 9b4cdfcba..45ab7911c 100644
    --- a/crates/storage-cassandra/tests/direct/transact_write_stream_sequence.rs
    +++ b/crates/storage-cassandra/tests/direct/transact_write_stream_sequence.rs
    @@ -84,7 +84,7 @@ async fn test_transact_write_sequence_number_is_taken_after_the_item_is_written(
             let id = format!("seq-upd-{i}");
             let item = item_with(&id, "before");
             engine
    -            .put_item(&table.key_info, item, false, None, &maps, None)
    +            .put_item(&table.key_info, item, false, None, &maps, None, None)
                 .await
                 .unwrap();
             let key = key_for(&id);
    diff --git a/crates/storage-cassandra/tests/direct/transaction_ledger.rs b/crates/storage-cassandra/tests/direct/transaction_ledger.rs
    index b74cd900b..a554346a3 100644
    --- a/crates/storage-cassandra/tests/direct/transaction_ledger.rs
    +++ b/crates/storage-cassandra/tests/direct/transaction_ledger.rs
    @@ -445,11 +445,11 @@ async fn test_committing_recovery_leaves_a_row_a_later_writer_owns_alone() {
         let k2 = gsi_item("rec", "K2", "2");
         let k3 = gsi_item("rec", "K3", "3");
         engine
    -        .put_item(&table.key_info, k1.clone(), false, None, &maps, None)
    +        .put_item(&table.key_info, k1.clone(), false, None, &maps, None, None)
             .await
             .unwrap();
         engine
    -        .put_item(&table.key_info, k3.clone(), false, None, &maps, None)
    +        .put_item(&table.key_info, k3.clone(), false, None, &maps, None, None)
             .await
             .unwrap();
         let (version_before, prepared) = base_row_state(&engine, &keyspace, &table_id, "rec").await;
    @@ -512,7 +512,7 @@ async fn test_committing_recovery_repairs_the_index_rows_of_a_row_it_commits() {
         let k1 = gsi_item("own", "K1", "1");
         let k2 = gsi_item("own", "K2", "2");
         engine
    -        .put_item(&table.key_info, k1.clone(), false, None, &maps, None)
    +        .put_item(&table.key_info, k1.clone(), false, None, &maps, None, None)
             .await
             .unwrap();
         let (version_before, _) = base_row_state(&engine, &keyspace, &table_id, "own").await;
    @@ -584,7 +584,7 @@ async fn test_committing_recovery_repairs_the_index_rows_of_a_commit_that_landed
         let k1 = gsi_item("landed", "K1", "1");
         let k2 = gsi_item("landed", "K2", "2");
         engine
    -        .put_item(&table.key_info, k1.clone(), false, None, &maps, None)
    +        .put_item(&table.key_info, k1.clone(), false, None, &maps, None, None)
             .await
             .unwrap();
         let (version_before, _) = base_row_state(&engine, &keyspace, &table_id, "landed").await;
    diff --git a/crates/storage-cassandra/tests/ttl_bench.rs b/crates/storage-cassandra/tests/ttl_bench.rs
    index 36c4b3125..0aac4fa63 100644
    --- a/crates/storage-cassandra/tests/ttl_bench.rs
    +++ b/crates/storage-cassandra/tests/ttl_bench.rs
    @@ -74,7 +74,7 @@ async fn bench_puts(
             }
             let started = std::time::Instant::now();
             engine
    -            .put_item(key_info, item, false, None, &Default::default(), None)
    +            .put_item(key_info, item, false, None, &Default::default(), None, None)
                 .await
                 .expect("bench put");
             samples.push(started.elapsed().as_micros());
    @@ -162,6 +162,7 @@ async fn bench_expiration_drain_throughput() {
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("seed put");
    diff --git a/crates/storage-cassandra/tests/ttl_integration.rs b/crates/storage-cassandra/tests/ttl_integration.rs
    index 4f712918a..734368ed4 100644
    --- a/crates/storage-cassandra/tests/ttl_integration.rs
    +++ b/crates/storage-cassandra/tests/ttl_integration.rs
    @@ -196,6 +196,7 @@ async fn test_ttl_metadata_enable_disable_and_listing() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("populate TTL queue");
    @@ -391,6 +392,7 @@ async fn test_ttl_queue_sweep_and_stale_candidate_protection() {
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("put TTL test item");
    @@ -406,6 +408,7 @@ async fn test_ttl_queue_sweep_and_stale_candidate_protection() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("put initially expired item");
    @@ -417,6 +420,7 @@ async fn test_ttl_queue_sweep_and_stale_candidate_protection() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("move TTL into future");
    @@ -546,7 +550,15 @@ async fn test_ttl_sweep_emits_service_remove_stream_record() {
         );
         item.insert("value".to_owned(), AttributeValue::S("old".to_owned()));
         engine
    -        .put_item(&key_info, item, false, None, &Default::default(), None)
    +        .put_item(
    +            &key_info,
    +            item,
    +            false,
    +            None,
    +            &Default::default(),
    +            None,
    +            None,
    +        )
             .await
             .expect("put expired stream item");
     
    @@ -787,6 +799,7 @@ async fn test_ttl_claim_serializes_delayed_writer() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -818,6 +831,7 @@ async fn test_ttl_claim_serializes_delayed_writer() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await;
         // The write retries the claim on a jittered backoff before giving up, and
    @@ -839,6 +853,7 @@ async fn test_ttl_claim_serializes_delayed_writer() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("renewed write succeeds after claim release");
    @@ -853,6 +868,7 @@ async fn test_ttl_claim_serializes_delayed_writer() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("successful put releases its TTL claim immediately");
    @@ -883,6 +899,7 @@ async fn test_ttl_claim_serializes_delayed_writer() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("successful delete releases its exact TTL claim immediately");
    @@ -939,7 +956,15 @@ async fn test_concurrent_ordinary_writes_on_ttl_table_both_succeed() {
                 );
                 item.insert("value".to_owned(), AttributeValue::S(value));
                 engine
    -                .put_item(&key_info, item, false, None, &Default::default(), None)
    +                .put_item(
    +                    &key_info,
    +                    item,
    +                    false,
    +                    None,
    +                    &Default::default(),
    +                    None,
    +                    None,
    +                )
                     .await
             }
         };
    @@ -1200,6 +1225,7 @@ async fn ttl_table_with_expired_item(
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -1480,6 +1506,7 @@ async fn test_outbox_pages_past_poison_prefix() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -1684,6 +1711,7 @@ async fn test_effects_applying_with_changed_image_completes_not_wedges() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("the key must be writable after recovery");
    @@ -1760,6 +1788,7 @@ async fn test_effects_applying_recovery_restores_shared_key_gsi_row() {
                 None,
                 &ExpressionMaps::default(),
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -2062,6 +2091,7 @@ async fn test_ttl_sweep_removes_synchronous_gsi_entry() {
                 None,
                 &ExpressionMaps::default(),
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -2203,6 +2233,7 @@ async fn test_ttl_reconciles_same_expiry_after_queue_only_claim() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -2280,6 +2311,7 @@ async fn test_ttl_reconciles_same_expiry_after_queue_only_claim() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("same-expiry update commits and records durable reconciliation work");
    @@ -2405,6 +2437,7 @@ async fn test_ttl_reconciles_same_expiry_after_queue_only_claim() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("worker releases orphaned exact work owner before retiring queue work");
    @@ -2574,6 +2607,7 @@ async fn test_conditional_put_recreates_metadata_only_row() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -2613,6 +2647,7 @@ async fn test_conditional_put_recreates_metadata_only_row() {
                 Some(&condition),
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("conditional put treats metadata-only row as absent");
    @@ -2679,6 +2714,7 @@ async fn test_outbox_restores_bucket_for_existing_pending_row() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -2819,6 +2855,7 @@ async fn test_inflight_repair_repeats_after_late_queue_destroy() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -3059,6 +3096,7 @@ async fn test_non_ttl_put_does_not_enqueue_ttl_reconciliation() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -3206,6 +3244,7 @@ async fn test_audit_restores_lost_queue_registration() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .unwrap();
    @@ -3359,6 +3398,7 @@ async fn test_ttl_lifecycle_states() {
                 None,
                 &Default::default(),
                 None,
    +            None,
             )
             .await
             .expect("put");
    @@ -3440,6 +3480,7 @@ async fn test_backfill_cursor_resume() {
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("seed");
    @@ -3586,6 +3627,7 @@ async fn test_backfill_cursor_is_honored() {
                     None,
                     &Default::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("seed");
    diff --git a/crates/storage-mongodb/src/backup_live_tests.rs b/crates/storage-mongodb/src/backup_live_tests.rs
    index e396ace38..bcc48cc3e 100644
    --- a/crates/storage-mongodb/src/backup_live_tests.rs
    +++ b/crates/storage-mongodb/src/backup_live_tests.rs
    @@ -2784,7 +2784,7 @@ async fn live_stream_record_created_at_matches_the_record_timestamp() {
             .into_iter()
             .collect();
             fx.engine
    -            .put_item(&key_info, item, false, None, &maps, Some(&capture))
    +            .put_item(&key_info, item, false, None, &maps, Some(&capture), None)
                 .await
                 .expect("put");
         }
    diff --git a/crates/storage-mongodb/src/bulk_operation_store.rs b/crates/storage-mongodb/src/bulk_operation_store.rs
    new file mode 100644
    index 000000000..0c48b7ceb
    --- /dev/null
    +++ b/crates/storage-mongodb/src/bulk_operation_store.rs
    @@ -0,0 +1,136 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! `BulkOperationStore` for the `MongoDB` backend.
    +//!
    +//! Bulk operations are not implemented on this backend in this version.
    +//! Every method refuses with the unsupported-feature message, which the
    +//! engine surfaces to the client as a `ValidationException`, matching how
    +//! this backend reports other capabilities it does not carry.
    +
    +use std::time::{Duration, SystemTime};
    +
    +use futures::future::BoxFuture;
    +
    +use extenddb_storage::error::StorageError;
    +use extenddb_storage::{
    +    BulkOperationListResult, BulkOperationRecord, BulkOperationStore, CancelOutcome, FinishState,
    +    LeaseState, ListCursor, NewBulkOperation, Progress, ReportKind, ReportRecord, StartOutcome,
    +    Terminal,
    +};
    +
    +use crate::MongoEngine;
    +
    +fn unsupported() -> BoxFuture<'static, Result> {
    +    Box::pin(async {
    +        Err(StorageError::Validation(
    +            "Bulk operations are not supported on this storage backend".to_owned(),
    +        ))
    +    })
    +}
    +
    +impl BulkOperationStore for MongoEngine {
    +    fn start_bulk_operation(
    +        &self,
    +        _account_id: &str,
    +        _req: NewBulkOperation,
    +        _account_limit: u32,
    +    ) -> BoxFuture<'_, Result> {
    +        unsupported()
    +    }
    +
    +    fn get_bulk_operation(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        unsupported()
    +    }
    +
    +    fn count_non_terminal(&self, _account_id: &str) -> BoxFuture<'_, Result> {
    +        unsupported()
    +    }
    +
    +    fn count_terminal_born_since(
    +        &self,
    +        _account_id: &str,
    +        _since_ms: i64,
    +    ) -> BoxFuture<'_, Result> {
    +        unsupported()
    +    }
    +
    +    fn list_bulk_operations(
    +        &self,
    +        _account_id: &str,
    +        _limit: u32,
    +        _after: Option,
    +    ) -> BoxFuture<'_, BulkOperationListResult> {
    +        unsupported()
    +    }
    +
    +    fn request_cancel(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        unsupported()
    +    }
    +
    +    fn claim_runnable(
    +        &self,
    +        _owner: &str,
    +        _lease: Duration,
    +        _max: usize,
    +        _exclude_accounts: &[String],
    +        _exclude_operations: &[String],
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        unsupported()
    +    }
    +
    +    fn checkpoint(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +        _owner: &str,
    +        _lease: Duration,
    +        _progress: Progress,
    +    ) -> BoxFuture<'_, Result> {
    +        unsupported()
    +    }
    +
    +    fn report_records_page(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +        _kind: ReportKind,
    +        _after: Option<(i64, i64)>,
    +        _limit: usize,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        unsupported()
    +    }
    +
    +    fn finish(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +        _owner: &str,
    +        _outcome: Terminal,
    +    ) -> BoxFuture<'_, Result> {
    +        unsupported()
    +    }
    +
    +    fn list_purgeable_before(
    +        &self,
    +        _cutoff: SystemTime,
    +        _limit: usize,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        unsupported()
    +    }
    +
    +    fn purge_operations(
    +        &self,
    +        _pairs: &[(String, String)],
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        unsupported()
    +    }
    +}
    diff --git a/crates/storage-mongodb/src/data_engine.rs b/crates/storage-mongodb/src/data_engine.rs
    index be8d9de03..06fd47e30 100644
    --- a/crates/storage-mongodb/src/data_engine.rs
    +++ b/crates/storage-mongodb/src/data_engine.rs
    @@ -84,7 +84,12 @@ impl DataEngine for MongoEngine {
             condition: Option<&Expr>,
             maps: &ExpressionMaps,
             stream: Option<&StreamCapture>,
    +        marker: Option<&extenddb_storage::BulkRowMarker>,
         ) -> BoxFuture<'_, Result, StorageError>> {
    +        // Bulk operations are not implemented on this backend, so no caller
    +        // can reach this method with a marker; the engine refuses the bulk
    +        // request first.
    +        let _ = marker;
             let key_info = key_info.clone();
             let item = item.clone();
             let condition = condition.cloned();
    @@ -103,6 +108,49 @@ impl DataEngine for MongoEngine {
             })
         }
     
    +    fn put_items_batch(
    +        &self,
    +        _key_info: &TableKeyInfo,
    +        _rows: Vec,
    +        _stream: Option<&StreamCapture>,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        // Bulk operations are not supported on this backend; the engine
    +        // refuses the request at start, so no batch reaches storage. The
    +        // refusal here keeps the contract explicit now that the trait method
    +        // is required.
    +        Box::pin(async {
    +            Err(StorageError::Validation(
    +                "Bulk operations are not supported on this storage backend".to_owned(),
    +            ))
    +        })
    +    }
    +
    +    fn bulk_row_outcome(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +        _file_index: i64,
    +        _row_index: i64,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        Box::pin(async {
    +            Err(StorageError::Validation(
    +                "Bulk operations are not supported on this storage backend".to_owned(),
    +            ))
    +        })
    +    }
    +
    +    fn delete_bulk_row_outcomes(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        Box::pin(async {
    +            Err(StorageError::Validation(
    +                "Bulk operations are not supported on this storage backend".to_owned(),
    +            ))
    +        })
    +    }
    +
         fn get_item(
             &self,
             key_info: &TableKeyInfo,
    diff --git a/crates/storage-mongodb/src/lib.rs b/crates/storage-mongodb/src/lib.rs
    index 3580b5fb5..546e36682 100644
    --- a/crates/storage-mongodb/src/lib.rs
    +++ b/crates/storage-mongodb/src/lib.rs
    @@ -11,6 +11,7 @@ mod authorization_store;
     mod backup_catalog;
     mod backup_engine;
     mod bootstrapper;
    +mod bulk_operation_store;
     mod catalog_store;
     pub mod condition;
     pub mod config;
    diff --git a/crates/storage-mongodb/src/migrations/mod.rs b/crates/storage-mongodb/src/migrations/mod.rs
    index 1c6337bc6..810fccd5f 100644
    --- a/crates/storage-mongodb/src/migrations/mod.rs
    +++ b/crates/storage-mongodb/src/migrations/mod.rs
    @@ -164,6 +164,10 @@ pub fn migration(id: MigrationId) -> Migration {
                 "a MongoDB write draws its stream sequence number from its shard's counter inside its \
                  transaction, so the records of a shard commit in number order",
             ),
    +        MigrationId::BulkOperations => Migration::NotApplicable(
    +            "the MongoDB backend has no bulk operation store: a bulk operation request that \
    +             reaches the store is refused with ValidationException",
    +        ),
         }
     }
     
    diff --git a/crates/storage-mongodb/src/snapshot_engine.rs b/crates/storage-mongodb/src/snapshot_engine.rs
    index 1b6c9e5ee..7c372a4c1 100644
    --- a/crates/storage-mongodb/src/snapshot_engine.rs
    +++ b/crates/storage-mongodb/src/snapshot_engine.rs
    @@ -714,6 +714,7 @@ mod tests {
                     None,
                     &extenddb_core::expression::ExpressionMaps::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("put");
    diff --git a/crates/storage-mongodb/tests/migrations_fresh.rs b/crates/storage-mongodb/tests/migrations_fresh.rs
    index 9b5270035..95e36909b 100644
    --- a/crates/storage-mongodb/tests/migrations_fresh.rs
    +++ b/crates/storage-mongodb/tests/migrations_fresh.rs
    @@ -323,6 +323,7 @@ async fn a_fresh_store_gets_every_migration_and_the_expected_definitions() {
                 (5, d.target.catalog.clone()),
                 (6, d.target.catalog.clone()),
                 (7, d.target.catalog.clone()),
    +            (8, d.target.catalog.clone()),
             ]
         );
         let not_applicable: Vec = report
    @@ -331,7 +332,7 @@ async fn a_fresh_store_gets_every_migration_and_the_expected_definitions() {
             .filter(|s| s.outcome == Outcome::NotApplicable)
             .map(|s| s.id)
             .collect();
    -    assert_eq!(not_applicable, [3, 4, 7], "{report:?}");
    +    assert_eq!(not_applicable, [3, 4, 7, 8], "{report:?}");
         assert_eq!(
             d.status().await,
             CatalogStatus::Current {
    @@ -350,6 +351,7 @@ async fn a_fresh_store_gets_every_migration_and_the_expected_definitions() {
                 (5, Outcome::Applied),
                 (6, Outcome::Applied),
                 (7, Outcome::NotApplicable),
    +            (8, Outcome::NotApplicable),
             ]
         );
         assert!(
    @@ -444,8 +446,8 @@ async fn a_second_run_applies_nothing_and_reconverge_keeps_the_rows() {
     
         let reconverged = d.reconverge().await.expect("reconverge");
         // Migration 1's steps in the catalog and the data database, and
    -    // migrations 5's and 6's in the catalog: 2 is not convergent, and 3, 4
    -    // and 7 do not apply on MongoDB.
    +    // migrations 5's and 6's in the catalog: 2 is not convergent, and 3, 4,
    +    // 7 and 8 do not apply on MongoDB.
         let ran: Vec<(u32, String)> = reconverged
             .applied
             .iter()
    @@ -535,19 +537,19 @@ async fn a_changed_checksum_a_newer_catalog_and_an_older_release_are_reported()
             doc! { "_id": id, "name": "later", "kind": kind, "checksum": "c", "outcome": "applied",
             "notes": "", "applied_at": bson::DateTime::now(), "applied_by": "9.9.9" }
         };
    -    ledger.insert_one(row(8, "breaking")).await.expect("insert");
    +    ledger.insert_one(row(9, "breaking")).await.expect("insert");
         let status = d.status().await;
         assert_eq!(status.label(), "AheadIncompatible");
         assert_eq!(status.exit_code(), exit::AHEAD_INCOMPATIBLE);
         ledger
    -        .delete_one(doc! { "_id": 8_i64 })
    +        .delete_one(doc! { "_id": 9_i64 })
             .await
             .expect("delete");
    -    ledger.insert_one(row(8, "additive")).await.expect("insert");
    +    ledger.insert_one(row(9, "additive")).await.expect("insert");
         assert_eq!(d.status().await.label(), "AheadCompatible");
         assert!(d.migrate().await.expect("migrate").applied.is_empty());
         ledger
    -        .delete_one(doc! { "_id": 8_i64 })
    +        .delete_one(doc! { "_id": 9_i64 })
             .await
             .expect("delete");
     
    @@ -628,6 +630,6 @@ async fn a_ledger_document_this_binary_cannot_read_stops_the_status() {
             .expect_err("an error");
         migrations.close().await;
         assert!(error.to_string().contains("\"sideways\""), "{error}");
    -    assert_eq!(MigrationId::LATEST.number(), 7);
    +    assert_eq!(MigrationId::LATEST.number(), 8);
         d.drop_databases().await;
     }
    diff --git a/crates/storage-mongodb/tests/migrations_locks.rs b/crates/storage-mongodb/tests/migrations_locks.rs
    index 604cfda33..9b469e7a9 100644
    --- a/crates/storage-mongodb/tests/migrations_locks.rs
    +++ b/crates/storage-mongodb/tests/migrations_locks.rs
    @@ -53,7 +53,7 @@ async fn two_migrators_started_together_apply_once_and_the_other_finds_nothing()
         applied.sort_unstable();
         assert_eq!(
             applied,
    -        [0, 8],
    +        [0, 9],
             "one applies every step and the other finds nothing"
         );
         assert_eq!(
    diff --git a/crates/storage-mongodb/tests/migrations_released.rs b/crates/storage-mongodb/tests/migrations_released.rs
    index d85f0342e..e7612e483 100644
    --- a/crates/storage-mongodb/tests/migrations_released.rs
    +++ b/crates/storage-mongodb/tests/migrations_released.rs
    @@ -161,7 +161,7 @@ async fn every_released_catalog_upgrades_to_the_definitions_of_a_fresh_install()
                 panic!("{state} is not behind");
             };
             assert_eq!(version, None, "{state} has no ledger");
    -        assert_eq!(pending.len(), 8, "{state}");
    +        assert_eq!(pending.len(), 9, "{state}");
             let report = d.migrate().await.expect("migrate");
             assert_eq!(
                 (report.from, report.to),
    diff --git a/crates/storage-postgres/Cargo.toml b/crates/storage-postgres/Cargo.toml
    index 81b205617..f962e09a2 100755
    --- a/crates/storage-postgres/Cargo.toml
    +++ b/crates/storage-postgres/Cargo.toml
    @@ -21,7 +21,8 @@ test-util = ["extenddb-storage/test-util"]
     # a scratch database, so they need a runtime and the test macro.
     tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
     # The checksum test checks this backend's migration table with the framework's
    -# test helper.
    +# test helper, and the bulk operation store contract tests come from
    +# `extenddb-storage` and run here against a scratch PostgreSQL database.
     extenddb-storage = { workspace = true, features = ["test-util"] }
     # The crate's own tests need `test-hooks` and `test-util`; a dev-dependency on
     # itself turns both features on for test builds only.
    diff --git a/crates/storage-postgres/data_migrations/0008_bulk_operations.sql b/crates/storage-postgres/data_migrations/0008_bulk_operations.sql
    new file mode 100644
    index 000000000..f70c6e487
    --- /dev/null
    +++ b/crates/storage-postgres/data_migrations/0008_bulk_operations.sql
    @@ -0,0 +1,53 @@
    +-- Copyright 2026 ExtendDB contributors
    +-- SPDX-License-Identifier: Apache-2.0
    +--
    +-- Migration 8, bulk_operations, PostgreSQL data database: exactly-once
    +-- application of bulk put rows. The catalog half is migrations/0008. Every
    +-- statement is IF NOT EXISTS, so a second run changes nothing.
    +--
    +-- A condition row is not idempotent under replay: a condition that passed
    +-- before a crash fails on replay against the operation's own first write.
    +-- The catalog checkpoint cannot be atomic with the data write because the
    +-- catalog and the data tables are separate databases (ADR-0003), so the fact
    +-- "this row already happened" lives here, beside the items, and rides the
    +-- same transaction as the item write. `gsi_pending` is the precedent: a
    +-- bookkeeping row inserted in the write transaction.
    +
    +-- Every row a bulk write batch applies, plain or conditional, with the size
    +-- of the item it replaced and, when the runner asks, that item. A run
    +-- resumed after a crash, a lease takeover or a transient fault reads the
    +-- rows recorded past its checkpoint, does not write them again, and
    +-- accounts for them from what the ledger kept. `outcome` is 'applied'
    +-- (inserted with the write) or 'condition_failed' (inserted alone, because
    +-- a failed condition writes nothing else). `finish` and the retention purge
    +-- delete an operation's rows through DataEngine::delete_bulk_row_outcomes.
    +--
    +-- Keyed by account because the catalog primary key permits the same
    +-- operation_id in two accounts; an unscoped key would interleave two
    +-- operations' rows on an id collision.
    +CREATE TABLE IF NOT EXISTS bulk_row_outcomes (
    +    account_id   TEXT NOT NULL,
    +    operation_id TEXT NOT NULL,
    +    file_index   BIGINT NOT NULL,
    +    row_index    BIGINT NOT NULL,
    +    outcome      TEXT NOT NULL,
    +    old_size     BIGINT,
    +    old_image    JSONB,
    +    PRIMARY KEY (account_id, operation_id, file_index, row_index)
    +);
    +
    +-- The one claim whose batches commit for an operation. Each claim writes its
    +-- owner before its first batch; a batch locks the row and writes nothing when
    +-- the owner is not its own, so a runner that lost its lease cannot commit a
    +-- late batch. The row also keeps the checkpoint cursor the claim restored and
    +-- how many claims in a row found it unchanged, which bounds resumes that make
    +-- no progress.
    +CREATE TABLE IF NOT EXISTS bulk_write_owners (
    +    account_id   TEXT NOT NULL,
    +    operation_id TEXT NOT NULL,
    +    owner        TEXT NOT NULL,
    +    cursor_file  BIGINT NOT NULL,
    +    cursor_row   BIGINT NOT NULL,
    +    stalled      BIGINT NOT NULL,
    +    PRIMARY KEY (account_id, operation_id)
    +);
    diff --git a/crates/storage-postgres/migrations/0008_bulk_operations.sql b/crates/storage-postgres/migrations/0008_bulk_operations.sql
    new file mode 100644
    index 000000000..384d1debe
    --- /dev/null
    +++ b/crates/storage-postgres/migrations/0008_bulk_operations.sql
    @@ -0,0 +1,119 @@
    +-- Copyright 2026 ExtendDB contributors
    +-- SPDX-License-Identifier: Apache-2.0
    +--
    +-- Migration 8, bulk_operations, PostgreSQL catalog database: the bulk put
    +-- items records. The data database half is data_migrations/0008. Every
    +-- statement is IF NOT EXISTS, so a second run changes nothing.
    +
    +-- One row per bulk operation, everything the wire surface and the runner
    +-- need: identity, configuration, counters, the resume cursor, and the lease.
    +--
    +-- No foreign key to tables(table_id) on purpose. The record must outlive its
    +-- table: a run whose table is deleted finishes FAILED and stays describable
    +-- until retention removes it. A cascading reference would erase the record
    +-- with the table, and a plain reference would block DeleteTable while a
    +-- terminal record is retained.
    +CREATE TABLE IF NOT EXISTS bulk_operations (
    +    account_id              TEXT NOT NULL,
    +    -- COLLATE "C" so the operation_id tiebreak in the list and claim
    +    -- orderings, and the exclusive list cursor, compare bytewise the way
    +    -- SQLite's memcmp does. Under a locale collation a future id shape with
    +    -- mixed case or punctuation would paginate differently per backend.
    +    operation_id            TEXT COLLATE "C" NOT NULL,
    +    table_id                TEXT NOT NULL,
    +    table_name              TEXT NOT NULL,
    +    table_arn               TEXT NOT NULL,
    +    status                  TEXT NOT NULL,
    +    client_token            TEXT,
    +    client_token_hash       TEXT,
    +    request_fingerprint     TEXT NOT NULL,
    +    input_config            JSONB NOT NULL,
    +    output_config           JSONB NOT NULL,
    +    -- Always '[]': the files are rows of bulk_operation_input_files.
    +    input_files             JSONB NOT NULL,
    +    max_write_request_units BIGINT,
    +    performance_tier        TEXT,
    +    start_time_ms           BIGINT NOT NULL,
    +    end_time_ms             BIGINT,
    +    processed_item_count    BIGINT NOT NULL DEFAULT 0,
    +    failed_item_count       BIGINT NOT NULL DEFAULT 0,
    +    processed_input_bytes   BIGINT NOT NULL DEFAULT 0,
    +    -- NULL until the input is enumerated; a failure born before the walk
    +    -- (missing input bucket, input owner mismatch) never sets it, and
    +    -- Describe then omits TotalInputBytes as the service does.
    +    total_input_bytes       BIGINT,
    +    report_record_count     BIGINT NOT NULL DEFAULT 0,
    +    report_bytes            BIGINT NOT NULL DEFAULT 0,
    +    usage                   JSONB NOT NULL,
    +    failure_code            TEXT,
    +    failure_message         TEXT,
    +    cancel_requested        BOOLEAN NOT NULL DEFAULT FALSE,
    +    cursor_file_index       BIGINT NOT NULL DEFAULT 0,
    +    cursor_row_index        BIGINT NOT NULL DEFAULT 0,
    +    lease_owner             TEXT,
    +    lease_until_ms          BIGINT,
    +    -- What the engine records at Start for the runner (the store and the
    +    -- caller); NULL for a row written without one. Never rendered.
    +    run_context             JSONB,
    +    PRIMARY KEY (account_id, operation_id)
    +);
    +
    +-- At most one non-terminal operation per table. The start transaction's
    +-- INSERT ... ON CONFLICT DO NOTHING runs against this index, so two
    +-- concurrent starts on one table cannot both succeed and the loser is
    +-- answered synchronously.
    +CREATE UNIQUE INDEX IF NOT EXISTS idx_bulk_ops_table_guard
    +    ON bulk_operations (account_id, table_id)
    +    WHERE status IN ('IN_PROGRESS', 'CANCELLING');
    +
    +-- ClientToken uniqueness, per account, on the SHA-256 hash so the unbounded
    +-- token never enters an index. NULL hashes are distinct, so tokenless
    +-- operations never collide here.
    +CREATE UNIQUE INDEX IF NOT EXISTS idx_bulk_ops_token
    +    ON bulk_operations (account_id, client_token_hash);
    +
    +-- ListBulkOperations pages newest first with an exclusive cursor.
    +CREATE INDEX IF NOT EXISTS idx_bulk_ops_list
    +    ON bulk_operations (account_id, start_time_ms DESC, operation_id DESC);
    +
    +-- The claim query filters on status and lease expiry across all accounts.
    +CREATE INDEX IF NOT EXISTS idx_bulk_ops_claim
    +    ON bulk_operations (status, lease_until_ms);
    +
    +-- Report records a checkpoint persisted and the report renderer has not yet
    +-- consumed. `finish` deletes an operation's rows after rendering, and the
    +-- retention purge deletes whatever a crashed run left behind.
    +--
    +-- `record` is serialized JSON text, not jsonb, and TEXT is deliberate: a
    +-- record embeds submitted item values, a DynamoDB string may contain
    +-- U+0000, and jsonb refuses the \u0000 escape. A checkpoint the column
    +-- refuses would be refused again on every retry, leaving the operation
    +-- permanently non-terminal. The SQLite backend stores the same text.
    +CREATE TABLE IF NOT EXISTS bulk_operation_report_records (
    +    account_id   TEXT NOT NULL,
    +    operation_id TEXT NOT NULL,
    +    kind         TEXT NOT NULL,
    +    file_index   BIGINT NOT NULL,
    +    row_index    BIGINT NOT NULL,
    +    record       TEXT NOT NULL,
    +    PRIMARY KEY (account_id, operation_id, kind, file_index, row_index)
    +);
    +
    +-- The input files of a bulk operation, one row per file in list order.
    +-- Start inserts them, and a checkpoint updates the rows of the files its
    +-- cursor passes, so a checkpoint writes only the files it passes.
    +CREATE TABLE IF NOT EXISTS bulk_operation_input_files (
    +    account_id   TEXT NOT NULL,
    +    -- COLLATE "C" as on bulk_operations.operation_id.
    +    operation_id TEXT COLLATE "C" NOT NULL,
    +    file_index   BIGINT NOT NULL,
    +    path         TEXT NOT NULL,
    +    size         BIGINT NOT NULL,
    +    -- Set by the checkpoint that moves the cursor past a file read to its
    +    -- end, with the file's tag.
    +    row_count    BIGINT,
    +    etag         TEXT,
    +    -- Set by the checkpoint that moves the cursor past an abandoned file.
    +    abandon      JSONB,
    +    PRIMARY KEY (account_id, operation_id, file_index)
    +);
    diff --git a/crates/storage-postgres/src/bulk_operation_store/claim.rs b/crates/storage-postgres/src/bulk_operation_store/claim.rs
    new file mode 100644
    index 000000000..30b30bfd9
    --- /dev/null
    +++ b/crates/storage-postgres/src/bulk_operation_store/claim.rs
    @@ -0,0 +1,359 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The lifecycle writes for the `PostgreSQL` `BulkOperationStore`: cancel,
    +//! the runnable claim with `FOR UPDATE SKIP LOCKED` and lease renewal, the
    +//! checkpoint that advances the cursor and persists report records, and the
    +//! terminal finish.
    +
    +use super::*;
    +
    +pub(super) fn request_cancel<'a>(
    +    engine: &'a PostgresEngine,
    +    account_id: &str,
    +    operation_id: &str,
    +) -> BoxFuture<'a, Result> {
    +    let account_id = account_id.to_owned();
    +    let operation_id = operation_id.to_owned();
    +    Box::pin(async move {
    +        // One transaction for the conditional update and the fallback
    +        // read, matching SQLite: a purge landing between the two
    +        // statements must not turn AlreadyTerminal into NotFound.
    +        let mut tx = begin(&engine.pool).await?;
    +        // One conditional update, so the answer is never a racy pre-read.
    +        let updated: Option<(String,)> = sqlx::query_as(
    +            "UPDATE bulk_operations SET cancel_requested = TRUE, status = 'CANCELLING' \
    +             WHERE account_id = $1 AND operation_id = $2 \
    +               AND status IN ('IN_PROGRESS', 'CANCELLING') \
    +               RETURNING status",
    +        )
    +        .bind(&account_id)
    +        .bind(&operation_id)
    +        .fetch_optional(&mut *tx)
    +        .await
    +        .map_err(internal)?;
    +        if updated.is_some() {
    +            tx.commit().await.map_err(internal)?;
    +            return Ok(CancelOutcome::Cancelling);
    +        }
    +        let current: Option<(String,)> = sqlx::query_as(
    +            "SELECT status FROM bulk_operations WHERE account_id = $1 AND operation_id = $2",
    +        )
    +        .bind(&account_id)
    +        .bind(&operation_id)
    +        .fetch_optional(&mut *tx)
    +        .await
    +        .map_err(internal)?;
    +        tx.commit().await.map_err(internal)?;
    +        Ok(match current {
    +            Some((status,)) => CancelOutcome::AlreadyTerminal(status),
    +            None => CancelOutcome::NotFound,
    +        })
    +    })
    +}
    +
    +pub(super) fn claim_runnable<'a>(
    +    engine: &'a PostgresEngine,
    +    owner: &str,
    +    lease: Duration,
    +    max: usize,
    +    exclude_accounts: &[String],
    +    exclude_operations: &[String],
    +) -> BoxFuture<'a, Result, StorageError>> {
    +    let owner = owner.to_owned();
    +    let exclude = exclude_accounts.to_vec();
    +    let exclude_ops = exclude_operations.to_vec();
    +    Box::pin(async move {
    +        // The end time of a row the claim ends as unreadable. Leases are
    +        // not read or written on this clock: the statements below take
    +        // them from the database's clock, so processes on hosts whose
    +        // clocks disagree agree on when a lease runs out.
    +        let now = now_ms();
    +        let lease_ms = lease_millis(lease);
    +        let max = i64::try_from(max).unwrap_or(i64::MAX);
    +        // A claim that finds nothing to claim takes no write lock and opens
    +        // no write transaction: this read selects on the predicate the
    +        // update below selects on, and an idle server answers from it on
    +        // every tick. A row that becomes runnable after it is claimed on the
    +        // next tick.
    +        let runnable: bool = sqlx::query_scalar(
    +            "SELECT EXISTS (SELECT 1 FROM bulk_operations \
    +                WHERE status IN ('IN_PROGRESS', 'CANCELLING') \
    +                  AND (lease_until_ms IS NULL \
    +                       OR lease_until_ms < (extract(epoch FROM clock_timestamp()) * 1000)::bigint) \
    +                  AND account_id <> ALL($1) \
    +                  AND operation_id <> ALL($2))",
    +        )
    +        .bind(&exclude)
    +        .bind(&exclude_ops)
    +        .fetch_one(&engine.pool)
    +        .await
    +        .map_err(internal)?;
    +        if !runnable {
    +            return Ok(Vec::new());
    +        }
    +        // SKIP LOCKED keeps two claiming processes from serialising on
    +        // each other's candidate rows: each takes what the other has not
    +        // locked, and no operation is handed to both. `<> ALL` against an
    +        // empty array is true for every row, so neither exclusion needs
    +        // branching. The lease and the ending of any row that does not
    +        // decode commit together, so a batch never leases a row it does
    +        // not return.
    +        let mut tx = begin(&engine.pool).await?;
    +        let rows = sqlx::query(
    +            "WITH candidates AS (\
    +               SELECT account_id, operation_id FROM bulk_operations \
    +                WHERE status IN ('IN_PROGRESS', 'CANCELLING') \
    +                  AND (lease_until_ms IS NULL \
    +                       OR lease_until_ms < (extract(epoch FROM clock_timestamp()) * 1000)::bigint) \
    +                  AND account_id <> ALL($1) \
    +                  AND operation_id <> ALL($2) \
    +                ORDER BY start_time_ms, operation_id LIMIT $3 \
    +                FOR UPDATE SKIP LOCKED) \
    +             UPDATE bulk_operations b SET lease_owner = $4, \
    +                 lease_until_ms = (extract(epoch FROM clock_timestamp()) * 1000)::bigint + $5 \
    +               FROM candidates c \
    +              WHERE b.account_id = c.account_id AND b.operation_id = c.operation_id \
    +             RETURNING b.*",
    +        )
    +        .bind(&exclude)
    +        .bind(&exclude_ops)
    +        .bind(max)
    +        .bind(&owner)
    +        .bind(lease_ms)
    +        .fetch_all(&mut *tx)
    +        .await
    +        .map_err(internal)?;
    +        // Each row decodes on its own. A row that does not decode is ended
    +        // in this transaction rather than failing the claim, which would
    +        // return none of the rows it had just leased.
    +        let mut records = Vec::with_capacity(rows.len());
    +        for row in &rows {
    +            let key = unreadable::row_key(row)?;
    +            let record = match unreadable::decode_or_log(row, &key, "the claim ends it") {
    +                Some(record) => {
    +                    super::files::with_files_or_log(&mut tx, record, "the claim ends it").await?
    +                }
    +                None => None,
    +            };
    +            match record {
    +                Some(record) => records.push(record),
    +                None => unreadable::fail_unreadable(&mut tx, &key, now).await?,
    +            }
    +        }
    +        tx.commit().await.map_err(internal)?;
    +        // The order of UPDATE ... RETURNING output is unspecified and
    +        // tracks the join plan; the CTE's ORDER BY only chooses which
    +        // rows LIMIT takes. Sort so callers receive the claimed rows
    +        // oldest start first, as SQLite does.
    +        records.sort_unstable_by(|a, b| {
    +            (a.start_time_ms, &a.operation_id).cmp(&(b.start_time_ms, &b.operation_id))
    +        });
    +        Ok(records)
    +    })
    +}
    +
    +pub(super) fn checkpoint<'a>(
    +    engine: &'a PostgresEngine,
    +    account_id: &str,
    +    operation_id: &str,
    +    owner: &str,
    +    lease: Duration,
    +    progress: Progress,
    +) -> BoxFuture<'a, Result> {
    +    let account_id = account_id.to_owned();
    +    let operation_id = operation_id.to_owned();
    +    let owner = owner.to_owned();
    +    Box::pin(async move {
    +        let report_record_count = i64::try_from(progress.report_record_count).unwrap_or(i64::MAX);
    +        let report_bytes = i64::try_from(progress.report_bytes).unwrap_or(i64::MAX);
    +        let mut tx = begin(&engine.pool).await?;
    +        // The renewed deadline is taken from the database's clock, as the
    +        // claim's is.
    +        let updated = sqlx::query(
    +            "UPDATE bulk_operations SET \
    +                 cursor_file_index = $1, cursor_row_index = $2, \
    +                 processed_item_count = $3, failed_item_count = $4, \
    +                 processed_input_bytes = $5, report_record_count = $6, \
    +                 report_bytes = $7, usage = $8, \
    +                 lease_until_ms = (extract(epoch FROM clock_timestamp()) * 1000)::bigint + $9 \
    +             WHERE account_id = $10 AND operation_id = $11 AND lease_owner = $12",
    +        )
    +        .bind(progress.cursor_file_index)
    +        .bind(progress.cursor_row_index)
    +        .bind(progress.processed_item_count)
    +        .bind(progress.failed_item_count)
    +        .bind(progress.processed_input_bytes)
    +        .bind(report_record_count)
    +        .bind(report_bytes)
    +        .bind(&progress.usage)
    +        .bind(lease_millis(lease))
    +        .bind(&account_id)
    +        .bind(&operation_id)
    +        .bind(&owner)
    +        .execute(&mut *tx)
    +        .await
    +        .map_err(internal)?;
    +        if updated.rows_affected() == 0 {
    +            // The rollback discards the transaction, so the record inserts
    +            // below never happen for a lost lease.
    +            tx.rollback().await.map_err(internal)?;
    +            return Ok(LeaseState::Lost);
    +        }
    +        // ON CONFLICT DO NOTHING on the five-column primary key: a record
    +        // is durable exactly when the checkpoint covering its row is, so a
    +        // duplicate key here can only be a replayed row whose record
    +        // already committed. Keeping the first record is the second line
    +        // of defence, the same as the ledger. The body is stored as serialized JSON text, not
    +        // jsonb: a record embeds submitted item values, a DynamoDB string
    +        // may contain U+0000, and jsonb refuses the \u0000 escape, which
    +        // would refuse every retry of the same checkpoint and leave the
    +        // operation permanently non-terminal.
    +        for record in &progress.records {
    +            let body = serde_json::to_string(&record.record)
    +                .map_err(|e| StorageError::Internal(format!("serialize a report record: {e}")))?;
    +            sqlx::query(
    +                "INSERT INTO bulk_operation_report_records \
    +                     (account_id, operation_id, kind, file_index, row_index, record) \
    +                 VALUES ($1, $2, $3, $4, $5, $6) \
    +                 ON CONFLICT DO NOTHING",
    +            )
    +            .bind(&account_id)
    +            .bind(&operation_id)
    +            .bind(record.kind.as_str())
    +            .bind(record.file_index)
    +            .bind(record.row_index)
    +            .bind(&body)
    +            .execute(&mut *tx)
    +            .await
    +            .map_err(internal)?;
    +        }
    +        // The finished files' figures and the abandoned files' outcomes
    +        // land on their rows of `bulk_operation_input_files` in the same
    +        // transaction as the cursor that passed them; no other file's row
    +        // is touched, so a checkpoint costs the same whatever the file
    +        // count. An index past the enumerated files names no row, which is
    +        // a runner defect, and refuses the whole checkpoint.
    +        let past = |index: usize| {
    +            StorageError::Internal(format!(
    +                "input file index {index} is past the enumerated files"
    +            ))
    +        };
    +        for finished in &progress.finished_files {
    +            let rows = i64::try_from(finished.rows).unwrap_or(i64::MAX);
    +            // A file finished without a tag keeps the one the listing gave.
    +            let updated = sqlx::query(
    +                "UPDATE bulk_operation_input_files \
    +                 SET row_count = $4, etag = COALESCE($5, etag) \
    +                 WHERE account_id = $1 AND operation_id = $2 AND file_index = $3",
    +            )
    +            .bind(&account_id)
    +            .bind(&operation_id)
    +            .bind(i64::try_from(finished.index).unwrap_or(i64::MAX))
    +            .bind(rows)
    +            .bind(&finished.etag)
    +            .execute(&mut *tx)
    +            .await
    +            .map_err(internal)?
    +            .rows_affected();
    +            if updated == 0 {
    +                return Err(past(finished.index));
    +            }
    +        }
    +        for abandoned in &progress.abandoned_files {
    +            let stored = FileAbandon {
    +                row: i64::try_from(abandoned.row).unwrap_or(i64::MAX),
    +                kind: abandoned.kind,
    +                bytes: Some(i64::try_from(abandoned.bytes).unwrap_or(i64::MAX)),
    +            }
    +            .to_json();
    +            let updated = sqlx::query(
    +                "UPDATE bulk_operation_input_files SET abandon = $4 \
    +                 WHERE account_id = $1 AND operation_id = $2 AND file_index = $3",
    +            )
    +            .bind(&account_id)
    +            .bind(&operation_id)
    +            .bind(i64::try_from(abandoned.index).unwrap_or(i64::MAX))
    +            .bind(&stored)
    +            .execute(&mut *tx)
    +            .await
    +            .map_err(internal)?
    +            .rows_affected();
    +            if updated == 0 {
    +                return Err(past(abandoned.index));
    +            }
    +        }
    +        tx.commit().await.map_err(internal)?;
    +        Ok(LeaseState::Held)
    +    })
    +}
    +
    +pub(super) fn finish<'a>(
    +    engine: &'a PostgresEngine,
    +    account_id: &str,
    +    operation_id: &str,
    +    owner: &str,
    +    outcome: Terminal,
    +) -> BoxFuture<'a, Result> {
    +    let account_id = account_id.to_owned();
    +    let operation_id = operation_id.to_owned();
    +    let owner = owner.to_owned();
    +    Box::pin(async move {
    +        let mut tx = begin(&engine.pool).await?;
    +        // A CANCELLING row becomes CANCELLED whatever the runner computed:
    +        // a cancel that landed after the runner's last boundary check must
    +        // still win, and the CASE decides that in the same statement that
    +        // checks the lease. RETURNING hands back the status the row now
    +        // stores, so the caller reports the stored status, not the one it
    +        // sent.
    +        let stored: Option = sqlx::query_scalar(
    +            // failure_code and failure_message follow the SAME CASE as
    +            // status: when a cancel forces the row to CANCELLED, the
    +            // runner-computed failure fields are replaced by the pair a
    +            // cancelled operation carries on the wire (measured on the
    +            // service). processed_input_bytes is not touched here, so the
    +            // runner's last checkpointed figure stands: the bytes read
    +            // before the stop, which the service reports for a cancelled
    +            // operation. Column references in a SET list read the pre-update
    +            // row, so every CASE expression tests the same original status.
    +            "UPDATE bulk_operations SET \
    +                 status = CASE WHEN status = 'CANCELLING' THEN 'CANCELLED' ELSE $1 END, \
    +                 end_time_ms = $2, \
    +                 failure_code = CASE WHEN status = 'CANCELLING' THEN $9 ELSE $3 END, \
    +                 failure_message = CASE WHEN status = 'CANCELLING' THEN $10 ELSE $4 END, \
    +                 usage = $5, \
    +                 lease_owner = NULL, lease_until_ms = NULL \
    +             WHERE account_id = $6 AND operation_id = $7 AND lease_owner = $8 \
    +               AND status IN ('IN_PROGRESS', 'CANCELLING') \
    +             RETURNING status",
    +        )
    +        .bind(&outcome.status)
    +        .bind(outcome.end_time_ms)
    +        .bind(&outcome.failure_code)
    +        .bind(&outcome.failure_message)
    +        .bind(&outcome.usage)
    +        .bind(&account_id)
    +        .bind(&operation_id)
    +        .bind(&owner)
    +        .bind(extenddb_storage::CANCELLED_FAILURE_CODE)
    +        .bind(extenddb_storage::CANCELLED_FAILURE_MESSAGE)
    +        .fetch_optional(&mut *tx)
    +        .await
    +        .map_err(internal)?;
    +        let Some(status) = stored else {
    +            tx.rollback().await.map_err(internal)?;
    +            return Ok(FinishState::Lost);
    +        };
    +        sqlx::query(
    +            "DELETE FROM bulk_operation_report_records \
    +             WHERE account_id = $1 AND operation_id = $2",
    +        )
    +        .bind(&account_id)
    +        .bind(&operation_id)
    +        .execute(&mut *tx)
    +        .await
    +        .map_err(internal)?;
    +        tx.commit().await.map_err(internal)?;
    +        Ok(FinishState::Held(status))
    +    })
    +}
    diff --git a/crates/storage-postgres/src/bulk_operation_store/files.rs b/crates/storage-postgres/src/bulk_operation_store/files.rs
    new file mode 100644
    index 000000000..1f9e643e7
    --- /dev/null
    +++ b/crates/storage-postgres/src/bulk_operation_store/files.rs
    @@ -0,0 +1,197 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The input files of a bulk operation, one row per file in
    +//! `bulk_operation_input_files` (catalog migration 8). Start inserts
    +//! them, a checkpoint updates the rows of the files its cursor passes, and
    +//! every full read of the record attaches them. The operation row's own
    +//! `input_files` column keeps an empty array; the per-file figures lived
    +//! there once, and every checkpoint that passed a file rewrote the whole
    +//! list, so a load of N files wrote N lists of N entries.
    +
    +use super::*;
    +
    +/// Insert the files of a new operation in list order, `file_index` from 0,
    +/// in one statement.
    +pub(super) async fn insert_files(
    +    conn: &mut sqlx::PgConnection,
    +    account_id: &str,
    +    operation_id: &str,
    +    files: &[InputFile],
    +) -> Result<(), StorageError> {
    +    if files.is_empty() {
    +        return Ok(());
    +    }
    +    let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
    +    let sizes: Vec = files.iter().map(|f| f.size).collect();
    +    let rows: Vec> = files.iter().map(|f| f.rows).collect();
    +    let etags: Vec> = files.iter().map(|f| f.etag.as_deref()).collect();
    +    let abandons: Vec> = files
    +        .iter()
    +        .map(|f| f.abandon.map(|a| a.to_json().to_string()))
    +        .collect();
    +    sqlx::query(
    +        "INSERT INTO bulk_operation_input_files \
    +             (account_id, operation_id, file_index, path, size, row_count, etag, abandon) \
    +         SELECT $1, $2, f.ord - 1, f.path, f.size, f.row_count, f.etag, f.abandon::jsonb \
    +           FROM UNNEST($3::text[], $4::bigint[], $5::bigint[], $6::text[], $7::text[]) \
    +                WITH ORDINALITY AS f(path, size, row_count, etag, abandon, ord)",
    +    )
    +    .bind(account_id)
    +    .bind(operation_id)
    +    .bind(&paths)
    +    .bind(&sizes)
    +    .bind(&rows)
    +    .bind(&etags)
    +    .bind(&abandons)
    +    .execute(conn)
    +    .await
    +    .map_err(internal)?;
    +    Ok(())
    +}
    +
    +/// One stored file row: index, path, size, row count, tag, abandon.
    +type FileRow = (i64, String, i64, Option, Option, Option);
    +
    +/// The stored file rows of one operation, in list order.
    +async fn fetch_files(
    +    conn: &mut sqlx::PgConnection,
    +    account_id: &str,
    +    operation_id: &str,
    +) -> Result, StorageError> {
    +    sqlx::query_as(
    +        "SELECT file_index, path, size, row_count, etag, abandon \
    +         FROM bulk_operation_input_files \
    +         WHERE account_id = $1 AND operation_id = $2 ORDER BY file_index",
    +    )
    +    .bind(account_id)
    +    .bind(operation_id)
    +    .fetch_all(conn)
    +    .await
    +    .map_err(internal)
    +}
    +
    +/// A stored abandon: absent is no abandon, and anything that is not an
    +/// abandon the store wrote, a JSON `null` included, is refused as
    +/// unreadable, as the SQLite store refuses it.
    +pub(super) fn abandon_from_stored(
    +    stored: Option<&Value>,
    +) -> Result, StorageError> {
    +    let Some(value) = stored else {
    +        return Ok(None);
    +    };
    +    FileAbandon::from_json(value).map(Some).ok_or_else(|| {
    +        StorageError::Internal(
    +            "bulk_operation_input_files row has an unreadable abandon".to_owned(),
    +        )
    +    })
    +}
    +
    +/// Decode the stored file rows into the record's file list. The rows must
    +/// number the files from 0 without a gap.
    +fn decode_files(rows: Vec) -> Result, StorageError> {
    +    rows.into_iter()
    +        .enumerate()
    +        .map(|(position, (index, path, size, rows, etag, abandon))| {
    +            if usize::try_from(index).ok() != Some(position) {
    +                return Err(StorageError::Internal(format!(
    +                    "bulk_operation_input_files has file {index} where file {position} belongs"
    +                )));
    +            }
    +            Ok(InputFile {
    +                path,
    +                size,
    +                rows,
    +                etag,
    +                abandon: abandon_from_stored(abandon.as_ref())?,
    +            })
    +        })
    +        .collect()
    +}
    +
    +/// Attach the stored files to a record decoded from its operation row.
    +pub(super) async fn attach_files(
    +    conn: &mut sqlx::PgConnection,
    +    record: &mut BulkOperationRecord,
    +) -> Result<(), StorageError> {
    +    let rows = fetch_files(conn, &record.account_id, &record.operation_id).await?;
    +    record.input_files = decode_files(rows)?;
    +    Ok(())
    +}
    +
    +/// Attach the stored files to a record the claim or the listing decoded,
    +/// or log at ERROR why they do not decode and answer `None`, the way
    +/// `unreadable::decode_or_log` treats the operation row. A read that fails
    +/// is an error, not an unreadable row.
    +pub(super) async fn with_files_or_log(
    +    conn: &mut sqlx::PgConnection,
    +    mut record: BulkOperationRecord,
    +    action: &str,
    +) -> Result, StorageError> {
    +    let rows = fetch_files(conn, &record.account_id, &record.operation_id).await?;
    +    match decode_files(rows) {
    +        Ok(files) => {
    +            record.input_files = files;
    +            Ok(Some(record))
    +        }
    +        Err(error) => {
    +            tracing::error!(
    +                account_id = %record.account_id,
    +                operation_id = %record.operation_id,
    +                "bulk operation record does not decode, {action}: {error}"
    +            );
    +            Ok(None)
    +        }
    +    }
    +}
    +
    +/// Delete the file rows of one operation, in the caller's transaction.
    +pub(super) async fn delete_files(
    +    conn: &mut sqlx::PgConnection,
    +    account_id: &str,
    +    operation_id: &str,
    +) -> Result<(), StorageError> {
    +    sqlx::query(
    +        "DELETE FROM bulk_operation_input_files WHERE account_id = $1 AND operation_id = $2",
    +    )
    +    .bind(account_id)
    +    .bind(operation_id)
    +    .execute(conn)
    +    .await
    +    .map_err(internal)?;
    +    Ok(())
    +}
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::*;
    +
    +    #[test]
    +    fn a_null_abandon_is_refused_like_an_unreadable_one() {
    +        assert!(
    +            matches!(
    +                abandon_from_stored(Some(&Value::Null)),
    +                Err(StorageError::Internal(_))
    +            ),
    +            "a null abandon reads as unreadable, as on SQLite"
    +        );
    +        assert!(
    +            abandon_from_stored(None)
    +                .expect("no abandon is readable")
    +                .is_none()
    +        );
    +    }
    +
    +    #[test]
    +    fn the_file_rows_must_number_the_files_without_a_gap() {
    +        let row = |index: i64| (index, format!("in/{index}.json"), 1, None, None, None);
    +        assert_eq!(
    +            decode_files(vec![row(0), row(1)]).expect("in order").len(),
    +            2
    +        );
    +        assert!(matches!(
    +            decode_files(vec![row(0), row(2)]),
    +            Err(StorageError::Internal(_))
    +        ));
    +    }
    +}
    diff --git a/crates/storage-postgres/src/bulk_operation_store/index_guard.rs b/crates/storage-postgres/src/bulk_operation_store/index_guard.rs
    new file mode 100644
    index 000000000..597fd7795
    --- /dev/null
    +++ b/crates/storage-postgres/src/bulk_operation_store/index_guard.rs
    @@ -0,0 +1,218 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Keeps a bulk operation and an index creation on one table apart.
    +//!
    +//! Start refuses a table with an index that is not `ACTIVE`, and an index
    +//! creation is refused while the table has an operation `IN_PROGRESS` or
    +//! `CANCELLING`. Each side reads the other's row inside the transaction
    +//! that writes its own, under the `tables` row lock: `UpdateTable` takes it
    +//! `FOR UPDATE` before its index rows are written, and the start
    +//! transaction takes it `FOR SHARE` before it reads the indexes. Whichever
    +//! commits second waits for the first and then reads its committed row, so
    +//! the two are never both accepted. The `indexes`, `vector_indexes`,
    +//! `tables` and `bulk_operations` rows all live in the catalog database.
    +//!
    +//! A start whose call began before or during a build that has finished by
    +//! the time its transaction reads the indexes would read them `ACTIVE`.
    +//! Builds in this process are therefore also counted per table in memory: a
    +//! start takes a [`BuildMark`] when it is called, before any await, and is
    +//! refused when a build was running then or began before its transaction
    +//! read the indexes. A build is counted from just before its catalog commit,
    +//! so an `UpdateTable` that fails before it moves no count. Builds in another
    +//! process are seen through the stored status.
    +//!
    +//! The start transaction waits for the account's advisory lock under a
    +//! `lock_timeout`, then takes the table row `FOR SHARE NOWAIT`. An
    +//! `UpdateTable` that holds the row `FOR UPDATE` takes a second pool
    +//! connection for the new index's data table; starts that each held a
    +//! connection while they waited could take every connection it needs. A
    +//! wait past the timeout, or a row another transaction holds, rolls the
    +//! attempt back, which returns its connection to the pool, and the start
    +//! tries again. The account's lock is therefore never held while a start
    +//! waits for a row, so the account's starts on other tables are not held
    +//! up; and only the start holding the account's lock holds a table's row,
    +//! for the rest of its transaction, so a stream of starts on a table does
    +//! not keep `UpdateTable` and `DeleteTable` on it waiting.
    +
    +use std::collections::HashMap;
    +use std::sync::{LazyLock, Mutex, PoisonError};
    +
    +use extenddb_core::types::GlobalSecondaryIndexUpdate;
    +use extenddb_storage::bulk_operation_store::index_creation_refused_message;
    +
    +use super::*;
    +
    +/// Per table id: how many builds have begun in this process, and how many
    +/// are running. Entries are never removed, so a count never repeats for a
    +/// table.
    +static BUILDS: LazyLock>> =
    +    LazyLock::new(|| Mutex::new(HashMap::new()));
    +
    +/// One table's builds in this process as one read saw them.
    +#[derive(Clone, Copy)]
    +pub(super) struct BuildMark {
    +    begun: u64,
    +    running: u32,
    +}
    +
    +impl BuildMark {
    +    pub(super) fn now(table_id: &str) -> Self {
    +        let builds = BUILDS.lock().unwrap_or_else(PoisonError::into_inner);
    +        let (begun, running) = builds.get(table_id).copied().unwrap_or((0, 0));
    +        Self { begun, running }
    +    }
    +
    +    /// Whether a build was running when this mark was taken.
    +    pub(super) fn building(self) -> bool {
    +        self.running > 0
    +    }
    +
    +    /// Whether a build began between `earlier` and this mark.
    +    pub(super) fn began_since(self, earlier: Self) -> bool {
    +        self.begun != earlier.begun
    +    }
    +}
    +
    +/// Held by `UpdateTable` that creates a global secondary index from just
    +/// before its catalog commit, while it still holds the table row
    +/// `FOR UPDATE`, until it returns, the build included. Dropping it ends the
    +/// build in the count.
    +pub(crate) struct BuildGuard {
    +    table_id: String,
    +}
    +
    +impl BuildGuard {
    +    /// Counts one build on the table as begun and running. `UpdateTable`
    +    /// calls it just before its catalog commit, after every step that can
    +    /// refuse or fail the request, so such a request never moves the count
    +    /// a start compares. A commit that fails after the call stays counted:
    +    /// it can fail after it took effect.
    +    pub(crate) fn begin(table_id: &str) -> Self {
    +        let mut builds = BUILDS.lock().unwrap_or_else(PoisonError::into_inner);
    +        let entry = builds.entry(table_id.to_owned()).or_insert((0, 0));
    +        entry.0 += 1;
    +        entry.1 += 1;
    +        Self {
    +            table_id: table_id.to_owned(),
    +        }
    +    }
    +}
    +
    +impl Drop for BuildGuard {
    +    fn drop(&mut self) {
    +        let mut builds = BUILDS.lock().unwrap_or_else(PoisonError::into_inner);
    +        if let Some(entry) = builds.get_mut(&self.table_id) {
    +            entry.1 = entry.1.saturating_sub(1);
    +        }
    +    }
    +}
    +
    +/// What the start transaction read of its table's row.
    +pub(super) enum RowRead {
    +    /// The table's stored status is `DELETING`, or its row is gone:
    +    /// `DeleteTable` removed it after the start handler's lookup.
    +    Deleting,
    +    /// The row is locked `FOR SHARE` and the table is not being deleted.
    +    Ready,
    +    /// Another transaction holds the row in a mode that conflicts with
    +    /// `FOR SHARE`; nothing was read.
    +    Locked,
    +}
    +
    +/// Whether `error` is the `lock_not_available` (55P03) that a wait past
    +/// `lock_timeout`, or a `NOWAIT` lock that is held, raises.
    +pub(super) fn lock_timed_out(error: &sqlx::Error) -> bool {
    +    error
    +        .as_database_error()
    +        .and_then(sqlx::error::DatabaseError::code)
    +        .is_some_and(|code| code == "55P03")
    +}
    +
    +/// Takes the table row lock the start transaction shares with an index
    +/// creation and with `DeleteTable`, without waiting, and reads the table's
    +/// status with it. A table row that is absent takes no lock and reads as
    +/// [`RowRead::Deleting`]: the start handler found the table at its lookup,
    +/// so `DeleteTable` removed the row since.
    +pub(super) async fn lock_table_row(
    +    conn: &mut sqlx::PgConnection,
    +    account_id: &str,
    +    table_id: &str,
    +) -> Result {
    +    let status: Option = match sqlx::query_scalar(
    +        "SELECT table_status FROM tables WHERE account_id = $1 AND table_id = $2 \
    +         FOR SHARE NOWAIT",
    +    )
    +    .bind(account_id)
    +    .bind(table_id)
    +    .fetch_optional(conn)
    +    .await
    +    {
    +        Ok(status) => status,
    +        Err(error) if lock_timed_out(&error) => return Ok(RowRead::Locked),
    +        Err(error) => return Err(internal(error)),
    +    };
    +    Ok(
    +        if status.as_deref().is_none_or(|status| status == "DELETING") {
    +            RowRead::Deleting
    +        } else {
    +            RowRead::Ready
    +        },
    +    )
    +}
    +
    +/// Whether the table has a secondary or vector index that is not `ACTIVE`.
    +/// Read under the table row lock, which an index creation holds until its
    +/// index rows commit.
    +pub(super) async fn index_busy(
    +    conn: &mut sqlx::PgConnection,
    +    table_id: &str,
    +) -> Result {
    +    let (busy,): (i64,) = sqlx::query_as(
    +        "SELECT COUNT(*) FROM ( \
    +             SELECT index_status FROM indexes WHERE table_id = $1 \
    +             UNION ALL \
    +             SELECT index_status FROM vector_indexes WHERE table_id = $1 \
    +         ) AS states WHERE index_status <> 'ACTIVE'",
    +    )
    +    .bind(table_id)
    +    .fetch_one(conn)
    +    .await
    +    .map_err(internal)?;
    +    Ok(busy > 0)
    +}
    +
    +/// Refuses an `UpdateTable` that creates a global secondary index while the
    +/// table has an operation `IN_PROGRESS` or `CANCELLING`, and otherwise
    +/// answers whether the request creates one. Called inside the
    +/// `UpdateTable` transaction after it took the table row `FOR UPDATE`, so
    +/// a start that holds the row `FOR SHARE` has committed before this reads.
    +/// The caller holds the [`BuildGuard`] it begins just before its commit,
    +/// until it returns.
    +pub(crate) async fn refuse_index_creation_during_run(
    +    conn: &mut sqlx::PgConnection,
    +    account_id: &str,
    +    table_id: &str,
    +    table_name: &str,
    +    updates: &[GlobalSecondaryIndexUpdate],
    +) -> Result {
    +    if !updates.iter().any(|update| update.create.is_some()) {
    +        return Ok(false);
    +    }
    +    let (running,): (i64,) = sqlx::query_as(
    +        "SELECT COUNT(*) FROM bulk_operations \
    +         WHERE account_id = $1 AND table_id = $2 \
    +         AND status IN ('IN_PROGRESS', 'CANCELLING')",
    +    )
    +    .bind(account_id)
    +    .bind(table_id)
    +    .fetch_one(conn)
    +    .await
    +    .map_err(internal)?;
    +    if running > 0 {
    +        return Err(StorageError::IndexesInUse(index_creation_refused_message(
    +            table_name,
    +        )));
    +    }
    +    Ok(true)
    +}
    diff --git a/crates/storage-postgres/src/bulk_operation_store/mod.rs b/crates/storage-postgres/src/bulk_operation_store/mod.rs
    new file mode 100644
    index 000000000..c6914b95d
    --- /dev/null
    +++ b/crates/storage-postgres/src/bulk_operation_store/mod.rs
    @@ -0,0 +1,340 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! `BulkOperationStore` for the `PostgreSQL` backend.
    +//!
    +//! Backs the shared bulk put lifecycle with the `bulk_operations` and
    +//! `bulk_operation_report_records` catalog tables (migration 8). The
    +//! lifecycle itself (claim policy, batching, resume, rendering, terminal
    +//! transitions) lives in the engine, as ADR-0005 requires; this file holds
    +//! only the primitives, and the two rules that are correct only inside one
    +//! transaction: the start transaction and the claim.
    +//!
    +//! Every query binds request data as parameters, per ADR-0002. No identifier
    +//! here is built from request data.
    +//!
    +//! The trait impl below delegates each method to a free function in one of
    +//! the sibling modules, grouped by what its SQL does: `start` (the start
    +//! transaction, token replay, the per-table and account caps), `claim`
    +//! (claim, lease renewal, checkpoint, cancel, finish), and `query` (describe,
    +//! list, report paging, purge). The shared time helpers and the row mappers
    +//! stay here.
    +
    +use std::time::{Duration, SystemTime, UNIX_EPOCH};
    +
    +use futures::future::BoxFuture;
    +use serde_json::Value;
    +use sqlx::Row;
    +use sqlx::postgres::PgRow;
    +
    +use extenddb_storage::error::StorageError;
    +use extenddb_storage::{
    +    BulkOperationListResult, BulkOperationRecord, BulkOperationStore, CancelOutcome, FileAbandon,
    +    FinishState, InputFile, LeaseState, ListCursor, NewBulkOperation, OperationFlags, Progress,
    +    ReportKind, ReportRecord, StartOutcome, Terminal,
    +};
    +
    +use crate::PostgresEngine;
    +
    +mod claim;
    +mod files;
    +mod index_guard;
    +mod query;
    +mod start;
    +mod unreadable;
    +
    +pub(crate) use index_guard::{BuildGuard, refuse_index_creation_during_run};
    +
    +/// `map_err` hands the error by value, so the helper takes it that way.
    +#[allow(clippy::needless_pass_by_value)]
    +fn internal(e: sqlx::Error) -> StorageError {
    +    StorageError::Internal(e.to_string())
    +}
    +
    +/// Current wall clock as epoch milliseconds. Saturates rather than wrapping,
    +/// on a clock before the epoch or after the i64 range.
    +fn now_ms() -> i64 {
    +    let now = i64::try_from(
    +        SystemTime::now()
    +            .duration_since(UNIX_EPOCH)
    +            .unwrap_or_default()
    +            .as_millis(),
    +    )
    +    .unwrap_or(i64::MAX);
    +    #[cfg(test)]
    +    let now = now.saturating_add(pg_tests::clock_skew_ms());
    +    now
    +}
    +
    +/// A `SystemTime` as epoch milliseconds, saturating like [`now_ms`].
    +fn system_time_ms(t: SystemTime) -> i64 {
    +    i64::try_from(t.duration_since(UNIX_EPOCH).unwrap_or_default().as_millis()).unwrap_or(i64::MAX)
    +}
    +
    +/// A lease duration as milliseconds, capped at a hundred years so the
    +/// deadline the database adds it to its own clock stays inside the bigint
    +/// range.
    +fn lease_millis(lease: Duration) -> i64 {
    +    const MAX_LEASE_MS: i64 = 100 * 365 * 24 * 60 * 60 * 1000;
    +    i64::try_from(lease.as_millis())
    +        .unwrap_or(i64::MAX)
    +        .min(MAX_LEASE_MS)
    +}
    +
    +/// The longest a bulk operation store transaction may sit idle between two
    +/// of its statements. A process stopped inside one (paused, swapped out,
    +/// frozen by a debugger or a signal) keeps the operation row locked: a
    +/// cancel waits on it and the claim's `SKIP LOCKED` passes over it for as
    +/// long as the process stays stopped. The server ends such a session after
    +/// this long, which rolls the transaction back and releases the row. The
    +/// statements of a running transaction follow each other within
    +/// milliseconds.
    +pub(crate) const IDLE_IN_TRANSACTION_TIMEOUT: &str = "10s";
    +
    +/// Begin a bulk operation store transaction with
    +/// [`IDLE_IN_TRANSACTION_TIMEOUT`] set for it alone.
    +async fn begin(
    +    pool: &sqlx::PgPool,
    +) -> Result, StorageError> {
    +    let mut tx = pool.begin().await.map_err(internal)?;
    +    sqlx::query("SELECT set_config('idle_in_transaction_session_timeout', $1, true)")
    +        .bind(IDLE_IN_TRANSACTION_TIMEOUT)
    +        .execute(&mut *tx)
    +        .await
    +        .map_err(internal)?;
    +    Ok(tx)
    +}
    +
    +/// One column of a `bulk_operations` row, with the column name in the error.
    +fn col<'r, T>(row: &'r PgRow, name: &str) -> Result
    +where
    +    T: sqlx::Decode<'r, sqlx::Postgres> + sqlx::Type,
    +{
    +    row.try_get::(name)
    +        .map_err(|e| StorageError::Internal(format!("bulk_operations.{name}: {e}")))
    +}
    +
    +/// Whether the operation row's `input_files` column holds what this build
    +/// writes there, an empty array. The files live one row each in
    +/// `bulk_operation_input_files` (see `files`); a row whose column holds
    +/// anything else was not written by this build, and does not decode.
    +fn no_input_files(stored: &Value) -> bool {
    +    stored.as_array().is_some_and(Vec::is_empty)
    +}
    +
    +/// Decode one `bulk_operations` row into the record the trait hands out.
    +fn record_from_row(row: &PgRow) -> Result {
    +    let input_files: Value = col(row, "input_files")?;
    +    if !no_input_files(&input_files) {
    +        return Err(StorageError::Internal(
    +            "bulk_operations.input_files holds a file list, which this build does not write"
    +                .to_owned(),
    +        ));
    +    }
    +    // The totals are non-negative by construction (the store only ever binds
    +    // unsigned figures); a negative value is corruption, not a shape to model.
    +    let unsigned = |name: &str| -> Result {
    +        let value: i64 = col(row, name)?;
    +        u64::try_from(value)
    +            .map_err(|_| StorageError::Internal(format!("bulk_operations.{name} is negative")))
    +    };
    +    // The counters stay `i64` on the record, but a negative stored value is
    +    // the same corruption and gets the same refusal, so Describe, List, and
    +    // the runner's claim all sit behind one boundary.
    +    let non_negative = |name: &str| -> Result {
    +        let value: i64 = col(row, name)?;
    +        if value < 0 {
    +            return Err(StorageError::Internal(format!(
    +                "bulk_operations.{name} is negative"
    +            )));
    +        }
    +        Ok(value)
    +    };
    +    Ok(BulkOperationRecord {
    +        account_id: col(row, "account_id")?,
    +        operation_id: col(row, "operation_id")?,
    +        table_id: col(row, "table_id")?,
    +        table_name: col(row, "table_name")?,
    +        table_arn: col(row, "table_arn")?,
    +        status: col(row, "status")?,
    +        client_token: col(row, "client_token")?,
    +        request_fingerprint: col(row, "request_fingerprint")?,
    +        input_config: col(row, "input_config")?,
    +        output_config: col(row, "output_config")?,
    +        // Attached from `bulk_operation_input_files` by the caller.
    +        input_files: Vec::new(),
    +        max_write_request_units: col(row, "max_write_request_units")?,
    +        performance_tier: col(row, "performance_tier")?,
    +        start_time_ms: col(row, "start_time_ms")?,
    +        end_time_ms: col(row, "end_time_ms")?,
    +        processed_item_count: non_negative("processed_item_count")?,
    +        failed_item_count: non_negative("failed_item_count")?,
    +        processed_input_bytes: non_negative("processed_input_bytes")?,
    +        total_input_bytes: {
    +            let value: Option = col(row, "total_input_bytes")?;
    +            if value.is_some_and(|v| v < 0) {
    +                return Err(StorageError::Internal(
    +                    "bulk_operations.total_input_bytes is negative".to_owned(),
    +                ));
    +            }
    +            value
    +        },
    +        report_record_count: unsigned("report_record_count")?,
    +        report_bytes: unsigned("report_bytes")?,
    +        usage: col(row, "usage")?,
    +        failure_code: col(row, "failure_code")?,
    +        failure_message: col(row, "failure_message")?,
    +        cancel_requested: col(row, "cancel_requested")?,
    +        cursor_file_index: non_negative("cursor_file_index")?,
    +        cursor_row_index: non_negative("cursor_row_index")?,
    +        lease_owner: col(row, "lease_owner")?,
    +        lease_until_ms: col(row, "lease_until_ms")?,
    +        run_context: col(row, "run_context")?,
    +    })
    +}
    +
    +impl BulkOperationStore for PostgresEngine {
    +    fn start_bulk_operation(
    +        &self,
    +        account_id: &str,
    +        req: NewBulkOperation,
    +        account_limit: u32,
    +    ) -> BoxFuture<'_, Result> {
    +        start::start_bulk_operation(self, account_id, req, account_limit)
    +    }
    +
    +    fn get_bulk_operation(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        query::get_bulk_operation(self, account_id, operation_id)
    +    }
    +
    +    fn get_bulk_operation_flags(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        query::get_bulk_operation_flags(self, account_id, operation_id)
    +    }
    +
    +    fn count_non_terminal(&self, account_id: &str) -> BoxFuture<'_, Result> {
    +        start::count_non_terminal(self, account_id)
    +    }
    +
    +    fn count_non_terminal_for_table(
    +        &self,
    +        account_id: &str,
    +        table_id: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        start::count_non_terminal_for_table(self, account_id, table_id)
    +    }
    +
    +    fn count_terminal_born_since(
    +        &self,
    +        account_id: &str,
    +        since_ms: i64,
    +    ) -> BoxFuture<'_, Result> {
    +        start::count_terminal_born_since(self, account_id, since_ms)
    +    }
    +
    +    fn list_bulk_operations(
    +        &self,
    +        account_id: &str,
    +        limit: u32,
    +        after: Option,
    +    ) -> BoxFuture<'_, BulkOperationListResult> {
    +        query::list_bulk_operations(self, account_id, limit, after)
    +    }
    +
    +    fn request_cancel(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        claim::request_cancel(self, account_id, operation_id)
    +    }
    +
    +    fn claim_runnable(
    +        &self,
    +        owner: &str,
    +        lease: Duration,
    +        max: usize,
    +        exclude_accounts: &[String],
    +        exclude_operations: &[String],
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        claim::claim_runnable(
    +            self,
    +            owner,
    +            lease,
    +            max,
    +            exclude_accounts,
    +            exclude_operations,
    +        )
    +    }
    +
    +    fn checkpoint(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        lease: Duration,
    +        progress: Progress,
    +    ) -> BoxFuture<'_, Result> {
    +        claim::checkpoint(self, account_id, operation_id, owner, lease, progress)
    +    }
    +
    +    fn report_records_page(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        kind: ReportKind,
    +        after: Option<(i64, i64)>,
    +        limit: usize,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        query::report_records_page(self, account_id, operation_id, kind, after, limit)
    +    }
    +
    +    fn finish(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        outcome: Terminal,
    +    ) -> BoxFuture<'_, Result> {
    +        claim::finish(self, account_id, operation_id, owner, outcome)
    +    }
    +
    +    fn list_purgeable_before(
    +        &self,
    +        cutoff: SystemTime,
    +        limit: usize,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        query::list_purgeable_before(self, cutoff, limit)
    +    }
    +
    +    fn purge_operations(
    +        &self,
    +        pairs: &[(String, String)],
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        query::purge_operations(self, pairs)
    +    }
    +}
    +
    +#[cfg(test)]
    +mod pg_tests;
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::*;
    +
    +    #[test]
    +    fn only_an_empty_file_list_is_what_this_build_writes() {
    +        assert!(no_input_files(&serde_json::json!([])));
    +        assert!(!no_input_files(
    +            &serde_json::json!([{"path": "in/part-0.json", "size": 1}])
    +        ));
    +        assert!(!no_input_files(&serde_json::json!({})));
    +    }
    +}
    diff --git a/crates/storage-postgres/src/bulk_operation_store/pg_tests.rs b/crates/storage-postgres/src/bulk_operation_store/pg_tests.rs
    new file mode 100644
    index 000000000..609efded9
    --- /dev/null
    +++ b/crates/storage-postgres/src/bulk_operation_store/pg_tests.rs
    @@ -0,0 +1,392 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The `PostgreSQL` bulk operation store against a live server, for the
    +//! behaviour the store's own clock and transactions decide: lease expiry
    +//! is read on the database's clock, a process stopped inside a store
    +//! transaction does not hold the operation row past a bound, and a claim
    +//! that finds nothing to claim takes no write lock.
    +//!
    +//! Each test builds a throwaway catalog database with the shipped catalog
    +//! migrations and drops it when it passes. Needs
    +//! `EXTENDDB_TEST_PG_CONNECTION_STRING`, a base URL with no database
    +//! component; without it every test reports a skip and passes, unless
    +//! `EXTENDDB_TEST_PG_REQUIRED` is set.
    +
    +use std::cell::Cell;
    +use std::time::{Duration, Instant};
    +
    +use sqlx::PgPool;
    +use sqlx::postgres::PgPoolOptions;
    +
    +use super::*;
    +use crate::PostgresConfig;
    +
    +const ACCOUNT: &str = "111111111111";
    +
    +thread_local! {
    +    static CLOCK_SKEW_MS: Cell = const { Cell::new(0) };
    +}
    +
    +/// How far this thread's view of the wall clock runs from the host's, in
    +/// milliseconds; `now_ms` adds it under test. The cases that set it run on
    +/// a current-thread runtime, so every store call they make reads it.
    +pub(super) fn clock_skew_ms() -> i64 {
    +    CLOCK_SKEW_MS.with(Cell::get)
    +}
    +
    +fn set_clock_skew_ms(ms: i64) {
    +    CLOCK_SKEW_MS.with(|skew| skew.set(ms));
    +}
    +
    +struct Scratch {
    +    engine: PostgresEngine,
    +    raw: PgPool,
    +    admin: PgPool,
    +    name: String,
    +}
    +
    +impl Scratch {
    +    async fn cleanup(self) {
    +        let Scratch {
    +            engine,
    +            raw,
    +            admin,
    +            name,
    +        } = self;
    +        engine.pool.close().await;
    +        engine.data_pool.close().await;
    +        raw.close().await;
    +        let _ = sqlx::query(&format!("DROP DATABASE IF EXISTS \"{name}\" WITH (FORCE)"))
    +            .execute(&admin)
    +            .await;
    +        admin.close().await;
    +    }
    +}
    +
    +async fn scratch(test: &str) -> Option {
    +    let Some(base) = std::env::var("EXTENDDB_TEST_PG_CONNECTION_STRING")
    +        .ok()
    +        .filter(|s| !s.trim().is_empty())
    +    else {
    +        assert!(
    +            std::env::var("EXTENDDB_TEST_PG_REQUIRED").map_or(true, |v| v.is_empty()),
    +            "{test}: EXTENDDB_TEST_PG_REQUIRED is set but EXTENDDB_TEST_PG_CONNECTION_STRING is not"
    +        );
    +        eprintln!("SKIP {test}: EXTENDDB_TEST_PG_CONNECTION_STRING is not set");
    +        return None;
    +    };
    +    let base = base.trim_end_matches('/').to_owned();
    +    let name = format!("eddb_bstore_{}", uuid::Uuid::new_v4().simple())[..26].to_owned();
    +    let admin = PgPoolOptions::new()
    +        .max_connections(1)
    +        .connect(&format!("{base}/postgres"))
    +        .await
    +        .expect("connect to the maintenance database");
    +    sqlx::query(&format!("CREATE DATABASE \"{name}\""))
    +        .execute(&admin)
    +        .await
    +        .expect("create the scratch database");
    +    let url = format!("{base}/{name}");
    +    let raw = PgPoolOptions::new()
    +        .max_connections(3)
    +        .connect(&url)
    +        .await
    +        .expect("connect to the scratch database");
    +    // The shipped catalog and data migration files, in name order, on one
    +    // database that stands in for both.
    +    let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
    +    for dir in ["migrations", "data_migrations"] {
    +        let mut files: Vec<_> = std::fs::read_dir(root.join(dir))
    +            .expect("read a migrations directory")
    +            .map(|entry| entry.expect("a directory entry").path())
    +            .filter(|p| p.extension().is_some_and(|e| e == "sql"))
    +            .collect();
    +        files.sort();
    +        for file in files {
    +            let sql = std::fs::read_to_string(&file).expect("read a migration file");
    +            sqlx::raw_sql(&sql)
    +                .execute(&raw)
    +                .await
    +                .unwrap_or_else(|e| panic!("apply {}: {e}", file.display()));
    +        }
    +    }
    +    let engine = PostgresEngine::new(
    +        &PostgresConfig {
    +            connection_string: url,
    +            pool_size: 10,
    +            max_item_size_bytes: 400_000,
    +        },
    +        "us-east-1",
    +    )
    +    .await
    +    .expect("open an engine on the scratch database");
    +    Some(Scratch {
    +        engine,
    +        raw,
    +        admin,
    +        name,
    +    })
    +}
    +
    +/// The database server's clock, in epoch milliseconds.
    +async fn db_now_ms(pool: &PgPool) -> i64 {
    +    sqlx::query_scalar("SELECT (extract(epoch FROM clock_timestamp()) * 1000)::bigint")
    +        .fetch_one(pool)
    +        .await
    +        .expect("read the database clock")
    +}
    +
    +/// Insert an `IN_PROGRESS` operation with no input files and the given
    +/// lease, as the start transaction leaves one once a claim has leased it.
    +async fn insert_running(
    +    pool: &PgPool,
    +    operation_id: &str,
    +    lease_owner: Option<&str>,
    +    lease_until_ms: Option,
    +) {
    +    sqlx::query(
    +        "INSERT INTO bulk_operations (account_id, operation_id, table_id, table_name, \
    +             table_arn, status, request_fingerprint, input_config, output_config, input_files, \
    +             start_time_ms, usage, lease_owner, lease_until_ms) \
    +         VALUES ($1, $2, 'table-1', 't', 'arn:aws:dynamodb:us-east-1:111111111111:table/t', \
    +             'IN_PROGRESS', 'fp', '{}', '{}', '[]', 1000, '{}', $3, $4)",
    +    )
    +    .bind(ACCOUNT)
    +    .bind(operation_id)
    +    .bind(lease_owner)
    +    .bind(lease_until_ms)
    +    .execute(pool)
    +    .await
    +    .expect("insert a running operation");
    +}
    +
    +async fn stored_lease(pool: &PgPool, operation_id: &str) -> (Option, Option) {
    +    sqlx::query_as(
    +        "SELECT lease_owner, lease_until_ms FROM bulk_operations \
    +         WHERE account_id = $1 AND operation_id = $2",
    +    )
    +    .bind(ACCOUNT)
    +    .bind(operation_id)
    +    .fetch_one(pool)
    +    .await
    +    .expect("read the lease")
    +}
    +
    +fn progress_with_one_record() -> Progress {
    +    Progress {
    +        cursor_file_index: 0,
    +        cursor_row_index: 2,
    +        processed_item_count: 2,
    +        failed_item_count: 1,
    +        processed_input_bytes: 20,
    +        report_record_count: 1,
    +        report_bytes: 10,
    +        usage: serde_json::json!({}),
    +        records: vec![ReportRecord {
    +            kind: ReportKind::Error,
    +            file_index: 0,
    +            row_index: 1,
    +            record: serde_json::json!({"itemIndex": 1}),
    +        }],
    +        finished_files: Vec::new(),
    +        abandoned_files: Vec::new(),
    +    }
    +}
    +
    +/// A lease another process wrote is live until the database's clock
    +/// passes it: a process whose own clock runs two minutes ahead does not
    +/// take the operation over while the lease has a minute left.
    +#[tokio::test]
    +async fn a_claim_reads_lease_expiry_on_the_database_clock() {
    +    let Some(s) = scratch("a_claim_reads_lease_expiry_on_the_database_clock").await else {
    +        return;
    +    };
    +    let now = db_now_ms(&s.raw).await;
    +    insert_running(&s.raw, "op-live", Some("holder"), Some(now + 60_000)).await;
    +
    +    set_clock_skew_ms(120_000);
    +    let claimed = s
    +        .engine
    +        .claim_runnable("ahead", Duration::from_secs(60), 1, &[], &[])
    +        .await;
    +    set_clock_skew_ms(0);
    +    let claimed = claimed.expect("claim");
    +    assert!(
    +        claimed.is_empty(),
    +        "a lease the database clock holds live was taken over by a process whose clock runs ahead"
    +    );
    +    assert_eq!(
    +        stored_lease(&s.raw, "op-live").await.0.as_deref(),
    +        Some("holder")
    +    );
    +    s.cleanup().await;
    +}
    +
    +/// A lease granted by a process whose clock runs two minutes behind lasts
    +/// its full length on the database's clock, at the claim and at every
    +/// checkpoint, so other processes do not find it already run out.
    +#[tokio::test]
    +async fn a_lease_granted_by_a_slow_clock_runs_its_full_length() {
    +    let Some(s) = scratch("a_lease_granted_by_a_slow_clock_runs_its_full_length").await else {
    +        return;
    +    };
    +    insert_running(&s.raw, "op-new", None, None).await;
    +
    +    set_clock_skew_ms(-120_000);
    +    let claimed = s
    +        .engine
    +        .claim_runnable("behind", Duration::from_secs(60), 1, &[], &[])
    +        .await;
    +    set_clock_skew_ms(0);
    +    assert_eq!(claimed.expect("claim").len(), 1, "the operation is claimed");
    +    let (_, until) = stored_lease(&s.raw, "op-new").await;
    +    let now = db_now_ms(&s.raw).await;
    +    let until = until.expect("the claim set a lease");
    +    assert!(
    +        until >= now + 55_000,
    +        "the claimed lease runs {} ms past the database clock",
    +        until - now
    +    );
    +    let taken = s
    +        .engine
    +        .claim_runnable("other", Duration::from_secs(60), 1, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert!(taken.is_empty(), "the fresh lease is not taken over");
    +
    +    set_clock_skew_ms(-120_000);
    +    let state = s
    +        .engine
    +        .checkpoint(
    +            ACCOUNT,
    +            "op-new",
    +            "behind",
    +            Duration::from_secs(60),
    +            progress_with_one_record(),
    +        )
    +        .await;
    +    set_clock_skew_ms(0);
    +    assert!(matches!(state.expect("checkpoint"), LeaseState::Held));
    +    let (_, until) = stored_lease(&s.raw, "op-new").await;
    +    let now = db_now_ms(&s.raw).await;
    +    let until = until.expect("the checkpoint renewed the lease");
    +    assert!(
    +        until >= now + 55_000,
    +        "the renewed lease runs {} ms past the database clock",
    +        until - now
    +    );
    +    s.cleanup().await;
    +}
    +
    +/// A process stopped inside a checkpoint transaction, after its update of
    +/// the operation row and before its commit, holds that row locked. A cancel
    +/// from another process answers `CANCELLING` within the bound the store
    +/// puts on an idle transaction, and once the stopped process's lease runs
    +/// out another process claims the operation.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_process_stopped_inside_a_checkpoint_releases_the_operation_row() {
    +    let Some(s) = scratch("a_process_stopped_inside_a_checkpoint_releases_the_operation_row").await
    +    else {
    +        return;
    +    };
    +    insert_running(&s.raw, "op-stopped", None, None).await;
    +    let claimed = s
    +        .engine
    +        .claim_runnable("stopped", Duration::from_secs(2), 1, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert_eq!(claimed.len(), 1);
    +
    +    // A transaction of the test's own holds the report record key the
    +    // checkpoint inserts, so the checkpoint stops at that insert with the
    +    // operation row already updated.
    +    let mut blocker = s.raw.begin().await.expect("begin the blocker");
    +    sqlx::query(
    +        "INSERT INTO bulk_operation_report_records \
    +             (account_id, operation_id, kind, file_index, row_index, record) \
    +         VALUES ($1, 'op-stopped', 'error', 0, 1, '{}')",
    +    )
    +    .bind(ACCOUNT)
    +    .execute(&mut *blocker)
    +    .await
    +    .expect("hold the record key");
    +    let mut checkpoint = Box::pin(s.engine.checkpoint(
    +        ACCOUNT,
    +        "op-stopped",
    +        "stopped",
    +        Duration::from_secs(2),
    +        progress_with_one_record(),
    +    ));
    +    assert!(
    +        tokio::time::timeout(Duration::from_millis(500), checkpoint.as_mut())
    +            .await
    +            .is_err(),
    +        "the checkpoint waits at its record insert"
    +    );
    +    // The insert now completes on the server, and the checkpoint is never
    +    // polled again: its connection sits idle inside the transaction,
    +    // holding the operation row, as a process stopped there leaves it.
    +    blocker.rollback().await.expect("release the record key");
    +    tokio::time::sleep(Duration::from_millis(300)).await;
    +
    +    let asked = Instant::now();
    +    let cancel = tokio::time::timeout(
    +        Duration::from_secs(40),
    +        s.engine.request_cancel(ACCOUNT, "op-stopped"),
    +    )
    +    .await
    +    .expect("the cancel answers while the stopped transaction is open")
    +    .expect("cancel");
    +    let waited = asked.elapsed();
    +    assert!(
    +        matches!(cancel, CancelOutcome::Cancelling),
    +        "the cancel answers CANCELLING"
    +    );
    +    assert!(
    +        waited < Duration::from_secs(25),
    +        "the cancel waited {waited:?} for the stopped transaction"
    +    );
    +    let taken = s
    +        .engine
    +        .claim_runnable("taker", Duration::from_secs(60), 1, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert_eq!(
    +        taken
    +            .iter()
    +            .map(|r| r.operation_id.as_str())
    +            .collect::>(),
    +        vec!["op-stopped"],
    +        "the operation is claimed once the stopped process's lease has run out"
    +    );
    +    drop(checkpoint);
    +    s.cleanup().await;
    +}
    +
    +/// A claim that finds nothing to claim takes no write lock: it answers
    +/// while another session holds `bulk_operations` locked against writes.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn an_idle_claim_takes_no_write_lock() {
    +    let Some(s) = scratch("an_idle_claim_takes_no_write_lock").await else {
    +        return;
    +    };
    +    let now = db_now_ms(&s.raw).await;
    +    insert_running(&s.raw, "op-leased", Some("holder"), Some(now + 60_000)).await;
    +    let mut holder = s.raw.begin().await.expect("begin the holder");
    +    sqlx::query("LOCK TABLE bulk_operations IN SHARE MODE")
    +        .execute(&mut *holder)
    +        .await
    +        .expect("lock the table against writes");
    +    let claimed = tokio::time::timeout(
    +        Duration::from_secs(5),
    +        s.engine
    +            .claim_runnable("idle", Duration::from_secs(60), 1, &[], &[]),
    +    )
    +    .await
    +    .expect("an idle claim answers while the table is locked against writes")
    +    .expect("claim");
    +    assert!(claimed.is_empty());
    +    holder.rollback().await.expect("release the table");
    +    s.cleanup().await;
    +}
    diff --git a/crates/storage-postgres/src/bulk_operation_store/query.rs b/crates/storage-postgres/src/bulk_operation_store/query.rs
    new file mode 100644
    index 000000000..11c829edd
    --- /dev/null
    +++ b/crates/storage-postgres/src/bulk_operation_store/query.rs
    @@ -0,0 +1,286 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The read and purge paths for the `PostgreSQL` `BulkOperationStore`:
    +//! describe one operation, list an account's operations by page, page a
    +//! report's records, and the two-step purge of terminal operations.
    +
    +use super::*;
    +
    +pub(super) fn get_bulk_operation<'a>(
    +    engine: &'a PostgresEngine,
    +    account_id: &str,
    +    operation_id: &str,
    +) -> BoxFuture<'a, Result, StorageError>> {
    +    let account_id = account_id.to_owned();
    +    let operation_id = operation_id.to_owned();
    +    Box::pin(async move {
    +        let row = sqlx::query(
    +            "SELECT * FROM bulk_operations WHERE account_id = $1 AND operation_id = $2",
    +        )
    +        .bind(&account_id)
    +        .bind(&operation_id)
    +        .fetch_optional(&engine.pool)
    +        .await
    +        .map_err(internal)?;
    +        let Some(row) = row else {
    +            return Ok(None);
    +        };
    +        let mut record = record_from_row(&row)?;
    +        let mut conn = engine.pool.acquire().await.map_err(internal)?;
    +        super::files::attach_files(&mut conn, &mut record).await?;
    +        Ok(Some(record))
    +    })
    +}
    +
    +/// The boundary read: the operation row's status, cancel flag and lease
    +/// holder, without the file list a full read attaches.
    +pub(super) fn get_bulk_operation_flags<'a>(
    +    engine: &'a PostgresEngine,
    +    account_id: &str,
    +    operation_id: &str,
    +) -> BoxFuture<'a, Result, StorageError>> {
    +    let account_id = account_id.to_owned();
    +    let operation_id = operation_id.to_owned();
    +    Box::pin(async move {
    +        let row: Option<(String, bool, Option)> = sqlx::query_as(
    +            "SELECT status, cancel_requested, lease_owner FROM bulk_operations \
    +             WHERE account_id = $1 AND operation_id = $2",
    +        )
    +        .bind(&account_id)
    +        .bind(&operation_id)
    +        .fetch_optional(&engine.pool)
    +        .await
    +        .map_err(internal)?;
    +        Ok(
    +            row.map(|(status, cancel_requested, lease_owner)| OperationFlags {
    +                status,
    +                cancel_requested,
    +                lease_owner,
    +            }),
    +        )
    +    })
    +}
    +
    +pub(super) fn list_bulk_operations<'a>(
    +    engine: &'a PostgresEngine,
    +    account_id: &str,
    +    limit: u32,
    +    after: Option,
    +) -> BoxFuture<'a, BulkOperationListResult> {
    +    let account_id = account_id.to_owned();
    +    Box::pin(async move {
    +        // The trait requires limit >= 1: an empty page for 0 would read
    +        // as an account with no operations.
    +        if limit == 0 {
    +            return Err(StorageError::Validation(
    +                "list_bulk_operations requires a limit of at least 1".to_owned(),
    +            ));
    +        }
    +        let page = usize::try_from(limit)
    +            .map_err(|_| StorageError::Internal("list limit does not fit in usize".to_owned()))?;
    +        // One row past the page decides whether a next cursor exists.
    +        let fetch = i64::from(limit).saturating_add(1);
    +        let rows = match &after {
    +            None => {
    +                sqlx::query(
    +                    "SELECT * FROM bulk_operations WHERE account_id = $1 \
    +                     ORDER BY start_time_ms DESC, operation_id DESC LIMIT $2",
    +                )
    +                .bind(&account_id)
    +                .bind(fetch)
    +                .fetch_all(&engine.pool)
    +                .await
    +            }
    +            // Exclusive cursor: strictly after the cursor row in the
    +            // (start_time_ms DESC, operation_id DESC) ordering, which is a
    +            // row-value comparison below it.
    +            Some(cursor) => {
    +                sqlx::query(
    +                    "SELECT * FROM bulk_operations WHERE account_id = $1 \
    +                     AND (start_time_ms, operation_id) < ($2, $3) \
    +                     ORDER BY start_time_ms DESC, operation_id DESC LIMIT $4",
    +                )
    +                .bind(&account_id)
    +                .bind(cursor.start_time_ms)
    +                .bind(&cursor.operation_id)
    +                .bind(fetch)
    +                .fetch_all(&engine.pool)
    +                .await
    +            }
    +        }
    +        .map_err(internal)?;
    +
    +        // A row that does not decode is logged and left out; its key still
    +        // counts toward the page and the cursor, so the next page starts
    +        // past it rather than failing on it again.
    +        let more = rows.len() > page;
    +        let mut records = Vec::with_capacity(rows.len().min(page));
    +        let mut last = None;
    +        let mut conn = engine.pool.acquire().await.map_err(internal)?;
    +        for row in rows.iter().take(page) {
    +            let key = unreadable::row_key(row)?;
    +            if let Some(record) =
    +                unreadable::decode_or_log(row, &key, "leaving it out of the listing")
    +                && let Some(record) = super::files::with_files_or_log(
    +                    &mut conn,
    +                    record,
    +                    "leaving it out of the listing",
    +                )
    +                .await?
    +            {
    +                records.push(record);
    +            }
    +            last = Some(key);
    +        }
    +        let next = if more {
    +            last.map(|key| ListCursor {
    +                start_time_ms: key.start_time_ms,
    +                operation_id: key.operation_id,
    +            })
    +        } else {
    +            None
    +        };
    +        Ok((records, next))
    +    })
    +}
    +
    +pub(super) fn report_records_page<'a>(
    +    engine: &'a PostgresEngine,
    +    account_id: &str,
    +    operation_id: &str,
    +    kind: ReportKind,
    +    after: Option<(i64, i64)>,
    +    limit: usize,
    +) -> BoxFuture<'a, Result, StorageError>> {
    +    let account_id = account_id.to_owned();
    +    let operation_id = operation_id.to_owned();
    +    Box::pin(async move {
    +        // The trait requires limit >= 1: an empty page for 0 would read
    +        // as the end of the report.
    +        if limit == 0 {
    +            return Err(StorageError::Validation(
    +                "report_records_page requires a limit of at least 1".to_owned(),
    +            ));
    +        }
    +        let limit = i64::try_from(limit).unwrap_or(i64::MAX);
    +        let rows: Vec<(i64, i64, String)> = match after {
    +            // Strictly after the cursor in (file_index, row_index)
    +            // ascending order, as a row-value comparison the way the list
    +            // cursor is.
    +            Some((file, row)) => {
    +                sqlx::query_as(
    +                    "SELECT file_index, row_index, record \
    +                     FROM bulk_operation_report_records \
    +                     WHERE account_id = $1 AND operation_id = $2 AND kind = $3 \
    +                       AND (file_index, row_index) > ($4, $5) \
    +                     ORDER BY file_index, row_index LIMIT $6",
    +                )
    +                .bind(&account_id)
    +                .bind(&operation_id)
    +                .bind(kind.as_str())
    +                .bind(file)
    +                .bind(row)
    +                .bind(limit)
    +                .fetch_all(&engine.pool)
    +                .await
    +            }
    +            None => {
    +                sqlx::query_as(
    +                    "SELECT file_index, row_index, record \
    +                     FROM bulk_operation_report_records \
    +                     WHERE account_id = $1 AND operation_id = $2 AND kind = $3 \
    +                     ORDER BY file_index, row_index LIMIT $4",
    +                )
    +                .bind(&account_id)
    +                .bind(&operation_id)
    +                .bind(kind.as_str())
    +                .bind(limit)
    +                .fetch_all(&engine.pool)
    +                .await
    +            }
    +        }
    +        .map_err(internal)?;
    +        rows.into_iter()
    +            .map(|(file_index, row_index, text)| {
    +                // Stored as serialized JSON text (see the checkpoint
    +                // insert); parse it back into the value the trait hands
    +                // out.
    +                Ok(ReportRecord {
    +                    kind,
    +                    file_index,
    +                    row_index,
    +                    record: serde_json::from_str(&text).map_err(|e| {
    +                        StorageError::Internal(format!("parse a report record: {e}"))
    +                    })?,
    +                })
    +            })
    +            .collect()
    +    })
    +}
    +
    +pub(super) fn list_purgeable_before(
    +    engine: &PostgresEngine,
    +    cutoff: SystemTime,
    +    limit: usize,
    +) -> BoxFuture<'_, Result, StorageError>> {
    +    Box::pin(async move {
    +        let cutoff_ms = system_time_ms(cutoff);
    +        let limit = i64::try_from(limit).unwrap_or(i64::MAX);
    +        sqlx::query_as(
    +            "SELECT account_id, operation_id FROM bulk_operations \
    +             WHERE status NOT IN ('IN_PROGRESS', 'CANCELLING') \
    +               AND end_time_ms IS NOT NULL AND end_time_ms < $1 \
    +             ORDER BY end_time_ms LIMIT $2",
    +        )
    +        .bind(cutoff_ms)
    +        .bind(limit)
    +        .fetch_all(&engine.pool)
    +        .await
    +        .map_err(internal)
    +    })
    +}
    +
    +pub(super) fn purge_operations<'a>(
    +    engine: &'a PostgresEngine,
    +    pairs: &[(String, String)],
    +) -> BoxFuture<'a, Result<(), StorageError>> {
    +    let pairs = pairs.to_vec();
    +    Box::pin(async move {
    +        if pairs.is_empty() {
    +            return Ok(());
    +        }
    +        let mut tx = begin(&engine.pool).await?;
    +        for (account_id, operation_id) in &pairs {
    +            // The row goes first, re-checking the terminal predicate,
    +            // so a pair that does not name a terminal row deletes
    +            // nothing; its report records follow only when the row was
    +            // deleted, in the same transaction.
    +            let deleted = sqlx::query(
    +                "DELETE FROM bulk_operations \
    +                 WHERE account_id = $1 AND operation_id = $2 \
    +                   AND status NOT IN ('IN_PROGRESS', 'CANCELLING')",
    +            )
    +            .bind(account_id)
    +            .bind(operation_id)
    +            .execute(&mut *tx)
    +            .await
    +            .map_err(internal)?
    +            .rows_affected();
    +            if deleted > 0 {
    +                sqlx::query(
    +                    "DELETE FROM bulk_operation_report_records \
    +                     WHERE account_id = $1 AND operation_id = $2",
    +                )
    +                .bind(account_id)
    +                .bind(operation_id)
    +                .execute(&mut *tx)
    +                .await
    +                .map_err(internal)?;
    +                super::files::delete_files(&mut tx, account_id, operation_id).await?;
    +            }
    +        }
    +        tx.commit().await.map_err(internal)?;
    +        Ok(())
    +    })
    +}
    diff --git a/crates/storage-postgres/src/bulk_operation_store/start.rs b/crates/storage-postgres/src/bulk_operation_store/start.rs
    new file mode 100644
    index 000000000..3b208c9ca
    --- /dev/null
    +++ b/crates/storage-postgres/src/bulk_operation_store/start.rs
    @@ -0,0 +1,420 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The start transaction for the `PostgreSQL` `BulkOperationStore`: the
    +//! advisory-locked five-step start, the token replay window, the per-table
    +//! guard and the account and terminal-born caps, plus the two helpers the
    +//! insert and token lookup use.
    +
    +use super::*;
    +
    +use extenddb_storage::CLIENT_TOKEN_WINDOW_MS;
    +use extenddb_storage::bulk_operation_store::TABLE_DELETING_MESSAGE;
    +
    +/// How long one attempt of the start transaction waits for the account's
    +/// advisory lock before it rolls back and gives its connection back to the
    +/// pool (see `index_guard`). The table row is taken without waiting.
    +const LOCK_WAIT: &str = "200ms";
    +
    +/// How long a start keeps trying before it answers that the service is
    +/// unavailable, a retryable answer. The pool waits as long for a connection.
    +const START_LOCK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
    +
    +/// The token lookup steps 2 and 5 of the start transaction share.
    +/// The record a token started inside the replay window: `since_ms` is the
    +/// requesting start's time minus [`CLIENT_TOKEN_WINDOW_MS`], so a record
    +/// at or past the window reads as no record.
    +async fn fetch_by_token(
    +    conn: &mut sqlx::PgConnection,
    +    account_id: &str,
    +    token_hash: &str,
    +    since_ms: i64,
    +) -> Result, StorageError> {
    +    let row = sqlx::query(
    +        "SELECT * FROM bulk_operations \
    +         WHERE account_id = $1 AND client_token_hash = $2 AND start_time_ms > $3",
    +    )
    +    .bind(account_id)
    +    .bind(token_hash)
    +    .bind(since_ms)
    +    .fetch_optional(&mut *conn)
    +    .await
    +    .map_err(internal)?;
    +    let Some(row) = row else {
    +        return Ok(None);
    +    };
    +    let mut record = record_from_row(&row)?;
    +    super::files::attach_files(conn, &mut record).await?;
    +    Ok(Some(record))
    +}
    +
    +/// Step 4 of the start transaction: the insert that can conflict, written
    +/// not to raise. A constraint violation would abort the transaction and
    +/// turn a replayed token into an error, so recovery in step 5 depends on
    +/// ON CONFLICT DO NOTHING rather than on catching a violation. An initial
    +/// failure lands as a row that is terminal from its first instant: FAILED,
    +/// end time equal to start time, no lease. Returns the inserted row, or
    +/// `None` on a conflict.
    +async fn insert_operation_row(
    +    conn: &mut sqlx::PgConnection,
    +    account_id: &str,
    +    req: &NewBulkOperation,
    +) -> Result, StorageError> {
    +    let (status, end_time_ms, failure_code, failure_message) = match &req.initial_failure {
    +        Some(failure) => (
    +            "FAILED",
    +            Some(req.start_time_ms),
    +            Some(failure.code.as_str()),
    +            Some(failure.message.as_str()),
    +        ),
    +        None => ("IN_PROGRESS", None, None, None),
    +    };
    +    sqlx::query(
    +        "INSERT INTO bulk_operations (\
    +             account_id, operation_id, table_id, table_name, table_arn, status, \
    +             client_token, client_token_hash, request_fingerprint, \
    +             input_config, output_config, input_files, \
    +             max_write_request_units, performance_tier, \
    +             start_time_ms, end_time_ms, \
    +             processed_item_count, failed_item_count, \
    +             processed_input_bytes, total_input_bytes, \
    +             report_record_count, report_bytes, \
    +             usage, failure_code, failure_message, cancel_requested, \
    +             cursor_file_index, cursor_row_index, lease_owner, lease_until_ms, \
    +             run_context) \
    +         VALUES ($1, $2, $3, $4, $5, $6, \
    +             $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, \
    +             0, 0, 0, $17, 0, 0, $18, $19, $20, FALSE, 0, 0, NULL, NULL, $21) \
    +         ON CONFLICT DO NOTHING \
    +         RETURNING *",
    +    )
    +    .bind(account_id)
    +    .bind(&req.operation_id)
    +    .bind(&req.table_id)
    +    .bind(&req.table_name)
    +    .bind(&req.table_arn)
    +    .bind(status)
    +    .bind(&req.client_token)
    +    .bind(&req.client_token_hash)
    +    .bind(&req.request_fingerprint)
    +    .bind(&req.input_config)
    +    .bind(&req.output_config)
    +    // The files go one row each into `bulk_operation_input_files`.
    +    .bind(serde_json::json!([]))
    +    .bind(req.max_write_request_units)
    +    .bind(&req.performance_tier)
    +    .bind(req.start_time_ms)
    +    .bind(end_time_ms)
    +    .bind(req.total_input_bytes)
    +    // The first checkpoint overwrites this; until then the operation
    +    // has consumed nothing.
    +    .bind(serde_json::json!({}))
    +    .bind(failure_code)
    +    .bind(failure_message)
    +    .bind(&req.run_context)
    +    .fetch_optional(conn)
    +    .await
    +    .map_err(internal)
    +}
    +
    +pub(super) fn start_bulk_operation<'a>(
    +    engine: &'a PostgresEngine,
    +    account_id: &str,
    +    req: NewBulkOperation,
    +    account_limit: u32,
    +) -> BoxFuture<'a, Result> {
    +    let account_id = account_id.to_owned();
    +    // Taken when the start is called, before any await (see `index_guard`).
    +    let before = super::index_guard::BuildMark::now(&req.table_id);
    +    Box::pin(async move {
    +        if before.building() {
    +            return Ok(StartOutcome::IndexOperationInProgress);
    +        }
    +
    +        // A lock wait past `LOCK_WAIT`, or a table row another transaction
    +        // holds, rolls the attempt back, which gives its connection back to
    +        // the pool, and the start tries again after a pause, until
    +        // `START_LOCK_DEADLINE` (see `index_guard`).
    +        let called = std::time::Instant::now();
    +        let mut pause = std::time::Duration::from_millis(10);
    +        loop {
    +            if let Some(outcome) =
    +                start_attempt(engine, &account_id, &req, account_limit, before).await?
    +            {
    +                return Ok(outcome);
    +            }
    +            if called.elapsed() >= START_LOCK_DEADLINE {
    +                return Err(StorageError::Transient(format!(
    +                    "bulk operation start waited {} s for its account lock or table row",
    +                    START_LOCK_DEADLINE.as_secs()
    +                )));
    +            }
    +            tokio::time::sleep(pause).await;
    +            pause = (pause * 2).min(std::time::Duration::from_millis(200));
    +        }
    +    })
    +}
    +
    +/// One attempt at the start transaction: steps 1 to 5. `Ok(None)` when the
    +/// wait for the account's advisory lock ran past [`LOCK_WAIT`] or the table
    +/// row was held by another transaction; the attempt is rolled back and has
    +/// inserted nothing.
    +async fn start_attempt(
    +    engine: &PostgresEngine,
    +    account_id: &str,
    +    req: &NewBulkOperation,
    +    account_limit: u32,
    +    before: super::index_guard::BuildMark,
    +) -> Result, StorageError> {
    +    let mut tx = begin(&engine.pool).await?;
    +
    +    // The wait for the account's lock in step 1 is bounded, and step 1b
    +    // does not wait (see `index_guard`); the statements after them wait as
    +    // long as they need.
    +    sqlx::query("SELECT set_config('lock_timeout', $1, true)")
    +        .bind(LOCK_WAIT)
    +        .execute(&mut *tx)
    +        .await
    +        .map_err(internal)?;
    +
    +    // Step 1. Serialise starts within one account. The lock is what
    +    // makes the account-limit count in step 3 exact, and it releases
    +    // with the transaction on every return path below.
    +    match sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1))")
    +        .bind(account_id)
    +        .execute(&mut *tx)
    +        .await
    +    {
    +        Ok(_) => {}
    +        Err(error) if super::index_guard::lock_timed_out(&error) => {
    +            tx.rollback().await.map_err(internal)?;
    +            return Ok(None);
    +        }
    +        Err(error) => return Err(internal(error)),
    +    }
    +    sqlx::query("SELECT set_config('lock_timeout', '0', true)")
    +        .execute(&mut *tx)
    +        .await
    +        .map_err(internal)?;
    +
    +    // Step 1b. The table row `FOR SHARE`, the lock an index creation takes
    +    // `FOR UPDATE` and `DeleteTable` takes to set `DELETING` or to remove
    +    // the row (see `index_guard`). Taken without waiting: a row another
    +    // transaction holds ends the attempt, so the account's lock is never
    +    // held while a start waits for a row.
    +    let read = super::index_guard::lock_table_row(&mut tx, account_id, &req.table_id).await?;
    +    if matches!(read, super::index_guard::RowRead::Locked) {
    +        tx.rollback().await.map_err(internal)?;
    +        return Ok(None);
    +    }
    +
    +    // Step 1c. A table being deleted, or removed since the handler's
    +    // lookup, refuses the start with the answer the handler gives at its
    +    // lookup for a table being deleted; then an index that is not ACTIVE,
    +    // or a build in this process that began since the call, refuses it.
    +    if matches!(read, super::index_guard::RowRead::Deleting) {
    +        tx.commit().await.map_err(internal)?;
    +        return Err(StorageError::Validation(TABLE_DELETING_MESSAGE.to_owned()));
    +    }
    +    let busy = super::index_guard::index_busy(&mut tx, &req.table_id).await?;
    +    if busy || super::index_guard::BuildMark::now(&req.table_id).began_since(before) {
    +        tx.commit().await.map_err(internal)?;
    +        return Ok(Some(StartOutcome::IndexOperationInProgress));
    +    }
    +
    +    // Step 2. A token seen inside the replay window answers from
    +    // the existing row. A token whose record is older than the
    +    // window is unseen: its hash is released from the unique index
    +    // (the record keeps the token itself for Describe) and the
    +    // start proceeds as new.
    +    let token_since_ms = req.start_time_ms.saturating_sub(CLIENT_TOKEN_WINDOW_MS);
    +    if let Some(hash) = req.client_token_hash.as_deref() {
    +        if let Some(existing) = fetch_by_token(&mut tx, account_id, hash, token_since_ms).await? {
    +            tx.commit().await.map_err(internal)?;
    +            return Ok(Some(
    +                if existing.request_fingerprint == req.request_fingerprint {
    +                    StartOutcome::IdempotentReplay(existing)
    +                } else {
    +                    StartOutcome::IdempotentMismatch
    +                },
    +            ));
    +        }
    +        sqlx::query(
    +            "UPDATE bulk_operations SET client_token_hash = NULL \
    +             WHERE account_id = $1 AND client_token_hash = $2 AND start_time_ms <= $3",
    +        )
    +        .bind(account_id)
    +        .bind(hash)
    +        .bind(token_since_ms)
    +        .execute(&mut *tx)
    +        .await
    +        .map_err(internal)?;
    +    }
    +
    +    // Step 3a. The per-table guard binds on every start, including one
    +    // whose input configuration already failed: while the table has an
    +    // operation IN_PROGRESS or CANCELLING the start answers busy and
    +    // nothing is inserted (measured on the service: with an operation
    +    // running on the table, a Start naming an input bucket that does
    +    // not exist answered the busy message and created nothing). The
    +    // guard answers before the account limit and before the
    +    // terminal-born cap when either would also reject, so a caller
    +    // blocked by its own running operation learns which table is busy.
    +    // For a start that is not born terminal, the partial unique index
    +    // on the insert below remains the authoritative guard and this read
    +    // only fixes the order of the answers. A row born terminal is
    +    // outside that index, so for it this read is the guard, exact
    +    // because the advisory lock held since step 1 serialises the
    +    // account's starts.
    +    let (table_busy,): (i64,) = sqlx::query_as(
    +        "SELECT COUNT(*) FROM bulk_operations \
    +         WHERE account_id = $1 AND table_id = $2 \
    +         AND status IN ('IN_PROGRESS', 'CANCELLING')",
    +    )
    +    .bind(account_id)
    +    .bind(&req.table_id)
    +    .fetch_one(&mut *tx)
    +    .await
    +    .map_err(internal)?;
    +    if table_busy > 0 {
    +        tx.commit().await.map_err(internal)?;
    +        return Ok(Some(StartOutcome::TableBusy));
    +    }
    +
    +    if let Some(failure) = &req.initial_failure {
    +        // A row born terminal takes no account concurrency slot, so
    +        // step 3b does not apply to it; its own cap does.
    +        //
    +        // Terminal-born cap, counted under the advisory lock held
    +        // since step 1, so a concurrent burst of absent-bucket
    +        // starts cannot overshoot it: each start sees the others'
    +        // committed rows because the lock serialises them. A row
    +        // is terminal-born when its end_time equals its start_time
    +        // and it is FAILED, which is exactly the shape the insert
    +        // below writes.
    +        let (born,): (i64,) = sqlx::query_as(
    +            "SELECT COUNT(*) FROM bulk_operations \
    +             WHERE account_id = $1 AND status = 'FAILED' \
    +               AND end_time_ms = start_time_ms AND start_time_ms >= $2",
    +        )
    +        .bind(account_id)
    +        .bind(failure.window_start_ms)
    +        .fetch_one(&mut *tx)
    +        .await
    +        .map_err(internal)?;
    +        if u64::try_from(born).unwrap_or(0) >= failure.cap {
    +            tx.commit().await.map_err(internal)?;
    +            return Ok(Some(StartOutcome::FailedStartLimit));
    +        }
    +    } else {
    +        // Step 3b. Non-terminal operations count against the
    +        // account limit.
    +        let (running,): (i64,) = sqlx::query_as(
    +            "SELECT COUNT(*) FROM bulk_operations \
    +             WHERE account_id = $1 AND status IN ('IN_PROGRESS', 'CANCELLING')",
    +        )
    +        .bind(account_id)
    +        .fetch_one(&mut *tx)
    +        .await
    +        .map_err(internal)?;
    +        if running >= i64::from(account_limit) {
    +            tx.commit().await.map_err(internal)?;
    +            return Ok(Some(StartOutcome::AccountLimitReached));
    +        }
    +    }
    +
    +    // Step 4, in [`insert_operation_row`]: the insert that can
    +    // conflict, recovered from in step 5 via ON CONFLICT DO
    +    // NOTHING.
    +    let inserted = insert_operation_row(&mut tx, account_id, req).await?;
    +
    +    if let Some(row) = inserted {
    +        let mut record = record_from_row(&row)?;
    +        super::files::insert_files(&mut tx, account_id, &req.operation_id, &req.input_files)
    +            .await?;
    +        record.input_files = req.input_files.clone();
    +        tx.commit().await.map_err(internal)?;
    +        return Ok(Some(StartOutcome::Created(record)));
    +    }
    +
    +    // Step 5. Zero rows and no error: some unique index was hit and
    +    // the transaction is still live. The token lookup decides which
    +    // one it was, so the answer does not depend on which index the
    +    // conflict physically hit.
    +    let outcome = if let Some(hash) = req.client_token_hash.as_deref() {
    +        match fetch_by_token(&mut tx, account_id, hash, token_since_ms).await? {
    +            Some(existing) if existing.request_fingerprint == req.request_fingerprint => {
    +                StartOutcome::IdempotentReplay(existing)
    +            }
    +            Some(_) => StartOutcome::IdempotentMismatch,
    +            None => StartOutcome::TableBusy,
    +        }
    +    } else {
    +        StartOutcome::TableBusy
    +    };
    +    tx.commit().await.map_err(internal)?;
    +    Ok(Some(outcome))
    +}
    +
    +pub(super) fn count_non_terminal_for_table<'a>(
    +    engine: &'a PostgresEngine,
    +    account_id: &str,
    +    table_id: &str,
    +) -> BoxFuture<'a, Result> {
    +    let account_id = account_id.to_owned();
    +    let table_id = table_id.to_owned();
    +    Box::pin(async move {
    +        let (count,): (i64,) = sqlx::query_as(
    +            "SELECT COUNT(*) FROM bulk_operations \
    +             WHERE account_id = $1 AND table_id = $2 \
    +             AND status IN ('IN_PROGRESS', 'CANCELLING')",
    +        )
    +        .bind(&account_id)
    +        .bind(&table_id)
    +        .fetch_one(&engine.pool)
    +        .await
    +        .map_err(internal)?;
    +        Ok(u64::try_from(count).unwrap_or(0))
    +    })
    +}
    +
    +pub(super) fn count_non_terminal<'a>(
    +    engine: &'a PostgresEngine,
    +    account_id: &str,
    +) -> BoxFuture<'a, Result> {
    +    let account_id = account_id.to_owned();
    +    Box::pin(async move {
    +        let (count,): (i64,) = sqlx::query_as(
    +            "SELECT COUNT(*) FROM bulk_operations \
    +             WHERE account_id = $1 AND status IN ('IN_PROGRESS', 'CANCELLING')",
    +        )
    +        .bind(&account_id)
    +        .fetch_one(&engine.pool)
    +        .await
    +        .map_err(internal)?;
    +        // COUNT(*) is non-negative by construction.
    +        Ok(u64::try_from(count).unwrap_or(0))
    +    })
    +}
    +
    +pub(super) fn count_terminal_born_since<'a>(
    +    engine: &'a PostgresEngine,
    +    account_id: &str,
    +    since_ms: i64,
    +) -> BoxFuture<'a, Result> {
    +    let account_id = account_id.to_owned();
    +    Box::pin(async move {
    +        let (count,): (i64,) = sqlx::query_as(
    +            "SELECT COUNT(*) FROM bulk_operations \
    +             WHERE account_id = $1 AND status = 'FAILED' \
    +             AND end_time_ms = start_time_ms AND start_time_ms >= $2",
    +        )
    +        .bind(&account_id)
    +        .bind(since_ms)
    +        .fetch_one(&engine.pool)
    +        .await
    +        .map_err(internal)?;
    +        // COUNT(*) is non-negative by construction.
    +        Ok(u64::try_from(count).unwrap_or(0))
    +    })
    +}
    diff --git a/crates/storage-postgres/src/bulk_operation_store/unreadable.rs b/crates/storage-postgres/src/bulk_operation_store/unreadable.rs
    new file mode 100644
    index 000000000..533dd760c
    --- /dev/null
    +++ b/crates/storage-postgres/src/bulk_operation_store/unreadable.rs
    @@ -0,0 +1,103 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Rows of `bulk_operations` that do not decode into a record. The claim
    +//! ends such a row instead of leasing it, and the listing leaves it out, so
    +//! one row a hand edit or a newer build wrote cannot stop the other
    +//! operations on the server from running or being listed.
    +
    +use extenddb_storage::bulk_operation_store::{UNREADABLE_FAILURE_CODE, UNREADABLE_FAILURE_MESSAGE};
    +use extenddb_storage::{CANCELLED_FAILURE_CODE, CANCELLED_FAILURE_MESSAGE};
    +
    +use super::*;
    +
    +/// The key and ordering columns of a row, read without decoding the rest.
    +pub(super) struct RowKey {
    +    pub account_id: String,
    +    pub operation_id: String,
    +    pub start_time_ms: i64,
    +}
    +
    +pub(super) fn row_key(row: &PgRow) -> Result {
    +    Ok(RowKey {
    +        account_id: col(row, "account_id")?,
    +        operation_id: col(row, "operation_id")?,
    +        start_time_ms: col(row, "start_time_ms")?,
    +    })
    +}
    +
    +/// Decodes one row, or logs at ERROR why it does not decode and answers
    +/// `None`. The line names the account and the operation so an operator can
    +/// find the row, and `action` says what the caller does with it.
    +pub(super) fn decode_or_log(
    +    row: &PgRow,
    +    key: &RowKey,
    +    action: &str,
    +) -> Option {
    +    match record_from_row(row) {
    +        Ok(record) => Some(record),
    +        Err(error) => {
    +            tracing::error!(
    +                account_id = %key.account_id,
    +                operation_id = %key.operation_id,
    +                "bulk operation record does not decode, {action}: {error}"
    +            );
    +            None
    +        }
    +    }
    +}
    +
    +/// Ends a row that does not decode, by the rule `finish` follows: a row
    +/// that is `CANCELLING`, or whose cancel was requested, ends `CANCELLED`
    +/// with the cancelled failure pair, and any other ends `FAILED` with the
    +/// internal failure pair; `end_time_ms` now, lease cleared. The statement
    +/// reads `status` and `cancel_requested` itself, so the ending needs no
    +/// decode and is not refused by a defect in another column. Keyed on the
    +/// primary key alone and guarded on the non-terminal statuses the claim
    +/// selected. The row's report records are deleted with it, in the
    +/// caller's transaction.
    +pub(super) async fn fail_unreadable(
    +    conn: &mut sqlx::PgConnection,
    +    key: &RowKey,
    +    now: i64,
    +) -> Result<(), StorageError> {
    +    // Column references in a SET list read the pre-update row, so every
    +    // CASE tests the same original status.
    +    let ended = sqlx::query(
    +        "UPDATE bulk_operations SET \
    +             status = CASE WHEN status = 'CANCELLING' OR cancel_requested \
    +                 THEN 'CANCELLED' ELSE 'FAILED' END, \
    +             failure_code = CASE WHEN status = 'CANCELLING' OR cancel_requested \
    +                 THEN $1 ELSE $2 END, \
    +             failure_message = CASE WHEN status = 'CANCELLING' OR cancel_requested \
    +                 THEN $3 ELSE $4 END, \
    +             end_time_ms = $5, lease_owner = NULL, lease_until_ms = NULL \
    +         WHERE account_id = $6 AND operation_id = $7 \
    +           AND status IN ('IN_PROGRESS', 'CANCELLING')",
    +    )
    +    .bind(CANCELLED_FAILURE_CODE)
    +    .bind(UNREADABLE_FAILURE_CODE)
    +    .bind(CANCELLED_FAILURE_MESSAGE)
    +    .bind(UNREADABLE_FAILURE_MESSAGE)
    +    .bind(now)
    +    .bind(&key.account_id)
    +    .bind(&key.operation_id)
    +    .execute(&mut *conn)
    +    .await
    +    .map_err(internal)?;
    +    if ended.rows_affected() == 0 {
    +        return Ok(());
    +    }
    +    // The operation's report records go in the same transaction, as
    +    // `finish` deletes them: nothing reads them for a terminal row.
    +    sqlx::query(
    +        "DELETE FROM bulk_operation_report_records \
    +         WHERE account_id = $1 AND operation_id = $2",
    +    )
    +    .bind(&key.account_id)
    +    .bind(&key.operation_id)
    +    .execute(conn)
    +    .await
    +    .map_err(internal)?;
    +    Ok(())
    +}
    diff --git a/crates/storage-postgres/src/data/bulk_row_outcomes.rs b/crates/storage-postgres/src/data/bulk_row_outcomes.rs
    new file mode 100644
    index 000000000..90c536f85
    --- /dev/null
    +++ b/crates/storage-postgres/src/data/bulk_row_outcomes.rs
    @@ -0,0 +1,355 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Row-outcome ledger for bulk put operations (`bulk_row_outcomes`) and
    +//! the operation's write owner (`bulk_write_owners`), both created in the
    +//! data database by migration 8.
    +//!
    +//! The catalog checkpoint cannot be atomic with a data write because the
    +//! catalog and the data tables are separate databases, so the fact "this row
    +//! already happened" lives in the data database and rides the same
    +//! transaction as the item write, the way `gsi_pending` rows do. Every row a
    +//! bulk batch applies is recorded, with the size of the item it replaced and,
    +//! when the runner asks, that item: a resumed run reads them back and
    +//! accounts for the rows without writing them again.
    +//!
    +//! The ledger key is `(account_id, operation_id, file_index, row_index)`:
    +//! the catalog primary key permits the same `operation_id` in two accounts,
    +//! so an unscoped key would interleave two operations' rows on an id
    +//! collision.
    +
    +use std::fmt::Write as _;
    +
    +use extenddb_core::types::{Item, item_size_bytes};
    +use extenddb_storage::error::StorageError;
    +use extenddb_storage::{BulkRowMarker, RecordedRow, RowOutcome};
    +
    +use crate::PostgresEngine;
    +
    +/// One marked row's ledger entry: its outcome, the size of `old`, the item
    +/// the row replaced or was evaluated against, and that item when the marker
    +/// asks to keep it or the condition failed. A batch builds one as each
    +/// marked row is written and inserts them together before its commit.
    +pub(crate) struct LedgerRow<'m> {
    +    marker: &'m BulkRowMarker,
    +    outcome: RowOutcome,
    +    old_size: Option,
    +    old_image: Option,
    +}
    +
    +impl<'m> LedgerRow<'m> {
    +    pub(crate) fn new(
    +        marker: &'m BulkRowMarker,
    +        outcome: RowOutcome,
    +        old: Option<&Item>,
    +    ) -> Result {
    +        let old_size = old.map(|item| i64::try_from(item_size_bytes(item)).unwrap_or(i64::MAX));
    +        let keep = marker.keep_old || outcome == RowOutcome::ConditionFailed;
    +        let old_image = match old {
    +            Some(item) if keep => Some(
    +                serde_json::to_value(item).map_err(|e| StorageError::Internal(e.to_string()))?,
    +            ),
    +            _ => None,
    +        };
    +        Ok(Self {
    +            marker,
    +            outcome,
    +            old_size,
    +            old_image,
    +        })
    +    }
    +}
    +
    +/// Values one ledger row binds: the seven columns of `insert_sql`.
    +const LEDGER_COLUMNS: usize = 7;
    +
    +/// Ledger rows per `INSERT`. A PostgreSQL statement binds at most 65,535
    +/// values: the Bind message counts its parameters in an unsigned 16-bit
    +/// field, and sqlx refuses a query with more before sending it. At seven
    +/// values a row that is 9,362 rows. The bulk runner's batches hold at most
    +/// 100 rows (`bulk_put_write_batch_rows`), so each is one statement.
    +const LEDGER_ROWS_PER_STATEMENT: usize = u16::MAX as usize / LEDGER_COLUMNS;
    +
    +/// The ledger `INSERT` of `rows` rows: `($1, ..., $7), ($8, ..., $14), ...`.
    +fn insert_sql(rows: usize) -> String {
    +    let mut sql = String::from(
    +        "INSERT INTO bulk_row_outcomes \
    +         (account_id, operation_id, file_index, row_index, outcome, old_size, old_image) VALUES ",
    +    );
    +    for row in 0..rows {
    +        sql.push_str(if row == 0 { "(" } else { ", (" });
    +        for column in 0..LEDGER_COLUMNS {
    +            if column > 0 {
    +                sql.push_str(", ");
    +            }
    +            let _ = write!(sql, "${}", row * LEDGER_COLUMNS + column + 1);
    +        }
    +        sql.push(')');
    +    }
    +    sql
    +}
    +
    +/// Insert ledger rows inside an open transaction, one multi-row `INSERT`
    +/// per `LEDGER_ROWS_PER_STATEMENT` rows. A plain insert: a second outcome
    +/// for one position, in these rows or already recorded, means the row was
    +/// submitted after it had been applied, and the primary key fails the
    +/// statement and the transaction with it.
    +pub(crate) async fn insert_outcomes_in_tx(
    +    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    +    rows: &[LedgerRow<'_>],
    +) -> Result<(), StorageError> {
    +    for chunk in rows.chunks(LEDGER_ROWS_PER_STATEMENT) {
    +        let sql = insert_sql(chunk.len());
    +        let mut query = sqlx::query(&sql);
    +        for row in chunk {
    +            query = query
    +                .bind(&row.marker.account_id)
    +                .bind(&row.marker.operation_id)
    +                .bind(row.marker.file_index)
    +                .bind(row.marker.row_index)
    +                .bind(row.outcome.as_str())
    +                .bind(row.old_size)
    +                .bind(&row.old_image);
    +        }
    +        query
    +            .execute(&mut **tx)
    +            .await
    +            .map_err(crate::data::index::db_error)?;
    +    }
    +    Ok(())
    +}
    +
    +/// Insert one ledger row inside an open transaction, for a single write
    +/// that carries a marker outside a batch.
    +pub(crate) async fn insert_outcome_in_tx(
    +    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    +    marker: &BulkRowMarker,
    +    outcome: RowOutcome,
    +    old: Option<&Item>,
    +) -> Result<(), StorageError> {
    +    insert_outcomes_in_tx(tx, &[LedgerRow::new(marker, outcome, old)?]).await
    +}
    +
    +/// The batch-level ledger work, before any row is written: bound the
    +/// transaction's idle time, lock the operation's owner row and refuse the
    +/// batch unless `marker.owner` is the owner it names, then delete the
    +/// ledger rows the last checkpoint covers. The lock makes a takeover wait
    +/// for this batch, or this batch wait for the takeover and then read the
    +/// new owner. A process stopped inside the batch would hold the owner row,
    +/// and the next claim's takeover with it, for as long as it stays stopped,
    +/// so the database ends a batch transaction left idle for the bound the
    +/// bulk store puts on its own transactions, which rolls it back.
    +pub(crate) async fn fence_and_prune(
    +    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    +    marker: &BulkRowMarker,
    +) -> Result<(), StorageError> {
    +    sqlx::query("SELECT set_config('idle_in_transaction_session_timeout', $1, true)")
    +        .bind(crate::bulk_operation_store::IDLE_IN_TRANSACTION_TIMEOUT)
    +        .execute(&mut **tx)
    +        .await
    +        .map_err(crate::data::index::db_error)?;
    +    let owner: Option = sqlx::query_scalar(
    +        "SELECT owner FROM bulk_write_owners \
    +         WHERE account_id = $1 AND operation_id = $2 FOR UPDATE",
    +    )
    +    .bind(&marker.account_id)
    +    .bind(&marker.operation_id)
    +    .fetch_optional(&mut **tx)
    +    .await
    +    .map_err(crate::data::index::db_error)?;
    +    if owner.as_deref() != Some(marker.owner.as_str()) {
    +        return Err(StorageError::Transient(format!(
    +            "bulk write batch refused: operation {} is owned by another claim",
    +            marker.operation_id
    +        )));
    +    }
    +    let (file, row) = marker.settled;
    +    sqlx::query(
    +        "DELETE FROM bulk_row_outcomes WHERE account_id = $1 AND operation_id = $2 \
    +         AND (file_index < $3 OR (file_index = $3 AND row_index < $4))",
    +    )
    +    .bind(&marker.account_id)
    +    .bind(&marker.operation_id)
    +    .bind(file)
    +    .bind(row)
    +    .execute(&mut **tx)
    +    .await
    +    .map_err(crate::data::index::db_error)?;
    +    Ok(())
    +}
    +
    +fn parse_outcome(outcome: &str) -> Result {
    +    RowOutcome::parse(outcome).ok_or_else(|| {
    +        StorageError::Internal(format!(
    +            "bulk_row_outcomes holds an unknown outcome: {outcome}"
    +        ))
    +    })
    +}
    +
    +impl PostgresEngine {
    +    /// Record a failed condition on the single-row path as its own
    +    /// single-statement transaction: a failed condition writes nothing
    +    /// else, so there is no write transaction for the outcome to ride.
    +    pub(crate) async fn record_condition_failed_outcome(
    +        &self,
    +        marker: &BulkRowMarker,
    +        old: Option<&Item>,
    +    ) -> Result<(), StorageError> {
    +        let mut tx = self
    +            .data_pool
    +            .begin()
    +            .await
    +            .map_err(crate::data::index::db_error)?;
    +        insert_outcome_in_tx(&mut tx, marker, RowOutcome::ConditionFailed, old).await?;
    +        tx.commit().await.map_err(crate::data::index::db_error)?;
    +        Ok(())
    +    }
    +
    +    /// Implementation of `DataEngine::bulk_row_outcome`.
    +    pub(crate) async fn bulk_row_outcome_impl(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        file_index: i64,
    +        row_index: i64,
    +    ) -> Result, StorageError> {
    +        let row: Option<(String,)> = sqlx::query_as(
    +            "SELECT outcome FROM bulk_row_outcomes \
    +             WHERE account_id = $1 AND operation_id = $2 \
    +               AND file_index = $3 AND row_index = $4",
    +        )
    +        .bind(account_id)
    +        .bind(operation_id)
    +        .bind(file_index)
    +        .bind(row_index)
    +        .fetch_optional(&self.data_pool)
    +        .await
    +        .map_err(crate::data::index::db_error)?;
    +        row.map(|(outcome,)| parse_outcome(&outcome)).transpose()
    +    }
    +
    +    /// Implementation of `DataEngine::bulk_write_takeover`. The upsert takes
    +    /// the owner row's lock, so it waits for a batch of the earlier owner
    +    /// that holds it.
    +    pub(crate) async fn bulk_write_takeover_impl(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        cursor: (i64, i64),
    +    ) -> Result {
    +        let stalled: i64 = sqlx::query_scalar(
    +            "INSERT INTO bulk_write_owners AS o \
    +             (account_id, operation_id, owner, cursor_file, cursor_row, stalled) \
    +             VALUES ($1, $2, $3, $4, $5, 0) \
    +             ON CONFLICT (account_id, operation_id) DO UPDATE SET \
    +             owner = EXCLUDED.owner, \
    +             stalled = CASE WHEN o.cursor_file = EXCLUDED.cursor_file \
    +               AND o.cursor_row = EXCLUDED.cursor_row THEN o.stalled + 1 ELSE 0 END, \
    +             cursor_file = EXCLUDED.cursor_file, cursor_row = EXCLUDED.cursor_row \
    +             RETURNING stalled",
    +        )
    +        .bind(account_id)
    +        .bind(operation_id)
    +        .bind(owner)
    +        .bind(cursor.0)
    +        .bind(cursor.1)
    +        .fetch_one(&self.data_pool)
    +        .await
    +        .map_err(crate::data::index::db_error)?;
    +        Ok(u32::try_from(stalled).unwrap_or(u32::MAX))
    +    }
    +
    +    /// Implementation of `DataEngine::bulk_rows_recorded`.
    +    pub(crate) async fn bulk_rows_recorded_impl(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        file_index: i64,
    +        from_row: i64,
    +        limit: i64,
    +    ) -> Result, StorageError> {
    +        let rows: Vec<(i64, String, Option, Option)> = sqlx::query_as(
    +            "SELECT row_index, outcome, old_size, old_image FROM bulk_row_outcomes \
    +             WHERE account_id = $1 AND operation_id = $2 AND file_index = $3 \
    +               AND row_index >= $4 ORDER BY row_index LIMIT $5",
    +        )
    +        .bind(account_id)
    +        .bind(operation_id)
    +        .bind(file_index)
    +        .bind(from_row)
    +        .bind(limit)
    +        .fetch_all(&self.data_pool)
    +        .await
    +        .map_err(crate::data::index::db_error)?;
    +        rows.into_iter()
    +            .map(|(row_index, outcome, old_size, old_image)| {
    +                Ok(RecordedRow {
    +                    row_index,
    +                    outcome: parse_outcome(&outcome)?,
    +                    old_size,
    +                    old_image: old_image
    +                        .map(serde_json::from_value)
    +                        .transpose()
    +                        .map_err(|e| StorageError::Internal(e.to_string()))?,
    +                })
    +            })
    +            .collect()
    +    }
    +
    +    /// Implementation of `DataEngine::delete_bulk_row_outcomes`: the
    +    /// operation's ledger rows, then its owner row.
    +    pub(crate) async fn delete_bulk_row_outcomes_impl(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +    ) -> Result {
    +        let result = sqlx::query(
    +            "DELETE FROM bulk_row_outcomes WHERE account_id = $1 AND operation_id = $2",
    +        )
    +        .bind(account_id)
    +        .bind(operation_id)
    +        .execute(&self.data_pool)
    +        .await
    +        .map_err(crate::data::index::db_error)?;
    +        sqlx::query("DELETE FROM bulk_write_owners WHERE account_id = $1 AND operation_id = $2")
    +            .bind(account_id)
    +            .bind(operation_id)
    +            .execute(&self.data_pool)
    +            .await
    +            .map_err(crate::data::index::db_error)?;
    +        Ok(result.rows_affected())
    +    }
    +}
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::*;
    +
    +    #[test]
    +    fn one_ledger_row_is_the_single_row_statement() {
    +        assert_eq!(
    +            insert_sql(1),
    +            "INSERT INTO bulk_row_outcomes \
    +             (account_id, operation_id, file_index, row_index, outcome, old_size, old_image) \
    +             VALUES ($1, $2, $3, $4, $5, $6, $7)"
    +        );
    +    }
    +
    +    #[test]
    +    fn a_full_ledger_statement_numbers_every_value_once() {
    +        let sql = insert_sql(LEDGER_ROWS_PER_STATEMENT);
    +        let last = LEDGER_ROWS_PER_STATEMENT * LEDGER_COLUMNS;
    +        assert_eq!(last, 65_534, "9,362 rows of seven values");
    +        assert!(sql.ends_with(&format!(
    +            "(${}, ${}, ${}, ${}, ${}, ${}, ${last})",
    +            last - 6,
    +            last - 5,
    +            last - 4,
    +            last - 3,
    +            last - 2,
    +            last - 1
    +        )));
    +        assert_eq!(sql.matches('$').count(), last);
    +    }
    +}
    diff --git a/crates/storage-postgres/src/data/data_engine.rs b/crates/storage-postgres/src/data/data_engine.rs
    index 88dd03794..62316c212 100755
    --- a/crates/storage-postgres/src/data/data_engine.rs
    +++ b/crates/storage-postgres/src/data/data_engine.rs
    @@ -39,22 +39,114 @@ impl DataEngine for PostgresEngine {
             condition: Option<&Expr>,
             maps: &ExpressionMaps,
             stream: Option<&StreamCapture>,
    +        marker: Option<&extenddb_storage::BulkRowMarker>,
         ) -> BoxFuture<'_, Result, StorageError>> {
             let key_info = key_info.clone();
             let condition = condition.cloned();
             let maps = maps.clone();
             let stream = stream.cloned();
    +        let marker = marker.cloned();
             Box::pin(async move {
    -            self.put_item_impl(
    -                &key_info,
    -                item,
    -                return_old,
    -                condition.as_ref(),
    -                &maps,
    -                stream.as_ref(),
    -            )
    -            .await
    -            .map_err(|e| table_gone(e, &key_info.table_name))
    +            let result = self
    +                .put_item_impl(
    +                    &key_info,
    +                    item,
    +                    return_old,
    +                    condition.as_ref(),
    +                    &maps,
    +                    stream.as_ref(),
    +                    marker.as_ref(),
    +                )
    +                .await
    +                .map_err(|e| table_gone(e, &key_info.table_name));
    +            // A failed condition writes nothing, so there is no write
    +            // transaction for the marker to ride; it is recorded as its own
    +            // transaction before the error returns, which is the order the
    +            // spec's Durability and restart section fixes. The applied case
    +            // is handled inside `put_item_impl`, in the write transaction.
    +            match (result, marker) {
    +                (Err(StorageError::ConditionFailed(old)), Some(m)) => {
    +                    self.record_condition_failed_outcome(&m, old.as_ref())
    +                        .await?;
    +                    Err(StorageError::ConditionFailed(old))
    +                }
    +                (result, _) => result,
    +            }
    +        })
    +    }
    +
    +    fn put_items_batch(
    +        &self,
    +        key_info: &TableKeyInfo,
    +        rows: Vec,
    +        stream: Option<&StreamCapture>,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        let key_info = key_info.clone();
    +        let stream = stream.cloned();
    +        Box::pin(async move {
    +            self.put_items_batch_impl(&key_info, rows, stream.as_ref())
    +                .await
    +                .map_err(|e| table_gone(e, &key_info.table_name))
    +        })
    +    }
    +
    +    fn bulk_row_outcome(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        file_index: i64,
    +        row_index: i64,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        let account_id = account_id.to_owned();
    +        let operation_id = operation_id.to_owned();
    +        Box::pin(async move {
    +            self.bulk_row_outcome_impl(&account_id, &operation_id, file_index, row_index)
    +                .await
    +        })
    +    }
    +
    +    fn bulk_write_takeover(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        cursor: (i64, i64),
    +    ) -> BoxFuture<'_, Result> {
    +        let account_id = account_id.to_owned();
    +        let operation_id = operation_id.to_owned();
    +        let owner = owner.to_owned();
    +        Box::pin(async move {
    +            self.bulk_write_takeover_impl(&account_id, &operation_id, &owner, cursor)
    +                .await
    +        })
    +    }
    +
    +    fn bulk_rows_recorded(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        file_index: i64,
    +        from_row: i64,
    +        limit: i64,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        let account_id = account_id.to_owned();
    +        let operation_id = operation_id.to_owned();
    +        Box::pin(async move {
    +            self.bulk_rows_recorded_impl(&account_id, &operation_id, file_index, from_row, limit)
    +                .await
    +        })
    +    }
    +
    +    fn delete_bulk_row_outcomes(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        let account_id = account_id.to_owned();
    +        let operation_id = operation_id.to_owned();
    +        Box::pin(async move {
    +            self.delete_bulk_row_outcomes_impl(&account_id, &operation_id)
    +                .await
             })
         }
     
    diff --git a/crates/storage-postgres/src/data/index.rs b/crates/storage-postgres/src/data/index.rs
    index 09c5627d7..014b8e944 100644
    --- a/crates/storage-postgres/src/data/index.rs
    +++ b/crates/storage-postgres/src/data/index.rs
    @@ -36,6 +36,13 @@ pub(crate) fn db_error(e: sqlx::Error) -> StorageError {
     pub(crate) fn sqlstate_message(e: &sqlx::Error) -> String {
         match e.as_database_error().and_then(|d| d.code()) {
             Some(code) => format!("SQLSTATE {code}: {e}"),
    +        // An I/O error on the connection, such as a reset or a close with
    +        // no message from the server: there is no code to keep. It is
    +        // rendered with the code SQLSTATE defines for a lost connection,
    +        // 08006 connection_failure, so every classifier that reads the
    +        // prefix (the bulk write fault class among them) reads it as the
    +        // connection fault it is, on a row statement as on a commit.
    +        None if matches!(e, sqlx::Error::Io(_)) => format!("SQLSTATE 08006: {e}"),
             None => e.to_string(),
         }
     }
    diff --git a/crates/storage-postgres/src/data/mod.rs b/crates/storage-postgres/src/data/mod.rs
    index d8056ea14..9982c03f1 100755
    --- a/crates/storage-postgres/src/data/mod.rs
    +++ b/crates/storage-postgres/src/data/mod.rs
    @@ -132,6 +132,7 @@ macro_rules! bind_sk_execute {
     }
     
     // Submodules declared after macros so they can use bind_sk_fetch_optional/bind_sk_execute.
    +mod bulk_row_outcomes;
     mod data_engine;
     mod ddl;
     mod delete_item;
    diff --git a/crates/storage-postgres/src/data/put_item.rs b/crates/storage-postgres/src/data/put_item.rs
    index 2fbc3102b..dbaccd126 100755
    --- a/crates/storage-postgres/src/data/put_item.rs
    +++ b/crates/storage-postgres/src/data/put_item.rs
    @@ -1,456 +1,473 @@
     // Copyright 2026 ExtendDB contributors
     // SPDX-License-Identifier: Apache-2.0
     
    -//! `put_item` and `get_item` implementations for the `PostgreSQL` backend.
    +//! `put_item`, `put_items_batch`, and `get_item` implementations for the
    +//! `PostgreSQL` backend.
     
     use extenddb_core::expression::{Expr, ExpressionMaps};
     use extenddb_core::types::{Item, TableKeyInfo};
    -use extenddb_storage::StreamCapture;
     use extenddb_storage::error::StorageError;
    -use extenddb_storage::util::{composite_pk_to_text, parse_sk, pk_to_text, sk_column, sk_info};
    -
    -use super::gsi_build::fix_up_stale_write;
    -use super::index::{enqueue_async_indexes, fetch_write_path_indexes, sync_indexes};
    +use extenddb_storage::util::{
    +    SortKeyValue, composite_pk_to_text, parse_sk, pk_to_text, sk_column, sk_info,
    +};
    +use extenddb_storage::vector_lifecycle::VectorIndexMeta;
    +use extenddb_storage::{BatchRow, BatchRowResult, BulkRowMarker, RowOutcome, StreamCapture};
    +
    +use super::gsi_build::{GRACE_WRITE, WriteStamp, fix_up_stale_write};
    +use super::index::{
    +    IndexMeta, db_error, enqueue_async_indexes, fetch_write_path_indexes, fresh_attr_defs,
    +    sync_indexes,
    +};
     use super::query::check_condition;
     use super::tx_helpers::write_stream_record_in_tx;
     use super::{data_table_name, json_to_item};
     use crate::PostgresEngine;
     
    -impl PostgresEngine {
    -    /// Implementation of `DataEngine::put_item`.
    -    pub(crate) async fn put_item_impl(
    +mod batch;
    +
    +/// What one table's write path needs, read once per call. A batch reads it
    +/// once for all its rows, so the whole batch sees one catalog state and one
    +/// stamp, and the stale-write repair runs once for the batch, before its
    +/// commit (`batch::fix_up_stale_batch`).
    +struct WritePlan {
    +    ddb_table: String,
    +    indexes: Vec,
    +    vector_metas: Vec<(VectorIndexMeta, String)>,
    +    /// When the catalog read behind `indexes` happened. A write that commits
    +    /// more than `GRACE_WRITE` after it re-reads the catalog and queues a
    +    /// repair for each GSI it did not know (`gsi_build::fix_up_stale_write`).
    +    stamp: WriteStamp,
    +    sys_delay: u64,
    +    /// SQL for the transactional path, shaped once by the key schema.
    +    select_sql: String,
    +    update_sql: String,
    +    insert_sql: String,
    +}
    +
    +impl WritePlan {
    +    /// Whether a write with these attributes needs the transactional path at
    +    /// all. A plain row on a table with no indexes, no stream, and no marker
    +    /// is one upsert statement. A write that is already slow takes the
    +    /// transaction path even with no index to maintain, so that a repair for
    +    /// a GSI created meanwhile commits with the write instead of after it.
    +    fn needs_tx(
             &self,
    -        key_info: &TableKeyInfo,
    -        item: Item,
    -        return_old: bool,
             condition: Option<&Expr>,
    -        maps: &ExpressionMaps,
    +        return_old: bool,
             stream: Option<&StreamCapture>,
    -    ) -> Result, StorageError> {
    -        let ddb_table = data_table_name(&key_info.table_id);
    +        marker: Option<&BulkRowMarker>,
    +    ) -> bool {
    +        condition.is_some()
    +            || return_old
    +            || !self.indexes.is_empty()
    +            || !self.vector_metas.is_empty()
    +            || stream.is_some()
    +            || marker.is_some()
    +            || self.stamp.elapsed() > GRACE_WRITE
    +    }
    +}
     
    -        let pk_text = composite_pk_to_text(&item, &key_info.key_schema)?;
    +/// The primary key of one row as the data table stores it.
    +struct RowKey {
    +    pk_text: String,
    +    sk: Option,
    +}
     
    -        let item_json =
    -            serde_json::to_value(&item).map_err(|e| StorageError::Internal(e.to_string()))?;
    -
    -        // Both index families in one catalog visit (D-4: sync + async split for the
    -        // secondary indexes).
    -        //
    -        // Vector indexes come from this fresh read rather than from the cached key
    -        // info, and the answer decides two things: whether this write needs a
    -        // transaction at all, and what maintenance runs inside it. A cached empty
    -        // set would send a write down the no-maintenance fast path and silently
    -        // leave an index missing a row.
    -        //
    -        // What this does and does not remove. The defect being designed out is the
    -        // cached membership gate, and that is gone: an index takes effect the moment
    -        // its catalog row commits. What remains is a window between this read and
    -        // the data transaction's commit, in which an index created concurrently is
    -        // missed. No transaction can close it, because the catalog and the data
    -        // tables are different databases. For a GSI it is closed by time instead:
    -        // the read's stamp lets a write that was slow to commit queue a repair
    -        // for any GSI it did not see (`gsi_build::fix_up_stale_write`), and a fast
    -        // write is one the backfill sees. Vector indexes still have the window.
    -        let (indexes, vector_metas, stamp) =
    -            fetch_write_path_indexes(&key_info.table_id, &self.pool).await?;
    +impl RowKey {
    +    fn of(item: &Item, key_info: &TableKeyInfo) -> Result {
    +        let pk_text = composite_pk_to_text(item, &key_info.key_schema)?;
    +        let sk = match sk_info(&key_info.key_schema, &key_info.attribute_definitions) {
    +            Some((sk_name, sk_type)) => {
    +                let sk_value = item
    +                    .get(sk_name)
    +                    .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?;
    +                Some(parse_sk(sk_value, sk_type)?)
    +            }
    +            None => None,
    +        };
    +        Ok(Self { pk_text, sk })
    +    }
    +}
     
    -        // Index key attributes present in the item must match their declared
    -        // scalar type and be non-empty, matching real DynamoDB. This is up-front
    -        // input validation (a top-level ValidationException), so it runs before
    -        // any write work.
    -        if !indexes.is_empty() {
    -            let index_refs: Vec> = indexes
    -                .iter()
    -                .map(|idx| extenddb_core::validation::IndexKeyRef {
    -                    index_name: &idx.index_name,
    -                    key_schema: &idx.key_schema,
    -                })
    -                .collect();
    -            extenddb_core::validation::validate_index_keys(
    -                &item,
    -                &index_refs,
    -                super::index::fresh_attr_defs(&indexes, &key_info.attribute_definitions),
    -            )
    -            .map_err(|e| StorageError::Validation(e.to_string()))?;
    -        }
    +/// Bind `pk` and, when present, `sk` as `$1` and `$2`.
    +fn bind_key<'q, O>(
    +    query: sqlx::query::QueryAs<'q, sqlx::Postgres, O, sqlx::postgres::PgArguments>,
    +    key: &'q RowKey,
    +) -> sqlx::query::QueryAs<'q, sqlx::Postgres, O, sqlx::postgres::PgArguments> {
    +    let query = query.bind(key.pk_text.as_str());
    +    match &key.sk {
    +        Some(SortKeyValue::S(s)) => query.bind(s),
    +        Some(SortKeyValue::N(n)) => query.bind(n),
    +        Some(SortKeyValue::B(b)) => query.bind(b),
    +        None => query,
    +    }
    +}
    +
    +/// Bind `pk`, the optional `sk`, and then `item_data` as the last parameter.
    +fn bind_key_and_item<'q>(
    +    query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
    +    key: &'q RowKey,
    +    item_json: &'q serde_json::Value,
    +) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments> {
    +    let query = query.bind(key.pk_text.as_str());
    +    let query = match &key.sk {
    +        Some(SortKeyValue::S(s)) => query.bind(s),
    +        Some(SortKeyValue::N(n)) => query.bind(n),
    +        Some(SortKeyValue::B(b)) => query.bind(b),
    +        None => query,
    +    };
    +    query.bind(item_json)
    +}
    +
    +/// The locking read of a row's current image inside a transaction.
    +async fn fetch_locked(
    +    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    +    sql: &str,
    +    key: &RowKey,
    +) -> Result, StorageError> {
    +    let row: Option<(serde_json::Value,)> = bind_key(sqlx::query_as(sql), key)
    +        .fetch_optional(&mut **tx)
    +        .await
    +        .map_err(db_error)?;
    +    Ok(row.map(|(v,)| v))
    +}
     
    -        // Read whenever anything can propagate, secondary or vector. Gating this on
    -        // the secondary set alone made a vector-only table ignore the configured
    -        // delay and apply its vector index inline, while a TransactWriteItems on the
    -        // same table read the delay unconditionally and enqueued: six write sites,
    -        // two answers, on a setting the differences doc says covers both index kinds.
    +/// What one row's write inside a transaction produced.
    +struct RowWrite {
    +    /// The replaced image, when one existed.
    +    old: Option,
    +    /// Async index rows enqueued, so the caller can wake the workers after
    +    /// commit.
    +    async_enqueued: usize,
    +}
    +
    +impl PostgresEngine {
    +    /// Read the write plan for one table: the catalog's index families and
    +    /// the propagation delay, plus the statements the key schema shapes.
    +    ///
    +    /// Vector indexes come from this fresh read rather than from the cached
    +    /// key info, and the answer decides two things: whether a write needs a
    +    /// transaction at all, and what maintenance runs inside it. A cached
    +    /// empty set would send a write down the no-maintenance fast path and
    +    /// silently leave an index missing a row. What remains is a window
    +    /// between this read and the data transaction's commit, in which an
    +    /// index created concurrently is missed. No transaction can close it,
    +    /// because the catalog and the data tables are different databases. For
    +    /// a GSI it is closed by time instead: the read's stamp lets a write that
    +    /// was slow to commit queue a repair for any GSI it did not see
    +    /// (`gsi_build::fix_up_stale_write`), and a fast write is one the
    +    /// backfill sees. Vector indexes still have the window.
    +    async fn write_plan(&self, key_info: &TableKeyInfo) -> Result {
    +        let ddb_table = data_table_name(&key_info.table_id);
    +        let (indexes, vector_metas, stamp) =
    +            fetch_write_path_indexes(&key_info.table_id, &self.pool).await?;
    +        // Read whenever anything can propagate, secondary or vector. Gating
    +        // this on the secondary set alone made a vector-only table ignore
    +        // the configured delay and apply its vector index inline, while a
    +        // TransactWriteItems on the same table read the delay
    +        // unconditionally and enqueued.
             let sys_delay = if indexes.is_empty() && vector_metas.is_empty() {
                 0
             } else {
                 self.index_propagation_delay().await
             };
    -
    -        // When there's a condition, return_old, indexes, or stream capture, we need a transaction
    -        // A write that is already slow takes the transaction path even with no
    -        // index to maintain, so that a repair for a GSI created meanwhile commits
    -        // with the write (`gsi_build::fix_up_stale_write`) instead of after it.
    -        let needs_tx = condition.is_some()
    -            || return_old
    -            || !indexes.is_empty()
    -            || !vector_metas.is_empty()
    -            || stream.is_some()
    -            || stamp.elapsed() > super::gsi_build::GRACE_WRITE;
    -
    -        if let Some((sk_name, sk_type)) =
    -            sk_info(&key_info.key_schema, &key_info.attribute_definitions)
    -        {
    -            let sk_value = item
    -                .get(sk_name)
    -                .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?;
    -            let sk = parse_sk(sk_value, sk_type)?;
    -            let sk_col = sk_column(sk_type);
    -
    -            if needs_tx {
    -                let select_sql = format!(
    -                    "SELECT item_data FROM {ddb_table} WHERE pk = $1 AND {sk_col} = $2 FOR UPDATE"
    -                );
    -
    -                let mut tx = self
    -                    .data_pool
    -                    .begin()
    -                    .await
    -                    .map_err(|e| StorageError::Internal(e.to_string()))?;
    -
    -                let mut old: Option<(serde_json::Value,)> =
    -                    bind_sk_fetch_optional!(&select_sql, pk_text.as_str(), &sk, &mut *tx)?;
    -
    -                let mut attempt: u32 = 0;
    -                loop {
    -                    if let Some((ref old_json,)) = old {
    -                        let old_item: Item = json_to_item(old_json.clone())?;
    -                        match check_condition(condition, &old_item, maps) {
    -                            Ok(()) => {}
    -                            Err(StorageError::ConditionFailed(_)) => {
    -                                return Err(StorageError::ConditionFailed(Some(old_item)));
    -                            }
    -                            Err(e) => return Err(e),
    -                        }
    -                        // Row exists, condition passed: update in place.
    -                        let update_sql = format!(
    +        let (select_sql, update_sql, insert_sql) =
    +            match sk_info(&key_info.key_schema, &key_info.attribute_definitions) {
    +                Some((_, sk_type)) => {
    +                    let sk_col = sk_column(sk_type);
    +                    (
    +                        format!(
    +                            "SELECT item_data FROM {ddb_table} WHERE pk = $1 AND {sk_col} = $2 \
    +                             FOR UPDATE"
    +                        ),
    +                        format!(
                                 "UPDATE {ddb_table} SET item_data = $3 WHERE pk = $1 AND {sk_col} = $2"
    -                        );
    -                        bind_sk_execute!(&update_sql, pk_text.as_str(), &sk, &item_json, &mut *tx)?;
    -                        break;
    -                    }
    -                    // No existing item: the condition checks against an empty item
    -                    let empty = std::collections::BTreeMap::new();
    -                    match check_condition(condition, &empty, maps) {
    -                        Ok(()) => {}
    -                        Err(StorageError::ConditionFailed(_)) => {
    -                            return Err(StorageError::ConditionFailed(None));
    -                        }
    -                        Err(e) => return Err(e),
    -                    }
    -                    let insert_sql = format!(
    -                        "INSERT INTO {ddb_table} (pk, {sk_col}, item_data) VALUES ($1, $2, $3) \
    -                         ON CONFLICT (pk, {sk_col}) DO NOTHING"
    -                    );
    -                    let result =
    -                        bind_sk_execute!(&insert_sql, pk_text.as_str(), &sk, &item_json, &mut *tx)?;
    -                    if result.rows_affected() == 1 {
    -                        break;
    -                    }
    -                    // Lost the create race. The winner has committed (the insert
    -                    // waited for it if it was in flight). The locking read returns
    -                    // its row, and this put overwrites it after checking its
    -                    // condition against it. If a delete committed since, the read
    -                    // returns no row and the insert is retried.
    -                    attempt += 1;
    -                    if attempt >= MAX_CREATE_RACE_ATTEMPTS {
    -                        return Err(create_race_exhausted(attempt));
    -                    }
    -                    old = bind_sk_fetch_optional!(&select_sql, pk_text.as_str(), &sk, &mut *tx)?;
    -                }
    -
    -                // Sync GSI/LSI update within transaction (D-4).
    -                let old_item_for_idx = if indexes.is_empty() {
    -                    None
    -                } else {
    -                    let oi = old
    -                        .as_ref()
    -                        .map(|(v,)| json_to_item(v.clone()))
    -                        .transpose()?;
    -                    sync_indexes(
    -                        &mut tx,
    -                        &key_info.key_schema,
    -                        &indexes,
    -                        oi.as_ref(),
    -                        Some(&item),
    -                        sys_delay,
    -                    )
    -                    .await?;
    -                    oi
    -                };
    -
    -                // Write stream record atomically within the transaction.
    -                if let Some(capture) = stream {
    -                    let old_for_stream = old
    -                        .as_ref()
    -                        .map(|(v,)| json_to_item(v.clone()))
    -                        .transpose()?;
    -                    write_stream_record_in_tx(
    -                        &mut tx,
    -                        key_info,
    -                        capture,
    -                        old_for_stream.as_ref(),
    -                        Some(&item),
    +                        ),
    +                        format!(
    +                            "INSERT INTO {ddb_table} (pk, {sk_col}, item_data) VALUES ($1, $2, $3) \
    +                             ON CONFLICT (pk, {sk_col}) DO NOTHING"
    +                        ),
                         )
    -                    .await?;
                     }
    -                // Persist async GSI work inside the same transaction — one row
    -                // per async index, each honoring its own propagation delay.
    -                let mut async_enqueued = enqueue_async_indexes(
    -                    &mut tx,
    -                    key_info,
    -                    &indexes,
    -                    old_item_for_idx.as_ref(),
    -                    Some(&item),
    -                    sys_delay,
    -                )
    -                .await?;
    -
    -                // Vector indexes, read fresh from the catalog rather than from the
    -                // cached key info, so a write cannot miss an index that was just
    -                // created. The old image is needed even when no secondary index
    -                // wanted it: the vector row is keyed by the base item, so a
    -                // partition move is a delete of the previous row.
    -                let old_for_vectors = match old_item_for_idx {
    -                    Some(ref oi) => Some(oi.clone()),
    -                    None => old
    -                        .as_ref()
    -                        .map(|(v,)| json_to_item(v.clone()))
    -                        .transpose()?,
    -                };
    -                async_enqueued += crate::data::vector_index::maintain_vector_indexes(
    -                    &mut tx,
    -                    &vector_metas,
    -                    &key_info.table_id,
    -                    &key_info.key_schema,
    -                    &key_info.attribute_definitions,
    -                    old_for_vectors.as_ref(),
    -                    Some(&item),
    -                    sys_delay,
    -                )
    -                .await?;
    -
    -                async_enqueued +=
    -                    fix_up_stale_write(&mut tx, &self.pool, stamp, key_info, &indexes, &item)
    -                        .await?;
    -
    -                tx.commit()
    -                    .await
    -                    .map_err(|e| StorageError::Internal(e.to_string()))?;
    +                None => (
    +                    format!("SELECT item_data FROM {ddb_table} WHERE pk = $1 FOR UPDATE"),
    +                    format!("UPDATE {ddb_table} SET item_data = $2 WHERE pk = $1"),
    +                    format!(
    +                        "INSERT INTO {ddb_table} (pk, item_data) VALUES ($1, $2) \
    +                         ON CONFLICT (pk) DO NOTHING"
    +                    ),
    +                ),
    +            };
    +        Ok(WritePlan {
    +            ddb_table,
    +            indexes,
    +            vector_metas,
    +            stamp,
    +            sys_delay,
    +            select_sql,
    +            update_sql,
    +            insert_sql,
    +        })
    +    }
     
    -                if async_enqueued > 0
    -                    && let Some(ref q) = self.gsi_queue
    -                {
    -                    q.notify_workers();
    -                }
    +    /// Index key attributes present in the item must match their declared
    +    /// scalar type and be non-empty, matching real DynamoDB. This is
    +    /// up-front input validation (a top-level ValidationException), so it
    +    /// runs before any write work. The types come from the definitions read
    +    /// with the indexes, not the cached key info: an UpdateTable that adds a
    +    /// GSI adds its key attributes' definitions, and the cache is dropped
    +    /// only when that UpdateTable returns (`IndexMeta::attribute_definitions`).
    +    fn validate_index_keys(
    +        plan: &WritePlan,
    +        key_info: &TableKeyInfo,
    +        item: &Item,
    +    ) -> Result<(), StorageError> {
    +        if plan.indexes.is_empty() {
    +            return Ok(());
    +        }
    +        let index_refs: Vec> = plan
    +            .indexes
    +            .iter()
    +            .map(|idx| extenddb_core::validation::IndexKeyRef {
    +                index_name: &idx.index_name,
    +                key_schema: &idx.key_schema,
    +            })
    +            .collect();
    +        extenddb_core::validation::validate_index_keys(
    +            item,
    +            &index_refs,
    +            fresh_attr_defs(&plan.indexes, &key_info.attribute_definitions),
    +        )
    +        .map_err(|e| StorageError::Validation(e.to_string()))
    +    }
     
    -                if return_old {
    -                    old.map(|(v,)| json_to_item(v)).transpose()
    -                } else {
    -                    Ok(None)
    +    /// One row's write inside an open transaction: the locking read, the
    +    /// condition against the current image, the write (with the create-race
    +    /// loop), and index and stream maintenance. The caller records a marked
    +    /// row's outcome from the returned image: a single write right after
    +    /// this returns, a batch with its other rows before its commit.
    +    ///
    +    /// A failed condition returns `Err(ConditionFailed(old))` after the
    +    /// locking read and before any statement that changes a row, so the
    +    /// transaction stays usable and a batch can continue with its next row
    +    /// while the lock on this row is held to commit.
    +    async fn put_row_in_tx(
    +        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    +        plan: &WritePlan,
    +        key_info: &TableKeyInfo,
    +        item: &Item,
    +        condition: Option<&Expr>,
    +        maps: &ExpressionMaps,
    +        stream: Option<&StreamCapture>,
    +    ) -> Result {
    +        let key = RowKey::of(item, key_info)?;
    +        let item_json =
    +            serde_json::to_value(item).map_err(|e| StorageError::Internal(e.to_string()))?;
    +
    +        let mut old = fetch_locked(tx, &plan.select_sql, &key).await?;
    +
    +        let mut attempt: u32 = 0;
    +        loop {
    +            if let Some(ref old_json) = old {
    +                let old_item: Item = json_to_item(old_json.clone())?;
    +                match check_condition(condition, &old_item, maps) {
    +                    Ok(()) => {}
    +                    Err(StorageError::ConditionFailed(_)) => {
    +                        return Err(StorageError::ConditionFailed(Some(old_item)));
    +                    }
    +                    Err(e) => return Err(e),
                     }
    -            } else {
    -                let upsert_sql = format!(
    -                    "INSERT INTO {ddb_table} (pk, {sk_col}, item_data) VALUES ($1, $2, $3) \
    -                     ON CONFLICT (pk, {sk_col}) DO UPDATE SET item_data = EXCLUDED.item_data"
    -                );
    -                super::gsi_build::pause_before_autocommit_statement().await;
    -                bind_sk_execute!(
    -                    &upsert_sql,
    -                    pk_text.as_str(),
    -                    &sk,
    -                    &item_json,
    -                    &self.data_pool
    -                )?;
    -                self.fix_up_autocommit(stamp, key_info, &item).await;
    -                Ok(None)
    -            }
    -        } else {
    -            // No sort key — PK-only table
    -            if needs_tx {
    -                let select_sql =
    -                    format!("SELECT item_data FROM {ddb_table} WHERE pk = $1 FOR UPDATE");
    -
    -                let mut tx = self
    -                    .data_pool
    -                    .begin()
    +                // Row exists, condition passed: update in place.
    +                bind_key_and_item(sqlx::query(&plan.update_sql), &key, &item_json)
    +                    .execute(&mut **tx)
                         .await
    -                    .map_err(|e| StorageError::Internal(e.to_string()))?;
    -
    -                let fetch_old = async |tx: &mut sqlx::PgConnection| {
    -                    sqlx::query_as::<_, (serde_json::Value,)>(&select_sql)
    -                        .bind(pk_text.as_str())
    -                        .fetch_optional(tx)
    -                        .await
    -                        .map_err(|e| StorageError::Internal(e.to_string()))
    -                };
    -                let mut old: Option<(serde_json::Value,)> = fetch_old(&mut tx).await?;
    -
    -                let mut attempt: u32 = 0;
    -                loop {
    -                    if let Some((ref old_json,)) = old {
    -                        let old_item: Item = json_to_item(old_json.clone())?;
    -                        match check_condition(condition, &old_item, maps) {
    -                            Ok(()) => {}
    -                            Err(StorageError::ConditionFailed(_)) => {
    -                                return Err(StorageError::ConditionFailed(Some(old_item)));
    -                            }
    -                            Err(e) => return Err(e),
    -                        }
    -                        // Row exists, condition passed: update in place.
    -                        let update_sql =
    -                            format!("UPDATE {ddb_table} SET item_data = $2 WHERE pk = $1");
    -                        sqlx::query(&update_sql)
    -                            .bind(pk_text.as_str())
    -                            .bind(&item_json)
    -                            .execute(&mut *tx)
    -                            .await
    -                            .map_err(|e| StorageError::Internal(e.to_string()))?;
    -                        break;
    -                    }
    -                    let empty = std::collections::BTreeMap::new();
    -                    match check_condition(condition, &empty, maps) {
    -                        Ok(()) => {}
    -                        Err(StorageError::ConditionFailed(_)) => {
    -                            return Err(StorageError::ConditionFailed(None));
    -                        }
    -                        Err(e) => return Err(e),
    -                    }
    -                    let insert_sql = format!(
    -                        "INSERT INTO {ddb_table} (pk, item_data) VALUES ($1, $2) \
    -                         ON CONFLICT (pk) DO NOTHING"
    -                    );
    -                    let result = sqlx::query(&insert_sql)
    -                        .bind(pk_text.as_str())
    -                        .bind(&item_json)
    -                        .execute(&mut *tx)
    -                        .await
    -                        .map_err(|e| StorageError::Internal(e.to_string()))?;
    -                    if result.rows_affected() == 1 {
    -                        break;
    -                    }
    -                    // Lost the create race: overwrite the winner, as above.
    -                    attempt += 1;
    -                    if attempt >= MAX_CREATE_RACE_ATTEMPTS {
    -                        return Err(create_race_exhausted(attempt));
    -                    }
    -                    old = fetch_old(&mut tx).await?;
    +                    .map_err(db_error)?;
    +                break;
    +            }
    +            // No existing item: the condition checks against an empty item.
    +            let empty = std::collections::BTreeMap::new();
    +            match check_condition(condition, &empty, maps) {
    +                Ok(()) => {}
    +                Err(StorageError::ConditionFailed(_)) => {
    +                    return Err(StorageError::ConditionFailed(None));
                     }
    +                Err(e) => return Err(e),
    +            }
    +            let result = bind_key_and_item(sqlx::query(&plan.insert_sql), &key, &item_json)
    +                .execute(&mut **tx)
    +                .await
    +                .map_err(db_error)?;
    +            if result.rows_affected() == 1 {
    +                break;
    +            }
    +            // Lost the create race. The winner has committed (the insert
    +            // waited for it if it was in flight). The locking read returns
    +            // its row, and this put overwrites it after checking its
    +            // condition against it. If a delete committed since, the read
    +            // returns no row and the insert is retried.
    +            attempt += 1;
    +            if attempt >= MAX_CREATE_RACE_ATTEMPTS {
    +                return Err(create_race_exhausted(attempt));
    +            }
    +            old = fetch_locked(tx, &plan.select_sql, &key).await?;
    +        }
     
    -                // Sync GSI/LSI update within transaction (D-4).
    -                let old_item_for_idx = if indexes.is_empty() {
    -                    None
    -                } else {
    -                    let oi = old
    -                        .as_ref()
    -                        .map(|(v,)| json_to_item(v.clone()))
    -                        .transpose()?;
    -                    sync_indexes(
    -                        &mut tx,
    -                        &key_info.key_schema,
    -                        &indexes,
    -                        oi.as_ref(),
    -                        Some(&item),
    -                        sys_delay,
    -                    )
    -                    .await?;
    -                    oi
    -                };
    -
    -                // Write stream record atomically within the transaction.
    -                if let Some(capture) = stream {
    -                    let old_for_stream = old
    -                        .as_ref()
    -                        .map(|(v,)| json_to_item(v.clone()))
    -                        .transpose()?;
    -                    write_stream_record_in_tx(
    -                        &mut tx,
    -                        key_info,
    -                        capture,
    -                        old_for_stream.as_ref(),
    -                        Some(&item),
    -                    )
    -                    .await?;
    -                }
    -                // Persist async GSI work inside the same transaction — one row
    -                // per async index, each honoring its own propagation delay.
    -                let mut async_enqueued = enqueue_async_indexes(
    -                    &mut tx,
    -                    key_info,
    -                    &indexes,
    -                    old_item_for_idx.as_ref(),
    -                    Some(&item),
    -                    sys_delay,
    -                )
    -                .await?;
    -
    -                // Vector indexes, read fresh from the catalog rather than from the
    -                // cached key info, so a write cannot miss an index that was just
    -                // created. The old image is needed even when no secondary index
    -                // wanted it: the vector row is keyed by the base item, so a
    -                // partition move is a delete of the previous row.
    -                let old_for_vectors = match old_item_for_idx {
    -                    Some(ref oi) => Some(oi.clone()),
    -                    None => old
    -                        .as_ref()
    -                        .map(|(v,)| json_to_item(v.clone()))
    -                        .transpose()?,
    -                };
    -                async_enqueued += crate::data::vector_index::maintain_vector_indexes(
    -                    &mut tx,
    -                    &vector_metas,
    -                    &key_info.table_id,
    -                    &key_info.key_schema,
    -                    &key_info.attribute_definitions,
    -                    old_for_vectors.as_ref(),
    -                    Some(&item),
    -                    sys_delay,
    -                )
    -                .await?;
    +        let old_item = old.map(json_to_item).transpose()?;
    +
    +        // Sync GSI/LSI update within the transaction (D-4).
    +        if !plan.indexes.is_empty() {
    +            sync_indexes(
    +                tx,
    +                &key_info.key_schema,
    +                &plan.indexes,
    +                old_item.as_ref(),
    +                Some(item),
    +                plan.sys_delay,
    +            )
    +            .await?;
    +        }
     
    -                async_enqueued +=
    -                    fix_up_stale_write(&mut tx, &self.pool, stamp, key_info, &indexes, &item)
    -                        .await?;
    +        // Write the stream record atomically within the transaction.
    +        if let Some(capture) = stream {
    +            write_stream_record_in_tx(tx, key_info, capture, old_item.as_ref(), Some(item)).await?;
    +        }
     
    -                tx.commit()
    -                    .await
    -                    .map_err(|e| StorageError::Internal(e.to_string()))?;
    +        // Persist async GSI work inside the same transaction: one row per
    +        // async index, each honoring its own propagation delay.
    +        let mut async_enqueued = enqueue_async_indexes(
    +            tx,
    +            key_info,
    +            &plan.indexes,
    +            old_item.as_ref(),
    +            Some(item),
    +            plan.sys_delay,
    +        )
    +        .await?;
    +
    +        // Vector indexes. The old image is needed even when no secondary
    +        // index wanted it: the vector row is keyed by the base item, so a
    +        // partition move is a delete of the previous row.
    +        async_enqueued += crate::data::vector_index::maintain_vector_indexes(
    +            tx,
    +            &plan.vector_metas,
    +            &key_info.table_id,
    +            &key_info.key_schema,
    +            &key_info.attribute_definitions,
    +            old_item.as_ref(),
    +            Some(item),
    +            plan.sys_delay,
    +        )
    +        .await?;
    +
    +        Ok(RowWrite {
    +            old: old_item,
    +            async_enqueued,
    +        })
    +    }
     
    -                if async_enqueued > 0
    -                    && let Some(ref q) = self.gsi_queue
    -                {
    -                    q.notify_workers();
    -                }
    +    /// Implementation of `DataEngine::put_item`.
    +    ///
    +    /// When `marker` is `Some` and the write commits, the row's applied
    +    /// outcome is inserted into `bulk_row_outcomes` inside the same
    +    /// transaction, so the marker and the write are durable together or not
    +    /// at all. The condition-failed outcome is recorded by the caller, since
    +    /// a failed condition leaves no transaction to ride.
    +    #[allow(clippy::too_many_arguments)]
    +    pub(crate) async fn put_item_impl(
    +        &self,
    +        key_info: &TableKeyInfo,
    +        item: Item,
    +        return_old: bool,
    +        condition: Option<&Expr>,
    +        maps: &ExpressionMaps,
    +        stream: Option<&StreamCapture>,
    +        marker: Option<&BulkRowMarker>,
    +    ) -> Result, StorageError> {
    +        let plan = self.write_plan(key_info).await?;
    +        Self::validate_index_keys(&plan, key_info, &item)?;
     
    -                if return_old {
    -                    old.map(|(v,)| json_to_item(v)).transpose()
    -                } else {
    -                    Ok(None)
    -                }
    -            } else {
    -                let upsert_sql = format!(
    -                    "INSERT INTO {ddb_table} (pk, item_data) VALUES ($1, $2) \
    -                     ON CONFLICT (pk) DO UPDATE SET item_data = EXCLUDED.item_data"
    -                );
    -                super::gsi_build::pause_before_autocommit_statement().await;
    -                sqlx::query(&upsert_sql)
    -                    .bind(pk_text.as_str())
    -                    .bind(&item_json)
    -                    .execute(&self.data_pool)
    -                    .await
    -                    .map_err(|e| StorageError::Internal(e.to_string()))?;
    -                self.fix_up_autocommit(stamp, key_info, &item).await;
    -                Ok(None)
    +        if !plan.needs_tx(condition, return_old, stream, marker) {
    +            return self.upsert_plain(&plan, key_info, &item).await;
    +        }
    +
    +        let mut tx = self
    +            .data_pool
    +            .begin()
    +            .await
    +            .map_err(|e| StorageError::Internal(e.to_string()))?;
    +        let mut written =
    +            Self::put_row_in_tx(&mut tx, &plan, key_info, &item, condition, maps, stream).await?;
    +        // The row's outcome, durable with the write: commit covers both or
    +        // neither, which is what lets a resumed run find the row applied and
    +        // account for it without writing it again.
    +        if let Some(m) = marker {
    +            super::bulk_row_outcomes::insert_outcome_in_tx(
    +                &mut tx,
    +                m,
    +                RowOutcome::Applied,
    +                written.old.as_ref(),
    +            )
    +            .await?;
    +        }
    +        written.async_enqueued += fix_up_stale_write(
    +            &mut tx,
    +            &self.pool,
    +            plan.stamp,
    +            key_info,
    +            &plan.indexes,
    +            &item,
    +        )
    +        .await?;
    +        tx.commit()
    +            .await
    +            .map_err(|e| StorageError::Internal(e.to_string()))?;
    +        self.wake_index_workers(written.async_enqueued);
    +
    +        Ok(if return_old { written.old } else { None })
    +    }
    +
    +    /// The one-statement path for a plain row on a table with nothing to
    +    /// maintain: no condition, no replaced image wanted, no indexes, no
    +    /// stream, no marker, and a catalog read recent enough. A write that
    +    /// became slow after that decision queues its GSI repair after the
    +    /// statement (`fix_up_autocommit`).
    +    async fn upsert_plain(
    +        &self,
    +        plan: &WritePlan,
    +        key_info: &TableKeyInfo,
    +        item: &Item,
    +    ) -> Result, StorageError> {
    +        let key = RowKey::of(item, key_info)?;
    +        let item_json =
    +            serde_json::to_value(item).map_err(|e| StorageError::Internal(e.to_string()))?;
    +        let ddb_table = &plan.ddb_table;
    +        let upsert_sql = match sk_info(&key_info.key_schema, &key_info.attribute_definitions) {
    +            Some((_, sk_type)) => {
    +                let sk_col = sk_column(sk_type);
    +                format!(
    +                    "INSERT INTO {ddb_table} (pk, {sk_col}, item_data) VALUES ($1, $2, $3) \
    +                     ON CONFLICT (pk, {sk_col}) DO UPDATE SET item_data = EXCLUDED.item_data"
    +                )
                 }
    +            None => format!(
    +                "INSERT INTO {ddb_table} (pk, item_data) VALUES ($1, $2) \
    +                 ON CONFLICT (pk) DO UPDATE SET item_data = EXCLUDED.item_data"
    +            ),
    +        };
    +        super::gsi_build::pause_before_autocommit_statement().await;
    +        bind_key_and_item(sqlx::query(&upsert_sql), &key, &item_json)
    +            .execute(&self.data_pool)
    +            .await
    +            .map_err(db_error)?;
    +        self.fix_up_autocommit(plan.stamp, key_info, item).await;
    +        Ok(None)
    +    }
    +
    +    fn wake_index_workers(&self, async_enqueued: usize) {
    +        if async_enqueued > 0
    +            && let Some(ref q) = self.gsi_queue
    +        {
    +            q.notify_workers();
             }
         }
     
    diff --git a/crates/storage-postgres/src/data/put_item/batch.rs b/crates/storage-postgres/src/data/put_item/batch.rs
    new file mode 100644
    index 000000000..0a89661d1
    --- /dev/null
    +++ b/crates/storage-postgres/src/data/put_item/batch.rs
    @@ -0,0 +1,232 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Batched put implementation for the `PostgreSQL` backend.
    +
    +use super::*;
    +use crate::data::bulk_row_outcomes::{LedgerRow, insert_outcomes_in_tx};
    +use crate::data::gsi_build::{missed_gsis, queue_repairs};
    +
    +impl PostgresEngine {
    +    /// Implementation of `DataEngine::put_items_batch`: every row of the
    +    /// batch in one transaction.
    +    ///
    +    /// The batch first locks the operation's write-owner row and is refused
    +    /// unless its markers name that owner, then deletes the ledger rows the
    +    /// last checkpoint covers (`bulk_row_outcomes::fence_and_prune`). Each
    +    /// row is then written through the same path a single put uses, and its
    +    /// outcome, a failed condition included, is kept with what the row
    +    /// replaced. After the last row the outcomes go into the ledger in this
    +    /// transaction, one multi-row insert for up to 9,362 rows, so a batch
    +    /// of the runner's size costs one ledger statement instead of one a
    +    /// row. Any other error returns before commit, so nothing from the
    +    /// batch is visible.
    +    ///
    +    /// A batch takes its row locks in input order, so two batches on
    +    /// overlapping keys in different orders can each wait on a lock the
    +    /// other holds; PostgreSQL detects that and aborts one transaction with
    +    /// SQLSTATE 40P01. The aborted batch wrote nothing, so it is retried
    +    /// after a short backoff, up to `MAX_DEADLOCK_RETRIES` times. One row per
    +    /// transaction could not deadlock; this is the cost the batch pays for
    +    /// its throughput, kept inside the backend so an operation never fails
    +    /// for colliding with a sibling.
    +    ///
    +    /// The catalog is read once, with its stamp, and every attempt reuses
    +    /// that plan and that stamp. `gsi_build::fix_up_stale_write` takes the
    +    /// index set "the write read at `stamp`", so the two have to come from
    +    /// the same read: a fresh stamp beside the first read's index set would
    +    /// make a retry look fast while it maintains only the indexes the first
    +    /// read knew. Kept, the stamp includes the wait for PostgreSQL's
    +    /// `deadlock_timeout` (one second by default) on every retry, so a
    +    /// retried attempt re-reads the catalog before its commit and queues a
    +    /// repair for each GSI created since the read.
    +    pub(crate) async fn put_items_batch_impl(
    +        &self,
    +        key_info: &TableKeyInfo,
    +        rows: Vec,
    +        stream: Option<&StreamCapture>,
    +    ) -> Result, StorageError> {
    +        if rows.is_empty() {
    +            return Ok(Vec::new());
    +        }
    +        let plan = self.write_plan(key_info).await?;
    +        for row in &rows {
    +            Self::validate_index_keys(&plan, key_info, &row.item)?;
    +        }
    +        let mut attempt: u32 = 0;
    +        loop {
    +            match self
    +                .put_items_batch_once(&plan, key_info, &rows, stream)
    +                .await
    +            {
    +                Err(StorageError::Internal(message))
    +                    if is_deadlock(&message) && attempt < MAX_DEADLOCK_RETRIES =>
    +                {
    +                    attempt += 1;
    +                    tracing::debug!(attempt, "bulk batch deadlocked with a sibling; retrying");
    +                    tokio::time::sleep(deadlock_backoff(attempt)).await;
    +                }
    +                other => return other,
    +            }
    +        }
    +    }
    +
    +    /// One attempt at the batch: one transaction, every row, the repair for
    +    /// GSIs the plan did not know, commit.
    +    async fn put_items_batch_once(
    +        &self,
    +        plan: &WritePlan,
    +        key_info: &TableKeyInfo,
    +        rows: &[BatchRow],
    +        stream: Option<&StreamCapture>,
    +    ) -> Result, StorageError> {
    +        let mut tx = self.data_pool.begin().await.map_err(batch_tx_error)?;
    +        if let Some(marker) = rows.iter().find_map(|row| row.marker.as_ref()) {
    +            crate::data::bulk_row_outcomes::fence_and_prune(&mut tx, marker).await?;
    +        }
    +        let mut results = Vec::with_capacity(rows.len());
    +        let mut written_items: Vec<&Item> = Vec::with_capacity(rows.len());
    +        let mut ledger: Vec> = Vec::with_capacity(rows.len());
    +        let mut async_enqueued: usize = 0;
    +        let default_maps = ExpressionMaps::default();
    +
    +        for row in rows {
    +            let (condition, maps) = match row.condition.as_ref() {
    +                Some((expr, maps)) => (Some(expr), maps),
    +                None => (None, &default_maps),
    +            };
    +            match Self::put_row_in_tx(&mut tx, plan, key_info, &row.item, condition, maps, stream)
    +                .await
    +            {
    +                Ok(written) => {
    +                    if let Some(m) = row.marker.as_ref() {
    +                        ledger.push(LedgerRow::new(
    +                            m,
    +                            RowOutcome::Applied,
    +                            written.old.as_ref(),
    +                        )?);
    +                    }
    +                    async_enqueued += written.async_enqueued;
    +                    written_items.push(&row.item);
    +                    results.push(BatchRowResult::Written {
    +                        old: if row.return_old { written.old } else { None },
    +                    });
    +                }
    +                Err(StorageError::ConditionFailed(old)) => {
    +                    if let Some(m) = row.marker.as_ref() {
    +                        ledger.push(LedgerRow::new(
    +                            m,
    +                            RowOutcome::ConditionFailed,
    +                            old.as_ref(),
    +                        )?);
    +                    }
    +                    results.push(BatchRowResult::ConditionFailed { old });
    +                }
    +                Err(error) => return Err(error),
    +            }
    +        }
    +
    +        insert_outcomes_in_tx(&mut tx, &ledger).await?;
    +        async_enqueued +=
    +            fix_up_stale_batch(&mut tx, &self.pool, plan, key_info, &written_items).await?;
    +        tx.commit().await.map_err(batch_tx_error)?;
    +        self.wake_index_workers(async_enqueued);
    +        Ok(results)
    +    }
    +}
    +
    +/// `gsi_build::fix_up_stale_write` for a batch, called after its last row and
    +/// before its commit, so the repairs commit or roll back with the rows.
    +///
    +/// When the batch has run longer than `GRACE_WRITE` since the plan's catalog
    +/// read, the backfill of a GSI created in between may have passed its rows,
    +/// and the barrier may have passed before its first base-table write. One
    +/// catalog re-read finds every GSI the plan did not know, and a repair is
    +/// queued for each of them and each row the batch wrote. Rows that wrote
    +/// nothing (a failed condition) need none.
    +async fn fix_up_stale_batch(
    +    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    +    catalog: &sqlx::PgPool,
    +    plan: &WritePlan,
    +    key_info: &TableKeyInfo,
    +    written: &[&Item],
    +) -> Result {
    +    if written.is_empty() || plan.stamp.elapsed() <= GRACE_WRITE {
    +        return Ok(0);
    +    }
    +    let missed = missed_gsis(catalog, &key_info.table_id, &plan.indexes).await?;
    +    let mut queued = 0;
    +    for item in written {
    +        queued += queue_repairs(tx, key_info, &missed, item).await?;
    +    }
    +    Ok(queued)
    +}
    +
    +/// Map an error from the batch's own transaction statements (`BEGIN`,
    +/// `COMMIT`). A pool that timed out, a dropped socket and a crashed worker
    +/// are `Transient`, which the bulk runner resumes after; a database error
    +/// keeps its SQLSTATE in the text, which the runner and `is_deadlock` read.
    +/// Before this mapping both arrived as bare `Internal` text and ended the
    +/// operation.
    +fn batch_tx_error(e: sqlx::Error) -> StorageError {
    +    match e {
    +        sqlx::Error::Io(_) | sqlx::Error::PoolTimedOut | sqlx::Error::WorkerCrashed => {
    +            StorageError::Transient(e.to_string())
    +        }
    +        other => crate::data::index::db_error(other),
    +    }
    +}
    +
    +/// Retries of a bulk batch that PostgreSQL aborted as a deadlock victim. Each
    +/// retry starts a fresh transaction after `deadlock_backoff`; a batch still
    +/// deadlocking after this many is returned as the internal error it is.
    +const MAX_DEADLOCK_RETRIES: u32 = 8;
    +
    +/// SQLSTATE 40P01 is `deadlock_detected`. The mapped error text carries the
    +/// code (see `sqlstate_message`), which is what is matched here.
    +fn is_deadlock(message: &str) -> bool {
    +    message.contains("SQLSTATE 40P01")
    +}
    +
    +/// Backoff before a deadlock retry: 5 ms doubling, capped at 160 ms, plus up
    +/// to one base step of jitter so two colliding batches do not retry in step.
    +fn deadlock_backoff(attempt: u32) -> std::time::Duration {
    +    let base_ms = 5u64 << attempt.min(5);
    +    let jitter_ms = u64::from(
    +        std::time::SystemTime::now()
    +            .duration_since(std::time::UNIX_EPOCH)
    +            .map_or(0, |d| d.subsec_nanos()),
    +    ) % 5;
    +    std::time::Duration::from_millis(base_ms.min(160) + jitter_ms)
    +}
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::*;
    +
    +    #[test]
    +    fn a_pool_timeout_or_a_dropped_socket_on_the_batch_transaction_is_transient() {
    +        for error in [
    +            sqlx::Error::PoolTimedOut,
    +            sqlx::Error::Io(std::io::Error::from(std::io::ErrorKind::ConnectionReset)),
    +            sqlx::Error::WorkerCrashed,
    +        ] {
    +            assert!(
    +                matches!(batch_tx_error(error), StorageError::Transient(_)),
    +                "the runner resumes only after a Transient batch fault"
    +            );
    +        }
    +    }
    +
    +    #[test]
    +    fn any_other_batch_transaction_error_stays_internal() {
    +        assert!(matches!(
    +            batch_tx_error(sqlx::Error::RowNotFound),
    +            StorageError::Internal(_)
    +        ));
    +        assert!(matches!(
    +            batch_tx_error(sqlx::Error::PoolClosed),
    +            StorageError::Internal(_)
    +        ));
    +    }
    +}
    diff --git a/crates/storage-postgres/src/lib.rs b/crates/storage-postgres/src/lib.rs
    index 58c87ea13..819bf965f 100755
    --- a/crates/storage-postgres/src/lib.rs
    +++ b/crates/storage-postgres/src/lib.rs
    @@ -13,6 +13,7 @@ mod authorization_store;
     mod backup_catalog;
     mod backup_engine;
     mod bootstrapper;
    +mod bulk_operation_store;
     mod catalog_store;
     pub mod config;
     mod create_table;
    diff --git a/crates/storage-postgres/src/migrations/mod.rs b/crates/storage-postgres/src/migrations/mod.rs
    index bc5df0466..4a2bdffe0 100644
    --- a/crates/storage-postgres/src/migrations/mod.rs
    +++ b/crates/storage-postgres/src/migrations/mod.rs
    @@ -145,6 +145,18 @@ pub fn migration(id: MigrationId) -> Migration {
                     "0007_stream_pending.sql"
                 )],
             },
    +        MigrationId::BulkOperations => Migration::Applies {
    +            kind: Kind::Breaking,
    +            convergent: true,
    +            bodies: vec![
    +                file_body!(StoreKind::Catalog, "migrations", "0008_bulk_operations.sql"),
    +                file_body!(
    +                    StoreKind::Data,
    +                    "data_migrations",
    +                    "0008_bulk_operations.sql"
    +                ),
    +            ],
    +        },
         }
     }
     
    @@ -168,7 +180,8 @@ pub(crate) const fn runs_in(id: MigrationId) -> RunsIn {
             | MigrationId::SortKeyDefinitions
             | MigrationId::DropContinuousBackups
             | MigrationId::BackupCatalogIndex
    -        | MigrationId::StreamPending => RunsIn::Transaction,
    +        | MigrationId::StreamPending
    +        | MigrationId::BulkOperations => RunsIn::Transaction,
             MigrationId::IndexTableBaseKey => RunsIn::Autocommit,
         }
     }
    diff --git a/crates/storage-postgres/src/update_table.rs b/crates/storage-postgres/src/update_table.rs
    index dec99a93b..789d682cf 100755
    --- a/crates/storage-postgres/src/update_table.rs
    +++ b/crates/storage-postgres/src/update_table.rs
    @@ -437,6 +437,23 @@ impl PostgresEngine {
             // UpdateTable, carried out of the catalog transaction so the post-commit
             // index DDL builds its columns from the same set the catalog now holds.
             let mut merged_attr_defs_for_ddl: Option> = None;
    +        // No index creation while a bulk operation on the table runs, read
    +        // under the table row lock a start shares. The build is counted in
    +        // memory only once every step below that can refuse or fail the
    +        // request has passed (see `_index_build` before the commit).
    +        let creates_gsi = match &input.global_secondary_index_updates {
    +            Some(updates) => {
    +                crate::bulk_operation_store::refuse_index_creation_during_run(
    +                    &mut tx,
    +                    account_id,
    +                    &table_id,
    +                    &input.table_name,
    +                    updates,
    +                )
    +                .await?
    +            }
    +            None => false,
    +        };
             if let Some(updates) = &input.global_secondary_index_updates {
                 {
                     // One index change at a time while a GSI builds, as the service
    @@ -874,6 +891,14 @@ impl PostgresEngine {
             // moment the infrastructure is already unhappy, which is the worst time to
             // leave a table's index propagation paused for a request the client already
             // knows failed.
    +        // Every step that can refuse or fail the request has passed, so the
    +        // build is counted from here, with the table row still held, until
    +        // this function returns, its backfill included: a start called
    +        // during it, or one that waited on the row and reads the index
    +        // `ACTIVE` after it, is refused. A request refused or failed above
    +        // never moved the count.
    +        let _index_build =
    +            creates_gsi.then(|| crate::bulk_operation_store::BuildGuard::begin(&table_id));
             if let Err(e) = tx
                 .commit()
                 .await
    diff --git a/crates/storage-postgres/tests/backup_restore.rs b/crates/storage-postgres/tests/backup_restore.rs
    index 0493c34cd..f6b7eb55d 100644
    --- a/crates/storage-postgres/tests/backup_restore.rs
    +++ b/crates/storage-postgres/tests/backup_restore.rs
    @@ -745,6 +745,7 @@ async fn indexed_source_with(s: &Scratch, name: &str, items: usize) -> String {
                     None,
                     &extenddb_core::expression::ExpressionMaps::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("put");
    diff --git a/crates/storage-postgres/tests/bulk_operation_control_plane.rs b/crates/storage-postgres/tests/bulk_operation_control_plane.rs
    new file mode 100644
    index 000000000..ebbe6b272
    --- /dev/null
    +++ b/crates/storage-postgres/tests/bulk_operation_control_plane.rs
    @@ -0,0 +1,428 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Storage-level tests for the `PostgreSQL` bulk operation catalog schema and
    +//! the start transaction's conflict recovery.
    +//!
    +//! Each test builds its own throwaway database, applies the shipped
    +//! migrations to it, and drops it when it passes. A failing test leaves its
    +//! database behind on purpose, named `eddb_bulk_pg_*`, so the state that
    +//! failed can be inspected.
    +//!
    +//! Requires `EXTENDDB_TEST_PG_CONNECTION_STRING`, a base URL with no database
    +//! component, pointing at a server whose role may create and drop databases.
    +//! Without it every test here reports a skip and passes, the convention
    +//! `vector_control_plane.rs` set.
    +
    +use extenddb_storage::{BulkOperationStore, InputFile, NewBulkOperation, StartOutcome};
    +use extenddb_storage_postgres::{PostgresConfig, PostgresEngine};
    +use sqlx::PgPool;
    +use sqlx::postgres::PgPoolOptions;
    +
    +const ACCOUNT: &str = "123456789012";
    +const REGION: &str = "us-east-1";
    +
    +/// One scratch environment at a time across this test binary, so the engine
    +/// pool's ten-connection floor cannot exhaust a shared server.
    +async fn environment_permit() -> tokio::sync::OwnedSemaphorePermit {
    +    static ENVIRONMENTS: std::sync::OnceLock> =
    +        std::sync::OnceLock::new();
    +    std::sync::Arc::clone(
    +        ENVIRONMENTS.get_or_init(|| std::sync::Arc::new(tokio::sync::Semaphore::new(1))),
    +    )
    +    .acquire_owned()
    +    .await
    +    .expect("the environment semaphore is never closed")
    +}
    +
    +fn base_conn() -> Option {
    +    let conn = std::env::var("EXTENDDB_TEST_PG_CONNECTION_STRING").ok()?;
    +    (!conn.trim().is_empty()).then(|| conn.trim_end_matches('/').to_owned())
    +}
    +
    +fn skip(test: &str) {
    +    // In the CI job that provides PostgreSQL, an unset connection string is a
    +    // configuration error, not a skip: the summary line of a skipped run reads
    +    // the same as a real pass (cargo captures the message below), so the test
    +    // fails loudly instead. That job sets EXTENDDB_TEST_PG_REQUIRED; every
    +    // other job (plain `cargo test --workspace`, the MongoDB and Cassandra
    +    // legs) has no PostgreSQL and skips like the rest of this crate's suites.
    +    assert!(
    +        std::env::var("EXTENDDB_TEST_PG_REQUIRED").map_or(true, |v| v.is_empty()),
    +        "{test}: EXTENDDB_TEST_PG_REQUIRED is set but EXTENDDB_TEST_PG_CONNECTION_STRING is \
    +         not; the PostgreSQL bulk store tests would silently pass without a database"
    +    );
    +    eprintln!(
    +        "SKIP {test}: EXTENDDB_TEST_PG_CONNECTION_STRING is not set, so there is no PostgreSQL \
    +         to build a scratch catalog in."
    +    );
    +}
    +
    +/// A throwaway database with every shipped migration applied.
    +struct Scratch {
    +    pool: PgPool,
    +    admin: PgPool,
    +    db_name: String,
    +    _permit: tokio::sync::OwnedSemaphorePermit,
    +}
    +
    +impl Scratch {
    +    /// Drop the scratch database. Called only on the success path, so a
    +    /// failure leaves the database for inspection.
    +    async fn cleanup(self) {
    +        let Scratch {
    +            pool,
    +            admin,
    +            db_name,
    +            _permit,
    +        } = self;
    +        pool.close().await;
    +        sqlx::query(&format!(
    +            "DROP DATABASE IF EXISTS \"{db_name}\" WITH (FORCE)"
    +        ))
    +        .execute(&admin)
    +        .await
    +        .expect("drop the scratch database");
    +        admin.close().await;
    +    }
    +}
    +
    +/// Build a scratch database and apply the shipped migrations, catalog and
    +/// data, included from the crate rather than restated here: a test that
    +/// built its own idea of the schema could pass against a shape no deployment
    +/// has.
    +async fn scratch() -> Scratch {
    +    let base = base_conn().expect("caller checks base_conn() first");
    +    let permit = environment_permit().await;
    +    let db_name = format!("eddb_bulk_pg_{}", uuid::Uuid::new_v4().simple())[..24].to_owned();
    +
    +    let admin = PgPoolOptions::new()
    +        .max_connections(1)
    +        .connect(&format!("{base}/postgres"))
    +        .await
    +        .expect("connect to the postgres maintenance database");
    +    sqlx::query(&format!("CREATE DATABASE \"{db_name}\""))
    +        .execute(&admin)
    +        .await
    +        .expect("create the scratch database");
    +
    +    let pool = PgPoolOptions::new()
    +        .max_connections(4)
    +        .connect(&format!("{base}/{db_name}"))
    +        .await
    +        .expect("connect to the scratch database");
    +
    +    // The shipped catalog and data migration files, in name order, on one
    +    // database that stands in for both.
    +    let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
    +    for dir in ["migrations", "data_migrations"] {
    +        let mut files: Vec<_> = std::fs::read_dir(root.join(dir))
    +            .expect("read a migrations directory")
    +            .map(|entry| entry.expect("a directory entry").path())
    +            .filter(|p| p.extension().is_some_and(|e| e == "sql"))
    +            .collect();
    +        files.sort();
    +        for file in files {
    +            let sql = std::fs::read_to_string(&file).expect("read a migration file");
    +            sqlx::raw_sql(&sql)
    +                .execute(&pool)
    +                .await
    +                .unwrap_or_else(|e| panic!("apply {}: {e}", file.display()));
    +        }
    +    }
    +
    +    Scratch {
    +        pool,
    +        admin,
    +        db_name,
    +        _permit: permit,
    +    }
    +}
    +
    +/// Insert a `bulk_operations` row with the given identity and status, leaving
    +/// the defaulted columns to the schema. Runs on any executor so the step 5
    +/// test can use it inside an uncommitted transaction.
    +async fn insert_operation<'e, E>(
    +    executor: E,
    +    account_id: &str,
    +    operation_id: &str,
    +    table_id: &str,
    +    status: &str,
    +    token_hash: Option<&str>,
    +    fingerprint: &str,
    +) -> Result<(), sqlx::Error>
    +where
    +    E: sqlx::Executor<'e, Database = sqlx::Postgres>,
    +{
    +    sqlx::query(
    +        "INSERT INTO bulk_operations (\
    +             account_id, operation_id, table_id, table_name, table_arn, status, \
    +             client_token, client_token_hash, request_fingerprint, \
    +             input_config, output_config, input_files, \
    +             start_time_ms, total_input_bytes, usage, end_time_ms) \
    +         VALUES ($1, $2, $3, 'orders', $4, $5, $6, $6, $7, \
    +             '{}'::jsonb, '{}'::jsonb, '[]'::jsonb, 1, 0, '{}'::jsonb, \
    +             CASE WHEN $5 IN ('IN_PROGRESS', 'CANCELLING') THEN NULL ELSE 2 END)",
    +    )
    +    .bind(account_id)
    +    .bind(operation_id)
    +    .bind(table_id)
    +    .bind(format!(
    +        "arn:aws:dynamodb:{REGION}:{account_id}:table/orders"
    +    ))
    +    .bind(status)
    +    .bind(token_hash)
    +    .bind(fingerprint)
    +    .execute(executor)
    +    .await
    +    .map(|_| ())
    +}
    +
    +/// A start request against the given table with the given token hash.
    +fn new_operation(
    +    operation_id: &str,
    +    table_id: &str,
    +    token_hash: &str,
    +    fingerprint: &str,
    +) -> NewBulkOperation {
    +    NewBulkOperation {
    +        operation_id: operation_id.to_owned(),
    +        table_id: table_id.to_owned(),
    +        table_name: "orders".to_owned(),
    +        table_arn: format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/orders"),
    +        client_token: Some("token".to_owned()),
    +        client_token_hash: Some(token_hash.to_owned()),
    +        request_fingerprint: fingerprint.to_owned(),
    +        input_config: serde_json::json!({}),
    +        output_config: serde_json::json!({}),
    +        input_files: vec![InputFile {
    +            path: "data/part-000.json".to_owned(),
    +            size: 10,
    +            rows: None,
    +            etag: None,
    +            abandon: None,
    +        }],
    +        max_write_request_units: None,
    +        performance_tier: None,
    +        start_time_ms: 1,
    +        total_input_bytes: Some(10),
    +        initial_failure: None,
    +        run_context: None,
    +    }
    +}
    +
    +/// The shipped migration files create the five bulk tables.
    +#[tokio::test]
    +async fn the_shipped_migrations_create_the_bulk_tables() {
    +    if base_conn().is_none() {
    +        skip("the_shipped_migrations_create_the_bulk_tables");
    +        return;
    +    }
    +    let scratch = scratch().await;
    +
    +    for table in [
    +        "bulk_operations",
    +        "bulk_operation_input_files",
    +        "bulk_operation_report_records",
    +        "bulk_row_outcomes",
    +        "bulk_write_owners",
    +    ] {
    +        let (exists,): (bool,) = sqlx::query_as("SELECT to_regclass('public.' || $1) IS NOT NULL")
    +            .bind(table)
    +            .fetch_one(&scratch.pool)
    +            .await
    +            .expect("check a migrated table");
    +        assert!(exists, "{table} must exist after the migrations");
    +    }
    +
    +    scratch.cleanup().await;
    +}
    +
    +/// The partial unique index admits one non-terminal row per table: a second
    +/// one is rejected by the index by name, while a terminal row and another
    +/// table's row both pass.
    +#[tokio::test]
    +async fn the_table_guard_rejects_a_second_non_terminal_row() {
    +    if base_conn().is_none() {
    +        skip("the_table_guard_rejects_a_second_non_terminal_row");
    +        return;
    +    }
    +    let scratch = scratch().await;
    +
    +    insert_operation(
    +        &scratch.pool,
    +        ACCOUNT,
    +        "op-1",
    +        "table-1",
    +        "IN_PROGRESS",
    +        None,
    +        "fp-1",
    +    )
    +    .await
    +    .expect("the first non-terminal row inserts");
    +
    +    let second = insert_operation(
    +        &scratch.pool,
    +        ACCOUNT,
    +        "op-2",
    +        "table-1",
    +        "CANCELLING",
    +        None,
    +        "fp-2",
    +    )
    +    .await
    +    .expect_err("a second non-terminal row for the same table must be rejected");
    +    assert!(
    +        second.to_string().contains("idx_bulk_ops_table_guard"),
    +        "the rejection must come from the table guard index: {second}"
    +    );
    +
    +    // Terminal rows are outside the index predicate, so history accumulates.
    +    insert_operation(
    +        &scratch.pool,
    +        ACCOUNT,
    +        "op-3",
    +        "table-1",
    +        "COMPLETED",
    +        None,
    +        "fp-3",
    +    )
    +    .await
    +    .expect("a terminal row for the same table inserts");
    +    // The guard scopes by table, not by account.
    +    insert_operation(
    +        &scratch.pool,
    +        ACCOUNT,
    +        "op-4",
    +        "table-2",
    +        "IN_PROGRESS",
    +        None,
    +        "fp-4",
    +    )
    +    .await
    +    .expect("a non-terminal row for another table inserts");
    +
    +    scratch.cleanup().await;
    +}
    +
    +/// Step 5 of the start transaction: a same-token insert that hits the token
    +/// index returns `IdempotentReplay`, not an error, and the transaction is not
    +/// aborted.
    +///
    +/// The advisory lock serialises starts within an account, so two API starts
    +/// cannot race into step 4. To force the token conflict past the step 2
    +/// lookup, the conflicting row is inserted by a raw transaction that holds no
    +/// advisory lock and commits only after the start is observed blocked on the
    +/// in-flight index entry. The start's step 2 then saw nothing, its insert
    +/// conflicts on commit, and the outcome proves the transaction survived to
    +/// run step 5's lookup: a plain insert in its place would answer with a
    +/// unique violation instead.
    +#[tokio::test]
    +async fn a_same_token_conflict_at_insert_recovers_to_idempotent_replay() {
    +    if base_conn().is_none() {
    +        skip("a_same_token_conflict_at_insert_recovers_to_idempotent_replay");
    +        return;
    +    }
    +    let base = base_conn().expect("checked above");
    +    let scratch = scratch().await;
    +
    +    let engine = PostgresEngine::new(
    +        &PostgresConfig {
    +            connection_string: format!("{base}/{}", scratch.db_name),
    +            pool_size: 10,
    +            max_item_size_bytes: 400_000,
    +        },
    +        REGION,
    +    )
    +    .await
    +    .expect("open a PostgresEngine on the scratch database");
    +
    +    // The conflicting row: same account, same token hash, same fingerprint,
    +    // a different table, so the only index the start can hit is the token's.
    +    // The start's own table exists: the start transaction refuses a table
    +    // whose row is absent.
    +    sqlx::query("INSERT INTO accounts (account_id, account_name) VALUES ($1, 'control-plane')")
    +        .bind(ACCOUNT)
    +        .execute(&scratch.pool)
    +        .await
    +        .expect("seed the account");
    +    sqlx::query(
    +        "INSERT INTO tables (account_id, table_name, key_schema, attribute_definitions, \
    +             table_status, table_arn, table_id) \
    +         VALUES ($1, 'orders', '[]', '[]', 'ACTIVE', $2, 'table-2')",
    +    )
    +    .bind(ACCOUNT)
    +    .bind(format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/orders"))
    +    .execute(&scratch.pool)
    +    .await
    +    .expect("seed the start's table");
    +    let mut raw_tx = scratch
    +        .pool
    +        .begin()
    +        .await
    +        .expect("begin the raw transaction");
    +    insert_operation(
    +        &mut *raw_tx,
    +        ACCOUNT,
    +        "op-first",
    +        "table-1",
    +        "IN_PROGRESS",
    +        Some("hash-1"),
    +        "fp-1",
    +    )
    +    .await
    +    .expect("insert the uncommitted same-token row");
    +
    +    let start = engine.start_bulk_operation(
    +        ACCOUNT,
    +        new_operation("op-second", "table-2", "hash-1", "fp-1"),
    +        10,
    +    );
    +
    +    let db_name = scratch.db_name.clone();
    +    let admin = scratch.admin.clone();
    +    let release = async move {
    +        // Commit the raw transaction only once the start is blocked on the
    +        // in-flight index entry, which pins the conflict to step 4: an
    +        // earlier commit would let step 2 answer and the test would not
    +        // exercise the recovery it exists for.
    +        let mut waited = 0u32;
    +        loop {
    +            let (blocked,): (bool,) = sqlx::query_as(
    +                "SELECT EXISTS(SELECT 1 FROM pg_stat_activity \
    +                 WHERE datname = $1 AND wait_event_type = 'Lock' \
    +                   AND query LIKE 'INSERT INTO bulk_operations%')",
    +            )
    +            .bind(&db_name)
    +            .fetch_one(&admin)
    +            .await
    +            .expect("poll pg_stat_activity");
    +            if blocked {
    +                break;
    +            }
    +            waited += 1;
    +            assert!(waited < 200, "the start never blocked on the token index");
    +            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
    +        }
    +        raw_tx.commit().await.expect("commit the raw transaction");
    +    };
    +
    +    let (outcome, ()) = tokio::join!(start, release);
    +    match outcome.expect("the same-token conflict must not surface as an error") {
    +        StartOutcome::IdempotentReplay(record) => {
    +            assert_eq!(record.operation_id, "op-first");
    +            assert_eq!(record.request_fingerprint, "fp-1");
    +        }
    +        StartOutcome::Created(_) => panic!("the token index did not fire"),
    +        StartOutcome::IdempotentMismatch => panic!("fingerprints are equal; mismatch is wrong"),
    +        StartOutcome::TableBusy => {
    +            panic!("a same-token start must never be answered as a table conflict")
    +        }
    +        StartOutcome::AccountLimitReached => panic!("the limit of 10 cannot be reached here"),
    +        StartOutcome::FailedStartLimit => panic!("this start carries no initial failure"),
    +        StartOutcome::IndexOperationInProgress => panic!("the table has no index"),
    +    }
    +
    +    drop(engine);
    +    scratch.cleanup().await;
    +}
    diff --git a/crates/storage-postgres/tests/bulk_operation_store.rs b/crates/storage-postgres/tests/bulk_operation_store.rs
    new file mode 100644
    index 000000000..59ee1ddbc
    --- /dev/null
    +++ b/crates/storage-postgres/tests/bulk_operation_store.rs
    @@ -0,0 +1,411 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The bulk operation store contract, run against the `PostgreSQL` backend.
    +//!
    +//! The cases live in `extenddb_storage::bulk_operation_store_contract` and are
    +//! shared with the `SQLite` suite; this file only builds the engine, following
    +//! the conventions of `vector_control_plane.rs`: each test builds its own
    +//! throwaway database, applies the shipped migrations, and drops it when it
    +//! passes. A failing test leaves its database behind, named `eddb_bulk_*`, so
    +//! the state that failed can be inspected.
    +//!
    +//! Requires `EXTENDDB_TEST_PG_CONNECTION_STRING`, a base URL with no database
    +//! component, pointing at a server whose role may create and drop databases.
    +//! Without it every test reports a skip and passes.
    +
    +use std::sync::Arc;
    +
    +use extenddb_storage::bulk_operation_store_contract::{self as contract, Fixture};
    +use extenddb_storage_postgres::{PostgresConfig, PostgresEngine};
    +use sqlx::PgPool;
    +use sqlx::postgres::PgPoolOptions;
    +
    +const ACCOUNT_A: &str = "111111111111";
    +const ACCOUNT_B: &str = "222222222222";
    +const REGION: &str = "us-east-1";
    +
    +fn base_conn() -> Option {
    +    let conn = std::env::var("EXTENDDB_TEST_PG_CONNECTION_STRING").ok()?;
    +    (!conn.trim().is_empty()).then(|| conn.trim_end_matches('/').to_owned())
    +}
    +
    +/// Report the reason a test did nothing, loudly enough to notice in a log.
    +fn skip(test: &str) {
    +    // In the CI job that provides PostgreSQL, an unset connection string is a
    +    // configuration error, not a skip: the summary line of a skipped run reads
    +    // the same as a real pass (cargo captures the message below), so the test
    +    // fails loudly instead. That job sets EXTENDDB_TEST_PG_REQUIRED; every
    +    // other job (plain `cargo test --workspace`, the MongoDB and Cassandra
    +    // legs) has no PostgreSQL and skips like the rest of this crate's suites.
    +    assert!(
    +        std::env::var("EXTENDDB_TEST_PG_REQUIRED").map_or(true, |v| v.is_empty()),
    +        "{test}: EXTENDDB_TEST_PG_REQUIRED is set but EXTENDDB_TEST_PG_CONNECTION_STRING is \
    +         not; the PostgreSQL bulk store tests would silently pass without a database"
    +    );
    +    eprintln!(
    +        "SKIP {test}: EXTENDDB_TEST_PG_CONNECTION_STRING is not set, so there is no PostgreSQL \
    +         to build a scratch catalog in."
    +    );
    +}
    +
    +/// One scratch environment at a time, across the whole test binary. Each
    +/// environment holds an engine pool with a floor of ten connections and these
    +/// tests share a server with whatever else runs against it; running them all
    +/// at once exhausts `max_connections` and reports a resource limit as a
    +/// product bug.
    +async fn environment_permit() -> tokio::sync::OwnedSemaphorePermit {
    +    static ENVIRONMENTS: std::sync::OnceLock> =
    +        std::sync::OnceLock::new();
    +    std::sync::Arc::clone(
    +        ENVIRONMENTS.get_or_init(|| std::sync::Arc::new(tokio::sync::Semaphore::new(1))),
    +    )
    +    .acquire_owned()
    +    .await
    +    .expect("the environment semaphore is never closed")
    +}
    +
    +/// A throwaway database with the shipped schema applied and an engine on it.
    +struct Scratch {
    +    fixture: Fixture,
    +    catalog: PgPool,
    +    admin: PgPool,
    +    db_name: String,
    +    _permit: tokio::sync::OwnedSemaphorePermit,
    +}
    +
    +impl Scratch {
    +    /// Drop the scratch database. Called only on the success path, so a
    +    /// failure leaves the database for inspection.
    +    async fn cleanup(self) {
    +        let Scratch {
    +            fixture,
    +            catalog,
    +            admin,
    +            db_name,
    +            _permit,
    +        } = self;
    +        drop(fixture);
    +        catalog.close().await;
    +        sqlx::query(&format!(
    +            "DROP DATABASE IF EXISTS \"{db_name}\" WITH (FORCE)"
    +        ))
    +        .execute(&admin)
    +        .await
    +        .expect("drop the scratch database");
    +        admin.close().await;
    +    }
    +}
    +
    +/// The shipped migration files, read from the crate directory at run time and
    +/// applied in name order, so a migration added next to these tests is applied
    +/// without editing this list.
    +fn migration_files(subdir: &str) -> Vec {
    +    let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(subdir);
    +    let mut files: Vec<_> = std::fs::read_dir(&dir)
    +        .unwrap_or_else(|e| panic!("list {}: {e}", dir.display()))
    +        .map(|entry| entry.expect("read a directory entry").path())
    +        .filter(|p| p.extension().is_some_and(|ext| ext == "sql"))
    +        .collect();
    +    files.sort();
    +    files
    +}
    +
    +/// Build a scratch database, apply the shipped migrations, pin the
    +/// control-plane delays to zero (no workers run here), seed the two contract
    +/// accounts, and open an engine.
    +async fn scratch(test: &str) -> Option {
    +    let Some(base) = base_conn() else {
    +        skip(test);
    +        return None;
    +    };
    +    let permit = environment_permit().await;
    +    let db_name = format!("eddb_bulk_{}", uuid::Uuid::new_v4().simple())[..24].to_owned();
    +
    +    let admin = PgPoolOptions::new()
    +        .max_connections(1)
    +        .connect(&format!("{base}/postgres"))
    +        .await
    +        .expect("connect to the postgres maintenance database");
    +    sqlx::query(&format!("CREATE DATABASE \"{db_name}\""))
    +        .execute(&admin)
    +        .await
    +        .expect("create the scratch database");
    +
    +    let url = format!("{base}/{db_name}");
    +    let catalog = PgPoolOptions::new()
    +        .max_connections(2)
    +        .connect(&url)
    +        .await
    +        .expect("connect to the scratch database");
    +
    +    for file in migration_files("migrations")
    +        .into_iter()
    +        .chain(migration_files("data_migrations"))
    +    {
    +        let sql = std::fs::read_to_string(&file)
    +            .unwrap_or_else(|e| panic!("read {}: {e}", file.display()));
    +        sqlx::raw_sql(&sql)
    +            .execute(&catalog)
    +            .await
    +            .unwrap_or_else(|e| panic!("apply {}: {e}", file.display()));
    +    }
    +
    +    for key in ["control_plane_delay_seconds", "index_propagation_delay_ms"] {
    +        sqlx::query(
    +            "INSERT INTO settings (key, value) VALUES ($1, '0') \
    +             ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value",
    +        )
    +        .bind(key)
    +        .execute(&catalog)
    +        .await
    +        .expect("pin a delay setting to zero");
    +    }
    +    for (id, name) in [(ACCOUNT_A, "contract-a"), (ACCOUNT_B, "contract-b")] {
    +        sqlx::query("INSERT INTO accounts (account_id, account_name) VALUES ($1, $2)")
    +            .bind(id)
    +            .bind(format!("{name}-{db_name}"))
    +            .execute(&catalog)
    +            .await
    +            .expect("seed a contract account");
    +    }
    +
    +    let engine = PostgresEngine::new(
    +        &PostgresConfig {
    +            connection_string: url,
    +            // The engine clamps anything below its floor of ten, so ask for
    +            // the floor.
    +            pool_size: 10,
    +            max_item_size_bytes: 400_000,
    +        },
    +        REGION,
    +    )
    +    .await
    +    .expect("open a PostgresEngine on the scratch database");
    +
    +    Some(Scratch {
    +        fixture: Fixture {
    +            engine: Arc::new(engine),
    +            account_a: ACCOUNT_A.to_owned(),
    +            account_b: ACCOUNT_B.to_owned(),
    +            region: REGION.to_owned(),
    +        },
    +        catalog,
    +        admin,
    +        db_name,
    +        _permit: permit,
    +    })
    +}
    +
    +macro_rules! contract_case {
    +    ($($name:ident),* $(,)?) => {$(
    +        #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +        async fn $name() {
    +            let Some(s) = scratch(stringify!($name)).await else {
    +                return;
    +            };
    +            contract::$name(&s.fixture).await;
    +            s.cleanup().await;
    +        }
    +    )*};
    +}
    +
    +contract_case!(
    +    // The start transaction.
    +    second_start_on_a_busy_table_is_refused,
    +    the_table_guard_answers_before_the_account_limit,
    +    twenty_concurrent_starts_with_distinct_tokens_admit_exactly_one,
    +    twenty_concurrent_starts_with_one_token_create_once_and_replay_the_rest,
    +    a_table_is_startable_again_after_its_operation_finishes,
    +    the_per_table_non_terminal_count_follows_the_operation,
    +    a_reused_token_with_different_parameters_is_refused_and_creates_nothing,
    +    a_reused_token_replays_even_when_terminal_and_skips_the_table_guard,
    +    a_token_replays_inside_the_window_and_starts_anew_past_it,
    +    tokenless_starts_do_not_collide_on_the_token_index,
    +    sixty_concurrent_starts_against_a_limit_of_fifty_admit_exactly_fifty,
    +    // A start and an index creation that cross.
    +    a_start_and_an_index_creation_that_cross_are_never_both_accepted,
    +    a_refused_index_creation_does_not_refuse_a_start_that_crossed_it,
    +    an_accepted_index_creation_refuses_a_start_called_before_it,
    +    starts_beyond_the_pool_behind_an_index_creation_all_answer,
    +    a_stream_of_starts_does_not_hold_off_delete_table,
    +    // A start whose table was removed after the handler's lookup.
    +    a_start_on_a_table_removed_after_the_lookup_is_refused,
    +    // Rows born terminal (initial failure).
    +    an_initial_failure_starts_terminal_and_is_never_claimed,
    +    an_initial_failure_does_not_count_as_non_terminal,
    +    a_table_with_an_initial_failure_row_accepts_a_new_start,
    +    a_reused_token_replays_to_an_initial_failure_row,
    +    count_terminal_born_since_counts_only_terminal_born_rows_in_the_window,
    +    an_initial_failure_is_inserted_despite_a_saturated_account,
    +    an_initial_failure_on_a_busy_table_answers_busy_and_inserts_nothing,
    +    // Reads and isolation.
    +    an_operation_is_invisible_and_inert_to_another_account,
    +    get_returns_the_record_as_stored,
    +    list_pages_newest_first_with_an_exclusive_cursor,
    +    // Claim and lease.
    +    two_owners_claiming_concurrently_share_no_operation,
    +    claim_returns_cancelling_operations_too,
    +    a_live_lease_blocks_claim_until_it_expires,
    +    an_expired_unclaimed_lease_still_accepts_the_owners_checkpoint_and_finish,
    +    claim_prefers_the_oldest_start_and_honours_max,
    +    claim_skips_the_rows_of_excluded_accounts,
    +    claim_skips_the_operations_in_the_exclude_list,
    +    // Checkpoint, cancel, and finish.
    +    checkpoint_persists_progress_counters_usage_and_records,
    +    a_checkpoint_from_a_non_owner_is_lost_and_leaves_no_trace,
    +    a_finish_from_a_non_owner_is_lost_and_changes_nothing,
    +    finishing_a_cancelling_operation_lands_cancelled_whatever_the_outcome_says,
    +    finish_clears_the_lease_and_deletes_report_records,
    +    cancel_moves_a_running_operation_to_cancelling_and_repeats_harmlessly,
    +    cancel_on_a_terminal_operation_reports_the_status_and_changes_nothing,
    +    cancel_on_an_unknown_operation_reports_not_found,
    +    // The row-outcome ledger.
    +    a_marked_write_records_applied_with_the_item,
    +    a_marked_condition_failure_records_the_failure_and_writes_nothing,
    +    unmarked_writes_leave_no_ledger_row,
    +    ledger_deletion_is_scoped_to_one_operation,
    +    // Retention.
    +    purge_removes_only_terminal_operations_older_than_the_cutoff,
    +    list_purgeable_before_honors_the_limit,
    +    the_terminal_born_cap_is_enforced_in_the_start_transaction,
    +    purge_deletes_only_the_given_pairs,
    +    a_purged_operations_token_is_free_for_a_new_start,
    +    // A second write at a recorded position.
    +    a_marked_write_at_a_recorded_position_is_refused_and_changes_nothing,
    +    // Batched writes.
    +    a_batch_applies_every_row_and_reports_each_outcome_in_order,
    +    a_batch_applies_two_rows_on_one_key_in_order,
    +    a_batch_overwrite_reports_the_replaced_item_for_sizing,
    +    // The write owner and the ledger a resumed run reads back.
    +    every_marked_batch_row_is_recorded_with_what_it_replaced,
    +    a_batch_prunes_the_ledger_rows_its_checkpoint_covers,
    +    a_batch_of_an_earlier_owner_writes_nothing,
    +    a_batch_row_at_a_recorded_position_fails_the_batch,
    +    two_rows_of_one_batch_at_one_position_fail_the_batch,
    +    a_batch_past_one_statement_of_rows_records_every_row,
    +    a_takeover_counts_the_resumes_that_find_the_checkpoint_unmoved,
    +    a_takeover_waits_for_a_batch_in_flight,
    +    a_batch_error_leaves_nothing_written,
    +    an_empty_batch_is_a_no_op,
    +    opposite_order_batches_on_one_key_set_both_complete,
    +    a_token_hash_shared_across_accounts_creates_in_each_account,
    +    // Paging, ordering, and terminal fidelity.
    +    list_on_an_exact_page_multiple_ends_without_a_cursor,
    +    report_records_read_back_in_file_and_row_order,
    +    finish_with_failed_carries_the_failure_code_and_message,
    +    claim_returns_records_oldest_start_first_in_order,
    +    claim_breaks_start_time_ties_by_operation_id,
    +    // Checkpoint integrity.
    +    a_checkpoint_reinserting_an_existing_record_key_keeps_the_first_record,
    +    checkpoint_usage_replaces_the_stored_value,
    +    checkpoint_records_each_finished_files_rows_and_etag,
    +    checkpoint_records_each_abandoned_files_outcome,
    +    // A load of many input files.
    +    a_many_file_load_costs_the_same_per_file_as_a_small_one,
    +    the_boundary_read_follows_the_cancel_and_the_lease,
    +    // Limit validation and purge follow-through.
    +    list_with_limit_zero_is_refused_as_validation,
    +    purged_pairs_name_the_ledger_rows_to_delete,
    +    // Paged report reads and report totals.
    +    paged_report_reads_return_every_record_exactly_once,
    +    a_report_page_after_the_last_record_is_empty,
    +    a_report_page_with_limit_zero_is_refused_as_validation,
    +    a_report_record_with_a_nul_escape_round_trips_through_checkpoint,
    +    report_totals_round_trip_through_checkpoint_and_start_at_zero,
    +    // The non-terminal count and the corrupt-counter refusal.
    +    count_non_terminal_counts_only_the_accounts_running_and_cancelling_rows,
    +    a_negative_stored_counter_refuses_the_read,
    +    // The run context recorded at Start.
    +    the_run_context_round_trips_through_every_read,
    +    a_terminal_born_row_keeps_its_run_context,
    +);
    +
    +/// Edits a stored operation row through the scratch catalog pool, for the
    +/// cases that need a row no trait method can write.
    +struct SqlEditor<'p> {
    +    pool: &'p PgPool,
    +}
    +
    +impl SqlEditor<'_> {
    +    async fn edit(&self, sql: &str, account_id: &str, operation_id: &str) {
    +        let updated = sqlx::query(sql)
    +            .bind(account_id)
    +            .bind(operation_id)
    +            .execute(self.pool)
    +            .await
    +            .expect("edit the stored row")
    +            .rows_affected();
    +        assert_eq!(updated, 1, "the edit names exactly one row");
    +    }
    +}
    +
    +impl contract::RowEditor for SqlEditor<'_> {
    +    fn corrupt<'a>(
    +        &'a self,
    +        account_id: &'a str,
    +        operation_id: &'a str,
    +    ) -> futures::future::BoxFuture<'a, ()> {
    +        Box::pin(self.edit(
    +            "UPDATE bulk_operation_input_files \
    +             SET abandon = '{\"row\": 3, \"kind\": \"NOT_A_KIND\"}'::jsonb \
    +             WHERE account_id = $1 AND operation_id = $2 AND file_index = 0",
    +            account_id,
    +            operation_id,
    +        ))
    +    }
    +
    +    fn repair<'a>(
    +        &'a self,
    +        account_id: &'a str,
    +        operation_id: &'a str,
    +    ) -> futures::future::BoxFuture<'a, ()> {
    +        Box::pin(self.edit(
    +            "UPDATE bulk_operation_input_files SET abandon = NULL \
    +             WHERE account_id = $1 AND operation_id = $2 AND file_index = 0",
    +            account_id,
    +            operation_id,
    +        ))
    +    }
    +
    +    fn mark_table_deleting<'a>(
    +        &'a self,
    +        account_id: &'a str,
    +        table_id: &'a str,
    +    ) -> futures::future::BoxFuture<'a, ()> {
    +        Box::pin(self.edit(
    +            "UPDATE tables SET table_status = 'DELETING' WHERE account_id = $1 AND table_id = $2",
    +            account_id,
    +            table_id,
    +        ))
    +    }
    +}
    +
    +macro_rules! edited_case {
    +    ($($name:ident),* $(,)?) => {$(
    +        #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +        async fn $name() {
    +            let Some(s) = scratch(stringify!($name)).await else {
    +                return;
    +            };
    +            contract::$name(&s.fixture, &SqlEditor { pool: &s.catalog }).await;
    +            s.cleanup().await;
    +        }
    +    )*};
    +}
    +
    +edited_case!(
    +    a_claim_ends_an_unreadable_row_and_returns_the_rest,
    +    a_claim_ends_an_unreadable_cancelled_row_cancelled,
    +    a_claim_ending_an_unreadable_row_deletes_its_report_records,
    +    a_listing_skips_an_unreadable_row_and_pages_past_it,
    +    a_start_on_a_table_set_deleting_after_the_lookup_is_refused,
    +);
    +
    +// The cases that hold a lock from a connection of their own.
    +#[path = "bulk_operation_store/locks.rs"]
    +mod locks;
    +
    +// Faults a write batch meets on a row statement.
    +#[path = "bulk_operation_store/row_faults.rs"]
    +mod row_faults;
    diff --git a/crates/storage-postgres/tests/bulk_operation_store/locks.rs b/crates/storage-postgres/tests/bulk_operation_store/locks.rs
    new file mode 100644
    index 000000000..be206f484
    --- /dev/null
    +++ b/crates/storage-postgres/tests/bulk_operation_store/locks.rs
    @@ -0,0 +1,95 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The lock-wait cases of the bulk operation store contract and the failing
    +//! index creation, run against the `PostgreSQL` backend: each holds a lock,
    +//! or installs a fault, from a connection of its own.
    +
    +use sqlx::PgPool;
    +
    +use super::{contract, scratch};
    +
    +/// A `hold` for the lock-wait cases: a transaction on a connection of its
    +/// own runs `lock` with the table id bound, keeps what it took for the given
    +/// milliseconds, and rolls back.
    +fn hold_with(
    +    pool: PgPool,
    +    lock: &'static str,
    +) -> impl FnOnce(String, u64) -> std::pin::Pin + Send>> {
    +    move |table_id, ms| {
    +        Box::pin(async move {
    +            let mut tx = pool.begin().await.expect("begin the holding transaction");
    +            let query = sqlx::query(lock);
    +            let query = if lock.contains("$1") {
    +                query.bind(table_id)
    +            } else {
    +                query
    +            };
    +            query.execute(&mut *tx).await.expect("take the lock");
    +            tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
    +            tx.rollback().await.expect("release the lock");
    +        })
    +    }
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn a_start_waiting_on_a_held_row_does_not_delay_another_table() {
    +    let Some(s) = scratch("a_start_waiting_on_a_held_row_does_not_delay_another_table").await
    +    else {
    +        return;
    +    };
    +    contract::a_start_waiting_on_a_held_row_does_not_delay_another_table(
    +        &s.fixture,
    +        hold_with(
    +            s.catalog.clone(),
    +            "SELECT 1 FROM tables WHERE table_id = $1 FOR UPDATE",
    +        ),
    +    )
    +    .await;
    +    s.cleanup().await;
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn a_start_whose_index_read_waits_is_created() {
    +    let Some(s) = scratch("a_start_whose_index_read_waits_is_created").await else {
    +        return;
    +    };
    +    contract::a_start_whose_index_read_waits_is_created(
    +        &s.fixture,
    +        hold_with(
    +            s.catalog.clone(),
    +            "LOCK TABLE indexes IN ACCESS EXCLUSIVE MODE",
    +        ),
    +    )
    +    .await;
    +    s.cleanup().await;
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn a_start_called_during_a_failing_index_creation_is_created() {
    +    let Some(s) = scratch("a_start_called_during_a_failing_index_creation_is_created").await else {
    +        return;
    +    };
    +    // The scratch database is also the data database, so the trigger makes
    +    // the index's data table creation wait a second and fail.
    +    let pool = s.catalog.clone();
    +    contract::a_start_called_during_a_failing_index_creation_is_created(
    +        &s.fixture,
    +        || async move {
    +            sqlx::raw_sql(
    +                "CREATE FUNCTION refuse_table_creation() RETURNS event_trigger \
    +             LANGUAGE plpgsql AS $$ BEGIN \
    +                 PERFORM pg_sleep(1); \
    +                 RAISE EXCEPTION 'table creation refused by the test'; \
    +             END $$; \
    +             CREATE EVENT TRIGGER refuse_table_creation ON ddl_command_start \
    +                 WHEN TAG IN ('CREATE TABLE') EXECUTE FUNCTION refuse_table_creation();",
    +            )
    +            .execute(&pool)
    +            .await
    +            .expect("install the event trigger");
    +        },
    +    )
    +    .await;
    +    s.cleanup().await;
    +}
    diff --git a/crates/storage-postgres/tests/bulk_operation_store/row_faults.rs b/crates/storage-postgres/tests/bulk_operation_store/row_faults.rs
    new file mode 100644
    index 000000000..05fa33d05
    --- /dev/null
    +++ b/crates/storage-postgres/tests/bulk_operation_store/row_faults.rs
    @@ -0,0 +1,236 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Faults a bulk write batch meets on a row statement, against the
    +//! `PostgreSQL` backend, and the class the bulk runner reads from each with
    +//! `StorageError::is_transient_write_fault`: a backend terminated mid-batch
    +//! (the server sends SQLSTATE 57P01 before it closes) and a connection
    +//! closed mid-batch with no message from the server (an I/O error, no
    +//! SQLSTATE). Both are faults a resume clears, and neither batch leaves a
    +//! row written.
    +
    +use std::sync::Arc;
    +use std::time::Duration;
    +
    +use extenddb_storage::DataEngine;
    +use extenddb_storage::error::StorageError;
    +use extenddb_storage_postgres::{PostgresConfig, PostgresEngine};
    +use sqlx::PgPool;
    +use tokio::net::{TcpListener, TcpStream};
    +use tokio::sync::Notify;
    +
    +use super::{REGION, base_conn, contract, scratch};
    +
    +/// Make the base table's second written row of each transaction run `act`
    +/// (a PL/pgSQL statement) before the write.
    +async fn act_at_the_second_row(pool: &PgPool, table_id: &str, act: &str) {
    +    sqlx::raw_sql(&format!(
    +        "CREATE FUNCTION eddb_second_row() RETURNS trigger LANGUAGE plpgsql AS $$ \
    +         DECLARE n int := coalesce(nullif(current_setting('eddb.rows', true), ''), '0')::int + 1; \
    +         BEGIN \
    +             PERFORM set_config('eddb.rows', n::text, true); \
    +             IF n = 2 THEN {act} END IF; \
    +             RETURN NEW; \
    +         END $$; \
    +         CREATE TRIGGER eddb_second_row BEFORE INSERT OR UPDATE ON \"_ddb_{table_id}\" \
    +             FOR EACH ROW EXECUTE FUNCTION eddb_second_row();"
    +    ))
    +    .execute(pool)
    +    .await
    +    .expect("install the trigger");
    +}
    +
    +async fn drop_the_trigger(pool: &PgPool, table_id: &str) {
    +    sqlx::raw_sql(&format!(
    +        "DROP TRIGGER eddb_second_row ON \"_ddb_{table_id}\"; DROP FUNCTION eddb_second_row();"
    +    ))
    +    .execute(pool)
    +    .await
    +    .expect("drop the trigger");
    +}
    +
    +async fn rows_in(pool: &PgPool, table_id: &str) -> i64 {
    +    sqlx::query_scalar(&format!("SELECT COUNT(*) FROM \"_ddb_{table_id}\""))
    +        .fetch_one(pool)
    +        .await
    +        .expect("count the table's rows")
    +}
    +
    +/// The batch's backend terminates itself at the batch's second row, the
    +/// way `pg_terminate_backend` from an administrator ends it: the server
    +/// sends FATAL 57P01 and closes. The error keeps the code, the runner's
    +/// classifier reads it as transient, and the batch wrote nothing.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn a_backend_terminated_mid_batch_is_a_transient_fault() {
    +    let Some(s) = scratch("a_backend_terminated_mid_batch_is_a_transient_fault").await else {
    +        return;
    +    };
    +    let (ki, rows) = contract::plain_batch_on_a_new_table(&s.fixture, "batch_terminated").await;
    +    act_at_the_second_row(
    +        &s.catalog,
    +        &ki.table_id,
    +        "PERFORM pg_terminate_backend(pg_backend_pid());",
    +    )
    +    .await;
    +    let error = s
    +        .fixture
    +        .engine
    +        .put_items_batch(&ki, rows, None)
    +        .await
    +        .expect_err("the batch's backend is terminated at its second row");
    +    assert!(
    +        matches!(&error, StorageError::Internal(m) if m.starts_with("SQLSTATE 57P01:")),
    +        "the error keeps the server's code, got {error:?}"
    +    );
    +    assert!(
    +        error.is_transient_write_fault(),
    +        "a terminated backend is a fault a resume clears, got {error:?}"
    +    );
    +    assert_eq!(
    +        rows_in(&s.catalog, &ki.table_id).await,
    +        0,
    +        "nothing committed"
    +    );
    +
    +    // A resume writes the same rows once the fault is gone. The pool can
    +    // still hold the terminated connection, which the first attempt finds.
    +    drop_the_trigger(&s.catalog, &ki.table_id).await;
    +    let mut applied = false;
    +    for attempt in 0..3 {
    +        let (_, again) =
    +            contract::plain_batch_on_a_new_table(&s.fixture, &format!("batch_again_{attempt}"))
    +                .await;
    +        if s.fixture
    +            .engine
    +            .put_items_batch(&ki, again, None)
    +            .await
    +            .is_ok()
    +        {
    +            applied = true;
    +            break;
    +        }
    +    }
    +    assert!(applied, "the rows apply once the fault is gone");
    +    assert_eq!(rows_in(&s.catalog, &ki.table_id).await, 2);
    +    s.cleanup().await;
    +}
    +
    +/// A TCP relay to the server whose open connections can all be closed at
    +/// once, the way a dropped network path ends them: no message from the
    +/// server reaches the client.
    +struct Relay {
    +    port: u16,
    +    close: Arc,
    +    accept: tokio::task::JoinHandle<()>,
    +}
    +
    +impl Relay {
    +    async fn start(upstream: String) -> Self {
    +        let listener = TcpListener::bind("127.0.0.1:0")
    +            .await
    +            .expect("bind the relay");
    +        let port = listener.local_addr().expect("the relay address").port();
    +        let close = Arc::new(Notify::new());
    +        let closing = Arc::clone(&close);
    +        let accept = tokio::spawn(async move {
    +            while let Ok((mut client, _)) = listener.accept().await {
    +                let upstream = upstream.clone();
    +                let closing = Arc::clone(&closing);
    +                tokio::spawn(async move {
    +                    let Ok(mut server) = TcpStream::connect(&upstream).await else {
    +                        return;
    +                    };
    +                    tokio::select! {
    +                        _ = tokio::io::copy_bidirectional(&mut client, &mut server) => {}
    +                        () = closing.notified() => {}
    +                    }
    +                });
    +            }
    +        });
    +        Self {
    +            port,
    +            close,
    +            accept,
    +        }
    +    }
    +
    +    fn close_all(&self) {
    +        self.close.notify_waiters();
    +    }
    +}
    +
    +impl Drop for Relay {
    +    fn drop(&mut self) {
    +        self.accept.abort();
    +    }
    +}
    +
    +/// The batch's data connection is closed at the batch's second row with no
    +/// message from the server, so the client sees an I/O error that carries
    +/// no SQLSTATE. The runner's classifier reads it as transient, and the
    +/// batch wrote nothing.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn a_connection_closed_mid_batch_is_a_transient_fault() {
    +    let Some(s) = scratch("a_connection_closed_mid_batch_is_a_transient_fault").await else {
    +        return;
    +    };
    +    let base = base_conn().expect("a base connection string");
    +    let (credentials, server) = base
    +        .rsplit_once('@')
    +        .expect("the base connection string names a user");
    +    let relay = Relay::start(server.to_owned()).await;
    +    // The engine opened here reaches its data database through the relay,
    +    // and its catalog directly.
    +    sqlx::query(
    +        "INSERT INTO settings (key, value) VALUES ('data_database_connection_string', $1) \
    +         ON CONFLICT (key) DO UPDATE SET value = excluded.value",
    +    )
    +    .bind(format!(
    +        "{credentials}@127.0.0.1:{}/{}",
    +        relay.port, s.db_name
    +    ))
    +    .execute(&s.catalog)
    +    .await
    +    .expect("route the data database through the relay");
    +    let relayed = Arc::new(
    +        PostgresEngine::new(
    +            &PostgresConfig {
    +                connection_string: format!("{base}/{}", s.db_name),
    +                pool_size: 10,
    +                max_item_size_bytes: 400_000,
    +            },
    +            REGION,
    +        )
    +        .await
    +        .expect("open an engine whose data pool goes through the relay"),
    +    );
    +
    +    let (ki, rows) = contract::plain_batch_on_a_new_table(&s.fixture, "batch_closed").await;
    +    act_at_the_second_row(&s.catalog, &ki.table_id, "PERFORM pg_sleep(3);").await;
    +    let batch = {
    +        let relayed = Arc::clone(&relayed);
    +        let ki = ki.clone();
    +        tokio::spawn(async move { relayed.put_items_batch(&ki, rows, None).await })
    +    };
    +    tokio::time::sleep(Duration::from_millis(1500)).await;
    +    relay.close_all();
    +    let error = batch
    +        .await
    +        .expect("the batch task")
    +        .expect_err("the batch's connection is closed at its second row");
    +    assert!(
    +        error.is_transient_write_fault(),
    +        "a connection closed under a row statement is a fault a resume clears, got {error:?}"
    +    );
    +    // The server sees the connection gone once the trigger's sleep ends,
    +    // and rolls the batch back.
    +    tokio::time::sleep(Duration::from_secs(3)).await;
    +    assert_eq!(
    +        rows_in(&s.catalog, &ki.table_id).await,
    +        0,
    +        "nothing committed"
    +    );
    +    drop(relayed);
    +    drop(relay);
    +    s.cleanup().await;
    +}
    diff --git a/crates/storage-postgres/tests/doc_version_literals.rs b/crates/storage-postgres/tests/doc_version_literals.rs
    index 2e00eedb9..49d8cfa21 100644
    --- a/crates/storage-postgres/tests/doc_version_literals.rs
    +++ b/crates/storage-postgres/tests/doc_version_literals.rs
    @@ -25,7 +25,7 @@ use std::path::{Path, PathBuf};
     const EXEMPT: &[(&str, &str)] = &[
         (
             "docs/manuals/07-upgrade-manual.md",
    -        "documents the 0.0.2 to 0.0.3 upgrade, so both versions appear on purpose",
    +        "documents past upgrades, so superseded catalog versions appear on purpose",
         ),
         (
             "docs/backlog.md",
    diff --git a/crates/storage-postgres/tests/gsi_bulk_batch.rs b/crates/storage-postgres/tests/gsi_bulk_batch.rs
    new file mode 100644
    index 000000000..fa3e5da13
    --- /dev/null
    +++ b/crates/storage-postgres/tests/gsi_bulk_batch.rs
    @@ -0,0 +1,564 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +//! Bulk put batches against a table that `UpdateTable` is adding a GSI to.
    +//!
    +//! A batch reads the catalog once for all its rows, so a GSI whose `CREATING`
    +//! row commits after that read is unknown to every row the batch writes. The
    +//! batch handles it the way a single write does: when it is slow enough that
    +//! the backfill may have passed it, it re-reads the catalog before its commit
    +//! and queues a repair for each row it wrote and each GSI it did not know.
    +//!
    +//! - Several bulk writers and one `PutItem` writer run while `UpdateTable` adds
    +//!   a GSI over a seeded table. Once the queue drains, the index must equal the
    +//!   projection of the base table. Ten iterations.
    +//! - A batch is held after its catalog read until the build has finished, so its
    +//!   first base-table write comes seconds after the read. This uses the
    +//!   process-wide write pause the stale-writer tests use, which is why every
    +//!   test here takes `SERIAL` first.
    +//! - A batch and a single put that carry the key info cached before the GSI was
    +//!   added are validated against the attribute definitions read with the
    +//!   indexes, so a wrong-typed new index key is refused.
    +//!
    +//! Each test builds its own throwaway database, applies the shipped migrations
    +//! to it, and drops it when it passes. A failing test leaves its database behind
    +//! on purpose, named `eddb_gsib_*`, so the state that failed can be inspected.
    +//!
    +//! Requires `EXTENDDB_TEST_PG_CONNECTION_STRING`, a base URL with no database
    +//! component (for example `postgresql://postgres@127.0.0.1:5432`), pointing at a
    +//! server whose role may create and drop databases. Without it every test here
    +//! reports a skip and passes, the same convention the other suites use.
    +
    +use std::collections::{BTreeMap, BTreeSet};
    +use std::sync::atomic::{AtomicBool, Ordering};
    +use std::time::{Duration, Instant};
    +
    +use extenddb_core::expression::ExpressionMaps;
    +use extenddb_core::types::{
    +    AttributeDefinition, AttributeValue, BillingMode, CreateTableInput, Item, KeySchemaElement,
    +    KeyType, ScalarAttributeType, TableKeyInfo, UpdateTableInput,
    +};
    +use extenddb_storage::error::StorageError;
    +use extenddb_storage::{BatchRow, BatchRowResult, DataEngine, TableEngine};
    +use extenddb_storage_postgres::{
    +    PauseWrites, PostgresConfig, PostgresEngine, pause_writes_for_tests,
    +};
    +use sqlx::PgPool;
    +use sqlx::postgres::PgPoolOptions;
    +
    +const ACCOUNT: &str = "123456789012";
    +const REGION: &str = "us-east-1";
    +/// Base rows seeded before the index is created.
    +const SEED_ROWS: u64 = 3000;
    +/// Rows per bulk batch in the concurrent test.
    +const BATCH_ROWS: u64 = 25;
    +
    +/// The write pause is process-wide, so the tests in this binary run one at a
    +/// time.
    +static SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
    +
    +struct Scratch {
    +    engine: PostgresEngine,
    +    db: PgPool,
    +    admin: PgPool,
    +    db_name: String,
    +}
    +
    +impl Scratch {
    +    async fn cleanup(self) {
    +        let Scratch {
    +            engine,
    +            db,
    +            admin,
    +            db_name,
    +        } = self;
    +        drop(engine);
    +        db.close().await;
    +        sqlx::query(&format!(
    +            "DROP DATABASE IF EXISTS \"{db_name}\" WITH (FORCE)"
    +        ))
    +        .execute(&admin)
    +        .await
    +        .expect("drop the scratch database");
    +        admin.close().await;
    +    }
    +}
    +
    +fn base_conn() -> Option {
    +    let conn = std::env::var("EXTENDDB_TEST_PG_CONNECTION_STRING").ok()?;
    +    (!conn.trim().is_empty()).then(|| conn.trim_end_matches('/').to_owned())
    +}
    +
    +/// Every `.sql` file under `migrations/` and `data_migrations/`, each directory
    +/// in filename order.
    +fn shipped_migrations() -> Vec {
    +    let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
    +    let mut out = Vec::new();
    +    for dir in ["migrations", "data_migrations"] {
    +        let mut files: Vec<_> = std::fs::read_dir(root.join(dir))
    +            .expect("read the migrations directory")
    +            .map(|e| e.expect("directory entry").path())
    +            .filter(|p| p.extension().is_some_and(|e| e == "sql"))
    +            .collect();
    +        files.sort();
    +        for f in files {
    +            out.push(std::fs::read_to_string(&f).expect("read a migration file"));
    +        }
    +    }
    +    out
    +}
    +
    +fn skip(test: &str) {
    +    eprintln!(
    +        "SKIP {test}: EXTENDDB_TEST_PG_CONNECTION_STRING is not set, so there is no PostgreSQL \
    +         to build a scratch catalog in."
    +    );
    +}
    +
    +/// A scratch database standing in for both the catalog and the data database,
    +/// with a 5 ms propagation delay so index maintenance goes through the queue.
    +async fn scratch() -> Scratch {
    +    let base = base_conn().expect("caller checks base_conn() first");
    +    let db_name = format!("eddb_gsib_{}", uuid::Uuid::new_v4().simple())[..24].to_owned();
    +    let admin = PgPoolOptions::new()
    +        .max_connections(1)
    +        .connect(&format!("{base}/postgres"))
    +        .await
    +        .expect("connect to the postgres maintenance database");
    +    sqlx::query(&format!("CREATE DATABASE \"{db_name}\""))
    +        .execute(&admin)
    +        .await
    +        .expect("create the scratch database");
    +    let url = format!("{base}/{db_name}");
    +    let db = PgPoolOptions::new()
    +        .max_connections(4)
    +        .connect(&url)
    +        .await
    +        .expect("connect to the scratch database");
    +    for sql in shipped_migrations() {
    +        sqlx::raw_sql(&sql)
    +            .execute(&db)
    +            .await
    +            .expect("apply a shipped migration");
    +    }
    +    // The shipped migrations create neither row, so each is inserted.
    +    for (key, value) in [
    +        ("control_plane_delay_seconds", "0"),
    +        ("index_propagation_delay_ms", "5"),
    +    ] {
    +        sqlx::query(
    +            "INSERT INTO settings (key, value) VALUES ($1, $2) \
    +             ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value",
    +        )
    +        .bind(key)
    +        .bind(value)
    +        .execute(&db)
    +        .await
    +        .expect("pin a setting");
    +    }
    +    sqlx::query("INSERT INTO accounts (account_id, account_name) VALUES ($1, $2)")
    +        .bind(ACCOUNT)
    +        .bind(format!("acct-{db_name}"))
    +        .execute(&db)
    +        .await
    +        .expect("seed the account row");
    +    let engine = PostgresEngine::new(
    +        &PostgresConfig {
    +            connection_string: url,
    +            pool_size: 16,
    +            max_item_size_bytes: 400_000,
    +        },
    +        REGION,
    +    )
    +    .await
    +    .expect("open a PostgresEngine on the scratch database")
    +    .start_gsi_workers();
    +    Scratch {
    +        engine,
    +        db,
    +        admin,
    +        db_name,
    +    }
    +}
    +
    +/// A hash-key table `table`, seeded with `seed` items `k0..` that carry `g1`.
    +/// Returns its key info, read before any GSI exists, and its table id.
    +async fn seeded_table(s: &Scratch, table: &str, seed: u64) -> (TableKeyInfo, String) {
    +    s.engine
    +        .create_table(
    +            ACCOUNT,
    +            CreateTableInput {
    +                table_name: table.to_owned(),
    +                key_schema: vec![KeySchemaElement {
    +                    attribute_name: "pk".to_owned(),
    +                    key_type: KeyType::Hash,
    +                }],
    +                attribute_definitions: vec![AttributeDefinition {
    +                    attribute_name: "pk".to_owned(),
    +                    attribute_type: ScalarAttributeType::S,
    +                }],
    +                billing_mode: Some(BillingMode::PayPerRequest),
    +                ..Default::default()
    +            },
    +        )
    +        .await
    +        .expect("create the table");
    +    let key_info = s
    +        .engine
    +        .table_key_info(ACCOUNT, table)
    +        .await
    +        .expect("read the key info");
    +    let tid = key_info.table_id.clone();
    +    if seed > 0 {
    +        // One statement rather than thousands of writes. No index exists yet, so
    +        // no maintenance is due.
    +        sqlx::query(&format!(
    +            "INSERT INTO \"_ddb_{tid}\" (pk, item_data) \
    +             SELECT 'k'||g, jsonb_build_object( \
    +                 'pk', jsonb_build_object('S', 'k'||g), \
    +                 'g1', jsonb_build_object('S', 'g'||(g % 97)), \
    +                 'pad', jsonb_build_object('S', repeat('x', 200))) \
    +             FROM generate_series(0, {}) g",
    +            seed - 1
    +        ))
    +        .execute(&s.db)
    +        .await
    +        .expect("seed the base table");
    +    }
    +    (key_info, tid)
    +}
    +
    +/// Add GSI `gsi1` on `g1` (S), keys only.
    +async fn add_gsi(s: &Scratch, table: &str) -> Result<(), StorageError> {
    +    let input: UpdateTableInput = serde_json::from_value(serde_json::json!({
    +        "TableName": table,
    +        "AttributeDefinitions": [{"AttributeName": "g1", "AttributeType": "S"}],
    +        "GlobalSecondaryIndexUpdates": [{"Create": {
    +            "IndexName": "gsi1",
    +            "KeySchema": [{"AttributeName": "g1", "KeyType": "HASH"}],
    +            "Projection": {"ProjectionType": "KEYS_ONLY"},
    +        }}],
    +    }))
    +    .expect("build the UpdateTable input");
    +    s.engine.update_table(ACCOUNT, input).await.map(|_| ())
    +}
    +
    +fn item(pk: &str, g1: Option) -> Item {
    +    let mut it = BTreeMap::from([
    +        ("pk".to_owned(), AttributeValue::S(pk.to_owned())),
    +        ("pad".to_owned(), AttributeValue::S("y".repeat(200))),
    +    ]);
    +    if let Some(g) = g1 {
    +        it.insert("g1".to_owned(), g);
    +    }
    +    it
    +}
    +
    +fn gs(v: impl Into) -> Option {
    +    Some(AttributeValue::S(v.into()))
    +}
    +
    +fn row(item: Item) -> BatchRow {
    +    BatchRow {
    +        item,
    +        condition: None,
    +        marker: None,
    +        return_old: false,
    +    }
    +}
    +
    +async fn gsi_index_id(db: &PgPool, tid: &str) -> String {
    +    sqlx::query_scalar("SELECT index_id FROM indexes WHERE table_id = $1 AND index_name = 'gsi1'")
    +        .bind(tid)
    +        .fetch_one(db)
    +        .await
    +        .expect("look up the index id")
    +}
    +
    +/// `(pk, g1)` of every base row that carries `g1`: the keys-only projection the
    +/// index must hold.
    +async fn base_projection(db: &PgPool, tid: &str) -> BTreeSet<(String, String)> {
    +    let rows: Vec<(String, String)> = sqlx::query_as(&format!(
    +        "SELECT item_data->'pk'->>'S', item_data->'g1'->>'S' FROM \"_ddb_{tid}\" \
    +         WHERE item_data ? 'g1'"
    +    ))
    +    .fetch_all(db)
    +    .await
    +    .expect("read the base projection");
    +    rows.into_iter().collect()
    +}
    +
    +/// `(pk, g1)` of every index row.
    +async fn index_rows(db: &PgPool, iid: &str) -> BTreeSet<(String, String)> {
    +    let rows: Vec<(String, String)> = sqlx::query_as(&format!(
    +        "SELECT item_data->'pk'->>'S', item_data->'g1'->>'S' FROM \"_ddb_{iid}\""
    +    ))
    +    .fetch_all(db)
    +    .await
    +    .expect("read the index rows");
    +    rows.into_iter().collect()
    +}
    +
    +async fn queue_depth(db: &PgPool, tid: &str) -> i64 {
    +    sqlx::query_scalar("SELECT COUNT(*) FROM gsi_pending WHERE table_id = $1")
    +        .bind(tid)
    +        .fetch_one(db)
    +        .await
    +        .expect("count the queued rows")
    +}
    +
    +/// Wait for the propagation queue to empty, twice in a row 50 ms apart.
    +async fn wait_for_drain(db: &PgPool, tid: &str) -> bool {
    +    for _ in 0..600 {
    +        if queue_depth(db, tid).await == 0 {
    +            tokio::time::sleep(Duration::from_millis(50)).await;
    +            if queue_depth(db, tid).await == 0 {
    +                return true;
    +            }
    +        }
    +        tokio::time::sleep(Duration::from_millis(50)).await;
    +    }
    +    false
    +}
    +
    +/// Compare the index with the base projection once the queue has drained.
    +async fn index_matches(s: &Scratch, tid: &str) -> Result {
    +    if !wait_for_drain(&s.db, tid).await {
    +        return Err(format!(
    +            "propagation did not drain: queue={}",
    +            queue_depth(&s.db, tid).await
    +        ));
    +    }
    +    let iid = gsi_index_id(&s.db, tid).await;
    +    let base = base_projection(&s.db, tid).await;
    +    let index = index_rows(&s.db, &iid).await;
    +    if base == index {
    +        return Ok(base.len());
    +    }
    +    let missing: Vec<_> = base.difference(&index).take(5).collect();
    +    let stale: Vec<_> = index.difference(&base).take(5).collect();
    +    Err(format!(
    +        "index does not equal the base projection: base={}, index={}, missing from index={} \
    +         (first {missing:?}), stale in index={} (first {stale:?})",
    +        base.len(),
    +        index.len(),
    +        base.difference(&index).count(),
    +        index.difference(&base).count(),
    +    ))
    +}
    +
    +/// One bulk writer: batches of `BATCH_ROWS` rows that move existing items to a
    +/// new index key, insert new items, and overwrite items without `g1` (which
    +/// removes them from the index), until `stop` is set. Keys repeat across
    +/// writers in different orders, so batches deadlock with each other and take
    +/// the retry path. Returns the number of batches written.
    +async fn bulk_writer(
    +    engine: &PostgresEngine,
    +    key_info: &TableKeyInfo,
    +    stop: &AtomicBool,
    +    seed: u64,
    +) -> Result {
    +    let mut i = seed;
    +    let mut batches = 0;
    +    while !stop.load(Ordering::Relaxed) {
    +        let mut rows = Vec::new();
    +        let mut keys = BTreeSet::new();
    +        for _ in 0..BATCH_ROWS {
    +            i += 1;
    +            let existing = format!("k{}", (i * 7919) % SEED_ROWS);
    +            let it = match i % 10 {
    +                0..=5 => item(&existing, gs(format!("h{}", i % 13))),
    +                6 | 7 => item(&format!("b{seed}_{i}"), gs(format!("g{}", i % 97))),
    +                _ => item(&existing, None),
    +            };
    +            let pk = it["pk"].clone();
    +            if keys.insert(format!("{pk:?}")) {
    +                rows.push(row(it));
    +            }
    +        }
    +        let n = rows.len();
    +        let results = engine
    +            .put_items_batch(key_info, rows, None)
    +            .await
    +            .map_err(|e| format!("bulk batch: {e}"))?;
    +        if results.len() != n
    +            || results
    +                .iter()
    +                .any(|r| !matches!(r, BatchRowResult::Written { .. }))
    +        {
    +            return Err(format!("bulk batch answered {results:?}"));
    +        }
    +        batches += 1;
    +    }
    +    Ok(batches)
    +}
    +
    +/// One `PutItem` writer beside the bulk writers, over the same keys.
    +async fn single_writer(engine: &PostgresEngine, key_info: &TableKeyInfo, stop: &AtomicBool) {
    +    let maps = ExpressionMaps::default();
    +    let mut i: u64 = 0;
    +    while !stop.load(Ordering::Relaxed) {
    +        i += 1;
    +        let it = item(
    +            &format!("k{}", (i * 104_729) % SEED_ROWS),
    +            gs(format!("p{}", i % 7)),
    +        );
    +        engine
    +            .put_item(key_info, it, false, None, &maps, None, None)
    +            .await
    +            .expect("put");
    +    }
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn bulk_batches_during_a_gsi_build_leave_the_index_equal_to_the_table() {
    +    let test = "bulk_batches_during_a_gsi_build_leave_the_index_equal_to_the_table";
    +    if base_conn().is_none() {
    +        return skip(test);
    +    }
    +    let _serial = SERIAL.lock().await;
    +    let s = scratch().await;
    +    for iter in 0..10 {
    +        let table = format!("gsib_{iter}");
    +        let (key_info, tid) = seeded_table(&s, &table, SEED_ROWS).await;
    +        let stop = AtomicBool::new(false);
    +        let build = async {
    +            // Let the writers get going first, so batches straddle the catalog
    +            // commit.
    +            tokio::time::sleep(Duration::from_millis(50)).await;
    +            let r = add_gsi(&s, &table).await;
    +            stop.store(true, Ordering::Relaxed);
    +            r
    +        };
    +        let writers = async {
    +            tokio::join!(
    +                bulk_writer(&s.engine, &key_info, &stop, 1),
    +                bulk_writer(&s.engine, &key_info, &stop, 1_000_003),
    +                bulk_writer(&s.engine, &key_info, &stop, 2_000_029),
    +                single_writer(&s.engine, &key_info, &stop),
    +            )
    +        };
    +        let (built, (a, b, c, ())) = tokio::join!(build, writers);
    +        built.unwrap_or_else(|e| panic!("iteration {iter}: UpdateTable failed: {e}"));
    +        let batches: u64 = [a, b, c]
    +            .into_iter()
    +            .map(|r| r.unwrap_or_else(|e| panic!("iteration {iter}: {e}")))
    +            .sum();
    +        match index_matches(&s, &tid).await {
    +            Ok(rows) => eprintln!("iteration {iter}: {batches} batches, {rows} index rows equal"),
    +            Err(e) => panic!("iteration {iter}: {e}"),
    +        }
    +    }
    +    s.cleanup().await;
    +}
    +
    +/// A batch that read the catalog before the GSI existed and wrote after the
    +/// build had finished: the backfill passed its rows before they changed, and
    +/// the barrier did not wait for it because it had not yet written to the base
    +/// table. Only the repair it queues before its commit brings the index level.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn a_batch_held_past_a_gsi_build_queues_a_repair_for_every_row() {
    +    let test = "a_batch_held_past_a_gsi_build_queues_a_repair_for_every_row";
    +    if base_conn().is_none() {
    +        return skip(test);
    +    }
    +    let _serial = SERIAL.lock().await;
    +    let s = scratch().await;
    +    let table = "gsib_held";
    +    let (key_info, tid) = seeded_table(&s, table, 40).await;
    +
    +    // Moved to a new index key, removed from the index, and new.
    +    let mut rows: Vec = (0..10)
    +        .map(|n| row(item(&format!("k{n}"), gs("late"))))
    +        .collect();
    +    rows.extend((10..15).map(|n| row(item(&format!("k{n}"), None))));
    +    rows.extend((0..10).map(|n| row(item(&format!("new{n}"), gs("new")))));
    +    let n = rows.len();
    +
    +    let hold = Duration::from_millis(3_000);
    +    pause_writes_for_tests(PauseWrites::AfterIndexRead, hold);
    +    let started = Instant::now();
    +    let batch = s.engine.put_items_batch(&key_info, rows, None);
    +    let build = async {
    +        tokio::time::sleep(Duration::from_millis(200)).await;
    +        let r = add_gsi(&s, table).await;
    +        let built_after = started.elapsed();
    +        pause_writes_for_tests(PauseWrites::AfterIndexRead, Duration::ZERO);
    +        r.map(|()| built_after)
    +    };
    +    let (results, built_after) = tokio::join!(batch, build);
    +    let built_after = built_after.expect("add the GSI");
    +    assert!(
    +        built_after < hold,
    +        "the build took {built_after:?}, longer than the {hold:?} hold, so the batch did not \
    +         write after it"
    +    );
    +    let results = results.expect("the held batch");
    +    assert_eq!(results.len(), n);
    +    assert!(
    +        results
    +            .iter()
    +            .all(|r| matches!(r, BatchRowResult::Written { .. })),
    +        "{results:?}"
    +    );
    +    if let Err(e) = index_matches(&s, &tid).await {
    +        panic!("the rows the held batch wrote were not repaired: {e}");
    +    }
    +    s.cleanup().await;
    +}
    +
    +/// A write carrying the key info cached before `UpdateTable` added a GSI is
    +/// checked against the definitions read with the indexes: a number where the
    +/// new index key is declared a string is refused, by a batch and by a put, and
    +/// the batch writes nothing.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_write_with_key_info_older_than_a_new_gsi_is_validated_against_its_definitions() {
    +    let test = "a_write_with_key_info_older_than_a_new_gsi_is_validated_against_its_definitions";
    +    if base_conn().is_none() {
    +        return skip(test);
    +    }
    +    let _serial = SERIAL.lock().await;
    +    let s = scratch().await;
    +    let table = "gsib_types";
    +    let (stale, tid) = seeded_table(&s, table, 0).await;
    +    add_gsi(&s, table).await.expect("add the GSI");
    +    let wrong = || Some(AttributeValue::N("7".to_owned()));
    +
    +    let batch = s
    +        .engine
    +        .put_items_batch(
    +            &stale,
    +            vec![row(item("ok", gs("fine"))), row(item("bad", wrong()))],
    +            None,
    +        )
    +        .await;
    +    assert!(
    +        matches!(batch, Err(StorageError::Validation(_))),
    +        "a batch row with a wrong-typed index key: {batch:?}"
    +    );
    +    let put = s
    +        .engine
    +        .put_item(
    +            &stale,
    +            item("bad", wrong()),
    +            false,
    +            None,
    +            &ExpressionMaps::default(),
    +            None,
    +            None,
    +        )
    +        .await;
    +    assert!(
    +        matches!(put, Err(StorageError::Validation(_))),
    +        "a put with a wrong-typed index key: {put:?}"
    +    );
    +    let written: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM \"_ddb_{tid}\""))
    +        .fetch_one(&s.db)
    +        .await
    +        .expect("count the base rows");
    +    assert_eq!(written, 0, "the refused batch wrote nothing");
    +    s.cleanup().await;
    +}
    +
    +// The ledger and write owner cases.
    +#[path = "gsi_bulk_batch/ledger.rs"]
    +mod ledger;
    diff --git a/crates/storage-postgres/tests/gsi_bulk_batch/ledger.rs b/crates/storage-postgres/tests/gsi_bulk_batch/ledger.rs
    new file mode 100644
    index 000000000..439276161
    --- /dev/null
    +++ b/crates/storage-postgres/tests/gsi_bulk_batch/ledger.rs
    @@ -0,0 +1,195 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The bulk write ledger and the write owner row during a bulk batch, run
    +//! against the `PostgreSQL` backend.
    +
    +use super::*;
    +
    +/// A ledger statement that fails names its SQLSTATE in the error, so the
    +/// bulk runner can tell a transient fault (a full disk, 53100) from a
    +/// terminal one. The ledger insert is made to fail with a check that no row
    +/// passes (23514), which the runner treats as terminal.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_ledger_statement_error_carries_its_sqlstate() {
    +    let test = "a_ledger_statement_error_carries_its_sqlstate";
    +    if base_conn().is_none() {
    +        return skip(test);
    +    }
    +    let _serial = SERIAL.lock().await;
    +    let s = scratch().await;
    +    let (key_info, _) = seeded_table(&s, "ledger_fault", 0).await;
    +    sqlx::query("ALTER TABLE bulk_row_outcomes ADD CONSTRAINT refuse_all CHECK (false) NOT VALID")
    +        .execute(&s.db)
    +        .await
    +        .expect("add the refusing check");
    +    s.engine
    +        .bulk_write_takeover(ACCOUNT, "op-ledger", "owner-1", (0, 0))
    +        .await
    +        .expect("record the write owner");
    +    let mut marked = row(item("k1", None));
    +    marked.marker = Some(extenddb_storage::BulkRowMarker {
    +        account_id: ACCOUNT.to_owned(),
    +        operation_id: "op-ledger".to_owned(),
    +        file_index: 0,
    +        row_index: 0,
    +        owner: "owner-1".to_owned(),
    +        keep_old: false,
    +        settled: (0, 0),
    +    });
    +    let error = s
    +        .engine
    +        .put_items_batch(&key_info, vec![marked], None)
    +        .await
    +        .expect_err("the ledger insert fails");
    +    match &error {
    +        StorageError::Internal(message) => assert!(
    +            message.starts_with("SQLSTATE 23514: "),
    +            "the error names its SQLSTATE: {message}"
    +        ),
    +        other => panic!("expected an internal error, got {other:?}"),
    +    }
    +    s.cleanup().await;
    +}
    +
    +/// A full disk met by the ledger insert is a transient write fault: the
    +/// error carries SQLSTATE 53100, which the bulk runner resumes after, and
    +/// the batch wrote nothing. A trigger on the ledger table raises
    +/// `disk_full` for every insert.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_full_disk_on_the_ledger_insert_is_a_transient_fault() {
    +    let test = "a_full_disk_on_the_ledger_insert_is_a_transient_fault";
    +    if base_conn().is_none() {
    +        return skip(test);
    +    }
    +    let _serial = SERIAL.lock().await;
    +    let s = scratch().await;
    +    let (key_info, _) = seeded_table(&s, "ledger_full", 0).await;
    +    sqlx::raw_sql(
    +        "CREATE FUNCTION refuse_ledger_rows() RETURNS trigger LANGUAGE plpgsql AS $$ \
    +         BEGIN RAISE EXCEPTION 'could not extend file: No space left on device' \
    +         USING ERRCODE = 'disk_full'; END $$; \
    +         CREATE TRIGGER refuse_ledger_rows BEFORE INSERT ON bulk_row_outcomes \
    +         FOR EACH STATEMENT EXECUTE FUNCTION refuse_ledger_rows();",
    +    )
    +    .execute(&s.db)
    +    .await
    +    .expect("add the refusing trigger");
    +    s.engine
    +        .bulk_write_takeover(ACCOUNT, "op-full", "owner-1", (0, 0))
    +        .await
    +        .expect("record the write owner");
    +    let rows: Vec = (0..3_i64)
    +        .map(|index| {
    +            let mut marked = row(item(&format!("k{index}"), None));
    +            marked.marker = Some(extenddb_storage::BulkRowMarker {
    +                account_id: ACCOUNT.to_owned(),
    +                operation_id: "op-full".to_owned(),
    +                file_index: 0,
    +                row_index: index,
    +                owner: "owner-1".to_owned(),
    +                keep_old: false,
    +                settled: (0, 0),
    +            });
    +            marked
    +        })
    +        .collect();
    +    let error = s
    +        .engine
    +        .put_items_batch(&key_info, rows, None)
    +        .await
    +        .expect_err("the ledger insert meets a full disk");
    +    assert!(
    +        error.is_transient_write_fault(),
    +        "a full disk on the ledger insert is transient: {error:?}"
    +    );
    +    let written: i64 = sqlx::query_scalar(&format!(
    +        "SELECT count(*) FROM \"_ddb_{}\"",
    +        key_info.table_id
    +    ))
    +    .fetch_one(&s.db)
    +    .await
    +    .expect("count the items");
    +    assert_eq!(written, 0, "the batch wrote nothing");
    +    s.cleanup().await;
    +}
    +
    +/// A process stopped inside a bulk batch transaction, after the batch has
    +/// locked the operation's write owner row, holds that row locked, and the
    +/// next claim's takeover waits on it. The data database ends a bulk batch
    +/// transaction left idle for the bound the store puts on its own
    +/// transactions, so the takeover returns within that bound and the stopped
    +/// batch's rows are rolled back.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_process_stopped_inside_a_bulk_batch_releases_the_write_owner_row() {
    +    let test = "a_process_stopped_inside_a_bulk_batch_releases_the_write_owner_row";
    +    if base_conn().is_none() {
    +        return skip(test);
    +    }
    +    let _serial = SERIAL.lock().await;
    +    let s = scratch().await;
    +    let (key_info, _) = seeded_table(&s, "stopped_batch", 0).await;
    +    s.engine
    +        .bulk_write_takeover(ACCOUNT, "op-stopped", "owner-1", (0, 0))
    +        .await
    +        .expect("record the write owner");
    +
    +    // A transaction of the test's own holds the ledger key the batch
    +    // inserts, so the batch stops at that insert with the owner row already
    +    // locked by its fence.
    +    let mut blocker = s.db.begin().await.expect("begin the blocker");
    +    sqlx::query(
    +        "INSERT INTO bulk_row_outcomes (account_id, operation_id, file_index, row_index, outcome) \
    +         VALUES ($1, 'op-stopped', 0, 0, 'applied')",
    +    )
    +    .bind(ACCOUNT)
    +    .execute(&mut *blocker)
    +    .await
    +    .expect("hold the ledger key");
    +    let mut marked = row(item("k1", None));
    +    marked.marker = Some(extenddb_storage::BulkRowMarker {
    +        account_id: ACCOUNT.to_owned(),
    +        operation_id: "op-stopped".to_owned(),
    +        file_index: 0,
    +        row_index: 0,
    +        owner: "owner-1".to_owned(),
    +        keep_old: false,
    +        settled: (0, 0),
    +    });
    +    let mut batch = Box::pin(s.engine.put_items_batch(&key_info, vec![marked], None));
    +    assert!(
    +        tokio::time::timeout(Duration::from_millis(500), batch.as_mut())
    +            .await
    +            .is_err(),
    +        "the batch waits at its ledger insert"
    +    );
    +    // The insert now completes on the server and the batch is never polled
    +    // again: its connection sits idle inside the transaction, holding the
    +    // owner row, as a process stopped there leaves it.
    +    blocker.rollback().await.expect("release the ledger key");
    +    tokio::time::sleep(Duration::from_millis(300)).await;
    +
    +    let asked = Instant::now();
    +    tokio::time::timeout(
    +        Duration::from_secs(40),
    +        s.engine
    +            .bulk_write_takeover(ACCOUNT, "op-stopped", "owner-2", (0, 0)),
    +    )
    +    .await
    +    .expect("the takeover returns while the stopped batch is open")
    +    .expect("takeover");
    +    let waited = asked.elapsed();
    +    assert!(
    +        waited < Duration::from_secs(25),
    +        "the takeover waited {waited:?} for the stopped batch"
    +    );
    +    let recorded: i64 = sqlx::query_scalar(
    +        "SELECT count(*) FROM bulk_row_outcomes WHERE operation_id = 'op-stopped'",
    +    )
    +    .fetch_one(&s.db)
    +    .await
    +    .expect("count the ledger rows");
    +    assert_eq!(recorded, 0, "the stopped batch was rolled back");
    +    drop(batch);
    +    s.cleanup().await;
    +}
    diff --git a/crates/storage-postgres/tests/gsi_online_build.rs b/crates/storage-postgres/tests/gsi_online_build.rs
    index 4a02d3147..83cafa240 100644
    --- a/crates/storage-postgres/tests/gsi_online_build.rs
    +++ b/crates/storage-postgres/tests/gsi_online_build.rs
    @@ -241,7 +241,15 @@ fn item(n: u32, g: Option<&str>, r: Option) -> Item {
     
     async fn put(s: &Scratch, key_info: &TableKeyInfo, it: Item) {
         s.engine
    -        .put_item(key_info, it, false, None, &ExpressionMaps::default(), None)
    +        .put_item(
    +            key_info,
    +            it,
    +            false,
    +            None,
    +            &ExpressionMaps::default(),
    +            None,
    +            None,
    +        )
             .await
             .expect("put");
     }
    @@ -522,7 +530,15 @@ async fn concurrent_build(delay_ms: u32) {
                             .map(|_| ()),
                         1 => s
                             .engine
    -                        .put_item(key_info, item(n, None, Some(i)), false, None, maps, None)
    +                        .put_item(
    +                            key_info,
    +                            item(n, None, Some(i)),
    +                            false,
    +                            None,
    +                            maps,
    +                            None,
    +                            None,
    +                        )
                             .await
                             .map(|_| ()),
                         2 => match s
    @@ -534,6 +550,7 @@ async fn concurrent_build(delay_ms: u32) {
                                 Some(exists),
                                 maps,
                                 None,
    +                            None,
                             )
                             .await
                         {
    @@ -549,6 +566,7 @@ async fn concurrent_build(delay_ms: u32) {
                                 None,
                                 maps,
                                 None,
    +                            None,
                             )
                             .await
                             .map(|_| ()),
    @@ -1142,6 +1160,7 @@ async fn a_write_to_a_dropped_data_table_is_table_not_found() {
                 None,
                 &ExpressionMaps::default(),
                 None,
    +            None,
             )
             .await;
         assert!(
    diff --git a/crates/storage-postgres/tests/gsi_stale_writer.rs b/crates/storage-postgres/tests/gsi_stale_writer.rs
    index 2791fbdfb..5e74cd5d1 100644
    --- a/crates/storage-postgres/tests/gsi_stale_writer.rs
    +++ b/crates/storage-postgres/tests/gsi_stale_writer.rs
    @@ -242,7 +242,15 @@ fn item(n: u32, g: Option<&str>, r: Option) -> Item {
     
     async fn put(s: &Scratch, key_info: &TableKeyInfo, it: Item) {
         s.engine
    -        .put_item(key_info, it, false, None, &ExpressionMaps::default(), None)
    +        .put_item(
    +            key_info,
    +            it,
    +            false,
    +            None,
    +            &ExpressionMaps::default(),
    +            None,
    +            None,
    +        )
             .await
             .expect("put");
     }
    @@ -301,9 +309,15 @@ async fn drive(at: PauseWrites, conditional: bool) {
         )
         .expect("parse");
         let writes = async {
    -        let a = s
    -            .engine
    -            .put_item(&ki, item(100, Some("late"), None), false, None, &maps, None);
    +        let a = s.engine.put_item(
    +            &ki,
    +            item(100, Some("late"), None),
    +            false,
    +            None,
    +            &maps,
    +            None,
    +            None,
    +        );
             let b = s.engine.put_item(
                 &ki,
                 item(1, Some("changed"), None),
    @@ -311,6 +325,7 @@ async fn drive(at: PauseWrites, conditional: bool) {
                 conditional.then_some(&cond),
                 &maps,
                 None,
    +            None,
             );
             let mut key2 = item(2, None, None);
             key2.remove("g");
    diff --git a/crates/storage-postgres/tests/key_collation.rs b/crates/storage-postgres/tests/key_collation.rs
    index 4f363eaa7..e19274e89 100644
    --- a/crates/storage-postgres/tests/key_collation.rs
    +++ b/crates/storage-postgres/tests/key_collation.rs
    @@ -338,7 +338,7 @@ async fn resuming_inside_a_partition_seeks_on_the_sort_key() {
                 ("gsk".to_owned(), AttributeValue::S(format!("k{i:05}"))),
             ]);
             s.engine
    -            .put_item(&key_info, item, false, None, &maps, None)
    +            .put_item(&key_info, item, false, None, &maps, None, None)
                 .await
                 .expect("put an item");
         }
    diff --git a/crates/storage-postgres/tests/put_create_race.rs b/crates/storage-postgres/tests/put_create_race.rs
    index e62d9a508..33bccc2fe 100644
    --- a/crates/storage-postgres/tests/put_create_race.rs
    +++ b/crates/storage-postgres/tests/put_create_race.rs
    @@ -298,9 +298,15 @@ async fn put_after_winner(
         let table = data_table(&s.db).await;
         let (creator, creator_pid) = create_in_flight(&s.db, &table, &item(range, "winner")).await;
         let maps = ExpressionMaps::default();
    -    let put = s
    -        .engine
    -        .put_item(key_info, item(range, "loser"), true, condition, &maps, None);
    +    let put = s.engine.put_item(
    +        key_info,
    +        item(range, "loser"),
    +        true,
    +        condition,
    +        &maps,
    +        None,
    +        None,
    +    );
         let commit = async {
             let waited = wait_until_blocked_by(&s.db, creator_pid, "transactionid").await;
             creator.commit().await.expect("commit the create");
    @@ -551,9 +557,15 @@ async fn deleted_winner_is_retried(test: &str, range: bool) {
         let s = scratch().await;
         let (key_info, data, mut gate, gate_pid) = gated_table(&s, range).await;
         let maps = ExpressionMaps::default();
    -    let put = s
    -        .engine
    -        .put_item(&key_info, item(range, "loser"), true, None, &maps, None);
    +    let put = s.engine.put_item(
    +        &key_info,
    +        item(range, "loser"),
    +        true,
    +        None,
    +        &maps,
    +        None,
    +        None,
    +    );
         let driver = async {
             let steps = async {
                 wait_until_blocked_by(&s.db, gate_pid, "advisory").await?;
    @@ -604,9 +616,15 @@ async fn create_race_churn_gives_up(test: &str, range: bool) {
         let s = scratch().await;
         let (key_info, data, mut gate, gate_pid) = gated_table(&s, range).await;
         let maps = ExpressionMaps::default();
    -    let put = s
    -        .engine
    -        .put_item(&key_info, item(range, "loser"), true, None, &maps, None);
    +    let put = s.engine.put_item(
    +        &key_info,
    +        item(range, "loser"),
    +        true,
    +        None,
    +        &maps,
    +        None,
    +        None,
    +    );
         let driver = async {
             let steps = async {
                 for k in 1..=4 {
    @@ -732,7 +750,15 @@ async fn a_lost_create_race_gives_the_lsi_and_the_stream_the_winner() {
         let maps = ExpressionMaps::default();
         let key_info = lsi_stream_table(&s).await;
         s.engine
    -        .put_item(&key_info, range_item("d", &[]), false, None, &maps, None)
    +        .put_item(
    +            &key_info,
    +            range_item("d", &[]),
    +            false,
    +            None,
    +            &maps,
    +            None,
    +            None,
    +        )
             .await
             .expect("seed d");
         let table_id: String =
    @@ -788,7 +814,15 @@ async fn a_lost_create_race_gives_the_lsi_and_the_stream_the_winner() {
             wait_until_blocked_by(&s.db, holder_pid, "transactionid").await?;
             Ok::<_, String>(
                 s.engine
    -                .put_item(&key_info, loser.clone(), true, None, &maps, Some(&capture))
    +                .put_item(
    +                    &key_info,
    +                    loser.clone(),
    +                    true,
    +                    None,
    +                    &maps,
    +                    Some(&capture),
    +                    None,
    +                )
                     .await,
             )
         };
    diff --git a/crates/storage-postgres/tests/stream_commit_order.rs b/crates/storage-postgres/tests/stream_commit_order.rs
    index 72f8acd89..331a22f2d 100644
    --- a/crates/storage-postgres/tests/stream_commit_order.rs
    +++ b/crates/storage-postgres/tests/stream_commit_order.rs
    @@ -150,6 +150,7 @@ impl Scratch {
                     None,
                     &ExpressionMaps::default(),
                     Some(&capture()),
    +                None,
                 )
                 .await
                 .expect("store the item");
    diff --git a/crates/storage-postgres/tests/stream_record_clock.rs b/crates/storage-postgres/tests/stream_record_clock.rs
    index d783b0960..3f860aa11 100644
    --- a/crates/storage-postgres/tests/stream_record_clock.rs
    +++ b/crates/storage-postgres/tests/stream_record_clock.rs
    @@ -264,6 +264,7 @@ async fn created_at_matches_the_record_timestamp_when_the_write_waited() {
                 None,
                 &maps,
                 Some(&capture),
    +            None,
             )
             .await
             .expect("seed the item");
    @@ -289,6 +290,7 @@ async fn created_at_matches_the_record_timestamp_when_the_write_waited() {
             None,
             &maps,
             Some(&capture),
    +        None,
         );
         let release = async {
             wait_for_lock_waiters(&s.db, holder_pid, 1).await;
    diff --git a/crates/storage-postgres/tests/table_snapshot.rs b/crates/storage-postgres/tests/table_snapshot.rs
    index 9dbc4a928..308311235 100644
    --- a/crates/storage-postgres/tests/table_snapshot.rs
    +++ b/crates/storage-postgres/tests/table_snapshot.rs
    @@ -424,7 +424,15 @@ async fn put_collation_keys(s: &Scratch, table: &str, as_range: bool) {
             .into_iter()
             .collect();
             s.engine
    -            .put_item(&ki, item, false, None, &ExpressionMaps::default(), None)
    +            .put_item(
    +                &ki,
    +                item,
    +                false,
    +                None,
    +                &ExpressionMaps::default(),
    +                None,
    +                None,
    +            )
                 .await
                 .expect("put");
         }
    diff --git a/crates/storage-postgres/tests/twi_conflict.rs b/crates/storage-postgres/tests/twi_conflict.rs
    index 50d1e10fe..0f9b8ae20 100644
    --- a/crates/storage-postgres/tests/twi_conflict.rs
    +++ b/crates/storage-postgres/tests/twi_conflict.rs
    @@ -196,7 +196,7 @@ async fn seeded_table(s: &Scratch, maps: &ExpressionMaps) -> TableKeyInfo {
             .expect("read the key info");
         for pk in ["a", "b"] {
             s.engine
    -            .put_item(&key_info, item(pk, "seed"), false, None, maps, None)
    +            .put_item(&key_info, item(pk, "seed"), false, None, maps, None, None)
                 .await
                 .expect("seed an item");
         }
    @@ -457,7 +457,7 @@ async fn an_invalid_first_op_answers_without_waiting_on_later_locks() {
         let key_info = gsi_table(&s).await;
         let b = item("b", "seed");
         s.engine
    -        .put_item(&key_info, b.clone(), false, None, &maps, None)
    +        .put_item(&key_info, b.clone(), false, None, &maps, None, None)
             .await
             .expect("seed b");
         let table = data_table(&s.db).await;
    @@ -678,7 +678,15 @@ async fn a_plain_put_waits_for_a_check_of_the_missing_item() {
             assert_eq!(read.0.expect("GetItem of 0c"), None, "GetItem sees no item");
             assert_eq!(read.1, 0, "no reader sees the placeholder row");
             s.engine
    -            .put_item(&key_info, item("0c", "plain"), false, None, &maps, None)
    +            .put_item(
    +                &key_info,
    +                item("0c", "plain"),
    +                false,
    +                None,
    +                &maps,
    +                None,
    +                None,
    +            )
                 .await
         };
         let release = async {
    @@ -781,7 +789,15 @@ async fn a_delete_that_loses_its_reservation_removes_the_winner_everywhere() {
         let maps = ExpressionMaps::default();
         let key_info = lsi_stream_table(&s).await;
         s.engine
    -        .put_item(&key_info, range_item("d", &[]), false, None, &maps, None)
    +        .put_item(
    +            &key_info,
    +            range_item("d", &[]),
    +            false,
    +            None,
    +            &maps,
    +            None,
    +            None,
    +        )
             .await
             .expect("seed d");
         let table_id: String =
    diff --git a/crates/storage-postgres/tests/vector_control_plane.rs b/crates/storage-postgres/tests/vector_control_plane.rs
    index 1f6fc3213..5fb9c43bd 100644
    --- a/crates/storage-postgres/tests/vector_control_plane.rs
    +++ b/crates/storage-postgres/tests/vector_control_plane.rs
    @@ -1657,7 +1657,7 @@ fn vector_item(pk: &str, tenant: Option<&str>, values: &[&str]) -> Item {
     async fn put(engine: &PostgresEngine, key_info: &TableKeyInfo, item: Item) {
         let maps = ExpressionMaps::new(HashMap::new(), HashMap::new());
         engine
    -        .put_item(key_info, item, false, None, &maps, None)
    +        .put_item(key_info, item, false, None, &maps, None, None)
             .await
             .expect("put an item");
     }
    @@ -3059,6 +3059,7 @@ async fn an_inline_write_survives_the_index_data_table_disappearing() {
                 None,
                 &maps,
                 None,
    +            None,
             )
             .await
             .expect("a write racing an index delete must succeed, not fail internally");
    diff --git a/crates/storage-sqlite/Cargo.toml b/crates/storage-sqlite/Cargo.toml
    index 589030ade..a595c350f 100644
    --- a/crates/storage-sqlite/Cargo.toml
    +++ b/crates/storage-sqlite/Cargo.toml
    @@ -41,7 +41,11 @@ libc = { workspace = true }
     libsqlite3-sys = { workspace = true }
     
     [dev-dependencies]
    +# The checksum test, and the bulk operation store contract tests, which build
    +# a real engine on a throwaway database file and drive it through the shared
    +# cases in `extenddb-storage`.
     extenddb-storage = { workspace = true, features = ["test-util"] }
    +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
     # The slow-statement test sets sqlx's threshold, a `log` level, and prints
     # every tracing event of a child process.
     log = "0.4"
    diff --git a/crates/storage-sqlite/migrations/0008_bulk_operations.sql b/crates/storage-sqlite/migrations/0008_bulk_operations.sql
    new file mode 100644
    index 000000000..239683839
    --- /dev/null
    +++ b/crates/storage-sqlite/migrations/0008_bulk_operations.sql
    @@ -0,0 +1,126 @@
    +-- Copyright 2026 ExtendDB contributors
    +-- SPDX-License-Identifier: Apache-2.0
    +--
    +-- Migration 8, bulk_operations, SQLite: the bulk put items tables. Column
    +-- names are kept identical to the PostgreSQL catalog and data databases,
    +-- whose 0008 files hold the comments on each table. Every statement is
    +-- IF NOT EXISTS, so a second run changes nothing.
    +
    +-- One row per bulk operation: identity, configuration, counters, the resume
    +-- cursor, and the lease. No foreign key to tables(table_id): the record
    +-- outlives its table.
    +CREATE TABLE IF NOT EXISTS bulk_operations (
    +    account_id TEXT NOT NULL,
    +    operation_id TEXT NOT NULL,
    +    table_id TEXT NOT NULL,
    +    table_name TEXT NOT NULL,
    +    table_arn TEXT NOT NULL,
    +    status TEXT NOT NULL,
    +    client_token TEXT,
    +    client_token_hash TEXT,
    +    request_fingerprint TEXT NOT NULL,
    +    input_config TEXT NOT NULL,
    +    output_config TEXT NOT NULL,
    +    input_files TEXT NOT NULL,
    +    max_write_request_units INTEGER,
    +    performance_tier TEXT,
    +    start_time_ms INTEGER NOT NULL,
    +    end_time_ms INTEGER,
    +    processed_item_count INTEGER NOT NULL DEFAULT 0,
    +    failed_item_count INTEGER NOT NULL DEFAULT 0,
    +    processed_input_bytes INTEGER NOT NULL DEFAULT 0,
    +    -- NULL until the input is enumerated; a failure born before the walk
    +    -- never sets it, and Describe then omits TotalInputBytes.
    +    total_input_bytes INTEGER,
    +    report_record_count INTEGER NOT NULL DEFAULT 0,
    +    report_bytes INTEGER NOT NULL DEFAULT 0,
    +    usage TEXT NOT NULL DEFAULT '{}',
    +    failure_code TEXT,
    +    failure_message TEXT,
    +    cancel_requested INTEGER NOT NULL DEFAULT 0,
    +    cursor_file_index INTEGER NOT NULL DEFAULT 0,
    +    cursor_row_index INTEGER NOT NULL DEFAULT 0,
    +    lease_owner TEXT,
    +    lease_until_ms INTEGER,
    +    -- JSON the engine records at Start for the runner (the store and the
    +    -- caller); NULL for a row written without one. Never rendered.
    +    run_context TEXT,
    +    PRIMARY KEY (account_id, operation_id)
    +);
    +
    +-- At most one non-terminal operation per table. The insert that can violate
    +-- this runs inside the start transaction with INSERT OR IGNORE, so the
    +-- conflict surfaces as changes() = 0 rather than as an error.
    +CREATE UNIQUE INDEX IF NOT EXISTS idx_bulk_ops_table_guard
    +    ON bulk_operations (account_id, table_id)
    +    WHERE status IN ('IN_PROGRESS', 'CANCELLING');
    +
    +-- Idempotency: one record per (account, token hash). NULL hashes are
    +-- distinct in SQLite unique indexes, so token-less operations never collide.
    +CREATE UNIQUE INDEX IF NOT EXISTS idx_bulk_ops_token
    +    ON bulk_operations (account_id, client_token_hash);
    +
    +CREATE INDEX IF NOT EXISTS idx_bulk_ops_list
    +    ON bulk_operations (account_id, start_time_ms DESC, operation_id DESC);
    +
    +CREATE INDEX IF NOT EXISTS idx_bulk_ops_claim
    +    ON bulk_operations (status, lease_until_ms);
    +
    +-- The input files of a bulk operation, one row per file in list order.
    +-- Start inserts them; a checkpoint updates the rows of the files its
    +-- cursor passes, with the row count and tag of a file read to its end or
    +-- the abandon of one the runner stopped reading. The operation row's
    +-- input_files column holds '[]'.
    +CREATE TABLE IF NOT EXISTS bulk_operation_input_files (
    +    account_id TEXT NOT NULL,
    +    operation_id TEXT NOT NULL,
    +    file_index INTEGER NOT NULL,
    +    path TEXT NOT NULL,
    +    size INTEGER NOT NULL,
    +    row_count INTEGER,
    +    etag TEXT,
    +    abandon TEXT,
    +    PRIMARY KEY (account_id, operation_id, file_index)
    +);
    +
    +-- Report records, written with each checkpoint and rendered to files at the
    +-- end of the run. Deleted by finish and by the retention purge.
    +CREATE TABLE IF NOT EXISTS bulk_operation_report_records (
    +    account_id TEXT NOT NULL,
    +    operation_id TEXT NOT NULL,
    +    kind TEXT NOT NULL,
    +    file_index INTEGER NOT NULL,
    +    row_index INTEGER NOT NULL,
    +    record TEXT NOT NULL,
    +    PRIMARY KEY (account_id, operation_id, kind, file_index, row_index)
    +);
    +
    +-- Row-outcome ledger for bulk put rows. Every row a bulk write batch
    +-- applies is inserted in the same transaction as the item write, with the
    +-- size of the item it replaced and, when the runner asks, that item, so a
    +-- run resumed after a crash finds the row applied and accounts for it
    +-- without writing it again. Keyed by account because the catalog primary
    +-- key permits the same operation_id in two accounts.
    +CREATE TABLE IF NOT EXISTS bulk_row_outcomes (
    +    account_id TEXT NOT NULL,
    +    operation_id TEXT NOT NULL,
    +    file_index INTEGER NOT NULL,
    +    row_index INTEGER NOT NULL,
    +    outcome TEXT NOT NULL,
    +    old_size INTEGER,
    +    old_image TEXT,
    +    PRIMARY KEY (account_id, operation_id, file_index, row_index)
    +);
    +
    +-- The claim whose bulk write batches commit for an operation, written by
    +-- each claim before its first batch, and the checkpoint cursor the claim
    +-- restored with the count of claims in a row that found it unchanged.
    +CREATE TABLE IF NOT EXISTS bulk_write_owners (
    +    account_id TEXT NOT NULL,
    +    operation_id TEXT NOT NULL,
    +    owner TEXT NOT NULL,
    +    cursor_file INTEGER NOT NULL,
    +    cursor_row INTEGER NOT NULL,
    +    stalled INTEGER NOT NULL,
    +    PRIMARY KEY (account_id, operation_id)
    +);
    diff --git a/crates/storage-sqlite/src/backup.rs b/crates/storage-sqlite/src/backup.rs
    index ab1768d49..72f7dc3cd 100644
    --- a/crates/storage-sqlite/src/backup.rs
    +++ b/crates/storage-sqlite/src/backup.rs
    @@ -1683,6 +1683,7 @@ mod restore_tests {
                         None,
                         &extenddb_core::expression::ExpressionMaps::default(),
                         None,
    +                    None,
                     )
                     .await
                     .expect("put");
    @@ -2731,6 +2732,7 @@ mod restore_tests {
                         None,
                         &extenddb_core::expression::ExpressionMaps::default(),
                         None,
    +                    None,
                     )
                     .await
                     .expect("put");
    @@ -3192,6 +3194,7 @@ mod restore_tests {
                             None,
                             &extenddb_core::expression::ExpressionMaps::default(),
                             None,
    +                        None,
                         )
                         .await
                         .expect("writer put");
    @@ -3283,6 +3286,7 @@ mod restore_tests {
                     None,
                     &extenddb_core::expression::ExpressionMaps::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("late put");
    diff --git a/crates/storage-sqlite/src/bulk_operation_store/claim.rs b/crates/storage-sqlite/src/bulk_operation_store/claim.rs
    new file mode 100644
    index 000000000..a40195ffe
    --- /dev/null
    +++ b/crates/storage-sqlite/src/bulk_operation_store/claim.rs
    @@ -0,0 +1,424 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The lifecycle writes for the `SQLite` `BulkOperationStore`: cancel, the
    +//! runnable claim and lease renewal, the checkpoint that advances the cursor
    +//! and persists report records, and the terminal finish.
    +
    +use super::*;
    +
    +pub(super) fn request_cancel<'a>(
    +    engine: &'a SqliteEngine,
    +    account_id: &str,
    +    operation_id: &str,
    +) -> BoxFuture<'a, Result> {
    +    let account_id = account_id.to_owned();
    +    let operation_id = operation_id.to_owned();
    +    Box::pin(async move {
    +        let _writer = engine.write_lock.lock().await;
    +        let mut tx = engine
    +            .pool
    +            .begin_with("BEGIN IMMEDIATE")
    +            .await
    +            .map_err(internal)?;
    +        // One conditional update; the answer is never a racy pre-read.
    +        let updated: Option<(String,)> = sqlx::query_as(
    +            "UPDATE bulk_operations SET cancel_requested = 1, status = 'CANCELLING' \
    +             WHERE account_id = ? AND operation_id = ? \
    +               AND status IN ('IN_PROGRESS', 'CANCELLING') \
    +               RETURNING status",
    +        )
    +        .bind(&account_id)
    +        .bind(&operation_id)
    +        .fetch_optional(&mut *tx)
    +        .await
    +        .map_err(internal)?;
    +        if updated.is_some() {
    +            tx.commit().await.map_err(internal)?;
    +            return Ok(CancelOutcome::Cancelling);
    +        }
    +        // Nothing updated: the row is terminal or absent. Read inside the
    +        // same transaction so the answer cannot race a delete.
    +        let status: Option<(String,)> = sqlx::query_as(
    +            "SELECT status FROM bulk_operations WHERE account_id = ? AND operation_id = ?",
    +        )
    +        .bind(&account_id)
    +        .bind(&operation_id)
    +        .fetch_optional(&mut *tx)
    +        .await
    +        .map_err(internal)?;
    +        tx.commit().await.map_err(internal)?;
    +        Ok(match status {
    +            Some((s,)) => CancelOutcome::AlreadyTerminal(s),
    +            None => CancelOutcome::NotFound,
    +        })
    +    })
    +}
    +
    +pub(super) fn claim_runnable<'a>(
    +    engine: &'a SqliteEngine,
    +    owner: &str,
    +    lease: Duration,
    +    max: usize,
    +    exclude_accounts: &[String],
    +    exclude_operations: &[String],
    +) -> BoxFuture<'a, Result, StorageError>> {
    +    let owner = owner.to_owned();
    +    let exclude = exclude_accounts.to_vec();
    +    let exclude_ops = exclude_operations.to_vec();
    +    Box::pin(async move {
    +        let now = now_epoch_ms();
    +        // The deadline is computed here rather than in SQL so a saturated
    +        // lease cannot overflow inside the database.
    +        let lease_until = now.saturating_add(lease_ms(lease));
    +        let max = i64::try_from(max).unwrap_or(i64::MAX);
    +        // One positional placeholder per excluded account and per
    +        // excluded operation; an empty list contributes no clause.
    +        // Placeholders only, per ADR-0002.
    +        let exclude_clause = if exclude.is_empty() {
    +            String::new()
    +        } else {
    +            let marks = vec!["?"; exclude.len()].join(", ");
    +            format!("AND account_id NOT IN ({marks}) ")
    +        };
    +        let exclude_ops_clause = if exclude_ops.is_empty() {
    +            String::new()
    +        } else {
    +            let marks = vec!["?"; exclude_ops.len()].join(", ");
    +            format!("AND operation_id NOT IN ({marks}) ")
    +        };
    +        // A claim that finds nothing to claim takes no write lock: this read
    +        // selects on the predicate the update below selects on, outside the
    +        // engine write lock, and an idle server answers from it on every
    +        // tick. A row that becomes runnable after it is claimed on the next
    +        // tick.
    +        let probe = format!(
    +            "SELECT EXISTS (SELECT 1 FROM bulk_operations \
    +              WHERE status IN ('IN_PROGRESS', 'CANCELLING') \
    +                AND (lease_until_ms IS NULL OR lease_until_ms < ?) \
    +                {exclude_clause}\
    +                {exclude_ops_clause})"
    +        );
    +        let mut probe_query = sqlx::query_scalar::<_, bool>(&probe).bind(now);
    +        for account in &exclude {
    +            probe_query = probe_query.bind(account);
    +        }
    +        for operation in &exclude_ops {
    +            probe_query = probe_query.bind(operation);
    +        }
    +        if !probe_query
    +            .fetch_one(&engine.pool)
    +            .await
    +            .map_err(internal)?
    +        {
    +            return Ok(Vec::new());
    +        }
    +        let _writer = engine.write_lock.lock().await;
    +        let mut tx = engine
    +            .pool
    +            .begin_with("BEGIN IMMEDIATE")
    +            .await
    +            .map_err(internal)?;
    +        // Same shape as the PostgreSQL claim, with `rowid IN (...)` in
    +        // place of `FOR UPDATE SKIP LOCKED`: BEGIN IMMEDIATE already
    +        // excludes every competing writer. The RETURNING clause names the
    +        // record columns so `client_token_hash` never leaves storage.
    +        let sql = format!(
    +            "UPDATE bulk_operations SET lease_owner = ?, lease_until_ms = ? \
    +             WHERE rowid IN ( \
    +                 SELECT rowid FROM bulk_operations \
    +                  WHERE status IN ('IN_PROGRESS', 'CANCELLING') \
    +                    AND (lease_until_ms IS NULL OR lease_until_ms < ?) \
    +                    {exclude_clause}\
    +                    {exclude_ops_clause}\
    +                  ORDER BY start_time_ms, operation_id LIMIT ?) \
    +             RETURNING {RECORD_COLUMNS}"
    +        );
    +        let mut query = sqlx::query(&sql).bind(&owner).bind(lease_until).bind(now);
    +        for account in &exclude {
    +            query = query.bind(account);
    +        }
    +        for operation in &exclude_ops {
    +            query = query.bind(operation);
    +        }
    +        let rows = query
    +            .bind(max)
    +            .fetch_all(&mut *tx)
    +            .await
    +            .map_err(internal)?;
    +        // Each row decodes on its own. A row that does not decode is ended
    +        // in this transaction rather than failing the claim: an error here
    +        // would roll the whole claim back, and the same row, still the
    +        // oldest, would be selected again on every tick.
    +        let mut records = Vec::with_capacity(rows.len());
    +        for row in &rows {
    +            let key = unreadable::row_key(row)?;
    +            let record = match unreadable::decode_or_log(row, &key, "the claim ends it") {
    +                Some(record) => {
    +                    files::with_files_or_log(&mut tx, record, "the claim ends it").await?
    +                }
    +                None => None,
    +            };
    +            match record {
    +                Some(record) => records.push(record),
    +                None => unreadable::fail_unreadable(&mut tx, &key, now).await?,
    +            }
    +        }
    +        tx.commit().await.map_err(internal)?;
    +        // SQLite defines the order of RETURNING output as undefined; the
    +        // subselect's ORDER BY only chooses which rows LIMIT takes. Sort
    +        // so callers receive the claimed rows oldest start first.
    +        records.sort_unstable_by(|a, b| {
    +            (a.start_time_ms, &a.operation_id).cmp(&(b.start_time_ms, &b.operation_id))
    +        });
    +        Ok(records)
    +    })
    +}
    +
    +pub(super) fn checkpoint<'a>(
    +    engine: &'a SqliteEngine,
    +    account_id: &str,
    +    operation_id: &str,
    +    owner: &str,
    +    lease: Duration,
    +    progress: Progress,
    +) -> BoxFuture<'a, Result> {
    +    let account_id = account_id.to_owned();
    +    let operation_id = operation_id.to_owned();
    +    let owner = owner.to_owned();
    +    Box::pin(async move {
    +        let usage = serde_json::to_string(&progress.usage).map_err(internal)?;
    +        let now = now_epoch_ms();
    +        // The deadline is computed here rather than in SQL so a saturated
    +        // lease cannot overflow inside the database.
    +        let lease_until = now.saturating_add(lease_ms(lease));
    +        let report_record_count = i64::try_from(progress.report_record_count).unwrap_or(i64::MAX);
    +        let report_bytes = i64::try_from(progress.report_bytes).unwrap_or(i64::MAX);
    +        let _writer = engine.write_lock.lock().await;
    +        let mut tx = engine
    +            .pool
    +            .begin_with("BEGIN IMMEDIATE")
    +            .await
    +            .map_err(internal)?;
    +        let updated = sqlx::query(
    +            "UPDATE bulk_operations SET cursor_file_index = ?, cursor_row_index = ?, \
    +             processed_item_count = ?, failed_item_count = ?, processed_input_bytes = ?, \
    +             report_record_count = ?, report_bytes = ?, usage = ?, lease_until_ms = ? \
    +             WHERE account_id = ? AND operation_id = ? AND lease_owner = ?",
    +        )
    +        .bind(progress.cursor_file_index)
    +        .bind(progress.cursor_row_index)
    +        .bind(progress.processed_item_count)
    +        .bind(progress.failed_item_count)
    +        .bind(progress.processed_input_bytes)
    +        .bind(report_record_count)
    +        .bind(report_bytes)
    +        .bind(&usage)
    +        .bind(lease_until)
    +        .bind(&account_id)
    +        .bind(&operation_id)
    +        .bind(&owner)
    +        .execute(&mut *tx)
    +        .await
    +        .map_err(internal)?
    +        .rows_affected();
    +        if updated == 0 {
    +            // Dropping the transaction rolls it back; a lost lease writes
    +            // nothing, report records included.
    +            return Ok(LeaseState::Lost);
    +        }
    +        // OR IGNORE on the five-column primary key: a record is durable
    +        // exactly when the checkpoint covering its row is, so a duplicate
    +        // key here can only be a replayed row whose record already
    +        // committed. Keeping the first record is the second line of
    +        // defence, the same as the ledger.
    +        for record in &progress.records {
    +            let body = serde_json::to_string(&record.record).map_err(internal)?;
    +            sqlx::query(
    +                "INSERT OR IGNORE INTO bulk_operation_report_records \
    +                 (account_id, operation_id, kind, file_index, row_index, record) \
    +                 VALUES (?, ?, ?, ?, ?, ?)",
    +            )
    +            .bind(&account_id)
    +            .bind(&operation_id)
    +            .bind(record.kind.as_str())
    +            .bind(record.file_index)
    +            .bind(record.row_index)
    +            .bind(&body)
    +            .execute(&mut *tx)
    +            .await
    +            .map_err(internal)?;
    +        }
    +        // The finished files' figures and the abandoned files' outcomes
    +        // land on their rows of `bulk_operation_input_files` in the same
    +        // transaction as the cursor that passed them; no other file's row
    +        // is touched, so a checkpoint costs the same whatever the file
    +        // count. An index past the enumerated files names no row, which is
    +        // a runner defect, and refuses the whole checkpoint.
    +        let past = |index: usize| {
    +            StorageError::Internal(format!(
    +                "input file index {index} is past the enumerated files"
    +            ))
    +        };
    +        for finished in &progress.finished_files {
    +            let rows = i64::try_from(finished.rows).unwrap_or(i64::MAX);
    +            // A file finished without a tag keeps the one the listing gave.
    +            let updated = sqlx::query(
    +                "UPDATE bulk_operation_input_files SET row_count = ?, etag = COALESCE(?, etag) \
    +                 WHERE account_id = ? AND operation_id = ? AND file_index = ?",
    +            )
    +            .bind(rows)
    +            .bind(&finished.etag)
    +            .bind(&account_id)
    +            .bind(&operation_id)
    +            .bind(i64::try_from(finished.index).unwrap_or(i64::MAX))
    +            .execute(&mut *tx)
    +            .await
    +            .map_err(internal)?
    +            .rows_affected();
    +            if updated == 0 {
    +                return Err(past(finished.index));
    +            }
    +        }
    +        // The abandon is stored as its JSON text.
    +        for abandoned in &progress.abandoned_files {
    +            let stored = FileAbandon {
    +                row: i64::try_from(abandoned.row).unwrap_or(i64::MAX),
    +                kind: abandoned.kind,
    +                bytes: Some(i64::try_from(abandoned.bytes).unwrap_or(i64::MAX)),
    +            }
    +            .to_json()
    +            .to_string();
    +            let updated = sqlx::query(
    +                "UPDATE bulk_operation_input_files SET abandon = ? \
    +                 WHERE account_id = ? AND operation_id = ? AND file_index = ?",
    +            )
    +            .bind(stored)
    +            .bind(&account_id)
    +            .bind(&operation_id)
    +            .bind(i64::try_from(abandoned.index).unwrap_or(i64::MAX))
    +            .execute(&mut *tx)
    +            .await
    +            .map_err(internal)?
    +            .rows_affected();
    +            if updated == 0 {
    +                return Err(past(abandoned.index));
    +            }
    +        }
    +        tx.commit().await.map_err(internal)?;
    +        Ok(LeaseState::Held)
    +    })
    +}
    +
    +pub(super) fn finish<'a>(
    +    engine: &'a SqliteEngine,
    +    account_id: &str,
    +    operation_id: &str,
    +    owner: &str,
    +    outcome: Terminal,
    +) -> BoxFuture<'a, Result> {
    +    let account_id = account_id.to_owned();
    +    let operation_id = operation_id.to_owned();
    +    let owner = owner.to_owned();
    +    Box::pin(async move {
    +        let usage = serde_json::to_string(&outcome.usage).map_err(internal)?;
    +        let writer = engine.write_lock.lock().await;
    +        let mut tx = engine
    +            .pool
    +            .begin_with("BEGIN IMMEDIATE")
    +            .await
    +            .map_err(internal)?;
    +        // A CANCELLING row becomes CANCELLED whatever the runner computed:
    +        // a cancel that landed after the runner's last boundary check must
    +        // still win. RETURNING hands back the status
    +        // the row now stores, so the caller reports the stored status,
    +        // not the one it sent.
    +        let stored: Option = sqlx::query_scalar(
    +            // failure_code and failure_message follow the SAME CASE as
    +            // status: when a cancel forces the row to CANCELLED, the
    +            // runner-computed failure fields are replaced by the pair a
    +            // cancelled operation carries on the wire (measured on the
    +            // service). processed_input_bytes is not touched here, so the
    +            // runner's last checkpointed figure stands: the bytes read
    +            // before the stop, which the service reports for a cancelled
    +            // operation. Column references in a SET list read the pre-update
    +            // row, so every CASE expression tests the same original status.
    +            "UPDATE bulk_operations SET \
    +             status = CASE WHEN status = 'CANCELLING' THEN 'CANCELLED' ELSE ? END, \
    +             end_time_ms = ?, \
    +             failure_code = CASE WHEN status = 'CANCELLING' THEN ? ELSE ? END, \
    +             failure_message = CASE WHEN status = 'CANCELLING' THEN ? ELSE ? END, \
    +             usage = ?, \
    +             lease_owner = NULL, lease_until_ms = NULL \
    +             WHERE account_id = ? AND operation_id = ? AND lease_owner = ? \
    +               AND status IN ('IN_PROGRESS', 'CANCELLING') \
    +               RETURNING status",
    +        )
    +        .bind(&outcome.status)
    +        .bind(outcome.end_time_ms)
    +        .bind(extenddb_storage::CANCELLED_FAILURE_CODE)
    +        .bind(&outcome.failure_code)
    +        .bind(extenddb_storage::CANCELLED_FAILURE_MESSAGE)
    +        .bind(&outcome.failure_message)
    +        .bind(&usage)
    +        .bind(&account_id)
    +        .bind(&operation_id)
    +        .bind(&owner)
    +        .fetch_optional(&mut *tx)
    +        .await
    +        .map_err(internal)?;
    +        let Some(status) = stored else {
    +            return Ok(FinishState::Lost);
    +        };
    +        tx.commit().await.map_err(internal)?;
    +        drop(writer);
    +        // The records go after the transition commits, in chunks that each
    +        // take the write lock, so other writers run between them. A crash
    +        // between chunks leaves records the retention purge deletes with the
    +        // operation; nothing reads the records of a terminal operation.
    +        if let Err(error) = delete_report_records(engine, &account_id, &operation_id).await {
    +            tracing::warn!(
    +                account_id = %account_id,
    +                operation_id = %operation_id,
    +                error = %error,
    +                "report records of a finished bulk operation were not all deleted; \
    +                 the retention purge deletes the rest"
    +            );
    +        }
    +        Ok(FinishState::Held(status))
    +    })
    +}
    +
    +/// Rows one chunk of an end-of-operation delete removes. Each chunk is one
    +/// statement under the engine write lock, which other writers take between
    +/// chunks; a delete of the whole set in one statement held the lock for
    +/// seconds at a few hundred thousand records.
    +const DELETE_CHUNK_ROWS: i64 = 2_000;
    +
    +/// Delete every report record of one operation, [`DELETE_CHUNK_ROWS`] at a
    +/// time, each chunk its own statement under the write lock.
    +pub(super) async fn delete_report_records(
    +    engine: &SqliteEngine,
    +    account_id: &str,
    +    operation_id: &str,
    +) -> Result<(), StorageError> {
    +    let chunk = u64::try_from(DELETE_CHUNK_ROWS).unwrap_or(u64::MAX);
    +    loop {
    +        let _writer = engine.write_lock.lock().await;
    +        let deleted = sqlx::query(
    +            "DELETE FROM bulk_operation_report_records WHERE rowid IN ( \
    +                 SELECT rowid FROM bulk_operation_report_records \
    +                  WHERE account_id = ? AND operation_id = ? LIMIT ?)",
    +        )
    +        .bind(account_id)
    +        .bind(operation_id)
    +        .bind(DELETE_CHUNK_ROWS)
    +        .execute(&engine.pool)
    +        .await
    +        .map_err(internal)?
    +        .rows_affected();
    +        if deleted < chunk {
    +            return Ok(());
    +        }
    +    }
    +}
    diff --git a/crates/storage-sqlite/src/bulk_operation_store/files.rs b/crates/storage-sqlite/src/bulk_operation_store/files.rs
    new file mode 100644
    index 000000000..5a09ee391
    --- /dev/null
    +++ b/crates/storage-sqlite/src/bulk_operation_store/files.rs
    @@ -0,0 +1,208 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The input files of a bulk operation, one row per file in
    +//! `bulk_operation_input_files`. Start inserts them, a checkpoint updates
    +//! the rows of the files its cursor passes, and every full read of the
    +//! record attaches them. The operation row's own `input_files` column
    +//! keeps an empty array; the per-file figures lived there once, and every
    +//! checkpoint that passed a file rewrote the whole list, so a load of N
    +//! files wrote N lists of N entries.
    +
    +use super::*;
    +
    +/// Files per insert statement at Start: eight values each, well inside
    +/// SQLite's limit on bound values per statement.
    +const INSERT_CHUNK_FILES: usize = 500;
    +
    +/// Insert the files of a new operation in list order, `file_index` from 0.
    +pub(super) async fn insert_files(
    +    conn: &mut sqlx::SqliteConnection,
    +    account_id: &str,
    +    operation_id: &str,
    +    files: &[InputFile],
    +) -> Result<(), StorageError> {
    +    for (chunk_index, chunk) in files.chunks(INSERT_CHUNK_FILES).enumerate() {
    +        let values = vec!["(?, ?, ?, ?, ?, ?, ?, ?)"; chunk.len()].join(", ");
    +        let sql = format!(
    +            "INSERT INTO bulk_operation_input_files \
    +                 (account_id, operation_id, file_index, path, size, row_count, etag, abandon) \
    +             VALUES {values}"
    +        );
    +        let mut query = sqlx::query(&sql);
    +        for (offset, file) in chunk.iter().enumerate() {
    +            let index = chunk_index * INSERT_CHUNK_FILES + offset;
    +            query = query
    +                .bind(account_id)
    +                .bind(operation_id)
    +                .bind(i64::try_from(index).unwrap_or(i64::MAX))
    +                .bind(&file.path)
    +                .bind(file.size)
    +                .bind(file.rows)
    +                .bind(&file.etag)
    +                .bind(file.abandon.map(|a| a.to_json().to_string()));
    +        }
    +        query.execute(&mut *conn).await.map_err(internal)?;
    +    }
    +    Ok(())
    +}
    +
    +/// One stored file row: index, path, size, row count, tag, abandon text.
    +type FileRow = (
    +    i64,
    +    String,
    +    i64,
    +    Option,
    +    Option,
    +    Option,
    +);
    +
    +/// The stored file rows of one operation, in list order.
    +pub(super) async fn fetch_files(
    +    conn: &mut sqlx::SqliteConnection,
    +    account_id: &str,
    +    operation_id: &str,
    +) -> Result, StorageError> {
    +    sqlx::query_as(
    +        "SELECT file_index, path, size, row_count, etag, abandon \
    +         FROM bulk_operation_input_files \
    +         WHERE account_id = ? AND operation_id = ? ORDER BY file_index",
    +    )
    +    .bind(account_id)
    +    .bind(operation_id)
    +    .fetch_all(conn)
    +    .await
    +    .map_err(internal)
    +}
    +
    +/// A stored abandon: absent is no abandon, and anything that is not an
    +/// abandon the store wrote, `null` included, is refused as unreadable, as
    +/// the PostgreSQL store refuses it.
    +pub(super) fn abandon_from_stored(
    +    stored: Option<&str>,
    +) -> Result, StorageError> {
    +    let Some(text) = stored else {
    +        return Ok(None);
    +    };
    +    serde_json::from_str::(text)
    +        .ok()
    +        .as_ref()
    +        .and_then(FileAbandon::from_json)
    +        .map(Some)
    +        .ok_or_else(|| {
    +            StorageError::Internal(
    +                "bulk_operation_input_files row has an unreadable abandon".to_owned(),
    +            )
    +        })
    +}
    +
    +/// Decode the stored file rows into the record's file list. The rows must
    +/// number the files from 0 without a gap.
    +pub(super) fn decode_files(rows: Vec) -> Result, StorageError> {
    +    rows.into_iter()
    +        .enumerate()
    +        .map(|(position, (index, path, size, rows, etag, abandon))| {
    +            if usize::try_from(index).ok() != Some(position) {
    +                return Err(StorageError::Internal(format!(
    +                    "bulk_operation_input_files has file {index} where file {position} belongs"
    +                )));
    +            }
    +            Ok(InputFile {
    +                path,
    +                size,
    +                rows,
    +                etag,
    +                abandon: abandon_from_stored(abandon.as_deref())?,
    +            })
    +        })
    +        .collect()
    +}
    +
    +/// Attach the stored files to a record decoded from its operation row.
    +pub(super) async fn attach_files(
    +    conn: &mut sqlx::SqliteConnection,
    +    record: &mut BulkOperationRecord,
    +) -> Result<(), StorageError> {
    +    let rows = fetch_files(conn, &record.account_id, &record.operation_id).await?;
    +    record.input_files = decode_files(rows)?;
    +    Ok(())
    +}
    +
    +/// Attach the stored files to a record the claim or the listing decoded,
    +/// or log at ERROR why they do not decode and answer `None`, the way
    +/// `unreadable::decode_or_log` treats the operation row. A read that fails
    +/// is an error, not an unreadable row.
    +pub(super) async fn with_files_or_log(
    +    conn: &mut sqlx::SqliteConnection,
    +    mut record: BulkOperationRecord,
    +    action: &str,
    +) -> Result, StorageError> {
    +    let rows = fetch_files(conn, &record.account_id, &record.operation_id).await?;
    +    match decode_files(rows) {
    +        Ok(files) => {
    +            record.input_files = files;
    +            Ok(Some(record))
    +        }
    +        Err(error) => {
    +            tracing::error!(
    +                account_id = %record.account_id,
    +                operation_id = %record.operation_id,
    +                "bulk operation record does not decode, {action}: {error}"
    +            );
    +            Ok(None)
    +        }
    +    }
    +}
    +
    +/// Delete the file rows of one operation, in the caller's transaction.
    +pub(super) async fn delete_files(
    +    conn: &mut sqlx::SqliteConnection,
    +    account_id: &str,
    +    operation_id: &str,
    +) -> Result<(), StorageError> {
    +    sqlx::query("DELETE FROM bulk_operation_input_files WHERE account_id = ? AND operation_id = ?")
    +        .bind(account_id)
    +        .bind(operation_id)
    +        .execute(conn)
    +        .await
    +        .map_err(internal)?;
    +    Ok(())
    +}
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::*;
    +
    +    #[test]
    +    fn a_null_abandon_is_refused_like_an_unreadable_one() {
    +        assert!(
    +            matches!(
    +                abandon_from_stored(Some("null")),
    +                Err(StorageError::Internal(_))
    +            ),
    +            "a null abandon reads as unreadable, as on PostgreSQL"
    +        );
    +        assert!(
    +            abandon_from_stored(None)
    +                .expect("no abandon is readable")
    +                .is_none()
    +        );
    +        assert!(matches!(
    +            abandon_from_stored(Some(r#"{"row": 3, "kind": "NOT_A_KIND"}"#)),
    +            Err(StorageError::Internal(_))
    +        ));
    +    }
    +
    +    #[test]
    +    fn the_file_rows_must_number_the_files_without_a_gap() {
    +        let row = |index: i64| (index, format!("in/{index}.json"), 1, None, None, None);
    +        assert_eq!(
    +            decode_files(vec![row(0), row(1)]).expect("in order").len(),
    +            2
    +        );
    +        assert!(matches!(
    +            decode_files(vec![row(0), row(2)]),
    +            Err(StorageError::Internal(_))
    +        ));
    +    }
    +}
    diff --git a/crates/storage-sqlite/src/bulk_operation_store/index_guard.rs b/crates/storage-sqlite/src/bulk_operation_store/index_guard.rs
    new file mode 100644
    index 000000000..63e88b55c
    --- /dev/null
    +++ b/crates/storage-sqlite/src/bulk_operation_store/index_guard.rs
    @@ -0,0 +1,161 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Keeps a bulk operation and an index creation on one table apart.
    +//!
    +//! Start refuses a table with an index that is not `ACTIVE`, and an index
    +//! creation is refused while the table has an operation `IN_PROGRESS` or
    +//! `CANCELLING`. Each side reads the other's row inside the
    +//! `BEGIN IMMEDIATE` transaction that writes its own, so the two are
    +//! serialized by the database's write lock, across processes on one file
    +//! as well as within one.
    +//!
    +//! A global secondary index build in this process keeps the engine write
    +//! lock until the index is `ACTIVE`, so a start that waited for the lock
    +//! behind a build would read the index already `ACTIVE`. Builds in this
    +//! process are therefore also counted per table in memory: a start takes a
    +//! [`BuildMark`] when it is called, before any await, and is refused when a
    +//! build was running then or began before its transaction read the
    +//! indexes. Builds in another process are seen through the stored status.
    +
    +use std::collections::HashMap;
    +use std::sync::{LazyLock, Mutex, PoisonError};
    +
    +use extenddb_core::types::GlobalSecondaryIndexUpdate;
    +use extenddb_storage::bulk_operation_store::index_creation_refused_message;
    +
    +use super::*;
    +
    +/// Per table id: how many builds have begun in this process, and how many
    +/// are running. Entries are never removed, so a count never repeats for a
    +/// table.
    +static BUILDS: LazyLock>> =
    +    LazyLock::new(|| Mutex::new(HashMap::new()));
    +
    +/// One table's builds in this process as one read saw them.
    +#[derive(Clone, Copy)]
    +pub(super) struct BuildMark {
    +    begun: u64,
    +    running: u32,
    +}
    +
    +impl BuildMark {
    +    pub(super) fn now(table_id: &str) -> Self {
    +        let builds = BUILDS.lock().unwrap_or_else(PoisonError::into_inner);
    +        let (begun, running) = builds.get(table_id).copied().unwrap_or((0, 0));
    +        Self { begun, running }
    +    }
    +
    +    /// Whether a build was running when this mark was taken.
    +    pub(super) fn building(self) -> bool {
    +        self.running > 0
    +    }
    +
    +    /// Whether a build began between `earlier` and this mark.
    +    pub(super) fn began_since(self, earlier: Self) -> bool {
    +        self.begun != earlier.begun
    +    }
    +}
    +
    +/// Held by `UpdateTable` that creates a global secondary index, from the
    +/// end of its catalog transaction, after every check that can refuse the
    +/// request and before the commit, until it returns, the build included.
    +/// Dropping it ends the build in the count.
    +pub(crate) struct BuildGuard {
    +    table_id: String,
    +}
    +
    +impl BuildGuard {
    +    /// Counts one build on the table as begun and running. `UpdateTable`
    +    /// calls it after the last check that can refuse the request, so a
    +    /// refused request never moves the count a start compares.
    +    pub(crate) fn begin(table_id: &str) -> Self {
    +        let mut builds = BUILDS.lock().unwrap_or_else(PoisonError::into_inner);
    +        let entry = builds.entry(table_id.to_owned()).or_insert((0, 0));
    +        entry.0 += 1;
    +        entry.1 += 1;
    +        Self {
    +            table_id: table_id.to_owned(),
    +        }
    +    }
    +}
    +
    +impl Drop for BuildGuard {
    +    fn drop(&mut self) {
    +        let mut builds = BUILDS.lock().unwrap_or_else(PoisonError::into_inner);
    +        if let Some(entry) = builds.get_mut(&self.table_id) {
    +            entry.1 = entry.1.saturating_sub(1);
    +        }
    +    }
    +}
    +
    +/// Whether the table's stored status is `DELETING`, or its row is gone. The
    +/// start handler found the table at its lookup, so an absent row means
    +/// `DeleteTable` removed it since.
    +pub(super) async fn table_deleting(
    +    conn: &mut sqlx::SqliteConnection,
    +    account_id: &str,
    +    table_id: &str,
    +) -> Result {
    +    let status: Option =
    +        sqlx::query_scalar("SELECT table_status FROM tables WHERE account_id = ? AND table_id = ?")
    +            .bind(account_id)
    +            .bind(table_id)
    +            .fetch_optional(conn)
    +            .await
    +            .map_err(internal)?;
    +    Ok(status.as_deref().is_none_or(|status| status == "DELETING"))
    +}
    +
    +/// Whether the table has a secondary or vector index that is not `ACTIVE`.
    +pub(super) async fn index_busy(
    +    conn: &mut sqlx::SqliteConnection,
    +    table_id: &str,
    +) -> Result {
    +    let busy: i64 = sqlx::query_scalar(
    +        "SELECT COUNT(*) FROM ( \
    +             SELECT index_status FROM indexes WHERE table_id = ?1 \
    +             UNION ALL \
    +             SELECT index_status FROM vector_indexes WHERE table_id = ?1 \
    +         ) WHERE index_status <> 'ACTIVE'",
    +    )
    +    .bind(table_id)
    +    .fetch_one(conn)
    +    .await
    +    .map_err(internal)?;
    +    Ok(busy > 0)
    +}
    +
    +/// Refuses an `UpdateTable` that creates a global secondary index while the
    +/// table has an operation `IN_PROGRESS` or `CANCELLING`, and otherwise
    +/// answers whether the request creates one. Called inside the
    +/// `UpdateTable` transaction, which holds the write lock a start also takes. The caller holds the
    +/// [`BuildGuard`] it begins once its last refusal has passed, until it
    +/// returns.
    +pub(crate) async fn refuse_index_creation_during_run(
    +    conn: &mut sqlx::SqliteConnection,
    +    account_id: &str,
    +    table_id: &str,
    +    table_name: &str,
    +    updates: &[GlobalSecondaryIndexUpdate],
    +) -> Result {
    +    if !updates.iter().any(|update| update.create.is_some()) {
    +        return Ok(false);
    +    }
    +    let running: i64 = sqlx::query_scalar(
    +        "SELECT COUNT(*) FROM bulk_operations \
    +         WHERE account_id = ? AND table_id = ? \
    +         AND status IN ('IN_PROGRESS', 'CANCELLING')",
    +    )
    +    .bind(account_id)
    +    .bind(table_id)
    +    .fetch_one(conn)
    +    .await
    +    .map_err(internal)?;
    +    if running > 0 {
    +        return Err(StorageError::IndexesInUse(index_creation_refused_message(
    +            table_name,
    +        )));
    +    }
    +    Ok(true)
    +}
    diff --git a/crates/storage-sqlite/src/bulk_operation_store/mod.rs b/crates/storage-sqlite/src/bulk_operation_store/mod.rs
    new file mode 100644
    index 000000000..000fc5f0c
    --- /dev/null
    +++ b/crates/storage-sqlite/src/bulk_operation_store/mod.rs
    @@ -0,0 +1,315 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! `BulkOperationStore` for the `SQLite` backend.
    +//!
    +//! Operation records live in the `bulk_operations` catalog table; report
    +//! records in `bulk_operation_report_records`. Every write here holds the
    +//! engine write lock (D1) and multi-statement writes run under
    +//! `BEGIN IMMEDIATE`, matching the conventions in `store.rs`. All values are
    +//! bound as parameters; no request data reaches a SQL identifier
    +//! (docs/adr/0002-sql-injection-defense.md).
    +//!
    +//! The trait impl below delegates each method to a free function in one of
    +//! the sibling modules, grouped by what its SQL does: `start` (the start
    +//! transaction, token replay, the per-table and account caps), `claim`
    +//! (claim, lease renewal, checkpoint, cancel, finish), and `query` (describe,
    +//! list, report paging, purge). The shared SQL constant and the row mappers
    +//! stay here.
    +
    +use std::time::{Duration, SystemTime};
    +
    +use futures::future::BoxFuture;
    +use serde_json::Value;
    +use sqlx::Row;
    +use sqlx::sqlite::SqliteRow;
    +
    +use extenddb_storage::error::StorageError;
    +use extenddb_storage::{
    +    BulkOperationListResult, BulkOperationRecord, BulkOperationStore, CancelOutcome, FileAbandon,
    +    FinishState, InputFile, LeaseState, ListCursor, NewBulkOperation, OperationFlags, Progress,
    +    ReportKind, ReportRecord, StartOutcome, Terminal,
    +};
    +
    +use crate::store::SqliteEngine;
    +
    +mod claim;
    +mod files;
    +mod index_guard;
    +mod query;
    +mod start;
    +mod unreadable;
    +
    +pub(crate) use index_guard::{BuildGuard, refuse_index_creation_during_run};
    +
    +#[cfg(test)]
    +mod tests;
    +
    +#[cfg(test)]
    +mod stall_tests;
    +
    +/// The `BulkOperationRecord` columns, in declaration order, for SELECTs.
    +/// `client_token_hash` is deliberately absent: it exists only to carry the
    +/// idempotency index and never leaves storage.
    +const RECORD_COLUMNS: &str = "account_id, operation_id, table_id, table_name, table_arn, \
    +     status, client_token, request_fingerprint, input_config, output_config, input_files, \
    +     max_write_request_units, performance_tier, start_time_ms, end_time_ms, \
    +     processed_item_count, failed_item_count, processed_input_bytes, total_input_bytes, \
    +     report_record_count, report_bytes, \
    +     usage, failure_code, failure_message, cancel_requested, cursor_file_index, \
    +     cursor_row_index, lease_owner, lease_until_ms, run_context";
    +
    +/// What the operation row's `input_files` column holds. The files live
    +/// one row each in `bulk_operation_input_files` (see `files`); a row whose
    +/// column holds anything else was not written by this build, and does not
    +/// decode.
    +const NO_INPUT_FILES: &str = "[]";
    +
    +fn internal(e: impl std::fmt::Display) -> StorageError {
    +    StorageError::Internal(e.to_string())
    +}
    +
    +fn now_epoch_ms() -> i64 {
    +    i64::try_from(
    +        SystemTime::now()
    +            .duration_since(SystemTime::UNIX_EPOCH)
    +            .unwrap_or_default()
    +            .as_millis(),
    +    )
    +    .unwrap_or(i64::MAX)
    +}
    +
    +fn lease_ms(lease: Duration) -> i64 {
    +    i64::try_from(lease.as_millis()).unwrap_or(i64::MAX)
    +}
    +
    +fn record_from_row(row: &SqliteRow) -> Result {
    +    let json_col = |col: &str| -> Result {
    +        let text: String = row.try_get(col).map_err(internal)?;
    +        serde_json::from_str(&text).map_err(internal)
    +    };
    +    // The totals are non-negative by construction (the store only ever binds
    +    // unsigned figures); a negative value is corruption, not a shape to model.
    +    let unsigned_col = |col: &str| -> Result {
    +        let value: i64 = row.try_get(col).map_err(internal)?;
    +        u64::try_from(value)
    +            .map_err(|_| StorageError::Internal(format!("bulk_operations.{col} is negative")))
    +    };
    +    // The counters stay `i64` on the record, but a negative stored value is
    +    // the same corruption and gets the same refusal, so Describe, List, and
    +    // the runner's claim all sit behind one boundary.
    +    let non_negative_col = |col: &str| -> Result {
    +        let value: i64 = row.try_get(col).map_err(internal)?;
    +        if value < 0 {
    +            return Err(StorageError::Internal(format!(
    +                "bulk_operations.{col} is negative"
    +            )));
    +        }
    +        Ok(value)
    +    };
    +    let input_files_text: String = row.try_get("input_files").map_err(internal)?;
    +    if input_files_text != NO_INPUT_FILES {
    +        return Err(StorageError::Internal(
    +            "bulk_operations.input_files holds a file list, which this build does not write"
    +                .to_owned(),
    +        ));
    +    }
    +    let cancel_requested: i64 = row.try_get("cancel_requested").map_err(internal)?;
    +    Ok(BulkOperationRecord {
    +        account_id: row.try_get("account_id").map_err(internal)?,
    +        operation_id: row.try_get("operation_id").map_err(internal)?,
    +        table_id: row.try_get("table_id").map_err(internal)?,
    +        table_name: row.try_get("table_name").map_err(internal)?,
    +        table_arn: row.try_get("table_arn").map_err(internal)?,
    +        status: row.try_get("status").map_err(internal)?,
    +        client_token: row.try_get("client_token").map_err(internal)?,
    +        request_fingerprint: row.try_get("request_fingerprint").map_err(internal)?,
    +        input_config: json_col("input_config")?,
    +        output_config: json_col("output_config")?,
    +        // Attached from `bulk_operation_input_files` by the caller.
    +        input_files: Vec::new(),
    +        max_write_request_units: row.try_get("max_write_request_units").map_err(internal)?,
    +        performance_tier: row.try_get("performance_tier").map_err(internal)?,
    +        start_time_ms: row.try_get("start_time_ms").map_err(internal)?,
    +        end_time_ms: row.try_get("end_time_ms").map_err(internal)?,
    +        processed_item_count: non_negative_col("processed_item_count")?,
    +        failed_item_count: non_negative_col("failed_item_count")?,
    +        processed_input_bytes: non_negative_col("processed_input_bytes")?,
    +        total_input_bytes: {
    +            let value: Option = row.try_get("total_input_bytes").map_err(internal)?;
    +            if value.is_some_and(|v| v < 0) {
    +                return Err(StorageError::Internal(
    +                    "bulk_operations.total_input_bytes is negative".to_owned(),
    +                ));
    +            }
    +            value
    +        },
    +        report_record_count: unsigned_col("report_record_count")?,
    +        report_bytes: unsigned_col("report_bytes")?,
    +        usage: json_col("usage")?,
    +        failure_code: row.try_get("failure_code").map_err(internal)?,
    +        failure_message: row.try_get("failure_message").map_err(internal)?,
    +        cancel_requested: cancel_requested != 0,
    +        cursor_file_index: non_negative_col("cursor_file_index")?,
    +        cursor_row_index: non_negative_col("cursor_row_index")?,
    +        lease_owner: row.try_get("lease_owner").map_err(internal)?,
    +        lease_until_ms: row.try_get("lease_until_ms").map_err(internal)?,
    +        run_context: {
    +            let text: Option = row.try_get("run_context").map_err(internal)?;
    +            text.map(|text| serde_json::from_str(&text).map_err(internal))
    +                .transpose()?
    +        },
    +    })
    +}
    +
    +async fn fetch_record(
    +    conn: &mut sqlx::SqliteConnection,
    +    account_id: &str,
    +    operation_id: &str,
    +) -> Result, StorageError> {
    +    let row = sqlx::query(&format!(
    +        "SELECT {RECORD_COLUMNS} FROM bulk_operations \
    +         WHERE account_id = ? AND operation_id = ?"
    +    ))
    +    .bind(account_id)
    +    .bind(operation_id)
    +    .fetch_optional(&mut *conn)
    +    .await
    +    .map_err(internal)?;
    +    let Some(row) = row else {
    +        return Ok(None);
    +    };
    +    let mut record = record_from_row(&row)?;
    +    files::attach_files(conn, &mut record).await?;
    +    Ok(Some(record))
    +}
    +
    +impl BulkOperationStore for SqliteEngine {
    +    /// The five-step start transaction.
    +    fn start_bulk_operation(
    +        &self,
    +        account_id: &str,
    +        req: NewBulkOperation,
    +        account_limit: u32,
    +    ) -> BoxFuture<'_, Result> {
    +        start::start_bulk_operation(self, account_id, req, account_limit)
    +    }
    +
    +    fn get_bulk_operation(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        query::get_bulk_operation(self, account_id, operation_id)
    +    }
    +
    +    fn get_bulk_operation_flags(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        query::get_bulk_operation_flags(self, account_id, operation_id)
    +    }
    +
    +    fn count_non_terminal(&self, account_id: &str) -> BoxFuture<'_, Result> {
    +        start::count_non_terminal(self, account_id)
    +    }
    +
    +    fn count_non_terminal_for_table(
    +        &self,
    +        account_id: &str,
    +        table_id: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        start::count_non_terminal_for_table(self, account_id, table_id)
    +    }
    +
    +    fn count_terminal_born_since(
    +        &self,
    +        account_id: &str,
    +        since_ms: i64,
    +    ) -> BoxFuture<'_, Result> {
    +        start::count_terminal_born_since(self, account_id, since_ms)
    +    }
    +
    +    fn list_bulk_operations(
    +        &self,
    +        account_id: &str,
    +        limit: u32,
    +        after: Option,
    +    ) -> BoxFuture<'_, BulkOperationListResult> {
    +        query::list_bulk_operations(self, account_id, limit, after)
    +    }
    +
    +    fn request_cancel(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        claim::request_cancel(self, account_id, operation_id)
    +    }
    +
    +    fn claim_runnable(
    +        &self,
    +        owner: &str,
    +        lease: Duration,
    +        max: usize,
    +        exclude_accounts: &[String],
    +        exclude_operations: &[String],
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        claim::claim_runnable(
    +            self,
    +            owner,
    +            lease,
    +            max,
    +            exclude_accounts,
    +            exclude_operations,
    +        )
    +    }
    +
    +    fn checkpoint(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        lease: Duration,
    +        progress: Progress,
    +    ) -> BoxFuture<'_, Result> {
    +        claim::checkpoint(self, account_id, operation_id, owner, lease, progress)
    +    }
    +
    +    fn report_records_page(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        kind: ReportKind,
    +        after: Option<(i64, i64)>,
    +        limit: usize,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        query::report_records_page(self, account_id, operation_id, kind, after, limit)
    +    }
    +
    +    fn finish(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        outcome: Terminal,
    +    ) -> BoxFuture<'_, Result> {
    +        claim::finish(self, account_id, operation_id, owner, outcome)
    +    }
    +
    +    fn list_purgeable_before(
    +        &self,
    +        cutoff: SystemTime,
    +        limit: usize,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        query::list_purgeable_before(self, cutoff, limit)
    +    }
    +
    +    fn purge_operations(
    +        &self,
    +        pairs: &[(String, String)],
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        query::purge_operations(self, pairs)
    +    }
    +}
    diff --git a/crates/storage-sqlite/src/bulk_operation_store/query.rs b/crates/storage-sqlite/src/bulk_operation_store/query.rs
    new file mode 100644
    index 000000000..bbbe64667
    --- /dev/null
    +++ b/crates/storage-sqlite/src/bulk_operation_store/query.rs
    @@ -0,0 +1,278 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The read and purge paths for the `SQLite` `BulkOperationStore`: describe
    +//! one operation, list an account's operations by page, page a report's
    +//! records, and the two-step purge of terminal operations.
    +
    +use super::*;
    +
    +pub(super) fn get_bulk_operation<'a>(
    +    engine: &'a SqliteEngine,
    +    account_id: &str,
    +    operation_id: &str,
    +) -> BoxFuture<'a, Result, StorageError>> {
    +    let account_id = account_id.to_owned();
    +    let operation_id = operation_id.to_owned();
    +    Box::pin(async move {
    +        let mut conn = engine.pool.acquire().await.map_err(internal)?;
    +        fetch_record(&mut conn, &account_id, &operation_id).await
    +    })
    +}
    +
    +/// The boundary read: the operation row's status, cancel flag and lease
    +/// holder, without the file list a full read attaches.
    +pub(super) fn get_bulk_operation_flags<'a>(
    +    engine: &'a SqliteEngine,
    +    account_id: &str,
    +    operation_id: &str,
    +) -> BoxFuture<'a, Result, StorageError>> {
    +    let account_id = account_id.to_owned();
    +    let operation_id = operation_id.to_owned();
    +    Box::pin(async move {
    +        let row: Option<(String, i64, Option)> = sqlx::query_as(
    +            "SELECT status, cancel_requested, lease_owner FROM bulk_operations \
    +             WHERE account_id = ? AND operation_id = ?",
    +        )
    +        .bind(&account_id)
    +        .bind(&operation_id)
    +        .fetch_optional(&engine.pool)
    +        .await
    +        .map_err(internal)?;
    +        Ok(
    +            row.map(|(status, cancel_requested, lease_owner)| OperationFlags {
    +                status,
    +                cancel_requested: cancel_requested != 0,
    +                lease_owner,
    +            }),
    +        )
    +    })
    +}
    +
    +pub(super) fn list_bulk_operations<'a>(
    +    engine: &'a SqliteEngine,
    +    account_id: &str,
    +    limit: u32,
    +    after: Option,
    +) -> BoxFuture<'a, BulkOperationListResult> {
    +    let account_id = account_id.to_owned();
    +    Box::pin(async move {
    +        // The trait requires limit >= 1: an empty page for 0 would read
    +        // as an account with no operations.
    +        if limit == 0 {
    +            return Err(StorageError::Validation(
    +                "list_bulk_operations requires a limit of at least 1".to_owned(),
    +            ));
    +        }
    +        let page = i64::from(limit);
    +        // Fetch one row past the page to learn whether another page
    +        // exists without a second query.
    +        let rows = match &after {
    +            Some(cursor) => {
    +                sqlx::query(&format!(
    +                    "SELECT {RECORD_COLUMNS} FROM bulk_operations \
    +                     WHERE account_id = ? \
    +                       AND (start_time_ms < ? \
    +                            OR (start_time_ms = ? AND operation_id < ?)) \
    +                     ORDER BY start_time_ms DESC, operation_id DESC LIMIT ?"
    +                ))
    +                .bind(&account_id)
    +                .bind(cursor.start_time_ms)
    +                .bind(cursor.start_time_ms)
    +                .bind(&cursor.operation_id)
    +                .bind(page + 1)
    +                .fetch_all(&engine.pool)
    +                .await
    +            }
    +            None => {
    +                sqlx::query(&format!(
    +                    "SELECT {RECORD_COLUMNS} FROM bulk_operations \
    +                     WHERE account_id = ? \
    +                     ORDER BY start_time_ms DESC, operation_id DESC LIMIT ?"
    +                ))
    +                .bind(&account_id)
    +                .bind(page + 1)
    +                .fetch_all(&engine.pool)
    +                .await
    +            }
    +        }
    +        .map_err(internal)?;
    +
    +        // A row that does not decode is logged and left out; its key still
    +        // counts toward the page and the cursor, so the next page starts
    +        // past it rather than failing on it again.
    +        let page = usize::try_from(page).unwrap_or(usize::MAX);
    +        let more = rows.len() > page;
    +        let mut records = Vec::with_capacity(rows.len().min(page));
    +        let mut last = None;
    +        let mut conn = engine.pool.acquire().await.map_err(internal)?;
    +        for row in rows.iter().take(page) {
    +            let key = unreadable::row_key(row)?;
    +            if let Some(record) =
    +                unreadable::decode_or_log(row, &key, "leaving it out of the listing")
    +                && let Some(record) =
    +                    files::with_files_or_log(&mut conn, record, "leaving it out of the listing")
    +                        .await?
    +            {
    +                records.push(record);
    +            }
    +            last = Some(key);
    +        }
    +        let cursor = if more {
    +            last.map(|key| ListCursor {
    +                start_time_ms: key.start_time_ms,
    +                operation_id: key.operation_id,
    +            })
    +        } else {
    +            None
    +        };
    +        Ok((records, cursor))
    +    })
    +}
    +
    +pub(super) fn report_records_page<'a>(
    +    engine: &'a SqliteEngine,
    +    account_id: &str,
    +    operation_id: &str,
    +    kind: ReportKind,
    +    after: Option<(i64, i64)>,
    +    limit: usize,
    +) -> BoxFuture<'a, Result, StorageError>> {
    +    let account_id = account_id.to_owned();
    +    let operation_id = operation_id.to_owned();
    +    Box::pin(async move {
    +        // The trait requires limit >= 1: an empty page for 0 would read
    +        // as the end of the report.
    +        if limit == 0 {
    +            return Err(StorageError::Validation(
    +                "report_records_page requires a limit of at least 1".to_owned(),
    +            ));
    +        }
    +        let limit = i64::try_from(limit).unwrap_or(i64::MAX);
    +        let rows: Vec<(i64, i64, String)> = match after {
    +            // Strictly after the cursor in (file_index, row_index)
    +            // ascending order, as a row-value comparison: SQLite seeks the
    +            // primary key index to the cursor for it, where the expanded
    +            // `file_index > ? OR (file_index = ? AND row_index > ?)` form
    +            // scans every record of the kind before the cursor, so paging
    +            // through a report cost time in the square of its size.
    +            Some((file, row)) => {
    +                sqlx::query_as(
    +                    "SELECT file_index, row_index, record \
    +                     FROM bulk_operation_report_records \
    +                     WHERE account_id = ? AND operation_id = ? AND kind = ? \
    +                       AND (file_index, row_index) > (?, ?) \
    +                     ORDER BY file_index, row_index LIMIT ?",
    +                )
    +                .bind(&account_id)
    +                .bind(&operation_id)
    +                .bind(kind.as_str())
    +                .bind(file)
    +                .bind(row)
    +                .bind(limit)
    +                .fetch_all(&engine.pool)
    +                .await
    +            }
    +            None => {
    +                sqlx::query_as(
    +                    "SELECT file_index, row_index, record \
    +                     FROM bulk_operation_report_records \
    +                     WHERE account_id = ? AND operation_id = ? AND kind = ? \
    +                     ORDER BY file_index, row_index LIMIT ?",
    +                )
    +                .bind(&account_id)
    +                .bind(&operation_id)
    +                .bind(kind.as_str())
    +                .bind(limit)
    +                .fetch_all(&engine.pool)
    +                .await
    +            }
    +        }
    +        .map_err(internal)?;
    +        rows.into_iter()
    +            .map(|(file_index, row_index, text)| {
    +                Ok(ReportRecord {
    +                    kind,
    +                    file_index,
    +                    row_index,
    +                    record: serde_json::from_str(&text).map_err(internal)?,
    +                })
    +            })
    +            .collect()
    +    })
    +}
    +
    +pub(super) fn list_purgeable_before(
    +    engine: &SqliteEngine,
    +    cutoff: SystemTime,
    +    limit: usize,
    +) -> BoxFuture<'_, Result, StorageError>> {
    +    Box::pin(async move {
    +        let cutoff_ms = cutoff
    +            .duration_since(SystemTime::UNIX_EPOCH)
    +            .ok()
    +            .and_then(|d| i64::try_from(d.as_millis()).ok())
    +            .unwrap_or(0);
    +        let limit = i64::try_from(limit).unwrap_or(i64::MAX);
    +        sqlx::query_as(
    +            "SELECT account_id, operation_id FROM bulk_operations \
    +             WHERE status NOT IN ('IN_PROGRESS', 'CANCELLING') \
    +               AND end_time_ms IS NOT NULL AND end_time_ms < ? \
    +             ORDER BY end_time_ms LIMIT ?",
    +        )
    +        .bind(cutoff_ms)
    +        .bind(limit)
    +        .fetch_all(&engine.pool)
    +        .await
    +        .map_err(internal)
    +    })
    +}
    +
    +pub(super) fn purge_operations<'a>(
    +    engine: &'a SqliteEngine,
    +    pairs: &[(String, String)],
    +) -> BoxFuture<'a, Result<(), StorageError>> {
    +    let pairs = pairs.to_vec();
    +    Box::pin(async move {
    +        if pairs.is_empty() {
    +            return Ok(());
    +        }
    +        let _writer = engine.write_lock.lock().await;
    +        let mut tx = engine
    +            .pool
    +            .begin_with("BEGIN IMMEDIATE")
    +            .await
    +            .map_err(internal)?;
    +        for (account_id, operation_id) in &pairs {
    +            // The row goes first, re-checking the terminal predicate,
    +            // so a pair that does not name a terminal row deletes
    +            // nothing; its report records follow only when the row was
    +            // deleted, in the same transaction.
    +            let deleted = sqlx::query(
    +                "DELETE FROM bulk_operations \
    +                 WHERE account_id = ? AND operation_id = ? \
    +                   AND status NOT IN ('IN_PROGRESS', 'CANCELLING')",
    +            )
    +            .bind(account_id)
    +            .bind(operation_id)
    +            .execute(&mut *tx)
    +            .await
    +            .map_err(internal)?
    +            .rows_affected();
    +            if deleted > 0 {
    +                sqlx::query(
    +                    "DELETE FROM bulk_operation_report_records \
    +                     WHERE account_id = ? AND operation_id = ?",
    +                )
    +                .bind(account_id)
    +                .bind(operation_id)
    +                .execute(&mut *tx)
    +                .await
    +                .map_err(internal)?;
    +                files::delete_files(&mut tx, account_id, operation_id).await?;
    +            }
    +        }
    +        tx.commit().await.map_err(internal)?;
    +        Ok(())
    +    })
    +}
    diff --git a/crates/storage-sqlite/src/bulk_operation_store/stall_tests.rs b/crates/storage-sqlite/src/bulk_operation_store/stall_tests.rs
    new file mode 100644
    index 000000000..0cee793cc
    --- /dev/null
    +++ b/crates/storage-sqlite/src/bulk_operation_store/stall_tests.rs
    @@ -0,0 +1,210 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! How long the `SQLite` store keeps other writers waiting, and how report
    +//! paging scales: the end of an operation deletes its report records in
    +//! chunks that release the engine write lock between them, a report page
    +//! seeks to its cursor, and a claim that finds nothing to claim takes no
    +//! write lock.
    +
    +use std::sync::Arc;
    +use std::sync::atomic::{AtomicBool, Ordering};
    +use std::time::{Duration, Instant};
    +
    +use super::tests::{engine, new_op};
    +use super::*;
    +use crate::store::SqliteEngine;
    +
    +const ACCOUNT: &str = "000000000000";
    +
    +/// The cases here time themselves, so they run one at a time.
    +static SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
    +
    +/// Insert `count` error records for one file of `operation_id`, row
    +/// indexes `0..count`, in one statement.
    +async fn insert_records(engine: &SqliteEngine, operation_id: &str, count: i64) {
    +    sqlx::query(
    +        "INSERT INTO bulk_operation_report_records \
    +             (account_id, operation_id, kind, file_index, row_index, record) \
    +         WITH RECURSIVE n(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM n WHERE i + 1 < ?) \
    +         SELECT ?, ?, 'error', 0, i, '{\"itemIndex\": 1}' FROM n",
    +    )
    +    .bind(count)
    +    .bind(ACCOUNT)
    +    .bind(operation_id)
    +    .execute(&engine.pool)
    +    .await
    +    .expect("insert report records");
    +}
    +
    +/// Page through every error record of one operation the way the report
    +/// renderer does, 1,000 at a time, and return how long it took.
    +async fn page_through(engine: &SqliteEngine, operation_id: &str, expected: usize) -> Duration {
    +    let began = Instant::now();
    +    let mut after = None;
    +    let mut read = 0;
    +    loop {
    +        let page = engine
    +            .report_records_page(ACCOUNT, operation_id, ReportKind::Error, after, 1000)
    +            .await
    +            .expect("report page");
    +        let Some(last) = page.last() else {
    +            break;
    +        };
    +        after = Some((last.file_index, last.row_index));
    +        read += page.len();
    +    }
    +    assert_eq!(read, expected, "every record is read once");
    +    began.elapsed()
    +}
    +
    +/// Reading a 300,000-record report costs no more per record than reading a
    +/// 30,000-record one. A page whose cursor test the index cannot seek to
    +/// scans every record before the cursor, so the cost of a page grows with
    +/// its position and the larger report costs about ten times more per
    +/// record.
    +#[tokio::test]
    +async fn report_paging_costs_the_same_per_record_at_any_report_size() {
    +    const SMALL: i64 = 30_000;
    +    const LARGE: i64 = 300_000;
    +    let _serial = SERIAL.lock().await;
    +    let engine = engine().await;
    +    insert_records(&engine, "op-small", SMALL).await;
    +    insert_records(&engine, "op-large", LARGE).await;
    +    // The smaller report is read twice and the faster read kept, so a
    +    // pause of the host during one read does not decide the comparison.
    +    let small = page_through(&engine, "op-small", 30_000)
    +        .await
    +        .min(page_through(&engine, "op-small", 30_000).await);
    +    let large = page_through(&engine, "op-large", 300_000).await;
    +    eprintln!("paging {SMALL} records took {small:?}, {LARGE} records {large:?}");
    +    #[allow(clippy::cast_precision_loss)] // the counts fit f64 exactly
    +    let (per_small, per_large) = (
    +        small.as_secs_f64() / SMALL as f64,
    +        large.as_secs_f64() / LARGE as f64,
    +    );
    +    assert!(
    +        large < Duration::from_secs(30),
    +        "paging {LARGE} records took {large:?}"
    +    );
    +    assert!(
    +        per_large < per_small * 3.0,
    +        "a record of a {LARGE}-record report cost {:.2} us against {:.2} us in a \
    +         {SMALL}-record report",
    +        per_large * 1e6,
    +        per_small * 1e6
    +    );
    +}
    +
    +/// Finishing an operation that holds 300,000 report records keeps no other
    +/// writer waiting for the engine write lock longer than a quarter of a
    +/// second, and deletes every record before it returns. One delete
    +/// statement holds the lock for the whole delete.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn finishing_an_operation_with_many_records_keeps_other_writers_waiting_briefly() {
    +    let _serial = SERIAL.lock().await;
    +    let engine = Arc::new(engine().await);
    +    let outcome = engine
    +        .start_bulk_operation(ACCOUNT, new_op("op-many", "table-1", None, "fp"), 50)
    +        .await
    +        .expect("start");
    +    assert!(matches!(outcome, StartOutcome::Created(_)));
    +    let claimed = engine
    +        .claim_runnable("owner", Duration::from_secs(60), 1, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert_eq!(claimed.len(), 1);
    +    insert_records(&engine, "op-many", 300_000).await;
    +
    +    // A writer that takes the write lock every few milliseconds while the
    +    // finish runs, and keeps the longest wait it saw.
    +    let done = Arc::new(AtomicBool::new(false));
    +    let lock = Arc::clone(&engine.write_lock);
    +    let stop = Arc::clone(&done);
    +    let probe = tokio::spawn(async move {
    +        let mut worst = Duration::ZERO;
    +        while !stop.load(Ordering::SeqCst) {
    +            let asked = Instant::now();
    +            let guard = lock.lock().await;
    +            worst = worst.max(asked.elapsed());
    +            drop(guard);
    +            tokio::time::sleep(Duration::from_millis(2)).await;
    +        }
    +        worst
    +    });
    +    tokio::time::sleep(Duration::from_millis(20)).await;
    +    let state = engine
    +        .finish(
    +            ACCOUNT,
    +            "op-many",
    +            "owner",
    +            Terminal {
    +                status: "COMPLETED_WITH_ERRORS".to_owned(),
    +                end_time_ms: 2_000,
    +                failure_code: None,
    +                failure_message: None,
    +                usage: serde_json::json!({}),
    +            },
    +        )
    +        .await
    +        .expect("finish");
    +    done.store(true, Ordering::SeqCst);
    +    let worst = probe.await.expect("probe");
    +    assert!(
    +        matches!(state, FinishState::Held(ref status) if status == "COMPLETED_WITH_ERRORS"),
    +        "the finish holds the lease"
    +    );
    +    let left: i64 = sqlx::query_scalar(
    +        "SELECT COUNT(*) FROM bulk_operation_report_records WHERE operation_id = 'op-many'",
    +    )
    +    .fetch_one(&engine.pool)
    +    .await
    +    .expect("count");
    +    assert_eq!(left, 0, "the finish deletes every report record");
    +    assert!(
    +        worst < Duration::from_millis(250),
    +        "another writer waited {worst:?} for the write lock during the finish"
    +    );
    +}
    +
    +/// A claim that finds nothing to claim takes no write lock: it answers
    +/// while another writer holds the lock. With a runnable row it claims the
    +/// row as before.
    +#[tokio::test]
    +async fn an_idle_claim_does_not_take_the_write_lock() {
    +    let _serial = SERIAL.lock().await;
    +    let engine = engine().await;
    +    let held = engine.write_lock.lock().await;
    +    let claimed = tokio::time::timeout(
    +        Duration::from_secs(2),
    +        engine.claim_runnable("owner", Duration::from_secs(60), 1, &[], &[]),
    +    )
    +    .await
    +    .expect("an idle claim answers while the write lock is held")
    +    .expect("claim");
    +    assert!(claimed.is_empty());
    +    drop(held);
    +
    +    let outcome = engine
    +        .start_bulk_operation(ACCOUNT, new_op("op-claim", "table-1", None, "fp"), 50)
    +        .await
    +        .expect("start");
    +    assert!(matches!(outcome, StartOutcome::Created(_)));
    +    let claimed = engine
    +        .claim_runnable("owner", Duration::from_secs(60), 1, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert_eq!(claimed.len(), 1, "a runnable row is claimed");
    +    // Leased now, and excluded by account: nothing is claimable, and the
    +    // claim again answers without the lock.
    +    let held = engine.write_lock.lock().await;
    +    let claimed = tokio::time::timeout(
    +        Duration::from_secs(2),
    +        engine.claim_runnable("other", Duration::from_secs(60), 1, &[], &[]),
    +    )
    +    .await
    +    .expect("a claim over a leased row answers while the write lock is held")
    +    .expect("claim");
    +    assert!(claimed.is_empty());
    +    drop(held);
    +}
    diff --git a/crates/storage-sqlite/src/bulk_operation_store/start.rs b/crates/storage-sqlite/src/bulk_operation_store/start.rs
    new file mode 100644
    index 000000000..7dd18c85a
    --- /dev/null
    +++ b/crates/storage-sqlite/src/bulk_operation_store/start.rs
    @@ -0,0 +1,352 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The start transaction for the `SQLite` `BulkOperationStore`: the
    +//! five-step start, the token replay window, the per-table guard and the
    +//! account and terminal-born caps, plus the two helpers the insert and token
    +//! lookup use.
    +
    +use super::*;
    +
    +use extenddb_storage::CLIENT_TOKEN_WINDOW_MS;
    +use extenddb_storage::bulk_operation_store::TABLE_DELETING_MESSAGE;
    +
    +/// The record a token started inside the replay window: `since_ms` is the
    +/// requesting start's time minus [`CLIENT_TOKEN_WINDOW_MS`], so a record
    +/// at or past the window reads as no record.
    +async fn fetch_record_by_token(
    +    conn: &mut sqlx::SqliteConnection,
    +    account_id: &str,
    +    token_hash: &str,
    +    since_ms: i64,
    +) -> Result, StorageError> {
    +    let row = sqlx::query(&format!(
    +        "SELECT {RECORD_COLUMNS} FROM bulk_operations \
    +         WHERE account_id = ? AND client_token_hash = ? AND start_time_ms > ?"
    +    ))
    +    .bind(account_id)
    +    .bind(token_hash)
    +    .bind(since_ms)
    +    .fetch_optional(&mut *conn)
    +    .await
    +    .map_err(internal)?;
    +    let Some(row) = row else {
    +        return Ok(None);
    +    };
    +    let mut record = record_from_row(&row)?;
    +    files::attach_files(conn, &mut record).await?;
    +    Ok(Some(record))
    +}
    +
    +/// Step 4 of the start transaction: the insert. `OR IGNORE` swallows any
    +/// unique conflict (table guard, token, primary key) without aborting the
    +/// transaction, so step 5 can classify it. An initial failure lands as a
    +/// row that is terminal from its first instant: FAILED, end time equal to
    +/// start time, no lease. Returns the rows inserted: one, or zero on a
    +/// conflict.
    +async fn insert_operation_row(
    +    conn: &mut sqlx::SqliteConnection,
    +    account_id: &str,
    +    req: &NewBulkOperation,
    +) -> Result {
    +    let input_config = serde_json::to_string(&req.input_config).map_err(internal)?;
    +    let output_config = serde_json::to_string(&req.output_config).map_err(internal)?;
    +    let run_context = req
    +        .run_context
    +        .as_ref()
    +        .map(serde_json::to_string)
    +        .transpose()
    +        .map_err(internal)?;
    +    let (status, end_time_ms, failure_code, failure_message) = match &req.initial_failure {
    +        Some(failure) => (
    +            "FAILED",
    +            Some(req.start_time_ms),
    +            Some(failure.code.as_str()),
    +            Some(failure.message.as_str()),
    +        ),
    +        None => ("IN_PROGRESS", None, None, None),
    +    };
    +    let inserted = sqlx::query(
    +        "INSERT OR IGNORE INTO bulk_operations ( \
    +             account_id, operation_id, table_id, table_name, table_arn, status, \
    +             client_token, client_token_hash, request_fingerprint, input_config, \
    +             output_config, input_files, max_write_request_units, performance_tier, \
    +             start_time_ms, end_time_ms, processed_item_count, failed_item_count, \
    +             processed_input_bytes, total_input_bytes, report_record_count, \
    +             report_bytes, usage, failure_code, failure_message, cancel_requested, \
    +             cursor_file_index, cursor_row_index, lease_owner, lease_until_ms, \
    +             run_context \
    +         ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \
    +                   ?, 0, 0, 0, ?, 0, 0, '{}', ?, ?, 0, 0, 0, NULL, NULL, ?)",
    +    )
    +    .bind(account_id)
    +    .bind(&req.operation_id)
    +    .bind(&req.table_id)
    +    .bind(&req.table_name)
    +    .bind(&req.table_arn)
    +    .bind(status)
    +    .bind(&req.client_token)
    +    .bind(&req.client_token_hash)
    +    .bind(&req.request_fingerprint)
    +    .bind(&input_config)
    +    .bind(&output_config)
    +    // The files go one row each into `bulk_operation_input_files`.
    +    .bind(NO_INPUT_FILES)
    +    .bind(req.max_write_request_units)
    +    .bind(&req.performance_tier)
    +    .bind(req.start_time_ms)
    +    .bind(end_time_ms)
    +    .bind(req.total_input_bytes)
    +    .bind(failure_code)
    +    .bind(failure_message)
    +    .bind(&run_context)
    +    .execute(conn)
    +    .await
    +    .map_err(internal)?
    +    .rows_affected();
    +    Ok(inserted)
    +}
    +
    +/// The five-step start transaction.
    +///
    +/// `BEGIN IMMEDIATE` acquires `SQLite`'s reserved write lock up front, so
    +/// two racing starts are fully serialized and the second one's step 2
    +/// SELECT sees the first one's committed row. The insert still uses
    +/// `INSERT OR IGNORE` and recovers from `changes() = 0` (step 5), because
    +/// the same decision tree must hold for a second process on the same
    +/// database file, where this process's write lock does not reach.
    +pub(super) fn start_bulk_operation<'a>(
    +    engine: &'a SqliteEngine,
    +    account_id: &str,
    +    req: NewBulkOperation,
    +    account_limit: u32,
    +) -> BoxFuture<'a, Result> {
    +    let account_id = account_id.to_owned();
    +    // Taken when the start is called, before any await (see `index_guard`).
    +    let before = super::index_guard::BuildMark::now(&req.table_id);
    +    Box::pin(async move {
    +        if before.building() {
    +            return Ok(StartOutcome::IndexOperationInProgress);
    +        }
    +
    +        // D1: every writer holds the engine write lock.
    +        let _writer = engine.write_lock.lock().await;
    +        let mut tx = engine
    +            .pool
    +            .begin_with("BEGIN IMMEDIATE")
    +            .await
    +            .map_err(internal)?;
    +
    +        // Step 1: a table being deleted refuses the start with the answer
    +        // the handler gives at its lookup, since `DeleteTable` can set it
    +        // `DELETING` after that lookup; then an index that is not ACTIVE,
    +        // or a build in this process that began since the call, refuses
    +        // it. Read under the write lock `DeleteTable` and an index creation
    +        // also hold.
    +        if super::index_guard::table_deleting(&mut tx, &account_id, &req.table_id).await? {
    +            tx.commit().await.map_err(internal)?;
    +            return Err(StorageError::Validation(TABLE_DELETING_MESSAGE.to_owned()));
    +        }
    +        let busy = super::index_guard::index_busy(&mut tx, &req.table_id).await?;
    +        if busy || super::index_guard::BuildMark::now(&req.table_id).began_since(before) {
    +            tx.commit().await.map_err(internal)?;
    +            return Ok(StartOutcome::IndexOperationInProgress);
    +        }
    +
    +        // Step 2: token lookup. A token replayed inside the window is
    +        // answered from the existing row, whatever its status; the
    +        // guard and the account limit are not consulted. A token whose
    +        // record is older than the window is unseen: its hash is
    +        // released from the unique index (the record keeps the token
    +        // itself for Describe) and the start proceeds as new.
    +        let token_since_ms = req.start_time_ms.saturating_sub(CLIENT_TOKEN_WINDOW_MS);
    +        if let Some(hash) = &req.client_token_hash {
    +            if let Some(existing) =
    +                fetch_record_by_token(&mut tx, &account_id, hash, token_since_ms).await?
    +            {
    +                tx.commit().await.map_err(internal)?;
    +                return Ok(if existing.request_fingerprint == req.request_fingerprint {
    +                    StartOutcome::IdempotentReplay(existing)
    +                } else {
    +                    StartOutcome::IdempotentMismatch
    +                });
    +            }
    +            sqlx::query(
    +                "UPDATE bulk_operations SET client_token_hash = NULL \
    +                 WHERE account_id = ? AND client_token_hash = ? AND start_time_ms <= ?",
    +            )
    +            .bind(&account_id)
    +            .bind(hash)
    +            .bind(token_since_ms)
    +            .execute(&mut *tx)
    +            .await
    +            .map_err(internal)?;
    +        }
    +
    +        // Step 3a: the per-table guard binds on every start, including one
    +        // whose input configuration already failed: while the table has an
    +        // operation IN_PROGRESS or CANCELLING the start answers busy and
    +        // nothing is inserted (measured on the service: with an operation
    +        // running on the table, a Start naming an input bucket that does
    +        // not exist answered the busy message and created nothing). The
    +        // guard answers before the account limit and before the
    +        // terminal-born cap when either would also reject, so a caller
    +        // blocked by its own running operation learns which table is busy.
    +        // For a start that is not born terminal, the partial unique index
    +        // on the insert below remains the authoritative guard and this read
    +        // only fixes the order of the answers. A row born terminal is
    +        // outside that index, so for it this read is the guard, exact
    +        // because the write lock and BEGIN IMMEDIATE serialize every start.
    +        let table_busy: i64 = sqlx::query_scalar(
    +            "SELECT COUNT(*) FROM bulk_operations \
    +             WHERE account_id = ? AND table_id = ? \
    +             AND status IN ('IN_PROGRESS', 'CANCELLING')",
    +        )
    +        .bind(&account_id)
    +        .bind(&req.table_id)
    +        .fetch_one(&mut *tx)
    +        .await
    +        .map_err(internal)?;
    +        if table_busy > 0 {
    +            tx.commit().await.map_err(internal)?;
    +            return Ok(StartOutcome::TableBusy);
    +        }
    +
    +        if let Some(failure) = &req.initial_failure {
    +            // A row born terminal takes no account concurrency slot, so
    +            // step 3b does not apply to it; its own cap does.
    +            //
    +            // Terminal-born cap, counted inside the same BEGIN
    +            // IMMEDIATE transaction under the engine write lock, so a
    +            // concurrent burst of absent-bucket starts cannot overshoot
    +            // it. A row is terminal-born when its end_time equals its
    +            // start_time and it is FAILED, the shape the insert writes.
    +            let born: i64 = sqlx::query_scalar(
    +                "SELECT COUNT(*) FROM bulk_operations \
    +                 WHERE account_id = ? AND status = 'FAILED' \
    +                   AND end_time_ms = start_time_ms AND start_time_ms >= ?",
    +            )
    +            .bind(&account_id)
    +            .bind(failure.window_start_ms)
    +            .fetch_one(&mut *tx)
    +            .await
    +            .map_err(internal)?;
    +            if u64::try_from(born).unwrap_or(0) >= failure.cap {
    +                tx.commit().await.map_err(internal)?;
    +                return Ok(StartOutcome::FailedStartLimit);
    +            }
    +        } else {
    +            // Step 3b: account concurrency limit, exact because the
    +            // write lock and BEGIN IMMEDIATE serialize every start.
    +            let non_terminal: i64 = sqlx::query_scalar(
    +                "SELECT COUNT(*) FROM bulk_operations \
    +                 WHERE account_id = ? AND status IN ('IN_PROGRESS', 'CANCELLING')",
    +            )
    +            .bind(&account_id)
    +            .fetch_one(&mut *tx)
    +            .await
    +            .map_err(internal)?;
    +            if non_terminal >= i64::from(account_limit) {
    +                tx.commit().await.map_err(internal)?;
    +                return Ok(StartOutcome::AccountLimitReached);
    +            }
    +        }
    +
    +        // Step 4: the insert, in [`insert_operation_row`]. OR IGNORE
    +        // swallows any unique conflict without aborting the
    +        // transaction, so step 5 can classify it.
    +        let inserted = insert_operation_row(&mut tx, &account_id, &req).await?;
    +
    +        if inserted == 1 {
    +            files::insert_files(&mut tx, &account_id, &req.operation_id, &req.input_files).await?;
    +            let record = fetch_record(&mut tx, &account_id, &req.operation_id)
    +                .await?
    +                .ok_or_else(|| internal("bulk operation row missing after insert"))?;
    +            tx.commit().await.map_err(internal)?;
    +            return Ok(StartOutcome::Created(record));
    +        }
    +
    +        // Step 5: the insert was ignored and the transaction is still
    +        // live. Re-run the step 2 SELECT: a token row present now means
    +        // the conflict was the idempotency index, so the answer is replay
    +        // or mismatch by fingerprint, never TableBusy. No token row means
    +        // the conflict was some other unique constraint, and the per-table
    +        // guard is the one a well-formed request can hit.
    +        if let Some(hash) = &req.client_token_hash
    +            && let Some(existing) =
    +                fetch_record_by_token(&mut tx, &account_id, hash, token_since_ms).await?
    +        {
    +            tx.commit().await.map_err(internal)?;
    +            return Ok(if existing.request_fingerprint == req.request_fingerprint {
    +                StartOutcome::IdempotentReplay(existing)
    +            } else {
    +                StartOutcome::IdempotentMismatch
    +            });
    +        }
    +        tx.commit().await.map_err(internal)?;
    +        Ok(StartOutcome::TableBusy)
    +    })
    +}
    +
    +pub(super) fn count_non_terminal_for_table<'a>(
    +    engine: &'a SqliteEngine,
    +    account_id: &str,
    +    table_id: &str,
    +) -> BoxFuture<'a, Result> {
    +    let account_id = account_id.to_owned();
    +    let table_id = table_id.to_owned();
    +    Box::pin(async move {
    +        let count: i64 = sqlx::query_scalar(
    +            "SELECT COUNT(*) FROM bulk_operations \
    +             WHERE account_id = ? AND table_id = ? \
    +             AND status IN ('IN_PROGRESS', 'CANCELLING')",
    +        )
    +        .bind(&account_id)
    +        .bind(&table_id)
    +        .fetch_one(&engine.pool)
    +        .await
    +        .map_err(internal)?;
    +        Ok(u64::try_from(count).unwrap_or(0))
    +    })
    +}
    +
    +pub(super) fn count_non_terminal<'a>(
    +    engine: &'a SqliteEngine,
    +    account_id: &str,
    +) -> BoxFuture<'a, Result> {
    +    let account_id = account_id.to_owned();
    +    Box::pin(async move {
    +        let count: i64 = sqlx::query_scalar(
    +            "SELECT COUNT(*) FROM bulk_operations \
    +             WHERE account_id = ? AND status IN ('IN_PROGRESS', 'CANCELLING')",
    +        )
    +        .bind(&account_id)
    +        .fetch_one(&engine.pool)
    +        .await
    +        .map_err(internal)?;
    +        // COUNT(*) is non-negative by construction.
    +        Ok(u64::try_from(count).unwrap_or(0))
    +    })
    +}
    +
    +pub(super) fn count_terminal_born_since<'a>(
    +    engine: &'a SqliteEngine,
    +    account_id: &str,
    +    since_ms: i64,
    +) -> BoxFuture<'a, Result> {
    +    let account_id = account_id.to_owned();
    +    Box::pin(async move {
    +        let count: i64 = sqlx::query_scalar(
    +            "SELECT COUNT(*) FROM bulk_operations \
    +             WHERE account_id = ? AND status = 'FAILED' \
    +             AND end_time_ms = start_time_ms AND start_time_ms >= ?",
    +        )
    +        .bind(&account_id)
    +        .bind(since_ms)
    +        .fetch_one(&engine.pool)
    +        .await
    +        .map_err(internal)?;
    +        // COUNT(*) is non-negative by construction.
    +        Ok(u64::try_from(count).unwrap_or(0))
    +    })
    +}
    diff --git a/crates/storage-sqlite/src/bulk_operation_store/tests.rs b/crates/storage-sqlite/src/bulk_operation_store/tests.rs
    new file mode 100644
    index 000000000..682d8d84e
    --- /dev/null
    +++ b/crates/storage-sqlite/src/bulk_operation_store/tests.rs
    @@ -0,0 +1,323 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Tests for the `SQLite` `BulkOperationStore`: index definitions, the start
    +//! transaction and the stored `input_files` shape.
    +
    +use super::*;
    +use crate::store::SqliteEngine;
    +
    +pub(super) async fn engine() -> SqliteEngine {
    +    let engine = SqliteEngine::new(":memory:", 2, "us-east-1", 409_600)
    +        .await
    +        .expect("engine");
    +    crate::migrations::apply_for_tests(&engine.pool).await;
    +    sqlx::query(
    +        "INSERT INTO accounts (account_id, account_name) VALUES ('000000000000', 'default')",
    +    )
    +    .execute(&engine.pool)
    +    .await
    +    .expect("account");
    +    // The start transaction refuses a table whose row is absent, so the
    +    // tables the cases start on exist.
    +    sqlx::query(
    +        "INSERT INTO tables (account_id, table_name, key_schema, attribute_definitions, \
    +             table_status, table_arn, table_id) \
    +         VALUES ('000000000000', 't', '[]', '[]', 'ACTIVE', \
    +             'arn:aws:dynamodb:us-east-1:000000000000:table/t', 'table-1'), \
    +             ('000000000000', 't2', '[]', '[]', 'ACTIVE', \
    +             'arn:aws:dynamodb:us-east-1:000000000000:table/t2', 'table-2'), \
    +             ('000000000000', 't3', '[]', '[]', 'ACTIVE', \
    +             'arn:aws:dynamodb:us-east-1:000000000000:table/t3', 'table-3')",
    +    )
    +    .execute(&engine.pool)
    +    .await
    +    .expect("tables");
    +    engine
    +}
    +
    +pub(super) fn new_op(
    +    operation_id: &str,
    +    table_id: &str,
    +    token: Option<&str>,
    +    fingerprint: &str,
    +) -> NewBulkOperation {
    +    NewBulkOperation {
    +        operation_id: operation_id.to_owned(),
    +        table_id: table_id.to_owned(),
    +        table_name: "t".to_owned(),
    +        table_arn: format!("arn:aws:dynamodb:us-east-1:000000000000:table/{table_id}"),
    +        client_token: token.map(str::to_owned),
    +        // The engine hashes the token before calling storage; any distinct
    +        // string stands in for the hash here.
    +        client_token_hash: token.map(|t| format!("hash-{t}")),
    +        request_fingerprint: fingerprint.to_owned(),
    +        input_config: serde_json::json!({"S3Bucket": "b"}),
    +        output_config: serde_json::json!({}),
    +        input_files: vec![InputFile {
    +            path: "data/f0.json".to_owned(),
    +            size: 10,
    +            rows: None,
    +            etag: None,
    +            abandon: None,
    +        }],
    +        max_write_request_units: None,
    +        performance_tier: None,
    +        start_time_ms: 1_000,
    +        total_input_bytes: Some(10),
    +        initial_failure: None,
    +        run_context: None,
    +    }
    +}
    +
    +/// Raw insert of a minimal row, bypassing the store, to exercise the
    +/// index definitions themselves.
    +async fn insert_raw(
    +    engine: &SqliteEngine,
    +    operation_id: &str,
    +    table_id: &str,
    +    status: &str,
    +) -> Result<(), sqlx::Error> {
    +    sqlx::query(
    +        "INSERT INTO bulk_operations ( \
    +             account_id, operation_id, table_id, table_name, table_arn, status, \
    +             request_fingerprint, input_config, output_config, input_files, \
    +             start_time_ms, total_input_bytes) \
    +         VALUES ('000000000000', ?, ?, 't', 'arn', ?, 'f', '{}', '{}', '[]', 0, 0)",
    +    )
    +    .bind(operation_id)
    +    .bind(table_id)
    +    .bind(status)
    +    .execute(&engine.pool)
    +    .await
    +    .map(|_| ())
    +}
    +
    +/// The per-table guard is a partial unique index: two non-terminal rows
    +/// for one table conflict, and a terminal row on the same table does not
    +/// participate. The contract tests only observe the guard through
    +/// `start_bulk_operation`; this pins the index definition itself.
    +#[tokio::test]
    +async fn the_table_guard_index_is_partial_and_unique() {
    +    let engine = engine().await;
    +    insert_raw(&engine, "op-1", "table-1", "IN_PROGRESS")
    +        .await
    +        .expect("first non-terminal row");
    +    let conflict = insert_raw(&engine, "op-2", "table-1", "CANCELLING")
    +        .await
    +        .expect_err("second non-terminal row on the same table must conflict");
    +    assert!(
    +        conflict.to_string().contains("UNIQUE"),
    +        "expected a unique constraint violation, got: {conflict}"
    +    );
    +    insert_raw(&engine, "op-3", "table-1", "COMPLETED")
    +        .await
    +        .expect("a terminal row is outside the partial index");
    +}
    +
    +/// Step 5 of the start transaction: the insert was ignored, the
    +/// transaction is still live, and the re-select of the token decides the
    +/// outcome. In-process, BEGIN IMMEDIATE plus the write lock mean a token
    +/// row committed by anyone else is already visible at step 2, so these
    +/// tests drive step 5 through the two conflicts that reach it: the
    +/// per-table guard and the primary key. The token-found branch defends
    +/// the same decision tree for a second process on one database file.
    +#[tokio::test]
    +async fn a_guard_conflict_with_a_fresh_token_answers_table_busy() {
    +    let engine = engine().await;
    +    let created = engine
    +        .start_bulk_operation("000000000000", new_op("op-1", "table-1", None, "f1"), 50)
    +        .await
    +        .expect("start");
    +    assert!(matches!(created, StartOutcome::Created(_)));
    +    // Same table, a token never seen: step 2 finds nothing, the insert is
    +    // ignored by the guard, and step 5's re-select finds nothing either.
    +    let busy = engine
    +        .start_bulk_operation(
    +            "000000000000",
    +            new_op("op-2", "table-1", Some("t2"), "f2"),
    +            50,
    +        )
    +        .await
    +        .expect("start");
    +    assert!(matches!(busy, StartOutcome::TableBusy));
    +}
    +
    +#[tokio::test]
    +async fn a_primary_key_conflict_without_a_token_row_answers_table_busy() {
    +    let engine = engine().await;
    +    let created = engine
    +        .start_bulk_operation("000000000000", new_op("op-1", "table-1", None, "f1"), 50)
    +        .await
    +        .expect("start");
    +    assert!(matches!(created, StartOutcome::Created(_)));
    +    // Same operation id, different table: the guard passes, the primary
    +    // key conflicts, and step 5 classifies it through the token re-select.
    +    let busy = engine
    +        .start_bulk_operation(
    +            "000000000000",
    +            new_op("op-1", "table-2", Some("t3"), "f3"),
    +            50,
    +        )
    +        .await
    +        .expect("start");
    +    assert!(matches!(busy, StartOutcome::TableBusy));
    +}
    +
    +#[tokio::test]
    +async fn a_seen_token_replays_or_mismatches_by_fingerprint() {
    +    let engine = engine().await;
    +    let created = engine
    +        .start_bulk_operation(
    +            "000000000000",
    +            new_op("op-1", "table-1", Some("tok"), "f1"),
    +            50,
    +        )
    +        .await
    +        .expect("start");
    +    assert!(matches!(created, StartOutcome::Created(_)));
    +    let replay = engine
    +        .start_bulk_operation(
    +            "000000000000",
    +            new_op("op-2", "table-2", Some("tok"), "f1"),
    +            50,
    +        )
    +        .await
    +        .expect("start");
    +    match replay {
    +        StartOutcome::IdempotentReplay(record) => assert_eq!(record.operation_id, "op-1"),
    +        _ => panic!("same token and fingerprint must replay"),
    +    }
    +    let mismatch = engine
    +        .start_bulk_operation(
    +            "000000000000",
    +            new_op("op-3", "table-3", Some("tok"), "different"),
    +            50,
    +        )
    +        .await
    +        .expect("start");
    +    assert!(matches!(mismatch, StartOutcome::IdempotentMismatch));
    +}
    +
    +/// A failure computed by the runner that races a cancel must end CANCELLED
    +/// (CANCELLING always wins) AND must not carry the
    +/// runner-computed FailureCode/FailureMessage: no cancelled operation has
    +/// one on the wire. This composes the store primitives exactly as the
    +/// runner does on the failure-plus-cancel path. Regression test for the
    +/// finish CASE on the failure fields.
    +#[tokio::test]
    +async fn a_cancel_racing_a_failure_ends_cancelled_with_the_cancelled_failure_code() {
    +    let engine = engine().await;
    +    let created = engine
    +        .start_bulk_operation("000000000000", new_op("op-1", "table-1", None, "f1"), 50)
    +        .await
    +        .expect("start");
    +    assert!(matches!(created, StartOutcome::Created(_)));
    +
    +    let claimed = engine
    +        .claim_runnable("owner-A", Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert_eq!(claimed.len(), 1);
    +
    +    let cancel = engine
    +        .request_cancel("000000000000", "op-1")
    +        .await
    +        .expect("cancel");
    +    assert!(matches!(cancel, CancelOutcome::Cancelling));
    +
    +    let state = engine
    +        .finish(
    +            "000000000000",
    +            "op-1",
    +            "owner-A",
    +            Terminal {
    +                status: "FAILED".to_owned(),
    +                end_time_ms: 2_000,
    +                failure_code: Some("DynamoDBResourceNotFoundException".to_owned()),
    +                failure_message: Some("Requested resource not found".to_owned()),
    +                usage: serde_json::json!({}),
    +            },
    +        )
    +        .await
    +        .expect("finish");
    +
    +    match state {
    +        FinishState::Held(status) => assert_eq!(status, "CANCELLED"),
    +        FinishState::Lost => panic!("the owner still holds the lease"),
    +    }
    +
    +    let row = engine
    +        .get_bulk_operation("000000000000", "op-1")
    +        .await
    +        .expect("get")
    +        .expect("row");
    +    assert_eq!(row.status, "CANCELLED");
    +    assert_eq!(
    +        row.failure_code.as_deref(),
    +        Some(extenddb_storage::CANCELLED_FAILURE_CODE),
    +        "a CANCELLED operation carries RequestCancelled, not the runner's failure code"
    +    );
    +    assert_eq!(
    +        row.failure_message.as_deref(),
    +        Some(extenddb_storage::CANCELLED_FAILURE_MESSAGE),
    +        "a CANCELLED operation carries the cancelled message, not the runner's"
    +    );
    +}
    +
    +/// A `cancel_requested` that holds text or a blob is what makes the row
    +/// unreadable; it is not a cancel request. The claim ends such a row
    +/// `FAILED` with the internal pair, as any other unreadable row without a
    +/// cancel, and not `CANCELLED`.
    +#[tokio::test]
    +async fn a_malformed_cancel_request_ends_an_unreadable_row_failed() {
    +    let engine = engine().await;
    +    for (i, value) in ["'abc'", "''", "x'78'"].iter().enumerate() {
    +        let operation_id = format!("op-{i}");
    +        let created = engine
    +            .start_bulk_operation(
    +                "000000000000",
    +                new_op(&operation_id, "table-1", None, "f1"),
    +                50,
    +            )
    +            .await
    +            .expect("start");
    +        assert!(matches!(created, StartOutcome::Created(_)), "{value}");
    +        sqlx::query(&format!(
    +            "UPDATE bulk_operations SET cancel_requested = {value} WHERE operation_id = ?"
    +        ))
    +        .bind(&operation_id)
    +        .execute(&engine.pool)
    +        .await
    +        .expect("store a malformed cancel_requested");
    +
    +        let claimed = engine
    +            .claim_runnable("owner-A", Duration::from_secs(60), 10, &[], &[])
    +            .await
    +            .expect("a claim over an unreadable row still succeeds");
    +        assert!(
    +            claimed.is_empty(),
    +            "{value}: the unreadable row is not returned"
    +        );
    +
    +        let (status, code, message): (String, Option, Option) = sqlx::query_as(
    +            "SELECT status, failure_code, failure_message FROM bulk_operations \
    +             WHERE operation_id = ?",
    +        )
    +        .bind(&operation_id)
    +        .fetch_one(&engine.pool)
    +        .await
    +        .expect("read the ended row");
    +        assert_eq!(status, "FAILED", "{value}");
    +        assert_eq!(
    +            code.as_deref(),
    +            Some(extenddb_storage::bulk_operation_store::UNREADABLE_FAILURE_CODE),
    +            "{value}"
    +        );
    +        assert_eq!(
    +            message.as_deref(),
    +            Some(extenddb_storage::bulk_operation_store::UNREADABLE_FAILURE_MESSAGE),
    +            "{value}"
    +        );
    +    }
    +}
    diff --git a/crates/storage-sqlite/src/bulk_operation_store/unreadable.rs b/crates/storage-sqlite/src/bulk_operation_store/unreadable.rs
    new file mode 100644
    index 000000000..06d72349e
    --- /dev/null
    +++ b/crates/storage-sqlite/src/bulk_operation_store/unreadable.rs
    @@ -0,0 +1,108 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Rows of `bulk_operations` that do not decode into a record. The claim
    +//! ends such a row instead of leasing it, and the listing leaves it out, so
    +//! one row a hand edit or a newer build wrote cannot stop the other
    +//! operations on the server from running or being listed.
    +
    +use extenddb_storage::bulk_operation_store::{UNREADABLE_FAILURE_CODE, UNREADABLE_FAILURE_MESSAGE};
    +use extenddb_storage::{CANCELLED_FAILURE_CODE, CANCELLED_FAILURE_MESSAGE};
    +
    +use super::*;
    +
    +/// The key and ordering columns of a row, read without decoding the rest.
    +pub(super) struct RowKey {
    +    pub account_id: String,
    +    pub operation_id: String,
    +    pub start_time_ms: i64,
    +}
    +
    +pub(super) fn row_key(row: &SqliteRow) -> Result {
    +    Ok(RowKey {
    +        account_id: row.try_get("account_id").map_err(internal)?,
    +        operation_id: row.try_get("operation_id").map_err(internal)?,
    +        start_time_ms: row.try_get("start_time_ms").map_err(internal)?,
    +    })
    +}
    +
    +/// Decodes one row, or logs at ERROR why it does not decode and answers
    +/// `None`. The line names the account and the operation so an operator can
    +/// find the row, and `action` says what the caller does with it.
    +pub(super) fn decode_or_log(
    +    row: &SqliteRow,
    +    key: &RowKey,
    +    action: &str,
    +) -> Option {
    +    match record_from_row(row) {
    +        Ok(record) => Some(record),
    +        Err(error) => {
    +            tracing::error!(
    +                account_id = %key.account_id,
    +                operation_id = %key.operation_id,
    +                "bulk operation record does not decode, {action}: {error}"
    +            );
    +            None
    +        }
    +    }
    +}
    +
    +/// Ends a row that does not decode, by the rule `finish` follows: a row
    +/// that is `CANCELLING`, or whose cancel was requested, ends `CANCELLED`
    +/// with the cancelled failure pair, and any other ends `FAILED` with the
    +/// internal failure pair; `end_time_ms` now, lease cleared. The statement
    +/// reads `status` and `cancel_requested` itself, so the ending needs no
    +/// decode and is not refused by a defect in another column. Keyed on the
    +/// primary key alone and guarded on the non-terminal statuses the claim
    +/// selected. The row's report records are deleted with it, in the
    +/// caller's transaction.
    +pub(super) async fn fail_unreadable(
    +    conn: &mut sqlx::SqliteConnection,
    +    key: &RowKey,
    +    now: i64,
    +) -> Result<(), StorageError> {
    +    // Column references in a SET list read the pre-update row, so every
    +    // CASE tests the same original status. Only an integer counts as a
    +    // cancel request: SQLite orders every text and blob value above every
    +    // number, so a malformed `cancel_requested` would compare `<> 0`.
    +    let ended = sqlx::query(
    +        "UPDATE bulk_operations SET \
    +             status = CASE WHEN status = 'CANCELLING' \
    +                 OR (typeof(cancel_requested) = 'integer' AND cancel_requested <> 0) \
    +                 THEN 'CANCELLED' ELSE 'FAILED' END, \
    +             failure_code = CASE WHEN status = 'CANCELLING' \
    +                 OR (typeof(cancel_requested) = 'integer' AND cancel_requested <> 0) \
    +                 THEN ? ELSE ? END, \
    +             failure_message = CASE WHEN status = 'CANCELLING' \
    +                 OR (typeof(cancel_requested) = 'integer' AND cancel_requested <> 0) \
    +                 THEN ? ELSE ? END, \
    +             end_time_ms = ?, lease_owner = NULL, lease_until_ms = NULL \
    +         WHERE account_id = ? AND operation_id = ? \
    +           AND status IN ('IN_PROGRESS', 'CANCELLING')",
    +    )
    +    .bind(CANCELLED_FAILURE_CODE)
    +    .bind(UNREADABLE_FAILURE_CODE)
    +    .bind(CANCELLED_FAILURE_MESSAGE)
    +    .bind(UNREADABLE_FAILURE_MESSAGE)
    +    .bind(now)
    +    .bind(&key.account_id)
    +    .bind(&key.operation_id)
    +    .execute(&mut *conn)
    +    .await
    +    .map_err(internal)?;
    +    if ended.rows_affected() == 0 {
    +        return Ok(());
    +    }
    +    // The operation's report records go in the same transaction, as
    +    // `finish` deletes them: nothing reads them for a terminal row.
    +    sqlx::query(
    +        "DELETE FROM bulk_operation_report_records \
    +         WHERE account_id = ? AND operation_id = ?",
    +    )
    +    .bind(&key.account_id)
    +    .bind(&key.operation_id)
    +    .execute(conn)
    +    .await
    +    .map_err(internal)?;
    +    Ok(())
    +}
    diff --git a/crates/storage-sqlite/src/data/data_engine.rs b/crates/storage-sqlite/src/data/data_engine.rs
    index 964ea5f96..930ef3443 100644
    --- a/crates/storage-sqlite/src/data/data_engine.rs
    +++ b/crates/storage-sqlite/src/data/data_engine.rs
    @@ -32,11 +32,13 @@ impl DataEngine for SqliteEngine {
             condition: Option<&Expr>,
             maps: &ExpressionMaps,
             stream: Option<&StreamCapture>,
    +        marker: Option<&extenddb_storage::BulkRowMarker>,
         ) -> BoxFuture<'_, Result, StorageError>> {
             let key_info = key_info.clone();
             let condition = condition.cloned();
             let maps = maps.clone();
             let stream = stream.cloned();
    +        let marker = marker.cloned();
             Box::pin(async move {
                 self.put_item_impl(
                     &key_info,
    @@ -45,11 +47,169 @@ impl DataEngine for SqliteEngine {
                     condition.as_ref(),
                     &maps,
                     stream.as_ref(),
    +                marker.as_ref(),
                 )
                 .await
             })
         }
     
    +    fn put_items_batch(
    +        &self,
    +        key_info: &TableKeyInfo,
    +        rows: Vec,
    +        stream: Option<&StreamCapture>,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        let key_info = key_info.clone();
    +        let stream = stream.cloned();
    +        Box::pin(async move {
    +            self.put_items_batch_impl(&key_info, rows, stream.as_ref())
    +                .await
    +        })
    +    }
    +
    +    fn bulk_row_outcome(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        file_index: i64,
    +        row_index: i64,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        let account_id = account_id.to_owned();
    +        let operation_id = operation_id.to_owned();
    +        Box::pin(async move {
    +            let row: Option<(String,)> = sqlx::query_as(
    +                "SELECT outcome FROM bulk_row_outcomes \
    +                 WHERE account_id = ? AND operation_id = ? \
    +                   AND file_index = ? AND row_index = ?",
    +            )
    +            .bind(&account_id)
    +            .bind(&operation_id)
    +            .bind(file_index)
    +            .bind(row_index)
    +            .fetch_optional(&self.pool)
    +            .await
    +            .map_err(|e| StorageError::Internal(e.to_string()))?;
    +            row.map(|(outcome,)| {
    +                extenddb_storage::RowOutcome::parse(&outcome).ok_or_else(|| {
    +                    StorageError::Internal(format!("unknown bulk row outcome '{outcome}'"))
    +                })
    +            })
    +            .transpose()
    +        })
    +    }
    +
    +    fn bulk_write_takeover(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        cursor: (i64, i64),
    +    ) -> BoxFuture<'_, Result> {
    +        let account_id = account_id.to_owned();
    +        let operation_id = operation_id.to_owned();
    +        let owner = owner.to_owned();
    +        Box::pin(async move {
    +            // Under the engine write lock, so a batch of the earlier owner
    +            // either committed before this or reads the new owner.
    +            let _writer = self.write_lock.lock().await;
    +            let stalled: i64 = sqlx::query_scalar(
    +                "INSERT INTO bulk_write_owners \
    +                 (account_id, operation_id, owner, cursor_file, cursor_row, stalled) \
    +                 VALUES (?, ?, ?, ?, ?, 0) \
    +                 ON CONFLICT (account_id, operation_id) DO UPDATE SET \
    +                 owner = excluded.owner, \
    +                 stalled = CASE WHEN bulk_write_owners.cursor_file = excluded.cursor_file \
    +                   AND bulk_write_owners.cursor_row = excluded.cursor_row \
    +                   THEN bulk_write_owners.stalled + 1 ELSE 0 END, \
    +                 cursor_file = excluded.cursor_file, cursor_row = excluded.cursor_row \
    +                 RETURNING stalled",
    +            )
    +            .bind(&account_id)
    +            .bind(&operation_id)
    +            .bind(&owner)
    +            .bind(cursor.0)
    +            .bind(cursor.1)
    +            .fetch_one(&self.pool)
    +            .await
    +            .map_err(crate::sqlite_util::map_sqlx_err)?;
    +            Ok(u32::try_from(stalled).unwrap_or(u32::MAX))
    +        })
    +    }
    +
    +    fn bulk_rows_recorded(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        file_index: i64,
    +        from_row: i64,
    +        limit: i64,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        let account_id = account_id.to_owned();
    +        let operation_id = operation_id.to_owned();
    +        Box::pin(async move {
    +            let rows: Vec<(i64, String, Option, Option)> = sqlx::query_as(
    +                "SELECT row_index, outcome, old_size, old_image FROM bulk_row_outcomes \
    +                 WHERE account_id = ? AND operation_id = ? AND file_index = ? \
    +                   AND row_index >= ? ORDER BY row_index LIMIT ?",
    +            )
    +            .bind(&account_id)
    +            .bind(&operation_id)
    +            .bind(file_index)
    +            .bind(from_row)
    +            .bind(limit)
    +            .fetch_all(&self.pool)
    +            .await
    +            .map_err(crate::sqlite_util::map_sqlx_err)?;
    +            rows.into_iter()
    +                .map(|(row_index, outcome, old_size, old_image)| {
    +                    let outcome =
    +                        extenddb_storage::RowOutcome::parse(&outcome).ok_or_else(|| {
    +                            StorageError::Internal(format!("unknown bulk row outcome '{outcome}'"))
    +                        })?;
    +                    let old_image = old_image
    +                        .map(|text| serde_json::from_str(&text))
    +                        .transpose()
    +                        .map_err(|e| StorageError::Internal(e.to_string()))?;
    +                    Ok(extenddb_storage::RecordedRow {
    +                        row_index,
    +                        outcome,
    +                        old_size,
    +                        old_image,
    +                    })
    +                })
    +                .collect()
    +        })
    +    }
    +
    +    fn delete_bulk_row_outcomes(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        let account_id = account_id.to_owned();
    +        let operation_id = operation_id.to_owned();
    +        Box::pin(async move {
    +            // D1: every writer holds the engine write lock. A single DELETE
    +            // is its own transaction.
    +            let _writer = self.write_lock.lock().await;
    +            let result = sqlx::query(
    +                "DELETE FROM bulk_row_outcomes WHERE account_id = ? AND operation_id = ?",
    +            )
    +            .bind(&account_id)
    +            .bind(&operation_id)
    +            .execute(&self.pool)
    +            .await
    +            .map_err(|e| StorageError::Internal(e.to_string()))?;
    +            sqlx::query("DELETE FROM bulk_write_owners WHERE account_id = ? AND operation_id = ?")
    +                .bind(&account_id)
    +                .bind(&operation_id)
    +                .execute(&self.pool)
    +                .await
    +                .map_err(|e| StorageError::Internal(e.to_string()))?;
    +            Ok(result.rows_affected())
    +        })
    +    }
    +
         fn get_item(
             &self,
             key_info: &TableKeyInfo,
    diff --git a/crates/storage-sqlite/src/data/mod.rs b/crates/storage-sqlite/src/data/mod.rs
    index b1be9b6e2..1cd17cd67 100644
    --- a/crates/storage-sqlite/src/data/mod.rs
    +++ b/crates/storage-sqlite/src/data/mod.rs
    @@ -34,6 +34,8 @@ mod ddl;
     mod delete_item;
     mod index;
     mod put_item;
    +#[cfg(test)]
    +mod put_item_tests;
     mod query;
     mod query_scan;
     mod transactions;
    diff --git a/crates/storage-sqlite/src/data/put_item.rs b/crates/storage-sqlite/src/data/put_item.rs
    index 7ad7a9c21..f78b1258c 100644
    --- a/crates/storage-sqlite/src/data/put_item.rs
    +++ b/crates/storage-sqlite/src/data/put_item.rs
    @@ -9,10 +9,10 @@
     //! dance is needed.
     
     use extenddb_core::expression::{Expr, ExpressionMaps};
    -use extenddb_core::types::{Item, TableKeyInfo};
    -use extenddb_storage::StreamCapture;
    +use extenddb_core::types::{Item, TableKeyInfo, item_size_bytes};
     use extenddb_storage::error::StorageError;
     use extenddb_storage::util::{pk_to_text, sk_column, sk_info};
    +use extenddb_storage::{RowOutcome, StreamCapture};
     
     use super::index::{enqueue_async_indexes, fetch_indexes_for_table, sync_indexes};
     use super::query::check_condition;
    @@ -20,7 +20,268 @@ use super::tx_helpers::{fetch_item_in_tx, upsert_item_in_tx, write_stream_record
     use super::{bind_sk_fetch_optional, data_table_name, json_to_item};
     use crate::store::SqliteEngine;
     
    +/// One marked row's ledger entry: its outcome, the size of `old`, the item
    +/// the row replaced or was evaluated against, and that item when the marker
    +/// asks to keep it or the condition failed. A batch builds one as each
    +/// marked row is written and inserts them together before its commit.
    +struct LedgerRow<'m> {
    +    marker: &'m extenddb_storage::BulkRowMarker,
    +    outcome: RowOutcome,
    +    old_size: Option,
    +    old_image: Option,
    +}
    +
    +impl<'m> LedgerRow<'m> {
    +    fn new(
    +        marker: &'m extenddb_storage::BulkRowMarker,
    +        outcome: RowOutcome,
    +        old: Option<&Item>,
    +    ) -> Result {
    +        let old_size = old.map(|item| i64::try_from(item_size_bytes(item)).unwrap_or(i64::MAX));
    +        let keep = marker.keep_old || outcome == RowOutcome::ConditionFailed;
    +        let old_image = match old {
    +            Some(item) if keep => Some(
    +                serde_json::to_string(item).map_err(|e| StorageError::Internal(e.to_string()))?,
    +            ),
    +            _ => None,
    +        };
    +        Ok(Self {
    +            marker,
    +            outcome,
    +            old_size,
    +            old_image,
    +        })
    +    }
    +}
    +
    +/// Values one ledger row binds: the seven columns of the insert.
    +const LEDGER_COLUMNS: usize = 7;
    +
    +/// Ledger rows per `INSERT`. SQLite binds at most `SQLITE_MAX_VARIABLE_NUMBER`
    +/// values to a statement, 32,766 by default since SQLite 3.32.0; the bundled
    +/// SQLite (3.46.0, through `libsqlite3-sys`) is built with that default. At
    +/// seven values a row that is 4,680 rows. The bulk runner's batches hold at
    +/// most 100 rows (`bulk_put_write_batch_rows`), so each is one statement.
    +const LEDGER_ROWS_PER_STATEMENT: usize = 32_766 / LEDGER_COLUMNS;
    +
    +/// Insert `bulk_row_outcomes` ledger rows, one multi-row `INSERT` per
    +/// `LEDGER_ROWS_PER_STATEMENT` rows. The caller chooses the connection: the
    +/// write transaction's for a batch and for an applied single row, a pool
    +/// connection (a single-statement transaction) for a failed condition on
    +/// the single-row path.
    +///
    +/// A plain insert: the ledger key is the row's position, and a second
    +/// outcome for one position, in these rows or already recorded, means the
    +/// row was submitted after it had been applied, which the batch refuses by
    +/// failing.
    +async fn insert_row_outcomes(
    +    conn: &mut sqlx::SqliteConnection,
    +    rows: &[LedgerRow<'_>],
    +) -> Result<(), StorageError> {
    +    for chunk in rows.chunks(LEDGER_ROWS_PER_STATEMENT) {
    +        let values = vec!["(?, ?, ?, ?, ?, ?, ?)"; chunk.len()].join(", ");
    +        let sql = format!(
    +            "INSERT INTO bulk_row_outcomes \
    +             (account_id, operation_id, file_index, row_index, outcome, old_size, old_image) \
    +             VALUES {values}"
    +        );
    +        let mut query = sqlx::query(&sql);
    +        for row in chunk {
    +            query = query
    +                .bind(&row.marker.account_id)
    +                .bind(&row.marker.operation_id)
    +                .bind(row.marker.file_index)
    +                .bind(row.marker.row_index)
    +                .bind(row.outcome.as_str())
    +                .bind(row.old_size)
    +                .bind(&row.old_image);
    +        }
    +        query
    +            .execute(&mut *conn)
    +            .await
    +            .map_err(|e| StorageError::Internal(e.to_string()))?;
    +    }
    +    Ok(())
    +}
    +
    +/// The batch-level ledger work, before any row is written: refuse the batch
    +/// unless `marker.owner` is the owner the last takeover recorded, then
    +/// delete the operation's ledger rows the last checkpoint covers.
    +async fn fence_and_prune(
    +    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
    +    marker: &extenddb_storage::BulkRowMarker,
    +) -> Result<(), StorageError> {
    +    let owner: Option<(String,)> = sqlx::query_as(
    +        "SELECT owner FROM bulk_write_owners WHERE account_id = ? AND operation_id = ?",
    +    )
    +    .bind(&marker.account_id)
    +    .bind(&marker.operation_id)
    +    .fetch_optional(&mut **tx)
    +    .await
    +    .map_err(crate::sqlite_util::map_sqlx_err)?;
    +    if owner.as_ref().map(|(owner,)| owner.as_str()) != Some(marker.owner.as_str()) {
    +        return Err(StorageError::Transient(format!(
    +            "bulk write batch refused: operation {} is owned by another claim",
    +            marker.operation_id
    +        )));
    +    }
    +    let (file, row) = marker.settled;
    +    sqlx::query(
    +        "DELETE FROM bulk_row_outcomes WHERE account_id = ? AND operation_id = ? \
    +         AND (file_index < ? OR (file_index = ? AND row_index < ?))",
    +    )
    +    .bind(&marker.account_id)
    +    .bind(&marker.operation_id)
    +    .bind(file)
    +    .bind(file)
    +    .bind(row)
    +    .execute(&mut **tx)
    +    .await
    +    .map_err(crate::sqlite_util::map_sqlx_err)?;
    +    Ok(())
    +}
    +
    +/// What one row's write inside a transaction produced.
    +struct RowWrite {
    +    old: Option,
    +    /// Whether async index work was enqueued, so the caller wakes the
    +    /// workers after commit.
    +    enqueued: bool,
    +}
    +
    +/// Index key attributes present in the item must match their declared
    +/// scalar type and be non-empty, matching real DynamoDB. This is up-front
    +/// input validation (a top-level ValidationException), so it runs before
    +/// any write work. Mirrors the PostgreSQL backend, including the definitions
    +/// it checks against: the ones read with the indexes, not the cached set.
    +fn validate_index_keys(
    +    indexes: &[super::index::IndexMeta],
    +    key_info: &TableKeyInfo,
    +    item: &Item,
    +) -> Result<(), StorageError> {
    +    if indexes.is_empty() {
    +        return Ok(());
    +    }
    +    let index_refs: Vec> = indexes
    +        .iter()
    +        .map(|idx| extenddb_core::validation::IndexKeyRef {
    +            index_name: &idx.index_name,
    +            key_schema: &idx.key_schema,
    +        })
    +        .collect();
    +    extenddb_core::validation::validate_index_keys(
    +        item,
    +        &index_refs,
    +        super::index::fresh_attr_defs(indexes, &key_info.attribute_definitions),
    +    )
    +    .map_err(|e| StorageError::Validation(e.to_string()))
    +}
    +
    +/// One row's write inside an open `BEGIN IMMEDIATE` transaction: the read
    +/// of the current image when anything needs it (a marked row needs it for
    +/// its ledger entry), the condition against it, the upsert, index and
    +/// stream maintenance. The caller records a marked row's outcome from the
    +/// returned image: a single write right after this returns, a batch with
    +/// its other rows before its commit.
    +///
    +/// A failed condition returns `Err(ConditionFailed(old))` after the read
    +/// and before any statement that changes a row, so the transaction stays
    +/// usable and a batch can continue with its next row.
    +#[allow(clippy::too_many_arguments)]
    +async fn put_row_in_tx(
    +    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
    +    key_info: &TableKeyInfo,
    +    indexes: &[super::index::IndexMeta],
    +    system_delay: u64,
    +    item: &Item,
    +    return_old: bool,
    +    condition: Option<&Expr>,
    +    maps: &ExpressionMaps,
    +    stream: Option<&StreamCapture>,
    +    marker: Option<&extenddb_storage::BulkRowMarker>,
    +) -> Result {
    +    let need_old = condition.is_some()
    +        || return_old
    +        || !indexes.is_empty()
    +        || stream.is_some()
    +        || marker.is_some();
    +
    +    let old = if need_old {
    +        fetch_item_in_tx(tx, key_info, item).await?
    +    } else {
    +        None
    +    };
    +
    +    if condition.is_some() {
    +        let empty = Item::new();
    +        let target = old.as_ref().unwrap_or(&empty);
    +        if let Err(e) = check_condition(condition, target, maps) {
    +            return Err(match e {
    +                StorageError::ConditionFailed(_) => StorageError::ConditionFailed(old),
    +                other => other,
    +            });
    +        }
    +    }
    +
    +    upsert_item_in_tx(tx, key_info, item).await?;
    +
    +    // Synchronous indexes (LSIs + zero-delay GSIs) are applied in-txn;
    +    // async GSIs are enqueued into gsi_pending within the same txn.
    +    if !indexes.is_empty() {
    +        sync_indexes(
    +            tx,
    +            &key_info.key_schema,
    +            &key_info.attribute_definitions,
    +            indexes,
    +            old.as_ref(),
    +            Some(item),
    +            system_delay,
    +        )
    +        .await?;
    +    }
    +    let enqueued_gsi = enqueue_async_indexes(
    +        tx,
    +        key_info,
    +        indexes,
    +        old.as_ref(),
    +        Some(item),
    +        system_delay,
    +    )
    +    .await?
    +        > 0;
    +    // Vector indexes: applied in this transaction when the propagation delay is
    +    // 0, otherwise enqueued alongside the async GSI work. Gated on the cached
    +    // key info so a table without them costs no extra query, and deliberately
    +    // outside the `indexes` guard above: a table may have a vector index and no
    +    // GSI or LSI at all.
    +    let enqueued_vector = if key_info.vector_indexes.is_empty() {
    +        false
    +    } else {
    +        crate::data::vector_index::maintain_vector_indexes(
    +            tx,
    +            &key_info.table_id,
    +            &key_info.key_schema,
    +            &key_info.attribute_definitions,
    +            old.as_ref(),
    +            Some(item),
    +            system_delay,
    +        )
    +        .await?
    +            > 0
    +    };
    +
    +    if let Some(capture) = stream {
    +        write_stream_record_in_tx(tx, key_info, capture, old.as_ref(), Some(item)).await?;
    +    }
    +
    +    Ok(RowWrite {
    +        old,
    +        enqueued: enqueued_gsi || enqueued_vector,
    +    })
    +}
    +
     impl SqliteEngine {
    +    #[allow(clippy::too_many_arguments)]
         pub(crate) async fn put_item_impl(
             &self,
             key_info: &TableKeyInfo,
    @@ -29,6 +290,7 @@ impl SqliteEngine {
             condition: Option<&Expr>,
             maps: &ExpressionMaps,
             stream: Option<&StreamCapture>,
    +        marker: Option<&extenddb_storage::BulkRowMarker>,
         ) -> Result, StorageError> {
             // Read the index set only after acquiring the write lock, so a GSI added
             // by a concurrent UpdateTable (which holds the same lock) cannot be missed
    @@ -40,28 +302,7 @@ impl SqliteEngine {
             let system_delay = self.index_propagation_delay().await;
             let _writer = self.write_lock.lock().await;
             let indexes = fetch_indexes_for_table(&key_info.table_id, &self.pool).await?;
    -
    -        // Index key attributes present in the item must match their declared
    -        // scalar type and be non-empty, matching real DynamoDB. This is up-front
    -        // input validation (a top-level ValidationException), so it runs before
    -        // any write work. Mirrors the PostgreSQL backend.
    -        if !indexes.is_empty() {
    -            let index_refs: Vec> = indexes
    -                .iter()
    -                .map(|idx| extenddb_core::validation::IndexKeyRef {
    -                    index_name: &idx.index_name,
    -                    key_schema: &idx.key_schema,
    -                })
    -                .collect();
    -            extenddb_core::validation::validate_index_keys(
    -                &item,
    -                &index_refs,
    -                super::index::fresh_attr_defs(&indexes, &key_info.attribute_definitions),
    -            )
    -            .map_err(|e| StorageError::Validation(e.to_string()))?;
    -        }
    -
    -        let need_old = condition.is_some() || return_old || !indexes.is_empty() || stream.is_some();
    +        validate_index_keys(&indexes, key_info, &item)?;
     
             let mut tx = self
                 .pool
    @@ -69,85 +310,163 @@ impl SqliteEngine {
                 .await
                 .map_err(|e| StorageError::Internal(e.to_string()))?;
     
    -        let old = if need_old {
    -            fetch_item_in_tx(&mut tx, key_info, &item).await?
    -        } else {
    -            None
    -        };
    -
    -        if condition.is_some() {
    -            let empty = Item::new();
    -            let target = old.as_ref().unwrap_or(&empty);
    -            if let Err(e) = check_condition(condition, target, maps) {
    -                return match e {
    -                    StorageError::ConditionFailed(_) => Err(StorageError::ConditionFailed(old)),
    -                    other => Err(other),
    -                };
    -            }
    -        }
    -
    -        upsert_item_in_tx(&mut tx, key_info, &item).await?;
    -
    -        // Synchronous indexes (LSIs + zero-delay GSIs) are applied in-txn;
    -        // async GSIs are enqueued into gsi_pending within the same txn.
    -        if !indexes.is_empty() {
    -            sync_indexes(
    -                &mut tx,
    -                &key_info.key_schema,
    -                &key_info.attribute_definitions,
    -                &indexes,
    -                old.as_ref(),
    -                Some(&item),
    -                system_delay,
    -            )
    -            .await?;
    -        }
    -        let enqueued_gsi = enqueue_async_indexes(
    +        let written = match put_row_in_tx(
                 &mut tx,
                 key_info,
                 &indexes,
    -            old.as_ref(),
    -            Some(&item),
                 system_delay,
    +            &item,
    +            return_old,
    +            condition,
    +            maps,
    +            stream,
    +            marker,
             )
    -        .await?
    -            > 0;
    -        // Vector indexes: applied in this transaction when the propagation delay is
    -        // 0, otherwise enqueued alongside the async GSI work. Gated on the cached
    -        // key info so a table without them costs no extra query, and deliberately
    -        // outside the `indexes` guard above: a table may have a vector index and no
    -        // GSI or LSI at all.
    -        let enqueued_vector = if key_info.vector_indexes.is_empty() {
    -            false
    -        } else {
    -            crate::data::vector_index::maintain_vector_indexes(
    -                &mut tx,
    -                &key_info.table_id,
    -                &key_info.key_schema,
    -                &key_info.attribute_definitions,
    -                old.as_ref(),
    -                Some(&item),
    -                system_delay,
    -            )
    -            .await?
    -                > 0
    +        .await
    +        {
    +            Ok(written) => written,
    +            Err(StorageError::ConditionFailed(old)) => {
    +                // Nothing else is written for a failed condition, so the
    +                // write transaction is discarded and the outcome is recorded
    +                // as a single-statement transaction of its own, still under
    +                // the write lock (D1). The ledger row lets a resumed run tell
    +                // this failure from a fresh one instead of re-evaluating the
    +                // condition against the operation's own earlier writes.
    +                drop(tx);
    +                if let Some(m) = marker {
    +                    let mut conn = self
    +                        .pool
    +                        .acquire()
    +                        .await
    +                        .map_err(|e| StorageError::Internal(e.to_string()))?;
    +                    let row = LedgerRow::new(m, RowOutcome::ConditionFailed, old.as_ref())?;
    +                    insert_row_outcomes(&mut conn, &[row]).await?;
    +                }
    +                return Err(StorageError::ConditionFailed(old));
    +            }
    +            Err(other) => return Err(other),
             };
    -        let enqueued = enqueued_gsi || enqueued_vector;
     
    -        if let Some(capture) = stream {
    -            write_stream_record_in_tx(&mut tx, key_info, capture, old.as_ref(), Some(&item))
    -                .await?;
    +        // The applied outcome rides the write transaction: both commit or
    +        // neither does, following the gsi_pending precedent for bookkeeping
    +        // rows inside the write transaction.
    +        if let Some(m) = marker {
    +            let row = LedgerRow::new(m, RowOutcome::Applied, written.old.as_ref())?;
    +            insert_row_outcomes(&mut tx, &[row]).await?;
             }
     
             tx.commit()
                 .await
                 .map_err(|e| StorageError::Internal(e.to_string()))?;
     
    -        if enqueued {
    +        if written.enqueued {
                 self.gsi_notify.notify_waiters();
             }
     
    -        Ok(if return_old { old } else { None })
    +        Ok(if return_old { written.old } else { None })
    +    }
    +
    +    /// Implementation of `DataEngine::put_items_batch`: every row of the
    +    /// batch in one `BEGIN IMMEDIATE` transaction under one acquisition of
    +    /// the process write lock.
    +    ///
    +    /// The first marked row's owner is checked against the operation's
    +    /// recorded owner before anything is written, and the ledger rows the
    +    /// last checkpoint covers are deleted (`fence_and_prune`). Each row then
    +    /// goes through the same path a single put uses, and its outcome, a
    +    /// failed condition included, is kept with what the row replaced. After
    +    /// the last row the outcomes go into the ledger in this transaction, one
    +    /// multi-row insert for up to 4,680 rows, so a batch of the runner's
    +    /// size costs one ledger statement instead of one a row. Any other error
    +    /// returns before commit, so nothing from the batch is visible.
    +    pub(crate) async fn put_items_batch_impl(
    +        &self,
    +        key_info: &TableKeyInfo,
    +        rows: Vec,
    +        stream: Option<&StreamCapture>,
    +    ) -> Result, StorageError> {
    +        use extenddb_storage::BatchRowResult;
    +        if rows.is_empty() {
    +            return Ok(Vec::new());
    +        }
    +        let system_delay = self.index_propagation_delay().await;
    +        let _writer = self.write_lock.lock().await;
    +        let indexes = fetch_indexes_for_table(&key_info.table_id, &self.pool).await?;
    +        for row in &rows {
    +            validate_index_keys(&indexes, key_info, &row.item)?;
    +        }
    +
    +        // BUSY and LOCKED past the busy timeout (another writer holding the
    +        // file, a backup for example) and a pool timeout arrive as
    +        // `Transient` through `map_sqlx_err`, which the bulk runner resumes
    +        // after instead of failing the operation.
    +        let mut tx = self
    +            .pool
    +            .begin_with("BEGIN IMMEDIATE")
    +            .await
    +            .map_err(crate::sqlite_util::map_sqlx_err)?;
    +        if let Some(marker) = rows.iter().find_map(|row| row.marker.as_ref()) {
    +            fence_and_prune(&mut tx, marker).await?;
    +        }
    +        let mut results = Vec::with_capacity(rows.len());
    +        let mut ledger: Vec> = Vec::with_capacity(rows.len());
    +        let mut enqueued = false;
    +        let default_maps = ExpressionMaps::default();
    +
    +        for row in &rows {
    +            let (condition, maps) = match row.condition.as_ref() {
    +                Some((expr, maps)) => (Some(expr), maps),
    +                None => (None, &default_maps),
    +            };
    +            match put_row_in_tx(
    +                &mut tx,
    +                key_info,
    +                &indexes,
    +                system_delay,
    +                &row.item,
    +                row.return_old,
    +                condition,
    +                maps,
    +                stream,
    +                row.marker.as_ref(),
    +            )
    +            .await
    +            {
    +                Ok(written) => {
    +                    if let Some(m) = row.marker.as_ref() {
    +                        ledger.push(LedgerRow::new(
    +                            m,
    +                            RowOutcome::Applied,
    +                            written.old.as_ref(),
    +                        )?);
    +                    }
    +                    enqueued |= written.enqueued;
    +                    results.push(BatchRowResult::Written {
    +                        old: if row.return_old { written.old } else { None },
    +                    });
    +                }
    +                Err(StorageError::ConditionFailed(old)) => {
    +                    if let Some(m) = row.marker.as_ref() {
    +                        ledger.push(LedgerRow::new(
    +                            m,
    +                            RowOutcome::ConditionFailed,
    +                            old.as_ref(),
    +                        )?);
    +                    }
    +                    results.push(BatchRowResult::ConditionFailed { old });
    +                }
    +                Err(error) => return Err(error),
    +            }
    +        }
    +
    +        insert_row_outcomes(&mut tx, &ledger).await?;
    +        tx.commit()
    +            .await
    +            .map_err(crate::sqlite_util::map_sqlx_err)?;
    +        if enqueued {
    +            self.gsi_notify.notify_waiters();
    +        }
    +        Ok(results)
         }
     
         pub(crate) async fn get_item_impl(
    diff --git a/crates/storage-sqlite/src/data/put_item_tests.rs b/crates/storage-sqlite/src/data/put_item_tests.rs
    new file mode 100644
    index 000000000..c731f5ec4
    --- /dev/null
    +++ b/crates/storage-sqlite/src/data/put_item_tests.rs
    @@ -0,0 +1,119 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! A put that carries key info read before an UpdateTable added a GSI is
    +//! checked against the definitions read with the indexes, on the single
    +//! path and on the batch path, so a wrong-typed key of the new index is
    +//! refused rather than written.
    +
    +use extenddb_core::expression::ExpressionMaps;
    +use extenddb_core::types::{AttributeValue, CreateTableInput, Item, UpdateTableInput};
    +use extenddb_storage::error::StorageError;
    +use extenddb_storage::{BatchRow, DataEngine, TableEngine};
    +use serde_json::json;
    +
    +use crate::SqliteEngine;
    +
    +const ACCOUNT: &str = "000000000000";
    +
    +fn item(pk: &str, g: AttributeValue) -> Item {
    +    let mut item = Item::new();
    +    item.insert("pk".to_owned(), AttributeValue::S(pk.to_owned()));
    +    item.insert("g".to_owned(), g);
    +    item
    +}
    +
    +fn row(item: Item) -> BatchRow {
    +    BatchRow {
    +        item,
    +        condition: None,
    +        marker: None,
    +        return_old: false,
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_write_with_key_info_older_than_a_new_gsi_is_validated_against_its_definitions() {
    +    let engine = SqliteEngine::new(":memory:", 1, "us-east-1", 409_600)
    +        .await
    +        .expect("engine");
    +    crate::migrations::apply_for_tests(&engine.pool).await;
    +    sqlx::query("INSERT INTO accounts (account_id, account_name) VALUES (?, 'default')")
    +        .bind(ACCOUNT)
    +        .execute(&engine.pool)
    +        .await
    +        .expect("account");
    +    let input: CreateTableInput = serde_json::from_value(json!({
    +        "TableName": "t",
    +        "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
    +        "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
    +        "BillingMode": "PAY_PER_REQUEST"
    +    }))
    +    .expect("input");
    +    engine
    +        .create_table_impl(ACCOUNT, input, false)
    +        .await
    +        .expect("create table");
    +    sqlx::query("UPDATE tables SET table_status = 'ACTIVE' WHERE table_name = 't'")
    +        .execute(&engine.pool)
    +        .await
    +        .expect("activate the table");
    +    // Read before the GSI exists, as the engine's cached key info is until
    +    // UpdateTable returns: it does not know `g`.
    +    let stale = engine.table_key_info(ACCOUNT, "t").await.expect("key info");
    +    let update: UpdateTableInput = serde_json::from_value(json!({
    +        "TableName": "t",
    +        "AttributeDefinitions": [{"AttributeName": "g", "AttributeType": "S"}],
    +        "GlobalSecondaryIndexUpdates": [{"Create": {
    +            "IndexName": "gidx",
    +            "KeySchema": [{"AttributeName": "g", "KeyType": "HASH"}],
    +            "Projection": {"ProjectionType": "ALL"}
    +        }}]
    +    }))
    +    .expect("update input");
    +    engine
    +        .update_table_impl(ACCOUNT, update)
    +        .await
    +        .expect("add the GSI");
    +    let wrong = || AttributeValue::N("7".to_owned());
    +
    +    let put = engine
    +        .put_item(
    +            &stale,
    +            item("bad", wrong()),
    +            false,
    +            None,
    +            &ExpressionMaps::default(),
    +            None,
    +            None,
    +        )
    +        .await;
    +    assert!(
    +        matches!(put, Err(StorageError::Validation(_))),
    +        "a put with a wrong-typed index key: {put:?}"
    +    );
    +    let batch = engine
    +        .put_items_batch(
    +            &stale,
    +            vec![
    +                row(item("ok", AttributeValue::S("fine".to_owned()))),
    +                row(item("bad", wrong())),
    +            ],
    +            None,
    +        )
    +        .await;
    +    assert!(
    +        matches!(batch, Err(StorageError::Validation(_))),
    +        "a batch row with a wrong-typed index key: {batch:?}"
    +    );
    +    let (table_id,): (String,) =
    +        sqlx::query_as("SELECT table_id FROM tables WHERE table_name = 't'")
    +            .fetch_one(&engine.pool)
    +            .await
    +            .expect("table id");
    +    let written: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM \"_ddb_{table_id}\""))
    +        .fetch_one(&engine.pool)
    +        .await
    +        .expect("count the base rows");
    +    assert_eq!(written, 0, "the refused put and batch wrote nothing");
    +}
    diff --git a/crates/storage-sqlite/src/data/tx_helpers.rs b/crates/storage-sqlite/src/data/tx_helpers.rs
    index 0ee6ba208..0e3ed0c32 100644
    --- a/crates/storage-sqlite/src/data/tx_helpers.rs
    +++ b/crates/storage-sqlite/src/data/tx_helpers.rs
    @@ -448,7 +448,7 @@ mod stream_clock_tests {
                     ("v".to_owned(), AttributeValue::S(v.to_owned())),
                 ]);
                 engine
    -                .put_item(&key_info, item, false, None, &maps, Some(&capture))
    +                .put_item(&key_info, item, false, None, &maps, Some(&capture), None)
                     .await
                     .expect("put");
             }
    diff --git a/crates/storage-sqlite/src/data/update_item_tests.rs b/crates/storage-sqlite/src/data/update_item_tests.rs
    index d173ad2d6..5933c4c8c 100644
    --- a/crates/storage-sqlite/src/data/update_item_tests.rs
    +++ b/crates/storage-sqlite/src/data/update_item_tests.rs
    @@ -149,7 +149,7 @@ async fn removal_on_an_existing_item_still_removes() {
         let mut item = k.clone();
         item.insert("a".to_owned(), AttributeValue::S("1".to_owned()));
         engine
    -        .put_item(&key_info, item.clone(), false, None, &maps, None)
    +        .put_item(&key_info, item.clone(), false, None, &maps, None, None)
             .await
             .expect("put");
     
    diff --git a/crates/storage-sqlite/src/lib.rs b/crates/storage-sqlite/src/lib.rs
    index 2e1401039..89c63abe6 100644
    --- a/crates/storage-sqlite/src/lib.rs
    +++ b/crates/storage-sqlite/src/lib.rs
    @@ -25,6 +25,7 @@ mod authorization_store;
     mod backup;
     mod backup_catalog;
     mod bootstrapper;
    +mod bulk_operation_store;
     mod catalog_store;
     pub mod config;
     mod create_table;
    diff --git a/crates/storage-sqlite/src/migrations/mod.rs b/crates/storage-sqlite/src/migrations/mod.rs
    index 94a987d99..6a2676c0a 100644
    --- a/crates/storage-sqlite/src/migrations/mod.rs
    +++ b/crates/storage-sqlite/src/migrations/mod.rs
    @@ -122,6 +122,15 @@ pub fn migration(id: MigrationId) -> Migration {
                 "SQLite runs one writer at a time, so its stream records commit in the order of \
                  their sequence numbers",
             ),
    +        MigrationId::BulkOperations => Migration::Applies {
    +            kind: Kind::Breaking,
    +            convergent: true,
    +            bodies: vec![file_body!(
    +                StoreKind::Catalog,
    +                "migrations",
    +                "0008_bulk_operations.sql"
    +            )],
    +        },
         }
     }
     
    diff --git a/crates/storage-sqlite/src/snapshot_engine.rs b/crates/storage-sqlite/src/snapshot_engine.rs
    index e79fc6da0..15598cf50 100644
    --- a/crates/storage-sqlite/src/snapshot_engine.rs
    +++ b/crates/storage-sqlite/src/snapshot_engine.rs
    @@ -623,7 +623,15 @@ mod tests {
                 .into_iter()
                 .collect();
                 engine
    -                .put_item(&ki, item, false, None, &ExpressionMaps::default(), None)
    +                .put_item(
    +                    &ki,
    +                    item,
    +                    false,
    +                    None,
    +                    &ExpressionMaps::default(),
    +                    None,
    +                    None,
    +                )
                     .await
                     .expect("put");
             }
    diff --git a/crates/storage-sqlite/src/update_table.rs b/crates/storage-sqlite/src/update_table.rs
    index d1e0e5769..95fe0b92c 100644
    --- a/crates/storage-sqlite/src/update_table.rs
    +++ b/crates/storage-sqlite/src/update_table.rs
    @@ -364,6 +364,23 @@ impl SqliteEngine {
             // out of the catalog transaction so the post-commit index DDL builds its
             // columns from the same set the catalog now holds.
             let mut merged_attr_defs_for_ddl: Option> = None;
    +        // No index creation while a bulk operation on the table runs, read
    +        // under the write lock a start also takes. The build is counted in
    +        // memory only once every check below that can refuse the request
    +        // has passed (see `_index_build` before the commit).
    +        let creates_gsi = match &input.global_secondary_index_updates {
    +            Some(updates) => {
    +                crate::bulk_operation_store::refuse_index_creation_during_run(
    +                    &mut tx,
    +                    account_id,
    +                    &table_id,
    +                    &input.table_name,
    +                    updates,
    +                )
    +                .await?
    +            }
    +            None => false,
    +        };
             if let Some(updates) = &input.global_secondary_index_updates {
                 {
                     // One index change at a time while a GSI builds, as the service
    @@ -694,6 +711,13 @@ impl SqliteEngine {
                 }
             }
     
    +        // Every check that can refuse the request has passed, so the build
    +        // is counted from here until this function returns, its backfill
    +        // under the write lock included: a start that waited for the lock
    +        // and reads the index `ACTIVE` after it is refused. A request
    +        // refused above never moved the count. Begun before the commit.
    +        let _index_build =
    +            creates_gsi.then(|| crate::bulk_operation_store::BuildGuard::begin(&table_id));
             tx.commit()
                 .await
                 .map_err(|e| StorageError::Internal(e.to_string()))?;
    @@ -1578,6 +1602,7 @@ mod reconciler_tests {
                     None,
                     &extenddb_core::expression::ExpressionMaps::default(),
                     None,
    +                None,
                 )
                 .await
                 .expect("put with the older key info");
    diff --git a/crates/storage-sqlite/src/vector_bench.rs b/crates/storage-sqlite/src/vector_bench.rs
    index f6f80dadc..9ce07193f 100644
    --- a/crates/storage-sqlite/src/vector_bench.rs
    +++ b/crates/storage-sqlite/src/vector_bench.rs
    @@ -156,7 +156,7 @@ mod tests {
                 );
                 item.insert("filler".to_owned(), AttributeValue::S(filler.clone()));
                 engine
    -                .put_item_impl(&key_info, item, false, None, &maps, None)
    +                .put_item_impl(&key_info, item, false, None, &maps, None, None)
                     .await
                     .expect("put item");
             }
    diff --git a/crates/storage-sqlite/tests/bulk_operation_store.rs b/crates/storage-sqlite/tests/bulk_operation_store.rs
    new file mode 100644
    index 000000000..364a4e2b5
    --- /dev/null
    +++ b/crates/storage-sqlite/tests/bulk_operation_store.rs
    @@ -0,0 +1,347 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The bulk operation store contract, run against the `SQLite` backend.
    +//!
    +//! The cases live in `extenddb_storage::bulk_operation_store_contract` and are
    +//! shared with the `PostgreSQL` suite; this file only builds the engine. Each
    +//! test gets its own database file in the system temporary directory so cases
    +//! cannot see each other's rows and the concurrent cases contend on a real
    +//! file. A file rather than `:memory:` because the in-memory database pins the
    +//! pool to one connection, and these tests exist to race transactions. The
    +//! file is removed when the test passes and left behind, named `eddb_bulk_*`,
    +//! when it fails.
    +
    +use std::sync::Arc;
    +use std::sync::atomic::{AtomicU64, Ordering};
    +
    +use extenddb_storage::bulk_operation_store_contract::{self as contract, Fixture};
    +use extenddb_storage::management_store::{ManagementStore, SettingsStore};
    +use extenddb_storage_sqlite::{SqliteCatalogStore, SqliteEngine};
    +
    +const ACCOUNT_A: &str = "111111111111";
    +const ACCOUNT_B: &str = "222222222222";
    +const REGION: &str = "us-east-1";
    +
    +static NEXT_DB: AtomicU64 = AtomicU64::new(0);
    +
    +/// A fresh file-backed engine with the schema applied, the control-plane
    +/// delay pinned to zero (no workers run here, so a scheduled CREATING to
    +/// ACTIVE transition would never happen), and both contract accounts seeded.
    +async fn fixture() -> (Fixture, std::path::PathBuf) {
    +    let path = std::env::temp_dir().join(format!(
    +        "eddb_bulk_{}_{}.sqlite",
    +        std::process::id(),
    +        NEXT_DB.fetch_add(1, Ordering::Relaxed)
    +    ));
    +    let path_str = path.to_str().expect("a UTF-8 temporary path").to_owned();
    +
    +    extenddb_storage_sqlite::migrations::migrate(&path_str, "test", std::time::Duration::ZERO)
    +        .await
    +        .expect("apply the migrations to the fresh database");
    +
    +    // Seed through the catalog store, the same surface the management API
    +    // uses, then release its pool before the engine opens its own.
    +    let pool = sqlx::sqlite::SqlitePoolOptions::new()
    +        .max_connections(1)
    +        .connect(&format!("sqlite://{path_str}?mode=rwc"))
    +        .await
    +        .expect("open a seeding pool");
    +    let catalog = SqliteCatalogStore::new(pool.clone());
    +    catalog
    +        .set_setting("control_plane_delay_seconds", "0")
    +        .await
    +        .expect("pin the control-plane delay to zero");
    +    for (id, name) in [(ACCOUNT_A, "contract-a"), (ACCOUNT_B, "contract-b")] {
    +        catalog
    +            .create_account(id, name)
    +            .await
    +            .expect("seed a contract account");
    +    }
    +    pool.close().await;
    +
    +    let engine = SqliteEngine::new(&path_str, 4, REGION, 409_600)
    +        .await
    +        .expect("open the engine on the seeded database");
    +
    +    (
    +        Fixture {
    +            engine: Arc::new(engine),
    +            account_a: ACCOUNT_A.to_owned(),
    +            account_b: ACCOUNT_B.to_owned(),
    +            region: REGION.to_owned(),
    +        },
    +        path,
    +    )
    +}
    +
    +/// Remove the database file, its WAL/SHM sidecars, and the engine's lock
    +/// file. Reached only when the case passed; a panic skips this and leaves
    +/// the file for inspection.
    +fn cleanup(fx: Fixture, path: &std::path::Path) {
    +    drop(fx);
    +    for suffix in ["", "-wal", "-shm", ".lock"] {
    +        let mut sidecar = path.as_os_str().to_owned();
    +        sidecar.push(suffix);
    +        let _ = std::fs::remove_file(std::path::PathBuf::from(sidecar));
    +    }
    +}
    +
    +macro_rules! contract_case {
    +    ($($name:ident),* $(,)?) => {$(
    +        #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +        async fn $name() {
    +            let (fx, path) = fixture().await;
    +            contract::$name(&fx).await;
    +            cleanup(fx, &path);
    +        }
    +    )*};
    +}
    +
    +contract_case!(
    +    // The start transaction.
    +    second_start_on_a_busy_table_is_refused,
    +    the_table_guard_answers_before_the_account_limit,
    +    twenty_concurrent_starts_with_distinct_tokens_admit_exactly_one,
    +    twenty_concurrent_starts_with_one_token_create_once_and_replay_the_rest,
    +    a_table_is_startable_again_after_its_operation_finishes,
    +    the_per_table_non_terminal_count_follows_the_operation,
    +    a_reused_token_with_different_parameters_is_refused_and_creates_nothing,
    +    a_reused_token_replays_even_when_terminal_and_skips_the_table_guard,
    +    a_token_replays_inside_the_window_and_starts_anew_past_it,
    +    tokenless_starts_do_not_collide_on_the_token_index,
    +    sixty_concurrent_starts_against_a_limit_of_fifty_admit_exactly_fifty,
    +    // A start and an index creation that cross.
    +    a_start_and_an_index_creation_that_cross_are_never_both_accepted,
    +    a_refused_index_creation_does_not_refuse_a_start_that_crossed_it,
    +    an_accepted_index_creation_refuses_a_start_called_before_it,
    +    starts_beyond_the_pool_behind_an_index_creation_all_answer,
    +    a_stream_of_starts_does_not_hold_off_delete_table,
    +    // A start whose table was removed after the handler's lookup.
    +    a_start_on_a_table_removed_after_the_lookup_is_refused,
    +    // Rows born terminal (initial failure).
    +    an_initial_failure_starts_terminal_and_is_never_claimed,
    +    an_initial_failure_does_not_count_as_non_terminal,
    +    a_table_with_an_initial_failure_row_accepts_a_new_start,
    +    a_reused_token_replays_to_an_initial_failure_row,
    +    count_terminal_born_since_counts_only_terminal_born_rows_in_the_window,
    +    an_initial_failure_is_inserted_despite_a_saturated_account,
    +    an_initial_failure_on_a_busy_table_answers_busy_and_inserts_nothing,
    +    // Reads and isolation.
    +    an_operation_is_invisible_and_inert_to_another_account,
    +    get_returns_the_record_as_stored,
    +    list_pages_newest_first_with_an_exclusive_cursor,
    +    // Claim and lease.
    +    two_owners_claiming_concurrently_share_no_operation,
    +    claim_returns_cancelling_operations_too,
    +    a_live_lease_blocks_claim_until_it_expires,
    +    an_expired_unclaimed_lease_still_accepts_the_owners_checkpoint_and_finish,
    +    claim_prefers_the_oldest_start_and_honours_max,
    +    claim_skips_the_rows_of_excluded_accounts,
    +    claim_skips_the_operations_in_the_exclude_list,
    +    // Checkpoint, cancel, and finish.
    +    checkpoint_persists_progress_counters_usage_and_records,
    +    a_checkpoint_from_a_non_owner_is_lost_and_leaves_no_trace,
    +    a_finish_from_a_non_owner_is_lost_and_changes_nothing,
    +    finishing_a_cancelling_operation_lands_cancelled_whatever_the_outcome_says,
    +    finish_clears_the_lease_and_deletes_report_records,
    +    cancel_moves_a_running_operation_to_cancelling_and_repeats_harmlessly,
    +    cancel_on_a_terminal_operation_reports_the_status_and_changes_nothing,
    +    cancel_on_an_unknown_operation_reports_not_found,
    +    // The row-outcome ledger.
    +    a_marked_write_records_applied_with_the_item,
    +    a_marked_condition_failure_records_the_failure_and_writes_nothing,
    +    unmarked_writes_leave_no_ledger_row,
    +    ledger_deletion_is_scoped_to_one_operation,
    +    // Retention.
    +    purge_removes_only_terminal_operations_older_than_the_cutoff,
    +    list_purgeable_before_honors_the_limit,
    +    the_terminal_born_cap_is_enforced_in_the_start_transaction,
    +    purge_deletes_only_the_given_pairs,
    +    a_purged_operations_token_is_free_for_a_new_start,
    +    // A second write at a recorded position.
    +    a_marked_write_at_a_recorded_position_is_refused_and_changes_nothing,
    +    // Batched writes.
    +    a_batch_applies_every_row_and_reports_each_outcome_in_order,
    +    a_batch_applies_two_rows_on_one_key_in_order,
    +    a_batch_overwrite_reports_the_replaced_item_for_sizing,
    +    // The write owner and the ledger a resumed run reads back.
    +    every_marked_batch_row_is_recorded_with_what_it_replaced,
    +    a_batch_prunes_the_ledger_rows_its_checkpoint_covers,
    +    a_batch_of_an_earlier_owner_writes_nothing,
    +    a_batch_row_at_a_recorded_position_fails_the_batch,
    +    two_rows_of_one_batch_at_one_position_fail_the_batch,
    +    a_batch_past_one_statement_of_rows_records_every_row,
    +    a_takeover_counts_the_resumes_that_find_the_checkpoint_unmoved,
    +    a_takeover_waits_for_a_batch_in_flight,
    +    a_batch_error_leaves_nothing_written,
    +    an_empty_batch_is_a_no_op,
    +    opposite_order_batches_on_one_key_set_both_complete,
    +    a_token_hash_shared_across_accounts_creates_in_each_account,
    +    // Paging, ordering, and terminal fidelity.
    +    list_on_an_exact_page_multiple_ends_without_a_cursor,
    +    report_records_read_back_in_file_and_row_order,
    +    finish_with_failed_carries_the_failure_code_and_message,
    +    claim_returns_records_oldest_start_first_in_order,
    +    claim_breaks_start_time_ties_by_operation_id,
    +    // Checkpoint integrity.
    +    a_checkpoint_reinserting_an_existing_record_key_keeps_the_first_record,
    +    checkpoint_usage_replaces_the_stored_value,
    +    checkpoint_records_each_finished_files_rows_and_etag,
    +    checkpoint_records_each_abandoned_files_outcome,
    +    // A load of many input files.
    +    a_many_file_load_costs_the_same_per_file_as_a_small_one,
    +    the_boundary_read_follows_the_cancel_and_the_lease,
    +    // Limit validation and purge follow-through.
    +    list_with_limit_zero_is_refused_as_validation,
    +    purged_pairs_name_the_ledger_rows_to_delete,
    +    // Paged report reads and report totals.
    +    paged_report_reads_return_every_record_exactly_once,
    +    a_report_page_after_the_last_record_is_empty,
    +    a_report_page_with_limit_zero_is_refused_as_validation,
    +    a_report_record_with_a_nul_escape_round_trips_through_checkpoint,
    +    report_totals_round_trip_through_checkpoint_and_start_at_zero,
    +    // The non-terminal count and the corrupt-counter refusal.
    +    count_non_terminal_counts_only_the_accounts_running_and_cancelling_rows,
    +    a_negative_stored_counter_refuses_the_read,
    +    // The run context recorded at Start.
    +    the_run_context_round_trips_through_every_read,
    +    a_terminal_born_row_keeps_its_run_context,
    +);
    +
    +/// A batch that cannot take the write lock because another connection holds
    +/// `BEGIN IMMEDIATE` past the busy timeout (a backup, a slow DDL commit)
    +/// answers `Transient`, the class the bulk runner resumes after. Before the
    +/// batch mapped its `BEGIN` through `map_sqlx_err` it answered `Internal`
    +/// and the operation ended `FAILED`. Takes the 5-second busy timeout.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn a_batch_blocked_by_another_writer_is_transient() {
    +    use extenddb_storage::DataEngine;
    +    use extenddb_storage::error::StorageError;
    +    use sqlx::Executor;
    +
    +    let (fx, path) = fixture().await;
    +    let (ki, rows) = contract::plain_batch_on_a_new_table(&fx, "batch_busy").await;
    +    let holder = sqlx::sqlite::SqlitePoolOptions::new()
    +        .max_connections(1)
    +        .connect(&format!(
    +            "sqlite://{}",
    +            path.to_str().expect("a UTF-8 path")
    +        ))
    +        .await
    +        .expect("open a second connection");
    +    let mut conn = holder.acquire().await.expect("a holder connection");
    +    conn.execute("BEGIN IMMEDIATE")
    +        .await
    +        .expect("hold the write lock");
    +
    +    let error = fx
    +        .engine
    +        .put_items_batch(&ki, rows, None)
    +        .await
    +        .expect_err("the batch cannot take the write lock");
    +    assert!(
    +        matches!(error, StorageError::Transient(_)),
    +        "a busy database must answer Transient, got {error:?}"
    +    );
    +
    +    conn.execute("ROLLBACK")
    +        .await
    +        .expect("release the write lock");
    +    drop(conn);
    +    holder.close().await;
    +    cleanup(fx, &path);
    +}
    +
    +/// Edits a stored operation row on a connection of its own, for the cases
    +/// that need a row no trait method can write.
    +struct SqlEditor {
    +    pool: sqlx::SqlitePool,
    +}
    +
    +impl SqlEditor {
    +    async fn open(path: &std::path::Path) -> Self {
    +        let pool = sqlx::sqlite::SqlitePoolOptions::new()
    +            .max_connections(1)
    +            .connect(&format!(
    +                "sqlite://{}",
    +                path.to_str().expect("a UTF-8 path")
    +            ))
    +            .await
    +            .expect("open an editing connection");
    +        Self { pool }
    +    }
    +
    +    async fn edit(&self, sql: &str, account_id: &str, operation_id: &str) {
    +        let updated = sqlx::query(sql)
    +            .bind(account_id)
    +            .bind(operation_id)
    +            .execute(&self.pool)
    +            .await
    +            .expect("edit the stored row")
    +            .rows_affected();
    +        assert_eq!(updated, 1, "the edit names exactly one row");
    +    }
    +}
    +
    +impl contract::RowEditor for SqlEditor {
    +    fn corrupt<'a>(
    +        &'a self,
    +        account_id: &'a str,
    +        operation_id: &'a str,
    +    ) -> futures::future::BoxFuture<'a, ()> {
    +        Box::pin(self.edit(
    +            "UPDATE bulk_operation_input_files \
    +             SET abandon = '{\"row\": 3, \"kind\": \"NOT_A_KIND\"}' \
    +             WHERE account_id = ? AND operation_id = ? AND file_index = 0",
    +            account_id,
    +            operation_id,
    +        ))
    +    }
    +
    +    fn repair<'a>(
    +        &'a self,
    +        account_id: &'a str,
    +        operation_id: &'a str,
    +    ) -> futures::future::BoxFuture<'a, ()> {
    +        Box::pin(self.edit(
    +            "UPDATE bulk_operation_input_files SET abandon = NULL \
    +             WHERE account_id = ? AND operation_id = ? AND file_index = 0",
    +            account_id,
    +            operation_id,
    +        ))
    +    }
    +
    +    fn mark_table_deleting<'a>(
    +        &'a self,
    +        account_id: &'a str,
    +        table_id: &'a str,
    +    ) -> futures::future::BoxFuture<'a, ()> {
    +        Box::pin(self.edit(
    +            "UPDATE tables SET table_status = 'DELETING' WHERE account_id = ? AND table_id = ?",
    +            account_id,
    +            table_id,
    +        ))
    +    }
    +}
    +
    +macro_rules! edited_case {
    +    ($($name:ident),* $(,)?) => {$(
    +        #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +        async fn $name() {
    +            let (fx, path) = fixture().await;
    +            let editor = SqlEditor::open(&path).await;
    +            contract::$name(&fx, &editor).await;
    +            editor.pool.close().await;
    +            cleanup(fx, &path);
    +        }
    +    )*};
    +}
    +
    +edited_case!(
    +    a_claim_ends_an_unreadable_row_and_returns_the_rest,
    +    a_claim_ends_an_unreadable_cancelled_row_cancelled,
    +    a_claim_ending_an_unreadable_row_deletes_its_report_records,
    +    a_listing_skips_an_unreadable_row_and_pages_past_it,
    +    a_start_on_a_table_set_deleting_after_the_lookup_is_refused,
    +);
    diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml
    index 3d18929b0..cfa9b7ecd 100755
    --- a/crates/storage/Cargo.toml
    +++ b/crates/storage/Cargo.toml
    @@ -9,7 +9,9 @@ license.workspace = true
     
     [features]
     # `test-util` exposes `migrations::check_released`, the checksum test each
    -# backend crate runs on its migration table. Production code never enables it.
    +# backend crate runs on its migration table, and the bulk operation store
    +# contract tests (`bulk_operation_store_contract`), which each backend's test
    +# suite runs against its own engine. Production code never enables it.
     test-util = []
     # Compiles the `snapshot_conformance` check that each backend's tests run
     # against a real database. Production builds do not compile it.
    diff --git a/crates/storage/src/bulk_operation_flags.rs b/crates/storage/src/bulk_operation_flags.rs
    new file mode 100644
    index 000000000..9c8e57a0d
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_flags.rs
    @@ -0,0 +1,41 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! What the bulk put runner reads of an operation at every batch boundary.
    +//! The two backends that run bulk operations answer it from the operation
    +//! row alone, without the input files a full read attaches, so the read
    +//! costs the same whatever the file count.
    +
    +use futures::future::BoxFuture;
    +
    +use crate::BulkOperationRecord;
    +use crate::error::StorageError;
    +
    +/// The part of an operation row the runner reads at a batch boundary: its
    +/// status, whether a cancel was requested, and which claim holds its lease.
    +#[derive(Clone, Debug, PartialEq, Eq)]
    +pub struct OperationFlags {
    +    pub status: String,
    +    pub cancel_requested: bool,
    +    pub lease_owner: Option,
    +}
    +
    +impl OperationFlags {
    +    /// The flags of a full record.
    +    #[must_use]
    +    pub fn of(record: &BulkOperationRecord) -> Self {
    +        Self {
    +            status: record.status.clone(),
    +            cancel_requested: record.cancel_requested,
    +            lease_owner: record.lease_owner.clone(),
    +        }
    +    }
    +
    +    /// The flags of the record a full read answers, for a backend that
    +    /// keeps no cheaper read.
    +    pub(crate) fn of_read(
    +        read: BoxFuture<'_, Result, StorageError>>,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        Box::pin(async move { Ok(read.await?.as_ref().map(Self::of)) })
    +    }
    +}
    diff --git a/crates/storage/src/bulk_operation_store.rs b/crates/storage/src/bulk_operation_store.rs
    new file mode 100644
    index 000000000..050a89aa2
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store.rs
    @@ -0,0 +1,531 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Account-scoped bulk operation records and the primitives the shared
    +//! bulk put lifecycle in the engine drives. Backends implement storage only;
    +//! the lifecycle (claim policy, batching, resume, rendering, terminal
    +//! transitions) lives once in the engine, as ADR-0005 requires.
    +
    +use std::time::{Duration, SystemTime};
    +
    +use futures::future::BoxFuture;
    +use serde_json::Value;
    +
    +use crate::error::StorageError;
    +
    +mod abandon;
    +
    +pub use abandon::{AbandonKind, AbandonedFile, FileAbandon};
    +
    +/// Everything a backend needs to insert a new bulk operation row. The engine
    +/// resolves the table, enumerates the input files, and computes the token
    +/// hash and fingerprint before calling storage.
    +pub struct NewBulkOperation {
    +    pub operation_id: String,
    +    pub table_id: String,
    +    pub table_name: String,
    +    pub table_arn: String,
    +    pub client_token: Option,
    +    pub client_token_hash: Option, // sha256 hex of the token
    +    pub request_fingerprint: String,
    +    pub input_config: Value,
    +    pub output_config: Value,
    +    pub input_files: Vec,
    +    pub max_write_request_units: Option,
    +    pub performance_tier: Option,
    +    pub start_time_ms: i64,
    +    /// The enumerated input total, or `None` when the operation never
    +    /// reached enumeration (every initial failure is decided before the
    +    /// walk). Stored as NULL and rendered as an absent `TotalInputBytes`,
    +    /// the shape the hosted service answers for a failure born before the
    +    /// input was read (measured on the service: a missing input bucket and an
    +    /// input owner mismatch both omit the field, counters at zero).
    +    pub total_input_bytes: Option,
    +    /// A failure the engine decided before any row was read, such as a
    +    /// named bucket directory that does not exist. When set, the start
    +    /// transaction inserts the row already terminal: status `FAILED`, the
    +    /// code and message, `end_time_ms` equal to `start_time_ms`, zero
    +    /// counters, no lease. The token hash and fingerprint are recorded as
    +    /// usual so the idempotency lookup finds the row. The per-table guard
    +    /// is consulted as for every start: while the table has an operation
    +    /// `IN_PROGRESS` or `CANCELLING`, the start answers
    +    /// `StartOutcome::TableBusy` and nothing is inserted (measured on the
    +    /// hosted service: a Start naming an input bucket that does not exist,
    +    /// on a table with an operation running, answered the busy message and
    +    /// created nothing). The account limit is not consulted: it binds on
    +    /// non-terminal rows only, and this row is terminal from its first
    +    /// instant, so it never makes the table busy for a later start either.
    +    pub initial_failure: Option,
    +    /// What the engine records at Start for the operation's runner: the
    +    /// store the operation started on and the caller it acts for. Opaque to
    +    /// storage, which stores it as given (NULL when `None`) and hands it
    +    /// back on every read; no wire response renders it.
    +    pub run_context: Option,
    +}
    +
    +/// The failure code and message of an operation inserted already terminal.
    +pub struct InitialFailure {
    +    pub code: String,
    +    pub message: String,
    +    /// The ceiling on terminal-born rows for this account in the trailing
    +    /// window, and the window start in epoch milliseconds. The start
    +    /// transaction counts terminal-born rows at or after `window_start_ms`
    +    /// under the per-account lock it already holds and refuses the insert
    +    /// with `StartOutcome::FailedStartLimit` at or above `cap`, so the
    +    /// bound is exact under a concurrent burst rather than a pre-check that
    +    /// several requests can race past. The count is read after the
    +    /// per-table guard, so a busy table answers `TableBusy` first.
    +    pub cap: u64,
    +    pub window_start_ms: i64,
    +}
    +
    +/// One input file, as a bucket-relative path plus its size in bytes, and
    +/// what the runner learned about it once it read the file to its end or
    +/// abandoned it.
    +#[derive(Clone, Debug, Default)]
    +pub struct InputFile {
    +    pub path: String,
    +    pub size: i64,
    +    /// Physical lines the file held, recorded by the checkpoint that moved
    +    /// the cursor past the file. Absent until then, and absent for a file
    +    /// the runner abandoned. The manifest reads it instead of counting the
    +    /// file again, so a resume never re-reads a finished file.
    +    pub rows: Option,
    +    /// The file's entity tag. Start records the tag the listing gave, when
    +    /// it gave one; the checkpoint that moves the cursor past a finished
    +    /// file records the lowercase hex MD5 of the file's first `size` bytes,
    +    /// computed while the runner read them. The manifest asks the store
    +    /// for a tag when it is absent.
    +    pub etag: Option,
    +    /// How the runner abandoned the file, recorded by the checkpoint that
    +    /// moved the cursor past it. Absent for a file read to its end and for
    +    /// a file the cursor has not passed.
    +    pub abandon: Option,
    +}
    +
    +/// What one checkpoint records about an input file read to its end.
    +#[derive(Clone, Debug)]
    +pub struct FinishedFile {
    +    /// Index into the operation's `input_files`.
    +    pub index: usize,
    +    pub rows: u64,
    +    /// `None` when the reader did not reach the file's end through the
    +    /// hashing path (an abandoned file is never finished this way).
    +    pub etag: Option,
    +}
    +
    +/// A bulk operation row as stored, everything the wire surface and the
    +/// runner need.
    +#[derive(Clone, Debug)]
    +pub struct BulkOperationRecord {
    +    pub account_id: String,
    +    pub operation_id: String,
    +    pub table_id: String,
    +    pub table_name: String,
    +    pub table_arn: String,
    +    pub status: String, // one of the six wire statuses
    +    pub client_token: Option,
    +    pub request_fingerprint: String,
    +    pub input_config: Value,
    +    pub output_config: Value,
    +    pub input_files: Vec,
    +    pub max_write_request_units: Option,
    +    pub performance_tier: Option,
    +    pub start_time_ms: i64,
    +    pub end_time_ms: Option,
    +    pub processed_item_count: i64,
    +    pub failed_item_count: i64,
    +    pub processed_input_bytes: i64,
    +    /// `None` when the input was never enumerated (a failure born before the
    +    /// walk); see [`NewBulkOperation::total_input_bytes`].
    +    pub total_input_bytes: Option,
    +    /// Report records kept so far, against `max_bulk_report_records`. Set
    +    /// absolutely by each checkpoint, so a resumed runner continues from the
    +    /// persisted total without reading the records back.
    +    pub report_record_count: u64,
    +    /// Serialized bytes of the kept records, against `max_bulk_report_bytes`.
    +    /// Set absolutely by each checkpoint, like the count.
    +    pub report_bytes: u64,
    +    pub usage: Value,
    +    pub failure_code: Option,
    +    pub failure_message: Option,
    +    pub cancel_requested: bool,
    +    pub cursor_file_index: i64,
    +    pub cursor_row_index: i64,
    +    pub lease_owner: Option,
    +    pub lease_until_ms: Option,
    +    /// The value Start recorded; see [`NewBulkOperation::run_context`].
    +    /// `None` for a row written without one.
    +    pub run_context: Option,
    +}
    +
    +/// Outcome of the start transaction. Callers destructure this once and drop
    +/// it, so the record rides in the variant rather than behind a Box.
    +#[allow(clippy::large_enum_variant)]
    +pub enum StartOutcome {
    +    Created(BulkOperationRecord),
    +    IdempotentReplay(BulkOperationRecord),
    +    IdempotentMismatch,
    +    TableBusy,
    +    AccountLimitReached,
    +    /// A terminal-born start refused because the account is at or above
    +    /// its cap of terminal-born rows in the trailing window, counted under
    +    /// the per-account lock inside the start transaction.
    +    FailedStartLimit,
    +    /// The table has a secondary or vector index that is not `ACTIVE`, or
    +    /// an index created while the start waited for its transaction. Read
    +    /// inside the start transaction, under a lock an index creation also
    +    /// takes, so a start and an index creation that cross are never both
    +    /// accepted.
    +    IndexOperationInProgress,
    +}
    +
    +/// The text an index creation is refused with while a bulk operation on
    +/// the table is `IN_PROGRESS` or `CANCELLING` (measured on the hosted
    +/// service, `ResourceInUseException`). The engine's check before the
    +/// backend call and the backends' check inside the `UpdateTable`
    +/// transaction answer with this one text.
    +#[must_use]
    +pub fn index_creation_refused_message(table_name: &str) -> String {
    +    format!(
    +        "Attempt to change a resource which is still in use: Index creation is not \
    +         allowed while table has ongoing bulk operation: {table_name}"
    +    )
    +}
    +
    +/// The text a start on a table being deleted is refused with (measured on
    +/// the hosted service, `ValidationException`, immediately after
    +/// `DeleteTable` and three seconds later; the table is not named). The
    +/// start handler answers it at its table lookup, and the start transaction
    +/// answers it as [`StorageError::Validation`] for a table set `DELETING`
    +/// after that lookup.
    +pub const TABLE_DELETING_MESSAGE: &str = "Invalid Request: Table not found";
    +
    +pub enum CancelOutcome {
    +    /// Row was `IN_PROGRESS` or CANCELLING; it is now CANCELLING.
    +    Cancelling,
    +    /// Row is terminal; carries its status for the `ValidationException` text.
    +    AlreadyTerminal(String),
    +    NotFound,
    +}
    +
    +/// Whether the caller still held the lease when its conditional write ran.
    +/// `Lost` means zero rows matched the owner predicate and nothing was written.
    +#[derive(Debug)]
    +pub enum LeaseState {
    +    Held,
    +    Lost,
    +}
    +
    +/// Whether the runner still held the lease when `finish` ran, and the
    +/// terminal status the row now stores when it did. The stored status can
    +/// differ from the one the runner computed: a CANCELLING row lands
    +/// CANCELLED whatever the outcome says, so a caller that reports the
    +/// terminal status must use this value, not the one it sent.
    +pub enum FinishState {
    +    /// The transition ran; carries the stored terminal status.
    +    Held(String),
    +    /// Zero rows matched the owner predicate and nothing was written.
    +    Lost,
    +}
    +
    +/// Which report file a record belongs to: the error report or the
    +/// condition-check-failure report.
    +#[derive(Clone, Copy, PartialEq, Eq, Debug)]
    +pub enum ReportKind {
    +    Error,
    +    ConditionCheckFailure,
    +}
    +
    +impl ReportKind {
    +    /// The stored vocabulary for this kind, shared by every backend so the
    +    /// column text never diverges.
    +    #[must_use]
    +    pub fn as_str(self) -> &'static str {
    +        match self {
    +            Self::Error => "error",
    +            Self::ConditionCheckFailure => "condition_check_failure",
    +        }
    +    }
    +
    +    /// Parse the stored vocabulary back; `None` for anything else.
    +    #[must_use]
    +    pub fn parse(s: &str) -> Option {
    +        match s {
    +            "error" => Some(Self::Error),
    +            "condition_check_failure" => Some(Self::ConditionCheckFailure),
    +            _ => None,
    +        }
    +    }
    +}
    +
    +/// One report record produced by the runner for a failed or condition-failed
    +/// row, persisted with the checkpoint that covers the row.
    +pub struct ReportRecord {
    +    pub kind: ReportKind,
    +    pub file_index: i64,
    +    pub row_index: i64,
    +    pub record: Value,
    +}
    +
    +/// One checkpoint's worth of progress, applied atomically. Every figure is
    +/// absolute, the report totals included; the runner owns accumulation.
    +pub struct Progress {
    +    pub cursor_file_index: i64,
    +    pub cursor_row_index: i64,
    +    pub processed_item_count: i64,
    +    pub failed_item_count: i64,
    +    pub processed_input_bytes: i64,
    +    /// Report records kept so far, stored on the operation record.
    +    pub report_record_count: u64,
    +    /// Serialized bytes of the kept records, stored on the operation record.
    +    pub report_bytes: u64,
    +    pub usage: Value,
    +    pub records: Vec,
    +    /// Input files this checkpoint moves the cursor past, with their row
    +    /// count and entity tag. Stored on the matching `input_files` entries
    +    /// in the same transaction as the cursor, so a committed cursor past a
    +    /// file always has that file's figures beside it.
    +    pub finished_files: Vec,
    +    /// Abandoned input files this checkpoint moves the cursor past, with
    +    /// the row and the kind of the abandon. Stored on the matching
    +    /// `input_files` entries in the same transaction as the cursor.
    +    pub abandoned_files: Vec,
    +}
    +
    +/// `FailureCode` a cancelled operation carries (measured on the service:
    +/// `CANCELLED` descriptions carry this code and
    +/// [`CANCELLED_FAILURE_MESSAGE`]). Both backends store the pair whenever
    +/// `finish` turns a `CANCELLING` row into `CANCELLED`, whatever the runner
    +/// computed, and when a claim ends a cancelled row it cannot decode.
    +pub const CANCELLED_FAILURE_CODE: &str = "RequestCancelled";
    +/// `FailureMessage` a cancelled operation carries (measured on the service).
    +pub const CANCELLED_FAILURE_MESSAGE: &str = "The request was cancelled";
    +
    +/// `FailureCode` a claim stores on a runnable row it cannot decode and
    +/// whose cancel was not requested. The row cannot be run, so the claim
    +/// ends it in the same transaction instead of leasing it, and the other
    +/// rows of the claim are returned.
    +pub const UNREADABLE_FAILURE_CODE: &str = "InternalServerError";
    +/// `FailureMessage` stored with [`UNREADABLE_FAILURE_CODE`]: the message
    +/// every internal fault carries on the wire. The decode error goes to the
    +/// log, never to a client.
    +pub const UNREADABLE_FAILURE_MESSAGE: &str = "Internal server error";
    +
    +/// How long a `ClientToken` replays the operation it started, in
    +/// milliseconds: eight hours, the window the hosted service documents.
    +/// Measured on the service: the same request
    +/// replays within seconds of its start and creates a new operation when
    +/// re-sent 25 hours later. A start whose `start_time_ms` is at or past
    +/// `existing.start_time_ms + CLIENT_TOKEN_WINDOW_MS` treats the token as
    +/// unseen: the aged record's token hash is released so the per-account
    +/// unique index admits the new row, and the record keeps its `ClientToken`
    +/// for Describe.
    +pub const CLIENT_TOKEN_WINDOW_MS: i64 = 8 * 60 * 60 * 1000;
    +
    +/// The terminal transition the runner requests when a run ends.
    +pub struct Terminal {
    +    pub status: String, // COMPLETED | COMPLETED_WITH_ERRORS | FAILED | CANCELLED
    +    pub end_time_ms: i64,
    +    pub failure_code: Option,
    +    pub failure_message: Option,
    +    pub usage: Value,
    +}
    +
    +#[derive(Clone, Debug, PartialEq, Eq)]
    +pub struct ListCursor {
    +    pub start_time_ms: i64,
    +    pub operation_id: String,
    +}
    +
    +/// Result of a list call: one page of records, newest first, plus the cursor
    +/// for the next page when more rows exist.
    +pub type BulkOperationListResult =
    +    Result<(Vec, Option), StorageError>;
    +
    +pub trait BulkOperationStore: Send + Sync {
    +    /// The start transaction. Besides the token, the per-table guard and
    +    /// the account caps, it answers
    +    /// [`StartOutcome::IndexOperationInProgress`] when an index on the table
    +    /// is not `ACTIVE`, or was created between the call and the
    +    /// transaction, read under a lock that `UpdateTable`'s index creation
    +    /// also takes. That creation, in turn, is refused with
    +    /// [`index_creation_refused_message`] while the table has a
    +    /// non-terminal operation, read inside its own transaction.
    +    ///
    +    /// A table whose stored status is `DELETING` when the transaction locks
    +    /// its row is refused with `StorageError::Validation` carrying
    +    /// [`TABLE_DELETING_MESSAGE`], before the other checks the transaction
    +    /// makes and before anything is inserted: the start handler gives that
    +    /// answer at its table lookup, and the table can be set `DELETING`
    +    /// after it. A table row that is absent is not refused here.
    +    fn start_bulk_operation(
    +        &self,
    +        account_id: &str,
    +        req: NewBulkOperation,
    +        account_limit: u32,
    +    ) -> BoxFuture<'_, Result>;
    +
    +    fn get_bulk_operation(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +    ) -> BoxFuture<'_, Result, StorageError>>;
    +
    +    /// The batch boundary read, [`crate::OperationFlags`]; the default reads the full record.
    +    fn get_bulk_operation_flags(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        crate::OperationFlags::of_read(self.get_bulk_operation(account_id, operation_id))
    +    }
    +
    +    /// The account's non-terminal (`IN_PROGRESS` or CANCELLING) operation
    +    /// count, read outside any transaction. The start transaction keeps
    +    /// its own exact check under its locks; this read lets the start
    +    /// handler refuse a saturated account before input enumeration
    +    /// runs.
    +    fn count_non_terminal(&self, account_id: &str) -> BoxFuture<'_, Result>;
    +
    +    /// The non-terminal operations bound to one table of the account, by
    +    /// table id, read outside any transaction. `UpdateTable` refuses an
    +    /// index creation while this is above zero (measured on the hosted
    +    /// service). Keyed by id, not name, so an operation still running on a
    +    /// deleted table does not count against a new table of the same name.
    +    /// Backends without bulk operations have none, so the default answers
    +    /// zero; the two backends that run them override it, and the contract
    +    /// suite holds them to it.
    +    fn count_non_terminal_for_table(
    +        &self,
    +        account_id: &str,
    +        table_id: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        let _ = (account_id, table_id);
    +        Box::pin(async { Ok(0) })
    +    }
    +
    +    /// The account's terminal-born rows younger than `since_ms`: rows with
    +    /// status `FAILED`, `end_time_ms` equal to `start_time_ms`, and
    +    /// `start_time_ms` at or after `since_ms`. Read outside any
    +    /// transaction; the start handler uses it to cap how many terminal
    +    /// rows an account can insert per window, so concurrent requests can
    +    /// overshoot the cap by the number in flight.
    +    fn count_terminal_born_since(
    +        &self,
    +        account_id: &str,
    +        since_ms: i64,
    +    ) -> BoxFuture<'_, Result>;
    +
    +    /// Newest first by (`start_time_ms` DESC, `operation_id` DESC); `after` is
    +    /// exclusive. `limit` must be at least 1; 0 is refused with
    +    /// `StorageError::Validation` rather than answered as an empty page,
    +    /// which would be indistinguishable from an account with no operations.
    +    /// A row that does not decode is logged at ERROR with its account,
    +    /// operation id and the decode error and left out of the page; the
    +    /// cursor still moves past it, so a page can hold fewer than `limit`
    +    /// records and still carry a cursor.
    +    fn list_bulk_operations(
    +        &self,
    +        account_id: &str,
    +        limit: u32,
    +        after: Option,
    +    ) -> BoxFuture<'_, BulkOperationListResult>;
    +
    +    fn request_cancel(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +    ) -> BoxFuture<'_, Result>;
    +
    +    /// Claims up to `max` rows with status `IN_PROGRESS` or CANCELLING whose
    +    /// lease is null or expired, oldest start first, setting `lease_owner`
    +    /// and `lease_until_ms`. Rows whose account is in `exclude_accounts` and
    +    /// rows whose operation id is in `exclude_operations` are skipped; an
    +    /// empty list is no filter. The worker passes the accounts already
    +    /// holding their per-account share of its slots and the operation ids
    +    /// already running in its process, so a run that outlives its lease is
    +    /// never claimed a second time by the process still running it.
    +    ///
    +    /// Each claimed row is decoded on its own. A row that does not decode is
    +    /// logged at ERROR with its account, operation id and the decode error,
    +    /// left out of the result, and ended in the claim's own transaction by
    +    /// the rule `finish` follows: a row that is `CANCELLING` or has
    +    /// `cancel_requested` set ends `CANCELLED` with
    +    /// [`CANCELLED_FAILURE_CODE`] and [`CANCELLED_FAILURE_MESSAGE`], and any
    +    /// other ends `FAILED` with [`UNREADABLE_FAILURE_CODE`] and
    +    /// [`UNREADABLE_FAILURE_MESSAGE`]; `end_time_ms` now, lease cleared, and
    +    /// its report records deleted in the same transaction, as `finish`
    +    /// deletes them. The
    +    /// other rows are returned with their lease, so one such row never stops
    +    /// the rest from being claimed.
    +    fn claim_runnable(
    +        &self,
    +        owner: &str,
    +        lease: Duration,
    +        max: usize,
    +        exclude_accounts: &[String],
    +        exclude_operations: &[String],
    +    ) -> BoxFuture<'_, Result, StorageError>>;
    +
    +    /// One transaction: cursor, counters, usage, report records, lease renewal.
    +    /// Conditional on `lease_owner` = owner; zero rows updated is Lost.
    +    fn checkpoint(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        lease: Duration,
    +        progress: Progress,
    +    ) -> BoxFuture<'_, Result>;
    +
    +    /// One page of report records of one kind, ordered `(file_index,
    +    /// row_index)` ascending, strictly after `after`. `limit` must be at
    +    /// least 1; 0 is refused with `StorageError::Validation`, because an
    +    /// empty page would read as the end of the report.
    +    fn report_records_page(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        kind: ReportKind,
    +        after: Option<(i64, i64)>,
    +        limit: usize,
    +    ) -> BoxFuture<'_, Result, StorageError>>;
    +
    +    /// Conditional terminal transition: status IN `('IN_PROGRESS','CANCELLING')`
    +    /// AND `lease_owner` = owner. A CANCELLING row becomes CANCELLED whatever
    +    /// `outcome.status` says. Clears the lease. Deletes the operation's report
    +    /// records before it returns (on SQLite after the transition, in chunks).
    +    /// Zero rows updated is Lost; otherwise `Held` carries the status the row now stores.
    +    fn finish(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        outcome: Terminal,
    +    ) -> BoxFuture<'_, Result>;
    +
    +    /// The `(account_id, operation_id)` pair of every terminal row with
    +    /// `end_time_ms` before the cutoff, up to `limit` rows: the rows
    +    /// `purge_operations` may delete. Reads only. `limit` bounds the pass
    +    /// so the retention worker can drain a large backlog in separately
    +    /// committed chunks rather than one unbounded transaction. The
    +    /// retention worker deletes each listed pair's row-outcome ledger rows
    +    /// (which live in the data database, out of this store's reach) and
    +    /// then purges only the pairs whose ledger delete succeeded, so ledger
    +    /// rows die while their operation record still exists and a crash
    +    /// between the two steps re-lists the same pairs on the next pass.
    +    fn list_purgeable_before(
    +        &self,
    +        cutoff: SystemTime,
    +        limit: usize,
    +    ) -> BoxFuture<'_, Result, StorageError>>;
    +
    +    /// Deletes exactly the named operations' records and report records.
    +    /// Only terminal rows are deleted, and a pair that no longer names a
    +    /// row deletes nothing, so the call is idempotent across retries.
    +    fn purge_operations(
    +        &self,
    +        pairs: &[(String, String)],
    +    ) -> BoxFuture<'_, Result<(), StorageError>>;
    +}
    diff --git a/crates/storage/src/bulk_operation_store/abandon.rs b/crates/storage/src/bulk_operation_store/abandon.rs
    new file mode 100644
    index 000000000..0427100a5
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store/abandon.rs
    @@ -0,0 +1,86 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! How the runner's abandon of an input file is recorded.
    +
    +use serde_json::Value;
    +
    +/// Why the runner stopped reading an input file before its end.
    +#[derive(Clone, Copy, Debug, PartialEq, Eq)]
    +pub enum AbandonKind {
    +    /// A line, or a compressed stream's header, the runner could not use.
    +    Content,
    +    /// The file was not a compressed object under the requested codec. The
    +    /// operation ends `FAILED` once the other files are read.
    +    NotCompressed,
    +    /// The object at the key was replaced after the operation listed it.
    +    /// The operation ends `FAILED` once the other files are read.
    +    Overwritten,
    +}
    +
    +impl AbandonKind {
    +    /// The stored vocabulary for this kind, shared by every backend.
    +    #[must_use]
    +    pub fn as_str(self) -> &'static str {
    +        match self {
    +            Self::Content => "content",
    +            Self::NotCompressed => "not_compressed",
    +            Self::Overwritten => "overwritten",
    +        }
    +    }
    +
    +    /// Parse the stored vocabulary back; `None` for anything else.
    +    #[must_use]
    +    pub fn parse(s: &str) -> Option {
    +        match s {
    +            "content" => Some(Self::Content),
    +            "not_compressed" => Some(Self::NotCompressed),
    +            "overwritten" => Some(Self::Overwritten),
    +            _ => None,
    +        }
    +    }
    +}
    +
    +/// An abandoned input file as its `input_files` entry keeps it.
    +#[derive(Clone, Copy, Debug, PartialEq, Eq)]
    +pub struct FileAbandon {
    +    /// Row index of the abandon. The file's processed range ends before it.
    +    pub row: i64,
    +    pub kind: AbandonKind,
    +    /// Bytes the file contributed to `ProcessedInputBytes`; `None` on an
    +    /// entry written before the figure was recorded.
    +    pub bytes: Option,
    +}
    +
    +impl FileAbandon {
    +    /// The stored form, the `abandon` member of an `input_files` element,
    +    /// shared by every backend.
    +    #[must_use]
    +    pub fn to_json(self) -> Value {
    +        let mut stored = serde_json::json!({ "row": self.row, "kind": self.kind.as_str() });
    +        stored["bytes"] = self.bytes.map_or(Value::Null, Value::from);
    +        stored
    +    }
    +
    +    /// Parse the stored form back; `None` for anything else.
    +    #[must_use]
    +    pub fn from_json(value: &Value) -> Option {
    +        Some(Self {
    +            row: value.get("row")?.as_i64()?,
    +            kind: AbandonKind::parse(value.get("kind")?.as_str()?)?,
    +            bytes: value.get("bytes").and_then(Value::as_i64),
    +        })
    +    }
    +}
    +
    +/// What one checkpoint records about an input file the runner abandoned.
    +#[derive(Clone, Copy, Debug)]
    +pub struct AbandonedFile {
    +    /// Index into the operation's `input_files`.
    +    pub index: usize,
    +    /// Row index of the abandon.
    +    pub row: u64,
    +    pub kind: AbandonKind,
    +    /// Bytes the file contributed to `ProcessedInputBytes`.
    +    pub bytes: u64,
    +}
    diff --git a/crates/storage/src/bulk_operation_store_contract/batch.rs b/crates/storage/src/bulk_operation_store_contract/batch.rs
    new file mode 100644
    index 000000000..628af6b85
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store_contract/batch.rs
    @@ -0,0 +1,328 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Batched writes through put_items_batch: ordering, failure, and rollback.
    +
    +use super::*;
    +
    +fn batch_plain(pk: &str, v: &str) -> BatchRow {
    +    BatchRow {
    +        item: item(pk, v),
    +        condition: None,
    +        marker: None,
    +        return_old: false,
    +    }
    +}
    +
    +fn batch_conditional(pk: &str, v: &str, condition: &str, m: BulkRowMarker) -> BatchRow {
    +    let tokens = expression::tokenize(condition).expect("tokenize");
    +    let expr = expression::parse_condition(&tokens).expect("parse the condition");
    +    BatchRow {
    +        item: item(pk, v),
    +        condition: Some((expr, ExpressionMaps::new(HashMap::new(), HashMap::new()))),
    +        marker: Some(m),
    +        return_old: false,
    +    }
    +}
    +
    +async fn stored_v(
    +    fx: &Fixture,
    +    ki: &extenddb_core::types::TableKeyInfo,
    +    pk: &str,
    +) -> Option {
    +    fx.engine
    +        .get_item(
    +            ki,
    +            &Item::from([("pk".to_owned(), AttributeValue::S(pk.to_owned()))]),
    +        )
    +        .await
    +        .expect("get_item")
    +        .and_then(|stored| match stored.get("v") {
    +            Some(AttributeValue::S(v)) => Some(v.clone()),
    +            _ => None,
    +        })
    +}
    +
    +/// One batch applies every row and answers one result per row in input
    +/// order. A failed condition on one row is its own result: the rows before
    +/// and after it still land, the failure's ledger row says `ConditionFailed`,
    +/// the passing condition row's says `Applied`, and plain rows sent without
    +/// a marker leave no ledger row.
    +pub async fn a_batch_applies_every_row_and_reports_each_outcome_in_order(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "batch_outcomes").await;
    +    let ki = key_info(fx, &table.table_name).await;
    +    let maps = ExpressionMaps::new(HashMap::new(), HashMap::new());
    +    fx.engine
    +        .put_item(&ki, item("a", "before"), false, None, &maps, None, None)
    +        .await
    +        .expect("seed the target item");
    +    take_over(fx, "op-batch").await;
    +
    +    let results = fx
    +        .engine
    +        .put_items_batch(
    +            &ki,
    +            vec![
    +                batch_plain("b", "1"),
    +                batch_conditional(
    +                    "a",
    +                    "after",
    +                    "attribute_not_exists(pk)",
    +                    marker(&fx.account_a, "op-batch", 1),
    +                ),
    +                batch_conditional(
    +                    "c",
    +                    "1",
    +                    "attribute_not_exists(pk)",
    +                    marker(&fx.account_a, "op-batch", 2),
    +                ),
    +                batch_plain("d", "1"),
    +            ],
    +            None,
    +        )
    +        .await
    +        .expect("the batch applies");
    +
    +    assert_eq!(results.len(), 4, "one result per row");
    +    assert_eq!(results[0], BatchRowResult::Written { old: None });
    +    match &results[1] {
    +        BatchRowResult::ConditionFailed { old: Some(old) } => assert_eq!(
    +            old.get("v"),
    +            Some(&AttributeValue::S("before".to_owned())),
    +            "the failure carries the image the condition was evaluated against"
    +        ),
    +        other => panic!("row 1 must be a condition failure with its old image, got {other:?}"),
    +    }
    +    assert_eq!(results[2], BatchRowResult::Written { old: None });
    +    assert_eq!(results[3], BatchRowResult::Written { old: None });
    +
    +    assert_eq!(stored_v(fx, &ki, "a").await.as_deref(), Some("before"));
    +    assert_eq!(stored_v(fx, &ki, "b").await.as_deref(), Some("1"));
    +    assert_eq!(stored_v(fx, &ki, "c").await.as_deref(), Some("1"));
    +    assert_eq!(stored_v(fx, &ki, "d").await.as_deref(), Some("1"));
    +
    +    let outcome = |row: i64| {
    +        fx.engine
    +            .bulk_row_outcome(&fx.account_a, "op-batch", 0, row)
    +    };
    +    assert_eq!(
    +        outcome(1).await.expect("ledger"),
    +        Some(RowOutcome::ConditionFailed),
    +        "the failed condition is recorded in the batch's transaction"
    +    );
    +    assert_eq!(
    +        outcome(2).await.expect("ledger"),
    +        Some(RowOutcome::Applied),
    +        "the passing condition row is recorded with its write"
    +    );
    +    assert_eq!(
    +        outcome(0).await.expect("ledger"),
    +        None,
    +        "plain rows carry no marker"
    +    );
    +    assert_eq!(
    +        outcome(3).await.expect("ledger"),
    +        None,
    +        "plain rows carry no marker"
    +    );
    +}
    +
    +/// Two rows on one key inside a batch behave as two sequential puts: the
    +/// second sees the first's write, so a condition on the key's existence
    +/// passes, and the replaced image it reports is the first row's.
    +pub async fn a_batch_applies_two_rows_on_one_key_in_order(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "batch_same_key").await;
    +    let ki = key_info(fx, &table.table_name).await;
    +    take_over(fx, "op-same").await;
    +
    +    let mut second = batch_conditional(
    +        "a",
    +        "second",
    +        "attribute_exists(pk)",
    +        marker(&fx.account_a, "op-same", 1),
    +    );
    +    second.return_old = true;
    +    let results = fx
    +        .engine
    +        .put_items_batch(&ki, vec![batch_plain("a", "first"), second], None)
    +        .await
    +        .expect("the batch applies");
    +
    +    assert_eq!(results[0], BatchRowResult::Written { old: None });
    +    match &results[1] {
    +        BatchRowResult::Written { old: Some(old) } => assert_eq!(
    +            old.get("v"),
    +            Some(&AttributeValue::S("first".to_owned())),
    +            "the second row replaced the first row's image"
    +        ),
    +        other => panic!("row 1 must be written over the first row, got {other:?}"),
    +    }
    +    assert_eq!(stored_v(fx, &ki, "a").await.as_deref(), Some("second"));
    +}
    +
    +/// An error on one row (here a row with no partition key) fails the whole
    +/// batch, and nothing from the batch is visible afterwards: the rows before
    +/// the bad one were in the same transaction and rolled back with it.
    +pub async fn a_batch_error_leaves_nothing_written(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "batch_rollback").await;
    +    let ki = key_info(fx, &table.table_name).await;
    +    take_over(fx, "op-rollback").await;
    +
    +    let no_key = BatchRow {
    +        item: Item::from([("v".to_owned(), AttributeValue::S("orphan".to_owned()))]),
    +        condition: None,
    +        marker: None,
    +        return_old: false,
    +    };
    +    let result = fx
    +        .engine
    +        .put_items_batch(
    +            &ki,
    +            vec![
    +                batch_plain("a", "1"),
    +                batch_conditional(
    +                    "c",
    +                    "1",
    +                    "attribute_not_exists(pk)",
    +                    marker(&fx.account_a, "op-rollback", 1),
    +                ),
    +                no_key,
    +                batch_plain("b", "1"),
    +            ],
    +            None,
    +        )
    +        .await;
    +
    +    assert!(result.is_err(), "a row without its key fails the batch");
    +    assert_eq!(
    +        stored_v(fx, &ki, "a").await,
    +        None,
    +        "the row before the error rolled back"
    +    );
    +    assert_eq!(
    +        stored_v(fx, &ki, "c").await,
    +        None,
    +        "the condition row rolled back"
    +    );
    +    assert_eq!(
    +        stored_v(fx, &ki, "b").await,
    +        None,
    +        "the row after the error never ran"
    +    );
    +    assert_eq!(
    +        fx.engine
    +            .bulk_row_outcome(&fx.account_a, "op-rollback", 0, 1)
    +            .await
    +            .expect("ledger"),
    +        None,
    +        "the marker rolled back with the write it rode"
    +    );
    +}
    +
    +/// An empty batch answers an empty result and touches nothing.
    +pub async fn an_empty_batch_is_a_no_op(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "batch_empty").await;
    +    let ki = key_info(fx, &table.table_name).await;
    +    let results = fx
    +        .engine
    +        .put_items_batch(&ki, Vec::new(), None)
    +        .await
    +        .expect("an empty batch applies");
    +    assert!(results.is_empty());
    +}
    +
    +/// Two operations writing the same keys in opposite orders, each as one
    +/// batch, both complete. A batch takes its row locks in input order, so two
    +/// concurrent batches can wait on each other's locks; the backend must
    +/// resolve that (PostgreSQL aborts one transaction and the batch is
    +/// retried; SQLite serialises writers before the transaction opens) rather
    +/// than surface it as a failed operation. Repeated so the interleaving is
    +/// reached.
    +pub async fn opposite_order_batches_on_one_key_set_both_complete(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "batch_deadlock").await;
    +    let ki = Arc::new(key_info(fx, &table.table_name).await);
    +    let keys: Vec = (0..60).map(|i| format!("k{i:03}")).collect();
    +    for round in 0..4 {
    +        let forward: Vec = keys
    +            .iter()
    +            .map(|k| batch_plain(k, &format!("f{round}")))
    +            .collect();
    +        let backward: Vec = keys
    +            .iter()
    +            .rev()
    +            .map(|k| batch_plain(k, &format!("b{round}")))
    +            .collect();
    +        let (engine_a, ki_a) = (Arc::clone(&fx.engine), Arc::clone(&ki));
    +        let (engine_b, ki_b) = (Arc::clone(&fx.engine), Arc::clone(&ki));
    +        let a = tokio::spawn(async move { engine_a.put_items_batch(&ki_a, forward, None).await });
    +        let b = tokio::spawn(async move { engine_b.put_items_batch(&ki_b, backward, None).await });
    +        let (ra, rb) = (a.await.expect("task a"), b.await.expect("task b"));
    +        assert!(
    +            ra.is_ok() && rb.is_ok(),
    +            "round {round}: both opposite-order batches must complete, got a={ra:?} b={rb:?}"
    +        );
    +        assert_eq!(ra.expect("a").len(), 60);
    +        assert_eq!(rb.expect("b").len(), 60);
    +    }
    +    // Every key holds one of the two last-round values: both batches landed.
    +    for k in &keys {
    +        let v = stored_v(fx, &ki, k).await.expect("every key written");
    +        assert!(v == "f3" || v == "b3", "key {k} holds {v}");
    +    }
    +}
    +
    +/// An overwrite inside a batch reports the replaced item whenever the row
    +/// asks for it, on a table with no index as well, so the runner can charge
    +/// the table the larger of the old and new item sizes on every overwrite
    +/// without a second read. An insert reports no old image.
    +pub async fn a_batch_overwrite_reports_the_replaced_item_for_sizing(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "batch_overwrite_size").await;
    +    let ki = key_info(fx, &table.table_name).await;
    +    let maps = ExpressionMaps::new(HashMap::new(), HashMap::new());
    +    fx.engine
    +        .put_item(&ki, item("a", "old"), false, None, &maps, None, None)
    +        .await
    +        .expect("seed the target item");
    +
    +    let mut overwrite = batch_plain("a", "new");
    +    overwrite.return_old = true;
    +    let mut insert = batch_plain("z", "new");
    +    insert.return_old = true;
    +    let results = fx
    +        .engine
    +        .put_items_batch(&ki, vec![overwrite, insert], None)
    +        .await
    +        .expect("the batch applies");
    +
    +    match &results[0] {
    +        BatchRowResult::Written { old: Some(old) } => assert_eq!(
    +            old.get("v"),
    +            Some(&AttributeValue::S("old".to_owned())),
    +            "the overwrite reports the replaced item for sizing"
    +        ),
    +        other => panic!("row 0 must report the replaced image, got {other:?}"),
    +    }
    +    assert_eq!(
    +        results[1],
    +        BatchRowResult::Written { old: None },
    +        "an insert reports no old image"
    +    );
    +}
    +
    +/// A table and a two-row plain batch for a backend's own fault cases: the
    +/// `SQLite` suite holds the database's write lock from a second connection
    +/// while the batch runs, a fault the shared contract cannot induce.
    +pub async fn plain_batch_on_a_new_table(
    +    fx: &Fixture,
    +    name: &str,
    +) -> (extenddb_core::types::TableKeyInfo, Vec) {
    +    let table = create_table(fx, &fx.account_a, name).await;
    +    let ki = key_info(fx, &table.table_name).await;
    +    (ki, vec![batch_plain("a", "1"), batch_plain("b", "1")])
    +}
    diff --git a/crates/storage/src/bulk_operation_store_contract/checkpoint.rs b/crates/storage/src/bulk_operation_store_contract/checkpoint.rs
    new file mode 100644
    index 000000000..944675a30
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store_contract/checkpoint.rs
    @@ -0,0 +1,513 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Checkpoint persistence and integrity: progress, counters, usage, records, and
    +//! finished-file entries.
    +
    +use super::*;
    +
    +/// A checkpoint from the lease owner persists the cursor, the counters, the
    +/// usage, and the report records (filtered by kind on read), and renews the
    +/// lease.
    +pub async fn checkpoint_persists_progress_counters_usage_and_records(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "checkpoint_persist").await;
    +    let owner = "owner-1";
    +    // A short first lease, then a long renewal: the renewal is what keeps the
    +    // second owner out past the first lease's expiry.
    +    let record = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table, None, "fp", 1000),
    +            50,
    +        )
    +        .await,
    +    );
    +    let claimed = fx
    +        .engine
    +        .claim_runnable(owner, Duration::from_millis(500), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert!(
    +        claimed
    +            .iter()
    +            .any(|r| r.operation_id == record.operation_id)
    +    );
    +    let id = record.operation_id;
    +
    +    let condition_record = ReportRecord {
    +        kind: ReportKind::ConditionCheckFailure,
    +        file_index: 0,
    +        row_index: 7,
    +        record: json!({"itemIndex": 7, "error": {"ErrorCode": "ConditionalCheckFailed"}}),
    +    };
    +    let state = fx
    +        .engine
    +        .checkpoint(
    +            &fx.account_a,
    +            &id,
    +            owner,
    +            Duration::from_secs(60),
    +            progress(vec![error_record(3), condition_record]),
    +        )
    +        .await
    +        .expect("checkpoint");
    +    assert!(matches!(state, LeaseState::Held));
    +
    +    let row = get(fx, &fx.account_a, &id).await.expect("row");
    +    assert_eq!(row.cursor_file_index, 0);
    +    assert_eq!(row.cursor_row_index, 25);
    +    assert_eq!(row.processed_item_count, 25);
    +    assert_eq!(row.failed_item_count, 1);
    +    assert_eq!(row.processed_input_bytes, 2500);
    +    assert_eq!(row.usage, json!({"WriteRequestUnits": 25600}));
    +    assert_eq!(
    +        row.status, "IN_PROGRESS",
    +        "a checkpoint does not end the run"
    +    );
    +
    +    let errors = all_report_records(fx, &fx.account_a, &id, ReportKind::Error).await;
    +    assert_eq!(errors.len(), 1);
    +    assert_eq!(errors[0]["itemIndex"], json!(3));
    +    let failures =
    +        all_report_records(fx, &fx.account_a, &id, ReportKind::ConditionCheckFailure).await;
    +    assert_eq!(failures.len(), 1);
    +    assert_eq!(failures[0]["itemIndex"], json!(7));
    +
    +    // Past the original 500 ms lease, the renewal from the checkpoint still
    +    // holds the row.
    +    tokio::time::sleep(Duration::from_millis(900)).await;
    +    let blocked = fx
    +        .engine
    +        .claim_runnable("owner-2", Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert!(
    +        !blocked.iter().any(|r| r.operation_id == id),
    +        "the checkpoint must have renewed the lease"
    +    );
    +}
    +
    +/// A checkpoint from a non-owner reports Lost and leaves no trace: the
    +/// cursor, the counters, and the report records move together or not at all.
    +pub async fn a_checkpoint_from_a_non_owner_is_lost_and_leaves_no_trace(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "checkpoint_lost").await;
    +    let id = started_and_claimed(fx, &table, "owner-1", 1000).await;
    +
    +    let state = fx
    +        .engine
    +        .checkpoint(
    +            &fx.account_a,
    +            &id,
    +            "impostor",
    +            Duration::from_secs(60),
    +            progress(vec![error_record(3)]),
    +        )
    +        .await
    +        .expect("checkpoint");
    +    assert!(matches!(state, LeaseState::Lost));
    +
    +    let row = get(fx, &fx.account_a, &id).await.expect("row");
    +    assert_eq!(row.cursor_row_index, 0, "the cursor must not have moved");
    +    assert_eq!(row.processed_item_count, 0);
    +    assert_eq!(row.failed_item_count, 0);
    +    assert_eq!(
    +        row.lease_owner.as_deref(),
    +        Some("owner-1"),
    +        "the lease is untouched"
    +    );
    +    let records = all_report_records(fx, &fx.account_a, &id, ReportKind::Error).await;
    +    assert!(
    +        records.is_empty(),
    +        "the refused checkpoint's records must have rolled back with it"
    +    );
    +}
    +
    +/// A checkpoint that re-inserts an existing (kind, `file_index`, `row_index`)
    +/// key succeeds and the first record wins. A record is durable exactly when
    +/// the checkpoint covering its row is, so a duplicate key can only be a
    +/// replayed row whose record already committed; keeping the first record is
    +/// the second line of defence, the same as the ledger.
    +pub async fn a_checkpoint_reinserting_an_existing_record_key_keeps_the_first_record<
    +    E: StorageEngine,
    +>(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "checkpoint_duplicate").await;
    +    let owner = "owner-1";
    +    let id = started_and_claimed(fx, &table, owner, 1000).await;
    +
    +    let record_at = |row_index: i64, marker: &str| ReportRecord {
    +        kind: ReportKind::Error,
    +        file_index: 0,
    +        row_index,
    +        record: json!({"itemIndex": row_index, "marker": marker}),
    +    };
    +    fx.engine
    +        .checkpoint(
    +            &fx.account_a,
    +            &id,
    +            owner,
    +            Duration::from_secs(60),
    +            progress(vec![record_at(3, "first")]),
    +        )
    +        .await
    +        .expect("the first checkpoint");
    +
    +    // A replayed row re-produces its record with whatever the retry computed;
    +    // the re-insert must neither fail the checkpoint nor replace the record.
    +    let state = fx
    +        .engine
    +        .checkpoint(
    +            &fx.account_a,
    +            &id,
    +            owner,
    +            Duration::from_secs(60),
    +            progress(vec![record_at(3, "second"), record_at(5, "fresh")]),
    +        )
    +        .await
    +        .expect("a checkpoint replaying an existing record key must succeed");
    +    assert!(matches!(state, LeaseState::Held));
    +
    +    let records = all_report_records(fx, &fx.account_a, &id, ReportKind::Error).await;
    +    assert_eq!(
    +        records.len(),
    +        2,
    +        "the duplicate must not add a third record"
    +    );
    +    assert_eq!(
    +        records[0]["marker"],
    +        json!("first"),
    +        "the first record wins at the replayed key"
    +    );
    +    assert_eq!(records[1]["itemIndex"], json!(5));
    +}
    +
    +/// Checkpoint stores the usage value it is given, replacing the previous
    +/// one; the runner owns accumulation, so a merge here would double-count.
    +pub async fn checkpoint_usage_replaces_the_stored_value(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "usage_replace").await;
    +    let owner = "owner-1";
    +    let id = started_and_claimed(fx, &table, owner, 1000).await;
    +
    +    for usage in [json!({"A": 1}), json!({"B": 2})] {
    +        fx.engine
    +            .checkpoint(
    +                &fx.account_a,
    +                &id,
    +                owner,
    +                Duration::from_secs(60),
    +                Progress {
    +                    cursor_file_index: 0,
    +                    cursor_row_index: 1,
    +                    processed_item_count: 1,
    +                    failed_item_count: 0,
    +                    processed_input_bytes: 100,
    +                    report_record_count: 0,
    +                    report_bytes: 0,
    +                    usage,
    +                    records: Vec::new(),
    +                    finished_files: Vec::new(),
    +                    abandoned_files: Vec::new(),
    +                },
    +            )
    +            .await
    +            .expect("checkpoint");
    +    }
    +
    +    let row = get(fx, &fx.account_a, &id).await.expect("row");
    +    assert_eq!(
    +        row.usage,
    +        json!({"B": 2}),
    +        "the stored usage is the last checkpoint's value, with no key merged in"
    +    );
    +}
    +
    +/// A checkpoint's `finished_files` land on the matching `input_files`
    +/// entries as `rows` and `etag`, read back by `get`; an entry the
    +/// checkpoint did not name stays without them; a finished file with no
    +/// tag records only its count; an index past the enumerated files is
    +/// refused and the whole checkpoint rolls back; a checkpoint from a
    +/// non-owner records nothing.
    +pub async fn checkpoint_records_each_finished_files_rows_and_etag(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "finished_files").await;
    +    let owner = "owner-1";
    +    let mut req = new_op(fx, &fx.account_a, &table, None, "fp-finished", 1000);
    +    req.input_files = vec![
    +        InputFile {
    +            path: "data/a.json".to_owned(),
    +            size: 100,
    +            rows: None,
    +            etag: None,
    +            abandon: None,
    +        },
    +        InputFile {
    +            path: "data/b.json".to_owned(),
    +            size: 200,
    +            rows: None,
    +            etag: None,
    +            abandon: None,
    +        },
    +        InputFile {
    +            path: "data/c.json".to_owned(),
    +            size: 300,
    +            rows: None,
    +            etag: None,
    +            abandon: None,
    +        },
    +    ];
    +    let record = created(start(fx, &fx.account_a, req, 50).await);
    +    let id = record.operation_id;
    +    fx.engine
    +        .claim_runnable(owner, Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    let progress = |cursor_file: i64, finished_files: Vec| Progress {
    +        cursor_file_index: cursor_file,
    +        cursor_row_index: 0,
    +        processed_item_count: 0,
    +        failed_item_count: 0,
    +        processed_input_bytes: 0,
    +        report_record_count: 0,
    +        report_bytes: 0,
    +        usage: json!({}),
    +        records: Vec::new(),
    +        finished_files,
    +        abandoned_files: Vec::new(),
    +    };
    +
    +    // File 0 finished with a tag; file 1 finished without one (its reader
    +    // did not reach the end through the hashing path).
    +    let state = fx
    +        .engine
    +        .checkpoint(
    +            &fx.account_a,
    +            &id,
    +            owner,
    +            Duration::from_secs(60),
    +            progress(
    +                2,
    +                vec![
    +                    FinishedFile {
    +                        index: 0,
    +                        rows: 40,
    +                        etag: Some("0123456789abcdef0123456789abcdef".to_owned()),
    +                    },
    +                    FinishedFile {
    +                        index: 1,
    +                        rows: 7,
    +                        etag: None,
    +                    },
    +                ],
    +            ),
    +        )
    +        .await
    +        .expect("checkpoint");
    +    assert!(matches!(state, LeaseState::Held));
    +    let row = get(fx, &fx.account_a, &id).await.expect("row");
    +    assert_eq!(row.input_files.len(), 3);
    +    assert_eq!(row.input_files[0].path, "data/a.json");
    +    assert_eq!(row.input_files[0].size, 100);
    +    assert_eq!(row.input_files[0].rows, Some(40));
    +    assert_eq!(
    +        row.input_files[0].etag.as_deref(),
    +        Some("0123456789abcdef0123456789abcdef")
    +    );
    +    assert_eq!(row.input_files[1].rows, Some(7));
    +    assert_eq!(row.input_files[1].etag, None);
    +    assert_eq!(row.input_files[2].rows, None);
    +    assert_eq!(row.input_files[2].etag, None);
    +    assert_eq!(row.cursor_file_index, 2);
    +
    +    // An index past the enumerated files is a runner defect: refused, and
    +    // the cursor move in the same checkpoint does not land either.
    +    let err = fx
    +        .engine
    +        .checkpoint(
    +            &fx.account_a,
    +            &id,
    +            owner,
    +            Duration::from_secs(60),
    +            progress(
    +                3,
    +                vec![FinishedFile {
    +                    index: 3,
    +                    rows: 1,
    +                    etag: None,
    +                }],
    +            ),
    +        )
    +        .await
    +        .expect_err("an index past the array must be refused");
    +    assert!(matches!(err, StorageError::Internal(_)), "got {err:?}");
    +    let row = get(fx, &fx.account_a, &id).await.expect("row");
    +    assert_eq!(row.input_files.len(), 3, "no stray element was appended");
    +    assert_eq!(
    +        row.cursor_file_index, 2,
    +        "the refused checkpoint rolled back"
    +    );
    +
    +    // A non-owner's checkpoint is lost and records nothing.
    +    let state = fx
    +        .engine
    +        .checkpoint(
    +            &fx.account_a,
    +            &id,
    +            "someone-else",
    +            Duration::from_secs(60),
    +            progress(
    +                3,
    +                vec![FinishedFile {
    +                    index: 2,
    +                    rows: 9,
    +                    etag: Some("ffffffffffffffffffffffffffffffff".to_owned()),
    +                }],
    +            ),
    +        )
    +        .await
    +        .expect("checkpoint call");
    +    assert!(matches!(state, LeaseState::Lost));
    +    let row = get(fx, &fx.account_a, &id).await.expect("row");
    +    assert_eq!(row.input_files[2].rows, None);
    +    assert_eq!(row.input_files[2].etag, None);
    +}
    +
    +/// A checkpoint's `abandoned_files` land on the matching `input_files`
    +/// entries as `abandon`, read back by `get` with the row and the kind, one
    +/// entry for each kind, beside a finished file in the same checkpoint; an
    +/// entry the checkpoint did not name stays without one; an index past the
    +/// enumerated files is refused and the whole checkpoint rolls back; a
    +/// checkpoint from a non-owner records nothing.
    +pub async fn checkpoint_records_each_abandoned_files_outcome(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "abandoned_files").await;
    +    let owner = "owner-1";
    +    let mut req = new_op(fx, &fx.account_a, &table, None, "fp-abandoned", 1000);
    +    req.input_files = ["a", "b", "c", "d", "e"]
    +        .iter()
    +        .map(|name| InputFile {
    +            path: format!("data/{name}.json"),
    +            size: 100,
    +            ..InputFile::default()
    +        })
    +        .collect();
    +    let record = created(start(fx, &fx.account_a, req, 50).await);
    +    let id = record.operation_id;
    +    fx.engine
    +        .claim_runnable(owner, Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    let progress = |cursor_file: i64, abandoned_files: Vec| Progress {
    +        cursor_file_index: cursor_file,
    +        cursor_row_index: 0,
    +        processed_item_count: 0,
    +        failed_item_count: 0,
    +        processed_input_bytes: 0,
    +        report_record_count: 0,
    +        report_bytes: 0,
    +        usage: json!({}),
    +        records: Vec::new(),
    +        finished_files: Vec::new(),
    +        abandoned_files,
    +    };
    +    let abandoned = |index: usize, row: u64, kind: AbandonKind| AbandonedFile {
    +        index,
    +        row,
    +        kind,
    +        bytes: row * 10,
    +    };
    +
    +    // Files 0 to 2 abandoned, one of each kind; file 3 finished.
    +    let mut both = progress(
    +        4,
    +        vec![
    +            abandoned(0, 7, AbandonKind::Content),
    +            abandoned(1, 0, AbandonKind::NotCompressed),
    +            abandoned(2, 12, AbandonKind::Overwritten),
    +        ],
    +    );
    +    both.finished_files = vec![FinishedFile {
    +        index: 3,
    +        rows: 40,
    +        etag: None,
    +    }];
    +    let state = fx
    +        .engine
    +        .checkpoint(&fx.account_a, &id, owner, Duration::from_secs(60), both)
    +        .await
    +        .expect("checkpoint");
    +    assert!(matches!(state, LeaseState::Held));
    +    let row = get(fx, &fx.account_a, &id).await.expect("row");
    +    assert_eq!(row.input_files.len(), 5);
    +    let expected = [
    +        Some(FileAbandon {
    +            row: 7,
    +            kind: AbandonKind::Content,
    +            bytes: Some(70),
    +        }),
    +        Some(FileAbandon {
    +            row: 0,
    +            kind: AbandonKind::NotCompressed,
    +            bytes: Some(0),
    +        }),
    +        Some(FileAbandon {
    +            row: 12,
    +            kind: AbandonKind::Overwritten,
    +            bytes: Some(120),
    +        }),
    +        None,
    +        None,
    +    ];
    +    for (file, expected) in row.input_files.iter().zip(expected) {
    +        assert_eq!(file.abandon, expected, "{}", file.path);
    +        assert_eq!(file.size, 100, "{}", file.path);
    +    }
    +    assert_eq!(
    +        row.input_files[0].rows, None,
    +        "an abandoned file has no count"
    +    );
    +    assert_eq!(row.input_files[3].rows, Some(40));
    +    assert_eq!(row.cursor_file_index, 4);
    +
    +    // An index past the enumerated files is a runner defect: refused, and
    +    // the cursor move in the same checkpoint does not land either.
    +    let err = fx
    +        .engine
    +        .checkpoint(
    +            &fx.account_a,
    +            &id,
    +            owner,
    +            Duration::from_secs(60),
    +            progress(5, vec![abandoned(5, 1, AbandonKind::Content)]),
    +        )
    +        .await
    +        .expect_err("an index past the array must be refused");
    +    assert!(matches!(err, StorageError::Internal(_)), "got {err:?}");
    +    let row = get(fx, &fx.account_a, &id).await.expect("row");
    +    assert_eq!(row.input_files.len(), 5, "no stray element was appended");
    +    assert_eq!(
    +        row.cursor_file_index, 4,
    +        "the refused checkpoint rolled back"
    +    );
    +
    +    // A non-owner's checkpoint is lost and records nothing.
    +    let state = fx
    +        .engine
    +        .checkpoint(
    +            &fx.account_a,
    +            &id,
    +            "someone-else",
    +            Duration::from_secs(60),
    +            progress(5, vec![abandoned(4, 3, AbandonKind::Overwritten)]),
    +        )
    +        .await
    +        .expect("checkpoint call");
    +    assert!(matches!(state, LeaseState::Lost));
    +    let row = get(fx, &fx.account_a, &id).await.expect("row");
    +    assert_eq!(row.input_files[4].abandon, None);
    +}
    diff --git a/crates/storage/src/bulk_operation_store_contract/counters.rs b/crates/storage/src/bulk_operation_store_contract/counters.rs
    new file mode 100644
    index 000000000..c0289317c
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store_contract/counters.rs
    @@ -0,0 +1,180 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The non-terminal operation count and the corrupt-counter refusal.
    +
    +use super::*;
    +
    +/// `count_non_terminal` counts the account's `IN_PROGRESS` and CANCELLING
    +/// rows only: a terminal row leaves the count and another account's rows
    +/// never enter it.
    +pub async fn count_non_terminal_counts_only_the_accounts_running_and_cancelling_rows<
    +    E: StorageEngine,
    +>(
    +    fx: &Fixture,
    +) {
    +    let count = |account: &str| {
    +        let account = account.to_owned();
    +        async move {
    +            fx.engine
    +                .count_non_terminal(&account)
    +                .await
    +                .expect("count_non_terminal")
    +        }
    +    };
    +    assert_eq!(count(&fx.account_a).await, 0, "a fresh account holds none");
    +
    +    let table_one = create_table(fx, &fx.account_a, "count_one").await;
    +    let table_two = create_table(fx, &fx.account_a, "count_two").await;
    +    let first = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table_one, None, "fp", 1000),
    +            50,
    +        )
    +        .await,
    +    );
    +    let second = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table_two, None, "fp", 1001),
    +            50,
    +        )
    +        .await,
    +    );
    +    assert_eq!(count(&fx.account_a).await, 2, "two running operations");
    +
    +    // CANCELLING is non-terminal, so a cancel request changes nothing.
    +    fx.engine
    +        .request_cancel(&fx.account_a, &first.operation_id)
    +        .await
    +        .expect("request_cancel");
    +    assert_eq!(count(&fx.account_a).await, 2, "cancelling still counts");
    +
    +    // A terminal row leaves the count.
    +    let owner = "counter";
    +    let claimed = fx
    +        .engine
    +        .claim_runnable(owner, Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert!(
    +        claimed
    +            .iter()
    +            .any(|r| r.operation_id == second.operation_id),
    +        "the second operation must be claimable"
    +    );
    +    fx.engine
    +        .finish(
    +            &fx.account_a,
    +            &second.operation_id,
    +            owner,
    +            terminal("COMPLETED", 2000),
    +        )
    +        .await
    +        .expect("finish");
    +    assert_eq!(count(&fx.account_a).await, 1, "terminal rows do not count");
    +
    +    // Another account's operation never enters the count.
    +    let foreign_table = create_table(fx, &fx.account_b, "count_foreign").await;
    +    created(
    +        start(
    +            fx,
    +            &fx.account_b,
    +            new_op(fx, &fx.account_b, &foreign_table, None, "fp", 1002),
    +            50,
    +        )
    +        .await,
    +    );
    +    assert_eq!(count(&fx.account_a).await, 1, "accounts count separately");
    +    assert_eq!(count(&fx.account_b).await, 1);
    +}
    +
    +/// A negative stored counter refuses the read with `StorageError::Internal`
    +/// naming the column, the same policy the two report totals already carry.
    +/// The checkpoint plants each counter directly, one field at a time, so
    +/// every refusal discriminates its own column; a negative enumerated total
    +/// is planted through the start request and refused by the same reader.
    +pub async fn a_negative_stored_counter_refuses_the_read(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "corrupt_counters").await;
    +    let owner = "corruptor";
    +    let id = started_and_claimed(fx, &table, owner, 1000).await;
    +
    +    let planted = |field: &'static str| {
    +        let mut progress = Progress {
    +            cursor_file_index: 0,
    +            cursor_row_index: 0,
    +            processed_item_count: 0,
    +            failed_item_count: 0,
    +            processed_input_bytes: 0,
    +            report_record_count: 0,
    +            report_bytes: 0,
    +            usage: json!({}),
    +            records: Vec::new(),
    +            finished_files: Vec::new(),
    +            abandoned_files: Vec::new(),
    +        };
    +        match field {
    +            "processed_item_count" => progress.processed_item_count = -1,
    +            "failed_item_count" => progress.failed_item_count = -1,
    +            "processed_input_bytes" => progress.processed_input_bytes = -1,
    +            "cursor_file_index" => progress.cursor_file_index = -1,
    +            "cursor_row_index" => progress.cursor_row_index = -1,
    +            other => panic!("unknown field {other}"),
    +        }
    +        progress
    +    };
    +    for field in [
    +        "processed_item_count",
    +        "failed_item_count",
    +        "processed_input_bytes",
    +        "cursor_file_index",
    +        "cursor_row_index",
    +    ] {
    +        fx.engine
    +            .checkpoint(
    +                &fx.account_a,
    +                &id,
    +                owner,
    +                Duration::from_secs(60),
    +                planted(field),
    +            )
    +            .await
    +            .expect("the checkpoint itself writes whatever it is handed");
    +        let err = fx
    +            .engine
    +            .get_bulk_operation(&fx.account_a, &id)
    +            .await
    +            .expect_err("a negative stored counter must refuse the read");
    +        match err {
    +            StorageError::Internal(message) => assert!(
    +                message.contains(field),
    +                "the refusal must name {field}, got: {message}"
    +            ),
    +            other => panic!("expected StorageError::Internal, got {other:?}"),
    +        }
    +    }
    +
    +    // `total_input_bytes` is written at insert, so the plant goes through
    +    // the start request and the created-row read refuses it in the same
    +    // transaction.
    +    let clean_table = create_table(fx, &fx.account_a, "corrupt_total").await;
    +    let mut op = new_op(fx, &fx.account_a, &clean_table, None, "fp", 1001);
    +    op.total_input_bytes = Some(-1);
    +    let err = match fx.engine.start_bulk_operation(&fx.account_a, op, 50).await {
    +        Err(err) => err,
    +        Ok(outcome) => panic!(
    +            "a negative enumerated total must refuse the created-row read, got {}",
    +            start_kind(&outcome)
    +        ),
    +    };
    +    match err {
    +        StorageError::Internal(message) => assert!(
    +            message.contains("total_input_bytes"),
    +            "the refusal must name total_input_bytes, got: {message}"
    +        ),
    +        other => panic!("expected StorageError::Internal, got {other:?}"),
    +    }
    +}
    diff --git a/crates/storage/src/bulk_operation_store_contract/finish.rs b/crates/storage/src/bulk_operation_store_contract/finish.rs
    new file mode 100644
    index 000000000..1308f290d
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store_contract/finish.rs
    @@ -0,0 +1,253 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Finish and cancel: terminal states, the cancel transitions, and the stored
    +//! failure fields.
    +
    +use super::*;
    +
    +/// A finish from a non-owner reports Lost and changes nothing.
    +pub async fn a_finish_from_a_non_owner_is_lost_and_changes_nothing(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "finish_lost").await;
    +    let id = started_and_claimed(fx, &table, "owner-1", 1000).await;
    +
    +    let state = fx
    +        .engine
    +        .finish(&fx.account_a, &id, "impostor", terminal("COMPLETED", 9000))
    +        .await
    +        .expect("finish");
    +    assert!(matches!(state, FinishState::Lost));
    +
    +    let row = get(fx, &fx.account_a, &id).await.expect("row");
    +    assert_eq!(row.status, "IN_PROGRESS");
    +    assert_eq!(row.end_time_ms, None);
    +    assert_eq!(row.lease_owner.as_deref(), Some("owner-1"));
    +}
    +
    +/// A CANCELLING row finishes CANCELLED whatever terminal status the runner
    +/// computed.
    +pub async fn finishing_a_cancelling_operation_lands_cancelled_whatever_the_outcome_says<
    +    E: StorageEngine,
    +>(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "finish_cancelling").await;
    +    let owner = "owner-1";
    +    let id = started_and_claimed(fx, &table, owner, 1000).await;
    +    let cancel = fx
    +        .engine
    +        .request_cancel(&fx.account_a, &id)
    +        .await
    +        .expect("request_cancel");
    +    assert!(matches!(cancel, CancelOutcome::Cancelling));
    +    // A checkpoint under the cancel: its byte figure is what the terminal
    +    // description reports.
    +    let state = fx
    +        .engine
    +        .checkpoint(
    +            &fx.account_a,
    +            &id,
    +            owner,
    +            Duration::from_secs(60),
    +            progress(Vec::new()),
    +        )
    +        .await
    +        .expect("checkpoint");
    +    assert!(matches!(state, LeaseState::Held));
    +
    +    let state = fx
    +        .engine
    +        .finish(&fx.account_a, &id, owner, terminal("COMPLETED", 9000))
    +        .await
    +        .expect("finish");
    +    match state {
    +        FinishState::Held(status) => assert_eq!(
    +            status, "CANCELLED",
    +            "finish answers with the status it stored, not the one it was sent"
    +        ),
    +        FinishState::Lost => panic!("the owner held the lease"),
    +    }
    +
    +    let row = get(fx, &fx.account_a, &id).await.expect("row");
    +    assert_eq!(row.status, "CANCELLED", "CANCELLING always lands CANCELLED");
    +    assert_eq!(row.end_time_ms, Some(9000));
    +    // The store supplies the cancelled pair whatever the runner sent and
    +    // keeps the checkpointed input bytes: a cancelled operation reports
    +    // what was read before the stop (measured on the service).
    +    assert_eq!(row.failure_code.as_deref(), Some(CANCELLED_FAILURE_CODE));
    +    assert_eq!(
    +        row.failure_message.as_deref(),
    +        Some(CANCELLED_FAILURE_MESSAGE)
    +    );
    +    assert_eq!(row.processed_input_bytes, 2500);
    +}
    +
    +/// Finishing clears the lease, stores the terminal fields, and deletes the
    +/// operation's report records.
    +pub async fn finish_clears_the_lease_and_deletes_report_records(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "finish_cleanup").await;
    +    let owner = "owner-1";
    +    let id = started_and_claimed(fx, &table, owner, 1000).await;
    +    fx.engine
    +        .checkpoint(
    +            &fx.account_a,
    +            &id,
    +            owner,
    +            Duration::from_secs(60),
    +            progress(vec![error_record(3)]),
    +        )
    +        .await
    +        .expect("checkpoint");
    +
    +    let outcome = Terminal {
    +        status: "COMPLETED_WITH_ERRORS".to_owned(),
    +        end_time_ms: 9000,
    +        failure_code: None,
    +        failure_message: None,
    +        usage: json!({"WriteRequestUnits": 51200}),
    +    };
    +    let state = fx
    +        .engine
    +        .finish(&fx.account_a, &id, owner, outcome)
    +        .await
    +        .expect("finish");
    +    assert!(matches!(state, FinishState::Held(status) if status == "COMPLETED_WITH_ERRORS"));
    +
    +    let row = get(fx, &fx.account_a, &id).await.expect("row");
    +    assert_eq!(row.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(row.end_time_ms, Some(9000));
    +    assert_eq!(row.usage, json!({"WriteRequestUnits": 51200}));
    +    assert_eq!(row.lease_owner, None, "finish clears the lease");
    +    assert_eq!(row.lease_until_ms, None);
    +
    +    for kind in [ReportKind::Error, ReportKind::ConditionCheckFailure] {
    +        let records = all_report_records(fx, &fx.account_a, &id, kind).await;
    +        assert!(records.is_empty(), "finish deletes the report records");
    +    }
    +
    +    // A second finish is refused: the row is terminal and the lease is gone.
    +    let again = fx
    +        .engine
    +        .finish(&fx.account_a, &id, owner, terminal("FAILED", 9500))
    +        .await
    +        .expect("finish");
    +    assert!(matches!(again, FinishState::Lost));
    +    let row = get(fx, &fx.account_a, &id).await.expect("row");
    +    assert_eq!(
    +        row.status, "COMPLETED_WITH_ERRORS",
    +        "a terminal row does not move"
    +    );
    +}
    +
    +/// Cancelling a running operation moves it to CANCELLING; a second cancel is
    +/// the same answer, not an error.
    +pub async fn cancel_moves_a_running_operation_to_cancelling_and_repeats_harmlessly<
    +    E: StorageEngine,
    +>(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "cancel_running").await;
    +    let record = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table, None, "fp", 1000),
    +            50,
    +        )
    +        .await,
    +    );
    +    let id = record.operation_id;
    +
    +    for _ in 0..2 {
    +        let cancel = fx
    +            .engine
    +            .request_cancel(&fx.account_a, &id)
    +            .await
    +            .expect("request_cancel");
    +        assert!(matches!(cancel, CancelOutcome::Cancelling));
    +    }
    +    let row = get(fx, &fx.account_a, &id).await.expect("row");
    +    assert_eq!(row.status, "CANCELLING");
    +    assert!(row.cancel_requested);
    +}
    +
    +/// Cancelling a terminal operation answers with the row's status and moves
    +/// nothing.
    +pub async fn cancel_on_a_terminal_operation_reports_the_status_and_changes_nothing<
    +    E: StorageEngine,
    +>(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "cancel_terminal").await;
    +    let owner = "owner-1";
    +    let id = started_and_claimed(fx, &table, owner, 1000).await;
    +    fx.engine
    +        .finish(&fx.account_a, &id, owner, terminal("COMPLETED", 5000))
    +        .await
    +        .expect("finish");
    +
    +    let cancel = fx
    +        .engine
    +        .request_cancel(&fx.account_a, &id)
    +        .await
    +        .expect("request_cancel");
    +    match cancel {
    +        CancelOutcome::AlreadyTerminal(status) => assert_eq!(status, "COMPLETED"),
    +        CancelOutcome::Cancelling => panic!("a terminal row must not move to CANCELLING"),
    +        CancelOutcome::NotFound => panic!("the row exists"),
    +    }
    +    let row = get(fx, &fx.account_a, &id).await.expect("row");
    +    assert_eq!(row.status, "COMPLETED");
    +    assert!(
    +        !row.cancel_requested,
    +        "the refused cancel must not set the flag"
    +    );
    +}
    +
    +/// Cancelling an id that does not exist reports `NotFound`.
    +pub async fn cancel_on_an_unknown_operation_reports_not_found(fx: &Fixture) {
    +    let cancel = fx
    +        .engine
    +        .request_cancel(&fx.account_a, "00000000000000deadbeef")
    +        .await
    +        .expect("request_cancel");
    +    assert!(matches!(cancel, CancelOutcome::NotFound));
    +}
    +
    +/// A FAILED finish carries its failure code and message through to the
    +/// stored record.
    +pub async fn finish_with_failed_carries_the_failure_code_and_message(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "finish_failed").await;
    +    let owner = "owner-1";
    +    let id = started_and_claimed(fx, &table, owner, 1000).await;
    +
    +    let outcome = Terminal {
    +        status: "FAILED".to_owned(),
    +        end_time_ms: 9000,
    +        failure_code: Some("ResourceNotFoundException".to_owned()),
    +        failure_message: Some("Requested resource not found".to_owned()),
    +        usage: json!({}),
    +    };
    +    let state = fx
    +        .engine
    +        .finish(&fx.account_a, &id, owner, outcome)
    +        .await
    +        .expect("finish");
    +    assert!(matches!(state, FinishState::Held(status) if status == "FAILED"));
    +
    +    let row = get(fx, &fx.account_a, &id).await.expect("row");
    +    assert_eq!(row.status, "FAILED");
    +    assert_eq!(
    +        row.failure_code.as_deref(),
    +        Some("ResourceNotFoundException")
    +    );
    +    assert_eq!(
    +        row.failure_message.as_deref(),
    +        Some("Requested resource not found")
    +    );
    +    assert_eq!(row.end_time_ms, Some(9000));
    +}
    diff --git a/crates/storage/src/bulk_operation_store_contract/index_crossing.rs b/crates/storage/src/bulk_operation_store_contract/index_crossing.rs
    new file mode 100644
    index 000000000..83154c852
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store_contract/index_crossing.rs
    @@ -0,0 +1,282 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! A start and an index creation sent together on one table. Start refuses
    +//! a table with an index that is not `ACTIVE` or being built, and an index
    +//! creation is refused while the table has a non-terminal operation; with
    +//! both checks inside the transaction that acts on them, exactly one of two
    +//! requests that cross is accepted.
    +
    +use std::time::Instant;
    +
    +use extenddb_core::types::UpdateTableInput;
    +
    +use crate::bulk_operation_store::index_creation_refused_message;
    +
    +use super::*;
    +
    +/// Crossing trials required per order of the two requests.
    +const CROSSINGS_PER_ORDER: u64 = 50;
    +
    +/// Trials allowed per order to collect them.
    +const TRIALS_PER_ORDER: u64 = 200;
    +
    +/// The bound on each request of a trial.
    +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
    +
    +/// How long before `UpdateTable` answered the start must have been called
    +/// for the trial to count as crossing. `UpdateTable` ends its build a few
    +/// statements before it answers; the margin keeps a start called in that
    +/// tail, after the build, out of the count.
    +const CROSSING_MARGIN: Duration = Duration::from_millis(2);
    +
    +/// An `UpdateTable` that creates one global secondary index.
    +fn create_index_request(table_name: &str) -> UpdateTableInput {
    +    serde_json::from_value(json!({
    +        "TableName": table_name,
    +        "AttributeDefinitions": [
    +            { "AttributeName": "pk", "AttributeType": "S" },
    +            { "AttributeName": "g", "AttributeType": "S" }
    +        ],
    +        "GlobalSecondaryIndexUpdates": [{ "Create": {
    +            "IndexName": "gix",
    +            "KeySchema": [{ "AttributeName": "g", "KeyType": "HASH" }],
    +            "Projection": { "ProjectionType": "ALL" }
    +        }}]
    +    }))
    +    .expect("a valid UpdateTable request")
    +}
    +
    +/// What one trial's two requests answered, and when each was called and
    +/// answered.
    +struct Trial {
    +    start_accepted: bool,
    +    index_accepted: bool,
    +    start_span: (Instant, Instant),
    +    update_span: (Instant, Instant),
    +}
    +
    +impl Trial {
    +    /// Each request was called before the other answered, the start with
    +    /// [`CROSSING_MARGIN`] to spare.
    +    fn crossed(&self) -> bool {
    +        self.start_span.0 + CROSSING_MARGIN < self.update_span.1
    +            && self.update_span.0 < self.start_span.1
    +    }
    +
    +    fn timings(&self) -> String {
    +        let origin = self.start_span.0.min(self.update_span.0);
    +        let at = |t: Instant| t.saturating_duration_since(origin);
    +        format!(
    +            "start {:?} to {:?}, UpdateTable {:?} to {:?}",
    +            at(self.start_span.0),
    +            at(self.start_span.1),
    +            at(self.update_span.0),
    +            at(self.update_span.1),
    +        )
    +    }
    +}
    +
    +/// One trial on a fresh table: both requests are released by one barrier,
    +/// and the one sent second waits `delay` first.
    +async fn one_trial(
    +    fx: &Fixture,
    +    trial: u64,
    +    start_first: bool,
    +    delay: Duration,
    +) -> Trial {
    +    let name = format!("cross_{trial}_{}", unique());
    +    let table = create_table(fx, &fx.account_a, &name).await;
    +    let req = new_op(fx, &fx.account_a, &table, None, "fp", 1000);
    +    let update = create_index_request(&name);
    +    let barrier = Arc::new(tokio::sync::Barrier::new(2));
    +    let (start_delay, update_delay) = if start_first {
    +        (Duration::ZERO, delay)
    +    } else {
    +        (delay, Duration::ZERO)
    +    };
    +
    +    let start_task = {
    +        let engine = Arc::clone(&fx.engine);
    +        let account = fx.account_a.clone();
    +        let barrier = Arc::clone(&barrier);
    +        tokio::spawn(async move {
    +            barrier.wait().await;
    +            tokio::time::sleep(start_delay).await;
    +            // Every accepted start stays IN_PROGRESS on its own table, so the
    +            // account limit sits above the trial count. The time is taken
    +            // after the call, which does its first read before it returns
    +            // the future.
    +            let call = engine.start_bulk_operation(&account, req, 1_000);
    +            let begin = Instant::now();
    +            let answer = tokio::time::timeout(REQUEST_TIMEOUT, call).await;
    +            (answer, (begin, Instant::now()))
    +        })
    +    };
    +    let update_task = {
    +        let engine = Arc::clone(&fx.engine);
    +        let account = fx.account_a.clone();
    +        let barrier = Arc::clone(&barrier);
    +        tokio::spawn(async move {
    +            barrier.wait().await;
    +            tokio::time::sleep(update_delay).await;
    +            let begin = Instant::now();
    +            let answer =
    +                tokio::time::timeout(REQUEST_TIMEOUT, engine.update_table(&account, update)).await;
    +            (answer, (begin, Instant::now()))
    +        })
    +    };
    +
    +    let (started, start_span) = start_task.await.expect("join the start");
    +    let started = started
    +        .unwrap_or_else(|_| panic!("trial {trial}: the start did not answer in time"))
    +        .expect("start_bulk_operation must not error at the transport level");
    +    let (updated, update_span) = update_task.await.expect("join the UpdateTable");
    +    let updated =
    +        updated.unwrap_or_else(|_| panic!("trial {trial}: UpdateTable did not answer in time"));
    +
    +    let start_accepted = match &started {
    +        StartOutcome::Created(_) => true,
    +        StartOutcome::IndexOperationInProgress => false,
    +        other => panic!(
    +            "trial {trial}: unexpected start outcome {}",
    +            start_kind(other)
    +        ),
    +    };
    +    let index_accepted = match &updated {
    +        Ok(_) => true,
    +        Err(StorageError::IndexesInUse(message)) => {
    +            assert_eq!(message, &index_creation_refused_message(&name));
    +            false
    +        }
    +        Err(other) => panic!("trial {trial}: unexpected UpdateTable error {other:?}"),
    +    };
    +    Trial {
    +        start_accepted,
    +        index_accepted,
    +        start_span,
    +        update_span,
    +    }
    +}
    +
    +/// A start and a global secondary index creation on a fresh table each
    +/// trial, in both orders, the second sent up to two milliseconds after the
    +/// first: in no trial where they cross are both accepted, and in every such
    +/// trial one of them is. Runs until each order has
    +/// [`CROSSINGS_PER_ORDER`] crossing trials.
    +pub async fn a_start_and_an_index_creation_that_cross_are_never_both_accepted<
    +    E: StorageEngine + 'static,
    +>(
    +    fx: &Fixture,
    +) {
    +    for start_first in [true, false] {
    +        let (mut crossings, mut starts_won, mut indexes_won) = (0u64, 0u64, 0u64);
    +        let mut trial = 0;
    +        while crossings < CROSSINGS_PER_ORDER && trial < TRIALS_PER_ORDER {
    +            let delay = Duration::from_micros((trial % 20) * 100);
    +            let outcome = one_trial(fx, trial, start_first, delay).await;
    +            trial += 1;
    +            if !outcome.crossed() {
    +                continue;
    +            }
    +            crossings += 1;
    +            assert!(
    +                !(outcome.start_accepted && outcome.index_accepted),
    +                "start first {start_first}, trial {trial}: both were accepted ({})",
    +                outcome.timings()
    +            );
    +            assert!(
    +                outcome.start_accepted || outcome.index_accepted,
    +                "start first {start_first}, trial {trial}: both were refused ({})",
    +                outcome.timings()
    +            );
    +            if outcome.start_accepted {
    +                starts_won += 1;
    +            } else {
    +                indexes_won += 1;
    +            }
    +        }
    +        assert_eq!(
    +            crossings, CROSSINGS_PER_ORDER,
    +            "start first {start_first}: only {crossings} of {trial} trials crossed"
    +        );
    +        eprintln!(
    +            "start first {start_first}: {trial} trials, {crossings} crossed, start accepted in \
    +             {starts_won}, index creation in {indexes_won}"
    +        );
    +    }
    +}
    +
    +/// Starts beyond the engine's pool size, sent while an index creation on the
    +/// same table holds the table row. The creation takes a second connection
    +/// for the new index's data table while it holds the row, and each start
    +/// takes one before it waits on the account's lock or the row. Every request
    +/// still answers, the creation within a few seconds, and a start and the
    +/// creation are never both accepted.
    +pub async fn starts_beyond_the_pool_behind_an_index_creation_all_answer<
    +    E: StorageEngine + 'static,
    +>(
    +    fx: &Fixture,
    +) {
    +    for round in 0..3u64 {
    +        let table = create_table(fx, &fx.account_a, &format!("pool_wait_{round}")).await;
    +        let engine = Arc::clone(&fx.engine);
    +        let account = fx.account_a.clone();
    +        let request = create_index_request(&table.table_name);
    +        let update = tokio::spawn(async move {
    +            let called = Instant::now();
    +            let answer =
    +                tokio::time::timeout(REQUEST_TIMEOUT, engine.update_table(&account, request)).await;
    +            (answer, called.elapsed())
    +        });
    +        tokio::time::sleep(Duration::from_millis(round)).await;
    +        let starts: Vec<_> = (0..16)
    +            .map(|i| {
    +                let engine = Arc::clone(&fx.engine);
    +                let account = fx.account_a.clone();
    +                let req = new_op(fx, &fx.account_a, &table, None, "fp", 1000 + i);
    +                tokio::spawn(async move {
    +                    tokio::time::timeout(
    +                        REQUEST_TIMEOUT,
    +                        engine.start_bulk_operation(&account, req, 50),
    +                    )
    +                    .await
    +                })
    +            })
    +            .collect();
    +        let mut created = 0;
    +        for task in starts {
    +            match task.await.expect("join a start") {
    +                Ok(Ok(StartOutcome::Created(_))) => created += 1,
    +                Ok(Ok(StartOutcome::TableBusy | StartOutcome::IndexOperationInProgress)) => {}
    +                Ok(Ok(other)) => panic!("round {round}: a start answered {}", start_kind(&other)),
    +                Ok(Err(error)) => panic!("round {round}: a start failed: {error:?}"),
    +                Err(_) => panic!("round {round}: a start did not answer in {REQUEST_TIMEOUT:?}"),
    +            }
    +        }
    +        let (answer, elapsed) = update.await.expect("join UpdateTable");
    +        let Ok(answer) = answer else {
    +            panic!("round {round}: UpdateTable did not answer in {REQUEST_TIMEOUT:?}");
    +        };
    +        match answer {
    +            Ok(_) => {
    +                assert_eq!(
    +                    created, 0,
    +                    "round {round}: a start and the index were both accepted"
    +                );
    +                assert!(
    +                    elapsed < Duration::from_secs(10),
    +                    "round {round}: UpdateTable answered after {elapsed:?}"
    +                );
    +            }
    +            Err(StorageError::IndexesInUse(message)) => {
    +                assert_eq!(message, index_creation_refused_message(&table.table_name));
    +                assert_eq!(
    +                    created, 1,
    +                    "round {round}: the creation was refused for one start"
    +                );
    +            }
    +            Err(error) => panic!("round {round}: UpdateTable failed after {elapsed:?}: {error:?}"),
    +        }
    +    }
    +}
    diff --git a/crates/storage/src/bulk_operation_store_contract/initial_failure.rs b/crates/storage/src/bulk_operation_store_contract/initial_failure.rs
    new file mode 100644
    index 000000000..0f47e5428
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store_contract/initial_failure.rs
    @@ -0,0 +1,517 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Rows born terminal from an initial failure, and the terminal-born cap the
    +//! start transaction enforces.
    +
    +use super::*;
    +
    +/// Turn a new-operation request into one carrying an initial failure, the
    +/// shape the engine builds for a named bucket directory that does not
    +/// exist: no input files, no enumerated total, the S3 code and message.
    +fn with_initial_failure(mut req: NewBulkOperation) -> NewBulkOperation {
    +    req.input_files = Vec::new();
    +    req.total_input_bytes = None;
    +    req.initial_failure = Some(crate::InitialFailure {
    +        code: "S3NoSuchBucket".to_owned(),
    +        message: "The specified bucket does not exist".to_owned(),
    +        // A high cap so existing cases that create a handful of
    +        // terminal-born rows are never refused by the cap; the cap itself
    +        // is exercised by its own case.
    +        cap: u64::MAX,
    +        window_start_ms: 0,
    +    });
    +    req
    +}
    +
    +/// Like `with_initial_failure` but with an explicit terminal-born cap and
    +/// window, for the case that exercises the cap itself.
    +fn with_initial_failure_capped(
    +    mut req: NewBulkOperation,
    +    cap: u64,
    +    window_start_ms: i64,
    +) -> NewBulkOperation {
    +    req.input_files = Vec::new();
    +    req.total_input_bytes = None;
    +    req.initial_failure = Some(crate::InitialFailure {
    +        code: "S3NoSuchBucket".to_owned(),
    +        message: "The specified bucket does not exist".to_owned(),
    +        cap,
    +        window_start_ms,
    +    });
    +    req
    +}
    +
    +/// The terminal-born cap is enforced inside the start transaction, under
    +/// the per-account lock, so a run of absent-bucket starts is refused once
    +/// the account is at the cap: it is exact, not a best-effort pre-check a
    +/// concurrent burst can race past. With a cap of 3, the first three starts
    +/// are `Created` and the fourth is `FailedStartLimit`.
    +pub async fn the_terminal_born_cap_is_enforced_in_the_start_transaction(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "cap_table").await;
    +    let cap = 3u64;
    +    for i in 0..cap {
    +        let req = with_initial_failure_capped(
    +            new_op(
    +                fx,
    +                &fx.account_a,
    +                &table,
    +                None,
    +                &format!("fp-{i}"),
    +                1_000 + i as i64,
    +            ),
    +            cap,
    +            0,
    +        );
    +        let outcome = start(fx, &fx.account_a, req, 50).await;
    +        assert!(
    +            matches!(outcome, StartOutcome::Created(_)),
    +            "terminal-born start {i} is below the cap, got {}",
    +            start_kind(&outcome)
    +        );
    +    }
    +    let over = with_initial_failure_capped(
    +        new_op(fx, &fx.account_a, &table, None, "fp-over", 2_000),
    +        cap,
    +        0,
    +    );
    +    let outcome = start(fx, &fx.account_a, over, 50).await;
    +    assert!(
    +        matches!(outcome, StartOutcome::FailedStartLimit),
    +        "the start at the cap is refused in the store, got {}",
    +        start_kind(&outcome)
    +    );
    +
    +    // A window that excludes the earlier rows admits a new terminal-born
    +    // start again: the cap counts only rows inside the trailing window.
    +    let fresh = with_initial_failure_capped(
    +        new_op(fx, &fx.account_a, &table, None, "fp-fresh", 3_000),
    +        cap,
    +        2_500,
    +    );
    +    let outcome = start(fx, &fx.account_a, fresh, 50).await;
    +    assert!(
    +        matches!(outcome, StartOutcome::Created(_)),
    +        "a start whose window excludes the earlier rows is admitted, got {}",
    +        start_kind(&outcome)
    +    );
    +}
    +
    +/// A start carrying an initial failure inserts the row already terminal:
    +/// FAILED with the code and message, end time equal to start time, zero
    +/// counters, no lease, and the claim loop never returns it.
    +pub async fn an_initial_failure_starts_terminal_and_is_never_claimed(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "initial_failure_terminal").await;
    +    let record = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            with_initial_failure(new_op(fx, &fx.account_a, &table, None, "fp", 1000)),
    +            50,
    +        )
    +        .await,
    +    );
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3NoSuchBucket"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("The specified bucket does not exist")
    +    );
    +    assert_eq!(record.end_time_ms, Some(record.start_time_ms));
    +    assert_eq!(record.processed_item_count, 0);
    +    assert_eq!(record.failed_item_count, 0);
    +    assert_eq!(record.processed_input_bytes, 0);
    +    // Never enumerated, so no total: Describe omits TotalInputBytes, as the
    +    // hosted service does for a failure born before the input was read.
    +    assert_eq!(record.total_input_bytes, None);
    +    assert!(record.lease_owner.is_none());
    +    assert!(record.lease_until_ms.is_none());
    +    let claimed = fx
    +        .engine
    +        .claim_runnable("claimer", Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert!(
    +        claimed.is_empty(),
    +        "a row born terminal must not be claimable, got {} rows",
    +        claimed.len()
    +    );
    +}
    +
    +/// A row born terminal never counts against the account's non-terminal
    +/// operations.
    +pub async fn an_initial_failure_does_not_count_as_non_terminal(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "initial_failure_count").await;
    +    created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            with_initial_failure(new_op(fx, &fx.account_a, &table, None, "fp", 1000)),
    +            50,
    +        )
    +        .await,
    +    );
    +    let count = fx
    +        .engine
    +        .count_non_terminal(&fx.account_a)
    +        .await
    +        .expect("count");
    +    assert_eq!(count, 0, "a FAILED row is terminal and must not be counted");
    +}
    +
    +/// The per-table guard sees only non-terminal rows, so a table whose only
    +/// operation was born terminal accepts a new start.
    +pub async fn a_table_with_an_initial_failure_row_accepts_a_new_start(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "initial_failure_guard").await;
    +    created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            with_initial_failure(new_op(fx, &fx.account_a, &table, None, "fp", 1000)),
    +            50,
    +        )
    +        .await,
    +    );
    +    let second = start(
    +        fx,
    +        &fx.account_a,
    +        new_op(fx, &fx.account_a, &table, None, "fp", 2000),
    +        50,
    +    )
    +    .await;
    +    assert!(
    +        matches!(second, StartOutcome::Created(_)),
    +        "a terminal row must not trip the table guard, got {}",
    +        start_kind(&second)
    +    );
    +}
    +
    +/// The token of a row born terminal is consumed like any other: a second
    +/// start with the same token and fingerprint replays the FAILED record,
    +/// whether or not the retry carries an initial failure of its own.
    +pub async fn a_reused_token_replays_to_an_initial_failure_row(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "initial_failure_replay").await;
    +    let first = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            with_initial_failure(new_op(
    +                fx,
    +                &fx.account_a,
    +                &table,
    +                Some("tok-failed"),
    +                "fp",
    +                1000,
    +            )),
    +            50,
    +        )
    +        .await,
    +    );
    +    let second = start(
    +        fx,
    +        &fx.account_a,
    +        new_op(fx, &fx.account_a, &table, Some("tok-failed"), "fp", 2000),
    +        50,
    +    )
    +    .await;
    +    match second {
    +        StartOutcome::IdempotentReplay(record) => {
    +            assert_eq!(record.operation_id, first.operation_id);
    +            assert_eq!(record.status, "FAILED");
    +            assert_eq!(record.failure_code.as_deref(), Some("S3NoSuchBucket"));
    +        }
    +        other => panic!("expected IdempotentReplay, got {}", start_kind(&other)),
    +    }
    +}
    +
    +/// `count_terminal_born_since` counts rows that are `FAILED` with
    +/// `end_time_ms` equal to `start_time_ms` and a `start_time_ms` at or
    +/// after the cutoff, and only those: a terminal-born row older than the
    +/// cutoff, a running row, a row failed by the runner (whose end time
    +/// differs from its start), and another account's terminal-born row all
    +/// stay out of the count.
    +pub async fn count_terminal_born_since_counts_only_terminal_born_rows_in_the_window<
    +    E: StorageEngine,
    +>(
    +    fx: &Fixture,
    +) {
    +    let count = |account: &str, since_ms: i64| {
    +        let account = account.to_owned();
    +        async move {
    +            fx.engine
    +                .count_terminal_born_since(&account, since_ms)
    +                .await
    +                .expect("count_terminal_born_since")
    +        }
    +    };
    +    assert_eq!(
    +        count(&fx.account_a, 0).await,
    +        0,
    +        "a fresh account holds none"
    +    );
    +
    +    // One terminal-born row inside the window, one before it.
    +    let table_in = create_table(fx, &fx.account_a, "born_in_window").await;
    +    created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            with_initial_failure(new_op(fx, &fx.account_a, &table_in, None, "fp", 5000)),
    +            50,
    +        )
    +        .await,
    +    );
    +    let table_old = create_table(fx, &fx.account_a, "born_before_window").await;
    +    created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            with_initial_failure(new_op(fx, &fx.account_a, &table_old, None, "fp", 1000)),
    +            50,
    +        )
    +        .await,
    +    );
    +
    +    // A running row and a row the runner failed (end time differs from
    +    // start time) are not terminal-born.
    +    let table_run = create_table(fx, &fx.account_a, "born_running").await;
    +    created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table_run, None, "fp", 5000),
    +            50,
    +        )
    +        .await,
    +    );
    +    let table_failed = create_table(fx, &fx.account_a, "born_runner_failed").await;
    +    let failed = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table_failed, None, "fp", 5000),
    +            50,
    +        )
    +        .await,
    +    );
    +    let owner = "born-counter";
    +    fx.engine
    +        .claim_runnable(owner, Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    fx.engine
    +        .finish(
    +            &fx.account_a,
    +            &failed.operation_id,
    +            owner,
    +            terminal("FAILED", 9000),
    +        )
    +        .await
    +        .expect("finish");
    +
    +    // Another account's terminal-born row is invisible to account A.
    +    let table_b = create_table(fx, &fx.account_b, "born_other_account").await;
    +    created(
    +        start(
    +            fx,
    +            &fx.account_b,
    +            with_initial_failure(new_op(fx, &fx.account_b, &table_b, None, "fp", 5000)),
    +            50,
    +        )
    +        .await,
    +    );
    +
    +    assert_eq!(
    +        count(&fx.account_a, 2000).await,
    +        1,
    +        "only the terminal-born row at or after the cutoff counts"
    +    );
    +    assert_eq!(
    +        count(&fx.account_a, 0).await,
    +        2,
    +        "a cutoff before both terminal-born rows counts both"
    +    );
    +    assert_eq!(
    +        count(&fx.account_a, 5001).await,
    +        0,
    +        "a cutoff after every terminal-born row counts none"
    +    );
    +}
    +
    +/// The account limit binds on non-terminal rows only: an account already
    +/// at its limit refuses a normal start but still accepts a start carrying
    +/// an initial failure, and the inserted row is terminal.
    +pub async fn an_initial_failure_is_inserted_despite_a_saturated_account(
    +    fx: &Fixture,
    +) {
    +    let busy = create_table(fx, &fx.account_a, "initial_failure_saturating").await;
    +    created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &busy, None, "fp-running", 1000),
    +            1,
    +        )
    +        .await,
    +    );
    +    let second_table = create_table(fx, &fx.account_a, "initial_failure_saturated").await;
    +    // The saturation premise: a normal start against the limit of one is
    +    // refused, so the acceptance below can only come from the skip.
    +    let refused = start(
    +        fx,
    +        &fx.account_a,
    +        new_op(fx, &fx.account_a, &second_table, None, "fp-refused", 2000),
    +        1,
    +    )
    +    .await;
    +    assert!(
    +        matches!(refused, StartOutcome::AccountLimitReached),
    +        "a normal start must be refused at the limit, got {}",
    +        start_kind(&refused)
    +    );
    +    let record = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            with_initial_failure(new_op(
    +                fx,
    +                &fx.account_a,
    +                &second_table,
    +                None,
    +                "fp-terminal",
    +                3000,
    +            )),
    +            1,
    +        )
    +        .await,
    +    );
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3NoSuchBucket"));
    +}
    +
    +/// The per-table guard binds on a start carrying an initial failure too
    +/// (measured on the hosted service: a Start naming an input bucket that
    +/// does not exist, on a table with an operation running, answered the busy
    +/// message and created nothing). On a busy table the start answers
    +/// `TableBusy` and inserts nothing, before the terminal-born cap even when
    +/// the cap would also refuse, and the running operation is untouched. Once
    +/// the operation has finished, the same start is born `FAILED`, and the cap
    +/// applies again.
    +pub async fn an_initial_failure_on_a_busy_table_answers_busy_and_inserts_nothing<
    +    E: StorageEngine,
    +>(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "initial_failure_busy_table").await;
    +    let running = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table, None, "fp-running", 1000),
    +            50,
    +        )
    +        .await,
    +    );
    +    let busy = start(
    +        fx,
    +        &fx.account_a,
    +        with_initial_failure(new_op(fx, &fx.account_a, &table, None, "fp-terminal", 2000)),
    +        50,
    +    )
    +    .await;
    +    assert!(
    +        matches!(busy, StartOutcome::TableBusy),
    +        "a start with an initial failure on a busy table answers busy, got {}",
    +        start_kind(&busy)
    +    );
    +    let capped = start(
    +        fx,
    +        &fx.account_a,
    +        with_initial_failure_capped(
    +            new_op(fx, &fx.account_a, &table, None, "fp-capped", 2500),
    +            0,
    +            0,
    +        ),
    +        50,
    +    )
    +    .await;
    +    assert!(
    +        matches!(capped, StartOutcome::TableBusy),
    +        "the per-table guard answers before the terminal-born cap, got {}",
    +        start_kind(&capped)
    +    );
    +    let (records, _) = fx
    +        .engine
    +        .list_bulk_operations(&fx.account_a, 10, None)
    +        .await
    +        .expect("list");
    +    let ids: Vec<&str> = records.iter().map(|r| r.operation_id.as_str()).collect();
    +    assert_eq!(
    +        ids,
    +        vec![running.operation_id.as_str()],
    +        "the refused starts inserted nothing"
    +    );
    +    let untouched = get(fx, &fx.account_a, &running.operation_id)
    +        .await
    +        .expect("the running operation exists");
    +    assert_eq!(
    +        untouched.status, "IN_PROGRESS",
    +        "the refused starts must not disturb the running operation"
    +    );
    +
    +    let claimed = fx
    +        .engine
    +        .claim_runnable("owner-1", Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert!(
    +        claimed
    +            .iter()
    +            .any(|r| r.operation_id == running.operation_id)
    +    );
    +    let state = fx
    +        .engine
    +        .finish(
    +            &fx.account_a,
    +            &running.operation_id,
    +            "owner-1",
    +            terminal("COMPLETED", 3000),
    +        )
    +        .await
    +        .expect("finish");
    +    assert!(matches!(state, FinishState::Held(_)));
    +
    +    let record = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            with_initial_failure(new_op(fx, &fx.account_a, &table, None, "fp-terminal", 4000)),
    +            50,
    +        )
    +        .await,
    +    );
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3NoSuchBucket"));
    +    let capped = start(
    +        fx,
    +        &fx.account_a,
    +        with_initial_failure_capped(
    +            new_op(fx, &fx.account_a, &table, None, "fp-capped", 5000),
    +            0,
    +            0,
    +        ),
    +        50,
    +    )
    +    .await;
    +    assert!(
    +        matches!(capped, StartOutcome::FailedStartLimit),
    +        "on an idle table the terminal-born cap still refuses, got {}",
    +        start_kind(&capped)
    +    );
    +}
    diff --git a/crates/storage/src/bulk_operation_store_contract/lease.rs b/crates/storage/src/bulk_operation_store_contract/lease.rs
    new file mode 100644
    index 000000000..568882fd8
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store_contract/lease.rs
    @@ -0,0 +1,398 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Claim and lease: ownership, fencing, takeover, claim ordering, and lease expiry.
    +
    +use super::*;
    +
    +/// Two owners claiming from one barrier never claim the same row, and
    +/// between them they claim every runnable row exactly once.
    +pub async fn two_owners_claiming_concurrently_share_no_operation(
    +    fx: &Fixture,
    +) {
    +    let mut ids = HashSet::new();
    +    for i in 0..6 {
    +        let table = create_table(fx, &fx.account_a, &format!("claim_race_{i}")).await;
    +        let record = created(
    +            start(
    +                fx,
    +                &fx.account_a,
    +                new_op(fx, &fx.account_a, &table, None, "fp", 1000 + i),
    +                50,
    +            )
    +            .await,
    +        );
    +        ids.insert(record.operation_id);
    +    }
    +    let barrier = Arc::new(tokio::sync::Barrier::new(2));
    +    let mut tasks = Vec::new();
    +    for owner in ["owner-1", "owner-2"] {
    +        let engine = Arc::clone(&fx.engine);
    +        let barrier = Arc::clone(&barrier);
    +        tasks.push(tokio::spawn(async move {
    +            barrier.wait().await;
    +            engine
    +                .claim_runnable(owner, Duration::from_secs(60), 6, &[], &[])
    +                .await
    +                .map(|rows| {
    +                    rows.into_iter()
    +                        .map(|r| (r.operation_id, r.lease_owner))
    +                        .collect::>()
    +                })
    +        }));
    +    }
    +    let mut all_claimed = Vec::new();
    +    for (task, owner) in tasks.into_iter().zip(["owner-1", "owner-2"]) {
    +        for (id, lease_owner) in task.await.expect("join").expect("claim") {
    +            assert_eq!(
    +                lease_owner.as_deref(),
    +                Some(owner),
    +                "a claimed row carries its claimer"
    +            );
    +            all_claimed.push(id);
    +        }
    +    }
    +    let distinct: HashSet<_> = all_claimed.iter().cloned().collect();
    +    assert_eq!(
    +        distinct.len(),
    +        all_claimed.len(),
    +        "no row may be claimed by both owners"
    +    );
    +    assert_eq!(distinct, ids, "every runnable row is claimed exactly once");
    +}
    +
    +/// The claim predicate covers CANCELLING rows, so a cancelled operation whose
    +/// runner died is picked up and can be finished.
    +pub async fn claim_returns_cancelling_operations_too(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "claim_cancelling").await;
    +    let record = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table, None, "fp", 1000),
    +            50,
    +        )
    +        .await,
    +    );
    +    let cancel = fx
    +        .engine
    +        .request_cancel(&fx.account_a, &record.operation_id)
    +        .await
    +        .expect("request_cancel");
    +    assert!(matches!(cancel, CancelOutcome::Cancelling));
    +
    +    let claimed = fx
    +        .engine
    +        .claim_runnable("owner-1", Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    let row = claimed
    +        .iter()
    +        .find(|r| r.operation_id == record.operation_id)
    +        .expect("a CANCELLING row with no lease must be claimable");
    +    assert_eq!(row.status, "CANCELLING");
    +}
    +
    +/// A held lease blocks a second claim; once it expires the row is claimable
    +/// again by another owner.
    +pub async fn a_live_lease_blocks_claim_until_it_expires(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "lease_expiry").await;
    +    let record = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table, None, "fp", 1000),
    +            50,
    +        )
    +        .await,
    +    );
    +
    +    // The blocked claim below must run before this lease expires. Two
    +    // seconds gives that one round trip a wide margin under host load;
    +    // anything the lease outlives here it also outlives in the expiry
    +    // sleep, which only needs to exceed it.
    +    let first = fx
    +        .engine
    +        .claim_runnable("owner-1", Duration::from_secs(2), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert!(first.iter().any(|r| r.operation_id == record.operation_id));
    +
    +    let blocked = fx
    +        .engine
    +        .claim_runnable("owner-2", Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert!(
    +        !blocked
    +            .iter()
    +            .any(|r| r.operation_id == record.operation_id),
    +        "a live lease must block the claim"
    +    );
    +
    +    tokio::time::sleep(Duration::from_millis(2500)).await;
    +
    +    let reclaimed = fx
    +        .engine
    +        .claim_runnable("owner-2", Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    let row = reclaimed
    +        .iter()
    +        .find(|r| r.operation_id == record.operation_id)
    +        .expect("an expired lease must release the row");
    +    assert_eq!(row.lease_owner.as_deref(), Some("owner-2"));
    +}
    +
    +/// Claiming honours `max` and takes the oldest starts first.
    +pub async fn claim_prefers_the_oldest_start_and_honours_max(fx: &Fixture) {
    +    let mut ids_by_time = Vec::new();
    +    for (i, t) in [3000_i64, 1000, 2000].iter().enumerate() {
    +        let table = create_table(fx, &fx.account_a, &format!("claim_order_{i}")).await;
    +        let record = created(
    +            start(
    +                fx,
    +                &fx.account_a,
    +                new_op(fx, &fx.account_a, &table, None, "fp", *t),
    +                50,
    +            )
    +            .await,
    +        );
    +        ids_by_time.push((*t, record.operation_id));
    +    }
    +    ids_by_time.sort();
    +
    +    let claimed = fx
    +        .engine
    +        .claim_runnable("owner-1", Duration::from_secs(60), 2, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert_eq!(claimed.len(), 2, "claim must honour max");
    +    let claimed_ids: HashSet<_> = claimed.iter().map(|r| r.operation_id.clone()).collect();
    +    let oldest_two: HashSet<_> = ids_by_time[..2].iter().map(|(_, id)| id.clone()).collect();
    +    assert_eq!(
    +        claimed_ids, oldest_two,
    +        "claim takes the oldest starts first"
    +    );
    +}
    +
    +/// Rows of an excluded account are skipped even when they are the oldest
    +/// and `max` has room for them: the claim takes only the other account's.
    +pub async fn claim_skips_the_rows_of_excluded_accounts(fx: &Fixture) {
    +    let mut ids_by_account: HashMap> = HashMap::new();
    +    // The excluded account's rows start older, so a claim that merely
    +    // orders by start time without the filter would take them first.
    +    for (account, base_time) in [(&fx.account_a, 1000_i64), (&fx.account_b, 5000)] {
    +        for i in 0..2 {
    +            let table = create_table(fx, account, &format!("claim_exclude_{i}")).await;
    +            let record = created(
    +                start(
    +                    fx,
    +                    account,
    +                    new_op(fx, account, &table, None, "fp", base_time + i),
    +                    50,
    +                )
    +                .await,
    +            );
    +            ids_by_account
    +                .entry(account.clone())
    +                .or_default()
    +                .insert(record.operation_id);
    +        }
    +    }
    +
    +    let claimed = fx
    +        .engine
    +        .claim_runnable(
    +            "owner-1",
    +            Duration::from_secs(60),
    +            4,
    +            std::slice::from_ref(&fx.account_a),
    +            &[],
    +        )
    +        .await
    +        .expect("claim");
    +    let claimed_ids: HashSet<_> = claimed.iter().map(|r| r.operation_id.clone()).collect();
    +    assert_eq!(
    +        claimed_ids, ids_by_account[&fx.account_b],
    +        "the claim must take exactly the non-excluded account's rows"
    +    );
    +}
    +
    +/// Operation ids in the exclude list are skipped even when they are the
    +/// oldest rows and `max` has room for them: the claim takes exactly the
    +/// others and leaves the excluded rows unleased. The worker passes the
    +/// operation ids running in its process, so a run that outlives its lease
    +/// is never claimed a second time by the process still running it.
    +pub async fn claim_skips_the_operations_in_the_exclude_list(fx: &Fixture) {
    +    let mut ids = Vec::new();
    +    // The excluded rows start oldest, so a claim that merely orders by
    +    // start time without the filter would take them first.
    +    for i in 0..4_i64 {
    +        let table = create_table(fx, &fx.account_a, &format!("claim_exclude_op_{i}")).await;
    +        let record = created(
    +            start(
    +                fx,
    +                &fx.account_a,
    +                new_op(fx, &fx.account_a, &table, None, "fp", 1000 + i),
    +                50,
    +            )
    +            .await,
    +        );
    +        ids.push(record.operation_id);
    +    }
    +    let excluded = vec![ids[0].clone(), ids[1].clone()];
    +
    +    let claimed = fx
    +        .engine
    +        .claim_runnable("owner-1", Duration::from_secs(60), 10, &[], &excluded)
    +        .await
    +        .expect("claim");
    +    let claimed_ids: HashSet<_> = claimed.iter().map(|r| r.operation_id.clone()).collect();
    +    let expected: HashSet<_> = ids[2..].iter().cloned().collect();
    +    assert_eq!(
    +        claimed_ids, expected,
    +        "the claim must skip exactly the excluded operation ids"
    +    );
    +    for id in &excluded {
    +        let record = get(fx, &fx.account_a, id).await.expect("record");
    +        assert_eq!(
    +            record.lease_owner, None,
    +            "an excluded row must not be leased"
    +        );
    +    }
    +}
    +
    +/// Claimed rows come back oldest start first in the returned Vec, not merely
    +/// as the right set: a capped runner drains claims in Vec order, so the
    +/// order is the fairness policy.
    +pub async fn claim_returns_records_oldest_start_first_in_order(fx: &Fixture) {
    +    let mut by_time = Vec::new();
    +    for (i, t) in [3000_i64, 1000, 2000].iter().enumerate() {
    +        let table = create_table(fx, &fx.account_a, &format!("claim_vec_order_{i}")).await;
    +        let record = created(
    +            start(
    +                fx,
    +                &fx.account_a,
    +                new_op(fx, &fx.account_a, &table, None, "fp", *t),
    +                50,
    +            )
    +            .await,
    +        );
    +        by_time.push((*t, record.operation_id));
    +    }
    +    by_time.sort();
    +
    +    let claimed = fx
    +        .engine
    +        .claim_runnable("owner-1", Duration::from_secs(60), 3, &[], &[])
    +        .await
    +        .expect("claim");
    +    let returned: Vec<(i64, String)> = claimed
    +        .iter()
    +        .map(|r| (r.start_time_ms, r.operation_id.clone()))
    +        .collect();
    +    assert_eq!(
    +        returned, by_time,
    +        "the returned Vec is ascending by (start_time_ms, operation_id)"
    +    );
    +}
    +
    +/// Two rows sharing a start time are claimed in `operation_id` order on every
    +/// backend, so the tiebreak does not depend on database collation or plan.
    +pub async fn claim_breaks_start_time_ties_by_operation_id(fx: &Fixture) {
    +    let mut ids = Vec::new();
    +    for i in 0..2 {
    +        let table = create_table(fx, &fx.account_a, &format!("claim_tie_{i}")).await;
    +        let record = created(
    +            start(
    +                fx,
    +                &fx.account_a,
    +                new_op(fx, &fx.account_a, &table, None, "fp", 1000),
    +                50,
    +            )
    +            .await,
    +        );
    +        ids.push(record.operation_id);
    +    }
    +    ids.sort();
    +
    +    let claimed = fx
    +        .engine
    +        .claim_runnable("owner-1", Duration::from_secs(60), 2, &[], &[])
    +        .await
    +        .expect("claim");
    +    let returned: Vec = claimed.iter().map(|r| r.operation_id.clone()).collect();
    +    assert_eq!(
    +        returned, ids,
    +        "equal start times order by operation_id ascending"
    +    );
    +}
    +
    +/// The checkpoint and `finish` bind on `lease_owner`, never on the lease
    +/// clock: an owner whose lease has expired, on an operation no other owner
    +/// has claimed, still checkpoints and finishes as `Held`. Expiry gates
    +/// claiming; ownership gates writing. A runner stalled past its lease on a
    +/// loaded host must keep its run unless a takeover actually happened, and
    +/// every existing lease case exercises either a live lease or a takeover,
    +/// so this boundary would otherwise go unpinned.
    +pub async fn an_expired_unclaimed_lease_still_accepts_the_owners_checkpoint_and_finish<
    +    E: StorageEngine,
    +>(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "expired_owner_writes").await;
    +    let record = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table, None, "fp", 1000),
    +            50,
    +        )
    +        .await,
    +    );
    +    let claimed = fx
    +        .engine
    +        .claim_runnable("owner-1", Duration::from_millis(50), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert!(
    +        claimed
    +            .iter()
    +            .any(|r| r.operation_id == record.operation_id)
    +    );
    +    let id = record.operation_id;
    +
    +    // Long past the 50 ms lease. The sleep only needs to exceed the lease,
    +    // so a slower machine widens the margin instead of eroding it.
    +    tokio::time::sleep(Duration::from_millis(200)).await;
    +
    +    let state = fx
    +        .engine
    +        .checkpoint(
    +            &fx.account_a,
    +            &id,
    +            "owner-1",
    +            Duration::from_secs(60),
    +            progress(Vec::new()),
    +        )
    +        .await
    +        .expect("checkpoint");
    +    assert!(
    +        matches!(state, LeaseState::Held),
    +        "an expired lease no one claimed must not turn the owner's checkpoint into Lost"
    +    );
    +
    +    let state = fx
    +        .engine
    +        .finish(&fx.account_a, &id, "owner-1", terminal("COMPLETED", 2000))
    +        .await
    +        .expect("finish");
    +    assert!(
    +        matches!(&state, FinishState::Held(status) if status == "COMPLETED"),
    +        "the owner's finish must run after its lease expired unclaimed"
    +    );
    +    let row = get(fx, &fx.account_a, &id).await.expect("row");
    +    assert_eq!(row.status, "COMPLETED");
    +    assert_eq!(row.lease_owner, None, "finish clears the lease");
    +}
    diff --git a/crates/storage/src/bulk_operation_store_contract/ledger.rs b/crates/storage/src/bulk_operation_store_contract/ledger.rs
    new file mode 100644
    index 000000000..5790c4393
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store_contract/ledger.rs
    @@ -0,0 +1,293 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The row-outcome ledger: marked writes, condition failures, deletion scope, and
    +//! a second write at a recorded position.
    +
    +use super::*;
    +
    +/// The size `bulk_rows_recorded` reports for `item`.
    +fn recorded_size(item: &Item) -> Option {
    +    Some(i64::try_from(extenddb_core::types::item_size_bytes(item)).expect("item size fits"))
    +}
    +
    +/// A marked unconditional write commits the item and the Applied ledger row
    +/// together, with the size of the item it replaced, and that item only when
    +/// the marker asks to keep it.
    +pub async fn a_marked_write_records_applied_with_the_item(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "ledger_applied").await;
    +    let ki = key_info(fx, &table.table_name).await;
    +    let maps = ExpressionMaps::new(HashMap::new(), HashMap::new());
    +
    +    fx.engine
    +        .put_item(&ki, item("a", "before"), false, None, &maps, None, None)
    +        .await
    +        .expect("seed the replaced item");
    +    fx.engine
    +        .put_item(
    +            &ki,
    +            item("a", "1"),
    +            false,
    +            None,
    +            &maps,
    +            None,
    +            Some(&marker(&fx.account_a, "op-1", 5)),
    +        )
    +        .await
    +        .expect("a marked put");
    +    fx.engine
    +        .put_item(
    +            &ki,
    +            item("a", "2"),
    +            false,
    +            None,
    +            &maps,
    +            None,
    +            Some(&BulkRowMarker {
    +                keep_old: true,
    +                ..marker(&fx.account_a, "op-1", 6)
    +            }),
    +        )
    +        .await
    +        .expect("a marked put that keeps the replaced item");
    +    let recorded: Vec<(i64, RowOutcome, Option, Option)> = fx
    +        .engine
    +        .bulk_rows_recorded(&fx.account_a, "op-1", 0, 0, i64::MAX)
    +        .await
    +        .expect("bulk_rows_recorded")
    +        .into_iter()
    +        .map(|r| (r.row_index, r.outcome, r.old_size, r.old_image))
    +        .collect();
    +    assert_eq!(
    +        recorded,
    +        vec![
    +            (
    +                5,
    +                RowOutcome::Applied,
    +                recorded_size(&item("a", "before")),
    +                None
    +            ),
    +            (
    +                6,
    +                RowOutcome::Applied,
    +                recorded_size(&item("a", "1")),
    +                Some(item("a", "1"))
    +            ),
    +        ],
    +        "each write records the size of the item it replaced, and that item where kept"
    +    );
    +
    +    let stored = fx
    +        .engine
    +        .get_item(
    +            &ki,
    +            &Item::from([("pk".to_owned(), AttributeValue::S("a".to_owned()))]),
    +        )
    +        .await
    +        .expect("get_item");
    +    assert!(stored.is_some(), "the item landed");
    +    assert_eq!(
    +        fx.engine
    +            .bulk_row_outcome(&fx.account_a, "op-1", 0, 5)
    +            .await
    +            .expect("bulk_row_outcome"),
    +        Some(RowOutcome::Applied)
    +    );
    +}
    +
    +/// A marked conditional write whose condition fails records `ConditionFailed`,
    +/// writes no item change, and records no Applied row: the marker's fate is
    +/// tied to the write's.
    +pub async fn a_marked_condition_failure_records_the_failure_and_writes_nothing(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "ledger_condition").await;
    +    let ki = key_info(fx, &table.table_name).await;
    +    let maps = ExpressionMaps::new(HashMap::new(), HashMap::new());
    +
    +    fx.engine
    +        .put_item(&ki, item("a", "before"), false, None, &maps, None, None)
    +        .await
    +        .expect("seed the target item");
    +
    +    let tokens = expression::tokenize("attribute_not_exists(pk)").expect("tokenize");
    +    let condition = expression::parse_condition(&tokens).expect("parse the condition");
    +    let result = fx
    +        .engine
    +        .put_item(
    +            &ki,
    +            item("a", "after"),
    +            false,
    +            Some(&condition),
    +            &maps,
    +            None,
    +            Some(&marker(&fx.account_a, "op-2", 9)),
    +        )
    +        .await;
    +    assert!(
    +        matches!(result, Err(StorageError::ConditionFailed(_))),
    +        "the condition must fail against the existing item"
    +    );
    +
    +    let stored = fx
    +        .engine
    +        .get_item(
    +            &ki,
    +            &Item::from([("pk".to_owned(), AttributeValue::S("a".to_owned()))]),
    +        )
    +        .await
    +        .expect("get_item")
    +        .expect("the item is still there");
    +    assert_eq!(
    +        stored.get("v"),
    +        Some(&AttributeValue::S("before".to_owned())),
    +        "the refused write must not have touched the item"
    +    );
    +    assert_eq!(
    +        fx.engine
    +            .bulk_row_outcome(&fx.account_a, "op-2", 0, 9)
    +            .await
    +            .expect("bulk_row_outcome"),
    +        Some(RowOutcome::ConditionFailed),
    +        "the failure is recorded so a resumed run does not re-evaluate the row"
    +    );
    +    let recorded: Vec<(i64, RowOutcome, Option, Option)> = fx
    +        .engine
    +        .bulk_rows_recorded(&fx.account_a, "op-2", 0, 0, i64::MAX)
    +        .await
    +        .expect("bulk_rows_recorded")
    +        .into_iter()
    +        .map(|r| (r.row_index, r.outcome, r.old_size, r.old_image))
    +        .collect();
    +    assert_eq!(
    +        recorded,
    +        vec![(
    +            9,
    +            RowOutcome::ConditionFailed,
    +            recorded_size(&item("a", "before")),
    +            Some(item("a", "before"))
    +        )],
    +        "a failed condition records the item it was evaluated against"
    +    );
    +}
    +
    +/// A write with no marker leaves no ledger row.
    +pub async fn unmarked_writes_leave_no_ledger_row(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "ledger_unmarked").await;
    +    let ki = key_info(fx, &table.table_name).await;
    +    let maps = ExpressionMaps::new(HashMap::new(), HashMap::new());
    +
    +    fx.engine
    +        .put_item(&ki, item("a", "1"), false, None, &maps, None, None)
    +        .await
    +        .expect("an unmarked put");
    +    assert_eq!(
    +        fx.engine
    +            .bulk_row_outcome(&fx.account_a, "op-none", 0, 0)
    +            .await
    +            .expect("bulk_row_outcome"),
    +        None
    +    );
    +}
    +
    +/// Deleting one operation's ledger rows leaves another operation's alone and
    +/// reports how many went.
    +pub async fn ledger_deletion_is_scoped_to_one_operation(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "ledger_scope").await;
    +    let ki = key_info(fx, &table.table_name).await;
    +    let maps = ExpressionMaps::new(HashMap::new(), HashMap::new());
    +
    +    for (op, pk, row) in [("op-a", "a1", 1_i64), ("op-a", "a2", 2), ("op-b", "b1", 1)] {
    +        fx.engine
    +            .put_item(
    +                &ki,
    +                item(pk, "1"),
    +                false,
    +                None,
    +                &maps,
    +                None,
    +                Some(&marker(&fx.account_a, op, row)),
    +            )
    +            .await
    +            .expect("a marked put");
    +    }
    +
    +    let deleted = fx
    +        .engine
    +        .delete_bulk_row_outcomes(&fx.account_a, "op-a")
    +        .await
    +        .expect("delete_bulk_row_outcomes");
    +    assert_eq!(deleted, 2);
    +    assert_eq!(
    +        fx.engine
    +            .bulk_row_outcome(&fx.account_a, "op-a", 0, 1)
    +            .await
    +            .expect("read"),
    +        None
    +    );
    +    assert_eq!(
    +        fx.engine
    +            .bulk_row_outcome(&fx.account_a, "op-a", 0, 2)
    +            .await
    +            .expect("read"),
    +        None
    +    );
    +    assert_eq!(
    +        fx.engine
    +            .bulk_row_outcome(&fx.account_a, "op-b", 0, 1)
    +            .await
    +            .expect("read"),
    +        Some(RowOutcome::Applied),
    +        "the other operation's ledger survives"
    +    );
    +}
    +
    +/// A marked write at a position the ledger already records is refused and
    +/// changes nothing: every applied row is recorded once, so a second outcome
    +/// for one position means the row was sent after it was applied.
    +pub async fn a_marked_write_at_a_recorded_position_is_refused_and_changes_nothing<
    +    E: StorageEngine,
    +>(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "ledger_recorded").await;
    +    let ki = key_info(fx, &table.table_name).await;
    +    let maps = ExpressionMaps::new(HashMap::new(), HashMap::new());
    +    let m = marker(&fx.account_a, "op-recorded", 4);
    +
    +    fx.engine
    +        .put_item(&ki, item("a", "first"), false, None, &maps, None, Some(&m))
    +        .await
    +        .expect("the first marked put");
    +    let again = fx
    +        .engine
    +        .put_item(&ki, item("a", "second"), false, None, &maps, None, Some(&m))
    +        .await;
    +    assert!(
    +        again.is_err(),
    +        "the second write at the position is refused"
    +    );
    +
    +    let stored = fx
    +        .engine
    +        .get_item(
    +            &ki,
    +            &Item::from([("pk".to_owned(), AttributeValue::S("a".to_owned()))]),
    +        )
    +        .await
    +        .expect("get_item")
    +        .expect("the item is there");
    +    assert_eq!(
    +        stored.get("v"),
    +        Some(&AttributeValue::S("first".to_owned())),
    +        "the refused write rolled back with its ledger row"
    +    );
    +    assert_eq!(
    +        fx.engine
    +            .bulk_row_outcome(&fx.account_a, "op-recorded", 0, 4)
    +            .await
    +            .expect("bulk_row_outcome"),
    +        Some(RowOutcome::Applied),
    +        "one ledger row, the first outcome"
    +    );
    +}
    diff --git a/crates/storage/src/bulk_operation_store_contract/list.rs b/crates/storage/src/bulk_operation_store_contract/list.rs
    new file mode 100644
    index 000000000..e2e557d7c
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store_contract/list.rs
    @@ -0,0 +1,275 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Reads, account isolation, the operation list and its paging, and limit validation.
    +
    +use super::*;
    +
    +/// Another account cannot see or move an operation: get, list, cancel,
    +/// checkpoint, finish, and report reads all come back empty-handed, and the
    +/// row is untouched afterwards.
    +pub async fn an_operation_is_invisible_and_inert_to_another_account(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "isolation").await;
    +    let owner = "owner-1";
    +    let id = started_and_claimed(fx, &table, owner, 1000).await;
    +
    +    assert!(
    +        get(fx, &fx.account_b, &id).await.is_none(),
    +        "get must not cross accounts"
    +    );
    +
    +    let (rows, cursor) = fx
    +        .engine
    +        .list_bulk_operations(&fx.account_b, 100, None)
    +        .await
    +        .expect("list");
    +    assert!(
    +        rows.is_empty() && cursor.is_none(),
    +        "list must not cross accounts"
    +    );
    +
    +    let cancel = fx
    +        .engine
    +        .request_cancel(&fx.account_b, &id)
    +        .await
    +        .expect("request_cancel");
    +    assert!(
    +        matches!(cancel, CancelOutcome::NotFound),
    +        "cancel must not cross accounts"
    +    );
    +
    +    let checkpoint = fx
    +        .engine
    +        .checkpoint(
    +            &fx.account_b,
    +            &id,
    +            owner,
    +            Duration::from_secs(60),
    +            progress(vec![error_record(3)]),
    +        )
    +        .await
    +        .expect("checkpoint");
    +    assert!(
    +        matches!(checkpoint, LeaseState::Lost),
    +        "checkpoint must not cross accounts"
    +    );
    +
    +    let finish = fx
    +        .engine
    +        .finish(&fx.account_b, &id, owner, terminal("COMPLETED", 9000))
    +        .await
    +        .expect("finish");
    +    assert!(
    +        matches!(finish, FinishState::Lost),
    +        "finish must not cross accounts"
    +    );
    +
    +    let reports = all_report_records(fx, &fx.account_b, &id, ReportKind::Error).await;
    +    assert!(reports.is_empty(), "report reads must not cross accounts");
    +
    +    let page = fx
    +        .engine
    +        .report_records_page(&fx.account_b, &id, ReportKind::Error, None, 10)
    +        .await
    +        .expect("report_records_page");
    +    assert!(page.is_empty(), "paged reads must not cross accounts");
    +
    +    let row = get(fx, &fx.account_a, &id).await.expect("the row survives");
    +    assert_eq!(row.status, "IN_PROGRESS");
    +    assert!(!row.cancel_requested);
    +    assert_eq!(row.cursor_row_index, 0);
    +    assert_eq!(row.processed_item_count, 0);
    +}
    +
    +/// A created record reads back with the fields it was stored with.
    +pub async fn get_returns_the_record_as_stored(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "fidelity").await;
    +    let req = new_op(fx, &fx.account_a, &table, Some("tok"), "fp", 1234);
    +    let req_id = req.operation_id.clone();
    +    let record = created(start(fx, &fx.account_a, req, 50).await);
    +    assert_eq!(record.operation_id, req_id);
    +
    +    let row = get(fx, &fx.account_a, &req_id).await.expect("stored row");
    +    assert_eq!(row.account_id, fx.account_a);
    +    assert_eq!(row.table_id, table.table_id);
    +    assert_eq!(row.table_name, table.table_name);
    +    assert_eq!(
    +        row.table_arn,
    +        arn(&fx.region, &fx.account_a, &table.table_name)
    +    );
    +    assert_eq!(row.status, "IN_PROGRESS");
    +    assert_eq!(row.client_token.as_deref(), Some("tok"));
    +    assert_eq!(row.request_fingerprint, "fp");
    +    assert_eq!(
    +        row.input_config,
    +        json!({"S3Bucket": "in-bucket", "S3KeyPrefix": "data/"})
    +    );
    +    assert_eq!(
    +        row.output_config,
    +        json!({"S3Bucket": "out-bucket", "OutputType": ["ERROR"]})
    +    );
    +    assert_eq!(row.input_files.len(), 1);
    +    assert_eq!(row.input_files[0].path, "data/one.json");
    +    assert_eq!(row.input_files[0].size, 100);
    +    assert_eq!(row.max_write_request_units, Some(1000));
    +    assert_eq!(row.performance_tier.as_deref(), Some("STANDARD"));
    +    assert_eq!(row.start_time_ms, 1234);
    +    assert_eq!(row.end_time_ms, None);
    +    assert_eq!(row.processed_item_count, 0);
    +    assert_eq!(row.failed_item_count, 0);
    +    assert_eq!(row.processed_input_bytes, 0);
    +    assert_eq!(row.total_input_bytes, Some(100));
    +    assert_eq!(row.failure_code, None);
    +    assert_eq!(row.failure_message, None);
    +    assert!(!row.cancel_requested);
    +    assert_eq!(row.cursor_file_index, 0);
    +    assert_eq!(row.cursor_row_index, 0);
    +    assert_eq!(row.lease_owner, None);
    +    assert_eq!(row.lease_until_ms, None);
    +
    +    assert!(
    +        get(fx, &fx.account_a, "00000000000000deadbeef")
    +            .await
    +            .is_none(),
    +        "an unknown id reads as None, not an error"
    +    );
    +}
    +
    +/// Listing is newest first by (`start_time_ms`, `operation_id`), the cursor is
    +/// exclusive, and paging across a boundary neither skips nor repeats a row.
    +/// Two rows share a start time so the id tiebreak is exercised.
    +pub async fn list_pages_newest_first_with_an_exclusive_cursor(fx: &Fixture) {
    +    let times: [i64; 5] = [1000, 2000, 3000, 3000, 4000];
    +    let mut ids = Vec::new();
    +    for (i, t) in times.iter().enumerate() {
    +        let table = create_table(fx, &fx.account_a, &format!("list_{i}")).await;
    +        let record = created(
    +            start(
    +                fx,
    +                &fx.account_a,
    +                new_op(fx, &fx.account_a, &table, None, "fp", *t),
    +                50,
    +            )
    +            .await,
    +        );
    +        ids.push((record.start_time_ms, record.operation_id));
    +    }
    +    // The expected order: start_time_ms descending, operation_id descending
    +    // within a tie.
    +    let mut expected = ids.clone();
    +    expected.sort_by(|a, b| b.cmp(a));
    +
    +    let mut seen = Vec::new();
    +    let mut cursor: Option = None;
    +    let mut pages = 0;
    +    loop {
    +        let (page, next) = fx
    +            .engine
    +            .list_bulk_operations(&fx.account_a, 2, cursor.clone())
    +            .await
    +            .expect("list");
    +        assert!(page.len() <= 2, "the page must respect the limit");
    +        for r in &page {
    +            seen.push((r.start_time_ms, r.operation_id.clone()));
    +        }
    +        pages += 1;
    +        assert!(pages <= 5, "paging must terminate");
    +        match next {
    +            Some(c) => cursor = Some(c),
    +            None => break,
    +        }
    +    }
    +    assert_eq!(
    +        seen, expected,
    +        "full scan must be newest first with no gap or repeat"
    +    );
    +
    +    // The cursor is exclusive: paging from the newest row's own position
    +    // returns everything after it and never the row itself.
    +    let after_first = ListCursor {
    +        start_time_ms: expected[0].0,
    +        operation_id: expected[0].1.clone(),
    +    };
    +    let (rest, _) = fx
    +        .engine
    +        .list_bulk_operations(&fx.account_a, 100, Some(after_first))
    +        .await
    +        .expect("list after cursor");
    +    let rest_ids: Vec<_> = rest
    +        .iter()
    +        .map(|r| (r.start_time_ms, r.operation_id.clone()))
    +        .collect();
    +    assert_eq!(rest_ids, expected[1..].to_vec());
    +
    +    // A cursor naming a position no row occupies still pages by order, it
    +    // does not error: rows strictly after the position come back.
    +    let synthetic = ListCursor {
    +        start_time_ms: 3000,
    +        operation_id: "z".repeat(22),
    +    };
    +    let (after_synthetic, _) = fx
    +        .engine
    +        .list_bulk_operations(&fx.account_a, 100, Some(synthetic))
    +        .await
    +        .expect("list after a synthetic cursor");
    +    assert!(
    +        after_synthetic.iter().all(|r| r.start_time_ms < 3000
    +            || (r.start_time_ms == 3000 && r.operation_id.as_str() < "z")),
    +        "a synthetic cursor filters by order"
    +    );
    +}
    +
    +/// An account whose row count is an exact multiple of the page size ends on
    +/// a full second page with no cursor; a live cursor never yields an empty
    +/// page.
    +pub async fn list_on_an_exact_page_multiple_ends_without_a_cursor(
    +    fx: &Fixture,
    +) {
    +    for i in 0..4 {
    +        let table = create_table(fx, &fx.account_a, &format!("page_multiple_{i}")).await;
    +        created(
    +            start(
    +                fx,
    +                &fx.account_a,
    +                new_op(fx, &fx.account_a, &table, None, "fp", 1000 + i),
    +                50,
    +            )
    +            .await,
    +        );
    +    }
    +
    +    let (first, cursor) = fx
    +        .engine
    +        .list_bulk_operations(&fx.account_a, 2, None)
    +        .await
    +        .expect("first page");
    +    assert_eq!(first.len(), 2);
    +    let cursor = cursor.expect("two rows remain, so the first page carries a cursor");
    +
    +    let (second, end) = fx
    +        .engine
    +        .list_bulk_operations(&fx.account_a, 2, Some(cursor))
    +        .await
    +        .expect("second page");
    +    assert_eq!(
    +        second.len(),
    +        2,
    +        "a live cursor must never resolve to an empty page"
    +    );
    +    assert!(
    +        end.is_none(),
    +        "the exact multiple ends on the second page with no cursor"
    +    );
    +}
    +
    +/// A list limit of zero is refused as a validation error; an empty page
    +/// would read as an account with no operations.
    +pub async fn list_with_limit_zero_is_refused_as_validation(fx: &Fixture) {
    +    let result = fx.engine.list_bulk_operations(&fx.account_a, 0, None).await;
    +    assert!(
    +        matches!(result, Err(StorageError::Validation(_))),
    +        "limit 0 must be StorageError::Validation"
    +    );
    +}
    diff --git a/crates/storage/src/bulk_operation_store_contract/lock_waits.rs b/crates/storage/src/bulk_operation_store_contract/lock_waits.rs
    new file mode 100644
    index 000000000..5b55160eb
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store_contract/lock_waits.rs
    @@ -0,0 +1,169 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Starts whose locks are held by another transaction, and starts that hold
    +//! locks others need. The cases that need a lock held from outside take a
    +//! `hold` from the backend's test file, which keeps the lock from a
    +//! connection of its own for a number of milliseconds; a backend whose start
    +//! takes no such lock does not run them.
    +
    +use std::future::Future;
    +use std::time::Instant;
    +
    +use extenddb_core::types::DeleteTableInput;
    +
    +use super::*;
    +
    +/// How long `hold` keeps the lock in these cases.
    +const HOLD_MS: u64 = 3_000;
    +
    +/// The bound on a start that waits for no lock but the account's, held by
    +/// other starts for a few statements at a time.
    +const UNHELD_BOUND: Duration = Duration::from_millis(500);
    +
    +/// The table row of table U is held. Eight starts on U retry while it is
    +/// held. Five starts of the same account on tables of their own, sent
    +/// through the hold, each answer within [`UNHELD_BOUND`]. Once the row is
    +/// free one start on U is created and the others answer busy.
    +pub async fn a_start_waiting_on_a_held_row_does_not_delay_another_table(
    +    fx: &Fixture,
    +    hold: F,
    +) where
    +    E: StorageEngine + 'static,
    +    F: FnOnce(String, u64) -> Fut,
    +    Fut: Future + Send + 'static,
    +{
    +    let u = create_table(fx, &fx.account_a, "held_row_u").await;
    +    let mut others = Vec::new();
    +    for i in 0..5 {
    +        others.push(create_table(fx, &fx.account_a, &format!("held_row_v{i}")).await);
    +    }
    +    let holder = tokio::spawn(hold(u.table_id.clone(), HOLD_MS));
    +    tokio::time::sleep(Duration::from_millis(100)).await;
    +
    +    let waiting: Vec<_> = (0..8)
    +        .map(|i| {
    +            let engine = Arc::clone(&fx.engine);
    +            let account = fx.account_a.clone();
    +            let req = new_op(fx, &fx.account_a, &u, None, "fp", 2000 + i);
    +            tokio::spawn(async move { engine.start_bulk_operation(&account, req, 50).await })
    +        })
    +        .collect();
    +
    +    for (i, other) in others.iter().enumerate() {
    +        tokio::time::sleep(Duration::from_millis(400)).await;
    +        let called = Instant::now();
    +        let req = new_op(
    +            fx,
    +            &fx.account_a,
    +            other,
    +            None,
    +            "fp",
    +            3000 + 10 * i64::try_from(i).unwrap(),
    +        );
    +        let outcome = fx
    +            .engine
    +            .start_bulk_operation(&fx.account_a, req, 50)
    +            .await
    +            .expect("a start on another table");
    +        let elapsed = called.elapsed();
    +        assert_eq!(start_kind(&outcome), "Created", "the start on table v{i}");
    +        assert!(
    +            elapsed < UNHELD_BOUND,
    +            "the start on table v{i} waited {elapsed:?} while another table's row was held"
    +        );
    +    }
    +
    +    holder.await.expect("join the holder");
    +    let mut kinds = Vec::new();
    +    for task in waiting {
    +        let outcome = task
    +            .await
    +            .expect("join a start")
    +            .expect("a start on the held table answers");
    +        kinds.push(start_kind(&outcome));
    +    }
    +    kinds.sort_unstable();
    +    let mut expected = vec!["TableBusy"; 7];
    +    expected.insert(0, "Created");
    +    assert_eq!(kinds, expected, "the starts on the held table");
    +}
    +
    +/// Eight tasks send starts on table T in a loop for three seconds, each
    +/// refused by an account limit of 0 after it has taken its locks. A
    +/// `DeleteTable` of T sent into the stream answers within a second.
    +pub async fn a_stream_of_starts_does_not_hold_off_delete_table(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "start_stream").await;
    +    let until = Instant::now() + Duration::from_millis(HOLD_MS);
    +    let senders: Vec<_> = (0..8_i64)
    +        .map(|i| {
    +            let engine = Arc::clone(&fx.engine);
    +            let account = fx.account_a.clone();
    +            // More than a sender gets through in the stream's time.
    +            let mut reqs: Vec = (0..2_000_i64)
    +                .map(|k| new_op(fx, &fx.account_a, &table, None, "fp", 10_000 * (i + 1) + k))
    +                .collect();
    +            tokio::spawn(async move {
    +                let mut sent = 0u32;
    +                while Instant::now() < until {
    +                    let Some(req) = reqs.pop() else { break };
    +                    // The answers do not matter here: a limit refusal while
    +                    // the table exists, the deleting answer after.
    +                    let _ = engine.start_bulk_operation(&account, req, 0).await;
    +                    sent += 1;
    +                }
    +                sent
    +            })
    +        })
    +        .collect();
    +
    +    tokio::time::sleep(Duration::from_millis(300)).await;
    +    let called = Instant::now();
    +    fx.engine
    +        .delete_table(
    +            &fx.account_a,
    +            DeleteTableInput {
    +                table_name: table.table_name.clone(),
    +            },
    +        )
    +        .await
    +        .expect("DeleteTable during the stream of starts");
    +    let elapsed = called.elapsed();
    +
    +    let mut sent = 0;
    +    for sender in senders {
    +        sent += sender.await.expect("join a sender");
    +    }
    +    assert!(sent > 8, "the stream sent {sent} starts");
    +    assert!(
    +        elapsed < Duration::from_millis(1_000),
    +        "DeleteTable answered after {elapsed:?} with {sent} starts sent on the table"
    +    );
    +}
    +
    +/// The catalog's index table is held while a start reads the table's index
    +/// states, after it has its locks. The start waits for the read and is
    +/// created; it is not answered with a fault.
    +pub async fn a_start_whose_index_read_waits_is_created(fx: &Fixture, hold: F)
    +where
    +    E: StorageEngine + 'static,
    +    F: FnOnce(String, u64) -> Fut,
    +    Fut: Future + Send + 'static,
    +{
    +    let table = create_table(fx, &fx.account_a, "held_index_read").await;
    +    let holder = tokio::spawn(hold(table.table_id.clone(), 1_000));
    +    tokio::time::sleep(Duration::from_millis(100)).await;
    +    let outcome = fx
    +        .engine
    +        .start_bulk_operation(
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table, None, "fp", 1000),
    +            50,
    +        )
    +        .await
    +        .expect("a start whose index read waits answers");
    +    assert_eq!(start_kind(&outcome), "Created");
    +    holder.await.expect("join the holder");
    +}
    diff --git a/crates/storage/src/bulk_operation_store_contract/many_files.rs b/crates/storage/src/bulk_operation_store_contract/many_files.rs
    new file mode 100644
    index 000000000..f6589d11d
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store_contract/many_files.rs
    @@ -0,0 +1,190 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! A load of many one-row input files costs the same per file whatever the
    +//! file count: the checkpoint that moves the cursor past a file writes that
    +//! file's figures alone, and the read the runner makes at every batch
    +//! boundary reads the operation row without its file list.
    +
    +use std::time::Instant;
    +
    +use super::*;
    +
    +/// The lease the loads run under, long enough that no case here sees it
    +/// run out.
    +const LOAD_LEASE: Duration = Duration::from_secs(600);
    +
    +/// One checkpoint of a load of one-row files: the cursor moves past file
    +/// `index`, which finished with one row and a tag.
    +fn passed_file(index: usize) -> Progress {
    +    let cursor = i64::try_from(index + 1).unwrap_or(i64::MAX);
    +    Progress {
    +        cursor_file_index: cursor,
    +        cursor_row_index: 0,
    +        processed_item_count: cursor,
    +        failed_item_count: 0,
    +        processed_input_bytes: cursor.saturating_mul(10),
    +        report_record_count: 0,
    +        report_bytes: 0,
    +        usage: json!({"WriteRequestUnits": cursor}),
    +        records: Vec::new(),
    +        finished_files: vec![FinishedFile {
    +            index,
    +            rows: 1,
    +            etag: Some(format!("{index:032x}")),
    +        }],
    +        abandoned_files: Vec::new(),
    +    }
    +}
    +
    +/// Runs the store calls a load of `files` one-row files makes and returns
    +/// how long the per-file part took: for every file, the checkpoint that
    +/// passes it and the boundary read that follows. The start, the claim and
    +/// the finish run outside the timed part.
    +async fn time_a_load(fx: &Fixture, files: usize) -> Duration {
    +    let table = create_table(fx, &fx.account_a, &format!("many_files_{files}")).await;
    +    let owner = format!("many-files-{files}");
    +    let mut req = new_op(fx, &fx.account_a, &table, None, "fp-many-files", 1000);
    +    req.input_files = (0..files)
    +        .map(|i| InputFile {
    +            path: format!("data/part-{i:05}.json"),
    +            size: 10,
    +            ..InputFile::default()
    +        })
    +        .collect();
    +    let id = created(start(fx, &fx.account_a, req, 50).await).operation_id;
    +    let claimed = fx
    +        .engine
    +        .claim_runnable(&owner, LOAD_LEASE, 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    let record = claimed
    +        .iter()
    +        .find(|r| r.operation_id == id)
    +        .expect("the load is claimed");
    +    assert_eq!(
    +        record.input_files.len(),
    +        files,
    +        "the claim reads every file"
    +    );
    +
    +    let began = Instant::now();
    +    for index in 0..files {
    +        let state = fx
    +            .engine
    +            .checkpoint(&fx.account_a, &id, &owner, LOAD_LEASE, passed_file(index))
    +            .await
    +            .expect("checkpoint");
    +        assert!(matches!(state, LeaseState::Held), "{state:?}");
    +        let flags = fx
    +            .engine
    +            .get_bulk_operation_flags(&fx.account_a, &id)
    +            .await
    +            .expect("boundary read")
    +            .expect("the operation exists");
    +        assert!(!flags.cancel_requested);
    +        assert_eq!(flags.lease_owner.as_deref(), Some(owner.as_str()));
    +    }
    +    let elapsed = began.elapsed();
    +
    +    let row = get(fx, &fx.account_a, &id).await.expect("row");
    +    assert_eq!(row.input_files.len(), files);
    +    assert_eq!(row.cursor_file_index, i64::try_from(files).unwrap_or(0));
    +    for (index, file) in row.input_files.iter().enumerate() {
    +        assert_eq!(file.path, format!("data/part-{index:05}.json"));
    +        assert_eq!(file.rows, Some(1), "{}", file.path);
    +        assert_eq!(
    +            file.etag.as_deref(),
    +            Some(format!("{index:032x}").as_str()),
    +            "{}",
    +            file.path
    +        );
    +    }
    +    let done = fx
    +        .engine
    +        .finish(
    +            &fx.account_a,
    +            &id,
    +            &owner,
    +            terminal("COMPLETED", 2000 + i64::try_from(files).unwrap_or(0)),
    +        )
    +        .await
    +        .expect("finish");
    +    assert!(matches!(done, FinishState::Held(_)));
    +    elapsed
    +}
    +
    +/// A load of 5,000 one-row files costs no more per file than a load of
    +/// 500, and its per-file store calls finish inside two minutes. A
    +/// checkpoint that rewrote the whole file list, or a boundary read that
    +/// read it, makes each file cost in proportion to the file count, which
    +/// puts the larger load about ten times over the smaller one per file.
    +pub async fn a_many_file_load_costs_the_same_per_file_as_a_small_one(
    +    fx: &Fixture,
    +) {
    +    const SMALL: usize = 500;
    +    const LARGE: usize = 5_000;
    +    let small = time_a_load(fx, SMALL).await;
    +    let large = time_a_load(fx, LARGE).await;
    +    eprintln!(
    +        "store calls of a {SMALL}-file load took {small:?}, of a {LARGE}-file load {large:?}"
    +    );
    +    #[allow(clippy::cast_precision_loss)] // the file counts fit f64 exactly
    +    let (per_small, per_large) = (
    +        small.as_secs_f64() / SMALL as f64,
    +        large.as_secs_f64() / LARGE as f64,
    +    );
    +    assert!(
    +        large < Duration::from_secs(120),
    +        "the per-file store calls of a {LARGE}-file load took {large:?}"
    +    );
    +    assert!(
    +        per_large < per_small * 3.0,
    +        "a file of a {LARGE}-file load cost {:.3} ms against {:.3} ms in a {SMALL}-file load",
    +        per_large * 1000.0,
    +        per_small * 1000.0
    +    );
    +}
    +
    +/// The boundary read answers the status, the cancel flag and the lease
    +/// holder as a full read does, through a claim and a cancel, and `None`
    +/// for an operation no row names.
    +pub async fn the_boundary_read_follows_the_cancel_and_the_lease(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "boundary_flags").await;
    +    let id = started_and_claimed(fx, &table, "flags-owner", 1000).await;
    +    let read = |id: String| async move {
    +        fx.engine
    +            .get_bulk_operation_flags(&fx.account_a, &id)
    +            .await
    +            .expect("boundary read")
    +    };
    +    let flags = read(id.clone()).await.expect("the operation exists");
    +    assert_eq!(flags.status, "IN_PROGRESS");
    +    assert!(!flags.cancel_requested);
    +    assert_eq!(flags.lease_owner.as_deref(), Some("flags-owner"));
    +
    +    let cancel = fx
    +        .engine
    +        .request_cancel(&fx.account_a, &id)
    +        .await
    +        .expect("cancel");
    +    assert!(matches!(cancel, CancelOutcome::Cancelling));
    +    let flags = read(id.clone()).await.expect("the operation exists");
    +    assert_eq!(flags.status, "CANCELLING");
    +    assert!(flags.cancel_requested);
    +    let full = get(fx, &fx.account_a, &id).await.expect("row");
    +    assert_eq!(flags, crate::OperationFlags::of(&full));
    +
    +    assert!(
    +        read(op_id(1000)).await.is_none(),
    +        "an id no row names reads as absent"
    +    );
    +    assert!(
    +        fx.engine
    +            .get_bulk_operation_flags(&fx.account_b, &id)
    +            .await
    +            .expect("boundary read")
    +            .is_none(),
    +        "another account does not see the operation"
    +    );
    +}
    diff --git a/crates/storage/src/bulk_operation_store_contract/mod.rs b/crates/storage/src/bulk_operation_store_contract/mod.rs
    new file mode 100644
    index 000000000..9d2349b55
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store_contract/mod.rs
    @@ -0,0 +1,371 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Contract tests for [`crate::BulkOperationStore`] and the row-outcome
    +//! ledger, written once against the trait and run by every backend's test
    +//! suite. Compiled only under the `test-util` feature, the same arrangement
    +//! `extenddb-cache` uses for its test helpers.
    +//!
    +//! Each function is one behavioural case. A backend's integration test binary
    +//! builds a fresh engine per case (a throwaway database, so cases cannot see
    +//! each other's rows), wraps it in a [`Fixture`], and calls the function. The
    +//! functions assert with `panic!`, so they run under `#[tokio::test]` entry
    +//! points; the concurrent cases spawn real tasks and need a multi-thread
    +//! runtime.
    +//!
    +//! Every table a case works with is created through the engine's own
    +//! `create_table`, so the `table_id` the bulk operation binds to is one the
    +//! catalog really holds.
    +
    +// Every case asserts by panicking under a #[tokio::test] entry point, so
    +// per-function panics documentation would restate the whole file.
    +#![allow(clippy::missing_panics_doc)]
    +
    +use std::collections::{HashMap, HashSet};
    +use std::sync::Arc;
    +use std::sync::atomic::{AtomicU64, Ordering};
    +use std::time::{Duration, SystemTime, UNIX_EPOCH};
    +
    +use extenddb_core::expression::{self, ExpressionMaps};
    +use extenddb_core::types::{
    +    AttributeDefinition, AttributeValue, BillingMode, CreateTableInput, Item, KeySchemaElement,
    +    KeyType, ScalarAttributeType, TableDescription,
    +};
    +use serde_json::json;
    +
    +use crate::error::StorageError;
    +use crate::{
    +    AbandonKind, AbandonedFile, BatchRow, BatchRowResult, BulkRowMarker, CANCELLED_FAILURE_CODE,
    +    CANCELLED_FAILURE_MESSAGE, CLIENT_TOKEN_WINDOW_MS, CancelOutcome, FileAbandon, FinishState,
    +    FinishedFile, InputFile, LeaseState, ListCursor, NewBulkOperation, Progress, ReportKind,
    +    ReportRecord, RowOutcome, StartOutcome, StorageEngine, Terminal,
    +};
    +
    +/// One backend engine plus the two seeded accounts the cases work with.
    +///
    +/// The entry point seeds both account rows before constructing this: several
    +/// backends hold a foreign key from `tables` (and from the bulk operation
    +/// rows) to `accounts`, so an unseeded account would fail table creation, not
    +/// the assertion under test.
    +pub struct Fixture {
    +    pub engine: Arc,
    +    /// The account most cases run in.
    +    pub account_a: String,
    +    /// A second account, for the isolation case.
    +    pub account_b: String,
    +    /// Region used when formatting table ARNs.
    +    pub region: String,
    +}
    +
    +// ── Helpers ────────────────────────────────────────────────────────────
    +
    +static NEXT_ID: AtomicU64 = AtomicU64::new(1);
    +
    +fn unique() -> u64 {
    +    NEXT_ID.fetch_add(1, Ordering::Relaxed)
    +}
    +
    +/// An operation id in the wire shape: 14 zero-padded digits, 8 lowercase hex.
    +fn op_id(start_time_ms: i64) -> String {
    +    format!("{:014}{:08x}", start_time_ms, unique())
    +}
    +
    +/// Deterministic per-token stand-in for the SHA-256 hex the engine computes.
    +/// The store treats the hash as an opaque unique key, so any injective
    +/// encoding exercises the same index.
    +fn token_hash(token: &str) -> String {
    +    use std::fmt::Write;
    +    token.bytes().fold(String::new(), |mut acc, b| {
    +        let _ = write!(acc, "{b:02x}");
    +        acc
    +    })
    +}
    +
    +async fn create_table(
    +    fx: &Fixture,
    +    account_id: &str,
    +    name: &str,
    +) -> TableDescription {
    +    fx.engine
    +        .create_table(
    +            account_id,
    +            CreateTableInput {
    +                table_name: name.to_owned(),
    +                key_schema: vec![KeySchemaElement {
    +                    attribute_name: "pk".to_owned(),
    +                    key_type: KeyType::Hash,
    +                }],
    +                attribute_definitions: vec![AttributeDefinition {
    +                    attribute_name: "pk".to_owned(),
    +                    attribute_type: ScalarAttributeType::S,
    +                }],
    +                billing_mode: Some(BillingMode::PayPerRequest),
    +                ..Default::default()
    +            },
    +        )
    +        .await
    +        .expect("create a table through the engine")
    +}
    +
    +fn arn(fx_region: &str, account_id: &str, table_name: &str) -> String {
    +    format!("arn:aws:dynamodb:{fx_region}:{account_id}:table/{table_name}")
    +}
    +
    +/// A new-operation request bound to a real table.
    +fn new_op(
    +    fx: &Fixture,
    +    account_id: &str,
    +    table: &TableDescription,
    +    token: Option<&str>,
    +    fingerprint: &str,
    +    start_time_ms: i64,
    +) -> NewBulkOperation {
    +    NewBulkOperation {
    +        operation_id: op_id(start_time_ms),
    +        table_id: table.table_id.clone(),
    +        table_name: table.table_name.clone(),
    +        table_arn: arn(&fx.region, account_id, &table.table_name),
    +        client_token: token.map(str::to_owned),
    +        client_token_hash: token.map(token_hash),
    +        request_fingerprint: fingerprint.to_owned(),
    +        input_config: json!({"S3Bucket": "in-bucket", "S3KeyPrefix": "data/"}),
    +        output_config: json!({"S3Bucket": "out-bucket", "OutputType": ["ERROR"]}),
    +        input_files: vec![InputFile {
    +            path: "data/one.json".to_owned(),
    +            size: 100,
    +            rows: None,
    +            etag: None,
    +            abandon: None,
    +        }],
    +        max_write_request_units: Some(1000),
    +        performance_tier: Some("STANDARD".to_owned()),
    +        start_time_ms,
    +        total_input_bytes: Some(100),
    +        initial_failure: None,
    +        run_context: None,
    +    }
    +}
    +
    +fn start_kind(outcome: &StartOutcome) -> &'static str {
    +    match outcome {
    +        StartOutcome::Created(_) => "Created",
    +        StartOutcome::IdempotentReplay(_) => "IdempotentReplay",
    +        StartOutcome::IdempotentMismatch => "IdempotentMismatch",
    +        StartOutcome::TableBusy => "TableBusy",
    +        StartOutcome::AccountLimitReached => "AccountLimitReached",
    +        StartOutcome::FailedStartLimit => "FailedStartLimit",
    +        StartOutcome::IndexOperationInProgress => "IndexOperationInProgress",
    +    }
    +}
    +
    +fn created(outcome: StartOutcome) -> crate::BulkOperationRecord {
    +    match outcome {
    +        StartOutcome::Created(record) => record,
    +        other => panic!("expected Created, got {}", start_kind(&other)),
    +    }
    +}
    +
    +async fn start(
    +    fx: &Fixture,
    +    account_id: &str,
    +    req: NewBulkOperation,
    +    limit: u32,
    +) -> StartOutcome {
    +    fx.engine
    +        .start_bulk_operation(account_id, req, limit)
    +        .await
    +        .expect("start_bulk_operation must not error at the transport level")
    +}
    +
    +/// Start an operation and claim it for `owner`, returning its id. Most of the
    +/// checkpoint and finish cases need a claimed row as their starting state.
    +async fn started_and_claimed(
    +    fx: &Fixture,
    +    table: &TableDescription,
    +    owner: &str,
    +    start_time_ms: i64,
    +) -> String {
    +    let record = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, table, None, "fp", start_time_ms),
    +            50,
    +        )
    +        .await,
    +    );
    +    let claimed = fx
    +        .engine
    +        .claim_runnable(owner, Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert!(
    +        claimed
    +            .iter()
    +            .any(|r| r.operation_id == record.operation_id),
    +        "the freshly started operation must be claimable"
    +    );
    +    record.operation_id
    +}
    +
    +fn progress(records: Vec) -> Progress {
    +    let report_record_count = u64::try_from(records.len()).unwrap_or(u64::MAX);
    +    Progress {
    +        cursor_file_index: 0,
    +        cursor_row_index: 25,
    +        processed_item_count: 25,
    +        failed_item_count: 1,
    +        processed_input_bytes: 2500,
    +        report_record_count,
    +        report_bytes: report_record_count.saturating_mul(100),
    +        usage: json!({"WriteRequestUnits": 25600}),
    +        records,
    +        finished_files: Vec::new(),
    +        abandoned_files: Vec::new(),
    +    }
    +}
    +
    +fn error_record(row_index: i64) -> ReportRecord {
    +    ReportRecord {
    +        kind: ReportKind::Error,
    +        file_index: 0,
    +        row_index,
    +        record: json!({"itemIndex": row_index, "error": {"ErrorCode": "ValidationError"}}),
    +    }
    +}
    +
    +fn terminal(status: &str, end_time_ms: i64) -> Terminal {
    +    Terminal {
    +        status: status.to_owned(),
    +        end_time_ms,
    +        failure_code: None,
    +        failure_message: None,
    +        usage: json!({"WriteRequestUnits": 25600}),
    +    }
    +}
    +
    +async fn get(
    +    fx: &Fixture,
    +    account_id: &str,
    +    operation_id: &str,
    +) -> Option {
    +    fx.engine
    +        .get_bulk_operation(account_id, operation_id)
    +        .await
    +        .expect("get_bulk_operation")
    +}
    +
    +/// Every record body of one kind, read through the paged API the way the
    +/// report renderer reads it, with a page smaller than most fixtures so the
    +/// loop itself is exercised.
    +async fn all_report_records(
    +    fx: &Fixture,
    +    account_id: &str,
    +    operation_id: &str,
    +    kind: ReportKind,
    +) -> Vec {
    +    const PAGE: usize = 2;
    +    let mut bodies = Vec::new();
    +    let mut after = None;
    +    loop {
    +        let page = fx
    +            .engine
    +            .report_records_page(account_id, operation_id, kind, after, PAGE)
    +            .await
    +            .expect("report_records_page");
    +        let Some(last) = page.last() else {
    +            break;
    +        };
    +        after = Some((last.file_index, last.row_index));
    +        let full = page.len() == PAGE;
    +        bodies.extend(page.into_iter().map(|record| record.record));
    +        if !full {
    +            break;
    +        }
    +    }
    +    bodies
    +}
    +
    +// Row and marker helpers shared by the ledger, batch, replay, and purge
    +// cases; kept here so no single concern file owns them.
    +
    +async fn key_info(
    +    fx: &Fixture,
    +    table_name: &str,
    +) -> extenddb_core::types::TableKeyInfo {
    +    fx.engine
    +        .table_key_info(&fx.account_a, table_name)
    +        .await
    +        .expect("key info")
    +}
    +
    +fn item(pk: &str, v: &str) -> Item {
    +    Item::from([
    +        ("pk".to_owned(), AttributeValue::S(pk.to_owned())),
    +        ("v".to_owned(), AttributeValue::S(v.to_owned())),
    +    ])
    +}
    +
    +/// The write owner the ledger cases record before a marked batch.
    +const OWNER: &str = "owner-1";
    +
    +fn marker(account_id: &str, operation_id: &str, row_index: i64) -> BulkRowMarker {
    +    BulkRowMarker {
    +        account_id: account_id.to_owned(),
    +        operation_id: operation_id.to_owned(),
    +        file_index: 0,
    +        row_index,
    +        owner: OWNER.to_owned(),
    +        keep_old: false,
    +        settled: (0, 0),
    +    }
    +}
    +
    +/// Record [`OWNER`] as the operation's write owner, as a claim does before
    +/// its first batch; a marked batch of any other owner writes nothing.
    +async fn take_over(fx: &Fixture, operation_id: &str) {
    +    fx.engine
    +        .bulk_write_takeover(&fx.account_a, operation_id, OWNER, (0, 0))
    +        .await
    +        .expect("record the write owner");
    +}
    +
    +mod batch;
    +mod checkpoint;
    +mod counters;
    +mod finish;
    +mod index_crossing;
    +mod initial_failure;
    +mod lease;
    +mod ledger;
    +mod list;
    +mod lock_waits;
    +mod many_files;
    +mod purge;
    +mod refused_index_creation;
    +mod report;
    +mod run_context;
    +mod start;
    +mod table_deleting;
    +mod unreadable;
    +mod write_owner;
    +
    +pub use self::batch::*;
    +pub use self::checkpoint::*;
    +pub use self::counters::*;
    +pub use self::finish::*;
    +pub use self::index_crossing::*;
    +pub use self::initial_failure::*;
    +pub use self::lease::*;
    +pub use self::ledger::*;
    +pub use self::list::*;
    +pub use self::lock_waits::*;
    +pub use self::many_files::*;
    +pub use self::purge::*;
    +pub use self::refused_index_creation::*;
    +pub use self::report::*;
    +pub use self::run_context::*;
    +pub use self::start::*;
    +pub use self::table_deleting::*;
    +pub use self::unreadable::*;
    +pub use self::write_owner::*;
    diff --git a/crates/storage/src/bulk_operation_store_contract/purge.rs b/crates/storage/src/bulk_operation_store_contract/purge.rs
    new file mode 100644
    index 000000000..2151e7921
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store_contract/purge.rs
    @@ -0,0 +1,374 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Retention: the purgeable list, purge scoping, and token reuse after a purge.
    +
    +use super::*;
    +
    +/// The list names terminal rows ending before the cutoff, and only those:
    +/// a terminal row ending after the cutoff and a running row are both
    +/// absent from the list and both survive the purge of the listed pairs.
    +pub async fn purge_removes_only_terminal_operations_older_than_the_cutoff(
    +    fx: &Fixture,
    +) {
    +    let owner = "owner-1";
    +
    +    let old_table = create_table(fx, &fx.account_a, "purge_old").await;
    +    let old_id = started_and_claimed(fx, &old_table, owner, 1000).await;
    +    fx.engine
    +        .finish(&fx.account_a, &old_id, owner, terminal("COMPLETED", 1000))
    +        .await
    +        .expect("finish the old row");
    +
    +    let recent_table = create_table(fx, &fx.account_a, "purge_recent").await;
    +    let recent_id = started_and_claimed(fx, &recent_table, owner, 2000).await;
    +    fx.engine
    +        .finish(
    +            &fx.account_a,
    +            &recent_id,
    +            owner,
    +            terminal("COMPLETED", 9000),
    +        )
    +        .await
    +        .expect("finish the recent row");
    +
    +    let running_table = create_table(fx, &fx.account_a, "purge_running").await;
    +    let running = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &running_table, None, "fp", 500),
    +            50,
    +        )
    +        .await,
    +    );
    +
    +    let cutoff = UNIX_EPOCH + Duration::from_millis(5000);
    +    let purgeable = fx
    +        .engine
    +        .list_purgeable_before(cutoff, usize::MAX)
    +        .await
    +        .expect("list_purgeable_before");
    +    assert_eq!(
    +        purgeable,
    +        vec![(fx.account_a.clone(), old_id.clone())],
    +        "exactly the old terminal row is listed, named so the ledger can go first"
    +    );
    +
    +    fx.engine
    +        .purge_operations(&purgeable)
    +        .await
    +        .expect("purge_operations");
    +
    +    assert!(
    +        get(fx, &fx.account_a, &old_id).await.is_none(),
    +        "the old row is gone"
    +    );
    +    assert!(
    +        get(fx, &fx.account_a, &recent_id).await.is_some(),
    +        "a terminal row ending after the cutoff survives"
    +    );
    +    assert!(
    +        get(fx, &fx.account_a, &running.operation_id)
    +            .await
    +            .is_some(),
    +        "a running row survives whatever its age"
    +    );
    +
    +    let records = all_report_records(fx, &fx.account_a, &old_id, ReportKind::Error).await;
    +    assert!(
    +        records.is_empty(),
    +        "the purged row leaves no report records behind"
    +    );
    +}
    +
    +/// `list_purgeable_before` returns no more than `limit` rows, so one
    +/// retention pass is bounded and the worker drains a backlog in separately
    +/// committed chunks rather than one unbounded transaction. The oldest
    +/// terminal rows come first, so successive bounded passes make progress.
    +pub async fn list_purgeable_before_honors_the_limit(fx: &Fixture) {
    +    let owner = "owner-1";
    +    let mut ids = Vec::new();
    +    for (i, name) in ["lim_a", "lim_b", "lim_c", "lim_d", "lim_e"]
    +        .iter()
    +        .enumerate()
    +    {
    +        let table = create_table(fx, &fx.account_a, name).await;
    +        let id = started_and_claimed(fx, &table, owner, 1000 + i as i64).await;
    +        // Distinct end_time_ms so the oldest-first order is well defined.
    +        fx.engine
    +            .finish(
    +                &fx.account_a,
    +                &id,
    +                owner,
    +                terminal("COMPLETED", 1000 + i as i64),
    +            )
    +            .await
    +            .expect("finish");
    +        ids.push(id);
    +    }
    +
    +    let cutoff = UNIX_EPOCH + Duration::from_millis(5000);
    +
    +    // A limit below the backlog returns exactly that many rows.
    +    let page = fx
    +        .engine
    +        .list_purgeable_before(cutoff, 2)
    +        .await
    +        .expect("list_purgeable_before with a limit");
    +    assert_eq!(page.len(), 2, "the limit bounds the pass to two rows");
    +
    +    // The whole backlog is reachable with a generous limit.
    +    let all = fx
    +        .engine
    +        .list_purgeable_before(cutoff, usize::MAX)
    +        .await
    +        .expect("list_purgeable_before unbounded");
    +    assert_eq!(all.len(), 5, "every terminal row is eligible");
    +}
    +
    +/// The purge deletes exactly the pairs it is given: a listed pair held
    +/// back from the call keeps its record until a later purge names it.
    +pub async fn purge_deletes_only_the_given_pairs(fx: &Fixture) {
    +    let owner = "owner-1";
    +    let mut ids = Vec::new();
    +    for name in ["purge_pair_a", "purge_pair_b", "purge_pair_c"] {
    +        let table = create_table(fx, &fx.account_a, name).await;
    +        let id = started_and_claimed(fx, &table, owner, 1000).await;
    +        fx.engine
    +            .finish(&fx.account_a, &id, owner, terminal("COMPLETED", 1000))
    +            .await
    +            .expect("finish");
    +        ids.push(id);
    +    }
    +
    +    let cutoff = UNIX_EPOCH + Duration::from_millis(5000);
    +    let listed: HashSet<(String, String)> = fx
    +        .engine
    +        .list_purgeable_before(cutoff, usize::MAX)
    +        .await
    +        .expect("list_purgeable_before")
    +        .into_iter()
    +        .collect();
    +    let expected: HashSet<(String, String)> = ids
    +        .iter()
    +        .map(|id| (fx.account_a.clone(), id.clone()))
    +        .collect();
    +    assert_eq!(listed, expected, "all three terminal rows are listed");
    +
    +    // Purge two of the three; the held-back pair keeps its record, the
    +    // way a pair whose ledger delete failed is held back for the next
    +    // pass.
    +    let first_two: Vec<(String, String)> = ids[..2]
    +        .iter()
    +        .map(|id| (fx.account_a.clone(), id.clone()))
    +        .collect();
    +    fx.engine
    +        .purge_operations(&first_two)
    +        .await
    +        .expect("purge_operations");
    +    assert!(get(fx, &fx.account_a, &ids[0]).await.is_none());
    +    assert!(get(fx, &fx.account_a, &ids[1]).await.is_none());
    +    assert!(
    +        get(fx, &fx.account_a, &ids[2]).await.is_some(),
    +        "a pair the purge was not given survives"
    +    );
    +
    +    // A later purge naming the held-back pair (and one already-purged
    +    // pair, which is skipped) removes it.
    +    let rest = vec![
    +        (fx.account_a.clone(), ids[0].clone()),
    +        (fx.account_a.clone(), ids[2].clone()),
    +    ];
    +    fx.engine
    +        .purge_operations(&rest)
    +        .await
    +        .expect("purge_operations retries");
    +    assert!(
    +        get(fx, &fx.account_a, &ids[2]).await.is_none(),
    +        "the retried pair is purged"
    +    );
    +}
    +
    +/// Deleting an operation's record releases its `ClientToken`: a token that
    +/// replays while the terminal record exists creates a fresh operation once
    +/// the purge has removed the record.
    +pub async fn a_purged_operations_token_is_free_for_a_new_start(fx: &Fixture) {
    +    let owner = "owner-1";
    +    let table = create_table(fx, &fx.account_a, "purge_token").await;
    +    let first = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table, Some("tok-held"), "fp", 1000),
    +            50,
    +        )
    +        .await,
    +    );
    +    let claimed = fx
    +        .engine
    +        .claim_runnable(owner, Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert!(
    +        claimed.iter().any(|r| r.operation_id == first.operation_id),
    +        "the operation must be claimable"
    +    );
    +    fx.engine
    +        .finish(
    +            &fx.account_a,
    +            &first.operation_id,
    +            owner,
    +            terminal("COMPLETED", 1000),
    +        )
    +        .await
    +        .expect("finish");
    +
    +    // The token is retained for as long as the record is: the same start
    +    // replays rather than creating.
    +    let held = start(
    +        fx,
    +        &fx.account_a,
    +        new_op(fx, &fx.account_a, &table, Some("tok-held"), "fp", 2000),
    +        50,
    +    )
    +    .await;
    +    match held {
    +        StartOutcome::IdempotentReplay(record) => {
    +            assert_eq!(
    +                record.operation_id, first.operation_id,
    +                "the retained token must answer the terminal operation"
    +            );
    +        }
    +        other => panic!(
    +            "expected IdempotentReplay before the purge, got {}",
    +            start_kind(&other)
    +        ),
    +    }
    +
    +    fx.engine
    +        .purge_operations(&[(fx.account_a.clone(), first.operation_id.clone())])
    +        .await
    +        .expect("purge_operations");
    +    assert!(
    +        get(fx, &fx.account_a, &first.operation_id).await.is_none(),
    +        "the record is gone"
    +    );
    +
    +    let fresh = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table, Some("tok-held"), "fp", 3000),
    +            50,
    +        )
    +        .await,
    +    );
    +    assert_ne!(
    +        fresh.operation_id, first.operation_id,
    +        "the released token must create a fresh operation, not replay the purged one"
    +    );
    +}
    +
    +/// The cutoff conversion helper the entry points may want: a `SystemTime`
    +/// from epoch milliseconds.
    +#[must_use]
    +pub fn cutoff_at_ms(ms: u64) -> SystemTime {
    +    UNIX_EPOCH + Duration::from_millis(ms)
    +}
    +
    +/// The listed (`account_id`, `operation_id`) pairs name exactly the ledger
    +/// rows the retention worker must delete before it purges: driving
    +/// `delete_bulk_row_outcomes` with them removes the purged operation's
    +/// ledger and leaves the other operation's alone, and the purge that
    +/// follows removes the record.
    +pub async fn purged_pairs_name_the_ledger_rows_to_delete(fx: &Fixture) {
    +    let owner = "owner-1";
    +    let table = create_table(fx, &fx.account_a, "purge_ledger").await;
    +    let ki = key_info(fx, &table.table_name).await;
    +    let maps = ExpressionMaps::new(HashMap::new(), HashMap::new());
    +
    +    let old_table = create_table(fx, &fx.account_a, "purge_ledger_old").await;
    +    let old_id = started_and_claimed(fx, &old_table, owner, 1000).await;
    +    for (pk, row) in [("a1", 1_i64), ("a2", 2)] {
    +        fx.engine
    +            .put_item(
    +                &ki,
    +                item(pk, "1"),
    +                false,
    +                None,
    +                &maps,
    +                None,
    +                Some(&marker(&fx.account_a, &old_id, row)),
    +            )
    +            .await
    +            .expect("a marked put for the old operation");
    +    }
    +    fx.engine
    +        .finish(&fx.account_a, &old_id, owner, terminal("COMPLETED", 1000))
    +        .await
    +        .expect("finish the old operation");
    +
    +    let live_table = create_table(fx, &fx.account_a, "purge_ledger_live").await;
    +    let live_id = started_and_claimed(fx, &live_table, owner, 2000).await;
    +    fx.engine
    +        .put_item(
    +            &ki,
    +            item("b1", "1"),
    +            false,
    +            None,
    +            &maps,
    +            None,
    +            Some(&marker(&fx.account_a, &live_id, 1)),
    +        )
    +        .await
    +        .expect("a marked put for the live operation");
    +
    +    let purgeable = fx
    +        .engine
    +        .list_purgeable_before(cutoff_at_ms(5000), usize::MAX)
    +        .await
    +        .expect("list_purgeable_before");
    +    assert_eq!(
    +        purgeable,
    +        vec![(fx.account_a.clone(), old_id.clone())],
    +        "exactly the old terminal operation is named"
    +    );
    +
    +    // The worker's ordering: ledger rows first, while the record still
    +    // exists, then the purge.
    +    for (account_id, operation_id) in &purgeable {
    +        fx.engine
    +            .delete_bulk_row_outcomes(account_id, operation_id)
    +            .await
    +            .expect("delete the purged operation's ledger rows");
    +    }
    +
    +    for row in [1, 2] {
    +        assert_eq!(
    +            fx.engine
    +                .bulk_row_outcome(&fx.account_a, &old_id, 0, row)
    +                .await
    +                .expect("read"),
    +            None,
    +            "the purged operation's ledger rows are gone"
    +        );
    +    }
    +    assert_eq!(
    +        fx.engine
    +            .bulk_row_outcome(&fx.account_a, &live_id, 0, 1)
    +            .await
    +            .expect("read"),
    +        Some(RowOutcome::Applied),
    +        "the live operation's ledger survives"
    +    );
    +
    +    fx.engine
    +        .purge_operations(&purgeable)
    +        .await
    +        .expect("purge_operations");
    +    assert!(
    +        get(fx, &fx.account_a, &old_id).await.is_none(),
    +        "the record follows its ledger rows"
    +    );
    +}
    diff --git a/crates/storage/src/bulk_operation_store_contract/refused_index_creation.rs b/crates/storage/src/bulk_operation_store_contract/refused_index_creation.rs
    new file mode 100644
    index 000000000..2f37c7967
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store_contract/refused_index_creation.rs
    @@ -0,0 +1,222 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! An index creation that `UpdateTable` refuses does not refuse a start
    +//! that crossed it, and one it accepts does. A start is refused when an
    +//! index build on its table was accepted between its call and its
    +//! transaction; a request refused before it created anything started no
    +//! build.
    +
    +use extenddb_core::types::{DescribeTableInput, UpdateTableInput};
    +
    +use super::*;
    +
    +fn update_request(body: serde_json::Value) -> UpdateTableInput {
    +    serde_json::from_value(body).expect("a valid UpdateTable request")
    +}
    +
    +/// Creates the global secondary index `index` keyed on `g`.
    +fn create_gsi(table_name: &str, index: &str) -> serde_json::Value {
    +    json!({
    +        "TableName": table_name,
    +        "AttributeDefinitions": [
    +            { "AttributeName": "pk", "AttributeType": "S" },
    +            { "AttributeName": "g", "AttributeType": "S" }
    +        ],
    +        "GlobalSecondaryIndexUpdates": [{ "Create": {
    +            "IndexName": index,
    +            "KeySchema": [{ "AttributeName": "g", "KeyType": "HASH" }],
    +            "Projection": { "ProjectionType": "ALL" }
    +        }}]
    +    })
    +}
    +
    +/// Builds the body of the refused `UpdateTable` for a table name.
    +type RefusedBody = fn(&str) -> serde_json::Value;
    +
    +/// The status of every global secondary index on the table.
    +async fn index_states(fx: &Fixture, table_name: &str) -> Vec {
    +    let description = fx
    +        .engine
    +        .describe_table(
    +            &fx.account_a,
    +            DescribeTableInput {
    +                table_name: table_name.to_owned(),
    +            },
    +        )
    +        .await
    +        .expect("describe the table");
    +    let value = serde_json::to_value(&description).expect("serialize the description");
    +    value
    +        .get("GlobalSecondaryIndexes")
    +        .and_then(serde_json::Value::as_array)
    +        .map(|indexes| {
    +            indexes
    +                .iter()
    +                .map(|index| {
    +                    index
    +                        .get("IndexStatus")
    +                        .and_then(serde_json::Value::as_str)
    +                        .unwrap_or("?")
    +                        .to_owned()
    +                })
    +                .collect()
    +        })
    +        .unwrap_or_default()
    +}
    +
    +/// A table with the global secondary index `gix` `ACTIVE`.
    +async fn table_with_active_index(
    +    fx: &Fixture,
    +    table_name: &str,
    +) -> TableDescription {
    +    let table = create_table(fx, &fx.account_a, table_name).await;
    +    fx.engine
    +        .update_table(&fx.account_a, update_request(create_gsi(table_name, "gix")))
    +        .await
    +        .expect("create the first index");
    +    for _ in 0..300 {
    +        let states = index_states(fx, table_name).await;
    +        if !states.is_empty() && states.iter().all(|s| s == "ACTIVE") {
    +            return table;
    +        }
    +        tokio::time::sleep(Duration::from_millis(50)).await;
    +    }
    +    panic!(
    +        "the first index never became ACTIVE: {:?}",
    +        index_states(fx, table_name).await
    +    );
    +}
    +
    +/// With `gix` `ACTIVE`, a start is called (its build mark taken), then an
    +/// `UpdateTable` that is refused, then the start is awaited: it is
    +/// `Created`. Run for a creation of `gix` again (`IndexAlreadyExists`) and
    +/// for a creation of a new index listed with a deletion of an absent one
    +/// (`IndexNotFound`, refused after the creation's own checks passed).
    +pub async fn a_refused_index_creation_does_not_refuse_a_start_that_crossed_it(
    +    fx: &Fixture,
    +) {
    +    let cases: [(&str, RefusedBody); 2] = [
    +        ("refused_dup", |name| create_gsi(name, "gix")),
    +        ("refused_absent_delete", |name| {
    +            let mut body = create_gsi(name, "gix2");
    +            body["GlobalSecondaryIndexUpdates"]
    +                .as_array_mut()
    +                .expect("the updates array")
    +                .push(json!({ "Delete": { "IndexName": "absent" } }));
    +            body
    +        }),
    +    ];
    +    for (label, refused_body) in cases {
    +        let name = format!("{label}_{}", unique());
    +        let table = table_with_active_index(fx, &name).await;
    +        let req = new_op(fx, &fx.account_a, &table, None, "fp", 1000);
    +        let pending = fx.engine.start_bulk_operation(&fx.account_a, req, 1_000);
    +
    +        let refused = fx
    +            .engine
    +            .update_table(&fx.account_a, update_request(refused_body(&name)))
    +            .await;
    +        match (label, &refused) {
    +            ("refused_dup", Err(StorageError::IndexAlreadyExists(index))) => {
    +                assert_eq!(index, "gix");
    +            }
    +            ("refused_absent_delete", Err(StorageError::IndexNotFound(index))) => {
    +                assert_eq!(index, "absent");
    +            }
    +            _ => panic!("{label}: the UpdateTable is refused, got {refused:?}"),
    +        }
    +
    +        let outcome = pending
    +            .await
    +            .expect("start_bulk_operation must not error at the transport level");
    +        assert_eq!(
    +            start_kind(&outcome),
    +            "Created",
    +            "{label}: no build ran, so the start is accepted (indexes {:?})",
    +            index_states(fx, &name).await
    +        );
    +    }
    +}
    +
    +/// A start is called, its build mark taken, then an `UpdateTable` creates a
    +/// global secondary index and its build ends with the index `ACTIVE`, then
    +/// the start is awaited. The two crossed and the creation was accepted, so
    +/// the start is refused, though its transaction reads every index `ACTIVE`.
    +pub async fn an_accepted_index_creation_refuses_a_start_called_before_it(
    +    fx: &Fixture,
    +) {
    +    let name = format!("accepted_before_{}", unique());
    +    let table = create_table(fx, &fx.account_a, &name).await;
    +    let req = new_op(fx, &fx.account_a, &table, None, "fp", 1000);
    +    let pending = fx.engine.start_bulk_operation(&fx.account_a, req, 1_000);
    +
    +    fx.engine
    +        .update_table(&fx.account_a, update_request(create_gsi(&name, "gix")))
    +        .await
    +        .expect("the index creation is accepted");
    +    let mut states = index_states(fx, &name).await;
    +    for _ in 0..300 {
    +        if !states.is_empty() && states.iter().all(|s| s == "ACTIVE") {
    +            break;
    +        }
    +        tokio::time::sleep(Duration::from_millis(50)).await;
    +        states = index_states(fx, &name).await;
    +    }
    +    assert_eq!(states, vec!["ACTIVE".to_owned()], "the build ended");
    +
    +    let outcome = pending
    +        .await
    +        .expect("start_bulk_operation must not error at the transport level");
    +    assert_eq!(
    +        start_kind(&outcome),
    +        "IndexOperationInProgress",
    +        "the start crossed an accepted index creation"
    +    );
    +}
    +
    +/// An `UpdateTable` that creates an index fails at its data table, slowly:
    +/// `fail_index_tables` makes every later table creation in the data
    +/// database wait a second and then fail. A start called while it runs is
    +/// created once the creation has failed; the failed creation never counted
    +/// as a build.
    +pub async fn a_start_called_during_a_failing_index_creation_is_created(
    +    fx: &Fixture,
    +    fail_index_tables: F,
    +) where
    +    E: StorageEngine + 'static,
    +    F: FnOnce() -> Fut,
    +    Fut: std::future::Future,
    +{
    +    let name = format!("failing_creation_{}", unique());
    +    let table = create_table(fx, &fx.account_a, &name).await;
    +    fail_index_tables().await;
    +
    +    let engine = Arc::clone(&fx.engine);
    +    let account = fx.account_a.clone();
    +    let request = update_request(create_gsi(&name, "gix"));
    +    let update = tokio::spawn(async move { engine.update_table(&account, request).await });
    +    tokio::time::sleep(Duration::from_millis(300)).await;
    +
    +    let outcome = fx
    +        .engine
    +        .start_bulk_operation(
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table, None, "fp", 1000),
    +            1_000,
    +        )
    +        .await
    +        .expect("start_bulk_operation must not error at the transport level");
    +    let updated = update.await.expect("join the UpdateTable");
    +    assert!(
    +        updated.is_err(),
    +        "the index creation fails at its data table, got {:?}",
    +        updated.map(|d| d.table_status)
    +    );
    +    assert_eq!(
    +        start_kind(&outcome),
    +        "Created",
    +        "no index was created, so the start is accepted (indexes {:?})",
    +        index_states(fx, &name).await
    +    );
    +}
    diff --git a/crates/storage/src/bulk_operation_store_contract/report.rs b/crates/storage/src/bulk_operation_store_contract/report.rs
    new file mode 100644
    index 000000000..c44e6dd89
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store_contract/report.rs
    @@ -0,0 +1,311 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Paged report reads and the report totals that round-trip through checkpoint.
    +
    +use super::*;
    +
    +/// Report records read back in (`file_index`, `row_index`) ascending order,
    +/// whatever order their checkpoints inserted them in; the error report file
    +/// is rendered in this order.
    +pub async fn report_records_read_back_in_file_and_row_order(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "report_order").await;
    +    let owner = "owner-1";
    +    let id = started_and_claimed(fx, &table, owner, 1000).await;
    +
    +    let out_of_order = [(1_i64, 2_i64), (0, 9), (0, 1)];
    +    let records = out_of_order
    +        .iter()
    +        .map(|(file, row)| ReportRecord {
    +            kind: ReportKind::Error,
    +            file_index: *file,
    +            row_index: *row,
    +            record: json!({"file": file, "itemIndex": row}),
    +        })
    +        .collect();
    +    fx.engine
    +        .checkpoint(
    +            &fx.account_a,
    +            &id,
    +            owner,
    +            Duration::from_secs(60),
    +            Progress {
    +                cursor_file_index: 1,
    +                cursor_row_index: 3,
    +                processed_item_count: 12,
    +                failed_item_count: 3,
    +                processed_input_bytes: 1200,
    +                report_record_count: 3,
    +                report_bytes: 300,
    +                usage: json!({}),
    +                records,
    +                finished_files: Vec::new(),
    +                abandoned_files: Vec::new(),
    +            },
    +        )
    +        .await
    +        .expect("checkpoint");
    +
    +    let read = all_report_records(fx, &fx.account_a, &id, ReportKind::Error).await;
    +    let positions: Vec<(i64, i64)> = read
    +        .iter()
    +        .map(|r| {
    +            (
    +                r["file"].as_i64().expect("file"),
    +                r["itemIndex"].as_i64().expect("itemIndex"),
    +            )
    +        })
    +        .collect();
    +    assert_eq!(
    +        positions,
    +        vec![(0, 1), (0, 9), (1, 2)],
    +        "read-back is (file_index, row_index) ascending"
    +    );
    +}
    +
    +/// Insert seven error records across two files through two checkpoints, out
    +/// of order, plus one condition record that must stay out of the error pages.
    +async fn seed_paged_records(fx: &Fixture, table_stem: &str) -> String {
    +    let owner = "owner-1";
    +    let table = create_table(fx, &fx.account_a, table_stem).await;
    +    let id = started_and_claimed(fx, &table, owner, 1000).await;
    +
    +    let error_at = |file: i64, row: i64| ReportRecord {
    +        kind: ReportKind::Error,
    +        file_index: file,
    +        row_index: row,
    +        record: json!({"file": file, "itemIndex": row}),
    +    };
    +    let first = vec![error_at(1, 4), error_at(0, 9), error_at(0, 1)];
    +    let second = vec![
    +        error_at(1, 2),
    +        error_at(0, 5),
    +        error_at(1, 11),
    +        error_at(0, 3),
    +        ReportRecord {
    +            kind: ReportKind::ConditionCheckFailure,
    +            file_index: 0,
    +            row_index: 2,
    +            record: json!({"file": 0, "itemIndex": 2}),
    +        },
    +    ];
    +    for records in [first, second] {
    +        fx.engine
    +            .checkpoint(
    +                &fx.account_a,
    +                &id,
    +                owner,
    +                Duration::from_secs(60),
    +                progress(records),
    +            )
    +            .await
    +            .expect("checkpoint");
    +    }
    +    id
    +}
    +
    +/// The seeded error records in `(file_index, row_index)` ascending order.
    +const PAGED_POSITIONS: [(i64, i64); 7] = [(0, 1), (0, 3), (0, 5), (0, 9), (1, 2), (1, 4), (1, 11)];
    +
    +/// Paging with a limit smaller than the record count returns every record of
    +/// the requested kind exactly once, in `(file_index, row_index)` order, with
    +/// no row repeated or skipped across page boundaries.
    +pub async fn paged_report_reads_return_every_record_exactly_once(
    +    fx: &Fixture,
    +) {
    +    let id = seed_paged_records(fx, "report_page_walk").await;
    +
    +    let mut positions = Vec::new();
    +    let mut after = None;
    +    loop {
    +        let page = fx
    +            .engine
    +            .report_records_page(&fx.account_a, &id, ReportKind::Error, after, 3)
    +            .await
    +            .expect("report_records_page");
    +        assert!(page.len() <= 3, "a page must not exceed its limit");
    +        let Some(last) = page.last() else {
    +            break;
    +        };
    +        after = Some((last.file_index, last.row_index));
    +        for record in &page {
    +            assert!(
    +                matches!(record.kind, ReportKind::Error),
    +                "a page must hold only the requested kind"
    +            );
    +            assert_eq!(
    +                record.record["itemIndex"],
    +                json!(record.row_index),
    +                "the body rides with its own key"
    +            );
    +            positions.push((record.file_index, record.row_index));
    +        }
    +        if page.len() < 3 {
    +            break;
    +        }
    +    }
    +    assert_eq!(
    +        positions,
    +        PAGED_POSITIONS.to_vec(),
    +        "every error record appears exactly once, in (file_index, row_index) order"
    +    );
    +
    +    // The same operation id under another account pages nothing: the records
    +    // above exist, so a page query missing the account filter would leak them.
    +    let foreign = fx
    +        .engine
    +        .report_records_page(&fx.account_b, &id, ReportKind::Error, None, 10)
    +        .await
    +        .expect("report_records_page");
    +    assert!(foreign.is_empty(), "paged reads must not cross accounts");
    +}
    +
    +/// A page requested strictly after the last record is empty.
    +pub async fn a_report_page_after_the_last_record_is_empty(fx: &Fixture) {
    +    let id = seed_paged_records(fx, "report_page_end").await;
    +
    +    let last = *PAGED_POSITIONS.last().expect("seeded positions");
    +    let page = fx
    +        .engine
    +        .report_records_page(&fx.account_a, &id, ReportKind::Error, Some(last), 3)
    +        .await
    +        .expect("report_records_page");
    +    assert!(
    +        page.is_empty(),
    +        "after the last record there is nothing left to page"
    +    );
    +}
    +
    +/// A page limit of zero is refused as a validation error; an empty page
    +/// would read as the end of the report.
    +pub async fn a_report_page_with_limit_zero_is_refused_as_validation(
    +    fx: &Fixture,
    +) {
    +    let id = seed_paged_records(fx, "report_page_zero").await;
    +
    +    let result = fx
    +        .engine
    +        .report_records_page(&fx.account_a, &id, ReportKind::Error, None, 0)
    +        .await;
    +    assert!(
    +        matches!(result, Err(StorageError::Validation(_))),
    +        "limit 0 must be StorageError::Validation"
    +    );
    +}
    +
    +/// A report record whose body carries a NUL escape in a string value must
    +/// round-trip through the checkpoint and the paged read. DynamoDB string
    +/// values may contain U+0000, condition records embed the submitted item
    +/// verbatim, and the checkpoint carrying the record is the runner's only
    +/// path forward: a store that refuses the record body refuses every retry
    +/// of the same checkpoint, so the operation can never reach a terminal
    +/// state and its table guard never releases.
    +pub async fn a_report_record_with_a_nul_escape_round_trips_through_checkpoint(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "report_nul").await;
    +    let id = started_and_claimed(fx, &table, "owner-nul", 61_000).await;
    +
    +    let body = json!({
    +        "itemLocation": {"inputSourceArn": "arn:aws:s3:::in-bucket/data/one.json", "itemIndex": 0},
    +        "errorMessages": ["The conditional request failed"],
    +        "newImage": {"Item": {"pk": {"S": "a\u{0}b"}}}
    +    });
    +    let record = ReportRecord {
    +        kind: ReportKind::Error,
    +        file_index: 0,
    +        row_index: 0,
    +        record: body.clone(),
    +    };
    +    let state = fx
    +        .engine
    +        .checkpoint(
    +            &fx.account_a,
    +            &id,
    +            "owner-nul",
    +            Duration::from_secs(60),
    +            progress(vec![record]),
    +        )
    +        .await
    +        .expect("a checkpoint carrying a NUL escape in a record body must commit");
    +    assert!(matches!(state, LeaseState::Held));
    +
    +    let bodies = all_report_records(fx, &fx.account_a, &id, ReportKind::Error).await;
    +    assert_eq!(bodies.len(), 1, "the record must read back");
    +    assert_eq!(
    +        bodies[0]["newImage"]["Item"]["pk"]["S"],
    +        json!("a\u{0}b"),
    +        "the stored body must carry the NUL byte unchanged"
    +    );
    +}
    +
    +/// The report totals are zero on a fresh record and each checkpoint stores
    +/// the pair absolutely, like the counters; the runner owns accumulation.
    +pub async fn report_totals_round_trip_through_checkpoint_and_start_at_zero(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "report_totals").await;
    +    let owner = "owner-1";
    +    let record = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table, None, "fp", 1000),
    +            50,
    +        )
    +        .await,
    +    );
    +    assert_eq!(record.report_record_count, 0, "fresh records start at zero");
    +    assert_eq!(record.report_bytes, 0, "fresh records start at zero");
    +    let id = record.operation_id;
    +    let claimed = fx
    +        .engine
    +        .claim_runnable(owner, Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert!(claimed.iter().any(|r| r.operation_id == id));
    +
    +    let totals_progress = |count: u64, bytes: u64| Progress {
    +        cursor_file_index: 0,
    +        cursor_row_index: 10,
    +        processed_item_count: 10,
    +        failed_item_count: 0,
    +        processed_input_bytes: 1000,
    +        report_record_count: count,
    +        report_bytes: bytes,
    +        usage: json!({}),
    +        records: Vec::new(),
    +        finished_files: Vec::new(),
    +        abandoned_files: Vec::new(),
    +    };
    +    fx.engine
    +        .checkpoint(
    +            &fx.account_a,
    +            &id,
    +            owner,
    +            Duration::from_secs(60),
    +            totals_progress(7, 977),
    +        )
    +        .await
    +        .expect("checkpoint");
    +    let row = get(fx, &fx.account_a, &id).await.expect("row");
    +    assert_eq!(row.report_record_count, 7);
    +    assert_eq!(row.report_bytes, 977);
    +
    +    fx.engine
    +        .checkpoint(
    +            &fx.account_a,
    +            &id,
    +            owner,
    +            Duration::from_secs(60),
    +            totals_progress(9, 1200),
    +        )
    +        .await
    +        .expect("checkpoint");
    +    let row = get(fx, &fx.account_a, &id).await.expect("row");
    +    assert_eq!(
    +        (row.report_record_count, row.report_bytes),
    +        (9, 1200),
    +        "the stored totals are the last checkpoint's values, not a sum"
    +    );
    +}
    diff --git a/crates/storage/src/bulk_operation_store_contract/run_context.rs b/crates/storage/src/bulk_operation_store_contract/run_context.rs
    new file mode 100644
    index 000000000..33bf01c3a
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store_contract/run_context.rs
    @@ -0,0 +1,100 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The run context the engine records at Start: stored as given and handed
    +//! back by every read, and absent on a row started without one.
    +
    +use super::*;
    +
    +/// A run context recorded at Start comes back unchanged from the start
    +/// outcome, `get`, `list` and `claim_runnable`; an operation started without
    +/// one reads back `None` on each of them.
    +pub async fn the_run_context_round_trips_through_every_read(fx: &Fixture) {
    +    let with = create_table(fx, &fx.account_a, "run_context_with").await;
    +    let without = create_table(fx, &fx.account_a, "run_context_without").await;
    +    let context = json!({
    +        "store": "filesystem",
    +        "principal": {"identity": {"User": {"account_id": "111111111111", "user_name": "l\u{e9}a"}}},
    +        "values": [1, "two", null, {"nested": true}],
    +    });
    +    let mut req = new_op(fx, &fx.account_a, &with, None, "fp-with", 1_000);
    +    req.run_context = Some(context.clone());
    +    let first = created(start(fx, &fx.account_a, req, 50).await);
    +    assert_eq!(first.run_context.as_ref(), Some(&context));
    +    let req = new_op(fx, &fx.account_a, &without, None, "fp-without", 2_000);
    +    let second = created(start(fx, &fx.account_a, req, 50).await);
    +    assert_eq!(second.run_context, None);
    +
    +    let expected = |operation_id: &str| {
    +        if operation_id == first.operation_id {
    +            Some(context.clone())
    +        } else {
    +            None
    +        }
    +    };
    +    for id in [&first.operation_id, &second.operation_id] {
    +        let record = get(fx, &fx.account_a, id).await.expect("the record exists");
    +        assert_eq!(record.run_context, expected(id), "get, {id}");
    +    }
    +    let (listed, _) = fx
    +        .engine
    +        .list_bulk_operations(&fx.account_a, 100, None)
    +        .await
    +        .expect("list");
    +    assert_eq!(listed.len(), 2);
    +    for record in &listed {
    +        assert_eq!(
    +            record.run_context,
    +            expected(&record.operation_id),
    +            "list, {}",
    +            record.operation_id
    +        );
    +    }
    +    let claimed = fx
    +        .engine
    +        .claim_runnable("owner-1", Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert_eq!(claimed.len(), 2);
    +    for record in &claimed {
    +        assert_eq!(
    +            record.run_context,
    +            expected(&record.operation_id),
    +            "claim, {}",
    +            record.operation_id
    +        );
    +    }
    +}
    +
    +/// A row inserted already terminal keeps the run context Start recorded, and
    +/// the replay of its token returns it.
    +pub async fn a_terminal_born_row_keeps_its_run_context(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "run_context_born").await;
    +    let context = json!({"store": "filesystem"});
    +    let build = || {
    +        let mut req = new_op(fx, &fx.account_a, &table, Some("tok"), "fp", 1_000);
    +        req.input_files = Vec::new();
    +        req.total_input_bytes = None;
    +        req.initial_failure = Some(crate::InitialFailure {
    +            code: "S3AccessDenied".to_owned(),
    +            message: "Access Denied".to_owned(),
    +            cap: u64::MAX,
    +            window_start_ms: 0,
    +        });
    +        req.run_context = Some(context.clone());
    +        req
    +    };
    +    let record = created(start(fx, &fx.account_a, build(), 50).await);
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.run_context.as_ref(), Some(&context));
    +    let stored = get(fx, &fx.account_a, &record.operation_id)
    +        .await
    +        .expect("the record exists");
    +    assert_eq!(stored.run_context.as_ref(), Some(&context));
    +    match start(fx, &fx.account_a, build(), 50).await {
    +        StartOutcome::IdempotentReplay(replayed) => {
    +            assert_eq!(replayed.run_context.as_ref(), Some(&context));
    +        }
    +        other => panic!("expected IdempotentReplay, got {}", start_kind(&other)),
    +    }
    +}
    diff --git a/crates/storage/src/bulk_operation_store_contract/start.rs b/crates/storage/src/bulk_operation_store_contract/start.rs
    new file mode 100644
    index 000000000..e0ec05f6f
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store_contract/start.rs
    @@ -0,0 +1,568 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The start transaction: the table guard, client-token idempotency, concurrent
    +//! starts, and the account limit.
    +
    +use super::*;
    +
    +/// A second start on a table with a non-terminal operation is refused
    +/// synchronously, on the guard, not on the account limit.
    +pub async fn second_start_on_a_busy_table_is_refused(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "busy_guard").await;
    +    created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table, None, "fp", 1000),
    +            50,
    +        )
    +        .await,
    +    );
    +    let second = start(
    +        fx,
    +        &fx.account_a,
    +        new_op(fx, &fx.account_a, &table, None, "fp", 2000),
    +        50,
    +    )
    +    .await;
    +    assert!(
    +        matches!(second, StartOutcome::TableBusy),
    +        "expected TableBusy, got {}",
    +        start_kind(&second)
    +    );
    +}
    +
    +/// When both the table guard and the account limit would reject, the guard
    +/// answers: a second start on a busy table under an account limit of one is
    +/// `TableBusy`, not `AccountLimitReached`.
    +pub async fn the_table_guard_answers_before_the_account_limit(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "guard_before_limit").await;
    +    created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table, None, "fp", 1000),
    +            1,
    +        )
    +        .await,
    +    );
    +    let second = start(
    +        fx,
    +        &fx.account_a,
    +        new_op(fx, &fx.account_a, &table, None, "fp", 2000),
    +        1,
    +    )
    +    .await;
    +    assert!(
    +        matches!(second, StartOutcome::TableBusy),
    +        "expected TableBusy when the guard and the limit both reject, got {}",
    +        start_kind(&second)
    +    );
    +}
    +
    +/// Twenty starts released by one barrier on one table, each with its own token:
    +/// exactly one is admitted and the other nineteen see the table guard.
    +pub async fn twenty_concurrent_starts_with_distinct_tokens_admit_exactly_one<
    +    E: StorageEngine + 'static,
    +>(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "race_distinct").await;
    +    let barrier = Arc::new(tokio::sync::Barrier::new(20));
    +    let mut tasks = Vec::new();
    +    for i in 0..20 {
    +        let engine = Arc::clone(&fx.engine);
    +        let account = fx.account_a.clone();
    +        let req = new_op(
    +            fx,
    +            &fx.account_a,
    +            &table,
    +            Some(&format!("token-{i}")),
    +            "fp",
    +            1000 + i,
    +        );
    +        let barrier = Arc::clone(&barrier);
    +        tasks.push(tokio::spawn(async move {
    +            barrier.wait().await;
    +            engine.start_bulk_operation(&account, req, 50).await
    +        }));
    +    }
    +    let mut counts: HashMap<&'static str, usize> = HashMap::new();
    +    for task in tasks {
    +        let outcome = task.await.expect("join").expect("start");
    +        *counts.entry(start_kind(&outcome)).or_default() += 1;
    +    }
    +    assert_eq!(counts.get("Created"), Some(&1), "counts: {counts:?}");
    +    assert_eq!(counts.get("TableBusy"), Some(&19), "counts: {counts:?}");
    +}
    +
    +/// Twenty starts released by one barrier with one shared token and identical
    +/// parameters: one row is created, the other nineteen replay it, and none of
    +/// them is ever answered with the table guard.
    +pub async fn twenty_concurrent_starts_with_one_token_create_once_and_replay_the_rest<
    +    E: StorageEngine + 'static,
    +>(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "race_same_token").await;
    +    let barrier = Arc::new(tokio::sync::Barrier::new(20));
    +    let mut tasks = Vec::new();
    +    for i in 0..20 {
    +        let engine = Arc::clone(&fx.engine);
    +        let account = fx.account_a.clone();
    +        // Each request carries its own operation id, as each HTTP request
    +        // would; the winner's id is the one every replay must come back with.
    +        let req = new_op(
    +            fx,
    +            &fx.account_a,
    +            &table,
    +            Some("shared-token"),
    +            "fp",
    +            1000 + i,
    +        );
    +        let barrier = Arc::clone(&barrier);
    +        tasks.push(tokio::spawn(async move {
    +            barrier.wait().await;
    +            engine.start_bulk_operation(&account, req, 50).await
    +        }));
    +    }
    +    let mut created_ids = Vec::new();
    +    let mut replayed_ids = Vec::new();
    +    for task in tasks {
    +        match task.await.expect("join").expect("start") {
    +            StartOutcome::Created(r) => created_ids.push(r.operation_id),
    +            StartOutcome::IdempotentReplay(r) => replayed_ids.push(r.operation_id),
    +            other => panic!(
    +                "a same-token start must never see {}, least of all TableBusy",
    +                start_kind(&other)
    +            ),
    +        }
    +    }
    +    assert_eq!(created_ids.len(), 1);
    +    assert_eq!(replayed_ids.len(), 19);
    +    assert!(
    +        replayed_ids.iter().all(|id| id == &created_ids[0]),
    +        "every replay must return the winner's record"
    +    );
    +}
    +
    +/// Once the running operation reaches a terminal status, the table guard
    +/// releases and a new start on the same table is admitted.
    +pub async fn a_table_is_startable_again_after_its_operation_finishes(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "guard_release").await;
    +    let first = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table, None, "fp", 1000),
    +            50,
    +        )
    +        .await,
    +    );
    +    let claimed = fx
    +        .engine
    +        .claim_runnable("owner-1", Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert!(claimed.iter().any(|r| r.operation_id == first.operation_id));
    +    let state = fx
    +        .engine
    +        .finish(
    +            &fx.account_a,
    +            &first.operation_id,
    +            "owner-1",
    +            terminal("COMPLETED", 5000),
    +        )
    +        .await
    +        .expect("finish");
    +    assert!(matches!(state, FinishState::Held(_)));
    +    let second = start(
    +        fx,
    +        &fx.account_a,
    +        new_op(fx, &fx.account_a, &table, None, "fp", 6000),
    +        50,
    +    )
    +    .await;
    +    created(second);
    +}
    +
    +/// The per-table non-terminal count follows the operation's lifecycle and
    +/// is keyed by table id within the account: one while the operation runs
    +/// or is cancelling, zero for another table, another account, and after
    +/// the finish.
    +pub async fn the_per_table_non_terminal_count_follows_the_operation(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "guard_count").await;
    +    let other = create_table(fx, &fx.account_a, "guard_count_other").await;
    +    let count = |account: &str, table_id: &str| {
    +        let account = account.to_owned();
    +        let table_id = table_id.to_owned();
    +        async move {
    +            fx.engine
    +                .count_non_terminal_for_table(&account, &table_id)
    +                .await
    +                .expect("count")
    +        }
    +    };
    +    assert_eq!(count(&fx.account_a, &table.table_id).await, 0);
    +    let first = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table, None, "fp", 1000),
    +            50,
    +        )
    +        .await,
    +    );
    +    assert_eq!(count(&fx.account_a, &table.table_id).await, 1);
    +    assert_eq!(count(&fx.account_a, &other.table_id).await, 0);
    +    assert_eq!(count(&fx.account_b, &table.table_id).await, 0);
    +    let claimed = fx
    +        .engine
    +        .claim_runnable("owner-1", Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert!(claimed.iter().any(|r| r.operation_id == first.operation_id));
    +    fx.engine
    +        .finish(
    +            &fx.account_a,
    +            &first.operation_id,
    +            "owner-1",
    +            terminal("COMPLETED", 5000),
    +        )
    +        .await
    +        .expect("finish");
    +    assert_eq!(count(&fx.account_a, &table.table_id).await, 0);
    +}
    +
    +/// A reused token with a different fingerprint is refused as a mismatch and
    +/// no new row appears, even on a table with no running operation.
    +pub async fn a_reused_token_with_different_parameters_is_refused_and_creates_nothing<
    +    E: StorageEngine,
    +>(
    +    fx: &Fixture,
    +) {
    +    let table_one = create_table(fx, &fx.account_a, "mismatch_one").await;
    +    let table_two = create_table(fx, &fx.account_a, "mismatch_two").await;
    +    created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table_one, Some("tok"), "fp-a", 1000),
    +            50,
    +        )
    +        .await,
    +    );
    +    // A different table, so the only thing that can refuse this is the token.
    +    let second = start(
    +        fx,
    +        &fx.account_a,
    +        new_op(fx, &fx.account_a, &table_two, Some("tok"), "fp-b", 2000),
    +        50,
    +    )
    +    .await;
    +    assert!(
    +        matches!(second, StartOutcome::IdempotentMismatch),
    +        "expected IdempotentMismatch, got {}",
    +        start_kind(&second)
    +    );
    +    let (all, _) = fx
    +        .engine
    +        .list_bulk_operations(&fx.account_a, 100, None)
    +        .await
    +        .expect("list");
    +    assert_eq!(all.len(), 1, "the mismatch must not have created a row");
    +}
    +
    +/// A replayed token answers with the existing record whatever its status,
    +/// before the table guard is consulted: the replay succeeds even while a
    +/// different operation runs on the same table.
    +pub async fn a_reused_token_replays_even_when_terminal_and_skips_the_table_guard<
    +    E: StorageEngine,
    +>(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "replay_terminal").await;
    +    let first = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table, Some("tok"), "fp", 1000),
    +            50,
    +        )
    +        .await,
    +    );
    +    fx.engine
    +        .claim_runnable("owner-1", Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    fx.engine
    +        .finish(
    +            &fx.account_a,
    +            &first.operation_id,
    +            "owner-1",
    +            terminal("COMPLETED", 2000),
    +        )
    +        .await
    +        .expect("finish");
    +    // A second, unrelated operation now occupies the table.
    +    created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table, Some("tok-2"), "fp-2", 3000),
    +            50,
    +        )
    +        .await,
    +    );
    +    let replay = start(
    +        fx,
    +        &fx.account_a,
    +        new_op(fx, &fx.account_a, &table, Some("tok"), "fp", 4000),
    +        50,
    +    )
    +    .await;
    +    match replay {
    +        StartOutcome::IdempotentReplay(r) => {
    +            assert_eq!(r.operation_id, first.operation_id);
    +            assert_eq!(
    +                r.status, "COMPLETED",
    +                "the replay carries the current status"
    +            );
    +        }
    +        other => panic!("expected IdempotentReplay, got {}", start_kind(&other)),
    +    }
    +}
    +
    +/// A token replays its operation for [`CLIENT_TOKEN_WINDOW_MS`] from the
    +/// operation's start, then reads as unseen: the same request re-sent at or
    +/// past the window creates a new operation (measured on the service: a
    +/// replay within seconds, a new operation at 25 hours;
    +/// the window itself is the eight hours the service documents). The aged
    +/// record keeps its token for Describe and no longer occupies the token
    +/// index, so the new row inserts without a conflict.
    +pub async fn a_token_replays_inside_the_window_and_starts_anew_past_it(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "token_window").await;
    +    let t0 = 1_000_000;
    +    let first = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table, Some("tok-w"), "fp", t0),
    +            50,
    +        )
    +        .await,
    +    );
    +    fx.engine
    +        .claim_runnable("owner-1", Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    fx.engine
    +        .finish(
    +            &fx.account_a,
    +            &first.operation_id,
    +            "owner-1",
    +            terminal("COMPLETED", t0 + 1),
    +        )
    +        .await
    +        .expect("finish");
    +
    +    // One millisecond inside the window: a replay.
    +    let inside = start(
    +        fx,
    +        &fx.account_a,
    +        new_op(
    +            fx,
    +            &fx.account_a,
    +            &table,
    +            Some("tok-w"),
    +            "fp",
    +            t0 + CLIENT_TOKEN_WINDOW_MS - 1,
    +        ),
    +        50,
    +    )
    +    .await;
    +    match inside {
    +        StartOutcome::IdempotentReplay(r) => assert_eq!(r.operation_id, first.operation_id),
    +        other => panic!(
    +            "expected IdempotentReplay inside the window, got {}",
    +            start_kind(&other)
    +        ),
    +    }
    +    // A different fingerprint inside the window is still a mismatch.
    +    let mismatch = start(
    +        fx,
    +        &fx.account_a,
    +        new_op(
    +            fx,
    +            &fx.account_a,
    +            &table,
    +            Some("tok-w"),
    +            "fp-other",
    +            t0 + CLIENT_TOKEN_WINDOW_MS - 1,
    +        ),
    +        50,
    +    )
    +    .await;
    +    assert!(
    +        matches!(mismatch, StartOutcome::IdempotentMismatch),
    +        "expected IdempotentMismatch inside the window, got {}",
    +        start_kind(&mismatch)
    +    );
    +
    +    // At the window: a new operation, with the same or a different
    +    // fingerprint.
    +    let second = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(
    +                fx,
    +                &fx.account_a,
    +                &table,
    +                Some("tok-w"),
    +                "fp",
    +                t0 + CLIENT_TOKEN_WINDOW_MS,
    +            ),
    +            50,
    +        )
    +        .await,
    +    );
    +    assert_ne!(second.operation_id, first.operation_id);
    +    assert_eq!(second.client_token.as_deref(), Some("tok-w"));
    +    let aged = fx
    +        .engine
    +        .get_bulk_operation(&fx.account_a, &first.operation_id)
    +        .await
    +        .expect("get")
    +        .expect("the aged record still exists");
    +    assert_eq!(
    +        aged.client_token.as_deref(),
    +        Some("tok-w"),
    +        "the aged record keeps its token for Describe"
    +    );
    +    // The token now replays the new operation.
    +    let replay = start(
    +        fx,
    +        &fx.account_a,
    +        new_op(
    +            fx,
    +            &fx.account_a,
    +            &table,
    +            Some("tok-w"),
    +            "fp",
    +            t0 + CLIENT_TOKEN_WINDOW_MS + 5,
    +        ),
    +        50,
    +    )
    +    .await;
    +    match replay {
    +        StartOutcome::IdempotentReplay(r) => assert_eq!(r.operation_id, second.operation_id),
    +        other => panic!(
    +            "expected IdempotentReplay of the new operation, got {}",
    +            start_kind(&other)
    +        ),
    +    }
    +}
    +
    +/// Two starts without a token never collide on the token index: a NULL hash
    +/// is not a duplicate of another NULL hash on either backend.
    +pub async fn tokenless_starts_do_not_collide_on_the_token_index(fx: &Fixture) {
    +    let table_one = create_table(fx, &fx.account_a, "tokenless_one").await;
    +    let table_two = create_table(fx, &fx.account_a, "tokenless_two").await;
    +    created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table_one, None, "fp", 1000),
    +            50,
    +        )
    +        .await,
    +    );
    +    created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table_two, None, "fp", 2000),
    +            50,
    +        )
    +        .await,
    +    );
    +}
    +
    +/// Sixty starts released by one barrier on sixty distinct tables against an account
    +/// limit of fifty: exactly fifty are admitted and ten refused on the limit.
    +pub async fn sixty_concurrent_starts_against_a_limit_of_fifty_admit_exactly_fifty<
    +    E: StorageEngine + 'static,
    +>(
    +    fx: &Fixture,
    +) {
    +    let mut tables = Vec::new();
    +    for i in 0..60 {
    +        tables.push(create_table(fx, &fx.account_a, &format!("limit_{i}")).await);
    +    }
    +    let barrier = Arc::new(tokio::sync::Barrier::new(60));
    +    let mut tasks = Vec::new();
    +    for (i, table) in tables.iter().enumerate() {
    +        let engine = Arc::clone(&fx.engine);
    +        let account = fx.account_a.clone();
    +        let req = new_op(
    +            fx,
    +            &fx.account_a,
    +            table,
    +            None,
    +            "fp",
    +            1000 + i64::try_from(i).expect("task index fits in i64"),
    +        );
    +        let barrier = Arc::clone(&barrier);
    +        tasks.push(tokio::spawn(async move {
    +            barrier.wait().await;
    +            engine.start_bulk_operation(&account, req, 50).await
    +        }));
    +    }
    +    let mut counts: HashMap<&'static str, usize> = HashMap::new();
    +    for task in tasks {
    +        let outcome = task.await.expect("join").expect("start");
    +        *counts.entry(start_kind(&outcome)).or_default() += 1;
    +    }
    +    assert_eq!(counts.get("Created"), Some(&50), "counts: {counts:?}");
    +    assert_eq!(
    +        counts.get("AccountLimitReached"),
    +        Some(&10),
    +        "counts: {counts:?}"
    +    );
    +}
    +
    +/// The token index is account-scoped: the same token hash in two accounts
    +/// creates an operation in each, with no replay, mismatch, or guard answer
    +/// across the boundary.
    +pub async fn a_token_hash_shared_across_accounts_creates_in_each_account(
    +    fx: &Fixture,
    +) {
    +    let table_a = create_table(fx, &fx.account_a, "token_scope_a").await;
    +    let table_b = create_table(fx, &fx.account_b, "token_scope_b").await;
    +
    +    created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table_a, Some("shared"), "fp-a", 1000),
    +            50,
    +        )
    +        .await,
    +    );
    +    let in_b = start(
    +        fx,
    +        &fx.account_b,
    +        new_op(fx, &fx.account_b, &table_b, Some("shared"), "fp-b", 2000),
    +        50,
    +    )
    +    .await;
    +    created(in_b);
    +}
    diff --git a/crates/storage/src/bulk_operation_store_contract/table_deleting.rs b/crates/storage/src/bulk_operation_store_contract/table_deleting.rs
    new file mode 100644
    index 000000000..d746e6a18
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store_contract/table_deleting.rs
    @@ -0,0 +1,139 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! A start whose table `DeleteTable` set `DELETING`, or removed, after the
    +//! start handler's lookup. The handler refuses a `DELETING` table at its
    +//! lookup; the start transaction refuses it the same way when the status
    +//! changed or the row went away after that lookup, before its other checks
    +//! and before any insert.
    +
    +use extenddb_core::types::{DeleteTableInput, DescribeTableInput};
    +
    +use crate::bulk_operation_store::TABLE_DELETING_MESSAGE;
    +
    +use super::*;
    +
    +/// Calls the storage start and requires the refusal the handler gives for a
    +/// table being deleted.
    +async fn refused_as_deleting(
    +    fx: &Fixture,
    +    req: NewBulkOperation,
    +    label: &str,
    +) {
    +    match fx.engine.start_bulk_operation(&fx.account_a, req, 50).await {
    +        Err(StorageError::Validation(message)) => {
    +            assert_eq!(message, TABLE_DELETING_MESSAGE, "{label}");
    +        }
    +        Ok(outcome) => panic!("{label}: the start was answered {}", start_kind(&outcome)),
    +        Err(other) => panic!("{label}: unexpected error {other:?}"),
    +    }
    +}
    +
    +/// A table set `DELETING` after the lookup: a new start is refused with the
    +/// handler's answer and inserts nothing, and a start that would replay a
    +/// token of an operation started before the deletion is refused the same
    +/// way, as the handler's lookup refuses it before its token replay.
    +pub async fn a_start_on_a_table_set_deleting_after_the_lookup_is_refused(
    +    fx: &Fixture,
    +    editor: &dyn RowEditor,
    +) {
    +    let fresh = create_table(fx, &fx.account_a, "deleting_fresh").await;
    +    editor
    +        .mark_table_deleting(&fx.account_a, &fresh.table_id)
    +        .await;
    +    refused_as_deleting(
    +        fx,
    +        new_op(fx, &fx.account_a, &fresh, None, "fp", 1000),
    +        "a new start",
    +    )
    +    .await;
    +    let (records, cursor) = fx
    +        .engine
    +        .list_bulk_operations(&fx.account_a, 10, None)
    +        .await
    +        .expect("list");
    +    assert!(
    +        records.is_empty() && cursor.is_none(),
    +        "the refused start inserted nothing, got {:?}",
    +        records.iter().map(|r| &r.operation_id).collect::>()
    +    );
    +
    +    let replayed = create_table(fx, &fx.account_a, "deleting_replayed").await;
    +    let first = created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &replayed, Some("tok"), "fp", 2000),
    +            50,
    +        )
    +        .await,
    +    );
    +    editor
    +        .mark_table_deleting(&fx.account_a, &replayed.table_id)
    +        .await;
    +    refused_as_deleting(
    +        fx,
    +        new_op(fx, &fx.account_a, &replayed, Some("tok"), "fp", 3000),
    +        "a token replay",
    +    )
    +    .await;
    +    let (records, _) = fx
    +        .engine
    +        .list_bulk_operations(&fx.account_a, 10, None)
    +        .await
    +        .expect("list");
    +    let ids: Vec<&str> = records.iter().map(|r| r.operation_id.as_str()).collect();
    +    assert_eq!(
    +        ids,
    +        vec![first.operation_id.as_str()],
    +        "only the operation started before the deletion exists"
    +    );
    +}
    +
    +/// A table `DeleteTable` removed after the lookup, as it does when its
    +/// control-plane delay is under a second: the start is refused as for a
    +/// table being deleted and inserts nothing.
    +pub async fn a_start_on_a_table_removed_after_the_lookup_is_refused(
    +    fx: &Fixture,
    +) {
    +    let removed = create_table(fx, &fx.account_a, "removed_after_lookup").await;
    +    fx.engine
    +        .delete_table(
    +            &fx.account_a,
    +            DeleteTableInput {
    +                table_name: removed.table_name.clone(),
    +            },
    +        )
    +        .await
    +        .expect("delete the table");
    +    let described = fx
    +        .engine
    +        .describe_table(
    +            &fx.account_a,
    +            DescribeTableInput {
    +                table_name: removed.table_name.clone(),
    +            },
    +        )
    +        .await;
    +    assert!(
    +        matches!(described, Err(StorageError::TableNotFound(_))),
    +        "the fixture's DeleteTable removes the table row at once, got {:?}",
    +        described.map(|d| d.table_status)
    +    );
    +    refused_as_deleting(
    +        fx,
    +        new_op(fx, &fx.account_a, &removed, None, "fp", 1000),
    +        "a start on a removed table",
    +    )
    +    .await;
    +    let (records, cursor) = fx
    +        .engine
    +        .list_bulk_operations(&fx.account_a, 10, None)
    +        .await
    +        .expect("list");
    +    assert!(
    +        records.is_empty() && cursor.is_none(),
    +        "the refused start inserted nothing, got {:?}",
    +        records.iter().map(|r| &r.operation_id).collect::>()
    +    );
    +}
    diff --git a/crates/storage/src/bulk_operation_store_contract/unreadable.rs b/crates/storage/src/bulk_operation_store_contract/unreadable.rs
    new file mode 100644
    index 000000000..c83fac8c3
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store_contract/unreadable.rs
    @@ -0,0 +1,283 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! A stored operation that does not decode: the claim ends it and returns
    +//! the other rows, and the listing leaves it out and pages past it.
    +//!
    +//! No trait method can write such a row, so each backend's test file hands
    +//! these cases a [`RowEditor`] that edits the stored row with SQL.
    +
    +use futures::future::BoxFuture;
    +
    +use crate::bulk_operation_store::{UNREADABLE_FAILURE_CODE, UNREADABLE_FAILURE_MESSAGE};
    +
    +use super::*;
    +
    +/// Edits stored rows directly, for the cases that need a row the trait
    +/// cannot write.
    +pub trait RowEditor: Send + Sync {
    +    /// Sets the `abandon` member of the operation's first `input_files`
    +    /// element to one of a kind no build knows, so the row no longer
    +    /// decodes.
    +    fn corrupt<'a>(&'a self, account_id: &'a str, operation_id: &'a str) -> BoxFuture<'a, ()>;
    +
    +    /// Removes that `abandon` member again, so the row decodes and its
    +    /// stored status can be read through the trait.
    +    fn repair<'a>(&'a self, account_id: &'a str, operation_id: &'a str) -> BoxFuture<'a, ()>;
    +
    +    /// Sets the stored status of the table with this id to `DELETING`,
    +    /// the status `DeleteTable` leaves while the deletion is scheduled.
    +    fn mark_table_deleting<'a>(
    +        &'a self,
    +        account_id: &'a str,
    +        table_id: &'a str,
    +    ) -> BoxFuture<'a, ()>;
    +}
    +
    +fn now_ms() -> i64 {
    +    i64::try_from(
    +        SystemTime::now()
    +            .duration_since(UNIX_EPOCH)
    +            .unwrap_or_default()
    +            .as_millis(),
    +    )
    +    .unwrap_or(i64::MAX)
    +}
    +
    +/// Starts one operation on a new table of its own, so several can be
    +/// runnable at once.
    +async fn started(fx: &Fixture, table: &str, start_time_ms: i64) -> String {
    +    let table = create_table(fx, &fx.account_a, table).await;
    +    created(
    +        start(
    +            fx,
    +            &fx.account_a,
    +            new_op(fx, &fx.account_a, &table, None, "fp", start_time_ms),
    +            50,
    +        )
    +        .await,
    +    )
    +    .operation_id
    +}
    +
    +/// A runnable row that does not decode, older than a good one: the claim
    +/// returns the good one with its lease and ends the bad one `FAILED` with
    +/// the internal failure pair and no lease, and a second claim returns
    +/// neither.
    +pub async fn a_claim_ends_an_unreadable_row_and_returns_the_rest(
    +    fx: &Fixture,
    +    editor: &dyn RowEditor,
    +) {
    +    let bad = started(fx, "unreadable_claim_bad", 1000).await;
    +    let good = started(fx, "unreadable_claim_good", 2000).await;
    +    editor.corrupt(&fx.account_a, &bad).await;
    +
    +    let before = now_ms();
    +    let claimed = fx
    +        .engine
    +        .claim_runnable("claimer", Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("a claim over an unreadable row still succeeds");
    +    let ids: Vec<&str> = claimed.iter().map(|r| r.operation_id.as_str()).collect();
    +    assert_eq!(
    +        ids,
    +        vec![good.as_str()],
    +        "the claim returns the good row and only it"
    +    );
    +    let good_row = get(fx, &fx.account_a, &good)
    +        .await
    +        .expect("the good row exists");
    +    assert_eq!(good_row.lease_owner.as_deref(), Some("claimer"));
    +    assert!(
    +        good_row.lease_until_ms.is_some(),
    +        "the good row keeps its lease"
    +    );
    +
    +    match fx.engine.get_bulk_operation(&fx.account_a, &bad).await {
    +        Err(StorageError::Internal(_)) => {}
    +        other => panic!("the corrupt row still refuses the read, got {other:?}"),
    +    }
    +    editor.repair(&fx.account_a, &bad).await;
    +    let bad_row = get(fx, &fx.account_a, &bad)
    +        .await
    +        .expect("the bad row exists");
    +    assert_eq!(bad_row.status, "FAILED");
    +    assert_eq!(
    +        bad_row.failure_code.as_deref(),
    +        Some(UNREADABLE_FAILURE_CODE)
    +    );
    +    assert_eq!(
    +        bad_row.failure_message.as_deref(),
    +        Some(UNREADABLE_FAILURE_MESSAGE)
    +    );
    +    assert!(
    +        bad_row.end_time_ms.is_some_and(|t| t >= before),
    +        "the claim stamps end_time_ms with its own time, got {:?}",
    +        bad_row.end_time_ms
    +    );
    +    assert_eq!(bad_row.lease_owner, None, "the ended row holds no lease");
    +    assert_eq!(bad_row.lease_until_ms, None, "the ended row holds no lease");
    +
    +    let again = fx
    +        .engine
    +        .claim_runnable("other", Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("second claim");
    +    assert!(
    +        again.is_empty(),
    +        "the ended row is not runnable and the good row is leased, got {:?}",
    +        again.iter().map(|r| &r.operation_id).collect::>()
    +    );
    +}
    +
    +/// A row that does not decode between two good ones: a listing returns the
    +/// good ones and leaves the bad one out, and a page that ends on the bad row
    +/// carries a cursor past it.
    +pub async fn a_listing_skips_an_unreadable_row_and_pages_past_it(
    +    fx: &Fixture,
    +    editor: &dyn RowEditor,
    +) {
    +    let old = started(fx, "unreadable_list_old", 1000).await;
    +    let bad = started(fx, "unreadable_list_bad", 2000).await;
    +    let new = started(fx, "unreadable_list_new", 3000).await;
    +    editor.corrupt(&fx.account_a, &bad).await;
    +
    +    let ids = |records: &[crate::BulkOperationRecord]| -> Vec {
    +        records.iter().map(|r| r.operation_id.clone()).collect()
    +    };
    +
    +    let (records, cursor) = fx
    +        .engine
    +        .list_bulk_operations(&fx.account_a, 10, None)
    +        .await
    +        .expect("a listing over an unreadable row still succeeds");
    +    assert_eq!(ids(&records), vec![new.clone(), old.clone()]);
    +    assert!(cursor.is_none(), "one page holds every row");
    +
    +    // A page of two covers the new row and the bad one.
    +    let (first, cursor) = fx
    +        .engine
    +        .list_bulk_operations(&fx.account_a, 2, None)
    +        .await
    +        .expect("first page");
    +    assert_eq!(ids(&first), vec![new.clone()]);
    +    let cursor = cursor.expect("a row is left after the page");
    +    assert_eq!(
    +        cursor,
    +        ListCursor {
    +            start_time_ms: 2000,
    +            operation_id: bad.clone(),
    +        },
    +        "the cursor names the last row the page covered, the bad one"
    +    );
    +    let (second, cursor) = fx
    +        .engine
    +        .list_bulk_operations(&fx.account_a, 2, Some(cursor))
    +        .await
    +        .expect("second page");
    +    assert_eq!(ids(&second), vec![old]);
    +    assert!(cursor.is_none(), "nothing is left after the old row");
    +}
    +
    +/// A cancelled runnable row that does not decode ends as `finish` ends a
    +/// `CANCELLING` row: `CANCELLED` with the cancelled failure pair, no lease.
    +/// A good row beside it is still returned.
    +pub async fn a_claim_ends_an_unreadable_cancelled_row_cancelled(
    +    fx: &Fixture,
    +    editor: &dyn RowEditor,
    +) {
    +    let bad = started(fx, "unreadable_cancel_bad", 1000).await;
    +    let good = started(fx, "unreadable_cancel_good", 2000).await;
    +    let cancel = fx
    +        .engine
    +        .request_cancel(&fx.account_a, &bad)
    +        .await
    +        .expect("cancel");
    +    assert!(
    +        matches!(cancel, CancelOutcome::Cancelling),
    +        "the running row accepts the cancel"
    +    );
    +    editor.corrupt(&fx.account_a, &bad).await;
    +
    +    let before = now_ms();
    +    let claimed = fx
    +        .engine
    +        .claim_runnable("claimer", Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("a claim over an unreadable row still succeeds");
    +    let ids: Vec<&str> = claimed.iter().map(|r| r.operation_id.as_str()).collect();
    +    assert_eq!(ids, vec![good.as_str()], "the claim returns the good row");
    +
    +    editor.repair(&fx.account_a, &bad).await;
    +    let bad_row = get(fx, &fx.account_a, &bad)
    +        .await
    +        .expect("the bad row exists");
    +    assert_eq!(bad_row.status, "CANCELLED");
    +    assert_eq!(
    +        bad_row.failure_code.as_deref(),
    +        Some(CANCELLED_FAILURE_CODE)
    +    );
    +    assert_eq!(
    +        bad_row.failure_message.as_deref(),
    +        Some(CANCELLED_FAILURE_MESSAGE)
    +    );
    +    assert!(
    +        bad_row.end_time_ms.is_some_and(|t| t >= before),
    +        "the claim stamps end_time_ms with its own time, got {:?}",
    +        bad_row.end_time_ms
    +    );
    +    assert!(bad_row.cancel_requested, "the cancel request is kept");
    +    assert_eq!(bad_row.lease_owner, None, "the ended row holds no lease");
    +    assert_eq!(bad_row.lease_until_ms, None, "the ended row holds no lease");
    +}
    +
    +/// A row with checkpointed report records whose lease ran out, then made
    +/// unreadable: the claim that ends it deletes its report records in the
    +/// same transaction, as `finish` does.
    +pub async fn a_claim_ending_an_unreadable_row_deletes_its_report_records(
    +    fx: &Fixture,
    +    editor: &dyn RowEditor,
    +) {
    +    let id = started(fx, "unreadable_records", 1000).await;
    +    let lease = Duration::from_millis(50);
    +    let claimed = fx
    +        .engine
    +        .claim_runnable("owner-1", lease, 10, &[], &[])
    +        .await
    +        .expect("first claim");
    +    assert!(claimed.iter().any(|r| r.operation_id == id));
    +    let state = fx
    +        .engine
    +        .checkpoint(
    +            &fx.account_a,
    +            &id,
    +            "owner-1",
    +            lease,
    +            progress(vec![error_record(1), error_record(2)]),
    +        )
    +        .await
    +        .expect("checkpoint");
    +    assert!(matches!(state, LeaseState::Held), "{state:?}");
    +    let stored = all_report_records(fx, &fx.account_a, &id, ReportKind::Error).await;
    +    assert_eq!(stored.len(), 2, "the checkpoint stored both records");
    +
    +    // Long past the 50 ms lease, so the next claim selects the row again.
    +    tokio::time::sleep(Duration::from_millis(200)).await;
    +    editor.corrupt(&fx.account_a, &id).await;
    +    let claimed = fx
    +        .engine
    +        .claim_runnable("owner-2", Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("a claim over an unreadable row still succeeds");
    +    assert!(claimed.is_empty(), "the unreadable row is not returned");
    +
    +    let left = all_report_records(fx, &fx.account_a, &id, ReportKind::Error).await;
    +    assert!(
    +        left.is_empty(),
    +        "the ending deletes the row's report records, {} left",
    +        left.len()
    +    );
    +    editor.repair(&fx.account_a, &id).await;
    +    let row = get(fx, &fx.account_a, &id).await.expect("the row exists");
    +    assert_eq!(row.status, "FAILED");
    +}
    diff --git a/crates/storage/src/bulk_operation_store_contract/write_owner.rs b/crates/storage/src/bulk_operation_store_contract/write_owner.rs
    new file mode 100644
    index 000000000..3b13ed1cf
    --- /dev/null
    +++ b/crates/storage/src/bulk_operation_store_contract/write_owner.rs
    @@ -0,0 +1,448 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The write owner and the per-row ledger a resumed run reads back: every
    +//! marked batch row recorded with what it replaced, the owner check that
    +//! keeps an earlier claim's batch out, the prune at the checkpoint, and the
    +//! count of resumes that found the checkpoint where the last one left it.
    +
    +use super::*;
    +
    +fn marked(pk: &str, v: &str, m: BulkRowMarker) -> BatchRow {
    +    BatchRow {
    +        item: item(pk, v),
    +        condition: None,
    +        marker: Some(m),
    +        return_old: true,
    +    }
    +}
    +
    +fn marked_conditional(pk: &str, v: &str, condition: &str, m: BulkRowMarker) -> BatchRow {
    +    let tokens = expression::tokenize(condition).expect("tokenize");
    +    let expr = expression::parse_condition(&tokens).expect("parse the condition");
    +    BatchRow {
    +        item: item(pk, v),
    +        condition: Some((expr, ExpressionMaps::new(HashMap::new(), HashMap::new()))),
    +        marker: Some(m),
    +        return_old: true,
    +    }
    +}
    +
    +fn at(operation_id: &str, file_index: i64, row_index: i64, fx_account: &str) -> BulkRowMarker {
    +    BulkRowMarker {
    +        file_index,
    +        ..marker(fx_account, operation_id, row_index)
    +    }
    +}
    +
    +async fn recorded(
    +    fx: &Fixture,
    +    operation_id: &str,
    +    file_index: i64,
    +) -> Vec {
    +    fx.engine
    +        .bulk_rows_recorded(&fx.account_a, operation_id, file_index, 0, i64::MAX)
    +        .await
    +        .expect("bulk_rows_recorded")
    +}
    +
    +async fn stored(
    +    fx: &Fixture,
    +    ki: &extenddb_core::types::TableKeyInfo,
    +    pk: &str,
    +) -> Option {
    +    fx.engine
    +        .get_item(
    +            ki,
    +            &Item::from([("pk".to_owned(), AttributeValue::S(pk.to_owned()))]),
    +        )
    +        .await
    +        .expect("get_item")
    +}
    +
    +fn size(item: &Item) -> Option {
    +    Some(i64::try_from(extenddb_core::types::item_size_bytes(item)).expect("item size fits"))
    +}
    +
    +/// Every marked row of a batch is recorded in the batch's transaction with
    +/// its outcome and the size of the item it replaced or was evaluated
    +/// against. The item itself is kept when the marker asks for it and for
    +/// every failed condition, whose report record carries it.
    +pub async fn every_marked_batch_row_is_recorded_with_what_it_replaced(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "owner_recorded").await;
    +    let ki = key_info(fx, &table.table_name).await;
    +    let maps = ExpressionMaps::new(HashMap::new(), HashMap::new());
    +    fx.engine
    +        .put_item(&ki, item("a", "before"), false, None, &maps, None, None)
    +        .await
    +        .expect("seed");
    +    take_over(fx, "op-rec").await;
    +
    +    let keep = |row| BulkRowMarker {
    +        keep_old: true,
    +        ..marker(&fx.account_a, "op-rec", row)
    +    };
    +    let results = fx
    +        .engine
    +        .put_items_batch(
    +            &ki,
    +            vec![
    +                marked("a", "new", marker(&fx.account_a, "op-rec", 0)),
    +                marked("b", "1", keep(1)),
    +                marked_conditional(
    +                    "a",
    +                    "cond",
    +                    "attribute_not_exists(pk)",
    +                    marker(&fx.account_a, "op-rec", 2),
    +                ),
    +                marked("a", "last", keep(3)),
    +            ],
    +            None,
    +        )
    +        .await
    +        .expect("the batch applies");
    +    assert_eq!(results.len(), 4);
    +
    +    let rows = recorded(fx, "op-rec", 0).await;
    +    let summary: Vec<(i64, RowOutcome, Option, Option)> = rows
    +        .into_iter()
    +        .map(|r| (r.row_index, r.outcome, r.old_size, r.old_image))
    +        .collect();
    +    assert_eq!(
    +        summary,
    +        vec![
    +            (0, RowOutcome::Applied, size(&item("a", "before")), None),
    +            (1, RowOutcome::Applied, None, None),
    +            (
    +                2,
    +                RowOutcome::ConditionFailed,
    +                size(&item("a", "new")),
    +                Some(item("a", "new")),
    +            ),
    +            (
    +                3,
    +                RowOutcome::Applied,
    +                size(&item("a", "new")),
    +                Some(item("a", "new")),
    +            ),
    +        ],
    +        "one entry per row, in order, with the replaced item's size and the item where kept"
    +    );
    +}
    +
    +/// A batch deletes the operation's ledger rows before its `settled` cursor,
    +/// the last checkpoint: rows of earlier files and rows of the cursor's file
    +/// before the cursor row. Rows at or after it stay.
    +pub async fn a_batch_prunes_the_ledger_rows_its_checkpoint_covers(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "owner_prune").await;
    +    let ki = key_info(fx, &table.table_name).await;
    +    let account = fx.account_a.clone();
    +    take_over(fx, "op-prune").await;
    +
    +    let first: Vec = (0..4)
    +        .map(|row| marked(&format!("k{row}"), "1", at("op-prune", 0, row, &account)))
    +        .collect();
    +    fx.engine
    +        .put_items_batch(&ki, first, None)
    +        .await
    +        .expect("the first batch");
    +    let second: Vec = (4..6)
    +        .map(|row| {
    +            let m = BulkRowMarker {
    +                settled: (0, 3),
    +                ..at("op-prune", 0, row, &account)
    +            };
    +            marked(&format!("k{row}"), "1", m)
    +        })
    +        .collect();
    +    fx.engine
    +        .put_items_batch(&ki, second, None)
    +        .await
    +        .expect("the second batch");
    +    let left: Vec = recorded(fx, "op-prune", 0)
    +        .await
    +        .iter()
    +        .map(|r| r.row_index)
    +        .collect();
    +    assert_eq!(
    +        left,
    +        [3, 4, 5],
    +        "rows before the settled cursor are deleted"
    +    );
    +
    +    let next_file = BulkRowMarker {
    +        settled: (1, 0),
    +        ..at("op-prune", 1, 0, &account)
    +    };
    +    fx.engine
    +        .put_items_batch(&ki, vec![marked("n0", "1", next_file)], None)
    +        .await
    +        .expect("a batch of the next file");
    +    assert!(
    +        recorded(fx, "op-prune", 0).await.is_empty(),
    +        "the earlier file's rows are covered by the checkpoint that passed it"
    +    );
    +    assert_eq!(recorded(fx, "op-prune", 1).await.len(), 1);
    +}
    +
    +/// Once another owner is recorded for the operation, a marked batch of the
    +/// earlier owner writes nothing and answers a transient error, and so does a
    +/// marked batch of an operation no owner was recorded for.
    +pub async fn a_batch_of_an_earlier_owner_writes_nothing(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "owner_fence").await;
    +    let ki = key_info(fx, &table.table_name).await;
    +    take_over(fx, "op-fence").await;
    +    fx.engine
    +        .bulk_write_takeover(&fx.account_a, "op-fence", "owner-2", (0, 0))
    +        .await
    +        .expect("the next claim takes the operation over");
    +
    +    let late = fx
    +        .engine
    +        .put_items_batch(
    +            &ki,
    +            vec![marked("a", "late", marker(&fx.account_a, "op-fence", 0))],
    +            None,
    +        )
    +        .await;
    +    assert!(
    +        matches!(late, Err(StorageError::Transient(_))),
    +        "the earlier owner's batch is refused, got {late:?}"
    +    );
    +    assert_eq!(stored(fx, &ki, "a").await, None, "nothing was written");
    +    assert!(recorded(fx, "op-fence", 0).await.is_empty());
    +
    +    let current = BulkRowMarker {
    +        owner: "owner-2".to_owned(),
    +        ..marker(&fx.account_a, "op-fence", 0)
    +    };
    +    fx.engine
    +        .put_items_batch(&ki, vec![marked("a", "current", current)], None)
    +        .await
    +        .expect("the current owner's batch applies");
    +    assert_eq!(stored(fx, &ki, "a").await, Some(item("a", "current")));
    +
    +    let unowned = fx
    +        .engine
    +        .put_items_batch(
    +            &ki,
    +            vec![marked("b", "1", marker(&fx.account_a, "op-unowned", 0))],
    +            None,
    +        )
    +        .await;
    +    assert!(
    +        matches!(unowned, Err(StorageError::Transient(_))),
    +        "a batch with no recorded owner is refused, got {unowned:?}"
    +    );
    +    assert_eq!(stored(fx, &ki, "b").await, None);
    +}
    +
    +/// A batch carrying a row whose position the ledger already holds fails as a
    +/// whole: nothing of it is visible and the recorded row keeps its outcome.
    +pub async fn a_batch_row_at_a_recorded_position_fails_the_batch(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "owner_twice").await;
    +    let ki = key_info(fx, &table.table_name).await;
    +    take_over(fx, "op-twice").await;
    +    fx.engine
    +        .put_items_batch(
    +            &ki,
    +            vec![marked("a", "1", marker(&fx.account_a, "op-twice", 0))],
    +            None,
    +        )
    +        .await
    +        .expect("the first batch");
    +
    +    let again = fx
    +        .engine
    +        .put_items_batch(
    +            &ki,
    +            vec![
    +                marked("b", "1", marker(&fx.account_a, "op-twice", 1)),
    +                marked("a", "2", marker(&fx.account_a, "op-twice", 0)),
    +            ],
    +            None,
    +        )
    +        .await;
    +    assert!(again.is_err(), "the batch fails");
    +    assert_eq!(stored(fx, &ki, "a").await, Some(item("a", "1")));
    +    assert_eq!(stored(fx, &ki, "b").await, None, "the batch rolled back");
    +    assert_eq!(recorded(fx, "op-twice", 0).await.len(), 1);
    +}
    +
    +/// Two rows of one batch at one position fail the batch as a row at a
    +/// recorded position does: the ledger key refuses the second outcome, and
    +/// nothing of the batch is visible, its first row at that position
    +/// included.
    +pub async fn two_rows_of_one_batch_at_one_position_fail_the_batch(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "owner_one_position").await;
    +    let ki = key_info(fx, &table.table_name).await;
    +    take_over(fx, "op-one-position").await;
    +
    +    let result = fx
    +        .engine
    +        .put_items_batch(
    +            &ki,
    +            vec![
    +                marked("a", "1", marker(&fx.account_a, "op-one-position", 0)),
    +                marked("b", "1", marker(&fx.account_a, "op-one-position", 1)),
    +                marked("c", "1", marker(&fx.account_a, "op-one-position", 0)),
    +            ],
    +            None,
    +        )
    +        .await;
    +    assert!(result.is_err(), "the batch fails, got {result:?}");
    +    for pk in ["a", "b", "c"] {
    +        assert_eq!(
    +            stored(fx, &ki, pk).await,
    +            None,
    +            "{pk}: the batch rolled back"
    +        );
    +    }
    +    assert!(
    +        recorded(fx, "op-one-position", 0).await.is_empty(),
    +        "no outcome of the batch is recorded"
    +    );
    +}
    +
    +/// Marked rows in one batch: more than one statement can carry at seven
    +/// bound values a row on either backend (PostgreSQL binds at most 65,535
    +/// values to a statement, the bundled SQLite at most 32,766).
    +const ROWS_PAST_ONE_STATEMENT: i64 = 9_400;
    +
    +/// A batch of more marked rows than one statement can bind records every
    +/// row, each once, and writes every item.
    +pub async fn a_batch_past_one_statement_of_rows_records_every_row(
    +    fx: &Fixture,
    +) {
    +    let table = create_table(fx, &fx.account_a, "owner_many_rows").await;
    +    let ki = key_info(fx, &table.table_name).await;
    +    take_over(fx, "op-many-rows").await;
    +
    +    let rows: Vec = (0..ROWS_PAST_ONE_STATEMENT)
    +        .map(|row| {
    +            marked(
    +                &format!("k{row}"),
    +                "1",
    +                marker(&fx.account_a, "op-many-rows", row),
    +            )
    +        })
    +        .collect();
    +    let results = fx
    +        .engine
    +        .put_items_batch(&ki, rows, None)
    +        .await
    +        .expect("the batch applies");
    +    assert_eq!(
    +        i64::try_from(results.len()).expect("fits"),
    +        ROWS_PAST_ONE_STATEMENT
    +    );
    +
    +    let recorded = recorded(fx, "op-many-rows", 0).await;
    +    let indexes: Vec = recorded.iter().map(|r| r.row_index).collect();
    +    assert_eq!(
    +        indexes,
    +        (0..ROWS_PAST_ONE_STATEMENT).collect::>(),
    +        "every row is recorded once, in order"
    +    );
    +    assert!(
    +        recorded
    +            .iter()
    +            .all(|r| r.outcome == RowOutcome::Applied && r.old_size.is_none()),
    +        "every row is an applied insert"
    +    );
    +    let last = format!("k{}", ROWS_PAST_ONE_STATEMENT - 1);
    +    assert_eq!(stored(fx, &ki, &last).await, Some(item(&last, "1")));
    +}
    +
    +/// Each takeover answers how many claims in a row, itself included, found
    +/// the checkpoint cursor where the claim before them found it: zero for the
    +/// first claim and for one that finds the cursor moved. Deleting the
    +/// operation's ledger rows deletes its owner record too.
    +pub async fn a_takeover_counts_the_resumes_that_find_the_checkpoint_unmoved(
    +    fx: &Fixture,
    +) {
    +    let mut answers = Vec::new();
    +    for (owner, cursor) in [
    +        ("c1", (0, 0)),
    +        ("c2", (0, 0)),
    +        ("c3", (0, 0)),
    +        ("c4", (0, 7)),
    +        ("c5", (0, 7)),
    +        ("c6", (1, 0)),
    +    ] {
    +        answers.push(
    +            fx.engine
    +                .bulk_write_takeover(&fx.account_a, "op-stall", owner, cursor)
    +                .await
    +                .expect("takeover"),
    +        );
    +    }
    +    assert_eq!(answers, [0, 1, 2, 0, 1, 0]);
    +
    +    fx.engine
    +        .delete_bulk_row_outcomes(&fx.account_a, "op-stall")
    +        .await
    +        .expect("delete the ledger");
    +    assert_eq!(
    +        fx.engine
    +            .bulk_write_takeover(&fx.account_a, "op-stall", "c7", (1, 0))
    +            .await
    +            .expect("takeover"),
    +        0,
    +        "the owner record went with the ledger"
    +    );
    +}
    +
    +/// A takeover issued while a batch of the earlier owner is in flight
    +/// returns only once that batch has committed or been refused: the rows a
    +/// resumed run reads right after its takeover include every row of a batch
    +/// that commits, so none is applied a second time. Repeated so a takeover
    +/// meets a batch inside its transaction.
    +pub async fn a_takeover_waits_for_a_batch_in_flight(fx: &Fixture) {
    +    let table = create_table(fx, &fx.account_a, "owner_in_flight").await;
    +    let ki = Arc::new(key_info(fx, &table.table_name).await);
    +    for round in 0..4_i64 {
    +        let op = format!("op-flight-{round}");
    +        take_over(fx, &op).await;
    +        let rows: Vec = (0..200)
    +            .map(|row| {
    +                marked(
    +                    &format!("r{round}k{row}"),
    +                    "1",
    +                    marker(&fx.account_a, &op, row),
    +                )
    +            })
    +            .collect();
    +        let (engine, batch_ki) = (Arc::clone(&fx.engine), Arc::clone(&ki));
    +        let batch =
    +            tokio::spawn(async move { engine.put_items_batch(&batch_ki, rows, None).await });
    +        tokio::time::sleep(Duration::from_millis(u64::try_from(round).unwrap_or(0) * 3)).await;
    +        fx.engine
    +            .bulk_write_takeover(&fx.account_a, &op, "owner-next", (0, 0))
    +            .await
    +            .expect("takeover");
    +        let seen = recorded(fx, &op, 0).await.len();
    +        let outcome = batch.await.expect("batch task");
    +        match outcome {
    +            Ok(results) => {
    +                assert_eq!(results.len(), 200);
    +                assert_eq!(
    +                    seen, 200,
    +                    "round {round}: a batch that committed is fully recorded when the takeover returns"
    +                );
    +            }
    +            Err(StorageError::Transient(_)) => {
    +                assert_eq!(seen, 0, "round {round}: a refused batch records nothing");
    +                let first = stored(fx, &ki, &format!("r{round}k0")).await;
    +                assert_eq!(first, None, "round {round}: a refused batch writes nothing");
    +            }
    +            Err(other) => panic!("round {round}: unexpected batch error {other:?}"),
    +        }
    +    }
    +}
    diff --git a/crates/storage/src/error.rs b/crates/storage/src/error.rs
    index 848a25f00..52bb6550e 100755
    --- a/crates/storage/src/error.rs
    +++ b/crates/storage/src/error.rs
    @@ -91,3 +91,54 @@ pub enum StorageError {
         #[error("Internal error: {0}")]
         Internal(String),
     }
    +
    +impl StorageError {
    +    /// Whether this error from a write is a transient fault a retry is
    +    /// expected to clear, so a bulk operation whose write batch met it is
    +    /// left non-terminal to resume rather than ended `FAILED`. The class is
    +    /// keyed on the variant and, for an internal error, on the SQLSTATE
    +    /// code the backends prefix, never on free message text, and is kept
    +    /// narrow: anything not positively a transient condition is not one.
    +    ///
    +    /// - `Transient` is the backends' own class for a retryable fault: a
    +    ///   pool timeout, an I/O error, and SQLite's BUSY and LOCKED result
    +    ///   codes.
    +    /// - `Connection` is a connection-level fault.
    +    /// - `Internal` carrying a transient SQLSTATE: a PostgreSQL statement
    +    ///   aborted by a connection or shutdown condition, a serialization or
    +    ///   deadlock abort, a lock or statement timeout, or an exhausted
    +    ///   resource such as a full disk surfaces as an internal error whose
    +    ///   message the backend renders with the `SQLSTATE :` prefix; the
    +    ///   code there is the discriminant.
    +    #[must_use]
    +    pub fn is_transient_write_fault(&self) -> bool {
    +        match self {
    +            Self::Transient(_) | Self::Connection(_) => true,
    +            Self::Internal(message) => has_transient_sqlstate(message),
    +            _ => false,
    +        }
    +    }
    +}
    +
    +/// Whether an internal storage error's message carries a PostgreSQL
    +/// SQLSTATE that names a transient condition, read from the fixed `SQLSTATE
    +/// :` prefix the backend writes (never from the human text that
    +/// follows). The transient codes: the connection exception class (`08...`),
    +/// a full disk and too many connections (`53100`, `53300`), which clear once
    +/// the operator frees the resource, the admin and crash shutdown family
    +/// (`57P01`, `57P02`, `57P03`), a statement cancel or lock timeout (`57014`,
    +/// `55P03`), and the two serialization aborts (`40001`, `40P01`).
    +fn has_transient_sqlstate(message: &str) -> bool {
    +    let Some(rest) = message.strip_prefix("SQLSTATE ") else {
    +        return false;
    +    };
    +    let code = rest
    +        .split(|c: char| !c.is_ascii_alphanumeric())
    +        .next()
    +        .unwrap_or("");
    +    code.starts_with("08")
    +        || matches!(
    +            code,
    +            "53100" | "53300" | "57P01" | "57P02" | "57P03" | "57014" | "55P03" | "40001" | "40P01"
    +        )
    +}
    diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs
    index 7e1036021..4d7bdf901 100755
    --- a/crates/storage/src/lib.rs
    +++ b/crates/storage/src/lib.rs
    @@ -12,6 +12,10 @@ pub mod backend;
     pub mod backup_catalog;
     pub mod backup_definition;
     pub mod bootstrapper;
    +mod bulk_operation_flags;
    +pub mod bulk_operation_store;
    +#[cfg(feature = "test-util")]
    +pub mod bulk_operation_store_contract;
     pub mod config;
     pub mod diagnostics;
     pub mod diagnostics_store;
    @@ -44,6 +48,14 @@ pub use snapshot::{SnapshotEngine, SnapshotInfo, TableSnapshot};
     
     pub use transact::{IdempotencyKey, TransactGetOp, TransactWriteOp};
     
    +pub use bulk_operation_flags::OperationFlags;
    +pub use bulk_operation_store::{
    +    AbandonKind, AbandonedFile, BulkOperationListResult, BulkOperationRecord, BulkOperationStore,
    +    CANCELLED_FAILURE_CODE, CANCELLED_FAILURE_MESSAGE, CLIENT_TOKEN_WINDOW_MS, CancelOutcome,
    +    FileAbandon, FinishState, FinishedFile, InitialFailure, InputFile, LeaseState, ListCursor,
    +    NewBulkOperation, Progress, ReportKind, ReportRecord, StartOutcome, Terminal,
    +};
    +
     pub use server_components::{
         BackendError, ServerComponents, ServerComponentsFactory, create_server_components,
     };
    @@ -194,6 +206,102 @@ pub struct StreamCapture {
         pub region: Arc,
     }
     
    +/// Marker carried by every row of a bulk write batch. The backend inserts
    +/// the row's outcome into `bulk_row_outcomes` in the same data transaction
    +/// as the item write, with the size of the item the row replaced (or was
    +/// evaluated against) and, when `keep_old` is set, that item, so a run
    +/// resumed after a crash finds the row applied, does not apply it again,
    +/// and accounts for it as the run that applied it did.
    +///
    +/// Carries `account_id` because the ledger key is account-scoped: the catalog
    +/// primary key permits the same `operation_id` in two accounts, so an
    +/// unscoped ledger would interleave two operations' rows on an id collision.
    +#[derive(Debug, Clone)]
    +pub struct BulkRowMarker {
    +    pub account_id: String,
    +    pub operation_id: String,
    +    pub file_index: i64,
    +    pub row_index: i64,
    +    /// The lease owner of the claim writing the row. A batch whose owner is
    +    /// not the one [`DataEngine::bulk_write_takeover`] last recorded for the
    +    /// operation writes nothing (see [`DataEngine::put_items_batch`]).
    +    pub owner: String,
    +    /// Whether the ledger keeps the replaced item beside its size, for the
    +    /// index charge. The item a failed condition was evaluated against is
    +    /// kept whatever this says: the condition-failure record carries it.
    +    pub keep_old: bool,
    +    /// The cursor of the operation's last committed checkpoint, as
    +    /// `(file_index, row_index)`. Ledger rows before it are covered by that
    +    /// checkpoint and are deleted with the batch.
    +    pub settled: (i64, i64),
    +}
    +
    +/// One row of a bulk operation the ledger records as applied: what the
    +/// write (or the failed condition) found, read back by a resumed run so it
    +/// accounts for the row without writing it again.
    +#[derive(Debug, Clone, PartialEq)]
    +pub struct RecordedRow {
    +    pub row_index: i64,
    +    pub outcome: RowOutcome,
    +    /// Logical size of the item the row replaced or was evaluated against;
    +    /// `None` when no item was stored under the key.
    +    pub old_size: Option,
    +    /// That item, when the row's marker asked for it to be kept or the
    +    /// row's condition failed.
    +    pub old_image: Option,
    +}
    +
    +/// What became of a marked bulk row: the write committed, or its condition
    +/// failed and the failure was recorded.
    +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
    +pub enum RowOutcome {
    +    Applied,
    +    ConditionFailed,
    +}
    +
    +impl RowOutcome {
    +    /// The stored vocabulary for this outcome, shared by every backend so the
    +    /// column text never diverges.
    +    #[must_use]
    +    pub fn as_str(self) -> &'static str {
    +        match self {
    +            Self::Applied => "applied",
    +            Self::ConditionFailed => "condition_failed",
    +        }
    +    }
    +}
    +
    +/// One row of a batched bulk write (`put_items_batch`). A condition row
    +/// carries its parsed expression. The bulk runner sends a marker with every
    +/// row, plain or conditional; `return_old` asks for the replaced image,
    +/// which the caller needs to charge the write.
    +#[derive(Debug, Clone)]
    +pub struct BatchRow {
    +    pub item: Item,
    +    pub condition: Option<(Expr, ExpressionMaps)>,
    +    pub marker: Option,
    +    pub return_old: bool,
    +}
    +
    +/// What became of one row of a batched bulk write, in input order.
    +#[derive(Debug, Clone, PartialEq)]
    +pub enum BatchRowResult {
    +    Written { old: Option },
    +    ConditionFailed { old: Option },
    +}
    +
    +impl RowOutcome {
    +    /// Parse the stored vocabulary back; `None` for anything else.
    +    #[must_use]
    +    pub fn parse(s: &str) -> Option {
    +        match s {
    +            "applied" => Some(Self::Applied),
    +            "condition_failed" => Some(Self::ConditionFailed),
    +            _ => None,
    +        }
    +    }
    +}
    +
     /// Table lifecycle operations.
     ///
     /// All methods receive `account_id` to scope operations to a single account.
    @@ -286,7 +394,16 @@ pub trait DataEngine: Send + Sync {
         /// When `stream` is `Some`, the stream record is inserted in the same
         /// transaction as the data write, guaranteeing atomicity.
         ///
    +    /// When `marker` is `Some` and the write commits, the backend inserts an
    +    /// `Applied` row into `bulk_row_outcomes` inside the write transaction.
    +    /// When the condition fails and `marker` is `Some`, the backend records a
    +    /// `ConditionFailed` row as its own transaction and then returns
    +    /// `StorageError::ConditionFailed` as usual. A second outcome for one
    +    /// marker's position fails the write. The bulk runner writes through
    +    /// [`Self::put_items_batch`]; this path records the outcome alone.
    +    ///
         /// Returns the previous item if `return_old` is true and an item existed.
    +    #[allow(clippy::too_many_arguments)]
         fn put_item(
             &self,
             key_info: &TableKeyInfo,
    @@ -295,8 +412,103 @@ pub trait DataEngine: Send + Sync {
             condition: Option<&Expr>,
             maps: &ExpressionMaps,
             stream: Option<&StreamCapture>,
    +        marker: Option<&BulkRowMarker>,
         ) -> BoxFuture<'_, Result, StorageError>>;
     
    +    /// Look up the recorded outcome of a marked bulk row, if any. Scoped by
    +    /// `account_id`, matching the ledger key.
    +    fn bulk_row_outcome(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        file_index: i64,
    +        row_index: i64,
    +    ) -> BoxFuture<'_, Result, StorageError>>;
    +
    +    /// Apply a batch of bulk rows and return one result per row, in order.
    +    ///
    +    /// Per-row outcomes are independent: a failed condition on one row is a
    +    /// `ConditionFailed` result and the other rows still apply. Each marked
    +    /// row's outcome (`Applied` or `ConditionFailed`) is inserted into the
    +    /// ledger in the batch's transaction with the replaced item's size and,
    +    /// when the marker asks, the item. The ledger key is the row's position,
    +    /// so a row applied twice violates it and the batch fails. Any error
    +    /// other than a condition failure is returned as `Err`, and the backend
    +    /// guarantees nothing from the batch is visible in that case.
    +    ///
    +    /// A batch whose markers name an owner other than the one
    +    /// [`Self::bulk_write_takeover`] last recorded for the operation writes
    +    /// nothing and answers `StorageError::Transient`: the claim that took
    +    /// the operation over continues it, and the caller stops as after any
    +    /// transient fault. The batch also deletes the operation's ledger rows
    +    /// before the markers' `settled` cursor.
    +    ///
    +    /// The method is required. The SQL backends apply the whole batch in one
    +    /// transaction. A backend that does not carry bulk operations refuses
    +    /// with `StorageError::Validation`, the same answer its
    +    /// `BulkOperationStore::start_bulk_operation` gives, so a bulk request is
    +    /// rejected before any row reaches storage.
    +    fn put_items_batch(
    +        &self,
    +        key_info: &TableKeyInfo,
    +        rows: Vec,
    +        stream: Option<&StreamCapture>,
    +    ) -> BoxFuture<'_, Result, StorageError>>;
    +
    +    /// Record `owner` as the only claim whose bulk write batches for the
    +    /// operation commit, in one data transaction that waits for a batch in
    +    /// flight under an earlier owner. Every batch that commits after it
    +    /// carries `owner`; every batch of an earlier owner either committed
    +    /// before it, and is in the ledger, or writes nothing.
    +    ///
    +    /// `cursor` is the checkpoint cursor the claim restored. Answers how many
    +    /// claims in a row, this one included, found the checkpoint where the
    +    /// claim before them found it: 0 for the first claim and for a claim
    +    /// that finds the checkpoint moved.
    +    fn bulk_write_takeover(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        cursor: (i64, i64),
    +    ) -> BoxFuture<'_, Result> {
    +        let _ = (account_id, operation_id, owner, cursor);
    +        Box::pin(async {
    +            Err(StorageError::Validation(
    +                "bulk operations are not supported by this storage backend".to_owned(),
    +            ))
    +        })
    +    }
    +
    +    /// At most `limit` ledger rows of one input file of an operation at
    +    /// `from_row` or after it, ordered by row: the rows a resumed run finds
    +    /// applied past its checkpoint, read a page at a time as the run
    +    /// reaches them, because a row can keep a replaced item of up to the
    +    /// item size limit.
    +    fn bulk_rows_recorded(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        file_index: i64,
    +        from_row: i64,
    +        limit: i64,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        let _ = (account_id, operation_id, file_index, from_row, limit);
    +        Box::pin(async {
    +            Err(StorageError::Validation(
    +                "bulk operations are not supported by this storage backend".to_owned(),
    +            ))
    +        })
    +    }
    +
    +    /// Delete every `bulk_row_outcomes` row for one operation. Returns the
    +    /// number of rows deleted.
    +    fn delete_bulk_row_outcomes(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +    ) -> BoxFuture<'_, Result>;
    +
         /// Read a single item by primary key.
         ///
         /// Returns `None` if the item does not exist (not an error).
    @@ -727,12 +939,13 @@ pub trait StorageEngine:
         + LegacyBackupReader
         + SnapshotEngine
         + WorkerStore
    +    + BulkOperationStore
         + Send
         + Sync
     {
     }
     
    -// Blanket implementation: any type implementing all 8 traits is a StorageEngine
    +// Blanket implementation: any type implementing all 9 traits is a StorageEngine
     impl StorageEngine for T where
         T: TableEngine
             + DataEngine
    @@ -742,6 +955,7 @@ impl StorageEngine for T where
             + LegacyBackupReader
             + SnapshotEngine
             + WorkerStore
    +        + BulkOperationStore
             + Send
             + Sync
     {
    @@ -774,9 +988,9 @@ pub trait CatalogStore:
     mod tests {
         use super::*;
     
    -    /// Verify that CatalogStore is dyn-compatible (object-safe).
    +    /// Verify that `CatalogStore` is dyn-compatible (object-safe).
         ///
    -    /// This test ensures all catalog traits use BoxFuture instead of RPITIT,
    +    /// This test ensures all catalog traits use `BoxFuture` instead of RPITIT,
         /// allowing us to use `Arc` in the factory pattern.
         #[test]
         fn catalog_store_is_dyn_compatible() {
    @@ -825,9 +1039,39 @@ mod vector_opt_out_tests {
                 _condition: Option<&Expr>,
                 _maps: &ExpressionMaps,
                 _stream: Option<&StreamCapture>,
    +            _marker: Option<&BulkRowMarker>,
             ) -> BoxFuture<'_, Result, StorageError>> {
                 Box::pin(async { Ok(None) })
             }
    +        fn put_items_batch(
    +            &self,
    +            _key_info: &TableKeyInfo,
    +            rows: Vec,
    +            _stream: Option<&StreamCapture>,
    +        ) -> BoxFuture<'_, Result, StorageError>> {
    +            Box::pin(async move {
    +                Ok(rows
    +                    .into_iter()
    +                    .map(|_| BatchRowResult::Written { old: None })
    +                    .collect())
    +            })
    +        }
    +        fn bulk_row_outcome(
    +            &self,
    +            _account_id: &str,
    +            _operation_id: &str,
    +            _file_index: i64,
    +            _row_index: i64,
    +        ) -> BoxFuture<'_, Result, StorageError>> {
    +            Box::pin(async { Ok(None) })
    +        }
    +        fn delete_bulk_row_outcomes(
    +            &self,
    +            _account_id: &str,
    +            _operation_id: &str,
    +        ) -> BoxFuture<'_, Result> {
    +            Box::pin(async { Ok(0) })
    +        }
             fn get_item(
                 &self,
                 _key_info: &TableKeyInfo,
    diff --git a/crates/storage/src/migrations/registry.rs b/crates/storage/src/migrations/registry.rs
    index 6f8038c5e..b9fdcc7ff 100644
    --- a/crates/storage/src/migrations/registry.rs
    +++ b/crates/storage/src/migrations/registry.rs
    @@ -79,11 +79,16 @@ migration_ids! {
         /// in (#808).
         StreamPending = 7, "stream_pending",
             "add stream_pending, which holds a PostgreSQL stream record until its write commits";
    +    /// Adds the bulk put items tables: the operation records, their input
    +    /// files and their pending report records in the catalog, and the
    +    /// row-outcome ledger and the write owners beside the items they record.
    +    BulkOperations = 8, "bulk_operations",
    +        "add the bulk put items tables: operations, input files, report records, row outcomes and write owners";
     }
     
     /// Highest id that has shipped in a release. Bodies at or below it are frozen:
     /// each backend pins their checksums, kinds and convergent flags, and
    -/// `forget` refuses them. The v1.0 release PR raises it to 7.
    +/// `forget` refuses them. The v1.0 release PR raises it to 8.
     pub const RELEASED_THROUGH: u32 = 0;
     
     impl MigrationId {
    diff --git a/crates/storage/src/snapshot_conformance.rs b/crates/storage/src/snapshot_conformance.rs
    index 04b58c9b2..6e22a0503 100644
    --- a/crates/storage/src/snapshot_conformance.rs
    +++ b/crates/storage/src/snapshot_conformance.rs
    @@ -502,6 +502,7 @@ async fn put(engine: &E, key_info: &TableKeyInfo, item: Item) {
                 None,
                 &ExpressionMaps::default(),
                 None,
    +            None,
             )
             .await
             .expect("put_item");
    @@ -1357,6 +1358,7 @@ async fn key_shapes(
                         None,
                         &ExpressionMaps::default(),
                         None,
    +                    None,
                     )
                     .await
                 {
    diff --git a/docs/schema/postgres.txt b/docs/schema/postgres.txt
    index 31ec3693c..17df89da0 100644
    --- a/docs/schema/postgres.txt
    +++ b/docs/schema/postgres.txt
    @@ -7,6 +7,9 @@ table backup_index
     table backup_items
     table backup_maintenance
     table backups
    +table bulk_operation_input_files
    +table bulk_operation_report_records
    +table bulk_operations
     table extenddb_migrations
     table iam_group_members
     table iam_groups
    @@ -85,6 +88,51 @@ column backups.provisioned_throughput: jsonb
     column backups.stream_specification: jsonb
     column backups.created_at: timestamp with time zone NOT NULL DEFAULT now()
     column backups.vector_indexes: jsonb
    +column bulk_operation_input_files.account_id: text NOT NULL
    +column bulk_operation_input_files.operation_id: text NOT NULL COLLATE "C"
    +column bulk_operation_input_files.file_index: bigint NOT NULL
    +column bulk_operation_input_files.path: text NOT NULL
    +column bulk_operation_input_files.size: bigint NOT NULL
    +column bulk_operation_input_files.row_count: bigint
    +column bulk_operation_input_files.etag: text
    +column bulk_operation_input_files.abandon: jsonb
    +column bulk_operation_report_records.account_id: text NOT NULL
    +column bulk_operation_report_records.operation_id: text NOT NULL
    +column bulk_operation_report_records.kind: text NOT NULL
    +column bulk_operation_report_records.file_index: bigint NOT NULL
    +column bulk_operation_report_records.row_index: bigint NOT NULL
    +column bulk_operation_report_records.record: text NOT NULL
    +column bulk_operations.account_id: text NOT NULL
    +column bulk_operations.operation_id: text NOT NULL COLLATE "C"
    +column bulk_operations.table_id: text NOT NULL
    +column bulk_operations.table_name: text NOT NULL
    +column bulk_operations.table_arn: text NOT NULL
    +column bulk_operations.status: text NOT NULL
    +column bulk_operations.client_token: text
    +column bulk_operations.client_token_hash: text
    +column bulk_operations.request_fingerprint: text NOT NULL
    +column bulk_operations.input_config: jsonb NOT NULL
    +column bulk_operations.output_config: jsonb NOT NULL
    +column bulk_operations.input_files: jsonb NOT NULL
    +column bulk_operations.max_write_request_units: bigint
    +column bulk_operations.performance_tier: text
    +column bulk_operations.start_time_ms: bigint NOT NULL
    +column bulk_operations.end_time_ms: bigint
    +column bulk_operations.processed_item_count: bigint NOT NULL DEFAULT 0
    +column bulk_operations.failed_item_count: bigint NOT NULL DEFAULT 0
    +column bulk_operations.processed_input_bytes: bigint NOT NULL DEFAULT 0
    +column bulk_operations.total_input_bytes: bigint
    +column bulk_operations.report_record_count: bigint NOT NULL DEFAULT 0
    +column bulk_operations.report_bytes: bigint NOT NULL DEFAULT 0
    +column bulk_operations.usage: jsonb NOT NULL
    +column bulk_operations.failure_code: text
    +column bulk_operations.failure_message: text
    +column bulk_operations.cancel_requested: boolean NOT NULL DEFAULT false
    +column bulk_operations.cursor_file_index: bigint NOT NULL DEFAULT 0
    +column bulk_operations.cursor_row_index: bigint NOT NULL DEFAULT 0
    +column bulk_operations.lease_owner: text
    +column bulk_operations.lease_until_ms: bigint
    +column bulk_operations.run_context: jsonb
     column extenddb_migrations.id: integer NOT NULL
     column extenddb_migrations.name: text NOT NULL
     column extenddb_migrations.kind: text NOT NULL
    @@ -241,6 +289,9 @@ index backup_definitions_pkey: CREATE UNIQUE INDEX backup_definitions_pkey ON pu
     index backup_index_pkey: CREATE UNIQUE INDEX backup_index_pkey ON public.backup_index USING btree (account_id, backup_id)
     index backup_maintenance_pkey: CREATE UNIQUE INDEX backup_maintenance_pkey ON public.backup_maintenance USING btree (singleton)
     index backups_pkey: CREATE UNIQUE INDEX backups_pkey ON public.backups USING btree (backup_arn)
    +index bulk_operation_input_files_pkey: CREATE UNIQUE INDEX bulk_operation_input_files_pkey ON public.bulk_operation_input_files USING btree (account_id, operation_id, file_index)
    +index bulk_operation_report_records_pkey: CREATE UNIQUE INDEX bulk_operation_report_records_pkey ON public.bulk_operation_report_records USING btree (account_id, operation_id, kind, file_index, row_index)
    +index bulk_operations_pkey: CREATE UNIQUE INDEX bulk_operations_pkey ON public.bulk_operations USING btree (account_id, operation_id)
     index extenddb_migrations_pkey: CREATE UNIQUE INDEX extenddb_migrations_pkey ON public.extenddb_migrations USING btree (id)
     index iam_group_members_pkey: CREATE UNIQUE INDEX iam_group_members_pkey ON public.iam_group_members USING btree (account_id, group_name, user_name)
     index iam_groups_group_arn_key: CREATE UNIQUE INDEX iam_groups_group_arn_key ON public.iam_groups USING btree (group_arn)
    @@ -261,6 +312,10 @@ index idx_backup_index_backup_id: CREATE UNIQUE INDEX idx_backup_index_backup_id
     index idx_backup_index_store_status: CREATE INDEX idx_backup_index_store_status ON public.backup_index USING btree (store_kind, store_root, store_id, status)
     index idx_backup_items_arn: CREATE INDEX idx_backup_items_arn ON public.backup_items USING btree (backup_arn)
     index idx_backups_table: CREATE INDEX idx_backups_table ON public.backups USING btree (account_id, table_name)
    +index idx_bulk_ops_claim: CREATE INDEX idx_bulk_ops_claim ON public.bulk_operations USING btree (status, lease_until_ms)
    +index idx_bulk_ops_list: CREATE INDEX idx_bulk_ops_list ON public.bulk_operations USING btree (account_id, start_time_ms DESC, operation_id DESC)
    +index idx_bulk_ops_table_guard: CREATE UNIQUE INDEX idx_bulk_ops_table_guard ON public.bulk_operations USING btree (account_id, table_id) WHERE (status = ANY (ARRAY['IN_PROGRESS'::text, 'CANCELLING'::text]))
    +index idx_bulk_ops_token: CREATE UNIQUE INDEX idx_bulk_ops_token ON public.bulk_operations USING btree (account_id, client_token_hash)
     index idx_idempotency_tokens_created: CREATE INDEX idx_idempotency_tokens_created ON public.idempotency_tokens USING btree (created_at)
     index idx_login_attempts_principal_time: CREATE INDEX idx_login_attempts_principal_time ON public.login_attempts USING btree (principal, attempted_at DESC)
     index idx_login_attempts_source_ip_time: CREATE INDEX idx_login_attempts_source_ip_time ON public.login_attempts USING btree (source_ip, attempted_at DESC) WHERE (source_ip IS NOT NULL)
    @@ -294,6 +349,9 @@ constraint backup_items.backup_items_backup_arn_fkey: FOREIGN KEY (backup_arn) R
     constraint backup_maintenance.backup_maintenance_pkey: PRIMARY KEY (singleton)
     constraint backup_maintenance.backup_maintenance_singleton_check: CHECK (singleton)
     constraint backups.backups_pkey: PRIMARY KEY (backup_arn)
    +constraint bulk_operation_input_files.bulk_operation_input_files_pkey: PRIMARY KEY (account_id, operation_id, file_index)
    +constraint bulk_operation_report_records.bulk_operation_report_records_pkey: PRIMARY KEY (account_id, operation_id, kind, file_index, row_index)
    +constraint bulk_operations.bulk_operations_pkey: PRIMARY KEY (account_id, operation_id)
     constraint extenddb_migrations.extenddb_migrations_pkey: PRIMARY KEY (id)
     constraint iam_group_members.iam_group_members_account_id_group_name_fkey: FOREIGN KEY (account_id, group_name) REFERENCES iam_groups(account_id, group_name) ON DELETE CASCADE
     constraint iam_group_members.iam_group_members_account_id_user_name_fkey: FOREIGN KEY (account_id, user_name) REFERENCES iam_users(account_id, user_name) ON DELETE CASCADE
    @@ -343,6 +401,8 @@ constraint vector_indexes.vector_indexes_pkey: PRIMARY KEY (table_id, index_name
     constraint vector_indexes.vector_indexes_table_id_fkey: FOREIGN KEY (table_id) REFERENCES tables(table_id) ON DELETE CASCADE
     sequence stream_seq: bigint START 1 INCREMENT 1
     # data database
    +table bulk_row_outcomes
    +table bulk_write_owners
     table extenddb_migrations
     table gsi_pending
     table idempotency_tokens
    @@ -351,6 +411,19 @@ table stream_pending
     table stream_records
     table stream_shards
     table vector_index_holds
    +column bulk_row_outcomes.account_id: text NOT NULL
    +column bulk_row_outcomes.operation_id: text NOT NULL
    +column bulk_row_outcomes.file_index: bigint NOT NULL
    +column bulk_row_outcomes.row_index: bigint NOT NULL
    +column bulk_row_outcomes.outcome: text NOT NULL
    +column bulk_row_outcomes.old_size: bigint
    +column bulk_row_outcomes.old_image: jsonb
    +column bulk_write_owners.account_id: text NOT NULL
    +column bulk_write_owners.operation_id: text NOT NULL
    +column bulk_write_owners.owner: text NOT NULL
    +column bulk_write_owners.cursor_file: bigint NOT NULL
    +column bulk_write_owners.cursor_row: bigint NOT NULL
    +column bulk_write_owners.stalled: bigint NOT NULL
     column extenddb_migrations.id: integer NOT NULL
     column extenddb_migrations.name: text NOT NULL
     column extenddb_migrations.kind: text NOT NULL
    @@ -394,6 +467,8 @@ column stream_shards.created_at: timestamp with time zone NOT NULL DEFAULT now()
     column vector_index_holds.table_id: text NOT NULL
     column vector_index_holds.index_id: text NOT NULL
     column vector_index_holds.created_at: timestamp with time zone NOT NULL DEFAULT now()
    +index bulk_row_outcomes_pkey: CREATE UNIQUE INDEX bulk_row_outcomes_pkey ON public.bulk_row_outcomes USING btree (account_id, operation_id, file_index, row_index)
    +index bulk_write_owners_pkey: CREATE UNIQUE INDEX bulk_write_owners_pkey ON public.bulk_write_owners USING btree (account_id, operation_id)
     index extenddb_migrations_pkey: CREATE UNIQUE INDEX extenddb_migrations_pkey ON public.extenddb_migrations USING btree (id)
     index gsi_pending_pkey: CREATE UNIQUE INDEX gsi_pending_pkey ON public.gsi_pending USING btree (id)
     index idempotency_tokens_pkey: CREATE UNIQUE INDEX idempotency_tokens_pkey ON public.idempotency_tokens USING btree (account_id, token)
    @@ -407,6 +482,8 @@ index stream_pending_pkey: CREATE UNIQUE INDEX stream_pending_pkey ON public.str
     index stream_records_pkey: CREATE UNIQUE INDEX stream_records_pkey ON public.stream_records USING btree (shard_id, sequence_number)
     index stream_shards_pkey: CREATE UNIQUE INDEX stream_shards_pkey ON public.stream_shards USING btree (shard_id)
     index vector_index_holds_pkey: CREATE UNIQUE INDEX vector_index_holds_pkey ON public.vector_index_holds USING btree (table_id, index_id)
    +constraint bulk_row_outcomes.bulk_row_outcomes_pkey: PRIMARY KEY (account_id, operation_id, file_index, row_index)
    +constraint bulk_write_owners.bulk_write_owners_pkey: PRIMARY KEY (account_id, operation_id)
     constraint extenddb_migrations.extenddb_migrations_pkey: PRIMARY KEY (id)
     constraint gsi_pending.gsi_pending_pkey: PRIMARY KEY (id)
     constraint idempotency_tokens.idempotency_tokens_pkey: PRIMARY KEY (account_id, token)
    diff --git a/docs/schema/sqlite.txt b/docs/schema/sqlite.txt
    index 24e052ba3..0f52ba1c7 100644
    --- a/docs/schema/sqlite.txt
    +++ b/docs/schema/sqlite.txt
    @@ -3,6 +3,10 @@ index idx_backup_index_backup_id on backup_index: CREATE UNIQUE INDEX idx_backup
     index idx_backup_index_store_status on backup_index: CREATE INDEX idx_backup_index_store_status ON backup_index (store_kind, store_root, store_id, status)
     index idx_backup_items_arn on backup_items: CREATE INDEX idx_backup_items_arn ON backup_items (backup_arn)
     index idx_backups_table on backups: CREATE INDEX idx_backups_table ON backups (account_id, table_name)
    +index idx_bulk_ops_claim on bulk_operations: CREATE INDEX idx_bulk_ops_claim ON bulk_operations (status, lease_until_ms)
    +index idx_bulk_ops_list on bulk_operations: CREATE INDEX idx_bulk_ops_list ON bulk_operations (account_id, start_time_ms DESC, operation_id DESC)
    +index idx_bulk_ops_table_guard on bulk_operations: CREATE UNIQUE INDEX idx_bulk_ops_table_guard ON bulk_operations (account_id, table_id) WHERE status IN ('IN_PROGRESS', 'CANCELLING')
    +index idx_bulk_ops_token on bulk_operations: CREATE UNIQUE INDEX idx_bulk_ops_token ON bulk_operations (account_id, client_token_hash)
     index idx_gsi_pending_claim on gsi_pending: CREATE INDEX idx_gsi_pending_claim ON gsi_pending (worker_partition, ready_at, id)
     index idx_idempotency_tokens_created on idempotency_tokens: CREATE INDEX idx_idempotency_tokens_created ON idempotency_tokens (created_at)
     index idx_login_attempts_principal_time on login_attempts: CREATE INDEX idx_login_attempts_principal_time ON login_attempts (principal, attempted_at DESC)
    @@ -22,6 +26,11 @@ table backup_index on backup_index: CREATE TABLE backup_index ( account_id TEXT
     table backup_items on backup_items: CREATE TABLE backup_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, backup_arn TEXT NOT NULL REFERENCES backups(backup_arn) ON DELETE CASCADE, pk TEXT NOT NULL, sk TEXT, item_data TEXT NOT NULL )
     table backup_maintenance on backup_maintenance: CREATE TABLE backup_maintenance ( singleton INTEGER PRIMARY KEY CHECK (singleton = 1), holder TEXT NOT NULL, owner_generation INTEGER NOT NULL, claimed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), heartbeat_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) )
     table backups on backups: CREATE TABLE backups ( backup_arn TEXT PRIMARY KEY, backup_name TEXT NOT NULL, table_id TEXT NOT NULL, table_name TEXT NOT NULL, account_id TEXT NOT NULL, backup_status TEXT NOT NULL DEFAULT 'AVAILABLE', backup_type TEXT NOT NULL DEFAULT 'USER', backup_size_bytes INTEGER NOT NULL DEFAULT 0, item_count INTEGER NOT NULL DEFAULT 0, key_schema TEXT NOT NULL, attribute_definitions TEXT NOT NULL, billing_mode TEXT NOT NULL DEFAULT 'PAY_PER_REQUEST', provisioned_throughput TEXT, stream_specification TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) )
    +table bulk_operation_input_files on bulk_operation_input_files: CREATE TABLE bulk_operation_input_files ( account_id TEXT NOT NULL, operation_id TEXT NOT NULL, file_index INTEGER NOT NULL, path TEXT NOT NULL, size INTEGER NOT NULL, row_count INTEGER, etag TEXT, abandon TEXT, PRIMARY KEY (account_id, operation_id, file_index) )
    +table bulk_operation_report_records on bulk_operation_report_records: CREATE TABLE bulk_operation_report_records ( account_id TEXT NOT NULL, operation_id TEXT NOT NULL, kind TEXT NOT NULL, file_index INTEGER NOT NULL, row_index INTEGER NOT NULL, record TEXT NOT NULL, PRIMARY KEY (account_id, operation_id, kind, file_index, row_index) )
    +table bulk_operations on bulk_operations: CREATE TABLE bulk_operations ( account_id TEXT NOT NULL, operation_id TEXT NOT NULL, table_id TEXT NOT NULL, table_name TEXT NOT NULL, table_arn TEXT NOT NULL, status TEXT NOT NULL, client_token TEXT, client_token_hash TEXT, request_fingerprint TEXT NOT NULL, input_config TEXT NOT NULL, output_config TEXT NOT NULL, input_files TEXT NOT NULL, max_write_request_units INTEGER, performance_tier TEXT, start_time_ms INTEGER NOT NULL, end_time_ms INTEGER, processed_item_count INTEGER NOT NULL DEFAULT 0, failed_item_count INTEGER NOT NULL DEFAULT 0, processed_input_bytes INTEGER NOT NULL DEFAULT 0, -- NULL until the input is enumerated; a failure born before the walk -- never sets it, and Describe then omits TotalInputBytes. total_input_bytes INTEGER, report_record_count INTEGER NOT NULL DEFAULT 0, report_bytes INTEGER NOT NULL DEFAULT 0, usage TEXT NOT NULL DEFAULT '{}', failure_code TEXT, failure_message TEXT, cancel_requested INTEGER NOT NULL DEFAULT 0, cursor_file_index INTEGER NOT NULL DEFAULT 0, cursor_row_index INTEGER NOT NULL DEFAULT 0, lease_owner TEXT, lease_until_ms INTEGER, -- JSON the engine records at Start for the runner (the store and the -- caller); NULL for a row written without one. Never rendered. run_context TEXT, PRIMARY KEY (account_id, operation_id) )
    +table bulk_row_outcomes on bulk_row_outcomes: CREATE TABLE bulk_row_outcomes ( account_id TEXT NOT NULL, operation_id TEXT NOT NULL, file_index INTEGER NOT NULL, row_index INTEGER NOT NULL, outcome TEXT NOT NULL, old_size INTEGER, old_image TEXT, PRIMARY KEY (account_id, operation_id, file_index, row_index) )
    +table bulk_write_owners on bulk_write_owners: CREATE TABLE bulk_write_owners ( account_id TEXT NOT NULL, operation_id TEXT NOT NULL, owner TEXT NOT NULL, cursor_file INTEGER NOT NULL, cursor_row INTEGER NOT NULL, stalled INTEGER NOT NULL, PRIMARY KEY (account_id, operation_id) )
     table extenddb_migrations on extenddb_migrations: CREATE TABLE extenddb_migrations (id INTEGER PRIMARY KEY, name TEXT NOT NULL, kind TEXT NOT NULL, checksum TEXT NOT NULL, outcome TEXT NOT NULL, notes TEXT NOT NULL DEFAULT '', applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), applied_by TEXT NOT NULL)
     table gsi_pending on gsi_pending: CREATE TABLE gsi_pending ( id INTEGER PRIMARY KEY AUTOINCREMENT, table_id TEXT NOT NULL, worker_partition INTEGER NOT NULL, old_item TEXT, new_item TEXT, index_context TEXT NOT NULL, ready_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) )
     table iam_group_members on iam_group_members: CREATE TABLE iam_group_members ( account_id TEXT NOT NULL, group_name TEXT NOT NULL, user_name TEXT NOT NULL, PRIMARY KEY (account_id, group_name, user_name), FOREIGN KEY (account_id, group_name) REFERENCES iam_groups(account_id, group_name) ON DELETE CASCADE, FOREIGN KEY (account_id, user_name) REFERENCES iam_users(account_id, user_name) ON DELETE CASCADE )
    
    From a9ff0d7b83441cb0b1c2693d5bc0ae4e0c79bcd0 Mon Sep 17 00:00:00 2001
    From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com>
    Date: Thu, 5 Nov 2026 22:10:03 +0000
    Subject: [PATCH 4/6] feat(engine,server): add StartBulkPutItems and the bulk
     operation APIs
    
    The engine dispatches StartBulkPutItems, DescribeBulkOperation,
    ListBulkOperations and CancelBulkOperation. Start checks the request in
    a fixed order, lists the input objects and records the operation. A
    replay with the same ClientToken and parameters returns that operation,
    and one with other parameters gets IdempotentParameterMismatchException.
    A worker claims runnable operations and applies each input row with
    PutItem's validation and condition handling, checkpointing after every
    batch. A stopped run resumes without writing a row twice. When the
    server stops its workers, a run still going after one second is
    aborted, and a later claim resumes it. The input and the gzip error
    reports go through the [bulk] store. The server evaluates each
    operation's S3 actions as its caller. UpdateTable refuses a new index
    while an operation on the table runs. A daemonized serve keeps the
    directory it started in, so a relative path in its configuration
    resolves as it does in the foreground. The server builds the engine's
    OperationContext, which gains the bulk store, the caller and the
    metrics, so both crates change in one commit.
    ---
     crates/app/src/cmd_serve.rs                   | 105 ++-
     crates/app/src/init_helpers.rs                | 197 +++++
     crates/auth/src/policy/condition/tests.rs     |   1 +
     .../src/policy/condition/tests/s3_prefix.rs   |  80 ++
     crates/auth/src/policy/context.rs             |  10 +
     crates/engine/src/bulk_put/arn.rs             | 129 ++++
     crates/engine/src/bulk_put/authorize.rs       | 296 ++++++++
     crates/engine/src/bulk_put/fingerprint.rs     | 231 ++++++
     .../engine/src/bulk_put/handlers/describe.rs  | 282 +++++++
     crates/engine/src/bulk_put/handlers/mod.rs    | 138 ++++
     .../src/bulk_put/handlers/model_pass.rs       | 302 ++++++++
     crates/engine/src/bulk_put/handlers/ops.rs    | 166 +++++
     crates/engine/src/bulk_put/handlers/start.rs  | 573 +++++++++++++++
     .../src/bulk_put/handlers/start/config.rs     |  50 ++
     .../src/bulk_put/handlers/start/permission.rs |  64 ++
     .../src/bulk_put/handlers/start/prefix.rs     |  68 ++
     .../src/bulk_put/handlers/start/replay.rs     |  63 ++
     crates/engine/src/bulk_put/handlers/tests.rs  | 362 +++++++++
     .../engine/src/bulk_put/handlers/validate.rs  | 356 +++++++++
     crates/engine/src/bulk_put/mod.rs             |  38 +
     crates/engine/src/bulk_put/pacer.rs           | 204 +++++
     crates/engine/src/bulk_put/report.rs          | 491 +++++++++++++
     crates/engine/src/bulk_put/rows/expression.rs | 198 +++++
     crates/engine/src/bulk_put/rows/messages.rs   | 205 ++++++
     crates/engine/src/bulk_put/rows/mod.rs        | 573 +++++++++++++++
     crates/engine/src/bulk_put/rows/scan.rs       | 295 ++++++++
     crates/engine/src/bulk_put/run_context.rs     |  74 ++
     crates/engine/src/bulk_put/runner/admit.rs    | 194 +++++
     crates/engine/src/bulk_put/runner/cut.rs      | 120 +++
     crates/engine/src/bulk_put/runner/failure.rs  | 218 ++++++
     crates/engine/src/bulk_put/runner/finalize.rs | 416 +++++++++++
     crates/engine/src/bulk_put/runner/held.rs     | 102 +++
     crates/engine/src/bulk_put/runner/in_place.rs | 162 ++++
     crates/engine/src/bulk_put/runner/manifest.rs | 171 +++++
     crates/engine/src/bulk_put/runner/mod.rs      | 336 +++++++++
     crates/engine/src/bulk_put/runner/outputs.rs  | 179 +++++
     .../engine/src/bulk_put/runner/permission.rs  | 121 +++
     crates/engine/src/bulk_put/runner/read.rs     | 568 ++++++++++++++
     crates/engine/src/bulk_put/runner/reader.rs   | 539 ++++++++++++++
     crates/engine/src/bulk_put/runner/recorded.rs |  26 +
     crates/engine/src/bulk_put/runner/resume.rs   | 238 ++++++
     crates/engine/src/bulk_put/runner/state.rs    | 490 +++++++++++++
     .../engine/src/bulk_put/runner/tests/admit.rs | 356 +++++++++
     .../engine/src/bulk_put/runner/tests/mod.rs   |   7 +
     .../src/bulk_put/runner/tests/resume.rs       |  13 +
     .../engine/src/bulk_put/runner/tests/state.rs | 450 ++++++++++++
     .../engine/src/bulk_put/runner/tests/write.rs | 100 +++
     crates/engine/src/bulk_put/runner/wait.rs     | 107 +++
     crates/engine/src/bulk_put/runner/write.rs    | 456 ++++++++++++
     crates/engine/src/bulk_put/s3/client.rs       | 209 ++++++
     crates/engine/src/bulk_put/s3/error.rs        | 261 +++++++
     crates/engine/src/bulk_put/s3/list.rs         | 146 ++++
     crates/engine/src/bulk_put/s3/manifest.rs     |  56 ++
     crates/engine/src/bulk_put/s3/mod.rs          | 346 +++++++++
     crates/engine/src/bulk_put/s3/probe.rs        | 129 ++++
     crates/engine/src/bulk_put/s3/read.rs         | 599 +++++++++++++++
     .../engine/src/bulk_put/s3/tests/checksums.rs | 202 +++++
     crates/engine/src/bulk_put/s3/tests/child.rs  |  64 ++
     .../engine/src/bulk_put/s3/tests/complete.rs  | 197 +++++
     .../src/bulk_put/s3/tests/credentials.rs      | 112 +++
     .../engine/src/bulk_put/s3/tests/deadlines.rs | 120 +++
     .../engine/src/bulk_put/s3/tests/dropped.rs   | 195 +++++
     crates/engine/src/bulk_put/s3/tests/list.rs   | 241 ++++++
     crates/engine/src/bulk_put/s3/tests/logs.rs   |  89 +++
     crates/engine/src/bulk_put/s3/tests/mod.rs    | 155 ++++
     crates/engine/src/bulk_put/s3/tests/read.rs   | 570 ++++++++++++++
     .../engine/src/bulk_put/s3/tests/redirect.rs  |  73 ++
     .../engine/src/bulk_put/s3/tests/replaced.rs  | 203 +++++
     crates/engine/src/bulk_put/s3/tests/smoke.rs  | 253 +++++++
     .../src/bulk_put/s3/tests/start_deadline.rs   | 164 +++++
     .../engine/src/bulk_put/s3/tests/startup.rs   | 101 +++
     crates/engine/src/bulk_put/s3/tests/stub.rs   | 218 ++++++
     .../engine/src/bulk_put/s3/tests/transient.rs | 252 +++++++
     crates/engine/src/bulk_put/s3/tests/write.rs  | 613 ++++++++++++++++
     crates/engine/src/bulk_put/s3/write.rs        | 512 +++++++++++++
     crates/engine/src/bulk_put/source/chain.rs    | 536 ++++++++++++++
     crates/engine/src/bulk_put/source/codec.rs    | 141 ++++
     .../bulk_put/source/contract/fs_fixture.rs    | 124 ++++
     .../src/bulk_put/source/contract/list.rs      | 256 +++++++
     .../src/bulk_put/source/contract/mod.rs       | 290 ++++++++
     .../src/bulk_put/source/contract/owner.rs     | 147 ++++
     .../src/bulk_put/source/contract/read.rs      | 191 +++++
     .../bulk_put/source/contract/s3_fixture.rs    | 298 ++++++++
     .../src/bulk_put/source/contract/sink.rs      | 300 ++++++++
     .../engine/src/bulk_put/source/fs_source.rs   | 186 +++++
     crates/engine/src/bulk_put/source/jail.rs     | 427 +++++++++++
     crates/engine/src/bulk_put/source/key_form.rs | 116 +++
     crates/engine/src/bulk_put/source/location.rs |  91 +++
     crates/engine/src/bulk_put/source/manifest.rs |  95 +++
     crates/engine/src/bulk_put/source/mod.rs      | 442 +++++++++++
     crates/engine/src/bulk_put/source/reader.rs   |  79 ++
     crates/engine/src/bulk_put/source/sink.rs     | 464 ++++++++++++
     .../src/bulk_put/source/sink/report_dir.rs    | 207 ++++++
     .../engine/src/bulk_put/source/tests/codec.rs | 344 +++++++++
     .../src/bulk_put/source/tests/dropped_fill.rs |  68 ++
     .../engine/src/bulk_put/source/tests/jail.rs  | 584 +++++++++++++++
     .../engine/src/bulk_put/source/tests/mod.rs   | 144 ++++
     .../src/bulk_put/source/tests/reader.rs       | 207 ++++++
     .../src/bulk_put/source/tests/rotation.rs     | 280 +++++++
     .../engine/src/bulk_put/source/tests/sink.rs  | 489 ++++++++++++
     crates/engine/src/bulk_put/source/walk.rs     | 296 ++++++++
     crates/engine/src/bulk_put/store.rs           | 236 ++++++
     crates/engine/src/bulk_put/store_root.rs      | 214 ++++++
     .../engine/src/bulk_put/tests/arn_parsing.rs  | 301 ++++++++
     .../src/bulk_put/tests/handlers/cancel.rs     | 176 +++++
     .../src/bulk_put/tests/handlers/describe.rs   | 272 +++++++
     .../src/bulk_put/tests/handlers/dispatch.rs   |  93 +++
     .../src/bulk_put/tests/handlers/list.rs       | 354 +++++++++
     .../bulk_put/tests/handlers/log_targets.rs    | 103 +++
     .../src/bulk_put/tests/handlers/logs.rs       |  90 +++
     .../engine/src/bulk_put/tests/handlers/mod.rs | 429 +++++++++++
     .../bulk_put/tests/handlers/run_context.rs    |  63 ++
     .../bulk_put/tests/handlers/s3_permissions.rs | 146 ++++
     .../bulk_put/tests/handlers/start_envelope.rs | 288 ++++++++
     .../bulk_put/tests/handlers/start_fields.rs   | 381 ++++++++++
     .../tests/handlers/start_locations.rs         | 309 ++++++++
     .../bulk_put/tests/handlers/start_outcomes.rs | 408 ++++++++++
     .../tests/handlers/start_prefix_forms.rs      | 186 +++++
     .../tests/handlers/start_prefix_rules.rs      | 210 ++++++
     .../bulk_put/tests/handlers/start_replay.rs   | 170 +++++
     .../tests/handlers/start_store_faults.rs      | 255 +++++++
     .../tests/handlers/start_terminal_born.rs     | 417 +++++++++++
     .../tests/handlers/start_validation.rs        | 362 +++++++++
     .../tests/handlers/unsupported_backend.rs     | 694 ++++++++++++++++++
     .../tests/handlers/update_table_guard.rs      | 112 +++
     .../src/bulk_put/tests/handlers/validation.rs | 494 +++++++++++++
     crates/engine/src/bulk_put/tests/mod.rs       |  97 +++
     .../src/bulk_put/tests/request_fingerprint.rs | 433 +++++++++++
     .../src/bulk_put/tests/row_parsing/kinds.rs   | 149 ++++
     .../src/bulk_put/tests/row_parsing/linear.rs  | 141 ++++
     .../src/bulk_put/tests/row_parsing/mod.rs     | 505 +++++++++++++
     .../src/bulk_put/tests/row_parsing/records.rs | 491 +++++++++++++
     .../src/bulk_put/tests/row_parsing/repeats.rs | 113 +++
     .../tests/runner/abandon_cut_restart.rs       | 109 +++
     .../src/bulk_put/tests/runner/batched.rs      | 550 ++++++++++++++
     .../src/bulk_put/tests/runner/cancellation.rs | 134 ++++
     .../engine/src/bulk_put/tests/runner/caps.rs  | 226 ++++++
     .../bulk_put/tests/runner/compressed_cut.rs   | 236 ++++++
     .../src/bulk_put/tests/runner/disk_full.rs    |  73 ++
     .../src/bulk_put/tests/runner/durability.rs   | 328 +++++++++
     .../src/bulk_put/tests/runner/exactly_once.rs | 399 ++++++++++
     .../bulk_put/tests/runner/fake_authorizer.rs  | 128 ++++
     .../src/bulk_put/tests/runner/faults.rs       | 388 ++++++++++
     .../bulk_put/tests/runner/faults_engines.rs   | 623 ++++++++++++++++
     .../src/bulk_put/tests/runner/harness.rs      | 444 +++++++++++
     .../bulk_put/tests/runner/harness/postgres.rs | 205 ++++++
     .../bulk_put/tests/runner/harness/tables.rs   | 280 +++++++
     .../bulk_put/tests/runner/held_location.rs    | 270 +++++++
     .../src/bulk_put/tests/runner/held_resume.rs  | 284 +++++++
     .../engine/src/bulk_put/tests/runner/input.rs | 326 ++++++++
     .../engine/src/bulk_put/tests/runner/jail.rs  | 117 +++
     .../bulk_put/tests/runner/lease_in_place.rs   | 283 +++++++
     .../src/bulk_put/tests/runner/lease_keeper.rs | 360 +++++++++
     .../bulk_put/tests/runner/lifecycle_cells.rs  | 621 ++++++++++++++++
     .../src/bulk_put/tests/runner/manifest.rs     | 492 +++++++++++++
     .../engine/src/bulk_put/tests/runner/mod.rs   | 268 +++++++
     .../src/bulk_put/tests/runner/open_refusal.rs | 340 +++++++++
     .../bulk_put/tests/runner/output_faults.rs    | 163 ++++
     .../tests/runner/outputs_after_outcome.rs     | 172 +++++
     .../src/bulk_put/tests/runner/overwritten.rs  | 289 ++++++++
     .../src/bulk_put/tests/runner/pacing_lease.rs | 378 ++++++++++
     .../src/bulk_put/tests/runner/parsing.rs      | 446 +++++++++++
     .../engine/src/bulk_put/tests/runner/pins.rs  | 458 ++++++++++++
     .../bulk_put/tests/runner/recorded_store.rs   | 130 ++++
     .../src/bulk_put/tests/runner/render.rs       | 239 ++++++
     .../src/bulk_put/tests/runner/replay.rs       | 154 ++++
     .../src/bulk_put/tests/runner/reports.rs      | 333 +++++++++
     .../bulk_put/tests/runner/restart_abandons.rs | 314 ++++++++
     .../src/bulk_put/tests/runner/resume.rs       | 561 ++++++++++++++
     .../src/bulk_put/tests/runner/resume_bound.rs |  97 +++
     .../src/bulk_put/tests/runner/rotation.rs     | 286 ++++++++
     .../engine/src/bulk_put/tests/runner/rows.rs  | 383 ++++++++++
     .../src/bulk_put/tests/runner/rows_faults.rs  | 396 ++++++++++
     .../src/bulk_put/tests/runner/s3_actions.rs   | 410 +++++++++++
     .../bulk_put/tests/runner/service_etags.rs    | 301 ++++++++
     .../src/bulk_put/tests/runner/sink_faults.rs  | 301 ++++++++
     .../bulk_put/tests/runner/source_faults.rs    | 458 ++++++++++++
     .../src/bulk_put/tests/runner/store_outage.rs | 335 +++++++++
     .../src/bulk_put/tests/runner/stored_key.rs   | 231 ++++++
     .../bulk_put/tests/runner/table_binding.rs    | 156 ++++
     .../src/bulk_put/tests/runner/takeover.rs     | 187 +++++
     .../src/bulk_put/tests/runner/writes.rs       | 471 ++++++++++++
     .../src/bulk_put/tests/worker/lifecycle.rs    | 187 +++++
     .../engine/src/bulk_put/tests/worker/mod.rs   | 292 ++++++++
     .../src/bulk_put/tests/worker/retention.rs    | 471 ++++++++++++
     crates/engine/src/bulk_put/usage.rs           | 343 +++++++++
     crates/engine/src/bulk_put/usage/tests.rs     | 341 +++++++++
     crates/engine/src/bulk_put/worker/claim.rs    | 304 ++++++++
     crates/engine/src/bulk_put/worker/mod.rs      | 348 +++++++++
     .../engine/src/bulk_put/worker/retention.rs   | 124 ++++
     .../src/bulk_put/worker/tests/cancel.rs       | 117 +++
     .../engine/src/bulk_put/worker/tests/claim.rs | 536 ++++++++++++++
     .../src/bulk_put/worker/tests/config.rs       |  88 +++
     .../engine/src/bulk_put/worker/tests/mod.rs   | 458 ++++++++++++
     .../src/bulk_put/worker/tests/retention.rs    | 299 ++++++++
     crates/engine/src/capacity_helpers.rs         |   2 +-
     crates/engine/src/lib.rs                      |  36 +
     crates/engine/src/update_table.rs             |  38 +
     crates/server/Cargo.toml                      |   6 +
     crates/server/src/authorization.rs            |  76 +-
     .../server/src/authorization/full_action.rs   | 405 ++++++++++
     .../src/authz_resource/cross_account.rs       |   5 +
     .../src/bulk_authorization_tests/arn.rs       | 167 +++++
     .../bulk_authorization_tests/conditions.rs    |  90 +++
     .../src/bulk_authorization_tests/mod.rs       | 356 +++++++++
     .../bulk_authorization_tests/s3_actions.rs    | 630 ++++++++++++++++
     .../src/bulk_authorization_tests/start.rs     | 333 +++++++++
     crates/server/src/bulk_authorizer.rs          | 290 ++++++++
     crates/server/src/bulk_store.rs               | 338 +++++++++
     crates/server/src/bulk_store/tests.rs         | 543 ++++++++++++++
     crates/server/src/handler.rs                  |  19 +
     crates/server/src/lib.rs                      |  14 +
     crates/server/src/management/account.rs       |  22 +
     crates/server/src/management/mod.rs           |   4 +
     crates/server/src/request_helpers.rs          |  18 +
     crates/server/src/request_helpers/bulk.rs     | 527 +++++++++++++
     crates/server/src/s3_client_key.rs            |  45 ++
     crates/server/src/s3_client_key/tests.rs      | 141 ++++
     crates/server/src/serve.rs                    |  46 +-
     crates/server/src/test_logs.rs                |  48 ++
     crates/server/src/workers.rs                  |  55 +-
     221 files changed, 55752 insertions(+), 37 deletions(-)
     create mode 100644 crates/auth/src/policy/condition/tests/s3_prefix.rs
     create mode 100644 crates/engine/src/bulk_put/arn.rs
     create mode 100644 crates/engine/src/bulk_put/authorize.rs
     create mode 100644 crates/engine/src/bulk_put/fingerprint.rs
     create mode 100644 crates/engine/src/bulk_put/handlers/describe.rs
     create mode 100644 crates/engine/src/bulk_put/handlers/mod.rs
     create mode 100644 crates/engine/src/bulk_put/handlers/model_pass.rs
     create mode 100644 crates/engine/src/bulk_put/handlers/ops.rs
     create mode 100644 crates/engine/src/bulk_put/handlers/start.rs
     create mode 100644 crates/engine/src/bulk_put/handlers/start/config.rs
     create mode 100644 crates/engine/src/bulk_put/handlers/start/permission.rs
     create mode 100644 crates/engine/src/bulk_put/handlers/start/prefix.rs
     create mode 100644 crates/engine/src/bulk_put/handlers/start/replay.rs
     create mode 100644 crates/engine/src/bulk_put/handlers/tests.rs
     create mode 100644 crates/engine/src/bulk_put/handlers/validate.rs
     create mode 100644 crates/engine/src/bulk_put/mod.rs
     create mode 100644 crates/engine/src/bulk_put/pacer.rs
     create mode 100644 crates/engine/src/bulk_put/report.rs
     create mode 100644 crates/engine/src/bulk_put/rows/expression.rs
     create mode 100644 crates/engine/src/bulk_put/rows/messages.rs
     create mode 100644 crates/engine/src/bulk_put/rows/mod.rs
     create mode 100644 crates/engine/src/bulk_put/rows/scan.rs
     create mode 100644 crates/engine/src/bulk_put/run_context.rs
     create mode 100644 crates/engine/src/bulk_put/runner/admit.rs
     create mode 100644 crates/engine/src/bulk_put/runner/cut.rs
     create mode 100644 crates/engine/src/bulk_put/runner/failure.rs
     create mode 100644 crates/engine/src/bulk_put/runner/finalize.rs
     create mode 100644 crates/engine/src/bulk_put/runner/held.rs
     create mode 100644 crates/engine/src/bulk_put/runner/in_place.rs
     create mode 100644 crates/engine/src/bulk_put/runner/manifest.rs
     create mode 100644 crates/engine/src/bulk_put/runner/mod.rs
     create mode 100644 crates/engine/src/bulk_put/runner/outputs.rs
     create mode 100644 crates/engine/src/bulk_put/runner/permission.rs
     create mode 100644 crates/engine/src/bulk_put/runner/read.rs
     create mode 100644 crates/engine/src/bulk_put/runner/reader.rs
     create mode 100644 crates/engine/src/bulk_put/runner/recorded.rs
     create mode 100644 crates/engine/src/bulk_put/runner/resume.rs
     create mode 100644 crates/engine/src/bulk_put/runner/state.rs
     create mode 100644 crates/engine/src/bulk_put/runner/tests/admit.rs
     create mode 100644 crates/engine/src/bulk_put/runner/tests/mod.rs
     create mode 100644 crates/engine/src/bulk_put/runner/tests/resume.rs
     create mode 100644 crates/engine/src/bulk_put/runner/tests/state.rs
     create mode 100644 crates/engine/src/bulk_put/runner/tests/write.rs
     create mode 100644 crates/engine/src/bulk_put/runner/wait.rs
     create mode 100644 crates/engine/src/bulk_put/runner/write.rs
     create mode 100644 crates/engine/src/bulk_put/s3/client.rs
     create mode 100644 crates/engine/src/bulk_put/s3/error.rs
     create mode 100644 crates/engine/src/bulk_put/s3/list.rs
     create mode 100644 crates/engine/src/bulk_put/s3/manifest.rs
     create mode 100644 crates/engine/src/bulk_put/s3/mod.rs
     create mode 100644 crates/engine/src/bulk_put/s3/probe.rs
     create mode 100644 crates/engine/src/bulk_put/s3/read.rs
     create mode 100644 crates/engine/src/bulk_put/s3/tests/checksums.rs
     create mode 100644 crates/engine/src/bulk_put/s3/tests/child.rs
     create mode 100644 crates/engine/src/bulk_put/s3/tests/complete.rs
     create mode 100644 crates/engine/src/bulk_put/s3/tests/credentials.rs
     create mode 100644 crates/engine/src/bulk_put/s3/tests/deadlines.rs
     create mode 100644 crates/engine/src/bulk_put/s3/tests/dropped.rs
     create mode 100644 crates/engine/src/bulk_put/s3/tests/list.rs
     create mode 100644 crates/engine/src/bulk_put/s3/tests/logs.rs
     create mode 100644 crates/engine/src/bulk_put/s3/tests/mod.rs
     create mode 100644 crates/engine/src/bulk_put/s3/tests/read.rs
     create mode 100644 crates/engine/src/bulk_put/s3/tests/redirect.rs
     create mode 100644 crates/engine/src/bulk_put/s3/tests/replaced.rs
     create mode 100644 crates/engine/src/bulk_put/s3/tests/smoke.rs
     create mode 100644 crates/engine/src/bulk_put/s3/tests/start_deadline.rs
     create mode 100644 crates/engine/src/bulk_put/s3/tests/startup.rs
     create mode 100644 crates/engine/src/bulk_put/s3/tests/stub.rs
     create mode 100644 crates/engine/src/bulk_put/s3/tests/transient.rs
     create mode 100644 crates/engine/src/bulk_put/s3/tests/write.rs
     create mode 100644 crates/engine/src/bulk_put/s3/write.rs
     create mode 100644 crates/engine/src/bulk_put/source/chain.rs
     create mode 100644 crates/engine/src/bulk_put/source/codec.rs
     create mode 100644 crates/engine/src/bulk_put/source/contract/fs_fixture.rs
     create mode 100644 crates/engine/src/bulk_put/source/contract/list.rs
     create mode 100644 crates/engine/src/bulk_put/source/contract/mod.rs
     create mode 100644 crates/engine/src/bulk_put/source/contract/owner.rs
     create mode 100644 crates/engine/src/bulk_put/source/contract/read.rs
     create mode 100644 crates/engine/src/bulk_put/source/contract/s3_fixture.rs
     create mode 100644 crates/engine/src/bulk_put/source/contract/sink.rs
     create mode 100644 crates/engine/src/bulk_put/source/fs_source.rs
     create mode 100644 crates/engine/src/bulk_put/source/jail.rs
     create mode 100644 crates/engine/src/bulk_put/source/key_form.rs
     create mode 100644 crates/engine/src/bulk_put/source/location.rs
     create mode 100644 crates/engine/src/bulk_put/source/manifest.rs
     create mode 100644 crates/engine/src/bulk_put/source/mod.rs
     create mode 100644 crates/engine/src/bulk_put/source/reader.rs
     create mode 100644 crates/engine/src/bulk_put/source/sink.rs
     create mode 100644 crates/engine/src/bulk_put/source/sink/report_dir.rs
     create mode 100644 crates/engine/src/bulk_put/source/tests/codec.rs
     create mode 100644 crates/engine/src/bulk_put/source/tests/dropped_fill.rs
     create mode 100644 crates/engine/src/bulk_put/source/tests/jail.rs
     create mode 100644 crates/engine/src/bulk_put/source/tests/mod.rs
     create mode 100644 crates/engine/src/bulk_put/source/tests/reader.rs
     create mode 100644 crates/engine/src/bulk_put/source/tests/rotation.rs
     create mode 100644 crates/engine/src/bulk_put/source/tests/sink.rs
     create mode 100644 crates/engine/src/bulk_put/source/walk.rs
     create mode 100644 crates/engine/src/bulk_put/store.rs
     create mode 100644 crates/engine/src/bulk_put/store_root.rs
     create mode 100644 crates/engine/src/bulk_put/tests/arn_parsing.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/cancel.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/describe.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/dispatch.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/list.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/log_targets.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/logs.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/mod.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/run_context.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/s3_permissions.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/start_envelope.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/start_fields.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/start_locations.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/start_outcomes.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/start_prefix_forms.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/start_prefix_rules.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/start_replay.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/start_store_faults.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/start_terminal_born.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/start_validation.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/unsupported_backend.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/update_table_guard.rs
     create mode 100644 crates/engine/src/bulk_put/tests/handlers/validation.rs
     create mode 100644 crates/engine/src/bulk_put/tests/mod.rs
     create mode 100644 crates/engine/src/bulk_put/tests/request_fingerprint.rs
     create mode 100644 crates/engine/src/bulk_put/tests/row_parsing/kinds.rs
     create mode 100644 crates/engine/src/bulk_put/tests/row_parsing/linear.rs
     create mode 100644 crates/engine/src/bulk_put/tests/row_parsing/mod.rs
     create mode 100644 crates/engine/src/bulk_put/tests/row_parsing/records.rs
     create mode 100644 crates/engine/src/bulk_put/tests/row_parsing/repeats.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/abandon_cut_restart.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/batched.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/cancellation.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/caps.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/compressed_cut.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/disk_full.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/durability.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/exactly_once.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/fake_authorizer.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/faults.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/faults_engines.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/harness.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/harness/postgres.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/harness/tables.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/held_location.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/held_resume.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/input.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/jail.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/lease_in_place.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/lease_keeper.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/lifecycle_cells.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/manifest.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/mod.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/open_refusal.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/output_faults.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/outputs_after_outcome.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/overwritten.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/pacing_lease.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/parsing.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/pins.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/recorded_store.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/render.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/replay.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/reports.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/restart_abandons.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/resume.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/resume_bound.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/rotation.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/rows.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/rows_faults.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/s3_actions.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/service_etags.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/sink_faults.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/source_faults.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/store_outage.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/stored_key.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/table_binding.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/takeover.rs
     create mode 100644 crates/engine/src/bulk_put/tests/runner/writes.rs
     create mode 100644 crates/engine/src/bulk_put/tests/worker/lifecycle.rs
     create mode 100644 crates/engine/src/bulk_put/tests/worker/mod.rs
     create mode 100644 crates/engine/src/bulk_put/tests/worker/retention.rs
     create mode 100644 crates/engine/src/bulk_put/usage.rs
     create mode 100644 crates/engine/src/bulk_put/usage/tests.rs
     create mode 100644 crates/engine/src/bulk_put/worker/claim.rs
     create mode 100644 crates/engine/src/bulk_put/worker/mod.rs
     create mode 100644 crates/engine/src/bulk_put/worker/retention.rs
     create mode 100644 crates/engine/src/bulk_put/worker/tests/cancel.rs
     create mode 100644 crates/engine/src/bulk_put/worker/tests/claim.rs
     create mode 100644 crates/engine/src/bulk_put/worker/tests/config.rs
     create mode 100644 crates/engine/src/bulk_put/worker/tests/mod.rs
     create mode 100644 crates/engine/src/bulk_put/worker/tests/retention.rs
     create mode 100644 crates/server/src/authorization/full_action.rs
     create mode 100644 crates/server/src/bulk_authorization_tests/arn.rs
     create mode 100644 crates/server/src/bulk_authorization_tests/conditions.rs
     create mode 100644 crates/server/src/bulk_authorization_tests/mod.rs
     create mode 100644 crates/server/src/bulk_authorization_tests/s3_actions.rs
     create mode 100644 crates/server/src/bulk_authorization_tests/start.rs
     create mode 100644 crates/server/src/bulk_authorizer.rs
     create mode 100644 crates/server/src/bulk_store.rs
     create mode 100644 crates/server/src/bulk_store/tests.rs
     create mode 100644 crates/server/src/request_helpers/bulk.rs
     create mode 100644 crates/server/src/s3_client_key.rs
     create mode 100644 crates/server/src/s3_client_key/tests.rs
     create mode 100644 crates/server/src/test_logs.rs
    
    diff --git a/crates/app/src/cmd_serve.rs b/crates/app/src/cmd_serve.rs
    index a754aa4c2..1ea7eba39 100755
    --- a/crates/app/src/cmd_serve.rs
    +++ b/crates/app/src/cmd_serve.rs
    @@ -234,7 +234,18 @@ pub fn run(args: &ServeArgs, build: BuildInfo) -> anyhow::Result<()> {
                 // before daemonizing so startup verification reads the new daemon's
                 // PID rather than a dead one from a previous run.
                 let _ = std::fs::remove_file(pid_file);
    -            let daemon = Daemonize::new().pid_file(pid_file);
    +            // The daemon keeps the directory `serve` was started in as its
    +            // working directory (the daemonize crate's default is `/`), so a
    +            // relative path resolves as it does in the foreground: the bulk
    +            // store path and its dev-mode default, the import and export
    +            // paths, the run directory and PID file, and the files the AWS SDK
    +            // reads from its environment. A start directory that cannot be
    +            // read leaves the default.
    +            let start_dir =
    +                std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("/"));
    +            let daemon = Daemonize::new()
    +                .pid_file(pid_file)
    +                .working_directory(&start_dir);
                 match daemon.execute() {
                     daemonize::Outcome::Parent(Ok(_)) => {
                         // Parent process: wait for the PID file to appear (written by
    @@ -373,8 +384,8 @@ fn render_startup_warnings(warnings: &[extenddb_storage::config::StartupWarning]
     }
     
     /// Print the developer-mode banner lines: the mode notice, the credentials to
    -/// sign with, and an advisory when an ambient `AWS_ACCESS_KEY_ID` would not
    -/// verify.
    +/// sign with, the bulk store path when dev mode substitutes the default, and
    +/// an advisory when an ambient `AWS_ACCESS_KEY_ID` would not verify.
     ///
     /// Resolving here (not just in `serve()`) fails fast on a misconfigured
     /// `EXTENDDB_DEV_*` pair before daemonizing, and gives the invoking user the
    @@ -396,6 +407,20 @@ fn print_dev_mode_banner(app_config: &config::AppConfig, foreground: bool) -> an
                 dev_credentials::describe(&dev_creds)
             ),
         ];
    +    if app_config.bulk.is_none() {
    +        // Dev mode substitutes the default store when [bulk] is absent. The
    +        // banner prints before the server canonicalizes the root, so it
    +        // names the configured value; the startup log carries the canonical
    +        // path.
    +        lines.push(format!(
    +            "  Bulk store: {} (dev-mode default; the startup log names the canonical path)",
    +            config::DEV_DEFAULT_BULK_PATH
    +        ));
    +    } else if let Some(line) =
    +        dev_mode_s3_line(app_config.bulk.as_ref(), &app_config.server.region)?
    +    {
    +        lines.push(line);
    +    }
         if let Some(ignored) = dev_credentials::ignored_aws_env_key(&dev_creds) {
             lines.push(format!(
                 "  Note: AWS_ACCESS_KEY_ID is set but not adopted; requests signed with \
    @@ -415,9 +440,37 @@ fn print_dev_mode_banner(app_config: &config::AppConfig, foreground: bool) -> an
         Ok(())
     }
     
    +/// The dev-mode banner line of an explicit S3 store, which is honoured in dev
    +/// mode (every caller has the reach of the server's own AWS credentials);
    +/// `None` for any other `[bulk]` section. A `[bulk.s3]` section is checked
    +/// first, so a value the startup checks refuse never reaches the banner.
    +///
    +/// # Errors
    +///
    +/// The startup refusal of the `[bulk.s3]` section, which never repeats the
    +/// value.
    +fn dev_mode_s3_line(
    +    bulk: Option<&config::BulkConfig>,
    +    server_region: &str,
    +) -> anyhow::Result> {
    +    let Some(bulk) = bulk else {
    +        return Ok(None);
    +    };
    +    if let Some(s3) = &bulk.s3 {
    +        s3.validate().map_err(anyhow::Error::msg)?;
    +    }
    +    if bulk.store != config::BulkStoreKind::S3 {
    +        return Ok(None);
    +    }
    +    Ok(Some(format!(
    +        "  Bulk store: {} with the server's AWS credentials",
    +        bulk.s3.clone().unwrap_or_default().describe(server_region)
    +    )))
    +}
    +
     #[cfg(test)]
     mod tests {
    -    use super::{ServeArgs, render_startup_warnings};
    +    use super::{ServeArgs, dev_mode_s3_line, render_startup_warnings};
         use clap::Parser;
         use extenddb_storage::config::StartupWarning;
     
    @@ -496,4 +549,48 @@ mod tests {
             assert!(rendered.contains("Writes remain atomic and durable"));
             assert!(rendered.contains("restore full DynamoDB transaction semantics"));
         }
    +
    +    fn bulk(section: serde_json::Value) -> extenddb_config::BulkConfig {
    +        serde_json::from_value(section).expect("a [bulk] section")
    +    }
    +
    +    #[test]
    +    fn the_dev_mode_banner_checks_the_s3_section_before_naming_it() {
    +        for store in ["s3", "filesystem"] {
    +            let section = bulk(serde_json::json!({
    +                "store": store,
    +                "filesystem": {"path": "/srv/bulk"},
    +                "s3": {"endpoint_url": "http://AKIDEXAMPLE:SECRETEXAMPLE@127.0.0.1:17070"},
    +            }));
    +            let error = dev_mode_s3_line(Some(§ion), "us-east-1")
    +                .expect_err("user information is refused before the banner")
    +                .to_string();
    +            assert_eq!(
    +                error, "[bulk.s3] endpoint_url must not carry user information",
    +                "{store}"
    +            );
    +        }
    +    }
    +
    +    #[test]
    +    fn the_dev_mode_banner_names_the_endpoint_by_scheme_host_and_port() {
    +        let section = bulk(serde_json::json!({
    +            "store": "s3",
    +            "s3": {"endpoint_url": "http://127.0.0.1:17070/base/path", "force_path_style": true},
    +        }));
    +        assert_eq!(
    +            dev_mode_s3_line(Some(§ion), "us-east-1").unwrap(),
    +            Some(
    +                "  Bulk store: s3 (region us-east-1, endpoint http://127.0.0.1:17070, path-style) \
    +                 with the server's AWS credentials"
    +                    .to_owned()
    +            )
    +        );
    +        let filesystem = bulk(serde_json::json!({"filesystem": {"path": "/srv/bulk"}}));
    +        assert_eq!(
    +            dev_mode_s3_line(Some(&filesystem), "us-east-1").unwrap(),
    +            None
    +        );
    +        assert_eq!(dev_mode_s3_line(None, "us-east-1").unwrap(), None);
    +    }
     }
    diff --git a/crates/app/src/init_helpers.rs b/crates/app/src/init_helpers.rs
    index 15e700f3f..713605fcc 100755
    --- a/crates/app/src/init_helpers.rs
    +++ b/crates/app/src/init_helpers.rs
    @@ -194,6 +194,79 @@ fn ensure_cert_covers_sans(
         )
     }
     
    +/// Commented bulk `[limits]` keys written into the generated config, with the
    +/// defaults. They are extenddb's own; DynamoDB exposes no equivalent. Kept in
    +/// sync with `extenddb.sample.toml` and covered by a test in this module.
    +const BULK_LIMITS_TEMPLATE: &str = "\
    +# Bulk operations (StartBulkPutItems and the workers that drive it).
    +# These keys are extenddb's own; DynamoDB exposes no equivalent.
    +# max_concurrent_bulk_operations = 50        # per account
    +# bulk_operation_retention_days = 30         # floored at 1; a purge pass runs at startup and daily
    +# bulk_operation_worker_concurrency = 4      # operations running at once per process; floored at 1
    +# bulk_operation_worker_per_account = 2      # of those, at most this many per account; floored at 1
    +# bulk_operation_lease_seconds = 60          # floored at 1
    +# bulk_put_batch_size = 25                   # rows per checkpoint; floored at 1, capped at 10000
    +# bulk_put_write_batch_rows = 25             # rows per storage transaction; capped at 100 and by bulk_put_batch_size
    +# max_bulk_input_bytes = 10737418240         # enumerated (compressed) total per operation; Start rejects above it
    +# max_bulk_input_files = 50000               # enumerated object count per operation; above it the operation fails
    +# max_bulk_input_object_bytes = 5368709120   # one enumerated input object; above it the operation fails
    +# max_bulk_client_token_bytes = 1024         # longest accepted ClientToken on StartBulkPutItems, in bytes
    +# max_bulk_line_bytes = 4194304              # one input line; longer abandons the file
    +# max_bulk_item_count = 100000000            # rows read per operation; more fails the operation
    +# max_bulk_report_records = 1000000          # records kept per operation; more are dropped and logged
    +# max_bulk_report_bytes = 1073741824         # serialized bytes of records kept per operation; more are dropped and logged
    +# max_bulk_failed_starts_per_hour = 100      # terminal-born starts per account per hour; above it Start answers ThrottlingException
    +# bulk_put_write_units_per_second = 80000    # ceiling on bulk writes per process, in 1 KB write units per second; 0 disables";
    +
    +/// Commented `[bulk]` store block written into the generated config, the
    +/// same block `extenddb.sample.toml` carries. The feature is disabled until
    +/// an operator uncomments one of its two examples. Covered by a test in this
    +/// module.
    +const BULK_STORE_TEMPLATE: &str = "\
    +# Bulk operations ([bulk]) are disabled by default (secure default). The bulk
    +# put operations read their input files and write their error reports through
    +# one store, independent of [import] and [export]. store selects it:
    +# \"filesystem\" or \"s3\".
    +#
    +# The filesystem store reads and writes beneath one root. The layout is
    +# {path}/{account id}/{S3Bucket}/...: the server creates {path} and the
    +# per-account directories; the caller creates the bucket directories beneath
    +# its own subtree and stages files there.
    +#
    +# Whoever can write beneath {path}/{account id} can feed data into any table
    +# that account starts a load on, so an account's subtree is as trusted as the
    +# mechanism that populates it.
    +#
    +# [bulk]
    +# store = \"filesystem\"
    +#
    +# [bulk.filesystem]
    +# path = \"/var/lib/extenddb/buckets\"
    +#
    +# The S3 store reads and writes the buckets the requests name, in AWS S3 or in
    +# an S3-compatible service at endpoint_url, with the credentials of the
    +# standard AWS credential chain (environment variables, the shared config and
    +# credentials files, web identity, container and instance credentials). This
    +# file holds no credentials. region defaults to [server] region; most
    +# S3-compatible services need force_path_style = true. An http:// endpoint_url
    +# must be loopback (127.x.y.z, ::1 or localhost) unless
    +# allow_insecure_endpoint = true, which accepts it and logs a warning at
    +# startup. Requests and responses to such an endpoint cross the network
    +# unencrypted.
    +#
    +# Every caller reaches the buckets the server's credentials reach, as far as
    +# its ExtendDB policies allow s3:ListBucket, s3:GetObject and s3:PutObject on
    +# them; dev mode evaluates no policy.
    +#
    +# [bulk]
    +# store = \"s3\"
    +#
    +# [bulk.s3]
    +# region = \"us-east-1\"
    +# endpoint_url = \"https://objects.example.internal:9000\"
    +# allow_insecure_endpoint = false
    +# force_path_style = true";
    +
     /// Generate a comprehensive config file with all settings.
     ///
     /// Computed values (connection string, bind address) are uncommented.
    @@ -300,6 +373,8 @@ key_path = "{tls_key}"
     # max_import_item_count = 10000000
     # max_export_item_count = 10000000
     
    +{BULK_LIMITS_TEMPLATE}
    +
     # [backup]
     # Backup and restore jobs. stale_job_after_secs must be at least three
     # heartbeat periods, the larger of heartbeat_interval_secs and the 15 s the
    @@ -321,6 +396,8 @@ key_path = "{tls_key}"
     # paths = []                     # Allowed directories for export operations
     
     # max_import_bytes = 10737418240 # Maximum import file size (10 GB)
    +
    +{BULK_STORE_TEMPLATE}
     "#,
         );
     
    @@ -329,6 +406,126 @@ key_path = "{tls_key}"
         Ok(())
     }
     
    +#[cfg(test)]
    +mod tests {
    +    use super::{BULK_LIMITS_TEMPLATE, BULK_STORE_TEMPLATE};
    +
    +    /// Strip one comment marker (`# ` or a bare `#`) from a template line.
    +    fn uncomment(line: &str) -> &str {
    +        line.strip_prefix("# ")
    +            .or_else(|| line.strip_prefix('#'))
    +            .unwrap_or(line)
    +    }
    +
    +    /// Each `[bulk]` block the generated config carries must uncomment to a
    +    /// section the config parser accepts, so the examples cannot drift from
    +    /// what the server reads. A block runs from a `# [bulk]` line to the
    +    /// next prose sentence (a line that uncomments to text starting with a
    +    /// capital letter); the prose between the blocks is dropped.
    +    #[test]
    +    fn bulk_store_template_uncomments_to_a_valid_section() {
    +        #[derive(serde::Deserialize)]
    +        struct Section {
    +            bulk: extenddb_config::BulkConfig,
    +        }
    +        let mut closed: Vec = Vec::new();
    +        let mut current: Vec<&str> = Vec::new();
    +        for line in BULK_STORE_TEMPLATE.lines() {
    +            let text = uncomment(line);
    +            if line == "# [bulk]" {
    +                if !current.is_empty() {
    +                    closed.push(current.join("\n"));
    +                }
    +                current = vec![text];
    +            } else if text.starts_with(|c: char| c.is_ascii_uppercase()) {
    +                if !current.is_empty() {
    +                    closed.push(current.join("\n"));
    +                    current = Vec::new();
    +                }
    +            } else if !current.is_empty() {
    +                current.push(text);
    +            }
    +        }
    +        if !current.is_empty() {
    +            closed.push(current.join("\n"));
    +        }
    +        assert_eq!(closed.len(), 2, "one filesystem and one s3 example");
    +
    +        let filesystem: Section = toml::from_str(&closed[0]).expect("filesystem block parses");
    +        assert_eq!(
    +            filesystem.bulk.store,
    +            extenddb_config::BulkStoreKind::Filesystem,
    +            "the first example is the filesystem store"
    +        );
    +        assert!(
    +            filesystem
    +                .bulk
    +                .filesystem
    +                .as_ref()
    +                .is_some_and(|fs| !fs.path.is_empty()),
    +            "the example names a filesystem path"
    +        );
    +
    +        let s3: Section = toml::from_str(&closed[1]).expect("s3 block parses");
    +        assert_eq!(
    +            s3.bulk.store,
    +            extenddb_config::BulkStoreKind::S3,
    +            "the second example is the S3 store"
    +        );
    +        let section = s3.bulk.s3.expect("the example carries [bulk.s3]");
    +        assert_eq!(section.region.as_deref(), Some("us-east-1"));
    +        assert!(section.endpoint_url.is_some());
    +        assert!(!section.allow_insecure_endpoint);
    +        assert!(section.force_path_style);
    +        assert_eq!(section.validate(), Ok(Vec::new()), "the example validates");
    +    }
    +
    +    /// The generated `[limits]` block must carry every bulk limit key. Taking
    +    /// the lines from the first key onward drops the prose, and parsing the
    +    /// uncommented keys confirms each is a valid TOML integer.
    +    #[test]
    +    fn bulk_limits_template_lists_every_bulk_key() {
    +        let body: String = BULK_LIMITS_TEMPLATE
    +            .lines()
    +            .skip_while(|l| !l.starts_with("# max_concurrent_bulk_operations"))
    +            .map(uncomment)
    +            .collect::>()
    +            .join("\n");
    +        let value: toml::Value = toml::from_str(&body).expect("bulk limit keys parse as TOML");
    +        let table = value.as_table().expect("a table of keys");
    +        let expected = [
    +            "max_concurrent_bulk_operations",
    +            "bulk_operation_retention_days",
    +            "bulk_operation_worker_concurrency",
    +            "bulk_operation_worker_per_account",
    +            "bulk_operation_lease_seconds",
    +            "bulk_put_batch_size",
    +            "bulk_put_write_batch_rows",
    +            "max_bulk_input_bytes",
    +            "max_bulk_input_files",
    +            "max_bulk_input_object_bytes",
    +            "max_bulk_client_token_bytes",
    +            "max_bulk_line_bytes",
    +            "max_bulk_item_count",
    +            "max_bulk_report_records",
    +            "max_bulk_report_bytes",
    +            "max_bulk_failed_starts_per_hour",
    +            "bulk_put_write_units_per_second",
    +        ];
    +        for key in expected {
    +            assert!(
    +                table.get(key).is_some_and(toml::Value::is_integer),
    +                "{key} is present with an integer default"
    +            );
    +        }
    +        assert_eq!(
    +            table.len(),
    +            expected.len(),
    +            "no bulk limit key is listed that the test does not name"
    +        );
    +    }
    +}
    +
     /// Write `contents` to `path` so that no other user can read it at any point:
     /// a new file is created 0600, and an existing one is narrowed to 0600 before
     /// it is truncated and rewritten. The config can carry the PostgreSQL
    diff --git a/crates/auth/src/policy/condition/tests.rs b/crates/auth/src/policy/condition/tests.rs
    index 670623709..8ac4631bf 100644
    --- a/crates/auth/src/policy/condition/tests.rs
    +++ b/crates/auth/src/policy/condition/tests.rs
    @@ -74,4 +74,5 @@ mod arn_operators;
     mod multivalued;
     mod operators;
     mod resource_tag;
    +mod s3_prefix;
     mod three_case;
    diff --git a/crates/auth/src/policy/condition/tests/s3_prefix.rs b/crates/auth/src/policy/condition/tests/s3_prefix.rs
    new file mode 100644
    index 000000000..49af9d768
    --- /dev/null
    +++ b/crates/auth/src/policy/condition/tests/s3_prefix.rs
    @@ -0,0 +1,80 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! `s3:prefix` on the real `RequestContext`.
    +//!
    +//! An `s3:ListBucket` evaluation carries the listing prefix as `s3:prefix`;
    +//! every other evaluation leaves the key absent. These tests pin the
    +//! resolution, the absent-key semantics when no prefix is set, and the
    +//! `StringLike` and `Null` forms a listing grant is written with.
    +
    +use std::collections::HashMap;
    +
    +use crate::policy::condition::evaluate_condition;
    +use crate::policy::context::{ConditionContext, RequestContext, RequestParams};
    +use crate::policy::document::{Condition, ConditionOperator};
    +
    +/// A request whose `s3:prefix` is `prefix`, or absent when `prefix` is `None`.
    +fn ctx(prefix: Option<&str>) -> RequestContext {
    +    RequestContext::build(
    +        HashMap::new(),
    +        HashMap::new(),
    +        false,
    +        RequestParams {
    +            s3_prefix: prefix.map(ToOwned::to_owned),
    +            ..Default::default()
    +        },
    +    )
    +}
    +
    +fn prefix_cond(op: ConditionOperator, values: &[&str]) -> Condition {
    +    Condition {
    +        operator: op,
    +        key: "s3:prefix".to_owned(),
    +        values: values.iter().map(|s| (*s).to_owned()).collect(),
    +    }
    +}
    +
    +#[test]
    +fn s3_prefix_resolves_to_the_listing_prefix() {
    +    let ctx = ctx(Some("loads/2026/"));
    +    assert_eq!(ctx.resolve_key("s3:prefix"), Some(vec!["loads/2026/"]));
    +    // The key name is matched without regard to case, like every other key
    +    // this context resolves.
    +    assert_eq!(ctx.resolve_key("S3:Prefix"), Some(vec!["loads/2026/"]));
    +    // A single-valued key: a bare operator applies to it.
    +    assert!(!ctx.is_multivalued_key("s3:prefix"));
    +}
    +
    +#[test]
    +fn s3_prefix_is_absent_when_unset_and_present_when_empty() {
    +    let unset = ctx(None);
    +    assert_eq!(unset.resolve_key("s3:prefix"), None);
    +    // Absent, not unresolvable: absent-key semantics apply and the key never
    +    // fails closed.
    +    assert!(!unset.is_unresolvable_key("s3:prefix"));
    +    // An empty prefix, a listing of the whole bucket, is a present key whose
    +    // value is the empty string.
    +    assert_eq!(ctx(Some("")).resolve_key("s3:prefix"), Some(vec![""]));
    +}
    +
    +#[test]
    +fn string_like_on_s3_prefix_matches_the_prefix_as_given() {
    +    let grant = prefix_cond(ConditionOperator::StringLike, &["loads/*"]);
    +    assert!(evaluate_condition(&grant, &ctx(Some("loads/"))));
    +    assert!(evaluate_condition(&grant, &ctx(Some("loads/2026/orders"))));
    +    assert!(!evaluate_condition(&grant, &ctx(Some("other/"))));
    +    // The prefix is compared as given: without the slash it does not match.
    +    assert!(!evaluate_condition(&grant, &ctx(Some("loads"))));
    +    // An empty prefix does not match, and neither does an absent key.
    +    assert!(!evaluate_condition(&grant, &ctx(Some(""))));
    +    assert!(!evaluate_condition(&grant, &ctx(None)));
    +}
    +
    +#[test]
    +fn null_on_s3_prefix_tells_an_absent_key_from_an_empty_prefix() {
    +    let absent = prefix_cond(ConditionOperator::Null, &["true"]);
    +    assert!(evaluate_condition(&absent, &ctx(None)));
    +    assert!(!evaluate_condition(&absent, &ctx(Some(""))));
    +    assert!(!evaluate_condition(&absent, &ctx(Some("loads/"))));
    +}
    diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs
    index dbc45b0f8..68b022225 100755
    --- a/crates/auth/src/policy/context.rs
    +++ b/crates/auth/src/policy/context.rs
    @@ -223,6 +223,9 @@ pub struct RequestParams {
         /// assumed without MFA) and leaves it absent for an IAM user with long-term
         /// keys. `Some(false)` for a role session, `None` for an IAM user.
         pub multi_factor_auth_present: Option,
    +    /// The key prefix an `s3:ListBucket` evaluation lists under (`s3:prefix`).
    +    /// `None` for every other evaluation, where the key is absent.
    +    pub s3_prefix: Option,
         /// The tags the request carries, for `aws:RequestTag/`. `None` for an
         /// operation that carries no tag values, where the key is absent.
         pub request_tags: Option>,
    @@ -283,6 +286,9 @@ pub struct RequestContext {
         /// `Some(false)` for a role session assumed without MFA, `None` for an IAM
         /// user (absent key), matching the service.
         pub multi_factor_auth_present: Option,
    +    /// The key prefix of an `s3:ListBucket` evaluation (`s3:prefix`); `None`
    +    /// (absent key) for every other evaluation.
    +    pub s3_prefix: Option,
         /// The request time formatted for `aws:CurrentTime` (RFC 3339 UTC).
         pub current_time: String,
         /// The request time formatted for `aws:EpochTime` (whole seconds).
    @@ -333,6 +339,7 @@ impl RequestContext {
                 resource_account: params.resource_account,
                 token_issue_time: params.token_issue_time,
                 multi_factor_auth_present: params.multi_factor_auth_present,
    +            s3_prefix: params.s3_prefix,
                 current_time,
                 epoch_time,
                 // An operation that carries a tag set but an empty one populates
    @@ -429,6 +436,9 @@ impl ConditionContext for RequestContext {
                     // caller.
                     "aws:viaawsservice" => Some(vec!["false"]),
                     "aws:principalisawsservice" => Some(vec!["false"]),
    +                // The listing prefix of an s3:ListBucket evaluation; absent for
    +                // every other evaluation.
    +                "s3:prefix" => self.s3_prefix.as_deref().map(|v| vec![v]),
                     _ => None,
                 }
             }
    diff --git a/crates/engine/src/bulk_put/arn.rs b/crates/engine/src/bulk_put/arn.rs
    new file mode 100644
    index 000000000..25943dab0
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/arn.rs
    @@ -0,0 +1,129 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Bulk operation ARN construction.
    +//!
    +//! An operation ARN is `arn:aws:dynamodb:{region}:{account}:bulk-operation/
    +//! {operation-id}` and an operation id is 14 decimal digits, a hyphen, and
    +//! 8 lowercase hex digits. Callers treat the id as opaque; this module fills
    +//! it with the start time in epoch milliseconds zero-padded to 14 digits and
    +//! 32 random bits.
    +//!
    +//! Parsing lives in `extenddb_core::arn`, because the server's
    +//! authorization layer reads the same ARNs when deriving policy resources
    +//! and the two readings must not drift apart. `own_arn` below is the one
    +//! the handlers call: it runs the shape check, then the account and region
    +//! comparisons, then the operation id check, which is the order the hosted
    +//! service answers a request wrong at two layers (measured on the
    +//! service). `parse_arn` is the same reading without a caller to compare
    +//! against, kept for callers that have no account in hand. Whether the
    +//! operation exists belongs to the handler, which looks up by the caller's
    +//! account so a foreign ARN never resolves.
    +
    +use std::time::{SystemTime, UNIX_EPOCH};
    +
    +/// The no-caller-context parse. Every handler reads an ARN through
    +/// `own_arn` below, so this spelling is reached only by the tests that pin
    +/// layer 1's length bounds and layer 2's id shape from inside, where no
    +/// caller account is in play.
    +#[cfg(test)]
    +pub(crate) use extenddb_core::arn::parse_bulk_operation_arn as parse_arn;
    +
    +use extenddb_core::error::DynamoDbError;
    +
    +/// Read an operation ARN on behalf of the caller, in the order the hosted
    +/// service applies the layers: shape, then the account and region
    +/// comparisons, then the partition, service, and operation id (measured
    +/// on the service). A foreign-account ARN is denied even when its id
    +/// is malformed, so the caller learns nothing about another account's
    +/// operations or id shapes from the answer. The region answer is the
    +/// not-found error, decided here without a storage read.
    +///
    +/// # Errors
    +///
    +/// Returns the layer's own wire error.
    +pub(crate) fn own_arn(
    +    value: &str,
    +    region: &str,
    +    account: &str,
    +) -> Result {
    +    extenddb_core::arn::own_bulk_operation_arn(value, region, account).map_err(
    +        |error| match error {
    +            extenddb_core::arn::BulkOperationArnError::Shape => {
    +                extenddb_core::arn::bulk_operation_arn_shape_error()
    +            }
    +            // The server's authorization layer answers a foreign account
    +            // first, with the full denial format; this branch serves dev mode.
    +            extenddb_core::arn::BulkOperationArnError::ForeignAccount => {
    +                DynamoDbError::AccessDeniedException("Access is denied".to_owned())
    +            }
    +            extenddb_core::arn::BulkOperationArnError::ForeignRegion => {
    +                extenddb_core::arn::bulk_operation_does_not_exist(value)
    +            }
    +            extenddb_core::arn::BulkOperationArnError::InvalidId => {
    +                extenddb_core::arn::bulk_operation_arn_id_error()
    +            }
    +        },
    +    )
    +}
    +
    +/// Build an operation id from a start time and 32 random bits: the time in
    +/// epoch milliseconds zero-padded to 14 decimal digits, a hyphen, and the
    +/// random bits as 8 lowercase hex digits.
    +pub(crate) fn new_operation_id(start_time_ms: u64, random: u32) -> String {
    +    // A 15-digit timestamp would break the 14-digit id shape; the bound is
    +    // unreachable before the year 5138.
    +    debug_assert!(start_time_ms < 100_000_000_000_000);
    +    format!("{start_time_ms:014}-{random:08x}")
    +}
    +
    +/// Build an operation id from the current time and fresh random bits.
    +pub(crate) fn generate_operation_id() -> String {
    +    let now_ms = SystemTime::now()
    +        .duration_since(UNIX_EPOCH)
    +        .map(|elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX))
    +        .unwrap_or(0);
    +    new_operation_id(now_ms, rand::random::())
    +}
    +
    +/// Render the ARN for one bulk operation.
    +pub(crate) fn format_arn(region: &str, account: &str, operation_id: &str) -> String {
    +    format!("arn:aws:dynamodb:{region}:{account}:bulk-operation/{operation_id}")
    +}
    +
    +/// Resolve a `TableName` value that may be a bare table name or a table ARN.
    +///
    +/// A bare name (any string not starting with `arn:`) passes through
    +/// unchanged. A string starting with `arn:` resolves only under the accept
    +/// rules of `extenddb_core::arn::own_table_arn_name`, which this function
    +/// shares with the server's authorization layer: exactly six
    +/// colon-separated parts, partition `aws`, service `dynamodb`, the
    +/// caller's region and account, resource `table/` plus a non-empty name
    +/// containing neither `/` nor `:`. A name never resolves across account,
    +/// region, or partition boundaries.
    +///
    +/// # Errors
    +///
    +/// A well-formed table ARN naming another account is denied with `Access
    +/// is denied` (measured), the string the engine already uses for the same
    +/// case on backups and tagging. One of the caller's account naming
    +/// another region is `ValidationException`, `1 validation error detected:
    +/// Invalid AWS region in '{arn}'` (measured), at validation step 2. An
    +/// own-account ARN whose name breaks the table-name rule answers the
    +/// measured `Table name of ARN` clause. Every other non-resolving `arn:`
    +/// spelling is `ValidationException` naming the value. The texts come from
    +/// `extenddb_core::arn`, which the server's authorization layer answers
    +/// with too.
    +pub(crate) fn table_name_from_name_or_arn(
    +    value: &str,
    +    region: &str,
    +    account: &str,
    +) -> Result {
    +    if !value.starts_with("arn:") {
    +        return Ok(value.to_owned());
    +    }
    +    let name = extenddb_core::arn::own_table_arn_name(value, region, account)
    +        .map_err(|error| extenddb_core::arn::table_arn_refusal(value, region, account, error))?;
    +    extenddb_core::arn::check_table_arn_name(value, &name)?;
    +    Ok(name)
    +}
    diff --git a/crates/engine/src/bulk_put/authorize.rs b/crates/engine/src/bulk_put/authorize.rs
    new file mode 100644
    index 000000000..3514ff281
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/authorize.rs
    @@ -0,0 +1,296 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The S3 actions a bulk operation performs as its caller, evaluated against
    +//! the caller's ExtendDB policies: `s3:ListBucket` on the input bucket at
    +//! Start, `s3:GetObject` on each input object when it is opened and at every
    +//! checkpoint boundary while it is read, and `s3:PutObject` on each report
    +//! object and on the manifest. The service reports a denial of each as
    +//! `S3AccessDenied`; the evaluation runs on every store, so the filesystem
    +//! store answers the same denials.
    +//!
    +//! The caller is captured once, by the server at Start, as a
    +//! [`BulkPrincipal`], and recorded on the operation; the runner evaluates the
    +//! recorded caller. The server implements [`BulkAuthorizer`] over its policy
    +//! cache. In developer mode there is no authorizer and every check is
    +//! skipped, as authorization is for every other operation.
    +
    +use std::collections::BTreeMap;
    +
    +use extenddb_storage::BoxedFuture as BoxFuture;
    +use serde::{Deserialize, Serialize};
    +
    +/// An S3 action a bulk operation performs as its caller.
    +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
    +pub enum S3Action {
    +    ListBucket,
    +    GetObject,
    +    PutObject,
    +}
    +
    +impl S3Action {
    +    /// The full action string a policy statement names.
    +    #[must_use]
    +    pub fn as_str(self) -> &'static str {
    +        match self {
    +            Self::ListBucket => "s3:ListBucket",
    +            Self::GetObject => "s3:GetObject",
    +            Self::PutObject => "s3:PutObject",
    +        }
    +    }
    +}
    +
    +/// The authenticated identity of a bulk operation's caller.
    +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    +#[serde(tag = "type")]
    +pub enum BulkIdentity {
    +    User {
    +        account_id: String,
    +        user_name: String,
    +    },
    +    RoleSession {
    +        account_id: String,
    +        role_name: String,
    +        session_name: String,
    +    },
    +}
    +
    +/// What a role session carried when Start ran: its inline session policy (the
    +/// stored document text), its session tags and its issue time. The runner
    +/// evaluates these instead of reading the session again, so a session that
    +/// expires or is removed during a long load neither widens the caller's
    +/// access nor fails the load.
    +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
    +pub struct SessionSnapshot {
    +    #[serde(default, skip_serializing_if = "Option::is_none")]
    +    pub policy: Option,
    +    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    +    pub tags: BTreeMap,
    +    #[serde(default, skip_serializing_if = "Option::is_none")]
    +    pub token_issue_time: Option,
    +}
    +
    +/// The caller of a `StartBulkPutItems` request as the server captured it,
    +/// with the request-level condition key values of that request
    +/// (`aws:SourceIp`, `aws:UserAgent`, `aws:SecureTransport`). Identity
    +/// policies, group policies, the permissions boundary and principal tags are
    +/// not part of it: they are read when each check runs.
    +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    +pub struct BulkPrincipal {
    +    pub identity: BulkIdentity,
    +    /// Set for a role session, `None` for an IAM user.
    +    #[serde(default, skip_serializing_if = "Option::is_none")]
    +    pub session: Option,
    +    #[serde(default, skip_serializing_if = "Option::is_none")]
    +    pub source_ip: Option,
    +    #[serde(default, skip_serializing_if = "Option::is_none")]
    +    pub user_agent: Option,
    +    #[serde(default)]
    +    pub secure_transport: bool,
    +}
    +
    +impl BulkPrincipal {
    +    /// The principal ARN as S3 spells it in a denial: an IAM user's ARN, or
    +    /// the `sts` assumed-role ARN of a role session.
    +    #[must_use]
    +    pub fn arn(&self) -> String {
    +        match &self.identity {
    +            BulkIdentity::User {
    +                account_id,
    +                user_name,
    +            } => format!("arn:aws:iam::{account_id}:user/{user_name}"),
    +            BulkIdentity::RoleSession {
    +                account_id,
    +                role_name,
    +                session_name,
    +            } => format!("arn:aws:sts::{account_id}:assumed-role/{role_name}/{session_name}"),
    +        }
    +    }
    +}
    +
    +/// Why an evaluation did not allow the action.
    +#[derive(Debug, Clone, PartialEq, Eq)]
    +pub enum AuthorizeError {
    +    /// The caller's policies do not allow it; `explicit` when a Deny
    +    /// statement matched.
    +    Denied { explicit: bool },
    +    /// The decision could not be made, for example because a policy could
    +    /// not be read. The detail is for the log.
    +    Unavailable(String),
    +}
    +
    +/// Evaluates an S3 action for a recorded caller.
    +pub trait BulkAuthorizer: Send + Sync {
    +    /// Evaluate `action` on `resource_arn` for `principal`. `s3_prefix` is the
    +    /// `s3:prefix` condition key value of an `s3:ListBucket`, `None` for the
    +    /// other actions.
    +    fn authorize<'a>(
    +        &'a self,
    +        principal: &'a BulkPrincipal,
    +        action: S3Action,
    +        resource_arn: &'a str,
    +        s3_prefix: Option<&'a str>,
    +    ) -> BoxFuture<'a, Result<(), AuthorizeError>>;
    +}
    +
    +/// The ARN of an S3 bucket.
    +pub(crate) fn bucket_arn(bucket: &str) -> String {
    +    format!("arn:aws:s3:::{bucket}")
    +}
    +
    +/// The ARN of the object `key` in `bucket`.
    +pub(crate) fn object_arn(bucket: &str, key: &str) -> String {
    +    format!("arn:aws:s3:::{bucket}/{key}")
    +}
    +
    +/// The `FailureMessage` of a denial, in S3's spelling with the resource
    +/// quoted. ExtendDB has no bucket policies, so only the identity-based forms
    +/// occur; no request was sent to S3, so there is no request-id tail.
    +#[must_use]
    +pub fn denial_message(
    +    principal: &BulkPrincipal,
    +    action: S3Action,
    +    resource_arn: &str,
    +    explicit: bool,
    +) -> String {
    +    let action = action.as_str();
    +    let head = format!(
    +        "User: {} is not authorized to perform: {action} on resource: \"{resource_arn}\"",
    +        principal.arn()
    +    );
    +    if explicit {
    +        format!("{head} with an explicit deny in an identity-based policy")
    +    } else {
    +        format!("{head} because no identity-based policy allows the {action} action")
    +    }
    +}
    +
    +/// The outcome of one check, as the Start handler and the runner act on it.
    +#[derive(Debug, Clone, PartialEq, Eq)]
    +pub(crate) enum Decision {
    +    Allowed,
    +    /// `S3AccessDenied` with this `FailureMessage`.
    +    Denied(String),
    +    /// The decision could not be made; the operation answers
    +    /// `InternalServerError`. Logged here.
    +    Unavailable,
    +}
    +
    +/// The `FailureMessage` of a check that fails closed because the operation
    +/// records no caller.
    +pub(crate) const NO_CALLER_MESSAGE: &str = "Access Denied";
    +
    +/// Run one check. With no authorizer (developer mode) every action is
    +/// allowed. With an authorizer and no recorded caller (an operation started
    +/// before callers were recorded) the check fails closed with a log line.
    +pub(crate) async fn decide(
    +    authorizer: Option<&dyn BulkAuthorizer>,
    +    principal: Option<&BulkPrincipal>,
    +    action: S3Action,
    +    resource_arn: &str,
    +    s3_prefix: Option<&str>,
    +    account_id: &str,
    +    operation_id: &str,
    +) -> Decision {
    +    let Some(authorizer) = authorizer else {
    +        return Decision::Allowed;
    +    };
    +    let Some(principal) = principal else {
    +        tracing::error!(
    +            account_id = %account_id,
    +            operation_id = %operation_id,
    +            action = action.as_str(),
    +            resource = %resource_arn,
    +            "bulk operation records no caller; the S3 permission check fails closed"
    +        );
    +        return Decision::Denied(NO_CALLER_MESSAGE.to_owned());
    +    };
    +    match authorizer
    +        .authorize(principal, action, resource_arn, s3_prefix)
    +        .await
    +    {
    +        Ok(()) => Decision::Allowed,
    +        Err(AuthorizeError::Denied { explicit }) => {
    +            tracing::info!(
    +                account_id = %account_id,
    +                operation_id = %operation_id,
    +                principal = %principal.arn(),
    +                action = action.as_str(),
    +                resource = %resource_arn,
    +                explicit,
    +                "bulk operation S3 permission check denied"
    +            );
    +            Decision::Denied(denial_message(principal, action, resource_arn, explicit))
    +        }
    +        Err(AuthorizeError::Unavailable(detail)) => {
    +            tracing::error!(
    +                account_id = %account_id,
    +                operation_id = %operation_id,
    +                action = action.as_str(),
    +                resource = %resource_arn,
    +                detail,
    +                "bulk operation S3 permission check could not be evaluated"
    +            );
    +            Decision::Unavailable
    +        }
    +    }
    +}
    +
    +#[cfg(test)]
    +mod tests {
    +    use serde_json::json;
    +
    +    use super::*;
    +
    +    fn role_session() -> BulkPrincipal {
    +        BulkPrincipal {
    +            identity: BulkIdentity::RoleSession {
    +                account_id: "123456789012".to_owned(),
    +                role_name: "loader".to_owned(),
    +                session_name: "night".to_owned(),
    +            },
    +            session: Some(SessionSnapshot {
    +                policy: Some("{}".to_owned()),
    +                tags: BTreeMap::from([("team".to_owned(), "a".to_owned())]),
    +                token_issue_time: Some("2026-10-25T16:00:00Z".to_owned()),
    +            }),
    +            source_ip: Some("10.0.0.1".to_owned()),
    +            user_agent: Some("ua".to_owned()),
    +            secure_transport: true,
    +        }
    +    }
    +
    +    #[test]
    +    fn a_role_session_is_named_by_its_sts_assumed_role_arn() {
    +        assert_eq!(
    +            denial_message(
    +                &role_session(),
    +                S3Action::PutObject,
    +                "arn:aws:s3:::b/k",
    +                false
    +            ),
    +            "User: arn:aws:sts::123456789012:assumed-role/loader/night is not authorized to \
    +             perform: s3:PutObject on resource: \"arn:aws:s3:::b/k\" because no identity-based \
    +             policy allows the s3:PutObject action"
    +        );
    +    }
    +
    +    #[test]
    +    fn the_recorded_caller_has_a_stable_stored_shape() {
    +        let value = serde_json::to_value(role_session()).unwrap();
    +        assert_eq!(
    +            value,
    +            json!({
    +                "identity": {"type": "RoleSession", "account_id": "123456789012",
    +                             "role_name": "loader", "session_name": "night"},
    +                "session": {"policy": "{}", "tags": {"team": "a"},
    +                            "token_issue_time": "2026-10-25T16:00:00Z"},
    +                "source_ip": "10.0.0.1",
    +                "user_agent": "ua",
    +                "secure_transport": true,
    +            })
    +        );
    +        let back: BulkPrincipal = serde_json::from_value(value).unwrap();
    +        assert_eq!(back, role_session());
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/fingerprint.rs b/crates/engine/src/bulk_put/fingerprint.rs
    new file mode 100644
    index 000000000..22a1e8afb
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/fingerprint.rs
    @@ -0,0 +1,231 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The idempotency request fingerprint for bulk put operations.
    +//!
    +//! When a `ClientToken` is replayed, the stored fingerprint decides between
    +//! returning the existing operation and
    +//! `IdempotentParameterMismatchException`. The fingerprint is SHA-256 over
    +//! a canonical JSON document of the request parameters that define the
    +//! operation, so two requests compare equal exactly when they ask for the
    +//! same work.
    +
    +use std::fmt::Write as _;
    +
    +use serde_json::{Map, Value};
    +use sha2::{Digest, Sha256};
    +
    +/// Compute the fingerprint of one start request.
    +///
    +/// The hashed document is `{"TableArn", "InputS3Configuration",
    +/// "OutputS3Configuration", "MaxWriteRequestUnits", "PerformanceTier"}`
    +/// with null members removed recursively and object keys sorted
    +/// recursively, so an explicit null and an absent member hash alike and
    +/// member order on the wire is irrelevant. `table_arn` is the target
    +/// table's ARN, not its bare name, so the fingerprint is stable across
    +/// the two ways of naming the table. The result is 64 lowercase hex
    +/// characters.
    +///
    +/// The caller normalizes the service defaults a retry may add or omit
    +/// (`strip_replayed_defaults` on each configuration, and `PerformanceTier`
    +/// `STANDARD` to an absent tier) before calling this, so the request as
    +/// submitted and the request with a default spelled out hash alike.
    +pub(crate) fn request_fingerprint(
    +    table_arn: &str,
    +    input: &Value,
    +    output: &Value,
    +    max_write_request_units: Option,
    +    performance_tier: Option<&str>,
    +) -> String {
    +    let mut document = Map::new();
    +    document.insert("TableArn".to_owned(), Value::from(table_arn));
    +    document.insert("InputS3Configuration".to_owned(), input.clone());
    +    document.insert("OutputS3Configuration".to_owned(), output.clone());
    +    if let Some(units) = max_write_request_units {
    +        document.insert("MaxWriteRequestUnits".to_owned(), Value::from(units));
    +    }
    +    if let Some(tier) = performance_tier {
    +        document.insert("PerformanceTier".to_owned(), Value::from(tier));
    +    }
    +    let mut canonical = String::new();
    +    write_canonical(&Value::Object(document), false, &mut canonical);
    +    hex::encode(Sha256::digest(canonical.as_bytes()))
    +}
    +
    +/// Remove the configuration members whose value equals a service default a
    +/// retry may add or omit without changing the operation, so the request as
    +/// submitted and the request with the default spelled out fingerprint alike
    +/// (measured on the service). The list is one entry per such default:
    +/// `InputCompressionType` `NONE` (input only; the output configuration has
    +/// no such member, so the rule is a no-op there) and `S3BucketOwner` equal
    +/// to the caller's own account (either configuration). `PerformanceTier`
    +/// `STANDARD` is the other replayed default; the caller drops it by passing
    +/// an absent tier. `S3SseAlgorithm` `AES256` is deliberately not here: a
    +/// retry that adds it mismatches (measured), so it stays in the
    +/// fingerprint. A member absent, or present with a non-default value, is
    +/// left untouched, so the fingerprint of a request that spells no default
    +/// is byte-identical to the pre-change fingerprint and a token issued
    +/// before this change still replays.
    +pub(crate) fn strip_replayed_defaults(config: &Value, caller_account: &str) -> Value {
    +    let mut config = config.clone();
    +    if let Some(members) = config.as_object_mut() {
    +        let defaults: [(&str, &str); 2] = [
    +            ("InputCompressionType", "NONE"),
    +            ("S3BucketOwner", caller_account),
    +        ];
    +        for (member, default) in defaults {
    +            if members.get(member).and_then(Value::as_str) == Some(default) {
    +                members.remove(member);
    +            }
    +        }
    +    }
    +    config
    +}
    +
    +/// Render the canonical form of one JSON value.
    +///
    +/// Object members whose value is null are dropped and keys are written in
    +/// lexicographic order, both applied recursively. `OutputType` is a set on
    +/// the wire, so the array under that key is sorted; every other array
    +/// keeps its order. `sort_elements` carries that one-key decision down to
    +/// the array itself.
    +fn write_canonical(value: &Value, sort_elements: bool, out: &mut String) {
    +    match value {
    +        Value::Null => out.push_str("null"),
    +        Value::Bool(true) => out.push_str("true"),
    +        Value::Bool(false) => out.push_str("false"),
    +        Value::Number(number) => {
    +            let _ = write!(out, "{number}");
    +        }
    +        Value::String(string) => write_string(string, out),
    +        Value::Array(elements) => {
    +            let mut rendered: Vec = elements
    +                .iter()
    +                .map(|element| {
    +                    let mut buffer = String::new();
    +                    write_canonical(element, false, &mut buffer);
    +                    buffer
    +                })
    +                .collect();
    +            if sort_elements {
    +                rendered.sort();
    +            }
    +            out.push('[');
    +            for (index, element) in rendered.iter().enumerate() {
    +                if index > 0 {
    +                    out.push(',');
    +                }
    +                out.push_str(element);
    +            }
    +            out.push(']');
    +        }
    +        Value::Object(members) => {
    +            let mut keys: Vec<&String> = members
    +                .iter()
    +                .filter(|(_, member)| !member.is_null())
    +                .map(|(key, _)| key)
    +                .collect();
    +            keys.sort();
    +            out.push('{');
    +            for (index, key) in keys.iter().enumerate() {
    +                if index > 0 {
    +                    out.push(',');
    +                }
    +                write_string(key, out);
    +                out.push(':');
    +                if let Some(member) = members.get(*key) {
    +                    write_canonical(member, *key == "OutputType", out);
    +                }
    +            }
    +            out.push('}');
    +        }
    +    }
    +}
    +
    +/// Write one JSON string literal. The escaping only needs to be
    +/// deterministic and injective for the hash; it escapes the quote, the
    +/// backslash, and control characters, which is valid JSON.
    +fn write_string(string: &str, out: &mut String) {
    +    out.push('"');
    +    for character in string.chars() {
    +        match character {
    +            '"' => out.push_str("\\\""),
    +            '\\' => out.push_str("\\\\"),
    +            character if (character as u32) < 0x20 => {
    +                let _ = write!(out, "\\u{:04x}", character as u32);
    +            }
    +            character => out.push(character),
    +        }
    +    }
    +    out.push('"');
    +}
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::request_fingerprint;
    +    use serde_json::json;
    +
    +    const TABLE_ARN: &str = "arn:aws:dynamodb:us-east-1:123456789012:table/orders";
    +
    +    /// Two Start requests that ask for the same work, written with their JSON
    +    /// members in a different order, with different whitespace, and with the
    +    /// OutputType set in a different order, hash to the same fingerprint;
    +    /// changing any value changes it. Object member order on the wire is
    +    /// normalised by the JSON parser before the canonical form is built, so
    +    /// the OutputType set order is the ordering difference the canonical form
    +    /// itself must absorb.
    +    #[test]
    +    fn member_order_and_whitespace_do_not_change_the_fingerprint() {
    +        let input_a: serde_json::Value = serde_json::from_str(
    +            r#"{"S3Bucket":"loads","S3KeyPrefix":"orders/","InputFormat":"DYNAMODB_JSON","InputCompressionType":"GZIP"}"#,
    +        )
    +        .unwrap();
    +        let input_b: serde_json::Value = serde_json::from_str(
    +            "{\n  \"InputCompressionType\" : \"GZIP\" ,\n  \"InputFormat\" : \"DYNAMODB_JSON\" ,\n  \"S3KeyPrefix\" : \"orders/\" ,\n  \"S3Bucket\"  :  \"loads\"\n}",
    +        )
    +        .unwrap();
    +        let output_a: serde_json::Value = serde_json::from_str(
    +            r#"{"S3KeyPrefix":"run/","OutputType":["ERROR","CONDITION_CHECK_FAILURE"]}"#,
    +        )
    +        .unwrap();
    +        let output_b: serde_json::Value = serde_json::from_str(
    +            "{ \"OutputType\" : [ \"CONDITION_CHECK_FAILURE\" , \"ERROR\" ] , \"S3KeyPrefix\" : \"run/\" }",
    +        )
    +        .unwrap();
    +
    +        let fp_a =
    +            request_fingerprint(TABLE_ARN, &input_a, &output_a, Some(1000), Some("STANDARD"));
    +        let fp_b =
    +            request_fingerprint(TABLE_ARN, &input_b, &output_b, Some(1000), Some("STANDARD"));
    +        assert_eq!(
    +            fp_a, fp_b,
    +            "member order and whitespace must not change the fingerprint"
    +        );
    +        assert_eq!(fp_a.len(), 64, "the fingerprint is 64 hex characters");
    +        assert!(
    +            fp_a.bytes()
    +                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
    +            "the fingerprint is lowercase hex: {fp_a}"
    +        );
    +
    +        let fp_units =
    +            request_fingerprint(TABLE_ARN, &input_a, &output_a, Some(2000), Some("STANDARD"));
    +        assert_ne!(
    +            fp_a, fp_units,
    +            "a changed MaxWriteRequestUnits must change the fingerprint"
    +        );
    +        let mut input_changed = input_a.clone();
    +        input_changed["S3Bucket"] = json!("other-bucket");
    +        let fp_bucket = request_fingerprint(
    +            TABLE_ARN,
    +            &input_changed,
    +            &output_a,
    +            Some(1000),
    +            Some("STANDARD"),
    +        );
    +        assert_ne!(
    +            fp_a, fp_bucket,
    +            "a changed input value must change the fingerprint"
    +        );
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/handlers/describe.rs b/crates/engine/src/bulk_put/handlers/describe.rs
    new file mode 100644
    index 000000000..ac5e0e95f
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/handlers/describe.rs
    @@ -0,0 +1,282 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Rendering stored bulk operation records onto the wire: the shared
    +//! description and summary renderers, the start outcome response builder,
    +//! status and tier parsing, the pagination cursor codec, and the error
    +//! helpers the handlers share.
    +
    +use super::*;
    +use crate::bulk_put::runner::OBJECTS_COUNT_LIMIT_CODE;
    +
    +/// Layer 3 of the operation ARN validation: a well-formed ARN that names no
    +/// operation in the caller's account, a foreign account, or another region.
    +/// The message lives in `extenddb_core::arn` because the server's
    +/// authorization layer answers a foreign ARN with the same error.
    +pub(super) fn does_not_exist(arn: &str) -> DynamoDbError {
    +    extenddb_core::arn::bulk_operation_does_not_exist(arn)
    +}
    +
    +/// A stored row the start handler wrote cannot be rendered. Client input
    +/// never reaches this path, so the client sees only the sanitized error.
    +fn corrupt_record(operation_id: &str, what: &str) -> DynamoDbError {
    +    tracing::error!(operation_id, what, "bulk operation record does not render");
    +    DynamoDbError::InternalServerError("Internal server error".to_owned())
    +}
    +
    +/// The wire answer for one start transaction outcome, shared by the
    +/// saturated-account path and the enumerated path so the messages cannot
    +/// diverge.
    +pub(super) fn start_response(
    +    outcome: StartOutcome,
    +    region: &str,
    +    account_limit: u32,
    +) -> Result {
    +    match outcome {
    +        StartOutcome::Created(record) | StartOutcome::IdempotentReplay(record) => {
    +            serialize_output(&StartBulkPutItemsOutput {
    +                bulk_operation_description: describe(&record, region)?,
    +            })
    +        }
    +        StartOutcome::IdempotentMismatch => {
    +            Err(DynamoDbError::IdempotentParameterMismatchException(
    +                IDEMPOTENT_MISMATCH_MESSAGE.to_owned(),
    +            ))
    +        }
    +        StartOutcome::TableBusy => Err(DynamoDbError::ResourceInUseException(
    +            TABLE_BUSY_MESSAGE.to_owned(),
    +        )),
    +        // The text is the measured one, missing separators between the
    +        // first three fragments included ("Other facts": a
    +        // sixth concurrent Start on the service answered it verbatim); the
    +        // configured limit is substituted where the hosted service names
    +        // its quota.
    +        StartOutcome::AccountLimitReached => Err(DynamoDbError::LimitExceededException(format!(
    +            "Subscriber limit exceededService quota exceeded You have reached the maximum \
    +             number of concurrent bulk operations for the selected PerformanceTier, which is \
    +             {account_limit}. Wait until other jobs finish, and try again. You can also reach \
    +             out to AWS Support to request a quota increase."
    +        ))),
    +        // The terminal-born cap, enforced exactly under the per-account
    +        // lock inside the start transaction. Same throttle the pre-check
    +        // fast path returns, so a burst that races past the pre-check is
    +        // still refused here.
    +        StartOutcome::FailedStartLimit => Err(DynamoDbError::ThrottlingException(
    +            "Rate of requests exceeds the allowed throughput.".to_owned(),
    +        )),
    +        // The index check the handler makes before enumeration, made again
    +        // inside the start transaction, so the same answer.
    +        StartOutcome::IndexOperationInProgress => Err(DynamoDbError::ResourceInUseException(
    +            INDEX_OPERATION_MESSAGE.to_owned(),
    +        )),
    +    }
    +}
    +
    +/// Render one stored record as its wire description. Start, Describe, and
    +/// the behaviour tests share this one renderer so the shapes cannot
    +/// diverge.
    +///
    +/// # Errors
    +///
    +/// Returns `InternalServerError` (with a log line, never a panic) when the
    +/// stored status, tier, or configuration does not parse; the start handler
    +/// wrote them, so a failure is an internal inconsistency, not client input.
    +pub(crate) fn describe(
    +    record: &BulkOperationRecord,
    +    region: &str,
    +) -> Result {
    +    let status = parse_status(&record.status)
    +        .ok_or_else(|| corrupt_record(&record.operation_id, "unknown stored status"))?;
    +    let performance_tier = match record.performance_tier.as_deref() {
    +        None => None,
    +        Some("STANDARD") => Some(BulkOperationPerformanceTier::Standard),
    +        Some("ACCELERATED") => Some(BulkOperationPerformanceTier::Accelerated),
    +        Some(_) => {
    +            return Err(corrupt_record(
    +                &record.operation_id,
    +                "unknown stored performance tier",
    +            ));
    +        }
    +    };
    +    let input_s3_configuration: InputS3Configuration =
    +        serde_json::from_value(record.input_config.clone())
    +            .map_err(|_| corrupt_record(&record.operation_id, "stored input configuration"))?;
    +    let output_s3_configuration: OutputS3Configuration =
    +        serde_json::from_value(record.output_config.clone())
    +            .map_err(|_| corrupt_record(&record.operation_id, "stored output configuration"))?;
    +    // The two byte members travel together and are absent when the hosted
    +    // service never kept an enumerated total: a failure born before the
    +    // walk (missing input bucket) and the object-count
    +    // quota failure (50,500 objects: neither member in
    +    // the description). The per-object size quota and every
    +    // other terminal shape carry both. ExtendDB stores the enumerated
    +    // total for the count class too, so the class is recognised by its
    +    // failure code here rather than by a storage change.
    +    let total_input_bytes = match record.failure_code.as_deref() {
    +        Some(OBJECTS_COUNT_LIMIT_CODE) => None,
    +        _ => record.total_input_bytes.map(non_negative),
    +    };
    +    Ok(BulkOperationDescription {
    +        bulk_operation_arn: arn::format_arn(region, &record.account_id, &record.operation_id),
    +        bulk_operation_status: status,
    +        // Re-derived on every render from the name and id the start
    +        // handler stored with the operation, so no migration is needed and
    +        // a Describe cannot disagree with the Start response.
    +        table_details: vec![BulkOperationTableDetail {
    +            table_name: record.table_name.clone(),
    +            table_id: record.table_id.clone(),
    +        }],
    +        // The model types these two members, but the service never emits
    +        // them: every description it answered during the runs
    +        // (326 of them among them) carried the table only in
    +        // `TableDetails`.
    +        table_arn: None,
    +        table_id: None,
    +        client_token: record.client_token.clone(),
    +        input_s3_configuration: Some(input_s3_configuration),
    +        output_s3_configuration: Some(output_s3_configuration),
    +        start_time: Some(millis_to_seconds(record.start_time_ms)),
    +        end_time: record.end_time_ms.map(millis_to_seconds),
    +        processed_item_count: Some(non_negative(record.processed_item_count)),
    +        processed_input_bytes: total_input_bytes
    +            .map(|_| non_negative(record.processed_input_bytes)),
    +        total_input_bytes,
    +        bulk_operation_usage: status
    +            .is_terminal()
    +            .then(|| vec![usage::usage_from_value(&record.usage, &record.table_name)]),
    +        failed_item_count: Some(non_negative(record.failed_item_count)),
    +        failure_code: record.failure_code.clone(),
    +        failure_message: record.failure_message.clone(),
    +        max_write_request_units: record.max_write_request_units,
    +        performance_tier,
    +    })
    +}
    +
    +/// Render one stored record as a list summary.
    +pub(super) fn summary(
    +    record: &BulkOperationRecord,
    +    region: &str,
    +) -> Result {
    +    let status = parse_status(&record.status)
    +        .ok_or_else(|| corrupt_record(&record.operation_id, "unknown stored status"))?;
    +    Ok(BulkOperationSummary {
    +        bulk_operation_arn: arn::format_arn(region, &record.account_id, &record.operation_id),
    +        bulk_operation_status: status,
    +        start_time: millis_to_seconds(record.start_time_ms),
    +        end_time: record.end_time_ms.map(millis_to_seconds),
    +    })
    +}
    +
    +/// Parse a stored status string; `None` for anything outside the six wire
    +/// statuses.
    +fn parse_status(status: &str) -> Option {
    +    match status {
    +        "IN_PROGRESS" => Some(BulkOperationStatus::InProgress),
    +        "COMPLETED" => Some(BulkOperationStatus::Completed),
    +        "COMPLETED_WITH_ERRORS" => Some(BulkOperationStatus::CompletedWithErrors),
    +        "CANCELLING" => Some(BulkOperationStatus::Cancelling),
    +        "CANCELLED" => Some(BulkOperationStatus::Cancelled),
    +        "FAILED" => Some(BulkOperationStatus::Failed),
    +        _ => None,
    +    }
    +}
    +
    +/// The stored vocabulary for one tier.
    +pub(super) fn tier_str(tier: BulkOperationPerformanceTier) -> &'static str {
    +    match tier {
    +        BulkOperationPerformanceTier::Standard => "STANDARD",
    +        BulkOperationPerformanceTier::Accelerated => "ACCELERATED",
    +    }
    +}
    +
    +/// A stored counter for the wire. The record readers refuse corrupt rows
    +/// where the contract requires it; rendering clamps at zero rather than
    +/// wrapping a negative into a huge unsigned figure.
    +fn non_negative(value: i64) -> u64 {
    +    u64::try_from(value).unwrap_or(0)
    +}
    +
    +/// Milliseconds since the epoch as the fractional seconds the wire carries.
    +#[allow(clippy::cast_precision_loss)] // epoch milliseconds stay far below 2^53
    +fn millis_to_seconds(millis: i64) -> f64 {
    +    millis as f64 / 1000.0
    +}
    +
    +/// The model's minimum and maximum length for `NextToken`, in characters.
    +/// The model constrains the token to `([0-9a-f]{16})+`, 1 to 1024
    +/// characters; the service adds a minimum of 112 (measured). 112 is a
    +/// multiple of 16, so a token padded to it still matches the pattern.
    +const NEXT_TOKEN_MIN_CHARS: usize = 112;
    +const NEXT_TOKEN_MAX_CHARS: usize = 1024;
    +
    +/// Render the pagination cursor: 16 hex digits of `start_time_ms`, then the
    +/// operation id bytes as lowercase hex, zero padded so the digit count is a
    +/// multiple of 16 and at least 112 (the model constrains the token to
    +/// `([0-9a-f]{16})+` and the service refuses a token under 112 characters).
    +/// The decoder strips trailing zero bytes, so the padding does not change
    +/// the cursor the token decodes to.
    +pub(super) fn encode_next_token(start_time_ms: i64, operation_id: &str) -> String {
    +    let mut token = format!("{:016x}", u64::try_from(start_time_ms).unwrap_or(0));
    +    token.push_str(&hex::encode(operation_id.as_bytes()));
    +    while token.len() < NEXT_TOKEN_MIN_CHARS || !token.len().is_multiple_of(16) {
    +        token.push('0');
    +    }
    +    token
    +}
    +
    +/// Decode a pagination cursor, answering each failing class with the text
    +/// the service answers (measured). A token over 1024 characters answers the
    +/// model length maximum; a token that does not match `([0-9a-f]{16})+`
    +/// (empty, not lowercase hex, or a length that is not a multiple of 16)
    +/// answers the model pattern; a well-formed hex token under 112 characters
    +/// answers the service minimum; a well-formed token of 112 or more that
    +/// does not decode to a cursor answers `Invalid Request: Provided nextToken
    +/// is invalid`. A token that decodes repositions within the caller's own
    +/// list, because the query is keyed by the caller's account, so a forged
    +/// token leaks nothing.
    +pub(super) fn decode_next_token(token: &str) -> Result {
    +    let count = token.chars().count();
    +    if count > NEXT_TOKEN_MAX_CHARS {
    +        return Err(DynamoDbError::ValidationException(format!(
    +            "1 validation error detected: Value '{token}' at 'nextToken' failed to satisfy \
    +             constraint: Member must have length less than or equal to {NEXT_TOKEN_MAX_CHARS}"
    +        )));
    +    }
    +    if token.is_empty()
    +        || !count.is_multiple_of(16)
    +        || !token
    +            .bytes()
    +            .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
    +    {
    +        return Err(DynamoDbError::ValidationException(format!(
    +            "1 validation error detected: Value '{token}' at 'nextToken' failed to satisfy \
    +             constraint: Member must satisfy regular expression pattern: ([0-9a-f]{{16}})+"
    +        )));
    +    }
    +    if count < NEXT_TOKEN_MIN_CHARS {
    +        return Err(DynamoDbError::ValidationException(format!(
    +            "Invalid Request: 1 validation error detected: Value '{token}' at 'nextToken' \
    +             failed to satisfy constraint: Member must have length greater than or equal to \
    +             {NEXT_TOKEN_MIN_CHARS}"
    +        )));
    +    }
    +    let not_a_cursor = || {
    +        DynamoDbError::ValidationException(format!(
    +            "Invalid Request: Provided nextToken is invalid: {token}"
    +        ))
    +    };
    +    let start_time = u64::from_str_radix(&token[..16], 16).map_err(|_| not_a_cursor())?;
    +    let start_time_ms = i64::try_from(start_time).map_err(|_| not_a_cursor())?;
    +    let mut id_bytes = hex::decode(&token[16..]).map_err(|_| not_a_cursor())?;
    +    while id_bytes.last() == Some(&0) {
    +        id_bytes.pop();
    +    }
    +    if id_bytes.is_empty() {
    +        return Err(not_a_cursor());
    +    }
    +    let operation_id = String::from_utf8(id_bytes).map_err(|_| not_a_cursor())?;
    +    Ok(ListCursor {
    +        start_time_ms,
    +        operation_id,
    +    })
    +}
    diff --git a/crates/engine/src/bulk_put/handlers/mod.rs b/crates/engine/src/bulk_put/handlers/mod.rs
    new file mode 100644
    index 000000000..ffd5ee4f8
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/handlers/mod.rs
    @@ -0,0 +1,138 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Request handlers for the four bulk operations: `StartBulkPutItems`,
    +//! `DescribeBulkOperation`, `ListBulkOperations`, and
    +//! `CancelBulkOperation`.
    +//!
    +//! `StartBulkPutItems` validates in seven observable steps: body
    +//! deserialization, field constraints, feature support, the bulk store,
    +//! table lookup, input enumeration, and the start transaction. The order is
    +//! part of the wire contract because each step has its own error; a request
    +//! wrong at two steps gets the earlier step's message. The other three
    +//! handlers share the three-layer operation ARN validation: shape, id
    +//! shape, then existence in the caller's account. A foreign-account or
    +//! foreign-region ARN answers the third layer without consulting storage,
    +//! so the caller cannot learn whether the foreign operation exists.
    +
    +use serde_json::Value;
    +use sha2::{Digest, Sha256};
    +
    +use extenddb_core::error::DynamoDbError;
    +use extenddb_core::error::{ErrorMessageKey, error_message};
    +use extenddb_core::types::{
    +    BulkOperationDescription, BulkOperationInputFormat, BulkOperationPerformanceTier,
    +    BulkOperationStatus, BulkOperationSummary, BulkOperationTableDetail, CancelBulkOperationInput,
    +    CancelBulkOperationOutput, DescribeBulkOperationInput, DescribeBulkOperationOutput,
    +    DescribeTableInput, InputCompressionType, InputS3Configuration, ListBulkOperationsInput,
    +    ListBulkOperationsOutput, OutputS3Configuration, StartBulkPutItemsInput,
    +    StartBulkPutItemsOutput, TableDescription, TableStatus,
    +};
    +use extenddb_storage::{
    +    BulkOperationRecord, CancelOutcome, InputFile, ListCursor, NewBulkOperation, StartOutcome,
    +};
    +
    +use crate::OperationContext;
    +use crate::bulk_put::arn;
    +use crate::bulk_put::epoch_millis;
    +use crate::bulk_put::fingerprint;
    +use crate::bulk_put::source::{BucketRef, SourceError};
    +use crate::bulk_put::usage;
    +use crate::create_table::storage_err_to_dynamo;
    +use crate::serialize_output;
    +
    +mod describe;
    +mod model_pass;
    +mod ops;
    +mod start;
    +mod validate;
    +
    +#[cfg(test)]
    +mod tests;
    +
    +use describe::*;
    +use model_pass::check_model_constraints;
    +use validate::*;
    +
    +pub use ops::{
    +    handle_cancel_bulk_operation, handle_describe_bulk_operation, handle_list_bulk_operations,
    +};
    +pub use start::handle_start_bulk_put_items;
    +
    +/// The two stored configurations together may serialize to at most this
    +/// many bytes, so the catalog row is bounded. The cap is this server's
    +/// own; the hosted service stores S3 locations and has no equivalent.
    +const MAX_CONFIGURATION_BYTES: usize = 8192;
    +
    +/// `ListBulkOperations` page size: the model's bounds and the default when
    +/// `MaxResults` is absent.
    +const MAX_RESULTS_CEILING: i64 = 25;
    +
    +/// The page size when `MaxResults` is absent (measured).
    +const MAX_RESULTS_DEFAULT: i64 = 10;
    +/// The model's cap on each `S3KeyPrefix` field, in characters.
    +const MAX_PREFIX_CHARS: usize = 1024;
    +
    +/// The minimum the service applies to `S3Bucket` and `S3KeyPrefix` beyond
    +/// the model (measured on the service).
    +const MIN_SERVICE_NAME_CHARS: usize = 3;
    +
    +/// The wire spellings the two bucket members are named by when the service
    +/// refuses the name outright rather than reporting a model constraint
    +/// (measured on the service: the message is `Invalid Request:
    +///  is invalid`).
    +const INPUT_BUCKET_MEMBER: &str = "InputS3Configuration.S3Bucket";
    +const OUTPUT_BUCKET_MEMBER: &str = "OutputS3Configuration.S3Bucket";
    +/// The model's cap on `S3SseKmsKeyId`, in characters.
    +const MAX_KMS_KEY_ID_CHARS: usize = 2048;
    +/// The model's cap on `TableName` (typed as `TableArn`), in characters. The
    +/// shape also carries a minimum of 1.
    +const MAX_TABLE_NAME_CHARS: usize = 1024;
    +
    +/// Measured mismatch message for a replayed token with different
    +/// parameters.
    +const IDEMPOTENT_MISMATCH_MESSAGE: &str = "Specified idempotent token was already used with \
    +     different request parameters within the idempotency window";
    +
    +/// Measured message for a second start on a table with a non-terminal
    +/// operation.
    +const TABLE_BUSY_MESSAGE: &str = "Table has ongoing bulk operations, please wait and try again";
    +
    +/// The service's minimum for `MaxWriteRequestUnits` (measured on the service:
    +/// 1000 and 1023 refused with the `Invalid Request:` message, 1024 started).
    +const MIN_WRITE_REQUEST_UNITS: i64 = 1024;
    +
    +/// Measured message for a start on a table being deleted: answered
    +/// immediately after `DeleteTable` and three seconds
    +/// later. The table name is not named, unlike the in-use message. The
    +/// start transaction answers the same text for a table set `DELETING`
    +/// after the lookup.
    +const TABLE_DELETING_MESSAGE: &str = extenddb_storage::bulk_operation_store::TABLE_DELETING_MESSAGE;
    +
    +/// Measured message for a start on a table with an index being created
    +/// (measured on the service, both creation phases).
    +const INDEX_OPERATION_MESSAGE: &str =
    +    "Table has ongoing index operations, please wait and try again";
    +
    +/// Maps a storage error for the bulk handlers. Every variant the table
    +/// handlers log at ERROR is mapped and logged here, with the same answer
    +/// and the same fields, so the line carries the bulk handlers' target
    +/// rather than the table handlers' one; every other variant maps as the
    +/// table handlers map it.
    +pub(in crate::bulk_put) fn bulk_storage_err(
    +    error: extenddb_storage::error::StorageError,
    +) -> DynamoDbError {
    +    use extenddb_storage::error::StorageError;
    +    let internal = || DynamoDbError::InternalServerError("Internal server error".to_owned());
    +    match error {
    +        StorageError::Internal(message) => {
    +            tracing::error!(internal_error = %message, "bulk operation storage internal error");
    +            internal()
    +        }
    +        StorageError::Connection(message) => {
    +            tracing::error!(internal_error = %message, "storage connection error");
    +            DynamoDbError::ServiceUnavailable("Service is temporarily unavailable".to_owned())
    +        }
    +        other => storage_err_to_dynamo(other),
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/handlers/model_pass.rs b/crates/engine/src/bulk_put/handlers/model_pass.rs
    new file mode 100644
    index 000000000..00af997df
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/handlers/model_pass.rs
    @@ -0,0 +1,302 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The model-constraint pass of `StartBulkPutItems`: every enum, pattern,
    +//! length and range constraint the model carries on the configuration and
    +//! top-level members, checked on the raw body and answered together in one
    +//! `N validation errors detected: c1; c2; ...` envelope.
    +//!
    +//! Measured on the service: a request wrong at several members answers one
    +//! envelope with a clause per fault, in a fixed order that is not
    +//! alphabetical (`MEMBER_ORDER` below), with at most ten clauses, and the
    +//! count names the clauses listed. `TableName` is judged in a pass of its
    +//! own before this one, and the service's `Invalid Request:` checks after
    +//! it.
    +//!
    +//! A member of the wrong JSON type raises no clause here; deserialization
    +//! answers it as the server's `SerializationException`.
    +
    +use serde_json::{Map, Value};
    +
    +use extenddb_core::error::DynamoDbError;
    +
    +use super::{
    +    MAX_KMS_KEY_ID_CHARS, MAX_PREFIX_CHARS, MIN_WRITE_REQUEST_UNITS, bucket_matches_pattern,
    +};
    +
    +/// The most clauses the service lists in one envelope (measured: thirteen
    +/// faults answered `10 validation errors detected` with the first ten).
    +const MAX_CLAUSES: usize = 10;
    +
    +/// The longest bucket name the model admits.
    +const MAX_BUCKET_CHARS: usize = 255;
    +
    +/// The order the service lists the members' clauses in (measured across
    +/// nineteen cells, each order consistent with every other).
    +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
    +enum Member {
    +    OutputSseAlgorithm,
    +    OutputType,
    +    OutputBucketOwner,
    +    OutputKeyPrefix,
    +    OutputBucket,
    +    OutputKmsKeyId,
    +    PerformanceTier,
    +    ClientToken,
    +    InputCompressionType,
    +    InputBucketOwner,
    +    InputFormat,
    +    InputKeyPrefix,
    +    InputBucket,
    +    MaxWriteRequestUnits,
    +}
    +
    +/// Collect the model-constraint clauses of a `StartBulkPutItems` body and
    +/// answer them in one envelope.
    +///
    +/// # Errors
    +///
    +/// `ValidationException` with every clause, in the service's member order,
    +/// capped at ten.
    +pub(super) fn check_model_constraints(body: &Value) -> Result<(), DynamoDbError> {
    +    let Some(obj) = body.as_object() else {
    +        return Ok(());
    +    };
    +    let mut clauses: Vec<(Member, String)> = Vec::new();
    +    if let Some(output) = obj.get("OutputS3Configuration").and_then(Value::as_object) {
    +        output_clauses(output, &mut clauses);
    +    }
    +    if let Some(Value::String(found)) = obj.get("PerformanceTier")
    +        && !matches!(found.as_str(), "STANDARD" | "ACCELERATED")
    +    {
    +        clauses.push((
    +            Member::PerformanceTier,
    +            enum_clause(found, "performanceTier", "[ACCELERATED, STANDARD]"),
    +        ));
    +    }
    +    if let Some(Value::String(token)) = obj.get("ClientToken")
    +        // The pattern also refuses U+0000: the token is stored verbatim in
    +        // a text column, and PostgreSQL refuses a NUL byte in text, so the
    +        // byte is answered with the pattern clause.
    +        && (token.is_empty() || token.contains('$') || token.contains('\0'))
    +    {
    +        clauses.push((
    +            Member::ClientToken,
    +            format!(
    +                "Value '{token}' at 'clientToken' failed to satisfy constraint: Member must \
    +                 satisfy regular expression pattern: ^[^\\$]+$"
    +            ),
    +        ));
    +    }
    +    if let Some(input) = obj.get("InputS3Configuration").and_then(Value::as_object) {
    +        input_clauses(input, &mut clauses);
    +    }
    +    if let Some(units) = obj.get("MaxWriteRequestUnits").and_then(Value::as_i64)
    +        && units < MIN_WRITE_REQUEST_UNITS
    +    {
    +        // Every value under the minimum answers the model clause (measured
    +        // for 0, 1, 1000 and 1023).
    +        clauses.push((
    +            Member::MaxWriteRequestUnits,
    +            format!(
    +                "Value '{units}' at 'maxWriteRequestUnits' failed to satisfy constraint: Member \
    +                 must have value greater than or equal to {MIN_WRITE_REQUEST_UNITS}"
    +            ),
    +        ));
    +    }
    +    envelope(clauses)
    +}
    +
    +fn output_clauses(output: &Map, clauses: &mut Vec<(Member, String)>) {
    +    if let Some(Value::String(found)) = output.get("S3SseAlgorithm")
    +        && !matches!(found.as_str(), "AES256" | "KMS")
    +    {
    +        clauses.push((
    +            Member::OutputSseAlgorithm,
    +            enum_clause(
    +                found,
    +                "outputS3Configuration.s3SseAlgorithm",
    +                "[AES256, KMS]",
    +            ),
    +        ));
    +    }
    +    if let Some(clause) = output_type_clause(output.get("OutputType")) {
    +        clauses.push((Member::OutputType, clause));
    +    }
    +    if let Some(Value::String(owner)) = output.get("S3BucketOwner") {
    +        owner_clause(owner, "outputS3Configuration.s3BucketOwner")
    +            .into_iter()
    +            .for_each(|clause| clauses.push((Member::OutputBucketOwner, clause)));
    +    }
    +    if let Some(Value::String(prefix)) = output.get("S3KeyPrefix") {
    +        prefix_clause(prefix, "outputS3Configuration.s3KeyPrefix")
    +            .into_iter()
    +            .for_each(|clause| clauses.push((Member::OutputKeyPrefix, clause)));
    +    }
    +    if let Some(Value::String(bucket)) = output.get("S3Bucket") {
    +        for clause in bucket_clauses(bucket, "outputS3Configuration.s3Bucket") {
    +            clauses.push((Member::OutputBucket, clause));
    +        }
    +    }
    +    if let Some(Value::String(key_id)) = output.get("S3SseKmsKeyId") {
    +        let length = key_id.chars().count();
    +        let rule = if length == 0 {
    +            Some("greater than or equal to 1".to_owned())
    +        } else if length > MAX_KMS_KEY_ID_CHARS {
    +            Some(format!("less than or equal to {MAX_KMS_KEY_ID_CHARS}"))
    +        } else {
    +            None
    +        };
    +        if let Some(rule) = rule {
    +            clauses.push((
    +                Member::OutputKmsKeyId,
    +                format!(
    +                    "Value '{key_id}' at 'outputS3Configuration.s3SseKmsKeyId' failed to satisfy \
    +                     constraint: Member must have length {rule}"
    +                ),
    +            ));
    +        }
    +    }
    +}
    +
    +fn input_clauses(input: &Map, clauses: &mut Vec<(Member, String)>) {
    +    if let Some(Value::String(found)) = input.get("InputCompressionType")
    +        && !matches!(found.as_str(), "GZIP" | "ZSTD" | "NONE")
    +    {
    +        clauses.push((
    +            Member::InputCompressionType,
    +            enum_clause(
    +                found,
    +                "inputS3Configuration.inputCompressionType",
    +                "[ZSTD, NONE, GZIP]",
    +            ),
    +        ));
    +    }
    +    if let Some(Value::String(owner)) = input.get("S3BucketOwner") {
    +        owner_clause(owner, "inputS3Configuration.s3BucketOwner")
    +            .into_iter()
    +            .for_each(|clause| clauses.push((Member::InputBucketOwner, clause)));
    +    }
    +    if let Some(Value::String(found)) = input.get("InputFormat")
    +        && !matches!(found.as_str(), "DYNAMODB_JSON" | "ION")
    +    {
    +        clauses.push((
    +            Member::InputFormat,
    +            enum_clause(
    +                found,
    +                "inputS3Configuration.inputFormat",
    +                "[ION, DYNAMODB_JSON]",
    +            ),
    +        ));
    +    }
    +    if let Some(Value::String(prefix)) = input.get("S3KeyPrefix") {
    +        prefix_clause(prefix, "inputS3Configuration.s3KeyPrefix")
    +            .into_iter()
    +            .for_each(|clause| clauses.push((Member::InputKeyPrefix, clause)));
    +    }
    +    if let Some(Value::String(bucket)) = input.get("S3Bucket") {
    +        for clause in bucket_clauses(bucket, "inputS3Configuration.s3Bucket") {
    +            clauses.push((Member::InputBucket, clause));
    +        }
    +    }
    +}
    +
    +/// The enum clause: the full lowerCamel path and the set in the service's
    +/// own order (measured).
    +fn enum_clause(found: &str, path: &str, set_text: &str) -> String {
    +    format!(
    +        "Value '{found}' at '{path}' failed to satisfy constraint: Member must satisfy enum \
    +         value set: {set_text}"
    +    )
    +}
    +
    +/// The `OutputType` clause: the whole submitted list printed as the value
    +/// in brackets, and the enum constraint wrapped in the list constraint
    +/// (measured). A list holding a non-string element is left to
    +/// deserialization.
    +fn output_type_clause(value: Option<&Value>) -> Option {
    +    let Value::Array(items) = value? else {
    +        return None;
    +    };
    +    let has_bad = items
    +        .iter()
    +        .any(|item| matches!(item, Value::String(s) if !matches!(s.as_str(), "ERROR" | "CONDITION_CHECK_FAILURE")));
    +    if !has_bad {
    +        return None;
    +    }
    +    let rendered: Vec = items
    +        .iter()
    +        .map(|item| match item {
    +            Value::String(s) => s.clone(),
    +            other => other.to_string(),
    +        })
    +        .collect();
    +    Some(format!(
    +        "Value '[{}]' at 'outputS3Configuration.outputType' failed to satisfy constraint: Member \
    +         must satisfy constraint: [Member must satisfy enum value set: [ERROR, \
    +         CONDITION_CHECK_FAILURE]]",
    +        rendered.join(", ")
    +    ))
    +}
    +
    +/// Both `S3BucketOwner` fields: twelve ASCII digits (measured text).
    +fn owner_clause(owner: &str, path: &str) -> Option {
    +    (owner.len() != 12 || !owner.bytes().all(|byte| byte.is_ascii_digit())).then(|| {
    +        format!(
    +            "Value '{owner}' at '{path}' failed to satisfy constraint: Member must satisfy \
    +             regular expression pattern: [0-9]{{12}}"
    +        )
    +    })
    +}
    +
    +/// Both `S3KeyPrefix` fields: at most 1024 characters (measured text). The
    +/// service's three-character minimum is an `Invalid Request:` check of the
    +/// second pass.
    +fn prefix_clause(prefix: &str, path: &str) -> Option {
    +    (prefix.chars().count() > MAX_PREFIX_CHARS).then(|| {
    +        format!(
    +            "Value '{prefix}' at '{path}' failed to satisfy constraint: Member must have length \
    +             less than or equal to {MAX_PREFIX_CHARS}"
    +        )
    +    })
    +}
    +
    +/// Both `S3Bucket` fields: at most 255 characters, then the pattern
    +/// `^[a-z0-9A-Z]+[\.\-\w]*[a-z0-9A-Z]+$`. The model has no minimum length;
    +/// a name of one character fails the pattern. A name failing both rules
    +/// answers both clauses, length first, the way the service answers a bare
    +/// table name that fails two rules.
    +fn bucket_clauses(bucket: &str, path: &str) -> Vec {
    +    let mut clauses = Vec::new();
    +    if bucket.chars().count() > MAX_BUCKET_CHARS {
    +        clauses.push(format!(
    +            "Value '{bucket}' at '{path}' failed to satisfy constraint: Member must have length \
    +             less than or equal to {MAX_BUCKET_CHARS}"
    +        ));
    +    }
    +    if !bucket_matches_pattern(bucket) {
    +        clauses.push(format!(
    +            "Value '{bucket}' at '{path}' failed to satisfy constraint: Member must satisfy \
    +             regular expression pattern: ^[a-z0-9A-Z]+[\\.\\-\\w]*[a-z0-9A-Z]+$"
    +        ));
    +    }
    +    clauses
    +}
    +
    +/// Answer the collected clauses in one envelope: sorted into the service's
    +/// member order (stable, so two clauses of one member keep their order),
    +/// capped at ten, and counted as listed.
    +fn envelope(mut clauses: Vec<(Member, String)>) -> Result<(), DynamoDbError> {
    +    if clauses.is_empty() {
    +        return Ok(());
    +    }
    +    clauses.sort_by_key(|(member, _)| *member);
    +    clauses.truncate(MAX_CLAUSES);
    +    let count = clauses.len();
    +    let joined: Vec = clauses.into_iter().map(|(_, clause)| clause).collect();
    +    Err(DynamoDbError::ValidationException(format!(
    +        "{count} validation error{} detected: {}",
    +        if count == 1 { "" } else { "s" },
    +        joined.join("; ")
    +    )))
    +}
    diff --git a/crates/engine/src/bulk_put/handlers/ops.rs b/crates/engine/src/bulk_put/handlers/ops.rs
    new file mode 100644
    index 000000000..a26ac1783
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/handlers/ops.rs
    @@ -0,0 +1,166 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The describe, list, and cancel handlers. The start handler and its
    +//! response builder live in the parent module; these three share the
    +//! operation ARN validation and the record renderers.
    +
    +use super::*;
    +
    +/// Handle a `DescribeBulkOperation` request.
    +///
    +/// # Errors
    +///
    +/// Returns the three-layer ARN errors, or `InternalServerError` for a
    +/// stored row that does not render.
    +pub async fn handle_describe_bulk_operation(
    +    body: Value,
    +    ctx: &OperationContext,
    +) -> Result {
    +    require_bulk_operation_arn(&body)?;
    +    let input: DescribeBulkOperationInput =
    +        serde_json::from_value(body).map_err(crate::deserialize_error)?;
    +    // The account and region comparisons run inside the read, ahead of the
    +    // operation id check: a foreign account is denied (measured; the
    +    // server's authorization layer answers it first with the full denial
    +    // format, so that branch serves dev mode) and a foreign region is not
    +    // found (measured with a real operation's ARN respelled to another
    +    // region). Both are decided without consulting storage, so neither
    +    // depends on whether the foreign operation exists.
    +    let parsed = arn::own_arn(&input.bulk_operation_arn, &ctx.region, &ctx.account_id)?;
    +    let record = ctx
    +        .storage
    +        .get_bulk_operation(&ctx.account_id, &parsed.operation_id)
    +        .await
    +        .map_err(bulk_storage_err)?
    +        .ok_or_else(|| does_not_exist(&input.bulk_operation_arn))?;
    +    serialize_output(&DescribeBulkOperationOutput {
    +        bulk_operation_description: describe(&record, &ctx.region)?,
    +    })
    +}
    +
    +/// Handle a `ListBulkOperations` request.
    +///
    +/// # Errors
    +///
    +/// Returns `ValidationException` for an out-of-range `MaxResults` or a
    +/// `NextToken` that does not decode.
    +pub async fn handle_list_bulk_operations(
    +    body: Value,
    +    ctx: &OperationContext,
    +) -> Result {
    +    let input: ListBulkOperationsInput =
    +        serde_json::from_value(body).map_err(crate::deserialize_error)?;
    +    let limit = input.max_results.unwrap_or(MAX_RESULTS_DEFAULT);
    +    if limit < 1 {
    +        return Err(DynamoDbError::ValidationException(format!(
    +            "1 validation error detected: Value '{limit}' at 'maxResults' failed to satisfy \
    +             constraint: Member must have value greater than or equal to 1"
    +        )));
    +    }
    +    if limit > MAX_RESULTS_CEILING {
    +        return Err(DynamoDbError::ValidationException(format!(
    +            "1 validation error detected: Value '{limit}' at 'maxResults' failed to satisfy \
    +             constraint: Member must have value less than or equal to {MAX_RESULTS_CEILING}"
    +        )));
    +    }
    +    let after = input
    +        .next_token
    +        .as_deref()
    +        .map(decode_next_token)
    +        .transpose()?;
    +    // The store fetches one row past the page and returns the cursor of the
    +    // last emitted row when more exist; the token carries no trust because
    +    // the query is keyed by the caller's account.
    +    let (records, cursor) = ctx
    +        .storage
    +        .list_bulk_operations(
    +            &ctx.account_id,
    +            u32::try_from(limit).unwrap_or(u32::MAX),
    +            after,
    +        )
    +        .await
    +        .map_err(bulk_storage_err)?;
    +    let summaries = records
    +        .iter()
    +        .map(|record| summary(record, &ctx.region))
    +        .collect::, _>>()?;
    +    serialize_output(&ListBulkOperationsOutput {
    +        bulk_operations_summary_list: summaries,
    +        next_token: cursor
    +            .map(|cursor| encode_next_token(cursor.start_time_ms, &cursor.operation_id)),
    +    })
    +}
    +
    +/// Handle a `CancelBulkOperation` request.
    +///
    +/// The transition is one conditional update in storage and the answer
    +/// comes from the row it returns, so two concurrent cancels and a cancel
    +/// racing the terminal transition never produce a wrong answer. The ARN
    +/// read is the same as Describe's: a foreign account is denied and a
    +/// foreign region is not found, both before the operation id is checked
    +/// and both without consulting storage.
    +///
    +/// # Errors
    +///
    +/// Returns the three-layer ARN errors, or `ValidationException` naming the
    +/// status for an operation already terminal.
    +pub async fn handle_cancel_bulk_operation(
    +    body: Value,
    +    ctx: &OperationContext,
    +) -> Result {
    +    require_bulk_operation_arn(&body)?;
    +    let input: CancelBulkOperationInput =
    +        serde_json::from_value(body).map_err(crate::deserialize_error)?;
    +    let parsed = arn::own_arn(&input.bulk_operation_arn, &ctx.region, &ctx.account_id)?;
    +    let outcome = ctx
    +        .storage
    +        .request_cancel(&ctx.account_id, &parsed.operation_id)
    +        .await
    +        .map_err(bulk_storage_err)?;
    +    match outcome {
    +        CancelOutcome::Cancelling => {
    +            // The transition log carries the fields of the terminal log
    +            // line. The conditional update returns only the outcome, so
    +            // one read after it fills in the table name and the counters;
    +            // the status is logged as the update wrote it, because the
    +            // runner can move the row on before the read answers. A read
    +            // that misses still logs the transition with the fields at
    +            // hand: the answer to the caller is already decided.
    +            if let Ok(Some(row)) = ctx
    +                .storage
    +                .get_bulk_operation(&ctx.account_id, &parsed.operation_id)
    +                .await
    +            {
    +                tracing::info!(
    +                    account_id = %row.account_id,
    +                    operation_id = %row.operation_id,
    +                    table_name = %row.table_name,
    +                    status = "CANCELLING",
    +                    processed_item_count = row.processed_item_count,
    +                    failed_item_count = row.failed_item_count,
    +                    "bulk operation cancel requested"
    +                );
    +            } else {
    +                tracing::info!(
    +                    account_id = %ctx.account_id,
    +                    operation_id = %parsed.operation_id,
    +                    status = "CANCELLING",
    +                    "bulk operation cancel requested"
    +                );
    +            }
    +            serialize_output(&CancelBulkOperationOutput {
    +                bulk_operation_arn: arn::format_arn(
    +                    &ctx.region,
    +                    &ctx.account_id,
    +                    &parsed.operation_id,
    +                ),
    +                bulk_operation_status: BulkOperationStatus::Cancelling,
    +            })
    +        }
    +        CancelOutcome::AlreadyTerminal(status) => Err(DynamoDbError::ValidationException(format!(
    +            "Invalid Request: Operation already in terminal state: {status}"
    +        ))),
    +        CancelOutcome::NotFound => Err(does_not_exist(&input.bulk_operation_arn)),
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/handlers/start.rs b/crates/engine/src/bulk_put/handlers/start.rs
    new file mode 100644
    index 000000000..cd5c1ffa1
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/handlers/start.rs
    @@ -0,0 +1,573 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The `StartBulkPutItems` handler: the seven observable validation steps,
    +//! the terminal-born input configuration path, and the start transaction.
    +//! The other three handlers, the renderers, and the field checks live
    +//! beside it in the parent module.
    +
    +use super::*;
    +
    +use crate::bulk_put::run_context::RecordedRunContext;
    +
    +mod config;
    +use config::{resolved_input_configuration, resolved_output_configuration};
    +mod permission;
    +mod prefix;
    +mod replay;
    +
    +/// An enumeration failure is server-side: the bucket name and prefix
    +/// passed their lexical checks, so a jail refusal or a read failure here
    +/// is not client input. Details go to the log, never to the client. Defined
    +/// here so the line is filed under the Start handler's target.
    +fn enumeration_error(error: &SourceError) -> DynamoDbError {
    +    tracing::error!(error = ?error, "bulk operation input enumeration failed");
    +    DynamoDbError::InternalServerError("Internal server error".to_owned())
    +}
    +
    +/// Handle a `StartBulkPutItems` request.
    +///
    +/// # Errors
    +///
    +/// Returns the step's error per the validation order in the module doc.
    +#[allow(clippy::too_many_lines)] // the seven validation steps, kept in spec order in one place
    +pub async fn handle_start_bulk_put_items(
    +    body: Value,
    +    ctx: &OperationContext,
    +) -> Result {
    +    // The filesystem store's input and report path hardening is unix-only
    +    // (see the source module doc), so other targets refuse the operation on
    +    // that store before reading the body rather than running the
    +    // unhardened fallback. The S3 store has no filesystem code.
    +    if cfg!(not(unix))
    +        && ctx
    +            .bulk
    +            .as_ref()
    +            .is_some_and(|store| store.filesystem_root().is_some())
    +    {
    +        return Err(DynamoDbError::ValidationException(
    +            "Bulk operations are not supported on this platform".to_owned(),
    +        ));
    +    }
    +
    +    // Step 1: deserialization and enum membership. A null or absent
    +    // required member is answered first, as the hosted ValidationException
    +    // for a null member rather than the serde SerializationException.
    +    if body
    +        .as_object()
    +        .is_some_and(|obj| is_absent_or_null(obj.get("TableName")))
    +    {
    +        return Err(DynamoDbError::ValidationException(
    +            TABLE_NAME_REQUIRED_MESSAGE.to_owned(),
    +        ));
    +    }
    +    require_members(&body, START_REQUIRED)?;
    +    // The tableName pass, on the raw body. The service judges TableName on
    +    // its own, before the other members: a bad TableName is answered alone
    +    // even when the configuration also carries enum or bucket faults
    +    // (measured with a bare `a*`, an own ARN `table/ab`, and an own index
    +    // ARN, each beside a bad compression type). The model types TableName
    +    // as TableArn, with a maximum of 1024 characters reported one-sided
    +    // (measured with 1025 characters); a bare name is then held to the
    +    // table-name rules (3 to 255 characters and the name pattern, every
    +    // failing rule in one envelope), and an ARN to the shared ARN rules,
    +    // which include the name rule on an own-account ARN. A foreign-account
    +    // ARN is denied here, before any configuration fault (measured).
    +    let table_name = match body.get("TableName") {
    +        Some(Value::String(value)) => {
    +            if value.chars().count() > MAX_TABLE_NAME_CHARS {
    +                return Err(DynamoDbError::ValidationException(format!(
    +                    "1 validation error detected: Value '{value}' at 'tableName' failed to \
    +                     satisfy constraint: Member must have length less than or equal to \
    +                     {MAX_TABLE_NAME_CHARS}"
    +                )));
    +            }
    +            if !value.starts_with("arn:") {
    +                validate_bare_table_name(value)?;
    +            }
    +            Some(arn::table_name_from_name_or_arn(
    +                value,
    +                &ctx.region,
    +                &ctx.account_id,
    +            )?)
    +        }
    +        _ => None,
    +    };
    +    // The model pass: every enum, pattern, length and range constraint of
    +    // the other members, answered together in one envelope in the
    +    // service's member order (measured), on the raw body so an enum
    +    // outside its set is answered with the full path and the service's set
    +    // order instead of the serde impls' bare name and declaration order.
    +    check_model_constraints(&body)?;
    +    let input: StartBulkPutItemsInput =
    +        serde_json::from_value(body).map_err(crate::deserialize_error)?;
    +    // A TableName of the wrong JSON type is answered by deserialization
    +    // above, so the name is always resolved here.
    +    let table_name = match table_name {
    +        Some(name) => name,
    +        None => arn::table_name_from_name_or_arn(&input.table_name, &ctx.region, &ctx.account_id)?,
    +    };
    +
    +    // Second pass: the service's own `Invalid Request:` checks, after every
    +    // model constraint above. Their order among themselves is kept as it
    +    // was, since it is unmeasured.
    +    if let Some(token) = &input.client_token {
    +        let cap = ctx.limits.max_bulk_client_token_bytes;
    +        if token.len() > cap {
    +            return Err(DynamoDbError::ValidationException(format!(
    +                "ClientToken exceeds max_bulk_client_token_bytes ({cap} bytes)"
    +            )));
    +        }
    +    }
    +    if input.output_s3_configuration.output_type.is_empty() {
    +        return Err(DynamoDbError::ValidationException(
    +            "Invalid Request: OutputS3Configuration.OutputType cannot be empty".to_owned(),
    +        ));
    +    }
    +    validate_bucket_service(
    +        &input.input_s3_configuration.s3_bucket,
    +        "inputS3Configuration.s3Bucket",
    +        INPUT_BUCKET_MEMBER,
    +    )?;
    +    // The output bucket's presence is checked at Start: an output
    +    // configuration whose OutputType is set with no S3Bucket answers this
    +    // message (measured on the service). Its existence and its owner
    +    // are still decided when the first report record is produced. A
    +    // present bucket with no S3KeyPrefix is accepted.
    +    let Some(output_bucket) = &input.output_s3_configuration.s3_bucket else {
    +        return Err(bucket_is_invalid(OUTPUT_BUCKET_MEMBER));
    +    };
    +    validate_bucket_service(
    +        output_bucket,
    +        "outputS3Configuration.s3Bucket",
    +        OUTPUT_BUCKET_MEMBER,
    +    )?;
    +    prefix::check_prefixes(ctx, &input)?;
    +    let input_config = serialize_output(&input.input_s3_configuration)?;
    +    let output_config = serialize_output(&input.output_s3_configuration)?;
    +    // The description echoes the caller's account as the owner of both
    +    // buckets, and `NONE` as the input compression, when the request omits
    +    // them (measured on the service). The resolved values are what
    +    // the row stores, so every later Describe renders them without
    +    // re-deriving anything. The fingerprint below is taken from the
    +    // configurations as submitted, so omitting a member and supplying the
    +    // value it resolves to stay two different requests for idempotency.
    +    let stored_input_config = serialize_output(&resolved_input_configuration(
    +        &input.input_s3_configuration,
    +        &ctx.account_id,
    +    ))?;
    +    let stored_output_config = serialize_output(&resolved_output_configuration(
    +        &input.output_s3_configuration,
    +        &ctx.account_id,
    +    ))?;
    +    validate_configuration_size(&stored_input_config, &stored_output_config)?;
    +    validate_configuration_characters(&stored_input_config, &stored_output_config)?;
    +
    +    // Step 3: modelled features this server does not implement.
    +    if input.input_s3_configuration.input_format == BulkOperationInputFormat::Ion {
    +        return Err(DynamoDbError::ValidationException(
    +            "InputFormat ION is not supported by this server".to_owned(),
    +        ));
    +    }
    +
    +    // Step 4: the bulk store. One store serves inputs and reports; with no
    +    // [bulk] section the feature is disabled.
    +    let Some(store) = ctx.bulk.as_ref() else {
    +        return Err(DynamoDbError::ValidationException(
    +            "Bulk operations are disabled. Configure [bulk] in extenddb.toml to enable.".to_owned(),
    +        ));
    +    };
    +
    +    // Step 5: table lookup. Not found and not-active reuse the existing
    +    // messages; the hosted service's documentation accepts ACTIVE and
    +    // UPDATING.
    +    let table = ctx
    +        .storage
    +        .describe_table(
    +            &ctx.account_id,
    +            DescribeTableInput {
    +                table_name: table_name.clone(),
    +            },
    +        )
    +        .await
    +        .map_err(|error| match bulk_storage_err(error) {
    +            // A table that does not exist answers the service's named
    +            // not-found text at Start (measured on the service): the
    +            // resource-not-found message carries the table name.
    +            DynamoDbError::ResourceNotFoundException(_) => {
    +                DynamoDbError::ResourceNotFoundException(format!(
    +                    "Requested resource not found: Table: {table_name} not found"
    +                ))
    +            }
    +            other => other,
    +        })?;
    +    // A table in DELETING answers a validation failure naming the table as
    +    // not found, measured both immediately after DeleteTable and three
    +    // seconds later. CREATING and UPDATING were not
    +    // measured that day: UPDATING is accepted and CREATING keeps the
    +    // in-use answer below.
    +    if table.table_status == TableStatus::Deleting {
    +        return Err(DynamoDbError::ValidationException(
    +            TABLE_DELETING_MESSAGE.to_owned(),
    +        ));
    +    }
    +    if !matches!(
    +        table.table_status,
    +        TableStatus::Active | TableStatus::Updating
    +    ) {
    +        return Err(DynamoDbError::ResourceInUseException(error_message(
    +            ErrorMessageKey::TableInUse,
    +            &[&table_name],
    +        )));
    +    }
    +    // A table with an index operation in progress refuses the start
    +    // (measured on the service: six attempts across
    +    // both creation phases of a global secondary index, every one
    +    // `ResourceInUseException` with this text, whether the table reported
    +    // `UPDATING` or `ACTIVE`). Every secondary and vector index must be
    +    // `ACTIVE`; on PostgreSQL a global secondary index is `CREATING` for
    +    // the whole backfill.
    +    if has_index_operation(&table) {
    +        return Err(DynamoDbError::ResourceInUseException(
    +            INDEX_OPERATION_MESSAGE.to_owned(),
    +        ));
    +    }
    +    // The accelerated tier is refused on a table with a vector index with
    +    // the measured text (a table carrying one active
    +    // vector index refuses ACCELERATED synchronously and accepts STANDARD).
    +    if input.performance_tier == Some(BulkOperationPerformanceTier::Accelerated)
    +        && table
    +            .vector_indexes
    +            .as_ref()
    +            .is_some_and(|indexes| !indexes.is_empty())
    +    {
    +        return Err(DynamoDbError::ValidationException(
    +            "Invalid Request: Value provided for parameter PerformanceTier is not supported for \
    +             tables with vector indexes."
    +                .to_owned(),
    +        ));
    +    }
    +
    +    // The request identity for steps 6 and 7. The fingerprint hashes the
    +    // request as submitted, except for the service defaults a retry may add
    +    // or omit without changing the operation (measured): an absent
    +    // PerformanceTier and a STANDARD one hash alike, and
    +    // `strip_replayed_defaults` drops an InputCompressionType of NONE and an
    +    // own-account S3BucketOwner on either configuration. The stored record
    +    // still carries the resolved default. The file list is filled in after
    +    // enumeration.
    +    let requested_tier = input.performance_tier.map(tier_str);
    +    let fingerprint_tier = requested_tier.filter(|tier| *tier != "STANDARD");
    +    let fingerprint_input = fingerprint::strip_replayed_defaults(&input_config, &ctx.account_id);
    +    let fingerprint_output = fingerprint::strip_replayed_defaults(&output_config, &ctx.account_id);
    +    let request_fingerprint = fingerprint::request_fingerprint(
    +        &table.table_arn,
    +        &fingerprint_input,
    +        &fingerprint_output,
    +        input
    +            .max_write_request_units
    +            .and_then(|units| u64::try_from(units).ok()),
    +        fingerprint_tier,
    +    );
    +    let client_token_hash = input
    +        .client_token
    +        .as_ref()
    +        .map(|token| hex::encode(Sha256::digest(token.as_bytes())));
    +    let mut new = NewBulkOperation {
    +        operation_id: arn::generate_operation_id(),
    +        table_id: table.table_id.clone(),
    +        table_name,
    +        table_arn: table.table_arn,
    +        client_token: input.client_token,
    +        client_token_hash,
    +        request_fingerprint,
    +        input_config: stored_input_config,
    +        output_config: stored_output_config,
    +        input_files: Vec::new(),
    +        max_write_request_units: input.max_write_request_units,
    +        performance_tier: Some(requested_tier.unwrap_or("STANDARD").to_owned()),
    +        start_time_ms: epoch_millis(),
    +        // Set by enumeration below; a failure born before the walk leaves
    +        // it absent, as the hosted service does.
    +        total_input_bytes: None,
    +        initial_failure: None,
    +        run_context: Some(
    +            RecordedRunContext::at_start(store, ctx.bulk_principal.clone()).to_value(),
    +        ),
    +    };
    +
    +    // A replayed token is answered from its row before any store call, the
    +    // listing, the byte bound and the failed-start cap (`replay`).
    +    if let Some(response) = replay::replayed(ctx, &new).await? {
    +        return Ok(response);
    +    }
    +
    +    // The input bucket as the store sees it, for the bucket check and the
    +    // enumeration.
    +    let input_at = BucketRef::new(
    +        &ctx.account_id,
    +        &input.input_s3_configuration.s3_bucket,
    +        input.input_s3_configuration.s3_bucket_owner.as_deref(),
    +    );
    +    let prefix = input
    +        .input_s3_configuration
    +        .s3_key_prefix
    +        .clone()
    +        .unwrap_or_default();
    +
    +    // A bad input configuration fails the operation rather than the
    +    // request: the row is inserted already terminal, the token consumed,
    +    // before any row is read. The store decides the
    +    // input bucket's owner, then its existence, and renders the fault (see
    +    // `BulkSource::check_bucket`); any other failure of the check is the
    +    // server's. Of the output configuration only the bucket member's
    +    // presence and name are checked at Start (measured on the service); its
    +    // existence and its owner are checked at the first report record. The
    +    // insert skips the account limit, which binds on non-terminal rows
    +    // only, so this path runs before the saturated-account check below. The
    +    // per-table guard still binds: on a table with an operation running the
    +    // start transaction answers busy and inserts nothing.
    +    // The caller's s3:ListBucket is decided first (`permission`).
    +    let denied =
    +        permission::list_bucket_denial(ctx, &new.operation_id, input_at.bucket, &prefix).await?;
    +    let initial_failure = match denied {
    +        Some(fault) => Some(fault),
    +        None => match store.source.check_bucket(&input_at, &prefix).await {
    +            Ok(()) => None,
    +            Err(SourceError::Store(fault)) => Some(fault),
    +            Err(error) => return Err(enumeration_error(&error)),
    +        },
    +    };
    +    // An SSE-KMS output key the store cannot use fails the operation the
    +    // same way, before the input walk and with both byte members absent
    +    // (measured on the service); a bucket or owner fault of the output is
    +    // left to the first report record. The key checked is report object 0
    +    // of the error report.
    +    let initial_failure = match initial_failure {
    +        Some(fault) => Some(fault),
    +        None => {
    +            let output_at = BucketRef::new(
    +                &ctx.account_id,
    +                output_bucket,
    +                input.output_s3_configuration.s3_bucket_owner.as_deref(),
    +            );
    +            let key = crate::bulk_put::report::report_key(
    +                input
    +                    .output_s3_configuration
    +                    .s3_key_prefix
    +                    .as_deref()
    +                    .unwrap_or_default(),
    +                &ctx.account_id,
    +                &new.operation_id,
    +                extenddb_storage::ReportKind::Error,
    +                0,
    +            );
    +            let sse = crate::bulk_put::source::OutputSse::of(&input.output_s3_configuration);
    +            match store.sink.check_encryption(&output_at, &key, &sse).await {
    +                Ok(()) => None,
    +                Err(crate::bulk_put::source::SinkError::Store(fault)) => Some(fault),
    +                Err(error) => {
    +                    tracing::error!(error = ?error, "bulk operation output encryption check failed");
    +                    return Err(DynamoDbError::InternalServerError(
    +                        "Internal server error".to_owned(),
    +                    ));
    +                }
    +            }
    +        }
    +    };
    +    if let Some(fault) = initial_failure {
    +        return start_terminal_born(ctx, new, fault).await;
    +    }
    +
    +    // A saturated account is answered before the enumeration walk runs.
    +    // The start transaction keeps its own exact check under its locks;
    +    // this read only decides whether the walk runs. A count at or above
    +    // the limit goes straight to the transaction with a zero limit, which
    +    // can answer everything the limit does not outrank: a replayed token
    +    // is still answered from its existing row and a busy table still
    +    // answers busy, in the transaction's own order. A zero limit admits
    +    // nothing, so `Created` cannot arrive from this call.
    +    let account_limit = ctx.limits.max_concurrent_bulk_operations;
    +    let non_terminal = ctx
    +        .storage
    +        .count_non_terminal(&ctx.account_id)
    +        .await
    +        .map_err(bulk_storage_err)?;
    +    if non_terminal >= u64::from(account_limit) {
    +        let outcome = ctx
    +            .storage
    +            .start_bulk_operation(&ctx.account_id, new, 0)
    +            .await
    +            .map_err(bulk_storage_err)?;
    +        return start_response(outcome, &ctx.region, account_limit);
    +    }
    +
    +    // Step 6: enumeration fixes the file list and the total. The count cap
    +    // binds during the walk as well as after it, then the two caps apply,
    +    // count before bytes.
    +    //
    +    // The object count quota is not a Start refusal: the walk stops one
    +    // object past the cap and returns what it collected, the record is
    +    // inserted with that list, and the runner ends the operation FAILED
    +    // with S3ObjectsCountLimitExceeded before reading a file (measured
    +    // ). The per-object size cap is the runner's as
    +    // well. Only the enumerated total stays a Start refusal; it
    +    // is ExtendDB's own bound.
    +    let max_files = ctx.limits.max_bulk_input_files;
    +    let files = match store.source.list(&input_at, &prefix, max_files).await {
    +        Ok(files) => files,
    +        // A fault the store rendered on a later page (the bucket removed or
    +        // access lost after the check) fails the operation as the check
    +        // would have.
    +        Err(SourceError::Store(fault)) => return start_terminal_born(ctx, new, fault).await,
    +        // Only the walk's visit ceiling (four times the cap in entries that
    +        // can hold a key under the prefix) still ends the enumeration here.
    +        Err(SourceError::TooManyFiles) => {
    +            return Err(DynamoDbError::ValidationException(format!(
    +                "Input exceeds max_bulk_input_files ({max_files} files)"
    +            )));
    +        }
    +        Err(other) => return Err(enumeration_error(&other)),
    +    };
    +    let total_input_bytes = files
    +        .iter()
    +        .fold(0_u64, |sum, file| sum.saturating_add(file.size));
    +    let max_bytes = ctx.limits.max_bulk_input_bytes;
    +    if total_input_bytes > max_bytes {
    +        return Err(DynamoDbError::ValidationException(format!(
    +            "Input exceeds max_bulk_input_bytes ({max_bytes} bytes)"
    +        )));
    +    }
    +
    +    // Step 7: the start transaction, on the enumerated input.
    +    new.input_files = files
    +        .into_iter()
    +        .map(|file| InputFile {
    +            path: file.path,
    +            size: i64::try_from(file.size).unwrap_or(i64::MAX),
    +            rows: None,
    +            etag: file.etag,
    +            abandon: None,
    +        })
    +        .collect();
    +    new.total_input_bytes = Some(i64::try_from(total_input_bytes).unwrap_or(i64::MAX));
    +    let outcome = ctx
    +        .storage
    +        .start_bulk_operation(&ctx.account_id, new, account_limit)
    +        .await
    +        .map_err(bulk_storage_err)?;
    +    // Only the handler can tell a creation from an idempotent replay; the
    +    // wire answers are identical, so the metric and the creation log line
    +    // are recorded here.
    +    if let StartOutcome::Created(record) = &outcome {
    +        ctx.metrics.record(
    +            extenddb_core::metrics::MetricName::BulkOperationsStarted,
    +            1.0,
    +            Some(&record.table_name),
    +            None,
    +            None,
    +        );
    +        tracing::info!(
    +            account_id = %record.account_id,
    +            operation_id = %record.operation_id,
    +            table_name = %record.table_name,
    +            status = %record.status,
    +            processed_item_count = record.processed_item_count,
    +            failed_item_count = record.failed_item_count,
    +            "bulk operation created"
    +        );
    +    }
    +    start_response(outcome, &ctx.region, account_limit)
    +}
    +
    +/// Insert the operation already terminal with a fault the store rendered:
    +/// a bad input bucket found by the check, or by a later page of the
    +/// enumeration (the bucket removed or access lost after the check). The
    +/// row is born `FAILED`, the token consumed, before any row is read. On a
    +/// table with an operation running nothing is inserted and the answer is
    +/// the busy one, as for any other start.
    +async fn start_terminal_born(
    +    ctx: &OperationContext,
    +    mut new: NewBulkOperation,
    +    fault: crate::bulk_put::source::StoreFault,
    +) -> Result {
    +    // Terminal-born rows are bounded: at or
    +    // above the cap of them born in the last hour, the request is
    +    // refused. This read is a cheap fast path that rejects an
    +    // obviously-over-cap caller before the start transaction; the
    +    // authoritative, exact check runs inside that transaction under
    +    // the per-account lock, so a concurrent burst that races past this
    +    // read is still refused. The cap and window travel with the failure
    +    // they govern. The floor keeps a zero configuration from refusing
    +    // every missing-bucket start.
    +    let cap = ctx.limits.max_bulk_failed_starts_per_hour.max(1);
    +    let since_ms = new.start_time_ms.saturating_sub(3_600_000);
    +    let born = ctx
    +        .storage
    +        .count_terminal_born_since(&ctx.account_id, since_ms)
    +        .await
    +        .map_err(bulk_storage_err)?;
    +    if born >= cap {
    +        // The transaction answers a busy table before the cap, so at the
    +        // cap the fast path refuses only a table with no operation
    +        // running and leaves a busy one to the transaction.
    +        let running = ctx
    +            .storage
    +            .count_non_terminal_for_table(&ctx.account_id, &new.table_id)
    +            .await
    +            .map_err(bulk_storage_err)?;
    +        if running == 0 {
    +            return Err(DynamoDbError::ThrottlingException(
    +                "Rate of requests exceeds the allowed throughput.".to_owned(),
    +            ));
    +        }
    +    }
    +    new.initial_failure = Some(extenddb_storage::InitialFailure {
    +        code: fault.code.to_owned(),
    +        message: fault.message,
    +        cap,
    +        window_start_ms: since_ms,
    +    });
    +    let outcome = ctx
    +        .storage
    +        .start_bulk_operation(
    +            &ctx.account_id,
    +            new,
    +            ctx.limits.max_concurrent_bulk_operations,
    +        )
    +        .await
    +        .map_err(bulk_storage_err)?;
    +    if let StartOutcome::Created(record) = &outcome {
    +        // Started and finished in the same instant: both metrics
    +        // count the operation, the finish carrying the FAILED status.
    +        // No worker sees the row, so only the handler can record the
    +        // finish.
    +        ctx.metrics.record(
    +            extenddb_core::metrics::MetricName::BulkOperationsStarted,
    +            1.0,
    +            Some(&record.table_name),
    +            None,
    +            None,
    +        );
    +        ctx.metrics.record(
    +            extenddb_core::metrics::MetricName::BulkOperationsFinished,
    +            1.0,
    +            Some(&record.table_name),
    +            None,
    +            Some("FAILED"),
    +        );
    +        tracing::info!(
    +            account_id = %record.account_id,
    +            operation_id = %record.operation_id,
    +            table_name = %record.table_name,
    +            status = %record.status,
    +            failure_code = record.failure_code.as_deref().unwrap_or(""),
    +            "bulk operation created terminal: the input configuration failed"
    +        );
    +    }
    +    start_response(
    +        outcome,
    +        &ctx.region,
    +        ctx.limits.max_concurrent_bulk_operations,
    +    )
    +}
    diff --git a/crates/engine/src/bulk_put/handlers/start/config.rs b/crates/engine/src/bulk_put/handlers/start/config.rs
    new file mode 100644
    index 000000000..34417793a
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/handlers/start/config.rs
    @@ -0,0 +1,50 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Request-to-echo resolution of the input and output configurations.
    +
    +use super::*;
    +
    +/// The input configuration as the description echoes it: an absent
    +/// `S3BucketOwner` resolves to the caller's account id and an absent
    +/// `InputCompressionType` to `NONE` (measured on the service). A
    +/// value the request supplied is left as submitted, having already passed
    +/// its own checks.
    +pub(super) fn resolved_input_configuration(
    +    submitted: &InputS3Configuration,
    +    account_id: &str,
    +) -> InputS3Configuration {
    +    InputS3Configuration {
    +        s3_bucket_owner: Some(
    +            submitted
    +                .s3_bucket_owner
    +                .clone()
    +                .unwrap_or_else(|| account_id.to_owned()),
    +        ),
    +        input_compression_type: Some(
    +            submitted
    +                .input_compression_type
    +                .unwrap_or(InputCompressionType::None),
    +        ),
    +        ..submitted.clone()
    +    }
    +}
    +
    +/// The output configuration as the description echoes it: an absent
    +/// `S3BucketOwner` resolves to the caller's account id (measured
    +/// on both configurations). The output configuration
    +/// carries no compression member.
    +pub(super) fn resolved_output_configuration(
    +    submitted: &OutputS3Configuration,
    +    account_id: &str,
    +) -> OutputS3Configuration {
    +    OutputS3Configuration {
    +        s3_bucket_owner: Some(
    +            submitted
    +                .s3_bucket_owner
    +                .clone()
    +                .unwrap_or_else(|| account_id.to_owned()),
    +        ),
    +        ..submitted.clone()
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/handlers/start/permission.rs b/crates/engine/src/bulk_put/handlers/start/permission.rs
    new file mode 100644
    index 000000000..4f54a481a
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/handlers/start/permission.rs
    @@ -0,0 +1,64 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The caller's `s3:ListBucket` on the input bucket, evaluated at Start
    +//! before the store's own bucket check.
    +
    +use super::*;
    +
    +use crate::bulk_put::authorize::{self, Decision, S3Action};
    +use crate::bulk_put::source::StoreFault;
    +
    +/// The terminal-born fault for a caller who may not list the input bucket
    +/// under the request's prefix, or `None` when the listing is allowed. The
    +/// service accepts such a Start and fails the operation with
    +/// `S3AccessDenied` and S3's text naming `s3:ListBucket` on the bucket ARN,
    +/// with no byte members and no manifest (measured on the service); here the
    +/// row is born terminal with that shape, before the bucket check and before
    +/// enumeration. `s3:prefix` is the request's `S3KeyPrefix`, or the empty
    +/// string when absent, and then, when the store lists another prefix for it
    +/// (`BulkSource::listed_prefix`), that prefix: the listing is allowed only
    +/// when both are.
    +///
    +/// # Errors
    +///
    +/// `InternalServerError` when the decision cannot be made.
    +pub(super) async fn list_bucket_denial(
    +    ctx: &OperationContext,
    +    operation_id: &str,
    +    bucket: &str,
    +    prefix: &str,
    +) -> Result, DynamoDbError> {
    +    let listed = ctx
    +        .bulk
    +        .as_ref()
    +        .map(|store| store.source.listed_prefix(prefix))
    +        .filter(|listed| listed != prefix);
    +    let mut decision = Decision::Allowed;
    +    for prefix in std::iter::once(prefix).chain(listed.as_deref()) {
    +        decision = authorize::decide(
    +            ctx.bulk_authorizer.as_deref(),
    +            ctx.bulk_principal.as_ref(),
    +            S3Action::ListBucket,
    +            &authorize::bucket_arn(bucket),
    +            Some(prefix),
    +            &ctx.account_id,
    +            operation_id,
    +        )
    +        .await;
    +        if decision != Decision::Allowed {
    +            break;
    +        }
    +    }
    +    match decision {
    +        Decision::Allowed => Ok(None),
    +        Decision::Denied(message) => Ok(Some(StoreFault {
    +            code: "S3AccessDenied",
    +            message,
    +            after_first_byte: false,
    +        })),
    +        Decision::Unavailable => Err(DynamoDbError::InternalServerError(
    +            "Internal server error".to_owned(),
    +        )),
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/handlers/start/prefix.rs b/crates/engine/src/bulk_put/handlers/start/prefix.rs
    new file mode 100644
    index 000000000..91dc331b7
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/handlers/start/prefix.rs
    @@ -0,0 +1,68 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The `S3KeyPrefix` checks of step 2: the service's length rule on both
    +//! prefixes, the path rules of the filesystem store, and the climb rule of
    +//! an S3-compatible endpoint.
    +
    +use super::*;
    +use crate::bulk_put::source::climbs_above_start;
    +
    +/// Check both prefixes. The service's length rule applies on every store.
    +/// The path rules (`validate_prefix`, `validate_output_prefix`) are the
    +/// filesystem store's, which joins a prefix onto a directory path: on the
    +/// S3 store every such prefix is a valid key prefix, used byte-wise in the
    +/// listing and verbatim in the report and manifest keys. A server with no
    +/// store applies them, so step 2 still answers before step 4 there.
    +///
    +/// An S3-compatible endpoint resolves a `..` segment against the segment
    +/// before it, so a prefix with more `..` segments than segments before them
    +/// names a key above the bucket. The permission checks evaluate the key
    +/// inside the bucket that resolution keeps, while the endpoint may resolve
    +/// the URL path into another bucket, so the prefix is refused there with
    +/// the path rules' text. AWS S3 stores such a key as given.
    +///
    +/// # Errors
    +///
    +/// The `ValidationException` of the first rule a prefix breaks.
    +pub(super) fn check_prefixes(
    +    ctx: &OperationContext,
    +    input: &StartBulkPutItemsInput,
    +) -> Result<(), DynamoDbError> {
    +    let path_rules = ctx
    +        .bulk
    +        .as_ref()
    +        .is_none_or(|store| store.filesystem_root().is_some());
    +    let climb_rule = ctx
    +        .bulk
    +        .as_ref()
    +        .is_some_and(|store| store.resolves_parent());
    +    if let Some(prefix) = &input.input_s3_configuration.s3_key_prefix {
    +        validate_prefix_length_service(prefix, "inputS3Configuration.s3KeyPrefix")?;
    +        if path_rules {
    +            validate_prefix(prefix)?;
    +        }
    +        if climb_rule {
    +            refuse_climb(prefix)?;
    +        }
    +    }
    +    if let Some(prefix) = &input.output_s3_configuration.s3_key_prefix {
    +        validate_prefix_length_service(prefix, "outputS3Configuration.s3KeyPrefix")?;
    +        if path_rules {
    +            validate_output_prefix(prefix)?;
    +        }
    +        if climb_rule {
    +            refuse_climb(prefix)?;
    +        }
    +    }
    +    Ok(())
    +}
    +
    +fn refuse_climb(prefix: &str) -> Result<(), DynamoDbError> {
    +    if climbs_above_start(prefix) {
    +        return Err(DynamoDbError::ValidationException(
    +            "S3KeyPrefix contains an invalid path component".to_owned(),
    +        ));
    +    }
    +    Ok(())
    +}
    diff --git a/crates/engine/src/bulk_put/handlers/start/replay.rs b/crates/engine/src/bulk_put/handlers/start/replay.rs
    new file mode 100644
    index 000000000..75b63e692
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/handlers/start/replay.rs
    @@ -0,0 +1,63 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! A `ClientToken` replay, answered from the operation the token started
    +//! before the Start asks the store or the policies anything.
    +
    +use super::*;
    +
    +/// The answer to a Start whose `ClientToken` an earlier Start of the account
    +/// used inside the replay window: that operation's current description when
    +/// the request fingerprint is equal, the measured mismatch error when it is
    +/// not (measured on the service: nothing is created and no other check is
    +/// consulted). `None` for a request with no token or an unseen one.
    +///
    +/// The lookup is the start transaction with an account limit of zero, which
    +/// answers a seen token from its row and admits no insert, as on the
    +/// saturated-account path below. The transaction at step 7 looks the token
    +/// up again under its locks, so concurrent Starts with one token still
    +/// create one operation.
    +///
    +/// # Errors
    +///
    +/// The storage error of the lookup.
    +pub(super) async fn replayed(
    +    ctx: &OperationContext,
    +    new: &NewBulkOperation,
    +) -> Result, DynamoDbError> {
    +    if new.client_token_hash.is_none() {
    +        return Ok(None);
    +    }
    +    let lookup = NewBulkOperation {
    +        operation_id: new.operation_id.clone(),
    +        table_id: new.table_id.clone(),
    +        table_name: new.table_name.clone(),
    +        table_arn: new.table_arn.clone(),
    +        client_token: new.client_token.clone(),
    +        client_token_hash: new.client_token_hash.clone(),
    +        request_fingerprint: new.request_fingerprint.clone(),
    +        input_config: new.input_config.clone(),
    +        output_config: new.output_config.clone(),
    +        input_files: Vec::new(),
    +        max_write_request_units: new.max_write_request_units,
    +        performance_tier: new.performance_tier.clone(),
    +        start_time_ms: new.start_time_ms,
    +        total_input_bytes: None,
    +        initial_failure: None,
    +        run_context: None,
    +    };
    +    let outcome = ctx
    +        .storage
    +        .start_bulk_operation(&ctx.account_id, lookup, 0)
    +        .await
    +        .map_err(bulk_storage_err)?;
    +    match outcome {
    +        StartOutcome::IdempotentReplay(_) | StartOutcome::IdempotentMismatch => start_response(
    +            outcome,
    +            &ctx.region,
    +            ctx.limits.max_concurrent_bulk_operations,
    +        )
    +        .map(Some),
    +        _ => Ok(None),
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/handlers/tests.rs b/crates/engine/src/bulk_put/handlers/tests.rs
    new file mode 100644
    index 000000000..cee920bd4
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/handlers/tests.rs
    @@ -0,0 +1,362 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Behaviour tests for the record renderers and the pagination cursor
    +//! codec, moved from the inline test module of the old handlers.rs file.
    +
    +use super::*;
    +use serde_json::json;
    +
    +const REGION: &str = "us-east-1";
    +const ACCOUNT: &str = "123456789012";
    +const OPERATION_ID: &str = "01789060589767-da9ddd4f";
    +
    +fn record(status: &str) -> BulkOperationRecord {
    +    BulkOperationRecord {
    +        account_id: ACCOUNT.to_owned(),
    +        operation_id: OPERATION_ID.to_owned(),
    +        table_id: "tid-1".to_owned(),
    +        table_name: "orders".to_owned(),
    +        table_arn: format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/orders"),
    +        status: status.to_owned(),
    +        client_token: Some("load-a".to_owned()),
    +        request_fingerprint: "f".repeat(64),
    +        input_config: json!({
    +            "S3Bucket": "loads",
    +            "S3KeyPrefix": "orders/",
    +            "InputFormat": "DYNAMODB_JSON",
    +            "InputCompressionType": "GZIP"
    +        }),
    +        output_config: json!({
    +            "OutputType": ["ERROR"],
    +            "S3Bucket": "loads",
    +            "S3KeyPrefix": "reports/"
    +        }),
    +        input_files: vec![InputFile {
    +            path: "orders/a.json".to_owned(),
    +            size: 4096,
    +            rows: None,
    +            etag: None,
    +            abandon: None,
    +        }],
    +        max_write_request_units: Some(1000),
    +        performance_tier: Some("STANDARD".to_owned()),
    +        start_time_ms: 1_789_060_589_767,
    +        end_time_ms: None,
    +        processed_item_count: 0,
    +        failed_item_count: 0,
    +        processed_input_bytes: 0,
    +        total_input_bytes: Some(4096),
    +        report_record_count: 0,
    +        report_bytes: 0,
    +        usage: json!({}),
    +        failure_code: None,
    +        failure_message: None,
    +        cancel_requested: false,
    +        cursor_file_index: 0,
    +        cursor_row_index: 0,
    +        lease_owner: None,
    +        lease_until_ms: None,
    +        run_context: None,
    +    }
    +}
    +
    +#[test]
    +fn next_token_encodes_the_documented_hex_layout() {
    +    let token = encode_next_token(1_789_060_589_767, OPERATION_ID);
    +    // 16 digits of time plus 46 digits of id, padded with zeros to 112,
    +    // the service minimum (112 is a multiple of 16).
    +    assert_eq!(token.len(), 112);
    +    assert_eq!(&token[..16], "000001a08c5228c7");
    +    assert!(token.ends_with("00"));
    +    assert!(
    +        token
    +            .bytes()
    +            .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
    +    );
    +}
    +
    +#[test]
    +fn next_token_round_trips() {
    +    let token = encode_next_token(1_789_060_589_767, OPERATION_ID);
    +    let cursor = decode_next_token(&token).expect("the encoded token must decode");
    +    assert_eq!(cursor.start_time_ms, 1_789_060_589_767);
    +    assert_eq!(cursor.operation_id, OPERATION_ID);
    +}
    +
    +#[test]
    +fn next_token_answers_each_malformed_class() {
    +    fn token_error(token: &str) -> String {
    +        match decode_next_token(token).expect_err("the token must be rejected") {
    +            DynamoDbError::ValidationException(message) => message,
    +            other => panic!("expected ValidationException, got {other:?}"),
    +        }
    +    }
    +
    +    let valid = encode_next_token(1_789_060_589_767, OPERATION_ID);
    +    assert_eq!(valid.len(), 112);
    +
    +    // Pattern class: empty, a length not a multiple of 16, uppercase hex,
    +    // and non-hex characters.
    +    for bad in [
    +        String::new(),
    +        valid[..valid.len() - 1].to_owned(),
    +        valid.to_uppercase(),
    +        format!("{}zz", &valid[..110]),
    +    ] {
    +        assert_eq!(
    +            token_error(&bad),
    +            format!(
    +                "1 validation error detected: Value '{bad}' at 'nextToken' failed to satisfy \
    +                 constraint: Member must satisfy regular expression pattern: ([0-9a-f]{{16}})+"
    +            ),
    +            "pattern class: {bad:?}"
    +        );
    +    }
    +
    +    // Model length maximum: over 1024 characters.
    +    let over = "0".repeat(1040);
    +    assert_eq!(
    +        token_error(&over),
    +        format!(
    +            "1 validation error detected: Value '{over}' at 'nextToken' failed to satisfy \
    +             constraint: Member must have length less than or equal to 1024"
    +        )
    +    );
    +
    +    // Service minimum: a well-formed hex token under 112 characters.
    +    for bad in ["0123456789abcdef".to_owned(), valid[..96].to_owned()] {
    +        assert_eq!(
    +            token_error(&bad),
    +            format!(
    +                "Invalid Request: 1 validation error detected: Value '{bad}' at 'nextToken' \
    +                 failed to satisfy constraint: Member must have length greater than or equal \
    +                 to 112"
    +            ),
    +            "minimum class: {bad:?}"
    +        );
    +    }
    +
    +    // A well-formed token of 112 or more that does not decode to a cursor.
    +    for bad in [
    +        format!("ffffffffffffffff{}", &valid[16..]), // time outside i64
    +        "0".repeat(112),                             // id all padding
    +        format!("{}{}", &valid[..16], "ff".repeat(48)), // id not UTF-8
    +    ] {
    +        assert_eq!(
    +            token_error(&bad),
    +            format!("Invalid Request: Provided nextToken is invalid: {bad}"),
    +            "not-a-cursor class: {bad:?}"
    +        );
    +    }
    +}
    +
    +#[test]
    +fn describe_renders_an_in_progress_record_without_terminal_fields() {
    +    let description = describe(&record("IN_PROGRESS"), REGION).expect("the record renders");
    +    assert_eq!(
    +        description.bulk_operation_arn,
    +        format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:bulk-operation/{OPERATION_ID}")
    +    );
    +    assert_eq!(
    +        description.bulk_operation_status,
    +        BulkOperationStatus::InProgress
    +    );
    +    let start = description.start_time.expect("start time is present");
    +    assert!((start - 1_789_060_589.767).abs() < 1e-6);
    +    assert_eq!(description.end_time, None);
    +    assert!(description.bulk_operation_usage.is_none());
    +    assert_eq!(description.failure_code, None);
    +    assert_eq!(description.failure_message, None);
    +    assert_eq!(description.client_token.as_deref(), Some("load-a"));
    +    assert_eq!(description.max_write_request_units, Some(1000));
    +    assert_eq!(
    +        description.performance_tier,
    +        Some(BulkOperationPerformanceTier::Standard)
    +    );
    +    assert_eq!(description.processed_item_count, Some(0));
    +    assert_eq!(description.total_input_bytes, Some(4096));
    +    let input = description
    +        .input_s3_configuration
    +        .expect("input configuration echoes");
    +    assert_eq!(input.s3_bucket, "loads");
    +    assert_eq!(input.s3_key_prefix.as_deref(), Some("orders/"));
    +    let output = description
    +        .output_s3_configuration
    +        .expect("output configuration echoes");
    +    assert_eq!(output.s3_bucket.as_deref(), Some("loads"));
    +}
    +
    +#[test]
    +fn describe_renders_a_terminal_record_with_usage_and_failure_fields() {
    +    let mut terminal = record("FAILED");
    +    terminal.end_time_ms = Some(1_789_060_650_634);
    +    terminal.processed_item_count = 7;
    +    terminal.failed_item_count = 2;
    +    terminal.failure_code = Some("ValidationException".to_owned());
    +    terminal.failure_message = Some("Operation exceeded max_bulk_item_count (5 rows)".to_owned());
    +    terminal.usage = json!({
    +        "TableName": terminal.table_arn,
    +        "BulkPutBytes": 7168,
    +        "Table": {"BulkPutBytes": 7168},
    +        "GlobalSecondaryIndexes": {"gsi1": {"BulkPutBytes": 1024}},
    +        "LocalSecondaryIndexes": {},
    +        "VectorIndexes": {"vi1": {"BulkPutVectorBytes": 8192}}
    +    });
    +    let description = describe(&terminal, REGION).expect("the record renders");
    +    let end = description.end_time.expect("end time is present");
    +    assert!((end - 1_789_060_650.634).abs() < 1e-6);
    +    assert_eq!(description.processed_item_count, Some(7));
    +    assert_eq!(description.failed_item_count, Some(2));
    +    assert_eq!(
    +        description.failure_code.as_deref(),
    +        Some("ValidationException")
    +    );
    +    assert_eq!(
    +        description.failure_message.as_deref(),
    +        Some("Operation exceeded max_bulk_item_count (5 rows)")
    +    );
    +    let usage = description
    +        .bulk_operation_usage
    +        .expect("usage is present once terminal");
    +    assert_eq!(usage.len(), 1);
    +    assert_eq!(usage[0].table_name, terminal.table_name);
    +    assert_eq!(usage[0].bulk_put_bytes, 7168);
    +    assert_eq!(usage[0].table.bulk_put_bytes, 7168);
    +    assert_eq!(
    +        usage[0].global_secondary_indexes["gsi1"].bulk_put_bytes,
    +        1024
    +    );
    +    assert!(usage[0].local_secondary_indexes.is_empty());
    +    assert_eq!(usage[0].vector_indexes["vi1"].bulk_put_vector_bytes, 8192);
    +}
    +
    +#[test]
    +fn describe_renders_a_zeroed_usage_entry_for_a_terminal_record_with_empty_usage() {
    +    let mut terminal = record("CANCELLED");
    +    terminal.end_time_ms = Some(1_789_060_650_634);
    +    let description = describe(&terminal, REGION).expect("the record renders");
    +    let usage = description
    +        .bulk_operation_usage
    +        .expect("usage is present once terminal");
    +    assert_eq!(usage[0].bulk_put_bytes, 0);
    +    assert_eq!(usage[0].table_name, terminal.table_name);
    +    assert!(usage[0].global_secondary_indexes.is_empty());
    +}
    +
    +#[test]
    +fn describe_reports_an_unknown_stored_status_as_an_internal_error() {
    +    let err = describe(&record("EXPLODED"), REGION).expect_err("the status must be refused");
    +    match err {
    +        DynamoDbError::InternalServerError(message) => {
    +            assert_eq!(message, "Internal server error");
    +        }
    +        other => panic!("expected InternalServerError, got {other:?}"),
    +    }
    +}
    +
    +#[test]
    +fn configuration_size_cap_binds_on_the_serialized_bytes() {
    +    // A JSON string of n ASCII characters serializes to n + 2 bytes.
    +    let at_cap_halves = MAX_CONFIGURATION_BYTES / 2 - 2;
    +    let input = Value::String("a".repeat(at_cap_halves));
    +    let output = Value::String("b".repeat(at_cap_halves));
    +    assert!(validate_configuration_size(&input, &output).is_ok());
    +    let over = Value::String("b".repeat(at_cap_halves + 1));
    +    let err = validate_configuration_size(&input, &over)
    +        .expect_err("one byte past the cap must be refused");
    +    match err {
    +        DynamoDbError::ValidationException(message) => {
    +            assert_eq!(message, "S3 configuration exceeds 8192 bytes");
    +        }
    +        other => panic!("expected ValidationException, got {other:?}"),
    +    }
    +}
    +
    +// Fidelity: a Start that names a missing input bucket inserts the
    +// operation already terminal FAILED, before enumeration, so it carries
    +// no input total. The hosted service returns that FAILED operation
    +// with TotalInputBytes absent and the counters present at zero
    +// (measured on the service); describe renders the same shape.
    +#[test]
    +fn describe_omits_total_input_bytes_on_a_missing_bucket_failure() {
    +    let mut terminal = record("FAILED");
    +    terminal.end_time_ms = Some(terminal.start_time_ms); // born terminal
    +    terminal.total_input_bytes = None;
    +    terminal.processed_item_count = 0;
    +    terminal.failed_item_count = 0;
    +    terminal.failure_code = Some("S3NoSuchBucket".to_owned());
    +    terminal.failure_message = Some("The specified bucket does not exist".to_owned());
    +
    +    let description = describe(&terminal, REGION).expect("the record renders");
    +    let wire = serde_json::to_value(&description).expect("the description serializes");
    +
    +    assert_eq!(wire.get("ProcessedItemCount"), Some(&json!(0)));
    +    assert_eq!(wire.get("FailedItemCount"), Some(&json!(0)));
    +    assert!(
    +        wire.get("TotalInputBytes").is_none(),
    +        "the hosted service omits TotalInputBytes on a missing-bucket FAILED operation"
    +    );
    +    // The two byte members travel together: the missing-bucket description
    +    // the hosted service answered carries neither.
    +    assert!(
    +        wire.get("ProcessedInputBytes").is_none(),
    +        "ProcessedInputBytes is absent whenever TotalInputBytes is absent"
    +    );
    +}
    +
    +// A failure on the input object count (measured on the service: 50,500
    +// objects under one prefix) runs for seconds on the hosted service and
    +// ends FAILED with neither byte member, the same shape as the missing
    +// bucket: the listing that would have fixed the total was never kept.
    +#[test]
    +fn describe_omits_both_byte_members_on_an_object_count_quota_failure() {
    +    let mut terminal = record("FAILED");
    +    terminal.end_time_ms = Some(terminal.start_time_ms + 15_386);
    +    terminal.total_input_bytes = Some(6_060_000);
    +    terminal.failure_code = Some("S3ObjectsCountLimitExceeded".to_owned());
    +    terminal.failure_message = Some(
    +        "You have reached the maximum number of objects allowed in a single bulk operation \
    +         for the specified prefix h1/, which is 50000. You can reach out to AWS Support to \
    +         request a quota increase."
    +            .to_owned(),
    +    );
    +
    +    let description = describe(&terminal, REGION).expect("the record renders");
    +    let wire = serde_json::to_value(&description).expect("the description serializes");
    +
    +    assert!(wire.get("TotalInputBytes").is_none());
    +    assert!(wire.get("ProcessedInputBytes").is_none());
    +    assert_eq!(wire.get("ProcessedItemCount"), Some(&json!(0)));
    +    assert_eq!(wire.get("FailedItemCount"), Some(&json!(0)));
    +}
    +
    +// The per-object size quota keeps both: TotalInputBytes from the
    +// listing, ProcessedInputBytes 0.
    +#[test]
    +fn describe_keeps_both_byte_members_on_an_object_size_quota_failure() {
    +    let mut terminal = record("FAILED");
    +    terminal.end_time_ms = Some(terminal.start_time_ms + 8_700);
    +    terminal.total_input_bytes = Some(5_369_000_000);
    +    terminal.failure_code = Some("InputObjectTooLarge".to_owned());
    +
    +    let description = describe(&terminal, REGION).expect("the record renders");
    +    let wire = serde_json::to_value(&description).expect("the description serializes");
    +
    +    assert_eq!(wire.get("TotalInputBytes"), Some(&json!(5_369_000_000_u64)));
    +    assert_eq!(wire.get("ProcessedInputBytes"), Some(&json!(0)));
    +}
    +
    +// The enumerated case keeps the field, including an enumerated total of
    +// zero (an empty input), so the absence above is tied to enumeration
    +// never having run, not to the value being zero.
    +#[test]
    +fn describe_keeps_a_zero_total_input_bytes_when_enumerated() {
    +    let mut enumerated = record("IN_PROGRESS");
    +    enumerated.total_input_bytes = Some(0);
    +
    +    let description = describe(&enumerated, REGION).expect("the record renders");
    +    let wire = serde_json::to_value(&description).expect("the description serializes");
    +
    +    assert_eq!(wire.get("TotalInputBytes"), Some(&json!(0)));
    +}
    diff --git a/crates/engine/src/bulk_put/handlers/validate.rs b/crates/engine/src/bulk_put/handlers/validate.rs
    new file mode 100644
    index 000000000..5c2c56a88
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/handlers/validate.rs
    @@ -0,0 +1,356 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Request validation for the bulk operation handlers: required-member
    +//! checks on the raw body, the operation ARN null guard, and the field
    +//! constraint checks the start handler applies in spec order.
    +
    +use super::*;
    +
    +/// Whether any global secondary or vector index of the table is in a state
    +/// other than `ACTIVE`. Local secondary indexes are created with the table
    +/// and have no lifecycle of their own.
    +pub(super) fn has_index_operation(table: &TableDescription) -> bool {
    +    let gsi_busy = table
    +        .global_secondary_indexes
    +        .as_ref()
    +        .is_some_and(|indexes| indexes.iter().any(|index| index.index_status != "ACTIVE"));
    +    let vector_busy = table
    +        .vector_indexes
    +        .as_ref()
    +        .is_some_and(|indexes| indexes.iter().any(|index| !index.index_status.is_active()));
    +    gsi_busy || vector_busy
    +}
    +
    +/// One required member to check on the raw request body before
    +/// deserialization. `parent` is the JSON name of the enclosing object for a
    +/// nested member, or `None` for a top level member; `json_name` is the
    +/// member's JSON name; `path` is the lowerCamel path the error message
    +/// names.
    +pub(super) struct RequiredMember {
    +    parent: Option<&'static str>,
    +    json_name: &'static str,
    +    path: &'static str,
    +}
    +
    +/// `StartBulkPutItems` required members other than `TableName`, in model
    +/// member order. Each present parent's required members follow it, so a
    +/// missing top level object is reported without also reporting its
    +/// children. `TableName` is checked ahead of these and alone, with its own
    +/// message (measured on the service: a request missing both `TableName` and
    +/// `InputS3Configuration` answers only the `TableName` message).
    +pub(super) const START_REQUIRED: &[RequiredMember] = &[
    +    RequiredMember {
    +        parent: None,
    +        json_name: "InputS3Configuration",
    +        path: "inputS3Configuration",
    +    },
    +    RequiredMember {
    +        parent: Some("InputS3Configuration"),
    +        json_name: "S3Bucket",
    +        path: "inputS3Configuration.s3Bucket",
    +    },
    +    RequiredMember {
    +        parent: Some("InputS3Configuration"),
    +        json_name: "InputFormat",
    +        path: "inputS3Configuration.inputFormat",
    +    },
    +    RequiredMember {
    +        parent: None,
    +        json_name: "OutputS3Configuration",
    +        path: "outputS3Configuration",
    +    },
    +    RequiredMember {
    +        parent: Some("OutputS3Configuration"),
    +        json_name: "OutputType",
    +        path: "outputS3Configuration.outputType",
    +    },
    +];
    +
    +/// The hosted answer for a `StartBulkPutItems` body with no `TableName`
    +/// (absent or JSON null), measured on the service. It is the message the
    +/// server's other table operations use for the same fault, and it is
    +/// answered before any other required member is checked.
    +pub(super) const TABLE_NAME_REQUIRED_MESSAGE: &str =
    +    "The parameter 'TableName' is required but was not present in the request";
    +
    +/// `DescribeBulkOperation` and `CancelBulkOperation` with no
    +/// `BulkOperationArn` (absent or JSON null) answer the ARN parser's shape
    +/// layer message, `Invalid Bulk operation ARN`, the same answer a malformed
    +/// ARN gets (measured on the service), not the null-member envelope.
    +pub(super) fn require_bulk_operation_arn(body: &Value) -> Result<(), DynamoDbError> {
    +    if body
    +        .as_object()
    +        .is_some_and(|obj| is_absent_or_null(obj.get("BulkOperationArn")))
    +    {
    +        return Err(extenddb_core::arn::bulk_operation_arn_shape_error());
    +    }
    +    Ok(())
    +}
    +
    +/// A member absent from a JSON object, or present as JSON null, is a null
    +/// required member.
    +pub(super) fn is_absent_or_null(value: Option<&Value>) -> bool {
    +    matches!(value, None | Some(Value::Null))
    +}
    +
    +/// Check the model's required members on the raw body before deserializing.
    +/// A null or absent required member answers the hosted `ValidationException`
    +/// (`Member must not be null`) that the rest of the server uses for this
    +/// fault, in place of the serde `SerializationException` a straight
    +/// deserialize gives. A nested member is checked only when its parent object
    +/// is present and is a JSON object; a parent that is absent or null is
    +/// reported in its own right, and a parent of the wrong type is left to
    +/// deserialization. Missing members are reported in model member order,
    +/// joined into the server's "N validation errors detected" envelope.
    +pub(super) fn require_members(
    +    body: &Value,
    +    required: &[RequiredMember],
    +) -> Result<(), DynamoDbError> {
    +    let Some(obj) = body.as_object() else {
    +        return Ok(());
    +    };
    +    let mut clauses: Vec = Vec::new();
    +    for member in required {
    +        let present = match member.parent {
    +            None => !is_absent_or_null(obj.get(member.json_name)),
    +            Some(parent) => match obj.get(parent).and_then(Value::as_object) {
    +                Some(parent_obj) => !is_absent_or_null(parent_obj.get(member.json_name)),
    +                None => true,
    +            },
    +        };
    +        if !present {
    +            clauses.push(format!(
    +                "Value null at '{}' failed to satisfy constraint: Member must not be null",
    +                member.path
    +            ));
    +        }
    +    }
    +    if clauses.is_empty() {
    +        return Ok(());
    +    }
    +    let count = clauses.len();
    +    Err(DynamoDbError::ValidationException(format!(
    +        "{count} validation error{} detected: {}",
    +        if count == 1 { "" } else { "s" },
    +        clauses.join("; ")
    +    )))
    +}
    +
    +/// A bare table name on `StartBulkPutItems` is held to the table-name rules
    +/// (3 to 255 characters, `[a-zA-Z0-9_.-]+`), reported together in one
    +/// envelope the way the hosted service answers an empty name (measured
    +/// "2 validation errors detected:... greater than or equal to
    +/// 3; ... regular expression pattern: [a-zA-Z0-9_.-]+"). A name over 1024
    +/// characters is answered by the `TableArn` maximum before this.
    +pub(super) fn validate_bare_table_name(name: &str) -> Result<(), DynamoDbError> {
    +    let mut clauses: Vec = Vec::new();
    +    let chars = name.chars().count();
    +    if chars < 3 {
    +        clauses.push(format!(
    +            "Value '{name}' at 'tableName' failed to satisfy constraint: Member must have \
    +             length greater than or equal to 3"
    +        ));
    +    }
    +    if chars > 255 {
    +        clauses.push(format!(
    +            "Value '{name}' at 'tableName' failed to satisfy constraint: Member must have \
    +             length less than or equal to 255"
    +        ));
    +    }
    +    // The pattern requires at least one character, so an empty name fails
    +    // it as well as the minimum (both clauses measured together).
    +    if name.is_empty()
    +        || !name
    +            .chars()
    +            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')
    +    {
    +        clauses.push(format!(
    +            "Value '{name}' at 'tableName' failed to satisfy constraint: Member must satisfy \
    +             regular expression pattern: [a-zA-Z0-9_.-]+"
    +        ));
    +    }
    +    if clauses.is_empty() {
    +        return Ok(());
    +    }
    +    let count = clauses.len();
    +    Err(DynamoDbError::ValidationException(format!(
    +        "{count} validation error{} detected: {}",
    +        if count == 1 { "" } else { "s" },
    +        clauses.join("; ")
    +    )))
    +}
    +
    +/// The service's answer for a bucket name it refuses outright, as opposed
    +/// to the model constraint messages above, which name the lowerCamel
    +/// member path. `member_name` is the wire spelling the message carries,
    +/// `InputS3Configuration.S3Bucket` or `OutputS3Configuration.S3Bucket`
    +/// (measured on the service).
    +pub(super) fn bucket_is_invalid(member_name: &str) -> DynamoDbError {
    +    DynamoDbError::ValidationException(format!("Invalid Request: {member_name} is invalid"))
    +}
    +
    +/// The `Invalid Request:` bucket checks the service adds beyond the model,
    +/// answered after every model constraint (measured on the service: an empty
    +/// OutputType beside a bad bucket pattern answers the pattern, which is a
    +/// model constraint, so these run in the second pass).
    +pub(super) fn validate_bucket_service(
    +    bucket: &str,
    +    member_path: &str,
    +    member_name: &str,
    +) -> Result<(), DynamoDbError> {
    +    // The service adds a minimum of three characters the model does not
    +    // carry, checked after the pattern and reported with the `Invalid
    +    // Request:` prefix (measured: 'a' answers the pattern, 'ab' answers
    +    // this).
    +    if bucket.chars().count() < MIN_SERVICE_NAME_CHARS {
    +        return Err(DynamoDbError::ValidationException(format!(
    +            "Invalid Request: 1 validation error detected: Value '{bucket}' at '{member_path}' \
    +             failed to satisfy constraint: Member must have length greater than or equal to \
    +             {MIN_SERVICE_NAME_CHARS}"
    +        )));
    +    }
    +    // A name spelled as a dotted IPv4 literal is refused (measured
    +    // with `10.20.30.40`). The literal passes the model pattern and both
    +    // length bounds, so it needs its own check; the order relative to the
    +    // checks above is not measured, because no string can fail this one and
    +    // any of those.
    +    if is_dotted_ipv4(bucket) {
    +        return Err(bucket_is_invalid(member_name));
    +    }
    +    Ok(())
    +}
    +
    +/// Whether the name is spelled as a dotted IPv4 literal: four
    +/// dot-separated groups of one to three ASCII digits. The bucket naming
    +/// rules exclude a name of this shape, and the service refuses it at
    +/// Start.
    +fn is_dotted_ipv4(bucket: &str) -> bool {
    +    let mut groups = 0_u8;
    +    for group in bucket.split('.') {
    +        groups += 1;
    +        if groups > 4
    +            || !(1..=3).contains(&group.len())
    +            || !group.bytes().all(|byte| byte.is_ascii_digit())
    +        {
    +            return false;
    +        }
    +    }
    +    groups == 4
    +}
    +
    +/// True when the name matches the model's bucket pattern: first and last
    +/// byte alphanumeric, every byte between them alphanumeric, `_`, `.`, or
    +/// `-`. The pattern excludes `/`, `\`, NUL, and the bare `.` and `..`.
    +pub(super) fn bucket_matches_pattern(bucket: &str) -> bool {
    +    let bytes = bucket.as_bytes();
    +    let Some((&first, rest)) = bytes.split_first() else {
    +        return false;
    +    };
    +    let Some((&last, middle)) = rest.split_last() else {
    +        return false;
    +    };
    +    first.is_ascii_alphanumeric()
    +        && last.is_ascii_alphanumeric()
    +        && middle
    +            .iter()
    +            .all(|&byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
    +}
    +
    +/// The service's `Invalid Request:` minimum of three characters on an
    +/// `S3KeyPrefix`, a bound the model does not carry, answered after every
    +/// model constraint (measured with 'x/'). An absent prefix is not checked.
    +pub(super) fn validate_prefix_length_service(
    +    prefix: &str,
    +    member_path: &str,
    +) -> Result<(), DynamoDbError> {
    +    if prefix.chars().count() < MIN_SERVICE_NAME_CHARS {
    +        return Err(DynamoDbError::ValidationException(format!(
    +            "Invalid Request: 1 validation error detected: Value '{prefix}' at '{member_path}' \
    +             failed to satisfy constraint: Member must have length greater than or equal to \
    +             {MIN_SERVICE_NAME_CHARS}"
    +        )));
    +    }
    +    Ok(())
    +}
    +
    +/// The path rules both `S3KeyPrefix` fields share: no `..` path
    +/// component, no `\`, NUL, or control character. Lexical checks only; the
    +/// canonical containment checks run on every path before any filesystem
    +/// operation.
    +///
    +/// Doubled and leading separators are not rules here. An input prefix is
    +/// matched byte-wise against bucket-relative paths and is never joined
    +/// onto a path, and the service accepts `a3//b//c/`, `a3/b//c/`, and
    +/// `/a3/b/` at Start (measured on the service).
    +pub(super) fn validate_prefix(prefix: &str) -> Result<(), DynamoDbError> {
    +    let invalid = prefix
    +        .chars()
    +        .any(|character| character == '\\' || character.is_control())
    +        || prefix.split('/').any(|component| component == "..");
    +    if invalid {
    +        return Err(DynamoDbError::ValidationException(
    +            "S3KeyPrefix contains an invalid path component".to_owned(),
    +        ));
    +    }
    +    Ok(())
    +}
    +
    +/// The output prefix carries one rule the input prefix does not: it must
    +/// not begin with `/`. It is joined onto the report base path, where an
    +/// absolute prefix would replace the base outright, and the jail re-checks
    +/// it there before any path is opened. A leading slash on the output side
    +/// is not measured, so the refusal stays.
    +pub(super) fn validate_output_prefix(prefix: &str) -> Result<(), DynamoDbError> {
    +    if prefix.starts_with('/') {
    +        return Err(DynamoDbError::ValidationException(
    +            "S3KeyPrefix contains an invalid path component".to_owned(),
    +        ));
    +    }
    +    validate_prefix(prefix)
    +}
    +
    +/// When present, `S3BucketOwner` must equal the caller's account id.
    +/// The serialized configurations together may not exceed
    +/// [`MAX_CONFIGURATION_BYTES`], so the catalog row is bounded.
    +pub(super) fn validate_configuration_size(
    +    input: &Value,
    +    output: &Value,
    +) -> Result<(), DynamoDbError> {
    +    let size = input.to_string().len() + output.to_string().len();
    +    if size > MAX_CONFIGURATION_BYTES {
    +        return Err(DynamoDbError::ValidationException(format!(
    +            "S3 configuration exceeds {MAX_CONFIGURATION_BYTES} bytes"
    +        )));
    +    }
    +    Ok(())
    +}
    +
    +/// No string field of either configuration may carry U+0000. The bucket
    +/// pattern and the prefix rules already exclude it; this check covers the
    +/// recorded fields (`S3BucketOwner`, `S3SseKmsKeyId`) as well. A NUL can
    +/// never name a real bucket, prefix, KMS key, or owner, and the stored
    +/// configurations are `jsonb` columns on the `PostgreSQL` backend, which
    +/// refuses the byte, so the request is refused before anything is created.
    +pub(super) fn validate_configuration_characters(
    +    input: &Value,
    +    output: &Value,
    +) -> Result<(), DynamoDbError> {
    +    if value_contains_nul(input) || value_contains_nul(output) {
    +        return Err(DynamoDbError::ValidationException(
    +            "S3 configuration contains an invalid character".to_owned(),
    +        ));
    +    }
    +    Ok(())
    +}
    +
    +/// Whether any string in the JSON tree carries U+0000. Keys need no check:
    +/// they come from the struct field names, not the request.
    +fn value_contains_nul(value: &Value) -> bool {
    +    match value {
    +        Value::String(text) => text.contains('\0'),
    +        Value::Array(items) => items.iter().any(value_contains_nul),
    +        Value::Object(map) => map.values().any(value_contains_nul),
    +        _ => false,
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/mod.rs b/crates/engine/src/bulk_put/mod.rs
    new file mode 100644
    index 000000000..9152398b5
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/mod.rs
    @@ -0,0 +1,38 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Bulk put operations: the four request handlers plus the primitives they
    +//! and the runner consume: operation ARN handling, input row parsing, the
    +//! idempotency request fingerprint, input file enumeration and reading,
    +//! and report rendering. The worker in [`worker`] claims operations and
    +//! drives [`runner`]; the server spawns it next to its own workers.
    +
    +use std::time::{SystemTime, UNIX_EPOCH};
    +
    +pub(crate) mod arn;
    +pub mod authorize;
    +pub(crate) mod fingerprint;
    +pub(crate) mod handlers;
    +pub(crate) mod pacer;
    +pub(crate) mod report;
    +pub(crate) mod rows;
    +pub(crate) mod run_context;
    +pub(crate) mod runner;
    +pub(crate) mod s3;
    +pub(crate) mod source;
    +pub mod store;
    +pub mod store_root;
    +pub(crate) mod usage;
    +pub(crate) mod worker;
    +
    +#[cfg(test)]
    +mod tests;
    +
    +/// The current time in epoch milliseconds, shared by the start handler
    +/// (`start_time_ms`) and the runner's terminal transition (`end_time_ms`).
    +pub(crate) fn epoch_millis() -> i64 {
    +    SystemTime::now()
    +        .duration_since(UNIX_EPOCH)
    +        .map(|elapsed| i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX))
    +        .unwrap_or(0)
    +}
    diff --git a/crates/engine/src/bulk_put/pacer.rs b/crates/engine/src/bulk_put/pacer.rs
    new file mode 100644
    index 000000000..c9e758d69
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/pacer.rs
    @@ -0,0 +1,204 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Write pacing for bulk put runners.
    +//!
    +//! A [`WritePacer`] is a token bucket denominated in write request units
    +//! (1 KB of item, rounded up, the same unit `BulkPutBytes` charges). Every
    +//! row write first takes its units from the bucket; when the bucket is in
    +//! deficit the runner sleeps until the deficit is repaid, so sustained
    +//! throughput converges on the configured rate.
    +//!
    +//! Two buckets pace a run:
    +//!
    +//! - the process bucket, shared by every runner in this server and sized by
    +//!   `limits.bulk_put_write_units_per_second`. It bounds how much of the
    +//!   storage write path bulk loads can take from request traffic on the same
    +//!   server. `0` disables it.
    +//! - the operation bucket, sized by the request's `MaxWriteRequestUnits`
    +//!   when present. The hosted service treats that field as a ceiling on the
    +//!   operation's write rate; pacing at or below it keeps a local run inside
    +//!   the same ceiling. Absent, only the process bucket applies.
    +//!
    +//! The bucket holds one second of capacity and starts full, so a run opens
    +//! at full rate and a short operation is not slowed. Tokens may go negative:
    +//! a row larger than the whole bucket is written after its deficit is paid
    +//! rather than refused, so a single 400 KB item at a low rate waits rather
    +//! than wedges. The clock is read under the lock, so two runners cannot
    +//! both see the same refill interval. The clock is tokio's, so tests drive
    +//! it with a paused runtime; outside tests it is the monotonic clock.
    +
    +use std::sync::Mutex;
    +use std::sync::atomic::{AtomicU64, Ordering};
    +use std::time::Duration;
    +
    +use tokio::time::Instant;
    +
    +/// Write request units for an item of `item_bytes` bytes: 1 KB each,
    +/// rounded up, at least one.
    +pub(crate) fn write_units(item_bytes: usize) -> u64 {
    +    u64::try_from(item_bytes.div_ceil(1024).max(1)).unwrap_or(u64::MAX)
    +}
    +
    +#[derive(Debug)]
    +struct Bucket {
    +    /// Tokens on hand; negative is a deficit the next acquire sleeps off.
    +    tokens: f64,
    +    last_refill: Instant,
    +}
    +
    +/// A token bucket in write request units. See the module documentation.
    +#[derive(Debug)]
    +pub(crate) struct WritePacer {
    +    /// Units per second, or `None` for no pacing.
    +    rate: Option,
    +    bucket: Mutex,
    +    /// Total time runners have slept on this bucket, in microseconds, for
    +    /// tests and the end-of-run log line.
    +    waited_micros: AtomicU64,
    +}
    +
    +impl WritePacer {
    +    /// A pacer at `units_per_second`; `0` means unbounded.
    +    pub(crate) fn new(units_per_second: u64) -> Self {
    +        let rate = (units_per_second > 0).then_some(units_per_second as f64);
    +        Self {
    +            rate,
    +            bucket: Mutex::new(Bucket {
    +                tokens: rate.unwrap_or(0.0),
    +                last_refill: Instant::now(),
    +            }),
    +            waited_micros: AtomicU64::new(0),
    +        }
    +    }
    +
    +    /// A pacer that never waits.
    +    #[cfg(test)]
    +    pub(crate) fn unbounded() -> Self {
    +        Self::new(0)
    +    }
    +
    +    pub(crate) fn is_bounded(&self) -> bool {
    +        self.rate.is_some()
    +    }
    +
    +    /// Total time spent waiting on this bucket so far.
    +    pub(crate) fn waited(&self) -> Duration {
    +        Duration::from_micros(self.waited_micros.load(Ordering::Relaxed))
    +    }
    +
    +    /// Take `units` from the bucket, sleeping first if the bucket is in
    +    /// deficit after the take. Returns the time slept.
    +    pub(crate) async fn acquire(&self, units: u64) -> Duration {
    +        let Some(rate) = self.rate else {
    +            return Duration::ZERO;
    +        };
    +        let wait = {
    +            // A poisoned lock means a panic mid-update on another runner;
    +            // the bucket's numbers are still usable, so pacing continues.
    +            let mut bucket = self
    +                .bucket
    +                .lock()
    +                .unwrap_or_else(std::sync::PoisonError::into_inner);
    +            let now = Instant::now();
    +            let elapsed = now.duration_since(bucket.last_refill).as_secs_f64();
    +            bucket.tokens = (bucket.tokens + elapsed * rate).min(rate);
    +            bucket.last_refill = now;
    +            bucket.tokens -= units as f64;
    +            if bucket.tokens >= 0.0 {
    +                Duration::ZERO
    +            } else {
    +                Duration::from_secs_f64(-bucket.tokens / rate)
    +            }
    +        };
    +        if !wait.is_zero() {
    +            tokio::time::sleep(wait).await;
    +            self.waited_micros.fetch_add(
    +                u64::try_from(wait.as_micros()).unwrap_or(u64::MAX),
    +                Ordering::Relaxed,
    +            );
    +        }
    +        wait
    +    }
    +
    +    /// The tokens the bucket held when it last settled (the last
    +    /// `acquire`), with no refill applied for the time since. Tests read
    +    /// the balance here rather than through a timed `acquire`, whose sleep
    +    /// drifts under host load.
    +    #[cfg(test)]
    +    pub(crate) fn settled_tokens(&self) -> f64 {
    +        self.bucket
    +            .lock()
    +            .unwrap_or_else(std::sync::PoisonError::into_inner)
    +            .tokens
    +    }
    +}
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::*;
    +
    +    #[test]
    +    fn write_units_round_up_to_a_kilobyte_with_a_floor_of_one() {
    +        assert_eq!(write_units(0), 1);
    +        assert_eq!(write_units(1), 1);
    +        assert_eq!(write_units(1024), 1);
    +        assert_eq!(write_units(1025), 2);
    +        assert_eq!(write_units(400 * 1024), 400);
    +    }
    +
    +    #[tokio::test]
    +    async fn an_unbounded_pacer_never_waits() {
    +        let pacer = WritePacer::unbounded();
    +        assert!(!pacer.is_bounded());
    +        for _ in 0..1000 {
    +            assert_eq!(pacer.acquire(400).await, Duration::ZERO);
    +        }
    +        assert_eq!(pacer.waited(), Duration::ZERO);
    +    }
    +
    +    #[tokio::test(start_paused = true)]
    +    async fn a_bounded_pacer_opens_at_full_rate_then_converges_on_it() {
    +        let pacer = WritePacer::new(10);
    +        // The first second of capacity is free.
    +        for _ in 0..10 {
    +            assert_eq!(pacer.acquire(1).await, Duration::ZERO);
    +        }
    +        // Ten more units at 10/s take one second in total.
    +        let before = Instant::now();
    +        for _ in 0..10 {
    +            pacer.acquire(1).await;
    +        }
    +        let elapsed = before.elapsed();
    +        assert!(
    +            elapsed >= Duration::from_millis(950) && elapsed <= Duration::from_millis(1100),
    +            "20 units at 10/s from a full bucket take about one second, got {elapsed:?}"
    +        );
    +        assert!(pacer.waited() >= Duration::from_millis(950));
    +    }
    +
    +    #[tokio::test(start_paused = true)]
    +    async fn a_row_larger_than_the_bucket_waits_for_its_deficit_rather_than_wedging() {
    +        let pacer = WritePacer::new(100);
    +        // 400 units against a 100-unit bucket: written now, then a
    +        // three-second deficit before the next row.
    +        let first = pacer.acquire(400).await;
    +        assert_eq!(first, Duration::from_secs(3));
    +        let before = Instant::now();
    +        pacer.acquire(1).await;
    +        // The deficit was repaid during the first sleep; the bucket is at
    +        // roughly zero, so a one-unit row waits about 10 ms.
    +        assert!(before.elapsed() <= Duration::from_millis(50));
    +    }
    +
    +    #[tokio::test(start_paused = true)]
    +    async fn idle_time_refills_at_most_one_second_of_capacity() {
    +        let pacer = WritePacer::new(10);
    +        tokio::time::advance(Duration::from_secs(60)).await;
    +        // Sixty idle seconds do not bank sixty seconds of burst.
    +        for _ in 0..10 {
    +            assert_eq!(pacer.acquire(1).await, Duration::ZERO);
    +        }
    +        assert!(pacer.acquire(1).await > Duration::ZERO);
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/report.rs b/crates/engine/src/bulk_put/report.rs
    new file mode 100644
    index 000000000..1492b43b1
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/report.rs
    @@ -0,0 +1,491 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Report encoding and record construction for bulk put operations.
    +//!
    +//! Each report file is gzip-compressed newline-delimited JSON, one record
    +//! per failed or condition-failed row. The object key layout and the record
    +//! shapes reproduce the hosted error report, so tooling written for that
    +//! report parses this output unchanged.
    +
    +use std::io::Write;
    +
    +use extenddb_core::types::Item;
    +use extenddb_storage::ReportKind;
    +use flate2::write::GzEncoder;
    +use serde::{Deserialize, Serialize};
    +use serde_json::{Value, json};
    +
    +/// The members of one report record, declared in the service's order
    +/// (`itemLocation`, `errorMessages`, `newImage`, `oldImage`). `serde_json`'s
    +/// `Map` is a `BTreeMap`, so a `Value` serializes its keys sorted; the
    +/// records travel as `Value` through the runner, so they are round-tripped
    +/// through this struct at serialization time to restore the service order.
    +/// Enabling `serde_json`'s `preserve_order` feature would reorder every map
    +/// the workspace serializes, so it is not used.
    +#[derive(Serialize, Deserialize)]
    +struct OrderedReportRecord {
    +    #[serde(rename = "itemLocation")]
    +    item_location: OrderedItemLocation,
    +    #[serde(rename = "errorMessages")]
    +    error_messages: Vec,
    +    #[serde(rename = "newImage", skip_serializing_if = "Option::is_none")]
    +    new_image: Option,
    +    #[serde(rename = "oldImage", skip_serializing_if = "Option::is_none")]
    +    old_image: Option,
    +}
    +
    +/// The members of `itemLocation`, declared in the service's order
    +/// (`inputSourceArn`, then `itemIndex` when present).
    +#[derive(Serialize, Deserialize)]
    +struct OrderedItemLocation {
    +    #[serde(rename = "inputSourceArn")]
    +    input_source_arn: String,
    +    #[serde(rename = "itemIndex", skip_serializing_if = "Option::is_none")]
    +    item_index: Option,
    +}
    +
    +/// The members of one manifest line, declared in the service's order
    +/// (`inputSourceArn`, `fileProcessingStatus`, `processedRanges`, `etag`).
    +#[derive(Serialize, Deserialize)]
    +struct OrderedManifestLine {
    +    #[serde(rename = "inputSourceArn")]
    +    input_source_arn: String,
    +    #[serde(rename = "fileProcessingStatus")]
    +    file_processing_status: String,
    +    #[serde(rename = "processedRanges")]
    +    processed_ranges: Vec,
    +    #[serde(rename = "etag")]
    +    etag: String,
    +}
    +
    +/// Serialize one report record with its members in the service's order,
    +/// round-tripping the `Value` through [`OrderedReportRecord`]. A record that
    +/// does not match the shape falls back to the `Value`'s own serialization
    +/// (the byte count is the same either way; only the key order differs).
    +fn ordered_record_json(record: &Value) -> String {
    +    match serde_json::from_value::(record.clone()) {
    +        Ok(ordered) => serde_json::to_string(&ordered).unwrap_or_else(|_| record.to_string()),
    +        Err(_) => record.to_string(),
    +    }
    +}
    +
    +/// Serialize one manifest line with its members in the service's order,
    +/// round-tripping the `Value` through [`OrderedManifestLine`]. The manifest
    +/// writer calls this so the written bytes carry the service member order.
    +pub(crate) fn write_manifest_line(
    +    writer: &mut W,
    +    line: &Value,
    +) -> Result<(), std::io::Error> {
    +    let bytes = match serde_json::from_value::(line.clone()) {
    +        Ok(ordered) => serde_json::to_string(&ordered).unwrap_or_else(|_| line.to_string()),
    +        Err(_) => line.to_string(),
    +    };
    +    writer.write_all(bytes.as_bytes())
    +}
    +
    +/// The message every condition-failure record carries, the one `PutItem`
    +/// returns for the same outcome.
    +const CONDITION_FAILED_MESSAGE: &str = "The conditional request failed";
    +
    +/// Gzip-encode one page of records into `encoder` as newline-delimited
    +/// JSON, one record per line. The encoder holds the gzip stream state
    +/// across pages; the caller finishes it once after the last page, so the
    +/// whole report is never held in memory.
    +pub(crate) fn encode_ndjson_page(
    +    encoder: &mut GzEncoder,
    +    records: &[Value],
    +) -> Result<(), std::io::Error> {
    +    for record in records {
    +        encoder.write_all(ordered_record_json(record).as_bytes())?;
    +        encoder.write_all(b"\n")?;
    +    }
    +    Ok(())
    +}
    +
    +/// The serialized length in bytes of one report record, the figure the
    +/// per-operation report byte cap counts. The newline terminator the encoder
    +/// appends is not included.
    +#[must_use]
    +pub(crate) fn record_bytes(record: &Value) -> u64 {
    +    u64::try_from(record.to_string().len()).unwrap_or(u64::MAX)
    +}
    +
    +/// The directory name each report kind renders under.
    +#[must_use]
    +pub(crate) fn kind_directory(kind: ReportKind) -> &'static str {
    +    match kind {
    +        ReportKind::Error => "errors",
    +        ReportKind::ConditionCheckFailure => "condition-check-failures",
    +    }
    +}
    +
    +/// The configured output prefix as it precedes `AWSDynamoDB/` in every
    +/// output key (measured on the service): an empty prefix
    +/// contributes nothing, so the key starts at `AWSDynamoDB/` with no
    +/// leading slash; a prefix ending in `/` is used as it is; any other
    +/// prefix gets one `/` appended. A trailing slash is never doubled.
    +fn prefix_segment(prefix: &str) -> String {
    +    if prefix.is_empty() || prefix.ends_with('/') {
    +        prefix.to_owned()
    +    } else {
    +        format!("{prefix}/")
    +    }
    +}
    +
    +/// The manifest's key: the operation directory itself, with no `-0` shard
    +/// suffix (measured on the service), and the same prefix rule as a report.
    +#[must_use]
    +pub(crate) fn manifest_key(prefix: &str, account: &str, operation_id: &str) -> String {
    +    let prefix = prefix_segment(prefix);
    +    format!("{prefix}AWSDynamoDB/BulkOperation/{account}:{operation_id}/manifest-files.json")
    +}
    +
    +/// Processing status of one input file as the manifest names it. Four
    +/// values, each measured on the service: the two a cancel shows, the one
    +/// written for an input object rejected before any read, and the one
    +/// written for an object whose read ended abnormally.
    +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
    +pub(crate) enum FileProcessingStatus {
    +    /// Read to its end with no abandon.
    +    FullyProcessed,
    +    /// Abandoned at a parse-level fault, or cut short by a cancel or a
    +    /// failure.
    +    PartiallyProcessed,
    +    /// Never opened: the operation ended before it reached this file.
    +    NotProcessed,
    +    /// The read ended abnormally because the object itself was gone or
    +    /// unreadable; no processed range is reported.
    +    NotAvailable,
    +}
    +
    +/// One manifest line (measured on the service): the file's S3 ARN, its status,
    +/// the processed row index ranges (`[[0, last]]`, or `[]` when no row was
    +/// counted), and the file's entity tag.
    +#[must_use]
    +pub(crate) fn manifest_line(
    +    bucket: &str,
    +    path: &str,
    +    status: FileProcessingStatus,
    +    last_counted_row: Option,
    +    etag: &str,
    +) -> Value {
    +    let ranges: Vec = match last_counted_row {
    +        Some(last) => vec![json!([0, last])],
    +        None => Vec::new(),
    +    };
    +    json!({
    +        "inputSourceArn": format!("arn:aws:s3:::{bucket}/{path}"),
    +        "fileProcessingStatus": match status {
    +            FileProcessingStatus::FullyProcessed => "FULLY_PROCESSED",
    +            FileProcessingStatus::PartiallyProcessed => "PARTIALLY_PROCESSED",
    +            FileProcessingStatus::NotProcessed => "NOT_PROCESSED",
    +            FileProcessingStatus::NotAvailable => "NOT_AVAILABLE",
    +        },
    +        "processedRanges": ranges,
    +        "etag": etag,
    +    })
    +}
    +
    +/// The object key of report object `index` of one kind, spelled as the
    +/// hosted service spells it, with the prefix rule of `prefix_segment`. The
    +/// first object is `output-00000.json.gz`; the writer rolls to
    +/// `output-00001.json.gz` and onward once the compressed bytes of an object
    +/// reach 200 MiB.
    +#[must_use]
    +pub(crate) fn report_key(
    +    prefix: &str,
    +    account: &str,
    +    operation_id: &str,
    +    kind: ReportKind,
    +    index: u32,
    +) -> String {
    +    let kind_dir = kind_directory(kind);
    +    let prefix = prefix_segment(prefix);
    +    format!(
    +        "{prefix}AWSDynamoDB/BulkOperation/{account}:{operation_id}-0/output/{kind_dir}/output-{index:05}.json.gz"
    +    )
    +}
    +
    +/// One error-report record, for a parse-level or item-level fault.
    +///
    +/// `item_index` is absent on the parse-level abandon record and present on
    +/// item-level records. `new_image` presence follows the fault class and the
    +/// runner decides it: measured present on missing-key faults, absent on
    +/// type-mismatch, oversized, and parse-level records.
    +#[must_use]
    +pub(crate) fn error_record(
    +    bucket: &str,
    +    path: &str,
    +    item_index: Option,
    +    message: &str,
    +    new_image: Option<&Item>,
    +) -> Value {
    +    let mut record = json!({
    +        "itemLocation": item_location(bucket, path, item_index),
    +        "errorMessages": [message],
    +    });
    +    if let Some(item) = new_image {
    +        record["newImage"] = wrapped_item(item);
    +    }
    +    record
    +}
    +
    +/// One condition-failure record: the fixed message, the submitted item as
    +/// `newImage`, and, when the stored item could be read, that item as
    +/// `oldImage`.
    +#[must_use]
    +pub(crate) fn condition_record(
    +    bucket: &str,
    +    path: &str,
    +    item_index: Option,
    +    new_image: &Item,
    +    old_image: Option<&Item>,
    +) -> Value {
    +    let mut record = json!({
    +        "itemLocation": item_location(bucket, path, item_index),
    +        "errorMessages": [CONDITION_FAILED_MESSAGE],
    +        "newImage": wrapped_item(new_image),
    +    });
    +    if let Some(item) = old_image {
    +        record["oldImage"] = wrapped_item(item);
    +    }
    +    record
    +}
    +
    +/// `itemLocation` names the source row: the input file spelled as an S3
    +/// object ARN over the bucket-relative path, plus the zero-based line index
    +/// when the fault is attributable to one line.
    +fn item_location(bucket: &str, path: &str, item_index: Option) -> Value {
    +    let mut location = json!({
    +        "inputSourceArn": format!("arn:aws:s3:::{bucket}/{path}"),
    +    });
    +    if let Some(index) = item_index {
    +        location["itemIndex"] = json!(index);
    +    }
    +    location
    +}
    +
    +/// `newImage` and `oldImage` wrap the item in `{"Item": ...}` (measured).
    +fn wrapped_item(item: &Item) -> Value {
    +    // A map with string keys cannot fail to serialize; Null appears only if
    +    // that invariant broke, and the record stays well-formed either way.
    +    let value = serde_json::to_value(item).unwrap_or(Value::Null);
    +    json!({"Item": value})
    +}
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::*;
    +    use extenddb_core::types::AttributeValue;
    +    use flate2::Compression;
    +
    +    const ACCOUNT: &str = "111122223333";
    +    const OP_ID: &str = "01789060589767-da9ddd4f";
    +
    +    fn item(key: &str, value: &str) -> Item {
    +        let mut item = Item::new();
    +        item.insert(key.to_owned(), AttributeValue::S(value.to_owned()));
    +        item
    +    }
    +
    +    #[test]
    +    fn report_key_without_a_trailing_slash_inserts_one_separator() {
    +        let key = report_key("loads/reports", ACCOUNT, OP_ID, ReportKind::Error, 0);
    +        assert_eq!(
    +            key,
    +            format!(
    +                "loads/reports/AWSDynamoDB/BulkOperation/{ACCOUNT}:{OP_ID}-0/output/errors/output-00000.json.gz"
    +            )
    +        );
    +    }
    +
    +    #[test]
    +    fn report_key_with_a_trailing_slash_keeps_one_separator() {
    +        // Measured on the service: `out-d3/` yields `out-d3/AWSDynamoDB`,
    +        // not a doubled slash.
    +        let key = report_key(
    +            "loads/reports/",
    +            ACCOUNT,
    +            OP_ID,
    +            ReportKind::ConditionCheckFailure,
    +            0,
    +        );
    +        assert_eq!(
    +            key,
    +            format!(
    +                "loads/reports/AWSDynamoDB/BulkOperation/{ACCOUNT}:{OP_ID}-0/output/condition-check-failures/output-00000.json.gz"
    +            )
    +        );
    +    }
    +
    +    #[test]
    +    fn later_report_objects_are_numbered_with_five_digits() {
    +        // The second object of a kind is `output-00001.json.gz`.
    +        assert_eq!(
    +            report_key("out", ACCOUNT, OP_ID, ReportKind::ConditionCheckFailure, 1),
    +            format!(
    +                "out/AWSDynamoDB/BulkOperation/{ACCOUNT}:{OP_ID}-0/output/condition-check-failures/output-00001.json.gz"
    +            )
    +        );
    +    }
    +
    +    #[test]
    +    fn keys_without_a_prefix_start_at_the_service_directory() {
    +        // Measured on the service: no output prefix puts both objects
    +        // at the bucket root with no leading slash.
    +        assert_eq!(
    +            report_key("", ACCOUNT, OP_ID, ReportKind::Error, 0),
    +            format!(
    +                "AWSDynamoDB/BulkOperation/{ACCOUNT}:{OP_ID}-0/output/errors/output-00000.json.gz"
    +            )
    +        );
    +        assert_eq!(
    +            manifest_key("", ACCOUNT, OP_ID),
    +            format!("AWSDynamoDB/BulkOperation/{ACCOUNT}:{OP_ID}/manifest-files.json")
    +        );
    +        assert_eq!(
    +            manifest_key("run/", ACCOUNT, OP_ID),
    +            format!("run/AWSDynamoDB/BulkOperation/{ACCOUNT}:{OP_ID}/manifest-files.json")
    +        );
    +    }
    +
    +    #[test]
    +    fn encode_ndjson_page_round_trips_across_pages() {
    +        let first = vec![serde_json::json!({"errorMessages": ["a"]})];
    +        let second = vec![serde_json::json!({"errorMessages": ["b"], "n": 1})];
    +        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
    +        encode_ndjson_page(&mut encoder, &first).unwrap();
    +        encode_ndjson_page(&mut encoder, &second).unwrap();
    +        let compressed = encoder.finish().unwrap();
    +
    +        let mut decoder = flate2::read::GzDecoder::new(compressed.as_slice());
    +        let mut plain = String::new();
    +        std::io::Read::read_to_string(&mut decoder, &mut plain).unwrap();
    +        let parsed: Vec = plain
    +            .lines()
    +            .map(|line| serde_json::from_str(line).unwrap())
    +            .collect();
    +        assert_eq!(parsed, [first, second].concat());
    +        assert_eq!(plain.matches('\n').count(), 2, "one terminator per record");
    +    }
    +
    +    #[test]
    +    fn record_bytes_is_the_serialized_length() {
    +        // `{"a":1}` serializes to seven bytes.
    +        assert_eq!(record_bytes(&serde_json::json!({"a": 1})), 7);
    +        // `{}` serializes to two.
    +        assert_eq!(record_bytes(&serde_json::json!({})), 2);
    +    }
    +
    +    #[test]
    +    fn error_record_carries_index_and_image_only_when_given() {
    +        let full = error_record(
    +            "loads",
    +            "orders/part-000.json",
    +            Some(2),
    +            "One or more parameter values were invalid: Missing the key sk in the item",
    +            Some(&item("pk", "a")),
    +        );
    +        assert_eq!(
    +            full["itemLocation"]["inputSourceArn"],
    +            "arn:aws:s3:::loads/orders/part-000.json"
    +        );
    +        assert_eq!(full["itemLocation"]["itemIndex"], 2);
    +        assert_eq!(
    +            full["errorMessages"],
    +            serde_json::json!([
    +                "One or more parameter values were invalid: Missing the key sk in the item"
    +            ])
    +        );
    +        assert_eq!(full["newImage"]["Item"]["pk"]["S"], "a");
    +        assert!(full.get("oldImage").is_none());
    +
    +        let abandon = error_record("loads", "orders/part-000.json", None, "m", None);
    +        assert!(abandon["itemLocation"].get("itemIndex").is_none());
    +        assert!(abandon.get("newImage").is_none());
    +    }
    +
    +    #[test]
    +    fn condition_record_carries_the_fixed_message_and_both_images() {
    +        let record = condition_record(
    +            "loads",
    +            "part-000.json",
    +            Some(5),
    +            &item("pk", "new"),
    +            Some(&item("pk", "old")),
    +        );
    +        assert_eq!(
    +            record["errorMessages"],
    +            serde_json::json!(["The conditional request failed"])
    +        );
    +        assert_eq!(record["newImage"]["Item"]["pk"]["S"], "new");
    +        assert_eq!(record["oldImage"]["Item"]["pk"]["S"], "old");
    +        assert_eq!(record["itemLocation"]["itemIndex"], 5);
    +
    +        let missing_old = condition_record("loads", "p.json", Some(0), &item("pk", "new"), None);
    +        assert!(missing_old.get("oldImage").is_none());
    +    }
    +
    +    #[test]
    +    fn an_error_record_serializes_in_service_member_order() {
    +        let record = error_record(
    +            "loads",
    +            "part-000.json",
    +            Some(2),
    +            "boom",
    +            Some(&item("pk", "a")),
    +        );
    +        assert_eq!(
    +            ordered_record_json(&record),
    +            r#"{"itemLocation":{"inputSourceArn":"arn:aws:s3:::loads/part-000.json","itemIndex":2},"errorMessages":["boom"],"newImage":{"Item":{"pk":{"S":"a"}}}}"#
    +        );
    +    }
    +
    +    #[test]
    +    fn a_condition_record_serializes_in_service_member_order() {
    +        let record = condition_record(
    +            "loads",
    +            "part-000.json",
    +            Some(5),
    +            &item("pk", "new"),
    +            Some(&item("pk", "old")),
    +        );
    +        assert_eq!(
    +            ordered_record_json(&record),
    +            r#"{"itemLocation":{"inputSourceArn":"arn:aws:s3:::loads/part-000.json","itemIndex":5},"errorMessages":["The conditional request failed"],"newImage":{"Item":{"pk":{"S":"new"}}},"oldImage":{"Item":{"pk":{"S":"old"}}}}"#
    +        );
    +    }
    +
    +    #[test]
    +    fn a_manifest_line_serializes_in_service_member_order() {
    +        let line = manifest_line(
    +            "loads",
    +            "orders/a.json",
    +            FileProcessingStatus::FullyProcessed,
    +            Some(5),
    +            "etag123",
    +        );
    +        let mut buffer: Vec = Vec::new();
    +        write_manifest_line(&mut buffer, &line).unwrap();
    +        assert_eq!(
    +            String::from_utf8(buffer).unwrap(),
    +            r#"{"inputSourceArn":"arn:aws:s3:::loads/orders/a.json","fileProcessingStatus":"FULLY_PROCESSED","processedRanges":[[0,5]],"etag":"etag123"}"#
    +        );
    +    }
    +
    +    #[test]
    +    fn encode_ndjson_page_emits_records_in_service_member_order() {
    +        let record = error_record("loads", "p.json", Some(1), "boom", Some(&item("pk", "a")));
    +        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
    +        encode_ndjson_page(&mut encoder, std::slice::from_ref(&record)).unwrap();
    +        let compressed = encoder.finish().unwrap();
    +        let mut decoder = flate2::read::GzDecoder::new(compressed.as_slice());
    +        let mut plain = String::new();
    +        std::io::Read::read_to_string(&mut decoder, &mut plain).unwrap();
    +        assert_eq!(
    +            plain,
    +            "{\"itemLocation\":{\"inputSourceArn\":\"arn:aws:s3:::loads/p.json\",\"itemIndex\":1},\"errorMessages\":[\"boom\"],\"newImage\":{\"Item\":{\"pk\":{\"S\":\"a\"}}}}\n"
    +        );
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/rows/expression.rs b/crates/engine/src/bulk_put/rows/expression.rs
    new file mode 100644
    index 000000000..de7b5aa3f
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/rows/expression.rs
    @@ -0,0 +1,198 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! One record's `ExpressionAttributeValues`: the per-entry decode and the
    +//! texts a failed entry reports.
    +//!
    +//! The map is held as raw JSON while the line parses and each entry is
    +//! decoded here, so a fault in one entry fails its row alone instead of
    +//! abandoning the file (measured on the hosted service). The record text
    +//! wraps the single-item text for the same
    +//! fault:
    +//!
    +//! ```text
    +//! ExpressionAttributeValues contains invalid value:  for key 
    +//! ```
    +//!
    +//! The inner texts come from the code the single-item path uses: the
    +//! `AttributeValue` deserializer for a value rule and for an unknown type
    +//! tag, and the shared number validator for a number that does not
    +//! convert, which the deserializer stores raw and leaves to this layer.
    +
    +use std::collections::HashMap;
    +
    +use extenddb_core::types::AttributeValue;
    +use extenddb_core::validation::number::validate_and_normalize_number;
    +use serde_json::{Map, Value};
    +
    +use super::{RowError, TYPE_TAG_MESSAGE, VALUE_RULE_PREFIX};
    +
    +/// The prefix of the deserializer messages for a value that carries no type
    +/// tag or more than one. The single-item path reports the first of them
    +/// under the same wrapper this module builds, so both are passed through as
    +/// the inner text (neither case is measured for a record's expression map).
    +const SUPPLIED_VALUE_PREFIX: &str = "Supplied AttributeValue";
    +
    +/// The serialized JSON length of the values map in bytes, counted without
    +/// holding the bytes: the map re-serialized without whitespace. The bound
    +/// is on the whole map, not on one value (measured on the service: two
    +/// values of 1,100,000 bytes are refused together), and it counts the JSON
    +/// text of each value, so a binary value counts its base64 text.
    +/// The edge sits at 2 MiB of this length.
    +pub(super) fn values_serialized_len(values: &Map) -> usize {
    +    serialized_len(values)
    +}
    +
    +/// The bytes the names map adds to the combined bound on both expression
    +/// maps: the placeholder and the attribute name of each entry, without
    +/// JSON punctuation. Measured on the service beside a `{"#v":"v"}` map: a
    +/// values map of 2,097,152 bytes fails the row with the combined-size text
    +///  and one of 2,097,148 bytes fails it with the expression-size
    +/// text instead, so the map adds between 1 and 4 bytes; beside a
    +/// `{"#vvvvvvvvv":"v"}` map a values map of 2,097,140 bytes is likewise not
    +/// the combined class, so that map adds at most 12. The raw
    +/// bytes (3 and 11) fit both; the compact JSON text (10 and 18) fits
    +/// neither.
    +pub(super) fn names_combined_len(names: &HashMap) -> usize {
    +    names
    +        .iter()
    +        .map(|(key, value)| key.len() + value.len())
    +        .sum()
    +}
    +
    +/// The bytes of the values themselves, as the expression-size bound counts
    +/// them. Measured on the service with the 35-byte condition
    +/// `attribute_not_exists(pk) OR #v = :v` at the 1 MiB edge: an `S` value
    +/// counts its bytes; a `B` value counts its decoded bytes, not its base64
    +/// text (text of 1,048,544 characters for 786,408 raw bytes was evaluated;
    +/// 1,048,541 raw bytes was refused); an `M` counts each attribute name as
    +/// well as its value (a one-byte key is the byte that reaches the edge);
    +/// the members of an `L` and of an `SS` sum. Type
    +/// tags and JSON punctuation never count. `N`, `NS` and `BS` were not
    +/// measured at the edge (a number cannot reach it) and follow the same
    +/// rule: digits as text, binary members as decoded bytes. The values are
    +/// decoded before the bound is judged, so a binary text that does
    +/// not decode has already failed the row by then and counts nothing here.
    +pub(super) fn values_content_len(values: &Map) -> usize {
    +    use base64::Engine as _;
    +    fn decoded_len(text: &str) -> usize {
    +        base64::engine::general_purpose::STANDARD
    +            .decode(text)
    +            .map_or(0, |bytes| bytes.len())
    +    }
    +    fn typed(value: &Value) -> usize {
    +        let Some(map) = value.as_object() else {
    +            return 0;
    +        };
    +        let Some((tag, inner)) = map.iter().next() else {
    +            return 0;
    +        };
    +        match (tag.as_str(), inner) {
    +            ("S" | "N", Value::String(s)) => s.len(),
    +            ("B", Value::String(s)) => decoded_len(s),
    +            ("SS" | "NS", Value::Array(items)) => {
    +                items.iter().filter_map(Value::as_str).map(str::len).sum()
    +            }
    +            ("BS", Value::Array(items)) => items
    +                .iter()
    +                .filter_map(Value::as_str)
    +                .map(decoded_len)
    +                .sum(),
    +            ("L", Value::Array(items)) => items.iter().map(typed).sum(),
    +            ("M", Value::Object(entries)) => entries
    +                .iter()
    +                .map(|(name, value)| name.len() + typed(value))
    +                .sum(),
    +            _ => 0,
    +        }
    +    }
    +    values.values().map(typed).sum()
    +}
    +
    +fn serialized_len(value: &T) -> usize {
    +    struct Counter(usize);
    +    impl std::io::Write for Counter {
    +        fn write(&mut self, buf: &[u8]) -> std::io::Result {
    +            self.0 += buf.len();
    +            Ok(buf.len())
    +        }
    +        fn flush(&mut self) -> std::io::Result<()> {
    +            Ok(())
    +        }
    +    }
    +    let mut counter = Counter(0);
    +    // The writer cannot fail and a map of JSON values always serializes.
    +    if serde_json::to_writer(&mut counter, value).is_err() {
    +        return 0;
    +    }
    +    counter.0
    +}
    +
    +/// Decode every entry of `ExpressionAttributeValues`.
    +///
    +/// Entries are visited in key order (the raw map is sorted), so a record
    +/// with two bad entries reports the lower key, as the single-item number
    +/// check does for the same map.
    +///
    +/// # Errors
    +///
    +/// Returns [`RowError::Item`] naming the first entry that fails, with the
    +/// wrapped text; the file continues at the next row.
    +pub(super) fn decode_values(
    +    raw: Map,
    +) -> Result, RowError> {
    +    let mut decoded = HashMap::with_capacity(raw.len());
    +    for (key, value) in raw {
    +        let value = decode_value(&key, value)?;
    +        decoded.insert(key, value);
    +    }
    +    Ok(decoded)
    +}
    +
    +/// Decode one entry: the deserializer first, then the number conversion it
    +/// defers.
    +fn decode_value(key: &str, value: Value) -> Result {
    +    let decoded: AttributeValue = serde_json::from_value(value).map_err(|error| {
    +        let message = error.to_string();
    +        if message.starts_with(VALUE_RULE_PREFIX) || message.starts_with(SUPPLIED_VALUE_PREFIX) {
    +            fault(key, &message)
    +        } else {
    +            // Every other deserializer message is a value whose type tag
    +            // the service does not know; its text names the tags it takes
    +            // (measured).
    +            fault(key, TYPE_TAG_MESSAGE)
    +        }
    +    })?;
    +    check_numbers(&decoded).map_err(|message| fault(key, &message))?;
    +    Ok(decoded)
    +}
    +
    +/// The record text for a failed entry: the single-item text for the fault,
    +/// wrapped in the map's own message, naming the entry's key.
    +fn fault(key: &str, inner: &str) -> RowError {
    +    RowError::Item(format!(
    +        "ExpressionAttributeValues contains invalid value: {inner} for key {key}"
    +    ))
    +}
    +
    +/// Whether every number in a decoded value converts, through the shared
    +/// number validator so the text is the one the single-item path answers
    +/// (`The parameter cannot be converted to a numeric value: `,
    +/// measured for a top-level `N`). The walk reaches `NS` members
    +/// and numbers inside `L` and `M`, where the validator the single-item path
    +/// runs reaches them; those positions are unmeasured for a record.
    +fn check_numbers(value: &AttributeValue) -> Result<(), String> {
    +    match value {
    +        AttributeValue::N(number) => convert(number),
    +        AttributeValue::NS(set) => set.iter().try_for_each(|number| convert(number)),
    +        AttributeValue::L(list) => list.iter().try_for_each(check_numbers),
    +        AttributeValue::M(map) => map.values().try_for_each(check_numbers),
    +        _ => Ok(()),
    +    }
    +}
    +
    +fn convert(number: &str) -> Result<(), String> {
    +    validate_and_normalize_number(number)
    +        .map(|_| ())
    +        .map_err(|error| error.to_string())
    +}
    diff --git a/crates/engine/src/bulk_put/rows/messages.rs b/crates/engine/src/bulk_put/rows/messages.rs
    new file mode 100644
    index 000000000..7bbdc9e70
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/rows/messages.rs
    @@ -0,0 +1,205 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The report messages the row parser emits and the bounds behind them,
    +//! each measured on the service.
    +/// The report message for a decoded attribute value with an unknown type
    +/// tag (measured), and for the parse-level causes with no message of their
    +/// own: a value with no type tag, a line over the byte cap (ExtendDB's own
    +/// limit), a condition field of the wrong JSON type. A parse-level error
    +/// abandons the remainder of the file, so every parse-level message says
    +/// so. Text that does not parse as JSON carries [`CORRUPT_STREAM_MESSAGE`]
    +/// instead, and a value with more than one tag carries
    +/// [`REPEATED_MEMBER_MESSAGE`].
    +pub(crate) const PARSE_ABANDON_MESSAGE: &str = "Expected DynamoDB type to be one of [S, N, B, BOOL, NULL, L, M, SS, NS, BS]. Remainder of the file will not be processed.";
    +
    +/// The same text without the abandon suffix: an unknown type tag inside an
    +/// `ExpressionAttributeValues` entry fails the row alone, so the entry's
    +/// message carries the bare text (measured on the service).
    +/// [`PARSE_ABANDON_MESSAGE`] is this text plus the suffix, pinned by a
    +/// test.
    +pub(crate) const TYPE_TAG_MESSAGE: &str =
    +    "Expected DynamoDB type to be one of [S, N, B, BOOL, NULL, L, M, SS, NS, BS]";
    +
    +/// The report message for a binary value that is not base64 (measured on
    +/// the service). The value did not decode, so the file is abandoned, with
    +/// this message rather than the type-tag one.
    +pub(crate) const BINARY_NOT_BASE64_MESSAGE: &str = "Expected a binary value in base64-encoded format. Remainder of the file will not be processed.";
    +
    +/// The report message for a line whose bytes are not valid UTF-8 (measured
    +/// on the service). The file is abandoned at it.
    +pub(crate) const INVALID_UTF8_MESSAGE: &str = "Expected a Unicode sequence with UTF-8 binary encoding. Remainder of the file will not be processed.";
    +
    +/// The report message for bytes the tokenizer cannot read as JSON: a line
    +/// that is not JSON text (plain words, a truncated object, invalid bytes
    +/// between tokens), a compressed input stream that is corrupt or ends
    +/// before its trailer, or gzip bytes read as text (all measured on the
    +/// service). The file is abandoned at it.
    +pub(crate) const CORRUPT_STREAM_MESSAGE: &str =
    +    "Unexpected token. Remainder of the file will not be processed.";
    +
    +/// The report message for a file that is not a compressed object when
    +/// `InputCompressionType` is `GZIP` (measured on the service). The file counts
    +/// one processed and failed row and the operation ends `FAILED`.
    +pub(crate) const NOT_COMPRESSED_MESSAGE: &str = "The S3 object could not be decompressed";
    +
    +/// The report message for an input object overwritten after the operation
    +/// listed it (measured on the service). The file counts one processed and
    +/// failed row and the operation ends `FAILED`.
    +pub(crate) const OVERWRITTEN_MESSAGE: &str =
    +    "The S3 object could not be imported because it was overwritten";
    +
    +/// The report message for a line whose object has no `Item` (measured).
    +pub(crate) const ITEM_MISSING_MESSAGE: &str =
    +    "Expected 'Item' top level container. Remainder of the file will not be processed.";
    +
    +/// The report message for a line whose object carries a top-level key other
    +/// than the four the record shape allows (measured on the service with an
    +/// extra key beside `Item` and with `Item` misspelled; the check runs
    +/// before the missing-`Item` one). The file is
    +/// abandoned at it.
    +pub(crate) const UNKNOWN_FIELD_MESSAGE: &str = "Unexpected top-level field. Expected one of [Item, ConditionExpression, ExpressionAttributeNames, ExpressionAttributeValues]. Remainder of the file will not be processed.";
    +
    +/// The report message for an attribute value nested deeper than
    +/// [`MAX_PARSE_NESTING_DEPTH`] levels (measured on the service at 64 and 65
    +/// levels). The file is abandoned at it. Between the item nesting limit and
    +/// this bound the row fails alone with the short message the runner writes.
    +pub(crate) const NESTING_ABANDON_MESSAGE: &str = "Nesting Levels have exceeded supported limits: Attributes in the item have nested levels beyond supported limit. Remainder of the file will not be processed.";
    +
    +/// Levels of map or list nesting in one attribute value at or beyond which
    +/// the file is abandoned (measured on the service: 63 levels is an item-level
    +/// fault, 64 abandons).
    +pub(super) const MAX_PARSE_NESTING_DEPTH: usize = 64;
    +
    +/// [`MAX_PARSE_NESTING_DEPTH`] as a bracket depth of the raw line: the
    +/// record object and the attribute map open two brackets, every map or list
    +/// level opens two more (`{"M":{` or `{"L":[`), and the leaf typed value
    +/// opens one. A line at or beyond this depth is abandoned before parsing.
    +pub(super) const MAX_PARSE_BRACKET_DEPTH: usize = 2 * MAX_PARSE_NESTING_DEPTH + 3;
    +
    +/// The four top-level keys a record may carry.
    +pub(super) const RECORD_KEYS: [&str; 4] = [
    +    "Item",
    +    "ConditionExpression",
    +    "ExpressionAttributeNames",
    +    "ExpressionAttributeValues",
    +];
    +
    +/// The report message for an `Item` that is not a JSON object (measured).
    +pub(crate) const ITEM_NOT_OBJECT_MESSAGE: &str =
    +    "Expected character {. Remainder of the file will not be processed.";
    +
    +/// The report message for an `N` value inside `Item` whose text is not a
    +/// number (measured on the service on a sort key). The value did not
    +/// decode, so the file is abandoned at the row with no item index. The same
    +/// text inside `ExpressionAttributeValues` fails the row alone.
    +pub(crate) const INVALID_NUMERIC_MESSAGE: &str =
    +    "Expected a valid numeric. Remainder of the file will not be processed.";
    +
    +/// The report message for a member given twice where one was expected: a
    +/// record carrying a top-level member twice, even with equal values, and a
    +/// typed value carrying more than one tag, whether the tag is repeated or
    +/// the tags differ (measured on the service). The
    +/// file is abandoned at the row.
    +pub(crate) const REPEATED_MEMBER_MESSAGE: &str =
    +    "Expected character }. Remainder of the file will not be processed.";
    +
    +/// The report message for a set tag whose value is not a JSON array
    +/// (measured on the service on `SS`). The file is abandoned at the
    +/// row.
    +pub(crate) const EXPECTED_ARRAY_MESSAGE: &str =
    +    "Expected character [. Remainder of the file will not be processed.";
    +
    +/// The `PutItem` message for `ExpressionAttributeNames` supplied with no
    +/// expression to reference them. It is the message
    +/// `expression::validate_expression_param_usage` produces for the names
    +/// case, which carries no suffix.
    +pub(crate) const NAMES_WITHOUT_EXPRESSION_MESSAGE: &str =
    +    "ExpressionAttributeNames can only be specified when using expressions";
    +
    +/// The `PutItem` message for `ExpressionAttributeValues` supplied with no
    +/// expression to reference them. It is the message
    +/// `expression::validate_expression_param_usage` produces for the values
    +/// case with `PutItem`'s single value-capable parameter,
    +/// `ConditionExpression`.
    +pub(crate) const VALUES_WITHOUT_EXPRESSION_MESSAGE: &str = "ExpressionAttributeValues can only be specified when using expressions: ConditionExpression is null";
    +
    +/// The record message for an empty `ExpressionAttributeNames` map supplied
    +/// with a `ConditionExpression` (measured on the service). The row
    +/// fails alone and the file continues. It is the text the single-item
    +/// deserializer produces for the same map.
    +pub(crate) const NAMES_EMPTY_MESSAGE: &str = "ExpressionAttributeNames must not be empty";
    +
    +/// The record message for an empty `ExpressionAttributeValues` map supplied
    +/// with a `ConditionExpression` (measured on the service).
    +pub(crate) const VALUES_EMPTY_MESSAGE: &str = "ExpressionAttributeValues must not be empty";
    +
    +/// The report message for an `ExpressionAttributeValues` map over
    +/// [`MAX_EXPRESSION_VALUES_BYTES`] (measured on the service, and
    +/// ). The file is abandoned at the row, which
    +/// counts one failed row and carries no item index; the rows before it are
    +/// written. The row measured sat under the line cap, so the line-length
    +/// check does not reach this case.
    +pub(crate) const VALUES_TOO_LARGE_MESSAGE: &str =
    +    "ExpressionAttributeValues exceeds max size. Remainder of the file will not be processed.";
    +
    +/// Serialized bytes of an `ExpressionAttributeValues` map at or below which
    +/// the map is not an abandon. Measured on the service: a map whose compact JSON
    +/// text is 2,097,152 bytes is not abandoned and one of 2,097,153
    +/// bytes is; the bound is on the whole map (two
    +/// values of 1,100,000 bytes abandon together) and counts each value's JSON
    +/// text, so a binary value counts its base64 text and a nested
    +/// value counts its map.
    +pub(crate) const MAX_EXPRESSION_VALUES_BYTES: usize = 2 * 1024 * 1024;
    +
    +/// The report message for a row whose `ExpressionAttributeNames` and
    +/// `ExpressionAttributeValues` together pass [`MAX_EXPRESSION_MAPS_BYTES`]
    +/// while the values map alone is within its abandon bound (measured on the
    +/// service: a values map of exactly 2,097,152 bytes beside the names map
    +/// `{"#v": "v"}`). An item-level fault: the row fails with its
    +/// item index and the file goes on.
    +pub(crate) const MAPS_TOO_LARGE_MESSAGE: &str =
    +    "Combined size of ExpressionAttributeNames and ExpressionAttributeValues exceeds max size";
    +
    +/// The bound the two maps are measured against together: the values map's
    +/// compact JSON text plus, for the names map, the bytes of each placeholder
    +/// and attribute name without JSON punctuation. Measured on the service: beside
    +/// `{"#v": "v"}` (3 such bytes) a values map of 2,097,152 bytes fails the
    +/// row with [`MAPS_TOO_LARGE_MESSAGE`] while 2,097,148 does not
    +/// reach this class; beside `{"#vvvvvvvvv": "v"}` (11 bytes)
    +/// 2,097,140 does not reach it. The compact JSON text of the names map
    +/// (10 and 18 bytes) would put both of those over the bound, so the names
    +/// map counts its raw bytes.
    +pub(crate) const MAX_EXPRESSION_MAPS_BYTES: usize = 2 * 1024 * 1024;
    +
    +/// The report message for a row whose `ConditionExpression` text plus the
    +/// content bytes of its `ExpressionAttributeValues` reach
    +/// [`MAX_EXPRESSION_SIZE_BYTES`] while the two bounds above hold: the
    +/// single-item `PutItem` expression-size text without its parameter prefix
    +/// or byte count (measured on the service). An item-level fault: the row
    +/// fails with its item index and the file goes on.
    +pub(crate) const EXPRESSION_SIZE_MESSAGE: &str =
    +    "Expression size has exceeded the maximum allowed size;";
    +
    +/// Bytes of the condition expression text plus the content of its values
    +/// at which the row fails with [`EXPRESSION_SIZE_MESSAGE`]. Measured
    +/// by bisection with the 35-byte condition
    +/// `attribute_not_exists(pk) OR #v = :v`: a value of 1,048,540 bytes
    +/// (1,048,575 together) is evaluated; a value of 1,048,541 bytes
    +/// (1,048,576 together) fails the row. Twelve sizes from 4,000 to
    +/// 2,097,133 bytes fit the rule.
    +pub(crate) const MAX_EXPRESSION_SIZE_BYTES: usize = 1024 * 1024;
    +
    +/// The report message for an item whose attribute name or string value
    +/// contains U+0000. The hosted service stores the character; ExtendDB's
    +/// PostgreSQL backend cannot (`jsonb` refuses the byte, SQLSTATE 22021)
    +/// and the single-item write path answers it with an internal error
    +/// today. The bulk parser refuses the row ahead of the write so the fault
    +/// names the row and the load continues; the message is ExtendDB's own.
    +pub(crate) const NUL_IN_STRING_MESSAGE: &str = "One or more parameter values were invalid: Attribute names and string values may not contain U+0000";
    +
    +/// The prefix of every `AttributeValue` deserializer message that names a
    +/// value rule a decoded value failed (an empty set, a duplicate member, a
    +/// `NULL` that is not `true`); `PutItem` answers these `ValidationException`
    +/// with the same text. Any other deserializer message is a shape fault.
    +pub(super) const VALUE_RULE_PREFIX: &str = "One or more parameter values were invalid:";
    diff --git a/crates/engine/src/bulk_put/rows/mod.rs b/crates/engine/src/bulk_put/rows/mod.rs
    new file mode 100644
    index 000000000..9b43da2db
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/rows/mod.rs
    @@ -0,0 +1,573 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Input row parsing for bulk put operations.
    +//!
    +//! Each input file is newline-delimited JSON, one object per line, with a
    +//! required `Item` and optional `ConditionExpression`,
    +//! `ExpressionAttributeNames`, and `ExpressionAttributeValues`. Item
    +//! deserialization is the same `Item` (and through it `AttributeValue`)
    +//! `Deserialize` path that `import_export_io::read_dynamodb_json` relies
    +//! on, so a bad type tag is rejected identically in both.
    +//!
    +//! A row failure falls into one of two classes, which the runner treats
    +//! differently:
    +//!
    +//! - [`RowError::Parse`] is a parse-level fault: malformed or truncated
    +//!   JSON, a missing or non-object `Item`, an attribute value that fails
    +//!   to decode (an unknown type tag, a binary value that is not base64),
    +//!   a line over the byte cap, or a condition field of the wrong JSON
    +//!   type. It abandons the rest of the file, so its message says so.
    +//! - [`RowError::Item`] is an item-level fault the parser can see before
    +//!   the runner does: a decoded attribute value that fails validation (an
    +//!   empty set, a `NULL` that is not `true`), a string or attribute name
    +//!   containing U+0000, an `ExpressionAttributeValues` entry that fails to
    +//!   decode, or expression maps supplied without a `ConditionExpression`.
    +//!   It fails the row alone and the file continues, carrying the `PutItem`
    +//!   message for the same fault. A repeated set member is not a fault on
    +//!   this path: the set is stored deduplicated, and the item size counts
    +//!   the set as submitted, repeated members included (both measured on the
    +//!   service).
    +//!
    +//! The line between the two classes for attribute values: a value of the
    +//! wrong JSON shape (no type tag, more than one tag, an unknown tag, a set
    +//! that is not an array, an `N` that is not a number, a binary value that
    +//! is not base64) did not decode and is parse-level, several of them with
    +//! their own measured message; a value of the right shape that failed a
    +//! DynamoDB value rule (an empty set, measured on the service as item-level
    +//! with the `PutItem` text; a `NULL` that is not `true`) decoded and is
    +//! item-level. The second class is exactly the set of deserializer
    +//! messages that begin with the `PutItem` prefix [`VALUE_RULE_PREFIX`],
    +//! plus the repeated attribute name the deserializer cannot see.
    +//!
    +//! Item-level checks the parser cannot make (key presence and types, item
    +//! size, nesting depth, condition-expression syntax) stay the runner's,
    +//! through the `PutItem` validators; the condition strings are handed to
    +//! the `PutItem` parsing functions unexamined.
    +
    +mod expression;
    +mod messages;
    +mod scan;
    +
    +pub(crate) use messages::*;
    +
    +use std::borrow::Cow;
    +use std::collections::{HashMap, HashSet};
    +
    +use base64::Engine as _;
    +use extenddb_core::limits::LimitsConfig;
    +use extenddb_core::types::{AttributeValue, Item, attribute_value_size};
    +use extenddb_core::validation::number::validate_and_normalize_number;
    +use serde::Deserialize;
    +
    +/// The condition attached to one input row, carried verbatim for the
    +/// runner to hand to the `PutItem` parsing functions.
    +#[derive(Debug, Clone, PartialEq)]
    +pub(crate) struct RowCondition {
    +    pub expression: String,
    +    pub names: Option>,
    +    pub values: Option>,
    +}
    +
    +/// One parsed input row.
    +#[derive(Debug, Clone, PartialEq)]
    +pub(crate) struct Row {
    +    pub item: Item,
    +    pub condition: Option,
    +    /// The item size of the set members the parser removed as repeats. The
    +    /// item holds one copy of each member; the size check counts the set as
    +    /// submitted (see [`Row::item_limits`]).
    +    pub repeated_member_bytes: usize,
    +}
    +
    +impl Row {
    +    /// The limits the item checks run against. The item size counts a set
    +    /// as submitted, repeated members included, though the set is stored
    +    /// with one copy of each member (measured on the service: an `SS` of two
    +    /// identical 250,000-byte strings fails the row with the item size
    +    /// message). The item holds one copy, so the bytes of the removed copies
    +    /// come off the item size limit. That is the same comparison as adding
    +    /// them to the item's size, and it is made by the `PutItem` validator,
    +    /// so the size check keeps its place in the order of the item checks.
    +    pub(crate) fn item_limits<'a>(&self, limits: &'a LimitsConfig) -> Cow<'a, LimitsConfig> {
    +        if self.repeated_member_bytes == 0 {
    +            return Cow::Borrowed(limits);
    +        }
    +        Cow::Owned(LimitsConfig {
    +            max_item_size_bytes: limits
    +                .max_item_size_bytes
    +                .saturating_sub(self.repeated_member_bytes),
    +            ..limits.clone()
    +        })
    +    }
    +}
    +
    +/// A row failure, in one of two classes the runner treats differently.
    +///
    +/// - `Parse` abandons the remainder of the file; its string is
    +///   [`PARSE_ABANDON_MESSAGE`], or the dedicated message for a missing or
    +///   non-object `Item`.
    +/// - `Item` fails the row alone and the file continues; its string is the
    +///   `PutItem` message for the fault.
    +#[derive(Debug, Clone, PartialEq, Eq)]
    +pub(crate) enum RowError {
    +    Parse(String),
    +    Item(String),
    +}
    +
    +/// The wire shape of one input line. A top-level key outside the four is
    +/// refused before this shape is read (see [`UNKNOWN_FIELD_MESSAGE`]).
    +/// `Item` is held as a raw JSON value so a missing one and a non-object one
    +/// answer their own messages (measured); the attribute values are
    +/// deserialized in a second step, whose failure keeps the shared abandon
    +/// message. `ExpressionAttributeValues` is held as a raw JSON object for
    +/// the same reason: a fault in one of its entries fails the row alone
    +/// (measured on the service), so its entries are decoded
    +/// after the line parses, by [`expression::decode_values`]. A values field
    +/// that is not a JSON object still fails here.
    +#[derive(Deserialize)]
    +struct RawRow {
    +    #[serde(rename = "Item")]
    +    item: Option,
    +    #[serde(rename = "ConditionExpression")]
    +    condition_expression: Option,
    +    #[serde(rename = "ExpressionAttributeNames")]
    +    expression_attribute_names: Option>,
    +    #[serde(rename = "ExpressionAttributeValues")]
    +    expression_attribute_values: Option>,
    +}
    +
    +/// Parse one input line.
    +///
    +/// Returns `Ok(None)` for a blank or whitespace-only line, which is
    +/// skipped without touching any counter.
    +///
    +/// Returns [`RowError::Parse`] for every parse-level cause: a line longer
    +/// than `max_line_bytes`, JSON that does not parse or is truncated, a
    +/// missing or non-object `Item`, a record member given twice, an
    +/// `ExpressionAttributeValues` map over [`MAX_EXPRESSION_VALUES_BYTES`]
    +/// (each with its own measured message), an attribute value that fails to
    +/// decode (unknown type tag, more than one tag, an empty value, an `N`
    +/// that is not a number, a set tag that is not an array, a binary value
    +/// that is not base64), or a condition field of the wrong JSON type (a
    +/// non-string `ConditionExpression`, a non-object names or values map).
    +/// The tree has no `PutItem` message for a malformed expression map, only
    +/// for a missing or unreferenced one, so a wrong-type field is classified
    +/// parse-level.
    +///
    +/// An empty-string `ConditionExpression` is treated as absent, mirroring
    +/// `handle_put_item` (`is_some_and(|s| !s.is_empty())`): a bare empty
    +/// expression parses unconditional, and an empty expression alongside a
    +/// non-empty names or values map fails item-level exactly as a missing
    +/// one does.
    +///
    +/// Returns [`RowError::Item`] when an attribute value decoded and failed a
    +/// validation rule (an empty string, number or binary set; a duplicate set
    +/// member), carrying the `PutItem` message for it; when an attribute name
    +/// is repeated in the same attribute map (measured on the service); when
    +/// an attribute name
    +/// or string value contains U+0000 ([`NUL_IN_STRING_MESSAGE`]); when an
    +/// `ExpressionAttributeValues` entry fails to decode or carries a number
    +/// that does not convert, carrying that fault's single-item text wrapped in
    +/// the map's own message (measured on the service); and when
    +/// `ExpressionAttributeNames` or `ExpressionAttributeValues` is supplied
    +/// with no `ConditionExpression` that could reference them. The parser can
    +/// see these faults, and each fails the row alone rather than abandoning
    +/// the file. For the expression maps the message is the one
    +/// `validate_expression_param_usage` produces for the same case; names
    +/// wins when both are present, matching that function's order. An empty
    +/// map supplied WITH a `ConditionExpression` fails the row with
    +/// [`NAMES_EMPTY_MESSAGE`] or [`VALUES_EMPTY_MESSAGE`] (measured on the
    +/// service); an empty map with no expression is exempt,
    +/// matching `validate_expression_param_usage`. The two maps serializing
    +/// together over [`MAX_EXPRESSION_MAPS_BYTES`] fail the row with
    +/// [`MAPS_TOO_LARGE_MESSAGE`] (measured on the service, which also fixes
    +/// the names map's contribution).
    +///
    +/// A row missing a key attribute or exceeding the item size limit parses
    +/// here; those faults are item-level and are the runner's, through the
    +/// `PutItem` validators.
    +pub(crate) fn parse_row(line: &str, max_line_bytes: usize) -> Result, RowError> {
    +    if line.len() > max_line_bytes {
    +        return Err(parse_error());
    +    }
    +    let trimmed = line.trim();
    +    if trimmed.is_empty() {
    +        return Ok(None);
    +    }
    +    // An attribute value nested at or beyond the parse bound abandons the
    +    // file with the long nesting message (measured on the service: 63 levels is
    +    // an item-level fault, 64 abandons). The bound is checked on the raw
    +    // text before parsing: the JSON parser's own recursion limit (128) sits
    +    // below the bracket depth of 63 attribute levels, so parsing runs with
    +    // that limit lifted, and this scan is what bounds the recursion.
    +    if bracket_depth(trimmed.as_bytes()) >= MAX_PARSE_BRACKET_DEPTH {
    +        return Err(RowError::Parse(NESTING_ABANDON_MESSAGE.to_owned()));
    +    }
    +    // A line that is not JSON at all answers the tokenizer's message, not
    +    // the type-tag one (measured on the service with plain text and with a
    +    // truncated object; the September type-tag measurement was for an
    +    // unknown tag inside well-formed JSON). A complete object followed by
    +    // more text on the line is the same class.
    +    let mut deserializer = serde_json::Deserializer::from_str(trimmed);
    +    deserializer.disable_recursion_limit();
    +    // The line is read into a tree that keeps a repeated object member
    +    // rather than collapsing it, so the item's structure can be judged
    +    // before its values are decoded.
    +    let node = scan::Node::deserialize(&mut deserializer)
    +        .and_then(|node| deserializer.end().map(|()| node))
    +        .map_err(|_| RowError::Parse(CORRUPT_STREAM_MESSAGE.to_owned()))?;
    +    let value = node.to_value();
    +    // A line that parses as JSON but is not a record object, an array
    +    // wrapping a record among them, answers the tokenizer's message
    +    // (measured on the service, on the array form; the other
    +    // non-object forms were not measured and are read the same way).
    +    if !value.is_object() {
    +        return Err(RowError::Parse(CORRUPT_STREAM_MESSAGE.to_owned()));
    +    }
    +    // A top-level key outside the record shape abandons the file, and the
    +    // check runs before the missing-`Item` one: a record whose `Item` is
    +    // misspelled answers this message (measured on the service).
    +    if let Some(object) = value.as_object()
    +        && object
    +            .keys()
    +            .any(|key| !RECORD_KEYS.contains(&key.as_str()))
    +    {
    +        return Err(RowError::Parse(UNKNOWN_FIELD_MESSAGE.to_owned()));
    +    }
    +    // A top-level member given twice abandons the file (measured
    +    // ). Its precedence against the
    +    // unknown-field check was not measured; the unknown-field check keeps
    +    // the position its own measurement gave it.
    +    scan::check_record_members(&node)?;
    +    // Well-formed JSON of the wrong shape (a condition field of the wrong
    +    // JSON type) keeps the shared abandon message (ExtendDB's choice;
    +    // unmeasured).
    +    let raw: RawRow = serde_json::from_value(value).map_err(|_| parse_error())?;
    +    let Some(item_value) = raw.item else {
    +        return Err(RowError::Parse(ITEM_MISSING_MESSAGE.to_owned()));
    +    };
    +    if !item_value.is_object() {
    +        return Err(RowError::Parse(ITEM_NOT_OBJECT_MESSAGE.to_owned()));
    +    }
    +    // The size of the values map is judged before any value in the record
    +    // is decoded, and the file is abandoned at the row (measured
    +    // ). Its precedence against the item's own
    +    // parse-level faults was not measured; both abandon the file.
    +    let values_len = raw
    +        .expression_attribute_values
    +        .as_ref()
    +        .map_or(0, expression::values_serialized_len);
    +    if values_len > MAX_EXPRESSION_VALUES_BYTES {
    +        return Err(RowError::Parse(VALUES_TOO_LARGE_MESSAGE.to_owned()));
    +    }
    +    // The item's structure, judged before its values are decoded: the
    +    // faults here are the ones the shared deserializer reports with another
    +    // class or not at all.
    +    scan::check_item(&node)?;
    +    // The bulk path accepts a set with a repeated member and stores the set
    +    // deduplicated (measured on the service: `["a", "a"]` is written as `["a"]`,
    +    // processed and not failed), where `PutItem` refuses the same value.
    +    // The repeat is removed before the shared deserializer sees it, and its
    +    // bytes are kept for the item size check, which counts it (measured).
    +    let mut item_value = item_value;
    +    let repeated_member_bytes = dedupe_set_members(&mut item_value);
    +    // A value that decoded and failed a rule (an empty set, a `NULL` that is
    +    // not `true`) is the row's fault alone and carries the message `PutItem`
    +    // answers for it; a value that did not decode (no or two type tags, an
    +    // unknown tag, a binary value that is not base64) abandons the file,
    +    // with the base64 case carrying its own measured message.
    +    let item: Item = serde_json::from_value(item_value).map_err(|e| {
    +        let message = e.to_string();
    +        if message.starts_with(VALUE_RULE_PREFIX) {
    +            RowError::Item(message)
    +        } else if message.contains("base64") {
    +            RowError::Parse(BINARY_NOT_BASE64_MESSAGE.to_owned())
    +        } else {
    +            parse_error()
    +        }
    +    })?;
    +    if item_contains_nul(&item) {
    +        return Err(RowError::Item(NUL_IN_STRING_MESSAGE.to_owned()));
    +    }
    +    // An empty-string expression counts as absent, like in handle_put_item.
    +    let expression = raw
    +        .condition_expression
    +        .filter(|expression| !expression.is_empty());
    +    let Some(expression) = expression else {
    +        // No expression can reference the maps. Names wins when both are
    +        // present and empty maps are exempt, matching
    +        // validate_expression_param_usage.
    +        if raw
    +            .expression_attribute_names
    +            .is_some_and(|m| !m.is_empty())
    +        {
    +            return Err(RowError::Item(NAMES_WITHOUT_EXPRESSION_MESSAGE.to_owned()));
    +        }
    +        if raw
    +            .expression_attribute_values
    +            .is_some_and(|m| !m.is_empty())
    +        {
    +            return Err(RowError::Item(VALUES_WITHOUT_EXPRESSION_MESSAGE.to_owned()));
    +        }
    +        return Ok(Some(Row {
    +            item,
    +            condition: None,
    +            repeated_member_bytes,
    +        }));
    +    };
    +    // An expression is present, so an empty map is the row's fault rather
    +    // than a map with nothing to reference: the names case first, the order
    +    // the without-expression checks below use (measured on the service one map
    +    // at a time; a record carrying both empty is
    +    // unmeasured).
    +    if raw
    +        .expression_attribute_names
    +        .as_ref()
    +        .is_some_and(HashMap::is_empty)
    +    {
    +        return Err(RowError::Item(NAMES_EMPTY_MESSAGE.to_owned()));
    +    }
    +    if raw
    +        .expression_attribute_values
    +        .as_ref()
    +        .is_some_and(serde_json::Map::is_empty)
    +    {
    +        return Err(RowError::Item(VALUES_EMPTY_MESSAGE.to_owned()));
    +    }
    +    // The sizes the two bounds below judge are measured on the raw maps,
    +    // then the values are decoded, then the bounds are judged: a value that
    +    // fails its own rule fails the row with the wrapped text even when the
    +    // map is over the expression-size edge (measured on the service: a
    +    // NULL false beside a 1 MiB value answers the wrapped NULL text).
    +    // Decoding here rather than inside the line shape keeps a fault in one
    +    // entry the row's alone (measured on the service).
    +    let content_len = raw
    +        .expression_attribute_values
    +        .as_ref()
    +        .map_or(0, expression::values_content_len);
    +    let names_len = raw
    +        .expression_attribute_names
    +        .as_ref()
    +        .map_or(0, expression::names_combined_len);
    +    let decoded = match raw.expression_attribute_values {
    +        Some(values) => Some(expression::decode_values(values)?),
    +        None => None,
    +    };
    +    // Two item-level bounds sit under the abandon bound (measured on the
    +    // service). The two maps together, the names map counted by its raw
    +    // bytes, over 2 MiB fail the row with the combined-size text (the edge
    +    // is exact at 2,097,152); under that, the condition text plus the
    +    // content of its values reaching 1 MiB fails the row with the
    +    // single-item expression-size text. An empty names map and a bad value
    +    // are judged before them, an unused placeholder after (judged by the
    +    // runner's expression parsing).
    +    if values_len + names_len > MAX_EXPRESSION_MAPS_BYTES {
    +        return Err(RowError::Item(MAPS_TOO_LARGE_MESSAGE.to_owned()));
    +    }
    +    if expression.len() + content_len >= MAX_EXPRESSION_SIZE_BYTES {
    +        return Err(RowError::Item(EXPRESSION_SIZE_MESSAGE.to_owned()));
    +    }
    +    Ok(Some(Row {
    +        item,
    +        condition: Some(RowCondition {
    +            expression,
    +            names: raw.expression_attribute_names,
    +            values: decoded,
    +        }),
    +        repeated_member_bytes,
    +    }))
    +}
    +
    +fn parse_error() -> RowError {
    +    RowError::Parse(PARSE_ABANDON_MESSAGE.to_owned())
    +}
    +
    +/// The deepest bracket nesting of a JSON line, counting `{` and `[` outside
    +/// string literals. Brackets inside a string, and escaped quotes inside it,
    +/// are skipped. Unbalanced text saturates at zero and is left for the
    +/// parser to refuse.
    +fn bracket_depth(line: &[u8]) -> usize {
    +    let mut depth = 0usize;
    +    let mut deepest = 0usize;
    +    let mut in_string = false;
    +    let mut escaped = false;
    +    for &byte in line {
    +        if in_string {
    +            if escaped {
    +                escaped = false;
    +            } else if byte == b'\\' {
    +                escaped = true;
    +            } else if byte == b'"' {
    +                in_string = false;
    +            }
    +            continue;
    +        }
    +        match byte {
    +            b'"' => in_string = true,
    +            b'{' | b'[' => {
    +                depth += 1;
    +                deepest = deepest.max(depth);
    +            }
    +            b'}' | b']' => depth = depth.saturating_sub(1),
    +            _ => {}
    +        }
    +    }
    +    deepest
    +}
    +
    +/// Remove repeated members from every `SS`, `NS` and `BS` value in the
    +/// item's JSON, at any depth, keeping first occurrences in order, and
    +/// return the item size of the members removed. `SS` members are compared
    +/// as text; `BS` members as the bytes they decode to; `NS` members as
    +/// numbers, so numerically equal members written differently collapse to
    +/// one. The walk follows the item's structure (an attribute map holds typed
    +/// values; an `M` holds another attribute map; an `L` holds typed values)
    +/// rather than matching key names, so an attribute that is itself named
    +/// `SS`, `NS` or `BS` is treated as an attribute and its value is still
    +/// reached (measured on the service: the service stores a set under an
    +/// attribute named `SS` deduplicated, like any other set). Only a set array
    +/// is touched; anything malformed is left for the deserializer to judge.
    +fn dedupe_set_members(item: &mut serde_json::Value) -> usize {
    +    let serde_json::Value::Object(attributes) = item else {
    +        return 0;
    +    };
    +    attributes.values_mut().map(dedupe_typed_value).sum()
    +}
    +
    +/// One typed attribute value: a single-key object whose key is the type tag.
    +/// A removed member is sized the way `item_size_bytes` sizes the copy that
    +/// is kept: a string by its bytes, a binary value by its decoded bytes, a
    +/// number by the number size of its normalized form. A string or binary
    +/// set member of the wrong shape counts nothing: its first copy is kept,
    +/// and the deserializer refuses the set.
    +fn dedupe_typed_value(typed: &mut serde_json::Value) -> usize {
    +    let serde_json::Value::Object(map) = typed else {
    +        return 0;
    +    };
    +    if map.len() != 1 {
    +        return 0;
    +    }
    +    let Some((tag, inner)) = map.iter_mut().next() else {
    +        return 0;
    +    };
    +    match (tag.as_str(), inner) {
    +        // String set members are compared as text, the way the service
    +        // dedupes them (measured on the service).
    +        ("SS", serde_json::Value::Array(members)) => retain_first(
    +            members,
    +            |member| match member.as_str() {
    +                Some(text) => MemberKey::Text(text.to_owned()),
    +                None => MemberKey::Json(member.to_string()),
    +            },
    +            |key| match key {
    +                MemberKey::Text(text) => text.len(),
    +                MemberKey::Bytes(_) | MemberKey::Json(_) => 0,
    +            },
    +        ),
    +        // Binary set members are compared as the bytes they decode to, with
    +        // the decoder the deserializer uses. That decoder accepts one text
    +        // for each byte string, so this compares the texts; a repeated
    +        // member is stored once (measured on the service). A member that
    +        // does not decode is keyed by its text and left for the
    +        // deserializer to refuse.
    +        ("BS", serde_json::Value::Array(members)) => retain_first(
    +            members,
    +            |member| match member.as_str() {
    +                Some(text) => base64::engine::general_purpose::STANDARD
    +                    .decode(text)
    +                    .map_or_else(|_| MemberKey::Text(text.to_owned()), MemberKey::Bytes),
    +                None => MemberKey::Json(member.to_string()),
    +            },
    +            |key| match key {
    +                MemberKey::Bytes(bytes) => bytes.len(),
    +                MemberKey::Text(_) | MemberKey::Json(_) => 0,
    +            },
    +        ),
    +        // Number set members are compared as numbers, so members written
    +        // differently but numerically equal (for example "1" and "1.0")
    +        // count as one and the first is kept: the service stores
    +        // NS ["1", "1.0"] as NS ["1"] with the operation COMPLETED (measured
    +        // on the service), where comparing the members as text would leave
    +        // both and the deserializer would then refuse the row as a duplicate
    +        // set. The dedupe key is the normalization ExtendDB already applies
    +        // to a number on write, so it matches what the deserializer would
    +        // store; a member that does not normalize is keyed by its raw text,
    +        // and one that is not a string by its JSON text in a key space of
    +        // its own, so it is never taken for a repeat of a string member and
    +        // reaches the deserializer, which refuses it, wherever it stands.
    +        ("NS", serde_json::Value::Array(members)) => retain_first(
    +            members,
    +            |member| match member.as_str() {
    +                Some(s) => MemberKey::Text(
    +                    validate_and_normalize_number(s).unwrap_or_else(|_| s.to_owned()),
    +                ),
    +                None => MemberKey::Json(member.to_string()),
    +            },
    +            |key| match key {
    +                MemberKey::Text(number) => attribute_value_size(&AttributeValue::N(number.clone())),
    +                MemberKey::Bytes(_) | MemberKey::Json(_) => 0,
    +            },
    +        ),
    +        ("M", nested) => dedupe_set_members(nested),
    +        ("L", serde_json::Value::Array(elements)) => {
    +            elements.iter_mut().map(dedupe_typed_value).sum()
    +        }
    +        _ => 0,
    +    }
    +}
    +
    +/// What a set member is compared by when its set is deduplicated.
    +#[derive(PartialEq, Eq, Hash)]
    +enum MemberKey {
    +    /// The member's text: an `SS` member, the normalized number of an `NS`
    +    /// member, or a `BS` member that does not decode.
    +    Text(String),
    +    /// The bytes a `BS` member decodes to.
    +    Bytes(Vec),
    +    /// A member that is not a JSON string, by its JSON text.
    +    Json(String),
    +}
    +
    +/// Keep the first member of each key, in order, and return the sum of
    +/// `size` over the members removed. The keys seen are held in a hash set,
    +/// so the pass is linear in the number of members.
    +fn retain_first(
    +    members: &mut Vec,
    +    key: impl Fn(&serde_json::Value) -> MemberKey,
    +    size: impl Fn(&MemberKey) -> usize,
    +) -> usize {
    +    let mut seen = HashSet::with_capacity(members.len());
    +    let mut removed = 0;
    +    members.retain(|member| {
    +        let key = key(member);
    +        if seen.contains(&key) {
    +            removed += size(&key);
    +            false
    +        } else {
    +            seen.insert(key)
    +        }
    +    });
    +    removed
    +}
    +
    +/// Whether any attribute name or string-typed value in the item, at any
    +/// depth of maps and lists, contains U+0000. Number strings are covered by
    +/// the number validator (the character is not a digit) and binary values
    +/// are bytes, so only `S`, `SS` members and map keys are walked.
    +fn item_contains_nul(item: &Item) -> bool {
    +    item.iter()
    +        .any(|(name, value)| name.contains('\0') || value_contains_nul(value))
    +}
    +
    +fn value_contains_nul(value: &AttributeValue) -> bool {
    +    match value {
    +        AttributeValue::S(s) => s.contains('\0'),
    +        AttributeValue::SS(set) => set.iter().any(|s| s.contains('\0')),
    +        AttributeValue::L(list) => list.iter().any(value_contains_nul),
    +        AttributeValue::M(map) => map
    +            .iter()
    +            .any(|(name, value)| name.contains('\0') || value_contains_nul(value)),
    +        _ => false,
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/rows/scan.rs b/crates/engine/src/bulk_put/rows/scan.rs
    new file mode 100644
    index 000000000..5016e1a78
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/rows/scan.rs
    @@ -0,0 +1,295 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The structure of one record's `Item`, read before the attribute values
    +//! are decoded.
    +//!
    +//! The line is parsed into [`Node`], a JSON tree that keeps repeated object
    +//! members rather than collapsing them to the last one, and the walk over
    +//! it follows the item's own shape: the attribute map holds typed values, an
    +//! `M` holds another attribute map, an `L` holds typed values. Faults the
    +//! shared deserializer reports with a different class, or does not report at
    +//! all, are reported here with the measured text for the position they sit
    +//! in.
    +
    +use std::collections::HashSet;
    +use std::fmt;
    +
    +use extenddb_core::validation::number::validate_and_normalize_number;
    +use serde::de::{self, MapAccess, SeqAccess, Visitor};
    +use serde::{Deserialize, Deserializer};
    +
    +use super::{
    +    EXPECTED_ARRAY_MESSAGE, INVALID_NUMERIC_MESSAGE, REPEATED_MEMBER_MESSAGE, RowError,
    +    VALUE_RULE_PREFIX,
    +};
    +
    +/// The prefix of the number validator's message for text that is not a
    +/// number at all, which is the only number fault the record parser reports:
    +/// a magnitude or digit-count fault is the row's own, through the item
    +/// validators the runner runs.
    +const NUMERIC_CONVERSION_PREFIX: &str = "The parameter cannot be converted to a numeric value";
    +
    +/// One JSON value of an input line, with repeated object members kept in
    +/// the order they were read.
    +pub(super) enum Node {
    +    Object(Vec<(String, Node)>),
    +    Array(Vec),
    +    Leaf(serde_json::Value),
    +}
    +
    +impl Node {
    +    /// This value as a `serde_json::Value`, which collapses a repeated
    +    /// member to the last one, the way the record shape and the attribute
    +    /// deserializers read it.
    +    pub(super) fn to_value(&self) -> serde_json::Value {
    +        match self {
    +            Self::Object(members) => serde_json::Value::Object(
    +                members
    +                    .iter()
    +                    .map(|(name, node)| (name.clone(), node.to_value()))
    +                    .collect(),
    +            ),
    +            Self::Array(elements) => {
    +                serde_json::Value::Array(elements.iter().map(Self::to_value).collect())
    +            }
    +            Self::Leaf(value) => value.clone(),
    +        }
    +    }
    +
    +    /// The member under `name`, the last one when it is repeated, matching
    +    /// which of them [`Node::to_value`] keeps.
    +    fn member(&self, name: &str) -> Option<&Node> {
    +        let Self::Object(members) = self else {
    +            return None;
    +        };
    +        members
    +            .iter()
    +            .rev()
    +            .find(|(member, _)| member == name)
    +            .map(|(_, node)| node)
    +    }
    +}
    +
    +impl<'de> Deserialize<'de> for Node {
    +    fn deserialize>(deserializer: D) -> Result {
    +        deserializer.deserialize_any(NodeVisitor)
    +    }
    +}
    +
    +struct NodeVisitor;
    +
    +impl<'de> Visitor<'de> for NodeVisitor {
    +    type Value = Node;
    +
    +    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    +        formatter.write_str("any JSON value")
    +    }
    +
    +    fn visit_map>(self, mut access: A) -> Result {
    +        let mut members = Vec::new();
    +        while let Some(member) = access.next_entry::()? {
    +            members.push(member);
    +        }
    +        Ok(Node::Object(members))
    +    }
    +
    +    fn visit_seq>(self, mut access: A) -> Result {
    +        let mut elements = Vec::new();
    +        while let Some(element) = access.next_element::()? {
    +            elements.push(element);
    +        }
    +        Ok(Node::Array(elements))
    +    }
    +
    +    fn visit_bool(self, value: bool) -> Result {
    +        Ok(Node::Leaf(value.into()))
    +    }
    +
    +    fn visit_i64(self, value: i64) -> Result {
    +        Ok(Node::Leaf(value.into()))
    +    }
    +
    +    fn visit_u64(self, value: u64) -> Result {
    +        Ok(Node::Leaf(value.into()))
    +    }
    +
    +    fn visit_f64(self, value: f64) -> Result {
    +        Ok(Node::Leaf(
    +            serde_json::Number::from_f64(value).map_or(serde_json::Value::Null, Into::into),
    +        ))
    +    }
    +
    +    fn visit_str(self, value: &str) -> Result {
    +        Ok(Node::Leaf(value.into()))
    +    }
    +
    +    fn visit_unit(self) -> Result {
    +        Ok(Node::Leaf(serde_json::Value::Null))
    +    }
    +
    +    fn visit_none(self) -> Result {
    +        Ok(Node::Leaf(serde_json::Value::Null))
    +    }
    +
    +    fn visit_some>(self, deserializer: D) -> Result {
    +        Node::deserialize(deserializer)
    +    }
    +}
    +
    +/// Check the record's own members.
    +///
    +/// A top-level member that appears twice abandons the file, even when both
    +/// carry the same value (measured on the service on `Item`;
    +/// a repeat of one of the other three members was not measured and is read
    +/// the same way).
    +///
    +/// # Errors
    +///
    +/// Returns [`RowError::Parse`] with [`REPEATED_MEMBER_MESSAGE`].
    +pub(super) fn check_record_members(record: &Node) -> Result<(), RowError> {
    +    let Node::Object(members) = record else {
    +        return Ok(());
    +    };
    +    let mut seen: HashSet<&str> = HashSet::with_capacity(members.len());
    +    for (name, _) in members {
    +        if !seen.insert(name) {
    +            return Err(RowError::Parse(REPEATED_MEMBER_MESSAGE.to_owned()));
    +        }
    +    }
    +    Ok(())
    +}
    +
    +/// Check the structure of the record's `Item`.
    +///
    +/// A record with no `Item`, or one that is not an object, answers its own
    +/// message before this runs, so either shape returns without a fault here.
    +///
    +/// # Errors
    +///
    +/// Returns the measured fault for the position it is found in. Faults are
    +/// reported in the order the members were read, so a record carrying two of
    +/// them answers the first; no record with two was measured.
    +pub(super) fn check_item(record: &Node) -> Result<(), RowError> {
    +    match record.member("Item") {
    +        Some(item) => check_attribute_map(item, &Path::Item),
    +        None => Ok(()),
    +    }
    +}
    +
    +/// Where a value sits in the item: the steps from `Item` down to it. The
    +/// walk hands it down by reference and spells it out only for a fault that
    +/// names it, so no value's path is copied once for every value beneath it.
    +enum Path<'a> {
    +    /// The item's own attribute map.
    +    Item,
    +    /// The value of the named attribute in the map at the parent path.
    +    Attribute(&'a Path<'a>, &'a str),
    +    /// The attribute map of the `M` value at the parent path.
    +    Map(&'a Path<'a>),
    +    /// One element of the `L` value at the parent path.
    +    Element(&'a Path<'a>, usize),
    +}
    +
    +impl fmt::Display for Path<'_> {
    +    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    +        match self {
    +            Self::Item => formatter.write_str("Item"),
    +            Self::Attribute(parent, name) => write!(formatter, "{parent}.{name}"),
    +            Self::Map(parent) => write!(formatter, "{parent}.M"),
    +            Self::Element(parent, index) => write!(formatter, "{parent}.L[{index}]"),
    +        }
    +    }
    +}
    +
    +/// One attribute map: the item's own, or the value of an `M`. A name is
    +/// mapped to a typed value.
    +///
    +/// A name that appears twice in the same map is the row's own fault
    +/// (measured on the service): the path in the message spells
    +/// the typed step, so a repeat of member `k` of map attribute `m` reads
    +/// `Item.m.M.k`. The repeat is found when its second occurrence is read, so
    +/// a fault in an earlier member of the same map is reported first; no
    +/// record carrying both was measured.
    +fn check_attribute_map(map: &Node, path: &Path<'_>) -> Result<(), RowError> {
    +    let Node::Object(members) = map else {
    +        // A non-object here is the deserializer's to refuse.
    +        return Ok(());
    +    };
    +    let mut seen: HashSet<&str> = HashSet::with_capacity(members.len());
    +    for (name, typed) in members {
    +        if !seen.insert(name) {
    +            return Err(RowError::Item(format!(
    +                "{VALUE_RULE_PREFIX} Value for {path}.{name} is ambiguous"
    +            )));
    +        }
    +        check_typed_value(typed, &Path::Attribute(path, name))?;
    +    }
    +    Ok(())
    +}
    +
    +/// One typed attribute value: a single-member object whose name is the type
    +/// tag.
    +///
    +/// More than one member abandons the file, whether the tag is repeated or
    +/// two different tags are given (measured on the service); a
    +/// value with no member at all is the deserializer's to refuse, which was
    +/// not measured.
    +fn check_typed_value(typed: &Node, path: &Path<'_>) -> Result<(), RowError> {
    +    let Node::Object(members) = typed else {
    +        return Ok(());
    +    };
    +    if members.len() > 1 {
    +        return Err(RowError::Parse(REPEATED_MEMBER_MESSAGE.to_owned()));
    +    }
    +    let Some((tag, inner)) = members.first() else {
    +        // A value with no type tag is the deserializer's to refuse.
    +        return Ok(());
    +    };
    +    match tag.as_str() {
    +        // An `N` whose text is not a number abandons the file (measured
    +        // on a sort key); the item validators answer
    +        // for it as a row fault otherwise. `NS` members were not measured
    +        // and are left to those validators.
    +        "N" => {
    +            if let Node::Leaf(serde_json::Value::String(text)) = inner
    +                && is_not_numeric(text)
    +            {
    +                return Err(RowError::Parse(INVALID_NUMERIC_MESSAGE.to_owned()));
    +            }
    +            Ok(())
    +        }
    +        // A set tag whose value is not an array abandons the file with the
    +        // message naming the character it expected (measured on the service on
    +        // `SS`). An `L` or an `M` of the wrong JSON type was not
    +        // measured and is left to the deserializer.
    +        "SS" | "NS" | "BS" => {
    +            if matches!(inner, Node::Array(_)) {
    +                Ok(())
    +            } else {
    +                Err(RowError::Parse(EXPECTED_ARRAY_MESSAGE.to_owned()))
    +            }
    +        }
    +        "M" => check_attribute_map(inner, &Path::Map(path)),
    +        "L" => {
    +            let Node::Array(elements) = inner else {
    +                return Ok(());
    +            };
    +            for (index, element) in elements.iter().enumerate() {
    +                check_typed_value(element, &Path::Element(path, index))?;
    +            }
    +            Ok(())
    +        }
    +        _ => Ok(()),
    +    }
    +}
    +
    +/// Whether the text is not a number at all, as against a number outside the
    +/// supported magnitude or digit count, which is a row fault the runner's
    +/// validators report.
    +fn is_not_numeric(text: &str) -> bool {
    +    match validate_and_normalize_number(text) {
    +        Ok(_) => false,
    +        Err(error) => error.to_string().starts_with(NUMERIC_CONVERSION_PREFIX),
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/run_context.rs b/crates/engine/src/bulk_put/run_context.rs
    new file mode 100644
    index 000000000..48b4fdf07
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/run_context.rs
    @@ -0,0 +1,74 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The run context the Start handler records on an operation for its
    +//! runner, in the record's `run_context` column: the kind of store the
    +//! operation started on and the caller it acts for. No wire response
    +//! renders it.
    +
    +use serde::{Deserialize, Serialize};
    +use serde_json::Value;
    +
    +use extenddb_storage::BulkOperationRecord;
    +
    +use crate::bulk_put::authorize::BulkPrincipal;
    +use crate::bulk_put::store::BulkStore;
    +
    +/// The store kinds, spelled as the `[bulk] store` setting names them.
    +pub(crate) const STORE_FILESYSTEM: &str = "filesystem";
    +pub(crate) const STORE_S3: &str = "s3";
    +
    +/// The kind of `store`: a store with a directory tree is the filesystem
    +/// store; every other store reaches S3 or an S3-compatible endpoint.
    +pub(crate) fn store_kind(store: &BulkStore) -> &'static str {
    +    if store.filesystem_root().is_some() {
    +        STORE_FILESYSTEM
    +    } else {
    +        STORE_S3
    +    }
    +}
    +
    +/// The stored shape of the column.
    +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    +pub(crate) struct RecordedRunContext {
    +    /// The kind of store the operation started on.
    +    pub(crate) store: String,
    +    /// The caller, as the server captured it at Start; `None` in developer
    +    /// mode and on an operation started before callers were recorded.
    +    #[serde(default, skip_serializing_if = "Option::is_none")]
    +    pub(crate) principal: Option,
    +}
    +
    +impl RecordedRunContext {
    +    /// What Start records for an operation on `store` started by
    +    /// `principal`.
    +    pub(crate) fn at_start(store: &BulkStore, principal: Option) -> Self {
    +        Self {
    +            store: store_kind(store).to_owned(),
    +            principal,
    +        }
    +    }
    +
    +    /// The column value to store.
    +    pub(crate) fn to_value(&self) -> Value {
    +        // Strings, string maps and a bool cannot fail to serialize; Null
    +        // appears only if that invariant broke, and the runner refuses a
    +        // Null.
    +        serde_json::to_value(self).unwrap_or(Value::Null)
    +    }
    +
    +    /// The run context of `record`. A record with no value in the column
    +    /// was started before the column existed, on the filesystem store, the
    +    /// only store there was. A value that does not parse is corruption and
    +    /// is refused with its detail.
    +    pub(crate) fn of(record: &BulkOperationRecord) -> Result {
    +        match &record.run_context {
    +            None => Ok(Self {
    +                store: STORE_FILESYSTEM.to_owned(),
    +                principal: None,
    +            }),
    +            Some(value) => serde_json::from_value(value.clone())
    +                .map_err(|error| format!("stored run context does not parse: {error}")),
    +        }
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/runner/admit.rs b/crates/engine/src/bulk_put/runner/admit.rs
    new file mode 100644
    index 000000000..0ac67d61c
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/admit.rs
    @@ -0,0 +1,194 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Row admission: deciding whether one input row may be written, in the
    +//! order `handle_put_item` applies its checks, and the item-level fault
    +//! record shape (message and newImage) for a refused row.
    +
    +use super::*;
    +
    +/// `newImage` for an item-level fault record. Measured present on the
    +/// missing-key class and on the secondary-index key classes (wrong type,
    +/// empty string), on the nesting-depth class and
    +/// on a base key holding an empty string, absent on
    +/// base-key type-mismatch and oversized records; the image is
    +/// attached only for those kinds and when the submitted item is within the
    +/// item size limit, so one record is bounded by the item limit plus the
    +/// fixed fields. A base-key type-mismatch fault carries no image even when
    +/// a key attribute is also missing, because the schema walk reports the
    +/// mismatch it reaches first, not the absence.
    +pub(super) fn item_fault_new_image(
    +    kind: PutPayloadFault,
    +    item: &Item,
    +    max_item_size_bytes: usize,
    +) -> Option<&Item> {
    +    if item_size_bytes(item) > max_item_size_bytes {
    +        return None;
    +    }
    +    matches!(
    +        kind,
    +        PutPayloadFault::MissingKey
    +            | PutPayloadFault::IndexKey
    +            | PutPayloadFault::NestingDepth
    +            | PutPayloadFault::KeyEmptyValue
    +    )
    +    .then_some(item)
    +}
    +
    +/// The record message for an item-level fault: the `PutItem` message,
    +/// except for the nesting-depth class, whose bulk record carries the short
    +/// form "Nesting Levels have exceeded supported limits" (measured
    +/// at 32, 33 and 63 levels; the long `PutItem` form with its
    +/// explanatory clause is the parse-level abandon message at 64 levels and
    +/// beyond, see `rows::NESTING_ABANDON_MESSAGE`).
    +pub(super) fn item_fault_message(kind: Option, message: String) -> String {
    +    match kind {
    +        Some(PutPayloadFault::NestingDepth) => NESTING_ITEM_MESSAGE.to_owned(),
    +        _ => message,
    +    }
    +}
    +
    +/// The item-level nesting-depth record message (measured on the service).
    +const NESTING_ITEM_MESSAGE: &str = "Nesting Levels have exceeded supported limits";
    +
    +/// The record message for a condition fault: the `PutItem` message without
    +/// the per-parameter prefix the single-item path puts on it. Measured on
    +/// the service: `Invalid ConditionExpression: ` is dropped on the operator
    +/// count, the 4 KB size bound and a syntax error;
    +/// `ExpressionAttributeNames contains invalid key: ` is dropped on the
    +/// placeholder key length fault. The unused-placeholder texts carry no
    +/// prefix on either path. The shared parser does
    +/// not raise the key length fault on this path today; the second prefix is
    +/// stripped so the rule holds when it does.
    +pub(super) fn condition_fault_message(message: String) -> String {
    +    for prefix in CONDITION_PREFIXES {
    +        if let Some(rest) = message.strip_prefix(prefix) {
    +            return rest.to_owned();
    +        }
    +    }
    +    message
    +}
    +
    +const CONDITION_PREFIXES: [&str; 2] = [
    +    "Invalid ConditionExpression: ",
    +    "ExpressionAttributeNames contains invalid key: ",
    +];
    +
    +/// One row's admission through the shared write core, in the order
    +/// `handle_put_item` applies the checks: the condition fields first, then
    +/// the item. A row carrying both an unparseable condition and a bad item
    +/// therefore reports the condition message, as `PutItem` answers for the
    +/// same item and expression.
    +pub(super) enum Admission {
    +    /// The row may be written; the parsed condition when the row has one.
    +    Write(Option<(Expr, ExpressionMaps)>),
    +    /// The row fails an item-level check: the record message and, for item
    +    /// faults, the fired check deciding the image rule. Condition faults
    +    /// carry no kind and no image.
    +    Fault {
    +        message: String,
    +        kind: Option,
    +    },
    +}
    +
    +pub(super) fn admit_row(row: &Row, key_info: &TableKeyInfo, limits: &LimitsConfig) -> Admission {
    +    let condition = match &row.condition {
    +        Some(condition) => match parse_row_condition(condition, limits) {
    +            Ok(parsed) => Some(parsed),
    +            Err(fault) => {
    +                return Admission::Fault {
    +                    message: condition_fault_message(fault.to_string()),
    +                    kind: None,
    +                };
    +            }
    +        },
    +        None => None,
    +    };
    +    if let Err(fault) = validate_put_payload(&row.item, key_info, &row.item_limits(limits)) {
    +        return Admission::Fault {
    +            message: fault.error.to_string(),
    +            kind: Some(fault.kind),
    +        };
    +    }
    +    // Secondary-index key attributes present in the row must match their
    +    // declared type and must not be empty. The storage backends run the same
    +    // check on every row of a batch before writing, but there it is a batch
    +    // error that fails the operation; here it is this row's fault, reported
    +    // with the `PutItem` message and no image, and the file continues.
    +    let index_refs: Vec> = key_info
    +        .global_secondary_indexes
    +        .iter()
    +        .chain(key_info.local_secondary_indexes.iter())
    +        .map(|idx| extenddb_core::validation::IndexKeyRef {
    +            index_name: &idx.index_name,
    +            key_schema: &idx.key_schema,
    +        })
    +        .collect();
    +    if let Err(error) = extenddb_core::validation::validate_index_keys(
    +        &row.item,
    +        &index_refs,
    +        &key_info.attribute_definitions,
    +    ) {
    +        return Admission::Fault {
    +            message: error.to_string(),
    +            kind: Some(PutPayloadFault::IndexKey),
    +        };
    +    }
    +    Admission::Write(condition)
    +}
    +
    +/// Parse one row's condition through the shared core `handle_put_item`
    +/// uses. The attribute name sizes in both expression maps are checked
    +/// first, as `handle_put_item` checks them ahead of the condition, with
    +/// the standalone request's wording; `condition_fault_message` leaves that
    +/// text as `PutItem` words it, because its `1 validation error detected: `
    +/// opening is not one of the prefixes it strips. Then
    +/// `resolve_put_condition` runs `resolve_condition`, the unused-attribute
    +/// check, and the nesting-depth check on the expression attribute values,
    +/// in that order.
    +fn parse_row_condition(
    +    condition: &RowCondition,
    +    limits: &LimitsConfig,
    +) -> Result<(Expr, ExpressionMaps), DynamoDbError> {
    +    extenddb_core::validation::validate_expression_attribute_name_sizes(
    +        condition.names.as_ref(),
    +        condition.values.as_ref(),
    +        limits,
    +        extenddb_core::validation::AttributeNameRequestForm::SingleItem,
    +    )?;
    +    let (parsed, maps) = resolve_put_condition(
    +        Some(&condition.expression),
    +        condition.names.as_ref(),
    +        condition.values.as_ref(),
    +        None,
    +        None,
    +        limits,
    +    )?;
    +    // parse_row filters empty expressions, so this branch is not reached;
    +    // the message is the tokenizer's for an empty condition expression.
    +    let Some(expr) = parsed else {
    +        return Err(DynamoDbError::ValidationException(
    +            "Invalid ConditionExpression: The expression can not be empty;".to_owned(),
    +        ));
    +    };
    +    Ok((expr, maps))
    +}
    +
    +/// Whether a read fault inside the resume skip region abandons the file.
    +/// A line over the line limit or one that is not valid UTF-8 means the
    +/// file changed since a committed checkpoint counted the region: the
    +/// earlier run abandoned the file at any such line, so its cursor cannot
    +/// lie past one (end of file is the other changed-file signal and is
    +/// handled before this). An I/O fault, a jail refusal, or a non-regular
    +/// file does not imply a changed file and fails the operation exactly as
    +/// it would on a first read, so a resume can never complete an operation
    +/// that a crash-free run would have failed.
    +pub(super) fn skip_fault_abandons(error: &SourceError) -> bool {
    +    matches!(
    +        error,
    +        SourceError::LineTooLong
    +            | SourceError::InvalidUtf8
    +            | SourceError::Corrupt
    +            | SourceError::DecodeFailed
    +    )
    +}
    diff --git a/crates/engine/src/bulk_put/runner/cut.rs b/crates/engine/src/bulk_put/runner/cut.rs
    new file mode 100644
    index 000000000..31d278aa2
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/cut.rs
    @@ -0,0 +1,120 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The abandon cut: how many bytes of an input object the progress counter
    +//! holds after an abandon part way through it, by the read geometry the
    +//! hosted service was measured to use.
    +//!
    +//! A plain object is read in 8,000-byte reads (measured on the service).
    +//! A compressed object is read through its decoder
    +//! and the counter holds the compressed bytes the decoder's reads pulled
    +//! (measured on the service). A gzip object is read as its 10-byte header then
    +//! 65,536-byte reads (faults leave the counter at 65,546, 131,082 and the
    +//! whole 30,758-byte object). A zstd object is read in 131,075-byte reads,
    +//! the decoder's input size for one block of the largest size plus its
    +//! header (a fault in the first read of a larger object leaves the counter
    +//! at 131,075; faults inside the second and the first read of objects
    +//! those reads cover count the whole object). When bytes of the object lie beyond the read that
    +//! held the fault, they are never counted and the operation ends `FAILED`
    +//! with the items-failed-validation pair; when the fault's read reached
    +//! the end of the object, its bytes count in full and the operation ends
    +//! `COMPLETED_WITH_ERRORS`.
    +
    +use super::InputCodec;
    +
    +/// Bytes of one read of a plain input object (measured on the service:
    +/// `ProcessedInputBytes` moves in whole multiples of
    +/// this figure, and an abandon leaves the counter at the end of the read
    +/// that held the fault).
    +pub(super) const READ_CHUNK_BYTES: u64 = 8_000;
    +
    +/// Bytes of one read of a gzip object after its header read (measured on
    +/// the service: the counter stops at 10 plus whole reads of this size).
    +pub(super) const GZIP_READ_BYTES: u64 = 65_536;
    +
    +/// Bytes of the header read of a gzip object.
    +const GZIP_HEAD_BYTES: u64 = 10;
    +
    +/// Bytes of one read of a zstd object: the largest block, 128 KiB, plus
    +/// its 3-byte header, the input size a zstd decoder asks for (measured on
    +/// the service: the counter stops at exactly this figure, and whole objects
    +/// of 198,831 and 99,334 bytes count in full with faults inside the second
    +/// and the first read).
    +pub(super) const ZSTD_READ_BYTES: u64 = 131_075;
    +
    +/// The read geometry of one input object: the size of the first read,
    +/// which is the codec's header read, and the size of every read after it.
    +/// A plain object has no header read.
    +#[derive(Clone, Copy, Debug, PartialEq, Eq)]
    +pub(super) struct ReadGeometry {
    +    pub(super) head: u64,
    +    pub(super) chunk: u64,
    +}
    +
    +impl ReadGeometry {
    +    /// The geometry the hosted service reads an object of `codec` with.
    +    pub(super) fn of(codec: InputCodec) -> Self {
    +        match codec {
    +            InputCodec::None => Self {
    +                head: 0,
    +                chunk: READ_CHUNK_BYTES,
    +            },
    +            InputCodec::Gzip => Self {
    +                head: GZIP_HEAD_BYTES,
    +                chunk: GZIP_READ_BYTES,
    +            },
    +            InputCodec::Zstd => Self {
    +                head: 0,
    +                chunk: ZSTD_READ_BYTES,
    +            },
    +        }
    +    }
    +
    +    /// The index of the read holding the byte at `offset`: 0 is the header
    +    /// read, 1 the first read after it.
    +    fn read_index(self, offset: u64) -> u64 {
    +        if offset < self.head {
    +            0
    +        } else {
    +            1 + (offset - self.head) / self.chunk
    +        }
    +    }
    +
    +    /// The exclusive end offset of read `index`.
    +    fn read_end(self, index: u64) -> u64 {
    +        self.head.saturating_add(index.saturating_mul(self.chunk))
    +    }
    +
    +    /// The processed bytes of an object abandoned at `fault_offset`, the
    +    /// offset within the stored object just past the last byte the fault is
    +    /// attributed to, when `size` is the object's enumerated size.
    +    ///
    +    /// `Some(bytes)` means the object is cut: the fault's read left bytes of
    +    /// the object behind it, those bytes are never counted, and the
    +    /// operation ends with the items-failed-validation pair. `None` means
    +    /// the fault's read reached the end of the object, so its bytes count
    +    /// in full and the terminal status follows the ordinary rule.
    +    pub(super) fn cut_processed_bytes(self, fault_offset: u64, size: u64) -> Option {
    +        if size == 0 {
    +            return None;
    +        }
    +        // `fault_offset` is exclusive, so the read that held the fault is
    +        // the one holding the byte before it: a line ending exactly on a
    +        // read boundary belongs to the read before the boundary.
    +        let fault_read = self.read_index(fault_offset.saturating_sub(1));
    +        let last_read = self.read_index(size - 1);
    +        (fault_read < last_read).then(|| self.read_end(fault_read))
    +    }
    +}
    +
    +/// The plain-object cut (measured on the service): a fault on line 1 of
    +/// 23,840 bytes gives 8,000 processed, FAILED; a fault at about byte
    +/// 1,100 of 9,990 gives 8,000 processed, FAILED; a fault on line 1 of
    +/// 1,980 bytes gives 1,980 processed, COMPLETED_WITH_ERRORS; a fault on
    +/// the last line of 23,840 bytes gives 23,840 processed,
    +/// COMPLETED_WITH_ERRORS. Kept for the plain-object tests; the runner
    +/// calls the geometry directly.
    +#[cfg(test)]
    +pub(super) fn cut_processed_bytes(fault_offset: u64, size: u64) -> Option {
    +    ReadGeometry::of(InputCodec::None).cut_processed_bytes(fault_offset, size)
    +}
    diff --git a/crates/engine/src/bulk_put/runner/failure.rs b/crates/engine/src/bulk_put/runner/failure.rs
    new file mode 100644
    index 000000000..f860acccc
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/failure.rs
    @@ -0,0 +1,218 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The failure a run ends with: the `FailureCode` and `FailureMessage`
    +//! pairs the runner records, and the mapping from a source fault to one.
    +
    +use super::*;
    +
    +/// The failure a run ends with, when one is set: the `FailureCode` and
    +/// `FailureMessage` of the terminal record.
    +#[derive(Debug, Clone)]
    +pub(super) struct Failure {
    +    pub(super) code: Cow<'static, str>,
    +    pub(super) message: String,
    +}
    +
    +/// The code of the failure a store answers when it was unavailable past its
    +/// retries. It is not an outcome: the run stops with the operation in
    +/// progress and a later claim resumes it from its checkpoint (`finalize`),
    +/// so the code is never written to a record.
    +const CODE_STORE_UNAVAILABLE: &str = "StoreUnavailable";
    +
    +/// The failure code of the object-count quota class. The Describe handler
    +/// recognises the class by this code to omit the two byte members the
    +/// hosted service leaves out of that description.
    +pub(crate) const OBJECTS_COUNT_LIMIT_CODE: &str = "S3ObjectsCountLimitExceeded";
    +
    +impl Failure {
    +    /// The table with the bound `table_id` is gone or unusable.
    +    pub(super) fn table_not_found() -> Self {
    +        Self {
    +            code: Cow::Borrowed(CODE_RESOURCE_NOT_FOUND),
    +            message: MESSAGE_RESOURCE_NOT_FOUND.to_owned(),
    +        }
    +    }
    +
    +    /// The input or output store failed with a transient fault that
    +    /// outlasted its retries (`StoreUnavailable`): the operation resumes
    +    /// from its checkpoint, as after a transient database fault.
    +    pub(super) fn store_unavailable() -> Self {
    +        Self {
    +            code: Cow::Borrowed(CODE_STORE_UNAVAILABLE),
    +            message: INTERNAL_MESSAGE.to_owned(),
    +        }
    +    }
    +
    +    /// Whether this is [`Self::store_unavailable`], directly or as the
    +    /// location fault of [`Self::manifest_unavailable`].
    +    pub(super) fn is_store_unavailable(&self) -> bool {
    +        self.code == CODE_STORE_UNAVAILABLE
    +    }
    +
    +    /// Anything whose detail belongs in the log and not on the wire.
    +    pub(super) fn internal() -> Self {
    +        Self {
    +            code: Cow::Borrowed(CODE_INTERNAL),
    +            message: INTERNAL_MESSAGE.to_owned(),
    +        }
    +    }
    +
    +    /// An input file was not a compressed object under `GZIP` (measured
    +    /// ): the operation runs to its end, then fails with this.
    +    pub(super) fn items_failed_validation() -> Self {
    +        Self {
    +            code: Cow::Borrowed(CODE_VALIDATION),
    +            message: "Some of the items failed validation checks and were not ingested".to_owned(),
    +        }
    +    }
    +
    +    /// The processed row count passed the per-operation cap.
    +    pub(super) fn row_limit(limit: u64) -> Self {
    +        Self {
    +            code: Cow::Borrowed(CODE_VALIDATION),
    +            message: format!("Operation exceeded max_bulk_item_count ({limit} rows)"),
    +        }
    +    }
    +
    +    /// The manifest could not be written because the output location is
    +    /// unusable: the bucket is missing or the configured owner is not the
    +    /// caller (measured on the service). The code is the
    +    /// location fault's own; the message carries the measured prefix.
    +    pub(super) fn manifest_unavailable(location: Self) -> Self {
    +        Self {
    +            code: location.code,
    +            message: format!("Manifest is not available. {}", location.message),
    +        }
    +    }
    +
    +    /// The output bucket directory is missing at the first report record.
    +    pub(super) fn output_bucket_missing() -> Self {
    +        Self {
    +            code: Cow::Borrowed("S3NoSuchBucket"),
    +            message: "The specified bucket does not exist".to_owned(),
    +        }
    +    }
    +
    +    /// An input object the operation had enumerated is gone when the
    +    /// runner opens or reads it (measured on the service: an object
    +    /// deleted mid-run ends the operation FAILED with this code). The
    +    /// service appends an S3 request-id tail in parentheses, which a
    +    /// local store cannot reproduce; the sentence is emitted alone, as
    +    /// `output_bucket_missing` does for the bucket.
    +    pub(super) fn no_such_key() -> Self {
    +        Self {
    +            code: Cow::Borrowed("S3NoSuchKey"),
    +            message: "The specified key does not exist.".to_owned(),
    +        }
    +    }
    +
    +    /// More enumerated input objects under the prefix than the configured
    +    /// count cap allows (measured on the service: the Start is
    +    /// accepted and the operation ends FAILED with this code, the prefix
    +    /// interpolated as the request gave it and the cap as a bare number).
    +    pub(super) fn objects_count_limit_exceeded(prefix: &str, limit: u64) -> Self {
    +        Self {
    +            code: Cow::Borrowed(OBJECTS_COUNT_LIMIT_CODE),
    +            message: format!(
    +                "You have reached the maximum number of objects allowed in a single bulk \
    +                 operation for the specified prefix {prefix}, which is {limit}. You can reach \
    +                 out to AWS Support to request a quota increase."
    +            ),
    +        }
    +    }
    +
    +    /// One enumerated input object above the configured input byte cap
    +    /// (measured on the service). The measured text renders the cap
    +    /// as `5.0 GB` for 5,368,709,120 bytes, so the figure is the cap
    +    /// divided by 2^30 to one decimal place, labelled GB.
    +    pub(super) fn input_object_too_large(limit_bytes: u64) -> Self {
    +        Self {
    +            code: Cow::Borrowed("InputObjectTooLarge"),
    +            message: format!(
    +                "This bulk operation contains one or more input objects larger than the \
    +                 allowed per-object size of {}. Split those objects into smaller parts and \
    +                 retry.",
    +                format_gibibytes(limit_bytes)
    +            ),
    +        }
    +    }
    +
    +    /// A location under the store the operation may not use: the output
    +    /// `S3BucketOwner` is not the caller's account, or a path in the
    +    /// caller's own tree is refused (a symlink in the input or report path,
    +    /// a component that is not a directory, a file that is not a regular
    +    /// file, a path that escapes the account directory). The code is the
    +    /// one the hosted service answers for an input or report location it
    +    /// may not read or write; the detail stays in the log.
    +    pub(super) fn access_denied() -> Self {
    +        Self {
    +            code: Cow::Borrowed("S3AccessDenied"),
    +            message: "Access Denied".to_owned(),
    +        }
    +    }
    +
    +    /// A fault the store rendered, with its own code and message.
    +    pub(super) fn from_store(fault: &StoreFault) -> Self {
    +        Self {
    +            code: Cow::Borrowed(fault.code),
    +            message: fault.message.clone(),
    +        }
    +    }
    +}
    +
    +/// The cap figure as the measured message renders it: the byte count
    +/// divided by 2^30 to one decimal place, labelled GB (measured on the
    +/// service: an object of 5,369,000,000 bytes was refused against a cap the
    +/// message spells `5.0 GB`, which is 5,368,709,120 bytes).
    +fn format_gibibytes(bytes: u64) -> String {
    +    // The cap is a configuration value, far below the precision limit of
    +    // f64, so the division is exact to the one decimal the text carries.
    +    #[allow(clippy::cast_precision_loss)]
    +    let gib = bytes as f64 / 1_073_741_824.0;
    +    format!("{gib:.1} GB")
    +}
    +
    +/// The failure for an input file that could not be opened or read. A
    +/// refusal of the caller's own tree (a symlink, a component that is not a
    +/// directory, a file that is not a regular file, a jail escape) is the
    +/// caller's to fix and is reported as access denied; a file that is gone
    +/// or unreadable answers the measured S3 code for that class; a store
    +/// unavailable past its retries is [`Failure::store_unavailable`]; any other
    +/// I/O error is the server's and is reported as internal. The detail is
    +/// logged by the caller of this function.
    +pub(super) fn source_failure(error: &SourceError) -> Failure {
    +    match error {
    +        SourceError::Io(io) if StoreUnavailable::of_io(io) => Failure::store_unavailable(),
    +        SourceError::Jail(_) | SourceError::NotRegularFile => Failure::access_denied(),
    +        SourceError::Io(io) => match io.kind() {
    +            std::io::ErrorKind::NotFound => Failure::no_such_key(),
    +            std::io::ErrorKind::PermissionDenied => Failure::access_denied(),
    +            _ => Failure::internal(),
    +        },
    +        SourceError::Store(fault) => Failure::from_store(fault),
    +        SourceError::LineTooLong
    +        | SourceError::InvalidUtf8
    +        | SourceError::Corrupt
    +        | SourceError::NotCompressed
    +        | SourceError::DecodeFailed
    +        | SourceError::Overwritten
    +        | SourceError::TooManyFiles => Failure::internal(),
    +    }
    +}
    +
    +/// Whether a source failure means the input object itself is gone or
    +/// unreadable, which the manifest reports with no processed range (see
    +/// `RunState::unavailable_file`). A store fault carries the answer in
    +/// `after_first_byte`.
    +pub(super) fn is_unavailable_object(error: &SourceError) -> bool {
    +    match error {
    +        SourceError::Jail(_) | SourceError::NotRegularFile => true,
    +        SourceError::Io(io) => matches!(
    +            io.kind(),
    +            std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied
    +        ),
    +        SourceError::Store(fault) => fault.after_first_byte,
    +        _ => false,
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/runner/finalize.rs b/crates/engine/src/bulk_put/runner/finalize.rs
    new file mode 100644
    index 000000000..e8e95e04f
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/finalize.rs
    @@ -0,0 +1,416 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Finalizing a run: rendering the manifest and the error and
    +//! condition-check-failure reports from persisted records, writing the
    +//! terminal transition, and recording the terminal metrics.
    +
    +use super::*;
    +
    +/// Page size the report renderer reads persisted records with.
    +pub(super) const RENDER_PAGE_SIZE: usize = 1000;
    +
    +/// Log a storage read failure during rendering; the operation ends with
    +/// the internal failure and the detail stays in the log.
    +pub(super) fn render_read_failure(account: &str, operation: &str, error: &StorageError) -> Failure {
    +    tracing::error!(
    +        account_id = %account,
    +        operation_id = %operation,
    +        error = %error,
    +        "report records could not be read for rendering"
    +    );
    +    Failure::internal()
    +}
    +
    +/// Log a sink failure during rendering; the operation ends with the
    +/// internal failure and no path reaches a client.
    +pub(super) fn render_sink_failure(account: &str, operation: &str, error: &SinkError) -> Failure {
    +    tracing::error!(
    +        account_id = %account,
    +        operation_id = %operation,
    +        error = ?error,
    +        "bulk report rendering failed"
    +    );
    +    // A refused report path (a symlink or a non-directory component in the
    +    // caller's tree, a jail escape) is the caller's to fix; an I/O error is
    +    // the server's, except the report bucket itself being gone, which is
    +    // the caller's location fault (the manifest check that follows the
    +    // render reports it in the measured form).
    +    match error {
    +        // The store was unavailable past its retries: the operation
    +        // resumes and writes its outputs again.
    +        SinkError::Io(io) if StoreUnavailable::of_io(io) => Failure::store_unavailable(),
    +        SinkError::Jail(_) | SinkError::Symlink(_) => Failure::access_denied(),
    +        SinkError::Io(io) if io.kind() == std::io::ErrorKind::NotFound => {
    +            Failure::output_bucket_missing()
    +        }
    +        // A report path the caller's own tree makes unusable (a regular
    +        // file where a directory component must be, a directory the server
    +        // may not write into) is the caller's location fault, the same
    +        // class as a symlink or jail refusal; the hosted service answers
    +        // S3AccessDenied for an unwritable report location. Genuinely
    +        // unexpected I/O kinds stay on InternalServerError.
    +        SinkError::Io(io) if location_denied(io) => Failure::access_denied(),
    +        SinkError::Io(_) => Failure::internal(),
    +        // A fault the store rendered keeps its own code and message.
    +        SinkError::Store(fault) => Failure::from_store(fault),
    +    }
    +}
    +
    +/// Whether an I/O error from the report-path walk or the `create_new` is a
    +/// location the caller's own tree makes unusable: a component that is not a
    +/// directory (`ENOTDIR`), a file where a directory component must be created
    +/// (`EEXIST`), or a directory the server may not write into (`EACCES`,
    +/// `EPERM`). These are the caller's to fix, so they map to `S3AccessDenied`
    +/// the way the jail and symlink refusals do. Classified by the error kind,
    +/// never by message text.
    +fn location_denied(error: &std::io::Error) -> bool {
    +    use std::io::ErrorKind;
    +    // `PermissionDenied` covers EACCES and EPERM, `AlreadyExists` EEXIST and
    +    // `NotADirectory` ENOTDIR on Unix, and the matching codes on Windows, so
    +    // the classification does not depend on the platform's raw numbers.
    +    matches!(
    +        error.kind(),
    +        ErrorKind::PermissionDenied | ErrorKind::AlreadyExists | ErrorKind::NotADirectory
    +    )
    +}
    +
    +/// Render each report kind that has persisted records: read the records in
    +/// pages, encode each page into the writer as it arrives, and finish the
    +/// file once, so the whole report is never held in memory. A failure
    +/// mid-render drops the writer, which removes the temp file.
    +/// How a render pass ended when it did not fail: every report was written,
    +/// or the lease had passed to another process and the runner must stop.
    +pub(super) enum Render {
    +    Rendered,
    +    LeaseLost,
    +    /// The lease re-read itself failed. Nothing can be decided about
    +    /// ownership, so the runner stops without a terminal transition and the
    +    /// record stays in progress for the next claim, as a failed checkpoint
    +    /// does.
    +    Interrupted(StorageError),
    +}
    +
    +pub(super) async fn render_reports(
    +    deps: &RunnerDeps,
    +    record: &BulkOperationRecord,
    +    config: &OperationConfig,
    +    output_bucket: &str,
    +) -> Result {
    +    let account = &record.account_id;
    +    let operation = &record.operation_id;
    +    for kind in [ReportKind::Error, ReportKind::ConditionCheckFailure] {
    +        let mut page = deps
    +            .storage
    +            .report_records_page(account, operation, kind, None, RENDER_PAGE_SIZE)
    +            .await
    +            .map_err(|error| render_read_failure(account, operation, &error))?;
    +        if page.is_empty() {
    +            continue;
    +        }
    +        // The caller's s3:PutObject on each report object, before the page
    +        // that writes into it; a writer dropped on a denial removes what it
    +        // wrote.
    +        let object_key =
    +            |index| report::report_key(&config.output_prefix, account, operation, kind, index);
    +        if let Some(failure) =
    +            put_object_failure(deps, record, config, output_bucket, &object_key(0)).await
    +        {
    +            return Err(failure);
    +        }
    +        let mut checked = 0;
    +        let mut writer = deps
    +            .sink
    +            .open(
    +                &config.output_at(account, output_bucket),
    +                &config.output_prefix,
    +                operation,
    +                kind,
    +                &deps.owner,
    +                &config.output_sse,
    +            )
    +            .await
    +            .map_err(|error| render_sink_failure(account, operation, &error))?;
    +        loop {
    +            let full = page.len() == RENDER_PAGE_SIZE;
    +            let after = page
    +                .last()
    +                .map(|record| (record.file_index, record.row_index));
    +            let bodies: Vec = page.into_iter().map(|record| record.record).collect();
    +            let index = writer.object_index();
    +            if index > checked {
    +                let key = object_key(index);
    +                if let Some(failure) =
    +                    put_object_failure(deps, record, config, output_bucket, &key).await
    +                {
    +                    return Err(failure);
    +                }
    +                checked = index;
    +            }
    +            writer
    +                .write_page(bodies)
    +                .await
    +                .map_err(|error| render_sink_failure(account, operation, &error))?;
    +            if !full {
    +                break;
    +            }
    +            page = deps
    +                .storage
    +                .report_records_page(account, operation, kind, after, RENDER_PAGE_SIZE)
    +                .await
    +                .map_err(|error| render_read_failure(account, operation, &error))?;
    +            if page.is_empty() {
    +                break;
    +            }
    +        }
    +        // Confirm the lease right before the
    +        // rename, once per report kind. Rendering is unbounded work; a
    +        // render outliving the lease must not replace a report the new
    +        // owner has already written, so a runner that no longer owns the
    +        // record drops its writer (the writer's Drop removes the temp
    +        // file) and stops with no further write of any kind.
    +        let holder = match deps.storage.get_bulk_operation(account, operation).await {
    +            Ok(holder) => holder,
    +            Err(error) => {
    +                tracing::warn!(
    +                    account_id = %account,
    +                    operation_id = %operation,
    +                    error = %error,
    +                    "lease could not be re-read before the report rename; the runner stops"
    +                );
    +                drop(writer);
    +                return Ok(Render::Interrupted(error));
    +            }
    +        };
    +        let held =
    +            holder.as_ref().and_then(|row| row.lease_owner.as_deref()) == Some(deps.owner.as_str());
    +        if !held {
    +            tracing::info!(
    +                account_id = %account,
    +                operation_id = %operation,
    +                "lease lost during report rendering; the temp file is dropped"
    +            );
    +            drop(writer);
    +            return Ok(Render::LeaseLost);
    +        }
    +        let key = writer
    +            .finish()
    +            .await
    +            .map_err(|error| render_sink_failure(account, operation, &error))?;
    +        tracing::info!(
    +            account_id = %account,
    +            operation_id = %operation,
    +            key = %key,
    +            "bulk report rendered"
    +        );
    +    }
    +    Ok(Render::Rendered)
    +}
    +
    +/// Step 9: the final checkpoint, the report files, the conditional
    +/// terminal transition, and the ledger delete once the transition holds.
    +pub(super) async fn finalize(
    +    deps: &RunnerDeps,
    +    record: &BulkOperationRecord,
    +    config: Option<&OperationConfig>,
    +    mut st: RunState,
    +) -> Result<(), RunnerError> {
    +    let account = &record.account_id;
    +    let operation = &record.operation_id;
    +
    +    // The final checkpoint persists the last rows and records and proves
    +    // the lease is still held before any file is rendered. No row is
    +    // staged here: every path out of the read loop flushed or discarded
    +    // them (`read_file`).
    +    debug_assert!(
    +        st.staged.is_empty(),
    +        "finalize reached with rows staged; the read loop must flush or discard them"
    +    );
    +    let progress = st.progress(&record.table_arn);
    +    let renewed_at = Instant::now();
    +    match deps
    +        .storage
    +        .checkpoint(account, operation, &deps.owner, deps.config.lease, progress)
    +        .await
    +    {
    +        Err(error) => return Err(RunnerError::Storage(error)),
    +        Ok(LeaseState::Lost) => {
    +            tracing::info!(
    +                account_id = %account,
    +                operation_id = %operation,
    +                "lease lost at the final checkpoint; the runner stops"
    +            );
    +            return Ok(());
    +        }
    +        Ok(LeaseState::Held) => {}
    +    }
    +    // A store unavailable past its retries is no outcome: with the progress
    +    // checkpointed, the operation stays in progress for the next claim.
    +    if st
    +        .failure
    +        .as_ref()
    +        .is_some_and(Failure::is_store_unavailable)
    +        && stop_for_unavailable_store(record, &mut st)
    +    {
    +        return Ok(());
    +    }
    +    // The report and manifest writes wait on the output store; the lease
    +    // the final checkpoint renewed is kept while they wait.
    +    let mut deadline = Some(renewed_at + deps.config.lease);
    +
    +    if st.dropped_records > 0 {
    +        tracing::info!(
    +            account_id = %account,
    +            operation_id = %operation,
    +            dropped_records = st.dropped_records,
    +            "report records beyond the record count or byte cap were dropped"
    +        );
    +    }
    +
    +    // Render each report kind that has records, when a report bucket is
    +    // configured. A rendering failure fails the operation; nothing about
    +    // the path reaches a client. A lease observed lost during rendering
    +    // stops the runner here: no finish, no further writes.
    +    if let Some(config) = config
    +        && let Some(output_bucket) = config.output_bucket.as_deref()
    +        && !st.skip_render
    +    {
    +        match render_outputs(deps, record, config, output_bucket, &mut st, &mut deadline).await {
    +            Outputs::Finish => {}
    +            Outputs::Stop => return Ok(()),
    +            Outputs::Interrupted(error) => return Err(RunnerError::Storage(error)),
    +            Outputs::Unavailable => {
    +                if stop_for_unavailable_store(record, &mut st) {
    +                    return Ok(());
    +                }
    +            }
    +        }
    +    }
    +
    +    // An abandon that left bytes of its file uncounted, a file refused as
    +    // not a compressed object, and an object overwritten after its listing
    +    // all end the operation with the validation pair once the run has
    +    // finished its remaining files (measured on the service for the
    +    // not-compressed class, the cut and the overwritten object). The
    +    // abandons of earlier incarnations are restored with their kinds.
    +    if st.failure.is_none()
    +        && !st.cancelled
    +        && (st.cut_file_seen || st.abandoned.values().any(|a| a.fails_operation()))
    +    {
    +        st.failure = Some(Failure::items_failed_validation());
    +    }
    +    let status = terminal_status(st.failure.as_ref(), st.cancelled, st.failed);
    +    let (failure_code, failure_message) = match st.failure {
    +        Some(failure) => (Some(failure.code.into_owned()), Some(failure.message)),
    +        None => (None, None),
    +    };
    +    let outcome = Terminal {
    +        status: status.to_owned(),
    +        end_time_ms: epoch_millis(),
    +        failure_code,
    +        failure_message,
    +        usage: st.usage.to_value(&record.table_arn),
    +    };
    +    // finish is conditional on the lease and turns a CANCELLING row into
    +    // CANCELLED whatever status the runner computed; it answers with the
    +    // status the row now stores.
    +    match deps
    +        .storage
    +        .finish(account, operation, &deps.owner, outcome)
    +        .await
    +    {
    +        Err(error) => Err(RunnerError::Storage(error)),
    +        Ok(FinishState::Lost) => {
    +            tracing::info!(
    +                account_id = %account,
    +                operation_id = %operation,
    +                "lease lost at the terminal transition; the runner stops"
    +            );
    +            Ok(())
    +        }
    +        Ok(FinishState::Held(stored)) => {
    +            // The ledger rows are deleted only after the terminal
    +            // transition succeeds. On the failure path the persisted
    +            // cursor is the last checkpoint, and the markers of rows a
    +            // resume would re-read must survive any crash before
    +            // `finish`; once the operation is terminal it is never
    +            // resumed, so the rows are never read again. A lost lease skips the delete with
    +            // everything else, and if the delete fails the retention
    +            // purge removes the rows later.
    +            if let Err(error) = deps
    +                .storage
    +                .delete_bulk_row_outcomes(account, operation)
    +                .await
    +            {
    +                tracing::warn!(
    +                    account_id = %account,
    +                    operation_id = %operation,
    +                    error = %error,
    +                    "bulk row outcome ledger delete failed"
    +                );
    +            }
    +            tracing::info!(
    +                account_id = %account,
    +                operation_id = %operation,
    +                table_name = %record.table_name,
    +                status = %stored,
    +                processed_item_count = st.processed,
    +                failed_item_count = st.failed,
    +                "bulk operation reached a terminal state"
    +            );
    +            record_finished_metrics(deps, &record.table_name, &stored, st.written);
    +            Ok(())
    +        }
    +    }
    +}
    +
    +/// Whether the run stops for a store unavailable past its retries, leaving
    +/// the operation in progress for the next claim to resume from its
    +/// checkpoint. A claim the resume bound refused does not stop: the
    +/// operation ends with the internal failure instead.
    +fn stop_for_unavailable_store(record: &BulkOperationRecord, st: &mut RunState) -> bool {
    +    if st.resumes_spent {
    +        if st
    +            .failure
    +            .as_ref()
    +            .is_none_or(Failure::is_store_unavailable)
    +        {
    +            st.failure = Some(Failure::internal());
    +        }
    +        return false;
    +    }
    +    tracing::warn!(
    +        account_id = %record.account_id,
    +        operation_id = %record.operation_id,
    +        "the bulk store was unavailable past its retries; the operation resumes at its checkpoint"
    +    );
    +    true
    +}
    +
    +/// The terminal-transition metrics, recorded only on the transition that
    +/// held the lease, so a resumed operation is counted once. The status
    +/// dimension is the status `finish` stored, which for a CANCELLING row is
    +/// CANCELLED whatever the runner computed. `written`
    +/// counts the rows whose write committed in this process; rows an earlier
    +/// claim applied and condition failures are not counted.
    +fn record_finished_metrics(deps: &RunnerDeps, table_name: &str, stored: &str, written: u64) {
    +    deps.metrics.record(
    +        MetricName::BulkOperationsFinished,
    +        1.0,
    +        Some(table_name),
    +        None,
    +        Some(stored),
    +    );
    +    if written > 0 {
    +        // Bounded by max_bulk_item_count, far below 2^53, so the value
    +        // is exact.
    +        #[allow(clippy::cast_precision_loss)]
    +        deps.metrics.record(
    +            MetricName::BulkPutItemsWritten,
    +            written as f64,
    +            Some(table_name),
    +            None,
    +            None,
    +        );
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/runner/held.rs b/crates/engine/src/bulk_put/runner/held.rs
    new file mode 100644
    index 000000000..4e4a51f0a
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/held.rs
    @@ -0,0 +1,102 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The output location fault found at the operation's first report record,
    +//! held while the run reads on to the end of the input, then made the
    +//! operation's failure.
    +//!
    +//! Measured on the service for a missing output bucket, an output owner that
    +//! is not the caller and a report write the output bucket refuses: the
    +//! service reads past the row whose record found the fault, writes every
    +//! good row to the end of the input, then ends `FAILED` with the fault, with
    +//! `ProcessedItemCount` the rows written, `FailedItemCount` 0,
    +//! `ProcessedInputBytes` 0 and zero usage, and writes no report and no
    +//! manifest. A cancel, or another fault met before the end, ends the
    +//! operation instead, as it does with no fault held.
    +//!
    +//! The held fault is stored with every checkpoint, the final one included,
    +//! in the usage value under [`HELD_FAULT_MEMBER`], and a claim that resumes
    +//! restores it, so a crash anywhere after the hold ends the operation as the
    +//! run without the crash does.
    +
    +use serde_json::json;
    +
    +use super::*;
    +
    +/// The member of the stored usage value that carries a held fault while the
    +/// operation runs: `{"FailureCode": ..., "FailureMessage": ...}`. Only the
    +/// checkpoints write it and only [`restore_held_fault`] reads it. Describe
    +/// renders usage through the named members of `bulk_put::usage` and only
    +/// once the operation is terminal, and the terminal transition stores the
    +/// usage without it, so no response carries it. A record with no such
    +/// member holds no fault.
    +pub(super) const HELD_FAULT_MEMBER: &str = "HeldOutputLocationFault";
    +
    +/// Hold `fault`, found by the location check at the first report record.
    +/// The records waiting for a checkpoint are dropped and no later record is
    +/// kept (`RunState::push_record`), so the persisted record count stays
    +/// where it was. Nothing is rendered on any path out of the run.
    +pub(super) fn hold_location_fault(st: &mut RunState, fault: Failure) {
    +    let pending = u64::try_from(st.pending.len()).unwrap_or(u64::MAX);
    +    st.kept_records = st.kept_records.saturating_sub(pending);
    +    st.kept_bytes = st.kept_bytes.saturating_sub(st.pending_bytes);
    +    st.pending.clear();
    +    st.pending_bytes = 0;
    +    st.skip_render = true;
    +    st.held_location = Some(fault);
    +}
    +
    +/// At the end of the input, make the held fault the operation's failure
    +/// with the counters the service reports for it. A run that ended on a
    +/// cancel or on another failure keeps that ending and its counters. The
    +/// fault stays held, so the final checkpoint stores it with those counters
    +/// and a claim that resumes after that checkpoint ends the same way.
    +pub(super) fn end_with_held_location_fault(st: &mut RunState) {
    +    let Some(fault) = st.held_location.clone() else {
    +        return;
    +    };
    +    if st.cancelled || st.failure.is_some() {
    +        return;
    +    }
    +    // Processed minus failed is the rows written plus the rows whose
    +    // condition failed, which ExtendDB counts as processed; the service's
    +    // count for a condition failure under this fault is not measured.
    +    st.processed = st.processed.saturating_sub(st.failed);
    +    st.failed = 0;
    +    st.completed_bytes = 0;
    +    st.current_bytes = 0;
    +    st.bytes_floor = 0;
    +    st.usage = UsageAccum::default();
    +    st.failure = Some(fault);
    +}
    +
    +/// The usage value a checkpoint stores: the accumulated usage, and the held
    +/// fault under [`HELD_FAULT_MEMBER`] while one is held.
    +pub(super) fn persisted_usage(st: &RunState, table_arn: &str) -> Value {
    +    let mut usage = st.usage.to_value(table_arn);
    +    if let (Some(fault), Some(members)) = (&st.held_location, usage.as_object_mut()) {
    +        members.insert(
    +            HELD_FAULT_MEMBER.to_owned(),
    +            json!({ "FailureCode": fault.code, "FailureMessage": fault.message }),
    +        );
    +    }
    +    usage
    +}
    +
    +/// `st`, restored from `record`, with the fault an earlier claim held: held
    +/// again, the output location taken as checked, and nothing rendered, as in
    +/// the claim that held it.
    +pub(super) fn restore_held_fault(record: &BulkOperationRecord, mut st: RunState) -> RunState {
    +    let held = record.usage.get(HELD_FAULT_MEMBER);
    +    let code = held.and_then(|fault| fault.get("FailureCode")?.as_str());
    +    let message = held.and_then(|fault| fault.get("FailureMessage")?.as_str());
    +    if let (Some(code), Some(message)) = (code, message) {
    +        st.held_location = Some(Failure {
    +            code: Cow::Owned(code.to_owned()),
    +            message: message.to_owned(),
    +        });
    +        st.output_checked = true;
    +        st.skip_render = true;
    +    }
    +    st
    +}
    diff --git a/crates/engine/src/bulk_put/runner/in_place.rs b/crates/engine/src/bulk_put/runner/in_place.rs
    new file mode 100644
    index 000000000..0d66785a5
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/in_place.rs
    @@ -0,0 +1,162 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Waiting on the output store while the lease is kept and the cursor
    +//! stays where it is.
    +//!
    +//! The output check at the first report record, the report writes and the
    +//! manifest write each wait on the store for up to its 120-second bound.
    +//! None of them may run a boundary: the check runs before the checkpoint
    +//! that would persist the record it checks for, and the writes run after
    +//! the final checkpoint. So while one of them waits, the lease is renewed
    +//! by a checkpoint that writes back the progress the record already holds,
    +//! the store having no call that renews the lease alone.
    +//!
    +//! What else the renewal acts on depends on where the run is. The output
    +//! check at the first report record runs while the run still reads input,
    +//! so a cancel or a deleted table seen by its renewal ends the wait as at a
    +//! boundary ([`Watch::CancelAndTable`]). The report and manifest writes run
    +//! after the run decided its outcome at the final checkpoint; their
    +//! renewals end a wait only on a lost lease ([`Watch::LeaseOnly`]), so a
    +//! cancelled or failed run still writes every report and its manifest, and
    +//! a cancel that arrives during the writes is recorded by the terminal
    +//! transition.
    +
    +use super::*;
    +
    +/// What a renewal during a wait acts on besides a lost lease.
    +#[derive(Clone, Copy, Debug, PartialEq, Eq)]
    +pub(super) enum Watch {
    +    /// A cancel or a deleted table ends the wait too.
    +    CancelAndTable,
    +    /// Only a lost lease, or a renewal that cannot run, ends the wait.
    +    LeaseOnly,
    +}
    +
    +/// What a wait on the output store ended with.
    +pub(super) enum InPlace {
    +    /// The store answered.
    +    Answer(T),
    +    /// The lease passed to another process, or the record is gone; the
    +    /// runner stops with no further write.
    +    Lost,
    +    /// The operation was cancelled during the wait (`Watch::CancelAndTable`).
    +    Cancelled,
    +    /// The table was deleted or replaced during the wait
    +    /// (`Watch::CancelAndTable`).
    +    TableGone,
    +    /// The renewal could not read or write the record. Nothing can be
    +    /// decided about ownership, so the runner stops without a terminal
    +    /// transition, as after a failed checkpoint.
    +    Interrupted(StorageError),
    +}
    +
    +/// Await `call`, renewing the lease without moving the cursor while it
    +/// waits. `deadline` is the local lease deadline, advanced by each renewal;
    +/// `None` awaits the call without renewing. The renewal runs at the same
    +/// instants a read's boundary does (`wait::renewal_instant`).
    +///
    +/// The call is polled while a renewal runs. The call may hold a storage
    +/// connection (a report page read, the lease re-read before a rename), and
    +/// a renewal that waited for the pool while the call was not polled could
    +/// wait for that connection forever.
    +pub(super) async fn keeping_lease_in_place(
    +    deps: &RunnerDeps,
    +    record: &BulkOperationRecord,
    +    deadline: &mut Option,
    +    watch: Watch,
    +    call: impl Future,
    +) -> InPlace {
    +    let lease = deps.config.lease;
    +    let started = Instant::now();
    +    let mut call = std::pin::pin!(call);
    +    loop {
    +        let Some(due) = *deadline else {
    +            return InPlace::Answer(call.await);
    +        };
    +        let renew_at = renewal_instant(due, lease, started);
    +        let renewal = async {
    +            tokio::time::sleep_until(tokio::time::Instant::from_std(renew_at)).await;
    +            renew_in_place(deps, record, watch).await
    +        };
    +        tokio::select! {
    +            biased;
    +            answer = &mut call => return InPlace::Answer(answer),
    +            renewed = renewal => match renewed {
    +                Ok(renewed_at) => *deadline = Some(renewed_at + lease),
    +                Err(stop) => return stop,
    +            },
    +        }
    +    }
    +}
    +
    +/// One renewal: read the record, check the table binding when `watch`
    +/// asks for it, write the record's own progress back under the lease, then
    +/// act on the cancel flag the read saw when `watch` asks for it. Answers
    +/// the instant the renewal was sent, from which the new lease runs.
    +async fn renew_in_place(
    +    deps: &RunnerDeps,
    +    record: &BulkOperationRecord,
    +    watch: Watch,
    +) -> Result> {
    +    let account = &record.account_id;
    +    let operation = &record.operation_id;
    +    let current = match deps.storage.get_bulk_operation(account, operation).await {
    +        Ok(Some(current)) => current,
    +        Ok(None) => return Err(InPlace::Lost),
    +        Err(error) => return Err(InPlace::Interrupted(error)),
    +    };
    +    if watch == Watch::CancelAndTable {
    +        match table_binding_holds(deps, record).await {
    +            Ok(true) => {}
    +            Ok(false) => return Err(InPlace::TableGone),
    +            Err(error) => return Err(InPlace::Interrupted(error)),
    +        }
    +    }
    +    // Taken before the call, as at a boundary: the store grants the lease
    +    // from its own clock when the statement runs.
    +    let renewed_at = Instant::now();
    +    match deps
    +        .storage
    +        .checkpoint(
    +            account,
    +            operation,
    +            &deps.owner,
    +            deps.config.lease,
    +            unchanged_progress(¤t),
    +        )
    +        .await
    +    {
    +        Err(error) => return Err(InPlace::Interrupted(error)),
    +        Ok(LeaseState::Lost) => return Err(InPlace::Lost),
    +        Ok(LeaseState::Held) => {}
    +    }
    +    tracing::info!(
    +        account_id = %account,
    +        operation_id = %operation,
    +        "lease renewed while the output store is waited on"
    +    );
    +    if watch == Watch::CancelAndTable && current.cancel_requested {
    +        return Err(InPlace::Cancelled);
    +    }
    +    Ok(renewed_at)
    +}
    +
    +/// The progress the record holds, unchanged: every figure as stored, with
    +/// no report record and no file outcome to add. Written back by a holder
    +/// of the lease it changes nothing but the lease.
    +fn unchanged_progress(current: &BulkOperationRecord) -> Progress {
    +    Progress {
    +        cursor_file_index: current.cursor_file_index,
    +        cursor_row_index: current.cursor_row_index,
    +        processed_item_count: current.processed_item_count,
    +        failed_item_count: current.failed_item_count,
    +        processed_input_bytes: current.processed_input_bytes,
    +        report_record_count: current.report_record_count,
    +        report_bytes: current.report_bytes,
    +        usage: current.usage.clone(),
    +        records: Vec::new(),
    +        finished_files: Vec::new(),
    +        abandoned_files: Vec::new(),
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/runner/manifest.rs b/crates/engine/src/bulk_put/runner/manifest.rs
    new file mode 100644
    index 000000000..b0c68d21d
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/manifest.rs
    @@ -0,0 +1,171 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Rendering the manifest, `manifest-files.json`: one line per enumerated
    +//! input file with its processing status, last counted row and entity tag.
    +
    +use super::*;
    +
    +/// Render the manifest: one line per enumerated input file, in enumeration
    +/// order (measured on the service). A file before the cursor was read to its
    +/// end or abandoned; a file at the cursor was cut short by a cancel or a
    +/// failure; a file after the cursor was never reached. The last counted
    +/// row of a finished file is its row count minus one, or the abandon row
    +/// minus one; of the cursor file, the cursor row minus one. A finished
    +/// file's row count and entity tag, and an abandoned file's abandon, come
    +/// from the run state, filled by this incarnation or restored from the
    +/// figures an earlier incarnation's checkpoint stored on the record; a
    +/// finished file is read again only when neither is there (an abandoned or
    +/// unreached file is hashed, never counted).
    +pub(super) async fn render_manifest(
    +    deps: &RunnerDeps,
    +    record: &BulkOperationRecord,
    +    config: &OperationConfig,
    +    output_bucket: &str,
    +    st: &RunState,
    +) -> Result<(), Failure> {
    +    let account = &record.account_id;
    +    let operation = &record.operation_id;
    +    let mut lines = Vec::with_capacity(record.input_files.len());
    +    let input_at = config.input_at(account);
    +    for (index, file) in record.input_files.iter().enumerate() {
    +        let (status, last) = if st.unavailable_file == Some(index) {
    +            // The object was gone or unreadable when the runner opened or
    +            // read it: no processed range, whatever rows it had written.
    +            // Refused at its open before any of its rows was counted, it
    +            // is `NOT_PROCESSED`; refused after, `NOT_AVAILABLE` (both
    +            // measured on the service).
    +            let refused_unread =
    +                index == st.cursor_file && st.cursor_row == 0 && !st.current_file_opened;
    +            let status = if refused_unread {
    +                report::FileProcessingStatus::NotProcessed
    +            } else {
    +                report::FileProcessingStatus::NotAvailable
    +            };
    +            (status, None)
    +        } else if let Some(abandon) = st.abandoned.get(&index) {
    +            (
    +                report::FileProcessingStatus::PartiallyProcessed,
    +                abandon.row.checked_sub(1),
    +            )
    +        } else if index < st.cursor_file {
    +            // Finished: every line was consumed. The last counted row is the
    +            // row count minus one; a file with no rows has none. The count
    +            // is in the run state for a file this or an earlier incarnation
    +            // finished; a file with no recorded count is counted here.
    +            let count = match st.file_row_counts.get(&index) {
    +                Some(&count) => count,
    +                None => deps
    +                    .source
    +                    .line_count(&input_at, file, config.compression)
    +                    .await
    +                    .map_err(|error| {
    +                        tracing::error!(
    +                            account_id = %account,
    +                            operation_id = %operation,
    +                            path = %file.path,
    +                            error = ?error,
    +                            "input file could not be counted for the manifest"
    +                        );
    +                        source_failure(&error)
    +                    })?,
    +            };
    +            (
    +                report::FileProcessingStatus::FullyProcessed,
    +                count.checked_sub(1),
    +            )
    +        } else if index == st.cursor_file && (st.cursor_row > 0 || st.current_file_opened) {
    +            // The file the run was inside when it ended. ExtendDB
    +            // checkpoints at row boundaries and knows the range, so it is
    +            // reported with the range it processed. The hosted service
    +            // loses the range of a reader that was inside a read when a
    +            // cancel landed and reports `NOT_AVAILABLE` with none;
    +            // the range is kept here, a stated divergence.
    +            (
    +                report::FileProcessingStatus::PartiallyProcessed,
    +                st.cursor_row.checked_sub(1),
    +            )
    +        } else {
    +            // Never opened: the operation ended before it reached this
    +            // file (measured on the service).
    +            (report::FileProcessingStatus::NotProcessed, None)
    +        };
    +        let overwritten = st
    +            .abandoned
    +            .get(&index)
    +            .is_some_and(|a| a.kind == AbandonKind::Overwritten);
    +        // A file read to its end has the tag its read reported. A file
    +        // listed without a tag that this incarnation opened on a store whose
    +        // tags the service assigns has the tag of the first answer the
    +        // store accepted for it, the object the counted rows came from.
    +        let read_tag = st
    +            .file_etags
    +            .get(&index)
    +            .or_else(|| st.opened_etags.get(&index));
    +        let etag = match read_tag {
    +            Some(etag) => etag.clone(),
    +            // The object now at the key replaced the one the operation
    +            // listed, so the line names the listed object by the tag the
    +            // listing gave (measured on the service), and by none when the
    +            // listing gave none: asking the store would name the
    +            // replacement.
    +            None if overwritten => file.etag.clone().unwrap_or_default(),
    +            // A store whose tags the service assigns has no tag for a file
    +            // listed without one that the run never accepted an answer
    +            // for, and the object now at the key may be a replacement.
    +            None if file.etag.is_none() && deps.source.service_assigns_etags() => String::new(),
    +            None => match deps.source.etag(&input_at, file).await {
    +                Ok(etag) => etag,
    +                // An object that is gone or unreadable cannot be hashed,
    +                // and the measured manifest still carries a record for it,
    +                // so the record is written with no tag rather than failing
    +                // the render and replacing the operation's failure code.
    +                Err(error) if st.unavailable_file == Some(index) => {
    +                    tracing::info!(
    +                        account_id = %account,
    +                        operation_id = %operation,
    +                        path = %file.path,
    +                        error = ?error,
    +                        "unavailable input file could not be hashed for the manifest"
    +                    );
    +                    String::new()
    +                }
    +                Err(error) => {
    +                    tracing::error!(
    +                        account_id = %account,
    +                        operation_id = %operation,
    +                        path = %file.path,
    +                        error = ?error,
    +                        "input file could not be hashed for the manifest"
    +                    );
    +                    return Err(source_failure(&error));
    +                }
    +            },
    +        };
    +        lines.push(report::manifest_line(
    +            &config.input_bucket,
    +            &file.path,
    +            status,
    +            last,
    +            &etag,
    +        ));
    +    }
    +    deps.sink
    +        .write_manifest(
    +            &config.output_at(account, output_bucket),
    +            &config.output_prefix,
    +            operation,
    +            &deps.owner,
    +            &config.output_sse,
    +            lines,
    +        )
    +        .await
    +        .map(|_| ())
    +        .map_err(|error| match error {
    +            // A fault the store renders on the write itself, after the
    +            // location check passed (the bucket removed or its policy
    +            // changed in between), keeps the measured manifest form.
    +            SinkError::Store(fault) => Failure::manifest_unavailable(Failure::from_store(&fault)),
    +            other => render_sink_failure(account, operation, &other),
    +        })
    +}
    diff --git a/crates/engine/src/bulk_put/runner/mod.rs b/crates/engine/src/bulk_put/runner/mod.rs
    new file mode 100644
    index 000000000..d1cd8c267
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/mod.rs
    @@ -0,0 +1,336 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The bulk put runner: drives one claimed operation to a terminal state.
    +//!
    +//! The runner reads the enumerated input files in order, applies each row
    +//! with `PutItem` semantics through the same validation and expression
    +//! functions `handle_put_item` uses, and checkpoints cursor, counters,
    +//! usage, and report records after every batch and at the end of every
    +//! file. Every checkpoint and the terminal transition are conditional on
    +//! the lease owner; a lost lease stops the runner without another write.
    +//!
    +//! Every row is applied exactly once. Each write batch records every row
    +//! it applies in the row-outcome ledger, in the batch's own data
    +//! transaction, with what the write found; a claim records itself as the
    +//! operation's write owner before its first batch, and a batch of an
    +//! earlier owner writes nothing. A resumed run reads the rows recorded past
    +//! its checkpoint and accounts for them from the ledger instead of writing
    +//! them again (`runner::resume`).
    +//!
    +//! Report records are rendered to files once, at the end, from the rows
    +//! the checkpoints persisted, so a record is durable exactly when the
    +//! checkpoint covering its row is.
    +
    +pub(in crate::bulk_put::runner) use std::borrow::Cow;
    +pub(in crate::bulk_put::runner) use std::sync::Arc;
    +pub(in crate::bulk_put::runner) use std::time::{Duration, Instant};
    +
    +pub(in crate::bulk_put::runner) use serde_json::Value;
    +
    +pub(in crate::bulk_put::runner) use extenddb_core::error::DynamoDbError;
    +pub(in crate::bulk_put::runner) use extenddb_core::expression::{Expr, ExpressionMaps};
    +pub(in crate::bulk_put::runner) use extenddb_core::limits::LimitsConfig;
    +pub(in crate::bulk_put::runner) use extenddb_core::metrics::{MetricName, MetricsCollector};
    +pub(in crate::bulk_put::runner) use extenddb_core::types::{
    +    DescribeTableInput, InputCompressionType, InputS3Configuration, Item, OutputS3Configuration,
    +    OutputType, TableKeyInfo, TableStatus, item_size_bytes,
    +};
    +pub(in crate::bulk_put::runner) use extenddb_storage::error::StorageError;
    +pub(in crate::bulk_put::runner) use extenddb_storage::{
    +    AbandonKind, AbandonedFile, BatchRow, BatchRowResult, BulkOperationRecord, BulkRowMarker,
    +    CancellationToken, FinishState, FinishedFile, InputFile, LeaseState, Progress, RecordedRow,
    +    ReportKind, ReportRecord, RowOutcome, StorageEngine, Terminal,
    +};
    +
    +pub(in crate::bulk_put::runner) use crate::bulk_put::authorize::{
    +    self, BulkAuthorizer, BulkPrincipal, Decision, S3Action,
    +};
    +pub(in crate::bulk_put::runner) use crate::bulk_put::epoch_millis;
    +pub(in crate::bulk_put::runner) use crate::bulk_put::pacer::{WritePacer, write_units};
    +pub(in crate::bulk_put::runner) use crate::bulk_put::report;
    +pub(in crate::bulk_put::runner) use crate::bulk_put::rows::{self, Row, RowCondition, RowError};
    +pub(in crate::bulk_put::runner) use crate::bulk_put::source::{
    +    BucketRef, BulkSink, BulkSource, InputCodec, LineReader, OutputSse, SinkError, SourceError,
    +    StoreFault, StoreUnavailable,
    +};
    +pub(in crate::bulk_put::runner) use crate::bulk_put::usage::UsageAccum;
    +pub(in crate::bulk_put::runner) use crate::put_item::{
    +    PutPayloadFault, resolve_put_condition, stream_capture_for, validate_put_payload,
    +};
    +
    +mod admit;
    +mod cut;
    +mod failure;
    +mod finalize;
    +mod held;
    +mod in_place;
    +mod manifest;
    +mod outputs;
    +mod permission;
    +mod read;
    +mod reader;
    +mod recorded;
    +mod resume;
    +mod state;
    +pub(crate) use failure::OBJECTS_COUNT_LIMIT_CODE;
    +mod wait;
    +mod write;
    +
    +pub(in crate::bulk_put::runner) use admit::*;
    +pub(in crate::bulk_put::runner) use cut::*;
    +pub(in crate::bulk_put::runner) use failure::*;
    +pub(in crate::bulk_put::runner) use finalize::*;
    +pub(in crate::bulk_put::runner) use held::*;
    +pub(in crate::bulk_put::runner) use in_place::*;
    +pub(in crate::bulk_put::runner) use manifest::*;
    +pub(in crate::bulk_put::runner) use outputs::*;
    +pub(in crate::bulk_put::runner) use permission::*;
    +pub(in crate::bulk_put::runner) use read::*;
    +pub(in crate::bulk_put::runner) use reader::*;
    +pub(in crate::bulk_put::runner) use recorded::*;
    +#[cfg(test)]
    +pub(crate) use resume::RECORDED_PAGE;
    +pub(in crate::bulk_put::runner) use resume::*;
    +pub(in crate::bulk_put::runner) use state::*;
    +pub(in crate::bulk_put::runner) use wait::*;
    +pub(in crate::bulk_put::runner) use write::*;
    +
    +/// Wire statuses the runner writes or reacts to.
    +const STATUS_CANCELLING: &str = "CANCELLING";
    +const STATUS_COMPLETED: &str = "COMPLETED";
    +const STATUS_COMPLETED_WITH_ERRORS: &str = "COMPLETED_WITH_ERRORS";
    +const STATUS_CANCELLED: &str = "CANCELLED";
    +const STATUS_FAILED: &str = "FAILED";
    +
    +/// The in-memory report buffer is flushed to a checkpoint once the records
    +/// pending since the last checkpoint reach this many serialized bytes,
    +/// regardless of `bulk_put_batch_size`. This caps the heap a single runner
    +/// holds for its report buffer on failing-heavy input: without it, a large
    +/// `batch_size` lets `pending` grow to `batch_size` records before a flush.
    +/// 16 MiB is far above a normal batch's failures and far below a memory
    +/// hazard, and a record is at most one item image, so one record never
    +/// overshoots it on its own.
    +const PENDING_FLUSH_BYTES: u64 = 16 * 1024 * 1024;
    +
    +/// `FailureCode` values. The table-deleted code is the measured string,
    +/// which is not the wire exception name; the others are exception type
    +/// names.
    +const CODE_RESOURCE_NOT_FOUND: &str = "DynamoDBResourceNotFoundException";
    +/// The measured `FailureMessage` for a table deleted mid-operation.
    +const MESSAGE_RESOURCE_NOT_FOUND: &str = "Requested resource not found";
    +const CODE_VALIDATION: &str = "ValidationException";
    +const CODE_INTERNAL: &str = "InternalServerError";
    +
    +/// The one `FailureMessage` every internal fault carries; detail goes to
    +/// the log, never to a client.
    +const INTERNAL_MESSAGE: &str = "Internal server error";
    +
    +/// Tuning for one runner, sourced from configuration by the caller.
    +pub(crate) struct RunnerConfig {
    +    /// Rows per checkpoint (default 25).
    +    pub batch_size: usize,
    +    /// Rows per storage transaction (default 25, at most 100). Staged rows
    +    /// are flushed when this many are waiting, at every checkpoint, at the
    +    /// end of every file, and before every flow that leaves the read loop.
    +    pub write_batch_rows: usize,
    +    /// Longest accepted input line in bytes (4 MiB).
    +    pub max_line_bytes: usize,
    +    /// Physical lines one operation may consume, blank lines included;
    +    /// exceeding it fails the operation. A bound on the work one
    +    /// operation can demand, not on the customer-visible
    +    /// `ProcessedItemCount`.
    +    pub max_item_count: u64,
    +    /// Report records kept per operation; beyond it records are dropped.
    +    pub max_report_records: u64,
    +    /// Serialized bytes of report records kept per operation; beyond it
    +    /// records are dropped.
    +    pub max_report_bytes: u64,
    +    /// Lease length passed to every checkpoint.
    +    pub lease: Duration,
    +}
    +
    +/// Everything a runner needs to drive one operation.
    +pub(crate) struct RunnerDeps {
    +    pub storage: Arc,
    +    pub source: Arc,
    +    pub sink: Arc,
    +    pub limits: Arc,
    +    pub region: Arc,
    +    /// Lease owner id of this process.
    +    pub owner: String,
    +    pub config: RunnerConfig,
    +    /// Collector for the terminal-transition metrics: `finalize` records
    +    /// `BulkOperationsFinished` and `BulkPutItemsWritten`.
    +    pub metrics: Arc,
    +    /// The process-wide write pacer, shared by every runner in this server
    +    /// and sized by `bulk_put_write_units_per_second`. Every row write takes
    +    /// its units here first. See [`crate::bulk_put::pacer`].
    +    pub pacer: Arc,
    +    /// The kind of the store `source` and `sink` belong to
    +    /// ([`crate::bulk_put::run_context::store_kind`]).
    +    pub store_kind: &'static str,
    +    /// Evaluates the S3 actions the run performs as the operation's caller
    +    /// (`runner::permission`); `None` in developer mode, where no check
    +    /// runs.
    +    pub authorizer: Option>,
    +    /// The server's shutdown token. Once it is cancelled the runner stops at
    +    /// the next row: it writes the staged rows, checkpoints with a lease
    +    /// that has already run out, so the next claim need not wait for it,
    +    /// and returns without a terminal transition.
    +    pub shutdown: CancellationToken,
    +}
    +
    +/// The borrowed context of one run, fixed once `run` has validated the
    +/// claim: the process dependencies plus the operation's record, parsed
    +/// configuration, and table key metadata. The file and row steps take it
    +/// as one parameter.
    +#[derive(Clone, Copy)]
    +struct RunContext<'a> {
    +    deps: &'a RunnerDeps,
    +    record: &'a BulkOperationRecord,
    +    config: &'a OperationConfig,
    +    /// The operation's own write ceiling, from `MaxWriteRequestUnits` when
    +    /// the request carried one; unbounded otherwise.
    +    op_pacer: &'a WritePacer,
    +}
    +
    +/// A programming or persistence fault the runner cannot record. The
    +/// operation is left non-terminal; the next claim after the lease expires
    +/// resumes it from the last committed checkpoint.
    +#[derive(Debug)]
    +// The payload is read through the derived Debug impl in the worker's error
    +// log line, which dead code analysis deliberately ignores.
    +#[allow(dead_code)]
    +pub(crate) enum RunnerError {
    +    /// The catalog refused a checkpoint or terminal write for a reason
    +    /// other than lease loss, or the claimed record's persisted counters
    +    /// are corrupt (negative) and the runner declined the claim.
    +    Storage(StorageError),
    +}
    +
    +/// Drive one claimed operation to a terminal state. Returns `Ok(())` when
    +/// the operation was finished by this runner, and also when the lease was
    +/// lost and the runner stopped cleanly. `Err` is reserved for faults the
    +/// runner cannot record, such as a catalog that refuses the checkpoint.
    +pub(crate) async fn run(deps: &RunnerDeps, record: BulkOperationRecord) -> Result<(), RunnerError> {
    +    // A record whose persisted counters do not restore is corrupt; the
    +    // runner declines the claim rather than running on made-up state.
    +    // A fault an earlier claim held is held again (`runner::held`).
    +    let st = RunState::restore(&record).map_err(RunnerError::Storage)?;
    +    let mut st = restore_held_fault(&record, st);
    +    // The claim that handed this record over granted a fresh lease an
    +    // instant ago; the store's clock and this one may differ, so the local
    +    // deadline is a floor on when the lease can have run out, never a
    +    // promise that it still holds.
    +    st.lease_deadline = Some(Instant::now() + deps.config.lease);
    +    let config = match OperationConfig::parse(&record) {
    +        Ok(config) => Some(config),
    +        Err(detail) => {
    +            tracing::error!(
    +                account_id = %record.account_id,
    +                operation_id = %record.operation_id,
    +                detail,
    +                "bulk operation configuration does not parse"
    +            );
    +            None
    +        }
    +    };
    +
    +    // A record started on another store than this server's (the operator
    +    // changed `[bulk] store` with operations in flight) is neither read
    +    // nor rendered here: it ends with the internal failure.
    +    let recorded = match check_recorded_store(deps, &record) {
    +        Ok(recorded) => recorded,
    +        Err(detail) => {
    +            tracing::error!(
    +                account_id = %record.account_id,
    +                operation_id = %record.operation_id,
    +                configured_store = deps.store_kind,
    +                detail,
    +                "bulk operation was not started on the configured store; it is failed unread"
    +            );
    +            st.failure = Some(Failure::internal());
    +            return finalize(deps, &record, None, st).await;
    +        }
    +    };
    +    let config = config.map(|config| OperationConfig {
    +        caller: recorded.principal,
    +        ..config
    +    });
    +
    +    // Before anything is written: this claim becomes the operation's only
    +    // write owner, and the rows applied past the checkpoint are loaded.
    +    if !take_over(deps, &record, &mut st).await? {
    +        // The resume bound is spent. A table deleted meanwhile ends the
    +        // operation with the deleted-table failure, as every other claim
    +        // after the deletion would; a pending cancel outranks both.
    +        if record.status != STATUS_CANCELLING
    +            && matches!(table_binding_holds(deps, &record).await, Ok(false))
    +        {
    +            st.failure = Some(Failure::table_not_found());
    +        }
    +        return finalize(deps, &record, config.as_ref(), st).await;
    +    }
    +
    +    // Step 1: a claimed CANCELLING operation reads no input. The persisted
    +    // counters and records are rendered and the operation ends CANCELLED.
    +    if record.status == STATUS_CANCELLING {
    +        st.cancelled = true;
    +        return finalize(deps, &record, config.as_ref(), st).await;
    +    }
    +    let Some(config) = config else {
    +        st.failure = Some(Failure::internal());
    +        return finalize(deps, &record, None, st).await;
    +    };
    +
    +    // Step 2: the table binding, then the key metadata. The metadata is
    +    // read again at every checkpoint boundary (see `refresh_key_info`).
    +    match table_binding_holds(deps, &record).await {
    +        Ok(true) => {}
    +        Ok(false) => {
    +            st.failure = Some(Failure::table_not_found());
    +            return finalize(deps, &record, Some(&config), st).await;
    +        }
    +        Err(error) => {
    +            tracing::error!(
    +                account_id = %record.account_id,
    +                operation_id = %record.operation_id,
    +                error = %error,
    +                "table binding check failed"
    +            );
    +            st.failure = Some(Failure::internal());
    +            return finalize(deps, &record, Some(&config), st).await;
    +        }
    +    }
    +    let key_info = match deps
    +        .storage
    +        .table_key_info(&record.account_id, &record.table_name)
    +        .await
    +    {
    +        Ok(info) if info.table_id == record.table_id => info,
    +        // A different table_id under the same name, or a table the data
    +        // plane cannot serve, ends the operation the way a deleted table
    +        // does.
    +        Ok(_) | Err(StorageError::TableNotFound(_) | StorageError::TableNotActive(_)) => {
    +            st.failure = Some(Failure::table_not_found());
    +            return finalize(deps, &record, Some(&config), st).await;
    +        }
    +        Err(error) => {
    +            tracing::error!(
    +                account_id = %record.account_id,
    +                operation_id = %record.operation_id,
    +                error = %error,
    +                "table key info fetch failed"
    +            );
    +            st.failure = Some(Failure::internal());
    +            return finalize(deps, &record, Some(&config), st).await;
    +        }
    +    };
    +    st.key_info = key_info;
    +
    +    drive(deps, &record, &config, st).await
    +}
    +
    +#[cfg(test)]
    +mod tests;
    diff --git a/crates/engine/src/bulk_put/runner/outputs.rs b/crates/engine/src/bulk_put/runner/outputs.rs
    new file mode 100644
    index 000000000..64b93516e
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/outputs.rs
    @@ -0,0 +1,179 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Writing the reports and the manifest at the end of a run, with the lease
    +//! kept while the output store is waited on (`in_place`). The run decided
    +//! its outcome at the final checkpoint, so only a lost lease ends a write
    +//! ([`Watch::LeaseOnly`]): a cancelled or failed run writes every report
    +//! and its manifest, and a cancel that arrives now is recorded by the
    +//! terminal transition.
    +
    +use super::*;
    +
    +/// How writing the outputs ended.
    +pub(super) enum Outputs {
    +    /// Go on to the terminal transition.
    +    Finish,
    +    /// The lease passed to another process; the runner stops with no
    +    /// further write.
    +    Stop,
    +    /// The lease could not be read or renewed; the runner stops without a
    +    /// terminal transition.
    +    Interrupted(StorageError),
    +    /// The output store was unavailable past its retries; the operation
    +    /// resumes and writes its outputs again (`finalize`).
    +    Unavailable,
    +}
    +
    +/// The answer of a held call, or how the outputs end when its lease
    +/// renewal saw the lease lost or could not run.
    +fn answer_of(held: InPlace, record: &BulkOperationRecord) -> Result {
    +    match held {
    +        InPlace::Answer(answer) => Ok(answer),
    +        InPlace::Lost => {
    +            tracing::info!(
    +                account_id = %record.account_id,
    +                operation_id = %record.operation_id,
    +                "lease lost while a report or the manifest was written; the runner stops"
    +            );
    +            Err(Outputs::Stop)
    +        }
    +        InPlace::Interrupted(error) => Err(Outputs::Interrupted(error)),
    +        // A `Watch::LeaseOnly` renewal reads neither the cancel flag nor
    +        // the table, so neither answer is produced for a write. Were one
    +        // produced, the write it ended is not done; leaving the operation
    +        // for the next claim, which writes the outputs again, loses none.
    +        InPlace::Cancelled | InPlace::TableGone => Err(Outputs::Interrupted(
    +            StorageError::Internal("an output write was ended by a cancel or a table check".into()),
    +        )),
    +    }
    +}
    +
    +/// Render the reports, then check the manifest's location and write the
    +/// manifest, each call keeping the lease while it waits.
    +pub(super) async fn render_outputs(
    +    deps: &RunnerDeps,
    +    record: &BulkOperationRecord,
    +    config: &OperationConfig,
    +    output_bucket: &str,
    +    st: &mut RunState,
    +    deadline: &mut Option,
    +) -> Outputs {
    +    match write_outputs(deps, record, config, output_bucket, st, deadline).await {
    +        Ok(()) => Outputs::Finish,
    +        Err(outputs) => outputs,
    +    }
    +}
    +
    +async fn write_outputs(
    +    deps: &RunnerDeps,
    +    record: &BulkOperationRecord,
    +    config: &OperationConfig,
    +    output_bucket: &str,
    +    st: &mut RunState,
    +    deadline: &mut Option,
    +) -> Result<(), Outputs> {
    +    let account = &record.account_id;
    +    let operation = &record.operation_id;
    +    let rendered = keeping_lease_in_place(
    +        deps,
    +        record,
    +        deadline,
    +        Watch::LeaseOnly,
    +        render_reports(deps, record, config, output_bucket),
    +    )
    +    .await;
    +    match answer_of(rendered, record)? {
    +        Ok(Render::Rendered) => {}
    +        Ok(Render::LeaseLost) => {
    +            tracing::info!(
    +                account_id = %account,
    +                operation_id = %operation,
    +                "lease lost during report rendering; the runner stops"
    +            );
    +            return Err(Outputs::Stop);
    +        }
    +        Ok(Render::Interrupted(error)) => return Err(Outputs::Interrupted(error)),
    +        Err(failure) if failure.is_store_unavailable() => return Err(Outputs::Unavailable),
    +        // An earlier failure (an input object gone mid-run, a cancel's
    +        // outcome) keeps precedence over a render failure: the first
    +        // fault the run met is the one reported. Measured only for the
    +        // bucket-removed case on its own; the combination is
    +        // ExtendDB's choice.
    +        Err(failure) => {
    +            if st.failure.is_none() {
    +                st.failure = Some(failure);
    +            }
    +        }
    +    }
    +    // The manifest follows the reports (measured on the service: it lands
    +    // after the report objects). It is written whenever the operation
    +    // read input, whatever the terminal status; a born-terminal
    +    // operation never reaches this function with a configuration. An
    +    // output location that the first report record never checked
    +    // (no failing rows) is checked here: a configured owner that is
    +    // not the caller, or a missing bucket, fails the operation with the
    +    // location fault's code and the measured "Manifest is not
    +    // available." prefix.
    +    // A bucket removed after report records were written reaches the
    +    // same shape: the hosted service finished every row and reported
    +    // the missing bucket from the manifest write,
    +    // so a render that found the bucket gone yields to this
    +    // check's form of the same fault.
    +    let render_lost_bucket = st
    +        .failure
    +        .as_ref()
    +        .is_some_and(|failure| failure.code == Failure::output_bucket_missing().code);
    +    let manifest_key = report::manifest_key(&config.output_prefix, account, operation);
    +    // The caller's s3:PutObject on the manifest comes first; a denial
    +    // takes the location fault's place in the measured form.
    +    let denied = put_object_failure(deps, record, config, output_bucket, &manifest_key).await;
    +    let location = match denied {
    +        Some(failure) => Err(failure),
    +        None => {
    +            let checked = keeping_lease_in_place(
    +                deps,
    +                record,
    +                deadline,
    +                Watch::LeaseOnly,
    +                deps.sink.check_location(
    +                    &config.output_at(account, output_bucket),
    +                    &manifest_key,
    +                    &config.output_sse,
    +                ),
    +            )
    +            .await;
    +            match answer_of(checked, record)? {
    +                Ok(()) => Ok(()),
    +                Err(SinkError::Store(fault)) => Err(Failure::from_store(&fault)),
    +                Err(error) => Err(render_sink_failure(account, operation, &error)),
    +            }
    +        }
    +    };
    +    match location {
    +        Err(fault) if fault.is_store_unavailable() => return Err(Outputs::Unavailable),
    +        Ok(()) => {
    +            let written = keeping_lease_in_place(
    +                deps,
    +                record,
    +                deadline,
    +                Watch::LeaseOnly,
    +                render_manifest(deps, record, config, output_bucket, &*st),
    +            )
    +            .await;
    +            match answer_of(written, record)? {
    +                Err(failure) if failure.is_store_unavailable() => {
    +                    return Err(Outputs::Unavailable);
    +                }
    +                Err(failure) if st.failure.is_none() => st.failure = Some(failure),
    +                _ => {}
    +            }
    +        }
    +        Err(fault) => {
    +            if st.failure.is_none() || render_lost_bucket {
    +                st.failure = Some(Failure::manifest_unavailable(fault));
    +            }
    +        }
    +    }
    +    Ok(())
    +}
    diff --git a/crates/engine/src/bulk_put/runner/permission.rs b/crates/engine/src/bulk_put/runner/permission.rs
    new file mode 100644
    index 000000000..d73e9f1e0
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/permission.rs
    @@ -0,0 +1,121 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The runner's checks of the S3 actions the run performs as the
    +//! operation's caller: `s3:GetObject` on an input object before it is opened
    +//! and at every checkpoint boundary while it is read, and `s3:PutObject` on a
    +//! report object before the first record or page that writes it and on the
    +//! manifest. In developer mode no check runs (`RunnerDeps::authorizer`).
    +
    +use super::*;
    +
    +/// The decision for `action` on `resource` for the operation's recorded
    +/// caller.
    +pub(super) async fn s3_decision(
    +    deps: &RunnerDeps,
    +    record: &BulkOperationRecord,
    +    config: &OperationConfig,
    +    action: S3Action,
    +    resource: &str,
    +) -> Decision {
    +    authorize::decide(
    +        deps.authorizer.as_deref(),
    +        config.caller.as_ref(),
    +        action,
    +        resource,
    +        None,
    +        &record.account_id,
    +        &record.operation_id,
    +    )
    +    .await
    +}
    +
    +/// The failure a decision that is not `Allowed` ends the operation with.
    +pub(super) fn decision_failure(decision: Decision) -> Option {
    +    match decision {
    +        Decision::Allowed => None,
    +        Decision::Denied(message) => Some(Failure {
    +            code: Cow::Borrowed("S3AccessDenied"),
    +            message,
    +        }),
    +        Decision::Unavailable => Some(Failure::internal()),
    +    }
    +}
    +
    +/// The decision for `s3:PutObject` on the object `key` of the output
    +/// bucket. `key` is evaluated first and then, when the store writes the
    +/// object under another key (`BulkSink::stored_key`), that key: the write is
    +/// allowed only when both are, and a denial names the key whose evaluation
    +/// denied.
    +pub(super) async fn put_object_decision(
    +    deps: &RunnerDeps,
    +    record: &BulkOperationRecord,
    +    config: &OperationConfig,
    +    bucket: &str,
    +    key: &str,
    +) -> Decision {
    +    let stored = deps.sink.stored_key(key);
    +    let keys = std::iter::once(key).chain((stored != key).then_some(stored.as_str()));
    +    for key in keys {
    +        let resource = authorize::object_arn(bucket, key);
    +        let decision = s3_decision(deps, record, config, S3Action::PutObject, &resource).await;
    +        if decision != Decision::Allowed {
    +            return decision;
    +        }
    +    }
    +    Decision::Allowed
    +}
    +
    +/// The failure for `s3:PutObject` on the object `key` of the output bucket,
    +/// or `None` when the caller may write it (`put_object_decision`).
    +pub(super) async fn put_object_failure(
    +    deps: &RunnerDeps,
    +    record: &BulkOperationRecord,
    +    config: &OperationConfig,
    +    bucket: &str,
    +    key: &str,
    +) -> Option {
    +    decision_failure(put_object_decision(deps, record, config, bucket, key).await)
    +}
    +
    +/// `s3:GetObject` on an input object, before the runner opens it, on a
    +/// resumed claim as well. A denial ends the operation `S3AccessDenied`;
    +/// rows already written stay written, and the manifest reports the file
    +/// `NOT_PROCESSED` when none of its rows was counted and `NOT_AVAILABLE`
    +/// otherwise (measured on the service).
    +pub(super) async fn check_get_at_open(
    +    ctx: &RunContext<'_>,
    +    st: &mut RunState,
    +    file: &InputFile,
    +    file_index: usize,
    +) -> Option {
    +    let resource = authorize::object_arn(&ctx.config.input_bucket, &file.path);
    +    let decision = s3_decision(
    +        ctx.deps,
    +        ctx.record,
    +        ctx.config,
    +        S3Action::GetObject,
    +        &resource,
    +    )
    +    .await;
    +    if matches!(decision, Decision::Denied(_)) {
    +        st.unavailable_file = Some(file_index);
    +    }
    +    st.failure = Some(decision_failure(decision)?);
    +    Some(Flow::Finish)
    +}
    +
    +/// `s3:GetObject` on the input object being read, at a checkpoint boundary
    +/// inside it, so a permission removed while the object is read takes effect
    +/// at the next boundary (measured on the service for a bucket policy changed
    +/// mid-object: the operation fails `S3AccessDenied` and the file is
    +/// `NOT_AVAILABLE`). A boundary at the end of a file checks nothing: the next
    +/// file is checked when it is opened.
    +pub(super) async fn check_get_at_boundary(ctx: &RunContext<'_>, st: &mut RunState) -> Option {
    +    if !st.current_file_opened {
    +        return None;
    +    }
    +    let file = ctx.record.input_files.get(st.cursor_file)?;
    +    let file_index = st.cursor_file;
    +    check_get_at_open(ctx, st, file, file_index).await
    +}
    diff --git a/crates/engine/src/bulk_put/runner/read.rs b/crates/engine/src/bulk_put/runner/read.rs
    new file mode 100644
    index 000000000..bd02358f3
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/read.rs
    @@ -0,0 +1,568 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The read side: driving one operation file by file, resuming across a
    +//! crash by skipping a committed region, checking the output configuration
    +//! at the first record, and the end-of-file and abandon bookkeeping.
    +
    +use super::*;
    +
    +/// Whether the catalog still holds the operation's table: same `table_id`,
    +/// status `ACTIVE` or `UPDATING`. A missing table answers false; any other
    +/// catalog failure is the caller's to map.
    +pub(super) async fn table_binding_holds(
    +    deps: &RunnerDeps,
    +    record: &BulkOperationRecord,
    +) -> Result {
    +    let input = DescribeTableInput {
    +        table_name: record.table_name.clone(),
    +    };
    +    match deps.storage.describe_table(&record.account_id, input).await {
    +        Ok(description) => Ok(description.table_id == record.table_id
    +            && matches!(
    +                description.table_status,
    +                TableStatus::Active | TableStatus::Updating
    +            )),
    +        Err(StorageError::TableNotFound(_)) => Ok(false),
    +        Err(error) => Err(error),
    +    }
    +}
    +
    +/// What the caller of a row or boundary step does next.
    +pub(super) enum Flow {
    +    /// Keep reading the current file.
    +    NextRow,
    +    /// The current file is finished or abandoned; move to the next one.
    +    FileDone,
    +    /// The lease is lost or the record is gone; stop without writing.
    +    Stop,
    +    /// A terminal condition is set on the state; run the end sequence.
    +    Finish,
    +}
    +
    +/// The per-operation input quotas, checked against the enumerated file
    +/// list before any file is opened. The hosted service accepts the Start
    +/// and ends the operation `FAILED` with its own code for each class
    +/// (measured on the service), so the runner answers the same
    +/// way for a record whose enumeration no longer fits the configured caps.
    +///
    +/// The returned flag says whether the output prefix is left untouched:
    +/// the count class writes neither reports nor manifest, while
    +/// the size class writes a manifest with the file `NOT_PROCESSED`.
    +/// `None` means both quotas hold and the files are read.
    +fn input_quota_failure(
    +    deps: &RunnerDeps,
    +    record: &BulkOperationRecord,
    +    config: &OperationConfig,
    +) -> Option<(Failure, bool)> {
    +    let limits = &deps.limits;
    +    let count = u64::try_from(record.input_files.len()).unwrap_or(u64::MAX);
    +    if count > limits.max_bulk_input_files {
    +        let failure = Failure::objects_count_limit_exceeded(
    +            &config.input_prefix,
    +            limits.max_bulk_input_files,
    +        );
    +        return Some((failure, true));
    +    }
    +    let over_size = record
    +        .input_files
    +        .iter()
    +        .any(|file| u64::try_from(file.size).unwrap_or(0) > limits.max_bulk_input_object_bytes);
    +    over_size.then(|| {
    +        (
    +            Failure::input_object_too_large(limits.max_bulk_input_object_bytes),
    +            false,
    +        )
    +    })
    +}
    +
    +/// Steps 4 to 8: read every file from the restored cursor, then run the
    +/// end sequence. Step 10 falls out of the loop: an empty file list goes
    +/// straight to the end sequence with zero counters.
    +pub(super) async fn drive(
    +    deps: &RunnerDeps,
    +    record: &BulkOperationRecord,
    +    config: &OperationConfig,
    +    mut st: RunState,
    +) -> Result<(), RunnerError> {
    +    if let Some((failure, skip_render)) = input_quota_failure(deps, record, config) {
    +        tracing::warn!(
    +            account_id = %record.account_id,
    +            operation_id = %record.operation_id,
    +            input_files = record.input_files.len(),
    +            max_bulk_input_files = deps.limits.max_bulk_input_files,
    +            max_bulk_input_object_bytes = deps.limits.max_bulk_input_object_bytes,
    +            failure_code = %failure.code,
    +            "bulk operation input passes a configured quota; no file is read"
    +        );
    +        st.failure = Some(failure);
    +        st.skip_render = skip_render;
    +        return finalize(deps, record, Some(config), st).await;
    +    }
    +    // `MaxWriteRequestUnits` was validated at Start to be at least 1 when
    +    // present; a stored value outside that range (hand-edited catalog) is
    +    // treated as absent rather than refusing the claim.
    +    let op_pacer = WritePacer::new(
    +        record
    +            .max_write_request_units
    +            .and_then(|units| u64::try_from(units).ok())
    +            .unwrap_or(0),
    +    );
    +    let ctx = RunContext {
    +        deps,
    +        record,
    +        config,
    +        op_pacer: &op_pacer,
    +    };
    +    let mut file_index = st.cursor_file;
    +    let mut skip_rows = st.cursor_row;
    +    while file_index < record.input_files.len() {
    +        let file = &record.input_files[file_index];
    +        let file_size = u64::try_from(file.size).unwrap_or(0);
    +        st.cursor_file = file_index;
    +        let step = read_file(&ctx, &mut st, file, file_index, file_size, skip_rows).await?;
    +        skip_rows = 0;
    +        match step {
    +            Flow::Stop => return Ok(()),
    +            Flow::Finish => break,
    +            Flow::FileDone | Flow::NextRow => file_index += 1,
    +        }
    +    }
    +    let paced = op_pacer.waited();
    +    if !paced.is_zero() {
    +        tracing::info!(
    +            account_id = %record.account_id,
    +            operation_id = %record.operation_id,
    +            paced_ms = paced.as_millis(),
    +            max_write_request_units = ?record.max_write_request_units,
    +            "bulk operation writes paced by MaxWriteRequestUnits"
    +        );
    +    }
    +    end_with_held_location_fault(&mut st);
    +    finalize(deps, record, Some(config), st).await
    +}
    +
    +/// Resume: skip the physical lines the restored checkpoint already
    +/// covered. Their counters and records are already persisted. Skipped
    +/// lines count toward the batch boundary and the line cap so the lease
    +/// renews, a cancel is observed, and the work bound holds at the same
    +/// cadence as fresh reads. `Some` ends the file or the run; `None` hands
    +/// the reader back positioned for fresh reads.
    +pub(super) async fn skip_covered_region(
    +    ctx: &RunContext<'_>,
    +    st: &mut RunState,
    +    reader: &mut dyn LineReader,
    +    file: &InputFile,
    +    file_index: usize,
    +    file_size: u64,
    +    skip_rows: u64,
    +) -> Result, RunnerError> {
    +    let RunContext { deps, record, .. } = *ctx;
    +    let mut row_index: u64 = 0;
    +    while row_index < skip_rows {
    +        let next =
    +            match keeping_lease(ctx, st, reader.next_line(deps.config.max_line_bytes)).await? {
    +                Waited::Answer(next) => next,
    +                Waited::Flow(flow) => return Ok(Some(flow)),
    +            };
    +        match next {
    +            Ok(Some(_)) => {
    +                row_index += 1;
    +                if st.consume_line(deps.config.max_item_count) {
    +                    return Ok(Some(Flow::Finish));
    +                }
    +                if st.checkpoint_due(deps.config.batch_size) || deps.shutdown.is_cancelled() {
    +                    match boundary(ctx, st).await? {
    +                        Flow::NextRow => {}
    +                        other => return Ok(Some(other)),
    +                    }
    +                }
    +            }
    +            Ok(None) => {
    +                let etag = reader.etag();
    +                return end_of_file(ctx, st, file_index, file_size, etag)
    +                    .await
    +                    .map(Some);
    +            }
    +            // The object was overwritten while the covered region was read
    +            // again: the rows the earlier claim counted stand, and so do
    +            // their bytes, as at a resumed open.
    +            Err(SourceError::Overwritten) => {
    +                let counted = st.bytes_floor.saturating_sub(st.completed_bytes);
    +                return record_overwritten(
    +                    ctx, st, file, file_index, skip_rows, counted, file_size,
    +                )
    +                .await
    +                .map(Some);
    +            }
    +            // A line over the line limit, or one that is not valid UTF-8,
    +            // inside the covered region means the file changed since a
    +            // committed checkpoint counted those rows, as an early end of
    +            // file does. The file is abandoned
    +            // with no record and no counter change; a resume must never
    +            // re-count or re-record a row index a committed checkpoint
    +            // covers.
    +            Err(error) if skip_fault_abandons(&error) => {
    +                tracing::warn!(
    +                    account_id = %record.account_id,
    +                    operation_id = %record.operation_id,
    +                    path = %file.path,
    +                    error = ?error,
    +                    "bulk input file changed under the cursor; the file is abandoned"
    +                );
    +                return end_of_file(ctx, st, file_index, file_size, None)
    +                    .await
    +                    .map(Some);
    +            }
    +            // Any other read fault does not imply a changed file and fails
    +            // the operation exactly as it would on a first read.
    +            Err(error) => {
    +                tracing::error!(
    +                    account_id = %record.account_id,
    +                    operation_id = %record.operation_id,
    +                    path = %file.path,
    +                    error = ?error,
    +                    "bulk input file failed mid-read"
    +                );
    +                if is_unavailable_object(&error) {
    +                    st.unavailable_file = Some(file_index);
    +                }
    +                st.failure = Some(source_failure(&error));
    +                return Ok(Some(Flow::Finish));
    +            }
    +        }
    +    }
    +    Ok(None)
    +}
    +
    +/// The output configuration, checked when report records are produced. The
    +/// caller's `s3:PutObject` on object 0 of each report kind is checked at
    +/// that kind's first record in this claim, and the sink's location check
    +/// runs once, at the operation's first record (the kept count moves from
    +/// zero to one): on the filesystem store the output bucket directory must
    +/// exist, decided with the sink's handle primitives, and the output
    +/// `S3BucketOwner`, when given, must be the caller's account. Measured in
    +/// both directions: a missing output bucket, or a mismatched owner, with no
    +/// failing rows completes, and the first record finds the fault, which
    +/// fails the operation with `S3NoSuchBucket` or `S3AccessDenied`. Ownership
    +/// is decided before existence, as at Start.
    +///
    +/// A denial, or a fault the store renders, is held (`hold_location_fault`):
    +/// the run reads on to the end of the input, writing and checkpointing
    +/// rows, and then fails with it (`end_with_held_location_fault`). Any other
    +/// check failure is the server's: the run state reverts to the last
    +/// checkpoint and the operation ends at once with the internal failure.
    +/// Nothing is rendered on either path (the records were never validated
    +/// against the output configuration); the render-time missing-bucket
    +/// failure stays as a last line of defence. A claim that resumes checks
    +/// each kind again at its own first record; the policies decide the same
    +/// way unless they changed.
    +///
    +/// Returns `Some(Flow::Finish)` when the operation must end, `None` to
    +/// continue.
    +pub(super) async fn check_output_config_at_first_record(
    +    ctx: &RunContext<'_>,
    +    st: &mut RunState,
    +) -> Option {
    +    let RunContext {
    +        deps,
    +        record,
    +        config,
    +        ..
    +    } = *ctx;
    +    if st.kept_records == 0 || st.held_location.is_some() {
    +        return None;
    +    }
    +    let Some(bucket) = config.output_bucket.as_deref() else {
    +        // No report is written; there is nothing to validate.
    +        st.output_checked = true;
    +        return None;
    +    };
    +    // The caller's s3:PutObject on object 0 of each kind waiting for a
    +    // checkpoint and not yet checked, oldest record first, is held the same
    +    // way as a location fault (measured on the service: a bucket policy
    +    // denying the report write). A check that cannot be evaluated is the
    +    // server's fault.
    +    let mut kinds: Vec = Vec::new();
    +    for pending in &st.pending {
    +        if !st.put_checked.contains(&pending.kind) && !kinds.contains(&pending.kind) {
    +            kinds.push(pending.kind);
    +        }
    +    }
    +    for kind in kinds {
    +        st.put_checked.push(kind);
    +        let key = report::report_key(
    +            &config.output_prefix,
    +            &record.account_id,
    +            &record.operation_id,
    +            kind,
    +            0,
    +        );
    +        match put_object_decision(deps, record, config, bucket, &key).await {
    +            Decision::Allowed => {}
    +            Decision::Denied(message) => {
    +                st.output_checked = true;
    +                hold_location_fault(
    +                    st,
    +                    Failure {
    +                        code: Cow::Borrowed("S3AccessDenied"),
    +                        message,
    +                    },
    +                );
    +                return None;
    +            }
    +            Decision::Unavailable => return Some(end_at_first_record(ctx, st).await),
    +        }
    +    }
    +    if st.output_checked {
    +        return None;
    +    }
    +    st.output_checked = true;
    +    // The key the location check names is report object 0 of the kind of
    +    // the oldest record waiting for a checkpoint (the error report when
    +    // none is waiting); the filesystem store does not read it.
    +    let kind = st
    +        .pending
    +        .first()
    +        .map_or(ReportKind::Error, |first| first.kind);
    +    let key = report::report_key(
    +        &config.output_prefix,
    +        &record.account_id,
    +        &record.operation_id,
    +        kind,
    +        0,
    +    );
    +    // The check waits on the store for up to its bound. It runs before the
    +    // checkpoint that would persist the record it checks for, so the lease
    +    // is kept without moving the cursor. Every caller flushed its staged
    +    // rows before the check, so a cancel or a deleted table ends the run
    +    // here as at a boundary.
    +    let mut deadline = st.lease_deadline;
    +    let checked = keeping_lease_in_place(
    +        deps,
    +        record,
    +        &mut deadline,
    +        Watch::CancelAndTable,
    +        deps.sink.check_location(
    +            &config.output_at(&record.account_id, bucket),
    +            &key,
    +            &config.output_sse,
    +        ),
    +    )
    +    .await;
    +    st.lease_deadline = deadline;
    +    let answer = match checked {
    +        InPlace::Answer(answer) => answer,
    +        InPlace::Lost => return Some(Flow::Stop),
    +        InPlace::Cancelled => {
    +            st.cancelled = true;
    +            return Some(Flow::Finish);
    +        }
    +        InPlace::TableGone => {
    +            st.failure = Some(Failure::table_not_found());
    +            return Some(Flow::Finish);
    +        }
    +        InPlace::Interrupted(error) => {
    +            tracing::error!(
    +                account_id = %record.account_id,
    +                operation_id = %record.operation_id,
    +                error = %error,
    +                "lease renewal failed during the output check; the runner stops"
    +            );
    +            return Some(Flow::Stop);
    +        }
    +    };
    +    match answer {
    +        Ok(()) => None,
    +        Err(SinkError::Store(fault)) => {
    +            tracing::info!(
    +                account_id = %record.account_id,
    +                operation_id = %record.operation_id,
    +                failure_code = fault.code,
    +                "output location fault at the first report record; held to the end of the input"
    +            );
    +            hold_location_fault(st, Failure::from_store(&fault));
    +            None
    +        }
    +        // The store was unavailable past its retries. The record that
    +        // asked for the check is behind no checkpoint, so the run reverts
    +        // to the last one, the next claim checks again at the same record,
    +        // and the run stops in progress (`finalize`).
    +        Err(SinkError::Io(io)) if StoreUnavailable::of_io(&io) => {
    +            revert_to_last_checkpoint(ctx, st).await;
    +            st.output_checked = false;
    +            st.failure = Some(Failure::store_unavailable());
    +            Some(Flow::Finish)
    +        }
    +        Err(error) => {
    +            tracing::error!(
    +                account_id = %record.account_id,
    +                operation_id = %record.operation_id,
    +                error = ?error,
    +                "output bucket check failed at the first report record"
    +            );
    +            Some(end_at_first_record(ctx, st).await)
    +        }
    +    }
    +}
    +
    +/// End the operation at once with the internal failure, from the first
    +/// report record: revert to the last checkpoint (the current window is
    +/// never checkpointed, and `output_checked` stays set so the revert is not
    +/// repeated) and render nothing.
    +async fn end_at_first_record(ctx: &RunContext<'_>, st: &mut RunState) -> Flow {
    +    revert_to_last_checkpoint(ctx, st).await;
    +    st.output_checked = true;
    +    st.failure = Some(Failure::internal());
    +    st.skip_render = true;
    +    Flow::Finish
    +}
    +
    +/// The end-of-file bookkeeping and boundary: `ProcessedInputBytes`
    +/// advances by `file_size`, the bytes this file contributed (its
    +/// enumerated size, or the end of the fault's read when an abandon cut it
    +/// short, or zero for a file refused as not compressed), and the cursor
    +/// moves to the
    +/// next file before the checkpoint. `etag` is the reader's entity tag when
    +/// the raw stream was consumed whole, `None` on an abandon or a changed
    +/// file; the checkpoint that passes a finished file stores its row count
    +/// and tag on the record, so neither is computed again by a resume or by
    +/// the manifest.
    +pub(super) async fn end_of_file(
    +    ctx: &RunContext<'_>,
    +    st: &mut RunState,
    +    file_index: usize,
    +    file_size: u64,
    +    etag: Option,
    +) -> Result {
    +    // The cursor row at end of file is the count of physical lines the
    +    // file held; the manifest reports the last counted row from it. An
    +    // abandoned file keeps its abandon row instead, and the bytes it
    +    // contributed, so a later claim restores a cut without inferring it
    +    // from the progress counter. Either way the next checkpoint stores the
    +    // file's outcome beside the cursor that passes it, so a later claim and
    +    // the manifest see it.
    +    if let Some(abandon) = st.abandoned.get(&file_index).copied() {
    +        st.pending_abandoned.push(AbandonedFile {
    +            index: file_index,
    +            row: abandon.row,
    +            kind: abandon.kind,
    +            bytes: file_size,
    +        });
    +    } else {
    +        st.file_row_counts.insert(file_index, st.cursor_row);
    +        if let Some(etag) = &etag {
    +            st.file_etags.insert(file_index, etag.clone());
    +        }
    +        st.pending_finished.push(FinishedFile {
    +            index: file_index,
    +            rows: st.cursor_row,
    +            etag,
    +        });
    +    }
    +    st.completed_bytes = st.completed_bytes.saturating_add(file_size);
    +    st.current_bytes = 0;
    +    st.cursor_file = file_index + 1;
    +    st.cursor_row = 0;
    +    // The next file has not been opened yet; the manifest reports a file
    +    // the operation never opened as `NOT_PROCESSED`.
    +    st.current_file_opened = false;
    +    match boundary(ctx, st).await? {
    +        Flow::NextRow => Ok(Flow::FileDone),
    +        other => Ok(other),
    +    }
    +}
    +
    +/// The bytes an abandoned file contributes to `ProcessedInputBytes`, by
    +/// the measured read rule: the end of the read that held the fault when
    +/// bytes of the file lie beyond it, the enumerated size otherwise. A cut is
    +/// recorded on the state, which ends the operation with the
    +/// items-failed-validation pair, and lowers the byte floor to the cut
    +/// total so a checkpoint inside the same file cannot hold the counter
    +/// above it (measured on the service).
    +///
    +/// Under a decoder the same rule runs on the compressed stream with the
    +/// decoder's read geometry (measured on the service), and
    +/// `fault_offset` is the compressed bytes the read chain had pulled when
    +/// the faulting line was decoded. ExtendDB's chain pulls the stored object
    +/// in 8 KiB fills, so that offset can sit up to 8 KiB past the compressed
    +/// byte the decoder needed; a fault whose compressed position lies within
    +/// one fill of a read boundary may be attributed to the next read.
    +pub(super) fn abandon_file_bytes(
    +    st: &mut RunState,
    +    codec: InputCodec,
    +    fault_offset: u64,
    +    file_size: u64,
    +) -> u64 {
    +    match ReadGeometry::of(codec).cut_processed_bytes(fault_offset, file_size) {
    +        Some(cut) => {
    +            st.cut_file_seen = true;
    +            st.bytes_floor = st.bytes_floor.min(st.completed_bytes.saturating_add(cut));
    +            cut
    +        }
    +        None => file_size,
    +    }
    +}
    +
    +/// Count and record an abandon: the offending row is processed and failed,
    +/// the record carries the one abandon message with no `itemIndex` and no
    +/// `newImage`, and the rest of the file is not read. The line itself was
    +/// already counted against the cap by the caller.
    +pub(super) fn record_abandon(
    +    deps: &RunnerDeps,
    +    config: &OperationConfig,
    +    st: &mut RunState,
    +    file: &InputFile,
    +    file_index: usize,
    +    abandon: Abandon,
    +    message: &str,
    +) {
    +    st.processed += 1;
    +    st.failed += 1;
    +    st.abandoned.insert(file_index, abandon);
    +    st.push_record(
    +        &ReportSink {
    +            max_records: deps.config.max_report_records,
    +            max_bytes: deps.config.max_report_bytes,
    +            requested: &config.output_types,
    +        },
    +        ReportKind::Error,
    +        file_index,
    +        abandon.row,
    +        report::error_record(&config.input_bucket, &file.path, None, message, None),
    +    );
    +}
    +
    +#[cfg(test)]
    +mod m60_tests {
    +    use super::*;
    +
    +    // The abandon cut lowers `bytes_floor` so a
    +    // resume whose restored byte figure already ran past the cut's read end
    +    // (decoder read-ahead a prior incarnation checkpointed) reports the cut,
    +    // not the read-ahead. The forward single-pass cut tests never reach it,
    +    // because in one pass the cut's read end only grows; it bites only when
    +    // a restored floor exceeds a later cut. Here a restored floor of 200,000
    +    // bytes meets a gzip abandon in the first read (cut 65,546), and the cut
    +    // must lower the floor to 65,546. Without the lowering the floor would
    +    // stand at 200,000 and the operation would over-report its processed
    +    // bytes.
    +    #[test]
    +    fn a_compressed_cut_lowers_the_bytes_floor_below_a_restored_read_ahead_figure() {
    +        let mut st = RunState {
    +            bytes_floor: 200_000,
    +            completed_bytes: 0,
    +            ..RunState::default()
    +        };
    +        let cut = abandon_file_bytes(&mut st, InputCodec::Gzip, 100, 204_490);
    +        assert_eq!(
    +            cut, 65_546,
    +            "the cut is the end of the gzip read that held the fault"
    +        );
    +        assert!(st.cut_file_seen, "the cut is recorded");
    +        assert_eq!(
    +            st.bytes_floor, 65_546,
    +            "the cut lowers the floor below the read-ahead figure a resume restored"
    +        );
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/runner/reader.rs b/crates/engine/src/bulk_put/runner/reader.rs
    new file mode 100644
    index 000000000..98c9c04f4
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/reader.rs
    @@ -0,0 +1,539 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Reading one input file: opening it through the source, reading its
    +//! lines under the line and item-count caps, and turning each read row
    +//! into a staged write or a fault record.
    +
    +use super::*;
    +
    +/// Read one input file from `skip_rows` to its end, or to an abandon or a
    +/// terminal condition. Ends with the end-of-file bookkeeping and boundary
    +/// checkpoint on every path that finishes the file.
    +///
    +/// Whatever path the read loop leaves by, no row stays staged: a flow that
    +/// continues or finishes the operation writes the staged rows first (a
    +/// failed flush becomes the terminal flow), and a `Stop` (the lease is
    +/// lost or the record is gone) discards them, because another owner may
    +/// already be driving the operation from the last checkpoint and these
    +/// rows are behind it.
    +pub(super) async fn read_file(
    +    ctx: &RunContext<'_>,
    +    st: &mut RunState,
    +    file: &InputFile,
    +    file_index: usize,
    +    file_size: u64,
    +    skip_rows: u64,
    +) -> Result {
    +    let flow = read_file_lines(ctx, st, file, file_index, file_size, skip_rows).await?;
    +    if st.staged.is_empty() {
    +        return Ok(flow);
    +    }
    +    if matches!(flow, Flow::Stop) {
    +        st.staged.clear();
    +        return Ok(flow);
    +    }
    +    match flush_staged(ctx, st).await? {
    +        Some(failed) => Ok(failed),
    +        None => Ok(flow),
    +    }
    +}
    +
    +/// The read loop of `read_file`. Staged rows are flushed before every
    +/// boundary, before the output-configuration check (a condition record
    +/// can be the first report record), and at the end of the file, so a
    +/// checkpoint never persists a cursor past an uncommitted write.
    +#[allow(clippy::too_many_lines)] // one file's read loop, kept in one place
    +async fn read_file_lines(
    +    ctx: &RunContext<'_>,
    +    st: &mut RunState,
    +    file: &InputFile,
    +    file_index: usize,
    +    file_size: u64,
    +    skip_rows: u64,
    +) -> Result {
    +    let RunContext {
    +        deps,
    +        record,
    +        config,
    +        ..
    +    } = *ctx;
    +    if let Some(flow) = check_get_at_open(ctx, st, file, file_index).await {
    +        return Ok(flow);
    +    }
    +    let input = config.input_at(&record.account_id);
    +    let opened =
    +        match keeping_lease(ctx, st, deps.source.open(&input, file, config.compression)).await? {
    +            Waited::Answer(opened) => opened,
    +            Waited::Flow(flow) => return Ok(flow),
    +        };
    +    let mut reader = match opened {
    +        Ok(reader) => reader,
    +        // Not a compressed object under GZIP: the file counts one processed
    +        // and failed row with its own record and the run moves to the next
    +        // file; the operation ends FAILED (measured on the service). The
    +        // file's bytes are not counted as processed input.
    +        Err(SourceError::NotCompressed) => {
    +            if let Some(flow) = flush_staged(ctx, st).await? {
    +                return Ok(flow);
    +            }
    +            if st.consume_line(deps.config.max_item_count) {
    +                return Ok(Flow::Finish);
    +            }
    +            record_abandon(
    +                deps,
    +                config,
    +                st,
    +                file,
    +                file_index,
    +                Abandon {
    +                    row: 0,
    +                    kind: AbandonKind::NotCompressed,
    +                },
    +                rows::NOT_COMPRESSED_MESSAGE,
    +            );
    +            if let Some(flow) = check_output_config_at_first_record(ctx, st).await {
    +                return Ok(flow);
    +            }
    +            return end_of_file(ctx, st, file_index, 0, None).await;
    +        }
    +        // A zstd frame whose header does not decode: the file counts one
    +        // processed and failed row with the decompression message, its
    +        // bytes count as processed input, and the run moves to the next
    +        // file (measured on the service; see `SourceError::DecodeFailed`).
    +        Err(SourceError::DecodeFailed) => {
    +            if let Some(flow) = flush_staged(ctx, st).await? {
    +                return Ok(flow);
    +            }
    +            if st.consume_line(deps.config.max_item_count) {
    +                return Ok(Flow::Finish);
    +            }
    +            record_abandon(
    +                deps,
    +                config,
    +                st,
    +                file,
    +                file_index,
    +                Abandon {
    +                    row: 0,
    +                    kind: AbandonKind::Content,
    +                },
    +                rows::NOT_COMPRESSED_MESSAGE,
    +            );
    +            if let Some(flow) = check_output_config_at_first_record(ctx, st).await {
    +                return Ok(flow);
    +            }
    +            return end_of_file(ctx, st, file_index, file_size, None).await;
    +        }
    +        // The object at the key is no longer the one the operation listed
    +        // (measured on the service; see `SourceError::Overwritten`). Found at
    +        // a resumed open, the rows an earlier claim counted stand, and so do
    +        // their bytes.
    +        Err(SourceError::Overwritten) => {
    +            let counted = if skip_rows > 0 {
    +                st.bytes_floor.saturating_sub(st.completed_bytes)
    +            } else {
    +                0
    +            };
    +            return record_overwritten(ctx, st, file, file_index, skip_rows, counted, file_size)
    +                .await;
    +        }
    +        Err(error) => {
    +            tracing::error!(
    +                account_id = %record.account_id,
    +                operation_id = %record.operation_id,
    +                path = %file.path,
    +                error = ?error,
    +                "bulk input file failed to open"
    +            );
    +            // An object that is gone or unreadable at the open ends the
    +            // operation with the measured S3 code. The manifest reports it
    +            // `NOT_PROCESSED` when none of its rows was counted, and
    +            // `NOT_AVAILABLE` when an earlier claim had counted rows of it.
    +            if unavailable_at_open(&error, skip_rows) {
    +                st.unavailable_file = Some(file_index);
    +            }
    +            st.failure = Some(source_failure(&error));
    +            return Ok(Flow::Finish);
    +        }
    +    };
    +    st.current_file_opened = true;
    +    // A file listed without a tag is named in the manifest by the tag of
    +    // the first answer the store accepted for it, never by the object at
    +    // the key when the manifest is written.
    +    if file.etag.is_none()
    +        && let Some(etag) = reader.opened_etag()
    +    {
    +        st.opened_etags.entry(file_index).or_insert(etag);
    +    }
    +
    +    let mut row_index: u64 = 0;
    +    if skip_rows > 0 {
    +        match skip_covered_region(
    +            ctx,
    +            st,
    +            reader.as_mut(),
    +            file,
    +            file_index,
    +            file_size,
    +            skip_rows,
    +        )
    +        .await?
    +        {
    +            Some(flow) => return Ok(flow),
    +            None => row_index = skip_rows,
    +        }
    +    }
    +
    +    loop {
    +        let next =
    +            match keeping_lease(ctx, st, reader.next_line(deps.config.max_line_bytes)).await? {
    +                Waited::Answer(next) => next,
    +                Waited::Flow(flow) => return Ok(flow),
    +            };
    +        match next {
    +            Ok(None) => {
    +                let etag = reader.etag();
    +                return end_of_file(ctx, st, file_index, file_size, etag).await;
    +            }
    +            Err(
    +                fault @ (SourceError::LineTooLong
    +                | SourceError::InvalidUtf8
    +                | SourceError::Corrupt
    +                | SourceError::DecodeFailed),
    +            ) => {
    +                if let Some(flow) = flush_staged(ctx, st).await? {
    +                    return Ok(flow);
    +                }
    +                if st.consume_line(deps.config.max_item_count) {
    +                    return Ok(Flow::Finish);
    +                }
    +                // A line that is not UTF-8 carries its own measured message;
    +                // the per-line byte cap is ExtendDB's own and keeps the
    +                // shared abandon text.
    +                let message = match fault {
    +                    SourceError::InvalidUtf8 => rows::INVALID_UTF8_MESSAGE,
    +                    SourceError::Corrupt => rows::CORRUPT_STREAM_MESSAGE,
    +                    SourceError::DecodeFailed => rows::NOT_COMPRESSED_MESSAGE,
    +                    _ => rows::PARSE_ABANDON_MESSAGE,
    +                };
    +                record_abandon(
    +                    deps,
    +                    config,
    +                    st,
    +                    file,
    +                    file_index,
    +                    Abandon {
    +                        row: row_index,
    +                        kind: AbandonKind::Content,
    +                    },
    +                    message,
    +                );
    +                if let Some(flow) = check_output_config_at_first_record(ctx, st).await {
    +                    return Ok(flow);
    +                }
    +                // The faulting line was not delivered, so the offset of
    +                // the last delivered line is the furthest point a line
    +                // fault can be attributed to. A stream fault is found
    +                // where the decoder stopped reading: a frame cut short is
    +                // read to the end of the object before it fails (measured
    +                // on the service: a 12,943-byte zstd frame cut at 90
    +                // percent counts 12,943 and ends COMPLETED_WITH_ERRORS),
    +                // so the bytes the chain pulled are its offset.
    +                let fault_offset =
    +                    if matches!(fault, SourceError::Corrupt | SourceError::DecodeFailed) {
    +                        reader.bytes_consumed()
    +                    } else {
    +                        reader.last_line_end()
    +                    };
    +                let bytes = abandon_file_bytes(st, config.compression, fault_offset, file_size);
    +                return end_of_file(ctx, st, file_index, bytes, None).await;
    +            }
    +            // The object was overwritten while it was read: the rows it
    +            // delivered stand, and so do their bytes.
    +            Err(SourceError::Overwritten) => {
    +                let delivered = reader.last_line_end();
    +                return record_overwritten(
    +                    ctx, st, file, file_index, row_index, delivered, file_size,
    +                )
    +                .await;
    +            }
    +            Err(error) => {
    +                tracing::error!(
    +                    account_id = %record.account_id,
    +                    operation_id = %record.operation_id,
    +                    path = %file.path,
    +                    error = ?error,
    +                    "bulk input file failed mid-read"
    +                );
    +                if is_unavailable_object(&error) {
    +                    st.unavailable_file = Some(file_index);
    +                }
    +                st.failure = Some(source_failure(&error));
    +                return Ok(Flow::Finish);
    +            }
    +            Ok(Some(line)) => {
    +                let this_row = row_index;
    +                st.current_bytes = reader.bytes_consumed().min(file_size);
    +                // The cap is checked before the cursor moves: a line the run
    +                // fails on is not consumed, so a resume after a crash between
    +                // the final checkpoint and finish reads it again instead of
    +                // skipping past a line that was never applied.
    +                if st.consume_line(deps.config.max_item_count) {
    +                    return Ok(Flow::Finish);
    +                }
    +                row_index += 1;
    +                st.cursor_row = row_index;
    +                let step = process_row(ctx, st, file, file_index, this_row, &line).await?;
    +                // The staged rows are written when the write batch is full,
    +                // when the output-configuration check is pending (its first
    +                // report record was just produced), and before any flow
    +                // that leaves this loop; the boundary flushes on its own
    +                // before it checkpoints. So rows read before the first
    +                // report record are written before the operation can fail
    +                // on the output configuration, and the persisted cursor
    +                // reflects committed writes.
    +                let flush_now = !matches!(step, Flow::NextRow)
    +                    || st.staged.len() >= deps.config.write_batch_rows
    +                    || (!st.output_checked && st.kept_records > 0);
    +                if flush_now && let Some(flow) = flush_staged(ctx, st).await? {
    +                    return Ok(flow);
    +                }
    +                // The output-configuration check runs before the boundary
    +                // that would checkpoint the record's batch, and only on
    +                // the flows that continue: a Finish already carries its
    +                // own terminal condition.
    +                if matches!(step, Flow::NextRow | Flow::FileDone)
    +                    && let Some(flow) = check_output_config_at_first_record(ctx, st).await
    +                {
    +                    return Ok(flow);
    +                }
    +                match step {
    +                    Flow::NextRow => {}
    +                    Flow::FileDone => {
    +                        // The parse-level abandon class: the faulting line
    +                        // was delivered, so its end offset decides whether
    +                        // bytes of the file lie beyond the fault's read
    +                        // (measured on the service).
    +                        let bytes = abandon_file_bytes(
    +                            st,
    +                            config.compression,
    +                            reader.last_line_end(),
    +                            file_size,
    +                        );
    +                        return end_of_file(ctx, st, file_index, bytes, None).await;
    +                    }
    +                    Flow::Stop => return Ok(Flow::Stop),
    +                    Flow::Finish => return Ok(Flow::Finish),
    +                }
    +                // A server shutdown stops the run at this row: the boundary
    +                // writes the staged rows and checkpoints, then stops.
    +                if st.checkpoint_due(deps.config.batch_size) || deps.shutdown.is_cancelled() {
    +                    match boundary(ctx, st).await? {
    +                        Flow::NextRow => {}
    +                        other => return Ok(other),
    +                    }
    +                }
    +            }
    +        }
    +    }
    +}
    +
    +/// Count and record an input object found overwritten after the operation
    +/// listed it, at `row_index`, the first row not counted, and end the file.
    +/// The record is the abandon record, with no `itemIndex`, carrying the
    +/// overwritten message. The file contributes `counted` bytes, the bytes of
    +/// the rows it delivered (none when the object was replaced before its
    +/// first byte, as measured on the service). The checkpoint that passes the
    +/// file stores the abandon with its kind, so a restart still ends the
    +/// operation `FAILED`.
    +pub(super) async fn record_overwritten(
    +    ctx: &RunContext<'_>,
    +    st: &mut RunState,
    +    file: &InputFile,
    +    file_index: usize,
    +    row_index: u64,
    +    counted: u64,
    +    file_size: u64,
    +) -> Result {
    +    let RunContext { deps, config, .. } = *ctx;
    +    if let Some(flow) = flush_staged(ctx, st).await? {
    +        return Ok(flow);
    +    }
    +    if st.consume_line(deps.config.max_item_count) {
    +        return Ok(Flow::Finish);
    +    }
    +    record_abandon(
    +        deps,
    +        config,
    +        st,
    +        file,
    +        file_index,
    +        Abandon {
    +            row: row_index,
    +            kind: AbandonKind::Overwritten,
    +        },
    +        rows::OVERWRITTEN_MESSAGE,
    +    );
    +    if let Some(flow) = check_output_config_at_first_record(ctx, st).await {
    +        return Ok(flow);
    +    }
    +    let bytes = counted.min(file_size);
    +    st.bytes_floor = st.bytes_floor.min(st.completed_bytes.saturating_add(bytes));
    +    end_of_file(ctx, st, file_index, bytes, None).await
    +}
    +
    +/// Whether a failed open leaves the file `NOT_AVAILABLE` in the manifest.
    +/// A store fault at an open that resumes the file after `skip_rows` of its
    +/// rows were counted is after the file's first byte, whatever the store
    +/// says of the GET that met it: the S3 store renders the first range of
    +/// every open as before the first byte. The filesystem store and the
    +/// service report such a file `NOT_AVAILABLE` with no range.
    +fn unavailable_at_open(error: &SourceError, skip_rows: u64) -> bool {
    +    match error {
    +        SourceError::Store(_) if skip_rows > 0 => true,
    +        other => is_unavailable_object(other),
    +    }
    +}
    +
    +/// Step 5 for one physical line: parse, validate, and apply it, with the
    +/// row-outcome ledger consulted before any condition is evaluated.
    +#[allow(clippy::too_many_lines)] // one row's step table, kept in one place
    +async fn process_row(
    +    ctx: &RunContext<'_>,
    +    st: &mut RunState,
    +    file: &InputFile,
    +    file_index: usize,
    +    row_index: u64,
    +    line: &str,
    +) -> Result {
    +    let RunContext {
    +        deps,
    +        record,
    +        config,
    +        ..
    +    } = *ctx;
    +    let bucket = &config.input_bucket;
    +    let path = &file.path;
    +    let item_index = to_i64(row_index);
    +    let record_cap = deps.config.max_report_records;
    +    let byte_cap = deps.config.max_report_bytes;
    +
    +    let row = match rows::parse_row(line, deps.config.max_line_bytes) {
    +        // A blank line consumes an index and touches no counter. The
    +        // caller already counted it toward the batch boundary and the
    +        // line cap, so the lease renews and a cancel is observed
    +        // whatever the input contains.
    +        Ok(None) => {
    +            return Ok(Flow::NextRow);
    +        }
    +        Err(RowError::Parse(message)) => {
    +            record_abandon(
    +                deps,
    +                config,
    +                st,
    +                file,
    +                file_index,
    +                Abandon {
    +                    row: row_index,
    +                    kind: AbandonKind::Content,
    +                },
    +                &message,
    +            );
    +            return Ok(Flow::FileDone);
    +        }
    +        Err(RowError::Item(message)) => {
    +            st.processed += 1;
    +            st.failed += 1;
    +            st.push_record(
    +                &ReportSink {
    +                    max_records: record_cap,
    +                    max_bytes: byte_cap,
    +                    requested: &config.output_types,
    +                },
    +                ReportKind::Error,
    +                file_index,
    +                row_index,
    +                report::error_record(bucket, path, Some(item_index), &message, None),
    +            );
    +            return Ok(Flow::NextRow);
    +        }
    +        Ok(Some(row)) => row,
    +    };
    +
    +    st.processed += 1;
    +
    +    // Admission through the shared write core, condition fields first, the
    +    // order `handle_put_item` applies. A failure is an item-level record;
    +    // only the missing-key check carries the submitted image.
    +    let condition = match admit_row(&row, &st.key_info, &deps.limits) {
    +        Admission::Fault { message, kind } => {
    +            st.failed += 1;
    +            let new_image = kind.and_then(|kind| {
    +                // The item holds one copy of each set member; the limit
    +                // the row's own checks use counts the copies removed, so
    +                // the image rule judges the item as submitted.
    +                let limit = row.item_limits(&deps.limits).max_item_size_bytes;
    +                item_fault_new_image(kind, &row.item, limit)
    +            });
    +            let message = item_fault_message(kind, message);
    +            st.push_record(
    +                &ReportSink {
    +                    max_records: record_cap,
    +                    max_bytes: byte_cap,
    +                    requested: &config.output_types,
    +                },
    +                ReportKind::Error,
    +                file_index,
    +                row_index,
    +                report::error_record(bucket, path, Some(item_index), &message, new_image),
    +            );
    +            return Ok(Flow::NextRow);
    +        }
    +        Admission::Write(condition) => condition,
    +    };
    +
    +    let has_indexes = !st.key_info.global_secondary_indexes.is_empty()
    +        || !st.key_info.local_secondary_indexes.is_empty()
    +        || !st.key_info.vector_indexes.is_empty();
    +    let item_bytes = item_size_bytes(&row.item);
    +    // The write is staged, not issued: rows go to storage in one batch at
    +    // the flush, and this row's counters, usage, and any condition record
    +    // are applied from the batch's results. Every row carries its marker,
    +    // so the backend records the row's outcome in the ledger inside the
    +    // batch's transaction and a resumed run does not apply it again. The
    +    // submitted image is kept when a condition record or an index charge
    +    // may need it; the ledger keeps the replaced item for the index charge,
    +    // and the item a failed condition was evaluated against in every case.
    +    let image = (condition.is_some() || has_indexes).then(|| row.item.clone());
    +    let marker = Some(BulkRowMarker {
    +        account_id: record.account_id.clone(),
    +        operation_id: record.operation_id.clone(),
    +        file_index: to_i64(u64::try_from(file_index).unwrap_or(u64::MAX)),
    +        row_index: item_index,
    +        owner: deps.owner.clone(),
    +        keep_old: has_indexes,
    +        settled: st.settled,
    +    });
    +    st.staged.push(Staged {
    +        row: BatchRow {
    +            item: row.item,
    +            condition,
    +            marker,
    +            // The old image is requested on every write: the table charge
    +            // on an overwrite is the larger of the old and new item sizes
    +            // (measured on the service), on a table with no index as well,
    +            // so the runner needs the replaced item's size for every row.
    +            // The storage layer already reads the old image on the batch
    +            // write path for PostgreSQL; on SQLite it reads it only when
    +            // asked, so this adds one read per overwritten row there (see
    +            // the report).
    +            return_old: true,
    +        },
    +        file_index,
    +        row_index,
    +        item_bytes,
    +        image,
    +    });
    +    Ok(Flow::NextRow)
    +}
    diff --git a/crates/engine/src/bulk_put/runner/recorded.rs b/crates/engine/src/bulk_put/runner/recorded.rs
    new file mode 100644
    index 000000000..a87c24d29
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/recorded.rs
    @@ -0,0 +1,26 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! What the Start handler recorded on the operation for its runner, in the
    +//! record's run context, checked once when a claim starts.
    +
    +use super::*;
    +
    +use crate::bulk_put::run_context::RecordedRunContext;
    +
    +/// The recorded run context, when it names the store this runner reads and
    +/// writes through. Otherwise the detail for the log line: a record started
    +/// on another store, or a run context that does not parse.
    +pub(super) fn check_recorded_store(
    +    deps: &RunnerDeps,
    +    record: &BulkOperationRecord,
    +) -> Result {
    +    let recorded = RecordedRunContext::of(record)?;
    +    if recorded.store != deps.store_kind {
    +        return Err(format!(
    +            "the operation was started on the {} store",
    +            recorded.store
    +        ));
    +    }
    +    Ok(recorded)
    +}
    diff --git a/crates/engine/src/bulk_put/runner/resume.rs b/crates/engine/src/bulk_put/runner/resume.rs
    new file mode 100644
    index 000000000..523e1d2e7
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/resume.rs
    @@ -0,0 +1,238 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Resuming an operation where its applied rows end.
    +//!
    +//! A write batch commits in the data database and the checkpoint that
    +//! covers it commits afterwards in the catalog, so an interruption between
    +//! the two (a kill, a lease takeover, a transient fault on a later batch of
    +//! the same checkpoint window) leaves rows applied past the persisted
    +//! cursor. Every batch records each row it applies in the row-outcome
    +//! ledger, in the batch's own transaction, with the size of the item the
    +//! row replaced and, when the index charge or a condition-failure record
    +//! needs it, that item. A claim first records itself as the operation's
    +//! write owner, which makes every batch of an earlier owner either already
    +//! committed or refused, and then reads the rows recorded past its
    +//! checkpoint. The run reads those rows again for the counters and the
    +//! records, and applies each recorded result in place of a write, so the
    +//! table, the counters, the usage and the reports are those of a run that
    +//! was never interrupted, and a client write that landed after a row's
    +//! batch committed is never overwritten by that row again.
    +//!
    +//! The claim also bounds resumes that make no progress: the owner record
    +//! counts the claims in a row that found the checkpoint where the claim
    +//! before them found it, and once that count reaches
    +//! [`MAX_STALLED_RESUMES`] the operation ends without reading input:
    +//! `FAILED` with the internal failure, the deleted-table failure when its
    +//! table is gone, or `CANCELLED` when a cancel is pending.
    +
    +use super::*;
    +
    +/// Consecutive resumes that find the checkpoint where the resume before
    +/// them found it; the one that reaches this count ends the operation
    +/// `FAILED` with `InternalServerError` instead of running.
    +pub(super) const MAX_STALLED_RESUMES: u32 = 10;
    +
    +/// The status the store records for an operation whose resume bound is
    +/// spent: `finish` ends a `CANCELLING` row `CANCELLED` with the cancel
    +/// pair whatever the runner computed, and any other row `FAILED`.
    +pub(super) fn outcome_after_the_bound(status: &str) -> &'static str {
    +    if status == STATUS_CANCELLING {
    +        "CANCELLED"
    +    } else {
    +        "FAILED"
    +    }
    +}
    +
    +/// Record this claim as the operation's write owner and load the rows the
    +/// ledger holds past the restored checkpoint. `Ok(false)` means the resume
    +/// bound ended the operation: the failure is set and the caller finalizes
    +/// without reading input. An error leaves the operation for the next claim.
    +pub(super) async fn take_over(
    +    deps: &RunnerDeps,
    +    record: &BulkOperationRecord,
    +    st: &mut RunState,
    +) -> Result {
    +    let account = &record.account_id;
    +    let operation = &record.operation_id;
    +    let cursor = (record.cursor_file_index, record.cursor_row_index);
    +    let stalled = match deps
    +        .storage
    +        .bulk_write_takeover(account, operation, &deps.owner, cursor)
    +        .await
    +    {
    +        Ok(stalled) => stalled,
    +        Err(error) => {
    +            tracing::warn!(
    +                account_id = %account,
    +                operation_id = %operation,
    +                error = %error,
    +                "bulk operation write owner could not be recorded; the operation resumes later"
    +            );
    +            return Err(RunnerError::Storage(error));
    +        }
    +    };
    +    if stalled >= MAX_STALLED_RESUMES {
    +        let ends = outcome_after_the_bound(&record.status);
    +        tracing::error!(
    +            account_id = %account,
    +            operation_id = %operation,
    +            resumes = stalled,
    +            cursor_file_index = cursor.0,
    +            cursor_row_index = cursor.1,
    +            "bulk operation resumed {MAX_STALLED_RESUMES} times without its checkpoint advancing; it ends {ends}"
    +        );
    +        st.failure = Some(Failure::internal());
    +        st.resumes_spent = true;
    +        return Ok(false);
    +    }
    +    if stalled > 0 {
    +        tracing::warn!(
    +            account_id = %account,
    +            operation_id = %operation,
    +            attempt = stalled,
    +            max_attempts = MAX_STALLED_RESUMES,
    +            cursor_file_index = cursor.0,
    +            cursor_row_index = cursor.1,
    +            "bulk operation resumed without its checkpoint advancing since the last resume"
    +        );
    +    }
    +    st.settled = cursor;
    +    st.recorded_next = u64::try_from(cursor.1)
    +        .ok()
    +        .map(|row| (st.cursor_file, row));
    +    load_recorded_page(deps, record, st).await?;
    +    if !st.recorded.is_empty() {
    +        tracing::info!(
    +            account_id = %account,
    +            operation_id = %operation,
    +            rows = st.recorded.len(),
    +            more = st.recorded_next.is_some(),
    +            "bulk operation resumes past rows applied after its last checkpoint"
    +        );
    +    }
    +    Ok(true)
    +}
    +
    +/// Rows of the ledger read in one page. A recorded row can keep the item
    +/// it replaced, up to the item size limit, so a resumed run holds at most
    +/// this many of them beyond the write batch it is flushing.
    +pub(crate) const RECORDED_PAGE: i64 = 128;
    +
    +/// Read the next page of recorded rows into `st.recorded` and move
    +/// `st.recorded_next` past it, or clear it when the page was not full.
    +async fn load_recorded_page(
    +    deps: &RunnerDeps,
    +    record: &BulkOperationRecord,
    +    st: &mut RunState,
    +) -> Result<(), RunnerError> {
    +    let Some((file, from)) = st.recorded_next else {
    +        return Ok(());
    +    };
    +    let (Ok(file_index), Ok(from_row)) = (i64::try_from(file), i64::try_from(from)) else {
    +        st.recorded_next = None;
    +        return Ok(());
    +    };
    +    let rows = deps
    +        .storage
    +        .bulk_rows_recorded(
    +            &record.account_id,
    +            &record.operation_id,
    +            file_index,
    +            from_row,
    +            RECORDED_PAGE,
    +        )
    +        .await
    +        .map_err(RunnerError::Storage)?;
    +    let full = i64::try_from(rows.len()).unwrap_or(i64::MAX) >= RECORDED_PAGE;
    +    let mut last = None;
    +    for row in rows {
    +        if let Ok(index) = u64::try_from(row.row_index) {
    +            last = Some(index);
    +            st.recorded.insert((file, index), row);
    +        }
    +    }
    +    st.recorded_next = match last {
    +        Some(last) if full => Some((file, last.saturating_add(1))),
    +        _ => None,
    +    };
    +    Ok(())
    +}
    +
    +/// Read pages of recorded rows until every recorded row of `file` up to
    +/// `through` is in `st.recorded`, so a flush of staged rows up to that row
    +/// finds each one the ledger holds.
    +pub(super) async fn load_recorded_through(
    +    deps: &RunnerDeps,
    +    record: &BulkOperationRecord,
    +    st: &mut RunState,
    +    file: usize,
    +    through: u64,
    +) -> Result<(), RunnerError> {
    +    while let Some((next_file, next_row)) = st.recorded_next {
    +        if next_file != file || next_row > through {
    +            break;
    +        }
    +        load_recorded_page(deps, record, st).await?;
    +    }
    +    Ok(())
    +}
    +
    +/// Account for one staged row the ledger holds as applied, the way the
    +/// run that applied it accounted for its write result: the table charge
    +/// from the replaced item's size, the index charge from the replaced item,
    +/// and a failed condition's charge and record with the item it was
    +/// evaluated against. Nothing is written and no write units are taken.
    +pub(super) fn apply_recorded(
    +    ctx: &RunContext<'_>,
    +    st: &mut RunState,
    +    staged: Staged,
    +    recorded: RecordedRow,
    +) {
    +    let RunContext {
    +        deps,
    +        record,
    +        config,
    +        ..
    +    } = *ctx;
    +    let has_indexes = !st.key_info.global_secondary_indexes.is_empty()
    +        || !st.key_info.local_secondary_indexes.is_empty()
    +        || !st.key_info.vector_indexes.is_empty();
    +    match recorded.outcome {
    +        RowOutcome::Applied => {
    +            let old_size = recorded
    +                .old_size
    +                .map_or(0, |size| usize::try_from(size).unwrap_or(usize::MAX));
    +            st.usage.add_base(staged.item_bytes.max(old_size));
    +            if has_indexes {
    +                st.usage.add_index_writes(
    +                    recorded.old_image.as_ref(),
    +                    staged.image.as_ref(),
    +                    &st.key_info,
    +                );
    +            }
    +        }
    +        RowOutcome::ConditionFailed => {
    +            st.usage.add_condition_failed();
    +            let submitted = staged.image.unwrap_or_default();
    +            let path = &record.input_files[staged.file_index].path;
    +            st.push_record(
    +                &ReportSink {
    +                    max_records: deps.config.max_report_records,
    +                    max_bytes: deps.config.max_report_bytes,
    +                    requested: &config.output_types,
    +                },
    +                ReportKind::ConditionCheckFailure,
    +                staged.file_index,
    +                staged.row_index,
    +                report::condition_record(
    +                    &config.input_bucket,
    +                    path,
    +                    Some(to_i64(staged.row_index)),
    +                    &submitted,
    +                    recorded.old_image.as_ref(),
    +                ),
    +            );
    +        }
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/runner/state.rs b/crates/engine/src/bulk_put/runner/state.rs
    new file mode 100644
    index 000000000..3db5706ea
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/state.rs
    @@ -0,0 +1,490 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Run state and terminal decisions: the mutable [`RunState`] restored
    +//! from the operation record, its configuration and staged-row types, and
    +//! the pure helpers that map state to a terminal status and a record kind.
    +
    +use super::*;
    +
    +/// The parsed input and output configuration of one operation.
    +pub(super) struct OperationConfig {
    +    pub(super) input_bucket: String,
    +    pub(super) input_bucket_owner: Option,
    +    /// The request's `S3KeyPrefix` for the input, as given: the
    +    /// object-count quota message interpolates it verbatim, trailing
    +    /// slash included (measured on the service).
    +    pub(super) input_prefix: String,
    +    pub(super) compression: InputCodec,
    +    pub(super) output_bucket: Option,
    +    pub(super) output_bucket_owner: Option,
    +    pub(super) output_prefix: String,
    +    pub(super) output_types: Vec,
    +    pub(super) output_sse: OutputSse,
    +    /// The caller recorded at Start, whose S3 permissions the runner checks.
    +    pub(super) caller: Option,
    +}
    +
    +impl OperationConfig {
    +    /// Parse the stored configuration values. The start handler validated
    +    /// and wrote them, so a failure here is an internal inconsistency, not
    +    /// client input.
    +    pub(super) fn parse(record: &BulkOperationRecord) -> Result {
    +        let input: InputS3Configuration = serde_json::from_value(record.input_config.clone())
    +            .map_err(|e| format!("stored input configuration does not parse: {e}"))?;
    +        let output: OutputS3Configuration = serde_json::from_value(record.output_config.clone())
    +            .map_err(|e| format!("stored output configuration does not parse: {e}"))?;
    +        let compression = match input.input_compression_type {
    +            Some(InputCompressionType::Gzip) => InputCodec::Gzip,
    +            Some(InputCompressionType::Zstd) => InputCodec::Zstd,
    +            None | Some(InputCompressionType::None) => InputCodec::None,
    +        };
    +        let output_sse = OutputSse::of(&output);
    +        Ok(Self {
    +            input_bucket: input.s3_bucket,
    +            input_bucket_owner: input.s3_bucket_owner,
    +            input_prefix: input.s3_key_prefix.unwrap_or_default(),
    +            compression,
    +            output_bucket: output.s3_bucket,
    +            output_bucket_owner: output.s3_bucket_owner,
    +            output_prefix: output.s3_key_prefix.unwrap_or_default(),
    +            output_types: output.output_type,
    +            output_sse,
    +            caller: None,
    +        })
    +    }
    +
    +    /// The input bucket of the operation in `account`, as the source sees
    +    /// it.
    +    pub(super) fn input_at<'a>(&'a self, account: &'a str) -> BucketRef<'a> {
    +        BucketRef::new(
    +            account,
    +            &self.input_bucket,
    +            self.input_bucket_owner.as_deref(),
    +        )
    +    }
    +
    +    /// The report bucket `bucket` of the operation in `account`, as the
    +    /// sink sees it.
    +    pub(super) fn output_at<'a>(&'a self, account: &'a str, bucket: &'a str) -> BucketRef<'a> {
    +        BucketRef::new(account, bucket, self.output_bucket_owner.as_deref())
    +    }
    +}
    +
    +/// Mutable state of one run, restored from the record and persisted at
    +/// every checkpoint.
    +/// One admitted row waiting in the write batch, with what applying its
    +/// result needs: the location for its report record, its size for usage
    +/// and pacing, and the submitted image when a condition record or an index
    +/// charge may need it (a plain row on a table without indexes keeps none).
    +pub(super) struct Staged {
    +    pub(super) row: BatchRow,
    +    pub(super) file_index: usize,
    +    pub(super) row_index: u64,
    +    pub(super) item_bytes: usize,
    +    pub(super) image: Option,
    +}
    +
    +/// How a file was abandoned: the row the abandon was recorded at, where
    +/// the file's processed range ends, and why the rest was not read.
    +#[derive(Clone, Copy, Debug, PartialEq, Eq)]
    +pub(super) struct Abandon {
    +    pub(super) row: u64,
    +    pub(super) kind: AbandonKind,
    +}
    +
    +impl Abandon {
    +    /// Whether the abandon ends the operation `FAILED` with the validation
    +    /// pair once the other files are read: a file that was not a
    +    /// compressed object, and an object overwritten after its listing
    +    /// (both measured on the service). An abandon of the `Content` kind
    +    /// does so only when it cut the file short, which `cut_file_seen`
    +    /// records, and the file's record keeps as the bytes it contributed.
    +    pub(super) fn fails_operation(self) -> bool {
    +        matches!(
    +            self.kind,
    +            AbandonKind::NotCompressed | AbandonKind::Overwritten
    +        )
    +    }
    +}
    +
    +#[derive(Default)]
    +pub(super) struct RunState {
    +    pub(super) processed: u64,
    +    pub(super) failed: u64,
    +    /// Whether the output configuration was checked at the operation's
    +    /// first report record. Restored true when the record already holds
    +    /// persisted report records: an earlier claim produced the first one
    +    /// and checked before checkpointing it.
    +    pub(super) output_checked: bool,
    +    /// Set on the output-configuration failure path: the accumulated
    +    /// records were never validated against the output configuration, so
    +    /// nothing is rendered.
    +    pub(super) skip_render: bool,
    +    /// The output location fault found at the first report record, held
    +    /// to the end of the input (`runner::held`).
    +    pub(super) held_location: Option,
    +    /// Report kinds whose object 0 this claim checked for `s3:PutObject`.
    +    pub(super) put_checked: Vec,
    +    /// Rows whose write committed in this process. Not persisted: it feeds
    +    /// the `BulkPutItemsWritten` metric, which measures the work this
    +    /// process did, so rows an earlier claim applied (found in the ledger)
    +    /// and condition failures (nothing written) are not counted.
    +    pub(super) written: u64,
    +    /// Enumerated bytes of every file already finished or abandoned.
    +    pub(super) completed_bytes: u64,
    +    /// Raw bytes consumed in the file being read, capped at its
    +    /// enumerated size.
    +    pub(super) current_bytes: u64,
    +    /// The persisted `ProcessedInputBytes`; a checkpoint never reports
    +    /// below it, so a run that fails before reopening the resumed file
    +    /// cannot regress the counter.
    +    pub(super) bytes_floor: u64,
    +    pub(super) usage: UsageAccum,
    +    /// Rows admitted for writing and not yet handed to storage. Flushed as
    +    /// one `put_items_batch` call; their counters, usage, and condition
    +    /// records are applied from the results, after the batch committed. A
    +    /// checkpoint never runs with rows staged, so the persisted cursor
    +    /// never points past a row whose write did not commit.
    +    pub(super) staged: Vec,
    +    /// Records produced since the last checkpoint.
    +    pub(super) pending: Vec,
    +    /// Serialized bytes currently buffered in `pending`, reset when the
    +    /// buffer drains at a checkpoint. Bounds the in-memory report buffer
    +    /// independent of `batch_size`: a checkpoint is forced once this
    +    /// crosses `PENDING_FLUSH_BYTES`, so a large `batch_size` on
    +    /// failing-heavy input cannot grow the heap without bound.
    +    pub(super) pending_bytes: u64,
    +    /// Records persisted or pending for this operation, against the count
    +    /// cap. Restored from the operation record on claim.
    +    pub(super) kept_records: u64,
    +    /// Serialized bytes of those records, against the byte cap. Restored
    +    /// from the operation record on claim, like the count.
    +    pub(super) kept_bytes: u64,
    +    pub(super) dropped_records: u64,
    +    pub(super) rows_since_checkpoint: usize,
    +    /// Physical lines consumed by this runner, blank and skipped lines
    +    /// included, compared to `max_item_count` as lines are read. Not
    +    /// persisted: a resume re-counts the skip region of the file it
    +    /// reopens, and lines of files already finished are behind committed
    +    /// checkpoints, so the count bounds what one claim can read without
    +    /// ever failing a resume that a crash-free run would have completed.
    +    pub(super) lines_consumed: u64,
    +    /// Next row to read, as (file index, physical line index).
    +    pub(super) cursor_file: usize,
    +    pub(super) cursor_row: u64,
    +    pub(super) cancelled: bool,
    +    pub(super) failure: Option,
    +    /// The instant this process's lease on the operation runs out, as the
    +    /// runner last learned it: set when the claim is handed to `run` and
    +    /// moved forward after every checkpoint the store answered `Held`.
    +    /// Checked before every write batch, so a process that stalled past
    +    /// its lease (paused, swapped, blocked on a lock) stops instead of
    +    /// committing rows a new owner has already passed. `None` only in unit
    +    /// tests that build a state without a claim.
    +    pub(super) lease_deadline: Option,
    +    /// The table's key metadata as the runner last read it: key schema,
    +    /// attribute definitions, indexes and stream specification. Read once
    +    /// when the claim starts and again at every checkpoint boundary before
    +    /// the staged rows are written, so a stream enabled or an index added
    +    /// while the operation runs applies to every row flushed from the next
    +    /// boundary on, the way a single `PutItem` reads it per request. A
    +    /// boundary read that finds the table `UPDATING` keeps this snapshot
    +    /// and reads again at the next boundary.
    +    pub(super) key_info: TableKeyInfo,
    +    /// Files abandoned before their end, by file index, with the row and
    +    /// the kind of the abandon: the ones this incarnation abandoned, plus
    +    /// the ones an earlier incarnation's checkpoints recorded on the
    +    /// operation's `input_files`, restored with the counters.
    +    pub(super) abandoned: std::collections::BTreeMap,
    +    /// Row counts of files read to their end, by file index: the ones this
    +    /// incarnation finished, plus the ones an earlier incarnation recorded
    +    /// on the operation's `input_files` at its own checkpoints, restored
    +    /// with the counters. A file the manifest finds here is not read again.
    +    pub(super) file_row_counts: std::collections::BTreeMap,
    +    /// Entity tags of files read to their end through the hashing reader,
    +    /// by file index, from this incarnation or restored from the record.
    +    /// The manifest hashes a file that has no entry here.
    +    pub(super) file_etags: std::collections::BTreeMap,
    +    /// For files listed without an entity tag on a store whose tags the
    +    /// service assigns: the tag of the first answer this incarnation
    +    /// accepted at each file's open, by file index. Kept in memory only, so
    +    /// a file opened by an earlier incarnation and not reopened by this one
    +    /// has no entry.
    +    pub(super) opened_etags: std::collections::BTreeMap,
    +    /// Files finished since the last checkpoint, carried to the next one,
    +    /// which stores their figures beside the cursor that passes them.
    +    pub(super) pending_finished: Vec,
    +    /// Files abandoned since the last checkpoint, carried to the next one
    +    /// the same way.
    +    pub(super) pending_abandoned: Vec,
    +    /// Whether an abandon left bytes of its file beyond the read that held
    +    /// the fault, so the file's processed bytes stop at the end of that
    +    /// read. The operation runs on and ends `FAILED` with the same
    +    /// validation pair as the not-compressed class (measured on the
    +    /// service).
    +    pub(super) cut_file_seen: bool,
    +    /// Whether the file at the cursor was opened by this incarnation. A
    +    /// file the operation never opened is `NOT_PROCESSED` in the manifest
    +    /// (measured on the service), which this separates from a file
    +    /// whose read started and stopped part way.
    +    pub(super) current_file_opened: bool,
    +    /// The file whose read ended abnormally: the object was gone or
    +    /// unreadable when the runner opened or read it. The manifest reports
    +    /// it with no processed range: `NOT_PROCESSED` when its open refused it
    +    /// before any of its rows was counted, `NOT_AVAILABLE` otherwise
    +    /// (measured on the service).
    +    pub(super) unavailable_file: Option,
    +    /// The cursor of the last checkpoint the catalog committed, as
    +    /// `(file_index, row_index)`; every write batch passes it to the ledger,
    +    /// which deletes the rows it covers.
    +    pub(super) settled: (i64, i64),
    +    /// Set when the resume bound ended the operation (`runner::resume`): a
    +    /// store unavailable past its retries then ends it with the internal
    +    /// failure instead of leaving it for another resume.
    +    pub(super) resumes_spent: bool,
    +    /// Rows the ledger holds as applied past the restored checkpoint, by
    +    /// `(file_index, row_index)`, read a page at a time: the first page when
    +    /// the claim takes the operation over, the next ones as the run's
    +    /// flushes reach them (`runner::resume`). A staged row found here is
    +    /// accounted for from its entry, removed, and not written again.
    +    pub(super) recorded: std::collections::BTreeMap<(usize, u64), RecordedRow>,
    +    /// Where the next page of recorded rows starts, `(file_index,
    +    /// row_index)`, while the last page read was full; `None` once every
    +    /// recorded row of the file has been read.
    +    pub(super) recorded_next: Option<(usize, u64)>,
    +}
    +
    +impl RunState {
    +    /// Restore the run state from the record's persisted counters. A
    +    /// negative stored counter is corruption, not a shape to model, and
    +    /// refuses the claim the way the record readers in both backends
    +    /// refuse a negative report total: `StorageError::Internal` naming
    +    /// the field, with a log line.
    +    pub(super) fn restore(record: &BulkOperationRecord) -> Result {
    +        let non_negative = |field: &'static str, value: i64| -> Result {
    +            u64::try_from(value).map_err(|_| {
    +                tracing::error!(
    +                    account_id = %record.account_id,
    +                    operation_id = %record.operation_id,
    +                    field,
    +                    value,
    +                    "bulk operation record counter is negative"
    +                );
    +                StorageError::Internal(format!("bulk operation {field} is negative"))
    +            })
    +        };
    +        let cursor_file = usize::try_from(non_negative(
    +            "cursor_file_index",
    +            record.cursor_file_index,
    +        )?)
    +        .map_err(|_| {
    +            StorageError::Internal("bulk operation cursor_file_index does not fit usize".to_owned())
    +        })?;
    +        let mut completed_bytes = 0u64;
    +        let mut cut_file_seen = false;
    +        // Whether a file before the cursor was abandoned by a build that
    +        // did not record the bytes an abandoned file contributed.
    +        let mut unrecorded_abandon = false;
    +        let mut file_row_counts = std::collections::BTreeMap::new();
    +        let mut file_etags = std::collections::BTreeMap::new();
    +        let mut abandoned = std::collections::BTreeMap::new();
    +        for (index, file) in record.input_files.iter().enumerate().take(cursor_file) {
    +            let size = non_negative("input file size", file.size)?;
    +            // Figures an earlier incarnation's checkpoint stored for a file
    +            // it finished or abandoned. A negative count or row is
    +            // corruption like the counters.
    +            if let Some(rows) = file.rows {
    +                file_row_counts.insert(index, non_negative("input file rows", rows)?);
    +            }
    +            if let Some(etag) = &file.etag {
    +                file_etags.insert(index, etag.clone());
    +            }
    +            // A finished file contributed its enumerated size; an abandoned
    +            // one the bytes its record keeps: the end of the read that held
    +            // the fault when the abandon cut it short, which ends the
    +            // operation with the items-failed-validation pair (measured on
    +            // the service), or what an overwritten or refused object
    +            // counted.
    +            let mut contributed = size;
    +            if let Some(abandon) = file.abandon {
    +                let kind = abandon.kind;
    +                abandoned.insert(
    +                    index,
    +                    Abandon {
    +                        row: non_negative("input file abandon row", abandon.row)?,
    +                        kind,
    +                    },
    +                );
    +                match abandon.bytes {
    +                    Some(bytes) => {
    +                        contributed = non_negative("input file abandon bytes", bytes)?.min(size);
    +                        cut_file_seen |= kind == AbandonKind::Content && contributed < size;
    +                    }
    +                    None => unrecorded_abandon = true,
    +                }
    +            }
    +            completed_bytes = completed_bytes.saturating_add(contributed);
    +        }
    +        let bytes_floor = non_negative("processed_input_bytes", record.processed_input_bytes)?;
    +        // A checkpoint never persists a figure below the bytes of the files
    +        // before its cursor, so the sum is clamped to the persisted figure
    +        // only where that figure is lower for another reason (a held output
    +        // location fault reports no bytes). An abandon recorded without its
    +        // bytes is restored from the comparison the figures allow: a
    +        // persisted figure below the sum of the sizes is a file that
    +        // contributed fewer bytes than its size. That comparison only holds
    +        // until the run reads into the next file, which is why the bytes
    +        // are recorded now.
    +        if unrecorded_abandon {
    +            cut_file_seen |= bytes_floor < completed_bytes;
    +        }
    +        let completed_bytes = completed_bytes.min(bytes_floor);
    +        Ok(Self {
    +            processed: non_negative("processed_item_count", record.processed_item_count)?,
    +            failed: non_negative("failed_item_count", record.failed_item_count)?,
    +            completed_bytes,
    +            bytes_floor,
    +            cut_file_seen,
    +            usage: UsageAccum::restore(&record.usage),
    +            output_checked: record.report_record_count > 0,
    +            kept_records: record.report_record_count,
    +            kept_bytes: record.report_bytes,
    +            cursor_file,
    +            cursor_row: non_negative("cursor_row_index", record.cursor_row_index)?,
    +            abandoned,
    +            file_row_counts,
    +            file_etags,
    +            ..Self::default()
    +        })
    +    }
    +
    +    /// One checkpoint's worth of progress; drains the pending records.
    +    pub(super) fn progress(&mut self, table_arn: &str) -> Progress {
    +        let bytes = self
    +            .completed_bytes
    +            .saturating_add(self.current_bytes)
    +            .max(self.bytes_floor);
    +        let records = std::mem::take(&mut self.pending);
    +        self.pending_bytes = 0;
    +        let finished_files = std::mem::take(&mut self.pending_finished);
    +        let abandoned_files = std::mem::take(&mut self.pending_abandoned);
    +        Progress {
    +            cursor_file_index: to_i64(u64::try_from(self.cursor_file).unwrap_or(u64::MAX)),
    +            cursor_row_index: to_i64(self.cursor_row),
    +            processed_item_count: to_i64(self.processed),
    +            failed_item_count: to_i64(self.failed),
    +            processed_input_bytes: to_i64(bytes),
    +            report_record_count: self.kept_records,
    +            report_bytes: self.kept_bytes,
    +            usage: persisted_usage(self, table_arn),
    +            records,
    +            finished_files,
    +            abandoned_files,
    +        }
    +    }
    +
    +    /// Keep one report record, or drop and count it past either cap: the
    +    /// record count against `max_report_records`, the serialized bytes
    +    /// against `max_report_bytes`.
    +    pub(super) fn push_record(
    +        &mut self,
    +        sink: &ReportSink<'_>,
    +        kind: ReportKind,
    +        file_index: usize,
    +        row_index: u64,
    +        record: Value,
    +    ) {
    +        // A record whose kind was not requested is not written anywhere
    +        // (measured on the service: with only ERROR requested
    +        // the condition record appears in no file, with only
    +        // CONDITION_CHECK_FAILURE requested the error record appears in no
    +        // file). It still counted toward the counters at the call site. No
    +        // record is kept once an output location fault is held.
    +        if !kind_requested(kind, sink.requested) || self.held_location.is_some() {
    +            return;
    +        }
    +        let bytes = report::record_bytes(&record);
    +        if self.kept_records >= sink.max_records
    +            || self.kept_bytes.saturating_add(bytes) > sink.max_bytes
    +        {
    +            self.dropped_records = self.dropped_records.saturating_add(1);
    +            return;
    +        }
    +        self.kept_records += 1;
    +        self.kept_bytes = self.kept_bytes.saturating_add(bytes);
    +        self.pending_bytes = self.pending_bytes.saturating_add(bytes);
    +        self.pending.push(ReportRecord {
    +            kind,
    +            file_index: to_i64(u64::try_from(file_index).unwrap_or(u64::MAX)),
    +            row_index: to_i64(row_index),
    +            record,
    +        });
    +    }
    +
    +    /// Count one consumed physical line toward the batch boundary and the
    +    /// per-operation line cap. Every consumed line counts: blank lines,
    +    /// lines inside the resume skip region, a line over the line limit
    +    /// and a line that is not valid UTF-8. Returns `true` past the cap,
    +    /// with the failure set; the
    +    /// caller finishes without processing the line.
    +    pub(super) fn consume_line(&mut self, max_item_count: u64) -> bool {
    +        self.lines_consumed = self.lines_consumed.saturating_add(1);
    +        self.rows_since_checkpoint += 1;
    +        if self.lines_consumed > max_item_count {
    +            self.failure = Some(Failure::row_limit(max_item_count));
    +            return true;
    +        }
    +        false
    +    }
    +
    +    /// A checkpoint is due when the row window reaches `batch_size`, or
    +    /// when the in-memory report buffer reaches `PENDING_FLUSH_BYTES`. The
    +    /// byte trigger bounds heap use on failing-heavy input independent of
    +    /// `batch_size`; an extra checkpoint is always safe (it only persists
    +    /// progress already made and drains the buffer).
    +    pub(super) fn checkpoint_due(&self, batch_size: usize) -> bool {
    +        self.rows_since_checkpoint >= batch_size || self.pending_bytes >= PENDING_FLUSH_BYTES
    +    }
    +}
    +
    +pub(super) fn to_i64(value: u64) -> i64 {
    +    i64::try_from(value).unwrap_or(i64::MAX)
    +}
    +
    +/// The terminal status of a run: a set failure wins, then an observed
    +/// cancel, then failed rows; condition failures count in none of them.
    +pub(super) fn terminal_status(
    +    failure: Option<&Failure>,
    +    cancelled: bool,
    +    failed_item_count: u64,
    +) -> &'static str {
    +    if failure.is_some() {
    +        STATUS_FAILED
    +    } else if cancelled {
    +        STATUS_CANCELLED
    +    } else if failed_item_count > 0 {
    +        STATUS_COMPLETED_WITH_ERRORS
    +    } else {
    +        STATUS_COMPLETED
    +    }
    +}
    +
    +/// What one report record is pushed against: the per-operation record and
    +/// byte caps and the kinds the request asked for.
    +pub(super) struct ReportSink<'a> {
    +    pub(super) max_records: u64,
    +    pub(super) max_bytes: u64,
    +    pub(super) requested: &'a [OutputType],
    +}
    +
    +/// Whether the request asked for this report kind. A record of a kind that
    +/// was not requested is written to no file (measured on the service); the
    +/// handler refuses an empty `OutputType`, so at least one kind
    +/// is always requested.
    +pub(super) fn kind_requested(kind: ReportKind, requested: &[OutputType]) -> bool {
    +    match kind {
    +        ReportKind::Error => requested.contains(&OutputType::Error),
    +        ReportKind::ConditionCheckFailure => requested.contains(&OutputType::ConditionCheckFailure),
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/runner/tests/admit.rs b/crates/engine/src/bulk_put/runner/tests/admit.rs
    new file mode 100644
    index 000000000..2ee7e1895
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/tests/admit.rs
    @@ -0,0 +1,356 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Unit tests for the admit concern of the runner.
    +
    +use super::super::*;
    +use extenddb_core::types::{
    +    AttributeDefinition, AttributeValue, KeySchemaElement, KeyType, ScalarAttributeType,
    +};
    +
    +// Usage arithmetic lives in `bulk_put::usage`, tested there.
    +
    +fn key(name: &str) -> KeySchemaElement {
    +    KeySchemaElement {
    +        attribute_name: name.to_owned(),
    +        key_type: KeyType::Hash,
    +    }
    +}
    +
    +fn item(entries: &[(&str, &str)]) -> Item {
    +    let mut item = Item::new();
    +    for (name, value) in entries {
    +        item.insert((*name).to_owned(), AttributeValue::S((*value).to_owned()));
    +    }
    +    item
    +}
    +
    +// newImage presence by fired check.
    +
    +#[test]
    +fn a_missing_key_fault_carries_the_submitted_image() {
    +    let missing = item(&[("pk", "a")]);
    +    assert_eq!(
    +        item_fault_new_image(PutPayloadFault::MissingKey, &missing, 400 * 1024),
    +        Some(&missing)
    +    );
    +}
    +
    +#[test]
    +fn a_type_mismatch_fault_carries_no_image() {
    +    let present = item(&[("pk", "a"), ("other", "b")]);
    +    assert_eq!(
    +        item_fault_new_image(PutPayloadFault::KeyTypeMismatch, &present, 400 * 1024),
    +        None
    +    );
    +}
    +
    +#[test]
    +fn an_index_key_fault_carries_the_image() {
    +    // Measured on the service: wrong-typed and empty-string index key
    +    // records both embed the submitted item.
    +    let present = item(&[("pk", "a"), ("g1", "")]);
    +    assert_eq!(
    +        item_fault_new_image(PutPayloadFault::IndexKey, &present, 400 * 1024),
    +        Some(&present)
    +    );
    +}
    +
    +#[test]
    +fn an_oversized_fault_carries_no_image() {
    +    let present = item(&[("pk", "a")]);
    +    assert_eq!(
    +        item_fault_new_image(PutPayloadFault::Oversized, &present, 400 * 1024),
    +        None
    +    );
    +    assert_eq!(
    +        item_fault_new_image(PutPayloadFault::Other, &present, 400 * 1024),
    +        None
    +    );
    +}
    +
    +#[test]
    +fn a_missing_key_fault_on_an_oversized_item_carries_no_image() {
    +    let missing = item(&[("pk", "a"), ("big", "bbbbbbbbbb")]);
    +    assert_eq!(
    +        item_fault_new_image(PutPayloadFault::MissingKey, &missing, 4),
    +        None,
    +        "an item past the size limit is not embedded in the record"
    +    );
    +}
    +
    +// Row admission through the shared write core.
    +
    +/// A `TableKeyInfo` with string keys `pk` and `sk`, enough for the
    +/// admission checks.
    +fn key_info() -> TableKeyInfo {
    +    TableKeyInfo {
    +        key_schema: vec![key("pk"), key("sk")],
    +        attribute_definitions: vec![
    +            AttributeDefinition {
    +                attribute_name: "pk".to_owned(),
    +                attribute_type: ScalarAttributeType::S,
    +            },
    +            AttributeDefinition {
    +                attribute_name: "sk".to_owned(),
    +                attribute_type: ScalarAttributeType::S,
    +            },
    +        ],
    +        ..TableKeyInfo::default()
    +    }
    +}
    +
    +fn limits() -> LimitsConfig {
    +    LimitsConfig::default()
    +}
    +
    +#[test]
    +fn a_row_with_a_missing_key_and_an_unparseable_condition_reports_the_condition_message_as_put_item_does()
    + {
    +    // handle_put_item resolves the condition fields before validating
    +    // the item, so the expression message wins when a row carries both
    +    // faults; the shared core fixes the runner to the same order.
    +    let row = Row {
    +        item: item(&[("pk", "a")]),
    +        condition: Some(RowCondition {
    +            expression: "attribute_not_exists((".to_owned(),
    +            names: None,
    +            values: None,
    +        }),
    +        repeated_member_bytes: 0,
    +    };
    +    let Admission::Fault { message, kind } = admit_row(&row, &key_info(), &limits()) else {
    +        panic!("the row must be refused");
    +    };
    +    // The record text is the shared parser's message for the same expression
    +    // without the single-item prefix (measured on the service).
    +    let put_item = crate::expression_helpers::resolve_condition(
    +        Some("attribute_not_exists(("),
    +        None,
    +        None,
    +        None,
    +        None,
    +        &limits(),
    +    )
    +    .expect_err("the condition expression is invalid")
    +    .to_string();
    +    let expected = put_item
    +        .strip_prefix("Invalid ConditionExpression: ")
    +        .expect("the single-item path prefixes the condition fault");
    +    assert_eq!(
    +        message, expected,
    +        "the condition message wins over the missing key"
    +    );
    +    assert_eq!(kind, None, "a condition fault carries no image kind");
    +}
    +
    +#[test]
    +fn a_row_with_a_missing_key_and_a_parseable_condition_reports_the_missing_key() {
    +    let row = Row {
    +        item: item(&[("pk", "a")]),
    +        condition: Some(RowCondition {
    +            expression: "attribute_not_exists(pk)".to_owned(),
    +            names: None,
    +            values: None,
    +        }),
    +        repeated_member_bytes: 0,
    +    };
    +    let Admission::Fault { message, kind } = admit_row(&row, &key_info(), &limits()) else {
    +        panic!("the row must be refused");
    +    };
    +    assert_eq!(
    +        message,
    +        "One or more parameter values were invalid: Missing the key sk in the item"
    +    );
    +    assert_eq!(kind, Some(PutPayloadFault::MissingKey));
    +}
    +
    +#[test]
    +fn a_row_whose_index_key_has_the_wrong_type_or_is_empty_fails_the_row_with_the_put_item_message() {
    +    use extenddb_core::types::{IndexInfo, IndexType, Projection, ProjectionType};
    +    let mut info = key_info();
    +    info.attribute_definitions.push(AttributeDefinition {
    +        attribute_name: "g1".to_owned(),
    +        attribute_type: ScalarAttributeType::S,
    +    });
    +    info.global_secondary_indexes.push(IndexInfo {
    +        index_name: "gsi1".to_owned(),
    +        index_id: "gsi1-id".to_owned(),
    +        index_type: IndexType::Gsi,
    +        key_schema: vec![key("g1")],
    +        projection: Projection {
    +            projection_type: ProjectionType::All,
    +            non_key_attributes: None,
    +        },
    +    });
    +    let mut mismatch = item(&[("pk", "a"), ("sk", "b")]);
    +    mismatch.insert("g1".to_owned(), AttributeValue::N("5".to_owned()));
    +    let Admission::Fault { message, kind } = admit_row(
    +        &Row {
    +            item: mismatch,
    +            condition: None,
    +            repeated_member_bytes: 0,
    +        },
    +        &info,
    +        &limits(),
    +    ) else {
    +        panic!("the type-mismatched index key must fail the row");
    +    };
    +    assert_eq!(
    +        message,
    +        "One or more parameter values were invalid: Type mismatch for Index Key g1 Expected: S \
    +             Actual: N IndexName: gsi1"
    +    );
    +    assert_eq!(kind, Some(PutPayloadFault::IndexKey));
    +
    +    let empty = item(&[("pk", "a"), ("sk", "b"), ("g1", "")]);
    +    let Admission::Fault { message, .. } = admit_row(
    +        &Row {
    +            item: empty,
    +            condition: None,
    +            repeated_member_bytes: 0,
    +        },
    +        &info,
    +        &limits(),
    +    ) else {
    +        panic!("the empty index key must fail the row");
    +    };
    +    assert!(
    +        message.contains("cannot contain an empty string value. IndexName: gsi1, IndexKey: g1"),
    +        "{message}"
    +    );
    +
    +    // An item without the index attribute is admitted (sparse index).
    +    let sparse = item(&[("pk", "a"), ("sk", "b")]);
    +    assert!(matches!(
    +        admit_row(
    +            &Row {
    +                item: sparse,
    +                condition: None,
    +                repeated_member_bytes: 0,
    +            },
    +            &info,
    +            &limits()
    +        ),
    +        Admission::Write(None)
    +    ));
    +}
    +
    +#[test]
    +fn a_valid_row_is_admitted_with_its_parsed_condition() {
    +    let row = Row {
    +        item: item(&[("pk", "a"), ("sk", "b")]),
    +        condition: Some(RowCondition {
    +            expression: "attribute_not_exists(pk)".to_owned(),
    +            names: None,
    +            values: None,
    +        }),
    +        repeated_member_bytes: 0,
    +    };
    +    match admit_row(&row, &key_info(), &limits()) {
    +        Admission::Write(Some(_)) => {}
    +        _ => panic!("the row must be admitted with a parsed condition"),
    +    }
    +    let plain = Row {
    +        item: item(&[("pk", "a"), ("sk", "b")]),
    +        condition: None,
    +        repeated_member_bytes: 0,
    +    };
    +    match admit_row(&plain, &key_info(), &limits()) {
    +        Admission::Write(None) => {}
    +        _ => panic!("the plain row must be admitted without a condition"),
    +    }
    +}
    +
    +/// The bytes of the set members the parser removed as repeats count toward
    +/// the item size (measured on the service), through the same check and at
    +/// the same place in the order: an item within the limit once deduplicated
    +/// and over it with its repeats fails as oversized, the same item with no
    +/// repeats is admitted, and a missing key is still reported first.
    +#[test]
    +fn removed_repeated_set_members_count_toward_the_item_size_in_the_put_item_order() {
    +    let mut doubled = item(&[("pk", "a"), ("sk", "b")]);
    +    doubled.insert(
    +        "ss".to_owned(),
    +        AttributeValue::SS(["x".repeat(250_000)].into_iter().collect()),
    +    );
    +    let row = Row {
    +        item: doubled.clone(),
    +        condition: None,
    +        repeated_member_bytes: 250_000,
    +    };
    +    let Admission::Fault { message, kind } = admit_row(&row, &key_info(), &limits()) else {
    +        panic!("the item is over the limit with its repeated member counted");
    +    };
    +    assert_eq!(message, "Item size has exceeded the maximum allowed size");
    +    assert_eq!(kind, Some(PutPayloadFault::Oversized));
    +
    +    let once = Row {
    +        repeated_member_bytes: 0,
    +        ..row
    +    };
    +    assert!(matches!(
    +        admit_row(&once, &key_info(), &limits()),
    +        Admission::Write(None)
    +    ));
    +
    +    doubled.remove("sk");
    +    let missing = Row {
    +        item: doubled,
    +        condition: None,
    +        repeated_member_bytes: 250_000,
    +    };
    +    let Admission::Fault { kind, .. } = admit_row(&missing, &key_info(), &limits()) else {
    +        panic!("the row must be refused");
    +    };
    +    assert_eq!(kind, Some(PutPayloadFault::MissingKey));
    +}
    +
    +/// A 65,536-byte attribute name fails the row with the standalone `PutItem`
    +/// text and the `Other` kind, which carries no image, whether or not a key
    +/// attribute is also missing: the name check runs ahead of the key checks.
    +#[test]
    +fn a_long_attribute_name_is_reported_ahead_of_a_missing_key_with_the_other_kind() {
    +    let long_name = "n".repeat(65_536);
    +    let with_keys = item(&[("pk", "a"), ("sk", "b"), (long_name.as_str(), "v")]);
    +    let without_sk = item(&[("pk", "a"), (long_name.as_str(), "v")]);
    +    for item in [with_keys, without_sk] {
    +        let row = Row {
    +            item,
    +            condition: None,
    +            repeated_member_bytes: 0,
    +        };
    +        let Admission::Fault { message, kind } = admit_row(&row, &key_info(), &limits()) else {
    +            panic!("the row must be refused");
    +        };
    +        assert_eq!(
    +            message,
    +            "1 validation error detected: Attribute name is too large, must be less than 65536 \
    +             bytes"
    +        );
    +        assert_eq!(kind, Some(PutPayloadFault::Other));
    +        let image = kind
    +            .and_then(|kind| item_fault_new_image(kind, &row.item, limits().max_item_size_bytes));
    +        assert_eq!(image, None, "the record carries no image");
    +    }
    +}
    +
    +// Skip-region fault routing.
    +
    +#[test]
    +fn only_a_line_over_the_limit_abandons_the_skip_region() {
    +    assert!(skip_fault_abandons(&SourceError::LineTooLong));
    +    assert!(skip_fault_abandons(&SourceError::InvalidUtf8));
    +    assert!(skip_fault_abandons(&SourceError::Corrupt));
    +}
    +
    +#[test]
    +fn io_jail_and_file_type_faults_in_the_skip_region_fail_the_operation() {
    +    assert!(!skip_fault_abandons(&SourceError::Io(
    +        std::io::Error::other("disk read failed")
    +    )));
    +    assert!(!skip_fault_abandons(&SourceError::Jail(
    +        "path escapes the account root".to_owned()
    +    )));
    +    assert!(!skip_fault_abandons(&SourceError::NotRegularFile));
    +}
    diff --git a/crates/engine/src/bulk_put/runner/tests/mod.rs b/crates/engine/src/bulk_put/runner/tests/mod.rs
    new file mode 100644
    index 000000000..01ac87592
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/tests/mod.rs
    @@ -0,0 +1,7 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +mod admit;
    +mod resume;
    +mod state;
    +mod write;
    diff --git a/crates/engine/src/bulk_put/runner/tests/resume.rs b/crates/engine/src/bulk_put/runner/tests/resume.rs
    new file mode 100644
    index 000000000..e2613881f
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/tests/resume.rs
    @@ -0,0 +1,13 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The outcome the resume bound's ERROR line names is the one the store
    +//! records: `finish` ends a `CANCELLING` row `CANCELLED`.
    +
    +use super::super::resume::outcome_after_the_bound;
    +
    +#[test]
    +fn the_bound_names_the_outcome_the_store_records() {
    +    assert_eq!(outcome_after_the_bound("CANCELLING"), "CANCELLED");
    +    assert_eq!(outcome_after_the_bound("IN_PROGRESS"), "FAILED");
    +}
    diff --git a/crates/engine/src/bulk_put/runner/tests/state.rs b/crates/engine/src/bulk_put/runner/tests/state.rs
    new file mode 100644
    index 000000000..327c9d8a1
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/tests/state.rs
    @@ -0,0 +1,450 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Unit tests for the state concern of the runner.
    +
    +use super::super::*;
    +use serde_json::json;
    +
    +const BOTH: &[OutputType] = &[OutputType::Error, OutputType::ConditionCheckFailure];
    +
    +// Terminal status selection.
    +
    +#[test]
    +fn no_failures_end_completed() {
    +    assert_eq!(terminal_status(None, false, 0), "COMPLETED");
    +}
    +
    +#[test]
    +fn failed_rows_end_completed_with_errors() {
    +    assert_eq!(terminal_status(None, false, 3), "COMPLETED_WITH_ERRORS");
    +}
    +
    +#[test]
    +fn an_observed_cancel_ends_cancelled_even_with_failed_rows() {
    +    assert_eq!(terminal_status(None, true, 0), "CANCELLED");
    +    assert_eq!(terminal_status(None, true, 3), "CANCELLED");
    +}
    +
    +#[test]
    +fn a_set_failure_wins_over_cancel_and_failed_rows() {
    +    let failure = Failure::internal();
    +    assert_eq!(terminal_status(Some(&failure), true, 3), "FAILED");
    +    assert_eq!(terminal_status(Some(&failure), false, 0), "FAILED");
    +}
    +
    +// Record kinds by output type (measured on the service).
    +
    +#[test]
    +fn a_kind_is_written_only_when_it_was_requested() {
    +    let both = [OutputType::Error, OutputType::ConditionCheckFailure];
    +    assert!(kind_requested(ReportKind::Error, &both));
    +    assert!(kind_requested(ReportKind::ConditionCheckFailure, &both));
    +    assert!(kind_requested(ReportKind::Error, &[OutputType::Error]));
    +    assert!(!kind_requested(
    +        ReportKind::ConditionCheckFailure,
    +        &[OutputType::Error]
    +    ));
    +    assert!(kind_requested(
    +        ReportKind::ConditionCheckFailure,
    +        &[OutputType::ConditionCheckFailure]
    +    ));
    +    assert!(!kind_requested(
    +        ReportKind::Error,
    +        &[OutputType::ConditionCheckFailure]
    +    ));
    +}
    +
    +// The failure-path cursor arithmetic.
    +
    +// The per-operation line cap.
    +
    +#[test]
    +fn every_consumed_line_advances_the_cap_count_and_the_boundary() {
    +    let mut st = RunState::default();
    +    assert!(!st.consume_line(10));
    +    assert!(!st.consume_line(10));
    +    assert_eq!(st.lines_consumed, 2);
    +    assert_eq!(st.rows_since_checkpoint, 2);
    +    assert!(st.failure.is_none());
    +}
    +
    +#[test]
    +fn the_line_past_the_cap_sets_the_row_limit_failure() {
    +    let mut st = RunState::default();
    +    for _ in 0..10 {
    +        assert!(!st.consume_line(10), "lines within the cap pass");
    +    }
    +    assert!(st.consume_line(10), "the eleventh line trips a cap of ten");
    +    let failure = st.failure.expect("the failure is set");
    +    assert_eq!(failure.code, CODE_VALIDATION);
    +    assert_eq!(
    +        failure.message,
    +        "Operation exceeded max_bulk_item_count (10 rows)"
    +    );
    +}
    +
    +#[test]
    +fn the_cap_count_does_not_reset_across_files_or_counters() {
    +    // The count is physical lines consumed, independent of the
    +    // processed and failed counters a row may or may not touch.
    +    let mut st = RunState {
    +        processed: 3,
    +        failed: 2,
    +        ..RunState::default()
    +    };
    +    assert!(!st.consume_line(4));
    +    assert!(!st.consume_line(4));
    +    assert!(!st.consume_line(4));
    +    assert!(!st.consume_line(4));
    +    assert!(st.consume_line(4));
    +    assert_eq!(st.processed, 3, "the cap count leaves processed alone");
    +    assert_eq!(st.failed, 2, "the cap count leaves failed alone");
    +}
    +
    +// The report-record caps.
    +
    +#[test]
    +fn the_report_buffer_forces_a_checkpoint_by_bytes_under_a_huge_batch_size() {
    +    // A large batch_size would otherwise let `pending` grow to
    +    // batch_size records before a flush. The byte trigger bounds the
    +    // buffer regardless: once pending bytes cross PENDING_FLUSH_BYTES,
    +    // a checkpoint is due even though the row window is far below the
    +    // batch size.
    +    let mut st = RunState::default();
    +    let huge_batch = 1_000_000usize;
    +    assert!(!st.checkpoint_due(huge_batch), "nothing pending yet");
    +
    +    // One ~1 MiB record per failing row; well under the count/byte caps
    +    // so every record is kept and buffered.
    +    let big = json!({"errorMessages": ["x".repeat(1024 * 1024)]});
    +    let mut rows = 0u64;
    +    while !st.checkpoint_due(huge_batch) {
    +        st.push_record(
    +            &ReportSink {
    +                max_records: u64::MAX,
    +                max_bytes: u64::MAX,
    +                requested: BOTH,
    +            },
    +            ReportKind::Error,
    +            0,
    +            rows,
    +            big.clone(),
    +        );
    +        st.rows_since_checkpoint += 1;
    +        rows += 1;
    +        assert!(
    +            rows < 1_000,
    +            "byte flush must trigger well before the batch size"
    +        );
    +    }
    +    assert!(
    +        st.pending_bytes >= PENDING_FLUSH_BYTES,
    +        "the checkpoint became due because the byte threshold was crossed"
    +    );
    +    assert!(
    +        st.rows_since_checkpoint < huge_batch,
    +        "the flush fired on bytes, not on the row window"
    +    );
    +
    +    // Draining at a checkpoint resets the byte accounting.
    +    let _ = st.progress("arn:aws:dynamodb:us-east-1:000000000000:table/t");
    +    assert_eq!(st.pending_bytes, 0);
    +    assert!(st.pending.is_empty());
    +}
    +
    +#[test]
    +fn records_beyond_the_count_cap_are_dropped_and_counted() {
    +    let mut st = RunState::default();
    +    for row in 0..5 {
    +        st.push_record(
    +            &ReportSink {
    +                max_records: 3,
    +                max_bytes: u64::MAX,
    +                requested: BOTH,
    +            },
    +            ReportKind::Error,
    +            0,
    +            row,
    +            json!({"errorMessages": ["x"]}),
    +        );
    +    }
    +    assert_eq!(st.pending.len(), 3);
    +    assert_eq!(st.kept_records, 3);
    +    assert_eq!(st.dropped_records, 2);
    +}
    +
    +#[test]
    +fn a_seeded_kept_count_tightens_the_cap_for_a_resumed_run() {
    +    let mut st = RunState {
    +        kept_records: 2,
    +        ..RunState::default()
    +    };
    +    st.push_record(
    +        &ReportSink {
    +            max_records: 3,
    +            max_bytes: u64::MAX,
    +            requested: BOTH,
    +        },
    +        ReportKind::Error,
    +        0,
    +        0,
    +        json!({"errorMessages": ["x"]}),
    +    );
    +    st.push_record(
    +        &ReportSink {
    +            max_records: 3,
    +            max_bytes: u64::MAX,
    +            requested: BOTH,
    +        },
    +        ReportKind::Error,
    +        0,
    +        1,
    +        json!({"errorMessages": ["y"]}),
    +    );
    +    assert_eq!(st.pending.len(), 1, "only one slot was left under the cap");
    +    assert_eq!(st.dropped_records, 1);
    +}
    +
    +#[test]
    +fn records_beyond_the_byte_cap_are_dropped_and_counted() {
    +    // Each record serializes to 21 bytes; the cap admits exactly two.
    +    let record = json!({"errorMessages": ["x"]});
    +    let bytes = report::record_bytes(&record);
    +    let mut st = RunState::default();
    +    for row in 0..3 {
    +        st.push_record(
    +            &ReportSink {
    +                max_records: u64::MAX,
    +                max_bytes: bytes * 2,
    +                requested: BOTH,
    +            },
    +            ReportKind::Error,
    +            0,
    +            row,
    +            record.clone(),
    +        );
    +    }
    +    assert_eq!(st.pending.len(), 2);
    +    assert_eq!(st.kept_bytes, bytes * 2);
    +    assert_eq!(st.dropped_records, 1);
    +}
    +
    +#[test]
    +fn seeded_kept_bytes_tighten_the_byte_cap_for_a_resumed_run() {
    +    let record = json!({"errorMessages": ["x"]});
    +    let bytes = report::record_bytes(&record);
    +    let mut st = RunState {
    +        kept_bytes: bytes,
    +        ..RunState::default()
    +    };
    +    st.push_record(
    +        &ReportSink {
    +            max_records: u64::MAX,
    +            max_bytes: bytes * 2,
    +            requested: BOTH,
    +        },
    +        ReportKind::Error,
    +        0,
    +        0,
    +        record.clone(),
    +    );
    +    st.push_record(
    +        &ReportSink {
    +            max_records: u64::MAX,
    +            max_bytes: bytes * 2,
    +            requested: BOTH,
    +        },
    +        ReportKind::Error,
    +        0,
    +        1,
    +        record,
    +    );
    +    assert_eq!(
    +        st.pending.len(),
    +        1,
    +        "only one record's bytes were left under the cap"
    +    );
    +    assert_eq!(st.dropped_records, 1);
    +}
    +
    +// Restore from a persisted record, and the checkpoint byte floor.
    +
    +/// A record persisted mid-run: two of three files finished, the cursor
    +/// five rows into the third, and 350 processed input bytes on the row,
    +/// more than the 300 the two finished files account for because the
    +/// checkpoint that wrote it was partway through the third file.
    +fn persisted_record() -> BulkOperationRecord {
    +    BulkOperationRecord {
    +        account_id: "111122223333".to_owned(),
    +        operation_id: "01789060589767-da9ddd4f".to_owned(),
    +        table_id: "tid-1".to_owned(),
    +        table_name: "orders".to_owned(),
    +        table_arn: "arn:aws:dynamodb:us-east-1:111122223333:table/orders".to_owned(),
    +        status: "IN_PROGRESS".to_owned(),
    +        client_token: None,
    +        request_fingerprint: "f".repeat(64),
    +        input_config: json!({}),
    +        output_config: json!({}),
    +        input_files: vec![
    +            extenddb_storage::InputFile {
    +                path: "a.json".to_owned(),
    +                size: 100,
    +                rows: None,
    +                etag: None,
    +                abandon: None,
    +            },
    +            extenddb_storage::InputFile {
    +                path: "b.json".to_owned(),
    +                size: 200,
    +                rows: None,
    +                etag: None,
    +                abandon: None,
    +            },
    +            extenddb_storage::InputFile {
    +                path: "c.json".to_owned(),
    +                size: 400,
    +                rows: None,
    +                etag: None,
    +                abandon: None,
    +            },
    +        ],
    +        max_write_request_units: None,
    +        performance_tier: Some("STANDARD".to_owned()),
    +        start_time_ms: 1_789_060_589_767,
    +        end_time_ms: None,
    +        processed_item_count: 7,
    +        failed_item_count: 1,
    +        processed_input_bytes: 350,
    +        total_input_bytes: Some(700),
    +        report_record_count: 2,
    +        report_bytes: 64,
    +        usage: json!({}),
    +        failure_code: None,
    +        failure_message: None,
    +        cancel_requested: false,
    +        cursor_file_index: 2,
    +        cursor_row_index: 5,
    +        lease_owner: Some("worker-1".to_owned()),
    +        lease_until_ms: Some(1_789_060_649_767),
    +        run_context: None,
    +    }
    +}
    +
    +#[test]
    +fn restore_sums_the_finished_files_and_seeds_the_cursor_and_caps() {
    +    let st = RunState::restore(&persisted_record()).expect("the record restores");
    +    assert_eq!(
    +        st.completed_bytes, 300,
    +        "the sizes of the files before the cursor, and only those"
    +    );
    +    assert_eq!(st.bytes_floor, 350, "the persisted ProcessedInputBytes");
    +    assert_eq!(st.cursor_file, 2);
    +    assert_eq!(st.cursor_row, 5);
    +    assert_eq!(st.processed, 7);
    +    assert_eq!(st.failed, 1);
    +    assert_eq!(st.kept_records, 2, "the report count cap resumes seeded");
    +    assert_eq!(st.kept_bytes, 64, "the report byte cap resumes seeded");
    +}
    +
    +#[test]
    +fn a_checkpoint_after_restore_never_reports_bytes_below_the_persisted_floor() {
    +    // A run that fails before reopening the resumed file has counted only
    +    // the two finished files (300 bytes); the floor keeps the persisted
    +    // 350 from regressing. Once fresh reads pass the floor, the larger
    +    // figure wins.
    +    let mut st = RunState::restore(&persisted_record()).expect("the record restores");
    +    let arn = "arn:aws:dynamodb:us-east-1:111122223333:table/orders";
    +    assert_eq!(st.progress(arn).processed_input_bytes, 350);
    +    st.current_bytes = 120;
    +    assert_eq!(st.progress(arn).processed_input_bytes, 420);
    +}
    +
    +#[test]
    +fn restore_refuses_a_negative_persisted_counter_naming_the_field() {
    +    let mut record = persisted_record();
    +    record.processed_item_count = -1;
    +    let Err(err) = RunState::restore(&record) else {
    +        panic!("a negative counter is corruption and must refuse the claim");
    +    };
    +    match err {
    +        StorageError::Internal(message) => {
    +            assert!(
    +                message.contains("processed_item_count"),
    +                "the refusal names the field: {message}"
    +            );
    +        }
    +        other => panic!("expected StorageError::Internal, got {other:?}"),
    +    }
    +}
    +
    +// The abandon cut (measured on the service).
    +
    +#[test]
    +fn the_cut_follows_the_read_that_held_the_faulting_line() {
    +    // A fault on line 1 of 23,840 bytes: the first read, 8,000 counted.
    +    assert_eq!(cut_processed_bytes(10, 23_840), Some(8_000));
    +    // A fault ending near byte 1,100 of 9,990: 8,000 counted.
    +    assert_eq!(cut_processed_bytes(1_100, 9_990), Some(8_000));
    +    // The whole file fits one read: no cut.
    +    assert_eq!(cut_processed_bytes(10, 1_980), None);
    +    // A fault on the last line of 23,840 bytes: no cut.
    +    assert_eq!(cut_processed_bytes(23_840, 23_840), None);
    +    // A line whose last byte is the last byte of a read belongs to that
    +    // read, not the next one: the cut lands at 8,000, not 16,000.
    +    assert_eq!(cut_processed_bytes(8_000, 23_840), Some(8_000));
    +    // The first byte of the next read is the next read.
    +    assert_eq!(cut_processed_bytes(8_001, 23_840), Some(16_000));
    +    assert_eq!(cut_processed_bytes(0, 0), None);
    +}
    +
    +// The compressed-object cut (measured on the service).
    +
    +#[test]
    +fn the_gzip_cut_follows_the_header_read_then_the_decoder_reads() {
    +    let gzip = ReadGeometry::of(InputCodec::Gzip);
    +    // The first line decoded from the first read of a 204,490-byte
    +    // object: the header read plus one read, 65,546.
    +    assert_eq!(gzip.cut_processed_bytes(8_192, 204_490), Some(65_546));
    +    // A fault once about 100 KB of the stream was pulled: 131,082.
    +    assert_eq!(gzip.cut_processed_bytes(106_496, 204_488), Some(131_082));
    +    // A 30,758-byte object fits the first read: no cut.
    +    assert_eq!(gzip.cut_processed_bytes(8_192, 30_758), None);
    +    // The fault on the last line: no cut.
    +    assert_eq!(gzip.cut_processed_bytes(204_561, 204_561), None);
    +    // The last byte of a read belongs to it; the next byte starts the next.
    +    assert_eq!(gzip.cut_processed_bytes(65_546, 204_490), Some(65_546));
    +    assert_eq!(gzip.cut_processed_bytes(65_547, 204_490), Some(131_082));
    +    // A fault attributed to the header read itself cuts at the header.
    +    assert_eq!(gzip.cut_processed_bytes(10, 204_490), Some(10));
    +}
    +
    +#[test]
    +fn the_zstd_cut_follows_whole_block_sized_reads() {
    +    let zstd = ReadGeometry::of(InputCodec::Zstd);
    +    // The first block pulled (about 66 KB) sits in the first read of
    +    // a 198,728-byte object: 131,075.
    +    assert_eq!(zstd.cut_processed_bytes(73_728, 198_728), Some(131_075));
    +    // The second block (about 132 KB pulled) sits in the second read,
    +    // which holds the end of the 198,831-byte object: no cut.
    +    assert_eq!(zstd.cut_processed_bytes(139_264, 198_831), None);
    +    // A 99,334-byte object fits the first read: no cut.
    +    assert_eq!(zstd.cut_processed_bytes(73_728, 99_334), None);
    +    assert_eq!(zstd.cut_processed_bytes(131_075, 300_000), Some(131_075));
    +    assert_eq!(zstd.cut_processed_bytes(131_076, 300_000), Some(262_150));
    +}
    +
    +#[test]
    +fn the_plain_geometry_is_the_eight_thousand_byte_read() {
    +    assert_eq!(
    +        ReadGeometry::of(InputCodec::None),
    +        ReadGeometry {
    +            head: 0,
    +            chunk: READ_CHUNK_BYTES
    +        }
    +    );
    +    assert_eq!(
    +        ReadGeometry::of(InputCodec::None).cut_processed_bytes(1_100, 9_990),
    +        cut_processed_bytes(1_100, 9_990)
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/runner/tests/write.rs b/crates/engine/src/bulk_put/runner/tests/write.rs
    new file mode 100644
    index 000000000..2455125ef
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/tests/write.rs
    @@ -0,0 +1,100 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Unit tests for the write batch fault classifier: which storage errors
    +//! leave the operation to resume and which end it.
    +
    +use super::super::write::write_batch_fault_resumes;
    +use super::super::*;
    +
    +fn internal(message: &str) -> StorageError {
    +    StorageError::Internal(message.to_owned())
    +}
    +
    +#[test]
    +fn a_full_disk_and_too_many_connections_resume() {
    +    for code in ["53100", "53300"] {
    +        let error = internal(&format!("SQLSTATE {code}: insufficient resources"));
    +        assert!(
    +            write_batch_fault_resumes(&error),
    +            "SQLSTATE {code} leaves the operation to resume"
    +        );
    +    }
    +}
    +
    +/// Out of memory, a configuration limit and the class's generic code can
    +/// recur on every attempt of the same batch (a transaction that takes more
    +/// locks than the server allows), so a resume would never end.
    +#[test]
    +fn resource_errors_a_batch_can_meet_every_time_stay_terminal() {
    +    for code in ["53000", "53200", "53400"] {
    +        let error = internal(&format!("SQLSTATE {code}: insufficient resources"));
    +        assert!(
    +            !write_batch_fault_resumes(&error),
    +            "SQLSTATE {code} ends the operation"
    +        );
    +    }
    +}
    +
    +#[test]
    +fn a_disk_full_batch_error_as_postgres_renders_it_resumes() {
    +    let error = internal(
    +        "SQLSTATE 53100: error returned from database: could not extend file \
    +         \"base/16384/16385\": No space left on device",
    +    );
    +    assert!(write_batch_fault_resumes(&error));
    +}
    +
    +#[test]
    +fn the_codes_that_already_resumed_still_resume() {
    +    for code in [
    +        "08000", "08006", "57P01", "57P02", "57P03", "57014", "55P03", "40001", "40P01",
    +    ] {
    +        let error = internal(&format!("SQLSTATE {code}: fault"));
    +        assert!(
    +            write_batch_fault_resumes(&error),
    +            "SQLSTATE {code} leaves the operation to resume"
    +        );
    +    }
    +}
    +
    +#[test]
    +fn codes_outside_the_transient_classes_stay_terminal() {
    +    // 42P01 undefined table, 23505 unique violation, 22001 string too long,
    +    // 54000 program limit exceeded (a neighbour of class 53 that a retry
    +    // does not clear), 5 as a bare prefix.
    +    for code in ["42P01", "23505", "22001", "54000", "5"] {
    +        let error = internal(&format!("SQLSTATE {code}: fault"));
    +        assert!(
    +            !write_batch_fault_resumes(&error),
    +            "SQLSTATE {code} ends the operation"
    +        );
    +    }
    +}
    +
    +#[test]
    +fn a_class_code_in_the_message_text_is_not_read() {
    +    // The code is read only from the fixed prefix, never from the text.
    +    let error = internal("error returned from database: SQLSTATE 53100 in the text");
    +    assert!(!write_batch_fault_resumes(&error));
    +}
    +
    +#[test]
    +fn the_sqlite_busy_and_locked_form_resumes() {
    +    // SQLite's busy and locked result codes reach the runner as `Transient`
    +    // through the storage layer's error mapping.
    +    let error = StorageError::Transient(
    +        "error returned from database: (code: 5) database is locked".to_owned(),
    +    );
    +    assert!(write_batch_fault_resumes(&error));
    +}
    +
    +#[test]
    +fn the_sqlite_full_form_carries_no_sqlstate_and_stays_terminal() {
    +    // SQLite reports a full disk as result code 13, which its storage layer
    +    // maps to `Internal` with sqlx's message and no SQLSTATE prefix. The
    +    // classifier keys on the variant and the prefix only, so this form ends
    +    // the operation.
    +    let error = internal("error returned from database: (code: 13) database or disk is full");
    +    assert!(!write_batch_fault_resumes(&error));
    +}
    diff --git a/crates/engine/src/bulk_put/runner/wait.rs b/crates/engine/src/bulk_put/runner/wait.rs
    new file mode 100644
    index 000000000..d4115cb00
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/wait.rs
    @@ -0,0 +1,107 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Waiting on the source while the lease is kept.
    +//!
    +//! The lease is renewed at a checkpoint, and checkpoints come between rows.
    +//! A source call can wait far longer than one lease: an S3 request that
    +//! gets no answer is retried for up to 120 seconds, and each attempt waits
    +//! up to 60 seconds for its answer. Without a renewal during the call the
    +//! lease would run out under the runner, the next write batch would drop
    +//! its staged rows and stop, and the next claim would read the same range
    +//! and meet the same fault, so the operation would never end while the
    +//! fault lasted.
    +
    +use super::*;
    +
    +/// What a wait on the source ended with.
    +pub(super) enum Waited {
    +    /// The source answered.
    +    Answer(T),
    +    /// A boundary run during the wait ended the read: the lease was lost,
    +    /// the operation was cancelled, or its table or the caller's
    +    /// permission changed. The source call was dropped unanswered.
    +    Flow(Flow),
    +}
    +
    +/// Await `call`, running a boundary while it waits so the lease does not
    +/// run out under the runner.
    +///
    +/// The boundary runs a third of the lease after the last renewal, and then
    +/// every third of the lease while the call still waits, but never before
    +/// the call has waited a sixth of the lease, so a short wait for the next
    +/// range never adds a checkpoint. It runs no later than a sixth of the
    +/// lease before the deadline. Every row staged at that point was read
    +/// before the call started, so the boundary writes them, checkpoints the
    +/// cursor after the last of them, and reads the cancel flag: a cancel is
    +/// seen during the wait.
    +pub(super) async fn keeping_lease(
    +    ctx: &RunContext<'_>,
    +    st: &mut RunState,
    +    call: impl Future,
    +) -> Result, RunnerError> {
    +    let lease = ctx.deps.config.lease;
    +    let started = Instant::now();
    +    let mut call = std::pin::pin!(call);
    +    loop {
    +        let Some(deadline) = st.lease_deadline else {
    +            return Ok(Waited::Answer(call.await));
    +        };
    +        let renew_at = renewal_instant(deadline, lease, started);
    +        // A server shutdown ends the wait at once: the boundary writes the
    +        // rows staged before the call, checkpoints, releases the lease and
    +        // stops the run (`boundary`).
    +        let shutdown = ctx.deps.shutdown.clone();
    +        tokio::select! {
    +            biased;
    +            answer = &mut call => return Ok(Waited::Answer(answer)),
    +            () = shutdown.cancelled() => {
    +                return boundary(ctx, st).await.map(Waited::Flow);
    +            }
    +            () = tokio::time::sleep_until(tokio::time::Instant::from_std(renew_at)) => {
    +                match boundary(ctx, st).await? {
    +                    Flow::NextRow => {}
    +                    flow => return Ok(Waited::Flow(flow)),
    +                }
    +            }
    +        }
    +    }
    +}
    +
    +/// When a wait that started at `started` runs a boundary, for a lease of
    +/// `lease` that runs out at `deadline`.
    +pub(super) fn renewal_instant(deadline: Instant, lease: Duration, started: Instant) -> Instant {
    +    let sixth = lease / 6;
    +    let renewed = deadline.checked_sub(lease).unwrap_or(started);
    +    let due = (renewed + lease / 3).max(started + sixth);
    +    let latest = deadline.checked_sub(sixth).unwrap_or(deadline);
    +    due.min(latest)
    +}
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::*;
    +
    +    const LEASE: Duration = Duration::from_secs(60);
    +
    +    #[test]
    +    fn a_wait_after_a_fresh_renewal_runs_a_third_of_the_lease_after_it() {
    +        let renewed = Instant::now();
    +        let at = renewal_instant(renewed + LEASE, LEASE, renewed + Duration::from_secs(1));
    +        assert_eq!(at, renewed + Duration::from_secs(20));
    +    }
    +
    +    #[test]
    +    fn a_wait_that_starts_late_waits_a_sixth_of_the_lease_first() {
    +        let renewed = Instant::now();
    +        let at = renewal_instant(renewed + LEASE, LEASE, renewed + Duration::from_secs(25));
    +        assert_eq!(at, renewed + Duration::from_secs(35));
    +    }
    +
    +    #[test]
    +    fn a_wait_near_the_deadline_runs_a_sixth_of_the_lease_before_it() {
    +        let renewed = Instant::now();
    +        let at = renewal_instant(renewed + LEASE, LEASE, renewed + Duration::from_secs(45));
    +        assert_eq!(at, renewed + Duration::from_secs(50));
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/runner/write.rs b/crates/engine/src/bulk_put/runner/write.rs
    new file mode 100644
    index 000000000..55c5be1cb
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/runner/write.rs
    @@ -0,0 +1,456 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The write side: flushing staged rows to storage in one transaction,
    +//! handling a batch failure, reverting to the last checkpoint, and the
    +//! per-boundary checkpoint and key-info refresh.
    +
    +use super::*;
    +
    +/// Hand the staged rows to storage as one batch and apply each result.
    +///
    +/// A staged row the ledger already holds as applied (a resumed run reading
    +/// rows an earlier claim wrote past its checkpoint) is accounted for from
    +/// its ledger entry and not sent (`runner::resume`). The batch's write
    +/// units are taken from the process bucket and the operation bucket before
    +/// the call. `None` means the batch committed and the run continues;
    +/// `Some(flow)` is the terminal flow after a batch error, with the run
    +/// state reverted to the last checkpoint (nothing since it was committed).
    +pub(super) async fn flush_staged(
    +    ctx: &RunContext<'_>,
    +    st: &mut RunState,
    +) -> Result, RunnerError> {
    +    if st.staged.is_empty() {
    +        return Ok(None);
    +    }
    +    let RunContext {
    +        deps,
    +        record,
    +        config,
    +        op_pacer,
    +    } = *ctx;
    +    let mut staged = Vec::with_capacity(st.staged.len());
    +    if let Some(last) = st.staged.iter().map(|s| (s.file_index, s.row_index)).max() {
    +        resume::load_recorded_through(deps, record, st, last.0, last.1).await?;
    +    }
    +    for s in std::mem::take(&mut st.staged) {
    +        match st.recorded.remove(&(s.file_index, s.row_index)) {
    +            Some(recorded) => apply_recorded(ctx, st, s, recorded),
    +            None => staged.push(s),
    +        }
    +    }
    +    if staged.is_empty() {
    +        return Ok(check_output_config_at_first_record(ctx, st).await);
    +    }
    +    // A lease that has run out by this process's own clock may already
    +    // belong to another process that resumed from the last checkpoint and
    +    // ran ahead. Writing the staged rows now would land them after that
    +    // owner's later rows for the same keys, so the rows are dropped (they
    +    // are behind no checkpoint and the new owner reads them again) and the
    +    // runner stops without another catalog write.
    +    if st
    +        .lease_deadline
    +        .is_some_and(|deadline| Instant::now() >= deadline)
    +    {
    +        let dropped = staged.len();
    +        tracing::info!(
    +            account_id = %record.account_id,
    +            operation_id = %record.operation_id,
    +            dropped_rows = dropped,
    +            "lease deadline passed before a write batch; the runner stops"
    +        );
    +        return Ok(Some(Flow::Stop));
    +    }
    +    let mut rows = Vec::with_capacity(staged.len());
    +    let mut metas = Vec::with_capacity(staged.len());
    +    let mut units: u64 = 0;
    +    for mut s in staged {
    +        units = units.saturating_add(write_units(s.item_bytes));
    +        if let Some(marker) = s.row.marker.as_mut() {
    +            marker.settled = st.settled;
    +        }
    +        rows.push(s.row);
    +        metas.push((s.file_index, s.row_index, s.item_bytes, s.image));
    +    }
    +    deps.pacer.acquire(units).await;
    +    op_pacer.acquire(units).await;
    +
    +    let stream = stream_capture_for(&st.key_info, &deps.region);
    +    let results = match deps
    +        .storage
    +        .put_items_batch(&st.key_info, rows, stream.as_ref())
    +        .await
    +    {
    +        Ok(results) => results,
    +        // A transient storage fault (a connection reset, an admin or crash
    +        // shutdown, a serialization or deadlock abort, a lock timeout, a
    +        // full disk or another exhausted resource, a busy or locked
    +        // database, a pool timeout) left the batch rolled back, and a batch
    +        // refused because another claim took the operation over wrote
    +        // nothing either. Returning it as a runner storage error leaves the
    +        // record non-terminal and its lease to expire, the same path a
    +        // checkpoint fault takes: the next claim restores from the last
    +        // checkpoint and finds every row the earlier batches of this window
    +        // applied in the ledger. Every other storage fault (validation, a
    +        // missing table, a genuine internal error) stays terminal through
    +        // `batch_failure`.
    +        Err(error) if write_batch_fault_resumes(&error) => {
    +            if lease_passed_on(ctx).await {
    +                tracing::info!(
    +                    account_id = %record.account_id,
    +                    operation_id = %record.operation_id,
    +                    "another claim owns the operation; the batch wrote nothing and the runner stops"
    +                );
    +                return Ok(Some(Flow::Stop));
    +            }
    +            tracing::warn!(
    +                account_id = %record.account_id,
    +                operation_id = %record.operation_id,
    +                table_name = %record.table_name,
    +                error = %error,
    +                "bulk batch write hit a transient storage fault; the operation resumes at the last checkpoint"
    +            );
    +            return Err(RunnerError::Storage(error));
    +        }
    +        Err(error) => return Ok(Some(batch_failure(ctx, st, &error).await)),
    +    };
    +    if results.len() != metas.len() {
    +        tracing::error!(
    +            account_id = %record.account_id,
    +            operation_id = %record.operation_id,
    +            rows = metas.len(),
    +            results = results.len(),
    +            "bulk batch answered a result count different from its row count"
    +        );
    +        let error = StorageError::Internal("bulk batch result count mismatch".to_owned());
    +        return Ok(Some(batch_failure(ctx, st, &error).await));
    +    }
    +
    +    let has_indexes = !st.key_info.global_secondary_indexes.is_empty()
    +        || !st.key_info.local_secondary_indexes.is_empty()
    +        || !st.key_info.vector_indexes.is_empty();
    +    let bucket = &config.input_bucket;
    +    let record_cap = deps.config.max_report_records;
    +    let byte_cap = deps.config.max_report_bytes;
    +    for ((file_index, row_index, item_bytes, image), result) in metas.into_iter().zip(results) {
    +        let path = &record.input_files[file_index].path;
    +        match result {
    +            BatchRowResult::Written { old } => {
    +                st.written = st.written.saturating_add(1);
    +                // Table charge: the larger of the old and new item sizes,
    +                // each floored to one kilobyte, the rule PutItem's consumed
    +                // capacity uses (`put_item.rs`: `new.max(old)` then the
    +                // floor; `add_base` applies the floor and the larger
    +                // distributes over it). The old image is the stored item the
    +                // write replaced: `reader.rs` asks storage for it on every
    +                // write, on a table with no index as well (measured: a
    +                // plain table's shrinking overwrite is charged the larger),
    +                // so only a true insert has no old image and is charged the
    +                // new item.
    +                let old_size = old.as_ref().map_or(0, item_size_bytes);
    +                st.usage.add_base(item_bytes.max(old_size));
    +                if has_indexes {
    +                    // The index charge from the old and new images: an
    +                    // unchanged projection under the same index key is
    +                    // charged nothing and reports no member, a changed
    +                    // projection the larger of old and new, a moved index key
    +                    // old plus new (measured on the service).
    +                    st.usage
    +                        .add_index_writes(old.as_ref(), image.as_ref(), &st.key_info);
    +                }
    +            }
    +            // A failed condition is billed the one kilobyte floor whatever
    +            // the item's size, counted as processed only (it was,
    +            // at staging), and recorded with both images.
    +            BatchRowResult::ConditionFailed { old } => {
    +                st.usage.add_condition_failed();
    +                let submitted = image.unwrap_or_default();
    +                st.push_record(
    +                    &ReportSink {
    +                        max_records: record_cap,
    +                        max_bytes: byte_cap,
    +                        requested: &config.output_types,
    +                    },
    +                    ReportKind::ConditionCheckFailure,
    +                    file_index,
    +                    row_index,
    +                    report::condition_record(
    +                        bucket,
    +                        path,
    +                        Some(to_i64(row_index)),
    +                        &submitted,
    +                        old.as_ref(),
    +                    ),
    +                );
    +            }
    +        }
    +    }
    +    // A condition record of this batch may be the first of its kind, whose
    +    // report object is checked now, after the rows before it were written.
    +    Ok(check_output_config_at_first_record(ctx, st).await)
    +}
    +
    +/// Whether the record names another lease owner, or is gone or terminal,
    +/// after a batch failed with a transient fault: the batch was refused
    +/// because another claim took the operation over, and this runner stops
    +/// as at a lost lease. A record that cannot be read answers false, so the
    +/// fault is handled as a transient one.
    +async fn lease_passed_on(ctx: &RunContext<'_>) -> bool {
    +    let RunContext { deps, record, .. } = *ctx;
    +    match deps
    +        .storage
    +        .get_bulk_operation(&record.account_id, &record.operation_id)
    +        .await
    +    {
    +        Ok(Some(current)) => current.lease_owner.as_deref() != Some(deps.owner.as_str()),
    +        Ok(None) => true,
    +        Err(_) => false,
    +    }
    +}
    +
    +/// Step 8: a batch that did not commit. The batch rolled back, but earlier
    +/// write batches of the same checkpoint window committed when
    +/// `bulk_put_write_batch_rows` is below `bulk_put_batch_size`: their rows
    +/// are in the table and in the ledger, while the window's counters, usage
    +/// and records live only in this state. The state reverts to the last
    +/// checkpoint and the operation ends with the deleted-table failure or the
    +/// internal one, so its counters are the checkpoint's, a floor on the rows
    +/// written, as a cancelled operation's are; the detail goes to the log
    +/// either way. A crash before `finish` then resumes at the checkpoint and
    +/// re-reads the window, finding the rows earlier batches of it applied in
    +/// the ledger.
    +async fn batch_failure(ctx: &RunContext<'_>, st: &mut RunState, error: &StorageError) -> Flow {
    +    let RunContext { deps, record, .. } = *ctx;
    +    tracing::error!(
    +        account_id = %record.account_id,
    +        operation_id = %record.operation_id,
    +        table_name = %record.table_name,
    +        error = %error,
    +        "bulk batch write failed"
    +    );
    +    revert_to_last_checkpoint(ctx, st).await;
    +    st.failure = Some(match table_binding_holds(deps, record).await {
    +        Ok(false) => Failure::table_not_found(),
    +        Ok(true) => Failure::internal(),
    +        Err(check_error) => {
    +            tracing::error!(
    +                account_id = %record.account_id,
    +                operation_id = %record.operation_id,
    +                error = %check_error,
    +                "table binding check failed after a batch write error"
    +            );
    +            Failure::internal()
    +        }
    +    });
    +    Flow::Finish
    +}
    +
    +/// Whether a storage error from a write batch is a transient fault a resume
    +/// is expected to clear, so the operation is left non-terminal rather than
    +/// ended `FAILED`. The class is the storage layer's
    +/// [`StorageError::is_transient_write_fault`], shared so the backends' own
    +/// tests drive the classifier the runner uses with real database errors.
    +pub(super) fn write_batch_fault_resumes(error: &StorageError) -> bool {
    +    error.is_transient_write_fault()
    +}
    +
    +/// Revert the in-memory run state to the last checkpoint. The persisted
    +/// record holds exactly that checkpoint's counters, records, and usage,
    +/// so restoring from it is the revert; `written`, the observed cancel,
    +/// the output check, a held location fault and the tags accepted at opens
    +/// are this process's and carry over. A restore failure keeps the in-memory state, with a log
    +/// line; the caller's terminal condition still applies.
    +pub(super) async fn revert_to_last_checkpoint(ctx: &RunContext<'_>, st: &mut RunState) {
    +    let RunContext { deps, record, .. } = *ctx;
    +    match deps
    +        .storage
    +        .get_bulk_operation(&record.account_id, &record.operation_id)
    +        .await
    +    {
    +        Ok(Some(current)) => match RunState::restore(¤t) {
    +            Ok(mut fresh) => {
    +                fresh.written = st.written;
    +                fresh.cancelled = st.cancelled;
    +                fresh.output_checked = st.output_checked;
    +                fresh.skip_render = st.skip_render;
    +                fresh.held_location = st.held_location.take();
    +                fresh.lease_deadline = st.lease_deadline;
    +                fresh.opened_etags = std::mem::take(&mut st.opened_etags);
    +                fresh.key_info = std::mem::take(&mut st.key_info);
    +                fresh.settled = st.settled;
    +                fresh.recorded = std::mem::take(&mut st.recorded);
    +                fresh.recorded_next = st.recorded_next;
    +                *st = fresh;
    +            }
    +            Err(error) => {
    +                tracing::error!(
    +                    account_id = %record.account_id,
    +                    operation_id = %record.operation_id,
    +                    error = %error,
    +                    "run state could not be reverted to the last checkpoint"
    +                );
    +            }
    +        },
    +        Ok(None) | Err(_) => {
    +            tracing::error!(
    +                account_id = %record.account_id,
    +                operation_id = %record.operation_id,
    +                "operation record could not be re-read for the checkpoint revert"
    +            );
    +        }
    +    }
    +}
    +
    +/// Step 6: the key metadata, the caller's `s3:GetObject` on the file being
    +/// read, then the staged rows, then the checkpoint, then the cancel flag.
    +/// The metadata read comes first so the rows staged since the last boundary
    +/// are written against the table as it is now (a stream enabled or an index
    +/// added since then applies to them, as it would to a `PutItem` issued
    +/// now); it also verifies the table binding,
    +/// so a deleted or replaced table is noticed here. The flush comes before
    +/// the checkpoint, so no caller can persist a cursor past a row whose
    +/// write did not commit; a flush that fails is the terminal flow.
    +pub(super) async fn boundary(ctx: &RunContext<'_>, st: &mut RunState) -> Result {
    +    if let Some(flow) = refresh_key_info(ctx, st).await {
    +        return Ok(flow);
    +    }
    +    if let Some(flow) = check_get_at_boundary(ctx, st).await {
    +        return Ok(flow);
    +    }
    +    if let Some(flow) = flush_staged(ctx, st).await? {
    +        return Ok(flow);
    +    }
    +    let RunContext { deps, record, .. } = *ctx;
    +    st.rows_since_checkpoint = 0;
    +    let progress = st.progress(&record.table_arn);
    +    let cursor = (progress.cursor_file_index, progress.cursor_row_index);
    +    // A server shutdown checkpoints with a lease that has already run out,
    +    // so the next claim takes the operation without waiting for it.
    +    let shutting_down = deps.shutdown.is_cancelled();
    +    let lease = if shutting_down {
    +        Duration::ZERO
    +    } else {
    +        deps.config.lease
    +    };
    +    // Taken before the call: the store grants the lease from its own
    +    // clock when the statement runs, so the local deadline must not credit
    +    // the time the round trip itself took.
    +    let renewed_at = Instant::now();
    +    match deps
    +        .storage
    +        .checkpoint(
    +            &record.account_id,
    +            &record.operation_id,
    +            &deps.owner,
    +            lease,
    +            progress,
    +        )
    +        .await
    +    {
    +        Err(error) => return Err(RunnerError::Storage(error)),
    +        Ok(LeaseState::Lost) => {
    +            tracing::info!(
    +                account_id = %record.account_id,
    +                operation_id = %record.operation_id,
    +                "lease lost at a checkpoint; the runner stops"
    +            );
    +            return Ok(Flow::Stop);
    +        }
    +        Ok(LeaseState::Held) => {
    +            st.lease_deadline = Some(renewed_at + lease);
    +            st.settled = cursor;
    +        }
    +    }
    +    if shutting_down {
    +        tracing::info!(
    +            account_id = %record.account_id,
    +            operation_id = %record.operation_id,
    +            cursor_file_index = cursor.0,
    +            cursor_row_index = cursor.1,
    +            "server shutting down: the bulk operation checkpointed and released its lease"
    +        );
    +        return Ok(Flow::Stop);
    +    }
    +    match deps
    +        .storage
    +        .get_bulk_operation_flags(&record.account_id, &record.operation_id)
    +        .await
    +    {
    +        Err(error) => {
    +            tracing::error!(
    +                account_id = %record.account_id,
    +                operation_id = %record.operation_id,
    +                error = %error,
    +                "operation record could not be read at a boundary"
    +            );
    +            st.failure = Some(Failure::internal());
    +            Ok(Flow::Finish)
    +        }
    +        Ok(None) => {
    +            tracing::warn!(
    +                account_id = %record.account_id,
    +                operation_id = %record.operation_id,
    +                "operation record is gone; the runner stops"
    +            );
    +            Ok(Flow::Stop)
    +        }
    +        Ok(Some(current)) => {
    +            if current.cancel_requested {
    +                st.cancelled = true;
    +                return Ok(Flow::Finish);
    +            }
    +            Ok(Flow::NextRow)
    +        }
    +    }
    +}
    +
    +/// Re-read the table's key metadata and verify the binding. `Some(flow)`
    +/// ends the run: the table is gone or replaced under its name, or the
    +/// catalog could not be read. A table found `UPDATING` keeps the current
    +/// snapshot (the data plane refuses to describe it until the update
    +/// commits) provided its binding still holds; the next boundary reads
    +/// again.
    +pub(super) async fn refresh_key_info(ctx: &RunContext<'_>, st: &mut RunState) -> Option {
    +    let RunContext { deps, record, .. } = *ctx;
    +    match deps
    +        .storage
    +        .table_key_info(&record.account_id, &record.table_name)
    +        .await
    +    {
    +        Ok(info) if info.table_id == record.table_id => {
    +            st.key_info = info;
    +            None
    +        }
    +        Ok(_) | Err(StorageError::TableNotFound(_)) => {
    +            st.failure = Some(Failure::table_not_found());
    +            Some(Flow::Finish)
    +        }
    +        Err(StorageError::TableNotActive(_)) => match table_binding_holds(deps, record).await {
    +            Ok(true) => None,
    +            Ok(false) => {
    +                st.failure = Some(Failure::table_not_found());
    +                Some(Flow::Finish)
    +            }
    +            Err(error) => {
    +                tracing::error!(
    +                    account_id = %record.account_id,
    +                    operation_id = %record.operation_id,
    +                    error = %error,
    +                    "table binding check failed at a boundary"
    +                );
    +                st.failure = Some(Failure::internal());
    +                Some(Flow::Finish)
    +            }
    +        },
    +        Err(error) => {
    +            tracing::error!(
    +                account_id = %record.account_id,
    +                operation_id = %record.operation_id,
    +                error = %error,
    +                "table key info read failed at a boundary"
    +            );
    +            st.failure = Some(Failure::internal());
    +            Some(Flow::Finish)
    +        }
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/s3/client.rs b/crates/engine/src/bulk_put/s3/client.rs
    new file mode 100644
    index 000000000..26ede21e6
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/client.rs
    @@ -0,0 +1,209 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The bulk put store's view of its S3 clients: the shared clients of
    +//! `extenddb-s3` (the region redirects, the retry bound, the startup
    +//! credential check), with the bytes of each ranged GET and uploaded part
    +//! and the deadline of the calls a Start makes.
    +
    +use std::future::Future;
    +use std::time::Duration;
    +
    +use aws_sdk_s3::Client;
    +use aws_sdk_s3::config::http::HttpResponse;
    +use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
    +use extenddb_s3::Startup;
    +#[cfg(test)]
    +pub(crate) use extenddb_s3::{CREDENTIALS_LATER, behavior_version, timeouts};
    +
    +use super::retry::Backoff;
    +use crate::bulk_put::store::{S3_STORE_LABEL, S3StoreSettings};
    +
    +/// How the bulk store names itself in its startup lines.
    +const STARTUP: Startup<'static> = Startup {
    +    label: S3_STORE_LABEL,
    +    until_resolved: "Start answers InternalServerError until they resolve",
    +};
    +
    +/// How long one S3 call a Start makes may run, its retries included: the
    +/// bucket check, each page of the listing, and the encryption check with
    +/// its abort. A Start then answers within the time an SDK client waits
    +/// for a response. A call still running at the deadline is dropped, which
    +/// closes its connection, and fails as the server's, so the Start answers
    +/// `InternalServerError` and creates no operation. The runner's calls are
    +/// bound by the retry total alone.
    +pub(crate) const START_DEADLINE: Duration = Duration::from_secs(10);
    +
    +/// Who makes an S3 call, which sets the bound it runs under.
    +#[derive(Clone, Copy, Debug, PartialEq, Eq)]
    +pub(crate) enum Phase {
    +    /// A Start: the retry bound and [`START_DEADLINE`].
    +    Start,
    +    /// The runner: the retry bound alone.
    +    Run,
    +}
    +
    +/// Build the store's clients from its settings and the standard AWS
    +/// credential chain (`extenddb_s3::connect`), the startup lines naming the
    +/// S3 bulk store.
    +pub(crate) async fn connect(settings: &S3StoreSettings) -> S3Clients {
    +    connect_with(settings, |builder| builder).await
    +}
    +
    +/// [`connect`], with `adjust` applied to the S3 client configuration
    +/// before it is built, so the store contract tests run on the clients the
    +/// settings describe with an interceptor of their own.
    +pub(crate) async fn connect_with(
    +    settings: &S3StoreSettings,
    +    adjust: impl FnOnce(aws_sdk_s3::config::Builder) -> aws_sdk_s3::config::Builder,
    +) -> S3Clients {
    +    S3Clients::from_shared(extenddb_s3::connect_with(settings, &STARTUP, adjust).await)
    +}
    +
    +/// The store's clients over a loaded SDK configuration
    +/// (`extenddb_s3::clients_from`).
    +#[cfg(test)]
    +pub(crate) fn clients_from(
    +    settings: &S3StoreSettings,
    +    sdk: &aws_config::SdkConfig,
    +    adjust: impl FnOnce(aws_sdk_s3::config::Builder) -> aws_sdk_s3::config::Builder,
    +) -> S3Clients {
    +    S3Clients::from_shared(extenddb_s3::clients_from(settings, sdk, adjust))
    +}
    +
    +/// The clients of the S3 bulk store.
    +pub(crate) struct S3Clients {
    +    shared: extenddb_s3::S3Clients,
    +    /// Bytes asked for per ranged GET and sent per uploaded part.
    +    part_bytes: u64,
    +    /// How long one call a Start makes may run ([`START_DEADLINE`]).
    +    start_deadline: Duration,
    +}
    +
    +impl S3Clients {
    +    /// The clients over `config`, the configuration of the store's region,
    +    /// for the tests that build their own configuration.
    +    #[cfg(test)]
    +    pub(crate) fn new(
    +        config: aws_sdk_s3::Config,
    +        follow_redirects: bool,
    +        checksums_when_required: bool,
    +    ) -> Self {
    +        Self::from_shared(extenddb_s3::S3Clients::new(
    +            config,
    +            follow_redirects,
    +            checksums_when_required,
    +        ))
    +    }
    +
    +    fn from_shared(shared: extenddb_s3::S3Clients) -> Self {
    +        Self {
    +            shared,
    +            part_bytes: super::read::GET_PART_BYTES,
    +            start_deadline: START_DEADLINE,
    +        }
    +    }
    +
    +    /// The retries of one call, from now under the store's bound.
    +    pub(crate) fn backoff(&self) -> Backoff {
    +        self.shared.backoff()
    +    }
    +
    +    /// Test hook: another bound on each call's retries, so a test sees
    +    /// retries without waiting seconds, or none at all.
    +    #[cfg(test)]
    +    pub(crate) fn with_retry(mut self, retry: extenddb_s3::retry::RetryPolicy) -> Self {
    +        self.shared = self.shared.with_retry(retry);
    +        self
    +    }
    +
    +    /// Test hook: another deadline for the calls a Start makes, so a test
    +    /// sees it end a call without waiting ten seconds.
    +    #[cfg(test)]
    +    pub(crate) fn with_start_deadline(mut self, deadline: Duration) -> Self {
    +        self.start_deadline = deadline;
    +        self
    +    }
    +
    +    /// Bytes asked for per ranged GET and sent per uploaded part.
    +    pub(crate) fn part_bytes(&self) -> u64 {
    +        self.part_bytes
    +    }
    +
    +    /// Test hook: smaller ranged GETs and upload parts, so a test reads
    +    /// and writes an object in several parts without megabytes of data.
    +    #[cfg(test)]
    +    pub(crate) fn with_part_bytes(mut self, bytes: u64) -> Self {
    +        self.part_bytes = bytes.max(1);
    +        self
    +    }
    +
    +    /// Whether the client sends checksums only when an operation requires
    +    /// them; multipart uploads then declare no checksum algorithm.
    +    pub(crate) fn checksums_when_required(&self) -> bool {
    +        self.shared.checksums_when_required()
    +    }
    +
    +    /// The access key ID of the credentials the startup check resolved
    +    /// (`extenddb_s3::S3Clients::access_key_id`).
    +    pub(crate) fn access_key_id(&self) -> Option<&str> {
    +        self.shared.access_key_id()
    +    }
    +
    +    /// Run one S3 call for `bucket` (`extenddb_s3::S3Clients::call`): on
    +    /// the client of the bucket's region, a redirect followed once, a
    +    /// transient failure sent again under the store's bound.
    +    pub(crate) async fn call(
    +        &self,
    +        bucket: &str,
    +        call: F,
    +    ) -> Result>
    +    where
    +        F: Fn(Client) -> Fut,
    +        Fut: Future>>,
    +        E: ProvideErrorMetadata + std::error::Error + Send + Sync + 'static,
    +    {
    +        self.shared.call(bucket, call).await
    +    }
    +
    +    /// [`Self::call`] under the bound of `phase`. A Start's call still
    +    /// running at the Start deadline is dropped and answered as an SDK
    +    /// timeout, which the store maps to the server's failure.
    +    pub(crate) async fn call_in(
    +        &self,
    +        phase: Phase,
    +        bucket: &str,
    +        call: F,
    +    ) -> Result>
    +    where
    +        F: Fn(Client) -> Fut,
    +        Fut: Future>>,
    +        E: ProvideErrorMetadata + std::error::Error + Send + Sync + 'static,
    +    {
    +        let bounded = self.call(bucket, call);
    +        match phase {
    +            Phase::Run => bounded.await,
    +            Phase::Start => match tokio::time::timeout(self.start_deadline, bounded).await {
    +                Ok(result) => result,
    +                Err(elapsed) => Err(SdkError::timeout_error(elapsed)),
    +            },
    +        }
    +    }
    +
    +    /// [`Self::call`] with the retries of a backoff the caller holds, so a
    +    /// ranged GET that is requested again after a cut body stays under one
    +    /// bound with the retries of its requests.
    +    pub(crate) async fn call_within(
    +        &self,
    +        bucket: &str,
    +        backoff: &mut Backoff,
    +        call: &F,
    +    ) -> Result>
    +    where
    +        F: Fn(Client) -> Fut,
    +        Fut: Future>>,
    +        E: ProvideErrorMetadata + std::error::Error + Send + Sync + 'static,
    +    {
    +        self.shared.call_within(bucket, backoff, call).await
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/s3/error.rs b/crates/engine/src/bulk_put/s3/error.rs
    new file mode 100644
    index 000000000..37742b281
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/error.rs
    @@ -0,0 +1,261 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! S3 failures as the client reads them: the `FailureCode` each S3 error
    +//! code maps to, and the `FailureMessage`, S3's own message followed by the
    +//! tail the service renders for the call that failed (measured on the
    +//! service). Transport failures, 5xx answers, throttling, the codes about
    +//! the server's own credentials (`extenddb_s3::SERVER_CREDENTIAL_CODES`)
    +//! and codes outside the table are the server's: they answer
    +//! `InternalServerError` and their detail, S3's code and message included,
    +//! goes to the log at ERROR.
    +
    +use aws_sdk_s3::config::http::HttpResponse;
    +use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
    +
    +use crate::bulk_put::source::StoreFault;
    +
    +/// The tail the service appends to S3's message, by the kind of call.
    +#[derive(Clone, Copy, Debug, PartialEq, Eq)]
    +pub(crate) enum Tail {
    +    /// Input reads, the Start-time list, the manifest write and the
    +    /// Start-time encryption check: `(Service: Amazon S3; Status Code: ...;
    +    /// Proxy: null)`.
    +    AmazonS3,
    +    /// Report writes, the first-record check included: `(Service: S3,
    +    /// Status Code: ...) (SDK Attempt Count: 1)`.
    +    S3,
    +}
    +
    +/// What one failed S3 call means for the operation.
    +#[derive(Debug)]
    +pub(crate) enum Outcome {
    +    /// A fault the client sees, rendered.
    +    Fault(StoreFault),
    +    /// A ranged read that starts at or past the object's end (416): the
    +    /// object is shorter than it was when enumerated, read as end of file.
    +    PastEnd,
    +    /// The server's failure; the caller logs the error.
    +    Internal,
    +}
    +
    +/// Classify one failed call. `after_first_byte` is recorded on the fault:
    +/// whether bytes of the object were read before the call failed.
    +pub(crate) fn classify(
    +    error: &SdkError,
    +    tail: Tail,
    +    after_first_byte: bool,
    +) -> Outcome
    +where
    +    E: ProvideErrorMetadata + std::error::Error + Send + Sync + 'static,
    +{
    +    let (SdkError::ServiceError(_), Some(response)) = (error, error.raw_response()) else {
    +        return Outcome::Internal;
    +    };
    +    if extenddb_s3::is_server_credential_error(error) {
    +        return Outcome::Internal;
    +    }
    +    let status = response.status().as_u16();
    +    let code = error.code();
    +    if status == 416 || code == Some("InvalidRange") {
    +        return Outcome::PastEnd;
    +    }
    +    let failure_code = match code {
    +        Some(code) if status < 500 => failure_code(code),
    +        _ => None,
    +    };
    +    let (Some(code), Some(failure_code)) = (code, failure_code) else {
    +        return Outcome::Internal;
    +    };
    +    let message = error
    +        .message()
    +        .map_or_else(|| default_message(code).to_owned(), str::to_owned);
    +    let request_id = response.headers().get("x-amz-request-id").unwrap_or("null");
    +    // An S3-compatible service that sends no extended request id renders
    +    // `null` in its place.
    +    let extended_id = response.headers().get("x-amz-id-2").unwrap_or("null");
    +    let tail = match tail {
    +        Tail::AmazonS3 => format!(
    +            " (Service: Amazon S3; Status Code: {status}; Error Code: {code}; Request ID: \
    +             {request_id}; S3 Extended Request ID: {extended_id}; Proxy: null)"
    +        ),
    +        // Every mapped code is a 4xx the SDK does not retry, so the
    +        // attempt count is 1.
    +        Tail::S3 => format!(
    +            " (Service: S3, Status Code: {status}, Request ID: {request_id}, Extended Request \
    +             ID: {extended_id}) (SDK Attempt Count: 1)"
    +        ),
    +    };
    +    Outcome::Fault(StoreFault {
    +        code: failure_code,
    +        message: format!("{message}{tail}"),
    +        after_first_byte,
    +    })
    +}
    +
    +/// The message for a code when S3 sent none (an error without a body).
    +fn default_message(code: &str) -> &'static str {
    +    match code {
    +        "NoSuchBucket" => "The specified bucket does not exist",
    +        "NoSuchKey" => "The specified key does not exist.",
    +        "AccessDenied" => "Access Denied",
    +        _ => "",
    +    }
    +}
    +
    +/// The `FailureCode` for an S3 error code: `S3` followed by the code for an
    +/// S3 code (measured: `S3AccessDenied`, `S3NoSuchBucket`, `S3NoSuchKey`),
    +/// and `Kms` followed by the exception name without its `KMS` lead for a
    +/// `KMS.` code (measured: `KmsDisabledException`,
    +/// `KmsInvalidKeyUsageException`). `RequestTimeout` and `SlowDown`, which
    +/// the SDK retries, and any code outside the tables are `None`.
    +pub(crate) fn failure_code(code: &str) -> Option<&'static str> {
    +    if let Some(name) = code.strip_prefix("KMS.") {
    +        return KMS_CODES
    +            .iter()
    +            .find(|(kms, _)| *kms == name)
    +            .map(|(_, failure)| *failure);
    +    }
    +    S3_CODES
    +        .iter()
    +        .find(|(s3, _)| *s3 == code)
    +        .map(|(_, failure)| *failure)
    +}
    +
    +/// The `KMS.` codes S3 answers when a KMS call it makes for the request
    +/// fails, without the `KMS.` lead.
    +const KMS_CODES: &[(&str, &str)] = &[
    +    ("DisabledException", "KmsDisabledException"),
    +    ("InvalidKeyUsageException", "KmsInvalidKeyUsageException"),
    +    ("KMSInvalidStateException", "KmsInvalidStateException"),
    +    ("InvalidStateException", "KmsInvalidStateException"),
    +    ("NotFoundException", "KmsNotFoundException"),
    +    ("KeyUnavailableException", "KmsKeyUnavailableException"),
    +    ("AccessDeniedException", "KmsAccessDeniedException"),
    +    (
    +        "InvalidCiphertextException",
    +        "KmsInvalidCiphertextException",
    +    ),
    +    ("IncorrectKeyException", "KmsIncorrectKeyException"),
    +    (
    +        "InvalidGrantTokenException",
    +        "KmsInvalidGrantTokenException",
    +    ),
    +    ("OptInRequired", "KmsOptInRequired"),
    +];
    +
    +macro_rules! s3_codes {
    +    ($($code:ident),* $(,)?) => {
    +        &[$((stringify!($code), concat!("S3", stringify!($code)))),*]
    +    };
    +}
    +
    +/// The S3 error codes a client error (4xx) of the calls the store makes
    +/// can carry, from the S3 error code list, less the codes about the
    +/// server's own credentials (`extenddb_s3::SERVER_CREDENTIAL_CODES`): the
    +/// store signs every request with the server's credentials, so those
    +/// answer `InternalServerError`, a Start answers 500 and creates no
    +/// operation, and the operator reads S3's code and message in the ERROR
    +/// line.
    +const S3_CODES: &[(&str, &str)] = s3_codes!(
    +    AccessDenied,
    +    AllAccessDisabled,
    +    AuthorizationHeaderMalformed,
    +    BadDigest,
    +    ConditionalRequestConflict,
    +    EntityTooLarge,
    +    EntityTooSmall,
    +    IllegalLocationConstraintException,
    +    IncompleteBody,
    +    InvalidArgument,
    +    InvalidBucketName,
    +    InvalidBucketState,
    +    InvalidDigest,
    +    InvalidEncryptionAlgorithmError,
    +    InvalidObjectState,
    +    InvalidPart,
    +    InvalidPartOrder,
    +    InvalidPayer,
    +    InvalidRequest,
    +    InvalidStorageClass,
    +    InvalidURI,
    +    KeyTooLongError,
    +    MalformedXML,
    +    MetadataTooLarge,
    +    MethodNotAllowed,
    +    MissingContentLength,
    +    MissingRequestBodyError,
    +    NoSuchBucket,
    +    NoSuchKey,
    +    NoSuchUpload,
    +    NoSuchVersion,
    +    NotImplemented,
    +    OperationAborted,
    +    PermanentRedirect,
    +    PreconditionFailed,
    +    Redirect,
    +    TemporaryRedirect,
    +    UnexpectedContent,
    +    XAmzContentSHA256Mismatch,
    +);
    +
    +#[cfg(test)]
    +mod tests {
    +    use extenddb_s3::{SERVER_CREDENTIAL_CODES, is_server_credential_code};
    +
    +    use super::*;
    +
    +    #[test]
    +    fn failure_codes_follow_the_measured_naming() {
    +        assert_eq!(failure_code("AccessDenied"), Some("S3AccessDenied"));
    +        assert_eq!(failure_code("NoSuchBucket"), Some("S3NoSuchBucket"));
    +        assert_eq!(failure_code("NoSuchKey"), Some("S3NoSuchKey"));
    +        assert_eq!(
    +            failure_code("InvalidObjectState"),
    +            Some("S3InvalidObjectState")
    +        );
    +        assert_eq!(
    +            failure_code("PreconditionFailed"),
    +            Some("S3PreconditionFailed")
    +        );
    +        assert_eq!(
    +            failure_code("KMS.DisabledException"),
    +            Some("KmsDisabledException")
    +        );
    +        assert_eq!(
    +            failure_code("KMS.InvalidKeyUsageException"),
    +            Some("KmsInvalidKeyUsageException")
    +        );
    +        assert_eq!(
    +            failure_code("KMS.KMSInvalidStateException"),
    +            Some("KmsInvalidStateException")
    +        );
    +        assert_eq!(
    +            failure_code("KMS.NotFoundException"),
    +            Some("KmsNotFoundException")
    +        );
    +        assert_eq!(
    +            failure_code("AllAccessDisabled"),
    +            Some("S3AllAccessDisabled")
    +        );
    +        for code in SERVER_CREDENTIAL_CODES {
    +            assert_eq!(failure_code(code), None, "{code}");
    +            assert!(is_server_credential_code(Some(code)), "{code}");
    +        }
    +        assert!(!is_server_credential_code(Some("AllAccessDisabled")));
    +        assert!(!is_server_credential_code(None));
    +        for retried_or_unknown in [
    +            "RequestTimeout",
    +            "SlowDown",
    +            "Throttling",
    +            "KMS.Unheard",
    +            "",
    +        ] {
    +            assert_eq!(
    +                failure_code(retried_or_unknown),
    +                None,
    +                "{retried_or_unknown}"
    +            );
    +        }
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/s3/list.rs b/crates/engine/src/bulk_put/s3/list.rs
    new file mode 100644
    index 000000000..5b7ed4ed5
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/list.rs
    @@ -0,0 +1,146 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The input bucket at Start: the bucket check and the enumeration, both
    +//! ListObjectsV2. The check lists one key under the request's prefix, so
    +//! S3 answers the owner, existence and `s3:ListBucket` faults with its own
    +//! code and message; a HEAD request would carry neither, since its error
    +//! has no body. The enumeration lists pages of 1,000 keys with
    +//! `encoding-type=url`, which keeps keys holding characters XML cannot
    +//! carry intact, and decodes them.
    +
    +use aws_sdk_s3::types::EncodingType;
    +
    +use super::client::Phase;
    +use super::{Location, S3Clients, source_error, unquoted};
    +use crate::bulk_put::source::{SourceError, SourceFile};
    +
    +/// Keys asked for per ListObjectsV2 page.
    +const LIST_PAGE_KEYS: i32 = 1_000;
    +
    +/// The Start-time check of the input bucket: one ListObjectsV2 of at most
    +/// one key under `prefix`, with the expected owner when one is set.
    +pub(super) async fn check_bucket(
    +    clients: &S3Clients,
    +    location: &Location,
    +    prefix: &str,
    +) -> Result<(), SourceError> {
    +    clients
    +        .call_in(Phase::Start, &location.bucket, |client| {
    +            client
    +                .list_objects_v2()
    +                .bucket(&location.bucket)
    +                .prefix(prefix)
    +                .max_keys(1)
    +                .encoding_type(EncodingType::Url)
    +                .set_expected_bucket_owner(location.owner.clone())
    +                .send()
    +        })
    +        .await
    +        .map(|_| ())
    +        .map_err(|error| source_error(&error, location, prefix, false, "bucket check"))
    +}
    +
    +/// Every object under `prefix` (byte-wise, as given: a doubled or leading
    +/// `/` included), zero-byte keys ending in `/` among them, in UTF-8 byte
    +/// order whatever order the service listed them in. Collecting more than
    +/// `max_files` stops the listing with `max_files + 1` entries, so the
    +/// caller can tell the bound was passed. A fault on any page is the
    +/// service's answer for that page. Each page is one call under the Start
    +/// deadline, so a long listing is not cut short while its pages answer.
    +pub(super) async fn list(
    +    clients: &S3Clients,
    +    location: &Location,
    +    prefix: &str,
    +    max_files: u64,
    +) -> Result, SourceError> {
    +    let cap = usize::try_from(max_files.saturating_add(1)).unwrap_or(usize::MAX);
    +    let mut files: Vec = Vec::new();
    +    let mut token: Option = None;
    +    loop {
    +        let page = clients
    +            .call_in(Phase::Start, &location.bucket, |client| {
    +                client
    +                    .list_objects_v2()
    +                    .bucket(&location.bucket)
    +                    .prefix(prefix)
    +                    .max_keys(LIST_PAGE_KEYS)
    +                    .encoding_type(EncodingType::Url)
    +                    .set_continuation_token(token.clone())
    +                    .set_expected_bucket_owner(location.owner.clone())
    +                    .send()
    +            })
    +            .await
    +            .map_err(|error| source_error(&error, location, prefix, false, "listing"))?;
    +        let encoded = page.encoding_type() == Some(&EncodingType::Url);
    +        for object in page.contents() {
    +            let Some(key) = object.key() else { continue };
    +            let path = if encoded {
    +                decode_key(key).ok_or_else(|| {
    +                    tracing::error!(
    +                        account_id = %location.account,
    +                        bucket = %location.bucket,
    +                        "S3 listing returned a key that does not decode"
    +                    );
    +                    SourceError::Io(std::io::Error::other("S3 listing key does not decode"))
    +                })?
    +            } else {
    +                key.to_owned()
    +            };
    +            // A service that ignored the prefix must not widen the input.
    +            if !path.as_bytes().starts_with(prefix.as_bytes()) {
    +                continue;
    +            }
    +            files.push(SourceFile {
    +                path,
    +                size: object
    +                    .size()
    +                    .and_then(|size| u64::try_from(size).ok())
    +                    .unwrap_or(0),
    +                etag: object.e_tag().map(unquoted),
    +            });
    +            if files.len() >= cap {
    +                break;
    +            }
    +        }
    +        if files.len() >= cap {
    +            break;
    +        }
    +        match (page.is_truncated(), page.next_continuation_token()) {
    +            (Some(true), Some(next)) if !next.is_empty() => token = Some(next.to_owned()),
    +            _ => break,
    +        }
    +    }
    +    files.sort_by(|a, b| a.path.cmp(&b.path));
    +    Ok(files)
    +}
    +
    +/// Decode one key S3 listed with `encoding-type=url`: `%XX` escapes and
    +/// `+` for a space, as S3 encodes them. `None` for a malformed escape or a
    +/// result that is not UTF-8.
    +pub(super) fn decode_key(encoded: &str) -> Option {
    +    let bytes = encoded.as_bytes();
    +    let mut out = Vec::with_capacity(bytes.len());
    +    let mut i = 0;
    +    while i < bytes.len() {
    +        match bytes[i] {
    +            b'+' => {
    +                out.push(b' ');
    +                i += 1;
    +            }
    +            b'%' => {
    +                let hex = encoded.get(i + 1..i + 3)?;
    +                if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
    +                    return None;
    +                }
    +                out.push(u8::from_str_radix(hex, 16).ok()?);
    +                i += 3;
    +            }
    +            other => {
    +                out.push(other);
    +                i += 1;
    +            }
    +        }
    +    }
    +    String::from_utf8(out).ok()
    +}
    diff --git a/crates/engine/src/bulk_put/s3/manifest.rs b/crates/engine/src/bulk_put/s3/manifest.rs
    new file mode 100644
    index 000000000..149ceaf72
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/manifest.rs
    @@ -0,0 +1,56 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The manifest on the S3 store: one PutObject of `manifest-files.json`
    +//! under the operation's key, one JSON object per line, with the expected
    +//! owner and the encryption fields (measured on the service: the manifest
    +//! is stored with the report's encryption settings). A failure carries the
    +//! tail the service renders for its manifest write.
    +
    +use aws_sdk_s3::primitives::ByteStream;
    +use serde_json::Value;
    +
    +use super::error::Tail;
    +use super::probe::{sink_error, sse_fields};
    +use super::{Location, S3Clients};
    +use crate::bulk_put::report;
    +use crate::bulk_put::source::{OutputSse, SinkError};
    +
    +/// Write the manifest and return its key.
    +pub(super) async fn write_manifest(
    +    clients: &S3Clients,
    +    location: &Location,
    +    prefix: &str,
    +    operation_id: &str,
    +    sse: &OutputSse,
    +    lines: &[Value],
    +) -> Result {
    +    let key = report::manifest_key(prefix, &location.account, operation_id);
    +    let mut body = Vec::new();
    +    for line in lines {
    +        report::write_manifest_line(&mut body, line).map_err(SinkError::Io)?;
    +        body.push(b'\n');
    +    }
    +    let (encryption, kms_key) = sse_fields(sse);
    +    clients
    +        .call(&location.bucket, |client| {
    +            client
    +                .put_object()
    +                .bucket(&location.bucket)
    +                .key(&key)
    +                .body(ByteStream::from(body.clone()))
    +                .set_expected_bucket_owner(location.owner.clone())
    +                .set_server_side_encryption(encryption.clone())
    +                .set_ssekms_key_id(kms_key.clone())
    +                .send()
    +        })
    +        .await
    +        .map_err(|error| sink_error(&error, location, &key, Tail::AmazonS3, "manifest write"))?;
    +    tracing::info!(
    +        account_id = %location.account,
    +        operation = "StartBulkPutItems",
    +        object = %format!("s3://{}/{key}", location.bucket),
    +        "import/export file access"
    +    );
    +    Ok(key)
    +}
    diff --git a/crates/engine/src/bulk_put/s3/mod.rs b/crates/engine/src/bulk_put/s3/mod.rs
    new file mode 100644
    index 000000000..e8f603835
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/mod.rs
    @@ -0,0 +1,346 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The S3 store: inputs listed and read from, and reports written to, the
    +//! buckets the requests name, in AWS S3 or an S3-compatible service, with
    +//! the server's own credentials. A request's `S3BucketOwner`, when it names
    +//! an account other than the caller's, travels as
    +//! `x-amz-expected-bucket-owner` on every request, and S3 decides. S3's
    +//! answers are rendered for the client by [`error`]; the decode chain is
    +//! the one the filesystem store reads through, fed by a reader over ranged
    +//! GETs ([`read`]).
    +
    +mod client;
    +mod error;
    +mod list;
    +mod manifest;
    +mod probe;
    +mod read;
    +mod write;
    +
    +#[cfg(test)]
    +pub(crate) mod tests;
    +
    +use std::sync::Arc;
    +
    +use extenddb_s3::retry;
    +use serde_json::Value;
    +
    +use extenddb_storage::BoxedFuture as BoxFuture;
    +use extenddb_storage::{InputFile, ReportKind};
    +
    +use crate::bulk_put::source::{
    +    BucketRef, BulkSink, BulkSource, InputCodec, LineReader, OutputSse, ReportWriter, SinkError,
    +    SourceError, SourceFile, StoreUnavailable,
    +};
    +
    +use client::Phase;
    +#[cfg(test)]
    +pub(crate) use client::connect_with;
    +pub(crate) use client::{S3Clients, connect};
    +use error::{Outcome, Tail};
    +
    +/// The last segment of the manifest's key (`report::manifest_key`). The
    +/// service writes the manifest as it reads its input, so a check of the
    +/// manifest key carries that call's tail; a report key carries the report
    +/// writer's.
    +const MANIFEST_SUFFIX: &str = "/manifest-files.json";
    +
    +/// The key a request for `key` names in the store: on AWS S3 the key as
    +/// given; against a configured endpoint the key with empty and `.` segments
    +/// removed and `..` resolved, as S3-compatible services with a file backend
    +/// store it. The clients send checksums only when required exactly when
    +/// the store has a configured endpoint (`connect`).
    +fn key_in_store(clients: &S3Clients, key: &str) -> String {
    +    if clients.checksums_when_required() {
    +        crate::bulk_put::source::stored_form(key, true)
    +    } else {
    +        key.to_owned()
    +    }
    +}
    +
    +/// One bucket as the S3 calls name it, owned so a call's future can hold
    +/// it.
    +#[derive(Debug, Clone)]
    +pub(crate) struct Location {
    +    /// The caller's account, for the log lines.
    +    pub(crate) account: String,
    +    pub(crate) bucket: String,
    +    /// The `x-amz-expected-bucket-owner` value, when one is sent.
    +    pub(crate) owner: Option,
    +}
    +
    +impl Location {
    +    pub(crate) fn of(at: &BucketRef<'_>) -> Self {
    +        Self {
    +            account: at.account.to_owned(),
    +            bucket: at.bucket.to_owned(),
    +            owner: at.expected_owner.map(str::to_owned),
    +        }
    +    }
    +}
    +
    +/// An S3 `ETag` without its quotes, as the manifest records it; a
    +/// multipart object's `{md5}-{parts}` form is kept.
    +pub(crate) fn unquoted(etag: &str) -> String {
    +    etag.trim_matches('"').to_owned()
    +}
    +
    +/// A failed input-side call as a [`SourceError`]: a rendered fault, or an
    +/// I/O error of kind `Other` for the server's own failures, which the
    +/// runner answers with `InternalServerError`, carrying a
    +/// [`StoreUnavailable`] when the failure is transient and outlasted the
    +/// retries, which the runner resumes after. The detail is logged here.
    +fn source_error(
    +    error: &aws_sdk_s3::error::SdkError,
    +    location: &Location,
    +    key: &str,
    +    after_first_byte: bool,
    +    what: &'static str,
    +) -> SourceError
    +where
    +    E: aws_sdk_s3::error::ProvideErrorMetadata + std::error::Error + Send + Sync + 'static,
    +{
    +    match error::classify(error, Tail::AmazonS3, after_first_byte) {
    +        Outcome::Fault(fault) => {
    +            tracing::info!(
    +                account_id = %location.account,
    +                bucket = %location.bucket,
    +                key,
    +                code = fault.code,
    +                message = %fault.message,
    +                "S3 {what} failed"
    +            );
    +            SourceError::Store(fault)
    +        }
    +        Outcome::PastEnd | Outcome::Internal => {
    +            log_internal(error, location, key, what);
    +            // A transient failure here outlasted the store's retries.
    +            if retry::is_transient(error) {
    +                SourceError::Io(StoreUnavailable::io(format!("S3 {what} failed")))
    +            } else {
    +                SourceError::Io(std::io::Error::other(format!("S3 {what} failed")))
    +            }
    +        }
    +    }
    +}
    +
    +/// The ERROR line for an S3 failure that is the server's: S3's code and
    +/// message when S3 answered, and a line of its own when the code is about
    +/// the server's own credentials, account or clock.
    +pub(super) fn log_internal(
    +    error: &aws_sdk_s3::error::SdkError,
    +    location: &Location,
    +    key: &str,
    +    what: &'static str,
    +) where
    +    E: aws_sdk_s3::error::ProvideErrorMetadata + std::error::Error + Send + Sync + 'static,
    +{
    +    use aws_sdk_s3::error::ProvideErrorMetadata;
    +    let s3_code = error.code().unwrap_or_default();
    +    let s3_message = error.message().unwrap_or_default();
    +    if extenddb_s3::is_server_credential_error(error) {
    +        tracing::error!(
    +            account_id = %location.account,
    +            bucket = %location.bucket,
    +            key,
    +            s3_code,
    +            s3_message,
    +            "S3 {what} failed: S3 refused the server's own credentials, account or clock; \
    +             the caller is answered InternalServerError"
    +        );
    +    } else {
    +        tracing::error!(
    +            account_id = %location.account,
    +            bucket = %location.bucket,
    +            key,
    +            s3_code,
    +            s3_message,
    +            error = %aws_sdk_s3::error::DisplayErrorContext(error),
    +            "S3 {what} failed"
    +        );
    +    }
    +}
    +
    +/// The input side of the S3 store.
    +pub(crate) struct S3Source {
    +    pub(crate) clients: Arc,
    +}
    +
    +impl BulkSource for S3Source {
    +    fn check_bucket(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +    ) -> BoxFuture<'_, Result<(), SourceError>> {
    +        let location = Location::of(at);
    +        let prefix = prefix.to_owned();
    +        Box::pin(async move { list::check_bucket(&self.clients, &location, &prefix).await })
    +    }
    +
    +    fn list(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        max_files: u64,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        let location = Location::of(at);
    +        let prefix = prefix.to_owned();
    +        Box::pin(async move { list::list(&self.clients, &location, &prefix, max_files).await })
    +    }
    +
    +    fn open(
    +        &self,
    +        at: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        let location = Location::of(at);
    +        let file = file.clone();
    +        Box::pin(async move {
    +            let reader =
    +                read::open(Arc::clone(&self.clients), location, &file, compression).await?;
    +            Ok(Box::new(reader) as Box)
    +        })
    +    }
    +
    +    fn etag(
    +        &self,
    +        at: &BucketRef<'_>,
    +        file: &InputFile,
    +    ) -> BoxFuture<'_, Result> {
    +        let location = Location::of(at);
    +        let file = file.clone();
    +        Box::pin(async move { read::etag(&self.clients, &location, &file).await })
    +    }
    +
    +    fn line_count(
    +        &self,
    +        at: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result> {
    +        let location = Location::of(at);
    +        let file = file.clone();
    +        Box::pin(async move {
    +            read::line_count(Arc::clone(&self.clients), location, &file, compression).await
    +        })
    +    }
    +
    +    fn listed_prefix(&self, prefix: &str) -> String {
    +        key_in_store(&self.clients, prefix)
    +    }
    +
    +    fn service_assigns_etags(&self) -> bool {
    +        true
    +    }
    +}
    +
    +/// The output side of the S3 store.
    +pub(crate) struct S3Sink {
    +    pub(crate) clients: Arc,
    +}
    +
    +impl BulkSink for S3Sink {
    +    fn check_location(
    +        &self,
    +        at: &BucketRef<'_>,
    +        key: &str,
    +        sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result<(), SinkError>> {
    +        let location = Location::of(at);
    +        let key = key.to_owned();
    +        let sse = sse.clone();
    +        let tail = if key.ends_with(MANIFEST_SUFFIX) {
    +            Tail::AmazonS3
    +        } else {
    +            Tail::S3
    +        };
    +        Box::pin(async move {
    +            probe::probe(&self.clients, &location, &key, &sse, tail, Phase::Run).await
    +        })
    +    }
    +
    +    fn check_encryption(
    +        &self,
    +        at: &BucketRef<'_>,
    +        key: &str,
    +        sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result<(), SinkError>> {
    +        let location = Location::of(at);
    +        let key = key.to_owned();
    +        let sse = sse.clone();
    +        Box::pin(async move {
    +            // Only an SSE-KMS key is checked at Start (measured on the
    +            // service: a disabled key and a key of the wrong usage fail
    +            // the operation before the input walk). A bucket or owner fault
    +            // the same check meets is left to the first report record, as
    +            // the service leaves it.
    +            if !matches!(sse, OutputSse::Kms { .. }) {
    +                return Ok(());
    +            }
    +            let checked = probe::probe(
    +                &self.clients,
    +                &location,
    +                &key,
    +                &sse,
    +                Tail::AmazonS3,
    +                Phase::Start,
    +            );
    +            match checked.await {
    +                Err(SinkError::Store(fault)) if !fault.code.starts_with("Kms") => Ok(()),
    +                other => other,
    +            }
    +        })
    +    }
    +
    +    fn open(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        operation_id: &str,
    +        kind: ReportKind,
    +        _owner: &str,
    +        sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result, SinkError>> {
    +        let writer = write::S3ReportWriter::new(
    +            Arc::clone(&self.clients),
    +            Location::of(at),
    +            prefix,
    +            operation_id,
    +            kind,
    +            sse,
    +        );
    +        Box::pin(async move { Ok(Box::new(writer) as Box) })
    +    }
    +
    +    fn write_manifest(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        operation_id: &str,
    +        _owner: &str,
    +        sse: &OutputSse,
    +        lines: Vec,
    +    ) -> BoxFuture<'_, Result> {
    +        let location = Location::of(at);
    +        let prefix = prefix.to_owned();
    +        let operation_id = operation_id.to_owned();
    +        let sse = sse.clone();
    +        Box::pin(async move {
    +            manifest::write_manifest(
    +                &self.clients,
    +                &location,
    +                &prefix,
    +                &operation_id,
    +                &sse,
    +                &lines,
    +            )
    +            .await
    +        })
    +    }
    +
    +    fn stored_key(&self, key: &str) -> String {
    +        key_in_store(&self.clients, key)
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/s3/probe.rs b/crates/engine/src/bulk_put/s3/probe.rs
    new file mode 100644
    index 000000000..1e4ce11c0
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/probe.rs
    @@ -0,0 +1,129 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The output side's shared pieces: the encryption fields a write sends,
    +//! the rendering of a failed write, and the write check. The check starts
    +//! a multipart upload on the key the operation is about to write, with the
    +//! expected owner and the encryption fields, and aborts it at once, so S3
    +//! answers a missing bucket, an owner mismatch, an `s3:PutObject` denial
    +//! and an unusable KMS key with its own code and message, and nothing is
    +//! written.
    +
    +use aws_sdk_s3::types::ServerSideEncryption;
    +
    +use super::client::Phase;
    +use super::error::{Outcome, Tail, classify};
    +use super::{Location, S3Clients};
    +use crate::bulk_put::source::{OutputSse, SinkError, StoreUnavailable};
    +
    +/// The `x-amz-server-side-encryption` and
    +/// `x-amz-server-side-encryption-aws-kms-key-id` values for `sse`.
    +pub(super) fn sse_fields(sse: &OutputSse) -> (Option, Option) {
    +    match sse {
    +        OutputSse::None => (None, None),
    +        OutputSse::Aes256 => (Some(ServerSideEncryption::Aes256), None),
    +        OutputSse::Kms { key_id } => (Some(ServerSideEncryption::AwsKms), key_id.clone()),
    +    }
    +}
    +
    +/// A failed output-side call as a [`SinkError`]: a rendered fault with
    +/// `tail`, or an I/O error of kind `Other` for the server's own failures,
    +/// which the runner answers with `InternalServerError`, carrying a
    +/// [`StoreUnavailable`] when the failure is transient and outlasted the
    +/// retries, which the runner resumes after. The detail is logged here.
    +pub(super) fn sink_error(
    +    error: &aws_sdk_s3::error::SdkError,
    +    location: &Location,
    +    key: &str,
    +    tail: Tail,
    +    what: &'static str,
    +) -> SinkError
    +where
    +    E: aws_sdk_s3::error::ProvideErrorMetadata + std::error::Error + Send + Sync + 'static,
    +{
    +    match classify(error, tail, false) {
    +        Outcome::Fault(fault) => {
    +            tracing::info!(
    +                account_id = %location.account,
    +                bucket = %location.bucket,
    +                key,
    +                code = fault.code,
    +                message = %fault.message,
    +                "S3 {what} failed"
    +            );
    +            SinkError::Store(fault)
    +        }
    +        Outcome::PastEnd | Outcome::Internal => {
    +            super::log_internal(error, location, key, what);
    +            // A transient failure here outlasted the store's retries.
    +            if super::retry::is_transient(error) {
    +                SinkError::Io(StoreUnavailable::io(format!("S3 {what} failed")))
    +            } else {
    +                SinkError::Io(std::io::Error::other(format!("S3 {what} failed")))
    +            }
    +        }
    +    }
    +}
    +
    +/// The write check for `key`: CreateMultipartUpload with the owner and
    +/// encryption fields, then AbortMultipartUpload, both under the bound of
    +/// `phase`.
    +pub(super) async fn probe(
    +    clients: &S3Clients,
    +    location: &Location,
    +    key: &str,
    +    sse: &OutputSse,
    +    tail: Tail,
    +    phase: Phase,
    +) -> Result<(), SinkError> {
    +    let (encryption, kms_key) = sse_fields(sse);
    +    let created = clients
    +        .call_in(phase, &location.bucket, |client| {
    +            client
    +                .create_multipart_upload()
    +                .bucket(&location.bucket)
    +                .key(key)
    +                .set_expected_bucket_owner(location.owner.clone())
    +                .set_server_side_encryption(encryption.clone())
    +                .set_ssekms_key_id(kms_key.clone())
    +                .send()
    +        })
    +        .await
    +        .map_err(|error| sink_error(&error, location, key, tail, "write check"))?;
    +    if let Some(upload_id) = created.upload_id() {
    +        abort(clients, location, key, upload_id, phase).await;
    +    }
    +    Ok(())
    +}
    +
    +/// Abort one multipart upload under the bound of `phase`. A failure
    +/// leaves the upload to the bucket's lifecycle rule; it is logged, never
    +/// the operation's fault.
    +pub(super) async fn abort(
    +    clients: &S3Clients,
    +    location: &Location,
    +    key: &str,
    +    upload_id: &str,
    +    phase: Phase,
    +) {
    +    let aborted = clients
    +        .call_in(phase, &location.bucket, |client| {
    +            client
    +                .abort_multipart_upload()
    +                .bucket(&location.bucket)
    +                .key(key)
    +                .upload_id(upload_id)
    +                .set_expected_bucket_owner(location.owner.clone())
    +                .send()
    +        })
    +        .await;
    +    if let Err(error) = aborted {
    +        tracing::warn!(
    +            account_id = %location.account,
    +            bucket = %location.bucket,
    +            key,
    +            error = %aws_sdk_s3::error::DisplayErrorContext(&error),
    +            "S3 multipart upload could not be aborted; a lifecycle rule removes it"
    +        );
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/s3/read.rs b/crates/engine/src/bulk_put/s3/read.rs
    new file mode 100644
    index 000000000..17da17b09
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/read.rs
    @@ -0,0 +1,599 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Reading one input object: sequential ranged GETs of 8 MiB, ending at
    +//! the enumerated size, behind a blocking `Read` the shared decode chain
    +//! consumes on the blocking pool. Every GET carries `If-Match` with the
    +//! `ETag` the listing gave at Start, the first range of an open and of a
    +//! resumed open included, so a read never takes bytes of an object other
    +//! than the one listed and one read never mixes two versions. A 412 means
    +//! the listed object is no longer at the key: the line reader reports the
    +//! file as overwritten (`SourceError::Overwritten`), as the service does
    +//! for an object overwritten after its listing (measured on the service).
    +//! For a record the listing gave no `ETag`, the first range goes without
    +//! `If-Match` and later ranges carry the first answer's `ETag`, and a first
    +//! answer that gives a size other than the listed one is reported as
    +//! overwritten too: the total of its `Content-Range`, or for an answer
    +//! without one that carries the whole object, its length. A replacement of
    +//! the same size is then caught within one read but not across a resume,
    +//! whose first range again takes the object as it is. A fault on the first
    +//! range is reported with `after_first_byte` false, on a later one true. A
    +//! body shorter than its `Content-Length`, or shorter than the range when
    +//! the object does not end there, is the server's failure, never an end of
    +//! stream: under a codec an early end would read as a corrupt stream.
    +//!
    +//! The ranged reader runs on the blocking pool, which a dropped future
    +//! does not stop. Each opened object therefore has a stop signal: the open
    +//! holds its sending half until it returns and then hands it to the line
    +//! reader. Dropping the open, the line reader, or a `next_line` call before
    +//! it answers fires the signal, and the ranged reader races every GET, its
    +//! retries and their waits against it, so a read the caller gave up on
    +//! drops the request in flight, sends no further one, and the blocking
    +//! fill returns at once.
    +
    +use std::io::{BufRead, Read};
    +use std::sync::Arc;
    +
    +use aws_sdk_s3::operation::RequestId;
    +use tokio::sync::watch;
    +
    +use extenddb_storage::BoxedFuture as BoxFuture;
    +use extenddb_storage::InputFile;
    +
    +use super::error::{Outcome, Tail, classify};
    +use super::retry::Backoff;
    +use super::{Location, S3Clients, source_error, unquoted};
    +use crate::bulk_put::source::{
    +    ChainLineReader, InputCodec, LineReader, SourceError, StoreFault, StoreUnavailable, line_reader,
    +};
    +
    +/// Bytes asked for per ranged GET.
    +pub(super) const GET_PART_BYTES: u64 = 8 * 1024 * 1024;
    +
    +/// One object being read.
    +struct Object {
    +    clients: Arc,
    +    location: Location,
    +    key: String,
    +}
    +
    +/// The bytes one GET gave.
    +struct Part {
    +    data: Vec,
    +    /// The response's `ETag`, quoted as S3 sends it.
    +    etag: Option,
    +    request_id: Option,
    +    /// The object ends with these bytes.
    +    last: bool,
    +    /// The object's size as the answer's `Content-Range` gives it, or the
    +    /// body's length for an answer without the header that carries the
    +    /// whole object; `None` otherwise and with a `*` total.
    +    total: Option,
    +}
    +
    +/// Why a GET gave no bytes.
    +enum Miss {
    +    /// The range starts at or past the object's end (416).
    +    PastEnd,
    +    Fault(SourceError),
    +}
    +
    +/// The server's failure reading `object`, marked `transient` when it outlasted the retries.
    +fn internal(object: &Object, detail: &str, transient: bool) -> Miss {
    +    tracing::error!(
    +        account_id = %object.location.account,
    +        bucket = %object.location.bucket,
    +        key = %object.key,
    +        detail,
    +        "S3 read failed"
    +    );
    +    let text = format!("S3 read failed: {detail}");
    +    Miss::Fault(SourceError::Io(if transient {
    +        StoreUnavailable::io(text)
    +    } else {
    +        std::io::Error::other(text)
    +    }))
    +}
    +
    +/// `bytes {first}-{last}/{total}` from a `Content-Range` header; the total
    +/// is `None` when the service sends `*`.
    +fn content_range(header: &str) -> Option<(u64, Option)> {
    +    let (span, total) = header.strip_prefix("bytes ")?.split_once('/')?;
    +    let (first, _) = span.split_once('-')?;
    +    Some((first.parse().ok()?, total.parse().ok()))
    +}
    +
    +/// A GET's 412: the only precondition a GET sends is `If-Match` with the
    +/// listed (or first) `ETag`, so the object at the key is no longer the one
    +/// the operation listed. The store renders it as a store fault, which
    +/// crosses the decode chain like any other, and the line reader hands it
    +/// to the runner as `SourceError::Overwritten`, at the open and at a later
    +/// range alike, so the runner records the file as overwritten, as the
    +/// service does.
    +fn listed_object_gone(error: SourceError) -> SourceError {
    +    match error {
    +        SourceError::Store(fault) if fault.code == "S3PreconditionFailed" => {
    +            SourceError::Overwritten
    +        }
    +        other => other,
    +    }
    +}
    +
    +/// GET bytes `start..=end` of the object. Every GET carries a `Range`, an
    +/// object listed at size 0 included (`bytes=0-0`, which S3 answers 416 for
    +/// an empty object, read as end of file), so what a GET keeps is bounded by
    +/// the range whatever the object holds when it is read. The body is read
    +/// to at most one byte past the range, enough to tell a longer body from
    +/// one that fits, and the rest is never read. A body cut mid-stream is
    +/// requested again for the same range with the same `If-Match`, under the
    +/// bound of the call's own retries (`retry`).
    +async fn get_range(
    +    object: &Object,
    +    start: u64,
    +    end: u64,
    +    if_match: Option<&str>,
    +    after_first_byte: bool,
    +) -> Result {
    +    let mut backoff = object.clients.backoff();
    +    loop {
    +        let cut = match fetch(object, start, end, if_match, after_first_byte, &mut backoff).await {
    +            Fetched::Done(result) => return result,
    +            Fetched::Cut(detail) => detail,
    +        };
    +        let Some(wait) = backoff.next_wait() else {
    +            return Err(internal(object, &cut, true));
    +        };
    +        tracing::warn!(
    +            account_id = %object.location.account,
    +            bucket = %object.location.bucket,
    +            key = %object.key,
    +            range = %format!("bytes={start}-{end}"),
    +            retry = backoff.retries(),
    +            wait_ms = u64::try_from(wait.as_millis()).unwrap_or(u64::MAX),
    +            detail = %cut,
    +            "S3 read body was cut; the range is requested again after the wait"
    +        );
    +        tokio::time::sleep(wait).await;
    +    }
    +}
    +
    +/// One GET of a range: its outcome, or a body cut before the range ended.
    +enum Fetched {
    +    Done(Result),
    +    Cut(String),
    +}
    +
    +async fn fetch(
    +    object: &Object,
    +    start: u64,
    +    end: u64,
    +    if_match: Option<&str>,
    +    after_first_byte: bool,
    +    backoff: &mut Backoff,
    +) -> Fetched {
    +    let range = format!("bytes={start}-{end}");
    +    let sent = object
    +        .clients
    +        .call_within(
    +            &object.location.bucket,
    +            backoff,
    +            &|client: aws_sdk_s3::Client| {
    +                client
    +                    .get_object()
    +                    .bucket(&object.location.bucket)
    +                    .key(&object.key)
    +                    .range(range.clone())
    +                    .set_if_match(if_match.map(str::to_owned))
    +                    .set_expected_bucket_owner(object.location.owner.clone())
    +                    .send()
    +            },
    +        )
    +        .await;
    +    let output = match sent {
    +        Ok(output) => output,
    +        Err(error) => {
    +            let miss = if matches!(
    +                classify(&error, Tail::AmazonS3, after_first_byte),
    +                Outcome::PastEnd
    +            ) {
    +                Miss::PastEnd
    +            } else {
    +                Miss::Fault(source_error(
    +                    &error,
    +                    &object.location,
    +                    &object.key,
    +                    after_first_byte,
    +                    "read",
    +                ))
    +            };
    +            return Fetched::Done(Err(miss));
    +        }
    +    };
    +    let etag = output.e_tag().map(str::to_owned);
    +    let request_id = output.request_id().map(str::to_owned);
    +    let range_header = output.content_range().map(str::to_owned);
    +    let requested = end - start + 1;
    +    let mut data = match bounded_body(output.body, requested.saturating_add(1)).await {
    +        Ok(data) => data,
    +        Err(detail) => return Fetched::Cut(detail),
    +    };
    +    let got = data.len() as u64;
    +    let endpoint_fault = |detail: &str| Fetched::Done(Err(internal(object, detail, false)));
    +    let range_total = match range_header.as_deref().and_then(content_range) {
    +        Some((_, total)) => total,
    +        // A whole-object answer gives the object's size by its length.
    +        None if start == 0 && got <= requested => Some(got),
    +        None => None,
    +    };
    +    let last = match range_header.as_deref().and_then(content_range) {
    +        Some((first, _)) if first != start => {
    +            return endpoint_fault("Content-Range does not start at the range asked for");
    +        }
    +        Some((_, total)) => {
    +            if got > requested {
    +                data.truncate(usize::try_from(requested).unwrap_or(usize::MAX));
    +            } else if got < requested && total != Some(start + got) {
    +                // Only the object's end may cut a range short.
    +                return Fetched::Cut("body shorter than the range asked for".to_owned());
    +            }
    +            total.is_some_and(|total| start + data.len() as u64 >= total)
    +        }
    +        // A service that ignores Range answers the whole object; that
    +        // places the bytes only for a read from the start, and only when
    +        // the whole object fits in the range asked for.
    +        None if start == 0 && got <= requested => true,
    +        None if start == 0 => {
    +            return endpoint_fault(
    +                "an answer without Content-Range is longer than the range asked for",
    +            );
    +        }
    +        None => return endpoint_fault("ranged GET answered without Content-Range"),
    +    };
    +    Fetched::Done(Ok(Part {
    +        data,
    +        etag,
    +        request_id,
    +        last,
    +        total: range_total,
    +    }))
    +}
    +
    +/// At most `limit` bytes of a response body; the rest is never read. The
    +/// SDK fails the body of a response that ends before its Content-Length,
    +/// and its stalled-stream protection a body that stops; both errors land
    +/// here, as the detail of a cut body.
    +async fn bounded_body(
    +    mut body: aws_sdk_s3::primitives::ByteStream,
    +    limit: u64,
    +) -> Result, String> {
    +    let limit = usize::try_from(limit).unwrap_or(usize::MAX);
    +    let mut data = Vec::new();
    +    while data.len() < limit {
    +        match body.next().await {
    +            None => break,
    +            Some(Ok(chunk)) => {
    +                let room = limit - data.len();
    +                data.extend_from_slice(&chunk[..chunk.len().min(room)]);
    +            }
    +            Some(Err(error)) => return Err(format!("response body: {error}")),
    +        }
    +    }
    +    Ok(data)
    +}
    +
    +/// Resolves once the object's reads are to stop: the signal was fired, or
    +/// its sending half was dropped.
    +async fn stopped(mut stop: watch::Receiver) {
    +    let _ = stop.wait_for(|stopped| *stopped).await;
    +}
    +
    +/// The fault of a read stopped by its signal. Nobody reads it: the caller
    +/// that would have has gone.
    +fn stopped_read() -> Miss {
    +    Miss::Fault(SourceError::Io(std::io::Error::other(
    +        "S3 read stopped: its reader was dropped",
    +    )))
    +}
    +
    +/// Fires the stop signal when dropped, unless disarmed first.
    +struct StopOnDrop<'a>(Option<&'a watch::Sender>);
    +
    +impl StopOnDrop<'_> {
    +    fn disarm(mut self) {
    +        self.0 = None;
    +    }
    +}
    +
    +impl Drop for StopOnDrop<'_> {
    +    fn drop(&mut self) {
    +        if let Some(stop) = self.0 {
    +            stop.send_replace(true);
    +        }
    +    }
    +}
    +
    +/// The blocking `Read` over the object's ranged GETs, run by the decode
    +/// chain on the blocking pool; each GET is driven on the runtime through
    +/// `Handle::block_on`, raced against the object's stop signal.
    +struct RangedObject {
    +    object: Arc,
    +    runtime: tokio::runtime::Handle,
    +    stop: watch::Receiver,
    +    size: u64,
    +    next: u64,
    +    buffer: Vec,
    +    pos: usize,
    +    /// The `ETag` sent as `If-Match` on every later range: the listed one,
    +    /// or the first answer's for a record listed without one.
    +    if_match: Option,
    +    done: bool,
    +}
    +
    +impl RangedObject {
    +    fn accept(&mut self, start: u64, result: Result) -> Result<(), SourceError> {
    +        self.pos = 0;
    +        match result {
    +            Ok(part) => {
    +                self.next = start.saturating_add(part.data.len() as u64);
    +                self.done = part.last || part.data.is_empty();
    +                self.buffer = part.data;
    +                Ok(())
    +            }
    +            Err(Miss::PastEnd) => {
    +                self.done = true;
    +                self.buffer = Vec::new();
    +                Ok(())
    +            }
    +            Err(Miss::Fault(error)) => Err(error),
    +        }
    +    }
    +}
    +
    +impl Read for RangedObject {
    +    fn read(&mut self, buf: &mut [u8]) -> std::io::Result {
    +        while self.pos >= self.buffer.len() {
    +            if self.done || self.next >= self.size {
    +                return Ok(0);
    +            }
    +            let start = self.next;
    +            let part_bytes = self.object.clients.part_bytes();
    +            let end = start.saturating_add(part_bytes).min(self.size) - 1;
    +            let object = Arc::clone(&self.object);
    +            let if_match = self.if_match.clone();
    +            let stop = self.stop.clone();
    +            // The signal is polled first, so a read stopped before this
    +            // range sends nothing; once it fires, the GET in flight or the
    +            // wait before its retry is dropped.
    +            let result = self.runtime.block_on(async move {
    +                tokio::select! {
    +                    biased;
    +                    () = stopped(stop) => Err(stopped_read()),
    +                    result = get_range(&object, start, end, if_match.as_deref(), true) => result,
    +                }
    +            });
    +            // A rendered fault travels to the chain inside the I/O error,
    +            // which hands it on as `SourceError::Store`.
    +            self.accept(start, result).map_err(|error| match error {
    +                SourceError::Store(fault) => std::io::Error::other(fault),
    +                SourceError::Io(io) => io,
    +                other => std::io::Error::other(format!("{other:?}")),
    +            })?;
    +        }
    +        let n = buf.len().min(self.buffer.len() - self.pos);
    +        buf[..n].copy_from_slice(&self.buffer[self.pos..self.pos + n]);
    +        self.pos += n;
    +        Ok(n)
    +    }
    +}
    +
    +/// The line reader of one S3 object: the shared chain, reporting at end of
    +/// file the `ETag` of the read (the first range's, which the service's
    +/// manifest records for a file read to its end) instead of the digest of
    +/// the bytes.
    +pub(super) struct S3LineReader {
    +    chain: ChainLineReader,
    +    etag: String,
    +    /// The sending half of the object's stop signal; dropped with the
    +    /// reader, it stops the object's reads.
    +    stop: watch::Sender,
    +}
    +
    +impl LineReader for S3LineReader {
    +    fn next_line(
    +        &mut self,
    +        max_line_bytes: usize,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        Box::pin(async move {
    +            // A call dropped before it answers stops the object's reads:
    +            // the blocking fill it started stays with the decode chain, and
    +            // the next call awaits that fill, which ends with the stop as
    +            // its fault after the lines it read.
    +            let armed = StopOnDrop(Some(&self.stop));
    +            let line = self
    +                .chain
    +                .next_line(max_line_bytes)
    +                .await
    +                .map_err(listed_object_gone);
    +            armed.disarm();
    +            line
    +        })
    +    }
    +
    +    fn bytes_consumed(&self) -> u64 {
    +        self.chain.bytes_consumed()
    +    }
    +
    +    fn last_line_end(&self) -> u64 {
    +        self.chain.last_line_end()
    +    }
    +
    +    fn etag(&self) -> Option {
    +        self.chain.etag().map(|_| self.etag.clone())
    +    }
    +
    +    fn opened_etag(&self) -> Option {
    +        Some(self.etag.clone())
    +    }
    +}
    +
    +fn enumerated_size(file: &InputFile) -> u64 {
    +    u64::try_from(file.size).unwrap_or(0)
    +}
    +
    +/// Open one enumerated object: fetch its first range, then build the
    +/// decode chain over the ranged reader.
    +pub(super) async fn open(
    +    clients: Arc,
    +    location: Location,
    +    file: &InputFile,
    +    compression: InputCodec,
    +) -> Result {
    +    let size = enumerated_size(file);
    +    // Held by this future until it returns and then by the line reader,
    +    // so a dropped open stops the reads it started too.
    +    let (stop, stop_signal) = watch::channel(false);
    +    let object = Arc::new(Object {
    +        clients,
    +        location,
    +        key: file.path.clone(),
    +    });
    +    // An object listed at size 0 is fetched too, as one byte: a missing,
    +    // denied or replaced object is then refused at its open as any other
    +    // object is, and the read keeps none of the bytes.
    +    let first_end = size.min(object.clients.part_bytes()).max(1) - 1;
    +    let listed = file.etag.as_deref().map(|etag| format!("\"{etag}\""));
    +    let first = get_range(&object, 0, first_end, listed.as_deref(), false).await;
    +    // Without a listed `ETag` the listed size is what the operation knows of
    +    // the object, so a first answer that gives another size is an object
    +    // replaced since the listing, recorded as for a 412.
    +    if listed.is_none()
    +        && let Ok(Part {
    +            total: Some(total), ..
    +        }) = &first
    +        && *total != size
    +    {
    +        return Err(SourceError::Overwritten);
    +    }
    +    let (read_etag, request_id) = match &first {
    +        Ok(part) => (part.etag.clone(), part.request_id.clone()),
    +        Err(_) => (None, None),
    +    };
    +    let mut ranged = RangedObject {
    +        object: Arc::clone(&object),
    +        runtime: tokio::runtime::Handle::current(),
    +        stop: stop_signal,
    +        size,
    +        next: 0,
    +        buffer: Vec::new(),
    +        pos: 0,
    +        if_match: listed.or_else(|| read_etag.clone()),
    +        done: false,
    +    };
    +    ranged.accept(0, first).map_err(listed_object_gone)?;
    +    // The audit event the import and export operations emit, with the
    +    // object read. Keys stay in the log and never reach a client.
    +    tracing::info!(
    +        account_id = %object.location.account,
    +        operation = "StartBulkPutItems",
    +        object = %format!("s3://{}/{}", object.location.bucket, object.key),
    +        request_id = request_id.as_deref().unwrap_or("null"),
    +        "import/export file access"
    +    );
    +    let etag = read_etag
    +        .as_deref()
    +        .map(unquoted)
    +        .or_else(|| file.etag.clone())
    +        .unwrap_or_default();
    +    let chain =
    +        tokio::task::spawn_blocking(move || line_reader(Box::new(ranged), size, compression))
    +            .await
    +            .map_err(|e| SourceError::Io(std::io::Error::other(e)))??;
    +    Ok(S3LineReader { chain, etag, stop })
    +}
    +
    +/// The entity tag for the manifest: the `ETag` listed at Start, or, for a
    +/// record without one, the `ETag` of a GET of the first byte. Only the
    +/// header is used and the body is not read, so a service that ignores
    +/// `Range` and answers the whole object gives the same tag.
    +pub(super) async fn etag(
    +    clients: &Arc,
    +    location: &Location,
    +    file: &InputFile,
    +) -> Result {
    +    if let Some(etag) = &file.etag {
    +        return Ok(etag.clone());
    +    }
    +    let object = Object {
    +        clients: Arc::clone(clients),
    +        location: location.clone(),
    +        key: file.path.clone(),
    +    };
    +    let mut backoff = object.clients.backoff();
    +    let sent = object
    +        .clients
    +        .call_within(
    +            &object.location.bucket,
    +            &mut backoff,
    +            &|client: aws_sdk_s3::Client| {
    +                client
    +                    .get_object()
    +                    .bucket(&object.location.bucket)
    +                    .key(&object.key)
    +                    .range("bytes=0-0")
    +                    .set_expected_bucket_owner(object.location.owner.clone())
    +                    .send()
    +            },
    +        )
    +        .await;
    +    match sent {
    +        Ok(output) => Ok(output.e_tag().map(unquoted).unwrap_or_default()),
    +        Err(error) if matches!(classify(&error, Tail::AmazonS3, false), Outcome::PastEnd) => {
    +            Ok(String::new())
    +        }
    +        Err(error) => Err(source_error(
    +            &error,
    +            &object.location,
    +            &object.key,
    +            false,
    +            "read",
    +        )),
    +    }
    +}
    +
    +/// The number of physical lines in one object, read the way the runner
    +/// reads it.
    +pub(super) async fn line_count(
    +    clients: Arc,
    +    location: Location,
    +    file: &InputFile,
    +    compression: InputCodec,
    +) -> Result {
    +    let reader = open(clients, location, file, compression).await?;
    +    // The stop signal stays with this future, so a dropped count stops
    +    // the reads of the blocking task below.
    +    let S3LineReader { chain, stop, .. } = reader;
    +    let counted = tokio::task::spawn_blocking(move || {
    +        let Some(mut inner) = chain.into_inner() else {
    +            return Ok(0);
    +        };
    +        let mut count: u64 = 0;
    +        let mut buf = Vec::new();
    +        loop {
    +            buf.clear();
    +            match inner.read_until(b'\n', &mut buf) {
    +                Ok(0) => break,
    +                Ok(_) => count += 1,
    +                Err(e) => {
    +                    return Err(
    +                        StoreFault::of_io(&e).map_or(SourceError::Io(e), SourceError::Store)
    +                    );
    +                }
    +            }
    +        }
    +        Ok(count)
    +    })
    +    .await
    +    .map_err(|e| SourceError::Io(std::io::Error::other(e)))?;
    +    drop(stop);
    +    counted
    +}
    diff --git a/crates/engine/src/bulk_put/s3/tests/checksums.rs b/crates/engine/src/bulk_put/s3/tests/checksums.rs
    new file mode 100644
    index 000000000..6e8635d2d
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/tests/checksums.rs
    @@ -0,0 +1,202 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The checksum settings of each mode, on the clients `clients_from`
    +//! builds: against a configured endpoint no request carries a checksum and
    +//! no read asks for one, and multipart uploads declare no algorithm (the
    +//! choice in `s3/write.rs`); on AWS S3 the SDK's defaults apply. The SDK
    +//! sends the checksum of an in-memory body in a header, not a trailer, so
    +//! both are looked for. Requests go through the replay client.
    +
    +use std::sync::Arc;
    +
    +use aws_sdk_s3::config::http::{HttpRequest, HttpResponse};
    +use aws_sdk_s3::config::retry::RetryConfig;
    +use aws_sdk_s3::primitives::SdkBody;
    +use aws_smithy_http_client::test_util::{ReplayEvent, StaticReplayClient};
    +
    +use extenddb_storage::ReportKind;
    +
    +use super::deadlines::{sdk_config, settings};
    +use super::read::{LINES, part};
    +use super::write::{
    +    OPERATION, PART, completed, created, gzip_size, method_and_uri, out, part_uploaded, put,
    +    records,
    +};
    +use super::*;
    +use crate::bulk_put::s3::S3Sink;
    +use crate::bulk_put::s3::client::clients_from;
    +use crate::bulk_put::s3::read::open;
    +use crate::bulk_put::source::{BulkSink, InputCodec, OutputSse};
    +
    +/// The store's clients for `endpoint` over a replay client answering
    +/// `responses`, with no retries by the SDK or by the store.
    +fn mode_clients(
    +    endpoint: Option<&str>,
    +    responses: Vec,
    +) -> (Arc, StaticReplayClient) {
    +    let replay = StaticReplayClient::new(
    +        responses
    +            .into_iter()
    +            .map(|response| ReplayEvent::new(HttpRequest::new(SdkBody::empty()), response))
    +            .collect(),
    +    );
    +    let http = replay.clone();
    +    let clients = clients_from(&settings(endpoint), &sdk_config(), move |builder| {
    +        builder
    +            .http_client(http)
    +            .retry_config(RetryConfig::disabled())
    +    })
    +    .with_retry(RetryPolicy::NONE)
    +    .with_part_bytes(PART);
    +    (shared(clients), replay)
    +}
    +
    +/// The requests of each kind the store sends, in one mode.
    +struct Sent {
    +    /// CreateMultipartUpload, then each UploadPart, then
    +    /// CompleteMultipartUpload of one report object, then the PutObject of
    +    /// a report object that never filled a part.
    +    writes: StaticReplayClient,
    +    parts: usize,
    +    /// The GetObject of an input object's first range.
    +    read: StaticReplayClient,
    +}
    +
    +async fn send(endpoint: Option<&str>) -> Sent {
    +    let pages = vec![records(2000)];
    +    let parts = gzip_size(&pages).div_ceil(usize::try_from(PART).unwrap());
    +    assert!(parts >= 2, "the report fills more than one part");
    +    let mut responses = vec![created("up-0")];
    +    responses.extend((1..=parts).map(|i| part_uploaded(&format!("p{i}"))));
    +    responses.push(completed());
    +    responses.push(put());
    +    let (clients, writes) = mode_clients(endpoint, responses);
    +    let sink = S3Sink { clients };
    +    let mut multipart = sink
    +        .open(
    +            &out(None),
    +            "reports",
    +            OPERATION,
    +            ReportKind::Error,
    +            "lease",
    +            &OutputSse::None,
    +        )
    +        .await
    +        .unwrap();
    +    for page in pages {
    +        multipart.write_page(page).await.unwrap();
    +    }
    +    multipart.finish().await.unwrap();
    +    let mut single = sink
    +        .open(
    +            &out(None),
    +            "reports",
    +            OPERATION,
    +            ReportKind::ConditionCheckFailure,
    +            "lease",
    +            &OutputSse::None,
    +        )
    +        .await
    +        .unwrap();
    +    single.write_page(records(2)).await.unwrap();
    +    single.finish().await.unwrap();
    +
    +    let (reading, read) = mode_clients(endpoint, vec![part(LINES, 0, LINES.len(), "listed-etag")]);
    +    open(
    +        reading,
    +        location("loads", None),
    +        &input_file("in/part-0.json", LINES.len(), Some("listed-etag")),
    +        InputCodec::None,
    +    )
    +    .await
    +    .unwrap();
    +
    +    let count = writes.actual_requests().count();
    +    assert_eq!(count, parts + 3, "create, every part, complete, put");
    +    let (method, uri) = method_and_uri(&writes, 0);
    +    assert!(
    +        method == "POST" && uri.ends_with("?uploads"),
    +        "{method} {uri}"
    +    );
    +    for i in 1..=parts {
    +        let (method, uri) = method_and_uri(&writes, i);
    +        assert!(
    +            method == "PUT" && uri.contains("x-id=UploadPart"),
    +            "{method} {uri}"
    +        );
    +    }
    +    let (method, uri) = method_and_uri(&writes, parts + 1);
    +    assert!(
    +        method == "POST" && uri.contains("uploadId=up-0"),
    +        "{method} {uri}"
    +    );
    +    let (method, uri) = method_and_uri(&writes, parts + 2);
    +    assert!(
    +        method == "PUT" && uri.contains("x-id=PutObject"),
    +        "{method} {uri}"
    +    );
    +    Sent {
    +        writes,
    +        parts,
    +        read,
    +    }
    +}
    +
    +/// The checksum headers of one request: any `x-amz-checksum-*`,
    +/// `x-amz-sdk-checksum-algorithm` or `x-amz-trailer`, as `name: value`.
    +fn checksum_headers(replay: &StaticReplayClient, index: usize) -> Vec {
    +    let request = replay.actual_requests().nth(index).expect("request");
    +    let mut found: Vec = request
    +        .headers()
    +        .iter()
    +        .filter(|(name, _)| {
    +            name.starts_with("x-amz-checksum-")
    +                || *name == "x-amz-sdk-checksum-algorithm"
    +                || *name == "x-amz-trailer"
    +        })
    +        .map(|(name, value)| format!("{name}: {value}"))
    +        .collect();
    +    found.sort();
    +    found
    +}
    +
    +#[tokio::test]
    +async fn against_a_configured_endpoint_no_request_carries_or_asks_for_a_checksum() {
    +    let sent = send(Some("http://127.0.0.1:9")).await;
    +    for i in 0..sent.parts + 3 {
    +        assert_eq!(
    +            checksum_headers(&sent.writes, i),
    +            Vec::::new(),
    +            "write request {i}"
    +        );
    +    }
    +    assert_eq!(checksum_headers(&sent.read, 0), Vec::::new());
    +}
    +
    +#[tokio::test]
    +async fn on_aws_s3_writes_carry_the_sdks_crc32_and_reads_ask_for_checksums() {
    +    let sent = send(None).await;
    +    assert_eq!(
    +        checksum_headers(&sent.writes, 0),
    +        ["x-amz-checksum-algorithm: CRC32"],
    +        "CreateMultipartUpload"
    +    );
    +    let with_body = (1..=sent.parts).chain([sent.parts + 2]);
    +    for i in with_body {
    +        let found = checksum_headers(&sent.writes, i);
    +        assert_eq!(found.len(), 2, "request {i}: {found:?}");
    +        assert!(
    +            found[0].starts_with("x-amz-checksum-crc32: "),
    +            "request {i}: {found:?}"
    +        );
    +        assert_eq!(
    +            found[1], "x-amz-sdk-checksum-algorithm: CRC32",
    +            "request {i}"
    +        );
    +    }
    +    assert_eq!(
    +        checksum_headers(&sent.read, 0),
    +        ["x-amz-checksum-mode: ENABLED"]
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/s3/tests/child.rs b/crates/engine/src/bulk_put/s3/tests/child.rs
    new file mode 100644
    index 000000000..0e5781c75
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/tests/child.rs
    @@ -0,0 +1,64 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! A child process of this test binary with a fixed environment, for the
    +//! tests that load the SDK's configuration from the process environment
    +//! and the AWS config files. The host's variables, profiles and instance
    +//! metadata stay out of the child: its environment holds a scratch `HOME`,
    +//! a config file of the test's own, a credentials file that does not
    +//! exist, instance metadata off, and the variables the test names.
    +
    +use std::future::Future;
    +use std::process::{Command, Stdio};
    +
    +/// The variable that tells a child process of this test binary to run the
    +/// test's body.
    +const CHILD: &str = "EXTENDDB_TEST_S3_STORE_CHILD";
    +
    +/// Whether this process is a child [`run`] started.
    +pub(super) fn is_child() -> bool {
    +    std::env::var_os(CHILD).is_some()
    +}
    +
    +/// Drive `body` to its end on a current-thread runtime, so the events it
    +/// logs reach a subscriber installed on this thread.
    +pub(super) fn block_on(body: F) -> F::Output {
    +    tokio::runtime::Builder::new_current_thread()
    +        .enable_all()
    +        .build()
    +        .unwrap()
    +        .block_on(body)
    +}
    +
    +/// Run the test `test` of the module `module` (its `module_path!()`) in a
    +/// child process with `variables` and the AWS config file `config`, and
    +/// assert that it ran and passed. `case` names the run in a failure.
    +pub(super) fn run(module: &str, test: &str, case: &str, variables: &[(&str, &str)], config: &str) {
    +    let module = module.split_once("::").map_or("", |(_, rest)| rest);
    +    let dir = std::env::temp_dir().join(format!("eb-s3-child-{}", uuid::Uuid::new_v4()));
    +    std::fs::create_dir_all(&dir).unwrap();
    +    std::fs::write(dir.join("config"), config).unwrap();
    +    let output = Command::new(std::env::current_exe().unwrap())
    +        .args([
    +            format!("{module}::{test}").as_str(),
    +            "--exact",
    +            "--nocapture",
    +            "--test-threads",
    +            "1",
    +        ])
    +        .env_clear()
    +        .env(CHILD, case)
    +        .env("HOME", &dir)
    +        .env("AWS_CONFIG_FILE", dir.join("config"))
    +        .env("AWS_SHARED_CREDENTIALS_FILE", dir.join("no-credentials"))
    +        .env("AWS_EC2_METADATA_DISABLED", "true")
    +        .envs(variables.iter().copied())
    +        .stdin(Stdio::null())
    +        .output()
    +        .unwrap();
    +    let _ = std::fs::remove_dir_all(&dir);
    +    let stdout = String::from_utf8_lossy(&output.stdout);
    +    let stderr = String::from_utf8_lossy(&output.stderr);
    +    assert!(output.status.success(), "{case}:\n{stdout}\n{stderr}");
    +    assert!(stdout.contains("1 passed"), "{case}:\n{stdout}\n{stderr}");
    +}
    diff --git a/crates/engine/src/bulk_put/s3/tests/complete.rs b/crates/engine/src/bulk_put/s3/tests/complete.rs
    new file mode 100644
    index 000000000..5ad28e4f4
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/tests/complete.rs
    @@ -0,0 +1,197 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! A `CompleteMultipartUpload` whose answer was lost after S3 completed the
    +//! upload: the request is sent again and S3 answers `NoSuchUpload`, because
    +//! a completed upload is gone. The report object is then in place.
    +
    +use std::time::Duration;
    +
    +use extenddb_storage::ReportKind;
    +use tracing::Level;
    +
    +use super::logs::Capture;
    +use super::write::{
    +    OPERATION, PART, created, gzip_size, method_and_uri, out, part_uploaded, records, store_fault,
    +};
    +use super::*;
    +use crate::bulk_put::s3::S3Sink;
    +use crate::bulk_put::source::{BulkSink, OutputSse, SinkError};
    +
    +/// Retries a few milliseconds apart, within half a second.
    +const QUICK: RetryPolicy = RetryPolicy {
    +    first: Duration::from_millis(5),
    +    max: Duration::from_millis(20),
    +    total: Duration::from_millis(500),
    +};
    +
    +fn no_such_upload() -> HttpResponse {
    +    s3_error(
    +        404,
    +        "NoSuchUpload",
    +        "The specified upload does not exist. The upload ID may be invalid, or the upload \
    +         may have been aborted or completed.",
    +    )
    +}
    +
    +/// A `HeadObject` answer for an object of `length` bytes.
    +fn head(status: u16, length: usize) -> HttpResponse {
    +    with_headers(
    +        HttpResponse::new(status.try_into().unwrap(), SdkBody::empty()),
    +        &[
    +            ("etag", "\"done-2\""),
    +            ("content-length", &length.to_string()),
    +        ],
    +    )
    +}
    +
    +/// The pages every test writes.
    +fn pages() -> Vec> {
    +    vec![records(2000), records(2000), records(2000)]
    +}
    +
    +/// The size of the report object the pages make.
    +fn written() -> usize {
    +    gzip_size(&pages())
    +}
    +
    +/// Write a report that takes several parts, with `answers` after the
    +/// creation and the parts, and finish it.
    +async fn finish_with(
    +    answers: Vec,
    +) -> (Result, StaticReplayClient) {
    +    finish_under(RetryPolicy::NONE, answers).await
    +}
    +
    +/// [`finish_with`], the store's calls retried under `retry`.
    +async fn finish_under(
    +    retry: RetryPolicy,
    +    answers: Vec,
    +) -> (Result, StaticReplayClient) {
    +    let pages = pages();
    +    let parts = gzip_size(&pages).div_ceil(usize::try_from(PART).unwrap());
    +    let mut responses = vec![created("up-0")];
    +    responses.extend((1..=parts).map(|i| part_uploaded(&format!("p{i}"))));
    +    responses.extend(answers);
    +    let (clients, replay) = clients(responses);
    +    let sink = S3Sink {
    +        clients: shared(clients.with_part_bytes(PART).with_retry(retry)),
    +    };
    +    let mut writer = sink
    +        .open(
    +            &out(None),
    +            "reports",
    +            OPERATION,
    +            ReportKind::Error,
    +            "lease",
    +            &OutputSse::Aes256,
    +        )
    +        .await
    +        .unwrap();
    +    for page in pages {
    +        writer.write_page(page).await.unwrap();
    +    }
    +    (writer.finish().await, replay)
    +}
    +
    +fn path(uri: &str) -> &str {
    +    uri.split('?').next().unwrap_or(uri)
    +}
    +
    +#[tokio::test]
    +async fn a_completion_answered_no_such_upload_with_the_object_in_place_is_installed() {
    +    let (key, replay) = finish_with(vec![no_such_upload(), head(200, written())]).await;
    +    key.expect("the report is installed");
    +    let count = replay.actual_requests().count();
    +    let (complete, complete_uri) = method_and_uri(&replay, count - 2);
    +    let (check, check_uri) = method_and_uri(&replay, count - 1);
    +    assert_eq!(complete, "POST");
    +    assert!(complete_uri.contains("uploadId=up-0"), "{complete_uri}");
    +    assert_eq!(check, "HEAD");
    +    assert_eq!(
    +        path(&check_uri),
    +        path(&complete_uri),
    +        "the object at the key"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_completion_answered_no_such_upload_without_the_object_fails() {
    +    let (key, replay) = finish_with(vec![no_such_upload(), head(404, 0)]).await;
    +    let fault = store_fault(key.unwrap_err());
    +    assert_eq!(fault.code, "S3NoSuchUpload");
    +    let count = replay.actual_requests().count();
    +    assert_eq!(method_and_uri(&replay, count - 1).0, "HEAD");
    +}
    +
    +#[tokio::test]
    +async fn a_completion_answered_no_such_upload_with_an_object_of_another_size_fails() {
    +    for length in [0, 10, written() - 1, written() + 1] {
    +        let (key, replay) = finish_with(vec![no_such_upload(), head(200, length)]).await;
    +        let fault = store_fault(key.unwrap_err());
    +        assert_eq!(fault.code, "S3NoSuchUpload", "{length} bytes");
    +        let count = replay.actual_requests().count();
    +        assert_eq!(
    +            method_and_uri(&replay, count - 1).0,
    +            "HEAD",
    +            "{length} bytes"
    +        );
    +    }
    +}
    +
    +#[tokio::test]
    +async fn another_completion_fault_is_not_checked_against_the_object() {
    +    let (key, replay) = finish_with(vec![s3_error(
    +        400,
    +        "InvalidPart",
    +        "One or more of the specified parts could not be found.",
    +    )])
    +    .await;
    +    let fault = store_fault(key.unwrap_err());
    +    assert_eq!(fault.code, "S3InvalidPart");
    +    let count = replay.actual_requests().count();
    +    assert_eq!(method_and_uri(&replay, count - 1).0, "POST");
    +}
    +
    +#[tokio::test]
    +async fn a_completion_check_that_fails_transiently_past_its_bound_is_the_servers_failure() {
    +    // HeadObject answers 500 until the bound of its retries has passed.
    +    let (capture, _guard) = Capture::install();
    +    let mut answers = vec![no_such_upload()];
    +    answers.extend((0..200).map(|_| s3_error(500, "InternalError", "Please try again.")));
    +    let (key, replay) = finish_under(QUICK, answers).await;
    +    let error = key.unwrap_err();
    +    assert!(
    +        matches!(&error, SinkError::Io(io) if io.to_string() == "S3 report completion check failed"),
    +        "{error:?}"
    +    );
    +    let heads = (0..replay.actual_requests().count())
    +        .filter(|&i| method_and_uri(&replay, i).0 == "HEAD")
    +        .count();
    +    assert!(heads > 2 && heads < 200, "{heads} HeadObject requests");
    +    let errors: Vec<_> = capture
    +        .events()
    +        .into_iter()
    +        .filter(|event| event.level == Level::ERROR && event.message().starts_with("S3 "))
    +        .collect();
    +    assert_eq!(errors.len(), 1, "{errors:?}");
    +    assert_eq!(errors[0].message(), "S3 report completion check failed");
    +    assert_eq!(errors[0].field("s3_code"), Some("InternalError"));
    +
    +    // A 403, the answer without s3:GetObject, still surfaces the
    +    // NoSuchUpload, at once.
    +    let (key, replay) = finish_under(
    +        QUICK,
    +        vec![
    +            no_such_upload(),
    +            with_headers(
    +                HttpResponse::new(403.try_into().unwrap(), SdkBody::empty()),
    +                &[("x-amz-request-id", REQUEST_ID)],
    +            ),
    +        ],
    +    )
    +    .await;
    +    assert_eq!(store_fault(key.unwrap_err()).code, "S3NoSuchUpload");
    +    let count = replay.actual_requests().count();
    +    assert_eq!(method_and_uri(&replay, count - 1).0, "HEAD");
    +}
    diff --git a/crates/engine/src/bulk_put/s3/tests/credentials.rs b/crates/engine/src/bulk_put/s3/tests/credentials.rs
    new file mode 100644
    index 000000000..f66a1a163
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/tests/credentials.rs
    @@ -0,0 +1,112 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! S3 codes about the server's own signing identity, account or clock: the
    +//! server's failure on every call, with S3's code and message at ERROR.
    +
    +use tracing::Level;
    +
    +use super::logs::Capture;
    +use super::read::{LINES, open_with};
    +use super::*;
    +use crate::bulk_put::s3::client::Phase;
    +use crate::bulk_put::s3::error::Tail;
    +use crate::bulk_put::s3::list::check_bucket;
    +use crate::bulk_put::s3::probe::probe;
    +use crate::bulk_put::source::{InputCodec, OutputSse, SinkError, StoreUnavailable};
    +
    +/// Each code with the status S3 answers it with.
    +const SERVER_FAULTS: &[(u16, &str)] = &[
    +    (403, "SignatureDoesNotMatch"),
    +    (403, "InvalidAccessKeyId"),
    +    (400, "ExpiredToken"),
    +    (400, "InvalidToken"),
    +    (400, "TokenRefreshRequired"),
    +    (403, "RequestTimeTooSkewed"),
    +    (400, "CredentialsNotSupported"),
    +    (403, "InvalidSecurity"),
    +    (400, "MissingSecurityHeader"),
    +    (403, "AccountProblem"),
    +    (403, "NotSignedUp"),
    +];
    +
    +/// The server's failure, and not one the runner resumes after.
    +fn is_internal_source(error: &SourceError) -> bool {
    +    matches!(error, SourceError::Io(io)
    +        if io.kind() == std::io::ErrorKind::Other && !StoreUnavailable::of_io(io))
    +}
    +
    +#[tokio::test]
    +async fn the_servers_own_credential_faults_are_the_servers_failure_on_every_call() {
    +    let (capture, _guard) = Capture::install();
    +    for &(status, code) in SERVER_FAULTS {
    +        let text = format!("S3 text for {code}.");
    +        let (reader, _replay) = open_with(
    +            vec![s3_error(status, code, &text)],
    +            16,
    +            LINES.len(),
    +            InputCodec::None,
    +        )
    +        .await;
    +        let error = reader.err().expect("refused");
    +        assert!(is_internal_source(&error), "read {code}: {error:?}");
    +
    +        let (listing, _replay) = clients(vec![s3_error(status, code, &text)]);
    +        let error = check_bucket(&listing, &location("loads", None), "in/")
    +            .await
    +            .unwrap_err();
    +        assert!(is_internal_source(&error), "bucket check {code}: {error:?}");
    +
    +        let (writing, _replay) = clients(vec![s3_error(status, code, &text)]);
    +        let error = probe(
    +            &writing,
    +            &location("reports", None),
    +            "run/AWSDynamoDB/BulkOperation/report-0.json.gz",
    +            &OutputSse::None,
    +            Tail::S3,
    +            Phase::Run,
    +        )
    +        .await
    +        .unwrap_err();
    +        assert!(
    +            matches!(&error, SinkError::Io(io)
    +                if io.kind() == std::io::ErrorKind::Other && !StoreUnavailable::of_io(io)),
    +            "write check {code}: {error:?}"
    +        );
    +
    +        let lines: Vec<_> = capture
    +            .events()
    +            .into_iter()
    +            .filter(|event| event.field("s3_code") == Some(code))
    +            .collect();
    +        assert_eq!(lines.len(), 3, "{code}: {lines:?}");
    +        for line in lines {
    +            assert_eq!(line.level, Level::ERROR, "{code}");
    +            assert_eq!(line.field("s3_message"), Some(text.as_str()), "{code}");
    +            assert!(
    +                line.message()
    +                    .contains("S3 refused the server's own credentials"),
    +                "{code}: {}",
    +                line.message()
    +            );
    +        }
    +    }
    +}
    +
    +#[tokio::test]
    +async fn all_access_disabled_stays_the_callers_fault() {
    +    let text = "All access to this Amazon S3 resource has been disabled.";
    +    let (reader, _replay) = open_with(
    +        vec![s3_error(403, "AllAccessDisabled", text)],
    +        16,
    +        LINES.len(),
    +        InputCodec::None,
    +    )
    +    .await;
    +    let fault = fault(reader.err().expect("refused"));
    +    assert_eq!(fault.code, "S3AllAccessDisabled");
    +    assert_eq!(
    +        fault.message,
    +        format!("{text}{}", amazon_s3_tail(403, "AllAccessDisabled"))
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/s3/tests/deadlines.rs b/crates/engine/src/bulk_put/s3/tests/deadlines.rs
    new file mode 100644
    index 000000000..a73635d92
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/tests/deadlines.rs
    @@ -0,0 +1,120 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The deadlines of the store's clients on the store's calls: a request
    +//! whose answer never comes, and a body that stops after its head, which
    +//! the SDK's stalled-stream protection ends. The connect and read timeouts
    +//! every client is built with are tested with the shared clients
    +//! (`extenddb-s3`).
    +
    +use std::time::{Duration, Instant};
    +
    +use aws_config::SdkConfig;
    +use aws_sdk_s3::config::{Credentials, Region, SharedCredentialsProvider};
    +
    +use super::read::drain;
    +use super::stub::{self, Answer, Stub};
    +use super::*;
    +use crate::bulk_put::s3::client::{clients_from, timeouts};
    +use crate::bulk_put::s3::list::check_bucket;
    +use crate::bulk_put::s3::read::open;
    +use crate::bulk_put::s3::retry::RetryPolicy;
    +use crate::bulk_put::source::InputCodec;
    +use crate::bulk_put::store::S3StoreSettings;
    +
    +/// An SDK configuration as the loader would give it, with static keys,
    +/// built without reading the environment.
    +pub(super) fn sdk_config() -> SdkConfig {
    +    SdkConfig::builder()
    +        .behavior_version(behavior_version())
    +        .region(Region::new("us-east-1"))
    +        .credentials_provider(SharedCredentialsProvider::new(Credentials::new(
    +            "AKIDTEST", "secret", None, None, "test",
    +        )))
    +        .build()
    +}
    +
    +pub(super) fn settings(endpoint_url: Option<&str>) -> S3StoreSettings {
    +    S3StoreSettings {
    +        region: "us-east-1".to_owned(),
    +        endpoint_url: endpoint_url.map(str::to_owned),
    +        force_path_style: endpoint_url.is_some(),
    +    }
    +}
    +
    +#[tokio::test]
    +async fn an_answer_that_never_comes_ends_at_the_read_deadline_after_the_sdks_attempts() {
    +    // The listener accepts every request and never answers. With the read
    +    // deadline at one second, each of the SDK's three attempts ends at it,
    +    // and the call fails as the server's failure (the store's own retries
    +    // are off here; they are tested on their own).
    +    let stub = Stub::start(vec![Answer::Hold, Answer::Hold, Answer::Hold]).await;
    +    let short = timeouts()
    +        .to_builder()
    +        .read_timeout(Duration::from_secs(1))
    +        .build();
    +    let clients = clients_from(&settings(Some(&stub.url)), &sdk_config(), |builder| {
    +        builder.timeout_config(short)
    +    })
    +    .with_retry(RetryPolicy::NONE);
    +    let started = Instant::now();
    +    let checked = tokio::time::timeout(
    +        Duration::from_secs(30),
    +        check_bucket(&clients, &location("loads", None), "in/"),
    +    )
    +    .await
    +    .expect("the call ends within its deadlines");
    +    let elapsed = started.elapsed();
    +    assert!(
    +        matches!(&checked, Err(SourceError::Io(io)) if io.kind() == std::io::ErrorKind::Other),
    +        "{checked:?}"
    +    );
    +    assert_eq!(stub.heads().len(), 3, "the SDK's three attempts");
    +    assert!(
    +        elapsed >= Duration::from_secs(3) && elapsed < Duration::from_secs(15),
    +        "{elapsed:?}"
    +    );
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_body_that_stops_after_its_head_is_ended_by_stalled_stream_protection() {
    +    // The clients are built as the store builds them, so the protection
    +    // in force is the pinned behaviour version's default.
    +    let head = stub::head(
    +        "206 Partial Content",
    +        &[
    +            ("content-length", "40"),
    +            ("content-range", "bytes 0-39/40"),
    +            ("etag", "\"e1\""),
    +        ],
    +    );
    +    let stub = Stub::start(vec![Answer::Stall {
    +        head,
    +        body: b"line-0001\n".to_vec(),
    +    }])
    +    .await;
    +    let clients = clients_from(&settings(Some(&stub.url)), &sdk_config(), |builder| builder)
    +        .with_retry(RetryPolicy::NONE);
    +    let started = Instant::now();
    +    let opened = tokio::time::timeout(
    +        Duration::from_secs(60),
    +        open(
    +            shared(clients),
    +            location("loads", None),
    +            &input_file("in/part-0.json", 40, Some("e1")),
    +            InputCodec::None,
    +        ),
    +    )
    +    .await
    +    .expect("the stalled body is ended");
    +    let elapsed = started.elapsed();
    +    let error = match opened {
    +        Ok(mut reader) => drain(&mut reader).await.1.expect("a fault"),
    +        Err(error) => error,
    +    };
    +    assert!(
    +        matches!(&error, SourceError::Io(io) if io.kind() == std::io::ErrorKind::Other),
    +        "{error:?}"
    +    );
    +    assert!(elapsed < Duration::from_secs(30), "{elapsed:?}");
    +}
    diff --git a/crates/engine/src/bulk_put/s3/tests/dropped.rs b/crates/engine/src/bulk_put/s3/tests/dropped.rs
    new file mode 100644
    index 000000000..370e144f7
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/tests/dropped.rs
    @@ -0,0 +1,195 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! A read the caller drops: the GET in flight is dropped with it and no
    +//! further request is sent for the object, though the ranged reader runs
    +//! on the blocking pool, which a dropped future does not stop.
    +
    +use std::future::Future;
    +use std::time::{Duration, Instant};
    +
    +use aws_sdk_s3::config::timeout::TimeoutConfig;
    +
    +use super::read::{LINES, drain};
    +use super::stub::{self, Answer, Stub, stub_clients};
    +use super::*;
    +use crate::bulk_put::s3::read::open;
    +use crate::bulk_put::s3::retry::RetryPolicy;
    +use crate::bulk_put::source::{InputCodec, LineReader};
    +
    +/// A held GET ends at the read deadline after one second and is sent again
    +/// after a wait of a fifth of a second, so a read that keeps going after
    +/// its drop sends its next request about 1.2 seconds after the hold.
    +const BACKOFF: RetryPolicy = RetryPolicy {
    +    first: Duration::from_millis(200),
    +    max: Duration::from_millis(200),
    +    total: Duration::from_secs(10),
    +};
    +
    +/// How long the tests watch the listener after a drop: past the read
    +/// deadline and more than two waits of [`BACKOFF`].
    +const WATCH: Duration = Duration::from_secs(2);
    +
    +fn timeouts() -> TimeoutConfig {
    +    TimeoutConfig::builder()
    +        .connect_timeout(Duration::from_secs(1))
    +        .read_timeout(Duration::from_secs(1))
    +        .build()
    +}
    +
    +/// The 206 answer for bytes `start..end` of [`LINES`].
    +fn range(start: usize, end: usize) -> Answer {
    +    let body = LINES[start..end].to_vec();
    +    Answer::Send {
    +        head: stub::head(
    +            "206 Partial Content",
    +            &[
    +                ("content-length", &body.len().to_string()),
    +                (
    +                    "content-range",
    +                    &format!("bytes {start}-{}/{}", end - 1, LINES.len()),
    +                ),
    +                ("etag", "\"e1\""),
    +            ],
    +        ),
    +        body,
    +    }
    +}
    +
    +fn holds(count: usize) -> impl Iterator {
    +    (0..count).map(|_| Answer::Hold)
    +}
    +
    +async fn reading(stub: &Stub) -> Arc {
    +    shared(
    +        stub_clients(&stub.url, timeouts())
    +            .with_part_bytes(16)
    +            .with_retry(BACKOFF),
    +    )
    +}
    +
    +/// Run `call` until the listener has received `requests` requests, then
    +/// drop it; a call that answers first fails the test.
    +async fn drop_when_held(stub: &Stub, requests: usize, call: F) {
    +    let held = async {
    +        while stub.heads().len() < requests {
    +            tokio::time::sleep(Duration::from_millis(5)).await;
    +        }
    +    };
    +    tokio::select! {
    +        _ = call => panic!("the call answered while its GET was held"),
    +        held = tokio::time::timeout(Duration::from_secs(5), held) => {
    +            held.expect("the GET reaches the listener");
    +        }
    +    }
    +}
    +
    +/// Wait until the client has closed `count` held connections; the time it
    +/// took.
    +async fn released(stub: &Stub, count: u64) -> Duration {
    +    let started = Instant::now();
    +    while stub.released() < count && started.elapsed() < WATCH {
    +        tokio::time::sleep(Duration::from_millis(5)).await;
    +    }
    +    started.elapsed()
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_line_read_dropped_while_its_range_is_held_sends_no_further_request() {
    +    // The open reads the first 16 bytes; the first line read then asks for
    +    // the second range, which the listener holds.
    +    let answers = std::iter::once(range(0, 16)).chain(holds(8)).collect();
    +    let stub = Stub::start(answers).await;
    +    let mut reader = open(
    +        reading(&stub).await,
    +        location("loads", None),
    +        &input_file("in/part-0.json", LINES.len(), Some("e1")),
    +        InputCodec::None,
    +    )
    +    .await
    +    .unwrap();
    +    drop_when_held(&stub, 2, reader.next_line(1 << 20)).await;
    +    // The reader stays alive until the end, so only the dropped call can
    +    // stop the read: dropping the reader would stop it on its own.
    +    let closed = released(&stub, 1).await;
    +    assert!(
    +        closed < Duration::from_millis(500),
    +        "closed after {closed:?}"
    +    );
    +    tokio::time::sleep(WATCH).await;
    +    assert_eq!(stub.heads().len(), 2, "requests after the drop");
    +    drop(reader);
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_line_read_dropped_while_its_range_is_held_answers_the_next_call_with_a_fault() {
    +    let answers = std::iter::once(range(0, 16)).chain(holds(8)).collect();
    +    let stub = Stub::start(answers).await;
    +    let mut reader = open(
    +        reading(&stub).await,
    +        location("loads", None),
    +        &input_file("in/part-0.json", LINES.len(), Some("e1")),
    +        InputCodec::None,
    +    )
    +    .await
    +    .unwrap();
    +    drop_when_held(&stub, 2, reader.next_line(1 << 20)).await;
    +    // The next call awaits the fill the dropped call started: the line it
    +    // read, then the stop as a fault, never an end of file before the
    +    // object's end.
    +    let (lines, error) = tokio::time::timeout(Duration::from_secs(5), drain(&mut reader))
    +        .await
    +        .expect("the next call answers");
    +    assert_eq!(lines, ["line-0001"]);
    +    assert!(
    +        matches!(error, Some(SourceError::Io(_))),
    +        "the stopped read is a fault: {error:?}"
    +    );
    +    assert_eq!(stub.heads().len(), 2, "requests after the drop");
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn an_open_dropped_while_its_first_range_is_held_sends_no_further_request() {
    +    let stub = Stub::start(holds(8).collect()).await;
    +    let clients = reading(&stub).await;
    +    let file = input_file("in/part-0.json", LINES.len(), Some("e1"));
    +    drop_when_held(
    +        &stub,
    +        1,
    +        open(clients, location("loads", None), &file, InputCodec::None),
    +    )
    +    .await;
    +    let closed = released(&stub, 1).await;
    +    assert!(
    +        closed < Duration::from_millis(500),
    +        "closed after {closed:?}"
    +    );
    +    tokio::time::sleep(WATCH).await;
    +    assert_eq!(stub.heads().len(), 1, "requests after the drop");
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_line_read_that_is_not_dropped_retries_a_held_range_and_completes() {
    +    let answers = vec![
    +        range(0, 16),
    +        Answer::Hold,
    +        range(16, 32),
    +        range(32, LINES.len()),
    +    ];
    +    let stub = Stub::start(answers).await;
    +    let mut reader = open(
    +        reading(&stub).await,
    +        location("loads", None),
    +        &input_file("in/part-0.json", LINES.len(), Some("e1")),
    +        InputCodec::None,
    +    )
    +    .await
    +    .unwrap();
    +    let (lines, error) = tokio::time::timeout(Duration::from_secs(10), drain(&mut reader))
    +        .await
    +        .expect("the read completes");
    +    assert!(error.is_none(), "{error:?}");
    +    assert_eq!(lines, ["line-0001", "line-0002", "line-0003", "line-0004"]);
    +    assert_eq!(stub.heads().len(), 4);
    +    assert_eq!(stub.header(1, "range"), stub.header(2, "range"));
    +}
    diff --git a/crates/engine/src/bulk_put/s3/tests/list.rs b/crates/engine/src/bulk_put/s3/tests/list.rs
    new file mode 100644
    index 000000000..3c151d08a
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/tests/list.rs
    @@ -0,0 +1,241 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The bucket check and the enumeration at Start: a missing bucket, an
    +//! `s3:ListBucket` denial, an owner mismatch, paging, key decoding, and
    +//! the failures that are the server's.
    +
    +use aws_sdk_s3::config::http::HttpResponse;
    +use aws_sdk_s3::primitives::SdkBody;
    +
    +use super::*;
    +use crate::bulk_put::s3::list::{check_bucket, decode_key, list};
    +
    +/// One ListObjectsV2 page. `keys` are (key as sent, size, ETag without
    +/// quotes); `encoded` adds `url`.
    +fn list_page(keys: &[(&str, u64, &str)], next: Option<&str>, encoded: bool) -> HttpResponse {
    +    let mut body = String::from(
    +        "\nloads1000\
    +         ",
    +    );
    +    if encoded {
    +        body.push_str("url");
    +    }
    +    match next {
    +        Some(token) => body.push_str(&format!(
    +            "true{token}"
    +        )),
    +        None => body.push_str("false"),
    +    }
    +    for (key, size, etag) in keys {
    +        body.push_str(&format!(
    +            "{key}"{etag}"{size}\
    +             STANDARD"
    +        ));
    +    }
    +    body.push_str("");
    +    with_headers(
    +        HttpResponse::new(200.try_into().unwrap(), SdkBody::from(body)),
    +        &[
    +            ("x-amz-request-id", REQUEST_ID),
    +            ("x-amz-id-2", EXTENDED_ID),
    +        ],
    +    )
    +}
    +
    +#[tokio::test]
    +async fn the_bucket_check_answers_a_missing_bucket_with_the_amazon_s3_tail() {
    +    let (clients, replay) = clients(vec![s3_error(
    +        404,
    +        "NoSuchBucket",
    +        "The specified bucket does not exist",
    +    )]);
    +    let error = check_bucket(&clients, &location("loads", None), "in/")
    +        .await
    +        .unwrap_err();
    +    let fault = fault(error);
    +    assert_eq!(fault.code, "S3NoSuchBucket");
    +    assert_eq!(
    +        fault.message,
    +        format!(
    +            "The specified bucket does not exist{}",
    +            amazon_s3_tail(404, "NoSuchBucket")
    +        )
    +    );
    +    assert!(!fault.after_first_byte);
    +    let request = uri(&replay, 0);
    +    assert!(request.contains("list-type=2"), "{request}");
    +    assert!(request.contains("max-keys=1"), "{request}");
    +    assert!(request.contains("prefix=in%2F"), "{request}");
    +    assert!(request.contains("encoding-type=url"), "{request}");
    +    assert_eq!(header(&replay, 0, "x-amz-expected-bucket-owner"), None);
    +}
    +
    +#[tokio::test]
    +async fn the_bucket_check_answers_a_list_bucket_denial_with_s3s_text() {
    +    let denial = "User: arn:aws:sts::111122223333:assumed-role/extenddb/server is not \
    +                  authorized to perform: s3:ListBucket on resource: \"arn:aws:s3:::loads\" \
    +                  with an explicit deny in a resource-based policy";
    +    let (clients, _replay) = clients(vec![s3_error(403, "AccessDenied", denial)]);
    +    let fault = fault(
    +        check_bucket(&clients, &location("loads", None), "in/")
    +            .await
    +            .unwrap_err(),
    +    );
    +    assert_eq!(fault.code, "S3AccessDenied");
    +    assert_eq!(
    +        fault.message,
    +        format!("{denial}{}", amazon_s3_tail(403, "AccessDenied"))
    +    );
    +    assert!(!fault.after_first_byte);
    +}
    +
    +#[tokio::test]
    +async fn an_expected_owner_travels_as_the_header_and_s3_decides() {
    +    let (clients, replay) = clients(vec![s3_error(403, "AccessDenied", "Access Denied")]);
    +    let fault = fault(
    +        check_bucket(&clients, &location("loads", Some("444455556666")), "")
    +            .await
    +            .unwrap_err(),
    +    );
    +    assert_eq!(fault.code, "S3AccessDenied");
    +    assert_eq!(
    +        fault.message,
    +        format!("Access Denied{}", amazon_s3_tail(403, "AccessDenied"))
    +    );
    +    assert_eq!(
    +        header(&replay, 0, "x-amz-expected-bucket-owner").as_deref(),
    +        Some("444455556666")
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_present_bucket_passes_the_check() {
    +    let (clients, _replay) = clients(vec![list_page(&[], None, true)]);
    +    check_bucket(&clients, &location("loads", None), "in/")
    +        .await
    +        .unwrap();
    +}
    +
    +#[tokio::test]
    +async fn the_listing_decodes_filters_sorts_and_unquotes_across_pages() {
    +    let (clients, replay) = clients(vec![
    +        list_page(
    +            &[
    +                ("in%2Fz.json", 30, "etag-z"),
    +                ("in%2F%2Fdouble+slash.json", 20, "etag-d"),
    +                ("out%2Fnot-under-the-prefix.json", 5, "etag-o"),
    +            ],
    +            Some("token-2"),
    +            true,
    +        ),
    +        list_page(
    +            &[
    +                (
    +                    "in%2Fa%2Bplus.json",
    +                    10,
    +                    "0123456789abcdef0123456789abcdef-3",
    +                ),
    +                ("in%2Fdir%2F", 0, "d41d8cd98f00b204e9800998ecf8427e"),
    +            ],
    +            None,
    +            true,
    +        ),
    +    ]);
    +    let files = list(&clients, &location("loads", None), "in/", 100)
    +        .await
    +        .unwrap();
    +    let listed: Vec<(&str, u64, Option<&str>)> = files
    +        .iter()
    +        .map(|f| (f.path.as_str(), f.size, f.etag.as_deref()))
    +        .collect();
    +    assert_eq!(
    +        listed,
    +        vec![
    +            ("in//double slash.json", 20, Some("etag-d")),
    +            (
    +                "in/a+plus.json",
    +                10,
    +                Some("0123456789abcdef0123456789abcdef-3")
    +            ),
    +            ("in/dir/", 0, Some("d41d8cd98f00b204e9800998ecf8427e")),
    +            ("in/z.json", 30, Some("etag-z")),
    +        ]
    +    );
    +    assert!(uri(&replay, 0).contains("max-keys=1000"));
    +    assert!(!uri(&replay, 0).contains("continuation-token"));
    +    assert!(uri(&replay, 1).contains("continuation-token=token-2"));
    +}
    +
    +#[tokio::test]
    +async fn a_listing_without_url_encoding_keeps_keys_as_given() {
    +    let (clients, _replay) = clients(vec![list_page(&[("in/a%2Fb+c", 1, "e")], None, false)]);
    +    let files = list(&clients, &location("loads", None), "in/", 10)
    +        .await
    +        .unwrap();
    +    assert_eq!(files.len(), 1);
    +    assert_eq!(files[0].path, "in/a%2Fb+c");
    +}
    +
    +#[tokio::test]
    +async fn the_listing_stops_one_object_past_the_cap() {
    +    let keys: Vec<(String, u64, &str)> = (0..5).map(|i| (format!("k{i}"), 1, "e")).collect();
    +    let page: Vec<(&str, u64, &str)> = keys.iter().map(|(k, s, e)| (k.as_str(), *s, *e)).collect();
    +    let (clients, replay) = clients(vec![list_page(&page, Some("more"), true)]);
    +    let files = list(&clients, &location("loads", None), "", 2)
    +        .await
    +        .unwrap();
    +    assert_eq!(files.len(), 3);
    +    assert_eq!(replay.actual_requests().count(), 1, "no page past the cap");
    +}
    +
    +#[tokio::test]
    +async fn a_fault_on_a_later_page_is_rendered_for_that_page() {
    +    let (clients, _replay) = clients(vec![
    +        list_page(&[("in%2Fa", 1, "e")], Some("token-2"), true),
    +        s3_error(404, "NoSuchBucket", "The specified bucket does not exist"),
    +    ]);
    +    let fault = fault(
    +        list(&clients, &location("loads", None), "in/", 10)
    +            .await
    +            .unwrap_err(),
    +    );
    +    assert_eq!(fault.code, "S3NoSuchBucket");
    +    assert_eq!(
    +        fault.message,
    +        format!(
    +            "The specified bucket does not exist{}",
    +            amazon_s3_tail(404, "NoSuchBucket")
    +        )
    +    );
    +}
    +
    +#[tokio::test]
    +async fn server_errors_and_unknown_codes_on_the_listing_are_internal() {
    +    for response in [
    +        s3_error(500, "InternalError", "We encountered an internal error."),
    +        s3_error(503, "SlowDown", "Please reduce your request rate."),
    +        s3_error(400, "RequestTimeout", "Your socket connection timed out."),
    +        s3_error(400, "SomeFutureCode", "Something new."),
    +    ] {
    +        let (clients, _replay) = clients(vec![response]);
    +        let error = list(&clients, &location("loads", None), "", 10)
    +            .await
    +            .unwrap_err();
    +        assert!(
    +            matches!(&error, SourceError::Io(io) if io.kind() == std::io::ErrorKind::Other),
    +            "{error:?}"
    +        );
    +    }
    +}
    +
    +#[test]
    +fn keys_decode_escapes_and_plus_and_refuse_malformed_escapes() {
    +    assert_eq!(decode_key("a%2Fb+c%2B").as_deref(), Some("a/b c+"));
    +    assert_eq!(decode_key("%E2%82%AC").as_deref(), Some("\u{20ac}"));
    +    assert_eq!(decode_key("plain").as_deref(), Some("plain"));
    +    for malformed in ["%", "%2", "%G1", "%+1", "%ff", "a%2"] {
    +        assert_eq!(decode_key(malformed), None, "{malformed}");
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/s3/tests/logs.rs b/crates/engine/src/bulk_put/s3/tests/logs.rs
    new file mode 100644
    index 000000000..56f20655f
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/tests/logs.rs
    @@ -0,0 +1,89 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! A subscriber that keeps the events logged on the current thread, for
    +//! the tests that assert a log line's level and fields.
    +
    +use std::sync::{Arc, Mutex};
    +
    +use tracing::field::{Field, Visit};
    +use tracing::span::{Attributes, Id, Record};
    +use tracing::{Event, Level, Metadata, Subscriber};
    +
    +/// One logged event: its level and its fields, `message` included.
    +#[derive(Clone, Debug)]
    +pub(super) struct Logged {
    +    pub(super) level: Level,
    +    fields: Vec<(String, String)>,
    +}
    +
    +impl Logged {
    +    pub(super) fn field(&self, name: &str) -> Option<&str> {
    +        self.fields
    +            .iter()
    +            .find(|(field, _)| field == name)
    +            .map(|(_, value)| value.as_str())
    +    }
    +
    +    pub(super) fn message(&self) -> &str {
    +        self.field("message").unwrap_or_default()
    +    }
    +}
    +
    +/// The events logged while the guard [`Capture::install`] returns lives.
    +#[derive(Clone, Default)]
    +pub(super) struct Capture(Arc>>);
    +
    +impl Capture {
    +    /// Install on the current thread until the guard drops. A test that
    +    /// uses it runs on a current-thread runtime, so the store's calls log
    +    /// on this thread.
    +    pub(super) fn install() -> (Self, tracing::dispatcher::DefaultGuard) {
    +        let capture = Self::default();
    +        let guard = tracing::dispatcher::set_default(&tracing::Dispatch::new(capture.clone()));
    +        (capture, guard)
    +    }
    +
    +    pub(super) fn events(&self) -> Vec {
    +        self.0.lock().unwrap().clone()
    +    }
    +}
    +
    +struct Fields<'a>(&'a mut Vec<(String, String)>);
    +
    +impl Visit for Fields<'_> {
    +    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
    +        self.0.push((field.name().to_owned(), format!("{value:?}")));
    +    }
    +
    +    fn record_str(&mut self, field: &Field, value: &str) {
    +        self.0.push((field.name().to_owned(), value.to_owned()));
    +    }
    +}
    +
    +impl Subscriber for Capture {
    +    fn enabled(&self, _: &Metadata<'_>) -> bool {
    +        true
    +    }
    +
    +    fn new_span(&self, _: &Attributes<'_>) -> Id {
    +        Id::from_u64(1)
    +    }
    +
    +    fn record(&self, _: &Id, _: &Record<'_>) {}
    +
    +    fn record_follows_from(&self, _: &Id, _: &Id) {}
    +
    +    fn event(&self, event: &Event<'_>) {
    +        let mut fields = Vec::new();
    +        event.record(&mut Fields(&mut fields));
    +        self.0.lock().unwrap().push(Logged {
    +            level: *event.metadata().level(),
    +            fields,
    +        });
    +    }
    +
    +    fn enter(&self, _: &Id) {}
    +
    +    fn exit(&self, _: &Id) {}
    +}
    diff --git a/crates/engine/src/bulk_put/s3/tests/mod.rs b/crates/engine/src/bulk_put/s3/tests/mod.rs
    new file mode 100644
    index 000000000..7c2e0abba
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/tests/mod.rs
    @@ -0,0 +1,155 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Fault tests for the S3 store over the SDK's replay HTTP client: each
    +//! canned S3 response (status, request ids, S3's XML error body) goes
    +//! through the store's own call, and the tests assert the `FailureCode`,
    +//! the exact `FailureMessage` with its tail, and `after_first_byte`.
    +
    +mod checksums;
    +mod child;
    +mod complete;
    +mod credentials;
    +mod deadlines;
    +mod dropped;
    +mod list;
    +mod logs;
    +mod read;
    +mod redirect;
    +mod replaced;
    +pub(crate) mod smoke;
    +mod start_deadline;
    +mod startup;
    +mod stub;
    +mod transient;
    +mod write;
    +
    +use std::sync::Arc;
    +
    +use aws_sdk_s3::config::http::{HttpRequest, HttpResponse};
    +use aws_sdk_s3::config::retry::RetryConfig;
    +use aws_sdk_s3::config::{Credentials, Region};
    +use aws_sdk_s3::primitives::SdkBody;
    +use aws_smithy_http_client::test_util::{ReplayEvent, StaticReplayClient};
    +
    +use extenddb_storage::InputFile;
    +
    +use super::client::behavior_version;
    +use super::retry::RetryPolicy;
    +use super::{Location, S3Clients};
    +use crate::bulk_put::source::{SourceError, StoreFault};
    +
    +pub(super) const REQUEST_ID: &str = "4KQS8G3V7XJ2M1PA";
    +pub(super) const EXTENDED_ID: &str = "pQ8x1T0z+7mY/2abcdEF3ghIJ4klMN5opQR6stUV7wxYZ8=";
    +
    +/// A store's clients over a replay client answering `responses` in order,
    +/// region us-east-1, virtual-hosted addressing, redirects followed, no
    +/// retries by the SDK or by the store.
    +pub(super) fn clients(responses: Vec) -> (S3Clients, StaticReplayClient) {
    +    let replay = StaticReplayClient::new(
    +        responses
    +            .into_iter()
    +            .map(|response| ReplayEvent::new(HttpRequest::new(SdkBody::empty()), response))
    +            .collect(),
    +    );
    +    let config = aws_sdk_s3::Config::builder()
    +        .behavior_version(behavior_version())
    +        .region(Region::new("us-east-1"))
    +        .credentials_provider(Credentials::new("AKIDTEST", "secret", None, None, "test"))
    +        .http_client(replay.clone())
    +        .retry_config(RetryConfig::disabled())
    +        .build();
    +    (
    +        S3Clients::new(config, true, false).with_retry(RetryPolicy::NONE),
    +        replay,
    +    )
    +}
    +
    +pub(super) fn with_headers(mut response: HttpResponse, headers: &[(&str, &str)]) -> HttpResponse {
    +    for (name, value) in headers {
    +        response
    +            .headers_mut()
    +            .insert((*name).to_owned(), (*value).to_owned());
    +    }
    +    response
    +}
    +
    +/// An S3 error response with its XML body and both request ids.
    +pub(super) fn s3_error(status: u16, code: &str, message: &str) -> HttpResponse {
    +    let body = format!(
    +        "\n{code}\
    +         {message}{REQUEST_ID}\
    +         {EXTENDED_ID}"
    +    );
    +    with_headers(
    +        HttpResponse::new(status.try_into().unwrap(), SdkBody::from(body)),
    +        &[
    +            ("content-type", "application/xml"),
    +            ("x-amz-request-id", REQUEST_ID),
    +            ("x-amz-id-2", EXTENDED_ID),
    +        ],
    +    )
    +}
    +
    +/// The tail the service renders on input reads, the Start-time list and
    +/// the manifest write.
    +pub(super) fn amazon_s3_tail(status: u16, code: &str) -> String {
    +    format!(
    +        " (Service: Amazon S3; Status Code: {status}; Error Code: {code}; Request ID: \
    +         {REQUEST_ID}; S3 Extended Request ID: {EXTENDED_ID}; Proxy: null)"
    +    )
    +}
    +
    +/// The tail the service renders on report writes.
    +pub(super) fn s3_tail(status: u16) -> String {
    +    format!(
    +        " (Service: S3, Status Code: {status}, Request ID: {REQUEST_ID}, Extended Request ID: \
    +         {EXTENDED_ID}) (SDK Attempt Count: 1)"
    +    )
    +}
    +
    +pub(super) fn location(bucket: &str, owner: Option<&str>) -> Location {
    +    Location {
    +        account: "000000000001".to_owned(),
    +        bucket: bucket.to_owned(),
    +        owner: owner.map(str::to_owned),
    +    }
    +}
    +
    +pub(super) fn input_file(path: &str, size: usize, etag: Option<&str>) -> InputFile {
    +    InputFile {
    +        path: path.to_owned(),
    +        size: i64::try_from(size).unwrap(),
    +        rows: None,
    +        etag: etag.map(str::to_owned),
    +        abandon: None,
    +    }
    +}
    +
    +/// The fault a source error carries, or a panic naming the error.
    +pub(super) fn fault(error: SourceError) -> StoreFault {
    +    match error {
    +        SourceError::Store(fault) => fault,
    +        other => panic!("expected a store fault, got {other:?}"),
    +    }
    +}
    +
    +/// The value of one request header the replay client recorded.
    +pub(super) fn header(replay: &StaticReplayClient, index: usize, name: &str) -> Option {
    +    replay
    +        .actual_requests()
    +        .nth(index)
    +        .and_then(|request| request.headers().get(name).map(str::to_owned))
    +}
    +
    +pub(super) fn uri(replay: &StaticReplayClient, index: usize) -> String {
    +    replay
    +        .actual_requests()
    +        .nth(index)
    +        .map(|request| request.uri().to_owned())
    +        .unwrap_or_default()
    +}
    +
    +pub(super) fn shared(clients: S3Clients) -> Arc {
    +    Arc::new(clients)
    +}
    diff --git a/crates/engine/src/bulk_put/s3/tests/read.rs b/crates/engine/src/bulk_put/s3/tests/read.rs
    new file mode 100644
    index 000000000..aeffdcb29
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/tests/read.rs
    @@ -0,0 +1,570 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Reading input objects with GetObject: the ranged reads, `If-Match`,
    +//! the read's `ETag`, and the fault rendering before and after the first
    +//! byte.
    +
    +use std::io::Write;
    +
    +use aws_sdk_s3::config::http::HttpResponse;
    +use aws_sdk_s3::primitives::SdkBody;
    +
    +use super::stub::{self, Answer, Stub, stub_clients};
    +use super::*;
    +use crate::bulk_put::s3::read::{S3LineReader, etag, line_count, open};
    +use crate::bulk_put::source::{InputCodec, LineReader};
    +
    +pub(super) const LINES: &[u8] = b"line-0001\nline-0002\nline-0003\nline-0004\n";
    +
    +/// A 206 answer for bytes `start..start + body.len()` of an object of
    +/// `total` bytes.
    +pub(super) fn part(body: &[u8], start: usize, total: usize, etag: &str) -> HttpResponse {
    +    let end = start + body.len() - 1;
    +    with_headers(
    +        HttpResponse::new(206.try_into().unwrap(), SdkBody::from(body.to_vec())),
    +        &[
    +            ("content-length", &body.len().to_string()),
    +            ("content-range", &format!("bytes {start}-{end}/{total}")),
    +            ("etag", &format!("\"{etag}\"")),
    +            ("x-amz-request-id", REQUEST_ID),
    +        ],
    +    )
    +}
    +
    +/// The responses for reading `object` whole in `chunk`-byte ranges.
    +pub(super) fn parts(object: &[u8], chunk: usize, etag: &str) -> Vec {
    +    object
    +        .chunks(chunk)
    +        .enumerate()
    +        .map(|(i, body)| part(body, i * chunk, object.len(), etag))
    +        .collect()
    +}
    +
    +pub(super) async fn open_with(
    +    responses: Vec,
    +    part_bytes: u64,
    +    size: usize,
    +    codec: InputCodec,
    +) -> (Result, StaticReplayClient) {
    +    let (clients, replay) = clients(responses);
    +    let reader = open(
    +        shared(clients.with_part_bytes(part_bytes)),
    +        location("loads", None),
    +        &input_file("in/part-0.json", size, Some("listed-etag")),
    +        codec,
    +    )
    +    .await;
    +    (reader, replay)
    +}
    +
    +/// Every line until the end or the first fault.
    +pub(super) async fn drain(reader: &mut S3LineReader) -> (Vec, Option) {
    +    let mut lines = Vec::new();
    +    loop {
    +        match reader.next_line(1 << 20).await {
    +            Ok(Some(line)) => lines.push(line),
    +            Ok(None) => return (lines, None),
    +            Err(error) => return (lines, Some(error)),
    +        }
    +    }
    +}
    +
    +fn gzip(content: &[u8]) -> Vec {
    +    let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
    +    encoder.write_all(content).unwrap();
    +    encoder.finish().unwrap()
    +}
    +
    +fn many_lines() -> Vec {
    +    (0..400)
    +        .map(|i| format!("{{\"Item\":{{\"pk\":{{\"S\":\"row-{i:05}\"}}}}}}\n"))
    +        .collect::()
    +        .into_bytes()
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn an_object_is_read_in_ranges_with_the_listed_etag_as_if_match() {
    +    // The answers carry an ETag of their own: If-Match still names the
    +    // listed one, and the manifest records the read's.
    +    let (reader, replay) = open_with(
    +        parts(LINES, 16, "etag-of-read"),
    +        16,
    +        LINES.len(),
    +        InputCodec::None,
    +    )
    +    .await;
    +    let mut reader = reader.unwrap();
    +    let (lines, fault) = drain(&mut reader).await;
    +    assert!(fault.is_none(), "{fault:?}");
    +    assert_eq!(lines, ["line-0001", "line-0002", "line-0003", "line-0004"]);
    +    assert_eq!(reader.bytes_consumed(), 40);
    +    // The manifest records the ETag of the read for a file read to its end.
    +    assert_eq!(reader.etag().as_deref(), Some("etag-of-read"));
    +    let ranges: Vec> = (0..3).map(|i| header(&replay, i, "range")).collect();
    +    assert_eq!(
    +        ranges,
    +        [
    +            Some("bytes=0-15".to_owned()),
    +            Some("bytes=16-31".to_owned()),
    +            Some("bytes=32-39".to_owned())
    +        ]
    +    );
    +    for i in 0..3 {
    +        assert_eq!(
    +            header(&replay, i, "if-match").as_deref(),
    +            Some("\"listed-etag\""),
    +            "request {i}"
    +        );
    +    }
    +    assert_eq!(replay.actual_requests().count(), 3);
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn faults_on_the_first_range_are_rendered_before_the_first_byte() {
    +    let kms_disabled = "arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab \
    +                        is disabled.";
    +    let get_denied = "User: arn:aws:sts::111122223333:assumed-role/extenddb/server is not \
    +                      authorized to perform: s3:GetObject on resource: \
    +                      \"arn:aws:s3:::loads/in/part-0.json\" with an explicit deny in a \
    +                      resource-based policy";
    +    let decrypt_denied = "User: arn:aws:sts::111122223333:assumed-role/extenddb/server is not \
    +                          authorized to perform: kms:Decrypt on this resource because the \
    +                          resource does not exist in this Region, no resource-based policies \
    +                          allow access, or a resource-based policy explicitly denies access";
    +    let cases = [
    +        (403, "AccessDenied", get_denied, "S3AccessDenied"),
    +        (
    +            404,
    +            "NoSuchKey",
    +            "The specified key does not exist.",
    +            "S3NoSuchKey",
    +        ),
    +        (
    +            400,
    +            "KMS.DisabledException",
    +            kms_disabled,
    +            "KmsDisabledException",
    +        ),
    +        (403, "AccessDenied", decrypt_denied, "S3AccessDenied"),
    +        (
    +            400,
    +            "KMS.KMSInvalidStateException",
    +            "The key is pending deletion.",
    +            "KmsInvalidStateException",
    +        ),
    +        (
    +            400,
    +            "KMS.NotFoundException",
    +            "Invalid keyId 'alias/gone'",
    +            "KmsNotFoundException",
    +        ),
    +        (
    +            403,
    +            "InvalidObjectState",
    +            "The operation is not valid for the object's storage class",
    +            "S3InvalidObjectState",
    +        ),
    +    ];
    +    for (status, code, message, expected) in cases {
    +        let (reader, _replay) = open_with(
    +            vec![s3_error(status, code, message)],
    +            16,
    +            LINES.len(),
    +            InputCodec::None,
    +        )
    +        .await;
    +        let fault = fault(reader.err().expect(code));
    +        assert_eq!(fault.code, expected, "{code}");
    +        assert_eq!(
    +            fault.message,
    +            format!("{message}{}", amazon_s3_tail(status, code))
    +        );
    +        assert!(!fault.after_first_byte, "{code}");
    +    }
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_fault_on_a_later_range_is_after_the_first_byte() {
    +    for (status, code, message, expected) in [
    +        (403, "AccessDenied", "Access Denied", "S3AccessDenied"),
    +        (
    +            404,
    +            "NoSuchKey",
    +            "The specified key does not exist.",
    +            "S3NoSuchKey",
    +        ),
    +    ] {
    +        let mut responses = parts(LINES, 16, "e1");
    +        responses.truncate(1);
    +        responses.push(s3_error(status, code, message));
    +        let (reader, _replay) = open_with(responses, 16, LINES.len(), InputCodec::None).await;
    +        let mut reader = reader.unwrap();
    +        let (lines, error) = drain(&mut reader).await;
    +        assert_eq!(lines, ["line-0001"], "{code}");
    +        let fault = fault(error.expect(code));
    +        assert_eq!(fault.code, expected);
    +        assert_eq!(
    +            fault.message,
    +            format!("{message}{}", amazon_s3_tail(status, code))
    +        );
    +        assert!(fault.after_first_byte, "{code}");
    +        assert_eq!(reader.etag(), None, "an abandoned read reports no ETag");
    +    }
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_mid_object_fault_under_gzip_and_zstd_keeps_its_code() {
    +    let content = many_lines();
    +    let compressed = [
    +        (InputCodec::Gzip, gzip(&content)),
    +        (
    +            InputCodec::Zstd,
    +            ruzstd::encoding::compress_to_vec(
    +                content.as_slice(),
    +                ruzstd::encoding::CompressionLevel::Fastest,
    +            ),
    +        ),
    +    ];
    +    for (codec, bytes) in compressed {
    +        let mut responses = parts(&bytes, 64, "e1");
    +        responses.truncate(1);
    +        responses.push(s3_error(403, "AccessDenied", "Access Denied"));
    +        let (reader, _replay) = open_with(responses, 64, bytes.len(), codec).await;
    +        let (_, error) = drain(&mut reader.unwrap()).await;
    +        let fault = fault(error.expect("a fault"));
    +        assert_eq!(fault.code, "S3AccessDenied", "{codec:?}");
    +        assert!(fault.after_first_byte, "{codec:?}");
    +    }
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_short_body_is_internal_and_never_a_corrupt_stream() {
    +    // A body shorter than its Content-Length on the first range.
    +    let mut short = part(&LINES[..16], 0, LINES.len(), "e1");
    +    *short.body_mut() = SdkBody::from(LINES[..10].to_vec());
    +    let (reader, _replay) = open_with(vec![short], 16, LINES.len(), InputCodec::None).await;
    +    let error = reader.err().expect("refused");
    +    assert!(
    +        matches!(&error, SourceError::Io(io) if io.kind() == std::io::ErrorKind::Other),
    +        "{error:?}"
    +    );
    +
    +    // A whole-object answer (no Content-Range) whose body ends before its
    +    // Content-Length.
    +    let whole = with_headers(
    +        HttpResponse::new(200.try_into().unwrap(), SdkBody::from(LINES[..10].to_vec())),
    +        &[("content-length", "40"), ("etag", "\"e1\"")],
    +    );
    +    let (reader, _replay) = open_with(vec![whole], 64, LINES.len(), InputCodec::None).await;
    +    let error = reader.err().expect("refused");
    +    assert!(
    +        matches!(&error, SourceError::Io(io) if io.kind() == std::io::ErrorKind::Other),
    +        "{error:?}"
    +    );
    +
    +    // Under GZIP, a later range that ends before the range asked for while
    +    // the object goes on.
    +    let bytes = gzip(&many_lines());
    +    let mut responses = parts(&bytes, 64, "e1");
    +    responses.truncate(1);
    +    let cut = with_headers(
    +        HttpResponse::new(
    +            206.try_into().unwrap(),
    +            SdkBody::from(bytes[64..100].to_vec()),
    +        ),
    +        &[
    +            ("content-range", &format!("bytes 64-99/{}", bytes.len())),
    +            ("etag", "\"e1\""),
    +        ],
    +    );
    +    responses.push(cut);
    +    let (reader, _replay) = open_with(responses, 64, bytes.len(), InputCodec::Gzip).await;
    +    let (_, error) = drain(&mut reader.unwrap()).await;
    +    let error = error.expect("a fault");
    +    assert!(
    +        matches!(&error, SourceError::Io(io) if io.kind() == std::io::ErrorKind::Other),
    +        "{error:?}"
    +    );
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn an_object_shorter_than_enumerated_ends_early() {
    +    // Row 11: a range past the object's end is end of file.
    +    let (reader, _replay) = open_with(
    +        vec![s3_error(
    +            416,
    +            "InvalidRange",
    +            "The requested range is not satisfiable",
    +        )],
    +        16,
    +        LINES.len(),
    +        InputCodec::None,
    +    )
    +    .await;
    +    let mut reader = reader.unwrap();
    +    let (lines, error) = drain(&mut reader).await;
    +    assert!(lines.is_empty() && error.is_none(), "{lines:?} {error:?}");
    +    assert_eq!(reader.etag().as_deref(), Some("listed-etag"));
    +
    +    // An object that shrank answers fewer bytes with its new total.
    +    let (reader, replay) = open_with(
    +        vec![part(&LINES[..20], 0, 20, "e2")],
    +        64,
    +        LINES.len(),
    +        InputCodec::None,
    +    )
    +    .await;
    +    let mut reader = reader.unwrap();
    +    let (lines, error) = drain(&mut reader).await;
    +    assert!(error.is_none(), "{error:?}");
    +    assert_eq!(lines, ["line-0001", "line-0002"]);
    +    assert_eq!(replay.actual_requests().count(), 1);
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn server_and_unknown_errors_on_a_read_are_internal() {
    +    for response in [
    +        s3_error(500, "InternalError", "We encountered an internal error."),
    +        s3_error(503, "SlowDown", "Please reduce your request rate."),
    +        s3_error(400, "SomeFutureCode", "Something new."),
    +    ] {
    +        let (reader, _replay) = open_with(vec![response], 16, LINES.len(), InputCodec::None).await;
    +        let error = reader.err().expect("refused");
    +        assert!(
    +            matches!(&error, SourceError::Io(io) if io.kind() == std::io::ErrorKind::Other),
    +            "{error:?}"
    +        );
    +    }
    +    // The replay client has no answer: a dispatch failure.
    +    let (reader, _replay) = open_with(Vec::new(), 16, LINES.len(), InputCodec::None).await;
    +    assert!(matches!(reader.err(), Some(SourceError::Io(_))));
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn the_expected_owner_travels_on_every_range_and_zero_byte_objects_read_one_byte() {
    +    let (clients, replay) = clients(parts(LINES, 16, "e1"));
    +    let mut reader = open(
    +        shared(clients.with_part_bytes(16)),
    +        location("loads", Some("444455556666")),
    +        &input_file("in/part-0.json", LINES.len(), None),
    +        InputCodec::None,
    +    )
    +    .await
    +    .unwrap();
    +    drain(&mut reader).await;
    +    for i in 0..3 {
    +        assert_eq!(
    +            header(&replay, i, "x-amz-expected-bucket-owner").as_deref(),
    +            Some("444455556666")
    +        );
    +    }
    +
    +    // An endpoint that ignores Range answers an empty object whole.
    +    let empty = with_headers(
    +        HttpResponse::new(200.try_into().unwrap(), SdkBody::empty()),
    +        &[
    +            ("content-length", "0"),
    +            ("etag", "\"d41d8cd98f00b204e9800998ecf8427e\""),
    +        ],
    +    );
    +    let (reader, replay) = open_with(vec![empty], 16, 0, InputCodec::None).await;
    +    let mut reader = reader.unwrap();
    +    let (lines, error) = drain(&mut reader).await;
    +    assert!(lines.is_empty() && error.is_none());
    +    assert_eq!(header(&replay, 0, "range").as_deref(), Some("bytes=0-0"));
    +    assert_eq!(
    +        reader.etag().as_deref(),
    +        Some("d41d8cd98f00b204e9800998ecf8427e")
    +    );
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_zero_size_record_is_read_as_one_byte_whatever_the_object_holds() {
    +    let grown = vec![b'x'; 1 << 20];
    +    let total = grown.len().to_string();
    +    // S3's 416 for an empty object is end of file.
    +    let (reader, replay) = open_with(
    +        vec![s3_error(
    +            416,
    +            "InvalidRange",
    +            "The requested range is not satisfiable",
    +        )],
    +        16,
    +        0,
    +        InputCodec::None,
    +    )
    +    .await;
    +    let (lines, error) = drain(&mut reader.unwrap()).await;
    +    assert!(lines.is_empty() && error.is_none(), "{lines:?} {error:?}");
    +    assert_eq!(header(&replay, 0, "range").as_deref(), Some("bytes=0-0"));
    +
    +    // The object grew after it was listed and the answer is longer than
    +    // the byte asked for: one byte is kept, and the read stays empty.
    +    let longer = with_headers(
    +        HttpResponse::new(206.try_into().unwrap(), SdkBody::from(grown.clone())),
    +        &[
    +            ("content-length", total.as_str()),
    +            ("content-range", &format!("bytes 0-0/{total}")),
    +            ("etag", "\"e2\""),
    +        ],
    +    );
    +    let (reader, replay) = open_with(vec![longer], 16, 0, InputCodec::None).await;
    +    let mut reader = reader.unwrap();
    +    let (lines, error) = drain(&mut reader).await;
    +    assert!(lines.is_empty() && error.is_none(), "{lines:?} {error:?}");
    +    assert_eq!(reader.bytes_consumed(), 0);
    +    assert_eq!(header(&replay, 0, "range").as_deref(), Some("bytes=0-0"));
    +    assert_eq!(replay.actual_requests().count(), 1);
    +
    +    // The same object served whole by an endpoint that ignores Range is
    +    // the endpoint's fault.
    +    let whole = with_headers(
    +        HttpResponse::new(200.try_into().unwrap(), SdkBody::from(grown)),
    +        &[("content-length", total.as_str()), ("etag", "\"e2\"")],
    +    );
    +    let (reader, replay) = open_with(vec![whole], 16, 0, InputCodec::None).await;
    +    let error = reader.err().expect("refused");
    +    assert!(
    +        matches!(&error, SourceError::Io(io) if io.kind() == std::io::ErrorKind::Other),
    +        "{error:?}"
    +    );
    +    assert_eq!(header(&replay, 0, "range").as_deref(), Some("bytes=0-0"));
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_whole_object_answer_longer_than_the_range_is_the_endpoints_fault() {
    +    // The first range asks for 16 bytes of a 40-byte object; an endpoint
    +    // that ignores Range answers all 40 without Content-Range.
    +    let whole = with_headers(
    +        HttpResponse::new(200.try_into().unwrap(), SdkBody::from(LINES.to_vec())),
    +        &[("content-length", "40"), ("etag", "\"e1\"")],
    +    );
    +    let (reader, replay) = open_with(vec![whole], 16, LINES.len(), InputCodec::None).await;
    +    let error = reader.err().expect("refused");
    +    assert!(
    +        matches!(&error, SourceError::Io(io) if io.kind() == std::io::ErrorKind::Other),
    +        "{error:?}"
    +    );
    +    assert_eq!(header(&replay, 0, "range").as_deref(), Some("bytes=0-15"));
    +
    +    // A whole-object answer that fits in the range is the object.
    +    let fits = with_headers(
    +        HttpResponse::new(200.try_into().unwrap(), SdkBody::from(LINES.to_vec())),
    +        &[("content-length", "40"), ("etag", "\"e1\"")],
    +    );
    +    let (reader, _replay) = open_with(vec![fits], 64, LINES.len(), InputCodec::None).await;
    +    let (lines, error) = drain(&mut reader.unwrap()).await;
    +    assert!(error.is_none(), "{error:?}");
    +    assert_eq!(lines.len(), 4);
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_body_longer_than_the_range_is_read_no_further_than_one_byte_past_it() {
    +    // Over a real connection: the zero-size record's GET is answered with
    +    // 64 MiB and no Content-Range. The read takes two bytes, refuses the
    +    // answer and drops the connection, so the listener can write no more
    +    // than the socket buffers hold.
    +    let total: u64 = 64 << 20;
    +    let stub = Stub::start(vec![Answer::Flood {
    +        head: stub::head(
    +            "200 OK",
    +            &[("content-length", &total.to_string()), ("etag", "\"e2\"")],
    +        ),
    +        total,
    +    }])
    +    .await;
    +    let clients = shared(stub_clients(&stub.url, stub::short_timeouts()));
    +    let opened = open(
    +        clients,
    +        location("loads", None),
    +        &input_file("in/empty.json", 0, None),
    +        InputCodec::None,
    +    )
    +    .await;
    +    assert!(
    +        matches!(&opened, Err(SourceError::Io(io)) if io.kind() == std::io::ErrorKind::Other),
    +        "{:?}",
    +        opened.err()
    +    );
    +    tokio::time::sleep(std::time::Duration::from_millis(200)).await;
    +    assert_eq!(stub.header(0, "range").as_deref(), Some("bytes=0-0"));
    +    assert!(
    +        stub.flooded() < 16 << 20,
    +        "{} bytes were sent before the read stopped",
    +        stub.flooded()
    +    );
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn line_count_reads_the_object_and_etag_prefers_the_listed_tag() {
    +    let (counting, _replay) = clients(parts(LINES, 16, "e1"));
    +    let count = line_count(
    +        shared(counting.with_part_bytes(16)),
    +        location("loads", None),
    +        &input_file("in/part-0.json", LINES.len(), None),
    +        InputCodec::None,
    +    )
    +    .await
    +    .unwrap();
    +    assert_eq!(count, 4);
    +
    +    let (unused, replay) = clients(Vec::new());
    +    let listed = etag(
    +        &shared(unused),
    +        &location("loads", None),
    +        &input_file("in/part-0.json", 40, Some("listed")),
    +    )
    +    .await
    +    .unwrap();
    +    assert_eq!(listed, "listed");
    +    assert_eq!(replay.actual_requests().count(), 0);
    +
    +    let (probing, replay) = clients(vec![part(&LINES[..1], 0, 40, "probed")]);
    +    let probed = etag(
    +        &shared(probing),
    +        &location("loads", None),
    +        &input_file("in/part-0.json", 40, None),
    +    )
    +    .await
    +    .unwrap();
    +    assert_eq!(probed, "probed");
    +    assert_eq!(header(&replay, 0, "range").as_deref(), Some("bytes=0-0"));
    +
    +    // A record listed at size 0 is probed with the same one-byte range.
    +    let (probing, replay) = clients(vec![s3_error(
    +        416,
    +        "InvalidRange",
    +        "The requested range is not satisfiable",
    +    )]);
    +    let probed = etag(
    +        &shared(probing),
    +        &location("loads", None),
    +        &input_file("in/empty.json", 0, None),
    +    )
    +    .await
    +    .unwrap();
    +    assert_eq!(probed, "");
    +    assert_eq!(header(&replay, 0, "range").as_deref(), Some("bytes=0-0"));
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn the_etag_probe_takes_the_tag_of_a_whole_object_answer() {
    +    // A service that ignores Range answers the probe with the whole object
    +    // and no Content-Range; only the tag is used.
    +    let whole = with_headers(
    +        HttpResponse::new(200.try_into().unwrap(), SdkBody::from(LINES.to_vec())),
    +        &[("content-length", "40"), ("etag", "\"whole\"")],
    +    );
    +    let (probing, replay) = clients(vec![whole]);
    +    let probed = etag(
    +        &shared(probing),
    +        &location("loads", None),
    +        &input_file("in/part-0.json", 40, None),
    +    )
    +    .await
    +    .unwrap();
    +    assert_eq!(probed, "whole");
    +    assert_eq!(replay.actual_requests().count(), 1);
    +    assert_eq!(header(&replay, 0, "range").as_deref(), Some("bytes=0-0"));
    +}
    diff --git a/crates/engine/src/bulk_put/s3/tests/redirect.rs b/crates/engine/src/bulk_put/s3/tests/redirect.rs
    new file mode 100644
    index 000000000..bdd6ae6cc
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/tests/redirect.rs
    @@ -0,0 +1,73 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! A redirect the store does not follow is the caller's fault, rendered
    +//! with S3's code and message: against a configured endpoint, and on AWS
    +//! S3 once one redirect was followed (the redirects themselves are tested
    +//! with the shared clients, `extenddb-s3`).
    +
    +use aws_sdk_s3::config::retry::RetryConfig;
    +use aws_sdk_s3::config::{Credentials, Region};
    +use aws_sdk_s3::primitives::SdkBody;
    +use aws_smithy_http_client::test_util::{ReplayEvent, StaticReplayClient};
    +
    +use super::*;
    +use crate::bulk_put::s3::list::check_bucket;
    +
    +fn redirect(region: &str) -> HttpResponse {
    +    with_headers(
    +        s3_error(301, "PermanentRedirect", "The bucket is in another region."),
    +        &[("x-amz-bucket-region", region)],
    +    )
    +}
    +
    +#[tokio::test]
    +async fn a_redirect_against_a_configured_endpoint_is_rendered_for_the_caller() {
    +    let replay = StaticReplayClient::new(vec![ReplayEvent::new(
    +        HttpRequest::new(SdkBody::empty()),
    +        redirect("us-west-2"),
    +    )]);
    +    let config = aws_sdk_s3::Config::builder()
    +        .behavior_version(behavior_version())
    +        .region(Region::new("us-east-1"))
    +        .endpoint_url("http://127.0.0.1:9")
    +        .force_path_style(true)
    +        .credentials_provider(Credentials::new("AKIDTEST", "secret", None, None, "test"))
    +        .http_client(replay.clone())
    +        .retry_config(RetryConfig::disabled())
    +        .build();
    +    let endpoint_clients = S3Clients::new(config, false, true).with_retry(RetryPolicy::NONE);
    +    let refused = fault(
    +        check_bucket(&endpoint_clients, &location("loads", None), "")
    +            .await
    +            .unwrap_err(),
    +    );
    +    assert_eq!(refused.code, "S3PermanentRedirect");
    +    assert_eq!(
    +        refused.message,
    +        format!(
    +            "The bucket is in another region.{}",
    +            amazon_s3_tail(301, "PermanentRedirect")
    +        )
    +    );
    +    assert_eq!(replay.actual_requests().count(), 1);
    +}
    +
    +#[tokio::test]
    +async fn a_second_redirect_on_aws_s3_is_rendered_for_the_caller() {
    +    let (aws, replay) = clients(vec![redirect("us-west-2"), redirect("eu-west-1")]);
    +    let again = fault(
    +        check_bucket(&aws, &location("loads", None), "")
    +            .await
    +            .unwrap_err(),
    +    );
    +    assert_eq!(again.code, "S3PermanentRedirect");
    +    assert_eq!(
    +        again.message,
    +        format!(
    +            "The bucket is in another region.{}",
    +            amazon_s3_tail(301, "PermanentRedirect")
    +        )
    +    );
    +    assert_eq!(replay.actual_requests().count(), 2);
    +}
    diff --git a/crates/engine/src/bulk_put/s3/tests/replaced.rs b/crates/engine/src/bulk_put/s3/tests/replaced.rs
    new file mode 100644
    index 000000000..793f2ead2
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/tests/replaced.rs
    @@ -0,0 +1,203 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! `If-Match` with the listed `ETag` on every GetObject, and a replaced
    +//! object, answered 412, reported as overwritten wherever the read found
    +//! it.
    +
    +use aws_sdk_s3::config::http::HttpResponse;
    +use aws_sdk_s3::primitives::SdkBody;
    +
    +use super::read::{LINES, drain, open_with, part, parts};
    +use super::*;
    +use crate::bulk_put::s3::read::open;
    +use crate::bulk_put::source::{InputCodec, LineReader};
    +
    +const MOVED: &str = "At least one of the pre-conditions you specified did not hold";
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_resumed_open_sends_the_listed_etag_on_its_first_range() {
    +    // The first claim opens the object and reads one range; the resumed
    +    // claim opens it again. Both first ranges carry the listed ETag.
    +    let mut responses = parts(LINES, 16, "listed-etag");
    +    responses.truncate(1);
    +    responses.extend(parts(LINES, 16, "listed-etag"));
    +    let (clients, replay) = clients(responses);
    +    let clients = shared(clients.with_part_bytes(16));
    +    let file = input_file("in/part-0.json", LINES.len(), Some("listed-etag"));
    +    let first = open(
    +        Arc::clone(&clients),
    +        location("loads", None),
    +        &file,
    +        InputCodec::None,
    +    )
    +    .await
    +    .unwrap();
    +    drop(first);
    +    let mut resumed = open(clients, location("loads", None), &file, InputCodec::None)
    +        .await
    +        .unwrap();
    +    let (lines, error) = drain(&mut resumed).await;
    +    assert!(error.is_none(), "{error:?}");
    +    assert_eq!(lines.len(), 4);
    +    assert_eq!(replay.actual_requests().count(), 4);
    +    for i in 0..4 {
    +        assert_eq!(
    +            header(&replay, i, "if-match").as_deref(),
    +            Some("\"listed-etag\""),
    +            "request {i}"
    +        );
    +    }
    +    assert_eq!(header(&replay, 1, "range").as_deref(), Some("bytes=0-15"));
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_record_listed_without_an_etag_matches_later_ranges_to_the_first_answer() {
    +    let (clients, replay) = clients(parts(LINES, 16, "first-answer"));
    +    let mut reader = open(
    +        shared(clients.with_part_bytes(16)),
    +        location("loads", None),
    +        &input_file("in/part-0.json", LINES.len(), None),
    +        InputCodec::None,
    +    )
    +    .await
    +    .unwrap();
    +    let (lines, error) = drain(&mut reader).await;
    +    assert!(error.is_none(), "{error:?}");
    +    assert_eq!(lines.len(), 4);
    +    assert_eq!(header(&replay, 0, "if-match"), None);
    +    for i in 1..3 {
    +        assert_eq!(
    +            header(&replay, i, "if-match").as_deref(),
    +            Some("\"first-answer\""),
    +            "request {i}"
    +        );
    +    }
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn an_open_without_a_listed_etag_reports_its_first_answers_tag() {
    +    let (clients, _replay) = clients(parts(LINES, 16, "first-answer"));
    +    let reader = open(
    +        shared(clients.with_part_bytes(16)),
    +        location("loads", None),
    +        &input_file("in/part-0.json", LINES.len(), None),
    +        InputCodec::None,
    +    )
    +    .await
    +    .unwrap();
    +    assert_eq!(reader.opened_etag().as_deref(), Some("first-answer"));
    +    assert_eq!(
    +        reader.etag(),
    +        None,
    +        "no read tag before the end of the object"
    +    );
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_replaced_object_at_its_first_range_is_overwritten() {
    +    let (reader, replay) = open_with(
    +        vec![s3_error(412, "PreconditionFailed", MOVED)],
    +        16,
    +        LINES.len(),
    +        InputCodec::None,
    +    )
    +    .await;
    +    assert!(
    +        matches!(reader, Err(SourceError::Overwritten)),
    +        "{:?}",
    +        reader.err()
    +    );
    +    assert_eq!(
    +        header(&replay, 0, "if-match").as_deref(),
    +        Some("\"listed-etag\"")
    +    );
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_replaced_object_at_a_later_range_is_overwritten_after_the_lines_it_delivered() {
    +    let mut responses = vec![part(&LINES[..16], 0, LINES.len(), "listed-etag")];
    +    responses.push(s3_error(412, "PreconditionFailed", MOVED));
    +    let (reader, replay) = open_with(responses, 16, LINES.len(), InputCodec::None).await;
    +    let mut reader = reader.unwrap();
    +    let (lines, error) = drain(&mut reader).await;
    +    assert_eq!(lines, ["line-0001"]);
    +    assert!(matches!(error, Some(SourceError::Overwritten)), "{error:?}");
    +    assert_eq!(
    +        header(&replay, 1, "if-match").as_deref(),
    +        Some("\"listed-etag\"")
    +    );
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_record_listed_without_an_etag_and_answered_at_another_size_is_overwritten() {
    +    // Listed at 0 bytes and replaced by a 400 MiB object, whose first byte
    +    // answers the one-byte range; and listed at 40 bytes, answered from a
    +    // 49-byte object.
    +    for (listed, first, answered) in [
    +        (0, &LINES[..1], 419_430_400),
    +        (LINES.len(), &LINES[..16], LINES.len() + 9),
    +    ] {
    +        let (clients, replay) = clients(vec![part(first, 0, answered, "new-object")]);
    +        let reader = open(
    +            shared(clients.with_part_bytes(16)),
    +            location("loads", None),
    +            &input_file("in/part-0.json", listed, None),
    +            InputCodec::None,
    +        )
    +        .await;
    +        assert!(
    +            matches!(reader, Err(SourceError::Overwritten)),
    +            "listed {listed}: {:?}",
    +            reader.err()
    +        );
    +        assert_eq!(replay.actual_requests().count(), 1, "listed {listed}");
    +    }
    +}
    +
    +/// A 200 answer with the whole of `body` and no `Content-Range`, as from a
    +/// service that ignores `Range`.
    +fn whole(body: &[u8], etag: &str) -> HttpResponse {
    +    with_headers(
    +        HttpResponse::new(200.try_into().unwrap(), SdkBody::from(body.to_vec())),
    +        &[
    +            ("content-length", &body.len().to_string()),
    +            ("etag", &format!("\"{etag}\"")),
    +            ("x-amz-request-id", REQUEST_ID),
    +        ],
    +    )
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_record_listed_without_an_etag_and_answered_whole_at_another_size_is_overwritten() {
    +    // Listed at 40 bytes, answered with a 20-byte object and no
    +    // Content-Range: the answer's length is the object's size.
    +    let (replaced, replay) = clients(vec![whole(&LINES[..20], "new-object")]);
    +    let reader = open(
    +        shared(replaced.with_part_bytes(64)),
    +        location("loads", None),
    +        &input_file("in/part-0.json", LINES.len(), None),
    +        InputCodec::None,
    +    )
    +    .await;
    +    assert!(
    +        matches!(reader, Err(SourceError::Overwritten)),
    +        "{:?}",
    +        reader.err()
    +    );
    +    assert_eq!(replay.actual_requests().count(), 1);
    +
    +    // The listed size, answered the same way, is read.
    +    let (same, _) = clients(vec![whole(LINES, "same-object")]);
    +    let mut reader = open(
    +        shared(same.with_part_bytes(64)),
    +        location("loads", None),
    +        &input_file("in/part-0.json", LINES.len(), None),
    +        InputCodec::None,
    +    )
    +    .await
    +    .unwrap();
    +    let (lines, error) = drain(&mut reader).await;
    +    assert!(error.is_none(), "{error:?}");
    +    assert_eq!(lines, ["line-0001", "line-0002", "line-0003", "line-0004"]);
    +}
    diff --git a/crates/engine/src/bulk_put/s3/tests/smoke.rs b/crates/engine/src/bulk_put/s3/tests/smoke.rs
    new file mode 100644
    index 000000000..de7d834f2
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/tests/smoke.rs
    @@ -0,0 +1,253 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! A smoke run of the S3 store against an S3-compatible service, gated on
    +//! `EXTENDDB_TEST_S3_SMOKE_ENDPOINT` (for example `http://127.0.0.1:7070`).
    +//! Credentials come from the standard AWS credential chain, as the server
    +//! takes them. Without the variable the test prints a skip line and
    +//! passes. It creates two buckets named after the process, reads an input
    +//! object through the source, writes a report in two parts plus a second
    +//! rotated object, a manifest, and a writer dropped without `finish`, and
    +//! reads every object back.
    +
    +use std::io::Read;
    +
    +use aws_sdk_s3::primitives::ByteStream;
    +use serde_json::json;
    +
    +use extenddb_storage::ReportKind;
    +
    +use super::*;
    +use crate::bulk_put::s3::{S3Sink, S3Source, connect};
    +use crate::bulk_put::source::{BucketRef, BulkSink, BulkSource, InputCodec, OutputSse};
    +use crate::bulk_put::store::S3StoreSettings;
    +
    +const ACCOUNT: &str = "000000000001";
    +
    +async fn get(clients: &S3Clients, bucket: &str, key: &str) -> Vec {
    +    clients
    +        .call(bucket, |client| {
    +            client.get_object().bucket(bucket).key(key).send()
    +        })
    +        .await
    +        .expect("object present")
    +        .body
    +        .collect()
    +        .await
    +        .unwrap()
    +        .to_vec()
    +}
    +
    +pub(crate) fn gunzip(bytes: &[u8]) -> String {
    +    let mut text = String::new();
    +    flate2::read::MultiGzDecoder::new(bytes)
    +        .read_to_string(&mut text)
    +        .unwrap();
    +    text
    +}
    +
    +/// Report records whose gzip stream is close to their size, so 9 MiB of
    +/// them fill more than one 8 MiB part.
    +pub(crate) fn noisy_records(count: usize, seed: u64) -> Vec {
    +    let mut state = seed;
    +    (0..count)
    +        .map(|i| {
    +            let noise: String = (0..16)
    +                .map(|_| {
    +                    state = state
    +                        .wrapping_mul(6_364_136_223_846_793_005)
    +                        .wrapping_add(1_442_695_040_888_963_407);
    +                    format!("{state:016x}")
    +                })
    +                .collect();
    +            json!({"itemLocation": {"itemIndex": i}, "errorMessages": [noise]})
    +        })
    +        .collect()
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn the_s3_store_reads_and_writes_against_an_s3_compatible_service() {
    +    let Ok(endpoint) = std::env::var("EXTENDDB_TEST_S3_SMOKE_ENDPOINT") else {
    +        println!("SKIP: EXTENDDB_TEST_S3_SMOKE_ENDPOINT is not set");
    +        return;
    +    };
    +    let settings = S3StoreSettings {
    +        region: "us-east-1".to_owned(),
    +        endpoint_url: Some(endpoint),
    +        force_path_style: true,
    +    };
    +    let clients = Arc::new(connect(&settings).await);
    +    let input = format!("smoke-in-{}", std::process::id());
    +    let output = format!("smoke-out-{}", std::process::id());
    +    for bucket in [&input, &output] {
    +        clients
    +            .call(bucket, |client| {
    +                client.create_bucket().bucket(bucket.as_str()).send()
    +            })
    +            .await
    +            .expect("bucket created");
    +    }
    +    let lines = b"{\"Item\":{\"pk\":{\"S\":\"a\"}}}\n{\"Item\":{\"pk\":{\"S\":\"b\"}}}\n";
    +    for key in ["in/part-0.json", "in/a b+c.json", "other/x.json"] {
    +        clients
    +            .call(&input, |client| {
    +                client
    +                    .put_object()
    +                    .bucket(&input)
    +                    .key(key)
    +                    .body(ByteStream::from(lines.to_vec()))
    +                    .send()
    +            })
    +            .await
    +            .expect("input written");
    +    }
    +
    +    // The source: the bucket check, the listing, a read to the end.
    +    let source = S3Source {
    +        clients: Arc::clone(&clients),
    +    };
    +    let at = BucketRef::new(ACCOUNT, &input, None);
    +    source.check_bucket(&at, "in/").await.unwrap();
    +    let files = source.list(&at, "in/", 10).await.unwrap();
    +    let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
    +    // The posix backend of the service the smoke run uses stores keys as
    +    // paths, so a doubled `/` is not part of this run; the replay tests
    +    // cover it.
    +    assert_eq!(paths, ["in/a b+c.json", "in/part-0.json"]);
    +    let listed = files[1].etag.clone().expect("listed ETag");
    +    let file = input_file(&files[1].path, lines.len(), Some(&listed));
    +    let mut reader = source.open(&at, &file, InputCodec::None).await.unwrap();
    +    let mut read = Vec::new();
    +    while let Some(line) = reader.next_line(1 << 20).await.unwrap() {
    +        read.push(line);
    +    }
    +    assert_eq!(read.len(), 2);
    +    assert_eq!(reader.etag().as_deref(), Some(listed.as_str()));
    +    assert_eq!(
    +        source
    +            .line_count(&at, &file, InputCodec::None)
    +            .await
    +            .unwrap(),
    +        2
    +    );
    +    let missing = BucketRef::new(ACCOUNT, "smoke-no-such-bucket", None);
    +    let error = source.check_bucket(&missing, "").await.unwrap_err();
    +    let fault = fault(error);
    +    assert_eq!(fault.code, "S3NoSuchBucket");
    +    assert!(
    +        fault
    +            .message
    +            .contains("(Service: Amazon S3; Status Code: 404; Error Code: NoSuchBucket"),
    +        "{}",
    +        fault.message
    +    );
    +    let gone = input_file("in/not-there.json", 10, None);
    +    let fault = fault_of_open(source.open(&at, &gone, InputCodec::None).await);
    +    assert_eq!(fault.code, "S3NoSuchKey");
    +    assert!(!fault.after_first_byte);
    +
    +    // The sink: the write check, a report of two parts and a rotated
    +    // second object, the manifest.
    +    let sink = S3Sink {
    +        clients: Arc::clone(&clients),
    +    };
    +    let out = BucketRef::new(ACCOUNT, &output, None);
    +    let operation = "01792940454300-c5f2ec39";
    +    let first_key =
    +        crate::bulk_put::report::report_key("reports", ACCOUNT, operation, ReportKind::Error, 0);
    +    sink.check_location(&out, &first_key, &OutputSse::None)
    +        .await
    +        .unwrap();
    +    let mut writer = sink
    +        .open(
    +            &out,
    +            "reports",
    +            operation,
    +            ReportKind::Error,
    +            "lease",
    +            &OutputSse::None,
    +        )
    +        .await
    +        .unwrap();
    +    writer.set_rotation_threshold(9 * 1024 * 1024);
    +    let mut written = 0;
    +    while writer.open_object_bytes() < 9 * 1024 * 1024 && written < 200_000 {
    +        writer
    +            .write_page(noisy_records(2_000, written as u64 + 1))
    +            .await
    +            .unwrap();
    +        written += 2_000;
    +        if writer.open_object_bytes() == 0 {
    +            break;
    +        }
    +    }
    +    writer.write_page(noisy_records(10, 7)).await.unwrap();
    +    assert_eq!(writer.finish().await.unwrap(), first_key);
    +    let second_key =
    +        crate::bulk_put::report::report_key("reports", ACCOUNT, operation, ReportKind::Error, 1);
    +    let first = gunzip(&get(&clients, &output, &first_key).await);
    +    let second = gunzip(&get(&clients, &output, &second_key).await);
    +    assert_eq!(first.lines().count(), written);
    +    assert_eq!(second.lines().count(), 10);
    +    let manifest_lines = vec![
    +        json!({"inputSourceArn": "arn:aws:s3:::in/part-0.json", "fileProcessingStatus": "FULLY_PROCESSED", "processedRanges": [[0, 1]], "etag": listed}),
    +    ];
    +    let manifest_key = sink
    +        .write_manifest(
    +            &out,
    +            "reports",
    +            operation,
    +            "lease",
    +            &OutputSse::None,
    +            manifest_lines,
    +        )
    +        .await
    +        .unwrap();
    +    let manifest = String::from_utf8(get(&clients, &output, &manifest_key).await).unwrap();
    +    assert_eq!(manifest.lines().count(), 1);
    +
    +    // A writer dropped without finish leaves no upload behind.
    +    let mut dropped = sink
    +        .open(
    +            &out,
    +            "dropped",
    +            operation,
    +            ReportKind::Error,
    +            "lease",
    +            &OutputSse::None,
    +        )
    +        .await
    +        .unwrap();
    +    for seed in 0..30 {
    +        dropped
    +            .write_page(noisy_records(2_000, 100 + seed))
    +            .await
    +            .unwrap();
    +    }
    +    drop(dropped);
    +    let mut left = usize::MAX;
    +    for _ in 0..100 {
    +        let uploads = clients
    +            .call(&output, |client| {
    +                client.list_multipart_uploads().bucket(&output).send()
    +            })
    +            .await
    +            .unwrap();
    +        left = uploads.uploads().len();
    +        if left == 0 {
    +            break;
    +        }
    +        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
    +    }
    +    assert_eq!(left, 0, "the dropped writer's upload was aborted");
    +    println!("smoke: {written} report records in two objects, input read, faults rendered");
    +}
    +
    +fn fault_of_open(
    +    opened: Result, SourceError>,
    +) -> StoreFault {
    +    match opened {
    +        Ok(_) => panic!("the open must fail"),
    +        Err(error) => fault(error),
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/s3/tests/start_deadline.rs b/crates/engine/src/bulk_put/s3/tests/start_deadline.rs
    new file mode 100644
    index 000000000..b7e4f2c61
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/tests/start_deadline.rs
    @@ -0,0 +1,164 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The Start deadline: each S3 call a Start makes (the bucket check, every
    +//! page of the listing, the encryption check and its abort) ends within
    +//! it, its retries included, and then fails as the server's. The calls the
    +//! runner makes are bound by the retry total alone.
    +
    +use std::time::{Duration, Instant};
    +
    +use super::stub::{self, Answer, Stub, stub_clients};
    +use super::*;
    +use crate::bulk_put::s3::client::{Phase, START_DEADLINE, timeouts};
    +use crate::bulk_put::s3::list::{check_bucket, list};
    +use crate::bulk_put::s3::probe::probe;
    +use crate::bulk_put::s3::{S3Sink, error::Tail};
    +use crate::bulk_put::source::{BucketRef, BulkSink, OutputSse, SinkError};
    +
    +/// The deadline the tests use in place of [`START_DEADLINE`].
    +const SHORT: Duration = Duration::from_millis(400);
    +
    +/// How long a test waits before it fails: past [`SHORT`], well inside the
    +/// read deadline of the clients below, so a call that ignores the Start
    +/// deadline fails the test instead of holding it.
    +const PATIENCE: Duration = Duration::from_secs(5);
    +
    +/// A store's clients against `stub`, with the store's read deadline (60 s)
    +/// and the Start deadline at [`SHORT`].
    +fn held_clients(stub: &Stub) -> S3Clients {
    +    stub_clients(&stub.url, timeouts()).with_start_deadline(SHORT)
    +}
    +
    +fn kms() -> OutputSse {
    +    OutputSse::Kms {
    +        key_id: Some("arn:aws:kms:us-east-1:000000000001:key/k1".to_owned()),
    +    }
    +}
    +
    +fn is_internal_sink(error: &SinkError) -> bool {
    +    matches!(error, SinkError::Io(io) if io.kind() == std::io::ErrorKind::Other)
    +}
    +
    +#[test]
    +fn the_start_deadline_is_ten_seconds() {
    +    assert_eq!(START_DEADLINE, Duration::from_secs(10));
    +}
    +
    +#[tokio::test]
    +async fn a_bucket_check_with_no_answer_ends_at_the_start_deadline() {
    +    let stub = Stub::start(vec![Answer::Hold]).await;
    +    let clients = held_clients(&stub);
    +    let started = Instant::now();
    +    let checked = tokio::time::timeout(
    +        PATIENCE,
    +        check_bucket(&clients, &location("loads", None), "in/"),
    +    )
    +    .await
    +    .expect("the bucket check ends at the Start deadline");
    +    assert!(
    +        matches!(&checked, Err(SourceError::Io(io)) if io.kind() == std::io::ErrorKind::Other),
    +        "{checked:?}"
    +    );
    +    assert!(started.elapsed() >= SHORT, "{:?}", started.elapsed());
    +    assert_eq!(stub.heads().len(), 1);
    +}
    +
    +#[tokio::test]
    +async fn a_listing_page_with_no_answer_ends_at_the_start_deadline() {
    +    // The first page answers with a continuation token; the second never
    +    // answers. Each page is one call under the deadline.
    +    let first = "\
    +        loadsin/1\
    +        1000urltrue\
    +        t2\
    +        in/a.json3"e1"\
    +        ";
    +    let head = stub::head(
    +        "200 OK",
    +        &[
    +            ("content-type", "application/xml"),
    +            ("content-length", &first.len().to_string()),
    +        ],
    +    );
    +    let stub = Stub::start(vec![
    +        Answer::Send {
    +            head,
    +            body: first.as_bytes().to_vec(),
    +        },
    +        Answer::Hold,
    +    ])
    +    .await;
    +    let clients = held_clients(&stub);
    +    let listed = tokio::time::timeout(
    +        PATIENCE,
    +        list(&clients, &location("loads", None), "in/", 100),
    +    )
    +    .await
    +    .expect("the listing ends at the Start deadline");
    +    assert!(
    +        matches!(&listed, Err(SourceError::Io(io)) if io.kind() == std::io::ErrorKind::Other),
    +        "{listed:?}"
    +    );
    +    assert_eq!(stub.heads().len(), 2, "both pages were asked for");
    +}
    +
    +#[tokio::test]
    +async fn an_encryption_check_with_no_answer_ends_at_the_start_deadline() {
    +    let stub = Stub::start(vec![Answer::Hold]).await;
    +    let sink = S3Sink {
    +        clients: shared(held_clients(&stub)),
    +    };
    +    let at = BucketRef {
    +        account: "000000000001",
    +        bucket: "reports",
    +        expected_owner: None,
    +    };
    +    let checked = tokio::time::timeout(
    +        PATIENCE,
    +        sink.check_encryption(
    +            &at,
    +            "run/AWSDynamoDB/BulkOperation/k/output/errors/0.json.gz",
    +            &kms(),
    +        ),
    +    )
    +    .await
    +    .expect("the encryption check ends at the Start deadline");
    +    assert!(
    +        matches!(&checked, Err(error) if is_internal_sink(error)),
    +        "{checked:?}"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_call_the_runner_makes_is_not_bound_by_the_start_deadline() {
    +    // The write check at the first report record waits past the Start
    +    // deadline for its answer, here the read deadline of one second.
    +    let stub = Stub::start(vec![Answer::Hold]).await;
    +    let short_read = timeouts()
    +        .to_builder()
    +        .read_timeout(Duration::from_secs(1))
    +        .build();
    +    let clients =
    +        stub_clients(&stub.url, short_read).with_start_deadline(Duration::from_millis(100));
    +    let started = Instant::now();
    +    let checked = tokio::time::timeout(
    +        PATIENCE,
    +        probe(
    +            &clients,
    +            &location("reports", None),
    +            "run/AWSDynamoDB/BulkOperation/k/output/errors/0.json.gz",
    +            &OutputSse::None,
    +            Tail::S3,
    +            Phase::Run,
    +        ),
    +    )
    +    .await
    +    .expect("the write check ends at the read deadline");
    +    let elapsed = started.elapsed();
    +    assert!(
    +        matches!(&checked, Err(error) if is_internal_sink(error)),
    +        "{checked:?}"
    +    );
    +    assert!(elapsed >= Duration::from_secs(1), "{elapsed:?}");
    +}
    diff --git a/crates/engine/src/bulk_put/s3/tests/startup.rs b/crates/engine/src/bulk_put/s3/tests/startup.rs
    new file mode 100644
    index 000000000..1e1146f5e
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/tests/startup.rs
    @@ -0,0 +1,101 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The startup credential check and calls made without credentials:
    +//! credentials that cannot be resolved are a warning that says which later
    +//! change needs a restart, and a call made without them is answered at
    +//! once, not retried. Each test runs in a child process of this test
    +//! binary (`child::run`) with no keys, an empty config file and instance
    +//! metadata off, so the chain fails without sending a request.
    +
    +use std::time::Duration;
    +
    +use aws_smithy_http_client::test_util::StaticReplayClient;
    +use tracing::Level;
    +
    +use super::child;
    +use super::deadlines::settings;
    +use super::logs::Capture;
    +use super::*;
    +use crate::bulk_put::s3::client::{CREDENTIALS_LATER, connect, connect_with};
    +use crate::bulk_put::s3::list::check_bucket;
    +
    +#[test]
    +fn credentials_missing_at_startup_are_a_warning_that_says_a_file_change_needs_a_restart() {
    +    if child::is_child() {
    +        child::block_on(warning());
    +        return;
    +    }
    +    child::run(
    +        module_path!(),
    +        "credentials_missing_at_startup_are_a_warning_that_says_a_file_change_needs_a_restart",
    +        "no credentials",
    +        &[],
    +        "",
    +    );
    +}
    +
    +async fn warning() {
    +    let (capture, _guard) = Capture::install();
    +    let _clients = connect(&settings(None)).await;
    +    let warnings: Vec<_> = capture
    +        .events()
    +        .into_iter()
    +        .filter(|event| event.level == Level::WARN && event.message().starts_with("S3 bulk store"))
    +        .collect();
    +    assert_eq!(warnings.len(), 1, "{warnings:?}");
    +    assert_eq!(
    +        warnings[0].message(),
    +        format!(
    +            "S3 bulk store credentials cannot be resolved; Start answers InternalServerError \
    +             until they resolve. {CREDENTIALS_LATER}"
    +        )
    +    );
    +    assert!(
    +        warnings[0]
    +            .message()
    +            .contains("so a change to them needs a restart"),
    +        "{warnings:?}"
    +    );
    +}
    +
    +#[test]
    +fn a_call_without_credentials_is_the_servers_failure_at_once() {
    +    if child::is_child() {
    +        child::block_on(call_without_credentials());
    +        return;
    +    }
    +    child::run(
    +        module_path!(),
    +        "a_call_without_credentials_is_the_servers_failure_at_once",
    +        "no credentials",
    +        &[],
    +        "",
    +    );
    +}
    +
    +/// The SDK reports credentials it cannot resolve as a dispatch failure of
    +/// no kind. A call that took it for a transient failure would still be
    +/// retrying under the store's 120-second bound when the ten seconds below
    +/// run out.
    +async fn call_without_credentials() {
    +    let replay = StaticReplayClient::new(Vec::new());
    +    let http = replay.clone();
    +    let clients = connect_with(&settings(None), move |builder| builder.http_client(http)).await;
    +    let error = tokio::time::timeout(
    +        Duration::from_secs(10),
    +        check_bucket(&clients, &location("loads", None), "in/"),
    +    )
    +    .await
    +    .expect("answered within ten seconds")
    +    .unwrap_err();
    +    assert!(
    +        matches!(&error, SourceError::Io(io) if io.kind() == std::io::ErrorKind::Other),
    +        "{error:?}"
    +    );
    +    assert_eq!(
    +        replay.actual_requests().count(),
    +        0,
    +        "no request is sent without credentials"
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/s3/tests/stub.rs b/crates/engine/src/bulk_put/s3/tests/stub.rs
    new file mode 100644
    index 000000000..f3176d7c7
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/tests/stub.rs
    @@ -0,0 +1,218 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! A scripted HTTP listener on 127.0.0.1 for the tests that need a real
    +//! connection under the SDK's own HTTP client: an answer that never comes,
    +//! a body that stops after its head, a body far longer than the range
    +//! asked for. Each scripted answer serves one connection, and every
    +//! answer closes its connection, so each request the client sends arrives
    +//! on a connection of its own; once the script is spent the listener is
    +//! closed and a further connection is refused.
    +
    +use std::sync::atomic::{AtomicU64, Ordering};
    +use std::sync::{Arc, Mutex};
    +use std::time::Duration;
    +
    +use aws_sdk_s3::config::retry::RetryConfig;
    +use aws_sdk_s3::config::timeout::TimeoutConfig;
    +use aws_sdk_s3::config::{Credentials, Region};
    +use tokio::io::{AsyncReadExt, AsyncWriteExt};
    +use tokio::net::{TcpListener, TcpStream};
    +
    +use super::super::S3Clients;
    +use super::super::client::behavior_version;
    +use super::super::retry::RetryPolicy;
    +
    +/// What the listener does with one connection, after reading the request
    +/// head.
    +pub(super) enum Answer {
    +    /// Send the head and the body, then close the connection; a body
    +    /// shorter than the head's `content-length` is a body cut mid-stream.
    +    Send { head: String, body: Vec },
    +    /// Read the request and close the connection without an answer.
    +    Close,
    +    /// Send the head and the start of the body, then send nothing more
    +    /// while holding the connection open.
    +    Stall { head: String, body: Vec },
    +    /// Read the request and never answer.
    +    Hold,
    +    /// Send the head, then write body bytes until `total` or until the
    +    /// client stops reading; the bytes written are counted.
    +    Flood { head: String, total: u64 },
    +}
    +
    +/// A response head with `connection: close` and the given headers.
    +pub(super) fn head(status: &str, headers: &[(&str, &str)]) -> String {
    +    let mut head = format!("HTTP/1.1 {status}\r\nconnection: close\r\n");
    +    for (name, value) in headers {
    +        head.push_str(&format!("{name}: {value}\r\n"));
    +    }
    +    head.push_str("\r\n");
    +    head
    +}
    +
    +/// The listener and what it saw.
    +pub(super) struct Stub {
    +    pub(super) url: String,
    +    heads: Arc>>,
    +    flooded: Arc,
    +    released: Arc,
    +    task: tokio::task::JoinHandle<()>,
    +}
    +
    +impl Stub {
    +    pub(super) async fn start(answers: Vec) -> Self {
    +        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    +        let url = format!("http://{}", listener.local_addr().unwrap());
    +        let heads = Arc::new(Mutex::new(Vec::new()));
    +        let flooded = Arc::new(AtomicU64::new(0));
    +        let released = Arc::new(AtomicU64::new(0));
    +        let task = {
    +            let heads = Arc::clone(&heads);
    +            let flooded = Arc::clone(&flooded);
    +            let released = Arc::clone(&released);
    +            tokio::spawn(async move {
    +                for answer in answers {
    +                    let Ok((stream, _)) = listener.accept().await else {
    +                        return;
    +                    };
    +                    let seen = Seen {
    +                        heads: Arc::clone(&heads),
    +                        flooded: Arc::clone(&flooded),
    +                        released: Arc::clone(&released),
    +                    };
    +                    tokio::spawn(serve(stream, answer, seen));
    +                }
    +            })
    +        };
    +        Self {
    +            url,
    +            heads,
    +            flooded,
    +            released,
    +            task,
    +        }
    +    }
    +
    +    /// The request heads received, in arrival order.
    +    pub(super) fn heads(&self) -> Vec {
    +        self.heads.lock().unwrap().clone()
    +    }
    +
    +    /// The value of header `name` in request `index`, names compared
    +    /// without case.
    +    pub(super) fn header(&self, index: usize, name: &str) -> Option {
    +        let heads = self.heads();
    +        heads.get(index)?.lines().find_map(|line| {
    +            let (key, value) = line.split_once(':')?;
    +            key.eq_ignore_ascii_case(name)
    +                .then(|| value.trim().to_owned())
    +        })
    +    }
    +
    +    /// Body bytes a `Flood` answer managed to write.
    +    pub(super) fn flooded(&self) -> u64 {
    +        self.flooded.load(Ordering::SeqCst)
    +    }
    +
    +    /// `Hold` connections the client has closed.
    +    pub(super) fn released(&self) -> u64 {
    +        self.released.load(Ordering::SeqCst)
    +    }
    +}
    +
    +impl Drop for Stub {
    +    fn drop(&mut self) {
    +        self.task.abort();
    +    }
    +}
    +
    +/// What the connections of one listener record.
    +struct Seen {
    +    heads: Arc>>,
    +    flooded: Arc,
    +    released: Arc,
    +}
    +
    +async fn serve(mut stream: TcpStream, answer: Answer, seen: Seen) {
    +    let mut request = Vec::new();
    +    let mut buf = [0u8; 4096];
    +    while !request.windows(4).any(|w| w == b"\r\n\r\n") {
    +        match stream.read(&mut buf).await {
    +            Ok(0) | Err(_) => return,
    +            Ok(n) => request.extend_from_slice(&buf[..n]),
    +        }
    +    }
    +    seen.heads
    +        .lock()
    +        .unwrap()
    +        .push(String::from_utf8_lossy(&request).into_owned());
    +    let flooded = seen.flooded;
    +    match answer {
    +        Answer::Send { head, body } => {
    +            let _ = stream.write_all(head.as_bytes()).await;
    +            let _ = stream.write_all(&body).await;
    +            let _ = stream.shutdown().await;
    +        }
    +        Answer::Close => {
    +            let _ = stream.shutdown().await;
    +        }
    +        Answer::Stall { head, body } => {
    +            let _ = stream.write_all(head.as_bytes()).await;
    +            let _ = stream.write_all(&body).await;
    +            let _ = stream.flush().await;
    +            hold(stream).await;
    +        }
    +        Answer::Hold => {
    +            hold(stream).await;
    +            seen.released.fetch_add(1, Ordering::SeqCst);
    +        }
    +        Answer::Flood { head, total } => {
    +            if stream.write_all(head.as_bytes()).await.is_err() {
    +                return;
    +            }
    +            let chunk = vec![b'x'; 64 * 1024];
    +            let mut written = 0u64;
    +            while written < total {
    +                let n = usize::try_from(total - written)
    +                    .unwrap_or(usize::MAX)
    +                    .min(chunk.len());
    +                if stream.write_all(&chunk[..n]).await.is_err() {
    +                    break;
    +                }
    +                written += n as u64;
    +                flooded.store(written, Ordering::SeqCst);
    +            }
    +        }
    +    }
    +}
    +
    +/// Keep the connection open until the client closes it.
    +async fn hold(mut stream: TcpStream) {
    +    let mut buf = [0u8; 1024];
    +    while matches!(stream.read(&mut buf).await, Ok(n) if n > 0) {}
    +}
    +
    +/// A store's clients against `url` as an S3-compatible endpoint, on the
    +/// SDK's own HTTP client, path-style, no retries by the SDK or by the
    +/// store, with `timeouts`.
    +pub(super) fn stub_clients(url: &str, timeouts: TimeoutConfig) -> S3Clients {
    +    let config = aws_sdk_s3::Config::builder()
    +        .behavior_version(behavior_version())
    +        .region(Region::new("us-east-1"))
    +        .endpoint_url(url)
    +        .force_path_style(true)
    +        .credentials_provider(Credentials::new("AKIDTEST", "secret", None, None, "test"))
    +        .retry_config(RetryConfig::disabled())
    +        .timeout_config(timeouts)
    +        .build();
    +    S3Clients::new(config, false, true).with_retry(RetryPolicy::NONE)
    +}
    +
    +/// Short timeouts for a test against the listener.
    +pub(super) fn short_timeouts() -> TimeoutConfig {
    +    TimeoutConfig::builder()
    +        .connect_timeout(Duration::from_secs(2))
    +        .read_timeout(Duration::from_secs(2))
    +        .build()
    +}
    diff --git a/crates/engine/src/bulk_put/s3/tests/transient.rs b/crates/engine/src/bulk_put/s3/tests/transient.rs
    new file mode 100644
    index 000000000..37460a59b
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/tests/transient.rs
    @@ -0,0 +1,252 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Transient S3 failures: sent again by the store after the SDK's own
    +//! attempts, with a bound on each call; a ranged GET whose body is cut is
    +//! requested again for the same range with the same `If-Match`.
    +
    +use std::time::{Duration, Instant};
    +
    +use aws_sdk_s3::config::http::HttpResponse;
    +use aws_sdk_s3::primitives::SdkBody;
    +
    +use super::read::{LINES, drain, part, parts};
    +use super::stub::{self, Answer, Stub, stub_clients};
    +use super::*;
    +use crate::bulk_put::s3::client::Phase;
    +use crate::bulk_put::s3::error::Tail;
    +use crate::bulk_put::s3::list::check_bucket;
    +use crate::bulk_put::s3::probe::probe;
    +use crate::bulk_put::s3::read::open;
    +use crate::bulk_put::s3::retry::RetryPolicy;
    +use crate::bulk_put::source::{InputCodec, OutputSse, SinkError, StoreUnavailable};
    +
    +/// Retries a few milliseconds apart, within half a second.
    +const QUICK: RetryPolicy = RetryPolicy {
    +    first: Duration::from_millis(5),
    +    max: Duration::from_millis(20),
    +    total: Duration::from_millis(500),
    +};
    +
    +fn empty_listing() -> HttpResponse {
    +    HttpResponse::new(
    +        200.try_into().unwrap(),
    +        SdkBody::from(
    +            "\nloads\
    +             false",
    +        ),
    +    )
    +}
    +
    +/// The server's failure, and not one the runner resumes after.
    +fn is_internal(error: &SourceError) -> bool {
    +    matches!(error, SourceError::Io(io)
    +        if io.kind() == std::io::ErrorKind::Other && !StoreUnavailable::of_io(io))
    +}
    +
    +/// A transient failure that outlasted the retries, which the runner
    +/// resumes after.
    +fn is_unavailable(error: &SourceError) -> bool {
    +    matches!(error, SourceError::Io(io) if StoreUnavailable::of_io(io))
    +}
    +
    +#[tokio::test]
    +async fn a_transient_answer_is_sent_again_and_the_call_succeeds() {
    +    for (status, code) in [
    +        (500, "InternalError"),
    +        (502, "BadGateway"),
    +        (503, "SlowDown"),
    +        (503, "ServiceUnavailable"),
    +        (504, "GatewayTimeout"),
    +        (400, "RequestTimeout"),
    +        (429, "TooManyRequests"),
    +    ] {
    +        let (clients, replay) = clients(vec![
    +            s3_error(status, code, "Please try again."),
    +            empty_listing(),
    +        ]);
    +        let clients = clients.with_retry(QUICK);
    +        check_bucket(&clients, &location("loads", None), "in/")
    +            .await
    +            .unwrap_or_else(|error| panic!("{code}: {error:?}"));
    +        assert_eq!(replay.actual_requests().count(), 2, "{code}");
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_connection_closed_without_an_answer_is_sent_again() {
    +    let listing = "\nloads\
    +                   false";
    +    let stub = Stub::start(vec![
    +        Answer::Close,
    +        Answer::Send {
    +            head: stub::head(
    +                "200 OK",
    +                &[
    +                    ("content-type", "application/xml"),
    +                    ("content-length", &listing.len().to_string()),
    +                ],
    +            ),
    +            body: listing.as_bytes().to_vec(),
    +        },
    +    ])
    +    .await;
    +    let clients = stub_clients(&stub.url, stub::short_timeouts()).with_retry(QUICK);
    +    check_bucket(&clients, &location("loads", None), "in/")
    +        .await
    +        .unwrap();
    +    assert_eq!(stub.heads().len(), 2);
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn a_cut_body_is_requested_again_for_the_same_range_with_if_match() {
    +    // The first range's body ends before its Content-Length, then the
    +    // third range's; each is requested again and the read completes.
    +    let mut cut_first = part(&LINES[..16], 0, LINES.len(), "listed-etag");
    +    *cut_first.body_mut() = SdkBody::from(LINES[..10].to_vec());
    +    let mut cut_third = part(&LINES[32..], 32, LINES.len(), "listed-etag");
    +    *cut_third.body_mut() = SdkBody::from(LINES[32..35].to_vec());
    +    let mut good = parts(LINES, 16, "listed-etag").into_iter();
    +    let mut next = || good.next().unwrap();
    +    let responses = vec![cut_first, next(), next(), cut_third, next()];
    +    let (clients, replay) = clients(responses);
    +    let mut reader = open(
    +        shared(clients.with_part_bytes(16).with_retry(QUICK)),
    +        location("loads", None),
    +        &input_file("in/part-0.json", LINES.len(), Some("listed-etag")),
    +        InputCodec::None,
    +    )
    +    .await
    +    .unwrap();
    +    let (lines, error) = drain(&mut reader).await;
    +    assert!(error.is_none(), "{error:?}");
    +    assert_eq!(lines, ["line-0001", "line-0002", "line-0003", "line-0004"]);
    +    let ranges: Vec<_> = (0..5).map(|i| header(&replay, i, "range")).collect();
    +    assert_eq!(
    +        ranges,
    +        [
    +            Some("bytes=0-15".to_owned()),
    +            Some("bytes=0-15".to_owned()),
    +            Some("bytes=16-31".to_owned()),
    +            Some("bytes=32-39".to_owned()),
    +            Some("bytes=32-39".to_owned()),
    +        ]
    +    );
    +    for i in 0..5 {
    +        assert_eq!(
    +            header(&replay, i, "if-match").as_deref(),
    +            Some("\"listed-etag\""),
    +            "request {i}"
    +        );
    +    }
    +}
    +
    +/// Ten times the bound of [`QUICK`]: a call still retrying by then has a
    +/// bound that does not end.
    +const PAST_THE_BOUND: Duration = Duration::from_secs(5);
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +async fn transient_failures_past_the_bound_are_answered_as_the_store_unavailable() {
    +    // Every answer is a 503: the call is sent again until the bound has
    +    // passed, then fails as the store unavailable, which the runner
    +    // resumes after.
    +    let answers = (0..200)
    +        .map(|_| s3_error(503, "SlowDown", "Please reduce your request rate."))
    +        .collect();
    +    let (listing, replay) = clients(answers);
    +    let listing = listing.with_retry(QUICK);
    +    let started = Instant::now();
    +    let error = tokio::time::timeout(
    +        PAST_THE_BOUND,
    +        check_bucket(&listing, &location("loads", None), "in/"),
    +    )
    +    .await
    +    .expect("the bound ends the retries")
    +    .unwrap_err();
    +    let elapsed = started.elapsed();
    +    assert!(is_unavailable(&error), "{error:?}");
    +    assert!(elapsed >= QUICK.total, "{elapsed:?}");
    +    assert!(elapsed < Duration::from_secs(5), "{elapsed:?}");
    +    let sent = replay.actual_requests().count();
    +    assert!(sent > 2 && sent < 200, "{sent} requests");
    +
    +    // A body cut on every request of the range: the same bound, then the
    +    // store unavailable.
    +    let mut cuts = Vec::new();
    +    for _ in 0..200 {
    +        let mut cut = part(&LINES[..16], 0, LINES.len(), "listed-etag");
    +        *cut.body_mut() = SdkBody::from(LINES[..10].to_vec());
    +        cuts.push(cut);
    +    }
    +    let (reading, replay) = clients(cuts);
    +    let started = Instant::now();
    +    let opened = tokio::time::timeout(
    +        PAST_THE_BOUND,
    +        open(
    +            shared(reading.with_part_bytes(16).with_retry(QUICK)),
    +            location("loads", None),
    +            &input_file("in/part-0.json", LINES.len(), Some("listed-etag")),
    +            InputCodec::None,
    +        ),
    +    )
    +    .await
    +    .expect("the bound ends the retries");
    +    let error = opened.err().expect("refused");
    +    assert!(is_unavailable(&error), "{error:?}");
    +    assert!(started.elapsed() >= QUICK.total);
    +    let sent = replay.actual_requests().count();
    +    assert!(sent > 2 && sent < 200, "{sent} requests");
    +
    +    // A report-side call answered 503 past the bound: the same.
    +    let answers = (0..200)
    +        .map(|_| s3_error(503, "SlowDown", "Please reduce your request rate."))
    +        .collect();
    +    let (writing, _replay) = clients(answers);
    +    let error = tokio::time::timeout(
    +        PAST_THE_BOUND,
    +        probe(
    +            &writing.with_retry(QUICK),
    +            &location("reports", None),
    +            "run/AWSDynamoDB/BulkOperation/report-0.json.gz",
    +            &OutputSse::None,
    +            Tail::S3,
    +            Phase::Run,
    +        ),
    +    )
    +    .await
    +    .expect("the bound ends the retries")
    +    .unwrap_err();
    +    assert!(
    +        matches!(&error, SinkError::Io(io) if StoreUnavailable::of_io(io)),
    +        "{error:?}"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn client_errors_outside_the_transient_codes_are_answered_at_once() {
    +    for (status, code, expected) in [
    +        (403, "AccessDenied", "S3AccessDenied"),
    +        (404, "NoSuchBucket", "S3NoSuchBucket"),
    +        (400, "InvalidRequest", "S3InvalidRequest"),
    +    ] {
    +        let (clients, replay) = clients(vec![s3_error(status, code, "Refused."), empty_listing()]);
    +        let clients = clients.with_retry(QUICK);
    +        let fault = fault(
    +            check_bucket(&clients, &location("loads", None), "in/")
    +                .await
    +                .unwrap_err(),
    +        );
    +        assert_eq!(fault.code, expected);
    +        assert_eq!(replay.actual_requests().count(), 1, "{code}");
    +    }
    +    // A 4xx code outside the table is the server's failure, at once.
    +    let (clients, replay) = clients(vec![
    +        s3_error(409, "SomeFutureConflict", "Something new."),
    +        empty_listing(),
    +    ]);
    +    let error = check_bucket(&clients.with_retry(QUICK), &location("loads", None), "in/")
    +        .await
    +        .unwrap_err();
    +    assert!(is_internal(&error), "{error:?}");
    +    assert_eq!(replay.actual_requests().count(), 1);
    +}
    diff --git a/crates/engine/src/bulk_put/s3/tests/write.rs b/crates/engine/src/bulk_put/s3/tests/write.rs
    new file mode 100644
    index 000000000..a984060fd
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/tests/write.rs
    @@ -0,0 +1,613 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The output side: the write check at the first report record and at the
    +//! manifest, the Start-time encryption check, the multipart report writer
    +//! with rotation, and the manifest.
    +
    +use std::io::Read;
    +
    +use aws_sdk_s3::config::http::HttpResponse;
    +use aws_sdk_s3::primitives::SdkBody;
    +use serde_json::json;
    +
    +use extenddb_storage::ReportKind;
    +
    +use super::*;
    +use crate::bulk_put::s3::S3Sink;
    +use crate::bulk_put::source::{BucketRef, BulkSink, OutputSse, SinkError};
    +
    +pub(super) const OPERATION: &str = "01792940454300-c5f2ec39";
    +const ACCOUNT: &str = "000000000001";
    +
    +fn ok(status: u16, body: &str, headers: &[(&str, &str)]) -> HttpResponse {
    +    with_headers(
    +        HttpResponse::new(status.try_into().unwrap(), SdkBody::from(body.to_owned())),
    +        headers,
    +    )
    +}
    +
    +pub(super) fn created(upload_id: &str) -> HttpResponse {
    +    ok(
    +        200,
    +        &format!(
    +            "\n\
    +             outk{upload_id}\
    +             "
    +        ),
    +        &[],
    +    )
    +}
    +
    +pub(super) fn part_uploaded(etag: &str) -> HttpResponse {
    +    ok(200, "", &[("etag", &format!("\"{etag}\""))])
    +}
    +
    +pub(super) fn completed() -> HttpResponse {
    +    ok(
    +        200,
    +        "\n\
    +         outk\"done-2\"",
    +        &[],
    +    )
    +}
    +
    +fn aborted() -> HttpResponse {
    +    ok(204, "", &[])
    +}
    +
    +pub(super) fn put() -> HttpResponse {
    +    ok(200, "", &[("etag", "\"put\"")])
    +}
    +
    +pub(super) fn s3_sink(
    +    responses: Vec,
    +    part_bytes: u64,
    +) -> (S3Sink, StaticReplayClient) {
    +    let (clients, replay) = clients(responses);
    +    (
    +        S3Sink {
    +            clients: shared(clients.with_part_bytes(part_bytes)),
    +        },
    +        replay,
    +    )
    +}
    +
    +pub(super) fn out(owner: Option<&str>) -> BucketRef<'_> {
    +    BucketRef {
    +        account: ACCOUNT,
    +        bucket: "out",
    +        expected_owner: owner,
    +    }
    +}
    +
    +fn report_key(index: u32) -> String {
    +    crate::bulk_put::report::report_key("reports", ACCOUNT, OPERATION, ReportKind::Error, index)
    +}
    +
    +fn manifest_key() -> String {
    +    crate::bulk_put::report::manifest_key("reports", ACCOUNT, OPERATION)
    +}
    +
    +pub(super) fn method_and_uri(replay: &StaticReplayClient, index: usize) -> (String, String) {
    +    let request = replay.actual_requests().nth(index).expect("request");
    +    (request.method().to_owned(), request.uri().to_owned())
    +}
    +
    +fn body(replay: &StaticReplayClient, index: usize) -> Vec {
    +    let request = replay.actual_requests().nth(index).expect("request");
    +    request.body().bytes().expect("an in-memory body").to_vec()
    +}
    +
    +fn gunzip(bytes: &[u8]) -> String {
    +    let mut text = String::new();
    +    flate2::read::MultiGzDecoder::new(bytes)
    +        .read_to_string(&mut text)
    +        .unwrap();
    +    text
    +}
    +
    +pub(super) fn store_fault(error: SinkError) -> crate::bulk_put::source::StoreFault {
    +    match error {
    +        SinkError::Store(fault) => fault,
    +        other => panic!("expected a store fault, got {other:?}"),
    +    }
    +}
    +
    +/// `count` report records with pseudo-random content, so the gzip stream
    +/// grows with the input and parts fill while pages are written.
    +pub(super) fn records(count: usize) -> Vec {
    +    let mut state: u64 = 0x9e37_79b9_7f4a_7c15;
    +    let mut noise = move || -> String {
    +        (0..4)
    +            .map(|_| {
    +                state = state
    +                    .wrapping_mul(6_364_136_223_846_793_005)
    +                    .wrapping_add(1_442_695_040_888_963_407);
    +                format!("{state:016x}")
    +            })
    +            .collect()
    +    };
    +    (0..count)
    +        .map(|i| json!({"itemLocation": {"itemIndex": i}, "errorMessages": [format!("row {i:04} failed {}", noise())]}))
    +        .collect()
    +}
    +
    +/// The part size the multipart tests use.
    +pub(super) const PART: u64 = 16 * 1024;
    +
    +#[tokio::test]
    +async fn the_first_record_check_starts_and_aborts_an_upload_with_owner_and_sse() {
    +    let (sink, replay) = s3_sink(vec![created("probe-1"), aborted()], 1 << 20);
    +    let sse = OutputSse::Kms {
    +        key_id: Some("alias/reports".to_owned()),
    +    };
    +    sink.check_location(&out(Some("444455556666")), &report_key(0), &sse)
    +        .await
    +        .unwrap();
    +    let (method, uri) = method_and_uri(&replay, 0);
    +    assert_eq!(method, "POST");
    +    assert!(uri.contains("output-00000.json.gz?uploads"), "{uri}");
    +    assert_eq!(
    +        header(&replay, 0, "x-amz-server-side-encryption").as_deref(),
    +        Some("aws:kms")
    +    );
    +    assert_eq!(
    +        header(&replay, 0, "x-amz-server-side-encryption-aws-kms-key-id").as_deref(),
    +        Some("alias/reports")
    +    );
    +    assert_eq!(
    +        header(&replay, 0, "x-amz-expected-bucket-owner").as_deref(),
    +        Some("444455556666")
    +    );
    +    let (method, uri) = method_and_uri(&replay, 1);
    +    assert_eq!(method, "DELETE");
    +    assert!(uri.contains("uploadId=probe-1"), "{uri}");
    +    assert_eq!(
    +        header(&replay, 1, "x-amz-expected-bucket-owner").as_deref(),
    +        Some("444455556666")
    +    );
    +}
    +
    +#[tokio::test]
    +async fn first_record_check_faults_carry_the_report_writers_tail() {
    +    let put_denied = format!(
    +        "User: arn:aws:sts::111122223333:assumed-role/extenddb/server is not authorized to \
    +         perform: s3:PutObject on resource: \"arn:aws:s3:::out/{}\" with an explicit deny in a \
    +         resource-based policy",
    +        report_key(0)
    +    );
    +    let cases = [
    +        (
    +            404,
    +            "NoSuchBucket",
    +            "The specified bucket does not exist".to_owned(),
    +            "S3NoSuchBucket",
    +        ),
    +        (
    +            403,
    +            "AccessDenied",
    +            "Access Denied".to_owned(),
    +            "S3AccessDenied",
    +        ),
    +        (403, "AccessDenied", put_denied, "S3AccessDenied"),
    +        (
    +            400,
    +            "KMS.InvalidKeyUsageException",
    +            "The request was rejected because the specified KMS key is not enabled for this \
    +             operation."
    +                .to_owned(),
    +            "KmsInvalidKeyUsageException",
    +        ),
    +    ];
    +    for (status, code, message, expected) in cases {
    +        let (sink, replay) = s3_sink(vec![s3_error(status, code, &message)], 1 << 20);
    +        let fault = store_fault(
    +            sink.check_location(&out(None), &report_key(0), &OutputSse::None)
    +                .await
    +                .unwrap_err(),
    +        );
    +        assert_eq!(fault.code, expected, "{code}");
    +        assert_eq!(fault.message, format!("{message}{}", s3_tail(status)));
    +        assert!(!fault.after_first_byte);
    +        assert_eq!(
    +            replay.actual_requests().count(),
    +            1,
    +            "no abort without an upload"
    +        );
    +    }
    +}
    +
    +#[tokio::test]
    +async fn manifest_check_faults_carry_the_amazon_s3_tail() {
    +    for (status, code, message, expected) in [
    +        (
    +            404,
    +            "NoSuchBucket",
    +            "The specified bucket does not exist",
    +            "S3NoSuchBucket",
    +        ),
    +        (403, "AccessDenied", "Access Denied", "S3AccessDenied"),
    +    ] {
    +        let (sink, _replay) = s3_sink(vec![s3_error(status, code, message)], 1 << 20);
    +        let fault = store_fault(
    +            sink.check_location(&out(None), &manifest_key(), &OutputSse::None)
    +                .await
    +                .unwrap_err(),
    +        );
    +        assert_eq!(fault.code, expected);
    +        assert_eq!(
    +            fault.message,
    +            format!("{message}{}", amazon_s3_tail(status, code))
    +        );
    +    }
    +}
    +
    +#[tokio::test]
    +async fn the_start_encryption_check_reports_kms_faults_only() {
    +    let kms = OutputSse::Kms {
    +        key_id: Some("arn:aws:kms:us-east-1:111122223333:key/k1".to_owned()),
    +    };
    +    let disabled = "arn:aws:kms:us-east-1:111122223333:key/k1 is disabled.";
    +    let (sink, _replay) = s3_sink(
    +        vec![s3_error(400, "KMS.DisabledException", disabled)],
    +        1 << 20,
    +    );
    +    let fault = store_fault(
    +        sink.check_encryption(&out(None), &report_key(0), &kms)
    +            .await
    +            .unwrap_err(),
    +    );
    +    assert_eq!(fault.code, "KmsDisabledException");
    +    assert_eq!(
    +        fault.message,
    +        format!("{disabled}{}", amazon_s3_tail(400, "KMS.DisabledException"))
    +    );
    +    // A bucket fault is left to the first report record.
    +    let (sink, _replay) = s3_sink(
    +        vec![s3_error(
    +            404,
    +            "NoSuchBucket",
    +            "The specified bucket does not exist",
    +        )],
    +        1 << 20,
    +    );
    +    sink.check_encryption(&out(None), &report_key(0), &kms)
    +        .await
    +        .unwrap();
    +    // A usable key: the probe's upload is aborted.
    +    let (sink, replay) = s3_sink(vec![created("probe-2"), aborted()], 1 << 20);
    +    sink.check_encryption(&out(None), &report_key(0), &kms)
    +        .await
    +        .unwrap();
    +    assert_eq!(replay.actual_requests().count(), 2);
    +    // Without SSE-KMS nothing is sent.
    +    for sse in [OutputSse::None, OutputSse::Aes256] {
    +        let (sink, replay) = s3_sink(Vec::new(), 1 << 20);
    +        sink.check_encryption(&out(None), &report_key(0), &sse)
    +            .await
    +            .unwrap();
    +        assert_eq!(replay.actual_requests().count(), 0);
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_report_that_never_fills_a_part_is_one_put_object_at_finish() {
    +    let (sink, replay) = s3_sink(vec![put()], 1 << 20);
    +    let mut writer = sink
    +        .open(
    +            &out(Some("444455556666")),
    +            "reports",
    +            OPERATION,
    +            ReportKind::Error,
    +            "lease",
    +            &OutputSse::Aes256,
    +        )
    +        .await
    +        .unwrap();
    +    writer.write_page(records(3)).await.unwrap();
    +    assert_eq!(
    +        writer.object_index(),
    +        0,
    +        "the open object is still object 0"
    +    );
    +    assert_eq!(
    +        replay.actual_requests().count(),
    +        0,
    +        "nothing is visible before finish"
    +    );
    +    let key = writer.finish().await.unwrap();
    +    assert_eq!(key, report_key(0));
    +    let (method, uri) = method_and_uri(&replay, 0);
    +    assert_eq!(method, "PUT");
    +    assert!(
    +        path_of(&uri).ends_with(&urlencoded_key(&report_key(0))),
    +        "{uri}"
    +    );
    +    assert_eq!(
    +        header(&replay, 0, "x-amz-server-side-encryption").as_deref(),
    +        Some("AES256")
    +    );
    +    assert_eq!(
    +        header(&replay, 0, "x-amz-expected-bucket-owner").as_deref(),
    +        Some("444455556666")
    +    );
    +    let text = gunzip(&body(&replay, 0));
    +    assert_eq!(text.lines().count(), 3);
    +    assert!(text.lines().next().unwrap().contains("row 0000 failed"));
    +}
    +
    +/// The path of a report key as the SDK sends it: `:` escaped.
    +fn urlencoded_key(key: &str) -> String {
    +    key.replace(':', "%3A")
    +}
    +
    +/// The path of a request URI, without its query.
    +fn path_of(uri: &str) -> &str {
    +    uri.split('?').next().unwrap_or(uri)
    +}
    +
    +/// The compressed size of `pages` written the way the writer writes them.
    +pub(super) fn gzip_size(pages: &[Vec]) -> usize {
    +    let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
    +    for page in pages {
    +        crate::bulk_put::report::encode_ndjson_page(&mut encoder, page).unwrap();
    +    }
    +    encoder.finish().unwrap().len()
    +}
    +
    +#[tokio::test]
    +async fn a_large_report_is_uploaded_in_parts_and_completed_at_finish() {
    +    let pages = vec![records(2000), records(2000), records(2000)];
    +    let parts = gzip_size(&pages).div_ceil(usize::try_from(PART).unwrap());
    +    let mut responses = vec![created("up-0")];
    +    responses.extend((1..=parts).map(|i| part_uploaded(&format!("p{i}"))));
    +    responses.push(completed());
    +    let (sink, replay) = s3_sink(responses, PART);
    +    let mut writer = sink
    +        .open(
    +            &out(None),
    +            "reports",
    +            OPERATION,
    +            ReportKind::Error,
    +            "lease",
    +            &OutputSse::Aes256,
    +        )
    +        .await
    +        .unwrap();
    +    for page in pages {
    +        writer.write_page(page).await.unwrap();
    +    }
    +    let before = replay.actual_requests().count();
    +    assert!(before >= 2, "parts are sent as they fill");
    +    let key = writer.finish().await.unwrap();
    +    assert_eq!(key, report_key(0));
    +    let count = replay.actual_requests().count();
    +    assert_eq!(count, parts + 2, "create, every part, complete");
    +    let (method, uri) = method_and_uri(&replay, 0);
    +    assert_eq!(method, "POST");
    +    assert!(uri.contains("?uploads"), "{uri}");
    +    assert_eq!(
    +        header(&replay, 0, "x-amz-server-side-encryption").as_deref(),
    +        Some("AES256")
    +    );
    +    for i in 1..=parts {
    +        let (method, uri) = method_and_uri(&replay, i);
    +        assert_eq!(method, "PUT");
    +        assert!(uri.contains(&format!("partNumber={i}")), "{uri}");
    +        assert!(uri.contains("uploadId=up-0"), "{uri}");
    +    }
    +    let (method, uri) = method_and_uri(&replay, parts + 1);
    +    assert_eq!(method, "POST");
    +    assert!(uri.contains("uploadId=up-0"), "{uri}");
    +    let complete = String::from_utf8(body(&replay, parts + 1)).unwrap();
    +    assert!(
    +        complete.contains(&format!("{parts}")),
    +        "{complete}"
    +    );
    +    assert!(complete.contains("p1"), "{complete}");
    +    // The parts reassemble into the report.
    +    let uploaded: Vec = (1..=parts).flat_map(|i| body(&replay, i)).collect();
    +    assert_eq!(gunzip(&uploaded).lines().count(), 6000);
    +}
    +
    +#[tokio::test]
    +async fn rotation_closes_an_object_at_the_threshold_and_finish_installs_them_in_order() {
    +    let (sink, replay) = s3_sink(vec![put(), put()], 1 << 20);
    +    let mut writer = sink
    +        .open(
    +            &out(None),
    +            "reports",
    +            OPERATION,
    +            ReportKind::Error,
    +            "lease",
    +            &OutputSse::None,
    +        )
    +        .await
    +        .unwrap();
    +    // Every page reaches the threshold once the gzip header is written,
    +    // so each page closes its object.
    +    writer.set_rotation_threshold(1);
    +    assert_eq!(writer.object_index(), 0, "the first page opens object 0");
    +    writer.write_page(records(2)).await.unwrap();
    +    assert_eq!(writer.open_object_bytes(), 0, "the page closed its object");
    +    assert_eq!(writer.object_index(), 1, "the next page opens object 1");
    +    writer.write_page(records(3)).await.unwrap();
    +    assert_eq!(writer.object_index(), 2, "after the second roll, object 2");
    +    assert_eq!(
    +        replay.actual_requests().count(),
    +        0,
    +        "nothing is visible before finish"
    +    );
    +    assert_eq!(writer.finish().await.unwrap(), report_key(0));
    +    let first = method_and_uri(&replay, 0).1;
    +    let second = method_and_uri(&replay, 1).1;
    +    assert!(
    +        path_of(&first).ends_with(&urlencoded_key(&report_key(0))),
    +        "{first}"
    +    );
    +    assert!(
    +        path_of(&second).ends_with(&urlencoded_key(&report_key(1))),
    +        "{second}"
    +    );
    +    assert_eq!(gunzip(&body(&replay, 0)).lines().count(), 2);
    +    assert_eq!(gunzip(&body(&replay, 1)).lines().count(), 3);
    +}
    +
    +#[tokio::test]
    +async fn a_writer_dropped_without_finish_aborts_its_uploads() {
    +    // Enough part answers for the page; the abort takes the next answer.
    +    let mut responses = vec![created("up-9")];
    +    responses.extend((0..40).map(|i| part_uploaded(&format!("p{i}"))));
    +    let (sink, replay) = s3_sink(responses, PART);
    +    let mut writer = sink
    +        .open(
    +            &out(None),
    +            "reports",
    +            OPERATION,
    +            ReportKind::Error,
    +            "lease",
    +            &OutputSse::None,
    +        )
    +        .await
    +        .unwrap();
    +    // One page whose gzip output passes one 64-byte part.
    +    writer.write_page(records(2000)).await.unwrap();
    +    assert!(replay.actual_requests().count() >= 2);
    +    drop(writer);
    +    for _ in 0..200 {
    +        if replay
    +            .actual_requests()
    +            .any(|request| request.method() == "DELETE")
    +        {
    +            break;
    +        }
    +        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
    +    }
    +    let deleted: Vec = replay
    +        .actual_requests()
    +        .filter(|request| request.method() == "DELETE")
    +        .map(|request| request.uri().to_owned())
    +        .collect();
    +    assert_eq!(deleted.len(), 1, "{deleted:?}");
    +    assert!(deleted[0].contains("uploadId=up-9"), "{deleted:?}");
    +}
    +
    +#[tokio::test]
    +async fn report_write_faults_carry_the_report_writers_tail() {
    +    // A part upload refused.
    +    let (sink, _replay) = s3_sink(
    +        vec![
    +            created("up-1"),
    +            s3_error(403, "AccessDenied", "Access Denied"),
    +            aborted(),
    +        ],
    +        PART,
    +    );
    +    let mut writer = sink
    +        .open(
    +            &out(None),
    +            "reports",
    +            OPERATION,
    +            ReportKind::Error,
    +            "lease",
    +            &OutputSse::None,
    +        )
    +        .await
    +        .unwrap();
    +    let fault = store_fault(writer.write_page(records(2000)).await.unwrap_err());
    +    assert_eq!(fault.code, "S3AccessDenied");
    +    assert_eq!(fault.message, format!("Access Denied{}", s3_tail(403)));
    +    assert!(
    +        writer.write_page(records(1)).await.is_err(),
    +        "a failed writer writes no more"
    +    );
    +    drop(writer);
    +
    +    // The single PutObject at finish finds the bucket gone.
    +    let (sink, _replay) = s3_sink(
    +        vec![s3_error(
    +            404,
    +            "NoSuchBucket",
    +            "The specified bucket does not exist",
    +        )],
    +        1 << 20,
    +    );
    +    let mut writer = sink
    +        .open(
    +            &out(None),
    +            "reports",
    +            OPERATION,
    +            ReportKind::Error,
    +            "lease",
    +            &OutputSse::None,
    +        )
    +        .await
    +        .unwrap();
    +    writer.write_page(records(2)).await.unwrap();
    +    let fault = store_fault(writer.finish().await.unwrap_err());
    +    assert_eq!(fault.code, "S3NoSuchBucket");
    +    assert_eq!(
    +        fault.message,
    +        format!("The specified bucket does not exist{}", s3_tail(404))
    +    );
    +}
    +
    +#[tokio::test]
    +async fn the_manifest_is_one_put_object_with_sse_and_its_faults_carry_the_amazon_s3_tail() {
    +    let lines = vec![
    +        json!({"inputSourceArn": "arn:aws:s3:::in/a.json", "fileProcessingStatus": "FULLY_PROCESSED", "processedRanges": [[0, 9]], "etag": "e1"}),
    +        json!({"inputSourceArn": "arn:aws:s3:::in/b.json", "fileProcessingStatus": "NOT_PROCESSED", "processedRanges": [], "etag": "e2"}),
    +    ];
    +    let sse = OutputSse::Kms { key_id: None };
    +    let (sink, replay) = s3_sink(vec![put()], 1 << 20);
    +    let key = sink
    +        .write_manifest(
    +            &out(None),
    +            "reports",
    +            OPERATION,
    +            "lease",
    +            &sse,
    +            lines.clone(),
    +        )
    +        .await
    +        .unwrap();
    +    assert_eq!(key, manifest_key());
    +    assert_eq!(
    +        header(&replay, 0, "x-amz-server-side-encryption").as_deref(),
    +        Some("aws:kms")
    +    );
    +    assert_eq!(
    +        header(&replay, 0, "x-amz-server-side-encryption-aws-kms-key-id"),
    +        None
    +    );
    +    let text = String::from_utf8(body(&replay, 0)).unwrap();
    +    assert_eq!(text.lines().count(), 2);
    +    assert!(
    +        text.starts_with("{\"inputSourceArn\":\"arn:aws:s3:::in/a.json\""),
    +        "{text}"
    +    );
    +    assert!(text.ends_with('\n'));
    +
    +    let (sink, _replay) = s3_sink(
    +        vec![s3_error(
    +            404,
    +            "NoSuchBucket",
    +            "The specified bucket does not exist",
    +        )],
    +        1 << 20,
    +    );
    +    let fault = store_fault(
    +        sink.write_manifest(&out(None), "reports", OPERATION, "lease", &sse, lines)
    +            .await
    +            .unwrap_err(),
    +    );
    +    assert_eq!(fault.code, "S3NoSuchBucket");
    +    assert_eq!(
    +        fault.message,
    +        format!(
    +            "The specified bucket does not exist{}",
    +            amazon_s3_tail(404, "NoSuchBucket")
    +        )
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/s3/write.rs b/crates/engine/src/bulk_put/s3/write.rs
    new file mode 100644
    index 000000000..2445b1ec6
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/s3/write.rs
    @@ -0,0 +1,512 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Report objects on the S3 store. Pages are gzip-encoded on the blocking
    +//! pool into the open object's part buffer; each full 8 MiB part is sent
    +//! with UploadPart on the object's multipart upload, which starts with the
    +//! first full part. A page that takes the object's compressed bytes to or
    +//! past the rotation threshold (200 MiB, as on the filesystem store and the
    +//! service) closes the object, and the next page opens
    +//! `output-0000N.json.gz` with N incremented. `finish` completes the
    +//! uploads in index order, or sends a single PutObject for an object that
    +//! never filled a part, so no object is visible before `finish`. A writer
    +//! dropped without `finish` aborts every upload it started. Every request
    +//! carries the expected owner, and the encryption fields go on
    +//! CreateMultipartUpload and PutObject.
    +
    +use std::io::Write;
    +use std::sync::Arc;
    +
    +use aws_sdk_s3::primitives::ByteStream;
    +use aws_sdk_s3::types::{ChecksumAlgorithm, CompletedMultipartUpload, CompletedPart};
    +use flate2::Compression;
    +use flate2::write::GzEncoder;
    +use serde_json::Value;
    +
    +use extenddb_storage::BoxedFuture as BoxFuture;
    +use extenddb_storage::ReportKind;
    +
    +use super::client::Phase;
    +use super::error::Tail;
    +use super::probe::{abort, sink_error, sse_fields};
    +use super::retry::is_transient;
    +use super::{Location, S3Clients};
    +use crate::bulk_put::report;
    +use crate::bulk_put::source::{OutputSse, ROTATION_THRESHOLD_BYTES, ReportWriter, SinkError};
    +
    +/// The compressed bytes of one object as the encoder writes them, and the
    +/// count written so far, uploaded parts included.
    +#[derive(Default)]
    +struct PartBuffer {
    +    bytes: Vec,
    +    written: u64,
    +}
    +
    +impl Write for PartBuffer {
    +    fn write(&mut self, buf: &[u8]) -> std::io::Result {
    +        self.bytes.extend_from_slice(buf);
    +        self.written = self.written.saturating_add(buf.len() as u64);
    +        Ok(buf.len())
    +    }
    +
    +    fn flush(&mut self) -> std::io::Result<()> {
    +        Ok(())
    +    }
    +}
    +
    +/// A started multipart upload and the parts it holds.
    +struct Upload {
    +    id: String,
    +    parts: Vec,
    +    /// The bytes of the parts uploaded, the size of the completed object.
    +    bytes: u64,
    +}
    +
    +/// The object pages are being written to.
    +struct OpenObject {
    +    key: String,
    +    encoder: GzEncoder,
    +    upload: Option,
    +}
    +
    +/// An object whose bytes are all written or uploaded, waiting for
    +/// `finish`.
    +enum Closed {
    +    Multipart { key: String, upload: Upload },
    +    Single { key: String, body: Vec },
    +}
    +
    +/// The report writer of one report kind on the S3 store.
    +pub(super) struct S3ReportWriter {
    +    clients: Arc,
    +    location: Location,
    +    prefix: String,
    +    operation_id: String,
    +    kind: ReportKind,
    +    sse: OutputSse,
    +    threshold: u64,
    +    open: Option,
    +    closed: Vec,
    +    /// Every upload started and not yet completed, as (key, upload id), so
    +    /// a writer dropped without `finish` can abort them.
    +    started: Vec<(String, String)>,
    +    /// A page or `finish` failed: nothing more is written.
    +    failed: bool,
    +}
    +
    +fn join_error(error: tokio::task::JoinError) -> SinkError {
    +    SinkError::Io(std::io::Error::other(error))
    +}
    +
    +impl S3ReportWriter {
    +    pub(super) fn new(
    +        clients: Arc,
    +        location: Location,
    +        prefix: &str,
    +        operation_id: &str,
    +        kind: ReportKind,
    +        sse: &OutputSse,
    +    ) -> Self {
    +        Self {
    +            clients,
    +            location,
    +            prefix: prefix.to_owned(),
    +            operation_id: operation_id.to_owned(),
    +            kind,
    +            sse: sse.clone(),
    +            threshold: ROTATION_THRESHOLD_BYTES,
    +            open: None,
    +            closed: Vec::new(),
    +            started: Vec::new(),
    +            failed: false,
    +        }
    +    }
    +
    +    fn key(&self, index: usize) -> String {
    +        report::report_key(
    +            &self.prefix,
    +            &self.location.account,
    +            &self.operation_id,
    +            self.kind,
    +            u32::try_from(index).unwrap_or(u32::MAX),
    +        )
    +    }
    +
    +    fn new_object(&self) -> OpenObject {
    +        OpenObject {
    +            key: self.key(self.closed.len()),
    +            encoder: GzEncoder::new(PartBuffer::default(), Compression::default()),
    +            upload: None,
    +        }
    +    }
    +
    +    /// Upload one part of the object at `key`, starting its multipart
    +    /// upload first when this is its first part.
    +    async fn upload_part(
    +        &mut self,
    +        key: &str,
    +        upload: &mut Option,
    +        body: Vec,
    +    ) -> Result<(), SinkError> {
    +        let checksum =
    +            (!self.clients.checksums_when_required()).then_some(ChecksumAlgorithm::Crc32);
    +        let location = &self.location;
    +        if upload.is_none() {
    +            let (encryption, kms_key) = sse_fields(&self.sse);
    +            let created = self
    +                .clients
    +                .call(&location.bucket, |client| {
    +                    client
    +                        .create_multipart_upload()
    +                        .bucket(&location.bucket)
    +                        .key(key)
    +                        .set_expected_bucket_owner(location.owner.clone())
    +                        .set_server_side_encryption(encryption.clone())
    +                        .set_ssekms_key_id(kms_key.clone())
    +                        .set_checksum_algorithm(checksum.clone())
    +                        .send()
    +                })
    +                .await
    +                .map_err(|error| sink_error(&error, location, key, Tail::S3, "report write"))?;
    +            let id = created.upload_id().unwrap_or_default().to_owned();
    +            self.started.push((key.to_owned(), id.clone()));
    +            *upload = Some(Upload {
    +                id,
    +                parts: Vec::new(),
    +                bytes: 0,
    +            });
    +        }
    +        let Some(upload) = upload.as_mut() else {
    +            return Ok(());
    +        };
    +        let number = i32::try_from(upload.parts.len() + 1).unwrap_or(i32::MAX);
    +        let id = upload.id.clone();
    +        let size = body.len() as u64;
    +        let uploaded = self
    +            .clients
    +            .call(&location.bucket, |client| {
    +                client
    +                    .upload_part()
    +                    .bucket(&location.bucket)
    +                    .key(key)
    +                    .upload_id(&id)
    +                    .part_number(number)
    +                    .body(ByteStream::from(body.clone()))
    +                    .set_expected_bucket_owner(location.owner.clone())
    +                    .set_checksum_algorithm(checksum.clone())
    +                    .send()
    +            })
    +            .await
    +            .map_err(|error| sink_error(&error, location, key, Tail::S3, "report write"))?;
    +        upload.parts.push(
    +            CompletedPart::builder()
    +                .part_number(number)
    +                .set_e_tag(uploaded.e_tag().map(str::to_owned))
    +                .set_checksum_crc32(uploaded.checksum_crc32().map(str::to_owned))
    +                .build(),
    +        );
    +        upload.bytes = upload.bytes.saturating_add(size);
    +        Ok(())
    +    }
    +
    +    /// Send every full part the open object's buffer holds.
    +    async fn upload_full_parts(&mut self, object: &mut OpenObject) -> Result<(), SinkError> {
    +        let part_bytes = usize::try_from(self.clients.part_bytes()).unwrap_or(usize::MAX);
    +        while object.encoder.get_ref().bytes.len() >= part_bytes {
    +            let body: Vec = object.encoder.get_mut().bytes.drain(..part_bytes).collect();
    +            self.upload_part(&object.key, &mut object.upload, body)
    +                .await?;
    +        }
    +        Ok(())
    +    }
    +
    +    /// Finish `object`'s gzip stream and send what is left of it: its last
    +    /// part when it has an upload, otherwise keep the whole body for one
    +    /// PutObject at `finish`.
    +    async fn close(&mut self, object: OpenObject) -> Result {
    +        let OpenObject {
    +            key,
    +            encoder,
    +            upload,
    +        } = object;
    +        let buffer = tokio::task::spawn_blocking(move || encoder.finish())
    +            .await
    +            .map_err(join_error)?
    +            .map_err(SinkError::Io)?;
    +        match upload {
    +            None => Ok(Closed::Single {
    +                key,
    +                body: buffer.bytes,
    +            }),
    +            Some(upload) => {
    +                let mut upload = Some(upload);
    +                // Full parts first, so every part but the last is one part
    +                // size, then the rest.
    +                let part_bytes = usize::try_from(self.clients.part_bytes()).unwrap_or(usize::MAX);
    +                let mut rest = buffer.bytes;
    +                while rest.len() > part_bytes {
    +                    let body: Vec = rest.drain(..part_bytes).collect();
    +                    self.upload_part(&key, &mut upload, body).await?;
    +                }
    +                if !rest.is_empty() {
    +                    self.upload_part(&key, &mut upload, rest).await?;
    +                }
    +                let upload = upload.unwrap_or(Upload {
    +                    id: String::new(),
    +                    parts: Vec::new(),
    +                    bytes: 0,
    +                });
    +                Ok(Closed::Multipart { key, upload })
    +            }
    +        }
    +    }
    +
    +    async fn write(&mut self, records: Vec) -> Result<(), SinkError> {
    +        let object = match self.open.take() {
    +            Some(object) => object,
    +            None => self.new_object(),
    +        };
    +        let threshold = self.threshold;
    +        let (mut object, roll) = tokio::task::spawn_blocking(move || {
    +            let mut object = object;
    +            report::encode_ndjson_page(&mut object.encoder, &records).map(|()| {
    +                let roll = object.encoder.get_ref().written >= threshold;
    +                (object, roll)
    +            })
    +        })
    +        .await
    +        .map_err(join_error)?
    +        .map_err(SinkError::Io)?;
    +        self.upload_full_parts(&mut object).await?;
    +        if roll {
    +            let closed = self.close(object).await?;
    +            self.closed.push(closed);
    +        } else {
    +            self.open = Some(object);
    +        }
    +        Ok(())
    +    }
    +
    +    async fn complete(&mut self) -> Result {
    +        // A writer that was given no page still writes its first object,
    +        // an empty gzip stream, as the filesystem store does.
    +        if self.open.is_none() && self.closed.is_empty() {
    +            self.open = Some(self.new_object());
    +        }
    +        if let Some(object) = self.open.take() {
    +            let closed = self.close(object).await?;
    +            self.closed.push(closed);
    +        }
    +        let (encryption, kms_key) = sse_fields(&self.sse);
    +        let location = self.location.clone();
    +        for object in std::mem::take(&mut self.closed) {
    +            match object {
    +                Closed::Multipart { key, upload } => {
    +                    let parts = CompletedMultipartUpload::builder()
    +                        .set_parts(Some(upload.parts))
    +                        .build();
    +                    let completed = self
    +                        .clients
    +                        .call(&location.bucket, |client| {
    +                            client
    +                                .complete_multipart_upload()
    +                                .bucket(&location.bucket)
    +                                .key(&key)
    +                                .upload_id(&upload.id)
    +                                .multipart_upload(parts.clone())
    +                                .set_expected_bucket_owner(location.owner.clone())
    +                                .send()
    +                        })
    +                        .await;
    +                    if let Err(error) = completed
    +                        && !self.completed_before(&error, &key, upload.bytes).await?
    +                    {
    +                        return Err(sink_error(
    +                            &error,
    +                            &location,
    +                            &key,
    +                            Tail::S3,
    +                            "report write",
    +                        ));
    +                    }
    +                    self.started.retain(|(_, id)| *id != upload.id);
    +                    self.audit(&key);
    +                }
    +                Closed::Single { key, body } => {
    +                    self.clients
    +                        .call(&location.bucket, |client| {
    +                            client
    +                                .put_object()
    +                                .bucket(&location.bucket)
    +                                .key(&key)
    +                                .body(ByteStream::from(body.clone()))
    +                                .set_expected_bucket_owner(location.owner.clone())
    +                                .set_server_side_encryption(encryption.clone())
    +                                .set_ssekms_key_id(kms_key.clone())
    +                                .send()
    +                        })
    +                        .await
    +                        .map_err(|e| sink_error(&e, &location, &key, Tail::S3, "report write"))?;
    +                    self.audit(&key);
    +                }
    +            }
    +        }
    +        Ok(self.key(0))
    +    }
    +
    +    /// The audit event the import and export operations emit, once per
    +    /// report object installed. Keys stay in the log.
    +    fn audit(&self, key: &str) {
    +        tracing::info!(
    +            account_id = %self.location.account,
    +            operation = "StartBulkPutItems",
    +            object = %format!("s3://{}/{key}", self.location.bucket),
    +            "import/export file access"
    +        );
    +    }
    +
    +    /// Whether a `CompleteMultipartUpload` that failed with `error` had in
    +    /// fact completed. When the answer to a completion that landed is lost,
    +    /// the request is sent again and S3 answers `NoSuchUpload`, because the
    +    /// upload is gone once it completes; the object is then at `key`. The
    +    /// key carries the operation and the object index, but an earlier run
    +    /// of the same operation or another writer can have put an object
    +    /// there, so the object counts as this upload's only when its length is
    +    /// `bytes`, the bytes of the parts this upload sent. A `HeadObject` that
    +    /// still fails transiently at the end of its retries is the server's
    +    /// failure, as for every other call; a 404, a 403 (no `s3:GetObject`)
    +    /// or any other client error leaves the `NoSuchUpload` to surface.
    +    async fn completed_before(
    +        &self,
    +        error: &aws_sdk_s3::error::SdkError,
    +        key: &str,
    +        bytes: u64,
    +    ) -> Result
    +    where
    +        E: aws_sdk_s3::error::ProvideErrorMetadata,
    +    {
    +        use aws_sdk_s3::error::ProvideErrorMetadata as _;
    +        if error.code() != Some("NoSuchUpload") {
    +            return Ok(false);
    +        }
    +        let location = &self.location;
    +        let head = match self
    +            .clients
    +            .call(&location.bucket, |client| {
    +                client
    +                    .head_object()
    +                    .bucket(&location.bucket)
    +                    .key(key)
    +                    .set_expected_bucket_owner(location.owner.clone())
    +                    .send()
    +            })
    +            .await
    +        {
    +            Ok(head) => head,
    +            Err(head_error) if is_transient(&head_error) => {
    +                return Err(sink_error(
    +                    &head_error,
    +                    location,
    +                    key,
    +                    Tail::S3,
    +                    "report completion check",
    +                ));
    +            }
    +            Err(_) => return Ok(false),
    +        };
    +        let length = head.content_length();
    +        if length.and_then(|length| u64::try_from(length).ok()) != Some(bytes) {
    +            tracing::warn!(
    +                account_id = %location.account,
    +                bucket = %location.bucket,
    +                key = %key,
    +                object_bytes = length.unwrap_or(-1),
    +                uploaded_bytes = bytes,
    +                "S3 report upload completion was answered NoSuchUpload and the object at the key is not the size this upload wrote"
    +            );
    +            return Ok(false);
    +        }
    +        tracing::warn!(
    +            account_id = %location.account,
    +            bucket = %location.bucket,
    +            key = %key,
    +            "S3 report upload completion was answered NoSuchUpload and the object is in place; the earlier completion landed"
    +        );
    +        Ok(true)
    +    }
    +}
    +
    +impl ReportWriter for S3ReportWriter {
    +    fn write_page(&mut self, records: Vec) -> BoxFuture<'_, Result<(), SinkError>> {
    +        Box::pin(async move {
    +            if records.is_empty() {
    +                return Ok(());
    +            }
    +            if self.failed {
    +                return Err(SinkError::Io(std::io::Error::other(
    +                    "report writer already failed",
    +                )));
    +            }
    +            let written = self.write(records).await;
    +            self.failed = written.is_err();
    +            written
    +        })
    +    }
    +
    +    fn finish(self: Box) -> BoxFuture<'static, Result> {
    +        Box::pin(async move {
    +            let mut this = self;
    +            if this.failed {
    +                return Err(SinkError::Io(std::io::Error::other(
    +                    "report writer already failed",
    +                )));
    +            }
    +            // On an error the writer is dropped here, which aborts the
    +            // uploads not yet completed.
    +            this.complete().await
    +        })
    +    }
    +
    +    fn object_index(&self) -> u32 {
    +        // The open object was created as index `closed.len()`, and once it
    +        // rolls into `closed` the next object takes that index.
    +        u32::try_from(self.closed.len()).unwrap_or(u32::MAX)
    +    }
    +
    +    #[cfg(test)]
    +    fn set_rotation_threshold(&mut self, bytes: u64) {
    +        self.threshold = bytes;
    +    }
    +
    +    #[cfg(test)]
    +    fn open_object_bytes(&self) -> u64 {
    +        self.open
    +            .as_ref()
    +            .map_or(0, |object| object.encoder.get_ref().written)
    +    }
    +}
    +
    +impl Drop for S3ReportWriter {
    +    fn drop(&mut self) {
    +        if self.started.is_empty() {
    +            return;
    +        }
    +        let started = std::mem::take(&mut self.started);
    +        let Ok(runtime) = tokio::runtime::Handle::try_current() else {
    +            tracing::warn!(
    +                account_id = %self.location.account,
    +                bucket = %self.location.bucket,
    +                uploads = started.len(),
    +                "S3 multipart uploads left for the bucket's lifecycle rule: no runtime to abort them"
    +            );
    +            return;
    +        };
    +        let clients = Arc::clone(&self.clients);
    +        let location = self.location.clone();
    +        runtime.spawn(async move {
    +            for (key, id) in started {
    +                abort(&clients, &location, &key, &id, Phase::Run).await;
    +            }
    +        });
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/source/chain.rs b/crates/engine/src/bulk_put/source/chain.rs
    new file mode 100644
    index 000000000..da837e430
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/chain.rs
    @@ -0,0 +1,536 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The decode chain every input store reads an object through: the
    +//! enumerated size as the read ceiling, the counting and hashing reader, the
    +//! codec's magic check and decoder, and the batched line reader that serves
    +//! capped lines off the blocking pool, with the JSON prefix classifier that
    +//! decides the fault class of an invalid byte. A store opens the object and
    +//! hands its raw bytes to [`line_reader`].
    +
    +use std::collections::VecDeque;
    +use std::io::{BufRead, BufReader, Read};
    +use std::sync::atomic::{AtomicU64, Ordering};
    +use std::sync::{Arc, Mutex};
    +
    +use super::codec::{CountingReader, ZstdFrames};
    +use super::*;
    +
    +/// Lines pulled per blocking hop by the line reader.
    +const LINE_BATCH: usize = 64;
    +
    +/// Byte bound for one batch of buffered lines: the fill stops once the
    +/// buffered lines reach this many bytes, whatever the line count, so a
    +/// batch of near-cap lines cannot hold hundreds of megabytes per open
    +/// file. Serving order and fault delivery are unchanged; the next fill
    +/// starts earlier.
    +const LINE_BATCH_BYTES: usize = 8 * 1024 * 1024;
    +
    +/// Build the line reader over the raw bytes of one opened input object.
    +/// The reader yields lines from at most `size` bytes, the enumerated
    +/// compressed size; bytes past it read as end of file. Under `Gzip` or
    +/// `Zstd` the stream is decompressed while reading, every member or frame
    +/// of a concatenated stream included. Blocking: it peeks the codec's
    +/// magic bytes, so callers run it on the blocking pool.
    +pub(crate) fn line_reader(
    +    raw: Box,
    +    size: u64,
    +    compression: InputCodec,
    +) -> Result {
    +    // The enumerated size is the read ceiling: bytes appended after
    +    // enumeration are never read, and hitting the ceiling reads as end
    +    // of file.
    +    let limited = raw.take(size);
    +    let count = Arc::new(AtomicU64::new(0));
    +    let hasher = Arc::new(Mutex::new(::new()));
    +    let counting = CountingReader {
    +        inner: limited,
    +        count: Arc::clone(&count),
    +        hasher: Arc::clone(&hasher),
    +    };
    +    let inner: Box = if compression.decodes() {
    +        // A file that does not start with the codec's magic is not a
    +        // compressed object at all (its own fault class); a stream that
    +        // fails after the header is corrupt. Peek the magic through the
    +        // counting reader so the position stays right.
    +        let magic = compression.magic();
    +        let mut counting = BufReader::new(counting);
    +        let head = counting
    +            .fill_buf()
    +            .map_err(|e| StoreFault::of_io(&e).map_or(SourceError::Io(e), SourceError::Store))?;
    +        if head.len() < magic.len() || &head[..magic.len()] != magic {
    +            return Err(SourceError::NotCompressed);
    +        }
    +        match compression {
    +            // `MultiGzDecoder` reads every member of a concatenated gzip
    +            // stream (RFC 1952); a single-member decoder would silently
    +            // stop at the first trailer.
    +            InputCodec::Gzip => {
    +                Box::new(BufReader::new(flate2::read::MultiGzDecoder::new(counting)))
    +            }
    +            // Every frame of a concatenated zstd stream is read (RFC
    +            // 8878); a truncated or corrupt frame surfaces as a read
    +            // error, the same contract as the gzip path. A header that
    +            // does not parse is corrupt data: the magic was already
    +            // checked, so this is not the `NotCompressed` class.
    +            InputCodec::Zstd => {
    +                let decoder = ZstdFrames::new(counting).map_err(|error| {
    +                    if error.kind() == std::io::ErrorKind::InvalidData {
    +                        SourceError::DecodeFailed
    +                    } else {
    +                        SourceError::Io(error)
    +                    }
    +                })?;
    +                Box::new(BufReader::new(decoder))
    +            }
    +            InputCodec::None => unreachable!("decodes() is false for None"),
    +        }
    +    } else {
    +        Box::new(BufReader::new(counting))
    +    };
    +    Ok(ChainLineReader {
    +        inner: Some(inner),
    +        filling: None,
    +        buffered: VecDeque::new(),
    +        line_ends: VecDeque::new(),
    +        last_line_end: 0,
    +        decoded_offset: 0,
    +        fault: None,
    +        compressed_bytes: count,
    +        hasher,
    +        at_end: false,
    +        codec: compression,
    +    })
    +}
    +
    +/// Whether a JSON tokenizer that has consumed `prefix` (valid UTF-8 up to
    +/// the first invalid byte) is inside a string literal of a well-formed
    +/// single-value JSON prefix. The tokenizer walks the prefix as the hosted
    +/// one does, left to right: a bareword, a second top-level value, or any
    +/// structural error before the invalid byte is an unexpected token and wins
    +/// over the encoding fault (measured on the service:
    +/// `name,"value` then an invalid byte, and a complete object followed by a
    +/// stray quote then an invalid byte, both answer "Unexpected token.").
    +fn inside_string_literal(prefix: &[u8]) -> bool {
    +    #[derive(Clone, Copy, PartialEq)]
    +    enum Frame {
    +        Object,
    +        Array,
    +    }
    +    #[derive(Clone, Copy, PartialEq)]
    +    enum Expect {
    +        Value,
    +        KeyOrEnd,
    +        Key,
    +        Colon,
    +        CommaOrEnd,
    +        Done,
    +    }
    +    let mut stack: Vec = Vec::new();
    +    let mut expect = Expect::Value;
    +    let mut i = 0;
    +    let n = prefix.len();
    +
    +    // Consume a string body starting after the opening quote. Returns None
    +    // when the prefix ends inside the string.
    +    fn skip_string(prefix: &[u8], mut i: usize) -> Option {
    +        while i < prefix.len() {
    +            match prefix[i] {
    +                b'\\' => i += 2,
    +                b'"' => return Some(i + 1),
    +                _ => i += 1,
    +            }
    +        }
    +        None
    +    }
    +
    +    // After a value closes, what the enclosing frame expects next.
    +    fn after_value(stack: &[Frame]) -> Expect {
    +        match stack.last() {
    +            None => Expect::Done,
    +            Some(_) => Expect::CommaOrEnd,
    +        }
    +    }
    +
    +    while i < n {
    +        let b = prefix[i];
    +        if b.is_ascii_whitespace() {
    +            i += 1;
    +            continue;
    +        }
    +        match expect {
    +            Expect::Done => return false,
    +            Expect::Value => match b {
    +                b'"' => match skip_string(prefix, i + 1) {
    +                    Some(next) => {
    +                        i = next;
    +                        expect = after_value(&stack);
    +                    }
    +                    None => return true,
    +                },
    +                b'{' => {
    +                    stack.push(Frame::Object);
    +                    expect = Expect::KeyOrEnd;
    +                    i += 1;
    +                }
    +                b'[' => {
    +                    stack.push(Frame::Array);
    +                    i += 1;
    +                    if i < n && prefix[i] == b']' {
    +                        stack.pop();
    +                        expect = after_value(&stack);
    +                        i += 1;
    +                    } else {
    +                        expect = Expect::Value;
    +                    }
    +                }
    +                b'-' | b'0'..=b'9' => {
    +                    while i < n
    +                        && matches!(prefix[i], b'-' | b'+' | b'.' | b'e' | b'E' | b'0'..=b'9')
    +                    {
    +                        i += 1;
    +                    }
    +                    expect = after_value(&stack);
    +                }
    +                b't' | b'f' | b'n' => {
    +                    let word: &[u8] = match b {
    +                        b't' => b"true",
    +                        b'f' => b"false",
    +                        _ => b"null",
    +                    };
    +                    let avail = (n - i).min(word.len());
    +                    if prefix[i..i + avail] != word[..avail] {
    +                        return false;
    +                    }
    +                    i += avail;
    +                    if avail < word.len() {
    +                        // The prefix ends inside the literal; the invalid
    +                        // byte follows a partial bareword, outside a string.
    +                        return false;
    +                    }
    +                    expect = after_value(&stack);
    +                }
    +                _ => return false,
    +            },
    +            Expect::KeyOrEnd | Expect::Key => match b {
    +                b'"' => match skip_string(prefix, i + 1) {
    +                    Some(next) => {
    +                        i = next;
    +                        expect = Expect::Colon;
    +                    }
    +                    None => return true,
    +                },
    +                b'}' if expect == Expect::KeyOrEnd => {
    +                    if stack.pop() != Some(Frame::Object) {
    +                        return false;
    +                    }
    +                    expect = after_value(&stack);
    +                    i += 1;
    +                }
    +                _ => return false,
    +            },
    +            Expect::Colon => {
    +                if b != b':' {
    +                    return false;
    +                }
    +                expect = Expect::Value;
    +                i += 1;
    +            }
    +            Expect::CommaOrEnd => match (b, stack.last()) {
    +                (b',', Some(Frame::Object)) => {
    +                    expect = Expect::Key;
    +                    i += 1;
    +                }
    +                (b',', Some(Frame::Array)) => {
    +                    expect = Expect::Value;
    +                    i += 1;
    +                }
    +                (b'}', Some(Frame::Object)) | (b']', Some(Frame::Array)) => {
    +                    stack.pop();
    +                    expect = after_value(&stack);
    +                    i += 1;
    +                }
    +                _ => return false,
    +            },
    +        }
    +    }
    +    // The prefix ended between tokens: the invalid byte is not inside a
    +    // string literal.
    +    false
    +}
    +
    +/// What one blocking fill ended with.
    +enum Fill {
    +    /// The batch filled; the file has more.
    +    More,
    +    /// End of file.
    +    Eof,
    +    /// A fault, delivered to the caller after the buffered lines.
    +    Fault(SourceError),
    +}
    +
    +/// What one blocking fill hands back: the chain, the lines it read with
    +/// their end offsets, how it ended, and the decoded offset after them.
    +type Filled = (Box, Vec<(String, u64)>, Fill, u64);
    +
    +/// Line reader over one input object's decode chain. One `spawn_blocking`
    +/// hop pulls up to [`LINE_BATCH`] lines, or [`LINE_BATCH_BYTES`] bytes of them,
    +/// whichever comes first, into a buffer and `next_line` serves from it, so
    +/// file reads and decompression never run on the async runtime, the
    +/// blocking pool is not visited once per line, and one batch is bounded in
    +/// memory. After end of file,
    +/// `LineTooLong`, or an I/O error the inner reader is dropped and, once the
    +/// fault is delivered, every later call answers `Ok(None)`: both fault
    +/// classes abandon the file, so nothing may read past the fault point.
    +///
    +/// The chain moves into the blocking fill while it runs. The fill's handle
    +/// stays in the reader, so a `next_line` dropped before the fill returns
    +/// leaves the fill running and the next call awaits the same fill and
    +/// serves its lines; dropping the call never loses the chain.
    +pub(crate) struct ChainLineReader {
    +    pub(super) inner: Option>,
    +    /// The blocking fill in progress, which holds the chain until it
    +    /// returns.
    +    filling: Option>,
    +    buffered: VecDeque,
    +    /// The end offset of each buffered line, in the same order: the file
    +    /// offset for a plain object, the compressed bytes consumed when a
    +    /// decoder sits in the chain.
    +    line_ends: VecDeque,
    +    /// The end offset of the last line handed to the caller, which the
    +    /// runner reads through `last_line_end`.
    +    last_line_end: u64,
    +    /// Raw bytes of the decoded stream consumed by the lines read so far,
    +    /// terminators included. For a plain object this is the file offset;
    +    /// for a decoded one the compressed counter is used instead.
    +    decoded_offset: u64,
    +    fault: Option,
    +    compressed_bytes: Arc,
    +    /// The running digest the counting reader feeds; see `CountingReader`.
    +    hasher: Arc>,
    +    /// Set when a fill reached end of file, so the digest covers the whole
    +    /// stream. A fault never sets it.
    +    at_end: bool,
    +    /// The codec `inner` decodes, which decides how a read error is
    +    /// classified (see `read_capped_line`).
    +    codec: InputCodec,
    +}
    +
    +impl ChainLineReader {
    +    /// The decoded stream, for a caller that counts its lines rather than
    +    /// reading them; `None` once the reader reached its end or a fault.
    +    pub(crate) fn into_inner(mut self) -> Option> {
    +        self.inner.take()
    +    }
    +
    +    /// Start one blocking fill of up to [`LINE_BATCH`] lines, or
    +    /// [`LINE_BATCH_BYTES`] bytes of them, over `inner`.
    +    fn spawn_fill(
    +        &self,
    +        mut inner: Box,
    +        max_line_bytes: usize,
    +    ) -> tokio::task::JoinHandle {
    +        let codec = self.codec;
    +        let decoded_start = self.decoded_offset;
    +        let compressed = Arc::clone(&self.compressed_bytes);
    +        tokio::task::spawn_blocking(move || {
    +            let mut lines: Vec<(String, u64)> = Vec::with_capacity(LINE_BATCH);
    +            let mut batch_bytes = 0usize;
    +            let mut fill = Fill::More;
    +            let mut decoded = decoded_start;
    +            while lines.len() < LINE_BATCH && batch_bytes < LINE_BATCH_BYTES {
    +                match read_capped_line(&mut inner, max_line_bytes, codec) {
    +                    Ok(Some((line, raw))) => {
    +                        batch_bytes = batch_bytes.saturating_add(line.len());
    +                        decoded = decoded.saturating_add(raw);
    +                        // A plain object's line ends at its own file
    +                        // offset; under a decoder the stored object's
    +                        // offsets are the compressed bytes the chain has
    +                        // pulled, which is what the progress counter
    +                        // reports.
    +                        let end = if codec.decodes() {
    +                            compressed.load(Ordering::Relaxed)
    +                        } else {
    +                            decoded
    +                        };
    +                        lines.push((line, end));
    +                    }
    +                    Ok(None) => {
    +                        fill = Fill::Eof;
    +                        break;
    +                    }
    +                    Err(fault) => {
    +                        fill = Fill::Fault(fault);
    +                        break;
    +                    }
    +                }
    +            }
    +            (inner, lines, fill, decoded)
    +        })
    +    }
    +}
    +
    +impl LineReader for ChainLineReader {
    +    fn next_line(
    +        &mut self,
    +        max_line_bytes: usize,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        Box::pin(async move {
    +            if let Some(line) = self.buffered.pop_front() {
    +                if let Some(end) = self.line_ends.pop_front() {
    +                    self.last_line_end = end;
    +                }
    +                return Ok(Some(line));
    +            }
    +            if let Some(fault) = self.fault.take() {
    +                return Err(fault);
    +            }
    +            if self.filling.is_none() {
    +                let Some(inner) = self.inner.take() else {
    +                    return Ok(None);
    +                };
    +                self.filling = Some(self.spawn_fill(inner, max_line_bytes));
    +            }
    +            // The handle stays in the reader while it is awaited, so a call
    +            // dropped here leaves the fill for the next call to await.
    +            let joined = match self.filling.as_mut() {
    +                Some(filling) => filling.await,
    +                None => return Ok(None),
    +            };
    +            self.filling = None;
    +            let (inner, lines, fill, decoded_offset) =
    +                joined.map_err(|e| SourceError::Io(std::io::Error::other(e)))?;
    +            match fill {
    +                Fill::More => self.inner = Some(inner),
    +                Fill::Eof => self.at_end = true,
    +                Fill::Fault(fault) => self.fault = Some(fault),
    +            }
    +            self.decoded_offset = decoded_offset;
    +            let (lines, ends): (VecDeque, VecDeque) = lines.into_iter().unzip();
    +            self.buffered = lines;
    +            self.line_ends = ends;
    +            match self.buffered.pop_front() {
    +                Some(line) => {
    +                    if let Some(end) = self.line_ends.pop_front() {
    +                        self.last_line_end = end;
    +                    }
    +                    Ok(Some(line))
    +                }
    +                None => match self.fault.take() {
    +                    Some(fault) => Err(fault),
    +                    None => Ok(None),
    +                },
    +            }
    +        })
    +    }
    +
    +    fn bytes_consumed(&self) -> u64 {
    +        self.compressed_bytes.load(Ordering::Relaxed)
    +    }
    +
    +    fn last_line_end(&self) -> u64 {
    +        self.last_line_end
    +    }
    +
    +    fn etag(&self) -> Option {
    +        if !self.at_end {
    +            return None;
    +        }
    +        let hasher = self.hasher.lock().ok()?;
    +        Some(format!("{:x}", md5::Digest::finalize(hasher.clone())))
    +    }
    +}
    +
    +/// Read one line of at most `max_line_bytes` bytes, excluding the
    +/// terminator, with the raw bytes the read consumed for it (the line, its
    +/// terminator and a stripped carriage return included). `Ok(None)` at end
    +/// of file. The accumulation buffer never
    +/// grows past the cap: the moment the bytes seen for the current line
    +/// exceed it, the read stops with `LineTooLong` and consumes nothing
    +/// further, which the caller treats as a parse-level abandon of the file.
    +fn read_capped_line(
    +    reader: &mut impl BufRead,
    +    max_line_bytes: usize,
    +    codec: InputCodec,
    +) -> Result, SourceError> {
    +    let mut line: Vec = Vec::new();
    +    let mut raw: u64 = 0;
    +    loop {
    +        let (consumed, done) = {
    +            // A decoder reports a corrupt or truncated stream as
    +            // InvalidData or UnexpectedEof on the read; a plain file read
    +            // never produces those kinds, so they classify the compressed
    +            // stream rather than the filesystem. The class depends on the
    +            // codec: a gzip stream decodes up to the damage, so the hosted
    +            // service reports the cut as an unreadable line; a zstd frame
    +            // that fails is reported as a failed decompression.
    +            let available = reader.fill_buf().map_err(|e| {
    +                // A fault the store rendered (an object store read that
    +                // failed mid-object) keeps its code and message under
    +                // every codec.
    +                if let Some(fault) = StoreFault::of_io(&e) {
    +                    return SourceError::Store(fault);
    +                }
    +                let decoder_fault = matches!(
    +                    e.kind(),
    +                    std::io::ErrorKind::InvalidData | std::io::ErrorKind::UnexpectedEof
    +                );
    +                match codec {
    +                    InputCodec::Gzip if decoder_fault => SourceError::Corrupt,
    +                    InputCodec::Zstd if decoder_fault => SourceError::DecodeFailed,
    +                    _ => SourceError::Io(e),
    +                }
    +            })?;
    +            if available.is_empty() {
    +                if line.is_empty() {
    +                    return Ok(None);
    +                }
    +                (0, true)
    +            } else if let Some(newline) = available.iter().position(|&b| b == b'\n') {
    +                if line.len() + newline > max_line_bytes {
    +                    return Err(SourceError::LineTooLong);
    +                }
    +                line.extend_from_slice(&available[..newline]);
    +                (newline + 1, true)
    +            } else {
    +                // No terminator in the buffer, so all of it belongs to the
    +                // current line: the line is already too long once the
    +                // running total passes the cap.
    +                if line.len() + available.len() > max_line_bytes {
    +                    return Err(SourceError::LineTooLong);
    +                }
    +                line.extend_from_slice(available);
    +                (available.len(), false)
    +            }
    +        };
    +        if consumed > 0 {
    +            reader.consume(consumed);
    +            raw = raw.saturating_add(consumed as u64);
    +        }
    +        if done {
    +            break;
    +        }
    +    }
    +    if line.last() == Some(&b'\r') {
    +        line.pop();
    +    }
    +    // JSON text is UTF-8, so a line that is not valid UTF-8 is not
    +    // well-formed JSON. It is reported as its own fault, which the runner
    +    // treats as parse-level (the class the spec gives malformed content);
    +    // decoding it lossily would write the customer's data altered, with
    +    // no counter, record or log line to say so. Which fault depends on
    +    // where the first invalid byte sits (measured on the service): inside a
    +    // string literal it is the UTF-8 fault, anywhere else the line is an
    +    // unexpected token.
    +    match String::from_utf8(line) {
    +        Ok(s) => Ok(Some((s, raw))),
    +        Err(e) => {
    +            let valid_up_to = e.utf8_error().valid_up_to();
    +            if inside_string_literal(&e.as_bytes()[..valid_up_to]) {
    +                Err(SourceError::InvalidUtf8)
    +            } else {
    +                Err(SourceError::Corrupt)
    +            }
    +        }
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/source/codec.rs b/crates/engine/src/bulk_put/source/codec.rs
    new file mode 100644
    index 000000000..2b40e49b6
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/codec.rs
    @@ -0,0 +1,141 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Byte-stream adapters shared by the reader: the counting, hashing
    +//! reader and the multi-frame zstd decoder.
    +
    +use std::io::{BufRead, Read};
    +use std::sync::atomic::{AtomicU64, Ordering};
    +use std::sync::{Arc, Mutex};
    +
    +/// Counts raw file bytes as they are pulled, from beneath the gzip decoder
    +/// when one is stacked on top, so the figure is compressed bytes and
    +/// comparable to the enumerated size. Buffered readahead can run past the
    +/// lines handed out so far; the enumerated size stays the ceiling the
    +/// runner reports against.
    +pub(super) struct CountingReader {
    +    pub(super) inner: R,
    +    pub(super) count: Arc,
    +    /// Running MD5 of the raw bytes read, the file's entity tag once the
    +    /// stream is consumed whole. Shared with the line reader, which reports
    +    /// it at end of file. A poisoned lock (a panic mid-update) means no tag
    +    /// is reported and the manifest hashes the file instead.
    +    pub(super) hasher: Arc>,
    +}
    +
    +impl Read for CountingReader {
    +    fn read(&mut self, buf: &mut [u8]) -> std::io::Result {
    +        let n = self.inner.read(buf)?;
    +        self.count
    +            .fetch_add(u64::try_from(n).unwrap_or(u64::MAX), Ordering::Relaxed);
    +        if let Ok(mut hasher) = self.hasher.lock() {
    +            md5::Digest::update(&mut *hasher, &buf[..n]);
    +        }
    +        Ok(n)
    +    }
    +}
    +
    +/// Reads every frame of a concatenated zstd stream (RFC 8878 allows frames
    +/// back to back, and the reference tool writes them that way for appended
    +/// inputs), the way `MultiGzDecoder` reads every gzip member. The one-frame
    +/// `StreamingDecoder` answers end of file at the first frame's end; this
    +/// adapter starts the next frame while the source still has bytes. A
    +/// decoder failure in the first frame is reported as `InvalidData` so
    +/// `read_capped_line` classifies it as a failed decompression; a failure
    +/// in a later frame ends the file instead, with the remaining bytes read
    +/// and discarded so the byte count and the entity tag still cover the whole
    +/// object (measured on the service: a complete frame followed by a
    +/// truncated one is read as the first frame's rows alone,
    +/// with no failed row and every byte counted, where a truncated first
    +/// frame is one failed row). An I/O failure of the source underneath (any
    +/// kind other than the decoder's own end-of-input) keeps its kind and is
    +/// reported as the file's failure.
    +pub(super) struct ZstdFrames {
    +    source: R,
    +    decoder: ruzstd::decoding::FrameDecoder,
    +    /// Frames read to their end so far.
    +    completed_frames: u64,
    +}
    +
    +impl ZstdFrames {
    +    pub(super) fn new(mut source: R) -> std::io::Result {
    +        let mut decoder = ruzstd::decoding::FrameDecoder::new();
    +        decoder.init(&mut source).map_err(Self::classify)?;
    +        Ok(Self {
    +            source,
    +            decoder,
    +            completed_frames: 0,
    +        })
    +    }
    +
    +    /// Map a decoder error: a source I/O error inside it keeps its kind
    +    /// (a read of the jailed file failed), anything else is corrupt data.
    +    fn classify(error: ruzstd::decoding::errors::FrameDecoderError) -> std::io::Error {
    +        let mut cause: Option<&(dyn std::error::Error + 'static)> = Some(&error);
    +        while let Some(current) = cause {
    +            if let Some(io) = current.downcast_ref::()
    +                && io.kind() != std::io::ErrorKind::UnexpectedEof
    +            {
    +                // A fault the store rendered, and a store failure that
    +                // outlasted its retries, travel on unchanged.
    +                if let Some(fault) = super::StoreFault::of_io(io) {
    +                    return std::io::Error::other(fault);
    +                }
    +                if let Some(unavailable) = io
    +                    .get_ref()
    +                    .and_then(|inner| inner.downcast_ref::())
    +                {
    +                    return std::io::Error::other(unavailable.clone());
    +                }
    +                return std::io::Error::new(io.kind(), error.to_string());
    +            }
    +            cause = current.source();
    +        }
    +        std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string())
    +    }
    +
    +    /// A decoder failure past the first frame ends the file (see the type
    +    /// doc); the bytes left in the source are consumed so the counters
    +    /// and the tag describe the whole object. A source I/O error is still
    +    /// the file's failure.
    +    fn after_decoder_error(&mut self, error: std::io::Error) -> std::io::Result {
    +        if self.completed_frames == 0 || error.kind() != std::io::ErrorKind::InvalidData {
    +            return Err(error);
    +        }
    +        std::io::copy(&mut self.source, &mut std::io::sink())?;
    +        Ok(0)
    +    }
    +}
    +
    +impl Read for ZstdFrames {
    +    fn read(&mut self, buf: &mut [u8]) -> std::io::Result {
    +        use ruzstd::decoding::BlockDecodingStrategy;
    +        loop {
    +            if self.decoder.is_finished() && self.decoder.can_collect() == 0 {
    +                // The frame is drained: end of file if the source is spent,
    +                // otherwise the next frame begins here.
    +                self.completed_frames += 1;
    +                if self.source.fill_buf()?.is_empty() {
    +                    return Ok(0);
    +                }
    +                if let Err(error) = self.decoder.reset(&mut self.source).map_err(Self::classify) {
    +                    return self.after_decoder_error(error);
    +                }
    +                continue;
    +            }
    +            while self.decoder.can_collect() == 0 && !self.decoder.is_finished() {
    +                if let Err(error) = self
    +                    .decoder
    +                    .decode_blocks(&mut self.source, BlockDecodingStrategy::UptoBlocks(1))
    +                    .map_err(Self::classify)
    +                {
    +                    return self.after_decoder_error(error);
    +                }
    +            }
    +            let n = self.decoder.read(buf)?;
    +            if n > 0 || buf.is_empty() {
    +                return Ok(n);
    +            }
    +        }
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/source/contract/fs_fixture.rs b/crates/engine/src/bulk_put/source/contract/fs_fixture.rs
    new file mode 100644
    index 000000000..2e0e90df6
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/contract/fs_fixture.rs
    @@ -0,0 +1,124 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The filesystem store's fixture: a fresh store root under the system temp
    +//! directory, removed when the fixture drops. A bucket is a directory under
    +//! the caller's account directory and an object a file beneath it, at the
    +//! key read as a relative path.
    +
    +use std::path::{Path, PathBuf};
    +use std::sync::atomic::{AtomicUsize, Ordering};
    +
    +use super::super::tests::Root;
    +use super::super::{FsSink, FsSource};
    +use super::{ACCOUNT, BulkSink, BulkSource, StoreFixture};
    +
    +pub(super) struct FsFixture {
    +    root: Root,
    +    source: FsSource,
    +    sink: FsSink,
    +    buckets: AtomicUsize,
    +}
    +
    +impl FsFixture {
    +    pub(super) fn new() -> Self {
    +        let root = Root::new();
    +        Self {
    +            source: FsSource { root: root.root() },
    +            sink: FsSink { root: root.root() },
    +            root,
    +            buckets: AtomicUsize::new(0),
    +        }
    +    }
    +
    +    /// The path of `key` in `bucket`. A key is a relative path here; the
    +    /// cases give keys with a leading `/` to the S3 store only.
    +    fn path(&self, bucket: &str, key: &str) -> PathBuf {
    +        assert!(!key.starts_with('/'), "{key:?} is not a relative path");
    +        self.root.root().join(ACCOUNT).join(bucket).join(key)
    +    }
    +}
    +
    +/// The regular files beneath `dir`, as paths relative to `base` joined with
    +/// `/`.
    +fn walk(base: &Path, dir: &Path, out: &mut Vec) {
    +    let Ok(entries) = std::fs::read_dir(dir) else {
    +        return;
    +    };
    +    for entry in entries {
    +        let path = entry.unwrap().path();
    +        let kind = std::fs::symlink_metadata(&path).unwrap().file_type();
    +        if kind.is_dir() {
    +            walk(base, &path, out);
    +        } else if kind.is_file() {
    +            let relative = path.strip_prefix(base).unwrap();
    +            let parts: Vec = relative
    +                .components()
    +                .map(|part| part.as_os_str().to_string_lossy().into_owned())
    +                .collect();
    +            out.push(parts.join("/"));
    +        }
    +    }
    +}
    +
    +impl StoreFixture for FsFixture {
    +    const S3: bool = false;
    +
    +    fn source(&self) -> &dyn BulkSource {
    +        &self.source
    +    }
    +
    +    fn sink(&self) -> &dyn BulkSink {
    +        &self.sink
    +    }
    +
    +    async fn bucket(&self) -> String {
    +        let name = format!("bucket-{}", self.buckets.fetch_add(1, Ordering::Relaxed));
    +        self.root.bucket_dir(ACCOUNT, &name);
    +        name
    +    }
    +
    +    fn missing_bucket(&self) -> String {
    +        "bucket-missing".to_owned()
    +    }
    +
    +    async fn put(&self, bucket: &str, key: &str, body: &[u8]) {
    +        let path = self.path(bucket, key);
    +        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    +        std::fs::write(path, body).unwrap();
    +    }
    +
    +    async fn get(&self, bucket: &str, key: &str) -> Option> {
    +        match std::fs::read(self.path(bucket, key)) {
    +            Ok(body) => Some(body),
    +            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
    +            Err(error) => panic!("reading {key:?}: {error}"),
    +        }
    +    }
    +
    +    async fn keys(&self, bucket: &str) -> Vec {
    +        let base = self.root.root().join(ACCOUNT).join(bucket);
    +        let mut keys = Vec::new();
    +        walk(&base, &base, &mut keys);
    +        keys.sort();
    +        keys
    +    }
    +
    +    async fn pending(&self, bucket: &str) -> Vec {
    +        // The report writer and the manifest write render under
    +        // `{name}.tmp-{owner}` and rename at the end.
    +        self.keys(bucket)
    +            .await
    +            .into_iter()
    +            .filter(|key| {
    +                key.rsplit('/')
    +                    .next()
    +                    .is_some_and(|name| name.contains(".tmp-"))
    +            })
    +            .collect()
    +    }
    +
    +    async fn cleanup(self) {
    +        // The root is removed when the fixture drops.
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/source/contract/list.rs b/crates/engine/src/bulk_put/source/contract/list.rs
    new file mode 100644
    index 000000000..4e1bc018a
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/contract/list.rs
    @@ -0,0 +1,256 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Cases for the input side at Start: the bucket check and the listing.
    +
    +use super::super::InputCodec;
    +use super::super::tests::read_all;
    +use super::*;
    +
    +/// A bucket that exists passes the check, with no prefix, with a prefix
    +/// that holds objects, and with one that holds none.
    +pub(super) async fn the_bucket_check_passes_a_bucket_that_exists(f: &F) {
    +    let bucket = f.bucket().await;
    +    f.source().check_bucket(&at(&bucket), "").await.unwrap();
    +    f.put(&bucket, "in/part-0.json", &rows(0, 1)).await;
    +    f.source().check_bucket(&at(&bucket), "in/").await.unwrap();
    +    f.source()
    +        .check_bucket(&at(&bucket), "nothing/")
    +        .await
    +        .unwrap();
    +}
    +
    +/// A bucket that does not exist is `S3NoSuchBucket`.
    +pub(super) async fn the_bucket_check_answers_a_missing_bucket_with_no_such_bucket<
    +    F: StoreFixture,
    +>(
    +    f: &F,
    +) {
    +    let missing = f.missing_bucket();
    +    let error = f
    +        .source()
    +        .check_bucket(&at(&missing), "in/")
    +        .await
    +        .expect_err("the bucket does not exist");
    +    assert_location_fault::(
    +        &source_fault(error),
    +        "S3NoSuchBucket",
    +        NO_SUCH_BUCKET,
    +        Tail::AmazonS3 {
    +            status: 404,
    +            code: "NoSuchBucket",
    +        },
    +    );
    +}
    +
    +/// The prefix matches keys byte by byte: `orders/2026` matches
    +/// `orders/2026-09-10.json` and everything under `orders/2026/`, and nothing
    +/// that only shares the first bytes up to a different one.
    +pub(super) async fn the_listing_matches_the_prefix_byte_wise(f: &F) {
    +    let bucket = f.bucket().await;
    +    for key in [
    +        "orders/2026-09-10.json",
    +        "orders/2026/nested/part.json",
    +        "orders/2025-12-31.json",
    +        "orders2026.json",
    +        "other/orders/2026-09-10.json",
    +    ] {
    +        f.put(&bucket, key, &rows(0, 1)).await;
    +    }
    +    let files = f
    +        .source()
    +        .list(&at(&bucket), "orders/2026", 100)
    +        .await
    +        .unwrap();
    +    assert_eq!(
    +        paths(&files),
    +        ["orders/2026-09-10.json", "orders/2026/nested/part.json"]
    +    );
    +}
    +
    +/// The listing is in byte order of the whole key, whatever the nesting:
    +/// `a-b.json`, `a.json` and `a/b.json` sort by their second byte.
    +pub(super) async fn the_listing_is_in_byte_order_of_the_whole_key(f: &F) {
    +    let bucket = f.bucket().await;
    +    for key in [
    +        "b.json", "a/z.json", "B.json", "a.json", "a/b.json", "a-b.json",
    +    ] {
    +        f.put(&bucket, key, &rows(0, 1)).await;
    +    }
    +    let files = f.source().list(&at(&bucket), "", 100).await.unwrap();
    +    assert_eq!(
    +        paths(&files),
    +        [
    +            "B.json", "a-b.json", "a.json", "a/b.json", "a/z.json", "b.json"
    +        ]
    +    );
    +}
    +
    +/// Collecting more than `max_files` ends the listing with `max_files + 1`
    +/// entries, so the caller can tell the bound was passed; which of the
    +/// matches they are is the store's (the filesystem walk stops in directory
    +/// order), and they come in byte order. A listing within the bound is whole.
    +pub(super) async fn the_listing_stops_one_past_max_files(f: &F) {
    +    let bucket = f.bucket().await;
    +    let keys: Vec = (0..5)
    +        .map(|index| format!("in/part-{index}.json"))
    +        .collect();
    +    for (index, key) in keys.iter().enumerate() {
    +        f.put(&bucket, key, &rows(index, 1)).await;
    +    }
    +    let files = f.source().list(&at(&bucket), "in/", 2).await.unwrap();
    +    let listed = paths(&files);
    +    assert_eq!(listed.len(), 3, "{listed:?}");
    +    assert!(
    +        listed.windows(2).all(|pair| pair[0] < pair[1]),
    +        "{listed:?}"
    +    );
    +    assert!(
    +        listed.iter().all(|path| keys.iter().any(|key| key == path)),
    +        "{listed:?}"
    +    );
    +    let files = f.source().list(&at(&bucket), "in/", 4).await.unwrap();
    +    assert_eq!(files.len(), 5);
    +    let files = f.source().list(&at(&bucket), "in/", 5).await.unwrap();
    +    assert_eq!(paths(&files), keys);
    +}
    +
    +/// An empty prefix lists every object in the bucket, each with its size.
    +pub(super) async fn an_empty_prefix_lists_every_object_with_its_size(f: &F) {
    +    let bucket = f.bucket().await;
    +    let objects = [
    +        ("deep/er/part.json", rows(0, 3)),
    +        ("in/part.json", rows(3, 1)),
    +        ("top.json", rows(4, 2)),
    +    ];
    +    for (key, body) in &objects {
    +        f.put(&bucket, key, body).await;
    +    }
    +    let files = f.source().list(&at(&bucket), "", 100).await.unwrap();
    +    let listed: Vec<(&str, u64)> = files.iter().map(|f| (f.path.as_str(), f.size)).collect();
    +    let written: Vec<(&str, u64)> = objects
    +        .iter()
    +        .map(|(key, body)| (*key, body.len() as u64))
    +        .collect();
    +    assert_eq!(listed, written);
    +}
    +
    +/// A prefix nothing lies under lists nothing, and is not a fault.
    +pub(super) async fn a_prefix_that_matches_nothing_lists_nothing(f: &F) {
    +    let bucket = f.bucket().await;
    +    f.put(&bucket, "in/part.json", &rows(0, 1)).await;
    +    for prefix in ["nothing/", "in/part.json.gz", "in/q"] {
    +        let files = f.source().list(&at(&bucket), prefix, 100).await.unwrap();
    +        assert!(files.is_empty(), "{prefix:?} listed {files:?}");
    +    }
    +}
    +
    +/// On S3 the listing carries each object's entity tag, unquoted: for an
    +/// object written with one PUT, the hex MD5 of its bytes.
    +pub(super) async fn on_s3_the_listing_carries_each_objects_etag(f: &F) {
    +    let bucket = f.bucket().await;
    +    let bodies = [rows(0, 2), rows(2, 3)];
    +    for (index, body) in bodies.iter().enumerate() {
    +        f.put(&bucket, &format!("in/part-{index}.json"), body).await;
    +    }
    +    let files = f.source().list(&at(&bucket), "in/", 10).await.unwrap();
    +    let etags: Vec> = files.iter().map(|file| file.etag.clone()).collect();
    +    let digests: Vec> = bodies.iter().map(|body| Some(md5_hex(body))).collect();
    +    assert_eq!(etags, digests);
    +}
    +
    +/// The filesystem store lists no entity tag; the reader computes it from
    +/// the bytes it reads.
    +pub(super) async fn on_the_filesystem_store_the_listing_carries_no_etag(f: &F) {
    +    let bucket = f.bucket().await;
    +    f.put(&bucket, "in/part.json", &rows(0, 2)).await;
    +    let file = only_file(f, &bucket, "in/").await;
    +    assert_eq!(file.etag, None);
    +}
    +
    +/// Reads `file` whole and returns its lines.
    +async fn read_back(f: &F, bucket: &str, file: &SourceFile) -> Vec {
    +    let mut reader = f
    +        .source()
    +        .open(&at(bucket), &recorded(file), InputCodec::None)
    +        .await
    +        .unwrap();
    +    read_all(&mut reader, MAX_LINE).await
    +}
    +
    +/// On S3 a key may hold characters no path rule of the filesystem store
    +/// allows: a space, `+` and `%`, a backslash, a tab, non-ASCII letters, and
    +/// a zero-byte key ending in `/`. Each is listed under its own key and read
    +/// back from it.
    +pub(super) async fn on_s3_keys_with_spaces_backslashes_and_tabs_are_listed_and_read_back<
    +    F: StoreFixture,
    +>(
    +    f: &F,
    +) {
    +    let bucket = f.bucket().await;
    +    let objects = [
    +        ("in/a b+c%25.json", rows(0, 1)),
    +        ("in/back\\slash.json", rows(1, 1)),
    +        ("in/dir/", Vec::new()),
    +        ("in/tab\tkey.json", rows(2, 1)),
    +        ("in/\u{fc}nicode.json", rows(3, 1)),
    +    ];
    +    for (key, body) in &objects {
    +        f.put(&bucket, key, body).await;
    +    }
    +    let files = f.source().list(&at(&bucket), "in/", 100).await.unwrap();
    +    let mut expected: Vec<&str> = objects.iter().map(|(key, _)| *key).collect();
    +    expected.sort_unstable();
    +    assert_eq!(paths(&files), expected);
    +    for file in &files {
    +        let (_, body) = objects.iter().find(|(key, _)| *key == file.path).unwrap();
    +        let text = String::from_utf8(body.clone()).unwrap();
    +        let want: Vec<&str> = text.lines().collect();
    +        assert_eq!(read_back(f, &bucket, file).await, want, "{:?}", file.path);
    +    }
    +}
    +
    +/// On S3 a key may hold a doubled `/`, a leading `/`, and `.` and `..`
    +/// segments. The listing gives the keys the service holds, and each is read
    +/// back from it; a key the service kept as written is listed and read under
    +/// that key. A service that stores objects as files may rewrite such keys,
    +/// and a key it rewrote is reported as skipped.
    +pub(super) async fn on_s3_path_like_keys_are_listed_and_read_back_where_the_service_keeps_them<
    +    F: StoreFixture,
    +>(
    +    f: &F,
    +) {
    +    let bucket = f.bucket().await;
    +    let objects = [
    +        ("a3//b//c/part.json", rows(0, 1)),
    +        ("/lead/part.json", rows(1, 1)),
    +        ("x/../y/part.json", rows(2, 1)),
    +        ("dot/./part.json", rows(3, 1)),
    +    ];
    +    for (key, body) in &objects {
    +        f.put(&bucket, key, body).await;
    +    }
    +    let held = f.keys(&bucket).await;
    +    let files = f.source().list(&at(&bucket), "", 100).await.unwrap();
    +    assert_eq!(paths(&files), held);
    +    for file in &files {
    +        assert_eq!(
    +            read_back(f, &bucket, file).await.len(),
    +            1,
    +            "{:?}",
    +            file.path
    +        );
    +    }
    +    for (index, (key, _)) in objects.iter().enumerate() {
    +        if !held.iter().any(|held| held == key) {
    +            eprintln!("SKIP {key:?}: the service holds the object under another key");
    +            continue;
    +        }
    +        let file = files.iter().find(|file| file.path == *key).unwrap();
    +        assert_eq!(
    +            read_back(f, &bucket, file).await,
    +            lines(index, 1),
    +            "{key:?}"
    +        );
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/source/contract/mod.rs b/crates/engine/src/bulk_put/source/contract/mod.rs
    new file mode 100644
    index 000000000..b88142422
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/contract/mod.rs
    @@ -0,0 +1,290 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The store contract: the cases every bulk store passes, run once against
    +//! the filesystem store and once against the S3 store, the way the catalog
    +//! contract runs once per backend. A [`StoreFixture`] makes buckets and
    +//! objects with the store's own primitives, beside the source and sink the
    +//! cases drive, and reads back what the sink wrote.
    +//!
    +//! The filesystem fixture works in a fresh store root under the system temp
    +//! directory. The S3 fixture works against the service that
    +//! `EXTENDDB_TEST_S3_ENDPOINT` names (for example `http://127.0.0.1:7070`),
    +//! in region us-east-1 with path-style addressing, with credentials from the
    +//! standard AWS variables `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`.
    +//! Without those variables every S3 case prints a SKIP line and passes; with
    +//! `EXTENDDB_TEST_S3_REQUIRED` set, a missing variable fails the case
    +//! instead. Each S3 case works in buckets of its own and removes them when
    +//! it passes, so a failed case leaves its buckets for inspection.
    +//!
    +//! A case that holds on one store only names that store and prints a SKIP
    +//! line on the other.
    +
    +mod fs_fixture;
    +mod list;
    +mod owner;
    +mod read;
    +mod s3_fixture;
    +mod sink;
    +
    +use extenddb_storage::InputFile;
    +
    +use super::tests::{LEASE_OWNER, OP_ID, OWNER};
    +use super::{BucketRef, BulkSink, BulkSource, SinkError, SourceError, SourceFile, StoreFault};
    +use fs_fixture::FsFixture;
    +use s3_fixture::S3Fixture;
    +
    +/// The caller's account in every case.
    +const ACCOUNT: &str = OWNER;
    +
    +/// The operation every report and manifest in the cases belongs to.
    +const OPERATION: &str = OP_ID;
    +
    +/// The lease owner the runner passes to the sink.
    +const LEASE: &str = LEASE_OWNER;
    +
    +/// The line cap every read in the cases uses.
    +const MAX_LINE: usize = 1 << 20;
    +
    +/// The head of every missing-bucket message, S3's own and the filesystem
    +/// store's.
    +const NO_SUCH_BUCKET: &str = "The specified bucket does not exist";
    +
    +/// What a case needs from a store beside its source and sink: buckets, and
    +/// objects written and read with the store's own primitives.
    +trait StoreFixture: Sync {
    +    /// Whether this is the S3 store, whose faults carry S3's message and the
    +    /// tail of the call that met them.
    +    const S3: bool;
    +
    +    fn source(&self) -> &dyn BulkSource;
    +
    +    fn sink(&self) -> &dyn BulkSink;
    +
    +    /// A new, empty bucket of the caller's.
    +    async fn bucket(&self) -> String;
    +
    +    /// A bucket name that names no bucket.
    +    fn missing_bucket(&self) -> String;
    +
    +    async fn put(&self, bucket: &str, key: &str, body: &[u8]);
    +
    +    /// The object at `key`, or `None` when there is none.
    +    async fn get(&self, bucket: &str, key: &str) -> Option>;
    +
    +    /// Every object in `bucket`, in byte order, keyed as the store holds it.
    +    async fn keys(&self, bucket: &str) -> Vec;
    +
    +    /// The writes started in `bucket` and not completed: multipart uploads
    +    /// in progress on S3, temp files on the filesystem.
    +    async fn pending(&self, bucket: &str) -> Vec;
    +
    +    /// Remove what the case made.
    +    async fn cleanup(self)
    +    where
    +        Self: Sized;
    +}
    +
    +/// The tail S3 faults carry, by the call that met them (`s3::error`).
    +enum Tail {
    +    /// Input reads, the Start-time listing and the manifest write.
    +    AmazonS3 { status: u16, code: &'static str },
    +    /// Report writes and the write check of a report key.
    +    S3 { status: u16 },
    +}
    +
    +/// `fault` is a location fault with `code` and the message `head`: exactly
    +/// `head` on the filesystem store; on S3, S3's own message, which starts
    +/// with `head`, followed by `tail`.
    +fn assert_location_fault(fault: &StoreFault, code: &str, head: &str, tail: Tail) {
    +    assert_eq!(fault.code, code, "{fault:?}");
    +    assert!(!fault.after_first_byte, "{fault:?}");
    +    if !F::S3 {
    +        assert_eq!(fault.message, head);
    +        return;
    +    }
    +    assert!(fault.message.starts_with(head), "{}", fault.message);
    +    let (opening, closing) = match tail {
    +        Tail::AmazonS3 { status, code } => (
    +            format!(
    +                " (Service: Amazon S3; Status Code: {status}; Error Code: {code}; Request ID: "
    +            ),
    +            "; Proxy: null)",
    +        ),
    +        Tail::S3 { status } => (
    +            format!(" (Service: S3, Status Code: {status}, Request ID: "),
    +            ") (SDK Attempt Count: 1)",
    +        ),
    +    };
    +    assert!(fault.message.contains(&opening), "{}", fault.message);
    +    assert!(fault.message.ends_with(closing), "{}", fault.message);
    +}
    +
    +/// The bucket `bucket` of the caller, with no expected owner.
    +fn at(bucket: &str) -> BucketRef<'_> {
    +    BucketRef::new(ACCOUNT, bucket, None)
    +}
    +
    +/// Input row `index` as one JSON line, newline excluded.
    +fn row(index: usize) -> String {
    +    format!("{{\"Item\":{{\"pk\":{{\"S\":\"row-{index:06}\"}}}}}}")
    +}
    +
    +/// Rows `first` to `first + count - 1`, one per line.
    +fn rows(first: usize, count: usize) -> Vec {
    +    (first..first + count)
    +        .flat_map(|index| format!("{}\n", row(index)).into_bytes())
    +        .collect()
    +}
    +
    +/// Rows `first` to `first + count - 1` as a reader yields them.
    +fn lines(first: usize, count: usize) -> Vec {
    +    (first..first + count).map(row).collect()
    +}
    +
    +/// The lowercase hex MD5 of `bytes`, the entity tag of a single-part
    +/// object.
    +fn md5_hex(bytes: &[u8]) -> String {
    +    format!("{:x}", ::digest(bytes))
    +}
    +
    +/// The record an enumerated file becomes at Start.
    +fn recorded(file: &SourceFile) -> InputFile {
    +    InputFile {
    +        path: file.path.clone(),
    +        size: i64::try_from(file.size).unwrap(),
    +        rows: None,
    +        etag: file.etag.clone(),
    +        abandon: None,
    +    }
    +}
    +
    +fn paths(files: &[SourceFile]) -> Vec<&str> {
    +    files.iter().map(|file| file.path.as_str()).collect()
    +}
    +
    +/// The one file the listing of `prefix` gives.
    +async fn only_file(f: &F, bucket: &str, prefix: &str) -> SourceFile {
    +    let mut files = f.source().list(&at(bucket), prefix, 10).await.unwrap();
    +    assert_eq!(files.len(), 1, "{files:?}");
    +    files.remove(0)
    +}
    +
    +fn source_fault(error: SourceError) -> StoreFault {
    +    match error {
    +        SourceError::Store(fault) => fault,
    +        other => panic!("expected a store fault, got {other:?}"),
    +    }
    +}
    +
    +fn sink_fault(error: SinkError) -> StoreFault {
    +    match error {
    +        SinkError::Store(fault) => fault,
    +        other => panic!("expected a store fault, got {other:?}"),
    +    }
    +}
    +
    +/// One test per case and store: `both` cases on each store; a case that
    +/// holds on one store only runs there and prints a SKIP line on the other.
    +macro_rules! store_contract {
    +    (
    +        both: [$($bm:ident::$both:ident),* $(,)?],
    +        filesystem_only: [$($fm:ident::$fs:ident),* $(,)?],
    +        s3_only: [$($sm:ident::$s3:ident),* $(,)?] $(,)?
    +    ) => {
    +        mod filesystem {
    +            $(
    +                #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +                async fn $both() {
    +                    let fixture = super::FsFixture::new();
    +                    super::$bm::$both(&fixture).await;
    +                    super::StoreFixture::cleanup(fixture).await;
    +                }
    +            )*
    +            $(
    +                #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +                async fn $fs() {
    +                    let fixture = super::FsFixture::new();
    +                    super::$fm::$fs(&fixture).await;
    +                    super::StoreFixture::cleanup(fixture).await;
    +                }
    +            )*
    +            $(
    +                #[test]
    +                fn $s3() {
    +                    eprintln!("SKIP {}: the case holds on the S3 store only", stringify!($s3));
    +                }
    +            )*
    +        }
    +
    +        mod s3 {
    +            $(
    +                #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +                async fn $both() {
    +                    let Some(fixture) = super::S3Fixture::connect(stringify!($both)).await else {
    +                        return;
    +                    };
    +                    super::$bm::$both(&fixture).await;
    +                    super::StoreFixture::cleanup(fixture).await;
    +                }
    +            )*
    +            $(
    +                #[test]
    +                fn $fs() {
    +                    eprintln!(
    +                        "SKIP {}: the case holds on the filesystem store only",
    +                        stringify!($fs)
    +                    );
    +                }
    +            )*
    +            $(
    +                #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    +                async fn $s3() {
    +                    let Some(fixture) = super::S3Fixture::connect(stringify!($s3)).await else {
    +                        return;
    +                    };
    +                    super::$sm::$s3(&fixture).await;
    +                    super::StoreFixture::cleanup(fixture).await;
    +                }
    +            )*
    +        }
    +    };
    +}
    +
    +store_contract! {
    +    both: [
    +        list::the_bucket_check_passes_a_bucket_that_exists,
    +        list::the_bucket_check_answers_a_missing_bucket_with_no_such_bucket,
    +        list::the_listing_matches_the_prefix_byte_wise,
    +        list::the_listing_is_in_byte_order_of_the_whole_key,
    +        list::the_listing_stops_one_past_max_files,
    +        list::an_empty_prefix_lists_every_object_with_its_size,
    +        list::a_prefix_that_matches_nothing_lists_nothing,
    +        read::every_gzip_member_is_read,
    +        read::every_zstd_frame_is_read,
    +        read::an_object_without_the_codecs_magic_is_not_compressed,
    +        read::a_read_to_the_end_reports_its_bytes_and_the_objects_etag,
    +        read::an_object_larger_than_one_read_part_is_read_whole,
    +        sink::a_report_is_installed_under_the_service_key_for_each_prefix_form,
    +        sink::rotation_installs_two_objects_and_neither_is_visible_before_finish,
    +        sink::a_writer_dropped_without_finish_leaves_nothing_behind,
    +        sink::a_report_larger_than_one_upload_part_is_one_object,
    +        sink::a_second_render_replaces_the_first,
    +        sink::the_manifest_is_installed_under_the_service_key_and_replaced,
    +        sink::the_location_check_passes_a_bucket_that_exists_and_writes_nothing,
    +        sink::the_location_check_answers_a_missing_bucket_with_no_such_bucket,
    +    ],
    +    filesystem_only: [
    +        list::on_the_filesystem_store_the_listing_carries_no_etag,
    +        owner::on_the_filesystem_store_an_expected_owner_is_access_denied,
    +        read::on_the_filesystem_store_a_read_stops_at_the_listed_size_after_the_file_grew,
    +        read::on_the_filesystem_store_a_read_ends_early_on_a_file_that_shrank,
    +    ],
    +    s3_only: [
    +        list::on_s3_the_listing_carries_each_objects_etag,
    +        list::on_s3_keys_with_spaces_backslashes_and_tabs_are_listed_and_read_back,
    +        list::on_s3_path_like_keys_are_listed_and_read_back_where_the_service_keeps_them,
    +        owner::on_s3_every_request_carries_the_expected_owner_when_one_is_set,
    +        read::on_s3_an_object_written_again_after_its_listing_is_overwritten,
    +    ],
    +}
    diff --git a/crates/engine/src/bulk_put/source/contract/owner.rs b/crates/engine/src/bulk_put/source/contract/owner.rs
    new file mode 100644
    index 000000000..c1d854f94
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/contract/owner.rs
    @@ -0,0 +1,147 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Cases for an `S3BucketOwner` that names an account other than the
    +//! caller's. The filesystem store refuses it; the S3 store sends it and
    +//! lets the service decide.
    +
    +use serde_json::json;
    +
    +use extenddb_storage::ReportKind;
    +
    +use super::super::{InputCodec, OutputSse};
    +use super::*;
    +use crate::bulk_put::report::{manifest_key, report_key};
    +
    +const OTHER_OWNER: &str = "999999999999";
    +
    +fn first_report_key() -> String {
    +    report_key("reports", ACCOUNT, OPERATION, ReportKind::Error, 0)
    +}
    +
    +/// On the filesystem store the caller's account is the only owner a bucket
    +/// can have: an expected owner of any other account is `S3AccessDenied`,
    +/// `Access Denied`, at the input check and at the write check, decided
    +/// before existence. An owner equal to the caller's account sets no
    +/// expectation.
    +pub(super) async fn on_the_filesystem_store_an_expected_owner_is_access_denied(
    +    f: &F,
    +) {
    +    let bucket = f.bucket().await;
    +    let missing = f.missing_bucket();
    +    for name in [&bucket, &missing] {
    +        let owned = BucketRef::new(ACCOUNT, name, Some(OTHER_OWNER));
    +        let error = f
    +            .source()
    +            .check_bucket(&owned, "in/")
    +            .await
    +            .expect_err("another owner");
    +        assert_location_fault::(
    +            &source_fault(error),
    +            "S3AccessDenied",
    +            "Access Denied",
    +            Tail::AmazonS3 {
    +                status: 403,
    +                code: "AccessDenied",
    +            },
    +        );
    +        for key in [
    +            first_report_key(),
    +            manifest_key("reports", ACCOUNT, OPERATION),
    +        ] {
    +            let error = f
    +                .sink()
    +                .check_location(&owned, &key, &OutputSse::None)
    +                .await
    +                .expect_err("another owner");
    +            assert_location_fault::(
    +                &sink_fault(error),
    +                "S3AccessDenied",
    +                "Access Denied",
    +                Tail::S3 { status: 403 },
    +            );
    +        }
    +    }
    +    let own = BucketRef::new(ACCOUNT, &bucket, Some(ACCOUNT));
    +    f.source().check_bucket(&own, "in/").await.unwrap();
    +    f.sink()
    +        .check_location(&own, &first_report_key(), &OutputSse::None)
    +        .await
    +        .unwrap();
    +}
    +
    +/// Every call the store makes for one bucket, input and output, each
    +/// answer ignored.
    +async fn every_call(f: &S3Fixture, at: &BucketRef<'_>, file: &InputFile) {
    +    let _ = f.source().check_bucket(at, "in/").await;
    +    let _ = f.source().list(at, "in/", 10).await;
    +    if let Ok(mut reader) = f.source().open(at, file, InputCodec::None).await {
    +        while let Ok(Some(_)) = reader.next_line(MAX_LINE).await {}
    +    }
    +    // The file carries no listed tag, so the store reads one byte for it.
    +    let _ = f.source().etag(at, file).await;
    +    let _ = f.source().line_count(at, file, InputCodec::None).await;
    +    let _ = f
    +        .sink()
    +        .check_location(at, &first_report_key(), &OutputSse::None)
    +        .await;
    +    if let Ok(mut writer) = f
    +        .sink()
    +        .open(
    +            at,
    +            "reports",
    +            OPERATION,
    +            ReportKind::Error,
    +            LEASE,
    +            &OutputSse::None,
    +        )
    +        .await
    +        && writer
    +            .write_page(vec![json!({"errorMessages": ["e"]})])
    +            .await
    +            .is_ok()
    +    {
    +        let _ = writer.finish().await;
    +    }
    +    let _ = f
    +        .sink()
    +        .write_manifest(
    +            at,
    +            "reports",
    +            OPERATION,
    +            LEASE,
    +            &OutputSse::None,
    +            vec![json!({"etag": "e"})],
    +        )
    +        .await;
    +}
    +
    +/// On S3 an expected owner travels as `x-amz-expected-bucket-owner` on
    +/// every request the store sends for the bucket, and no request carries
    +/// the header when none is set. A service may ignore the header or refuse
    +/// the request, so the requests are read as sent, whatever the service
    +/// answered.
    +pub(super) async fn on_s3_every_request_carries_the_expected_owner_when_one_is_set(f: &S3Fixture) {
    +    let bucket = f.bucket().await;
    +    let body = rows(0, 2);
    +    f.put(&bucket, "in/part.json", &body).await;
    +    let file = InputFile {
    +        path: "in/part.json".to_owned(),
    +        size: i64::try_from(body.len()).unwrap(),
    +        rows: None,
    +        etag: None,
    +        abandon: None,
    +    };
    +    for owner in [Some(OTHER_OWNER), None] {
    +        let before = f.sent().len();
    +        every_call(f, &BucketRef::new(ACCOUNT, &bucket, owner), &file).await;
    +        let sent = f.sent().split_off(before);
    +        // One request at least per call: the bucket check, the listing,
    +        // the read, the tag, the line count, the write check, the report
    +        // and the manifest.
    +        assert!(sent.len() >= 8, "{sent:?}");
    +        for request in &sent {
    +            assert_eq!(request.owner.as_deref(), owner, "{}", request.request);
    +        }
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/source/contract/read.rs b/crates/engine/src/bulk_put/source/contract/read.rs
    new file mode 100644
    index 000000000..1fecaceb4
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/contract/read.rs
    @@ -0,0 +1,191 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Cases for reading one enumerated object: the size ceiling, the codecs,
    +//! and what a read to the end reports.
    +
    +use super::super::tests::{gzip_bytes, read_all, zstd_bytes};
    +use super::super::{InputCodec, LineReader};
    +use super::*;
    +
    +async fn open(
    +    f: &F,
    +    bucket: &str,
    +    file: &SourceFile,
    +    codec: InputCodec,
    +) -> Box {
    +    f.source()
    +        .open(&at(bucket), &recorded(file), codec)
    +        .await
    +        .unwrap()
    +}
    +
    +/// On the filesystem store the reader reads no further than the size the
    +/// listing gave: rows added to the file after the listing are not read.
    +pub(super) async fn on_the_filesystem_store_a_read_stops_at_the_listed_size_after_the_file_grew<
    +    F: StoreFixture,
    +>(
    +    f: &F,
    +) {
    +    let bucket = f.bucket().await;
    +    let listed = rows(0, 3);
    +    f.put(&bucket, "in/part.json", &listed).await;
    +    let file = only_file(f, &bucket, "in/").await;
    +    let mut grown = listed.clone();
    +    grown.extend(rows(3, 2));
    +    f.put(&bucket, "in/part.json", &grown).await;
    +    let mut reader = open(f, &bucket, &file, InputCodec::None).await;
    +    assert_eq!(read_all(&mut reader, MAX_LINE).await, lines(0, 3));
    +    assert_eq!(reader.bytes_consumed(), listed.len() as u64);
    +}
    +
    +/// On the filesystem store a file that shrank after the listing reads to
    +/// its new end and no further, without a fault.
    +pub(super) async fn on_the_filesystem_store_a_read_ends_early_on_a_file_that_shrank<
    +    F: StoreFixture,
    +>(
    +    f: &F,
    +) {
    +    let bucket = f.bucket().await;
    +    f.put(&bucket, "in/part.json", &rows(0, 4)).await;
    +    let file = only_file(f, &bucket, "in/").await;
    +    let shrunk = rows(0, 2);
    +    f.put(&bucket, "in/part.json", &shrunk).await;
    +    let mut reader = open(f, &bucket, &file, InputCodec::None).await;
    +    assert_eq!(read_all(&mut reader, MAX_LINE).await, lines(0, 2));
    +    assert_eq!(reader.bytes_consumed(), shrunk.len() as u64);
    +}
    +
    +/// On S3 an object written again after the listing, larger or smaller, is
    +/// not the object listed: every GET carries the listed ETag in `If-Match`,
    +/// so the open answers `Overwritten` and no byte of the new object is read.
    +pub(super) async fn on_s3_an_object_written_again_after_its_listing_is_overwritten<
    +    F: StoreFixture,
    +>(
    +    f: &F,
    +) {
    +    let bucket = f.bucket().await;
    +    for (prefix, replacement) in [("grew/", rows(0, 5)), ("shrank/", rows(0, 2))] {
    +        let key = format!("{prefix}part.json");
    +        f.put(&bucket, &key, &rows(0, 3)).await;
    +        let file = only_file(f, &bucket, prefix).await;
    +        f.put(&bucket, &key, &replacement).await;
    +        let opened = f
    +            .source()
    +            .open(&at(&bucket), &recorded(&file), InputCodec::None)
    +            .await;
    +        assert!(
    +            matches!(opened, Err(SourceError::Overwritten)),
    +            "{prefix}: {:?}",
    +            opened.err()
    +        );
    +    }
    +}
    +
    +/// Reads `body`, stored under `codec`, to its end and checks the lines, the
    +/// compressed bytes consumed and the entity tag the reader reports.
    +async fn read_encoded(f: &F, body: &[u8], codec: InputCodec, count: usize) {
    +    let bucket = f.bucket().await;
    +    f.put(&bucket, "in/part.bin", body).await;
    +    let file = only_file(f, &bucket, "in/").await;
    +    let mut reader = open(f, &bucket, &file, codec).await;
    +    assert_eq!(read_all(&mut reader, MAX_LINE).await, lines(0, count));
    +    assert_eq!(reader.bytes_consumed(), body.len() as u64);
    +    assert_eq!(reader.etag(), Some(md5_hex(body)));
    +}
    +
    +/// A gzip object of several members is read whole, every member included.
    +pub(super) async fn every_gzip_member_is_read(f: &F) {
    +    let mut body = gzip_bytes(&rows(0, 3));
    +    body.extend(gzip_bytes(&rows(3, 2)));
    +    body.extend(gzip_bytes(&rows(5, 4)));
    +    read_encoded(f, &body, InputCodec::Gzip, 9).await;
    +}
    +
    +/// A zstd object of several frames is read whole, every frame included.
    +pub(super) async fn every_zstd_frame_is_read(f: &F) {
    +    let mut body = zstd_bytes(&rows(0, 300));
    +    body.extend(zstd_bytes(&rows(300, 2)));
    +    read_encoded(f, &body, InputCodec::Zstd, 302).await;
    +}
    +
    +/// Under a codec, an object that does not start with the codec's magic is
    +/// `NotCompressed`, refused at open.
    +pub(super) async fn an_object_without_the_codecs_magic_is_not_compressed(f: &F) {
    +    let bucket = f.bucket().await;
    +    f.put(&bucket, "in/part.json", &rows(0, 2)).await;
    +    let file = only_file(f, &bucket, "in/").await;
    +    for codec in [InputCodec::Gzip, InputCodec::Zstd] {
    +        let opened = f.source().open(&at(&bucket), &recorded(&file), codec).await;
    +        assert!(
    +            matches!(opened, Err(SourceError::NotCompressed)),
    +            "{codec:?}: {:?}",
    +            opened.err()
    +        );
    +    }
    +}
    +
    +/// A read to the end has consumed the listed size and reports the object's
    +/// entity tag: the listed one on S3, the MD5 of the bytes on the filesystem
    +/// store, which for an object written with one PUT are the same. The
    +/// store's `etag` and `line_count`, which the manifest falls back on, agree
    +/// with the read.
    +pub(super) async fn a_read_to_the_end_reports_its_bytes_and_the_objects_etag(
    +    f: &F,
    +) {
    +    let bucket = f.bucket().await;
    +    let body = rows(0, 10);
    +    f.put(&bucket, "in/part.json", &body).await;
    +    let file = only_file(f, &bucket, "in/").await;
    +    let expected = file.etag.clone().unwrap_or_else(|| md5_hex(&body));
    +    assert_eq!(expected, md5_hex(&body));
    +    let mut reader = open(f, &bucket, &file, InputCodec::None).await;
    +    assert_eq!(reader.etag(), None, "no tag before the end of the object");
    +    assert_eq!(read_all(&mut reader, MAX_LINE).await, lines(0, 10));
    +    assert_eq!(reader.bytes_consumed(), body.len() as u64);
    +    assert_eq!(reader.etag(), Some(expected.clone()));
    +    let input = recorded(&file);
    +    assert_eq!(
    +        f.source().etag(&at(&bucket), &input).await.unwrap(),
    +        expected
    +    );
    +    let counted = f
    +        .source()
    +        .line_count(&at(&bucket), &input, InputCodec::None)
    +        .await
    +        .unwrap();
    +    assert_eq!(counted, 10);
    +}
    +
    +/// An object larger than one read part (8 MiB on S3, where each later part
    +/// is a ranged GET conditional on the first part's entity tag) is read
    +/// whole, with the tag of the object.
    +pub(super) async fn an_object_larger_than_one_read_part_is_read_whole(f: &F) {
    +    let bucket = f.bucket().await;
    +    // 10,488,000 bytes in rows of 114.
    +    let count = 92_000;
    +    let pad = "p".repeat(66);
    +    let body: Vec = (0..count)
    +        .flat_map(|index| {
    +            format!(
    +                "{{\"Item\":{{\"pk\":{{\"S\":\"{index:08}\"}},\"pad\":{{\"S\":\"{pad}\"}}}}}}\n"
    +            )
    +            .into_bytes()
    +        })
    +        .collect();
    +    assert!(body.len() > 9 * 1024 * 1024);
    +    f.put(&bucket, "in/big.json", &body).await;
    +    let file = only_file(f, &bucket, "in/").await;
    +    let mut reader = open(f, &bucket, &file, InputCodec::None).await;
    +    let mut read = 0;
    +    while let Some(line) = reader.next_line(MAX_LINE).await.unwrap() {
    +        assert!(
    +            line.contains(&format!("\"{read:08}\"")),
    +            "row {read}: {line}"
    +        );
    +        read += 1;
    +    }
    +    assert_eq!(read, count);
    +    assert_eq!(reader.bytes_consumed(), body.len() as u64);
    +    assert_eq!(reader.etag(), Some(md5_hex(&body)));
    +}
    diff --git a/crates/engine/src/bulk_put/source/contract/s3_fixture.rs b/crates/engine/src/bulk_put/source/contract/s3_fixture.rs
    new file mode 100644
    index 000000000..5f2dbaab9
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/contract/s3_fixture.rs
    @@ -0,0 +1,298 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The S3 store's fixture: the store's clients, built by the store's own
    +//! connect path for the service `EXTENDDB_TEST_S3_ENDPOINT` names, with an
    +//! interceptor that records every request they send; buckets named for the
    +//! case and created, filled and read through the same clients.
    +
    +use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
    +
    +use aws_sdk_s3::config::interceptors::BeforeTransmitInterceptorContextRef;
    +use aws_sdk_s3::config::{ConfigBag, Intercept, RuntimeComponents};
    +use aws_sdk_s3::error::BoxError;
    +use aws_sdk_s3::primitives::ByteStream;
    +
    +use super::{BulkSink, BulkSource, StoreFixture};
    +use crate::bulk_put::s3::{S3Clients, S3Sink, S3Source, connect_with};
    +use crate::bulk_put::store::S3StoreSettings;
    +
    +const ENDPOINT: &str = "EXTENDDB_TEST_S3_ENDPOINT";
    +const REQUIRED: &str = "EXTENDDB_TEST_S3_REQUIRED";
    +const CREDENTIALS: [&str; 2] = ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"];
    +
    +/// One request the store's clients sent.
    +#[derive(Debug, Clone)]
    +pub(super) struct Sent {
    +    /// The method and the URI, for the failure messages.
    +    pub(super) request: String,
    +    /// The `x-amz-expected-bucket-owner` header, when it was sent.
    +    pub(super) owner: Option,
    +}
    +
    +#[derive(Debug, Clone, Default)]
    +struct Recorder(Arc>>);
    +
    +impl Recorder {
    +    fn sent(&self) -> MutexGuard<'_, Vec> {
    +        self.0.lock().unwrap_or_else(PoisonError::into_inner)
    +    }
    +}
    +
    +impl Intercept for Recorder {
    +    fn name(&self) -> &'static str {
    +        "StoreContractRecorder"
    +    }
    +
    +    fn read_before_transmit(
    +        &self,
    +        context: &BeforeTransmitInterceptorContextRef<'_>,
    +        _runtime_components: &RuntimeComponents,
    +        _cfg: &mut ConfigBag,
    +    ) -> Result<(), BoxError> {
    +        let request = context.request();
    +        let sent = Sent {
    +            request: format!("{} {}", request.method(), request.uri()),
    +            owner: request
    +                .headers()
    +                .get("x-amz-expected-bucket-owner")
    +                .map(str::to_owned),
    +        };
    +        self.sent().push(sent);
    +        Ok(())
    +    }
    +}
    +
    +pub(super) struct S3Fixture {
    +    clients: Arc,
    +    source: S3Source,
    +    sink: S3Sink,
    +    buckets: Mutex>,
    +    recorder: Recorder,
    +}
    +
    +fn is_set(name: &str) -> bool {
    +    std::env::var_os(name).is_some_and(|value| !value.is_empty())
    +}
    +
    +/// A bucket name of this run's, lowercase as S3 requires.
    +fn bucket_name(kind: &str) -> String {
    +    let id = uuid::Uuid::new_v4().simple().to_string();
    +    format!("contract-{kind}{}", &id[..20])
    +}
    +
    +impl S3Fixture {
    +    /// The fixture for `case`, or `None` after a SKIP line when the S3
    +    /// service or its credentials are not given. With
    +    /// `EXTENDDB_TEST_S3_REQUIRED` set, a missing variable fails the case.
    +    pub(super) async fn connect(case: &str) -> Option {
    +        let missing: Vec<&str> = std::iter::once(ENDPOINT)
    +            .chain(CREDENTIALS)
    +            .filter(|name| !is_set(name))
    +            .collect();
    +        if !missing.is_empty() {
    +            let missing = missing.join(" and ");
    +            assert!(
    +                !is_set(REQUIRED),
    +                "{case}: {REQUIRED} is set but {missing} is not; the S3 store contract would \
    +                 pass without an S3 service"
    +            );
    +            eprintln!(
    +                "SKIP {case}: {missing} is not set, so there is no S3 service to run the case \
    +                 against"
    +            );
    +            return None;
    +        }
    +        let settings = S3StoreSettings {
    +            region: "us-east-1".to_owned(),
    +            endpoint_url: std::env::var(ENDPOINT).ok(),
    +            force_path_style: true,
    +        };
    +        let recorder = Recorder::default();
    +        let interceptor = recorder.clone();
    +        let clients = Arc::new(
    +            connect_with(&settings, move |builder| builder.interceptor(interceptor)).await,
    +        );
    +        Some(Self {
    +            source: S3Source {
    +                clients: Arc::clone(&clients),
    +            },
    +            sink: S3Sink {
    +                clients: Arc::clone(&clients),
    +            },
    +            clients,
    +            buckets: Mutex::new(Vec::new()),
    +            recorder,
    +        })
    +    }
    +
    +    /// Every request the store's clients sent so far, the fixture's own
    +    /// included.
    +    pub(super) fn sent(&self) -> Vec {
    +        self.recorder.sent().clone()
    +    }
    +
    +    async fn remove_bucket(&self, bucket: &str) {
    +        let uploads = self
    +            .clients
    +            .call(bucket, |client| {
    +                client.list_multipart_uploads().bucket(bucket).send()
    +            })
    +            .await
    +            .map(|output| output.uploads().to_vec())
    +            .unwrap_or_default();
    +        for upload in uploads {
    +            let _ = self
    +                .clients
    +                .call(bucket, |client| {
    +                    client
    +                        .abort_multipart_upload()
    +                        .bucket(bucket)
    +                        .key(upload.key().unwrap_or_default())
    +                        .upload_id(upload.upload_id().unwrap_or_default())
    +                        .send()
    +                })
    +                .await;
    +        }
    +        for key in self.keys(bucket).await {
    +            let _ = self
    +                .clients
    +                .call(bucket, |client| {
    +                    client.delete_object().bucket(bucket).key(&key).send()
    +                })
    +                .await;
    +        }
    +        if let Err(error) = self
    +            .clients
    +            .call(bucket, |client| {
    +                client.delete_bucket().bucket(bucket).send()
    +            })
    +            .await
    +        {
    +            eprintln!(
    +                "bucket {bucket} was not removed: {}",
    +                aws_sdk_s3::error::DisplayErrorContext(&error)
    +            );
    +        }
    +    }
    +}
    +
    +impl StoreFixture for S3Fixture {
    +    const S3: bool = true;
    +
    +    fn source(&self) -> &dyn BulkSource {
    +        &self.source
    +    }
    +
    +    fn sink(&self) -> &dyn BulkSink {
    +        &self.sink
    +    }
    +
    +    async fn bucket(&self) -> String {
    +        let name = bucket_name("");
    +        self.clients
    +            .call(&name, |client| client.create_bucket().bucket(&name).send())
    +            .await
    +            .expect("bucket created");
    +        self.buckets
    +            .lock()
    +            .unwrap_or_else(PoisonError::into_inner)
    +            .push(name.clone());
    +        name
    +    }
    +
    +    fn missing_bucket(&self) -> String {
    +        bucket_name("missing-")
    +    }
    +
    +    async fn put(&self, bucket: &str, key: &str, body: &[u8]) {
    +        self.clients
    +            .call(bucket, |client| {
    +                client
    +                    .put_object()
    +                    .bucket(bucket)
    +                    .key(key)
    +                    .body(ByteStream::from(body.to_vec()))
    +                    .send()
    +            })
    +            .await
    +            .expect("object written");
    +    }
    +
    +    async fn get(&self, bucket: &str, key: &str) -> Option> {
    +        match self
    +            .clients
    +            .call(bucket, |client| {
    +                client.get_object().bucket(bucket).key(key).send()
    +            })
    +            .await
    +        {
    +            Ok(output) => Some(output.body.collect().await.unwrap().to_vec()),
    +            Err(error)
    +                if error.as_service_error().is_some_and(
    +                    aws_sdk_s3::operation::get_object::GetObjectError::is_no_such_key,
    +                ) =>
    +            {
    +                None
    +            }
    +            Err(error) => panic!(
    +                "reading {key:?}: {}",
    +                aws_sdk_s3::error::DisplayErrorContext(&error)
    +            ),
    +        }
    +    }
    +
    +    async fn keys(&self, bucket: &str) -> Vec {
    +        let mut keys = Vec::new();
    +        let mut token: Option = None;
    +        loop {
    +            let page = self
    +                .clients
    +                .call(bucket, |client| {
    +                    client
    +                        .list_objects_v2()
    +                        .bucket(bucket)
    +                        .set_continuation_token(token.clone())
    +                        .send()
    +                })
    +                .await
    +                .expect("bucket listed");
    +            keys.extend(
    +                page.contents()
    +                    .iter()
    +                    .filter_map(|o| o.key().map(str::to_owned)),
    +            );
    +            match page.next_continuation_token() {
    +                Some(next) if page.is_truncated() == Some(true) => token = Some(next.to_owned()),
    +                _ => break,
    +            }
    +        }
    +        keys.sort();
    +        keys
    +    }
    +
    +    async fn pending(&self, bucket: &str) -> Vec {
    +        let uploads = self
    +            .clients
    +            .call(bucket, |client| {
    +                client.list_multipart_uploads().bucket(bucket).send()
    +            })
    +            .await
    +            .expect("uploads listed");
    +        let mut keys: Vec = uploads
    +            .uploads()
    +            .iter()
    +            .filter_map(|upload| upload.key().map(str::to_owned))
    +            .collect();
    +        keys.sort();
    +        keys
    +    }
    +
    +    async fn cleanup(self) {
    +        let buckets =
    +            std::mem::take(&mut *self.buckets.lock().unwrap_or_else(PoisonError::into_inner));
    +        for bucket in buckets {
    +            self.remove_bucket(&bucket).await;
    +        }
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/source/contract/sink.rs b/crates/engine/src/bulk_put/source/contract/sink.rs
    new file mode 100644
    index 000000000..7bb29757c
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/contract/sink.rs
    @@ -0,0 +1,300 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Cases for the output side: report objects, the manifest, and the write
    +//! check the runner makes at the first report record and before the
    +//! manifest.
    +
    +use serde_json::{Value, json};
    +
    +use extenddb_storage::ReportKind;
    +
    +use super::super::{OutputSse, ReportWriter};
    +use super::*;
    +use crate::bulk_put::report::{FileProcessingStatus, manifest_key, manifest_line, report_key};
    +use crate::bulk_put::s3::tests::smoke::{gunzip, noisy_records};
    +
    +/// Error report records `first` to `first + count - 1`.
    +fn records(first: usize, count: usize) -> Vec {
    +    (first..first + count)
    +        .map(|index| json!({"itemLocation": {"itemIndex": index}, "errorMessages": ["e"]}))
    +        .collect()
    +}
    +
    +fn error_key(prefix: &str, index: u32) -> String {
    +    report_key(prefix, ACCOUNT, OPERATION, ReportKind::Error, index)
    +}
    +
    +async fn open_writer(f: &F, bucket: &str, prefix: &str) -> Box {
    +    f.sink()
    +        .open(
    +            &at(bucket),
    +            prefix,
    +            OPERATION,
    +            ReportKind::Error,
    +            LEASE,
    +            &OutputSse::None,
    +        )
    +        .await
    +        .unwrap()
    +}
    +
    +/// The records of the report object at `key`, or `None` when there is no
    +/// object there.
    +async fn report_records(f: &F, bucket: &str, key: &str) -> Option> {
    +    let body = f.get(bucket, key).await?;
    +    Some(
    +        gunzip(&body)
    +            .lines()
    +            .map(|line| serde_json::from_str(line).unwrap())
    +            .collect(),
    +    )
    +}
    +
    +/// `pending` once the store has had time to abort what a dropped writer
    +/// started: the S3 writer aborts its uploads on a task of its own.
    +async fn settled_pending(f: &F, bucket: &str) -> Vec {
    +    for _ in 0..100 {
    +        let pending = f.pending(bucket).await;
    +        if pending.is_empty() {
    +            return pending;
    +        }
    +        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
    +    }
    +    f.pending(bucket).await
    +}
    +
    +/// A report is installed under the key the service spells for its prefix,
    +/// for no prefix, one ending in `/`, one without, and one with a doubled
    +/// `/`, and holds the records written.
    +pub(super) async fn a_report_is_installed_under_the_service_key_for_each_prefix_form<
    +    F: StoreFixture,
    +>(
    +    f: &F,
    +) {
    +    let bucket = f.bucket().await;
    +    for (index, prefix) in ["", "reports/", "reports", "deep//er"]
    +        .into_iter()
    +        .enumerate()
    +    {
    +        let written = records(index * 10, 3);
    +        let mut writer = open_writer(f, &bucket, prefix).await;
    +        writer.write_page(written.clone()).await.unwrap();
    +        let key = writer.finish().await.unwrap();
    +        assert_eq!(key, error_key(prefix, 0), "{prefix:?}");
    +        assert_eq!(
    +            report_records(f, &bucket, &key).await,
    +            Some(written),
    +            "{prefix:?}"
    +        );
    +    }
    +    assert!(f.pending(&bucket).await.is_empty());
    +}
    +
    +/// With the rotation threshold lowered, two pages give two objects; neither
    +/// is visible before `finish`, which installs both in index order and no
    +/// third.
    +pub(super) async fn rotation_installs_two_objects_and_neither_is_visible_before_finish<
    +    F: StoreFixture,
    +>(
    +    f: &F,
    +) {
    +    let bucket = f.bucket().await;
    +    let mut writer = open_writer(f, &bucket, "reports").await;
    +    writer.set_rotation_threshold(1);
    +    writer.write_page(records(0, 3)).await.unwrap();
    +    writer.write_page(records(3, 2)).await.unwrap();
    +    let keys: Vec = (0..3).map(|index| error_key("reports", index)).collect();
    +    for key in &keys {
    +        assert_eq!(f.get(&bucket, key).await, None, "{key} before finish");
    +    }
    +    assert_eq!(writer.finish().await.unwrap(), keys[0]);
    +    assert_eq!(
    +        report_records(f, &bucket, &keys[0]).await,
    +        Some(records(0, 3))
    +    );
    +    assert_eq!(
    +        report_records(f, &bucket, &keys[1]).await,
    +        Some(records(3, 2))
    +    );
    +    assert_eq!(f.get(&bucket, &keys[2]).await, None);
    +    assert!(f.pending(&bucket).await.is_empty());
    +}
    +
    +/// A writer dropped without `finish` leaves no object and no write in
    +/// progress, after enough records that the S3 writer had a multipart
    +/// upload under way.
    +pub(super) async fn a_writer_dropped_without_finish_leaves_nothing_behind(f: &F) {
    +    let bucket = f.bucket().await;
    +    let mut writer = open_writer(f, &bucket, "reports").await;
    +    for seed in 0..30 {
    +        writer
    +            .write_page(noisy_records(2_000, 100 + seed))
    +            .await
    +            .unwrap();
    +    }
    +    drop(writer);
    +    let pending = settled_pending(f, &bucket).await;
    +    assert!(pending.is_empty(), "{pending:?}");
    +    let keys = f.keys(&bucket).await;
    +    assert!(keys.is_empty(), "{keys:?}");
    +}
    +
    +/// A report larger than one upload part (8 MiB on S3, which sends it as a
    +/// multipart upload and completes it at `finish`) is one object holding
    +/// every record in order, with no write left in progress.
    +pub(super) async fn a_report_larger_than_one_upload_part_is_one_object(f: &F) {
    +    let bucket = f.bucket().await;
    +    let mut writer = open_writer(f, &bucket, "reports").await;
    +    let mut written = Vec::new();
    +    for seed in 0..30 {
    +        let page = noisy_records(2_000, 200 + seed);
    +        written.extend(page.iter().cloned());
    +        writer.write_page(page).await.unwrap();
    +    }
    +    let key = writer.finish().await.unwrap();
    +    assert_eq!(f.keys(&bucket).await, std::slice::from_ref(&key));
    +    let body = f.get(&bucket, &key).await.unwrap();
    +    assert!(body.len() > 8 * 1024 * 1024, "{} bytes", body.len());
    +    assert_eq!(report_records(f, &bucket, &key).await, Some(written));
    +    assert!(f.pending(&bucket).await.is_empty());
    +}
    +
    +/// A second render of the same report replaces the first.
    +pub(super) async fn a_second_render_replaces_the_first(f: &F) {
    +    let bucket = f.bucket().await;
    +    for (first, count) in [(0, 5), (100, 2)] {
    +        let mut writer = open_writer(f, &bucket, "reports").await;
    +        writer.write_page(records(first, count)).await.unwrap();
    +        let key = writer.finish().await.unwrap();
    +        assert_eq!(
    +            report_records(f, &bucket, &key).await,
    +            Some(records(first, count))
    +        );
    +    }
    +    assert_eq!(f.keys(&bucket).await, [error_key("reports", 0)]);
    +}
    +
    +/// The lines of the manifest at `key`.
    +async fn manifest_lines(f: &F, bucket: &str, key: &str) -> Vec {
    +    let body = f.get(bucket, key).await.expect("manifest written");
    +    String::from_utf8(body)
    +        .unwrap()
    +        .lines()
    +        .map(|line| serde_json::from_str(line).unwrap())
    +        .collect()
    +}
    +
    +/// The manifest is installed under the key the service spells, one JSON
    +/// object per line in order, and a second write replaces it.
    +pub(super) async fn the_manifest_is_installed_under_the_service_key_and_replaced<
    +    F: StoreFixture,
    +>(
    +    f: &F,
    +) {
    +    let bucket = f.bucket().await;
    +    let first = vec![
    +        manifest_line(
    +            "loads",
    +            "in/part-0.json",
    +            FileProcessingStatus::FullyProcessed,
    +            Some(4),
    +            "etag-0",
    +        ),
    +        manifest_line(
    +            "loads",
    +            "in/part-1.json",
    +            FileProcessingStatus::NotProcessed,
    +            None,
    +            "etag-1",
    +        ),
    +    ];
    +    let second = vec![manifest_line(
    +        "loads",
    +        "in/part-0.json",
    +        FileProcessingStatus::PartiallyProcessed,
    +        Some(1),
    +        "etag-0",
    +    )];
    +    for lines in [first, second] {
    +        let key = f
    +            .sink()
    +            .write_manifest(
    +                &at(&bucket),
    +                "reports",
    +                OPERATION,
    +                LEASE,
    +                &OutputSse::None,
    +                lines.clone(),
    +            )
    +            .await
    +            .unwrap();
    +        assert_eq!(key, manifest_key("reports", ACCOUNT, OPERATION));
    +        assert_eq!(manifest_lines(f, &bucket, &key).await, lines);
    +    }
    +    assert!(f.pending(&bucket).await.is_empty());
    +}
    +
    +/// The write check passes a bucket that exists, for a report key and for
    +/// the manifest key, and leaves no object and no write in progress.
    +pub(super) async fn the_location_check_passes_a_bucket_that_exists_and_writes_nothing<
    +    F: StoreFixture,
    +>(
    +    f: &F,
    +) {
    +    let bucket = f.bucket().await;
    +    for key in [
    +        error_key("reports", 0),
    +        manifest_key("reports", ACCOUNT, OPERATION),
    +    ] {
    +        f.sink()
    +            .check_location(&at(&bucket), &key, &OutputSse::None)
    +            .await
    +            .unwrap();
    +    }
    +    assert!(f.keys(&bucket).await.is_empty());
    +    assert!(settled_pending(f, &bucket).await.is_empty());
    +}
    +
    +/// The write check answers a bucket that does not exist with
    +/// `S3NoSuchBucket`, with the report writer's tail for a report key and the
    +/// manifest write's for the manifest key.
    +pub(super) async fn the_location_check_answers_a_missing_bucket_with_no_such_bucket<
    +    F: StoreFixture,
    +>(
    +    f: &F,
    +) {
    +    // A bucket of the caller's exists, as it does once Start has run; the
    +    // check names another.
    +    f.bucket().await;
    +    let missing = f.missing_bucket();
    +    let error = f
    +        .sink()
    +        .check_location(&at(&missing), &error_key("reports", 0), &OutputSse::None)
    +        .await
    +        .expect_err("the bucket does not exist");
    +    assert_location_fault::(
    +        &sink_fault(error),
    +        "S3NoSuchBucket",
    +        NO_SUCH_BUCKET,
    +        Tail::S3 { status: 404 },
    +    );
    +    let error = f
    +        .sink()
    +        .check_location(
    +            &at(&missing),
    +            &manifest_key("reports", ACCOUNT, OPERATION),
    +            &OutputSse::None,
    +        )
    +        .await
    +        .expect_err("the bucket does not exist");
    +    assert_location_fault::(
    +        &sink_fault(error),
    +        "S3NoSuchBucket",
    +        NO_SUCH_BUCKET,
    +        Tail::AmazonS3 {
    +            status: 404,
    +            code: "NoSuchBucket",
    +        },
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/source/fs_source.rs b/crates/engine/src/bulk_put/source/fs_source.rs
    new file mode 100644
    index 000000000..671cd89ef
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/fs_source.rs
    @@ -0,0 +1,186 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The filesystem input source: the bucket check, enumeration and
    +//! opening of input files beneath the caller's account subtree.
    +
    +use super::*;
    +
    +impl FsSource {
    +    /// Whether `{root}/{account}/{bucket}` exists and is a directory,
    +    /// decided with the same primitives the enumeration uses: the bucket
    +    /// name is checked as one component and opened with `openat(O_DIRECTORY
    +    /// | O_NOFOLLOW)` from the verified account handle, so a symlink is
    +    /// never followed, and a symlink or a non-directory at the name answers
    +    /// `false` exactly as the enumeration would find no files beneath it.
    +    /// `check_bucket` decides the input bucket's existence with this.
    +    pub(crate) fn bucket_exists(
    +        &self,
    +        account: &str,
    +        bucket: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        let root = Arc::clone(&self.root);
    +        let account = account.to_owned();
    +        let bucket = bucket.to_owned();
    +        Box::pin(async move {
    +            tokio::task::spawn_blocking(move || bucket_exists_blocking(&[root], &account, &bucket))
    +                .await
    +                .map_err(|e| SourceError::Io(std::io::Error::other(e)))?
    +        })
    +    }
    +}
    +
    +/// The enumerated size the record stores for an input file, the read
    +/// ceiling; a negative stored size reads as zero.
    +fn enumerated_size(file: &InputFile) -> u64 {
    +    u64::try_from(file.size).unwrap_or(0)
    +}
    +
    +impl BulkSource for FsSource {
    +    fn check_bucket(
    +        &self,
    +        at: &BucketRef<'_>,
    +        _prefix: &str,
    +    ) -> BoxFuture<'_, Result<(), SourceError>> {
    +        let root = Arc::clone(&self.root);
    +        let account = at.account.to_owned();
    +        let bucket = at.bucket.to_owned();
    +        let owner_mismatch = at.expected_owner.is_some();
    +        Box::pin(async move {
    +            // The caller's account directory under the store root, created
    +            // here when it is missing so a caller can always find
    +            // `{root}/{account}`. A failure is the server's fault, not the
    +            // request's: the details go to the log and the client sees the
    +            // sanitized error.
    +            let dir_account = account.clone();
    +            tokio::task::spawn_blocking(move || ensure_account_dir(&root, &dir_account))
    +                .await
    +                .map_err(|e| {
    +                    tracing::error!(error = %e, "bulk store account directory task failed");
    +                    SourceError::Io(std::io::Error::other(e))
    +                })?
    +                .map_err(|error| {
    +                    tracing::error!(
    +                        account_id = %account,
    +                        %error,
    +                        "cannot create the bulk store account directory"
    +                    );
    +                    SourceError::Io(error)
    +                })?;
    +            // An input S3BucketOwner that is not the caller's account is
    +            // S3AccessDenied (measured: the hosted service flips the
    +            // operation to FAILED with that code); on the filesystem store
    +            // the caller's account is the only owner a bucket directory can
    +            // have. Ownership is decided before existence (ExtendDB's
    +            // choice), so a caller naming another owner cannot learn
    +            // whether the bucket exists. An input bucket directory that
    +            // does not exist, or is not a directory, is S3NoSuchBucket.
    +            if owner_mismatch {
    +                return Err(SourceError::Store(StoreFault::access_denied()));
    +            }
    +            if self.bucket_exists(&account, &bucket).await? {
    +                Ok(())
    +            } else {
    +                Err(SourceError::Store(StoreFault::no_such_bucket()))
    +            }
    +        })
    +    }
    +
    +    fn list(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        max_files: u64,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        // The jail primitives (`account_scoped_roots`, `validate_path`)
    +        // are shared with `ImportTable` and `ExportTableToPointInTime`,
    +        // whose configuration remains a list, so the one bulk store root
    +        // travels as a one-element list.
    +        let root = Arc::clone(&self.root);
    +        let account = at.account.to_owned();
    +        let bucket = at.bucket.to_owned();
    +        let prefix = prefix.to_owned();
    +        Box::pin(async move {
    +            tokio::task::spawn_blocking(move || {
    +                list_blocking(&[root], &account, &bucket, &prefix, max_files)
    +            })
    +            .await
    +            .map_err(|e| SourceError::Io(std::io::Error::other(e)))?
    +        })
    +    }
    +
    +    fn open(
    +        &self,
    +        at: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        let root = Arc::clone(&self.root);
    +        let account = at.account.to_owned();
    +        let bucket = at.bucket.to_owned();
    +        let path = file.path.clone();
    +        let size = enumerated_size(file);
    +        Box::pin(async move {
    +            let reader = tokio::task::spawn_blocking(move || {
    +                open_blocking(&[root], &account, &bucket, &path, compression, size)
    +            })
    +            .await
    +            .map_err(|e| SourceError::Io(std::io::Error::other(e)))??;
    +            Ok(Box::new(reader) as Box)
    +        })
    +    }
    +
    +    fn etag(
    +        &self,
    +        at: &BucketRef<'_>,
    +        file: &InputFile,
    +    ) -> BoxFuture<'_, Result> {
    +        let root = Arc::clone(&self.root);
    +        let account = at.account.to_owned();
    +        let bucket = at.bucket.to_owned();
    +        let path = file.path.clone();
    +        let size = enumerated_size(file);
    +        Box::pin(async move {
    +            tokio::task::spawn_blocking(move || {
    +                etag_blocking(&[root], &account, &bucket, &path, size)
    +            })
    +            .await
    +            .map_err(|e| SourceError::Io(std::io::Error::other(e)))?
    +        })
    +    }
    +
    +    fn line_count(
    +        &self,
    +        at: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result> {
    +        let root = Arc::clone(&self.root);
    +        let account = at.account.to_owned();
    +        let bucket = at.bucket.to_owned();
    +        let path = file.path.clone();
    +        let size = enumerated_size(file);
    +        Box::pin(async move {
    +            tokio::task::spawn_blocking(move || {
    +                let mut reader =
    +                    open_blocking(&[root], &account, &bucket, &path, compression, size)?;
    +                let Some(mut inner) = reader.inner.take() else {
    +                    return Ok(0);
    +                };
    +                let mut count: u64 = 0;
    +                let mut buf = Vec::new();
    +                loop {
    +                    buf.clear();
    +                    match inner.read_until(b'\n', &mut buf) {
    +                        Ok(0) => break,
    +                        Ok(_) => count += 1,
    +                        Err(e) => return Err(SourceError::Io(e)),
    +                    }
    +                }
    +                Ok(count)
    +            })
    +            .await
    +            .map_err(|e| SourceError::Io(std::io::Error::other(e)))?
    +        })
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/source/jail.rs b/crates/engine/src/bulk_put/source/jail.rs
    new file mode 100644
    index 000000000..35d0b6bf7
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/jail.rs
    @@ -0,0 +1,427 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Path jail primitives for the bulk put source and sink: lexical
    +//! component checks, the verified account directory handle, and the
    +//! handle-relative component walks that open input files and report
    +//! directories without following a symlink out of the account subtree.
    +
    +use std::path::{Component, Path, PathBuf};
    +use std::sync::Arc;
    +
    +use crate::import_export_io::{account_scoped_roots, validate_path};
    +
    +use super::*;
    +
    +/// The blocking half of [`FsSource::bucket_exists`].
    +pub(super) fn bucket_exists_blocking(
    +    roots: &[Arc],
    +    account: &str,
    +    bucket: &str,
    +) -> Result {
    +    check_component(bucket).map_err(SourceError::Jail)?;
    +    let scoped =
    +        account_scoped_roots(roots, account).map_err(|e| SourceError::Jail(e.to_string()))?;
    +    for scoped_root in &scoped {
    +        match bucket_dir_state(scoped_root, roots, account, bucket)? {
    +            BucketDirState::Missing => {}
    +            BucketDirState::NotADirectory | BucketDirState::Symlink => return Ok(false),
    +            BucketDirState::Directory(_) => return Ok(true),
    +        }
    +    }
    +    Ok(false)
    +}
    +
    +/// What sits at `{account}/{bucket}` under one scoped root, decided by the
    +/// handle walk: the verified account handle, then one `openat(O_DIRECTORY |
    +/// O_NOFOLLOW)` of the bucket component, so the answer and the handle the
    +/// enumeration descends from are the same open and no path is resolved
    +/// between them.
    +#[cfg(unix)]
    +pub(super) enum BucketDirState {
    +    /// The account subtree or the bucket component does not exist.
    +    Missing,
    +    /// A symlink sits at the bucket name; never followed, wherever it
    +    /// points.
    +    Symlink,
    +    /// Something that is neither a directory nor a symlink.
    +    NotADirectory,
    +    /// A real directory, held open for the walk.
    +    Directory(std::fs::File),
    +}
    +
    +/// The non-unix shape carries the checked path instead of a handle; the
    +/// fallback walk below resolves by path, and the request handler refuses
    +/// the API on these targets.
    +#[cfg(not(unix))]
    +pub(super) enum BucketDirState {
    +    Missing,
    +    Symlink,
    +    NotADirectory,
    +    Directory(PathBuf),
    +}
    +
    +#[cfg(unix)]
    +pub(super) fn bucket_dir_state(
    +    scoped_root: &Path,
    +    roots: &[Arc],
    +    account: &str,
    +    bucket: &str,
    +) -> Result {
    +    use rustix::fs::{Mode, OFlags, openat};
    +    let account_dir = match open_verified_account_dir(scoped_root, roots, account) {
    +        Ok(Some(handle)) => handle,
    +        Ok(None) => return Ok(BucketDirState::Missing),
    +        Err(AccountDirError::Jail(message)) => return Err(SourceError::Jail(message)),
    +        Err(AccountDirError::Io(e)) => return Err(SourceError::Io(e)),
    +    };
    +    let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
    +    match openat(&account_dir, bucket, flags, Mode::empty()) {
    +        Ok(fd) => Ok(BucketDirState::Directory(std::fs::File::from(fd))),
    +        Err(e) if e == rustix::io::Errno::NOENT => Ok(BucketDirState::Missing),
    +        Err(e) if e == rustix::io::Errno::LOOP => Ok(BucketDirState::Symlink),
    +        Err(e) if e == rustix::io::Errno::NOTDIR => Ok(BucketDirState::NotADirectory),
    +        Err(e) => Err(SourceError::Io(e.into())),
    +    }
    +}
    +
    +/// The non-unix fallback keeps the containment check and the
    +/// `symlink_metadata` decision without the handle-relative hardening; the
    +/// request handler refuses the API on these targets.
    +#[cfg(not(unix))]
    +pub(super) fn bucket_dir_state(
    +    scoped_root: &Path,
    +    roots: &[Arc],
    +    account: &str,
    +    bucket: &str,
    +) -> Result {
    +    let bucket_dir = scoped_root.join(bucket);
    +    let meta = match std::fs::symlink_metadata(&bucket_dir) {
    +        Ok(meta) => meta,
    +        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
    +            return Ok(BucketDirState::Missing);
    +        }
    +        Err(e) => return Err(SourceError::Io(e)),
    +    };
    +    if meta.file_type().is_symlink() {
    +        return Ok(BucketDirState::Symlink);
    +    }
    +    if !meta.is_dir() {
    +        return Ok(BucketDirState::NotADirectory);
    +    }
    +    let raw = bucket_dir
    +        .to_str()
    +        .ok_or_else(|| SourceError::Jail("bucket directory path is not valid UTF-8".to_owned()))?;
    +    validate_path(raw, roots, account).map_err(|e| SourceError::Jail(e.to_string()))?;
    +    Ok(BucketDirState::Directory(bucket_dir))
    +}
    +
    +/// Reject a name that is not one normal path component. Bucket names, the
    +/// operation id, and the lease owner each contribute exactly one directory
    +/// or filename level to a composed path, so none of them may carry a
    +/// separator or a dot component. The request handler enforces the same rule
    +/// on the wire fields at Start; re-checking here keeps the jail decision
    +/// local to the code that composes the path.
    +pub(super) fn check_component(name: &str) -> Result<(), String> {
    +    if name.is_empty()
    +        || name == "."
    +        || name == ".."
    +        || name.contains('/')
    +        || name.contains('\\')
    +        || name.contains('\0')
    +    {
    +        return Err("path component is empty or contains a separator".to_owned());
    +    }
    +    Ok(())
    +}
    +
    +/// Lexical rules for the output key prefix: relative, no `..` component,
    +/// no backslash, no NUL. The empty prefix is allowed. A prefix may contain
    +/// `/` and so span several directory levels. Enforced at Start on the wire
    +/// field and re-checked here before the prefix is joined onto a base path,
    +/// where an absolute prefix would otherwise replace the base outright.
    +pub(super) fn check_prefix(prefix: &str) -> Result<(), String> {
    +    if prefix.contains('\\') || prefix.contains('\0') {
    +        return Err("prefix must not contain a backslash or NUL".to_owned());
    +    }
    +    if prefix.starts_with('/') {
    +        return Err("prefix must not begin with a separator".to_owned());
    +    }
    +    for component in Path::new(prefix).components() {
    +        match component {
    +            Component::Normal(_) | Component::CurDir => {}
    +            _ => return Err("prefix must not contain '..' or root components".to_owned()),
    +        }
    +    }
    +    Ok(())
    +}
    +
    +/// Split a bucket-relative input path into its components for the walk.
    +/// Rejects empty, `.`, `..`, and NUL-carrying components, so an absolute
    +/// path, a doubled separator, and a traversal are all refused before any
    +/// filesystem access. Enumeration never produces such a path; the check
    +/// keeps the jail decision local to the code that opens the file.
    +pub(super) fn split_input_components(rel: &str) -> Result, String> {
    +    if rel.is_empty() {
    +        return Err("input path is empty".to_owned());
    +    }
    +    let mut components = Vec::new();
    +    for component in rel.split('/') {
    +        if component.is_empty() || component == "." || component == ".." || component.contains('\0')
    +        {
    +            return Err("input path component is empty or a dot component".to_owned());
    +        }
    +        components.push(component);
    +    }
    +    Ok(components)
    +}
    +
    +/// Resolve and open one enumerated file beneath one scoped root by the
    +/// component walk. `Ok(None)` when the account subtree or a walked
    +/// component does not exist under this root, so the caller can try the
    +/// next root the way enumeration chose among them.
    +#[cfg(unix)]
    +pub(super) fn open_one(
    +    scoped_root: &Path,
    +    roots: &[Arc],
    +    account: &str,
    +    bucket: &str,
    +    components: &[&str],
    +) -> Result, SourceError> {
    +    let account_dir = match open_verified_account_dir(scoped_root, roots, account) {
    +        Ok(Some(handle)) => handle,
    +        Ok(None) => return Ok(None),
    +        Err(AccountDirError::Jail(message)) => return Err(SourceError::Jail(message)),
    +        Err(AccountDirError::Io(e)) => return Err(SourceError::Io(e)),
    +    };
    +    walk_open_input(&account_dir, bucket, components)
    +}
    +
    +/// Walk `bucket` and the path components from the verified account handle:
    +/// one `openat(O_DIRECTORY | O_NOFOLLOW)` per directory component, then an
    +/// `openat(O_NOFOLLOW | O_NONBLOCK)` of the final component, whose handle
    +/// must be a regular file. A symlink at any component fails its own
    +/// `openat`, so no ancestor swap can redirect the walk, and nothing is
    +/// compared against a path string after the account root, so there is no
    +/// window between a check and the open. `O_NONBLOCK` keeps a swapped-in
    +/// FIFO from blocking the open and is cleared once the handle is known to
    +/// be a regular file, so ordinary reads block normally. `Ok(None)` when a
    +/// component does not exist.
    +#[cfg(unix)]
    +pub(super) fn walk_open_input(
    +    account_dir: &std::fs::File,
    +    bucket: &str,
    +    components: &[&str],
    +) -> Result, SourceError> {
    +    use rustix::fs::{Mode, OFlags, openat};
    +    let Some((file_name, dirs)) = components.split_last() else {
    +        return Err(SourceError::Jail("input path is empty".to_owned()));
    +    };
    +    let dir_flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
    +    let mut cursor = match openat(account_dir, bucket, dir_flags, Mode::empty()) {
    +        Ok(fd) => std::fs::File::from(fd),
    +        Err(e) => return map_input_walk_errno(e),
    +    };
    +    for component in dirs.iter().copied() {
    +        cursor = match openat(&cursor, component, dir_flags, Mode::empty()) {
    +            Ok(fd) => std::fs::File::from(fd),
    +            Err(e) => return map_input_walk_errno(e),
    +        };
    +    }
    +    let file_flags = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC;
    +    let file = match openat(&cursor, *file_name, file_flags, Mode::empty()) {
    +        Ok(fd) => std::fs::File::from(fd),
    +        Err(e) => return map_input_walk_errno(e),
    +    };
    +    let got = file.metadata().map_err(SourceError::Io)?;
    +    if !got.file_type().is_file() {
    +        return Err(SourceError::NotRegularFile);
    +    }
    +    let flags = rustix::fs::fcntl_getfl(&file).map_err(|e| SourceError::Io(e.into()))?;
    +    rustix::fs::fcntl_setfl(&file, flags.difference(rustix::fs::OFlags::NONBLOCK))
    +        .map_err(|e| SourceError::Io(e.into()))?;
    +    Ok(Some(file))
    +}
    +
    +/// One verdict per walked component: a missing component means the file is
    +/// not under this root, a symlink is a jail violation wherever it points,
    +/// and a non-directory in the middle of the path is one as well.
    +#[cfg(unix)]
    +fn map_input_walk_errno(e: rustix::io::Errno) -> Result, SourceError> {
    +    if e == rustix::io::Errno::NOENT {
    +        return Ok(None);
    +    }
    +    if e == rustix::io::Errno::LOOP {
    +        return Err(SourceError::Jail("symlink in the input path".to_owned()));
    +    }
    +    if e == rustix::io::Errno::NOTDIR {
    +        return Err(SourceError::Jail(
    +            "input path component is not a directory".to_owned(),
    +        ));
    +    }
    +    Err(SourceError::Io(e.into()))
    +}
    +
    +/// Open a handle on the caller's account directory beneath one root and
    +/// verify it: the shared `validate_path` containment check on the
    +/// directory, then a device and inode match between the handle and the
    +/// canonicalized account root. The account directory's parent is the
    +/// configured root, which no tenant can write, so this reference cannot be
    +/// redirected the way a tenant-owned ancestor could; every later component
    +/// is resolved by the walk from the handle, never by a path lookup.
    +/// `Ok(None)` when the account subtree does not exist under this root.
    +#[cfg(unix)]
    +pub(super) fn open_verified_account_dir(
    +    dir: &Path,
    +    roots: &[Arc],
    +    account: &str,
    +) -> Result, AccountDirError> {
    +    use rustix::fs::{CWD, Mode, OFlags, openat};
    +    use std::os::unix::fs::MetadataExt;
    +    match std::fs::symlink_metadata(dir) {
    +        Ok(_) => {}
    +        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
    +        Err(e) => return Err(AccountDirError::Io(e)),
    +    }
    +    let raw = dir.to_str().ok_or_else(|| {
    +        AccountDirError::Jail("account directory path is not valid UTF-8".to_owned())
    +    })?;
    +    // Canonical containment and the symlink refusal on the directory
    +    // itself, shared with every other import/export path decision.
    +    let canonical =
    +        validate_path(raw, roots, account).map_err(|e| AccountDirError::Jail(e.to_string()))?;
    +    let expected = std::fs::symlink_metadata(&canonical).map_err(AccountDirError::Io)?;
    +    let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
    +    let handle = match openat(CWD, dir, flags, Mode::empty()) {
    +        Ok(fd) => std::fs::File::from(fd),
    +        Err(e) => return Err(AccountDirError::Io(e.into())),
    +    };
    +    let got = handle.metadata().map_err(AccountDirError::Io)?;
    +    if !got.is_dir() || got.dev() != expected.dev() || got.ino() != expected.ino() {
    +        return Err(AccountDirError::Jail(
    +            "account directory changed between validation and open".to_owned(),
    +        ));
    +    }
    +    Ok(Some(handle))
    +}
    +
    +/// A failure while verifying the account directory handle.
    +#[cfg(unix)]
    +#[derive(Debug)]
    +pub(super) enum AccountDirError {
    +    Jail(String),
    +    Io(std::io::Error),
    +}
    +
    +/// The bulk input layout is POSIX-only; on other platforms the open keeps
    +/// the containment check and the file-type check without the walk-based
    +/// hardening, and the request handler refuses the API.
    +#[cfg(not(unix))]
    +pub(super) fn open_one(
    +    scoped_root: &Path,
    +    roots: &[Arc],
    +    account: &str,
    +    bucket: &str,
    +    components: &[&str],
    +) -> Result, SourceError> {
    +    let mut full = scoped_root.join(bucket);
    +    for component in components.iter().copied() {
    +        full.push(component);
    +    }
    +    match std::fs::symlink_metadata(&full) {
    +        Ok(_) => {}
    +        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
    +        Err(e) => return Err(SourceError::Io(e)),
    +    }
    +    let raw = full
    +        .to_str()
    +        .ok_or_else(|| SourceError::Jail("input file path is not valid UTF-8".to_owned()))?;
    +    let canonical =
    +        validate_path(raw, roots, account).map_err(|e| SourceError::Jail(e.to_string()))?;
    +    let file = std::fs::File::open(&canonical).map_err(SourceError::Io)?;
    +    let got = file.metadata().map_err(SourceError::Io)?;
    +    if !got.file_type().is_file() {
    +        return Err(SourceError::NotRegularFile);
    +    }
    +    Ok(Some(file))
    +}
    +
    +/// One walk step on the write side: open the component as a real
    +/// directory, creating it with `mkdirat` on the parent's handle when it is
    +/// missing. An `EEXIST` from `mkdirat` means another runner raced the
    +/// creation, and the open after it decides. `create_dir_all` is never
    +/// used: it would traverse a symlink racing this loop. A directory created
    +/// here holds reports, whose records carry item values, so it is open to
    +/// the server's own user only; an existing one keeps its mode.
    +#[cfg(unix)]
    +pub(super) fn open_or_create_report_component(
    +    parent: &std::fs::File,
    +    component: &str,
    +) -> Result {
    +    use rustix::fs::{Mode, OFlags, mkdirat, openat};
    +    let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
    +    match openat(parent, component, flags, Mode::empty()) {
    +        Ok(fd) => return Ok(std::fs::File::from(fd)),
    +        Err(e) if e == rustix::io::Errno::NOENT => {}
    +        Err(e) => return Err(map_report_walk_errno(parent, component, e)),
    +    }
    +    match mkdirat(parent, component, Mode::from_raw_mode(0o700)) {
    +        Ok(()) => {}
    +        Err(e) if e == rustix::io::Errno::EXIST => {}
    +        Err(e) => return Err(SinkError::Io(e.into())),
    +    }
    +    match openat(parent, component, flags, Mode::empty()) {
    +        Ok(fd) => Ok(std::fs::File::from(fd)),
    +        Err(e) => Err(map_report_walk_errno(parent, component, e)),
    +    }
    +}
    +
    +/// The open-only walk step used for the bucket component: the same
    +/// `openat(O_DIRECTORY | O_NOFOLLOW)` but nothing is created, so a missing
    +/// bucket directory fails the render.
    +#[cfg(unix)]
    +pub(super) fn open_report_component(
    +    parent: &std::fs::File,
    +    component: &str,
    +) -> Result {
    +    use rustix::fs::{Mode, OFlags, openat};
    +    let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
    +    match openat(parent, component, flags, Mode::empty()) {
    +        Ok(fd) => Ok(std::fs::File::from(fd)),
    +        Err(e) if e == rustix::io::Errno::NOENT => Err(SinkError::Io(std::io::Error::new(
    +            std::io::ErrorKind::NotFound,
    +            "report bucket directory does not exist",
    +        ))),
    +        Err(e) => Err(map_report_walk_errno(parent, component, e)),
    +    }
    +}
    +
    +/// One verdict per chain component: a symlink fails the render, wherever
    +/// it points, and so does anything that is not a directory. `O_DIRECTORY |
    +/// O_NOFOLLOW` answers `ENOTDIR` for a symlink as well as for a plain
    +/// file; the refusal is already decided when this runs, so the extra stat
    +/// only picks the error variant.
    +#[cfg(unix)]
    +fn map_report_walk_errno(
    +    parent: &std::fs::File,
    +    component: &str,
    +    e: rustix::io::Errno,
    +) -> SinkError {
    +    let symlink = || SinkError::Symlink("symlink in the report path".to_owned());
    +    if e == rustix::io::Errno::LOOP {
    +        return symlink();
    +    }
    +    if e == rustix::io::Errno::NOTDIR {
    +        if let Ok(stat) =
    +            rustix::fs::statat(parent, component, rustix::fs::AtFlags::SYMLINK_NOFOLLOW)
    +            && rustix::fs::FileType::from_raw_mode(stat.st_mode) == rustix::fs::FileType::Symlink
    +        {
    +            return symlink();
    +        }
    +        return SinkError::Io(std::io::Error::new(
    +            std::io::ErrorKind::NotADirectory,
    +            "report path component is not a directory",
    +        ));
    +    }
    +    SinkError::Io(e.into())
    +}
    diff --git a/crates/engine/src/bulk_put/source/key_form.rs b/crates/engine/src/bulk_put/source/key_form.rs
    new file mode 100644
    index 000000000..dae8d5f3f
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/key_form.rs
    @@ -0,0 +1,116 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The key a store writes, or lists, for a key as the request spells it.
    +//!
    +//! The filesystem store walks a key segment by segment, and an empty
    +//! segment or a `.` segment names the directory it is already in, so
    +//! `./protected/x`, `.//protected/x` and `protected/x` are one file. An
    +//! S3-compatible service with a file backend does the same and also
    +//! resolves `..` against the segment before it. AWS S3 stores every key as
    +//! given. The S3 permission checks evaluate the key as spelled and, where
    +//! it differs, the key the store uses, so a policy on one spelling holds
    +//! for every spelling of the same object.
    +
    +/// The segments of `key` the filesystem store walks: every `/`-separated
    +/// segment except an empty one and `.`. The report writer, the manifest
    +/// writer and [`stored_form`] all take their segments from here.
    +pub(crate) fn walked_segments(key: &str) -> impl Iterator {
    +    key.split('/')
    +        .filter(|part| !part.is_empty() && *part != ".")
    +}
    +
    +/// `key` as the store holds it: the [`walked_segments`] joined with `/`,
    +/// and with `resolve_parent` each `..` segment removing the segment kept
    +/// before it (at the start it removes nothing). A trailing `/` is kept
    +/// when any segment remains, so a prefix keeps the boundary it names.
    +pub(crate) fn stored_form(key: &str, resolve_parent: bool) -> String {
    +    let mut kept: Vec<&str> = Vec::new();
    +    for part in walked_segments(key) {
    +        if resolve_parent && part == ".." {
    +            kept.pop();
    +        } else {
    +            kept.push(part);
    +        }
    +    }
    +    let mut stored = kept.join("/");
    +    if key.ends_with('/') && !stored.is_empty() {
    +        stored.push('/');
    +    }
    +    stored
    +}
    +
    +/// Whether resolving the `..` segments of `key` against the segments before
    +/// them goes above the start of the key, as in `../x` and `a/../../x`.
    +/// [`stored_form`] drops such a climb, so the key it gives is inside the
    +/// bucket while a service that resolves the URL path may name another one.
    +pub(crate) fn climbs_above_start(key: &str) -> bool {
    +    let mut depth: usize = 0;
    +    for part in walked_segments(key) {
    +        if part == ".." {
    +            let Some(up) = depth.checked_sub(1) else {
    +                return true;
    +            };
    +            depth = up;
    +        } else {
    +            depth += 1;
    +        }
    +    }
    +    false
    +}
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::*;
    +
    +    #[test]
    +    fn empty_and_dot_segments_are_dropped_and_a_parent_kept_without_resolution() {
    +        for (key, stored) in [
    +            ("protected/x", "protected/x"),
    +            ("./protected/x", "protected/x"),
    +            (".//protected/x", "protected/x"),
    +            ("x/./protected/x", "x/protected/x"),
    +            ("protected/.", "protected"),
    +            ("a//b/", "a/b/"),
    +            ("/lead/", "lead/"),
    +            ("x/../protected/x", "x/../protected/x"),
    +            ("", ""),
    +            ("./", ""),
    +        ] {
    +            assert_eq!(stored_form(key, false), stored, "{key}");
    +        }
    +    }
    +
    +    #[test]
    +    fn a_parent_segment_removes_the_segment_before_it_when_resolved() {
    +        for (key, stored) in [
    +            ("x/../protected/x", "protected/x"),
    +            ("x/./../protected/", "protected/"),
    +            ("../protected/x", "protected/x"),
    +            ("a/b/../../c", "c"),
    +            ("a/..", ""),
    +            ("a/../", ""),
    +        ] {
    +            assert_eq!(stored_form(key, true), stored, "{key}");
    +        }
    +    }
    +
    +    #[test]
    +    fn a_climb_above_the_start_is_found_after_dot_and_empty_segments_drop() {
    +        for (key, climbs) in [
    +            ("../victim/x", true),
    +            ("a/../../victim/x", true),
    +            ("x/../../victim", true),
    +            ("./../victim/", true),
    +            ("a//../..", true),
    +            ("x/../protected/x", false),
    +            ("a/b/../../c", false),
    +            ("a/..", false),
    +            ("...", false),
    +            ("a/.../b", false),
    +            ("", false),
    +        ] {
    +            assert_eq!(climbs_above_start(key), climbs, "{key}");
    +        }
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/source/location.rs b/crates/engine/src/bulk_put/source/location.rs
    new file mode 100644
    index 000000000..c971632cd
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/location.rs
    @@ -0,0 +1,91 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! What a store is told about a location and what it answers: the bucket
    +//! one side of an operation names, the encryption a write asks for, and a
    +//! fault the store renders for the client. The builders are in
    +//! `bulk_put::store`.
    +
    +/// The bucket one side of an operation names, as a store sees it. Built
    +/// with [`BucketRef::new`], which applies the owner normalization.
    +#[derive(Debug, Clone, Copy)]
    +pub(crate) struct BucketRef<'a> {
    +    /// The caller's account id: the filesystem store's namespace, and the
    +    /// account an `S3BucketOwner` is compared with.
    +    pub account: &'a str,
    +    pub bucket: &'a str,
    +    /// The configuration's `S3BucketOwner` when it names an account other
    +    /// than the caller's; `None` when it is absent or equal to the caller's
    +    /// account.
    +    pub expected_owner: Option<&'a str>,
    +}
    +
    +/// The server-side encryption a report or manifest write asks for, from
    +/// the output configuration's `S3SseAlgorithm` and `S3SseKmsKeyId`. The
    +/// filesystem store records it and writes plain files.
    +#[derive(Debug, Clone, PartialEq, Eq)]
    +pub(crate) enum OutputSse {
    +    None,
    +    Aes256,
    +    Kms { key_id: Option },
    +}
    +
    +/// A fault the store has already rendered for the client: the operation
    +/// ends with `code` as its `FailureCode` and `message` as its
    +/// `FailureMessage`.
    +#[derive(Debug, Clone, PartialEq, Eq)]
    +pub(crate) struct StoreFault {
    +    pub code: &'static str,
    +    pub message: String,
    +    /// Whether the fault came after the first byte of an input object was
    +    /// read. A location check sets it false.
    +    pub after_first_byte: bool,
    +}
    +
    +impl StoreFault {
    +    /// The fault an I/O error carries, when a store's reader raised it with
    +    /// `std::io::Error::other(fault)`: a read that fails mid-object reaches
    +    /// the decode chain as an I/O error, and the chain hands the fault on
    +    /// as `SourceError::Store` with its code and message.
    +    pub(crate) fn of_io(error: &std::io::Error) -> Option {
    +        error.get_ref()?.downcast_ref::().cloned()
    +    }
    +}
    +
    +impl std::fmt::Display for StoreFault {
    +    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    +        write!(f, "{}: {}", self.code, self.message)
    +    }
    +}
    +
    +impl std::error::Error for StoreFault {}
    +
    +/// A store failure the store classifies as transient (a 5xx answer,
    +/// throttling, a timeout, a connection failure) that outlasted its retries.
    +/// It travels inside `std::io::Error::other`, as a [`StoreFault`] does
    +/// mid-object, so it reaches the runner through every decode chain as
    +/// `SourceError::Io` or `SinkError::Io`. The runner then leaves the
    +/// operation in progress for the next claim, as after a transient database
    +/// fault; every other caller answers it as the server's failure.
    +#[derive(Debug, Clone)]
    +pub(crate) struct StoreUnavailable(pub(crate) String);
    +
    +impl StoreUnavailable {
    +    /// The I/O error that carries the failure, `detail` its text.
    +    pub(crate) fn io(detail: String) -> std::io::Error {
    +        std::io::Error::other(Self(detail))
    +    }
    +
    +    /// Whether `error` carries a [`StoreUnavailable`].
    +    pub(crate) fn of_io(error: &std::io::Error) -> bool {
    +        error.get_ref().is_some_and(|inner| inner.is::())
    +    }
    +}
    +
    +impl std::fmt::Display for StoreUnavailable {
    +    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    +        f.write_str(&self.0)
    +    }
    +}
    +
    +impl std::error::Error for StoreUnavailable {}
    diff --git a/crates/engine/src/bulk_put/source/manifest.rs b/crates/engine/src/bulk_put/source/manifest.rs
    new file mode 100644
    index 000000000..a0ea0ba22
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/manifest.rs
    @@ -0,0 +1,95 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The operation manifest file: the list of input files an operation
    +//! ingested, written to the operation directory.
    +
    +use std::path::PathBuf;
    +use std::sync::Arc;
    +
    +use crate::bulk_put::report;
    +use crate::import_export_io::account_scoped_roots;
    +
    +use super::jail::{check_component, check_prefix};
    +use super::key_form::walked_segments;
    +use super::sink::ReportDirectory;
    +use super::*;
    +
    +/// The manifest's file name in the operation directory.
    +const MANIFEST_NAME: &str = "manifest-files.json";
    +
    +/// Write the manifest: the same lexical checks and walk as a report, to the
    +/// operation directory itself (the report directories hang beneath the
    +/// `-0` shard directory beside it), then the temp file, the lines, sync,
    +/// and the rename over the final name.
    +pub(super) fn write_manifest_blocking(
    +    roots: &[Arc],
    +    account: &str,
    +    bucket: &str,
    +    prefix: &str,
    +    operation_id: &str,
    +    owner: &str,
    +    lines: &[Value],
    +) -> Result {
    +    use std::io::Write;
    +    check_component(bucket).map_err(SinkError::Jail)?;
    +    check_prefix(prefix).map_err(SinkError::Jail)?;
    +    check_component(operation_id).map_err(SinkError::Jail)?;
    +    check_component(owner).map_err(SinkError::Jail)?;
    +    if roots.is_empty() {
    +        return Err(SinkError::Jail("no bulk store is configured".to_owned()));
    +    }
    +    let scoped =
    +        account_scoped_roots(roots, account).map_err(|e| SinkError::Jail(e.to_string()))?;
    +    let existing = scoped.iter().find(|dir| {
    +        std::fs::symlink_metadata(dir)
    +            .map(|meta| meta.is_dir())
    +            .unwrap_or(false)
    +    });
    +    let base = if let Some(dir) = existing {
    +        dir.clone()
    +    } else {
    +        let Some(first) = scoped.first() else {
    +            return Err(SinkError::Jail("no bulk store is configured".to_owned()));
    +        };
    +        std::fs::create_dir_all(first).map_err(SinkError::Io)?;
    +        first.clone()
    +    };
    +    let mut components: Vec = vec![bucket.to_owned()];
    +    components.extend(walked_segments(prefix).map(str::to_owned));
    +    components.push("AWSDynamoDB".to_owned());
    +    components.push("BulkOperation".to_owned());
    +    components.push(format!("{account}:{operation_id}"));
    +    let dir_handle = ReportDirectory::establish(&base, roots, account, &components)?;
    +    dir_handle.remove_stale_temps(&format!("{MANIFEST_NAME}.tmp-"))?;
    +    let tmp_name = format!("{MANIFEST_NAME}.tmp-{owner}");
    +    let mut file = dir_handle.create_new(&tmp_name)?;
    +    let written = (|| -> std::io::Result<()> {
    +        for line in lines {
    +            report::write_manifest_line(&mut file, line)?;
    +            file.write_all(b"\n")?;
    +        }
    +        file.sync_all()
    +    })();
    +    if let Err(error) = written {
    +        drop(file);
    +        let _ = dir_handle.remove_leftover(&tmp_name);
    +        return Err(SinkError::Io(error));
    +    }
    +    drop(file);
    +    dir_handle.rename(&tmp_name, MANIFEST_NAME)?;
    +    tracing::info!(
    +        account_id = %account,
    +        operation = "StartBulkPutItems",
    +        resolved_path = %{
    +            let mut path = base.clone();
    +            for component in &components {
    +                path.push(component);
    +            }
    +            path.push(MANIFEST_NAME);
    +            path.display().to_string()
    +        },
    +        "import/export file access"
    +    );
    +    Ok(report::manifest_key(prefix, account, operation_id))
    +}
    diff --git a/crates/engine/src/bulk_put/source/mod.rs b/crates/engine/src/bulk_put/source/mod.rs
    new file mode 100644
    index 000000000..8288dd291
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/mod.rs
    @@ -0,0 +1,442 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Input files and report files for bulk put operations.
    +//!
    +//! [`BulkSource`] enumerates and opens input files; [`BulkSink`] opens
    +//! report files that a [`ReportWriter`] renders page by page. This module
    +//! holds the filesystem implementations, [`FsSource`] and [`FsSink`]; the
    +//! S3 store's are in `bulk_put::s3`.
    +//!
    +//! Both filesystem implementations live inside the `import_export_io` jail. Every path
    +//! is composed from the account id of the authenticated identity and
    +//! checked lexically before any filesystem access. Enumeration descends
    +//! with `openat(O_DIRECTORY | O_NOFOLLOW)` directory handles from the
    +//! verified account handle and reads entries from those handles, so a
    +//! symlink is never followed into or out of the subtree and there is no
    +//! interval between examining a component and descending into it in which
    +//! a swapped symlink could be followed; the bucket existence check uses
    +//! the same primitives. Opening an input file or a report
    +//! directory starts from a
    +//! handle on the caller's account directory, verified against the
    +//! canonicalized, containment-checked account root, and walks the
    +//! remaining components with `openat(O_NOFOLLOW)`, so a component swapped
    +//! for a symlink at any point fails its own open instead of redirecting
    +//! the walk, and no path is resolved again after the account root. The
    +//! final input handle must be a regular file; `O_NONBLOCK` keeps a
    +//! swapped-in FIFO from blocking the open. Report rendering creates
    +//! missing directories with `mkdirat` on their parent's handle and holds
    +//! the final directory handle for the temp-file create, write, and rename.
    +//! The non-unix fallback is unhardened and the API is refused on those
    +//! targets by the request handler. Blocking filesystem work, encoding
    +//! included, runs on `spawn_blocking`, never on the async runtime.
    +
    +use std::io::BufRead;
    +use std::path::PathBuf;
    +use std::sync::Arc;
    +
    +use serde_json::Value;
    +
    +use extenddb_storage::BoxedFuture as BoxFuture;
    +use extenddb_storage::{InputFile, ReportKind};
    +
    +use crate::bulk_put::store_root::ensure_account_dir;
    +
    +mod chain;
    +mod codec;
    +mod fs_source;
    +mod jail;
    +mod key_form;
    +mod location;
    +mod manifest;
    +mod reader;
    +mod sink;
    +mod walk;
    +
    +#[cfg(test)]
    +mod contract;
    +#[cfg(test)]
    +mod tests;
    +
    +pub(crate) use chain::{ChainLineReader, line_reader};
    +use jail::bucket_exists_blocking;
    +pub(crate) use key_form::{climbs_above_start, stored_form};
    +pub(crate) use location::{BucketRef, OutputSse, StoreFault, StoreUnavailable};
    +use reader::{etag_blocking, open_blocking};
    +pub(crate) use sink::ROTATION_THRESHOLD_BYTES;
    +use walk::list_blocking;
    +
    +/// One enumerated input file, as stored on the operation record.
    +#[derive(Debug, Clone, PartialEq, Eq)]
    +pub(crate) struct SourceFile {
    +    pub path: String,
    +    pub size: u64,
    +    /// The entity tag the store listed for the object; `None` when the
    +    /// store derives it from the bytes instead, as the filesystem store
    +    /// does.
    +    pub etag: Option,
    +}
    +
    +/// A failure while enumerating or reading input files.
    +///
    +/// `TooManyFiles` comes only from `list`, when the walk passes one of the
    +/// bounds derived from the caller's file limit; the Start handler maps it
    +/// to the `max_bulk_input_files` `ValidationException`. `LineTooLong`,
    +/// `InvalidUtf8` and `Corrupt` follow the parse-level abandon path; `Store`
    +/// carries its own code and message; the runner maps the remaining three to
    +/// a `FAILED` operation with `InternalServerError`, so none of those
    +/// strings reaches a client.
    +#[derive(Debug)]
    +// The String and Io payloads are read through the derived Debug impl in the
    +// runner's log lines, which dead code analysis deliberately ignores.
    +#[allow(dead_code)]
    +pub(crate) enum SourceError {
    +    Jail(String),
    +    NotRegularFile,
    +    LineTooLong,
    +    /// A line whose bytes are not valid UTF-8 inside a JSON string literal.
    +    /// JSON text is UTF-8, so the line is not well-formed JSON; it is
    +    /// reported, not decoded lossily. Invalid bytes outside a string
    +    /// literal are a `Corrupt` line instead: the hosted parser tokenizes
    +    /// first and decodes string contents when it reaches them, so it
    +    /// reports "Unexpected token" for a bad byte between tokens and the
    +    /// UTF-8 message only for one inside a string (measured on the service).
    +    InvalidUtf8,
    +    /// The compressed stream is corrupt or ends before its trailer. The
    +    /// bytes are not readable input; the file is abandoned at that point
    +    /// as the hosted service does (measured on the service: a truncated gzip
    +    /// file counts one processed and failed row with the message
    +    /// "Unexpected token. Remainder of the file will not be processed."
    +    /// and the operation continues with the next file).
    +    Corrupt,
    +    /// `InputCompressionType` names a codec and the file does not begin
    +    /// with that codec's magic bytes: it is not a compressed object.
    +    /// Measured on the service for GZIP: the file counts one processed and
    +    /// failed row with the message "The S3 object could not be
    +    /// decompressed", the operation continues with the next file, and it
    +    /// ends FAILED with FailureCode ValidationException, "Some of the items
    +    /// failed validation checks and were not ingested".
    +    NotCompressed,
    +    /// `InputCompressionType` is `ZSTD`, the file begins with the zstd
    +    /// magic, and the frame does not decode (truncated or corrupt). The
    +    /// hosted service reports the file as one failed row with the message
    +    /// "The S3 object could not be decompressed", counts its bytes as
    +    /// processed input, and continues with the next file; the operation
    +    /// ends by its other rows (measured on the service: a truncated zstd frame
    +    /// beside a good file ends `COMPLETED_WITH_ERRORS`, where a plain file
    +    /// under `ZSTD` ends `FAILED` as under `GZIP`).
    +    DecodeFailed,
    +    /// The object at the key is no longer the one the operation listed: the
    +    /// S3 store's GET, sent with `If-Match` and the listed `ETag`, was
    +    /// answered 412. Measured on the service for an object overwritten
    +    /// after the listing and before its read: the file counts one processed
    +    /// and failed row with the message "The S3 object could not be imported
    +    /// because it was overwritten" and no `itemIndex`, none of its bytes are
    +    /// processed input, the manifest marks it `PARTIALLY_PROCESSED` with no
    +    /// range and the listed entity tag, the run continues with the next
    +    /// file, and the operation ends `FAILED` with `ValidationException`,
    +    /// "Some of the items failed validation checks and were not ingested".
    +    Overwritten,
    +    TooManyFiles,
    +    Io(std::io::Error),
    +    /// A location fault the store rendered (see [`StoreFault`]).
    +    Store(StoreFault),
    +}
    +
    +/// How an input object is encoded, from the operation's
    +/// `InputCompressionType`. The reader decompresses while it reads; the
    +/// enumerated size, `ProcessedInputBytes` and the entity tag all describe
    +/// the stored (compressed) bytes.
    +#[derive(Clone, Copy, Debug, PartialEq, Eq)]
    +pub(crate) enum InputCodec {
    +    None,
    +    Gzip,
    +    Zstd,
    +}
    +
    +impl InputCodec {
    +    /// Whether a decoder sits in the read chain.
    +    pub(crate) fn decodes(self) -> bool {
    +        !matches!(self, Self::None)
    +    }
    +
    +    /// The codec's leading magic bytes, checked when the file is opened;
    +    /// a file that does not start with them is `NotCompressed`.
    +    fn magic(self) -> &'static [u8] {
    +        match self {
    +            Self::None => &[],
    +            Self::Gzip => &[0x1f, 0x8b],
    +            Self::Zstd => &[0x28, 0xb5, 0x2f, 0xfd],
    +        }
    +    }
    +}
    +
    +/// A failure while rendering a report file. The runner maps `Store` to its
    +/// own code and message, and every other variant to a `FAILED` operation
    +/// by its class with a log entry; no path reaches a client.
    +#[derive(Debug)]
    +// The payloads are read through the derived Debug impl in the runner's log
    +// lines, which dead code analysis deliberately ignores.
    +#[allow(dead_code)]
    +pub(crate) enum SinkError {
    +    Jail(String),
    +    Symlink(String),
    +    Io(std::io::Error),
    +    /// A location fault the store rendered (see [`StoreFault`]).
    +    Store(StoreFault),
    +}
    +
    +/// Where input files come from: [`FsSource`] on the filesystem store, the
    +/// S3 store's source in `bulk_put::s3`. `at` names the input bucket on
    +/// every call.
    +pub(crate) trait BulkSource: Send + Sync {
    +    /// The Start-time check of the input bucket: the expected owner first,
    +    /// then existence; a failure of either is a [`SourceError::Store`]
    +    /// fault. `prefix` is the request's `S3KeyPrefix`. The filesystem store
    +    /// creates the caller's account directory first, then answers an
    +    /// expected owner with `S3AccessDenied` (on this store the caller's
    +    /// account is the only owner a bucket directory can have) and a bucket
    +    /// directory that does not exist, or is not a directory, with
    +    /// `S3NoSuchBucket`.
    +    fn check_bucket(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +    ) -> BoxFuture<'_, Result<(), SourceError>>;
    +
    +    /// Enumerate regular files under `{root}/{account}/{bucket}` whose
    +    /// bucket-relative path starts with `prefix` (byte-wise, no decoding),
    +    /// lexical order, descending with `openat(O_DIRECTORY | O_NOFOLLOW)`
    +    /// handles from the verified account handle and reading entries from
    +    /// those handles, never descending into or including symlinks. The
    +    /// walk is bounded while it runs: a directory
    +    /// is entered only when its path is still compatible with `prefix`,
    +    /// collecting more than `max_files` matches stops the walk and returns
    +    /// `max_files + 1` entries, so a caller that finds more than
    +    /// `max_files` entries knows the bound was passed; visiting more than
    +    /// four times `max_files` directory entries that can hold a key under
    +    /// `prefix` is `TooManyFiles`, and entries beside the prefix are passed
    +    /// over without counting.
    +    /// Errors: bucket directory missing is `Ok` and empty; a jail violation
    +    /// is `Err`.
    +    fn list(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        max_files: u64,
    +    ) -> BoxFuture<'_, Result, SourceError>>;
    +
    +    /// Open one enumerated file, `file.path`, by the component walk: a
    +    /// verified handle on the caller's account directory, one
    +    /// `openat(O_DIRECTORY | O_NOFOLLOW)` per directory component, an
    +    /// `openat(O_NOFOLLOW | O_NONBLOCK)` of the final component, and an
    +    /// `fstat` that requires a regular file.
    +    /// The returned reader yields lines from at most `file.size` bytes of
    +    /// the file, the enumerated compressed size; bytes past it read as end
    +    /// of file. Under `Gzip` or `Zstd` the stream is decompressed while
    +    /// reading, every member or frame of a concatenated stream included.
    +    /// Runs the blocking parts on `spawn_blocking`.
    +    fn open(
    +        &self,
    +        at: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result, SourceError>>;
    +
    +    /// The entity tag of one input file for the manifest: the lowercase hex
    +    /// MD5 of the first `file.size` bytes, which is what S3 reports as the
    +    /// ETag of a single-part object (measured on the service: the manifest's
    +    /// `etag` equals the object's ETag). Resolved through the same jailed
    +    /// walk as `open`, so a swapped or symlinked file is refused, not
    +    /// hashed.
    +    fn etag(
    +        &self,
    +        at: &BucketRef<'_>,
    +        file: &InputFile,
    +    ) -> BoxFuture<'_, Result>;
    +
    +    /// The number of physical lines in one input file, read the way the
    +    /// runner reads it (the enumerated size as the ceiling, decompressed
    +    /// under `Gzip` or `Zstd`). Used for the manifest of a file an earlier incarnation
    +    /// finished, whose count the current process never saw.
    +    fn line_count(
    +        &self,
    +        at: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result>;
    +
    +    /// The prefix a listing of `prefix` resolves to in this store (see
    +    /// [`key_form`]); `s3:ListBucket` is evaluated on it as well as on
    +    /// `prefix`. The default is `prefix` itself, which is what the
    +    /// filesystem store's walk matches byte-wise against the relative paths
    +    /// it finds; the S3 store against a configured endpoint resolves it.
    +    fn listed_prefix(&self, prefix: &str) -> String {
    +        prefix.to_owned()
    +    }
    +
    +    /// Whether the service assigns the entity tags (S3), so the manifest
    +    /// never asks for the tag of a file listed without one: the object now
    +    /// at the key may be a replacement. The filesystem store hashes the
    +    /// bytes and answers `false`, the default.
    +    fn service_assigns_etags(&self) -> bool {
    +        false
    +    }
    +}
    +
    +/// Async line source. `next_line` returns `Ok(None)` at end of file. Lines
    +/// longer than `max_line_bytes` are returned as `Err(LineTooLong)` without
    +/// buffering more than the cap; a line that is not valid UTF-8 is returned
    +/// as `Err(InvalidUtf8)` after it has been consumed.
    +pub(crate) trait LineReader: Send {
    +    fn next_line(
    +        &mut self,
    +        max_line_bytes: usize,
    +    ) -> BoxFuture<'_, Result, SourceError>>;
    +    /// Compressed bytes consumed so far (for `ProcessedInputBytes`).
    +    fn bytes_consumed(&self) -> u64;
    +    /// The offset of the end of the last line this reader delivered,
    +    /// within the stored object: the byte offset in the file for a plain
    +    /// object, and the compressed bytes consumed when a decoder sits in
    +    /// the chain. The runner decides from it whether an abandon leaves
    +    /// bytes of the file beyond the read that held the fault. The default
    +    /// is the raw consumed count, which a reader that tracks no per-line
    +    /// offset reports instead.
    +    fn last_line_end(&self) -> u64 {
    +        self.bytes_consumed()
    +    }
    +    /// The entity tag of the file, the lowercase hex MD5 of the bytes the
    +    /// reader consumed, once `next_line` has answered `Ok(None)` for end of
    +    /// file; `None` before that, and `None` after a fault, since an
    +    /// abandoned file was not read whole. Equal to what `BulkSource::etag`
    +    /// computes for the same file, so the runner records it at the
    +    /// checkpoint that finishes the file instead of hashing the file again
    +    /// when the manifest is rendered.
    +    fn etag(&self) -> Option {
    +        None
    +    }
    +    /// The tag of the first answer the store accepted at the open, from a
    +    /// store whose tags the service assigns; `None` by default.
    +    fn opened_etag(&self) -> Option {
    +        None
    +    }
    +}
    +
    +/// Where reports go. `at` names the output bucket on every call; `sse` is
    +/// the encryption the output configuration asks for.
    +pub(crate) trait BulkSink: Send + Sync {
    +    /// The output location check: the expected owner first, then
    +    /// existence; a failure of either is a [`SinkError::Store`] fault. `key`
    +    /// is the object the operation is about to write. The runner asks this
    +    /// when the operation's first report record is produced and before the
    +    /// manifest is written. The filesystem store answers an expected owner
    +    /// with `S3AccessDenied` and a bucket directory that does not exist with
    +    /// `S3NoSuchBucket`, deciding existence with the same handle primitives
    +    /// the report walk uses (a symlink at the bucket name is never
    +    /// followed), and ignores `key` and `sse`.
    +    fn check_location(
    +        &self,
    +        at: &BucketRef<'_>,
    +        key: &str,
    +        sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result<(), SinkError>>;
    +
    +    /// The Start-time check of the output encryption settings for `key`; a
    +    /// failure is a [`SinkError::Store`] fault. The filesystem store records
    +    /// the encryption choice and checks nothing, which is the default.
    +    fn check_encryption(
    +        &self,
    +        _at: &BucketRef<'_>,
    +        _key: &str,
    +        _sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result<(), SinkError>> {
    +        Box::pin(async { Ok(()) })
    +    }
    +
    +    /// Prepare one report file for writing: the lexical checks, a verified
    +    /// handle on the caller's account directory, a component walk that
    +    /// opens each directory with `openat(O_DIRECTORY | O_NOFOLLOW)`. The
    +    /// bucket directory is opened, never created (the caller stages it;
    +    /// the runner already failed the operation at the first report record
    +    /// when it was missing, so a missing one here is a crash-window last
    +    /// line of defence); the report
    +    /// tree beneath it is created with `mkdirat` on the parent handle.
    +    /// Then a sweep that unlinks every temp file an earlier incarnation
    +    /// left in the directory, and a temp file created with `create_new`
    +    /// relative to the final directory handle. The returned writer
    +    /// renders the file page by page and renames it into place on
    +    /// `finish`.
    +    fn open(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        operation_id: &str,
    +        kind: ReportKind,
    +        owner: &str,
    +        sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result, SinkError>>;
    +
    +    /// Write the operation's manifest, `manifest-files.json`, in the
    +    /// operation directory (`{account}:{operation_id}`, no shard suffix;
    +    /// measured on the service), one JSON object per line in `lines` order,
    +    /// uncompressed. Same lexical checks, directory walk, temp file and
    +    /// rename as a report; an existing manifest is replaced. Returns the
    +    /// key written.
    +    fn write_manifest(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        sse: &OutputSse,
    +        lines: Vec,
    +    ) -> BoxFuture<'_, Result>;
    +
    +    /// The key a write of `key` lands under in this store (see
    +    /// [`key_form`]); `s3:PutObject` is evaluated on it as well as on
    +    /// `key`. The default is `key` itself.
    +    fn stored_key(&self, key: &str) -> String {
    +        key.to_owned()
    +    }
    +}
    +
    +/// One report file being rendered. Dropping a writer without `finish`
    +/// removes the temp file, so a failed render leaves nothing behind.
    +pub(crate) trait ReportWriter: Send {
    +    /// Encode and append one page of records. Runs on the blocking pool.
    +    fn write_page(&mut self, records: Vec) -> BoxFuture<'_, Result<(), SinkError>>;
    +    /// Finish the gzip stream, fsync, rename over the final name, and
    +    /// return the object key as the hosted service spells it (with the
    +    /// inserted `/` after the prefix, doubled if the prefix ends in `/`).
    +    fn finish(self: Box) -> BoxFuture<'static, Result>;
    +    /// The index of the report object the next `write_page` writes into:
    +    /// the open object's, or the next one's once the open one has rolled.
    +    /// The runner checks the caller's `s3:PutObject` on an object before the
    +    /// page that opens it.
    +    fn object_index(&self) -> u32;
    +    /// Test hook: lower the compressed byte count at which the writer
    +    /// rolls to the next report object, so a rotation test does not have
    +    /// to write 200 MiB. Wrappers that do not render leave it a no-op.
    +    #[cfg(test)]
    +    fn set_rotation_threshold(&mut self, _bytes: u64) {}
    +    /// Test hook: the compressed bytes written to the currently open
    +    /// report object so far, so a rotation test can set the threshold to
    +    /// exactly that figure and pin the boundary comparison. Zero when no
    +    /// object is open or the wrapper does not render.
    +    #[cfg(test)]
    +    fn open_object_bytes(&self) -> u64 {
    +        0
    +    }
    +}
    +
    +/// Filesystem input source reading beneath the caller's account subtree of
    +/// the bulk store root.
    +pub(crate) struct FsSource {
    +    pub root: Arc,
    +}
    +
    +/// Filesystem report sink writing beneath the caller's account subtree of
    +/// the bulk store root.
    +pub(crate) struct FsSink {
    +    pub root: Arc,
    +}
    diff --git a/crates/engine/src/bulk_put/source/reader.rs b/crates/engine/src/bulk_put/source/reader.rs
    new file mode 100644
    index 000000000..10985f3a6
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/reader.rs
    @@ -0,0 +1,79 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Input file reading on the filesystem store: opening a jailed file for
    +//! the decode chain in `chain.rs`, and hashing a jailed file for the
    +//! manifest.
    +
    +use std::io::Read;
    +use std::path::PathBuf;
    +use std::sync::Arc;
    +
    +use crate::import_export_io::account_scoped_roots;
    +
    +use super::chain::{ChainLineReader, line_reader};
    +use super::jail::{check_component, open_one, split_input_components};
    +use super::*;
    +
    +pub(super) fn open_blocking(
    +    roots: &[Arc],
    +    account: &str,
    +    bucket: &str,
    +    rel_path: &str,
    +    compression: InputCodec,
    +    size: u64,
    +) -> Result {
    +    check_component(bucket).map_err(SourceError::Jail)?;
    +    let components = split_input_components(rel_path).map_err(SourceError::Jail)?;
    +    let scoped =
    +        account_scoped_roots(roots, account).map_err(|e| SourceError::Jail(e.to_string()))?;
    +    for scoped_root in &scoped {
    +        let Some(file) = open_one(scoped_root, roots, account, bucket, &components)? else {
    +            continue;
    +        };
    +        // The audit event the import and export operations emit, with the
    +        // path the walk resolved. Paths stay in the log and never reach a
    +        // client.
    +        tracing::info!(
    +            account_id = %account,
    +            operation = "StartBulkPutItems",
    +            resolved_path = %scoped_root.join(bucket).join(rel_path).display(),
    +            "import/export file access"
    +        );
    +        return line_reader(Box::new(file), size, compression);
    +    }
    +    Err(SourceError::Io(std::io::Error::new(
    +        std::io::ErrorKind::NotFound,
    +        "input file not found under any import root",
    +    )))
    +}
    +
    +/// Hash one input file for the manifest `etag`: the same jailed resolution
    +/// as `open_blocking`, then the MD5 of the first `size` bytes (the
    +/// enumerated size, so bytes appended since enumeration are not hashed,
    +/// matching what the reader consumed).
    +pub(super) fn etag_blocking(
    +    roots: &[Arc],
    +    account: &str,
    +    bucket: &str,
    +    rel_path: &str,
    +    size: u64,
    +) -> Result {
    +    use md5::Digest;
    +    check_component(bucket).map_err(SourceError::Jail)?;
    +    let components = split_input_components(rel_path).map_err(SourceError::Jail)?;
    +    let scoped =
    +        account_scoped_roots(roots, account).map_err(|e| SourceError::Jail(e.to_string()))?;
    +    for scoped_root in &scoped {
    +        let Some(file) = open_one(scoped_root, roots, account, bucket, &components)? else {
    +            continue;
    +        };
    +        let mut hasher = md5::Md5::new();
    +        std::io::copy(&mut file.take(size), &mut hasher).map_err(SourceError::Io)?;
    +        return Ok(format!("{:x}", hasher.finalize()));
    +    }
    +    Err(SourceError::Io(std::io::Error::new(
    +        std::io::ErrorKind::NotFound,
    +        "input file not found under any import root",
    +    )))
    +}
    diff --git a/crates/engine/src/bulk_put/source/sink.rs b/crates/engine/src/bulk_put/source/sink.rs
    new file mode 100644
    index 000000000..cb38ed144
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/sink.rs
    @@ -0,0 +1,464 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Report file rendering: the report directory handle produced by the
    +//! write-side component walk, the streaming gzip report writer, and the
    +//! [`BulkSink`] implementation that drives them.
    +
    +mod report_dir;
    +
    +use std::path::PathBuf;
    +use std::sync::Arc;
    +
    +use flate2::Compression;
    +use flate2::write::GzEncoder;
    +
    +use crate::bulk_put::report;
    +use crate::import_export_io::account_scoped_roots;
    +
    +use super::jail::{bucket_exists_blocking, check_component, check_prefix};
    +use super::key_form::walked_segments;
    +use super::manifest::write_manifest_blocking;
    +use super::*;
    +pub(super) use report_dir::ReportDirectory;
    +
    +/// The name of the first report object of each kind; later objects are
    +/// `output-00001.json.gz` onward (see [`object_name`]).
    +#[cfg(test)]
    +pub(super) const FINAL_NAME: &str = "output-00000.json.gz";
    +
    +/// The compressed byte count at which the writer closes the current report
    +/// object and the next page opens the next one: 200 MiB. Measured on the service: the hosted service closed `output-00000.json.gz` at
    +/// 211,529,545 bytes, 200 MiB plus the records already buffered when the
    +/// threshold tripped, and wrote the remaining 73.11 MiB to
    +/// `output-00001.json.gz`. The overall record and byte caps on a report
    +/// are separate and unchanged.
    +pub(crate) const ROTATION_THRESHOLD_BYTES: u64 = 200 * 1024 * 1024;
    +
    +impl BulkSink for FsSink {
    +    fn check_location(
    +        &self,
    +        at: &BucketRef<'_>,
    +        _key: &str,
    +        _sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result<(), SinkError>> {
    +        let root = Arc::clone(&self.root);
    +        let account = at.account.to_owned();
    +        let bucket = at.bucket.to_owned();
    +        let owner_mismatch = at.expected_owner.is_some();
    +        Box::pin(async move {
    +            // Ownership is decided before existence, as at Start: on this
    +            // store the caller's account is the only owner a bucket
    +            // directory can have.
    +            if owner_mismatch {
    +                return Err(SinkError::Store(StoreFault::access_denied()));
    +            }
    +            let exists = tokio::task::spawn_blocking(move || {
    +                bucket_exists_blocking(&[root], &account, &bucket)
    +            })
    +            .await
    +            .map_err(|e| SinkError::Io(std::io::Error::other(e)))?
    +            .map_err(|error| match error {
    +                SourceError::Jail(detail) => SinkError::Jail(detail),
    +                SourceError::Io(io) => SinkError::Io(io),
    +                other => SinkError::Io(std::io::Error::other(format!("{other:?}"))),
    +            })?;
    +            if exists {
    +                Ok(())
    +            } else {
    +                Err(SinkError::Store(StoreFault::no_such_bucket()))
    +            }
    +        })
    +    }
    +
    +    fn open(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        operation_id: &str,
    +        kind: ReportKind,
    +        owner: &str,
    +        _sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result, SinkError>> {
    +        let root = Arc::clone(&self.root);
    +        let account = at.account.to_owned();
    +        let bucket = at.bucket.to_owned();
    +        let prefix = prefix.to_owned();
    +        let operation_id = operation_id.to_owned();
    +        let owner = owner.to_owned();
    +        Box::pin(async move {
    +            let writer = tokio::task::spawn_blocking(move || {
    +                open_writer_blocking(
    +                    &[root],
    +                    &account,
    +                    &bucket,
    +                    &prefix,
    +                    &operation_id,
    +                    kind,
    +                    &owner,
    +                )
    +            })
    +            .await
    +            .map_err(|e| SinkError::Io(std::io::Error::other(e)))??;
    +            Ok(Box::new(writer) as Box)
    +        })
    +    }
    +
    +    fn write_manifest(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        _sse: &OutputSse,
    +        lines: Vec,
    +    ) -> BoxFuture<'_, Result> {
    +        let root = Arc::clone(&self.root);
    +        let account = at.account.to_owned();
    +        let bucket = at.bucket.to_owned();
    +        let prefix = prefix.to_owned();
    +        let operation_id = operation_id.to_owned();
    +        let owner = owner.to_owned();
    +        Box::pin(async move {
    +            tokio::task::spawn_blocking(move || {
    +                write_manifest_blocking(
    +                    &[root],
    +                    &account,
    +                    &bucket,
    +                    &prefix,
    +                    &operation_id,
    +                    &owner,
    +                    &lines,
    +                )
    +            })
    +            .await
    +            .map_err(|e| SinkError::Io(std::io::Error::other(e)))?
    +        })
    +    }
    +
    +    fn stored_key(&self, key: &str) -> String {
    +        stored_form(key, false)
    +    }
    +}
    +
    +/// Rendering steps 1 to 4: lexical checks, base selection, the verified
    +/// account handle and the component walk to the report directory, and
    +/// `create_new` on the temp name relative to the walked handle. The
    +/// returned writer performs the writes and the rename.
    +fn open_writer_blocking(
    +    roots: &[Arc],
    +    account: &str,
    +    bucket: &str,
    +    prefix: &str,
    +    operation_id: &str,
    +    kind: ReportKind,
    +    owner: &str,
    +) -> Result {
    +    // Step 1: lexical checks. Start enforces the same rules on the wire
    +    // fields; they are re-checked here so the sink is safe on its own terms
    +    // whatever the caller did.
    +    check_component(bucket).map_err(SinkError::Jail)?;
    +    check_prefix(prefix).map_err(SinkError::Jail)?;
    +    check_component(operation_id).map_err(SinkError::Jail)?;
    +    check_component(owner).map_err(SinkError::Jail)?;
    +    if roots.is_empty() {
    +        return Err(SinkError::Jail("no bulk store is configured".to_owned()));
    +    }
    +    let scoped =
    +        account_scoped_roots(roots, account).map_err(|e| SinkError::Jail(e.to_string()))?;
    +
    +    // The account directory under the store root. Start created it through
    +    // `ensure_account_dir`; creating it again here covers a directory
    +    // removed while the operation ran or a resume on a process whose
    +    // startup pass has not seen the account yet.
    +    let existing = scoped.iter().find(|dir| {
    +        std::fs::symlink_metadata(dir)
    +            .map(|meta| meta.is_dir())
    +            .unwrap_or(false)
    +    });
    +    let base = if let Some(dir) = existing {
    +        dir.clone()
    +    } else {
    +        let Some(first) = scoped.first() else {
    +            return Err(SinkError::Jail("no bulk store is configured".to_owned()));
    +        };
    +        std::fs::create_dir_all(first).map_err(SinkError::Io)?;
    +        first.clone()
    +    };
    +
    +    // The component chain beneath the account directory. `check_prefix`
    +    // rejected `..`; an empty or `.` prefix component names the same
    +    // directory on the filesystem, matching the hosted key rule that a
    +    // doubled slash is one separator, so neither adds a walk step.
    +    let mut components: Vec = vec![bucket.to_owned()];
    +    components.extend(walked_segments(prefix).map(str::to_owned));
    +    components.push("AWSDynamoDB".to_owned());
    +    components.push("BulkOperation".to_owned());
    +    components.push(format!("{account}:{operation_id}-0"));
    +    components.push("output".to_owned());
    +    components.push(report::kind_directory(kind).to_owned());
    +
    +    // Steps 2 and 3: a verified handle on the account directory, the
    +    // component walk that opens or creates each directory, and the final
    +    // directory handle every later step operates against.
    +    let dir_handle = ReportDirectory::establish(&base, roots, account, &components)?;
    +
    +    // Step 4 begins: sweep every temp file left in the directory, then
    +    // create this writer's first temp with create_new. The directory is
    +    // exclusive to one operation and kind, so every entry carrying the
    +    // temp marker, whatever its object index, belongs to a dead or stale
    +    // incarnation of this same operation: a dead runner's temp embeds an
    +    // owner id no successor recomputes and would otherwise sit in the
    +    // caller's export tree forever, and unlinking a live stale writer's
    +    // temp makes that writer's later rename fail with NOENT instead of
    +    // replacing this owner's report. The final objects do not carry the
    +    // marker and are untouched until this writer's finish installs its
    +    // own. create_new keeps refusing everything else, symlinks included,
    +    // without wedging the retry.
    +    dir_handle.remove_matching(&is_report_temp)?;
    +    let file = dir_handle.create_new(&temp_name(0, owner))?;
    +    // The report directory path, kept for the file access audit event
    +    // each rename emits. The walk operates on handles; this string exists
    +    // for the log only.
    +    let mut resolved_dir = base;
    +    for component in &components {
    +        resolved_dir.push(component);
    +    }
    +    Ok(FsReportWriter {
    +        dir: Arc::new(dir_handle),
    +        stream: Stream::Open(GzEncoder::new(
    +            CountingFile::new(file),
    +            Compression::default(),
    +        )),
    +        closed: 0,
    +        threshold: ROTATION_THRESHOLD_BYTES,
    +        owner: owner.to_owned(),
    +        first_key: report::report_key(prefix, account, operation_id, kind, 0),
    +        finished: false,
    +        account: account.to_owned(),
    +        resolved_dir,
    +    })
    +}
    +
    +/// The name of report object `index` within its kind directory.
    +#[must_use]
    +pub(super) fn object_name(index: u32) -> String {
    +    format!("output-{index:05}.json.gz")
    +}
    +
    +/// The temp name an object is rendered under before its rename.
    +fn temp_name(index: u32, owner: &str) -> String {
    +    format!("{}.tmp-{owner}", object_name(index))
    +}
    +
    +/// Whether a directory entry is the temp of some report object.
    +fn is_report_temp(name: &str) -> bool {
    +    name.starts_with("output-") && name.contains(".json.gz.tmp-")
    +}
    +
    +/// The index of a final report object name; `None` for anything else.
    +fn object_index(name: &str) -> Option {
    +    let digits = name.strip_prefix("output-")?.strip_suffix(".json.gz")?;
    +    if digits.len() != 5 || !digits.bytes().all(|b| b.is_ascii_digit()) {
    +        return None;
    +    }
    +    digits.parse().ok()
    +}
    +
    +/// A file that counts the compressed bytes the encoder hands it, so the
    +/// writer can measure the current object's size without a stat.
    +struct CountingFile {
    +    file: std::fs::File,
    +    written: u64,
    +}
    +
    +impl CountingFile {
    +    fn new(file: std::fs::File) -> Self {
    +        Self { file, written: 0 }
    +    }
    +}
    +
    +impl std::io::Write for CountingFile {
    +    fn write(&mut self, buf: &[u8]) -> std::io::Result {
    +        let n = self.file.write(buf)?;
    +        self.written += n as u64;
    +        Ok(n)
    +    }
    +
    +    fn flush(&mut self) -> std::io::Result<()> {
    +        self.file.flush()
    +    }
    +}
    +
    +/// Where the writer stands between pages.
    +enum Stream {
    +    /// Object `closed` is open under its temp name and receiving pages.
    +    Open(GzEncoder),
    +    /// The last page took the current object past the threshold and it
    +    /// was closed; the next page creates object `closed`. Opening lazily
    +    /// means a roll on the final page never leaves an empty object.
    +    Rolled,
    +    /// A page failed or `finish` ran.
    +    Closed,
    +}
    +
    +/// Finish the gzip stream of one object and sync its file.
    +fn close_object(encoder: GzEncoder) -> Result<(), SinkError> {
    +    let counting = encoder.finish().map_err(SinkError::Io)?;
    +    counting.file.sync_all().map_err(SinkError::Io)
    +}
    +
    +/// Streaming writer for the report objects of one kind. Created by
    +/// [`FsSink::open`] with the first temp file already in place. Pages are
    +/// gzip-encoded on the blocking pool as they arrive, the encoder moving
    +/// across `spawn_blocking` hops the way the line reader's inner reader
    +/// does. After each page the compressed bytes written to the current
    +/// object are compared with the threshold; a page that takes the object to
    +/// or past it closes that object (the page stays in it, the hosted
    +/// service's overshoot shape) and the next page opens
    +/// `output-0000N.json.gz` with N incremented. `finish` closes the open
    +/// object, renames every temp to its final name in index order, and
    +/// removes any higher-numbered object an earlier incarnation left. Rename
    +/// is atomic and replaces an earlier rendering of the same object, so a
    +/// re-render after a crash between rendering and the terminal transition
    +/// is idempotent.
    +struct FsReportWriter {
    +    dir: Arc,
    +    stream: Stream,
    +    /// Objects closed so far; also the index of the open or next object.
    +    closed: u32,
    +    threshold: u64,
    +    owner: String,
    +    first_key: String,
    +    finished: bool,
    +    /// The two fields of the file access audit event each rename emits.
    +    account: String,
    +    resolved_dir: PathBuf,
    +}
    +
    +impl ReportWriter for FsReportWriter {
    +    fn write_page(&mut self, records: Vec) -> BoxFuture<'_, Result<(), SinkError>> {
    +        Box::pin(async move {
    +            if records.is_empty() {
    +                return Ok(());
    +            }
    +            let stream = std::mem::replace(&mut self.stream, Stream::Closed);
    +            let encoder = match stream {
    +                Stream::Open(encoder) => Some(encoder),
    +                Stream::Rolled => None,
    +                Stream::Closed => {
    +                    return Err(SinkError::Io(std::io::Error::other(
    +                        "report writer already failed or finished",
    +                    )));
    +                }
    +            };
    +            let dir = Arc::clone(&self.dir);
    +            let next_temp = temp_name(self.closed, &self.owner);
    +            let threshold = self.threshold;
    +            let result = tokio::task::spawn_blocking(move || {
    +                let mut encoder = match encoder {
    +                    Some(encoder) => encoder,
    +                    None => GzEncoder::new(
    +                        CountingFile::new(dir.create_new(&next_temp)?),
    +                        Compression::default(),
    +                    ),
    +                };
    +                report::encode_ndjson_page(&mut encoder, &records).map_err(SinkError::Io)?;
    +                if encoder.get_ref().written >= threshold {
    +                    close_object(encoder)?;
    +                    Ok(Stream::Rolled)
    +                } else {
    +                    Ok(Stream::Open(encoder))
    +                }
    +            })
    +            .await
    +            .map_err(|e| SinkError::Io(std::io::Error::other(e)))?;
    +            // On an error the encoder was dropped with its file handle
    +            // inside the closure; the temp files are removed when the
    +            // writer is dropped.
    +            let stream = result?;
    +            if matches!(stream, Stream::Rolled) {
    +                self.closed += 1;
    +            }
    +            self.stream = stream;
    +            Ok(())
    +        })
    +    }
    +
    +    fn finish(self: Box) -> BoxFuture<'static, Result> {
    +        Box::pin(async move {
    +            tokio::task::spawn_blocking(move || {
    +                let mut this = self;
    +                match std::mem::replace(&mut this.stream, Stream::Closed) {
    +                    Stream::Open(encoder) => {
    +                        close_object(encoder)?;
    +                        this.closed += 1;
    +                    }
    +                    Stream::Rolled => {}
    +                    Stream::Closed => {
    +                        return Err(SinkError::Io(std::io::Error::other(
    +                            "report writer already failed",
    +                        )));
    +                    }
    +                }
    +                for index in 0..this.closed {
    +                    let name = object_name(index);
    +                    this.dir.rename(&temp_name(index, &this.owner), &name)?;
    +                    // The audit event the import and export operations
    +                    // emit, on the rename that installs each object. Paths
    +                    // stay in the log and never reach a client.
    +                    tracing::info!(
    +                        account_id = %this.account,
    +                        operation = "StartBulkPutItems",
    +                        resolved_path = %this.resolved_dir.join(name).display(),
    +                        "import/export file access"
    +                    );
    +                }
    +                // An earlier incarnation that rolled further than this
    +                // render leaves higher-numbered objects the renames did
    +                // not replace; they would read as part of this report.
    +                let count = this.closed;
    +                this.dir
    +                    .remove_matching(&|name| object_index(name).is_some_and(|i| i >= count))?;
    +                this.finished = true;
    +                Ok(this.first_key.clone())
    +            })
    +            .await
    +            .map_err(|e| SinkError::Io(std::io::Error::other(e)))?
    +        })
    +    }
    +
    +    fn object_index(&self) -> u32 {
    +        // The open object is `closed`; after a roll, `closed` is the next.
    +        self.closed
    +    }
    +
    +    #[cfg(test)]
    +    fn set_rotation_threshold(&mut self, bytes: u64) {
    +        self.threshold = bytes;
    +    }
    +
    +    #[cfg(test)]
    +    fn open_object_bytes(&self) -> u64 {
    +        match &self.stream {
    +            Stream::Open(encoder) => encoder.get_ref().written,
    +            Stream::Rolled | Stream::Closed => 0,
    +        }
    +    }
    +}
    +
    +impl Drop for FsReportWriter {
    +    fn drop(&mut self) {
    +        if self.finished {
    +            return;
    +        }
    +        // The failure path: close the open encoder's file handle, then
    +        // remove every object's temp, the closed ones and the open or
    +        // half-created one. Blocking calls are acceptable here because the
    +        // happy path never drops an unfinished writer.
    +        drop(std::mem::replace(&mut self.stream, Stream::Closed));
    +        for index in 0..=self.closed {
    +            let _ = self.dir.remove_leftover(&temp_name(index, &self.owner));
    +        }
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/source/sink/report_dir.rs b/crates/engine/src/bulk_put/source/sink/report_dir.rs
    new file mode 100644
    index 000000000..6598c7488
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/sink/report_dir.rs
    @@ -0,0 +1,207 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The report directory handle: the result of the write-side component
    +//! walk, and the handle-relative create, unlink, and rename primitives
    +//! every report and manifest write runs through.
    +
    +use std::path::{Path, PathBuf};
    +use std::sync::Arc;
    +
    +use crate::bulk_put::source::SinkError;
    +#[cfg(unix)]
    +use crate::bulk_put::source::jail::{
    +    AccountDirError, open_or_create_report_component, open_report_component,
    +    open_verified_account_dir,
    +};
    +
    +/// The directory handle a report write runs against, produced by the
    +/// component walk from the verified account handle (rendering steps 2 and
    +/// 3): one `openat(O_DIRECTORY | O_NOFOLLOW)` per component, `mkdirat` on
    +/// the parent's handle when the component does not exist, and the same
    +/// open again after creating it. A component that is a symlink fails its
    +/// own `openat`, wherever it points, so no path is resolved again after
    +/// the account root and a component swapped for a symlink at any point
    +/// fails instead of redirecting the write. Every later create, unlink, and
    +/// rename is performed relative to the held handle.
    +#[cfg(unix)]
    +pub(crate) struct ReportDirectory {
    +    handle: std::fs::File,
    +}
    +
    +#[cfg(unix)]
    +impl ReportDirectory {
    +    pub(crate) fn establish(
    +        base: &Path,
    +        roots: &[Arc],
    +        account: &str,
    +        components: &[String],
    +    ) -> Result {
    +        let account_dir = match open_verified_account_dir(base, roots, account) {
    +            Ok(Some(handle)) => handle,
    +            // The caller chose or created the base just before this call,
    +            // so a missing directory means it was removed underneath the
    +            // operation.
    +            Ok(None) => {
    +                return Err(SinkError::Io(std::io::Error::new(
    +                    std::io::ErrorKind::NotFound,
    +                    "account export directory does not exist",
    +                )));
    +            }
    +            Err(AccountDirError::Jail(message)) => return Err(SinkError::Jail(message)),
    +            Err(AccountDirError::Io(e)) => return Err(SinkError::Io(e)),
    +        };
    +        let mut cursor = account_dir;
    +        for (index, component) in components.iter().enumerate() {
    +            // The first component is the report bucket directory: the
    +            // caller stages it, the render only opens it. Start already
    +            // failed the operation when it was missing, so reaching a
    +            // missing bucket here means it was removed while the operation
    +            // ran, and the render fails rather than recreating a directory
    +            // the caller owns. Everything beneath the bucket is the
    +            // report tree the render creates as needed.
    +            cursor = if index == 0 {
    +                open_report_component(&cursor, component)?
    +            } else {
    +                open_or_create_report_component(&cursor, component)?
    +            };
    +        }
    +        Ok(Self { handle: cursor })
    +    }
    +
    +    pub(crate) fn remove_leftover(&self, name: &str) -> Result<(), SinkError> {
    +        match rustix::fs::unlinkat(&self.handle, name, rustix::fs::AtFlags::empty()) {
    +            Ok(()) => Ok(()),
    +            Err(e) if e == rustix::io::Errno::NOENT => Ok(()),
    +            Err(e) => Err(SinkError::Io(e.into())),
    +        }
    +    }
    +
    +    /// Unlink every entry whose name starts with `prefix`, through the
    +    /// held handle.
    +    pub(crate) fn remove_stale_temps(&self, prefix: &str) -> Result<(), SinkError> {
    +        self.remove_matching(&|name| name.starts_with(prefix))
    +    }
    +
    +    /// Unlink every entry whose name `matches`, through the held handle.
    +    /// Names are collected before any unlink so the sweep never mutates
    +    /// the directory stream it is reading; an entry another sweep removed
    +    /// first is fine.
    +    pub(crate) fn remove_matching(&self, matches: &dyn Fn(&str) -> bool) -> Result<(), SinkError> {
    +        let dir = rustix::fs::Dir::read_from(&self.handle).map_err(|e| SinkError::Io(e.into()))?;
    +        let mut stale = Vec::new();
    +        for entry in dir {
    +            let entry = entry.map_err(|e| SinkError::Io(e.into()))?;
    +            let name = entry.file_name().to_string_lossy().into_owned();
    +            if matches(&name) {
    +                stale.push(name);
    +            }
    +        }
    +        for name in stale {
    +            self.remove_leftover(&name)?;
    +        }
    +        Ok(())
    +    }
    +
    +    /// Create `name` in the report directory, failing when anything is
    +    /// already there. Report objects and the manifest hold item values, so
    +    /// the file is readable and writable by the server's own user only.
    +    pub(crate) fn create_new(&self, name: &str) -> Result {
    +        use rustix::fs::{Mode, OFlags};
    +        let fd = rustix::fs::openat(
    +            &self.handle,
    +            name,
    +            OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
    +            Mode::from_raw_mode(0o600),
    +        )
    +        .map_err(|e| SinkError::Io(e.into()))?;
    +        Ok(std::fs::File::from(fd))
    +    }
    +
    +    pub(crate) fn rename(&self, from: &str, to: &str) -> Result<(), SinkError> {
    +        rustix::fs::renameat(&self.handle, from, &self.handle, to)
    +            .map_err(|e| SinkError::Io(e.into()))
    +    }
    +}
    +
    +/// The bulk report layout is POSIX-only; on other platforms the writer
    +/// falls back to path-based operations without the handle-relative
    +/// hardening, and the request handler refuses the API.
    +#[cfg(not(unix))]
    +pub(crate) struct ReportDirectory {
    +    path: PathBuf,
    +}
    +
    +#[cfg(not(unix))]
    +impl ReportDirectory {
    +    pub(crate) fn establish(
    +        base: &Path,
    +        _roots: &[Arc],
    +        _account: &str,
    +        components: &[String],
    +    ) -> Result {
    +        let mut path = base.to_path_buf();
    +        let Some((bucket, rest)) = components.split_first() else {
    +            return Err(SinkError::Jail("report path has no components".to_owned()));
    +        };
    +        // The bucket directory is the caller's; the render opens it and
    +        // creates only the report tree beneath it, mirroring the unix walk.
    +        path.push(bucket);
    +        match std::fs::symlink_metadata(&path) {
    +            Ok(meta) if meta.is_dir() && !meta.file_type().is_symlink() => {}
    +            Ok(_) => {
    +                return Err(SinkError::Symlink(
    +                    "report bucket is a symlink or not a directory".to_owned(),
    +                ));
    +            }
    +            Err(e) => return Err(SinkError::Io(e)),
    +        }
    +        for component in rest {
    +            path.push(component);
    +        }
    +        std::fs::create_dir_all(&path).map_err(SinkError::Io)?;
    +        Ok(Self { path })
    +    }
    +
    +    pub(crate) fn remove_leftover(&self, name: &str) -> Result<(), SinkError> {
    +        match std::fs::remove_file(self.path.join(name)) {
    +            Ok(()) => Ok(()),
    +            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
    +            Err(e) => Err(SinkError::Io(e)),
    +        }
    +    }
    +
    +    /// Remove every entry whose name starts with `prefix`.
    +    pub(crate) fn remove_stale_temps(&self, prefix: &str) -> Result<(), SinkError> {
    +        self.remove_matching(&|name| name.starts_with(prefix))
    +    }
    +
    +    /// Remove every entry whose name `matches`. Names are collected before
    +    /// any removal; an entry another sweep removed first is fine.
    +    pub(crate) fn remove_matching(&self, matches: &dyn Fn(&str) -> bool) -> Result<(), SinkError> {
    +        let mut stale = Vec::new();
    +        for entry in std::fs::read_dir(&self.path).map_err(SinkError::Io)? {
    +            let entry = entry.map_err(SinkError::Io)?;
    +            let name = entry.file_name().to_string_lossy().into_owned();
    +            if matches(&name) {
    +                stale.push(name);
    +            }
    +        }
    +        for name in stale {
    +            self.remove_leftover(&name)?;
    +        }
    +        Ok(())
    +    }
    +
    +    pub(crate) fn create_new(&self, name: &str) -> Result {
    +        std::fs::OpenOptions::new()
    +            .write(true)
    +            .create_new(true)
    +            .open(self.path.join(name))
    +            .map_err(SinkError::Io)
    +    }
    +
    +    pub(crate) fn rename(&self, from: &str, to: &str) -> Result<(), SinkError> {
    +        std::fs::rename(self.path.join(from), self.path.join(to)).map_err(SinkError::Io)
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/source/tests/codec.rs b/crates/engine/src/bulk_put/source/tests/codec.rs
    new file mode 100644
    index 000000000..e60e58ed3
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/tests/codec.rs
    @@ -0,0 +1,344 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Bulk put source/sink codec tests.
    +
    +use super::*;
    +
    +#[tokio::test]
    +async fn zstd_round_trip_counts_compressed_bytes_and_reports_the_compressed_etag() {
    +    let root = Root::new();
    +    let mut plain = Vec::new();
    +    for i in 0..400 {
    +        plain.extend_from_slice(format!("{{\"Item\":{{\"pk\":{{\"S\":\"k{i}\"}}}}}}\n").as_bytes());
    +    }
    +    let compressed = zstd_bytes(&plain);
    +    let size = u64::try_from(compressed.len()).unwrap();
    +    root.file(OWNER, "loads", "part-000.json.zst", &compressed);
    +    let src = source(&root);
    +    let mut reader = src
    +        .open(
    +            &at("loads"),
    +            &input_file("part-000.json.zst", size),
    +            InputCodec::Zstd,
    +        )
    +        .await
    +        .unwrap();
    +    let lines = read_all(&mut reader, 4096).await;
    +    assert_eq!(lines.len(), 400);
    +    assert_eq!(lines[399], "{\"Item\":{\"pk\":{\"S\":\"k399\"}}}");
    +    assert_eq!(
    +        reader.bytes_consumed(),
    +        size,
    +        "consumed bytes are compressed bytes"
    +    );
    +    let hashed = src
    +        .etag(&at("loads"), &input_file("part-000.json.zst", size))
    +        .await
    +        .unwrap();
    +    assert_eq!(reader.etag(), Some(hashed));
    +    assert_eq!(
    +        src.line_count(
    +            &at("loads"),
    +            &input_file("part-000.json.zst", size),
    +            InputCodec::Zstd
    +        )
    +        .await
    +        .unwrap(),
    +        400
    +    );
    +}
    +
    +#[tokio::test]
    +async fn two_concatenated_zstd_frames_are_read_whole() {
    +    let root = Root::new();
    +    let mut bytes = zstd_bytes(b"{\"a\":1}\n{\"a\":2}\n");
    +    bytes.extend(zstd_bytes(b"{\"a\":3}\n"));
    +    let size = u64::try_from(bytes.len()).unwrap();
    +    root.file(OWNER, "loads", "part-000.json.zst", &bytes);
    +    let mut reader = source(&root)
    +        .open(
    +            &at("loads"),
    +            &input_file("part-000.json.zst", size),
    +            InputCodec::Zstd,
    +        )
    +        .await
    +        .unwrap();
    +    let lines = read_all(&mut reader, 4096).await;
    +    assert_eq!(lines, vec!["{\"a\":1}", "{\"a\":2}", "{\"a\":3}"]);
    +}
    +
    +/// A complete frame followed by a truncated one reads as the first
    +/// frame's lines and a clean end of file, with every byte of the object
    +/// consumed so the byte count and the tag cover it (measured on the
    +/// service: the operation ends COMPLETED with the first frame's rows, no
    +/// failed row, and ProcessedInputBytes equal to the size).
    +#[tokio::test]
    +async fn a_truncated_frame_after_a_complete_frame_ends_the_file_cleanly() {
    +    let root = Root::new();
    +    let mut bytes = zstd_bytes(b"{\"a\":1}\n{\"a\":2}\n");
    +    let second = zstd_bytes(b"{\"a\":3}\n{\"a\":4}\n");
    +    bytes.extend_from_slice(&second[..second.len() / 2]);
    +    let size = u64::try_from(bytes.len()).unwrap();
    +    root.file(OWNER, "loads", "part-000.json.zst", &bytes);
    +    let src = source(&root);
    +    let mut reader = src
    +        .open(
    +            &at("loads"),
    +            &input_file("part-000.json.zst", size),
    +            InputCodec::Zstd,
    +        )
    +        .await
    +        .unwrap();
    +    let lines = read_all(&mut reader, 4096).await;
    +    assert_eq!(lines, vec!["{\"a\":1}", "{\"a\":2}"]);
    +    assert_eq!(reader.bytes_consumed(), size);
    +    let hashed = src
    +        .etag(&at("loads"), &input_file("part-000.json.zst", size))
    +        .await
    +        .unwrap();
    +    assert_eq!(reader.etag(), Some(hashed));
    +}
    +
    +/// A zstd frame cut short is a failed decompression, its own class
    +/// (the hosted service reports the file as "could not be decompressed"
    +/// and counts its bytes, measured on the service), not the gzip path's
    +/// corrupt-stream class. Whatever the decoder yielded before the cut is
    +/// served first; the fault follows.
    +#[tokio::test]
    +async fn a_truncated_zstd_frame_is_a_decode_failure() {
    +    let root = Root::new();
    +    let mut plain = Vec::new();
    +    // 20,000 rows compress to about ten 128 KiB blocks, so a cut at 90
    +    // percent of the bytes lands after several complete blocks.
    +    for i in 0..20000 {
    +        plain.extend_from_slice(format!("{{\"i\":{i},\"pad\":\"{:0>40}\"}}\n", i).as_bytes());
    +    }
    +    let whole = zstd_bytes(&plain);
    +    let truncated = &whole[..whole.len() * 9 / 10];
    +    let size = u64::try_from(truncated.len()).unwrap();
    +    root.file(OWNER, "loads", "part-000.json.zst", truncated);
    +    let mut reader = source(&root)
    +        .open(
    +            &at("loads"),
    +            &input_file("part-000.json.zst", size),
    +            InputCodec::Zstd,
    +        )
    +        .await
    +        .unwrap();
    +    let mut served = 0usize;
    +    let err = loop {
    +        match reader.next_line(4096).await {
    +            Ok(Some(_)) => served += 1,
    +            Ok(None) => panic!("a truncated zstd frame must not read as clean end of file"),
    +            Err(e) => break e,
    +        }
    +    };
    +    assert!(matches!(err, SourceError::DecodeFailed), "got {err:?}");
    +    assert!(
    +        served > 0 && served < 20000,
    +        "the complete blocks before the cut are served ({served} lines), the rest are not"
    +    );
    +    assert_eq!(reader.etag(), None);
    +}
    +
    +/// Under `ZSTD`, a file that does not begin with the zstd magic is not a
    +/// compressed object: gzip bytes and plain text both answer
    +/// `NotCompressed` at open, the class the gzip path answers for the
    +/// mirror case (measured on the service: both end `FAILED` with the
    +/// validation pair).
    +#[tokio::test]
    +async fn bytes_without_the_zstd_magic_under_zstd_are_not_compressed() {
    +    let root = Root::new();
    +    let plain = b"{\"a\":1}\n".to_vec();
    +    root.file(OWNER, "loads", "plain.json", &plain);
    +    root.file(OWNER, "loads", "part.json.gz", &gzip_bytes(&plain));
    +    root.file(OWNER, "loads", "part.json.zst", &zstd_bytes(&plain));
    +    let src = source(&root);
    +    for (path, codec) in [
    +        ("plain.json", InputCodec::Zstd),
    +        ("part.json.gz", InputCodec::Zstd),
    +        ("part.json.zst", InputCodec::Gzip),
    +    ] {
    +        let size = std::fs::metadata(root.0.join(OWNER).join("loads").join(path))
    +            .unwrap()
    +            .len();
    +        let err = src
    +            .open(&at("loads"), &input_file(path, size), codec)
    +            .await
    +            .err()
    +            .unwrap_or_else(|| panic!("{path} under {codec:?} must be refused"));
    +        assert!(
    +            matches!(err, SourceError::NotCompressed),
    +            "{path} under {codec:?}: got {err:?}"
    +        );
    +    }
    +}
    +
    +#[tokio::test]
    +async fn gzip_round_trip() {
    +    let root = Root::new();
    +    let plain = b"{\"Item\":{\"pk\":{\"S\":\"a\"}}}\n{\"Item\":{\"pk\":{\"S\":\"b\"}}}\n";
    +    let compressed = gzip_bytes(plain);
    +    let compressed_len = u64::try_from(compressed.len()).unwrap();
    +    root.file(OWNER, "loads", "part-000.json.gz", &compressed);
    +
    +    let mut reader = source(&root)
    +        .open(
    +            &at("loads"),
    +            &input_file("part-000.json.gz", compressed_len),
    +            InputCodec::Gzip,
    +        )
    +        .await
    +        .unwrap();
    +    let lines = read_all(&mut reader, 4096).await;
    +    assert_eq!(
    +        lines,
    +        vec![
    +            "{\"Item\":{\"pk\":{\"S\":\"a\"}}}",
    +            "{\"Item\":{\"pk\":{\"S\":\"b\"}}}"
    +        ]
    +    );
    +    assert_eq!(
    +        reader.bytes_consumed(),
    +        compressed_len,
    +        "consumed bytes are compressed bytes"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn two_concatenated_gzip_members_are_read_whole() {
    +    let root = Root::new();
    +    let mut compressed = gzip_bytes(b"{\"Item\":{\"pk\":{\"S\":\"a\"}}}\n");
    +    compressed.extend_from_slice(&gzip_bytes(b"{\"Item\":{\"pk\":{\"S\":\"b\"}}}\n"));
    +    let size = u64::try_from(compressed.len()).unwrap();
    +    root.file(OWNER, "loads", "part-000.json.gz", &compressed);
    +
    +    let mut reader = source(&root)
    +        .open(
    +            &at("loads"),
    +            &input_file("part-000.json.gz", size),
    +            InputCodec::Gzip,
    +        )
    +        .await
    +        .unwrap();
    +    let lines = read_all(&mut reader, 4096).await;
    +    assert_eq!(
    +        lines,
    +        vec![
    +            "{\"Item\":{\"pk\":{\"S\":\"a\"}}}",
    +            "{\"Item\":{\"pk\":{\"S\":\"b\"}}}"
    +        ],
    +        "rows in the second member are read"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_truncated_gzip_stream_is_a_corrupt_stream_fault() {
    +    let root = Root::new();
    +    let mut plain = Vec::new();
    +    for i in 0..50 {
    +        plain.extend_from_slice(format!("{{\"i\":{i}}}\n").as_bytes());
    +    }
    +    let whole = gzip_bytes(&plain);
    +    // Cut inside the deflate data, before the gzip trailer.
    +    let truncated = &whole[..whole.len() - 12];
    +    let size = u64::try_from(truncated.len()).unwrap();
    +    root.file(OWNER, "loads", "part-000.json.gz", truncated);
    +
    +    let mut reader = source(&root)
    +        .open(
    +            &at("loads"),
    +            &input_file("part-000.json.gz", size),
    +            InputCodec::Gzip,
    +        )
    +        .await
    +        .unwrap();
    +    let err = loop {
    +        match reader.next_line(4096).await {
    +            Ok(Some(_)) => {}
    +            Ok(None) => panic!("a truncated gzip stream must not read as clean end of file"),
    +            Err(e) => break e,
    +        }
    +    };
    +    assert!(matches!(err, SourceError::Corrupt), "got {err:?}");
    +    assert_eq!(
    +        reader.etag(),
    +        None,
    +        "a reader that faulted did not consume the file whole and reports no tag"
    +    );
    +}
    +
    +/// The reader's entity tag is the MD5 of the raw bytes it consumed: absent
    +/// until end of file, then equal to what `etag` computes by reading the
    +/// file again, for a plain file and for a gzip file (the compressed
    +/// bytes, matching the object's ETag).
    +#[tokio::test]
    +async fn the_reader_reports_the_etag_only_once_the_file_is_consumed_whole() {
    +    let root = Root::new();
    +    let mut plain = Vec::new();
    +    for i in 0..300 {
    +        plain.extend_from_slice(format!("{{\"i\":{i}}}\n").as_bytes());
    +    }
    +    let compressed = gzip_bytes(&plain);
    +    root.file(OWNER, "loads", "plain.json", &plain);
    +    root.file(OWNER, "loads", "part.json.gz", &compressed);
    +    let src = source(&root);
    +    for (path, compression, bytes) in [
    +        ("plain.json", InputCodec::None, plain.as_slice()),
    +        ("part.json.gz", InputCodec::Gzip, compressed.as_slice()),
    +    ] {
    +        let size = u64::try_from(bytes.len()).unwrap();
    +        let mut reader = src
    +            .open(&at("loads"), &input_file(path, size), compression)
    +            .await
    +            .unwrap();
    +        let first = reader.next_line(4096).await.unwrap();
    +        assert!(first.is_some());
    +        assert_eq!(
    +            reader.etag(),
    +            None,
    +            "{path}: no tag while lines remain to be read"
    +        );
    +        let mut lines = 1;
    +        while reader.next_line(4096).await.unwrap().is_some() {
    +            lines += 1;
    +        }
    +        assert_eq!(lines, 300);
    +        let hashed = src
    +            .etag(&at("loads"), &input_file(path, size))
    +            .await
    +            .unwrap();
    +        assert_eq!(
    +            reader.etag(),
    +            Some(hashed),
    +            "{path}: the streamed tag equals the hashed tag"
    +        );
    +    }
    +}
    +
    +/// The zstd decoder loads a frame declaring a window up
    +/// to 128 MiB and refuses one declaring more. The bound is the crate
    +/// default and matches the service's acceptance edge: frames declaring a
    +/// 64 MiB and a 128 MiB window load and complete on the service, a 256 MiB
    +/// window does not. A frame header carrying the window descriptor alone
    +/// decides it: 0x88 is a 128 MiB window, 0x90 a 256 MiB window.
    +#[test]
    +fn the_zstd_decoder_loads_a_128_mib_window_and_refuses_a_256_mib_window() {
    +    use super::super::codec::ZstdFrames;
    +    // zstd magic, a frame header descriptor with no content-size and no
    +    // single-segment flag so a window descriptor follows, then the window
    +    // descriptor byte.
    +    let frame = |descriptor: u8| vec![0x28u8, 0xb5, 0x2f, 0xfd, 0x00, descriptor];
    +    assert!(
    +        ZstdFrames::new(std::io::Cursor::new(frame(0x88))).is_ok(),
    +        "a 128 MiB window loads"
    +    );
    +    let refused = ZstdFrames::new(std::io::Cursor::new(frame(0x90)));
    +    assert!(refused.is_err(), "a 256 MiB window is refused");
    +    assert_eq!(
    +        refused.err().unwrap().kind(),
    +        std::io::ErrorKind::InvalidData,
    +        "an over-large window reads as corrupt data"
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/source/tests/dropped_fill.rs b/crates/engine/src/bulk_put/source/tests/dropped_fill.rs
    new file mode 100644
    index 000000000..7b227b77c
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/tests/dropped_fill.rs
    @@ -0,0 +1,68 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! A `next_line` dropped while its blocking fill runs: the next call on
    +//! the same reader serves the lines that fill read, never an early end of
    +//! file.
    +
    +use std::io::Read;
    +use std::sync::mpsc;
    +use std::time::Duration;
    +
    +use super::*;
    +
    +/// Raw bytes that are held back until the gate opens, as an object store
    +/// read that waits on its answer.
    +struct Gated {
    +    gate: mpsc::Receiver<()>,
    +    started: mpsc::Sender<()>,
    +    open: bool,
    +    data: std::io::Cursor>,
    +}
    +
    +impl Read for Gated {
    +    fn read(&mut self, buf: &mut [u8]) -> std::io::Result {
    +        if !self.open {
    +            let _ = self.started.send(());
    +            let _ = self.gate.recv();
    +            self.open = true;
    +        }
    +        self.data.read(buf)
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_line_read_dropped_during_its_fill_hands_the_lines_to_the_next_call() {
    +    let data = b"line-1\nline-2\nline-3\n".to_vec();
    +    let size = u64::try_from(data.len()).unwrap();
    +    let (open_gate, gate) = mpsc::channel();
    +    let (started, fill_started) = mpsc::channel();
    +    let raw = Gated {
    +        gate,
    +        started,
    +        open: false,
    +        data: std::io::Cursor::new(data),
    +    };
    +    let mut reader = line_reader(Box::new(raw), size, InputCodec::None).unwrap();
    +
    +    let first = tokio::time::timeout(Duration::from_millis(100), reader.next_line(1024)).await;
    +    assert!(first.is_err(), "the call answered while its fill was held");
    +    fill_started
    +        .recv_timeout(Duration::from_secs(5))
    +        .expect("the fill reached the held read");
    +    open_gate.send(()).unwrap();
    +
    +    let mut lines = Vec::new();
    +    loop {
    +        let next = tokio::time::timeout(Duration::from_secs(5), reader.next_line(1024))
    +            .await
    +            .expect("the next call answers")
    +            .expect("no fault");
    +        match next {
    +            Some(line) => lines.push(line),
    +            None => break,
    +        }
    +    }
    +    assert_eq!(lines, ["line-1", "line-2", "line-3"]);
    +    assert!(reader.etag().is_some(), "the reader reached its end");
    +}
    diff --git a/crates/engine/src/bulk_put/source/tests/jail.rs b/crates/engine/src/bulk_put/source/tests/jail.rs
    new file mode 100644
    index 000000000..0e5a719db
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/tests/jail.rs
    @@ -0,0 +1,584 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Bulk put source/sink jail tests.
    +
    +#[cfg(unix)]
    +use super::super::jail::{open_verified_account_dir, walk_open_input};
    +#[cfg(unix)]
    +use super::super::walk::{MAX_WALK_DEPTH, WalkBounds, walk_regular_files};
    +use super::*;
    +
    +#[cfg(unix)]
    +#[tokio::test]
    +async fn list_skips_symlinked_files_and_does_not_descend_into_symlinked_dirs() {
    +    let root = Root::new();
    +    root.file(OWNER, "loads", "a.json", b"{}\n");
    +    let victim_file = root.file(OTHER, "loads", "secret.json", b"{}\n");
    +    let victim_dir = root.bucket_dir(OTHER, "loads");
    +    let own_bucket = root.bucket_dir(OWNER, "loads");
    +    std::os::unix::fs::symlink(&victim_file, own_bucket.join("b.json")).unwrap();
    +    std::os::unix::fs::symlink(&victim_dir, own_bucket.join("sub")).unwrap();
    +
    +    let files = source(&root)
    +        .list(&at("loads"), "", u64::MAX)
    +        .await
    +        .unwrap();
    +    let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
    +    assert_eq!(paths, vec!["a.json"], "only the regular file is enumerated");
    +}
    +
    +#[tokio::test]
    +async fn list_with_a_missing_bucket_directory_is_empty() {
    +    let root = Root::new();
    +    root.bucket_dir(OWNER, "other-bucket");
    +    let files = source(&root)
    +        .list(&at("loads"), "", u64::MAX)
    +        .await
    +        .unwrap();
    +    assert!(files.is_empty());
    +}
    +
    +/// The Start existence check never follows a symlink planted at the
    +/// bucket name: a link to another account's populated bucket, and a
    +/// link to a directory inside the caller's own subtree, both answer
    +/// `false`, the verdict a missing bucket gives. A check that resolved
    +/// the link would answer `true` for the foreign target and let the
    +/// operation proceed against it.
    +#[cfg(unix)]
    +#[tokio::test]
    +async fn bucket_exists_refuses_to_follow_a_planted_symlink() {
    +    let root = Root::new();
    +    // The foreign target genuinely exists and holds files, so a
    +    // `false` can only come from the symlink refusal, not absence.
    +    root.file(OTHER, "loads", "part-000.json", b"{}\n");
    +    let foreign = root.0.join(OTHER).join("loads");
    +    std::fs::create_dir_all(root.0.join(OWNER)).unwrap();
    +    std::os::unix::fs::symlink(&foreign, root.0.join(OWNER).join("loads")).unwrap();
    +    let exists = source(&root).bucket_exists(OWNER, "loads").await.unwrap();
    +    assert!(!exists, "a symlinked bucket name must answer false");
    +
    +    // A link inside the caller's own subtree is refused the same way:
    +    // the check is on the directory entry itself, not its target.
    +    root.file(OWNER, "real-bucket", "part-000.json", b"{}\n");
    +    let own_target = root.0.join(OWNER).join("real-bucket");
    +    std::os::unix::fs::symlink(&own_target, root.0.join(OWNER).join("linked")).unwrap();
    +    let exists = source(&root).bucket_exists(OWNER, "linked").await.unwrap();
    +    assert!(!exists, "a symlink to an own directory must answer false");
    +
    +    // The real directory itself answers true.
    +    let exists = source(&root)
    +        .bucket_exists(OWNER, "real-bucket")
    +        .await
    +        .unwrap();
    +    assert!(exists);
    +}
    +
    +#[tokio::test]
    +async fn list_rejects_a_bucket_that_escapes_the_account_subtree() {
    +    let root = Root::new();
    +    // The victim's data genuinely exists, so a rejection can only come
    +    // from the jail and not from the path being absent.
    +    root.file(OTHER, "loads", "secret.json", b"{}\n");
    +    for bucket in [
    +        "..",
    +        &format!("../{OTHER}"),
    +        "a/b",
    +        &format!("../{OTHER}/loads"),
    +    ] {
    +        let err = source(&root)
    +            .list(&at(bucket), "", u64::MAX)
    +            .await
    +            .unwrap_err();
    +        assert!(
    +            matches!(err, SourceError::Jail(_)),
    +            "bucket {bucket:?} must be a jail violation, got {err:?}"
    +        );
    +    }
    +}
    +
    +#[tokio::test]
    +async fn list_is_lexical_and_filters_by_prefix() {
    +    let root = Root::new();
    +    root.file(OWNER, "loads", "c.json", b"3");
    +    root.file(OWNER, "loads", "a.json", b"1");
    +    root.file(OWNER, "loads", "a/b.json", b"22");
    +
    +    let all = source(&root)
    +        .list(&at("loads"), "", u64::MAX)
    +        .await
    +        .unwrap();
    +    let paths: Vec<&str> = all.iter().map(|f| f.path.as_str()).collect();
    +    assert_eq!(paths, vec!["a.json", "a/b.json", "c.json"]);
    +    assert_eq!(all[1].size, 2, "size comes from the entry metadata");
    +
    +    let some = source(&root)
    +        .list(&at("loads"), "a", u64::MAX)
    +        .await
    +        .unwrap();
    +    let paths: Vec<&str> = some.iter().map(|f| f.path.as_str()).collect();
    +    assert_eq!(paths, vec!["a.json", "a/b.json"]);
    +}
    +
    +#[tokio::test]
    +async fn a_bucket_with_one_matching_file_over_the_limit_returns_the_match_past_it() {
    +    let root = Root::new();
    +    for i in 0..4 {
    +        root.file(OWNER, "loads", &format!("part-{i}.json"), b"{}\n");
    +    }
    +    let files = source(&root)
    +        .list(&at("loads"), "", 3)
    +        .await
    +        .expect("four files over a limit of three are returned as four");
    +    assert_eq!(
    +        files.len(),
    +        4,
    +        "the match past the bound is kept so the caller can tell the bound was passed"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_bucket_with_exactly_the_limit_is_listed_whole() {
    +    let root = Root::new();
    +    for i in 0..3 {
    +        root.file(OWNER, "loads", &format!("part-{i}.json"), b"{}\n");
    +    }
    +    let files = source(&root).list(&at("loads"), "", 3).await.unwrap();
    +    assert_eq!(files.len(), 3);
    +}
    +
    +/// Observed at the walk itself: collection stops the moment one more
    +/// match would exceed the limit, so the error arrives with at most
    +/// `max_files` entries collected, not after the whole bucket is read
    +/// into the list.
    +#[cfg(unix)]
    +#[test]
    +fn the_walk_collects_no_more_than_the_limit_before_failing() {
    +    let root = Root::new();
    +    for i in 0..10 {
    +        root.file(OWNER, "loads", &format!("part-{i}.json"), b"{}\n");
    +    }
    +    let bucket_dir = open_dir_handle(&root.0.join(OWNER).join("loads"));
    +    let mut out = Vec::new();
    +    let mut bounds = WalkBounds {
    +        max_files: 2,
    +        visit_ceiling: 40,
    +        visited: 0,
    +    };
    +    let err = walk_regular_files(&bucket_dir, "", "", 0, &mut bounds, &mut out)
    +        .expect_err("ten files over a limit of two must stop the walk");
    +    assert!(matches!(err, SourceError::TooManyFiles), "got {err:?}");
    +    assert_eq!(out.len(), 3, "collection stops one match past the limit");
    +}
    +
    +/// Nesting deeper than `MAX_WALK_DEPTH` is refused before the walk
    +/// descends into it, so a chain of single-child directories cannot
    +/// grow the recursion beyond the cap however large the visit ceiling.
    +#[cfg(unix)]
    +#[test]
    +fn the_walk_refuses_directory_nesting_beyond_the_depth_cap() {
    +    let root = Root::new();
    +    let bucket_dir = root.0.join(OWNER).join("loads");
    +    let mut deep = bucket_dir.clone();
    +    for _ in 0..=MAX_WALK_DEPTH {
    +        deep.push("d");
    +    }
    +    std::fs::create_dir_all(&deep).expect("deep chain");
    +    std::fs::write(deep.join("part-000.json"), b"{}\n").expect("leaf file");
    +    let mut out = Vec::new();
    +    let mut bounds = WalkBounds {
    +        max_files: 10,
    +        visit_ceiling: u64::MAX,
    +        visited: 0,
    +    };
    +    let handle = open_dir_handle(&bucket_dir);
    +    let err = walk_regular_files(&handle, "", "", 0, &mut bounds, &mut out)
    +        .expect_err("a chain deeper than the cap must be refused");
    +    assert!(
    +        matches!(&err, SourceError::Jail(msg) if msg.contains("nesting exceeds")),
    +        "got {err:?}"
    +    );
    +    assert!(out.is_empty(), "nothing below the cap is enumerated");
    +
    +    // One level shallower is walked to its leaf.
    +    let root = Root::new();
    +    let bucket_dir = root.0.join(OWNER).join("loads");
    +    let mut ok = bucket_dir.clone();
    +    for _ in 0..MAX_WALK_DEPTH {
    +        ok.push("d");
    +    }
    +    std::fs::create_dir_all(&ok).expect("chain at the cap");
    +    std::fs::write(ok.join("part-000.json"), b"{}\n").expect("leaf file");
    +    let mut out = Vec::new();
    +    let mut bounds = WalkBounds {
    +        max_files: 10,
    +        visit_ceiling: u64::MAX,
    +        visited: 0,
    +    };
    +    let handle = open_dir_handle(&bucket_dir);
    +    walk_regular_files(&handle, "", "", 0, &mut bounds, &mut out)
    +        .expect("a chain at the cap is walked");
    +    assert_eq!(out.len(), 1);
    +}
    +
    +/// Driven through the handle walk itself: a directory entry that is a
    +/// symlink to a directory outside the account is neither descended
    +/// into nor counted, so the foreign names and sizes never enter the
    +/// enumeration however the walk is reached.
    +#[cfg(unix)]
    +#[test]
    +fn the_handle_walk_neither_descends_nor_counts_a_symlink_to_a_foreign_directory() {
    +    let root = Root::new();
    +    root.file(OWNER, "loads", "part-000.json", b"{}\n");
    +    // The foreign directory genuinely holds files, so an empty result
    +    // for it can only come from the walk refusing the link.
    +    root.file(OTHER, "loads", "stolen-0.json", b"{}\n");
    +    root.file(OTHER, "loads", "stolen-1.json", b"{}\n");
    +    let own_bucket = root.0.join(OWNER).join("loads");
    +    std::os::unix::fs::symlink(root.0.join(OTHER).join("loads"), own_bucket.join("sub")).unwrap();
    +
    +    let handle = open_dir_handle(&own_bucket);
    +    let mut out = Vec::new();
    +    let mut bounds = WalkBounds {
    +        max_files: 100,
    +        visit_ceiling: 400,
    +        visited: 0,
    +    };
    +    walk_regular_files(&handle, "", "", 0, &mut bounds, &mut out).unwrap();
    +    let paths: Vec<&str> = out.iter().map(|f| f.path.as_str()).collect();
    +    assert_eq!(paths, vec!["part-000.json"], "only the regular file");
    +    assert_eq!(
    +        bounds.visited, 2,
    +        "the link is one visited entry; nothing beneath it is visited"
    +    );
    +}
    +
    +/// The visited-entry ceiling bounds the walk over entries that can hold a
    +/// key under the prefix even when none of them is a match: more such
    +/// entries than four times the file limit ends it with the error rather
    +/// than reading the subtree to the end. Each empty directory below has a
    +/// name under the prefix, so each is visited and none yields a file.
    +#[tokio::test]
    +async fn the_visited_entry_ceiling_bounds_a_walk_over_entries_under_the_prefix_with_no_match() {
    +    let root = Root::new();
    +    for i in 0..9 {
    +        std::fs::create_dir_all(root.bucket_dir(OWNER, "loads").join(format!("match-{i}")))
    +            .unwrap();
    +    }
    +    let err = source(&root)
    +        .list(&at("loads"), "match-", 2)
    +        .await
    +        .expect_err("nine entries under the prefix over a ceiling of eight must be refused");
    +    assert!(matches!(err, SourceError::TooManyFiles), "got {err:?}");
    +}
    +
    +/// Only entries that can hold a key under the prefix count toward the
    +/// visited-entry ceiling: files and directories beside the prefix, at the
    +/// bucket and at each level above the matches, are passed over uncounted,
    +/// so a bucket holding many times the ceiling in other entries still lists
    +/// the matches. The ceiling here is eight.
    +#[tokio::test]
    +async fn entries_outside_the_prefix_do_not_count_toward_the_visit_ceiling() {
    +    let root = Root::new();
    +    for i in 0..20 {
    +        root.file(OWNER, "loads", &format!("other-{i}.json"), b"{}\n");
    +        root.file(OWNER, "loads", &format!("dir-{i}/part.json"), b"{}\n");
    +        root.file(OWNER, "loads", &format!("in/other-{i}.json"), b"{}\n");
    +        root.file(OWNER, "loads", &format!("in/2026-{i}/part.json"), b"{}\n");
    +    }
    +    for i in 0..2 {
    +        root.file(OWNER, "loads", &format!("in/match-{i}.json"), b"{}\n");
    +    }
    +    let files = source(&root)
    +        .list(&at("loads"), "in/match-", 2)
    +        .await
    +        .expect("eighty entries beside the prefix are not counted");
    +    let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
    +    assert_eq!(paths, vec!["in/match-0.json", "in/match-1.json"]);
    +}
    +
    +/// The count quota still binds with entries beside the prefix: more
    +/// matches than the limit come back as the limit plus one, which the
    +/// runner ends `FAILED` with `S3ObjectsCountLimitExceeded`, rather than as
    +/// the ceiling error a Start refuses.
    +#[tokio::test]
    +async fn matches_over_the_limit_beside_many_other_entries_return_the_match_past_it() {
    +    let root = Root::new();
    +    for i in 0..50 {
    +        root.file(OWNER, "loads", &format!("other-{i}.json"), b"{}\n");
    +    }
    +    for i in 0..4 {
    +        root.file(OWNER, "loads", &format!("match-{i}.json"), b"{}\n");
    +    }
    +    let files = source(&root)
    +        .list(&at("loads"), "match-", 3)
    +        .await
    +        .expect("four matches over a limit of three are returned as four");
    +    assert_eq!(files.len(), 4);
    +}
    +
    +/// One directory holding more matches than four times the limit: the walk
    +/// counts each entry when it examines it, after the matches before it, so
    +/// it ends at the limit plus one, as a listing does, and Start accepts the
    +/// operation for the runner to end `FAILED` with
    +/// `S3ObjectsCountLimitExceeded`, instead of refusing it at the ceiling.
    +#[tokio::test]
    +async fn a_directory_of_matches_over_the_ceiling_returns_the_match_past_the_limit() {
    +    let root = Root::new();
    +    for i in 0..63 {
    +        root.file(OWNER, "loads", &format!("in/match-{i:02}.json"), b"{}\n");
    +    }
    +    let files = source(&root)
    +        .list(&at("loads"), "in/", 10)
    +        .await
    +        .expect("63 matches over a limit of ten are returned as eleven");
    +    assert_eq!(files.len(), 11);
    +}
    +
    +/// Pruning is what bounds the walk over a large non-matching subtree:
    +/// the directory is never opened. Proven by making it unreadable, so a
    +/// walk that entered it would answer a permission error instead of the
    +/// matches.
    +#[cfg(unix)]
    +#[tokio::test]
    +async fn a_directory_outside_the_prefix_is_never_opened() {
    +    use std::os::unix::fs::PermissionsExt;
    +    struct Restore(PathBuf);
    +    impl Drop for Restore {
    +        fn drop(&mut self) {
    +            let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755));
    +        }
    +    }
    +    let root = Root::new();
    +    for i in 0..10_000 {
    +        root.file(OWNER, "loads", &format!("skip/other-{i}.json"), b"{}\n");
    +    }
    +    for i in 0..3 {
    +        root.file(OWNER, "loads", &format!("match/part-{i}.json"), b"{}\n");
    +    }
    +    let skip_dir = root.0.join(OWNER).join("loads").join("skip");
    +    std::fs::set_permissions(&skip_dir, std::fs::Permissions::from_mode(0o000)).unwrap();
    +    let _restore = Restore(skip_dir);
    +
    +    let files = source(&root)
    +        .list(&at("loads"), "match/", 100)
    +        .await
    +        .unwrap();
    +    let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
    +    assert_eq!(
    +        paths,
    +        vec![
    +            "match/part-0.json",
    +            "match/part-1.json",
    +            "match/part-2.json"
    +        ]
    +    );
    +}
    +
    +/// A directory whose own name is a proper prefix of the requested
    +/// prefix is descended: the matches live below it.
    +#[tokio::test]
    +async fn a_directory_that_is_a_proper_prefix_of_the_prefix_is_descended() {
    +    let root = Root::new();
    +    root.file(OWNER, "loads", "sub/part-000.json", b"{}\n");
    +    root.file(OWNER, "loads", "sub/other.json", b"{}\n");
    +    let files = source(&root)
    +        .list(&at("loads"), "sub/part-", 10)
    +        .await
    +        .unwrap();
    +    let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
    +    assert_eq!(paths, vec!["sub/part-000.json"]);
    +}
    +
    +#[cfg(unix)]
    +#[tokio::test]
    +async fn open_refuses_a_fifo_swapped_in_after_enumeration() {
    +    let root = Root::new();
    +    let bucket = root.bucket_dir(OWNER, "loads");
    +    let status = std::process::Command::new("mkfifo")
    +        .arg(bucket.join("part-000.json"))
    +        .status()
    +        .unwrap();
    +    assert!(status.success(), "mkfifo must succeed for this test");
    +
    +    let Err(err) = source(&root)
    +        .open(
    +            &at("loads"),
    +            &input_file("part-000.json", 0),
    +            InputCodec::None,
    +        )
    +        .await
    +    else {
    +        panic!("a FIFO is not a regular file");
    +    };
    +    assert!(matches!(err, SourceError::NotRegularFile), "got {err:?}");
    +}
    +
    +#[cfg(unix)]
    +#[tokio::test]
    +async fn open_refuses_a_symlink_swapped_in_after_enumeration() {
    +    let root = Root::new();
    +    let victim = root.file(OTHER, "loads", "secret.json", b"{\"stolen\":true}\n");
    +    let bucket = root.bucket_dir(OWNER, "loads");
    +    std::os::unix::fs::symlink(&victim, bucket.join("part-000.json")).unwrap();
    +
    +    let Err(err) = source(&root)
    +        .open(
    +            &at("loads"),
    +            &input_file("part-000.json", 16),
    +            InputCodec::None,
    +        )
    +        .await
    +    else {
    +        panic!("a symlink at the input path must be refused");
    +    };
    +    assert!(matches!(err, SourceError::Jail(_)), "got {err:?}");
    +}
    +
    +/// The window after the account handle is taken: a symlink swapped in
    +/// at the final component fails the walk's own `openat`, because it
    +/// carries `O_NOFOLLOW`.
    +#[cfg(unix)]
    +#[test]
    +fn walk_refuses_a_symlink_swapped_in_after_the_account_handle_is_taken() {
    +    let root = Root::new();
    +    let target = root.file(OWNER, "loads", "part-000.json", b"{}\n");
    +    let victim = root.file(OTHER, "loads", "secret.json", b"{\"stolen\":true}\n");
    +    let roots = [root.root()];
    +    let account_dir = open_verified_account_dir(&root.0.join(OWNER), &roots, OWNER)
    +        .unwrap()
    +        .expect("the account directory exists");
    +
    +    std::fs::remove_file(&target).unwrap();
    +    std::os::unix::fs::symlink(&victim, &target).unwrap();
    +
    +    let err = walk_open_input(&account_dir, "loads", &["part-000.json"])
    +        .expect_err("a symlink swapped in after the handle is taken must be refused");
    +    assert!(matches!(err, SourceError::Jail(_)), "got {err:?}");
    +}
    +
    +/// The same window with a FIFO: the open must neither block nor hand
    +/// the FIFO on, because it carries `O_NONBLOCK` and the handle's file
    +/// type is checked before the flag is cleared.
    +#[cfg(unix)]
    +#[tokio::test]
    +async fn walk_refuses_a_fifo_swapped_in_after_the_account_handle_is_taken() {
    +    let root = Root::new();
    +    let target = root.file(OWNER, "loads", "part-000.json", b"{}\n");
    +    let roots = [root.root()];
    +    let account_dir = open_verified_account_dir(&root.0.join(OWNER), &roots, OWNER)
    +        .unwrap()
    +        .expect("the account directory exists");
    +    std::fs::remove_file(&target).unwrap();
    +    let status = std::process::Command::new("mkfifo")
    +        .arg(&target)
    +        .status()
    +        .unwrap();
    +    assert!(status.success(), "mkfifo must succeed for this test");
    +
    +    let result = tokio::time::timeout(
    +        std::time::Duration::from_secs(5),
    +        tokio::task::spawn_blocking(move || {
    +            walk_open_input(&account_dir, "loads", &["part-000.json"])
    +        }),
    +    )
    +    .await
    +    .expect("the open must not block on a FIFO with no writer")
    +    .unwrap();
    +    let err = result.expect_err("a FIFO swapped in after the handle is taken must be refused");
    +    assert!(matches!(err, SourceError::NotRegularFile), "got {err:?}");
    +}
    +
    +/// A regular file renamed over the enumerated path stays within the
    +/// caller's subtree by construction of the walk, so it is served: the
    +/// jail guards containment, not file identity, and the enumerated size
    +/// still caps the read.
    +#[cfg(unix)]
    +#[tokio::test]
    +async fn a_regular_file_replaced_after_enumeration_is_served_within_the_jail() {
    +    let root = Root::new();
    +    let replacement = root.file(OWNER, "loads", "replacement.json", b"{\"b\":2}\n");
    +    let target = root.file(OWNER, "loads", "part-000.json", b"{\"a\":1}\n");
    +    let files = source(&root)
    +        .list(&at("loads"), "part-", u64::MAX)
    +        .await
    +        .unwrap();
    +    assert_eq!(files.len(), 1);
    +    std::fs::rename(&replacement, &target).unwrap();
    +
    +    let mut reader = source(&root)
    +        .open(
    +            &at("loads"),
    +            &input_file("part-000.json", files[0].size),
    +            InputCodec::None,
    +        )
    +        .await
    +        .unwrap();
    +    let lines = read_all(&mut reader, 4096).await;
    +    assert_eq!(lines, vec!["{\"b\":2}"]);
    +}
    +
    +/// The R-1 read-side window: after enumeration validates a path, an
    +/// ancestor directory of it is swapped for a symlink into a sibling
    +/// account's subtree. The walk resolves components from the verified
    +/// account handle, so the swapped component fails its own `openat`
    +/// instead of redirecting the read.
    +#[cfg(unix)]
    +#[tokio::test]
    +async fn open_refuses_an_ancestor_swapped_for_a_foreign_symlink_after_enumeration() {
    +    let root = Root::new();
    +    root.file(OWNER, "loads", "sub/part-000.json", b"{\"mine\":true}\n");
    +    // The foreign file genuinely exists at the redirected path, so a
    +    // refusal can only come from the walk and not from absence.
    +    root.file(OTHER, "loads", "sub/part-000.json", b"{\"stolen\":true}\n");
    +    let files = source(&root)
    +        .list(&at("loads"), "", u64::MAX)
    +        .await
    +        .unwrap();
    +    assert_eq!(files[0].path, "sub/part-000.json");
    +
    +    let own_sub = root.0.join(OWNER).join("loads").join("sub");
    +    let moved = own_sub.with_file_name("sub-moved");
    +    std::fs::rename(&own_sub, &moved).unwrap();
    +    std::os::unix::fs::symlink(root.0.join(OTHER).join("loads").join("sub"), &own_sub).unwrap();
    +
    +    let Err(err) = source(&root)
    +        .open(
    +            &at("loads"),
    +            &input_file("sub/part-000.json", files[0].size),
    +            InputCodec::None,
    +        )
    +        .await
    +    else {
    +        panic!("a swapped ancestor must be refused");
    +    };
    +    assert!(matches!(err, SourceError::Jail(_)), "got {err:?}");
    +}
    +
    +/// An enumerated path is walked component by component, so a dot or
    +/// empty component is refused before any filesystem access however the
    +/// `SourceFile` was constructed.
    +#[tokio::test]
    +async fn open_refuses_dot_and_empty_components_in_an_enumerated_path() {
    +    let root = Root::new();
    +    root.file(OWNER, "loads", "sub/part-000.json", b"{}\n");
    +    for rel in [
    +        "../part-000.json",
    +        "./part-000.json",
    +        "sub/../part-000.json",
    +        "sub//part-000.json",
    +        "/etc/passwd",
    +        "",
    +    ] {
    +        let Err(err) = source(&root)
    +            .open(&at("loads"), &input_file(rel, 4), InputCodec::None)
    +            .await
    +        else {
    +            panic!("path {rel:?} must be refused");
    +        };
    +        assert!(
    +            matches!(err, SourceError::Jail(_)),
    +            "path {rel:?} must be a jail violation, got {err:?}"
    +        );
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/source/tests/mod.rs b/crates/engine/src/bulk_put/source/tests/mod.rs
    new file mode 100644
    index 000000000..e4ad571e7
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/tests/mod.rs
    @@ -0,0 +1,144 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Tests for the bulk put source and sink, split by subject.
    +
    +use std::io::Write;
    +use std::path::Path;
    +
    +use super::*;
    +
    +mod codec;
    +mod dropped_fill;
    +mod jail;
    +mod reader;
    +mod rotation;
    +mod sink;
    +
    +pub(super) const OWNER: &str = "111122223333";
    +
    +pub(super) const OTHER: &str = "999999999999";
    +
    +pub(super) const OP_ID: &str = "01789060589767-da9ddd4f";
    +
    +pub(super) const LEASE_OWNER: &str = "worker-1";
    +
    +/// A fresh directory acting as a configured bulk store root.
    +pub(super) struct Root(PathBuf);
    +
    +impl Root {
    +    pub(super) fn new() -> Self {
    +        let dir = std::env::temp_dir().join(format!("eb-bulk-src-{}", uuid::Uuid::new_v4()));
    +        std::fs::create_dir_all(&dir).unwrap();
    +        Self(dir.canonicalize().unwrap())
    +    }
    +
    +    pub(super) fn root(&self) -> Arc {
    +        Arc::new(self.0.clone())
    +    }
    +
    +    pub(super) fn bucket_dir(&self, account: &str, bucket: &str) -> PathBuf {
    +        let dir = self.0.join(account).join(bucket);
    +        std::fs::create_dir_all(&dir).unwrap();
    +        dir
    +    }
    +
    +    /// Create `///` with content.
    +    fn file(&self, account: &str, bucket: &str, rel: &str, contents: &[u8]) -> PathBuf {
    +        let path = self.0.join(account).join(bucket).join(rel);
    +        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    +        std::fs::write(&path, contents).unwrap();
    +        path
    +    }
    +}
    +
    +impl Drop for Root {
    +    fn drop(&mut self) {
    +        let _ = std::fs::remove_dir_all(&self.0);
    +    }
    +}
    +
    +pub(super) fn source(root: &Root) -> FsSource {
    +    FsSource { root: root.root() }
    +}
    +
    +pub(super) fn sink(root: &Root) -> FsSink {
    +    FsSink { root: root.root() }
    +}
    +
    +/// The bucket `bucket` of the caller `OWNER`, with no expected owner.
    +pub(super) fn at(bucket: &str) -> BucketRef<'_> {
    +    BucketRef::new(OWNER, bucket, None)
    +}
    +
    +/// An enumerated input file of `size` bytes, as the record stores it.
    +pub(super) fn input_file(path: &str, size: u64) -> InputFile {
    +    InputFile {
    +        path: path.to_owned(),
    +        size: i64::try_from(size).unwrap(),
    +        rows: None,
    +        etag: None,
    +        abandon: None,
    +    }
    +}
    +
    +pub(super) fn gzip_bytes(content: &[u8]) -> Vec {
    +    let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
    +    enc.write_all(content).unwrap();
    +    enc.finish().unwrap()
    +}
    +
    +/// One zstd frame. `Fastest` emits compressed blocks, so a frame of a
    +/// few hundred rows spans several blocks and a cut lands mid-block.
    +pub(super) fn zstd_bytes(content: &[u8]) -> Vec {
    +    ruzstd::encoding::compress_to_vec(content, ruzstd::encoding::CompressionLevel::Fastest)
    +}
    +
    +pub(super) async fn read_all(
    +    reader: &mut Box,
    +    max_line_bytes: usize,
    +) -> Vec {
    +    let mut lines = Vec::new();
    +    while let Some(line) = reader.next_line(max_line_bytes).await.unwrap() {
    +        lines.push(line);
    +    }
    +    lines
    +}
    +
    +/// Open one report writer, write the records as a single page, and
    +/// finish it, the way the runner renders one kind.
    +pub(super) async fn render_one(
    +    sink: &FsSink,
    +    prefix: &str,
    +    records: Vec,
    +) -> Result {
    +    let mut writer = sink
    +        .open(
    +            &at("loads"),
    +            prefix,
    +            OP_ID,
    +            ReportKind::Error,
    +            LEASE_OWNER,
    +            &OutputSse::None,
    +        )
    +        .await?;
    +    writer.write_page(records).await?;
    +    writer.finish().await
    +}
    +
    +/// Open a directory handle the way the enumeration does, for tests
    +/// that drive the walk directly.
    +#[cfg(unix)]
    +pub(super) fn open_dir_handle(path: &Path) -> std::fs::File {
    +    use rustix::fs::{CWD, Mode, OFlags, openat};
    +    let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
    +    std::fs::File::from(openat(CWD, path, flags, Mode::empty()).unwrap())
    +}
    +
    +pub(super) fn decode_report(path: &Path) -> Vec {
    +    let compressed = std::fs::read(path).unwrap();
    +    let mut decoder = flate2::read::GzDecoder::new(compressed.as_slice());
    +    let mut plain = String::new();
    +    std::io::Read::read_to_string(&mut decoder, &mut plain).unwrap();
    +    plain.lines().map(str::to_owned).collect()
    +}
    diff --git a/crates/engine/src/bulk_put/source/tests/reader.rs b/crates/engine/src/bulk_put/source/tests/reader.rs
    new file mode 100644
    index 000000000..f788c4903
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/tests/reader.rs
    @@ -0,0 +1,207 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Bulk put source/sink reader tests.
    +
    +use super::*;
    +
    +#[tokio::test]
    +async fn line_one_byte_over_the_cap_is_too_long_and_stops_the_file() {
    +    let root = Root::new();
    +    let cap = 16;
    +    let mut content = vec![b'a'; cap + 1];
    +    content.push(b'\n');
    +    content.extend_from_slice(b"{\"Item\":{}}\n");
    +    let size = u64::try_from(content.len()).unwrap();
    +    root.file(OWNER, "loads", "part-000.json", &content);
    +
    +    let mut reader = source(&root)
    +        .open(
    +            &at("loads"),
    +            &input_file("part-000.json", size),
    +            InputCodec::None,
    +        )
    +        .await
    +        .unwrap();
    +    let err = reader.next_line(cap).await.expect_err("over the cap");
    +    assert!(matches!(err, SourceError::LineTooLong), "got {err:?}");
    +    // The rest of the file is not read: the valid line after the fault
    +    // is never yielded.
    +    assert!(reader.next_line(cap).await.unwrap().is_none());
    +}
    +
    +#[tokio::test]
    +async fn line_exactly_at_the_cap_is_returned() {
    +    let root = Root::new();
    +    let cap = 16;
    +    let mut content = vec![b'b'; cap];
    +    content.push(b'\n');
    +    let size = u64::try_from(content.len()).unwrap();
    +    root.file(OWNER, "loads", "part-000.json", &content);
    +
    +    let mut reader = source(&root)
    +        .open(
    +            &at("loads"),
    +            &input_file("part-000.json", size),
    +            InputCodec::None,
    +        )
    +        .await
    +        .unwrap();
    +    let line = reader.next_line(cap).await.unwrap().unwrap();
    +    assert_eq!(line.len(), cap);
    +    assert!(reader.next_line(cap).await.unwrap().is_none());
    +}
    +
    +#[tokio::test]
    +async fn reading_stops_at_the_enumerated_size_when_the_file_grew() {
    +    let root = Root::new();
    +    let enumerated = b"{\"Item\":{\"pk\":{\"S\":\"a\"}}}\n{\"Item\":{\"pk\":{\"S\":\"b\"}}}\n";
    +    let size = u64::try_from(enumerated.len()).unwrap();
    +    let path = root.file(OWNER, "loads", "part-000.json", enumerated);
    +
    +    // The file grows after enumeration; the appended rows are past the
    +    // enumerated size and must never be read.
    +    let mut file = std::fs::OpenOptions::new()
    +        .append(true)
    +        .open(&path)
    +        .unwrap();
    +    file.write_all(b"{\"Item\":{\"pk\":{\"S\":\"appended\"}}}\n")
    +        .unwrap();
    +    drop(file);
    +
    +    let mut reader = source(&root)
    +        .open(
    +            &at("loads"),
    +            &input_file("part-000.json", size),
    +            InputCodec::None,
    +        )
    +        .await
    +        .unwrap();
    +    let lines = read_all(&mut reader, 4096).await;
    +    assert_eq!(
    +        lines,
    +        vec![
    +            "{\"Item\":{\"pk\":{\"S\":\"a\"}}}",
    +            "{\"Item\":{\"pk\":{\"S\":\"b\"}}}"
    +        ],
    +        "only the enumerated rows are read"
    +    );
    +    assert_eq!(reader.bytes_consumed(), size);
    +}
    +
    +#[tokio::test]
    +async fn a_200_line_file_is_served_in_order_across_blocking_hops() {
    +    let root = Root::new();
    +    let mut content = Vec::new();
    +    for i in 0..200 {
    +        content.extend_from_slice(format!("{{\"i\":{i}}}\n").as_bytes());
    +    }
    +    let size = u64::try_from(content.len()).unwrap();
    +    root.file(OWNER, "loads", "part-000.json", &content);
    +
    +    let mut reader = source(&root)
    +        .open(
    +            &at("loads"),
    +            &input_file("part-000.json", size),
    +            InputCodec::None,
    +        )
    +        .await
    +        .unwrap();
    +    let lines = read_all(&mut reader, 4096).await;
    +    assert_eq!(lines.len(), 200);
    +    for (i, line) in lines.iter().enumerate() {
    +        assert_eq!(line, &format!("{{\"i\":{i}}}"));
    +    }
    +}
    +
    +#[tokio::test]
    +async fn the_line_cap_is_enforced_per_line_within_a_batch() {
    +    let root = Root::new();
    +    let cap = 32;
    +    let mut content = Vec::new();
    +    // 150 short lines cross two batch boundaries before the fault.
    +    for i in 0..150 {
    +        content.extend_from_slice(format!("{{\"i\":{i}}}\n").as_bytes());
    +    }
    +    content.extend_from_slice(&vec![b'x'; cap + 1]);
    +    content.push(b'\n');
    +    content.extend_from_slice(b"{\"i\":151}\n");
    +    let size = u64::try_from(content.len()).unwrap();
    +    root.file(OWNER, "loads", "part-000.json", &content);
    +
    +    let mut reader = source(&root)
    +        .open(
    +            &at("loads"),
    +            &input_file("part-000.json", size),
    +            InputCodec::None,
    +        )
    +        .await
    +        .unwrap();
    +    let mut lines = Vec::new();
    +    let err = loop {
    +        match reader.next_line(cap).await {
    +            Ok(Some(line)) => lines.push(line),
    +            Ok(None) => panic!(
    +                "the over-cap line must fault, got EOF after {}",
    +                lines.len()
    +            ),
    +            Err(e) => break e,
    +        }
    +    };
    +    assert!(matches!(err, SourceError::LineTooLong), "got {err:?}");
    +    assert_eq!(lines.len(), 150, "every line before the fault is served");
    +    assert_eq!(lines[149], "{\"i\":149}");
    +    // Nothing past the fault point is served.
    +    assert!(reader.next_line(cap).await.unwrap().is_none());
    +}
    +
    +/// The fill loop is bounded by bytes as well as lines: lines totalling
    +/// more than [`LINE_BATCH_BYTES`] within one [`LINE_BATCH`] stop the
    +/// batch early, observable as the first blocking hop consuming about
    +/// the bound rather than the whole file, while every line is still
    +/// served in order.
    +#[tokio::test]
    +async fn the_line_batch_is_bounded_by_bytes_as_well_as_lines() {
    +    let root = Root::new();
    +    let line_len = 1024 * 1024;
    +    let line_count = 12;
    +    let mut content = Vec::new();
    +    for i in 0..line_count {
    +        let mut line = format!("{i:02}").into_bytes();
    +        line.resize(line_len, b'x');
    +        content.extend_from_slice(&line);
    +        content.push(b'\n');
    +    }
    +    let size = u64::try_from(content.len()).unwrap();
    +    root.file(OWNER, "loads", "part-000.json", &content);
    +
    +    let cap = 2 * 1024 * 1024;
    +    let mut reader = source(&root)
    +        .open(
    +            &at("loads"),
    +            &input_file("part-000.json", size),
    +            InputCodec::None,
    +        )
    +        .await
    +        .unwrap();
    +    let first = reader.next_line(cap).await.unwrap().unwrap();
    +    // The first fill stopped at the byte bound: eight lines of one MiB
    +    // reach it, so the raw bytes consumed stay near eight MiB instead
    +    // of the twelve the line bound alone would have buffered.
    +    let consumed = reader.bytes_consumed();
    +    assert!(
    +        (8 * 1024 * 1024..9 * 1024 * 1024).contains(&consumed),
    +        "the fill must stop at the byte bound, consumed {consumed}"
    +    );
    +
    +    let mut lines = vec![first];
    +    lines.extend(read_all(&mut reader, cap).await);
    +    assert_eq!(lines.len(), line_count);
    +    for (i, line) in lines.iter().enumerate() {
    +        assert!(
    +            line.starts_with(&format!("{i:02}")),
    +            "line {i} served out of order"
    +        );
    +        assert_eq!(line.len(), line_len);
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/source/tests/rotation.rs b/crates/engine/src/bulk_put/source/tests/rotation.rs
    new file mode 100644
    index 000000000..5457c8376
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/tests/rotation.rs
    @@ -0,0 +1,280 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Report object rotation: the writer rolls to `output-00001.json.gz` and
    +//! onward once the compressed bytes of the current object reach the
    +//! threshold (the hosted service closed its first object at
    +//! 201.73 MiB and wrote the remainder to the second).
    +
    +use super::*;
    +
    +/// The report directory of `OP_ID`'s error report under `bucket`.
    +fn errors_dir(root: &Root, bucket: &str) -> PathBuf {
    +    root.0
    +        .join(OWNER)
    +        .join(bucket)
    +        .join("AWSDynamoDB")
    +        .join("BulkOperation")
    +        .join(format!("{OWNER}:{OP_ID}-0"))
    +        .join("output")
    +        .join("errors")
    +}
    +
    +/// Every entry name in `dir`, sorted.
    +fn names_in(dir: &Path) -> Vec {
    +    let mut names: Vec = std::fs::read_dir(dir)
    +        .unwrap()
    +        .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
    +        .collect();
    +    names.sort();
    +    names
    +}
    +
    +/// A hex payload of `len` characters from a fixed-seed xorshift, so the
    +/// gzip stream gains little from compression and bytes reach the file
    +/// as pages are written.
    +fn noise(seed: u64, len: usize) -> String {
    +    let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
    +    let mut out = String::with_capacity(len);
    +    while out.len() < len {
    +        state ^= state << 13;
    +        state ^= state >> 7;
    +        state ^= state << 17;
    +        out.push_str(&format!("{state:016x}"));
    +    }
    +    out.truncate(len);
    +    out
    +}
    +
    +/// One page of `count` records of about 4 KiB each, numbered from
    +/// `first`.
    +fn page(first: usize, count: usize) -> Vec {
    +    (first..first + count)
    +        .map(|i| {
    +            serde_json::json!({
    +                "itemLocation": {"itemIndex": i},
    +                "errorMessages": [noise(i as u64, 4096)],
    +            })
    +        })
    +        .collect()
    +}
    +
    +fn encoded(records: &[Value]) -> Vec {
    +    records.iter().map(Value::to_string).collect()
    +}
    +
    +/// The page that trips the threshold stays in the object it
    +/// was written to; the next page opens the next numbered object. A
    +/// small page under the threshold shares its object with the page that
    +/// trips it.
    +#[tokio::test]
    +async fn pages_past_the_compressed_threshold_roll_to_numbered_objects() {
    +    let root = Root::new();
    +    root.bucket_dir(OWNER, "loads");
    +    let sink = sink(&root);
    +    let mut writer = sink
    +        .open(
    +            &at("loads"),
    +            "",
    +            OP_ID,
    +            ReportKind::Error,
    +            LEASE_OWNER,
    +            &OutputSse::None,
    +        )
    +        .await
    +        .unwrap();
    +    writer.set_rotation_threshold(32 * 1024);
    +
    +    // One small page, then three pages of about 128 KiB raw each, every
    +    // one of which passes 32 KiB compressed on its own.
    +    let pages = [page(0, 1), page(1, 32), page(33, 32), page(65, 32)];
    +    for records in &pages {
    +        writer.write_page(records.clone()).await.unwrap();
    +    }
    +    writer.finish().await.unwrap();
    +
    +    let dir = errors_dir(&root, "loads");
    +    assert_eq!(
    +        names_in(&dir),
    +        vec![
    +            "output-00000.json.gz",
    +            "output-00001.json.gz",
    +            "output-00002.json.gz"
    +        ],
    +        "three objects, no temp file"
    +    );
    +    let mut expected_first = encoded(&pages[0]);
    +    expected_first.extend(encoded(&pages[1]));
    +    assert_eq!(
    +        decode_report(&dir.join("output-00000.json.gz")),
    +        expected_first,
    +        "the small page and the page that tripped the threshold share the first object"
    +    );
    +    assert_eq!(
    +        decode_report(&dir.join("output-00001.json.gz")),
    +        encoded(&pages[2])
    +    );
    +    assert_eq!(
    +        decode_report(&dir.join("output-00002.json.gz")),
    +        encoded(&pages[3])
    +    );
    +    let total: usize = names_in(&dir)
    +        .iter()
    +        .map(|name| decode_report(&dir.join(name)).len())
    +        .sum();
    +    assert_eq!(total, 97, "every record lands in exactly one object");
    +    for name in names_in(&dir) {
    +        let lines = decode_report(&dir.join(&name));
    +        assert!(!lines.is_empty(), "{name} must not be empty");
    +        for line in lines {
    +            serde_json::from_str::(&line).expect("every line is one complete JSON record");
    +        }
    +    }
    +}
    +
    +/// A re-render that produces fewer objects than an earlier incarnation
    +/// left behind removes the surplus numbered objects, and the open sweep
    +/// removes a dead owner's temp for any object index.
    +#[tokio::test]
    +async fn a_re_render_with_fewer_objects_removes_the_surplus_and_sweeps_every_temp() {
    +    let root = Root::new();
    +    root.bucket_dir(OWNER, "loads");
    +    let sink = sink(&root);
    +
    +    // An earlier incarnation rendered three objects.
    +    let mut writer = sink
    +        .open(
    +            &at("loads"),
    +            "",
    +            OP_ID,
    +            ReportKind::Error,
    +            LEASE_OWNER,
    +            &OutputSse::None,
    +        )
    +        .await
    +        .unwrap();
    +    writer.set_rotation_threshold(32 * 1024);
    +    for records in [page(0, 32), page(32, 32), page(64, 32)] {
    +        writer.write_page(records).await.unwrap();
    +    }
    +    writer.finish().await.unwrap();
    +    let dir = errors_dir(&root, "loads");
    +    assert_eq!(names_in(&dir).len(), 3);
    +
    +    // A dead owner's temp for the second object sits beside them.
    +    let dead_temp = dir.join("output-00001.json.gz.tmp-dead-owner");
    +    std::fs::write(&dead_temp, b"partial").unwrap();
    +
    +    // The re-render fits in one object.
    +    render_one(&sink, "", vec![serde_json::json!({"errorMessages": ["a"]})])
    +        .await
    +        .unwrap();
    +    assert_eq!(
    +        names_in(&dir),
    +        vec!["output-00000.json.gz"],
    +        "the surplus objects and the dead owner's temp are gone"
    +    );
    +    assert_eq!(
    +        decode_report(&dir.join("output-00000.json.gz")),
    +        vec!["{\"errorMessages\":[\"a\"]}"]
    +    );
    +}
    +
    +/// Dropping a writer that has rolled removes every object's temp file.
    +#[tokio::test]
    +async fn dropping_a_rolled_writer_leaves_no_temp_file() {
    +    let root = Root::new();
    +    root.bucket_dir(OWNER, "loads");
    +    let sink = sink(&root);
    +    let mut writer = sink
    +        .open(
    +            &at("loads"),
    +            "",
    +            OP_ID,
    +            ReportKind::Error,
    +            LEASE_OWNER,
    +            &OutputSse::None,
    +        )
    +        .await
    +        .unwrap();
    +    writer.set_rotation_threshold(32 * 1024);
    +    writer.write_page(page(0, 32)).await.unwrap();
    +    writer.write_page(page(32, 32)).await.unwrap();
    +    drop(writer);
    +
    +    let dir = errors_dir(&root, "loads");
    +    assert!(
    +        names_in(&dir).is_empty(),
    +        "every temp is removed: {:?}",
    +        names_in(&dir)
    +    );
    +}
    +
    +/// A page that takes the open object to exactly the
    +/// rotation threshold closes it (the comparison is `>=`, not `>`). The exact
    +/// edge is not measured on the service (the measured case was an overshoot); this
    +/// pins the code's boundary. A probe writer measures the compressed bytes
    +/// one page writes to a fresh object; a second writer whose threshold is
    +/// exactly that figure writes the same page, landing written == threshold,
    +/// and must roll so the next page opens the next object.
    +#[tokio::test]
    +async fn a_page_landing_exactly_on_the_threshold_rolls_to_the_next_object() {
    +    let root = Root::new();
    +    root.bucket_dir(OWNER, "loads");
    +    let sink = sink(&root);
    +
    +    let probe_records = page(0, 64);
    +    let mut probe = sink
    +        .open(
    +            &at("loads"),
    +            "",
    +            OP_ID,
    +            ReportKind::Error,
    +            LEASE_OWNER,
    +            &OutputSse::None,
    +        )
    +        .await
    +        .unwrap();
    +    probe.set_rotation_threshold(u64::MAX);
    +    probe.write_page(probe_records.clone()).await.unwrap();
    +    let exact = probe.open_object_bytes();
    +    assert!(exact > 0, "the probe page wrote compressed bytes");
    +    drop(probe);
    +
    +    let mut writer = sink
    +        .open(
    +            &at("loads"),
    +            "",
    +            OP_ID,
    +            ReportKind::Error,
    +            LEASE_OWNER,
    +            &OutputSse::None,
    +        )
    +        .await
    +        .unwrap();
    +    writer.set_rotation_threshold(exact);
    +    writer.write_page(probe_records).await.unwrap();
    +    writer.write_page(page(64, 32)).await.unwrap();
    +    writer.finish().await.unwrap();
    +
    +    let dir = errors_dir(&root, "loads");
    +    assert_eq!(
    +        names_in(&dir),
    +        vec!["output-00000.json.gz", "output-00001.json.gz"],
    +        "a page exactly on the threshold rolls to the next object"
    +    );
    +}
    +
    +/// The production rotation threshold is 200 MiB.
    +/// Every rotation test lowers it through the test hook, so the constant
    +/// itself is otherwise unpinned (halving it passes the suite). The hosted
    +/// service was measured closing its first report object at 200 MiB plus the
    +/// records buffered when the threshold tripped.
    +#[test]
    +fn the_production_rotation_threshold_is_two_hundred_mebibytes() {
    +    assert_eq!(super::super::sink::ROTATION_THRESHOLD_BYTES, 209_715_200);
    +    assert_eq!(
    +        super::super::sink::ROTATION_THRESHOLD_BYTES,
    +        200 * 1024 * 1024
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/source/tests/sink.rs b/crates/engine/src/bulk_put/source/tests/sink.rs
    new file mode 100644
    index 000000000..660b79d6c
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/tests/sink.rs
    @@ -0,0 +1,489 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Tests of the filesystem report sink.
    +
    +#[cfg(unix)]
    +use super::super::sink::FINAL_NAME;
    +use super::*;
    +
    +/// The render opens the bucket directory, never creates it: a report
    +/// bucket removed after Start fails the render with `NotFound`, and no
    +/// directory is created at the bucket name.
    +#[tokio::test]
    +async fn render_fails_when_the_report_bucket_directory_is_missing() {
    +    let root = Root::new();
    +    // The account directory exists; the bucket does not.
    +    std::fs::create_dir_all(root.0.join(OWNER)).unwrap();
    +    let records = vec![serde_json::json!({"errorMessages": ["x"]})];
    +    let err = render_one(&sink(&root), "", records)
    +        .await
    +        .expect_err("a missing bucket directory must fail the render");
    +    match err {
    +        SinkError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::NotFound, "{e}"),
    +        other => panic!("expected an Io NotFound, got {other:?}"),
    +    }
    +    assert!(
    +        !root.0.join(OWNER).join("loads").exists(),
    +        "the render must not create the bucket directory"
    +    );
    +}
    +
    +#[cfg(unix)]
    +#[tokio::test]
    +async fn render_fails_on_a_planted_symlink_named_awsdynamodb() {
    +    let root = Root::new();
    +    let victim_dir = root.bucket_dir(OTHER, "loads");
    +    let own_bucket = root.bucket_dir(OWNER, "loads");
    +    std::os::unix::fs::symlink(&victim_dir, own_bucket.join("AWSDynamoDB")).unwrap();
    +
    +    let records = vec![serde_json::json!({"errorMessages": ["x"]})];
    +    let err = render_one(&sink(&root), "", records)
    +        .await
    +        .expect_err("a planted symlink must fail the render");
    +    assert!(matches!(err, SinkError::Symlink(_)), "got {err:?}");
    +    // Nothing was created inside the victim's subtree.
    +    assert_eq!(std::fs::read_dir(&victim_dir).unwrap().count(), 0);
    +}
    +
    +/// The R-1 write-side window: an ancestor of the report directory is
    +/// swapped for a symlink to another account's completed report
    +/// directory before the sink opens. The walk from the verified account
    +/// handle fails at the swapped component, and the victim's rendered
    +/// report is untouched.
    +#[cfg(unix)]
    +#[tokio::test]
    +async fn render_refuses_an_ancestor_swapped_for_a_foreign_report_directory() {
    +    let root = Root::new();
    +    root.bucket_dir(OWNER, "loads");
    +    // The victim's completed report, the file the redirected rename
    +    // would otherwise replace.
    +    let victim_operation = root
    +        .0
    +        .join(OTHER)
    +        .join("loads")
    +        .join("AWSDynamoDB")
    +        .join("BulkOperation")
    +        .join(format!("{OTHER}:{OP_ID}-0"));
    +    let victim_errors = victim_operation.join("output").join("errors");
    +    std::fs::create_dir_all(&victim_errors).unwrap();
    +    let victim_report = victim_errors.join(FINAL_NAME);
    +    std::fs::write(&victim_report, b"victim").unwrap();
    +
    +    // The owner's chain exists down to BulkOperation; the operation
    +    // directory is a symlink whose target holds the remaining trailing
    +    // components.
    +    let own_bulk = root
    +        .0
    +        .join(OWNER)
    +        .join("loads")
    +        .join("AWSDynamoDB")
    +        .join("BulkOperation");
    +    std::fs::create_dir_all(&own_bulk).unwrap();
    +    std::os::unix::fs::symlink(
    +        &victim_operation,
    +        own_bulk.join(format!("{OWNER}:{OP_ID}-0")),
    +    )
    +    .unwrap();
    +
    +    let records = vec![serde_json::json!({"errorMessages": ["x"]})];
    +    let err = render_one(&sink(&root), "", records)
    +        .await
    +        .expect_err("a swapped ancestor must fail the render");
    +    assert!(matches!(err, SinkError::Symlink(_)), "got {err:?}");
    +    assert_eq!(std::fs::read(&victim_report).unwrap(), b"victim");
    +}
    +
    +#[tokio::test]
    +async fn render_rejects_a_prefix_that_escapes_the_account_subtree() {
    +    let root = Root::new();
    +    root.bucket_dir(OWNER, "loads");
    +    let victim_dir = root.bucket_dir(OTHER, "loads");
    +
    +    for prefix in [format!("../../{OTHER}/loads"), "/etc/cron.d".to_owned()] {
    +        let records = vec![serde_json::json!({"errorMessages": ["x"]})];
    +        let err = render_one(&sink(&root), &prefix, records)
    +            .await
    +            .expect_err("an escaping prefix must be refused");
    +        assert!(matches!(err, SinkError::Jail(_)), "got {err:?}");
    +    }
    +    assert_eq!(std::fs::read_dir(&victim_dir).unwrap().count(), 0);
    +}
    +
    +#[tokio::test]
    +async fn render_creates_the_chain_and_twice_replaces_the_file() {
    +    let root = Root::new();
    +    root.bucket_dir(OWNER, "loads");
    +    let first = vec![serde_json::json!({"errorMessages": ["first"]})];
    +    let second = vec![
    +        serde_json::json!({"errorMessages": ["second"]}),
    +        serde_json::json!({"errorMessages": ["third"]}),
    +    ];
    +
    +    let key = render_one(&sink(&root), "reports", first).await.unwrap();
    +    assert_eq!(
    +        key,
    +        format!(
    +            "reports/AWSDynamoDB/BulkOperation/{OWNER}:{OP_ID}-0/output/errors/output-00000.json.gz"
    +        )
    +    );
    +
    +    let final_path = root
    +        .0
    +        .join(OWNER)
    +        .join("loads")
    +        .join("reports")
    +        .join("AWSDynamoDB")
    +        .join("BulkOperation")
    +        .join(format!("{OWNER}:{OP_ID}-0"))
    +        .join("output")
    +        .join("errors")
    +        .join("output-00000.json.gz");
    +    assert_eq!(
    +        decode_report(&final_path),
    +        vec!["{\"errorMessages\":[\"first\"]}"]
    +    );
    +
    +    // Every component of the chain is a real directory, not a symlink.
    +    let mut cursor = root.0.join(OWNER).join("loads");
    +    for component in [
    +        "reports",
    +        "AWSDynamoDB",
    +        "BulkOperation",
    +        &format!("{OWNER}:{OP_ID}-0"),
    +        "output",
    +        "errors",
    +    ] {
    +        cursor = cursor.join(component);
    +        let meta = std::fs::symlink_metadata(&cursor).unwrap();
    +        assert!(meta.is_dir() && !meta.file_type().is_symlink());
    +    }
    +
    +    // A leftover temp file of the same owner from an earlier crash does
    +    // not wedge the retry: it is removed before create_new.
    +    let dir = final_path.parent().unwrap();
    +    std::fs::write(
    +        dir.join(format!("output-00000.json.gz.tmp-{LEASE_OWNER}")),
    +        b"stale",
    +    )
    +    .unwrap();
    +
    +    // A second render replaces the file through the rename.
    +    render_one(&sink(&root), "reports", second).await.unwrap();
    +    assert_eq!(
    +        decode_report(&final_path),
    +        vec![
    +            "{\"errorMessages\":[\"second\"]}",
    +            "{\"errorMessages\":[\"third\"]}"
    +        ]
    +    );
    +    // No temporary file is left behind.
    +    assert_eq!(std::fs::read_dir(dir).unwrap().count(), 1);
    +}
    +
    +#[tokio::test]
    +async fn two_pages_then_finish_produce_one_gzip_with_all_records_in_order() {
    +    let root = Root::new();
    +    root.bucket_dir(OWNER, "loads");
    +    let sink = sink(&root);
    +    let mut writer = sink
    +        .open(
    +            &at("loads"),
    +            "",
    +            OP_ID,
    +            ReportKind::Error,
    +            LEASE_OWNER,
    +            &OutputSse::None,
    +        )
    +        .await
    +        .unwrap();
    +    writer
    +        .write_page(vec![
    +            serde_json::json!({"errorMessages": ["a"]}),
    +            serde_json::json!({"errorMessages": ["b"]}),
    +        ])
    +        .await
    +        .unwrap();
    +    writer
    +        .write_page(vec![serde_json::json!({"errorMessages": ["c"]})])
    +        .await
    +        .unwrap();
    +    writer.finish().await.unwrap();
    +
    +    let final_path = root
    +        .0
    +        .join(OWNER)
    +        .join("loads")
    +        .join("AWSDynamoDB")
    +        .join("BulkOperation")
    +        .join(format!("{OWNER}:{OP_ID}-0"))
    +        .join("output")
    +        .join("errors")
    +        .join("output-00000.json.gz");
    +    assert_eq!(
    +        decode_report(&final_path),
    +        vec![
    +            "{\"errorMessages\":[\"a\"]}",
    +            "{\"errorMessages\":[\"b\"]}",
    +            "{\"errorMessages\":[\"c\"]}"
    +        ]
    +    );
    +}
    +
    +#[tokio::test]
    +async fn dropping_a_writer_without_finish_leaves_no_temp_file() {
    +    let root = Root::new();
    +    root.bucket_dir(OWNER, "loads");
    +    let sink = sink(&root);
    +    let mut writer = sink
    +        .open(
    +            &at("loads"),
    +            "",
    +            OP_ID,
    +            ReportKind::Error,
    +            LEASE_OWNER,
    +            &OutputSse::None,
    +        )
    +        .await
    +        .unwrap();
    +    writer
    +        .write_page(vec![serde_json::json!({"errorMessages": ["a"]})])
    +        .await
    +        .unwrap();
    +    drop(writer);
    +
    +    let dir = root
    +        .0
    +        .join(OWNER)
    +        .join("loads")
    +        .join("AWSDynamoDB")
    +        .join("BulkOperation")
    +        .join(format!("{OWNER}:{OP_ID}-0"))
    +        .join("output")
    +        .join("errors");
    +    assert_eq!(
    +        std::fs::read_dir(&dir).unwrap().count(),
    +        0,
    +        "the abandoned temp file is removed"
    +    );
    +}
    +
    +/// A hard-killed runner leaves a temp file whose name embeds an owner
    +/// id no successor recomputes. The next open of the report directory
    +/// sweeps it, and only it: the final report file stays.
    +#[tokio::test]
    +async fn opening_the_report_directory_removes_a_dead_owners_temp_and_keeps_the_final_file() {
    +    let root = Root::new();
    +    root.bucket_dir(OWNER, "loads");
    +    let sink = sink(&root);
    +
    +    // A finished report and a dead incarnation's leftover temp.
    +    render_one(&sink, "", vec![serde_json::json!({"errorMessages": ["a"]})])
    +        .await
    +        .unwrap();
    +    let dir = root
    +        .0
    +        .join(OWNER)
    +        .join("loads")
    +        .join("AWSDynamoDB")
    +        .join("BulkOperation")
    +        .join(format!("{OWNER}:{OP_ID}-0"))
    +        .join("output")
    +        .join("errors");
    +    let dead_temp = dir.join("output-00000.json.gz.tmp-dead-owner");
    +    std::fs::write(&dead_temp, b"partial").unwrap();
    +    let final_file = dir.join("output-00000.json.gz");
    +    let final_before = std::fs::read(&final_file).unwrap();
    +
    +    let writer = sink
    +        .open(
    +            &at("loads"),
    +            "",
    +            OP_ID,
    +            ReportKind::Error,
    +            LEASE_OWNER,
    +            &OutputSse::None,
    +        )
    +        .await
    +        .unwrap();
    +    assert!(
    +        !dead_temp.exists(),
    +        "the dead owner's temp file is gone after open"
    +    );
    +    assert_eq!(
    +        std::fs::read(&final_file).unwrap(),
    +        final_before,
    +        "the final file is untouched"
    +    );
    +    drop(writer);
    +}
    +
    +/// The handle-relative discipline: once the directory handle is taken,
    +/// swapping the validated parent for a symlink cannot redirect the
    +/// write, because the create, write, and rename all address the handle.
    +#[cfg(unix)]
    +#[tokio::test]
    +async fn a_parent_swapped_after_the_handle_is_taken_cannot_redirect_the_write() {
    +    let root = Root::new();
    +    root.bucket_dir(OWNER, "loads");
    +    let victim_dir = root.bucket_dir(OTHER, "loads");
    +    let sink = sink(&root);
    +    let mut writer = sink
    +        .open(
    +            &at("loads"),
    +            "",
    +            OP_ID,
    +            ReportKind::Error,
    +            LEASE_OWNER,
    +            &OutputSse::None,
    +        )
    +        .await
    +        .unwrap();
    +
    +    // The validated parent exists now; swap it for a symlink into the
    +    // victim's subtree.
    +    let errors_dir = root
    +        .0
    +        .join(OWNER)
    +        .join("loads")
    +        .join("AWSDynamoDB")
    +        .join("BulkOperation")
    +        .join(format!("{OWNER}:{OP_ID}-0"))
    +        .join("output")
    +        .join("errors");
    +    let moved = errors_dir.with_file_name("errors-moved");
    +    std::fs::rename(&errors_dir, &moved).unwrap();
    +    std::os::unix::fs::symlink(&victim_dir, &errors_dir).unwrap();
    +
    +    writer
    +        .write_page(vec![serde_json::json!({"errorMessages": ["x"]})])
    +        .await
    +        .unwrap();
    +    writer.finish().await.unwrap();
    +
    +    // The report landed in the original directory, wherever its name
    +    // went, and nothing reached the victim's subtree.
    +    assert_eq!(
    +        decode_report(&moved.join("output-00000.json.gz")),
    +        vec!["{\"errorMessages\":[\"x\"]}"]
    +    );
    +    assert_eq!(std::fs::read_dir(&victim_dir).unwrap().count(), 0);
    +}
    +
    +/// The final install step: a symlink planted at the final report name
    +/// before the render must be replaced by the rename, not written
    +/// through. The tenant controls its subtree, so the whole report chain
    +/// and the planted link are in place before the sink opens; the foreign
    +/// target keeps its content, the link is gone, and the final name is a
    +/// regular file holding the report.
    +#[cfg(unix)]
    +#[tokio::test]
    +async fn a_symlink_planted_at_the_final_report_name_is_replaced_not_followed() {
    +    let root = Root::new();
    +    root.bucket_dir(OWNER, "loads");
    +    let victim_dir = root.bucket_dir(OTHER, "loads");
    +    let victim_file = victim_dir.join("precious.json");
    +    std::fs::write(&victim_file, b"victim").unwrap();
    +
    +    let errors_dir = root
    +        .0
    +        .join(OWNER)
    +        .join("loads")
    +        .join("AWSDynamoDB")
    +        .join("BulkOperation")
    +        .join(format!("{OWNER}:{OP_ID}-0"))
    +        .join("output")
    +        .join("errors");
    +    std::fs::create_dir_all(&errors_dir).unwrap();
    +    std::os::unix::fs::symlink(&victim_file, errors_dir.join(FINAL_NAME)).unwrap();
    +
    +    let records = vec![serde_json::json!({"errorMessages": ["x"]})];
    +    render_one(&sink(&root), "", records)
    +        .await
    +        .expect("the render must succeed by replacing the planted link");
    +
    +    assert_eq!(
    +        std::fs::read(&victim_file).unwrap(),
    +        b"victim",
    +        "the planted link's target must be untouched"
    +    );
    +    let final_path = errors_dir.join(FINAL_NAME);
    +    let meta = std::fs::symlink_metadata(&final_path).unwrap();
    +    assert!(
    +        meta.file_type().is_file(),
    +        "the final name must be a regular file, not the planted link"
    +    );
    +    assert_eq!(
    +        decode_report(&final_path),
    +        vec!["{\"errorMessages\":[\"x\"]}"]
    +    );
    +}
    +
    +/// Report objects and the manifest hold item values, so they are created
    +/// readable and writable by the server's own user only (0600), and every
    +/// directory the render creates beneath the bucket is 0700. The bucket
    +/// directory is the caller's and keeps its own mode.
    +#[cfg(unix)]
    +#[tokio::test]
    +async fn report_files_and_the_directories_the_render_creates_are_private_to_the_server_user() {
    +    use std::os::unix::fs::PermissionsExt;
    +    let mode = |path: &Path| {
    +        std::fs::symlink_metadata(path)
    +            .unwrap()
    +            .permissions()
    +            .mode()
    +            & 0o777
    +    };
    +    let root = Root::new();
    +    let bucket = root.bucket_dir(OWNER, "loads");
    +    let bucket_mode = mode(&bucket);
    +
    +    let records = vec![serde_json::json!({"errorMessages": ["x"]})];
    +    render_one(&sink(&root), "reports/daily", records)
    +        .await
    +        .unwrap();
    +    sink(&root)
    +        .write_manifest(
    +            &at("loads"),
    +            "reports/daily",
    +            OP_ID,
    +            LEASE_OWNER,
    +            &OutputSse::None,
    +            vec![serde_json::json!({"fileProcessingStatus": "FULLY_PROCESSED"})],
    +        )
    +        .await
    +        .unwrap();
    +
    +    let operations = bucket
    +        .join("reports")
    +        .join("daily")
    +        .join("AWSDynamoDB")
    +        .join("BulkOperation");
    +    let errors = operations
    +        .join(format!("{OWNER}:{OP_ID}-0"))
    +        .join("output")
    +        .join("errors");
    +    let manifest = operations
    +        .join(format!("{OWNER}:{OP_ID}"))
    +        .join("manifest-files.json");
    +    for file in [errors.join(FINAL_NAME), manifest] {
    +        assert_eq!(mode(&file), 0o600, "{}", file.display());
    +    }
    +    let mut created = vec![bucket.join("reports")];
    +    for component in [
    +        "daily".to_owned(),
    +        "AWSDynamoDB".to_owned(),
    +        "BulkOperation".to_owned(),
    +        format!("{OWNER}:{OP_ID}-0"),
    +        "output".to_owned(),
    +        "errors".to_owned(),
    +    ] {
    +        let next = created.last().unwrap().join(component);
    +        created.push(next);
    +    }
    +    created.push(operations.join(format!("{OWNER}:{OP_ID}")));
    +    for dir in &created {
    +        assert_eq!(mode(dir), 0o700, "{}", dir.display());
    +    }
    +    assert_eq!(mode(&bucket), bucket_mode, "the bucket keeps its mode");
    +}
    diff --git a/crates/engine/src/bulk_put/source/walk.rs b/crates/engine/src/bulk_put/source/walk.rs
    new file mode 100644
    index 000000000..5abf7c54f
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/source/walk.rs
    @@ -0,0 +1,296 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Input file enumeration: the bounded, symlink-safe directory walk
    +//! that lists regular files beneath a bucket in lexical order.
    +
    +#[cfg(not(unix))]
    +use std::path::Path;
    +use std::path::PathBuf;
    +use std::sync::Arc;
    +
    +use crate::import_export_io::account_scoped_roots;
    +
    +use super::jail::{BucketDirState, bucket_dir_state, check_component};
    +use super::*;
    +
    +pub(super) fn list_blocking(
    +    roots: &[Arc],
    +    account: &str,
    +    bucket: &str,
    +    prefix: &str,
    +    max_files: u64,
    +) -> Result, SourceError> {
    +    check_component(bucket).map_err(SourceError::Jail)?;
    +    let scoped =
    +        account_scoped_roots(roots, account).map_err(|e| SourceError::Jail(e.to_string()))?;
    +    for scoped_root in &scoped {
    +        // The same handle walk the open and render paths use: the verified
    +        // account handle, then the bucket component. The handle the state
    +        // carries is the one the walk descends from, so nothing is
    +        // resolved by path between the check and the descent.
    +        let bucket_dir = match bucket_dir_state(scoped_root, roots, account, bucket)? {
    +            BucketDirState::Missing => continue,
    +            BucketDirState::Symlink => {
    +                return Err(SourceError::Jail(
    +                    "bucket directory is a symlink".to_owned(),
    +                ));
    +            }
    +            // A non-directory at the bucket name has no files beneath it.
    +            BucketDirState::NotADirectory => return Ok(Vec::new()),
    +            BucketDirState::Directory(dir) => dir,
    +        };
    +        let mut files = Vec::new();
    +        let mut bounds = WalkBounds {
    +            max_files,
    +            visit_ceiling: max_files.saturating_mul(4),
    +            visited: 0,
    +        };
    +        match walk_regular_files(&bucket_dir, "", prefix, 0, &mut bounds, &mut files) {
    +            Ok(()) => {}
    +            // The walk collected one match past `max_files` and stopped:
    +            // the caller sees `max_files + 1` entries and knows the count
    +            // quota is passed, without the walk reading the rest of the
    +            // tree. The visit ceiling, which stops the walk with fewer
    +            // matches than that, stays an error.
    +            Err(SourceError::TooManyFiles)
    +                if u64::try_from(files.len()).unwrap_or(u64::MAX) > max_files => {}
    +            Err(error) => return Err(error),
    +        }
    +        files.sort_by(|a, b| a.path.cmp(&b.path));
    +        return Ok(files);
    +    }
    +    Ok(Vec::new())
    +}
    +
    +/// Deepest directory nesting the enumeration walk descends, counted from
    +/// the bucket directory. The recursion runs on a blocking thread whose
    +/// stack is finite and each level holds one open directory handle, so the
    +/// depth is capped explicitly rather than left to `PATH_MAX` or the file
    +/// descriptor limit. Bucket-relative paths deeper than this are not
    +/// enumerable; the walk refuses rather than skipping them silently.
    +pub(super) const MAX_WALK_DEPTH: usize = 256;
    +
    +/// Bounds on one enumeration walk, derived from the caller's file limit.
    +/// `visited` counts every directory entry that can hold a key under the
    +/// prefix, matching or not, so the ceiling holds however the names beneath
    +/// the prefix are arranged; entries beside the prefix are not counted.
    +pub(super) struct WalkBounds {
    +    /// Collecting more than this many matches ends the walk.
    +    pub(super) max_files: u64,
    +    /// Visiting more than this many directory entries that can hold a key
    +    /// under the prefix ends the walk.
    +    pub(super) visit_ceiling: u64,
    +    /// Such entries seen so far, across the whole walk.
    +    pub(super) visited: u64,
    +}
    +
    +/// Whether the entry at bucket-relative path `rel` can hold a key under
    +/// `prefix` (byte-wise, no decoding, as with the file rule): its own path
    +/// starts with the prefix, or the prefix runs on below it, so the entry is a
    +/// directory on the way to the matches. No path beneath any other entry
    +/// starts with the prefix.
    +fn may_hold_a_key(rel: &str, prefix: &str) -> bool {
    +    let (rel, prefix) = (rel.as_bytes(), prefix.as_bytes());
    +    rel.starts_with(prefix)
    +        || (prefix.len() > rel.len() && prefix.starts_with(rel) && prefix[rel.len()] == b'/')
    +}
    +
    +/// Collect regular files beneath the directory handle `dir`, recording
    +/// paths relative to the bucket directory (`rel_dir` is the walked-so-far
    +/// relative path, empty at the bucket itself). Entries are read from the
    +/// handle with `rustix::fs::Dir::read_from` and each subdirectory is
    +/// opened from its parent's handle with `openat(O_DIRECTORY | O_NOFOLLOW)`,
    +/// so there is no interval between examining a component and descending
    +/// into it in which a swapped symlink could be followed. A symlinked file
    +/// is not included and a symlinked directory is not descended into, so
    +/// nothing outside the account subtree can be reached however the links
    +/// are arranged.
    +///
    +/// The walk is bounded while it runs, not only checked afterwards. An entry
    +/// is examined only when it can hold a key under the prefix: every path
    +/// below a directory begins with its own relative path plus `/`, so an
    +/// entry is kept only when its path starts with `prefix` or `prefix` starts
    +/// with its path plus `/` (byte-wise, no decoding, as with the file rule).
    +/// Every other entry is passed over by name, before its `stat`, without
    +/// counting toward the ceiling, and no directory among them is opened. The
    +/// walk ends with `TooManyFiles` the moment the collected matches exceed
    +/// `bounds.max_files` (the match that passed the bound is kept, so `out`
    +/// holds `max_files + 1` entries) or the entries kept exceed
    +/// `bounds.visit_ceiling`, so an oversized tree under the prefix costs at
    +/// most the ceiling in entries examined and the limit plus one in entries
    +/// collected.
    +#[cfg(unix)]
    +pub(super) fn walk_regular_files(
    +    dir: &std::fs::File,
    +    rel_dir: &str,
    +    prefix: &str,
    +    depth: usize,
    +    bounds: &mut WalkBounds,
    +    out: &mut Vec,
    +) -> Result<(), SourceError> {
    +    use rustix::fs::{AtFlags, FileType, Mode, OFlags, openat, statat};
    +    if depth > MAX_WALK_DEPTH {
    +        return Err(SourceError::Jail(format!(
    +            "input directory nesting exceeds {MAX_WALK_DEPTH} levels"
    +        )));
    +    }
    +    // Entry names are streamed from the handle and kept when they can hold
    +    // a key under the prefix. The stream is dropped before any descent so
    +    // the recursion holds one handle per level, as the path walk did. Each
    +    // kept entry is counted against the visit ceiling when the second pass
    +    // examines it, after the matches before it, so a directory of matches
    +    // ends the walk at the limit plus one, as a listing does, before it
    +    // reaches the ceiling. The first pass keeps at most one name past the
    +    // ceiling's remaining budget: the second pass ends the walk by then.
    +    let budget = bounds
    +        .visit_ceiling
    +        .saturating_sub(bounds.visited)
    +        .saturating_add(1);
    +    let mut names = Vec::new();
    +    {
    +        let entries = rustix::fs::Dir::read_from(dir).map_err(|e| SourceError::Io(e.into()))?;
    +        for entry in entries {
    +            let entry = entry.map_err(|e| SourceError::Io(e.into()))?;
    +            // The handle stream yields the directory itself and its
    +            // parent, which a path-based listing never shows; neither is
    +            // an entry.
    +            let name = entry.file_name();
    +            if name == c"." || name == c".." {
    +                continue;
    +            }
    +            // Input paths are stored as UTF-8 strings on the operation
    +            // record; a name that cannot round-trip is not enumerated.
    +            let Ok(name) = name.to_str() else {
    +                continue;
    +            };
    +            let rel = if rel_dir.is_empty() {
    +                name.to_owned()
    +            } else {
    +                format!("{rel_dir}/{name}")
    +            };
    +            // An entry that can hold no key under the prefix is passed
    +            // over before its stat and is not counted, however many of
    +            // them the directory holds.
    +            if !may_hold_a_key(&rel, prefix) {
    +                continue;
    +            }
    +            names.push((name.to_owned(), rel));
    +            if u64::try_from(names.len()).unwrap_or(u64::MAX) >= budget {
    +                break;
    +            }
    +        }
    +    }
    +    for (name, rel) in names {
    +        bounds.visited = bounds.visited.saturating_add(1);
    +        if bounds.visited > bounds.visit_ceiling {
    +            return Err(SourceError::TooManyFiles);
    +        }
    +        let stat = statat(dir, name.as_str(), AtFlags::SYMLINK_NOFOLLOW)
    +            .map_err(|e| SourceError::Io(e.into()))?;
    +        let file_type = FileType::from_raw_mode(stat.st_mode);
    +        if file_type == FileType::Symlink {
    +            continue;
    +        }
    +        if file_type == FileType::Directory {
    +            let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
    +            let child = match openat(dir, name.as_str(), flags, Mode::empty()) {
    +                Ok(fd) => std::fs::File::from(fd),
    +                // The entry changed between the stat and the open: a
    +                // symlink swapped in fails with LOOP and is skipped
    +                // exactly as a symlink seen at the stat would be, and
    +                // an entry removed or replaced is not enumerated.
    +                Err(e)
    +                    if e == rustix::io::Errno::NOENT
    +                        || e == rustix::io::Errno::LOOP
    +                        || e == rustix::io::Errno::NOTDIR =>
    +                {
    +                    continue;
    +                }
    +                Err(e) => return Err(SourceError::Io(e.into())),
    +            };
    +            walk_regular_files(&child, &rel, prefix, depth + 1, bounds, out)?;
    +            continue;
    +        }
    +        if file_type != FileType::RegularFile {
    +            // FIFOs, sockets, and devices are not input files.
    +            continue;
    +        }
    +        if rel.as_bytes().starts_with(prefix.as_bytes()) {
    +            out.push(SourceFile {
    +                path: rel,
    +                size: u64::try_from(stat.st_size).unwrap_or(0),
    +                etag: None,
    +            });
    +            if u64::try_from(out.len()).unwrap_or(u64::MAX) > bounds.max_files {
    +                return Err(SourceError::TooManyFiles);
    +            }
    +        }
    +    }
    +    Ok(())
    +}
    +
    +/// The non-unix fallback walk resolves by path with `symlink_metadata`,
    +/// without the handle-relative hardening; the request handler refuses the
    +/// API on these targets. Bounds and prefix rules match the unix walk.
    +#[cfg(not(unix))]
    +pub(super) fn walk_regular_files(
    +    dir: &Path,
    +    rel_dir: &str,
    +    prefix: &str,
    +    depth: usize,
    +    bounds: &mut WalkBounds,
    +    out: &mut Vec,
    +) -> Result<(), SourceError> {
    +    if depth > MAX_WALK_DEPTH {
    +        return Err(SourceError::Jail(format!(
    +            "input directory nesting exceeds {MAX_WALK_DEPTH} levels"
    +        )));
    +    }
    +    let entries = std::fs::read_dir(dir).map_err(SourceError::Io)?;
    +    for entry in entries {
    +        let entry = entry.map_err(SourceError::Io)?;
    +        let path = entry.path();
    +        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
    +            // Input paths are stored as UTF-8 strings on the operation
    +            // record; a name that cannot round-trip is not enumerated.
    +            continue;
    +        };
    +        let rel = if rel_dir.is_empty() {
    +            name.to_owned()
    +        } else {
    +            format!("{rel_dir}/{name}")
    +        };
    +        if !may_hold_a_key(&rel, prefix) {
    +            continue;
    +        }
    +        bounds.visited = bounds.visited.saturating_add(1);
    +        if bounds.visited > bounds.visit_ceiling {
    +            return Err(SourceError::TooManyFiles);
    +        }
    +        let meta = std::fs::symlink_metadata(&path).map_err(SourceError::Io)?;
    +        let file_type = meta.file_type();
    +        if file_type.is_symlink() {
    +            continue;
    +        }
    +        if file_type.is_dir() {
    +            walk_regular_files(&path, &rel, prefix, depth + 1, bounds, out)?;
    +            continue;
    +        }
    +        if !file_type.is_file() {
    +            // FIFOs, sockets, and devices are not input files.
    +            continue;
    +        }
    +        if rel.as_bytes().starts_with(prefix.as_bytes()) {
    +            out.push(SourceFile {
    +                path: rel,
    +                size: meta.len(),
    +                etag: None,
    +            });
    +            if u64::try_from(out.len()).unwrap_or(u64::MAX) > bounds.max_files {
    +                return Err(SourceError::TooManyFiles);
    +            }
    +        }
    +    }
    +    Ok(())
    +}
    diff --git a/crates/engine/src/bulk_put/store.rs b/crates/engine/src/bulk_put/store.rs
    new file mode 100644
    index 000000000..52572dfa7
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/store.rs
    @@ -0,0 +1,236 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The bulk store handle: the store the server configuration designates,
    +//! with the source every operation reads its input through and the sink it
    +//! writes its reports through, and the builders that turn an operation's
    +//! configurations into the arguments the source and sink take.
    +
    +use std::path::PathBuf;
    +use std::sync::Arc;
    +
    +use extenddb_core::types::{OutputS3Configuration, S3SseAlgorithm};
    +use extenddb_storage::BulkOperationRecord;
    +
    +use crate::bulk_put::run_context::{RecordedRunContext, store_kind};
    +use crate::bulk_put::s3::{S3Sink, S3Source, connect};
    +use crate::bulk_put::source::{
    +    BucketRef, BulkSink, BulkSource, FsSink, FsSource, OutputSse, StoreFault,
    +};
    +
    +/// Which store a [`BulkStore`] is.
    +enum StoreKind {
    +    /// The filesystem store: one directory tree, `{root}/{account id}/
    +    /// {S3Bucket}/...`, under the canonical root the server resolved at
    +    /// startup.
    +    Filesystem { root: Arc },
    +    /// The S3 store: the buckets the requests name, in AWS S3 or an
    +    /// S3-compatible service.
    +    S3 {
    +        /// Whether `endpoint_url` names an S3-compatible service, which
    +        /// resolves `..` key segments (see `source::stored_form`).
    +        endpoint: bool,
    +        /// The access key ID of the credentials the store's startup check
    +        /// resolved.
    +        access_key_id: Option,
    +    },
    +}
    +
    +/// The name the S3 store's startup lines give it.
    +pub const S3_STORE_LABEL: &str = "S3 bulk store";
    +
    +/// The settings of the S3 store, from `[bulk.s3]`.
    +pub use extenddb_s3::S3StoreSettings;
    +
    +/// The bulk store: inputs are read through `source` and reports written
    +/// through `sink`. The server builds one at startup and shares it with the
    +/// request handlers, the account management API and the worker.
    +pub struct BulkStore {
    +    kind: StoreKind,
    +    pub(crate) source: Arc,
    +    pub(crate) sink: Arc,
    +}
    +
    +impl BulkStore {
    +    /// The filesystem store under `root`, the canonical path
    +    /// [`crate::bulk_put::store_root::resolve_store_root`] returned.
    +    #[must_use]
    +    pub fn filesystem(root: Arc) -> Self {
    +        Self {
    +            source: Arc::new(FsSource {
    +                root: Arc::clone(&root),
    +            }),
    +            sink: Arc::new(FsSink {
    +                root: Arc::clone(&root),
    +            }),
    +            kind: StoreKind::Filesystem { root },
    +        }
    +    }
    +
    +    /// The S3 store with `settings`, its clients built from the standard
    +    /// AWS credential chain (see [`crate::bulk_put::s3::connect`]).
    +    pub async fn s3(settings: &S3StoreSettings) -> Self {
    +        let clients = Arc::new(connect(settings).await);
    +        Self {
    +            kind: StoreKind::S3 {
    +                endpoint: settings.endpoint_url.is_some(),
    +                access_key_id: clients.access_key_id().map(str::to_owned),
    +            },
    +            source: Arc::new(S3Source {
    +                clients: Arc::clone(&clients),
    +            }),
    +            sink: Arc::new(S3Sink { clients }),
    +        }
    +    }
    +
    +    /// The access key ID of the credentials the S3 store's startup check
    +    /// resolved, for the server to compare with the keys it accepts from
    +    /// its clients; `None` for the filesystem store and for credentials
    +    /// that did not resolve. Never logged.
    +    #[must_use]
    +    pub fn s3_access_key_id(&self) -> Option<&str> {
    +        match &self.kind {
    +            StoreKind::S3 { access_key_id, .. } => access_key_id.as_deref(),
    +            StoreKind::Filesystem { .. } => None,
    +        }
    +    }
    +
    +    /// The kind of this store as the `[bulk] store` setting spells it,
    +    /// `filesystem` or `s3`: what Start records on each operation it
    +    /// starts.
    +    #[must_use]
    +    pub fn kind_name(&self) -> &'static str {
    +        store_kind(self)
    +    }
    +
    +    /// The filesystem store's canonical root, beneath which each account
    +    /// has its directory; `None` for a store with no directory tree.
    +    #[must_use]
    +    pub fn filesystem_root(&self) -> Option<&Arc> {
    +        match &self.kind {
    +            StoreKind::Filesystem { root } => Some(root),
    +            StoreKind::S3 { .. } => None,
    +        }
    +    }
    +
    +    /// Whether this store resolves a `..` key segment against the segment
    +    /// before it: the S3 store against a configured endpoint. AWS S3 keeps
    +    /// such a key as given, and the filesystem store refuses it at Start.
    +    #[must_use]
    +    pub fn resolves_parent(&self) -> bool {
    +        matches!(self.kind, StoreKind::S3 { endpoint: true, .. })
    +    }
    +
    +    /// Test hook: a filesystem store under `root` whose source and sink are
    +    /// replaced, for handler tests that need a store answer the filesystem
    +    /// cannot give.
    +    #[cfg(test)]
    +    pub(crate) fn with_parts(
    +        root: Arc,
    +        source: Arc,
    +        sink: Arc,
    +    ) -> Self {
    +        Self {
    +            kind: StoreKind::Filesystem { root },
    +            source,
    +            sink,
    +        }
    +    }
    +
    +    /// Test hook: an S3 store over `source` and `sink`, for handler tests of
    +    /// the rules that depend on the kind of store, with no client built.
    +    #[cfg(test)]
    +    pub(crate) fn s3_with_parts(source: Arc, sink: Arc) -> Self {
    +        Self {
    +            kind: StoreKind::S3 {
    +                endpoint: false,
    +                access_key_id: None,
    +            },
    +            source,
    +            sink,
    +        }
    +    }
    +
    +    /// Test hook: [`Self::s3_with_parts`] for an S3 store against a
    +    /// configured endpoint.
    +    #[cfg(test)]
    +    pub(crate) fn s3_endpoint_with_parts(
    +        source: Arc,
    +        sink: Arc,
    +    ) -> Self {
    +        Self {
    +            kind: StoreKind::S3 {
    +                endpoint: true,
    +                access_key_id: None,
    +            },
    +            source,
    +            sink,
    +        }
    +    }
    +}
    +
    +/// The kind of store the operation `record` was started on, as Start
    +/// recorded it and spelled as [`BulkStore::kind_name`] spells it. An
    +/// operation with no recorded run context was started on the filesystem
    +/// store, the only one there was.
    +///
    +/// # Errors
    +///
    +/// The detail of a recorded run context that does not parse.
    +pub fn recorded_store_kind(record: &BulkOperationRecord) -> Result {
    +    RecordedRunContext::of(record).map(|context| context.store)
    +}
    +
    +impl<'a> BucketRef<'a> {
    +    /// The bucket `bucket` in the caller's account `account`, with the
    +    /// configuration's `S3BucketOwner` as `owner`. An owner equal to the
    +    /// caller's account sets no expectation, the normalization the request
    +    /// fingerprint applies, so an absent owner and the caller's own account
    +    /// behave alike.
    +    pub(crate) fn new(account: &'a str, bucket: &'a str, owner: Option<&'a str>) -> Self {
    +        Self {
    +            account,
    +            bucket,
    +            expected_owner: owner.filter(|owner| *owner != account),
    +        }
    +    }
    +}
    +
    +impl OutputSse {
    +    /// The encryption the output configuration asks for. A key id is kept
    +    /// only with `KMS`, the algorithm that uses one.
    +    pub(crate) fn of(output: &OutputS3Configuration) -> Self {
    +        match output.s3_sse_algorithm {
    +            None => Self::None,
    +            Some(S3SseAlgorithm::Aes256) => Self::Aes256,
    +            Some(S3SseAlgorithm::Kms) => Self::Kms {
    +                key_id: output.s3_sse_kms_key_id.clone(),
    +            },
    +        }
    +    }
    +}
    +
    +impl StoreFault {
    +    /// A bucket the caller may not use (measured on the service: the
    +    /// hosted service fails the operation with this code). The filesystem
    +    /// store answers it for an `S3BucketOwner` other than the caller's
    +    /// account.
    +    pub(crate) fn access_denied() -> Self {
    +        Self {
    +            code: "S3AccessDenied",
    +            message: "Access Denied".to_owned(),
    +            after_first_byte: false,
    +        }
    +    }
    +
    +    /// A bucket that does not exist (measured on the service). The
    +    /// filesystem store answers it for a bucket directory that is missing
    +    /// or is not a directory.
    +    pub(crate) fn no_such_bucket() -> Self {
    +        Self {
    +            code: "S3NoSuchBucket",
    +            message: "The specified bucket does not exist".to_owned(),
    +            after_first_byte: false,
    +        }
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/store_root.rs b/crates/engine/src/bulk_put/store_root.rs
    new file mode 100644
    index 000000000..392e74871
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/store_root.rs
    @@ -0,0 +1,214 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Bulk store root resolution and per-account directory creation.
    +//!
    +//! The `[bulk]` store is one directory tree, `{root}/{account id}/
    +//! {S3Bucket}/...`, holding both inputs and reports. The server resolves
    +//! the configured root once at startup with [`resolve_store_root`] and
    +//! creates the per-account level with [`ensure_account_dir`]: at startup
    +//! for every existing account, when an account is created, and at Start
    +//! when the directory is missing. The account level is the trust boundary
    +//! between tenants, so its check reads `symlink_metadata` and refuses a
    +//! symlink outright; a planted link at `{root}/{account id}` would
    +//! otherwise redirect every path the enumeration and the report sink
    +//! build beneath it.
    +
    +use std::io;
    +use std::path::{Path, PathBuf};
    +
    +/// Resolve a configured bulk store root: create the directory if it is
    +/// missing, canonicalize it, and require the result to be a directory.
    +/// The canonical path is what the server logs and what every containment
    +/// comparison uses.
    +///
    +/// # Errors
    +///
    +/// Returns the underlying I/O error when the directory cannot be created
    +/// or canonicalized, and `io::ErrorKind::Other` when the resolved path is
    +/// not a directory.
    +pub fn resolve_store_root(path: &Path) -> io::Result {
    +    std::fs::create_dir_all(path)?;
    +    let canonical = std::fs::canonicalize(path)?;
    +    if !std::fs::metadata(&canonical)?.is_dir() {
    +        return Err(io::Error::other(format!(
    +            "bulk store root {} is not a directory",
    +            canonical.display()
    +        )));
    +    }
    +    Ok(canonical)
    +}
    +
    +/// Create `{root}/{account_id}` if it is missing and return its path.
    +///
    +/// The account id must be one normal path component: an id that is empty,
    +/// contains a separator or NUL, or is `.` or `..` is refused before any
    +/// filesystem access, mirroring `account_scoped_roots` in
    +/// `import_export_io`. Every caller feeds a validated id; the check here
    +/// is defence in depth, so a malformed id from a catalog row can never
    +/// name a directory outside the root.
    +///
    +/// `create_dir` racing another creator is fine: `AlreadyExists` is
    +/// success. Whatever is at the path afterwards must be a directory and
    +/// not a symlink, checked with `symlink_metadata` so the check itself
    +/// never follows a link.
    +///
    +/// # Errors
    +///
    +/// Returns `io::ErrorKind::InvalidInput` when the account id is not one
    +/// normal path component, the underlying I/O error when the directory
    +/// cannot be created or read, and `io::ErrorKind::Other` when the path
    +/// holds a symlink or a non-directory.
    +pub fn ensure_account_dir(root: &Path, account_id: &str) -> io::Result {
    +    if account_id.is_empty()
    +        || account_id.contains('/')
    +        || account_id.contains('\\')
    +        || account_id.contains('\0')
    +        || account_id == "."
    +        || account_id == ".."
    +    {
    +        return Err(io::Error::new(
    +            io::ErrorKind::InvalidInput,
    +            "bulk store account id is empty or contains a path separator",
    +        ));
    +    }
    +    let dir = root.join(account_id);
    +    match std::fs::create_dir(&dir) {
    +        Ok(()) => {}
    +        Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
    +        Err(e) => return Err(e),
    +    }
    +    let meta = std::fs::symlink_metadata(&dir)?;
    +    if meta.file_type().is_symlink() {
    +        return Err(io::Error::other(format!(
    +            "bulk store account directory {} is a symlink",
    +            dir.display()
    +        )));
    +    }
    +    if !meta.is_dir() {
    +        return Err(io::Error::other(format!(
    +            "bulk store account directory {} is not a directory",
    +            dir.display()
    +        )));
    +    }
    +    Ok(dir)
    +}
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::*;
    +
    +    const ACCOUNT: &str = "111122223333";
    +
    +    /// A fresh directory acting as a configured bulk store root.
    +    struct Root(PathBuf);
    +
    +    impl Root {
    +        fn new() -> Self {
    +            let dir = std::env::temp_dir().join(format!("eb-bulk-root-{}", uuid::Uuid::new_v4()));
    +            std::fs::create_dir_all(&dir).unwrap();
    +            Self(dir.canonicalize().unwrap())
    +        }
    +    }
    +
    +    impl Drop for Root {
    +        fn drop(&mut self) {
    +            let _ = std::fs::remove_dir_all(&self.0);
    +        }
    +    }
    +
    +    #[test]
    +    fn resolve_creates_a_missing_root() {
    +        let base = Root::new();
    +        let target = base.0.join("store");
    +        let resolved = resolve_store_root(&target).unwrap();
    +        assert!(resolved.is_dir());
    +        assert_eq!(resolved, target.canonicalize().unwrap());
    +    }
    +
    +    #[test]
    +    fn resolve_accepts_an_existing_root_and_canonicalizes() {
    +        let base = Root::new();
    +        let target = base.0.join("store");
    +        std::fs::create_dir_all(&target).unwrap();
    +        let dotted = base.0.join(".").join("store");
    +        let resolved = resolve_store_root(&dotted).unwrap();
    +        assert_eq!(resolved, target.canonicalize().unwrap());
    +    }
    +
    +    #[test]
    +    fn resolve_refuses_a_file_at_the_root_path() {
    +        let base = Root::new();
    +        let target = base.0.join("store");
    +        std::fs::write(&target, b"not a directory").unwrap();
    +        let err = resolve_store_root(&target).unwrap_err();
    +        assert_ne!(err.kind(), io::ErrorKind::NotFound, "{err}");
    +    }
    +
    +    #[test]
    +    fn ensure_creates_a_missing_account_dir() {
    +        let root = Root::new();
    +        let dir = ensure_account_dir(&root.0, ACCOUNT).unwrap();
    +        assert_eq!(dir, root.0.join(ACCOUNT));
    +        assert!(std::fs::symlink_metadata(&dir).unwrap().is_dir());
    +    }
    +
    +    #[test]
    +    fn ensure_accepts_an_existing_account_dir() {
    +        let root = Root::new();
    +        std::fs::create_dir(root.0.join(ACCOUNT)).unwrap();
    +        let dir = ensure_account_dir(&root.0, ACCOUNT).unwrap();
    +        assert_eq!(dir, root.0.join(ACCOUNT));
    +    }
    +
    +    #[cfg(unix)]
    +    #[test]
    +    fn ensure_refuses_a_symlink_at_the_account_dir() {
    +        let root = Root::new();
    +        let elsewhere = root.0.join("elsewhere");
    +        std::fs::create_dir(&elsewhere).unwrap();
    +        std::os::unix::fs::symlink(&elsewhere, root.0.join(ACCOUNT)).unwrap();
    +        let err = ensure_account_dir(&root.0, ACCOUNT).unwrap_err();
    +        assert_eq!(err.kind(), io::ErrorKind::Other);
    +        assert!(err.to_string().contains("symlink"), "{err}");
    +    }
    +
    +    #[test]
    +    fn ensure_refuses_a_file_at_the_account_dir() {
    +        let root = Root::new();
    +        std::fs::write(root.0.join(ACCOUNT), b"not a directory").unwrap();
    +        let err = ensure_account_dir(&root.0, ACCOUNT).unwrap_err();
    +        assert_eq!(err.kind(), io::ErrorKind::Other);
    +        assert!(err.to_string().contains("not a directory"), "{err}");
    +    }
    +
    +    #[test]
    +    fn ensure_refuses_an_account_id_that_is_not_one_component() {
    +        let root = Root::new();
    +        for id in ["", ".", "..", "a/b", "a\\b", "a\0b", "../111122223333"] {
    +            let err = ensure_account_dir(&root.0, id).unwrap_err();
    +            assert_eq!(
    +                err.kind(),
    +                io::ErrorKind::InvalidInput,
    +                "id {id:?} must be refused as invalid input, got {err}"
    +            );
    +        }
    +        // Nothing was created inside the root by any of the refusals.
    +        assert_eq!(std::fs::read_dir(&root.0).unwrap().count(), 0);
    +    }
    +
    +    /// The escape probe: `"../escape"` must refuse and
    +    /// create nothing beside the root.
    +    #[test]
    +    fn ensure_refuses_an_escaping_account_id_and_creates_nothing() {
    +        let base = Root::new();
    +        let store = base.0.join("store");
    +        std::fs::create_dir(&store).unwrap();
    +        let err = ensure_account_dir(&store, "../escape").unwrap_err();
    +        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
    +        assert!(
    +            !base.0.join("escape").exists(),
    +            "no directory may be created beside the root"
    +        );
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/tests/arn_parsing.rs b/crates/engine/src/bulk_put/tests/arn_parsing.rs
    new file mode 100644
    index 000000000..fdb56b6e6
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/arn_parsing.rs
    @@ -0,0 +1,301 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// Operation id and ARN formatting.
    +
    +#[test]
    +fn new_operation_id_formats_the_known_example() {
    +    assert_eq!(
    +        arn::new_operation_id(1_789_060_589_767, 0xda9d_dd4f),
    +        "01789060589767-da9ddd4f"
    +    );
    +}
    +
    +#[test]
    +fn new_operation_id_zero_pads_the_timestamp_to_14_digits() {
    +    assert_eq!(arn::new_operation_id(1, 0), "00000000000001-00000000");
    +}
    +
    +#[test]
    +fn generate_operation_id_matches_the_id_shape() {
    +    let id = arn::generate_operation_id();
    +    assert_eq!(id.len(), 23, "14 digits, a hyphen, 8 hex digits: {id}");
    +    let (stamp, hex) = id.split_once('-').expect("id carries one hyphen");
    +    assert_eq!(stamp.len(), 14);
    +    assert!(stamp.bytes().all(|b| b.is_ascii_digit()), "bad stamp: {id}");
    +    assert_eq!(hex.len(), 8);
    +    assert!(
    +        hex.bytes()
    +            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
    +        "bad hex: {id}"
    +    );
    +}
    +
    +#[test]
    +fn format_arn_produces_the_bulk_operation_arn_shape() {
    +    assert_eq!(
    +        arn::format_arn(REGION, ACCOUNT, "01789060589767-da9ddd4f"),
    +        VALID_ARN
    +    );
    +}
    +
    +// parse_arn layer 1: shape faults answer with the bare message.
    +
    +#[test]
    +fn parse_arn_rejects_a_plain_word() {
    +    assert_eq!(parse_arn_message("orders"), NOT_AN_ARN);
    +}
    +
    +#[test]
    +fn parse_arn_rejects_a_five_part_arn() {
    +    assert_eq!(
    +        parse_arn_message("arn:aws:dynamodb:us-east-1:123456789012"),
    +        NOT_AN_ARN
    +    );
    +}
    +
    +#[test]
    +fn parse_arn_rejects_a_36_char_string() {
    +    // Six parts and the right resource type, so only the minimum length
    +    // (37, from the model) can reject it.
    +    let input = "arn:aws:dynamodb:::bulk-operation/xy";
    +    assert_eq!(input.len(), 36);
    +    assert_eq!(parse_arn_message(input), NOT_AN_ARN);
    +}
    +
    +#[test]
    +fn parse_arn_rejects_a_1025_char_string() {
    +    let prefix = "arn:aws:dynamodb:us-east-1:123456789012:bulk-operation/";
    +    let input = format!("{prefix}{}", "a".repeat(1025 - prefix.len()));
    +    assert_eq!(input.len(), 1025);
    +    assert_eq!(parse_arn_message(&input), NOT_AN_ARN);
    +}
    +
    +#[test]
    +fn parse_arn_rejects_a_table_resource_arn() {
    +    assert_eq!(
    +        parse_arn_message("arn:aws:dynamodb:us-east-1:123456789012:table/x"),
    +        NOT_AN_ARN
    +    );
    +}
    +
    +// parse_arn layer 2: a well-formed ARN whose id has the wrong shape.
    +
    +#[test]
    +fn parse_arn_rejects_a_uuid_shaped_id() {
    +    assert_eq!(
    +        parse_arn_message(
    +            "arn:aws:dynamodb:us-east-1:123456789012:bulk-operation/\
    +             f81d4fae-7dec-11d0-a765-00a0c91e6bf6"
    +        ),
    +        BAD_ID
    +    );
    +}
    +
    +#[test]
    +fn parse_arn_rejects_a_13_digit_timestamp_id() {
    +    assert_eq!(
    +        parse_arn_message(
    +            "arn:aws:dynamodb:us-east-1:123456789012:bulk-operation/1789060589767-da9ddd4f"
    +        ),
    +        BAD_ID
    +    );
    +}
    +
    +#[test]
    +fn parse_arn_rejects_uppercase_hex_in_the_id() {
    +    assert_eq!(
    +        parse_arn_message(
    +            "arn:aws:dynamodb:us-east-1:123456789012:bulk-operation/01789060589767-DA9DDD4F"
    +        ),
    +        BAD_ID
    +    );
    +}
    +
    +#[test]
    +fn parse_arn_rejects_an_id_without_the_hyphen() {
    +    assert_eq!(
    +        parse_arn_message(
    +            "arn:aws:dynamodb:us-east-1:123456789012:bulk-operation/01789060589767da9ddd4f"
    +        ),
    +        BAD_ID
    +    );
    +}
    +
    +// parse_arn success.
    +
    +#[test]
    +fn parse_arn_returns_the_three_parts_of_a_valid_arn() {
    +    let parsed = arn::parse_arn(VALID_ARN).unwrap();
    +    assert_eq!(parsed.region, REGION);
    +    assert_eq!(parsed.account, ACCOUNT);
    +    assert_eq!(parsed.operation_id, "01789060589767-da9ddd4f");
    +}
    +
    +#[test]
    +fn parse_arn_does_not_decide_account_foreignness() {
    +    // Layer 3, the existence check, and the foreign-account decision belong
    +    // to the handler; a foreign but well-formed ARN parses.
    +    let parsed = arn::parse_arn(
    +        "arn:aws:dynamodb:eu-west-1:999999999999:bulk-operation/01789060589767-da9ddd4f",
    +    )
    +    .unwrap();
    +    assert_eq!(parsed.region, "eu-west-1");
    +    assert_eq!(parsed.account, "999999999999");
    +}
    +
    +// table_name_from_name_or_arn.
    +
    +#[test]
    +fn table_name_passes_a_bare_name_through() {
    +    assert_eq!(
    +        arn::table_name_from_name_or_arn("orders", REGION, ACCOUNT).unwrap(),
    +        "orders"
    +    );
    +}
    +
    +#[test]
    +fn table_name_is_extracted_from_a_matching_table_arn() {
    +    assert_eq!(
    +        arn::table_name_from_name_or_arn(
    +            "arn:aws:dynamodb:us-east-1:123456789012:table/orders",
    +            REGION,
    +            ACCOUNT
    +        )
    +        .unwrap(),
    +        "orders"
    +    );
    +}
    +
    +#[test]
    +fn table_name_denies_a_foreign_account_arn() {
    +    // Measured: a well-formed table ARN of another account is denied, not
    +    // called invalid.
    +    let input = "arn:aws:dynamodb:us-east-1:999999999999:table/orders";
    +    match arn::table_name_from_name_or_arn(input, REGION, ACCOUNT).unwrap_err() {
    +        DynamoDbError::AccessDeniedException(message) => {
    +            assert_eq!(message, "Access is denied");
    +        }
    +        other => panic!("expected AccessDeniedException, got {other:?}"),
    +    }
    +}
    +
    +#[test]
    +fn table_name_rejects_a_wrong_region_arn_with_the_region_message() {
    +    // Measured: a table ARN of the caller's account in another region is
    +    // a validation failure at step 2, not a denial.
    +    let input = "arn:aws:dynamodb:eu-west-1:123456789012:table/orders";
    +    let err = arn::table_name_from_name_or_arn(input, REGION, ACCOUNT).unwrap_err();
    +    assert_eq!(
    +        validation_message(&err),
    +        format!("1 validation error detected: Invalid AWS region in '{input}'")
    +    );
    +}
    +
    +#[test]
    +fn table_name_rejects_an_index_arn() {
    +    let input = "arn:aws:dynamodb:us-east-1:123456789012:table/orders/index/gsi1";
    +    let err = arn::table_name_from_name_or_arn(input, REGION, ACCOUNT).unwrap_err();
    +    assert_eq!(
    +        validation_message(&err),
    +        format!(
    +            "1 validation error detected: Value '{input}' at 'tableName' failed to satisfy \
    +             constraint: Valid ARN format is \
    +             'arn:::::resourceType/resourceName', \
    +             where Resource name must have length less than or equal to 255, Resource name \
    +             must have length greater than or equal to 3, Resource name must satisfy regular \
    +             expression pattern: [a-zA-Z0-9_.-]+"
    +        )
    +    );
    +}
    +
    +#[test]
    +fn table_name_rejects_an_arn_of_another_shape() {
    +    let input = "arn:aws:s3:::orders";
    +    let err = arn::table_name_from_name_or_arn(input, REGION, ACCOUNT).unwrap_err();
    +    assert_eq!(
    +        validation_message(&err),
    +        format!("Invalid table ARN: {input}")
    +    );
    +}
    +
    +#[test]
    +fn table_name_rejects_a_colon_after_the_table_name() {
    +    // No table name may contain a colon, so a seventh colon-separated field
    +    // is not the table ARN shape and must not resolve as the name.
    +    let input = "arn:aws:dynamodb:us-east-1:123456789012:table/orders:extra";
    +    let err = arn::table_name_from_name_or_arn(input, REGION, ACCOUNT).unwrap_err();
    +    assert_eq!(
    +        validation_message(&err),
    +        format!("Invalid table ARN: {input}")
    +    );
    +}
    +
    +#[test]
    +fn table_name_rejects_a_wrong_partition_arn() {
    +    // Resolving across a partition boundary is forbidden by the exact-shape
    +    // rule; only the aws partition matches.
    +    let input = "arn:aws-cn:dynamodb:us-east-1:123456789012:table/orders";
    +    let err = arn::table_name_from_name_or_arn(input, REGION, ACCOUNT).unwrap_err();
    +    assert_eq!(
    +        validation_message(&err),
    +        format!("Invalid table ARN: {input}")
    +    );
    +}
    +
    +#[test]
    +fn table_name_rejects_an_empty_table_name() {
    +    let input = "arn:aws:dynamodb:us-east-1:123456789012:table/";
    +    let err = arn::table_name_from_name_or_arn(input, REGION, ACCOUNT).unwrap_err();
    +    assert_eq!(
    +        validation_message(&err),
    +        format!("Invalid table ARN: {input}")
    +    );
    +}
    +
    +#[test]
    +fn parse_arn_answers_the_id_message_at_the_lower_length_bound() {
    +    // A 37 character string with six parts, the aws partition, the
    +    // dynamodb service, and the right resource type passes every layer 1
    +    // check, so a malformed id must answer the layer 2 message. This pins
    +    // the lower length bound from inside: raising it past 37 flips this
    +    // input to the layer 1 message.
    +    let input = "arn:aws:dynamodb:::bulk-operation/xyz";
    +    assert_eq!(input.len(), 37);
    +    assert_eq!(parse_arn_message(input), BAD_ID);
    +}
    +
    +#[test]
    +fn parse_arn_answers_the_id_message_at_the_upper_length_bound() {
    +    // A well-formed 1024 character ARN is within the model's bounds, so a
    +    // malformed id must answer the layer 2 message. This pins the upper
    +    // length bound from inside: lowering it past 1024 flips this input to
    +    // the layer 1 message.
    +    let prefix = "arn:aws:dynamodb:us-east-1:123456789012:bulk-operation/";
    +    let input = format!("{prefix}{}", "a".repeat(1024 - prefix.len()));
    +    assert_eq!(input.len(), 1024);
    +    assert_eq!(parse_arn_message(&input), BAD_ID);
    +}
    +
    +#[test]
    +fn parse_arn_rejects_a_wrong_partition_or_service_segment() {
    +    // The resource evaluated for Describe and Cancel is this ARN, and
    +    // policy resource matching is literal, so a respelling of the
    +    // partition or service is a string no Deny written against the
    +    // documented shape could match. Layer 2 refuses both (measured); the
    +    // refusal still lands before any policy is fetched.
    +    assert_eq!(
    +        parse_arn_message(
    +            "arn:aws:s3:us-east-1:123456789012:bulk-operation/01789060589767-da9ddd4f"
    +        ),
    +        BAD_ID
    +    );
    +    assert_eq!(
    +        parse_arn_message(
    +            "arn:aws-cn:dynamodb:us-east-1:123456789012:bulk-operation/01789060589767-da9ddd4f"
    +        ),
    +        BAD_ID
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/tests/handlers/cancel.rs b/crates/engine/src/bulk_put/tests/handlers/cancel.rs
    new file mode 100644
    index 000000000..0337e3099
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/cancel.rs
    @@ -0,0 +1,176 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// CancelBulkOperation.
    +
    +#[tokio::test]
    +async fn cancel_moves_in_progress_to_cancelling_with_the_two_field_shape() {
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    let description = start_ok(&ctx, start_body()).await;
    +    let arn = description_arn(&description);
    +    let response = cancel(&ctx, &arn).await.expect("cancel");
    +    assert_eq!(
    +        response,
    +        json!({ "BulkOperationArn": arn, "BulkOperationStatus": "CANCELLING" }),
    +        "the response carries exactly the two modelled fields"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_second_cancel_returns_cancelling_again() {
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    let description = start_ok(&ctx, start_body()).await;
    +    let arn = description_arn(&description);
    +    cancel(&ctx, &arn).await.expect("first cancel");
    +    let response = cancel(&ctx, &arn).await.expect("second cancel");
    +    assert_eq!(response["BulkOperationStatus"], json!("CANCELLING"));
    +}
    +
    +#[tokio::test]
    +async fn cancel_on_each_terminal_status_names_that_status() {
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    let statuses = ["COMPLETED", "COMPLETED_WITH_ERRORS", "FAILED", "CANCELLED"];
    +    let mut ops = Vec::new();
    +    for (i, _) in statuses.iter().enumerate() {
    +        ops.push(
    +            raw_op(
    +                &h,
    +                ACCOUNT,
    +                u32::try_from(i).unwrap(),
    +                1000 + i64::try_from(i).unwrap(),
    +            )
    +            .await,
    +        );
    +    }
    +    let claimed = h
    +        .storage
    +        .claim_runnable("finisher", Duration::from_secs(60), 100, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert_eq!(claimed.len(), statuses.len());
    +    for (op, status) in ops.iter().zip(statuses) {
    +        finish_op(&h, ACCOUNT, "finisher", op, status).await;
    +    }
    +    for (op, status) in ops.iter().zip(statuses) {
    +        let message = expect_validation(cancel(&ctx, &arn_of(ACCOUNT, op)).await);
    +        assert_eq!(
    +            message,
    +            format!("Invalid Request: Operation already in terminal state: {status}")
    +        );
    +    }
    +}
    +
    +#[tokio::test]
    +async fn cancel_rejects_a_plain_string_arn() {
    +    let h = Harness::new().await;
    +    assert_eq!(
    +        expect_validation(cancel(&base_ctx(&h), "orders").await),
    +        NOT_AN_ARN
    +    );
    +}
    +
    +#[tokio::test]
    +async fn cancel_rejects_a_uuid_shaped_operation_id() {
    +    let h = Harness::new().await;
    +    let arn = format!(
    +        "arn:aws:dynamodb:{REGION}:{ACCOUNT}:bulk-operation/\
    +         f81d4fae-7dec-11d0-a765-00a0c91e6bf6"
    +    );
    +    assert_eq!(expect_validation(cancel(&base_ctx(&h), &arn).await), BAD_ID);
    +}
    +
    +#[tokio::test]
    +async fn cancel_answers_not_found_for_an_unknown_operation() {
    +    let h = Harness::new().await;
    +    let arn = arn_of(ACCOUNT, "01789060589767-da9ddd4f");
    +    assert_eq!(
    +        expect_not_found(cancel(&base_ctx(&h), &arn).await),
    +        format!("Bulk operation with arn: {arn} does not exist")
    +    );
    +}
    +
    +#[tokio::test]
    +async fn cancel_denies_another_accounts_operation() {
    +    let h = Harness::new().await;
    +    h.catalog
    +        .create_account(OTHER_ACCOUNT, "handler-tests")
    +        .await
    +        .expect("create account");
    +    let foreign_op = raw_op(&h, OTHER_ACCOUNT, 1, 1_700_000_000_000).await;
    +    let arn = arn_of(OTHER_ACCOUNT, &foreign_op);
    +    match cancel(&base_ctx(&h), &arn).await {
    +        Err(DynamoDbError::AccessDeniedException(message)) => {
    +            assert_eq!(message, "Access is denied");
    +        }
    +        other => panic!("expected AccessDeniedException, got {other:?}"),
    +    }
    +}
    +
    +#[tokio::test]
    +async fn cancel_answers_not_found_for_another_region() {
    +    let h = Harness::new().await;
    +    let op = raw_op(&h, ACCOUNT, 1, 1_700_000_000_000).await;
    +    let arn = format!("arn:aws:dynamodb:eu-west-1:{ACCOUNT}:bulk-operation/{op}");
    +    // The operation exists in the caller's account; the foreign region in
    +    // the ARN must answer the third layer anyway, as Describe does.
    +    assert_eq!(
    +        expect_not_found(cancel(&base_ctx(&h), &arn).await),
    +        format!("Bulk operation with arn: {arn} does not exist")
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_cancel_racing_the_terminal_transition_never_answers_wrongly() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(30)));
    +    let files = h.enumerate().await;
    +    let op = h
    +        .start(files, stored_input_config(), stored_output_config())
    +        .await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    // The runner processes every row, writes its final checkpoint, and
    +    // pends inside the terminal transition.
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let mut faulting = FaultingStorage::new(&h.storage);
    +    faulting.pend_finish = Some(Arc::clone(¬ify));
    +    let mut config = runner_config();
    +    config.lease = Duration::from_millis(100);
    +    let deps = h.deps_with_storage("runner-1", config, Arc::new(faulting));
    +    tokio::select! {
    +        _ = run_operation(&deps, record) => {
    +            panic!("the runner must not finish while the terminal write pends")
    +        }
    +        () = notify.notified() => {}
    +    }
    +
    +    // The cancel lands after the runner passed its last batch boundary and
    +    // before the terminal transition; it must answer CANCELLING, never an
    +    // error and never a terminal answer.
    +    let ctx = base_ctx(&h);
    +    let arn = arn_of(ACCOUNT, &op);
    +    let response = cancel(&ctx, &arn).await.expect("the cancel must succeed");
    +    assert_eq!(response["BulkOperationStatus"], json!("CANCELLING"));
    +
    +    // The first runner died in the pend; after the lease expires a new
    +    // claim resumes the CANCELLING operation and the conditional finish
    +    // lands CANCELLED regardless of the outcome the runner computed.
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    run_operation(&h.deps("runner-2", runner_config()), record)
    +        .await
    +        .expect("resumed run");
    +    let described = describe(&ctx, &arn).await.expect("describe");
    +    let description = &described["BulkOperationDescription"];
    +    assert_eq!(description["BulkOperationStatus"], json!("CANCELLED"));
    +    assert_eq!(
    +        description["ProcessedItemCount"],
    +        json!(30),
    +        "the counters keep every processed row"
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/tests/handlers/describe.rs b/crates/engine/src/bulk_put/tests/handlers/describe.rs
    new file mode 100644
    index 000000000..a077492c0
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/describe.rs
    @@ -0,0 +1,272 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// DescribeBulkOperation.
    +
    +#[tokio::test]
    +async fn describe_rejects_a_plain_string_arn() {
    +    let h = Harness::new().await;
    +    assert_eq!(
    +        expect_validation(describe(&base_ctx(&h), "orders").await),
    +        NOT_AN_ARN
    +    );
    +}
    +
    +#[tokio::test]
    +async fn describe_rejects_a_uuid_shaped_operation_id() {
    +    let h = Harness::new().await;
    +    let arn = format!(
    +        "arn:aws:dynamodb:{REGION}:{ACCOUNT}:bulk-operation/\
    +         f81d4fae-7dec-11d0-a765-00a0c91e6bf6"
    +    );
    +    assert_eq!(
    +        expect_validation(describe(&base_ctx(&h), &arn).await),
    +        BAD_ID
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_respelled_bulk_operation_arn_is_refused_at_layer_two() {
    +    let h = Harness::new().await;
    +    // A real operation, so only the partition or the service respelling is
    +    // at fault: the id would resolve if the string reached layer three,
    +    // and a respelling no policy can name must never get that far.
    +    let ctx = base_ctx(&h);
    +    let description = start_ok(&ctx, start_body()).await;
    +    let canonical = description_arn(&description);
    +    let op = canonical
    +        .rsplit_once('/')
    +        .expect("the ARN carries the operation id")
    +        .1
    +        .to_owned();
    +    let respelled = [
    +        format!("arn:aws-cn:dynamodb:{REGION}:{ACCOUNT}:bulk-operation/{op}"),
    +        format!("arn:aws:s3:{REGION}:{ACCOUNT}:bulk-operation/{op}"),
    +    ];
    +    for arn in &respelled {
    +        assert_eq!(
    +            expect_validation(describe(&ctx, arn).await),
    +            BAD_ID,
    +            "describe must refuse {arn} with the layer two message"
    +        );
    +        assert_eq!(
    +            expect_validation(cancel(&ctx, arn).await),
    +            BAD_ID,
    +            "cancel must refuse {arn} with the layer two message"
    +        );
    +    }
    +    // The canonical spelling of the same operation still resolves.
    +    let described = describe(&ctx, &canonical)
    +        .await
    +        .expect("the canonical ARN resolves");
    +    assert!(
    +        described["BulkOperationDescription"].is_object(),
    +        "the canonical spelling answers the description"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn describe_answers_not_found_for_an_unknown_operation() {
    +    let h = Harness::new().await;
    +    let arn = arn_of(ACCOUNT, "01789060589767-da9ddd4f");
    +    assert_eq!(
    +        expect_not_found(describe(&base_ctx(&h), &arn).await),
    +        format!("Bulk operation with arn: {arn} does not exist")
    +    );
    +}
    +
    +#[tokio::test]
    +async fn describe_denies_another_accounts_operation() {
    +    let h = Harness::new().await;
    +    h.catalog
    +        .create_account(OTHER_ACCOUNT, "handler-tests")
    +        .await
    +        .expect("create account");
    +    let foreign_op = raw_op(&h, OTHER_ACCOUNT, 1, 1_700_000_000_000).await;
    +    let arn = arn_of(OTHER_ACCOUNT, &foreign_op);
    +    // The operation exists in the other account; the answer is decided
    +    // before storage is consulted, so it must not depend on that.
    +    match describe(&base_ctx(&h), &arn).await {
    +        Err(DynamoDbError::AccessDeniedException(message)) => {
    +            assert_eq!(message, "Access is denied");
    +        }
    +        other => panic!("expected AccessDeniedException, got {other:?}"),
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_foreign_account_is_answered_before_the_operation_id_shape() {
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    // Measured on the service: a foreign-account ARN carrying the
    +    // id `nope` is denied, while the caller's own account with the same id
    +    // answers the layer two validation message. Neither account holds an
    +    // operation with that id, so only the order of the two checks decides
    +    // which answer comes back.
    +    let foreign = arn_of(OTHER_ACCOUNT, "nope");
    +    for result in [describe(&ctx, &foreign).await, cancel(&ctx, &foreign).await] {
    +        match result {
    +            Err(DynamoDbError::AccessDeniedException(message)) => {
    +                assert_eq!(message, "Access is denied");
    +            }
    +            other => panic!("expected AccessDeniedException, got {other:?}"),
    +        }
    +    }
    +    let own = arn_of(ACCOUNT, "nope");
    +    assert_eq!(expect_validation(describe(&ctx, &own).await), BAD_ID);
    +    assert_eq!(expect_validation(cancel(&ctx, &own).await), BAD_ID);
    +}
    +
    +#[tokio::test]
    +async fn a_foreign_account_outranks_a_foreign_region() {
    +    // An ARN naming both another account and another region
    +    // answers the account's AccessDeniedException. The account check runs
    +    // before the region check, so a foreign account wins over a foreign
    +    // region; the answer is decided before storage is consulted.
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    let arn = format!(
    +        "arn:aws:dynamodb:us-west-2:{OTHER_ACCOUNT}:bulk-operation/01789060589767-da9ddd4f"
    +    );
    +    for result in [describe(&ctx, &arn).await, cancel(&ctx, &arn).await] {
    +        match result {
    +            Err(DynamoDbError::AccessDeniedException(message)) => {
    +                assert_eq!(message, "Access is denied");
    +            }
    +            other => panic!("expected AccessDeniedException, got {other:?}"),
    +        }
    +    }
    +}
    +
    +#[tokio::test]
    +async fn describe_answers_not_found_for_another_region() {
    +    let h = Harness::new().await;
    +    let op = raw_op(&h, ACCOUNT, 1, 1_700_000_000_000).await;
    +    let arn = format!("arn:aws:dynamodb:eu-west-1:{ACCOUNT}:bulk-operation/{op}");
    +    assert_eq!(
    +        expect_not_found(describe(&base_ctx(&h), &arn).await),
    +        format!("Bulk operation with arn: {arn} does not exist")
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_terminal_operation_description_carries_end_time_and_usage() {
    +    let h = Harness::new().await;
    +    let rows = vec![row(0), row_missing_sk(1), row(2)];
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(
    +            files,
    +            stored_input_config(),
    +            stored_output_config(),
    +            runner_config(),
    +        )
    +        .await;
    +    let arn = arn_of(ACCOUNT, &record.operation_id);
    +    let response = describe(&base_ctx(&h), &arn).await.expect("describe");
    +    let description = &response["BulkOperationDescription"];
    +    assert_eq!(
    +        description["BulkOperationStatus"],
    +        json!("COMPLETED_WITH_ERRORS")
    +    );
    +    // ProcessedItemCount counts rows read and attempted, including the
    +    // failed row.
    +    assert_eq!(description["ProcessedItemCount"], json!(3));
    +    assert_eq!(description["FailedItemCount"], json!(1));
    +    let start_time = description["StartTime"].as_f64().expect("StartTime");
    +    let end_time = description["EndTime"].as_f64().expect("EndTime");
    +    assert!(end_time >= start_time);
    +    let usage = description["BulkOperationUsage"]
    +        .as_array()
    +        .expect("BulkOperationUsage is a list");
    +    assert_eq!(usage.len(), 1, "one usage entry for the table");
    +    assert!(
    +        usage[0]["BulkPutBytes"].as_u64().expect("BulkPutBytes") > 0,
    +        "the usage entry counts the written bytes"
    +    );
    +    let members = description.as_object().unwrap();
    +    assert!(!members.contains_key("FailureCode"));
    +    assert!(!members.contains_key("FailureMessage"));
    +}
    +
    +#[tokio::test]
    +async fn a_failed_operation_description_carries_the_failure_fields() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(5)));
    +    let files = h.enumerate().await;
    +    let mut config = runner_config();
    +    config.max_item_count = 2;
    +    let record = h
    +        .run_to_end(files, stored_input_config(), stored_output_config(), config)
    +        .await;
    +    let arn = arn_of(ACCOUNT, &record.operation_id);
    +    let response = describe(&base_ctx(&h), &arn).await.expect("describe");
    +    let description = &response["BulkOperationDescription"];
    +    assert_eq!(description["BulkOperationStatus"], json!("FAILED"));
    +    assert_eq!(description["FailureCode"], json!("ValidationException"));
    +    assert_eq!(
    +        description["FailureMessage"],
    +        json!("Operation exceeded max_bulk_item_count (2 rows)")
    +    );
    +    assert!(description["EndTime"].is_f64(), "EndTime must be present");
    +}
    +
    +#[tokio::test]
    +async fn an_unknown_stored_status_answers_internal_server_error() {
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    let description = start_ok(&ctx, start_body()).await;
    +    let arn = description_arn(&description);
    +    let mut faulting = FaultingStorage::new(&h.storage);
    +    faulting.doctor_status = Some("MYSTERY".to_owned());
    +    let doctored = ctx_with_storage(&h, Arc::new(faulting));
    +    match describe(&doctored, &arn).await {
    +        Err(DynamoDbError::InternalServerError(_)) => {}
    +        other => panic!("expected InternalServerError, got {other:?}"),
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_negative_stored_counter_answers_internal_server_error_on_describe() {
    +    // The backend record readers refuse a negative counter with a storage
    +    // error (the storage contract pins each column); this test pins the
    +    // wire mapping of that refusal. The counter is planted through a
    +    // claimed checkpoint, which binds the figures it is handed, so the
    +    // corrupt row reaches the real reader on the describe path.
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    let description = start_ok(&ctx, start_body()).await;
    +    let arn = description_arn(&description);
    +    let record = h.claim("corruptor", Duration::from_secs(60)).await;
    +    let state = h
    +        .storage
    +        .checkpoint(
    +            ACCOUNT,
    +            &record.operation_id,
    +            "corruptor",
    +            Duration::from_secs(60),
    +            Progress {
    +                cursor_file_index: 0,
    +                cursor_row_index: 0,
    +                processed_item_count: -1,
    +                failed_item_count: 0,
    +                processed_input_bytes: 0,
    +                report_record_count: 0,
    +                report_bytes: 0,
    +                usage: json!({}),
    +                records: Vec::new(),
    +                finished_files: Vec::new(),
    +                abandoned_files: Vec::new(),
    +            },
    +        )
    +        .await
    +        .expect("the checkpoint itself writes whatever it is handed");
    +    assert!(matches!(state, LeaseState::Held), "the lease is held");
    +    match describe(&ctx, &arn).await {
    +        Err(DynamoDbError::InternalServerError(_)) => {}
    +        other => panic!("expected InternalServerError, got {other:?}"),
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/tests/handlers/dispatch.rs b/crates/engine/src/bulk_put/tests/handlers/dispatch.rs
    new file mode 100644
    index 000000000..2e681be55
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/dispatch.rs
    @@ -0,0 +1,93 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// Dispatch.
    +
    +#[test]
    +fn the_four_operation_names_are_known_operations() {
    +    for name in [
    +        "StartBulkPutItems",
    +        "DescribeBulkOperation",
    +        "ListBulkOperations",
    +        "CancelBulkOperation",
    +    ] {
    +        assert!(crate::is_known_operation(name), "{name} must be known");
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_non_object_body_answers_the_standard_serialization_error() {
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    for operation in [
    +        "StartBulkPutItems",
    +        "DescribeBulkOperation",
    +        "ListBulkOperations",
    +        "CancelBulkOperation",
    +    ] {
    +        match call(&ctx, operation, json!(7)).await {
    +            Err(DynamoDbError::SerializationException(message)) => assert!(
    +                message.starts_with("Start of structure or map found where not expected"),
    +                "{operation}: {message}"
    +            ),
    +            other => panic!("{operation}: expected SerializationException, got {other:?}"),
    +        }
    +    }
    +}
    +
    +// Fault-order parity with the bulk runner.
    +
    +#[tokio::test]
    +async fn put_item_reports_the_condition_message_when_the_key_is_also_missing() {
    +    // The bulk runner pins its fault order to handle_put_item's: the
    +    // condition fields resolve before the item validates, so the
    +    // expression message wins when a row carries both faults. This is the
    +    // PutItem half of that parity, driven with the same sk-less item and
    +    // unparseable expression the runner unit test uses.
    +    let h = Harness::new().await;
    +    let expected = resolve_condition(
    +        Some("attribute_not_exists(("),
    +        None,
    +        None,
    +        None,
    +        None,
    +        &LimitsConfig::default(),
    +    )
    +    .expect_err("the condition expression is invalid")
    +    .to_string();
    +    assert!(
    +        expected.starts_with("Invalid ConditionExpression"),
    +        "the shared core names the condition fault: {expected}"
    +    );
    +    let body = json!({
    +        "TableName": TABLE,
    +        "Item": {"pk": {"S": "a"}},
    +        "ConditionExpression": "attribute_not_exists(("
    +    });
    +    let message = expect_validation(call(&base_ctx(&h), "PutItem", body).await);
    +    assert_eq!(
    +        message, expected,
    +        "the condition message must win over the missing key"
    +    );
    +}
    +
    +#[cfg(unix)]
    +#[tokio::test]
    +async fn a_symlink_at_the_bucket_name_starts_the_operation_terminal_failed() {
    +    let h = Harness::new().await;
    +    // A real directory with an input file, reachable only through a
    +    // symlink planted at the bucket name. The existence check reads the
    +    // link itself and answers as if the bucket were missing; a check that
    +    // followed the link would find a directory inside the caller's own
    +    // subtree and let the operation proceed.
    +    let elsewhere = h.store.0.join(ACCOUNT).join("elsewhere");
    +    std::fs::create_dir_all(&elsewhere).unwrap();
    +    std::fs::write(elsewhere.join("part-000.json"), b"{}\n").unwrap();
    +    std::os::unix::fs::symlink(&elsewhere, h.store.0.join(ACCOUNT).join("linked")).unwrap();
    +    let body = with_input(start_body(), "S3Bucket", json!("linked"));
    +    let description = start_ok(&base_ctx(&h), body).await;
    +    assert_eq!(description["BulkOperationStatus"], json!("FAILED"));
    +    assert_eq!(description["FailureCode"], json!("S3NoSuchBucket"));
    +}
    diff --git a/crates/engine/src/bulk_put/tests/handlers/list.rs b/crates/engine/src/bulk_put/tests/handlers/list.rs
    new file mode 100644
    index 000000000..c5a9ed780
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/list.rs
    @@ -0,0 +1,354 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// ListBulkOperations.
    +
    +#[tokio::test]
    +async fn list_defaults_to_a_page_of_10() {
    +    // Measured: an absent MaxResults answers 10 rows; 25 stays the
    +    // ceiling an explicit value may name.
    +    let h = Harness::new().await;
    +    let base = 1_700_000_000_000_i64;
    +    let mut ids = Vec::new();
    +    for i in 0..12 {
    +        ids.push(raw_op(&h, ACCOUNT, i, base + i64::from(i) * 1000).await);
    +    }
    +    let response = call(&base_ctx(&h), "ListBulkOperations", json!({}))
    +        .await
    +        .expect("list");
    +    let arns = summary_arns(&response);
    +    assert_eq!(arns.len(), 10);
    +    assert_eq!(arns[0], arn_of(ACCOUNT, &ids[11]), "newest first");
    +    assert_eq!(arns[9], arn_of(ACCOUNT, &ids[2]));
    +    assert_token_shape(
    +        response["NextToken"]
    +            .as_str()
    +            .expect("an 11th row forces a NextToken"),
    +    );
    +}
    +
    +#[tokio::test]
    +async fn list_accepts_an_explicit_25() {
    +    let h = Harness::new().await;
    +    let base = 1_700_000_000_000_i64;
    +    for i in 0..26 {
    +        raw_op(&h, ACCOUNT, i, base + i64::from(i) * 1000).await;
    +    }
    +    let response = call(
    +        &base_ctx(&h),
    +        "ListBulkOperations",
    +        json!({ "MaxResults": 25 }),
    +    )
    +    .await
    +    .expect("list");
    +    assert_eq!(summary_arns(&response).len(), 25, "25 stays the ceiling");
    +}
    +
    +#[tokio::test]
    +async fn list_rejects_max_results_zero() {
    +    let h = Harness::new().await;
    +    let message = expect_validation(
    +        call(
    +            &base_ctx(&h),
    +            "ListBulkOperations",
    +            json!({"MaxResults": 0}),
    +        )
    +        .await,
    +    );
    +    assert_eq!(
    +        message,
    +        "1 validation error detected: Value '0' at 'maxResults' failed to satisfy \
    +         constraint: Member must have value greater than or equal to 1"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn list_rejects_max_results_26() {
    +    let h = Harness::new().await;
    +    let message = expect_validation(
    +        call(
    +            &base_ctx(&h),
    +            "ListBulkOperations",
    +            json!({"MaxResults": 26}),
    +        )
    +        .await,
    +    );
    +    assert_eq!(
    +        message,
    +        "1 validation error detected: Value '26' at 'maxResults' failed to satisfy \
    +         constraint: Member must have value less than or equal to 25"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn list_rejects_a_negative_max_results() {
    +    let h = Harness::new().await;
    +    let message = expect_validation(
    +        call(
    +            &base_ctx(&h),
    +            "ListBulkOperations",
    +            json!({"MaxResults": -3}),
    +        )
    +        .await,
    +    );
    +    assert_eq!(
    +        message,
    +        "1 validation error detected: Value '-3' at 'maxResults' failed to satisfy \
    +         constraint: Member must have value greater than or equal to 1"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn list_orders_newest_first_then_id_descending() {
    +    let h = Harness::new().await;
    +    let old = raw_op(&h, ACCOUNT, 3, 1000).await;
    +    let tied_low = raw_op(&h, ACCOUNT, 1, 2000).await;
    +    let tied_high = raw_op(&h, ACCOUNT, 2, 2000).await;
    +    let newest = raw_op(&h, ACCOUNT, 4, 3000).await;
    +    let response = call(&base_ctx(&h), "ListBulkOperations", json!({}))
    +        .await
    +        .expect("list");
    +    let arns = summary_arns(&response);
    +    assert_eq!(
    +        arns,
    +        vec![
    +            arn_of(ACCOUNT, &newest),
    +            arn_of(ACCOUNT, &tied_high),
    +            arn_of(ACCOUNT, &tied_low),
    +            arn_of(ACCOUNT, &old),
    +        ],
    +        "newest first, then operation id descending on equal start times"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn next_token_pages_without_duplicate_or_gap() {
    +    let h = Harness::new().await;
    +    let base = 1_700_000_000_000_i64;
    +    let mut ids = Vec::new();
    +    for i in 0..7 {
    +        ids.push(raw_op(&h, ACCOUNT, i, base + i64::from(i) * 1000).await);
    +    }
    +    let expected: Vec = ids.iter().rev().map(|id| arn_of(ACCOUNT, id)).collect();
    +
    +    let ctx = base_ctx(&h);
    +    let mut collected = Vec::new();
    +    let mut token: Option = None;
    +    let mut pages = 0;
    +    loop {
    +        let mut body = json!({"MaxResults": 3});
    +        if let Some(t) = &token {
    +            body["NextToken"] = json!(t);
    +        }
    +        let response = call(&ctx, "ListBulkOperations", body).await.expect("list");
    +        collected.extend(summary_arns(&response));
    +        pages += 1;
    +        match response["NextToken"].as_str() {
    +            Some(t) => {
    +                assert_token_shape(t);
    +                token = Some(t.to_owned());
    +            }
    +            None => break,
    +        }
    +        assert!(pages < 10, "the pagination must terminate");
    +    }
    +    assert_eq!(pages, 3, "seven rows at three per page are three pages");
    +    assert_eq!(collected, expected, "no duplicate, no gap, stable order");
    +}
    +
    +#[tokio::test]
    +async fn a_foreign_token_repositions_only_within_the_callers_rows() {
    +    let h = Harness::new().await;
    +    h.catalog
    +        .create_account(OTHER_ACCOUNT, "handler-tests")
    +        .await
    +        .expect("create account");
    +    for i in 0..3 {
    +        raw_op(&h, ACCOUNT, i, 1000 + i64::from(i) * 1000).await;
    +    }
    +    for i in 10..13 {
    +        raw_op(&h, OTHER_ACCOUNT, i, 3500 + i64::from(i) * 1000).await;
    +    }
    +    // A token minted from the other account's list decodes but names a
    +    // position in that account's key space.
    +    let other = ctx_for_account(&h, OTHER_ACCOUNT);
    +    let foreign_page = call(&other, "ListBulkOperations", json!({"MaxResults": 1}))
    +        .await
    +        .expect("list");
    +    let token = foreign_page["NextToken"]
    +        .as_str()
    +        .expect("the other account has more rows")
    +        .to_owned();
    +
    +    let response = call(
    +        &base_ctx(&h),
    +        "ListBulkOperations",
    +        json!({"NextToken": token}),
    +    )
    +    .await
    +    .expect("a foreign token is only a position");
    +    let arns = summary_arns(&response);
    +    assert!(
    +        !arns.is_empty(),
    +        "the caller's rows past the position appear"
    +    );
    +    for arn in &arns {
    +        assert!(
    +            arn.contains(&format!(":{ACCOUNT}:")),
    +            "only the caller's rows may appear: {arn}"
    +        );
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_foreign_token_that_lands_mid_list_returns_only_the_callers_older_rows() {
    +    let h = Harness::new().await;
    +    h.catalog
    +        .create_account(OTHER_ACCOUNT, "handler-tests")
    +        .await
    +        .expect("create account");
    +    let oldest = raw_op(&h, ACCOUNT, 1, 1000).await;
    +    let middle = raw_op(&h, ACCOUNT, 2, 2000).await;
    +    raw_op(&h, ACCOUNT, 3, 3000).await;
    +    // The other account's newest row sits between the caller's rows at
    +    // 2000 and 3000, so its page token names a mid-list position in the
    +    // caller's ordering.
    +    raw_op(&h, OTHER_ACCOUNT, 10, 2500).await;
    +    raw_op(&h, OTHER_ACCOUNT, 11, 400).await;
    +    let other = ctx_for_account(&h, OTHER_ACCOUNT);
    +    let foreign_page = call(&other, "ListBulkOperations", json!({"MaxResults": 1}))
    +        .await
    +        .expect("list");
    +    let token = foreign_page["NextToken"]
    +        .as_str()
    +        .expect("the other account has a second row")
    +        .to_owned();
    +
    +    // A dropped token would put the caller's newest row first; a token
    +    // applied to the other account's rows would surface them. Exactly the
    +    // caller's rows older than the cursor may appear, newest first.
    +    let response = call(
    +        &base_ctx(&h),
    +        "ListBulkOperations",
    +        json!({"NextToken": token}),
    +    )
    +    .await
    +    .expect("a foreign token is only a position");
    +    assert_eq!(
    +        summary_arns(&response),
    +        vec![arn_of(ACCOUNT, &middle), arn_of(ACCOUNT, &oldest)],
    +        "only the caller's rows older than the cursor appear"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_malformed_next_token_answers_its_class() {
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    // Pattern class: non-hex, and a hex length that is not a multiple of 16.
    +    for bad in ["zzzzzzzzzzzzzzzz", "abc"] {
    +        let message =
    +            expect_validation(call(&ctx, "ListBulkOperations", json!({"NextToken": bad})).await);
    +        assert_eq!(
    +            message,
    +            format!(
    +                "1 validation error detected: Value '{bad}' at 'nextToken' failed to satisfy \
    +                 constraint: Member must satisfy regular expression pattern: ([0-9a-f]{{16}})+"
    +            ),
    +            "pattern class: {bad}"
    +        );
    +    }
    +    // Service minimum: a well-formed hex token under 112 characters.
    +    let short = format!("{:016x}", 1_700_000_000_000_u64);
    +    let message =
    +        expect_validation(call(&ctx, "ListBulkOperations", json!({"NextToken": short})).await);
    +    assert_eq!(
    +        message,
    +        format!(
    +            "Invalid Request: 1 validation error detected: Value '{short}' at 'nextToken' \
    +             failed to satisfy constraint: Member must have length greater than or equal to 112"
    +        )
    +    );
    +    // Model length maximum: over 1024 characters.
    +    let over = "0".repeat(1040);
    +    let message =
    +        expect_validation(call(&ctx, "ListBulkOperations", json!({"NextToken": over})).await);
    +    assert_eq!(
    +        message,
    +        format!(
    +            "1 validation error detected: Value '{over}' at 'nextToken' failed to satisfy \
    +             constraint: Member must have length less than or equal to 1024"
    +        )
    +    );
    +    // A well-formed token of 112 or more that does not decode to a cursor.
    +    for bad in ["0".repeat(112), "f".repeat(112)] {
    +        let message =
    +            expect_validation(call(&ctx, "ListBulkOperations", json!({"NextToken": bad})).await);
    +        assert_eq!(
    +            message,
    +            format!("Invalid Request: Provided nextToken is invalid: {bad}"),
    +            "not-a-cursor class: {bad}"
    +        );
    +    }
    +}
    +
    +#[tokio::test]
    +async fn only_the_callers_account_is_listed() {
    +    let h = Harness::new().await;
    +    h.catalog
    +        .create_account(OTHER_ACCOUNT, "handler-tests")
    +        .await
    +        .expect("create account");
    +    for i in 0..2 {
    +        raw_op(&h, ACCOUNT, i, 1000 + i64::from(i)).await;
    +    }
    +    for i in 10..13 {
    +        raw_op(&h, OTHER_ACCOUNT, i, 2000 + i64::from(i)).await;
    +    }
    +    let response = call(&base_ctx(&h), "ListBulkOperations", json!({}))
    +        .await
    +        .expect("list");
    +    let arns = summary_arns(&response);
    +    assert_eq!(arns.len(), 2);
    +    for arn in &arns {
    +        assert!(arn.contains(&format!(":{ACCOUNT}:")), "foreign row: {arn}");
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_terminal_summary_carries_end_time() {
    +    let h = Harness::new().await;
    +    let running = raw_op(&h, ACCOUNT, 1, 1000).await;
    +    let finished = raw_op(&h, ACCOUNT, 2, 2000).await;
    +    let claimed = h
    +        .storage
    +        .claim_runnable("finisher", Duration::from_secs(60), 100, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert_eq!(claimed.len(), 2);
    +    finish_op(&h, ACCOUNT, "finisher", &finished, "COMPLETED").await;
    +
    +    let response = call(&base_ctx(&h), "ListBulkOperations", json!({}))
    +        .await
    +        .expect("list");
    +    let rows = summaries(&response);
    +    assert_eq!(rows.len(), 2);
    +    let finished_row = rows
    +        .iter()
    +        .find(|s| s["BulkOperationArn"] == json!(arn_of(ACCOUNT, &finished)))
    +        .expect("the finished row is listed");
    +    assert_eq!(finished_row["BulkOperationStatus"], json!("COMPLETED"));
    +    assert!(finished_row["EndTime"].is_f64(), "EndTime must be present");
    +    let running_row = rows
    +        .iter()
    +        .find(|s| s["BulkOperationArn"] == json!(arn_of(ACCOUNT, &running)))
    +        .expect("the running row is listed");
    +    assert_eq!(running_row["BulkOperationStatus"], json!("IN_PROGRESS"));
    +    assert!(
    +        !running_row.as_object().unwrap().contains_key("EndTime"),
    +        "EndTime must be absent while running"
    +    );
    +    assert!(running_row["StartTime"].is_f64(), "StartTime is carried");
    +}
    diff --git a/crates/engine/src/bulk_put/tests/handlers/log_targets.rs b/crates/engine/src/bulk_put/tests/handlers/log_targets.rs
    new file mode 100644
    index 000000000..8562911c9
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/log_targets.rs
    @@ -0,0 +1,103 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Which target the bulk handlers file their ERROR lines under. An
    +//! operator filters the log by target, so a bulk handler fault filed under
    +//! another handler's target is hard to find.
    +
    +use super::logs::Capture;
    +use super::*;
    +
    +/// The target every bulk handler module logs under.
    +const HANDLERS_TARGET: &str = "extenddb_engine::bulk_put::handlers";
    +
    +/// A Describe whose storage read fails internally answers
    +/// `InternalServerError`, and the ERROR line is filed under the bulk
    +/// handlers' target, not the table handlers' one.
    +#[tokio::test]
    +async fn a_describe_storage_fault_is_logged_under_the_bulk_handlers_target() {
    +    let h = Harness::new().await;
    +    let operation_id = raw_op(&h, ACCOUNT, 1, now_ms()).await;
    +    let mut faulting = FaultingStorage::new(&h.storage);
    +    faulting.fail_get_on_call = Some(1);
    +    let ctx = ctx_with_storage(&h, Arc::new(faulting));
    +
    +    let (capture, guard) = Capture::install();
    +    let result = describe(&ctx, &arn_of(ACCOUNT, &operation_id)).await;
    +    drop(guard);
    +
    +    assert!(
    +        matches!(&result, Err(DynamoDbError::InternalServerError(m)) if m == "Internal server error"),
    +        "{result:?}"
    +    );
    +    let lines = capture.errors_containing("storage internal error");
    +    assert_eq!(
    +        lines.len(),
    +        1,
    +        "one ERROR line for the fault, got {lines:?}"
    +    );
    +    assert_eq!(lines[0].target, HANDLERS_TARGET, "{lines:?}");
    +}
    +
    +/// An input enumeration that fails for a reason other than a store fault
    +/// is logged under the Start handler's target.
    +#[tokio::test]
    +async fn a_start_enumeration_failure_is_logged_under_the_start_target() {
    +    let h = Harness::new().await;
    +    let ctx = super::start_store_faults::ctx_with_listing(&h, None);
    +
    +    let (capture, guard) = Capture::install();
    +    let result = start(&ctx, start_body()).await;
    +    drop(guard);
    +
    +    assert!(
    +        matches!(&result, Err(DynamoDbError::InternalServerError(m)) if m == "Internal server error"),
    +        "{result:?}"
    +    );
    +    let lines = capture.errors_containing("bulk operation input enumeration failed");
    +    assert_eq!(lines.len(), 1, "{lines:?}");
    +    assert_eq!(
    +        lines[0].target, "extenddb_engine::bulk_put::handlers::start",
    +        "{lines:?}"
    +    );
    +}
    +
    +/// Each storage fault the table handlers log at ERROR, mapped by the bulk
    +/// handlers: the same answer and the same message as the table handlers
    +/// give, in one ERROR line filed under the bulk handlers' target.
    +#[test]
    +fn every_logged_storage_fault_is_logged_under_the_bulk_handlers_target() {
    +    use extenddb_storage::error::StorageError;
    +    type Fault = fn() -> StorageError;
    +    let faults: Vec<(&str, Fault)> = vec![("Connection", || {
    +        StorageError::Connection("connection refused".to_owned())
    +    })];
    +    for (name, fault) in faults {
    +        let (capture, guard) = Capture::install();
    +        let table_answer = crate::create_table::storage_err_to_dynamo(fault());
    +        let table_lines = capture.errors_containing("");
    +        drop(guard);
    +
    +        let (capture, guard) = Capture::install();
    +        let bulk_answer = crate::bulk_put::handlers::bulk_storage_err(fault());
    +        let lines = capture.errors_containing("");
    +        drop(guard);
    +
    +        assert_eq!(
    +            format!("{bulk_answer:?}"),
    +            format!("{table_answer:?}"),
    +            "{name}: the bulk handlers answer as the table handlers do"
    +        );
    +        assert_eq!(lines.len(), 1, "{name}: one ERROR line, got {lines:?}");
    +        assert_eq!(lines[0].target, HANDLERS_TARGET, "{name}: {lines:?}");
    +        assert_eq!(
    +            table_lines.len(),
    +            1,
    +            "{name}: the table handlers log one line"
    +        );
    +        assert_eq!(
    +            lines[0].message, table_lines[0].message,
    +            "{name}: the same message as the table handlers"
    +        );
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/tests/handlers/logs.rs b/crates/engine/src/bulk_put/tests/handlers/logs.rs
    new file mode 100644
    index 000000000..aca262f26
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/logs.rs
    @@ -0,0 +1,90 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! A subscriber that keeps the events logged on the current thread with
    +//! their level, target and message, for the tests that pin which handler
    +//! a log line is filed under.
    +
    +use std::sync::{Arc, Mutex};
    +
    +use tracing::field::{Field, Visit};
    +use tracing::span::{Attributes, Id, Record};
    +use tracing::{Event, Level, Metadata, Subscriber};
    +
    +/// One logged event.
    +#[derive(Clone, Debug)]
    +pub(super) struct Logged {
    +    pub(super) level: Level,
    +    pub(super) target: String,
    +    pub(super) message: String,
    +}
    +
    +/// The events logged while the guard [`Capture::install`] returns lives.
    +#[derive(Clone, Default)]
    +pub(super) struct Capture(Arc>>);
    +
    +impl Capture {
    +    /// Install on the current thread until the guard drops. A test that
    +    /// uses it runs on the default current-thread runtime, so the handler
    +    /// logs on this thread.
    +    pub(super) fn install() -> (Self, tracing::dispatcher::DefaultGuard) {
    +        let capture = Self::default();
    +        let guard = tracing::dispatcher::set_default(&tracing::Dispatch::new(capture.clone()));
    +        (capture, guard)
    +    }
    +
    +    /// The ERROR events whose message contains `needle`.
    +    pub(super) fn errors_containing(&self, needle: &str) -> Vec {
    +        self.0
    +            .lock()
    +            .unwrap()
    +            .iter()
    +            .filter(|e| e.level == Level::ERROR && e.message.contains(needle))
    +            .cloned()
    +            .collect()
    +    }
    +}
    +
    +struct Message<'a>(&'a mut String);
    +
    +impl Visit for Message<'_> {
    +    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
    +        if field.name() == "message" {
    +            *self.0 = format!("{value:?}");
    +        }
    +    }
    +
    +    fn record_str(&mut self, field: &Field, value: &str) {
    +        if field.name() == "message" {
    +            value.clone_into(self.0);
    +        }
    +    }
    +}
    +
    +impl Subscriber for Capture {
    +    fn enabled(&self, _: &Metadata<'_>) -> bool {
    +        true
    +    }
    +
    +    fn new_span(&self, _: &Attributes<'_>) -> Id {
    +        Id::from_u64(1)
    +    }
    +
    +    fn record(&self, _: &Id, _: &Record<'_>) {}
    +
    +    fn record_follows_from(&self, _: &Id, _: &Id) {}
    +
    +    fn event(&self, event: &Event<'_>) {
    +        let mut message = String::new();
    +        event.record(&mut Message(&mut message));
    +        self.0.lock().unwrap().push(Logged {
    +            level: *event.metadata().level(),
    +            target: event.metadata().target().to_owned(),
    +            message,
    +        });
    +    }
    +
    +    fn enter(&self, _: &Id) {}
    +
    +    fn exit(&self, _: &Id) {}
    +}
    diff --git a/crates/engine/src/bulk_put/tests/handlers/mod.rs b/crates/engine/src/bulk_put/tests/handlers/mod.rs
    new file mode 100644
    index 000000000..707619a0a
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/mod.rs
    @@ -0,0 +1,429 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Behaviour tests for the bulk operation request handlers, written from
    +//! the specification.
    +//!
    +//! Every test drives the four operations through `dispatch`, so operation
    +//! routing is covered together with the handler, against a real in-memory
    +//! `SQLite` storage engine with a temporary directory as the bulk store
    +//! root. The storage harness and its fault-injecting wrapper are shared
    +//! with the runner behaviour tests in `runner.rs`.
    +
    +use std::collections::HashSet;
    +use std::sync::Arc;
    +use std::time::Duration;
    +
    +use serde_json::{Value, json};
    +
    +use extenddb_auth::AuthCacheRegistry;
    +use extenddb_core::error::DynamoDbError;
    +use extenddb_core::limits::LimitsConfig;
    +use extenddb_core::metrics::MetricsCollector;
    +use extenddb_storage::{
    +    FinishState, LeaseState, NewBulkOperation, Progress, StartOutcome, StorageEngine, Terminal,
    +};
    +
    +use super::runner::{FaultingStorage, Harness, file_of, plain_rows, row, row_missing_sk};
    +use crate::OperationContext;
    +use crate::bulk_put::arn;
    +use crate::bulk_put::authorize::BulkAuthorizer;
    +use crate::bulk_put::runner::{RunnerConfig, run as run_operation};
    +use crate::bulk_put::store::BulkStore;
    +use crate::expression_helpers::resolve_condition;
    +
    +const REGION: &str = "us-east-1";
    +const ACCOUNT: &str = "123456789012";
    +const OTHER_ACCOUNT: &str = "999999999999";
    +const TABLE: &str = "orders";
    +const IN_BUCKET: &str = "loads";
    +const OUT_BUCKET: &str = "reports";
    +
    +/// Layer 1 of the operation ARN table.
    +const NOT_AN_ARN: &str = "Invalid Bulk operation ARN";
    +/// Layer 2 of the operation ARN table.
    +const BAD_ID: &str = "Invalid Request: Bulk operation ARN is invalid.";
    +const ION_REFUSED: &str = "InputFormat ION is not supported by this server";
    +const BAD_PREFIX: &str = "S3KeyPrefix contains an invalid path component";
    +const EMPTY_OUTPUT_TYPE: &str = "Invalid Request: OutputS3Configuration.OutputType cannot be empty";
    +const CONFIG_TOO_BIG: &str = "S3 configuration exceeds 8192 bytes";
    +const BULK_DISABLED: &str =
    +    "Bulk operations are disabled. Configure [bulk] in extenddb.toml to enable.";
    +const NO_SUCH_BUCKET: &str = "The specified bucket does not exist";
    +const TABLE_BUSY: &str = "Table has ongoing bulk operations, please wait and try again";
    +const IDEMPOTENT_MISMATCH: &str = "Specified idempotent token was already used with different \
    +                                   request parameters within the idempotency window";
    +const TABLE_NOT_FOUND: &str = "Requested resource not found";
    +const THROTTLED: &str = "Rate of requests exceeds the allowed throughput.";
    +
    +// Context and request plumbing.
    +
    +fn now_ms() -> i64 {
    +    i64::try_from(
    +        std::time::SystemTime::now()
    +            .duration_since(std::time::SystemTime::UNIX_EPOCH)
    +            .unwrap()
    +            .as_millis(),
    +    )
    +    .unwrap()
    +}
    +
    +/// A `LimitsConfig` carrying the bulk operation keys, built through serde so
    +/// this file compiles independently of the limits struct; on a tree without
    +/// the fields the parse fails at run time and names what is missing.
    +fn bulk_limits(overrides: &[(&str, Value)]) -> LimitsConfig {
    +    let mut body = json!({
    +        "max_bulk_input_bytes": 10_737_418_240_u64,
    +        "max_bulk_input_files": 10_000,
    +        "max_concurrent_bulk_operations": 50,
    +        "max_bulk_client_token_bytes": 1024,
    +    });
    +    for (key, value) in overrides {
    +        body[*key] = value.clone();
    +    }
    +    serde_json::from_value(body).expect("LimitsConfig must carry the max_bulk_* fields")
    +}
    +
    +fn ctx_with_limits(h: &Harness, limits: LimitsConfig) -> OperationContext {
    +    OperationContext {
    +        storage: Arc::clone(&h.storage),
    +        backup: Arc::new(crate::BackupRuntime::new(4)),
    +        limits: Arc::new(limits),
    +        region: Arc::from(REGION),
    +        account_id: Arc::from(ACCOUNT),
    +        // Empty on purpose: bulk operations read only `bulk`, so a
    +        // regression that reaches for the import or export paths fails
    +        // these tests instead of passing by accident.
    +        import_paths: Vec::new().into(),
    +        export_paths: Vec::new().into(),
    +        bulk: Some(Arc::new(BulkStore::filesystem(h.store.root()))),
    +        bulk_authorizer: None,
    +        bulk_principal: None,
    +        authorized_key_info: Default::default(),
    +        auth_cache: AuthCacheRegistry::empty(),
    +        table_key_info_lookup: None,
    +        metrics: Arc::new(MetricsCollector::new()),
    +    }
    +}
    +
    +fn base_ctx(h: &Harness) -> OperationContext {
    +    ctx_with_limits(h, bulk_limits(&[]))
    +}
    +
    +fn ctx_without_store(h: &Harness) -> OperationContext {
    +    let mut ctx = base_ctx(h);
    +    ctx.bulk = None;
    +    ctx
    +}
    +
    +fn ctx_for_account(h: &Harness, account: &str) -> OperationContext {
    +    let mut ctx = base_ctx(h);
    +    ctx.account_id = Arc::from(account);
    +    ctx
    +}
    +
    +fn ctx_with_storage(h: &Harness, storage: Arc) -> OperationContext {
    +    let mut ctx = base_ctx(h);
    +    ctx.storage = storage;
    +    ctx
    +}
    +
    +async fn call(
    +    ctx: &OperationContext,
    +    operation: &str,
    +    body: Value,
    +) -> Result {
    +    crate::dispatch(operation, body, ctx, "127.0.0.1:0")
    +        .await
    +        .map(|result| result.body)
    +}
    +
    +/// A valid start request: empty input prefix over the staged input bucket
    +/// directory, and both report kinds into the staged report bucket.
    +fn start_body() -> Value {
    +    json!({
    +        "TableName": TABLE,
    +        "InputS3Configuration": {
    +            "S3Bucket": IN_BUCKET,
    +            "InputFormat": "DYNAMODB_JSON",
    +            "InputCompressionType": "NONE"
    +        },
    +        "OutputS3Configuration": {
    +            "S3Bucket": OUT_BUCKET,
    +            "S3KeyPrefix": "run",
    +            "OutputType": ["ERROR", "CONDITION_CHECK_FAILURE"]
    +        }
    +    })
    +}
    +
    +fn with_input(mut body: Value, key: &str, value: Value) -> Value {
    +    body["InputS3Configuration"][key] = value;
    +    body
    +}
    +
    +fn with_output(mut body: Value, key: &str, value: Value) -> Value {
    +    body["OutputS3Configuration"][key] = value;
    +    body
    +}
    +
    +fn with_top(mut body: Value, key: &str, value: Value) -> Value {
    +    body[key] = value;
    +    body
    +}
    +
    +async fn start(ctx: &OperationContext, body: Value) -> Result {
    +    call(ctx, "StartBulkPutItems", body).await
    +}
    +
    +async fn start_ok(ctx: &OperationContext, body: Value) -> Value {
    +    let response = start(ctx, body).await.expect("the start must succeed");
    +    let description = response["BulkOperationDescription"].clone();
    +    assert!(description.is_object(), "start must return a description");
    +    description
    +}
    +
    +async fn describe(ctx: &OperationContext, operation_arn: &str) -> Result {
    +    call(
    +        ctx,
    +        "DescribeBulkOperation",
    +        json!({ "BulkOperationArn": operation_arn }),
    +    )
    +    .await
    +}
    +
    +async fn cancel(ctx: &OperationContext, operation_arn: &str) -> Result {
    +    call(
    +        ctx,
    +        "CancelBulkOperation",
    +        json!({ "BulkOperationArn": operation_arn }),
    +    )
    +    .await
    +}
    +
    +fn arn_of(account: &str, operation_id: &str) -> String {
    +    format!("arn:aws:dynamodb:{REGION}:{account}:bulk-operation/{operation_id}")
    +}
    +
    +fn description_arn(description: &Value) -> String {
    +    description["BulkOperationArn"]
    +        .as_str()
    +        .expect("the description carries an ARN")
    +        .to_owned()
    +}
    +
    +fn summaries(response: &Value) -> Vec {
    +    response["BulkOperationsSummaryList"]
    +        .as_array()
    +        .expect("a summary list")
    +        .clone()
    +}
    +
    +fn summary_arns(response: &Value) -> Vec {
    +    summaries(response)
    +        .iter()
    +        .map(|s| s["BulkOperationArn"].as_str().unwrap().to_owned())
    +        .collect()
    +}
    +
    +/// The model constrains `NextToken` to `([0-9a-f]{16})+`, and the service
    +/// refuses a token under 112 characters, so an issued token is at least 112
    +/// characters, a multiple of 16, and lowercase hex.
    +fn assert_token_shape(token: &str) {
    +    assert!(
    +        token.len() >= 112 && token.len().is_multiple_of(16),
    +        "an issued token must be at least 112 characters and a multiple of 16: {token}"
    +    );
    +    assert!(
    +        token
    +            .bytes()
    +            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
    +        "token must be lowercase hex: {token}"
    +    );
    +}
    +
    +// Error extraction.
    +
    +#[track_caller]
    +fn expect_validation(result: Result) -> String {
    +    match result {
    +        Err(DynamoDbError::ValidationException(message)) => message,
    +        Err(other) => panic!("expected ValidationException, got {other:?}"),
    +        Ok(body) => panic!("expected ValidationException, got success: {body}"),
    +    }
    +}
    +
    +#[track_caller]
    +fn expect_throttling(result: Result) -> String {
    +    match result {
    +        Err(DynamoDbError::ThrottlingException(message)) => message,
    +        Err(other) => panic!("expected ThrottlingException, got {other:?}"),
    +        Ok(body) => panic!("expected ThrottlingException, got success: {body}"),
    +    }
    +}
    +
    +#[track_caller]
    +fn expect_not_found(result: Result) -> String {
    +    match result {
    +        Err(DynamoDbError::ResourceNotFoundException(message)) => message,
    +        Err(other) => panic!("expected ResourceNotFoundException, got {other:?}"),
    +        Ok(body) => panic!("expected ResourceNotFoundException, got success: {body}"),
    +    }
    +}
    +
    +#[track_caller]
    +fn expect_in_use(result: Result) -> String {
    +    match result {
    +        Err(DynamoDbError::ResourceInUseException(message)) => message,
    +        Err(other) => panic!("expected ResourceInUseException, got {other:?}"),
    +        Ok(body) => panic!("expected ResourceInUseException, got success: {body}"),
    +    }
    +}
    +
    +#[track_caller]
    +fn expect_limit_exceeded(result: Result) -> String {
    +    match result {
    +        Err(DynamoDbError::LimitExceededException(message)) => message,
    +        Err(other) => panic!("expected LimitExceededException, got {other:?}"),
    +        Ok(body) => panic!("expected LimitExceededException, got success: {body}"),
    +    }
    +}
    +
    +// Storage-level setup for List, Cancel, and foreign-account cases.
    +
    +/// The id of table `raw{index}` of `account`, created on first use: the
    +/// start transaction refuses a table whose row is absent.
    +async fn raw_table(h: &Harness, account: &str, index: u32) -> String {
    +    let name = format!("raw{index}");
    +    let input: extenddb_core::types::CreateTableInput = serde_json::from_value(json!({
    +        "TableName": name,
    +        "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
    +        "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
    +        "BillingMode": "PAY_PER_REQUEST"
    +    }))
    +    .expect("create table input");
    +    match h.storage.create_table(account, input).await {
    +        Ok(description) => description.table_id,
    +        Err(extenddb_storage::error::StorageError::TableAlreadyExists(_)) => {
    +            h.storage
    +                .describe_table(
    +                    account,
    +                    extenddb_core::types::DescribeTableInput { table_name: name },
    +                )
    +                .await
    +                .expect("describe the raw table")
    +                .table_id
    +        }
    +        Err(error) => panic!("create the raw table: {error:?}"),
    +    }
    +}
    +
    +/// Insert one operation record directly, with a controlled start time and a
    +/// table of its own, so list ordering and terminal transitions can be
    +/// staged without a runner.
    +async fn raw_op(h: &Harness, account: &str, index: u32, start_time_ms: i64) -> String {
    +    let operation_id = arn::new_operation_id(u64::try_from(start_time_ms).unwrap(), index);
    +    let table_id = raw_table(h, account, index).await;
    +    let outcome = h
    +        .storage
    +        .start_bulk_operation(
    +            account,
    +            NewBulkOperation {
    +                operation_id: operation_id.clone(),
    +                table_id,
    +                table_name: format!("raw{index}"),
    +                table_arn: format!("arn:aws:dynamodb:{REGION}:{account}:table/raw{index}"),
    +                client_token: None,
    +                client_token_hash: None,
    +                request_fingerprint: format!("fp-{account}-{index}-{start_time_ms}"),
    +                input_config: json!({}),
    +                output_config: json!({}),
    +                input_files: vec![],
    +                max_write_request_units: None,
    +                performance_tier: None,
    +                start_time_ms,
    +                total_input_bytes: Some(0),
    +                initial_failure: None,
    +                run_context: None,
    +            },
    +            10_000,
    +        )
    +        .await
    +        .expect("raw start");
    +    match outcome {
    +        StartOutcome::Created(record) => record.operation_id,
    +        _ => panic!("the raw start must create the operation"),
    +    }
    +}
    +
    +async fn finish_op(h: &Harness, account: &str, owner: &str, operation_id: &str, status: &str) {
    +    let state = h
    +        .storage
    +        .finish(
    +            account,
    +            operation_id,
    +            owner,
    +            Terminal {
    +                status: status.to_owned(),
    +                end_time_ms: now_ms(),
    +                failure_code: None,
    +                failure_message: None,
    +                usage: json!({}),
    +            },
    +        )
    +        .await
    +        .expect("finish");
    +    assert!(
    +        matches!(state, FinishState::Held(_)),
    +        "the finisher must still hold the lease"
    +    );
    +}
    +
    +fn runner_config() -> RunnerConfig {
    +    RunnerConfig {
    +        batch_size: 25,
    +        write_batch_rows: 25,
    +        max_line_bytes: 4 << 20,
    +        max_item_count: 1_000_000,
    +        max_report_records: 10_000,
    +        max_report_bytes: 64 << 20,
    +        lease: Duration::from_secs(60),
    +    }
    +}
    +
    +fn stored_input_config() -> Value {
    +    json!({
    +        "S3Bucket": IN_BUCKET,
    +        "InputFormat": "DYNAMODB_JSON",
    +        "InputCompressionType": "NONE",
    +    })
    +}
    +
    +fn stored_output_config() -> Value {
    +    json!({
    +        "S3Bucket": OUT_BUCKET,
    +        "S3KeyPrefix": "run",
    +        "OutputType": ["ERROR", "CONDITION_CHECK_FAILURE"],
    +    })
    +}
    +
    +mod cancel;
    +mod describe;
    +mod dispatch;
    +mod list;
    +mod log_targets;
    +mod logs;
    +mod run_context;
    +mod s3_permissions;
    +mod start_envelope;
    +mod start_fields;
    +mod start_locations;
    +mod start_outcomes;
    +mod start_prefix_forms;
    +mod start_prefix_rules;
    +mod start_replay;
    +mod start_store_faults;
    +mod start_terminal_born;
    +mod start_validation;
    +mod unsupported_backend;
    +mod update_table_guard;
    +mod validation;
    diff --git a/crates/engine/src/bulk_put/tests/handlers/run_context.rs b/crates/engine/src/bulk_put/tests/handlers/run_context.rs
    new file mode 100644
    index 000000000..d88e79c9d
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/run_context.rs
    @@ -0,0 +1,63 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// The run context Start records on the operation for its runner: the store
    +// the operation started on. No response renders it.
    +
    +async fn stored_run_context(h: &Harness, description: &Value) -> Option {
    +    let operation_arn = description_arn(description);
    +    let id = operation_arn
    +        .rsplit('/')
    +        .next()
    +        .expect("the ARN ends with the operation id");
    +    h.storage
    +        .get_bulk_operation(ACCOUNT, id)
    +        .await
    +        .expect("get bulk operation")
    +        .expect("the operation exists")
    +        .run_context
    +}
    +
    +#[tokio::test]
    +async fn start_records_the_store_and_no_response_renders_it() {
    +    let h = Harness::new().await;
    +    h.write_input("part-0.json", &file_of(&plain_rows(1)));
    +    let ctx = base_ctx(&h);
    +    let description = start_ok(&ctx, start_body()).await;
    +    assert_eq!(
    +        stored_run_context(&h, &description).await,
    +        Some(json!({"store": "filesystem"}))
    +    );
    +
    +    let operation_arn = description_arn(&description);
    +    let described = describe(&ctx, &operation_arn).await.expect("describe");
    +    let listed = call(&ctx, "ListBulkOperations", json!({}))
    +        .await
    +        .expect("list");
    +    for (what, body) in [
    +        ("start", &description),
    +        ("describe", &described),
    +        ("list", &listed),
    +    ] {
    +        let text = body.to_string();
    +        assert!(
    +            !text.contains("filesystem") && !text.to_lowercase().contains("context"),
    +            "the {what} response carries no run context: {text}"
    +        );
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_terminal_born_start_records_the_store_too() {
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    let body = with_input(start_body(), "S3Bucket", json!("absent-loads"));
    +    let description = start_ok(&ctx, body).await;
    +    assert_eq!(description["BulkOperationStatus"], json!("FAILED"));
    +    assert_eq!(
    +        stored_run_context(&h, &description).await,
    +        Some(json!({"store": "filesystem"}))
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/tests/handlers/s3_permissions.rs b/crates/engine/src/bulk_put/tests/handlers/s3_permissions.rs
    new file mode 100644
    index 000000000..e19582d4e
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/s3_permissions.rs
    @@ -0,0 +1,146 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +use crate::bulk_put::authorize::S3Action;
    +use crate::bulk_put::tests::runner::{FakeAuthorizer, caller, denied};
    +
    +// The caller's s3:ListBucket on the input bucket, decided at Start before the
    +// store's own bucket check (measured on the service: a bucket policy denying
    +// the listing fails the operation S3AccessDenied with no byte members and no
    +// manifest). Here the row is born terminal.
    +
    +const BUCKET_ARN: &str = "arn:aws:s3:::loads";
    +
    +fn checked_ctx(h: &Harness, authorizer: &Arc) -> OperationContext {
    +    let mut ctx = base_ctx(h);
    +    ctx.bulk_authorizer = Some(Arc::clone(authorizer) as Arc);
    +    ctx.bulk_principal = Some(caller());
    +    ctx
    +}
    +
    +async fn stored(h: &Harness, description: &Value) -> extenddb_storage::BulkOperationRecord {
    +    let operation_arn = description_arn(description);
    +    let id = operation_arn.rsplit('/').next().expect("an operation id");
    +    h.storage
    +        .get_bulk_operation(ACCOUNT, id)
    +        .await
    +        .expect("get bulk operation")
    +        .expect("the operation exists")
    +}
    +
    +#[tokio::test]
    +async fn a_denied_listing_is_born_failed_with_no_byte_members() {
    +    let h = Harness::new().await;
    +    h.write_input("part-0.json", &file_of(&plain_rows(2)));
    +    let fake = Arc::new(FakeAuthorizer::denying(S3Action::ListBucket, BUCKET_ARN));
    +    let description = start_ok(&checked_ctx(&h, &fake), start_body()).await;
    +
    +    assert_eq!(description["BulkOperationStatus"], json!("FAILED"));
    +    assert_eq!(description["FailureCode"], json!("S3AccessDenied"));
    +    assert_eq!(
    +        description["FailureMessage"],
    +        json!(denied("s3:ListBucket", BUCKET_ARN)),
    +        "S3's spelling: the bucket ARN quoted, no prefix, no tail"
    +    );
    +    assert!(description.get("TotalInputBytes").is_none());
    +    assert!(description.get("ProcessedInputBytes").is_none());
    +    assert_eq!(
    +        fake.checks(),
    +        vec![(
    +            S3Action::ListBucket,
    +            BUCKET_ARN.to_owned(),
    +            Some(String::new())
    +        )],
    +        "an absent S3KeyPrefix is the empty s3:prefix"
    +    );
    +    let record = stored(&h, &description).await;
    +    assert!(record.input_files.is_empty(), "nothing is enumerated");
    +}
    +
    +#[tokio::test]
    +async fn the_listing_check_carries_the_request_prefix() {
    +    let h = Harness::new().await;
    +    h.write_input("orders/part-0.json", &file_of(&plain_rows(1)));
    +    let fake = Arc::new(FakeAuthorizer::default());
    +    let body = with_input(start_body(), "S3KeyPrefix", json!("orders/"));
    +    let description = start_ok(&checked_ctx(&h, &fake), body).await;
    +    assert_eq!(description["BulkOperationStatus"], json!("IN_PROGRESS"));
    +    assert_eq!(
    +        fake.checks(),
    +        vec![(
    +            S3Action::ListBucket,
    +            BUCKET_ARN.to_owned(),
    +            Some("orders/".to_owned())
    +        )]
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_allowed_start_records_the_caller_for_the_runner() {
    +    let h = Harness::new().await;
    +    h.write_input("part-0.json", &file_of(&plain_rows(1)));
    +    let fake = Arc::new(FakeAuthorizer::default());
    +    let description = start_ok(&checked_ctx(&h, &fake), start_body()).await;
    +    let record = stored(&h, &description).await;
    +    assert_eq!(
    +        record.run_context,
    +        Some(json!({
    +            "store": "filesystem",
    +            "principal": serde_json::to_value(caller()).unwrap(),
    +        }))
    +    );
    +    assert!(
    +        !description.to_string().contains("principal"),
    +        "the response carries no caller"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn the_listing_is_decided_before_the_owner_rule() {
    +    let h = Harness::new().await;
    +    let fake = Arc::new(FakeAuthorizer::denying(S3Action::ListBucket, BUCKET_ARN));
    +    let body = with_input(start_body(), "S3BucketOwner", json!(OTHER_ACCOUNT));
    +    let description = start_ok(&checked_ctx(&h, &fake), body).await;
    +    assert_eq!(description["FailureCode"], json!("S3AccessDenied"));
    +    assert_eq!(
    +        description["FailureMessage"],
    +        json!(denied("s3:ListBucket", BUCKET_ARN))
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_authorizer_with_no_caller_fails_closed_at_start() {
    +    let h = Harness::new().await;
    +    h.write_input("part-0.json", &file_of(&plain_rows(1)));
    +    let fake = Arc::new(FakeAuthorizer::default());
    +    let mut ctx = checked_ctx(&h, &fake);
    +    ctx.bulk_principal = None;
    +    let description = start_ok(&ctx, start_body()).await;
    +    assert_eq!(description["BulkOperationStatus"], json!("FAILED"));
    +    assert_eq!(description["FailureCode"], json!("S3AccessDenied"));
    +    assert_eq!(description["FailureMessage"], json!("Access Denied"));
    +    assert!(fake.checks().is_empty());
    +}
    +
    +#[tokio::test]
    +async fn a_listing_that_cannot_be_evaluated_creates_no_operation() {
    +    let h = Harness::new().await;
    +    h.write_input("part-0.json", &file_of(&plain_rows(1)));
    +    let fake = Arc::new(FakeAuthorizer {
    +        unavailable: true,
    +        ..FakeAuthorizer::default()
    +    });
    +    let ctx = checked_ctx(&h, &fake);
    +    match start(&ctx, start_body()).await {
    +        Err(DynamoDbError::InternalServerError(message)) => {
    +            assert_eq!(message, "Internal server error");
    +        }
    +        other => panic!("expected InternalServerError, got {other:?}"),
    +    }
    +    let listed = call(&ctx, "ListBulkOperations", json!({}))
    +        .await
    +        .expect("list");
    +    assert_eq!(listed["BulkOperationsSummaryList"], json!([]));
    +}
    diff --git a/crates/engine/src/bulk_put/tests/handlers/start_envelope.rs b/crates/engine/src/bulk_put/tests/handlers/start_envelope.rs
    new file mode 100644
    index 000000000..e0f0c6124
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/start_envelope.rs
    @@ -0,0 +1,288 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The validation envelope of `StartBulkPutItems`: the tableName pass on
    +//! its own, then every model constraint of the other members in one
    +//! envelope, in the service's member order, at most ten clauses. Every
    +//! expected message is a measured service answer.
    +
    +use super::*;
    +
    +const BUCKET_PATTERN: &str = "^[a-z0-9A-Z]+[\\.\\-\\w]*[a-z0-9A-Z]+$";
    +
    +fn bucket_clause(value: &str, path: &str) -> String {
    +    format!(
    +        "Value '{value}' at '{path}' failed to satisfy constraint: Member must satisfy regular \
    +         expression pattern: {BUCKET_PATTERN}"
    +    )
    +}
    +
    +fn enum_clause(value: &str, path: &str, set: &str) -> String {
    +    format!(
    +        "Value '{value}' at '{path}' failed to satisfy constraint: Member must satisfy enum \
    +         value set: {set}"
    +    )
    +}
    +
    +fn compression_clause() -> String {
    +    enum_clause(
    +        "BZIP2",
    +        "inputS3Configuration.inputCompressionType",
    +        "[ZSTD, NONE, GZIP]",
    +    )
    +}
    +
    +fn format_clause() -> String {
    +    enum_clause(
    +        "CSV",
    +        "inputS3Configuration.inputFormat",
    +        "[ION, DYNAMODB_JSON]",
    +    )
    +}
    +
    +fn tier_clause() -> String {
    +    enum_clause("NO_SUCH", "performanceTier", "[ACCELERATED, STANDARD]")
    +}
    +
    +fn envelope(clauses: &[String]) -> String {
    +    let count = clauses.len();
    +    format!(
    +        "{count} validation error{} detected: {}",
    +        if count == 1 { "" } else { "s" },
    +        clauses.join("; ")
    +    )
    +}
    +
    +async fn answer(body: Value) -> String {
    +    let h = Harness::new().await;
    +    expect_validation(start(&base_ctx(&h), body).await)
    +}
    +
    +#[tokio::test]
    +async fn two_input_faults_answer_one_envelope() {
    +    // Measured: InputFormat CSV beside a bucket `b`.
    +    let body = with_input(
    +        with_input(start_body(), "InputFormat", json!("CSV")),
    +        "S3Bucket",
    +        json!("b"),
    +    );
    +    assert_eq!(
    +        answer(body).await,
    +        envelope(&[
    +            format_clause(),
    +            bucket_clause("b", "inputS3Configuration.s3Bucket")
    +        ])
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_output_type_fault_and_an_output_bucket_fault_answer_one_envelope() {
    +    // Measured: OutputType [EVERYTHING] beside an output bucket `b`.
    +    let body = with_output(
    +        with_output(start_body(), "OutputType", json!(["EVERYTHING"])),
    +        "S3Bucket",
    +        json!("b"),
    +    );
    +    assert_eq!(
    +        answer(body).await,
    +        envelope(&[
    +            "Value '[EVERYTHING]' at 'outputS3Configuration.outputType' failed to satisfy \
    +             constraint: Member must satisfy constraint: [Member must satisfy enum value set: \
    +             [ERROR, CONDITION_CHECK_FAILURE]]"
    +                .to_owned(),
    +            bucket_clause("b", "outputS3Configuration.s3Bucket"),
    +        ])
    +    );
    +}
    +
    +#[tokio::test]
    +async fn input_clauses_follow_the_measured_member_order() {
    +    // Measured: a bucket `b`, a prefix of 1,025 characters and InputFormat
    +    // CSV beside MaxWriteRequestUnits 1, and the same with BZIP2.
    +    let prefix = "x".repeat(1025);
    +    let body = with_top(
    +        with_input(
    +            with_input(
    +                with_input(start_body(), "S3Bucket", json!("b")),
    +                "S3KeyPrefix",
    +                json!(prefix),
    +            ),
    +            "InputFormat",
    +            json!("CSV"),
    +        ),
    +        "MaxWriteRequestUnits",
    +        json!(1),
    +    );
    +    let body = with_input(body, "InputCompressionType", json!("BZIP2"));
    +    assert_eq!(
    +        answer(body).await,
    +        envelope(&[
    +            compression_clause(),
    +            format_clause(),
    +            format!(
    +                "Value '{prefix}' at 'inputS3Configuration.s3KeyPrefix' failed to satisfy \
    +                 constraint: Member must have length less than or equal to 1024"
    +            ),
    +            bucket_clause("b", "inputS3Configuration.s3Bucket"),
    +            "Value '1' at 'maxWriteRequestUnits' failed to satisfy constraint: Member must have \
    +             value greater than or equal to 1024"
    +                .to_owned(),
    +        ])
    +    );
    +}
    +
    +#[tokio::test]
    +async fn output_and_top_level_clauses_come_before_the_input_clauses() {
    +    // Measured: an output bucket `b` before an input fault; PerformanceTier
    +    // before an input fault; ClientToken between PerformanceTier and the
    +    // input members.
    +    let body = with_output(
    +        with_input(start_body(), "InputCompressionType", json!("BZIP2")),
    +        "S3Bucket",
    +        json!("b"),
    +    );
    +    assert_eq!(
    +        answer(body).await,
    +        envelope(&[
    +            bucket_clause("b", "outputS3Configuration.s3Bucket"),
    +            compression_clause()
    +        ])
    +    );
    +    let body = with_top(
    +        with_top(
    +            with_output(start_body(), "S3Bucket", json!("b")),
    +            "ClientToken",
    +            json!("a$b"),
    +        ),
    +        "PerformanceTier",
    +        json!("NO_SUCH"),
    +    );
    +    assert_eq!(
    +        answer(body).await,
    +        envelope(&[
    +            bucket_clause("b", "outputS3Configuration.s3Bucket"),
    +            tier_clause(),
    +            "Value 'a$b' at 'clientToken' failed to satisfy constraint: Member must satisfy \
    +             regular expression pattern: ^[^\\$]+$"
    +                .to_owned(),
    +        ])
    +    );
    +    let body = with_output(
    +        with_output(start_body(), "S3SseAlgorithm", json!("XX")),
    +        "S3Bucket",
    +        json!("b"),
    +    );
    +    assert_eq!(
    +        answer(body).await,
    +        envelope(&[
    +            enum_clause(
    +                "XX",
    +                "outputS3Configuration.s3SseAlgorithm",
    +                "[AES256, KMS]"
    +            ),
    +            bucket_clause("b", "outputS3Configuration.s3Bucket"),
    +        ])
    +    );
    +}
    +
    +#[tokio::test]
    +async fn thirteen_faults_answer_the_first_ten() {
    +    // Measured: thirteen member faults answer `10 validation errors
    +    // detected` with the first ten clauses in member order.
    +    let mut body = start_body();
    +    body["InputS3Configuration"] = json!({
    +        "S3Bucket": "b", "S3KeyPrefix": "x".repeat(1025), "InputFormat": "CSV",
    +        "InputCompressionType": "BZIP2", "S3BucketOwner": "abc"
    +    });
    +    body["OutputS3Configuration"] = json!({
    +        "S3Bucket": "c", "S3KeyPrefix": "y".repeat(1025), "OutputType": ["NOPE"],
    +        "S3BucketOwner": "def", "S3SseAlgorithm": "XX", "S3SseKmsKeyId": "k".repeat(2049)
    +    });
    +    body["PerformanceTier"] = json!("NO_SUCH");
    +    body["MaxWriteRequestUnits"] = json!(1);
    +    let message = answer(body).await;
    +    assert!(
    +        message.starts_with("10 validation errors detected: "),
    +        "{message}"
    +    );
    +    let paths: Vec<&str> = message
    +        .split("' at '")
    +        .skip(1)
    +        .map(|rest| rest.split('\'').next().unwrap_or_default())
    +        .collect();
    +    assert_eq!(
    +        paths,
    +        [
    +            "outputS3Configuration.s3SseAlgorithm",
    +            "outputS3Configuration.outputType",
    +            "outputS3Configuration.s3BucketOwner",
    +            "outputS3Configuration.s3KeyPrefix",
    +            "outputS3Configuration.s3Bucket",
    +            "outputS3Configuration.s3SseKmsKeyId",
    +            "performanceTier",
    +            "inputS3Configuration.inputCompressionType",
    +            "inputS3Configuration.s3BucketOwner",
    +            "inputS3Configuration.inputFormat",
    +        ]
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_bad_table_name_is_answered_alone() {
    +    // Measured: the tableName pass answers before the configuration, so
    +    // the enum and bucket faults beside it raise no clause.
    +    let faulted = with_input(
    +        with_input(start_body(), "InputCompressionType", json!("BZIP2")),
    +        "S3Bucket",
    +        json!("b"),
    +    );
    +    assert_eq!(
    +        answer(with_top(faulted.clone(), "TableName", json!("a*"))).await,
    +        envelope(&[
    +            "Value 'a*' at 'tableName' failed to satisfy constraint: Member must have length \
    +             greater than or equal to 3"
    +                .to_owned(),
    +            "Value 'a*' at 'tableName' failed to satisfy constraint: Member must satisfy \
    +             regular expression pattern: [a-zA-Z0-9_.-]+"
    +                .to_owned(),
    +        ])
    +    );
    +    let own = format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/ab");
    +    assert_eq!(
    +        answer(with_top(faulted.clone(), "TableName", json!(own))).await,
    +        format!(
    +            "1 validation error detected: Value '{own}' at 'tableName' failed to satisfy \
    +             constraint: Table name of ARN must have length greater than or equal to 3"
    +        )
    +    );
    +    let index = format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/x/index/gsi");
    +    let message = answer(with_top(faulted, "TableName", json!(index))).await;
    +    assert!(
    +        message.starts_with(&format!(
    +            "1 validation error detected: Value '{index}' at 'tableName' failed to satisfy \
    +             constraint: Valid ARN format is"
    +        )),
    +        "{message}"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_foreign_account_table_arn_is_denied_before_the_configuration() {
    +    // Measured: Access is denied beside a bad compression type, and beside
    +    // a name that breaks the rule.
    +    let h = Harness::new().await;
    +    for name in ["xyz", "ab"] {
    +        let arn = format!("arn:aws:dynamodb:{REGION}:{OTHER_ACCOUNT}:table/{name}");
    +        let body = with_top(
    +            with_input(start_body(), "InputCompressionType", json!("BZIP2")),
    +            "TableName",
    +            json!(arn),
    +        );
    +        match start(&base_ctx(&h), body).await {
    +            Err(DynamoDbError::AccessDeniedException(message)) => {
    +                assert_eq!(message, "Access is denied");
    +            }
    +            other => panic!("expected AccessDeniedException, got {other:?}"),
    +        }
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/tests/handlers/start_fields.rs b/crates/engine/src/bulk_put/tests/handlers/start_fields.rs
    new file mode 100644
    index 000000000..9cf9aadab
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/start_fields.rs
    @@ -0,0 +1,381 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +#[tokio::test]
    +async fn an_input_bucket_owner_of_another_account_starts_the_operation_terminal_failed() {
    +    // Measured: the mismatch fails the operation, not the request. The
    +    // token is consumed like any other terminal-born start.
    +    let h = Harness::new().await;
    +    let body = with_input(
    +        with_top(start_body(), "ClientToken", json!("tok-owner-mismatch")),
    +        "S3BucketOwner",
    +        json!(OTHER_ACCOUNT),
    +    );
    +    let description = start_ok(&base_ctx(&h), body.clone()).await;
    +    assert_eq!(description["BulkOperationStatus"], json!("FAILED"));
    +    assert_eq!(description["FailureCode"], json!("S3AccessDenied"));
    +    assert_eq!(description["FailureMessage"], json!("Access Denied"));
    +    assert_eq!(description["ProcessedItemCount"], json!(0));
    +    assert_eq!(description["EndTime"], description["StartTime"]);
    +    let replay = start_ok(&base_ctx(&h), body).await;
    +    assert_eq!(
    +        replay["BulkOperationArn"], description["BulkOperationArn"],
    +        "the token is consumed and replays to the failed operation"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_input_owner_mismatch_answers_before_the_bucket_existence_check() {
    +    // Ownership is decided before existence, so a caller naming another
    +    // owner cannot learn whether the bucket exists: a missing bucket with
    +    // a mismatched owner answers S3AccessDenied, not S3NoSuchBucket.
    +    let h = Harness::new().await;
    +    let body = with_input(
    +        with_input(start_body(), "S3BucketOwner", json!(OTHER_ACCOUNT)),
    +        "S3Bucket",
    +        json!("absent"),
    +    );
    +    let description = start_ok(&base_ctx(&h), body).await;
    +    assert_eq!(description["BulkOperationStatus"], json!("FAILED"));
    +    assert_eq!(description["FailureCode"], json!("S3AccessDenied"));
    +}
    +
    +#[tokio::test]
    +async fn an_output_bucket_owner_of_another_account_is_not_checked_at_start() {
    +    // The output configuration is checked at the first report record, not
    +    // at Start (measured: a mismatched output owner with clean rows
    +    // completes).
    +    let h = Harness::new().await;
    +    let body = with_output(start_body(), "S3BucketOwner", json!(OTHER_ACCOUNT));
    +    let description = start_ok(&base_ctx(&h), body).await;
    +    assert_eq!(description["BulkOperationStatus"], json!("IN_PROGRESS"));
    +}
    +
    +#[tokio::test]
    +async fn the_description_carries_the_table_name_and_id_in_table_details() {
    +    // Measured on the service: the Start response and every
    +    // Describe carry TableDetails as a one-entry list of the bare table
    +    // name and the table id.
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    let description = start_ok(&ctx, start_body()).await;
    +    let expected = json!([{ "TableName": TABLE, "TableId": h.table_id }]);
    +    assert_eq!(description["TableDetails"], expected);
    +    let described = describe(&ctx, &description_arn(&description))
    +        .await
    +        .expect("the operation describes");
    +    assert_eq!(
    +        described["BulkOperationDescription"]["TableDetails"], expected,
    +        "every Describe carries the same entry"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_absent_bucket_owner_is_echoed_as_the_callers_account() {
    +    // Measured on the service: with S3BucketOwner omitted on both
    +    // configurations, the description echoes the caller's account id on
    +    // both, and an absent InputCompressionType is echoed as NONE.
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    let mut body = start_body();
    +    body["InputS3Configuration"]
    +        .as_object_mut()
    +        .unwrap()
    +        .remove("InputCompressionType");
    +    let description = start_ok(&ctx, body).await;
    +    assert_eq!(
    +        description["InputS3Configuration"]["S3BucketOwner"],
    +        json!(ACCOUNT)
    +    );
    +    assert_eq!(
    +        description["OutputS3Configuration"]["S3BucketOwner"],
    +        json!(ACCOUNT)
    +    );
    +    assert_eq!(
    +        description["InputS3Configuration"]["InputCompressionType"],
    +        json!("NONE")
    +    );
    +    // The resolved values are stored, so a later Describe renders them
    +    // without re-deriving anything.
    +    let described = describe(&ctx, &description_arn(&description))
    +        .await
    +        .expect("the operation describes");
    +    let described = &described["BulkOperationDescription"];
    +    assert_eq!(
    +        described["InputS3Configuration"]["S3BucketOwner"],
    +        json!(ACCOUNT)
    +    );
    +    assert_eq!(
    +        described["OutputS3Configuration"]["S3BucketOwner"],
    +        json!(ACCOUNT)
    +    );
    +    assert_eq!(
    +        described["InputS3Configuration"]["InputCompressionType"],
    +        json!("NONE")
    +    );
    +}
    +
    +#[tokio::test]
    +async fn supplying_the_own_account_bucket_owner_replays_the_request_that_omitted_it() {
    +    // The own-account S3BucketOwner is a service default a retry may add
    +    // without changing the operation (measured), so the fingerprint
    +    // normalizes it to absent and the replay returns the existing
    +    // operation rather than a parameter mismatch.
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    let token = "owner-resolution";
    +    let first = with_top(start_body(), "ClientToken", json!(token));
    +    let first_desc = start_ok(&ctx, first).await;
    +    let second = with_input(
    +        with_top(start_body(), "ClientToken", json!(token)),
    +        "S3BucketOwner",
    +        json!(ACCOUNT),
    +    );
    +    let replayed = start_ok(&ctx, second).await;
    +    assert_eq!(description_arn(&replayed), description_arn(&first_desc));
    +}
    +
    +#[tokio::test]
    +async fn an_oversized_stored_configuration_is_rejected() {
    +    let h = Harness::new().await;
    +    let body = with_output(start_body(), "OutputType", json!(vec!["ERROR"; 1200]));
    +    assert_eq!(
    +        expect_validation(start(&base_ctx(&h), body).await),
    +        CONFIG_TOO_BIG
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_bare_table_name_is_accepted() {
    +    let h = Harness::new().await;
    +    let description = start_ok(&base_ctx(&h), start_body()).await;
    +    assert_eq!(description["TableDetails"][0]["TableName"], json!(TABLE));
    +}
    +
    +#[tokio::test]
    +async fn a_matching_table_arn_is_accepted() {
    +    let h = Harness::new().await;
    +    let body = with_top(start_body(), "TableName", json!(h.table_arn.clone()));
    +    let description = start_ok(&base_ctx(&h), body).await;
    +    assert_eq!(description["TableDetails"][0]["TableName"], json!(TABLE));
    +}
    +
    +#[tokio::test]
    +async fn a_foreign_account_table_arn_is_denied() {
    +    // Measured: the answer is the denial, not a validation message, and
    +    // it is decided before the table is looked up.
    +    let h = Harness::new().await;
    +    let foreign = format!("arn:aws:dynamodb:{REGION}:{OTHER_ACCOUNT}:table/{TABLE}");
    +    let body = with_top(start_body(), "TableName", json!(foreign.clone()));
    +    match start(&base_ctx(&h), body).await {
    +        Err(DynamoDbError::AccessDeniedException(message)) => {
    +            assert_eq!(message, "Access is denied");
    +        }
    +        other => panic!("expected AccessDeniedException, got {other:?}"),
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_foreign_region_table_arn_fails_the_region_validation() {
    +    // Measured: the caller's own table ARN respelled to another region is
    +    // a validation failure at step 2.
    +    let h = Harness::new().await;
    +    let foreign = format!("arn:aws:dynamodb:eu-west-1:{ACCOUNT}:table/{TABLE}");
    +    let body = with_top(start_body(), "TableName", json!(foreign.clone()));
    +    assert_eq!(
    +        expect_validation(start(&base_ctx(&h), body).await),
    +        format!("1 validation error detected: Invalid AWS region in '{foreign}'")
    +    );
    +}
    +
    +#[tokio::test]
    +async fn ion_input_format_is_refused() {
    +    let h = Harness::new().await;
    +    let body = with_input(start_body(), "InputFormat", json!("ION"));
    +    assert_eq!(
    +        expect_validation(start(&base_ctx(&h), body).await),
    +        ION_REFUSED
    +    );
    +}
    +
    +/// `InputCompressionType` ZSTD is accepted and echoed, as GZIP is (measured
    +/// on the service: a zstd input is read and its compressed size is
    +/// `TotalInputBytes`).
    +#[tokio::test]
    +async fn zstd_compression_is_accepted_and_echoed() {
    +    let h = Harness::new().await;
    +    let body = with_input(start_body(), "InputCompressionType", json!("ZSTD"));
    +    let description = start_ok(&base_ctx(&h), body).await;
    +    assert_eq!(
    +        description["InputS3Configuration"]["InputCompressionType"],
    +        json!("ZSTD")
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_missing_store_answers_the_disabled_message() {
    +    let h = Harness::new().await;
    +    assert_eq!(
    +        expect_validation(start(&ctx_without_store(&h), start_body()).await),
    +        BULK_DISABLED
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_missing_table_answers_the_table_not_found_error() {
    +    let h = Harness::new().await;
    +    let body = with_top(start_body(), "TableName", json!("absent"));
    +    assert_eq!(
    +        expect_not_found(start(&base_ctx(&h), body).await),
    +        format!("{TABLE_NOT_FOUND}: Table: absent not found")
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_table_that_is_not_active_answers_the_in_use_error() {
    +    let h = Harness::new().await;
    +    // A non-zero control-plane delay leaves the new table CREATING, and no
    +    // control-plane worker runs in these tests to mature it.
    +    h.catalog
    +        .set_setting("control_plane_delay_seconds", "3600")
    +        .await
    +        .expect("set delay");
    +    Harness::create_table(&h.storage, "pending").await;
    +    h.catalog
    +        .set_setting("control_plane_delay_seconds", "0")
    +        .await
    +        .expect("reset delay");
    +    let body = with_top(start_body(), "TableName", json!("pending"));
    +    assert_eq!(
    +        expect_in_use(start(&base_ctx(&h), body).await),
    +        "Table is being created, updated, or deleted: pending"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_table_being_deleted_answers_the_table_not_found_validation() {
    +    // Measured on the service: a start against a table in DELETING
    +    // answers 400 ValidationException with this text, both immediately
    +    // after DeleteTable and three seconds later, not the in-use error a
    +    // CREATING table gets.
    +    let h = Harness::new().await;
    +    Harness::create_table(&h.storage, "going").await;
    +    // A delay of a second or more leaves the delete at DELETING with a
    +    // scheduled transition, and no control-plane worker runs here to
    +    // complete it.
    +    h.catalog
    +        .set_setting("control_plane_delay_seconds", "3600")
    +        .await
    +        .expect("set delay");
    +    let deleted = h
    +        .storage
    +        .delete_table(
    +            ACCOUNT,
    +            extenddb_core::types::DeleteTableInput {
    +                table_name: "going".to_owned(),
    +            },
    +        )
    +        .await
    +        .expect("delete table");
    +    h.catalog
    +        .set_setting("control_plane_delay_seconds", "0")
    +        .await
    +        .expect("reset delay");
    +    assert_eq!(
    +        deleted.table_status,
    +        extenddb_core::types::TableStatus::Deleting,
    +        "the delay must leave the table DELETING"
    +    );
    +    let body = with_top(start_body(), "TableName", json!("going"));
    +    assert_eq!(
    +        expect_validation(start(&base_ctx(&h), body).await),
    +        "Invalid Request: Table not found"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_input_over_the_object_count_cap_is_accepted_at_start() {
    +    // Measured on the service: the Start is accepted and the
    +    // operation ends FAILED with S3ObjectsCountLimitExceeded (the runner's
    +    // check); the walk stops one object past the cap, so the record
    +    // carries max plus one files for the runner to count.
    +    let h = Harness::new().await;
    +    for i in 0..5 {
    +        h.write_input(&format!("part-{i}.json"), b"0123456789");
    +    }
    +    let ctx = ctx_with_limits(&h, bulk_limits(&[("max_bulk_input_files", json!(2))]));
    +    let description = start_ok(&ctx, start_body()).await;
    +    assert_eq!(description["BulkOperationStatus"], json!("IN_PROGRESS"));
    +    let operation_id = description["BulkOperationArn"]
    +        .as_str()
    +        .and_then(|arn| arn.rsplit('/').next())
    +        .expect("operation id");
    +    let record = h
    +        .storage
    +        .get_bulk_operation(ACCOUNT, operation_id)
    +        .await
    +        .expect("get")
    +        .expect("record");
    +    assert_eq!(record.input_files.len(), 3, "the cap plus one");
    +}
    +
    +#[tokio::test]
    +async fn the_input_byte_cap_answers_the_byte_message() {
    +    let h = Harness::new().await;
    +    h.write_input("part-0.json", b"01234567");
    +    h.write_input("part-1.json", b"01234567");
    +    let ctx = ctx_with_limits(&h, bulk_limits(&[("max_bulk_input_bytes", json!(10))]));
    +    assert_eq!(
    +        expect_validation(start(&ctx, start_body()).await),
    +        "Input exceeds max_bulk_input_bytes (10 bytes)"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn the_byte_cap_still_refuses_when_the_count_cap_is_also_passed() {
    +    // The count cap is no longer a Start refusal, so with both passed the
    +    // enumerated total is what answers.
    +    let h = Harness::new().await;
    +    for i in 0..3 {
    +        h.write_input(&format!("part-{i}.json"), b"01234567");
    +    }
    +    let ctx = ctx_with_limits(
    +        &h,
    +        bulk_limits(&[
    +            ("max_bulk_input_files", json!(2)),
    +            ("max_bulk_input_bytes", json!(10)),
    +        ]),
    +    );
    +    assert_eq!(
    +        expect_validation(start(&ctx, start_body()).await),
    +        "Input exceeds max_bulk_input_bytes (10 bytes)"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn total_input_bytes_is_the_enumerated_sum() {
    +    let h = Harness::new().await;
    +    h.write_input("part-0.json", b"0123456789");
    +    h.write_input("part-1.json", b"01234567890123456789");
    +    let description = start_ok(&base_ctx(&h), start_body()).await;
    +    assert_eq!(description["TotalInputBytes"], json!(30));
    +}
    +
    +#[tokio::test]
    +async fn an_absent_performance_tier_echoes_standard() {
    +    let h = Harness::new().await;
    +    let description = start_ok(&base_ctx(&h), start_body()).await;
    +    assert_eq!(description["PerformanceTier"], json!("STANDARD"));
    +}
    +
    +#[tokio::test]
    +async fn an_empty_input_answers_in_progress_with_zero_total_bytes() {
    +    let h = Harness::new().await;
    +    // The bucket exists (the harness staged it) and is empty; enumeration
    +    // matches no files.
    +    let description = start_ok(&base_ctx(&h), start_body()).await;
    +    assert_eq!(description["BulkOperationStatus"], json!("IN_PROGRESS"));
    +    assert_eq!(description["TotalInputBytes"], json!(0));
    +}
    diff --git a/crates/engine/src/bulk_put/tests/handlers/start_locations.rs b/crates/engine/src/bulk_put/tests/handlers/start_locations.rs
    new file mode 100644
    index 000000000..7fdeaa738
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/start_locations.rs
    @@ -0,0 +1,309 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The S3 location rules the start handler applies: the bucket name
    +//! constraints on both configurations, the required output bucket, and the
    +//! key prefix rules. The validation step order and the scalar field
    +//! constraints are in `start_validation.rs`.
    +
    +use super::*;
    +
    +#[tokio::test]
    +async fn a_one_character_input_bucket_fails_the_pattern_not_a_length_bound() {
    +    let h = Harness::new().await;
    +    let body = with_input(start_body(), "S3Bucket", json!("a"));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert!(
    +        message.contains("Member must satisfy regular expression pattern"),
    +        "a one-character name fails the pattern: {message}"
    +    );
    +    assert!(
    +        !message.contains("greater than or equal to 3"),
    +        "the removed minimum length must not be reported: {message}"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_two_character_input_bucket_fails_the_service_minimum() {
    +    // Measured on the service: the model has no minimum, but the service refuses
    +    // a bucket under three characters after the pattern check, with the
    +    // `Invalid Request:` prefix. A one-character name answers the pattern.
    +    let h = Harness::new().await;
    +    let body = with_input(start_body(), "S3Bucket", json!("ab"));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert_eq!(
    +        message,
    +        "Invalid Request: 1 validation error detected: Value 'ab' at \
    +         'inputS3Configuration.s3Bucket' failed to satisfy constraint: Member must have \
    +         length greater than or equal to 3"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_output_configuration_without_a_bucket_is_refused_at_start() {
    +    // Measured on the service: an output configuration whose
    +    // OutputType is set with no S3Bucket answers this at Start. The
    +    // bucket's existence and its owner are still decided at the first
    +    // report record.
    +    let h = Harness::new().await;
    +    let mut body = start_body();
    +    body["OutputS3Configuration"]
    +        .as_object_mut()
    +        .unwrap()
    +        .remove("S3Bucket");
    +    assert_eq!(
    +        expect_validation(start(&base_ctx(&h), body).await),
    +        "Invalid Request: OutputS3Configuration.S3Bucket is invalid"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_output_bucket_without_a_key_prefix_is_accepted() {
    +    // Measured on the service: with the bucket present and no
    +    // S3KeyPrefix the start is accepted, and the report tree is written
    +    // at the bucket root.
    +    let h = Harness::new().await;
    +    let mut body = start_body();
    +    body["OutputS3Configuration"]
    +        .as_object_mut()
    +        .unwrap()
    +        .remove("S3KeyPrefix");
    +    let description = start_ok(&base_ctx(&h), body).await;
    +    assert_eq!(
    +        description["OutputS3Configuration"]["S3Bucket"],
    +        json!(OUT_BUCKET)
    +    );
    +    assert!(
    +        description["OutputS3Configuration"]
    +            .get("S3KeyPrefix")
    +            .is_none(),
    +        "an absent output prefix is not echoed: {description}"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_dotted_ip_input_bucket_is_refused() {
    +    // Measured on the service: `10.20.30.40` answers 400
    +    // ValidationException with this text. The literal passes the model
    +    // pattern and both length bounds, so nothing but this check can
    +    // refuse it.
    +    let h = Harness::new().await;
    +    let body = with_input(start_body(), "S3Bucket", json!("10.20.30.40"));
    +    assert_eq!(
    +        expect_validation(start(&base_ctx(&h), body).await),
    +        "Invalid Request: InputS3Configuration.S3Bucket is invalid"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_dotted_ip_output_bucket_is_refused() {
    +    // The output bucket shares the input bucket's constraints, so the
    +    // same literal is refused there, named by its own member. The IP case
    +    // on the output side is extrapolated from, not measured.
    +    let h = Harness::new().await;
    +    let body = with_output(start_body(), "S3Bucket", json!("10.20.30.40"));
    +    assert_eq!(
    +        expect_validation(start(&base_ctx(&h), body).await),
    +        "Invalid Request: OutputS3Configuration.S3Bucket is invalid"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_name_that_only_resembles_an_ip_literal_is_accepted() {
    +    // The refusal is for four dot-separated groups of digits. A name with
    +    // a non-digit group, one with five groups, and one with three are not
    +    // IP literals and pass the bucket rules, so they must reach the
    +    // missing-bucket path instead of the refusal.
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    for name in ["10.20.30.4a", "10.20.30.40.50", "10.20.30"] {
    +        let body = with_input(start_body(), "S3Bucket", json!(name));
    +        let description = start_ok(&ctx, body).await;
    +        assert_eq!(
    +            description["FailureCode"],
    +            json!("S3NoSuchBucket"),
    +            "{name} must pass validation and fail on the bucket directory"
    +        );
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_short_or_empty_input_prefix_fails_the_service_minimum() {
    +    // Measured on the service: a present S3KeyPrefix under three characters is
    +    // refused ('x/'; the empty string on both the
    +    // input and the output configuration), an
    +    // absent prefix is accepted and reads the whole bucket, and a
    +    // three-character prefix is accepted.
    +    let h = Harness::new().await;
    +    for prefix in ["ab", ""] {
    +        let body = with_input(start_body(), "S3KeyPrefix", json!(prefix));
    +        let message = expect_validation(start(&base_ctx(&h), body).await);
    +        assert_eq!(
    +            message,
    +            format!(
    +                "Invalid Request: 1 validation error detected: Value '{prefix}' at \
    +                 'inputS3Configuration.s3KeyPrefix' failed to satisfy constraint: Member must \
    +                 have length greater than or equal to 3"
    +            )
    +        );
    +    }
    +    let body = with_input(start_body(), "S3KeyPrefix", json!("abc"));
    +    let description = start_ok(&base_ctx(&h), body).await;
    +    assert_eq!(description["BulkOperationStatus"], json!("IN_PROGRESS"));
    +}
    +
    +#[tokio::test]
    +async fn a_long_input_bucket_fails_the_length_constraint() {
    +    let h = Harness::new().await;
    +    let body = with_input(start_body(), "S3Bucket", json!("a".repeat(256)));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert!(
    +        message.contains("less than or equal to 255"),
    +        "the maximum length must be reported: {message}"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_input_bucket_with_a_slash_fails_the_pattern_constraint() {
    +    let h = Harness::new().await;
    +    let body = with_input(start_body(), "S3Bucket", json!("bad/name"));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert!(
    +        message.contains("Member must satisfy regular expression pattern"),
    +        "the bucket pattern must be reported: {message}"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_one_character_output_bucket_fails_the_pattern_not_a_length_bound() {
    +    let h = Harness::new().await;
    +    let body = with_output(start_body(), "S3Bucket", json!("a"));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert!(
    +        message.contains("Member must satisfy regular expression pattern"),
    +        "the output bucket shares the pattern: {message}"
    +    );
    +    assert!(
    +        message.contains("outputS3Configuration.s3Bucket"),
    +        "the violation must name the output member: {message}"
    +    );
    +    assert!(
    +        !message.contains("greater than or equal to 3"),
    +        "the removed minimum length must not be reported: {message}"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_long_output_bucket_fails_the_length_constraint() {
    +    let h = Harness::new().await;
    +    let body = with_output(start_body(), "S3Bucket", json!("a".repeat(256)));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert!(
    +        message.contains("less than or equal to 255"),
    +        "the output bucket shares the maximum length: {message}"
    +    );
    +    assert!(
    +        message.contains("outputS3Configuration.s3Bucket"),
    +        "the violation must name the output member: {message}"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_output_bucket_with_a_slash_fails_the_pattern_constraint() {
    +    let h = Harness::new().await;
    +    let body = with_output(start_body(), "S3Bucket", json!("bad/name"));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert!(
    +        message.contains("Member must satisfy regular expression pattern"),
    +        "the output bucket shares the pattern: {message}"
    +    );
    +    assert!(
    +        message.contains("outputS3Configuration.s3Bucket"),
    +        "the violation must name the output member: {message}"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_parent_component_in_the_input_prefix_is_rejected() {
    +    let h = Harness::new().await;
    +    let body = with_input(start_body(), "S3KeyPrefix", json!("a/../b"));
    +    assert_eq!(
    +        expect_validation(start(&base_ctx(&h), body).await),
    +        BAD_PREFIX
    +    );
    +}
    +
    +#[tokio::test]
    +async fn doubled_and_leading_slashes_in_the_input_prefix_are_accepted() {
    +    // Measured on the service: all three prefixes start
    +    // successfully. The input prefix is matched byte-wise against
    +    // bucket-relative paths and is never joined onto a path, so none of
    +    // them can reach outside the bucket directory; each simply matches no
    +    // file, which is not a failure.
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    // One non-terminal operation per table, so each prefix gets its own.
    +    for (index, prefix) in ["a3//b//c/", "a3/b//c/", "/a3/b/"].iter().enumerate() {
    +        let table = format!("slashes{index}");
    +        Harness::create_table(&h.storage, &table).await;
    +        let body = with_input(
    +            with_top(start_body(), "TableName", json!(table)),
    +            "S3KeyPrefix",
    +            json!(prefix),
    +        );
    +        let description = start_ok(&ctx, body).await;
    +        assert_eq!(
    +            description["BulkOperationStatus"],
    +            json!("IN_PROGRESS"),
    +            "the start with prefix {prefix} must be accepted"
    +        );
    +        assert_eq!(
    +            description["TotalInputBytes"],
    +            json!(0),
    +            "prefix {prefix} matches no file"
    +        );
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_leading_slash_in_the_output_prefix_is_rejected() {
    +    // The output prefix is joined onto the report base path, where an
    +    // absolute prefix would replace the base, so the refusal stays on
    +    // that side. A leading slash on the output prefix is not measured.
    +    let h = Harness::new().await;
    +    let body = with_output(start_body(), "S3KeyPrefix", json!("/etc"));
    +    assert_eq!(
    +        expect_validation(start(&base_ctx(&h), body).await),
    +        BAD_PREFIX
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_backslash_in_the_input_prefix_is_rejected() {
    +    let h = Harness::new().await;
    +    let body = with_input(start_body(), "S3KeyPrefix", json!("a\\b"));
    +    assert_eq!(
    +        expect_validation(start(&base_ctx(&h), body).await),
    +        BAD_PREFIX
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_control_character_in_the_input_prefix_is_rejected() {
    +    let h = Harness::new().await;
    +    let body = with_input(start_body(), "S3KeyPrefix", json!("a\u{1}b"));
    +    assert_eq!(
    +        expect_validation(start(&base_ctx(&h), body).await),
    +        BAD_PREFIX
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_parent_component_in_the_output_prefix_is_rejected() {
    +    let h = Harness::new().await;
    +    let body = with_output(start_body(), "S3KeyPrefix", json!("a/../b"));
    +    assert_eq!(
    +        expect_validation(start(&base_ctx(&h), body).await),
    +        BAD_PREFIX
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/tests/handlers/start_outcomes.rs b/crates/engine/src/bulk_put/tests/handlers/start_outcomes.rs
    new file mode 100644
    index 000000000..644cd7d8d
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/start_outcomes.rs
    @@ -0,0 +1,408 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +#[tokio::test]
    +async fn the_start_description_carries_every_submitted_field() {
    +    let h = Harness::new().await;
    +    h.write_input("orders/part-0.json", b"0123456789");
    +    let input_config = json!({
    +        "S3Bucket": IN_BUCKET,
    +        "S3KeyPrefix": "orders/",
    +        "InputFormat": "DYNAMODB_JSON",
    +        "InputCompressionType": "GZIP",
    +        "S3BucketOwner": ACCOUNT
    +    });
    +    let output_config = json!({
    +        "S3Bucket": OUT_BUCKET,
    +        "S3KeyPrefix": "reports/",
    +        "OutputType": ["ERROR", "CONDITION_CHECK_FAILURE"],
    +        "S3SseAlgorithm": "AES256",
    +        "S3SseKmsKeyId": "alias/example",
    +        "S3BucketOwner": ACCOUNT
    +    });
    +    let body = json!({
    +        "TableName": TABLE,
    +        "ClientToken": "load-2026-09-10-a",
    +        "InputS3Configuration": input_config,
    +        "OutputS3Configuration": output_config,
    +        "MaxWriteRequestUnits": 2048,
    +        "PerformanceTier": "ACCELERATED"
    +    });
    +    let ctx = base_ctx(&h);
    +    let description = start_ok(&ctx, body).await;
    +
    +    let operation_arn = description_arn(&description);
    +    let prefix = format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:bulk-operation/");
    +    let id = operation_arn
    +        .strip_prefix(&prefix)
    +        .expect("the ARN carries the caller's region and account");
    +    let (stamp, hex) = id.split_once('-').expect("the id carries one hyphen");
    +    assert_eq!(stamp.len(), 14);
    +    assert!(stamp.bytes().all(|b| b.is_ascii_digit()));
    +    assert_eq!(hex.len(), 8);
    +
    +    assert_eq!(description["BulkOperationStatus"], json!("IN_PROGRESS"));
    +    assert_eq!(
    +        description["TableDetails"],
    +        json!([{ "TableName": TABLE, "TableId": h.table_id }])
    +    );
    +    // Modelled but never emitted by the service (measured on the
    +    // service): the table travels only in TableDetails.
    +    assert!(description.get("TableArn").is_none());
    +    assert!(description.get("TableId").is_none());
    +    assert_eq!(description["ClientToken"], json!("load-2026-09-10-a"));
    +    assert_eq!(description["InputS3Configuration"], input_config);
    +    assert_eq!(description["OutputS3Configuration"], output_config);
    +    assert_eq!(description["ProcessedItemCount"], json!(0));
    +    assert_eq!(description["FailedItemCount"], json!(0));
    +    assert_eq!(description["ProcessedInputBytes"], json!(0));
    +    assert_eq!(description["TotalInputBytes"], json!(10));
    +    assert_eq!(description["MaxWriteRequestUnits"], json!(2048));
    +    assert_eq!(description["PerformanceTier"], json!("ACCELERATED"));
    +
    +    // StartTime is fractional seconds, not milliseconds.
    +    let start_time = description["StartTime"]
    +        .as_f64()
    +        .expect("StartTime is a number");
    +    // Whole seconds convert exactly (they fit u32 until 2106), and the
    +    // 300-second tolerance below does not need the milliseconds.
    +    let now_s = f64::from(u32::try_from(now_ms() / 1000).expect("epoch seconds fit u32"));
    +    assert!(
    +        (start_time - now_s).abs() < 300.0,
    +        "StartTime must be on the seconds scale: {start_time}"
    +    );
    +
    +    let members = description.as_object().unwrap();
    +    for absent in [
    +        "EndTime",
    +        "BulkOperationUsage",
    +        "FailureCode",
    +        "FailureMessage",
    +    ] {
    +        assert!(
    +            !members.contains_key(absent),
    +            "{absent} must not be emitted for a running operation"
    +        );
    +    }
    +
    +    // Describe renders the same description.
    +    let described = describe(&ctx, &operation_arn)
    +        .await
    +        .expect("describe must succeed");
    +    assert_eq!(described["BulkOperationDescription"], description);
    +}
    +
    +// Start: transaction outcomes through the handler.
    +
    +#[tokio::test]
    +async fn a_replayed_token_returns_the_same_arn_and_creates_nothing() {
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    let body = with_top(start_body(), "ClientToken", json!("tok-replay"));
    +    let first = start_ok(&ctx, body.clone()).await;
    +    let second = start_ok(&ctx, body).await;
    +    assert_eq!(description_arn(&first), description_arn(&second));
    +    let listed = call(&ctx, "ListBulkOperations", json!({}))
    +        .await
    +        .expect("list");
    +    assert_eq!(
    +        summaries(&listed).len(),
    +        1,
    +        "the replay must create nothing"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_token_replayed_against_a_terminal_operation_returns_its_status_and_counters() {
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[row(0), row_missing_sk(1), row(2)]),
    +    );
    +    let body = with_top(start_body(), "ClientToken", json!("tok-terminal"));
    +    let first = start_ok(&ctx, body.clone()).await;
    +
    +    // Drive the started operation to COMPLETED_WITH_ERRORS through the
    +    // runner, the way a worker would.
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    run_operation(&h.deps("runner-1", runner_config()), record)
    +        .await
    +        .expect("the run finishes");
    +
    +    // The replay returns the existing operation's
    +    // current description with HTTP 200, whatever its status.
    +    let replayed = start_ok(&ctx, body).await;
    +    assert_eq!(description_arn(&replayed), description_arn(&first));
    +    assert_eq!(
    +        replayed["BulkOperationStatus"],
    +        json!("COMPLETED_WITH_ERRORS")
    +    );
    +    assert_eq!(replayed["ProcessedItemCount"], json!(3));
    +    assert_eq!(replayed["FailedItemCount"], json!(1));
    +    assert!(
    +        replayed["EndTime"].is_f64(),
    +        "the terminal description carries EndTime"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_replayed_token_with_different_parameters_is_a_mismatch() {
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    let body = with_top(start_body(), "ClientToken", json!("tok-mismatch"));
    +    start_ok(&ctx, body.clone()).await;
    +    let changed = with_top(body, "MaxWriteRequestUnits", json!(4096));
    +    match start(&ctx, changed).await {
    +        Err(DynamoDbError::IdempotentParameterMismatchException(message)) => {
    +            assert_eq!(message, IDEMPOTENT_MISMATCH);
    +        }
    +        other => panic!("expected IdempotentParameterMismatchException, got {other:?}"),
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_second_start_on_a_busy_table_answers_the_busy_message() {
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    start_ok(&ctx, start_body()).await;
    +    assert_eq!(expect_in_use(start(&ctx, start_body()).await), TABLE_BUSY);
    +}
    +
    +#[tokio::test]
    +async fn the_guard_answers_when_the_account_limit_would_also_reject() {
    +    let h = Harness::new().await;
    +    let ctx = ctx_with_limits(
    +        &h,
    +        bulk_limits(&[("max_concurrent_bulk_operations", json!(1))]),
    +    );
    +    start_ok(&ctx, start_body()).await;
    +    // The second start on the same table violates the guard and the account
    +    // limit together; the guard must answer.
    +    assert_eq!(expect_in_use(start(&ctx, start_body()).await), TABLE_BUSY);
    +}
    +
    +#[tokio::test]
    +async fn the_account_limit_answers_across_tables() {
    +    let h = Harness::new().await;
    +    Harness::create_table(&h.storage, "second").await;
    +    Harness::create_table(&h.storage, "third").await;
    +    let ctx = ctx_with_limits(
    +        &h,
    +        bulk_limits(&[("max_concurrent_bulk_operations", json!(2))]),
    +    );
    +    start_ok(&ctx, start_body()).await;
    +    start_ok(&ctx, with_top(start_body(), "TableName", json!("second"))).await;
    +    let message = expect_limit_exceeded(
    +        start(&ctx, with_top(start_body(), "TableName", json!("third"))).await,
    +    );
    +    // The measured text, missing separators included, with the configured
    +    // limit of 2 substituted.
    +    assert_eq!(
    +        message,
    +        "Subscriber limit exceededService quota exceeded You have reached the maximum \
    +         number of concurrent bulk operations for the selected PerformanceTier, which is \
    +         2. Wait until other jobs finish, and try again. You can also reach out to \
    +         AWS Support to request a quota increase."
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_saturated_account_is_refused_before_the_enumeration_walk() {
    +    let h = Harness::new().await;
    +    Harness::create_table(&h.storage, "second").await;
    +    let ctx = ctx_with_limits(
    +        &h,
    +        bulk_limits(&[
    +            ("max_concurrent_bulk_operations", json!(1)),
    +            ("max_bulk_input_files", json!(2)),
    +        ]),
    +    );
    +    start_ok(&ctx, start_body()).await;
    +
    +    // The second start's input bucket holds more files than the cap, so
    +    // an enumeration walk would answer the max_bulk_input_files message.
    +    // The concurrency limit message can only come from the pre-check path
    +    // that skips the walk.
    +    let bucket_dir = h.store.0.join(ACCOUNT).join("oversized");
    +    std::fs::create_dir_all(&bucket_dir).unwrap();
    +    for i in 0..3 {
    +        std::fs::write(bucket_dir.join(format!("part-{i}.json")), b"{}").unwrap();
    +    }
    +
    +    let body = with_input(
    +        with_top(start_body(), "TableName", json!("second")),
    +        "S3Bucket",
    +        json!("oversized"),
    +    );
    +    let message = expect_limit_exceeded(start(&ctx, body).await);
    +    assert_eq!(
    +        message,
    +        "Subscriber limit exceededService quota exceeded You have reached the maximum \
    +         number of concurrent bulk operations for the selected PerformanceTier, which is \
    +         1. Wait until other jobs finish, and try again. You can also reach out to \
    +         AWS Support to request a quota increase.",
    +        "the account limit must answer without running the walk"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_replayed_token_on_a_saturated_account_returns_the_existing_operation() {
    +    // A replayed token is answered from its existing row before the
    +    // account limit, on the saturated path as well as the ordinary one:
    +    // the one operation counted against the limit is the operation the
    +    // token created, so a retry of the same start must get that operation
    +    // back, not LimitExceededException. A mismatched fingerprint under the
    +    // same saturation answers the mismatch, for the same reason.
    +    let h = Harness::new().await;
    +    let ctx = ctx_with_limits(
    +        &h,
    +        bulk_limits(&[("max_concurrent_bulk_operations", json!(1))]),
    +    );
    +    let body = with_top(start_body(), "ClientToken", json!("tok-saturated"));
    +    let first = start_ok(&ctx, body.clone()).await;
    +    let first_arn = first["BulkOperationArn"]
    +        .as_str()
    +        .expect("the first start carries an ARN")
    +        .to_owned();
    +
    +    let replayed = start_ok(&ctx, body.clone()).await;
    +    assert_eq!(
    +        replayed["BulkOperationArn"].as_str(),
    +        Some(first_arn.as_str()),
    +        "the replay must return the existing operation, not the account limit"
    +    );
    +
    +    let changed = with_top(body, "MaxWriteRequestUnits", json!(4096));
    +    match start(&ctx, changed).await {
    +        Err(DynamoDbError::IdempotentParameterMismatchException(message)) => {
    +            assert_eq!(message, IDEMPOTENT_MISMATCH);
    +        }
    +        other => panic!(
    +            "a mismatched replay on a saturated account must answer the mismatch, got {other:?}"
    +        ),
    +    }
    +}
    +
    +#[tokio::test]
    +async fn twenty_concurrent_same_token_starts_yield_one_operation() {
    +    let h = Harness::new().await;
    +    let ctx = Arc::new(base_ctx(&h));
    +    let body = with_top(start_body(), "ClientToken", json!("tok-race"));
    +    let mut tasks = tokio::task::JoinSet::new();
    +    for _ in 0..20 {
    +        let ctx = Arc::clone(&ctx);
    +        let body = body.clone();
    +        tasks.spawn(async move {
    +            crate::dispatch("StartBulkPutItems", body, &ctx, "127.0.0.1:0")
    +                .await
    +                .map(|result| result.body)
    +        });
    +    }
    +    let mut arns = HashSet::new();
    +    while let Some(joined) = tasks.join_next().await {
    +        let response = joined
    +            .expect("the task must not panic")
    +            .expect("no same-token start may answer busy or any other error");
    +        arns.insert(description_arn(&response["BulkOperationDescription"]));
    +    }
    +    assert_eq!(arns.len(), 1, "every start must answer the same operation");
    +    let listed = call(&ctx, "ListBulkOperations", json!({}))
    +        .await
    +        .expect("list");
    +    assert_eq!(summaries(&listed).len(), 1, "exactly one operation exists");
    +}
    +
    +// The service replays a retry that adds a default the first request
    +// omitted (measured). These drive the whole handler, so the normalization
    +// start.rs applies before fingerprinting is pinned end to end.
    +
    +/// A start body with the ClientToken set and InputCompressionType omitted.
    +fn tokened_body_without_compression(token: &str) -> Value {
    +    json!({
    +        "TableName": TABLE,
    +        "ClientToken": token,
    +        "InputS3Configuration": { "S3Bucket": IN_BUCKET, "InputFormat": "DYNAMODB_JSON" },
    +        "OutputS3Configuration": {
    +            "S3Bucket": OUT_BUCKET,
    +            "S3KeyPrefix": "run",
    +            "OutputType": ["ERROR", "CONDITION_CHECK_FAILURE"]
    +        }
    +    })
    +}
    +
    +#[tokio::test]
    +async fn a_retry_that_adds_none_compression_replays() {
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    let body = tokened_body_without_compression("tok-none");
    +    let first = start_ok(&ctx, body.clone()).await;
    +    let retry = with_input(body, "InputCompressionType", json!("NONE"));
    +    let replayed = start_ok(&ctx, retry).await;
    +    assert_eq!(description_arn(&replayed), description_arn(&first));
    +}
    +
    +#[tokio::test]
    +async fn a_retry_that_drops_none_compression_replays() {
    +    // Normalization applies in both directions (measured): the first
    +    // request carries InputCompressionType NONE and the retry omits it.
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    let body = with_top(start_body(), "ClientToken", json!("tok-none-drop"));
    +    assert_eq!(
    +        body["InputS3Configuration"]["InputCompressionType"],
    +        json!("NONE"),
    +        "the first request carries an explicit NONE"
    +    );
    +    let first = start_ok(&ctx, body.clone()).await;
    +    let mut retry = body;
    +    retry["InputS3Configuration"]
    +        .as_object_mut()
    +        .expect("the input configuration is an object")
    +        .remove("InputCompressionType");
    +    let replayed = start_ok(&ctx, retry).await;
    +    assert_eq!(description_arn(&replayed), description_arn(&first));
    +}
    +
    +#[tokio::test]
    +async fn a_retry_that_adds_standard_tier_replays() {
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    let body = with_top(start_body(), "ClientToken", json!("tok-std"));
    +    let first = start_ok(&ctx, body.clone()).await;
    +    let retry = with_top(body, "PerformanceTier", json!("STANDARD"));
    +    let replayed = start_ok(&ctx, retry).await;
    +    assert_eq!(description_arn(&replayed), description_arn(&first));
    +}
    +
    +#[tokio::test]
    +async fn a_retry_that_adds_own_account_bucket_owners_replays() {
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    let body = with_top(start_body(), "ClientToken", json!("tok-owner"));
    +    let first = start_ok(&ctx, body.clone()).await;
    +    let retry = with_output(
    +        with_input(body, "S3BucketOwner", json!(ACCOUNT)),
    +        "S3BucketOwner",
    +        json!(ACCOUNT),
    +    );
    +    let replayed = start_ok(&ctx, retry).await;
    +    assert_eq!(description_arn(&replayed), description_arn(&first));
    +}
    +
    +#[tokio::test]
    +async fn a_retry_that_adds_aes256_mismatches() {
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    let body = with_top(start_body(), "ClientToken", json!("tok-aes"));
    +    start_ok(&ctx, body.clone()).await;
    +    let retry = with_output(body, "S3SseAlgorithm", json!("AES256"));
    +    match start(&ctx, retry).await {
    +        Err(DynamoDbError::IdempotentParameterMismatchException(message)) => {
    +            assert_eq!(message, IDEMPOTENT_MISMATCH);
    +        }
    +        other => panic!("expected IdempotentParameterMismatchException, got {other:?}"),
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/tests/handlers/start_prefix_forms.rs b/crates/engine/src/bulk_put/tests/handlers/start_prefix_forms.rs
    new file mode 100644
    index 000000000..d7047932d
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/start_prefix_forms.rs
    @@ -0,0 +1,186 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +use extenddb_storage::{BoxedFuture as BoxFuture, InputFile};
    +
    +use crate::bulk_put::authorize::{AuthorizeError, BulkPrincipal, S3Action};
    +use crate::bulk_put::source::{
    +    BucketRef, BulkSource, FsSink, FsSource, InputCodec, LineReader, SourceError, SourceFile,
    +    stored_form,
    +};
    +use crate::bulk_put::tests::runner::{FakeAuthorizer, caller, denied};
    +
    +// s3:ListBucket is evaluated with s3:prefix set to the request's S3KeyPrefix
    +// and, when the store lists another prefix for it, to that prefix. The
    +// filesystem store's walk matches the prefix as given against the relative
    +// paths it finds, so it is evaluated once there. An S3-compatible endpoint
    +// resolves empty, `.` and `..` segments, so it is evaluated on the resolved
    +// prefix as well.
    +
    +const BUCKET_ARN: &str = "arn:aws:s3:::loads";
    +
    +/// Deny `s3:ListBucket` when `s3:prefix` is `prefix`; allow everything else.
    +struct DenyPrefix(&'static str);
    +
    +impl BulkAuthorizer for DenyPrefix {
    +    fn authorize<'a>(
    +        &'a self,
    +        _principal: &'a BulkPrincipal,
    +        action: S3Action,
    +        _resource_arn: &'a str,
    +        s3_prefix: Option<&'a str>,
    +    ) -> BoxFuture<'a, Result<(), AuthorizeError>> {
    +        let denied = action == S3Action::ListBucket && s3_prefix == Some(self.0);
    +        Box::pin(async move {
    +            if denied {
    +                Err(AuthorizeError::Denied { explicit: false })
    +            } else {
    +                Ok(())
    +            }
    +        })
    +    }
    +}
    +
    +/// The filesystem source with the listing prefix of an S3-compatible
    +/// endpoint (`S3Source::listed_prefix` against a configured endpoint).
    +struct EndpointListing(FsSource);
    +
    +impl BulkSource for EndpointListing {
    +    fn check_bucket(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +    ) -> BoxFuture<'_, Result<(), SourceError>> {
    +        self.0.check_bucket(bucket, prefix)
    +    }
    +
    +    fn list(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +        max_files: u64,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        self.0.list(bucket, prefix, max_files)
    +    }
    +
    +    fn open(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        self.0.open(bucket, file, compression)
    +    }
    +
    +    fn etag(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +    ) -> BoxFuture<'_, Result> {
    +        self.0.etag(bucket, file)
    +    }
    +
    +    fn line_count(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result> {
    +        self.0.line_count(bucket, file, compression)
    +    }
    +
    +    fn listed_prefix(&self, prefix: &str) -> String {
    +        stored_form(prefix, true)
    +    }
    +}
    +
    +fn ctx_with(h: &Harness, authorizer: Arc) -> OperationContext {
    +    let mut ctx = base_ctx(h);
    +    ctx.bulk_authorizer = Some(authorizer);
    +    ctx.bulk_principal = Some(caller());
    +    ctx
    +}
    +
    +fn endpoint_ctx_with(h: &Harness, authorizer: Arc) -> OperationContext {
    +    let mut ctx = ctx_with(h, authorizer);
    +    ctx.bulk = Some(Arc::new(BulkStore::s3_endpoint_with_parts(
    +        Arc::new(EndpointListing(h.fs_source())),
    +        Arc::new(FsSink {
    +            root: h.store.root(),
    +        }),
    +    )));
    +    ctx
    +}
    +
    +fn list_prefixes(fake: &FakeAuthorizer) -> Vec> {
    +    fake.checks()
    +        .into_iter()
    +        .filter(|(action, _, _)| *action == S3Action::ListBucket)
    +        .map(|(_, _, prefix)| prefix)
    +        .collect()
    +}
    +
    +#[tokio::test]
    +async fn the_filesystem_store_checks_the_listing_once_on_the_prefix_as_given() {
    +    let h = Harness::new().await;
    +    h.write_input("orders/part-0.json", &file_of(&plain_rows(1)));
    +    let fake = Arc::new(FakeAuthorizer::default());
    +    let body = with_input(start_body(), "S3KeyPrefix", json!(".//orders/"));
    +    let description = start_ok(&ctx_with(&h, Arc::clone(&fake) as _), body).await;
    +    assert_eq!(list_prefixes(&fake), vec![Some(".//orders/".to_owned())]);
    +    assert_eq!(
    +        description["TotalInputBytes"],
    +        json!(0),
    +        "no relative path starts with .//, so nothing is listed"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_prefix_the_store_lists_as_given_is_checked_once() {
    +    let h = Harness::new().await;
    +    h.write_input("orders/part-0.json", &file_of(&plain_rows(1)));
    +    let fake = Arc::new(FakeAuthorizer::default());
    +    let body = with_input(start_body(), "S3KeyPrefix", json!("orders/"));
    +    start_ok(&ctx_with(&h, Arc::clone(&fake) as _), body).await;
    +    assert_eq!(fake.count(S3Action::ListBucket), 1);
    +}
    +
    +#[tokio::test]
    +async fn an_s3_compatible_endpoint_checks_the_listing_as_given_and_as_it_resolves() {
    +    let h = Harness::new().await;
    +    h.write_input("orders/part-0.json", &file_of(&plain_rows(1)));
    +    let fake = Arc::new(FakeAuthorizer::default());
    +    let body = with_input(start_body(), "S3KeyPrefix", json!("x/..//./orders/"));
    +    start_ok(&endpoint_ctx_with(&h, Arc::clone(&fake) as _), body).await;
    +    assert_eq!(
    +        list_prefixes(&fake),
    +        vec![
    +            Some("x/..//./orders/".to_owned()),
    +            Some("orders/".to_owned())
    +        ]
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_listing_denied_on_the_resolved_prefix_is_born_failed() {
    +    for given in ["./orders/", ".//orders/", "orders//", "x/../orders/"] {
    +        let h = Harness::new().await;
    +        h.write_input("orders/part-0.json", &file_of(&plain_rows(1)));
    +        let body = with_input(start_body(), "S3KeyPrefix", json!(given));
    +        let ctx = endpoint_ctx_with(&h, Arc::new(DenyPrefix("orders/")));
    +        let description = start_ok(&ctx, body).await;
    +        assert_eq!(
    +            description["BulkOperationStatus"],
    +            json!("FAILED"),
    +            "{given}"
    +        );
    +        assert_eq!(description["FailureCode"], json!("S3AccessDenied"));
    +        assert_eq!(
    +            description["FailureMessage"],
    +            json!(denied("s3:ListBucket", BUCKET_ARN))
    +        );
    +        assert!(description.get("TotalInputBytes").is_none());
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/tests/handlers/start_prefix_rules.rs b/crates/engine/src/bulk_put/tests/handlers/start_prefix_rules.rs
    new file mode 100644
    index 000000000..34e988262
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/start_prefix_rules.rs
    @@ -0,0 +1,210 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use extenddb_storage::{BoxedFuture, InputFile};
    +
    +use super::*;
    +use crate::bulk_put::source::{
    +    BucketRef, BulkSource, FsSink, InputCodec, LineReader, SourceError, SourceFile,
    +};
    +
    +// The S3KeyPrefix path rules belong to the filesystem store, which joins a
    +// prefix onto a directory path. On the S3 store every such prefix is a valid
    +// key prefix; the service's length rule applies on both stores.
    +
    +/// A source with an existing input bucket that lists nothing.
    +struct EmptyBucket;
    +
    +fn not_read() -> SourceError {
    +    SourceError::Io(std::io::Error::other("not read"))
    +}
    +
    +impl BulkSource for EmptyBucket {
    +    fn check_bucket(
    +        &self,
    +        _bucket: &BucketRef<'_>,
    +        _prefix: &str,
    +    ) -> BoxedFuture<'_, Result<(), SourceError>> {
    +        Box::pin(async { Ok(()) })
    +    }
    +
    +    fn list(
    +        &self,
    +        _bucket: &BucketRef<'_>,
    +        _prefix: &str,
    +        _max_files: u64,
    +    ) -> BoxedFuture<'_, Result, SourceError>> {
    +        Box::pin(async { Ok(Vec::new()) })
    +    }
    +
    +    fn open(
    +        &self,
    +        _bucket: &BucketRef<'_>,
    +        _file: &InputFile,
    +        _compression: InputCodec,
    +    ) -> BoxedFuture<'_, Result, SourceError>> {
    +        Box::pin(async { Err(not_read()) })
    +    }
    +
    +    fn etag(
    +        &self,
    +        _bucket: &BucketRef<'_>,
    +        _file: &InputFile,
    +    ) -> BoxedFuture<'_, Result> {
    +        Box::pin(async { Err(not_read()) })
    +    }
    +
    +    fn line_count(
    +        &self,
    +        _bucket: &BucketRef<'_>,
    +        _file: &InputFile,
    +        _compression: InputCodec,
    +    ) -> BoxedFuture<'_, Result> {
    +        Box::pin(async { Err(not_read()) })
    +    }
    +}
    +
    +fn s3_ctx(h: &Harness) -> OperationContext {
    +    let mut ctx = base_ctx(h);
    +    ctx.bulk = Some(Arc::new(BulkStore::s3_with_parts(
    +        Arc::new(EmptyBucket),
    +        Arc::new(FsSink {
    +            root: h.store.root(),
    +        }),
    +    )));
    +    ctx
    +}
    +
    +/// Bodies whose one prefix breaks one path rule.
    +fn path_rule_cases() -> Vec<(&'static str, Value)> {
    +    vec![
    +        (
    +            "input ..",
    +            with_input(start_body(), "S3KeyPrefix", json!("s0/../s0/")),
    +        ),
    +        (
    +            "input backslash",
    +            with_input(start_body(), "S3KeyPrefix", json!("s0\\x/")),
    +        ),
    +        (
    +            "input control",
    +            with_input(start_body(), "S3KeyPrefix", json!("s0\u{1}/")),
    +        ),
    +        (
    +            "output leading /",
    +            with_output(start_body(), "S3KeyPrefix", json!("/out-lead/")),
    +        ),
    +        (
    +            "output ..",
    +            with_output(start_body(), "S3KeyPrefix", json!("out/../up/")),
    +        ),
    +    ]
    +}
    +
    +#[tokio::test]
    +async fn the_s3_store_accepts_prefixes_the_path_rules_refuse() {
    +    for (case, body) in path_rule_cases() {
    +        // A table admits one operation at a time, so each case gets its own.
    +        let h = Harness::new().await;
    +        let description = start_ok(&s3_ctx(&h), body.clone()).await;
    +        assert_eq!(
    +            description["BulkOperationStatus"],
    +            json!("IN_PROGRESS"),
    +            "{case}"
    +        );
    +        for config in ["InputS3Configuration", "OutputS3Configuration"] {
    +            assert_eq!(
    +                description[config]["S3KeyPrefix"], body[config]["S3KeyPrefix"],
    +                "{case}: the prefix is kept as given"
    +            );
    +        }
    +    }
    +}
    +
    +#[tokio::test]
    +async fn the_filesystem_store_still_refuses_them() {
    +    let h = Harness::new().await;
    +    for (case, body) in path_rule_cases() {
    +        let message = expect_validation(start(&base_ctx(&h), body).await);
    +        assert_eq!(message, BAD_PREFIX, "{case}");
    +    }
    +}
    +
    +#[tokio::test]
    +async fn the_length_rule_holds_on_the_s3_store() {
    +    let h = Harness::new().await;
    +    let long = "p".repeat(1025);
    +    let body = with_input(start_body(), "S3KeyPrefix", json!(long));
    +    let message = expect_validation(start(&s3_ctx(&h), body).await);
    +    assert!(message.contains("s3KeyPrefix"), "{message}");
    +}
    +
    +fn s3_endpoint_ctx(h: &Harness) -> OperationContext {
    +    let mut ctx = base_ctx(h);
    +    ctx.bulk = Some(Arc::new(BulkStore::s3_endpoint_with_parts(
    +        Arc::new(EmptyBucket),
    +        Arc::new(FsSink {
    +            root: h.store.root(),
    +        }),
    +    )));
    +    ctx
    +}
    +
    +/// Bodies whose one prefix has more `..` segments than segments before
    +/// them, so resolving it goes above the bucket.
    +fn climb_cases() -> Vec<(&'static str, Value)> {
    +    vec![
    +        (
    +            "input ../",
    +            with_input(start_body(), "S3KeyPrefix", json!("../victim/")),
    +        ),
    +        (
    +            "input a/../../",
    +            with_input(start_body(), "S3KeyPrefix", json!("a/../../victim/cc/")),
    +        ),
    +        (
    +            "output ../",
    +            with_output(start_body(), "S3KeyPrefix", json!("../victim/x")),
    +        ),
    +        (
    +            "output ./ then ..",
    +            with_output(start_body(), "S3KeyPrefix", json!("./../victim")),
    +        ),
    +    ]
    +}
    +
    +#[tokio::test]
    +async fn an_s3_compatible_endpoint_refuses_a_prefix_that_climbs_above_the_bucket() {
    +    let h = Harness::new().await;
    +    for (case, body) in climb_cases() {
    +        let message = expect_validation(start(&s3_endpoint_ctx(&h), body).await);
    +        assert_eq!(message, BAD_PREFIX, "{case}");
    +    }
    +}
    +
    +#[tokio::test]
    +async fn aws_s3_keeps_a_climbing_prefix_as_given() {
    +    for (case, body) in climb_cases() {
    +        let h = Harness::new().await;
    +        let description = start_ok(&s3_ctx(&h), body).await;
    +        assert_eq!(
    +            description["BulkOperationStatus"],
    +            json!("IN_PROGRESS"),
    +            "{case}"
    +        );
    +    }
    +}
    +
    +#[tokio::test]
    +async fn an_s3_compatible_endpoint_accepts_the_prefixes_the_path_rules_refuse() {
    +    // Each `..` in these resolves against a segment before it.
    +    for (case, body) in path_rule_cases() {
    +        let h = Harness::new().await;
    +        let description = start_ok(&s3_endpoint_ctx(&h), body).await;
    +        assert_eq!(
    +            description["BulkOperationStatus"],
    +            json!("IN_PROGRESS"),
    +            "{case}"
    +        );
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/tests/handlers/start_replay.rs b/crates/engine/src/bulk_put/tests/handlers/start_replay.rs
    new file mode 100644
    index 000000000..97ad91577
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/start_replay.rs
    @@ -0,0 +1,170 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use extenddb_storage::{BoxedFuture, InputFile};
    +
    +use super::*;
    +use crate::bulk_put::source::{
    +    BucketRef, BulkSource, FsSink, InputCodec, LineReader, SourceError, SourceFile,
    +};
    +
    +// A Start that replays a ClientToken is answered from the operation the
    +// token started, before any store call, the listing, the byte bound and the
    +// failed-start cap (measured on the service: the existing
    +// description with HTTP 200, or the mismatch error, and nothing else
    +// consulted).
    +
    +const TOKEN: &str = "tok-replay";
    +
    +/// A source every call of which fails as an unreachable store does.
    +struct Unreachable;
    +
    +fn unreachable_error() -> SourceError {
    +    SourceError::Io(std::io::Error::other("connection refused"))
    +}
    +
    +impl BulkSource for Unreachable {
    +    fn check_bucket(
    +        &self,
    +        _bucket: &BucketRef<'_>,
    +        _prefix: &str,
    +    ) -> BoxedFuture<'_, Result<(), SourceError>> {
    +        Box::pin(async { Err(unreachable_error()) })
    +    }
    +
    +    fn list(
    +        &self,
    +        _bucket: &BucketRef<'_>,
    +        _prefix: &str,
    +        _max_files: u64,
    +    ) -> BoxedFuture<'_, Result, SourceError>> {
    +        Box::pin(async { Err(unreachable_error()) })
    +    }
    +
    +    fn open(
    +        &self,
    +        _bucket: &BucketRef<'_>,
    +        _file: &InputFile,
    +        _compression: InputCodec,
    +    ) -> BoxedFuture<'_, Result, SourceError>> {
    +        Box::pin(async { Err(unreachable_error()) })
    +    }
    +
    +    fn etag(
    +        &self,
    +        _bucket: &BucketRef<'_>,
    +        _file: &InputFile,
    +    ) -> BoxedFuture<'_, Result> {
    +        Box::pin(async { Err(unreachable_error()) })
    +    }
    +
    +    fn line_count(
    +        &self,
    +        _bucket: &BucketRef<'_>,
    +        _file: &InputFile,
    +        _compression: InputCodec,
    +    ) -> BoxedFuture<'_, Result> {
    +        Box::pin(async { Err(unreachable_error()) })
    +    }
    +}
    +
    +fn ctx_unreachable(h: &Harness) -> OperationContext {
    +    let mut ctx = base_ctx(h);
    +    let root = h.store.root();
    +    ctx.bulk = Some(Arc::new(BulkStore::with_parts(
    +        Arc::clone(&root),
    +        Arc::new(Unreachable),
    +        Arc::new(FsSink { root }),
    +    )));
    +    ctx
    +}
    +
    +fn tokened() -> Value {
    +    with_top(start_body(), "ClientToken", json!(TOKEN))
    +}
    +
    +#[tokio::test]
    +async fn a_replay_is_answered_while_every_store_call_fails() {
    +    let h = Harness::new().await;
    +    h.write_input("part-0.json", &file_of(&plain_rows(2)));
    +    let first = start_ok(&base_ctx(&h), tokened()).await;
    +    let unreachable = ctx_unreachable(&h);
    +    // The store is consulted for a request with no replay to answer.
    +    let error = start(&unreachable, start_body()).await.unwrap_err();
    +    assert!(
    +        matches!(error, DynamoDbError::InternalServerError(_)),
    +        "{error:?}"
    +    );
    +    let replay = start_ok(&unreachable, tokened()).await;
    +    assert_eq!(replay, first);
    +}
    +
    +#[tokio::test]
    +async fn a_mismatched_replay_is_answered_while_every_store_call_fails() {
    +    let h = Harness::new().await;
    +    h.write_input("part-0.json", &file_of(&plain_rows(2)));
    +    start_ok(&base_ctx(&h), tokened()).await;
    +    let body = with_input(tokened(), "S3KeyPrefix", json!("other/"));
    +    match start(&ctx_unreachable(&h), body).await {
    +        Err(DynamoDbError::IdempotentParameterMismatchException(message)) => assert_eq!(
    +            message,
    +            "Specified idempotent token was already used with different request parameters \
    +             within the idempotency window"
    +        ),
    +        other => panic!("expected the mismatch error, got {other:?}"),
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_replay_is_answered_after_the_prefix_grew_past_the_byte_bound() {
    +    let h = Harness::new().await;
    +    h.write_input("part-0.json", &file_of(&plain_rows(2)));
    +    let size = std::fs::metadata(h.store.0.join(ACCOUNT).join(IN_BUCKET).join("part-0.json"))
    +        .unwrap()
    +        .len();
    +    let ctx = ctx_with_limits(
    +        &h,
    +        bulk_limits(&[("max_bulk_input_bytes", json!(size + 10))]),
    +    );
    +    let first = start_ok(&ctx, tokened()).await;
    +    h.write_input("part-1.json", &file_of(&plain_rows(2)));
    +    let fresh = expect_validation(start(&ctx, start_body()).await);
    +    assert!(
    +        fresh.starts_with("Input exceeds max_bulk_input_bytes"),
    +        "{fresh}"
    +    );
    +    let replay = start_ok(&ctx, tokened()).await;
    +    assert_eq!(replay["BulkOperationArn"], first["BulkOperationArn"]);
    +    assert_eq!(replay["TotalInputBytes"], json!(size));
    +}
    +
    +#[tokio::test]
    +async fn a_replay_is_answered_at_the_failed_start_cap() {
    +    let h = Harness::new().await;
    +    h.write_input("part-0.json", &file_of(&plain_rows(2)));
    +    Harness::create_table(&h.storage, "second").await;
    +    let ctx = ctx_with_limits(
    +        &h,
    +        bulk_limits(&[("max_bulk_failed_starts_per_hour", json!(1))]),
    +    );
    +    let first = start_ok(&ctx, tokened()).await;
    +    // One terminal-born start reaches the cap. It names another table: the
    +    // operation just started keeps this one busy, and a busy table answers
    +    // busy rather than inserting the failed row.
    +    let missing = with_input(
    +        with_top(start_body(), "TableName", json!("second")),
    +        "S3Bucket",
    +        json!("absent-loads"),
    +    );
    +    let born = start_ok(&ctx, missing.clone()).await;
    +    assert_eq!(born["BulkOperationStatus"], json!("FAILED"));
    +    expect_throttling(start(&ctx, missing).await);
    +    // The input bucket is gone, so the bucket check of a new Start would
    +    // fail: on this table, which the first operation keeps busy, it would
    +    // answer busy, and on an idle one it would be refused at the cap. The
    +    // replay is answered from its row before the check.
    +    std::fs::remove_dir_all(h.store.0.join(ACCOUNT).join(IN_BUCKET)).unwrap();
    +    let replay = start_ok(&ctx, tokened()).await;
    +    assert_eq!(replay["BulkOperationArn"], first["BulkOperationArn"]);
    +    assert_eq!(replay["BulkOperationStatus"], first["BulkOperationStatus"]);
    +}
    diff --git a/crates/engine/src/bulk_put/tests/handlers/start_store_faults.rs b/crates/engine/src/bulk_put/tests/handlers/start_store_faults.rs
    new file mode 100644
    index 000000000..8187eb5d8
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/start_store_faults.rs
    @@ -0,0 +1,255 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Start against a store whose enumeration fails after the bucket check
    +//! passed: a fault the store rendered on a later listing page (the bucket
    +//! removed or access lost in between) starts the operation terminal, as
    +//! the check would have; any other enumeration failure stays a request
    +//! error. The same holds for the Start-time check of an output SSE-KMS
    +//! key.
    +
    +use extenddb_storage::{BoxedFuture, InputFile};
    +
    +use super::*;
    +use crate::bulk_put::source::{
    +    BucketRef, BulkSink, BulkSource, FsSink, FsSource, InputCodec, LineReader, OutputSse,
    +    ReportWriter, SinkError, SourceError, SourceFile, StoreFault,
    +};
    +
    +const LATER_PAGE_MESSAGE: &str = "The specified bucket does not exist (Service: Amazon S3; \
    +     Status Code: 404; Error Code: NoSuchBucket; Request ID: R1; S3 Extended Request ID: X1; \
    +     Proxy: null)";
    +
    +/// A source whose bucket check passes and whose listing fails with
    +/// `fault`, or with an I/O error when `fault` is `None`.
    +struct ListingFails {
    +    fault: Option,
    +}
    +
    +impl BulkSource for ListingFails {
    +    fn check_bucket(
    +        &self,
    +        _bucket: &BucketRef<'_>,
    +        _prefix: &str,
    +    ) -> BoxedFuture<'_, Result<(), SourceError>> {
    +        Box::pin(async { Ok(()) })
    +    }
    +
    +    fn list(
    +        &self,
    +        _bucket: &BucketRef<'_>,
    +        _prefix: &str,
    +        _max_files: u64,
    +    ) -> BoxedFuture<'_, Result, SourceError>> {
    +        let error = match &self.fault {
    +            Some(fault) => SourceError::Store(fault.clone()),
    +            None => SourceError::Io(std::io::Error::other("connection reset")),
    +        };
    +        Box::pin(async move { Err(error) })
    +    }
    +
    +    fn open(
    +        &self,
    +        _bucket: &BucketRef<'_>,
    +        _file: &InputFile,
    +        _compression: InputCodec,
    +    ) -> BoxedFuture<'_, Result, SourceError>> {
    +        Box::pin(async { Err(SourceError::Io(std::io::Error::other("not read"))) })
    +    }
    +
    +    fn etag(
    +        &self,
    +        _bucket: &BucketRef<'_>,
    +        _file: &InputFile,
    +    ) -> BoxedFuture<'_, Result> {
    +        Box::pin(async { Err(SourceError::Io(std::io::Error::other("not read"))) })
    +    }
    +
    +    fn line_count(
    +        &self,
    +        _bucket: &BucketRef<'_>,
    +        _file: &InputFile,
    +        _compression: InputCodec,
    +    ) -> BoxedFuture<'_, Result> {
    +        Box::pin(async { Err(SourceError::Io(std::io::Error::other("not read"))) })
    +    }
    +}
    +
    +pub(super) fn ctx_with_listing(h: &Harness, fault: Option) -> OperationContext {
    +    let mut ctx = base_ctx(h);
    +    let root = h.store.root();
    +    ctx.bulk = Some(Arc::new(BulkStore::with_parts(
    +        Arc::clone(&root),
    +        Arc::new(ListingFails { fault }),
    +        Arc::new(FsSink { root }),
    +    )));
    +    ctx
    +}
    +
    +#[tokio::test]
    +async fn a_store_fault_on_a_later_listing_page_starts_the_operation_terminal_failed() {
    +    let h = Harness::new().await;
    +    let ctx = ctx_with_listing(
    +        &h,
    +        Some(StoreFault {
    +            code: "S3NoSuchBucket",
    +            message: LATER_PAGE_MESSAGE.to_owned(),
    +            after_first_byte: false,
    +        }),
    +    );
    +    let body = with_top(start_body(), "ClientToken", json!("tok-later-page"));
    +    let description = start_ok(&ctx, body.clone()).await;
    +    assert_eq!(description["BulkOperationStatus"], json!("FAILED"));
    +    assert_eq!(description["FailureCode"], json!("S3NoSuchBucket"));
    +    assert_eq!(description["FailureMessage"], json!(LATER_PAGE_MESSAGE));
    +    assert_eq!(description["ProcessedItemCount"], json!(0));
    +    assert!(description.get("TotalInputBytes").is_none());
    +    assert_eq!(description["EndTime"], description["StartTime"]);
    +    let replay = start_ok(&ctx, body).await;
    +    assert_eq!(replay["BulkOperationArn"], description["BulkOperationArn"]);
    +}
    +
    +#[tokio::test]
    +async fn another_enumeration_failure_stays_an_internal_request_error() {
    +    let h = Harness::new().await;
    +    let ctx = ctx_with_listing(&h, None);
    +    let error = start(&ctx, start_body()).await.unwrap_err();
    +    assert!(
    +        matches!(&error, DynamoDbError::InternalServerError(message) if message == "Internal server error"),
    +        "{error:?}"
    +    );
    +}
    +
    +/// A sink whose Start-time encryption check fails with `fault`, or with an
    +/// I/O error when `fault` is `None`; it records the key it was asked about.
    +struct EncryptionFails {
    +    fault: Option,
    +    asked: std::sync::Mutex>,
    +}
    +
    +impl BulkSink for EncryptionFails {
    +    fn check_location(
    +        &self,
    +        _bucket: &BucketRef<'_>,
    +        _key: &str,
    +        _sse: &OutputSse,
    +    ) -> BoxedFuture<'_, Result<(), SinkError>> {
    +        Box::pin(async { Ok(()) })
    +    }
    +
    +    fn check_encryption(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        key: &str,
    +        sse: &OutputSse,
    +    ) -> BoxedFuture<'_, Result<(), SinkError>> {
    +        self.asked
    +            .lock()
    +            .unwrap()
    +            .push((bucket.bucket.to_owned(), key.to_owned(), sse.clone()));
    +        let error = match &self.fault {
    +            Some(fault) => SinkError::Store(fault.clone()),
    +            None => SinkError::Io(std::io::Error::other("connection reset")),
    +        };
    +        Box::pin(async move { Err(error) })
    +    }
    +
    +    fn open(
    +        &self,
    +        _bucket: &BucketRef<'_>,
    +        _prefix: &str,
    +        _operation_id: &str,
    +        _kind: extenddb_storage::ReportKind,
    +        _owner: &str,
    +        _sse: &OutputSse,
    +    ) -> BoxedFuture<'_, Result, SinkError>> {
    +        Box::pin(async { Err(SinkError::Io(std::io::Error::other("not written"))) })
    +    }
    +
    +    fn write_manifest(
    +        &self,
    +        _bucket: &BucketRef<'_>,
    +        _prefix: &str,
    +        _operation_id: &str,
    +        _owner: &str,
    +        _sse: &OutputSse,
    +        _lines: Vec,
    +    ) -> BoxedFuture<'_, Result> {
    +        Box::pin(async { Err(SinkError::Io(std::io::Error::other("not written"))) })
    +    }
    +}
    +
    +#[tokio::test]
    +async fn an_output_kms_key_the_store_refuses_starts_the_operation_terminal_failed() {
    +    let h = Harness::new().await;
    +    let root = h.store.root();
    +    let message = "arn:aws:kms:us-east-1:111122223333:key/k1 is disabled. (Service: Amazon S3; \
    +                   Status Code: 400; Error Code: KMS.DisabledException; Request ID: R1; S3 \
    +                   Extended Request ID: X1; Proxy: null)";
    +    let sink = Arc::new(EncryptionFails {
    +        fault: Some(StoreFault {
    +            code: "KmsDisabledException",
    +            message: message.to_owned(),
    +            after_first_byte: false,
    +        }),
    +        asked: std::sync::Mutex::new(Vec::new()),
    +    });
    +    let mut ctx = base_ctx(&h);
    +    ctx.bulk = Some(Arc::new(BulkStore::with_parts(
    +        Arc::clone(&root),
    +        Arc::new(FsSource {
    +            root: Arc::clone(&root),
    +        }),
    +        Arc::clone(&sink) as Arc,
    +    )));
    +    let body = with_output(
    +        with_output(start_body(), "S3SseAlgorithm", json!("KMS")),
    +        "S3SseKmsKeyId",
    +        json!("arn:aws:kms:us-east-1:111122223333:key/k1"),
    +    );
    +    let description = start_ok(&ctx, body).await;
    +    assert_eq!(description["BulkOperationStatus"], json!("FAILED"));
    +    assert_eq!(description["FailureCode"], json!("KmsDisabledException"));
    +    assert_eq!(description["FailureMessage"], json!(message));
    +    assert!(description.get("TotalInputBytes").is_none());
    +    assert!(description.get("ProcessedInputBytes").is_none());
    +    let asked = sink.asked.lock().unwrap().clone();
    +    assert_eq!(asked.len(), 1);
    +    let operation_id = description["BulkOperationArn"]
    +        .as_str()
    +        .and_then(|arn| arn.rsplit('/').next())
    +        .unwrap()
    +        .to_owned();
    +    assert!(
    +        asked[0].1.ends_with(&format!(
    +            "{ACCOUNT}:{operation_id}-0/output/errors/output-00000.json.gz"
    +        )),
    +        "{asked:?}"
    +    );
    +    assert_eq!(
    +        asked[0].2,
    +        OutputSse::Kms {
    +            key_id: Some("arn:aws:kms:us-east-1:111122223333:key/k1".to_owned())
    +        }
    +    );
    +
    +    // A failure of the check that the store did not render is the
    +    // server's: the request answers InternalServerError and no operation
    +    // is created.
    +    let mut ctx = base_ctx(&h);
    +    ctx.bulk = Some(Arc::new(BulkStore::with_parts(
    +        Arc::clone(&root),
    +        Arc::new(FsSource {
    +            root: Arc::clone(&root),
    +        }),
    +        Arc::new(EncryptionFails {
    +            fault: None,
    +            asked: std::sync::Mutex::new(Vec::new()),
    +        }),
    +    )));
    +    let error = start(&ctx, start_body()).await.unwrap_err();
    +    assert!(
    +        matches!(&error, DynamoDbError::InternalServerError(_)),
    +        "{error:?}"
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/tests/handlers/start_terminal_born.rs b/crates/engine/src/bulk_put/tests/handlers/start_terminal_born.rs
    new file mode 100644
    index 000000000..c0b382df4
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/start_terminal_born.rs
    @@ -0,0 +1,417 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Starts that create an already-terminal operation: a missing or
    +//! symlinked input bucket, an owner mismatch, the replay of a token that
    +//! named one, and the per-hour cap on terminal-born rows. The output
    +//! bucket checks that stay with the first report record are here too, and
    +//! the busy table that refuses such a start before anything is created.
    +
    +use super::*;
    +
    +#[tokio::test]
    +async fn a_missing_input_bucket_starts_the_operation_terminal_failed() {
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    let body = with_input(start_body(), "S3Bucket", json!("absent"));
    +    let description = start_ok(&ctx, body).await;
    +    assert_eq!(description["BulkOperationStatus"], json!("FAILED"));
    +    assert_eq!(description["FailureCode"], json!("S3NoSuchBucket"));
    +    assert_eq!(description["FailureMessage"], json!(NO_SUCH_BUCKET));
    +    assert_eq!(description["ProcessedItemCount"], json!(0));
    +    assert_eq!(description["FailedItemCount"], json!(0));
    +    // Born terminal before enumeration: the hosted service omits
    +    // TotalInputBytes here (measured on the service) while keeping the counters.
    +    assert!(
    +        description.get("TotalInputBytes").is_none(),
    +        "TotalInputBytes must be absent on a failure born before enumeration"
    +    );
    +    assert_eq!(
    +        description["EndTime"], description["StartTime"],
    +        "the operation ends the instant it starts"
    +    );
    +    // Started and finished both count the operation, the finish under the
    +    // FAILED status; no worker ever sees the row.
    +    let started = ctx.metrics.query(&extenddb_core::metrics::MetricsQuery {
    +        metric: Some(extenddb_core::metrics::MetricName::BulkOperationsStarted),
    +        window: Some(extenddb_core::metrics::TimeWindow::AllTime),
    +        ..extenddb_core::metrics::MetricsQuery::default()
    +    });
    +    assert_eq!(started.len(), 1, "one Started series: {started:?}");
    +    assert_eq!(started[0].count, 1, "started once");
    +    let finished = ctx.metrics.query(&extenddb_core::metrics::MetricsQuery {
    +        metric: Some(extenddb_core::metrics::MetricName::BulkOperationsFinished),
    +        window: Some(extenddb_core::metrics::TimeWindow::AllTime),
    +        ..extenddb_core::metrics::MetricsQuery::default()
    +    });
    +    assert_eq!(finished.len(), 1, "one Finished series: {finished:?}");
    +    assert_eq!(finished[0].count, 1, "finished once");
    +    assert!(
    +        finished[0]
    +            .dimensions
    +            .contains(&extenddb_core::metrics::Dimension::Operation(
    +                "FAILED".to_owned()
    +            )),
    +        "the Finished dimension carries FAILED: {:?}",
    +        finished[0].dimensions
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_missing_report_bucket_is_not_checked_at_start() {
    +    // Measured: only the input bucket is checked at Start; a missing
    +    // output bucket with clean rows completes. A report bucket missing at
    +    // render time fails the render instead.
    +    let h = Harness::new().await;
    +    // The input bucket exists (the harness staged it); only the named
    +    // report bucket directory is absent.
    +    let body = with_output(start_body(), "S3Bucket", json!("absent-reports"));
    +    let description = start_ok(&base_ctx(&h), body).await;
    +    assert_eq!(description["BulkOperationStatus"], json!("IN_PROGRESS"));
    +    assert!(
    +        description.get("FailureCode").is_none(),
    +        "no failure is recorded for a missing report bucket"
    +    );
    +}
    +
    +/// A symlink a tenant plants at a bucket name is a missing bucket on the
    +/// wire, wherever it points: the operation starts terminal `FAILED` with
    +/// `S3NoSuchBucket` and no row is read through the link. An existence
    +/// check that resolved the link would instead answer `IN_PROGRESS` and
    +/// enumerate the link's target.
    +#[cfg(unix)]
    +#[tokio::test]
    +async fn a_symlinked_input_bucket_starts_the_operation_terminal_failed() {
    +    let h = Harness::new().await;
    +    // The link's target is the account's own staged input bucket, so the
    +    // target genuinely exists, holds files, and would enumerate cleanly:
    +    // a FAILED verdict can only come from the link refusal.
    +    let target = h.store.0.join(ACCOUNT).join(IN_BUCKET);
    +    std::os::unix::fs::symlink(&target, h.store.0.join(ACCOUNT).join("linked")).unwrap();
    +    let body = with_input(start_body(), "S3Bucket", json!("linked"));
    +    let description = start_ok(&base_ctx(&h), body).await;
    +    assert_eq!(description["BulkOperationStatus"], json!("FAILED"));
    +    assert_eq!(description["FailureCode"], json!("S3NoSuchBucket"));
    +    assert_eq!(description["FailureMessage"], json!(NO_SUCH_BUCKET));
    +    assert!(
    +        description.get("TotalInputBytes").is_none(),
    +        "TotalInputBytes must be absent on a failure born before enumeration"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_replayed_token_returns_the_terminal_failed_operation() {
    +    let h = Harness::new().await;
    +    let body = with_input(
    +        with_top(start_body(), "ClientToken", json!("tok-failed-replay")),
    +        "S3Bucket",
    +        json!("absent"),
    +    );
    +    let first = start_ok(&base_ctx(&h), body.clone()).await;
    +    let second = start_ok(&base_ctx(&h), body).await;
    +    assert_eq!(second["BulkOperationArn"], first["BulkOperationArn"]);
    +    assert_eq!(second["BulkOperationStatus"], json!("FAILED"));
    +}
    +
    +#[tokio::test]
    +async fn a_replayed_token_wins_over_a_bucket_removed_after_the_first_start() {
    +    // The idempotency path is unchanged by the bucket check: the replay is
    +    // decided inside the start transaction, so a retry with the token of an
    +    // operation that started IN_PROGRESS returns that operation even when
    +    // the input bucket directory has been removed since. The retry must not
    +    // manufacture a new FAILED row.
    +    let h = Harness::new().await;
    +    let body = with_top(start_body(), "ClientToken", json!("tok-replay-wins"));
    +    let first = start_ok(&base_ctx(&h), body.clone()).await;
    +    assert_eq!(first["BulkOperationStatus"], json!("IN_PROGRESS"));
    +    std::fs::remove_dir_all(h.store.0.join(ACCOUNT).join(IN_BUCKET)).unwrap();
    +    let second = start_ok(&base_ctx(&h), body).await;
    +    assert_eq!(second["BulkOperationArn"], first["BulkOperationArn"]);
    +    assert_eq!(
    +        second["BulkOperationStatus"],
    +        json!("IN_PROGRESS"),
    +        "the replay answers the original operation, not a new terminal row"
    +    );
    +}
    +
    +#[cfg(unix)]
    +#[tokio::test]
    +async fn a_bucket_that_is_a_symlink_starts_the_operation_terminal_failed() {
    +    // The bucket existence check reads symlink_metadata, so a symlink at
    +    // the bucket name is never followed: even one pointing at a real
    +    // directory full of files answers the same terminal FAILED as a
    +    // missing bucket, matching the enumeration, which would not follow it.
    +    let h = Harness::new().await;
    +    let account_dir = h.store.0.join(ACCOUNT);
    +    let real = account_dir.join("real-bucket");
    +    std::fs::create_dir_all(&real).unwrap();
    +    std::fs::write(real.join("part-0.json"), b"{\"Item\": {}}\n").unwrap();
    +    std::os::unix::fs::symlink(&real, account_dir.join("linked")).unwrap();
    +    let body = with_input(start_body(), "S3Bucket", json!("linked"));
    +    let description = start_ok(&base_ctx(&h), body).await;
    +    assert_eq!(description["BulkOperationStatus"], json!("FAILED"));
    +    assert_eq!(description["FailureCode"], json!("S3NoSuchBucket"));
    +    assert_eq!(description["FailureMessage"], json!(NO_SUCH_BUCKET));
    +}
    +
    +#[tokio::test]
    +async fn the_account_directory_is_created_at_start_when_missing() {
    +    let h = Harness::new().await;
    +    let account_dir = h.store.0.join(ACCOUNT);
    +    std::fs::remove_dir_all(&account_dir).unwrap();
    +    // With the account directory gone the staged buckets are gone too, so
    +    // the start lands terminal FAILED; the directory itself must exist
    +    // again, created before the bucket check ran.
    +    let description = start_ok(&base_ctx(&h), start_body()).await;
    +    assert_eq!(description["BulkOperationStatus"], json!("FAILED"));
    +    assert_eq!(description["FailureCode"], json!("S3NoSuchBucket"));
    +    let meta = std::fs::symlink_metadata(&account_dir).expect("the account directory exists");
    +    assert!(meta.is_dir());
    +}
    +
    +#[tokio::test]
    +async fn a_missing_bucket_on_a_saturated_account_still_starts_terminal_failed() {
    +    // A row born terminal occupies no concurrency slot, so the account
    +    // limit is not consulted for it: the caller gets the failed operation,
    +    // not LimitExceededException.
    +    let h = Harness::new().await;
    +    Harness::create_table(&h.storage, "second").await;
    +    let ctx = ctx_with_limits(
    +        &h,
    +        bulk_limits(&[("max_concurrent_bulk_operations", json!(1))]),
    +    );
    +    start_ok(&ctx, start_body()).await;
    +    let body = with_input(
    +        with_top(start_body(), "TableName", json!("second")),
    +        "S3Bucket",
    +        json!("absent"),
    +    );
    +    let description = start_ok(&ctx, body).await;
    +    assert_eq!(description["BulkOperationStatus"], json!("FAILED"));
    +    assert_eq!(description["FailureCode"], json!("S3NoSuchBucket"));
    +}
    +
    +/// Insert one terminal-born row directly through the store with an
    +/// explicit start time, the way the cap's window test needs rows older
    +/// than the handler's real clock can produce.
    +async fn insert_terminal_born(h: &Harness, start_time_ms: i64) {
    +    let new = NewBulkOperation {
    +        operation_id: arn::generate_operation_id(),
    +        table_id: h.table_id.clone(),
    +        table_name: TABLE.to_owned(),
    +        table_arn: h.table_arn.clone(),
    +        client_token: None,
    +        client_token_hash: None,
    +        request_fingerprint: "fp-terminal-born".to_owned(),
    +        input_config: json!({"S3Bucket": "absent"}),
    +        output_config: json!({}),
    +        input_files: Vec::new(),
    +        max_write_request_units: None,
    +        performance_tier: Some("STANDARD".to_owned()),
    +        start_time_ms,
    +        total_input_bytes: None,
    +        initial_failure: Some(extenddb_storage::InitialFailure {
    +            code: "S3NoSuchBucket".to_owned(),
    +            message: "The specified bucket does not exist".to_owned(),
    +            cap: u64::MAX,
    +            window_start_ms: 0,
    +        }),
    +        run_context: None,
    +    };
    +    match h
    +        .storage
    +        .start_bulk_operation(ACCOUNT, new, 50)
    +        .await
    +        .expect("insert a terminal-born row")
    +    {
    +        StartOutcome::Created(_) => {}
    +        _ => panic!("the direct insert must create the row"),
    +    }
    +}
    +
    +#[tokio::test]
    +async fn the_101st_missing_bucket_start_in_an_hour_is_refused() {
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    let body = with_input(start_body(), "S3Bucket", json!("absent"));
    +    // The default cap admits one hundred terminal-born starts. Terminal
    +    // rows trip neither the per-table guard nor the account limit, so one
    +    // table serves them all.
    +    for _ in 0..100 {
    +        let description = start_ok(&ctx, body.clone()).await;
    +        assert_eq!(description["BulkOperationStatus"], json!("FAILED"));
    +    }
    +    assert_eq!(expect_throttling(start(&ctx, body).await), THROTTLED);
    +    // The refusal inserted nothing: the account still holds exactly the
    +    // hundred terminal-born rows.
    +    let born = h
    +        .storage
    +        .count_terminal_born_since(ACCOUNT, 0)
    +        .await
    +        .expect("count");
    +    assert_eq!(born, 100, "the refused request must not insert a row");
    +}
    +
    +#[tokio::test]
    +async fn a_start_on_an_existing_bucket_is_neither_counted_nor_refused_by_the_cap() {
    +    let h = Harness::new().await;
    +    Harness::create_table(&h.storage, "second").await;
    +    Harness::create_table(&h.storage, "third").await;
    +    let ctx = ctx_with_limits(
    +        &h,
    +        bulk_limits(&[("max_bulk_failed_starts_per_hour", json!(1))]),
    +    );
    +    // A start on an existing bucket succeeds and does not consume the cap
    +    // of one: the missing-bucket start after it still lands terminal.
    +    let ok = start_ok(&ctx, start_body()).await;
    +    assert_eq!(ok["BulkOperationStatus"], json!("IN_PROGRESS"));
    +    let missing = with_input(
    +        with_top(start_body(), "TableName", json!("second")),
    +        "S3Bucket",
    +        json!("absent"),
    +    );
    +    let failed = start_ok(&ctx, missing.clone()).await;
    +    assert_eq!(failed["BulkOperationStatus"], json!("FAILED"));
    +    // The cap is now consumed, so the next missing-bucket start is
    +    // refused; a start on an existing bucket is still admitted.
    +    assert_eq!(expect_throttling(start(&ctx, missing).await), THROTTLED);
    +    let still_ok = start_ok(&ctx, with_top(start_body(), "TableName", json!("third"))).await;
    +    assert_eq!(still_ok["BulkOperationStatus"], json!("IN_PROGRESS"));
    +}
    +
    +#[tokio::test]
    +async fn the_failed_start_cap_ignores_rows_outside_the_hour_window() {
    +    let h = Harness::new().await;
    +    let ctx = ctx_with_limits(
    +        &h,
    +        bulk_limits(&[("max_bulk_failed_starts_per_hour", json!(1))]),
    +    );
    +    // Two terminal-born rows older than the window, inserted through the
    +    // store with explicit start times.
    +    insert_terminal_born(&h, now_ms() - 3_700_000).await;
    +    insert_terminal_born(&h, now_ms() - 7_200_000).await;
    +    // They are invisible to the cap: a fresh missing-bucket start still
    +    // answers the terminal FAILED operation under a cap of one.
    +    let body = with_input(start_body(), "S3Bucket", json!("absent"));
    +    let description = start_ok(&ctx, body.clone()).await;
    +    assert_eq!(description["BulkOperationStatus"], json!("FAILED"));
    +    // The fresh row is inside the window, so the cap of one now refuses.
    +    assert_eq!(expect_throttling(start(&ctx, body).await), THROTTLED);
    +}
    +
    +#[tokio::test]
    +async fn a_report_bucket_directory_that_does_not_exist_is_not_checked_at_start() {
    +    let h = Harness::new().await;
    +    // The S3Bucket member is required at Start, but whether the
    +    // directory it names exists is decided at the first report record,
    +    // not here. Only the input bucket exists; the report bucket directory
    +    // is gone.
    +    std::fs::remove_dir_all(h.store.0.join(ACCOUNT).join(OUT_BUCKET)).unwrap();
    +    let body = with_top(
    +        start_body(),
    +        "OutputS3Configuration",
    +        json!({ "OutputType": ["ERROR"], "S3Bucket": OUT_BUCKET }),
    +    );
    +    let description = start_ok(&base_ctx(&h), body).await;
    +    assert_eq!(description["BulkOperationStatus"], json!("IN_PROGRESS"));
    +}
    +
    +/// The ids of every operation the account holds, newest first.
    +async fn stored_operation_ids(h: &Harness) -> Vec {
    +    let (records, _) = h
    +        .storage
    +        .list_bulk_operations(ACCOUNT, 50, None)
    +        .await
    +        .expect("list the stored operations");
    +    records.into_iter().map(|r| r.operation_id).collect()
    +}
    +
    +/// With an operation running on the table, a valid Start, one naming an
    +/// input bucket that does not exist, one whose prefix matches no object
    +/// and another valid one each answer the busy message and create nothing
    +/// (measured on the hosted service with these four Starts). Once the
    +/// operation has finished, the missing-bucket Start is born `FAILED`.
    +#[tokio::test]
    +async fn every_start_on_a_busy_table_answers_busy_whatever_its_input() {
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    h.write_input("data/part-0.json", b"0123456789");
    +    let with_data = with_input(start_body(), "S3KeyPrefix", json!("data/"));
    +    let running = start_ok(&ctx, with_data.clone()).await;
    +    assert_eq!(running["BulkOperationStatus"], json!("IN_PROGRESS"));
    +    let running_id = description_arn(&running)
    +        .rsplit('/')
    +        .next()
    +        .expect("operation id")
    +        .to_owned();
    +
    +    let missing = with_input(start_body(), "S3Bucket", json!("absent"));
    +    let no_match = with_input(start_body(), "S3KeyPrefix", json!("nothing-here/"));
    +    for (label, body) in [
    +        ("a valid start", with_data.clone()),
    +        ("a missing input bucket", missing.clone()),
    +        ("a prefix that matches no object", no_match),
    +        ("another valid start", with_data),
    +    ] {
    +        match start(&ctx, body).await {
    +            Err(DynamoDbError::ResourceInUseException(message)) => {
    +                assert_eq!(message, TABLE_BUSY, "{label}");
    +            }
    +            other => panic!("{label}: expected the busy answer, got {other:?}"),
    +        }
    +    }
    +    assert_eq!(
    +        stored_operation_ids(&h).await,
    +        vec![running_id.clone()],
    +        "the refused starts created nothing"
    +    );
    +
    +    let claimed = h
    +        .storage
    +        .claim_runnable("owner-1", Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert!(claimed.iter().any(|r| r.operation_id == running_id));
    +    finish_op(&h, ACCOUNT, "owner-1", &running_id, "COMPLETED").await;
    +    let born = start_ok(&ctx, missing).await;
    +    assert_eq!(born["BulkOperationStatus"], json!("FAILED"));
    +    assert_eq!(born["FailureCode"], json!("S3NoSuchBucket"));
    +    assert_eq!(born["FailureMessage"], json!(NO_SUCH_BUCKET));
    +}
    +
    +/// At the failed-start cap a missing-bucket Start on a table with an
    +/// operation running still answers busy, as the start transaction orders
    +/// the two; on an idle table it is throttled. Neither inserts a row.
    +#[tokio::test]
    +async fn a_missing_bucket_start_at_the_cap_answers_busy_on_a_busy_table() {
    +    let h = Harness::new().await;
    +    Harness::create_table(&h.storage, "second").await;
    +    let ctx = ctx_with_limits(
    +        &h,
    +        bulk_limits(&[("max_bulk_failed_starts_per_hour", json!(1))]),
    +    );
    +    let missing_on_second = with_input(
    +        with_top(start_body(), "TableName", json!("second")),
    +        "S3Bucket",
    +        json!("absent"),
    +    );
    +    let failed = start_ok(&ctx, missing_on_second.clone()).await;
    +    assert_eq!(failed["BulkOperationStatus"], json!("FAILED"));
    +    start_ok(&ctx, start_body()).await;
    +
    +    let missing_on_busy = with_input(start_body(), "S3Bucket", json!("absent"));
    +    assert_eq!(
    +        expect_in_use(start(&ctx, missing_on_busy).await),
    +        TABLE_BUSY
    +    );
    +    assert_eq!(
    +        expect_throttling(start(&ctx, missing_on_second).await),
    +        THROTTLED
    +    );
    +    let born = h
    +        .storage
    +        .count_terminal_born_since(ACCOUNT, 0)
    +        .await
    +        .expect("count");
    +    assert_eq!(born, 1, "neither refused start inserted a row");
    +}
    diff --git a/crates/engine/src/bulk_put/tests/handlers/start_validation.rs b/crates/engine/src/bulk_put/tests/handlers/start_validation.rs
    new file mode 100644
    index 000000000..be25df3fc
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/start_validation.rs
    @@ -0,0 +1,362 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// Start: validation order. Each request is wrong at one step and also
    +// wrong at a later step, so the answered error proves the earlier step ran
    +// first.
    +
    +#[tokio::test]
    +async fn a_bad_enum_value_is_rejected_before_a_bad_prefix() {
    +    let h = Harness::new().await;
    +    let body = with_input(
    +        with_input(start_body(), "InputFormat", json!("CSV")),
    +        "S3KeyPrefix",
    +        json!("a/../b"),
    +    );
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert_eq!(
    +        message,
    +        "1 validation error detected: Value 'CSV' at 'inputS3Configuration.inputFormat' \
    +         failed to satisfy constraint: Member must satisfy enum value set: [ION, DYNAMODB_JSON]",
    +        "step 1 must answer before step 2, with the measured nested path and set order"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_bad_output_bucket_pattern_answers_before_an_empty_output_type() {
    +    // Measured on the service: an empty OutputType beside a bad output
    +    // bucket pattern answers the bucket pattern, a model constraint, not
    +    // the empty-OutputType `Invalid Request:` check.
    +    let h = Harness::new().await;
    +    let body = with_output(
    +        with_output(start_body(), "S3Bucket", json!("b")),
    +        "OutputType",
    +        json!([]),
    +    );
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert_eq!(
    +        message,
    +        "1 validation error detected: Value 'b' at 'outputS3Configuration.s3Bucket' failed \
    +         to satisfy constraint: Member must satisfy regular expression pattern: \
    +         ^[a-z0-9A-Z]+[\\.\\-\\w]*[a-z0-9A-Z]+$"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_table_name_over_1024_answers_before_a_short_prefix() {
    +    // A TableName of 1025 characters beside S3KeyPrefix 'x/'
    +    // answers only the tableName length, a model constraint, not the
    +    // prefix minimum, an `Invalid Request:` check.
    +    let h = Harness::new().await;
    +    let name = "a".repeat(1025);
    +    let body = with_top(
    +        with_input(start_body(), "S3KeyPrefix", json!("x/")),
    +        "TableName",
    +        json!(name),
    +    );
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert_eq!(
    +        message,
    +        format!(
    +            "1 validation error detected: Value '{name}' at 'tableName' failed to satisfy \
    +             constraint: Member must have length less than or equal to 1024"
    +        )
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_bad_prefix_is_rejected_before_unsupported_ion() {
    +    let h = Harness::new().await;
    +    let body = with_input(
    +        with_input(start_body(), "InputFormat", json!("ION")),
    +        "S3KeyPrefix",
    +        json!("a/../b"),
    +    );
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert_eq!(message, BAD_PREFIX, "step 2 must answer before step 3");
    +}
    +
    +#[tokio::test]
    +async fn unsupported_ion_is_rejected_before_a_missing_store() {
    +    let h = Harness::new().await;
    +    let body = with_input(start_body(), "InputFormat", json!("ION"));
    +    let message = expect_validation(start(&ctx_without_store(&h), body).await);
    +    assert_eq!(message, ION_REFUSED, "step 3 must answer before step 4");
    +}
    +
    +#[tokio::test]
    +async fn a_bad_prefix_is_rejected_before_a_missing_store() {
    +    let h = Harness::new().await;
    +    let body = with_input(start_body(), "S3KeyPrefix", json!("a/../b"));
    +    let message = expect_validation(start(&ctx_without_store(&h), body).await);
    +    assert_eq!(message, BAD_PREFIX, "step 2 must answer before step 4");
    +}
    +
    +#[tokio::test]
    +async fn a_missing_store_is_rejected_before_a_missing_table() {
    +    let h = Harness::new().await;
    +    let body = with_top(start_body(), "TableName", json!("absent"));
    +    let message = expect_validation(start(&ctx_without_store(&h), body).await);
    +    assert_eq!(message, BULK_DISABLED, "step 4 must answer before step 5");
    +}
    +
    +#[tokio::test]
    +async fn a_missing_table_is_rejected_before_the_input_file_cap() {
    +    let h = Harness::new().await;
    +    for i in 0..3 {
    +        h.write_input(&format!("part-{i}.json"), b"0123456789");
    +    }
    +    let ctx = ctx_with_limits(&h, bulk_limits(&[("max_bulk_input_files", json!(2))]));
    +    let body = with_top(start_body(), "TableName", json!("absent"));
    +    let message = expect_not_found(start(&ctx, body).await);
    +    assert_eq!(
    +        message,
    +        format!("{TABLE_NOT_FOUND}: Table: absent not found"),
    +        "step 5 must answer before step 6"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn the_table_guard_answers_an_input_over_the_object_count_cap() {
    +    // The count cap is the runner's failure, not a Start refusal,
    +    // so a busy table is what the second start hears.
    +    let h = Harness::new().await;
    +    start_ok(&base_ctx(&h), start_body()).await;
    +    for i in 0..3 {
    +        h.write_input(&format!("part-{i}.json"), b"0123456789");
    +    }
    +    let ctx = ctx_with_limits(&h, bulk_limits(&[("max_bulk_input_files", json!(2))]));
    +    let message = expect_in_use(start(&ctx, start_body()).await);
    +    assert_eq!(message, TABLE_BUSY);
    +}
    +
    +// The three step 2 checks the prefix chain does not reach: each pairs the
    +// field fault with a missing table, so the field's message proves the
    +// check ran before the table lookup at step 5.
    +
    +#[tokio::test]
    +async fn an_oversized_configuration_is_rejected_before_a_missing_table() {
    +    let h = Harness::new().await;
    +    // Every string member is bounded below the total cap on its own, so the
    +    // total is reached through the one unbounded member, the OutputType
    +    // list, which accepts repeated values.
    +    let body = with_top(
    +        with_output(start_body(), "OutputType", json!(vec!["ERROR"; 1200])),
    +        "TableName",
    +        json!("absent"),
    +    );
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert_eq!(message, CONFIG_TOO_BIG, "step 2 must answer before step 5");
    +}
    +
    +#[tokio::test]
    +async fn a_token_over_the_byte_cap_is_rejected_before_a_missing_table() {
    +    let h = Harness::new().await;
    +    let ctx = ctx_with_limits(
    +        &h,
    +        bulk_limits(&[("max_bulk_client_token_bytes", json!(64))]),
    +    );
    +    let body = with_top(
    +        with_top(start_body(), "ClientToken", json!("t".repeat(65))),
    +        "TableName",
    +        json!("absent"),
    +    );
    +    let message = expect_validation(start(&ctx, body).await);
    +    assert_eq!(
    +        message, "ClientToken exceeds max_bulk_client_token_bytes (64 bytes)",
    +        "step 2 must answer before step 5"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_missing_table_answers_before_the_owner_mismatch() {
    +    // The owner check moved from validation step 2 to the terminal-born
    +    // insert, which needs the table row, so the table lookup answers
    +    // first.
    +    let h = Harness::new().await;
    +    let body = with_top(
    +        with_input(start_body(), "S3BucketOwner", json!(OTHER_ACCOUNT)),
    +        "TableName",
    +        json!("absent"),
    +    );
    +    match start(&base_ctx(&h), body).await {
    +        Err(DynamoDbError::ResourceNotFoundException(_)) => {}
    +        other => panic!("expected ResourceNotFoundException, got {other:?}"),
    +    }
    +}
    +
    +// Start: field constraints, each alone in an otherwise valid request.
    +
    +// Measured on the service: every value under 1024 (0, 1, 1000 and 1023)
    +// answers the model clause with the 1024 minimum; 1024 starts and is
    +// echoed. Negative values answer the same clause.
    +#[tokio::test]
    +async fn max_write_request_units_below_1024_fails_the_model_minimum() {
    +    let h = Harness::new().await;
    +    for units in [-5, 0, 1, 1000, 1023] {
    +        let body = with_top(start_body(), "MaxWriteRequestUnits", json!(units));
    +        let message = expect_validation(start(&base_ctx(&h), body).await);
    +        assert_eq!(
    +            message,
    +            format!(
    +                "1 validation error detected: Value '{units}' at 'maxWriteRequestUnits' failed \
    +                 to satisfy constraint: Member must have value greater than or equal to 1024"
    +            ),
    +            "units {units}"
    +        );
    +    }
    +}
    +
    +#[tokio::test]
    +async fn max_write_request_units_1024_is_accepted_and_echoed() {
    +    let h = Harness::new().await;
    +    let body = with_top(start_body(), "MaxWriteRequestUnits", json!(1024));
    +    let description = start_ok(&base_ctx(&h), body).await;
    +    assert_eq!(description["MaxWriteRequestUnits"], json!(1024));
    +}
    +
    +#[tokio::test]
    +async fn a_client_token_containing_a_dollar_sign_is_rejected() {
    +    let h = Harness::new().await;
    +    let body = with_top(start_body(), "ClientToken", json!("bad$token"));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert!(
    +        message.contains("Member must satisfy regular expression pattern"),
    +        "the token pattern must be reported: {message}"
    +    );
    +    assert!(
    +        message.contains("clientToken"),
    +        "the field must be named: {message}"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_empty_client_token_is_rejected_with_the_pattern_message() {
    +    // The pattern's `+` requires at least one character, so the empty
    +    // string fails it the same way a `$` does.
    +    let h = Harness::new().await;
    +    let body = with_top(start_body(), "ClientToken", json!(""));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert_eq!(
    +        message,
    +        "1 validation error detected: Value '' at 'clientToken' failed to \
    +         satisfy constraint: Member must satisfy regular expression pattern: ^[^\\$]+$"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_client_token_containing_a_nul_byte_is_rejected_with_the_pattern_message() {
    +    // The token is stored verbatim in a text column, and PostgreSQL
    +    // refuses a raw NUL byte in text, so the handler rejects the byte
    +    // with the pattern message.
    +    let h = Harness::new().await;
    +    let body = with_top(start_body(), "ClientToken", json!("load\u{0}a"));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert!(
    +        message.contains("Member must satisfy regular expression pattern"),
    +        "the token pattern must be reported: {message}"
    +    );
    +    assert!(
    +        message.contains("clientToken"),
    +        "the field must be named: {message}"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_configuration_string_containing_a_nul_byte_is_rejected() {
    +    // The recorded fields are stored as submitted, and PostgreSQL jsonb
    +    // refuses U+0000 in strings, so step 2 rejects the byte in any string
    +    // field of the two configurations.
    +    let h = Harness::new().await;
    +    let body = with_output(start_body(), "S3SseKmsKeyId", json!("key\u{0}id"));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert_eq!(message, "S3 configuration contains an invalid character");
    +}
    +
    +#[tokio::test]
    +async fn a_client_token_over_the_byte_cap_is_rejected() {
    +    let h = Harness::new().await;
    +    let ctx = ctx_with_limits(
    +        &h,
    +        bulk_limits(&[("max_bulk_client_token_bytes", json!(64))]),
    +    );
    +    let body = with_top(start_body(), "ClientToken", json!("t".repeat(65)));
    +    let message = expect_validation(start(&ctx, body).await);
    +    assert_eq!(
    +        message,
    +        "ClientToken exceeds max_bulk_client_token_bytes (64 bytes)"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_client_token_exactly_at_the_byte_cap_is_accepted() {
    +    let h = Harness::new().await;
    +    let ctx = ctx_with_limits(
    +        &h,
    +        bulk_limits(&[("max_bulk_client_token_bytes", json!(64))]),
    +    );
    +    let token = "t".repeat(64);
    +    let body = with_top(start_body(), "ClientToken", json!(token));
    +    let description = start_ok(&ctx, body).await;
    +    assert_eq!(description["ClientToken"], json!(token));
    +}
    +
    +#[tokio::test]
    +async fn an_empty_output_type_list_is_rejected() {
    +    let h = Harness::new().await;
    +    let body = with_output(start_body(), "OutputType", json!([]));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert_eq!(message, EMPTY_OUTPUT_TYPE);
    +}
    +
    +#[tokio::test]
    +async fn an_output_type_outside_the_enum_is_rejected() {
    +    let h = Harness::new().await;
    +    let body = with_output(start_body(), "OutputType", json!(["EVERYTHING"]));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert_eq!(
    +        message,
    +        "1 validation error detected: Value '[EVERYTHING]' at \
    +         'outputS3Configuration.outputType' failed to satisfy constraint: Member must satisfy \
    +         constraint: [Member must satisfy enum value set: [ERROR, CONDITION_CHECK_FAILURE]]"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_sse_algorithm_outside_the_enum_is_rejected() {
    +    let h = Harness::new().await;
    +    let body = with_output(start_body(), "S3SseAlgorithm", json!("XX"));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert_eq!(
    +        message,
    +        "1 validation error detected: Value 'XX' at 'outputS3Configuration.s3SseAlgorithm' \
    +         failed to satisfy constraint: Member must satisfy enum value set: [AES256, KMS]"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_performance_tier_outside_the_enum_is_rejected() {
    +    let h = Harness::new().await;
    +    let body = with_top(start_body(), "PerformanceTier", json!("NO_SUCH_TIER"));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert_eq!(
    +        message,
    +        "1 validation error detected: Value 'NO_SUCH_TIER' at 'performanceTier' failed to \
    +         satisfy constraint: Member must satisfy enum value set: [ACCELERATED, STANDARD]"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_input_compression_type_outside_the_enum_is_rejected() {
    +    let h = Harness::new().await;
    +    let body = with_input(start_body(), "InputCompressionType", json!("BZIP2"));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert_eq!(
    +        message,
    +        "1 validation error detected: Value 'BZIP2' at \
    +         'inputS3Configuration.inputCompressionType' failed to satisfy constraint: Member must \
    +         satisfy enum value set: [ZSTD, NONE, GZIP]"
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/tests/handlers/unsupported_backend.rs b/crates/engine/src/bulk_put/tests/handlers/unsupported_backend.rs
    new file mode 100644
    index 000000000..27bfc552d
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/unsupported_backend.rs
    @@ -0,0 +1,694 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +// The MongoDB and Cassandra backends refuse every bulk operation with
    +// ValidationException "Bulk operations are not supported on this storage
    +// backend". This double stands in for such a backend: it
    +// delegates describe_table to a real engine (so Start passes the table
    +// lookup) and refuses every BulkOperationStore method, so every handler
    +// path reaches the refusal the client sees.
    +use std::sync::Arc;
    +use std::time::{Duration, SystemTime};
    +
    +use extenddb_core::expression::{Expr, ExpressionMaps, KeyCondition, UpdateAction};
    +use extenddb_core::types::{
    +    CreateTableInput, DeleteTableInput, DescribeStreamInput, DescribeTableInput, IndexInfo, Item,
    +    ListTablesInput, ListTablesOutput, StreamDescription, StreamRecord, TableDescription,
    +    TableKeyInfo, Tag, TimeToLiveDescription, UpdateTableInput,
    +};
    +use extenddb_storage::error::StorageError;
    +use extenddb_storage::{
    +    BoxedFuture as BoxFuture, BulkOperationListResult, BulkOperationRecord, BulkOperationStore,
    +    BulkRowMarker, CancelOutcome, DataEngine, FinishState, IdempotencyKey, ItemPairResult,
    +    LeaseState, ListCursor, MetadataEngine, NewBulkOperation, Progress, QueryResult, ReportKind,
    +    ReportRecord, RowOutcome, StartOutcome, StorageEngine, StreamCapture, StreamEngine,
    +    StreamListResult, StreamRecordsResult, TableEngine, Terminal, TransactGetOp, TransactWriteOp,
    +    TtlTableInfo, WorkerStore,
    +};
    +
    +/// The refusal message both unsupported backends answer. A toggle lets
    +/// a test turn the refusal into an internal error.
    +pub(super) const UNSUPPORTED_MESSAGE: &str =
    +    "Bulk operations are not supported on this storage backend";
    +
    +fn refuse() -> BoxFuture<'static, Result> {
    +    Box::pin(async { Err(StorageError::Validation(UNSUPPORTED_MESSAGE.to_owned())) })
    +}
    +
    +/// A storage engine whose bulk store refuses every operation, wrapping a
    +/// real engine for the catalog lookup Start performs before it. When
    +/// `creating_index` is set, the description it answers marks that
    +/// global secondary index `CREATING`, the state a backfill leaves it in.
    +pub(super) struct BulkUnsupportedStorage {
    +    pub(super) inner: Arc,
    +    pub(super) creating_index: Option,
    +}
    +
    +impl TableEngine for BulkUnsupportedStorage {
    +    fn create_table(
    +        &self,
    +        _account_id: &str,
    +        _input: CreateTableInput,
    +    ) -> BoxFuture<'_, Result> {
    +        unimplemented!()
    +    }
    +    fn delete_table(
    +        &self,
    +        _account_id: &str,
    +        _input: DeleteTableInput,
    +    ) -> BoxFuture<'_, Result> {
    +        unimplemented!()
    +    }
    +    fn describe_table(
    +        &self,
    +        account_id: &str,
    +        input: DescribeTableInput,
    +    ) -> BoxFuture<'_, Result> {
    +        let described = self.inner.describe_table(account_id, input);
    +        let creating = self.creating_index.clone();
    +        Box::pin(async move {
    +            let mut table = described.await?;
    +            if let Some(name) = creating {
    +                for index in table.global_secondary_indexes.iter_mut().flatten() {
    +                    if index.index_name == name {
    +                        index.index_status = "CREATING".to_owned();
    +                    }
    +                }
    +            }
    +            Ok(table)
    +        })
    +    }
    +    fn list_tables(
    +        &self,
    +        _account_id: &str,
    +        _input: ListTablesInput,
    +    ) -> BoxFuture<'_, Result> {
    +        unimplemented!()
    +    }
    +    fn update_table(
    +        &self,
    +        _account_id: &str,
    +        _input: UpdateTableInput,
    +    ) -> BoxFuture<'_, Result> {
    +        unimplemented!()
    +    }
    +    fn table_key_info(
    +        &self,
    +        _account_id: &str,
    +        _table_name: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        unimplemented!()
    +    }
    +    fn index_info(
    +        &self,
    +        _account_id: &str,
    +        _table_name: &str,
    +        _index_name: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        unimplemented!()
    +    }
    +    fn index_info_by_table_id(
    +        &self,
    +        _table_id: &str,
    +        _index_name: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        unimplemented!()
    +    }
    +}
    +
    +impl DataEngine for BulkUnsupportedStorage {
    +    fn put_items_batch(
    +        &self,
    +        _key_info: &TableKeyInfo,
    +        _rows: Vec,
    +        _stream: Option<&StreamCapture>,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        refuse()
    +    }
    +    #[allow(clippy::too_many_arguments)]
    +    fn put_item(
    +        &self,
    +        _key_info: &TableKeyInfo,
    +        _item: Item,
    +        _return_old: bool,
    +        _condition: Option<&Expr>,
    +        _maps: &ExpressionMaps,
    +        _stream: Option<&StreamCapture>,
    +        _marker: Option<&BulkRowMarker>,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        unimplemented!()
    +    }
    +    fn bulk_row_outcome(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +        _file_index: i64,
    +        _row_index: i64,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        unimplemented!()
    +    }
    +    fn delete_bulk_row_outcomes(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        unimplemented!()
    +    }
    +    fn get_item(
    +        &self,
    +        _key_info: &TableKeyInfo,
    +        _key: &Item,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        unimplemented!()
    +    }
    +    #[allow(clippy::too_many_arguments)]
    +    fn delete_item(
    +        &self,
    +        _key_info: &TableKeyInfo,
    +        _key: &Item,
    +        _return_old: bool,
    +        _condition: Option<&Expr>,
    +        _maps: &ExpressionMaps,
    +        _stream: Option<&StreamCapture>,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        unimplemented!()
    +    }
    +    #[allow(clippy::too_many_arguments)]
    +    fn update_item(
    +        &self,
    +        _key_info: &TableKeyInfo,
    +        _key: &Item,
    +        _actions: &[UpdateAction],
    +        _return_old: bool,
    +        _return_new: bool,
    +        _condition: Option<&Expr>,
    +        _maps: &ExpressionMaps,
    +        _stream: Option<&StreamCapture>,
    +    ) -> BoxFuture<'_, ItemPairResult> {
    +        unimplemented!()
    +    }
    +    #[allow(clippy::too_many_arguments)]
    +    fn query(
    +        &self,
    +        _key_info: &TableKeyInfo,
    +        _key_condition: &KeyCondition,
    +        _maps: &ExpressionMaps,
    +        _forward: bool,
    +        _limit: Option,
    +        _exclusive_start_key: Option<&Item>,
    +        _index_name: Option<&str>,
    +    ) -> BoxFuture<'_, QueryResult> {
    +        unimplemented!()
    +    }
    +    #[allow(clippy::too_many_arguments)]
    +    fn scan(
    +        &self,
    +        _key_info: &TableKeyInfo,
    +        _limit: Option,
    +        _exclusive_start_key: Option<&Item>,
    +        _segment: Option,
    +        _total_segments: Option,
    +        _index_name: Option<&str>,
    +    ) -> BoxFuture<'_, QueryResult> {
    +        unimplemented!()
    +    }
    +    fn scan_key_in_segment(
    +        &self,
    +        _key_info: &TableKeyInfo,
    +        _key: &Item,
    +        _segment: i64,
    +        _total_segments: i64,
    +        _index_name: Option<&str>,
    +    ) -> BoxFuture<'_, Result> {
    +        unimplemented!()
    +    }
    +    fn transact_get_items(
    +        &self,
    +        _ops: &[TransactGetOp<'_>],
    +    ) -> BoxFuture<'_, Result>, StorageError>> {
    +        unimplemented!()
    +    }
    +    fn transact_write_items(
    +        &self,
    +        _ops: &[TransactWriteOp<'_>],
    +        _idempotency: Option>,
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        unimplemented!()
    +    }
    +    fn cleanup_expired_idempotency_tokens(
    +        &self,
    +        _max_age_seconds: i64,
    +    ) -> BoxFuture<'_, Result> {
    +        unimplemented!()
    +    }
    +}
    +
    +impl MetadataEngine for BulkUnsupportedStorage {
    +    fn describe_ttl(
    +        &self,
    +        _account_id: &str,
    +        _table_name: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        unimplemented!()
    +    }
    +    fn update_ttl(
    +        &self,
    +        _account_id: &str,
    +        _table_name: &str,
    +        _attribute_name: &str,
    +        _enabled: bool,
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        unimplemented!()
    +    }
    +    fn tag_resource(&self, _arn: &str, _tags: &[Tag]) -> BoxFuture<'_, Result<(), StorageError>> {
    +        unimplemented!()
    +    }
    +    fn untag_resource(
    +        &self,
    +        _arn: &str,
    +        _tag_keys: &[String],
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        unimplemented!()
    +    }
    +    fn list_tags(&self, _arn: &str) -> BoxFuture<'_, Result, StorageError>> {
    +        unimplemented!()
    +    }
    +    fn tables_with_ttl(
    +        &self,
    +        _account_id: &str,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        unimplemented!()
    +    }
    +    fn all_tables_with_ttl(&self) -> BoxFuture<'_, Result, StorageError>> {
    +        unimplemented!()
    +    }
    +    fn all_tables_with_ttl_index_ready(
    +        &self,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        unimplemented!()
    +    }
    +    fn create_ttl_index(
    +        &self,
    +        _account_id: &str,
    +        _table_name: &str,
    +        _ttl_attribute: &str,
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        unimplemented!()
    +    }
    +    fn drop_ttl_index(
    +        &self,
    +        _account_id: &str,
    +        _table_name: &str,
    +        _ttl_attribute: &str,
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        unimplemented!()
    +    }
    +    fn find_expired_items_indexed(
    +        &self,
    +        _account_id: &str,
    +        _table_name: &str,
    +        _ttl_attribute: &str,
    +        _limit: usize,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        unimplemented!()
    +    }
    +    fn refresh_table_size(
    +        &self,
    +        _account_id: &str,
    +        _table_name: &str,
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        unimplemented!()
    +    }
    +    fn list_active_table_names(
    +        &self,
    +        _account_id: &str,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        unimplemented!()
    +    }
    +    fn all_active_tables(&self) -> BoxFuture<'_, Result, StorageError>> {
    +        unimplemented!()
    +    }
    +}
    +
    +impl StreamEngine for BulkUnsupportedStorage {
    +    fn write_stream_record(
    +        &self,
    +        _account_id: &str,
    +        _record: &StreamRecord,
    +        _shard_id: &str,
    +        _table_name: &str,
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        unimplemented!()
    +    }
    +    fn get_stream_records(
    +        &self,
    +        _account_id: &str,
    +        _stream_arn: &str,
    +        _shard_id: &str,
    +        _after_sequence: Option<&str>,
    +        _limit: i64,
    +    ) -> BoxFuture<'_, StreamRecordsResult> {
    +        unimplemented!()
    +    }
    +    fn describe_stream(
    +        &self,
    +        _account_id: &str,
    +        _input: &DescribeStreamInput,
    +    ) -> BoxFuture<'_, Result> {
    +        unimplemented!()
    +    }
    +    fn list_streams(
    +        &self,
    +        _account_id: &str,
    +        _table_name: Option<&str>,
    +        _limit: i64,
    +        _exclusive_start_stream_arn: Option<&str>,
    +    ) -> BoxFuture<'_, StreamListResult> {
    +        unimplemented!()
    +    }
    +    fn cleanup_expired_stream_records(
    +        &self,
    +        _retention_hours: i64,
    +    ) -> BoxFuture<'_, Result> {
    +        unimplemented!()
    +    }
    +    fn assign_shard(
    +        &self,
    +        _account_id: &str,
    +        _table_name: &str,
    +        _partition_key: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        unimplemented!()
    +    }
    +    fn next_sequence_number(&self, _shard_id: &str) -> BoxFuture<'_, Result> {
    +        unimplemented!()
    +    }
    +    fn validate_shard(
    +        &self,
    +        _account_id: &str,
    +        _stream_arn: &str,
    +        _shard_id: &str,
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        unimplemented!()
    +    }
    +    fn latest_sequence_number(
    +        &self,
    +        _shard_id: &str,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        unimplemented!()
    +    }
    +}
    +
    +impl WorkerStore for BulkUnsupportedStorage {
    +    fn process_control_plane_transitions(
    +        &self,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        unimplemented!()
    +    }
    +}
    +
    +impl extenddb_storage::BackupCatalog for BulkUnsupportedStorage {
    +    fn catalog_id(&self) -> BoxFuture<'_, Result> {
    +        extenddb_storage::BackupCatalog::catalog_id(&*self.inner)
    +    }
    +
    +    fn claim_backup<'a>(
    +        &'a self,
    +        row: &'a extenddb_storage::backup_catalog::BackupRow,
    +    ) -> BoxFuture<'a, Result, StorageError>> {
    +        extenddb_storage::BackupCatalog::claim_backup(&*self.inner, row)
    +    }
    +
    +    fn list_backup_rows<'a>(
    +        &'a self,
    +        status: Option,
    +        store: &'a extenddb_storage::backup_catalog::StoreIdentity,
    +    ) -> futures::stream::BoxStream<
    +        'a,
    +        Result,
    +    > {
    +        extenddb_storage::BackupCatalog::list_backup_rows(&*self.inner, status, store)
    +    }
    +
    +    fn list_account_backup_rows<'a>(
    +        &'a self,
    +        account_id: &'a str,
    +        table_name: Option<&'a str>,
    +        bounds: &'a extenddb_storage::backup_catalog::TimeBounds,
    +    ) -> futures::stream::BoxStream<
    +        'a,
    +        Result,
    +    > {
    +        extenddb_storage::BackupCatalog::list_account_backup_rows(
    +            &*self.inner,
    +            account_id,
    +            table_name,
    +            bounds,
    +        )
    +    }
    +
    +    fn read_backup_row<'a>(
    +        &'a self,
    +        arn: &'a extenddb_core::backup_format::BackupArn,
    +    ) -> BoxFuture<'a, Result, StorageError>>
    +    {
    +        extenddb_storage::BackupCatalog::read_backup_row(&*self.inner, arn)
    +    }
    +
    +    fn start_backup_delete<'a>(
    +        &'a self,
    +        key: &'a extenddb_storage::backup_catalog::BackupKey,
    +    ) -> BoxFuture<'a, Result> {
    +        extenddb_storage::BackupCatalog::start_backup_delete(&*self.inner, key)
    +    }
    +
    +    fn set_backup_deleted<'a>(
    +        &'a self,
    +        key: &'a extenddb_storage::backup_catalog::BackupKey,
    +    ) -> BoxFuture<'a, Result> {
    +        extenddb_storage::BackupCatalog::set_backup_deleted(&*self.inner, key)
    +    }
    +
    +    fn remove_backup_row<'a>(
    +        &'a self,
    +        key: &'a extenddb_storage::backup_catalog::BackupKey,
    +    ) -> BoxFuture<'a, Result> {
    +        extenddb_storage::BackupCatalog::remove_backup_row(&*self.inner, key)
    +    }
    +
    +    fn backup_in_use<'a>(
    +        &'a self,
    +        key: &'a extenddb_storage::backup_catalog::BackupKey,
    +    ) -> BoxFuture<'a, Result, StorageError>> {
    +        extenddb_storage::BackupCatalog::backup_in_use(&*self.inner, key)
    +    }
    +
    +    fn remove_restore_target<'a>(
    +        &'a self,
    +        key: &'a extenddb_storage::backup_catalog::RestoreTargetKey,
    +    ) -> BoxFuture<'a, Result> {
    +        extenddb_storage::BackupCatalog::remove_restore_target(&*self.inner, key)
    +    }
    +
    +    fn list_dead_restore_jobs(
    +        &self,
    +        stale_after: Duration,
    +    ) -> BoxFuture<'_, Result, StorageError>>
    +    {
    +        extenddb_storage::BackupCatalog::list_dead_restore_jobs(&*self.inner, stale_after)
    +    }
    +
    +    fn fail_restore_job<'a>(
    +        &'a self,
    +        key: &'a extenddb_storage::backup_catalog::RestoreTargetKey,
    +        owner_generation: u64,
    +        heartbeat_at: f64,
    +        reason: &'a str,
    +    ) -> BoxFuture<'a, Result> {
    +        extenddb_storage::BackupCatalog::fail_restore_job(
    +            &*self.inner,
    +            key,
    +            owner_generation,
    +            heartbeat_at,
    +            reason,
    +        )
    +    }
    +
    +    fn check_maintenance_claim(&self) -> BoxFuture<'_, Result> {
    +        extenddb_storage::BackupCatalog::check_maintenance_claim(&*self.inner)
    +    }
    +}
    +
    +impl extenddb_storage::LegacyBackupReader for BulkUnsupportedStorage {
    +    fn create(
    +        &self,
    +        account_id: &str,
    +        table_name: &str,
    +        backup_name: &str,
    +        permit: extenddb_storage::JobPermit,
    +    ) -> BoxFuture<'_, Result> {
    +        extenddb_storage::LegacyBackupReader::create(
    +            &*self.inner,
    +            account_id,
    +            table_name,
    +            backup_name,
    +            permit,
    +        )
    +    }
    +
    +    fn describe(
    +        &self,
    +        account_id: &str,
    +        backup_arn: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        extenddb_storage::LegacyBackupReader::describe(&*self.inner, account_id, backup_arn)
    +    }
    +
    +    fn list(
    +        &self,
    +        account_id: &str,
    +        table_name: Option<&str>,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        extenddb_storage::LegacyBackupReader::list(&*self.inner, account_id, table_name)
    +    }
    +
    +    fn delete(
    +        &self,
    +        account_id: &str,
    +        backup_arn: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        extenddb_storage::LegacyBackupReader::delete(&*self.inner, account_id, backup_arn)
    +    }
    +
    +    fn restore_source(
    +        &self,
    +        account_id: &str,
    +        backup_arn: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        extenddb_storage::LegacyBackupReader::restore_source(&*self.inner, account_id, backup_arn)
    +    }
    +
    +    fn restore(
    +        &self,
    +        account_id: &str,
    +        request: extenddb_storage::RestoreRequest,
    +        permit: extenddb_storage::JobPermit,
    +    ) -> BoxFuture<'_, Result> {
    +        extenddb_storage::LegacyBackupReader::restore(&*self.inner, account_id, request, permit)
    +    }
    +
    +    fn abandoned_creating_backups(
    +        &self,
    +        stale_after: Duration,
    +    ) -> BoxFuture<
    +        '_,
    +        Result, StorageError>,
    +    > {
    +        extenddb_storage::LegacyBackupReader::abandoned_creating_backups(&*self.inner, stale_after)
    +    }
    +}
    +
    +impl extenddb_storage::SnapshotEngine for BulkUnsupportedStorage {
    +    fn open_table_snapshot(
    +        &self,
    +        _account_id: &str,
    +        _table_name: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        unimplemented!()
    +    }
    +}
    +
    +impl BulkOperationStore for BulkUnsupportedStorage {
    +    fn start_bulk_operation(
    +        &self,
    +        _account_id: &str,
    +        _req: NewBulkOperation,
    +        _account_limit: u32,
    +    ) -> BoxFuture<'_, Result> {
    +        refuse()
    +    }
    +    fn get_bulk_operation(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        refuse()
    +    }
    +    fn count_non_terminal(&self, _account_id: &str) -> BoxFuture<'_, Result> {
    +        refuse()
    +    }
    +    fn count_terminal_born_since(
    +        &self,
    +        _account_id: &str,
    +        _since_ms: i64,
    +    ) -> BoxFuture<'_, Result> {
    +        refuse()
    +    }
    +    fn list_bulk_operations(
    +        &self,
    +        _account_id: &str,
    +        _limit: u32,
    +        _after: Option,
    +    ) -> BoxFuture<'_, BulkOperationListResult> {
    +        refuse()
    +    }
    +    fn request_cancel(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        refuse()
    +    }
    +    fn claim_runnable(
    +        &self,
    +        _owner: &str,
    +        _lease: Duration,
    +        _max: usize,
    +        _exclude_accounts: &[String],
    +        _exclude_operations: &[String],
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        refuse()
    +    }
    +    fn checkpoint(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +        _owner: &str,
    +        _lease: Duration,
    +        _progress: Progress,
    +    ) -> BoxFuture<'_, Result> {
    +        refuse()
    +    }
    +    fn report_records_page(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +        _kind: ReportKind,
    +        _after: Option<(i64, i64)>,
    +        _limit: usize,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        refuse()
    +    }
    +    fn finish(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +        _owner: &str,
    +        _outcome: Terminal,
    +    ) -> BoxFuture<'_, Result> {
    +        refuse()
    +    }
    +    fn list_purgeable_before(
    +        &self,
    +        _cutoff: SystemTime,
    +        _limit: usize,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        refuse()
    +    }
    +    fn purge_operations(
    +        &self,
    +        _pairs: &[(String, String)],
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        refuse()
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/tests/handlers/update_table_guard.rs b/crates/engine/src/bulk_put/tests/handlers/update_table_guard.rs
    new file mode 100644
    index 000000000..b81c26159
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/update_table_guard.rs
    @@ -0,0 +1,112 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// UpdateTable while a bulk operation is in progress on the table (measured on
    +// the hosted service).
    +
    +const INDEX_CREATE_REFUSED: &str = "Attempt to change a resource which is still in use: Index \
    +                                    creation is not allowed while table has ongoing bulk \
    +                                    operation: orders";
    +
    +fn gsi_create_body(table: &str) -> Value {
    +    json!({
    +        "TableName": table,
    +        "AttributeDefinitions": [{"AttributeName": "g", "AttributeType": "S"}],
    +        "GlobalSecondaryIndexUpdates": [{
    +            "Create": {
    +                "IndexName": "gsi_new",
    +                "KeySchema": [{"AttributeName": "g", "KeyType": "HASH"}],
    +                "Projection": {"ProjectionType": "ALL"}
    +            }
    +        }]
    +    })
    +}
    +
    +async fn update_table(ctx: &OperationContext, body: Value) -> Result {
    +    call(ctx, "UpdateTable", body).await
    +}
    +
    +#[tokio::test]
    +async fn index_creation_is_refused_while_an_operation_runs_and_allowed_after_it_finishes() {
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    let description = start_ok(&ctx, start_body()).await;
    +    let operation_id = description["BulkOperationArn"]
    +        .as_str()
    +        .and_then(|arn| arn.rsplit('/').next())
    +        .expect("operation id")
    +        .to_owned();
    +
    +    // ResourceInUseException with the measured text and the table
    +    // name appended.
    +    let refused = expect_in_use(update_table(&ctx, gsi_create_body(TABLE)).await);
    +    assert_eq!(refused, INDEX_CREATE_REFUSED);
    +
    +    // Another table of the account is not held by this operation.
    +    Harness::create_table(&h.storage, "other").await;
    +    update_table(&ctx, gsi_create_body("other"))
    +        .await
    +        .expect("an index on an unrelated table is created");
    +
    +    // Once the operation is terminal the same request on the bound table
    +    // is accepted.
    +    let claimed = h
    +        .storage
    +        .claim_runnable("owner-1", Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert!(claimed.iter().any(|r| r.operation_id == operation_id));
    +    finish_op(&h, ACCOUNT, "owner-1", &operation_id, "COMPLETED").await;
    +    update_table(&ctx, gsi_create_body(TABLE))
    +        .await
    +        .expect("index creation after the operation finished");
    +}
    +
    +#[tokio::test]
    +async fn index_deletion_is_accepted_while_an_operation_runs() {
    +    // A GSI delete during a run is accepted.
    +    let h = Harness::new_with_gsi().await;
    +    let ctx = base_ctx(&h);
    +    start_ok(&ctx, start_body()).await;
    +    let response = update_table(
    +        &ctx,
    +        json!({
    +            "TableName": TABLE,
    +            "GlobalSecondaryIndexUpdates": [{"Delete": {"IndexName": "gsi1"}}]
    +        }),
    +    )
    +    .await
    +    .expect("index deletion during a run");
    +    assert!(response["TableDescription"].is_object());
    +}
    +
    +#[tokio::test]
    +async fn an_operation_on_a_deleted_table_does_not_refuse_an_index_on_its_successor() {
    +    // The operation stays IN_PROGRESS on the old table's id after the
    +    // table is deleted; a new table of the same name has a new id.
    +    let h = Harness::new().await;
    +    let ctx = base_ctx(&h);
    +    let description = start_ok(&ctx, start_body()).await;
    +    assert_eq!(
    +        description["BulkOperationStatus"], "IN_PROGRESS",
    +        "{description}"
    +    );
    +    h.storage
    +        .delete_table(
    +            ACCOUNT,
    +            extenddb_core::types::DeleteTableInput {
    +                table_name: TABLE.to_owned(),
    +            },
    +        )
    +        .await
    +        .expect("delete the table");
    +    let new_id = Harness::create_table(&h.storage, TABLE).await;
    +    assert_ne!(new_id, h.table_id, "the new table has a new id");
    +
    +    let response = update_table(&ctx, gsi_create_body(TABLE))
    +        .await
    +        .expect("an index on the new table is created");
    +    assert!(response["TableDescription"].is_object());
    +}
    diff --git a/crates/engine/src/bulk_put/tests/handlers/validation.rs b/crates/engine/src/bulk_put/tests/handlers/validation.rs
    new file mode 100644
    index 000000000..c4aa0ea48
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/handlers/validation.rs
    @@ -0,0 +1,494 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// PerformanceTier against the table's features (launch source of truth:
    +// the accelerated tier is not available on a table with a vector index).
    +
    +#[tokio::test]
    +async fn accelerated_is_rejected_on_a_table_with_a_vector_index_and_standard_is_not() {
    +    let h = Harness::new().await;
    +    let table = format!("{TABLE}-vectors");
    +    Harness::create_table_with_vector(&h.storage, &table).await;
    +    let ctx = base_ctx(&h);
    +
    +    let body = with_top(
    +        with_top(start_body(), "TableName", json!(table)),
    +        "PerformanceTier",
    +        json!("ACCELERATED"),
    +    );
    +    let message = expect_validation(start(&ctx, body).await);
    +    // Measured on the service.
    +    assert_eq!(
    +        message,
    +        "Invalid Request: Value provided for parameter PerformanceTier is not supported for \
    +         tables with vector indexes."
    +    );
    +
    +    // STANDARD on the same table starts; the plain table accepts ACCELERATED.
    +    let body = with_top(
    +        with_top(start_body(), "TableName", json!(table)),
    +        "PerformanceTier",
    +        json!("STANDARD"),
    +    );
    +    let description = start_ok(&ctx, body).await;
    +    assert_eq!(description["PerformanceTier"], json!("STANDARD"));
    +    let body = with_top(start_body(), "PerformanceTier", json!("ACCELERATED"));
    +    let description = start_ok(&ctx, body).await;
    +    assert_eq!(description["PerformanceTier"], json!("ACCELERATED"));
    +}
    +
    +// Model bounds on the S3 configuration members.
    +
    +#[tokio::test]
    +async fn an_s3_key_prefix_above_the_model_cap_is_rejected() {
    +    let h = Harness::new().await;
    +    let prefix = "p".repeat(1025);
    +    let body = with_input(start_body(), "S3KeyPrefix", json!(prefix));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert!(
    +        message.contains(
    +            "at 'inputS3Configuration.s3KeyPrefix' failed to satisfy constraint: Member must \
    +             have length less than or equal to 1024"
    +        ),
    +        "{message}"
    +    );
    +    let body = with_output(start_body(), "S3KeyPrefix", json!("q".repeat(1025)));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert!(
    +        message.contains("at 'outputS3Configuration.s3KeyPrefix'"),
    +        "{message}"
    +    );
    +    // Exactly the cap passes the length check.
    +    let body = with_output(start_body(), "S3KeyPrefix", json!("r".repeat(1024)));
    +    start_ok(&base_ctx(&h), body).await;
    +}
    +
    +#[tokio::test]
    +async fn an_s3_bucket_owner_that_is_not_twelve_digits_is_rejected_with_the_pattern_message() {
    +    let h = Harness::new().await;
    +    for (owner, member) in [
    +        ("12345678901", "inputS3Configuration.s3BucketOwner"),
    +        ("1234567890123", "inputS3Configuration.s3BucketOwner"),
    +        ("12345678901a", "inputS3Configuration.s3BucketOwner"),
    +    ] {
    +        let body = with_input(start_body(), "S3BucketOwner", json!(owner));
    +        let message = expect_validation(start(&base_ctx(&h), body).await);
    +        assert_eq!(
    +            message,
    +            format!(
    +                "1 validation error detected: Value '{owner}' at '{member}' failed to satisfy \
    +                 constraint: Member must satisfy regular expression pattern: [0-9]{{12}}"
    +            )
    +        );
    +    }
    +    let body = with_output(start_body(), "S3BucketOwner", json!("abc"));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert!(
    +        message.contains("at 'outputS3Configuration.s3BucketOwner'"),
    +        "{message}"
    +    );
    +    // A well-formed foreign owner still reaches the ownership check, which
    +    // is a terminal-born failure rather than a validation error.
    +    let body = with_input(start_body(), "S3BucketOwner", json!("999999999999"));
    +    let description = start_ok(&base_ctx(&h), body).await;
    +    assert_eq!(description["BulkOperationStatus"], json!("FAILED"));
    +    assert_eq!(description["FailureCode"], json!("S3AccessDenied"));
    +}
    +
    +#[tokio::test]
    +async fn an_s3_sse_kms_key_id_outside_the_model_bounds_is_rejected() {
    +    let h = Harness::new().await;
    +    // Measured for 2,049 characters; the empty key id's clause follows the
    +    // model's other minimum-length texts and is unmeasured.
    +    for (key_id, rule) in [
    +        (String::new(), "greater than or equal to 1"),
    +        ("k".repeat(2049), "less than or equal to 2048"),
    +    ] {
    +        let body = with_output(start_body(), "S3SseKmsKeyId", json!(key_id));
    +        let message = expect_validation(start(&base_ctx(&h), body).await);
    +        assert_eq!(
    +            message,
    +            format!(
    +                "1 validation error detected: Value '{key_id}' at \
    +                 'outputS3Configuration.s3SseKmsKeyId' failed to satisfy constraint: Member \
    +                 must have length {rule}"
    +            )
    +        );
    +    }
    +    let body = with_output(start_body(), "S3SseKmsKeyId", json!("k".repeat(2048)));
    +    start_ok(&base_ctx(&h), body).await;
    +}
    +
    +// ListBulkOperations on a server without a [bulk] section reaches storage
    +// and answers whatever the backend answers. The Python suite's skip fixture
    +// relies on this order on the MongoDB and Cassandra jobs, whose test
    +// drivers configure no bulk store: the backend's refusal must be the
    +// message the client sees, not the disabled-store message.
    +
    +#[tokio::test]
    +async fn list_without_a_bulk_store_reaches_storage_before_any_store_check() {
    +    let h = Harness::new().await;
    +    let ctx = ctx_without_store(&h);
    +    // SQLite supports bulk operations, so the list succeeds with an empty
    +    // page; a refusing backend would surface its own error here instead.
    +    let listed = call(&ctx, "ListBulkOperations", json!({}))
    +        .await
    +        .expect("list reaches storage even with no bulk store configured");
    +    assert_eq!(summaries(&listed).len(), 0);
    +    // Start on the same context answers the disabled-store message, so the
    +    // order is specific to List.
    +    let message = expect_validation(start(&ctx, start_body()).await);
    +    assert_eq!(message, BULK_DISABLED);
    +}
    +
    +// A null or absent required member answers the hosted ValidationException
    +// the rest of the server uses for a null member, not the serde
    +// SerializationException a straight deserialize gives. Required members
    +// come from the API model's input shapes (2012-08-10):
    +// StartBulkPutItemsInput requires InputS3Configuration, OutputS3Configuration,
    +// TableName; InputS3Configuration requires S3Bucket and InputFormat;
    +// OutputS3Configuration requires OutputType; Describe and Cancel require
    +// BulkOperationArn.
    +
    +fn null_member_message(path: &str) -> String {
    +    format!(
    +        "1 validation error detected: Value null at '{path}' failed to satisfy constraint: \
    +         Member must not be null"
    +    )
    +}
    +
    +/// `start_body` with one top level member removed.
    +fn start_without_top(key: &str) -> Value {
    +    let mut body = start_body();
    +    body.as_object_mut().unwrap().remove(key);
    +    body
    +}
    +
    +/// `start_body` with one member removed from `InputS3Configuration`.
    +fn start_without_input_member(key: &str) -> Value {
    +    let mut body = start_body();
    +    body["InputS3Configuration"]
    +        .as_object_mut()
    +        .unwrap()
    +        .remove(key);
    +    body
    +}
    +
    +/// `start_body` with one member removed from `OutputS3Configuration`.
    +fn start_without_output_member(key: &str) -> Value {
    +    let mut body = start_body();
    +    body["OutputS3Configuration"]
    +        .as_object_mut()
    +        .unwrap()
    +        .remove(key);
    +    body
    +}
    +
    +#[tokio::test]
    +async fn start_missing_input_configuration_answers_the_null_member_validation() {
    +    let h = Harness::new().await;
    +    let message =
    +        expect_validation(start(&base_ctx(&h), start_without_top("InputS3Configuration")).await);
    +    assert_eq!(message, null_member_message("inputS3Configuration"));
    +}
    +
    +#[tokio::test]
    +async fn start_missing_output_configuration_answers_the_null_member_validation() {
    +    let h = Harness::new().await;
    +    let message =
    +        expect_validation(start(&base_ctx(&h), start_without_top("OutputS3Configuration")).await);
    +    assert_eq!(message, null_member_message("outputS3Configuration"));
    +}
    +
    +// Measured on the service: a missing or null
    +// TableName answers the server's table-operation message, not the null
    +// member envelope, and it is answered first and alone.
    +const TABLE_NAME_REQUIRED: &str =
    +    "The parameter 'TableName' is required but was not present in the request";
    +
    +#[tokio::test]
    +async fn start_missing_table_name_answers_the_required_parameter_message() {
    +    let h = Harness::new().await;
    +    let message = expect_validation(start(&base_ctx(&h), start_without_top("TableName")).await);
    +    assert_eq!(message, TABLE_NAME_REQUIRED);
    +}
    +
    +#[tokio::test]
    +async fn start_missing_table_name_and_input_configuration_answers_only_table_name() {
    +    let h = Harness::new().await;
    +    let mut body = start_without_top("TableName");
    +    body.as_object_mut().unwrap().remove("InputS3Configuration");
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert_eq!(message, TABLE_NAME_REQUIRED);
    +}
    +
    +#[tokio::test]
    +async fn start_null_table_name_answers_the_required_parameter_message() {
    +    let h = Harness::new().await;
    +    let body = with_top(start_body(), "TableName", Value::Null);
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert_eq!(message, TABLE_NAME_REQUIRED);
    +}
    +
    +#[tokio::test]
    +async fn start_missing_input_bucket_answers_the_null_member_validation() {
    +    let h = Harness::new().await;
    +    let message =
    +        expect_validation(start(&base_ctx(&h), start_without_input_member("S3Bucket")).await);
    +    assert_eq!(
    +        message,
    +        null_member_message("inputS3Configuration.s3Bucket")
    +    );
    +}
    +
    +#[tokio::test]
    +async fn start_missing_input_format_answers_the_null_member_validation() {
    +    let h = Harness::new().await;
    +    let message =
    +        expect_validation(start(&base_ctx(&h), start_without_input_member("InputFormat")).await);
    +    assert_eq!(
    +        message,
    +        null_member_message("inputS3Configuration.inputFormat")
    +    );
    +}
    +
    +#[tokio::test]
    +async fn start_missing_output_type_answers_the_null_member_validation() {
    +    let h = Harness::new().await;
    +    let message =
    +        expect_validation(start(&base_ctx(&h), start_without_output_member("OutputType")).await);
    +    assert_eq!(
    +        message,
    +        null_member_message("outputS3Configuration.outputType")
    +    );
    +}
    +
    +#[tokio::test]
    +async fn start_two_missing_members_answer_the_combined_message_in_model_order() {
    +    let h = Harness::new().await;
    +    let mut body = start_body();
    +    {
    +        let members = body.as_object_mut().unwrap();
    +        members.remove("InputS3Configuration");
    +        members.remove("OutputS3Configuration");
    +    }
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert_eq!(
    +        message,
    +        "2 validation errors detected: Value null at 'inputS3Configuration' failed to satisfy \
    +         constraint: Member must not be null; Value null at 'outputS3Configuration' failed to \
    +         satisfy constraint: Member must not be null"
    +    );
    +}
    +
    +// Measured on the service: a missing or null BulkOperationArn on Describe and
    +// Cancel answers the ARN parser's shape layer, the same message a malformed
    +// ARN gets.
    +#[tokio::test]
    +async fn describe_missing_arn_answers_the_arn_shape_message() {
    +    let h = Harness::new().await;
    +    let message = expect_validation(call(&base_ctx(&h), "DescribeBulkOperation", json!({})).await);
    +    assert_eq!(message, "Invalid Bulk operation ARN");
    +}
    +
    +#[tokio::test]
    +async fn describe_null_arn_answers_the_arn_shape_message() {
    +    let h = Harness::new().await;
    +    let message = expect_validation(
    +        call(
    +            &base_ctx(&h),
    +            "DescribeBulkOperation",
    +            json!({ "BulkOperationArn": null }),
    +        )
    +        .await,
    +    );
    +    assert_eq!(message, "Invalid Bulk operation ARN");
    +}
    +
    +#[tokio::test]
    +async fn cancel_missing_arn_answers_the_arn_shape_message() {
    +    let h = Harness::new().await;
    +    let message = expect_validation(call(&base_ctx(&h), "CancelBulkOperation", json!({})).await);
    +    assert_eq!(message, "Invalid Bulk operation ARN");
    +}
    +
    +#[tokio::test]
    +async fn an_unsupported_backend_refuses_all_four_operations_with_validation() {
    +    let h = Harness::new().await;
    +    let storage: Arc = Arc::new(unsupported_backend::BulkUnsupportedStorage {
    +        inner: Arc::clone(&h.storage),
    +        creating_index: None,
    +    });
    +    let ctx = ctx_with_storage(&h, storage);
    +    let message = unsupported_backend::UNSUPPORTED_MESSAGE;
    +    // Start reaches the bulk store after the table lookup (delegated to the
    +    // real engine) and the staged input bucket check.
    +    assert_eq!(expect_validation(start(&ctx, start_body()).await), message);
    +    let operation_arn = arn_of(ACCOUNT, "00000000000000-0000abcd");
    +    assert_eq!(
    +        expect_validation(describe(&ctx, &operation_arn).await),
    +        message
    +    );
    +    assert_eq!(
    +        expect_validation(cancel(&ctx, &operation_arn).await),
    +        message
    +    );
    +    assert_eq!(
    +        expect_validation(call(&ctx, "ListBulkOperations", json!({})).await),
    +        message
    +    );
    +}
    +
    +// Measured on the service: a start on a table whose
    +// global secondary index is being created answers ResourceInUseException
    +// "Table has ongoing index operations, please wait and try again" in both
    +// creation phases, whether the table reads UPDATING or ACTIVE. The check
    +// runs at the table lookup, before the bulk store is reached: the double's
    +// store refuses everything, and the start never gets that far.
    +#[tokio::test]
    +async fn a_start_on_a_table_with_an_index_being_created_is_refused() {
    +    let h = Harness::new_with_gsi().await;
    +    let storage: Arc = Arc::new(unsupported_backend::BulkUnsupportedStorage {
    +        inner: Arc::clone(&h.storage),
    +        creating_index: Some("gsi1".to_owned()),
    +    });
    +    let ctx = ctx_with_storage(&h, storage);
    +    let message = expect_in_use(start(&ctx, start_body()).await);
    +    assert_eq!(
    +        message,
    +        "Table has ongoing index operations, please wait and try again"
    +    );
    +
    +    // The same table with every index ACTIVE passes the lookup and reaches
    +    // the store, so the check keys on the index status alone.
    +    let storage: Arc = Arc::new(unsupported_backend::BulkUnsupportedStorage {
    +        inner: Arc::clone(&h.storage),
    +        creating_index: None,
    +    });
    +    let ctx = ctx_with_storage(&h, storage);
    +    assert_eq!(
    +        expect_validation(start(&ctx, start_body()).await),
    +        unsupported_backend::UNSUPPORTED_MESSAGE
    +    );
    +}
    +
    +// TableName is typed as TableArn in the model, bounded to 1..=1024
    +// characters. The bound is checked at step 2 with the standard constraint
    +// message, so an out-of-range name never answers resource-not-found.
    +
    +#[tokio::test]
    +async fn an_empty_table_name_fails_the_table_name_rules() {
    +    // Measured on the service: an empty bare name answers the table-name rules
    +    // (minimum 3 and the pattern) together in one envelope.
    +    let h = Harness::new().await;
    +    let body = with_top(start_body(), "TableName", json!(""));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert_eq!(
    +        message,
    +        "2 validation errors detected: Value '' at 'tableName' failed to satisfy constraint: \
    +         Member must have length greater than or equal to 3; Value '' at 'tableName' failed \
    +         to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_bare_table_name_of_256_characters_fails_the_table_name_maximum() {
    +    let h = Harness::new().await;
    +    let name = "a".repeat(256);
    +    let body = with_top(start_body(), "TableName", json!(name));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert_eq!(
    +        message,
    +        format!(
    +            "1 validation error detected: Value '{name}' at 'tableName' failed to satisfy \
    +             constraint: Member must have length less than or equal to 255"
    +        )
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_table_name_of_1025_characters_fails_the_length_bound() {
    +    let h = Harness::new().await;
    +    let name = "a".repeat(1025);
    +    let body = with_top(start_body(), "TableName", json!(name));
    +    let message = expect_validation(start(&base_ctx(&h), body).await);
    +    assert_eq!(
    +        message,
    +        format!(
    +            "1 validation error detected: Value '{name}' at 'tableName' failed to satisfy \
    +             constraint: Member must have length less than or equal to 1024"
    +        ),
    +        "the service reports the TableArn maximum one-sided (measured on the service)"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_table_arn_of_1024_characters_passes_the_bound_and_meets_the_name_rule() {
    +    let h = Harness::new().await;
    +    // At the TableArn maximum the bound passes, and the name the ARN
    +    // resolves to is held to the table-name rule: a 1024-character ARN
    +    // carries a name over 255 characters and answers the measured `Table
    +    // name of ARN` maximum. An ARN whose name is inside the rule reaches
    +    // the lookup and answers resource-not-found.
    +    let prefix = format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/");
    +    let bare = "a".repeat(1024 - prefix.len());
    +    let name = format!("{prefix}{bare}");
    +    let body = with_top(start_body(), "TableName", json!(name));
    +    assert_eq!(
    +        expect_validation(start(&base_ctx(&h), body).await),
    +        format!(
    +            "1 validation error detected: Value '{name}' at 'tableName' failed to satisfy \
    +             constraint: Table name of ARN must have length less than or equal to 255"
    +        )
    +    );
    +    let bare = "a".repeat(255);
    +    let body = with_top(start_body(), "TableName", json!(format!("{prefix}{bare}")));
    +    assert_eq!(
    +        expect_not_found(start(&base_ctx(&h), body).await),
    +        format!("{TABLE_NOT_FOUND}: Table: {bare} not found")
    +    );
    +}
    +
    +// A Start the account concurrency cap will refuse must not pay the input
    +// enumeration walk first. The account limit is read before the walk; the
    +// authoritative check stays in the start transaction.
    +// A second start on a different table whose prefix holds more files than the
    +// file cap answers the limit, not the file cap, so the walk did not run. The
    +// busy-table guard is deliberately not pre-checked: a start on a busy table
    +// over the file cap answers the file cap today (pinned by
    +// the_input_file_cap_is_rejected_before_the_table_guard), so a busy-table
    +// pre-check would reorder that observable outcome.
    +
    +#[tokio::test]
    +async fn a_saturated_account_is_refused_before_the_input_walk() {
    +    let h = Harness::new().await;
    +    Harness::create_table(&h.storage, "second").await;
    +    let ctx = ctx_with_limits(
    +        &h,
    +        bulk_limits(&[
    +            ("max_concurrent_bulk_operations", json!(1)),
    +            ("max_bulk_input_files", json!(2)),
    +        ]),
    +    );
    +    // Occupy the account's one slot with a running operation on the first
    +    // table (the input bucket is empty, so this start enumerates nothing).
    +    start_ok(&ctx, start_body()).await;
    +    // Place more files than the file cap under a distinct prefix. If the
    +    // walk ran, the second start would answer the file-cap message; the
    +    // account-limit pre-check answers first, without reading the prefix.
    +    for i in 0..3 {
    +        h.write_input(&format!("walk/part-{i}.json"), b"0123456789");
    +    }
    +    let second = with_top(
    +        with_input(start_body(), "S3KeyPrefix", json!("walk/")),
    +        "TableName",
    +        json!("second"),
    +    );
    +    let message = expect_limit_exceeded(start(&ctx, second).await);
    +    assert!(
    +        message.contains("maximum number of concurrent bulk operations"),
    +        "the account limit answers before the walk reads the prefix: {message}"
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/tests/mod.rs b/crates/engine/src/bulk_put/tests/mod.rs
    new file mode 100644
    index 000000000..39c59e48a
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/mod.rs
    @@ -0,0 +1,97 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +mod handlers;
    +mod runner;
    +mod worker;
    +
    +use crate::bulk_put::{arn, fingerprint, rows};
    +
    +use std::collections::HashMap;
    +
    +use extenddb_core::error::DynamoDbError;
    +use extenddb_core::types::AttributeValue;
    +use serde_json::json;
    +
    +const REGION: &str = "us-east-1";
    +const ACCOUNT: &str = "123456789012";
    +const VALID_ARN: &str =
    +    "arn:aws:dynamodb:us-east-1:123456789012:bulk-operation/01789060589767-da9ddd4f";
    +
    +/// Layer 1 of the `DescribeBulkOperation` ARN table.
    +const NOT_AN_ARN: &str = "Invalid Bulk operation ARN";
    +/// Layer 2 of the `DescribeBulkOperation` ARN table.
    +const BAD_ID: &str = "Invalid Request: Bulk operation ARN is invalid.";
    +/// The one message every parse-level row fault carries.
    +const ABANDON: &str = "Expected DynamoDB type to be one of [S, N, B, BOOL, NULL, L, M, SS, NS, \
    +                       BS]. Remainder of the file will not be processed.";
    +
    +fn validation_message(err: &DynamoDbError) -> &str {
    +    match err {
    +        DynamoDbError::ValidationException(message) => message,
    +        other => panic!("expected ValidationException, got {other:?}"),
    +    }
    +}
    +
    +fn parse_arn_message(input: &str) -> String {
    +    let err = arn::parse_arn(input).expect_err("this ARN must be rejected");
    +    validation_message(&err).to_owned()
    +}
    +
    +fn abandon_message(line: &str) -> String {
    +    match rows::parse_row(line, 1 << 20) {
    +        Err(rows::RowError::Parse(message)) => message,
    +        other => panic!("expected a parse-level error, got {other:?}"),
    +    }
    +}
    +
    +/// One attribute value nested `depth` map levels below the item.
    +fn nested_item_line(depth: usize) -> String {
    +    let mut value = String::from(r#"{"S":"leaf"}"#);
    +    for _ in 0..depth {
    +        value = format!(r#"{{"M":{{"k":{value}}}}}"#);
    +    }
    +    format!(r#"{{"Item":{{"pk":{{"S":"a"}},"deep":{value}}}}}"#)
    +}
    +
    +fn item_message(line: &str) -> String {
    +    match rows::parse_row(line, 1 << 20) {
    +        Err(rows::RowError::Item(message)) => message,
    +        other => panic!("expected an item-level error, got {other:?}"),
    +    }
    +}
    +
    +// Request fingerprint.
    +
    +fn base_input() -> serde_json::Value {
    +    json!({
    +        "S3Bucket": "loads",
    +        "S3KeyPrefix": "orders/2026-09-10/",
    +        "InputFormat": "DYNAMODB_JSON",
    +        "InputCompressionType": "GZIP"
    +    })
    +}
    +
    +fn base_output() -> serde_json::Value {
    +    json!({
    +        "S3Bucket": "loads",
    +        "S3KeyPrefix": "reports/",
    +        "OutputType": ["ERROR", "CONDITION_CHECK_FAILURE"]
    +    })
    +}
    +
    +const TABLE_ARN: &str = "arn:aws:dynamodb:us-east-1:123456789012:table/orders";
    +
    +fn base_fingerprint() -> String {
    +    fingerprint::request_fingerprint(
    +        TABLE_ARN,
    +        &base_input(),
    +        &base_output(),
    +        Some(1000),
    +        Some("STANDARD"),
    +    )
    +}
    +
    +mod arn_parsing;
    +mod request_fingerprint;
    +mod row_parsing;
    diff --git a/crates/engine/src/bulk_put/tests/request_fingerprint.rs b/crates/engine/src/bulk_put/tests/request_fingerprint.rs
    new file mode 100644
    index 000000000..19597a190
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/request_fingerprint.rs
    @@ -0,0 +1,433 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +#[test]
    +fn identical_requests_share_a_fingerprint() {
    +    assert_eq!(base_fingerprint(), base_fingerprint());
    +}
    +
    +#[test]
    +fn fingerprint_is_64_lowercase_hex_chars() {
    +    let fp = base_fingerprint();
    +    assert_eq!(fp.len(), 64);
    +    assert!(
    +        fp.bytes()
    +            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
    +        "not lowercase hex: {fp}"
    +    );
    +}
    +
    +#[test]
    +fn input_member_order_does_not_change_the_fingerprint() {
    +    let reordered: serde_json::Value = serde_json::from_str(
    +        r#"{"InputCompressionType": "GZIP", "InputFormat": "DYNAMODB_JSON",
    +            "S3KeyPrefix": "orders/2026-09-10/", "S3Bucket": "loads"}"#,
    +    )
    +    .unwrap();
    +    let fp = fingerprint::request_fingerprint(
    +        TABLE_ARN,
    +        &reordered,
    +        &base_output(),
    +        Some(1000),
    +        Some("STANDARD"),
    +    );
    +    assert_eq!(fp, base_fingerprint());
    +}
    +
    +#[test]
    +fn output_type_order_does_not_change_the_fingerprint() {
    +    let mut output = base_output();
    +    output["OutputType"] = json!(["CONDITION_CHECK_FAILURE", "ERROR"]);
    +    let fp = fingerprint::request_fingerprint(
    +        TABLE_ARN,
    +        &base_input(),
    +        &output,
    +        Some(1000),
    +        Some("STANDARD"),
    +    );
    +    assert_eq!(
    +        fp,
    +        base_fingerprint(),
    +        "OutputType is a set on the wire; its order must not matter"
    +    );
    +}
    +
    +#[test]
    +fn null_members_equal_absent_members() {
    +    let mut input = base_input();
    +    input["S3BucketOwner"] = serde_json::Value::Null;
    +    let mut output = base_output();
    +    output["S3SseAlgorithm"] = serde_json::Value::Null;
    +    let fp =
    +        fingerprint::request_fingerprint(TABLE_ARN, &input, &output, Some(1000), Some("STANDARD"));
    +    assert_eq!(
    +        fp,
    +        base_fingerprint(),
    +        "null members are removed before hashing, so null equals absent"
    +    );
    +}
    +
    +#[test]
    +fn arrays_other_than_output_type_keep_their_order() {
    +    // No real configuration member is an ordered array today; the canonical
    +    // form must still only sort OutputType, so an artificial array member
    +    // discriminates an implementation that sorts every array.
    +    let mut a = base_input();
    +    a["Marker"] = json!(["x", "y"]);
    +    let mut b = base_input();
    +    b["Marker"] = json!(["y", "x"]);
    +    let fp_a = fingerprint::request_fingerprint(
    +        TABLE_ARN,
    +        &a,
    +        &base_output(),
    +        Some(1000),
    +        Some("STANDARD"),
    +    );
    +    let fp_b = fingerprint::request_fingerprint(
    +        TABLE_ARN,
    +        &b,
    +        &base_output(),
    +        Some(1000),
    +        Some("STANDARD"),
    +    );
    +    assert_ne!(fp_a, fp_b);
    +}
    +
    +#[test]
    +fn changing_the_table_arn_changes_the_fingerprint() {
    +    let fp = fingerprint::request_fingerprint(
    +        "arn:aws:dynamodb:us-east-1:123456789012:table/other",
    +        &base_input(),
    +        &base_output(),
    +        Some(1000),
    +        Some("STANDARD"),
    +    );
    +    assert_ne!(fp, base_fingerprint());
    +}
    +
    +#[test]
    +fn changing_the_input_bucket_changes_the_fingerprint() {
    +    let mut input = base_input();
    +    input["S3Bucket"] = json!("other-loads");
    +    let fp = fingerprint::request_fingerprint(
    +        TABLE_ARN,
    +        &input,
    +        &base_output(),
    +        Some(1000),
    +        Some("STANDARD"),
    +    );
    +    assert_ne!(fp, base_fingerprint());
    +}
    +
    +#[test]
    +fn changing_the_input_prefix_changes_the_fingerprint() {
    +    let mut input = base_input();
    +    input["S3KeyPrefix"] = json!("orders/2026-09-11/");
    +    let fp = fingerprint::request_fingerprint(
    +        TABLE_ARN,
    +        &input,
    +        &base_output(),
    +        Some(1000),
    +        Some("STANDARD"),
    +    );
    +    assert_ne!(fp, base_fingerprint());
    +}
    +
    +#[test]
    +fn changing_the_compression_changes_the_fingerprint() {
    +    let mut input = base_input();
    +    input["InputCompressionType"] = json!("NONE");
    +    let fp = fingerprint::request_fingerprint(
    +        TABLE_ARN,
    +        &input,
    +        &base_output(),
    +        Some(1000),
    +        Some("STANDARD"),
    +    );
    +    assert_ne!(fp, base_fingerprint());
    +}
    +
    +#[test]
    +fn changing_the_output_type_changes_the_fingerprint() {
    +    let mut output = base_output();
    +    output["OutputType"] = json!(["ERROR"]);
    +    let fp = fingerprint::request_fingerprint(
    +        TABLE_ARN,
    +        &base_input(),
    +        &output,
    +        Some(1000),
    +        Some("STANDARD"),
    +    );
    +    assert_ne!(fp, base_fingerprint());
    +}
    +
    +#[test]
    +fn escaped_string_values_keep_distinct_requests_distinct() {
    +    // Under an unescaped rendering, a bucket value spelling
    +    // `x","S3KeyPrefix":"a` would collapse the one-member configuration
    +    // into the two-member one below (the keys already sort that way), and
    +    // two different requests would replay each other's operation. The
    +    // canonical form escapes the quotes, so the fingerprints differ.
    +    let crafted = json!({"S3Bucket": "x\",\"S3KeyPrefix\":\"a"});
    +    let plain = json!({"S3Bucket": "x", "S3KeyPrefix": "a"});
    +    let output = base_output();
    +    let crafted_fp = fingerprint::request_fingerprint(TABLE_ARN, &crafted, &output, None, None);
    +    let plain_fp = fingerprint::request_fingerprint(TABLE_ARN, &plain, &output, None, None);
    +    assert_ne!(
    +        crafted_fp, plain_fp,
    +        "escaping keeps the canonical form injective"
    +    );
    +}
    +
    +#[test]
    +fn changing_the_write_request_units_changes_the_fingerprint() {
    +    let some_other = fingerprint::request_fingerprint(
    +        TABLE_ARN,
    +        &base_input(),
    +        &base_output(),
    +        Some(2000),
    +        Some("STANDARD"),
    +    );
    +    assert_ne!(some_other, base_fingerprint());
    +    let absent = fingerprint::request_fingerprint(
    +        TABLE_ARN,
    +        &base_input(),
    +        &base_output(),
    +        None,
    +        Some("STANDARD"),
    +    );
    +    assert_ne!(absent, base_fingerprint());
    +}
    +
    +#[test]
    +fn changing_the_performance_tier_changes_the_fingerprint() {
    +    let accelerated = fingerprint::request_fingerprint(
    +        TABLE_ARN,
    +        &base_input(),
    +        &base_output(),
    +        Some(1000),
    +        Some("ACCELERATED"),
    +    );
    +    assert_ne!(accelerated, base_fingerprint());
    +    let absent = fingerprint::request_fingerprint(
    +        TABLE_ARN,
    +        &base_input(),
    +        &base_output(),
    +        Some(1000),
    +        None,
    +    );
    +    assert_ne!(absent, base_fingerprint());
    +}
    +
    +#[test]
    +fn escaped_characters_in_the_prefix_produce_distinct_fingerprints() {
    +    // A quote, a backslash, and a control character are legal in
    +    // S3KeyPrefix under the spec's path rules, so the canonical form must
    +    // stay injective across them for idempotency to compare requests
    +    // correctly.
    +    let fp = |prefix: &str| {
    +        let mut input = base_input();
    +        input["S3KeyPrefix"] = json!(prefix);
    +        fingerprint::request_fingerprint(
    +            TABLE_ARN,
    +            &input,
    +            &base_output(),
    +            Some(1000),
    +            Some("STANDARD"),
    +        )
    +    };
    +    let quote = fp("a\"b");
    +    let backslash = fp("a\\b");
    +    let control = fp("a\u{1}b");
    +    assert_ne!(quote, backslash);
    +    assert_ne!(quote, control);
    +    assert_ne!(backslash, control);
    +    assert_eq!(fp("a\"b"), quote);
    +}
    +
    +#[test]
    +fn a_prefix_that_mimics_the_canonical_form_does_not_collide() {
    +    // Under a canonical writer that copies strings without escaping, this
    +    // prefix renders byte-identically to the two separate members below,
    +    // so two different requests would share a fingerprint and a token
    +    // replay would be answered with the wrong operation instead of
    +    // IdempotentParameterMismatchException.
    +    let mut smuggled = base_input();
    +    smuggled["S3KeyPrefix"] = json!(r#"a","S3Z":"b"#);
    +    let mut split = base_input();
    +    split["S3KeyPrefix"] = json!("a");
    +    split["S3Z"] = json!("b");
    +    let fp_smuggled = fingerprint::request_fingerprint(
    +        TABLE_ARN,
    +        &smuggled,
    +        &base_output(),
    +        Some(1000),
    +        Some("STANDARD"),
    +    );
    +    let fp_split = fingerprint::request_fingerprint(
    +        TABLE_ARN,
    +        &split,
    +        &base_output(),
    +        Some(1000),
    +        Some("STANDARD"),
    +    );
    +    assert_ne!(fp_smuggled, fp_split);
    +}
    +
    +#[test]
    +fn nested_null_members_equal_absent_members() {
    +    // Null members are removed recursively, so a null one level down
    +    // hashes as absent too.
    +    let mut with_null = base_input();
    +    with_null["Nested"] = json!({"Keep": "x", "Drop": null});
    +    let mut without = base_input();
    +    without["Nested"] = json!({"Keep": "x"});
    +    let fp_with_null = fingerprint::request_fingerprint(
    +        TABLE_ARN,
    +        &with_null,
    +        &base_output(),
    +        Some(1000),
    +        Some("STANDARD"),
    +    );
    +    let fp_without = fingerprint::request_fingerprint(
    +        TABLE_ARN,
    +        &without,
    +        &base_output(),
    +        Some(1000),
    +        Some("STANDARD"),
    +    );
    +    assert_eq!(fp_with_null, fp_without);
    +}
    +
    +// The service replays a retry that adds a default a request may omit
    +// without changing the operation. The handler normalizes these before
    +// fingerprinting; `normalized_fp` mirrors that composition
    +// (`strip_replayed_defaults` on each configuration, PerformanceTier
    +// STANDARD to an absent tier).
    +
    +/// The account the TABLE_ARN names; an own-account S3BucketOwner is a
    +/// replayed default.
    +const OWN_ACCOUNT: &str = "123456789012";
    +
    +fn wm4_input() -> serde_json::Value {
    +    json!({ "S3Bucket": "loads", "InputFormat": "DYNAMODB_JSON" })
    +}
    +
    +fn wm4_output() -> serde_json::Value {
    +    json!({ "S3Bucket": "reports", "OutputType": ["ERROR"] })
    +}
    +
    +fn normalized_fp(
    +    input: &serde_json::Value,
    +    output: &serde_json::Value,
    +    tier: Option<&str>,
    +) -> String {
    +    fingerprint::request_fingerprint(
    +        TABLE_ARN,
    +        &fingerprint::strip_replayed_defaults(input, OWN_ACCOUNT),
    +        &fingerprint::strip_replayed_defaults(output, OWN_ACCOUNT),
    +        Some(1000),
    +        tier.filter(|t| *t != "STANDARD"),
    +    )
    +}
    +
    +#[test]
    +fn an_explicit_none_compression_replays() {
    +    let mut input = wm4_input();
    +    input["InputCompressionType"] = json!("NONE");
    +    assert_eq!(
    +        normalized_fp(&input, &wm4_output(), None),
    +        normalized_fp(&wm4_input(), &wm4_output(), None),
    +        "a retry that adds InputCompressionType NONE replays"
    +    );
    +}
    +
    +#[test]
    +fn an_explicit_standard_tier_replays() {
    +    assert_eq!(
    +        normalized_fp(&wm4_input(), &wm4_output(), Some("STANDARD")),
    +        normalized_fp(&wm4_input(), &wm4_output(), None),
    +        "a retry that adds PerformanceTier STANDARD replays"
    +    );
    +}
    +
    +#[test]
    +fn an_own_account_input_bucket_owner_replays() {
    +    let mut input = wm4_input();
    +    input["S3BucketOwner"] = json!(OWN_ACCOUNT);
    +    assert_eq!(
    +        normalized_fp(&input, &wm4_output(), None),
    +        normalized_fp(&wm4_input(), &wm4_output(), None),
    +        "a retry that adds an own-account input S3BucketOwner replays"
    +    );
    +}
    +
    +#[test]
    +fn an_own_account_output_bucket_owner_replays() {
    +    let mut output = wm4_output();
    +    output["S3BucketOwner"] = json!(OWN_ACCOUNT);
    +    assert_eq!(
    +        normalized_fp(&wm4_input(), &output, None),
    +        normalized_fp(&wm4_input(), &wm4_output(), None),
    +        "a retry that adds an own-account output S3BucketOwner replays"
    +    );
    +}
    +
    +#[test]
    +fn an_added_sse_algorithm_mismatches() {
    +    let mut output = wm4_output();
    +    output["S3SseAlgorithm"] = json!("AES256");
    +    assert_ne!(
    +        normalized_fp(&wm4_input(), &output, None),
    +        normalized_fp(&wm4_input(), &wm4_output(), None),
    +        "a retry that adds S3SseAlgorithm AES256 mismatches"
    +    );
    +}
    +
    +#[test]
    +fn a_foreign_bucket_owner_is_not_a_replayed_default() {
    +    let mut input = wm4_input();
    +    input["S3BucketOwner"] = json!("999999999999");
    +    assert_ne!(
    +        normalized_fp(&input, &wm4_output(), None),
    +        normalized_fp(&wm4_input(), &wm4_output(), None),
    +        "only the caller's own account is the S3BucketOwner default"
    +    );
    +}
    +
    +#[test]
    +fn a_request_without_defaults_keeps_its_pre_change_fingerprint() {
    +    // Stored-record compatibility: strip_replayed_defaults removes only a
    +    // member present at its default value, so a configuration that spells
    +    // no default (or a non-default value) is returned unchanged. The
    +    // canonical document, and therefore the fingerprint, is byte-identical
    +    // to what the code produced before this change, so a token issued
    +    // before an upgrade still replays. A record whose original request
    +    // spelled a default explicitly is the one case that changes, which is
    +    // the intended normalization.
    +    let mut input = wm4_input();
    +    input["InputCompressionType"] = json!("GZIP");
    +    input["S3BucketOwner"] = json!("999999999999");
    +    assert_eq!(
    +        fingerprint::strip_replayed_defaults(&input, OWN_ACCOUNT),
    +        input
    +    );
    +    assert_eq!(
    +        fingerprint::strip_replayed_defaults(&wm4_output(), OWN_ACCOUNT),
    +        wm4_output()
    +    );
    +    // With nothing stripped, the normalized fingerprint equals the raw
    +    // request_fingerprint of the same request.
    +    assert_eq!(
    +        normalized_fp(&input, &wm4_output(), Some("ACCELERATED")),
    +        fingerprint::request_fingerprint(
    +            TABLE_ARN,
    +            &input,
    +            &wm4_output(),
    +            Some(1000),
    +            Some("ACCELERATED")
    +        )
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/tests/row_parsing/kinds.rs b/crates/engine/src/bulk_put/tests/row_parsing/kinds.rs
    new file mode 100644
    index 000000000..1ca3224d6
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/row_parsing/kinds.rs
    @@ -0,0 +1,149 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! How the expression-size bound counts each value kind, measured on the
    +//! hosted service at the 1 MiB edge. The
    +//! condition is the 35-byte `attribute_not_exists(pk) OR #v = :v`, so a
    +//! content count of 1,048,541 reaches the bound and 1,048,540 does not.
    +
    +use super::super::*;
    +use base64::Engine as _;
    +
    +const COND: &str = "attribute_not_exists(pk) OR #v = :v";
    +const EDGE_IN: usize = 1024 * 1024 - 35 - 1;
    +const EDGE_OUT: usize = 1024 * 1024 - 35;
    +
    +fn line_with_value(value: serde_json::Value) -> String {
    +    let values = serde_json::json!({ ":v": value });
    +    format!(
    +        "{{\"Item\": {{\"pk\": {{\"S\": \"a\"}}}}, \
    +         \"ConditionExpression\": \"{COND}\", \
    +         \"ExpressionAttributeNames\": {{\"#v\": \"v\"}}, \
    +         \"ExpressionAttributeValues\": {values}}}"
    +    )
    +}
    +
    +fn parse(line: &str) -> Result, rows::RowError> {
    +    rows::parse_row(line, 4 * 1024 * 1024)
    +}
    +
    +fn expect_size_fault(line: &str, label: &str) {
    +    match parse(line) {
    +        Err(rows::RowError::Item(message)) => {
    +            assert_eq!(message, rows::EXPRESSION_SIZE_MESSAGE, "{label}");
    +        }
    +        other => panic!("{label}: expected the expression-size fault, got {other:?}"),
    +    }
    +}
    +
    +fn expect_evaluated(line: &str, label: &str) {
    +    assert!(
    +        parse(line)
    +            .unwrap_or_else(|e| panic!("{label}: {e:?}"))
    +            .is_some(),
    +        "{label}"
    +    );
    +}
    +
    +fn b64(raw_len: usize) -> String {
    +    base64::engine::general_purpose::STANDARD.encode(vec![0u8; raw_len])
    +}
    +
    +// A binary value counts its decoded bytes, not its base64 text.
    +#[test]
    +fn a_binary_value_counts_its_decoded_bytes() {
    +    // Text of 1,048,544 chars holding 786,408 raw bytes: under the edge by raw bytes.
    +    let text = b64(786_408);
    +    assert_eq!(text.len(), 1_048_544);
    +    expect_evaluated(
    +        &line_with_value(serde_json::json!({ "B": text })),
    +        "binary under the edge",
    +    );
    +    // 1,048,541 raw bytes: at the edge by raw bytes.
    +    expect_size_fault(
    +        &line_with_value(serde_json::json!({ "B": b64(EDGE_OUT) })),
    +        "binary at the edge",
    +    );
    +    expect_evaluated(
    +        &line_with_value(serde_json::json!({ "B": b64(EDGE_IN) })),
    +        "one byte under",
    +    );
    +}
    +
    +// A map counts each attribute name as well as its value.
    +#[test]
    +fn a_map_counts_its_attribute_names() {
    +    // A one-byte key is the byte that reaches the edge.
    +    let m = serde_json::json!({ "M": { "k": { "S": "a".repeat(EDGE_IN) } } });
    +    expect_size_fault(&line_with_value(m), "one-byte key at the edge");
    +    // The same value under a map with no key bytes to spare is evaluated one byte under.
    +    let m = serde_json::json!({ "M": { "k": { "S": "a".repeat(EDGE_IN - 1) } } });
    +    expect_evaluated(&line_with_value(m), "one-byte key one under");
    +    // A twenty-byte key beside a value ten under the edge is over by ten.
    +    let m = serde_json::json!({ "M": { "k".repeat(20): { "S": "a".repeat(EDGE_IN - 10) } } });
    +    expect_size_fault(&line_with_value(m), "twenty-byte key");
    +}
    +
    +// List members and set members sum.
    +#[test]
    +fn list_and_set_members_sum() {
    +    let half = EDGE_OUT / 2;
    +    let rest = EDGE_OUT - half;
    +    let l = serde_json::json!({ "L": [ { "S": "a".repeat(half) }, { "S": "b".repeat(rest) } ] });
    +    expect_size_fault(&line_with_value(l), "list at the edge");
    +    let ss = serde_json::json!({ "SS": [ "a".repeat(half), "b".repeat(rest) ] });
    +    expect_size_fault(&line_with_value(ss), "string set at the edge");
    +    let l =
    +        serde_json::json!({ "L": [ { "S": "a".repeat(half) }, { "S": "b".repeat(rest - 1) } ] });
    +    expect_evaluated(&line_with_value(l), "list one under");
    +}
    +
    +// Type tags never count: an `S` value at the edge is the same count whether
    +// read as a bare value or as the single member of a list.
    +#[test]
    +fn type_tags_do_not_count() {
    +    let bare = serde_json::json!({ "S": "a".repeat(EDGE_IN) });
    +    expect_evaluated(&line_with_value(bare), "bare S one under");
    +    let listed = serde_json::json!({ "L": [ { "S": "a".repeat(EDGE_IN) } ] });
    +    expect_evaluated(&line_with_value(listed), "listed S one under");
    +}
    +
    +// An empty names map and a value that fails its own rule
    +// are judged before the size bounds; an unused placeholder is not
    +// a parse-time fault at all, so the size bound wins there.
    +#[test]
    +fn an_empty_names_map_and_a_bad_value_are_judged_before_the_size_bounds() {
    +    let big = "a".repeat(EDGE_OUT);
    +    let empty_names = format!(
    +        "{{\"Item\": {{\"pk\": {{\"S\": \"a\"}}}}, \
    +         \"ConditionExpression\": \"{COND}\", \
    +         \"ExpressionAttributeNames\": {{}}, \
    +         \"ExpressionAttributeValues\": {{\":v\": {{\"S\": \"{big}\"}}}}}}"
    +    );
    +    match parse(&empty_names) {
    +        Err(rows::RowError::Item(message)) => {
    +            assert_eq!(message, rows::NAMES_EMPTY_MESSAGE, "an empty names map");
    +        }
    +        other => panic!("expected the empty-names fault, got {other:?}"),
    +    }
    +    let bad_value = format!(
    +        "{{\"Item\": {{\"pk\": {{\"S\": \"a\"}}}}, \
    +         \"ConditionExpression\": \"attribute_not_exists(pk) OR #v = :v OR #v = :w\", \
    +         \"ExpressionAttributeNames\": {{\"#v\": \"v\"}}, \
    +         \"ExpressionAttributeValues\": {{\":v\": {{\"S\": \"{big}\"}}, \":w\": {{\"NULL\": false}}}}}}"
    +    );
    +    match parse(&bad_value) {
    +        Err(rows::RowError::Item(message)) => assert!(
    +            message.contains("Null attribute value types must have the value of true for key :w"),
    +            "a bad value: got {message}"
    +        ),
    +        other => panic!("expected the wrapped NULL fault, got {other:?}"),
    +    }
    +    let unused_placeholder = format!(
    +        "{{\"Item\": {{\"pk\": {{\"S\": \"a\"}}}}, \
    +         \"ConditionExpression\": \"{COND}\", \
    +         \"ExpressionAttributeNames\": {{\"#v\": \"v\", \"#w\": \"w\"}}, \
    +         \"ExpressionAttributeValues\": {{\":v\": {{\"S\": \"{big}\"}}}}}}"
    +    );
    +    expect_size_fault(&unused_placeholder, "an unused placeholder");
    +}
    diff --git a/crates/engine/src/bulk_put/tests/row_parsing/linear.rs b/crates/engine/src/bulk_put/tests/row_parsing/linear.rs
    new file mode 100644
    index 000000000..5ac8b0209
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/row_parsing/linear.rs
    @@ -0,0 +1,141 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Row parsing is linear in the size of the row. A row is parsed on a
    +//! runtime worker, so a check that compares each member of a set, or each
    +//! name of an attribute map, with every one before it, or that copies the
    +//! path of a value for every element beneath it, lets one row under the
    +//! 4 MiB line cap hold the worker for minutes. Each case parses on a thread
    +//! of its own and fails when the parse has not returned within [`BOUND`].
    +
    +use std::sync::mpsc;
    +use std::time::Duration;
    +
    +use super::super::*;
    +
    +/// How long one of the rows below may take to parse.
    +const BOUND: Duration = Duration::from_secs(10);
    +
    +/// The default bulk line cap, 4 MiB.
    +const MAX_LINE: usize = 4 * 1024 * 1024;
    +
    +/// Parse `line` on a thread of its own and wait at most [`BOUND`] for the
    +/// row. A parse still running at the bound is left to finish on its thread.
    +fn parse_within_bound(line: String, label: &str) -> rows::Row {
    +    assert!(line.len() <= MAX_LINE, "{label}: the line is over the cap");
    +    let (sender, receiver) = mpsc::channel();
    +    std::thread::spawn(move || {
    +        // The receiver is gone once the bound has passed.
    +        let _ = sender.send(rows::parse_row(&line, MAX_LINE));
    +    });
    +    match receiver.recv_timeout(BOUND) {
    +        Ok(Ok(Some(row))) => row,
    +        Ok(other) => panic!("{label}: expected a row, got {other:?}"),
    +        Err(_) => panic!("{label}: the row did not parse within {BOUND:?}"),
    +    }
    +}
    +
    +/// One line whose item holds `pk` and the given typed attributes.
    +fn item_line(attributes: impl IntoIterator) -> String {
    +    let mut item = serde_json::Map::new();
    +    item.insert("pk".to_owned(), json!({"S": "a"}));
    +    item.extend(attributes);
    +    json!({ "Item": item }).to_string()
    +}
    +
    +fn one(name: &str, value: serde_json::Value) -> [(String, serde_json::Value); 1] {
    +    [(name.to_owned(), value)]
    +}
    +
    +#[test]
    +fn a_string_set_of_80000_members_parses_within_the_bound() {
    +    let members: Vec = (0..80_000).map(|i| format!("member-{i:05}")).collect();
    +    let row = parse_within_bound(item_line(one("s", json!({ "SS": members }))), "SS");
    +    let Some(AttributeValue::SS(set)) = row.item.get("s") else {
    +        panic!("s is a string set");
    +    };
    +    assert_eq!(set.len(), 80_000);
    +}
    +
    +#[test]
    +fn a_string_set_of_80000_entries_with_each_member_twice_keeps_one_copy_of_each() {
    +    let members: Vec = (0..80_000)
    +        .map(|i| format!("member-{:05}", i % 40_000))
    +        .collect();
    +    let row = parse_within_bound(item_line(one("s", json!({ "SS": members }))), "SS twice");
    +    let Some(AttributeValue::SS(set)) = row.item.get("s") else {
    +        panic!("s is a string set");
    +    };
    +    assert_eq!(set.len(), 40_000);
    +}
    +
    +#[test]
    +fn a_number_set_of_80000_members_parses_within_the_bound() {
    +    let members: Vec = (0..80_000).map(|i| i.to_string()).collect();
    +    let row = parse_within_bound(item_line(one("n", json!({ "NS": members }))), "NS");
    +    let Some(AttributeValue::NS(set)) = row.item.get("n") else {
    +        panic!("n is a number set");
    +    };
    +    assert_eq!(set.len(), 80_000);
    +}
    +
    +#[test]
    +fn a_binary_set_of_80000_members_parses_within_the_bound() {
    +    use base64::Engine as _;
    +    let members: Vec = (0_u32..80_000)
    +        .map(|i| base64::engine::general_purpose::STANDARD.encode(i.to_be_bytes()))
    +        .collect();
    +    let row = parse_within_bound(item_line(one("b", json!({ "BS": members }))), "BS");
    +    let Some(AttributeValue::BS(set)) = row.item.get("b") else {
    +        panic!("b is a binary set");
    +    };
    +    assert_eq!(set.len(), 80_000);
    +}
    +
    +#[test]
    +fn an_item_of_80000_attribute_names_parses_within_the_bound() {
    +    let attributes = (0..80_000).map(|i| (format!("a{i:05}"), json!({"NULL": true})));
    +    let row = parse_within_bound(item_line(attributes), "item names");
    +    assert_eq!(row.item.len(), 80_001);
    +}
    +
    +#[test]
    +fn a_map_of_80000_member_names_parses_within_the_bound() {
    +    let members: serde_json::Map = (0..80_000)
    +        .map(|i| (format!("k{i:05}"), json!({"NULL": true})))
    +        .collect();
    +    let row = parse_within_bound(item_line(one("m", json!({ "M": members }))), "map names");
    +    let Some(AttributeValue::M(map)) = row.item.get("m") else {
    +        panic!("m is a map");
    +    };
    +    assert_eq!(map.len(), 80_000);
    +}
    +
    +/// A list of 230,000 empty maps under an attribute named with 2 MiB. The
    +/// name is over the attribute name limit, which the runner judges after the
    +/// parse; the parse itself must not copy the name for each element, nor for
    +/// each element's map.
    +#[test]
    +fn a_list_under_a_long_attribute_name_parses_within_the_bound() {
    +    let name = "n".repeat(2 * 1024 * 1024);
    +    let elements = vec![json!({"M": {}}); 230_000];
    +    let row = parse_within_bound(item_line(one(&name, json!({ "L": elements }))), "list");
    +    let Some(AttributeValue::L(list)) = row.item.get(&name) else {
    +        panic!("the long name holds a list");
    +    };
    +    assert_eq!(list.len(), 230_000);
    +}
    +
    +/// A map of 100,000 members under an attribute named with 1,000,000 bytes.
    +#[test]
    +fn a_map_under_a_long_attribute_name_parses_within_the_bound() {
    +    let name = "n".repeat(1_000_000);
    +    let members: serde_json::Map = (0..100_000)
    +        .map(|i| (format!("k{i:05}"), json!({"NULL": true})))
    +        .collect();
    +    let row = parse_within_bound(item_line(one(&name, json!({ "M": members }))), "map");
    +    let Some(AttributeValue::M(map)) = row.item.get(&name) else {
    +        panic!("the long name holds a map");
    +    };
    +    assert_eq!(map.len(), 100_000);
    +}
    diff --git a/crates/engine/src/bulk_put/tests/row_parsing/mod.rs b/crates/engine/src/bulk_put/tests/row_parsing/mod.rs
    new file mode 100644
    index 000000000..e778d92b3
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/row_parsing/mod.rs
    @@ -0,0 +1,505 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +mod kinds;
    +mod linear;
    +mod records;
    +mod repeats;
    +
    +// Row parsing: the happy path.
    +
    +#[test]
    +fn plain_item_line_parses_with_no_condition() {
    +    let row = rows::parse_row(r#"{"Item": {"pk": {"S": "a"}, "n": {"N": "1"}}}"#, 1 << 20)
    +        .unwrap()
    +        .expect("a plain item line is a row");
    +    assert_eq!(row.item["pk"], AttributeValue::S("a".to_owned()));
    +    assert_eq!(row.item["n"], AttributeValue::N("1".to_owned()));
    +    assert!(row.condition.is_none());
    +}
    +
    +#[test]
    +fn bare_condition_expression_is_carried() {
    +    let row = rows::parse_row(
    +        r#"{"Item": {"pk": {"S": "b"}}, "ConditionExpression": "attribute_not_exists(pk)"}"#,
    +        1 << 20,
    +    )
    +    .unwrap()
    +    .expect("row");
    +    let condition = row.condition.expect("condition is carried");
    +    assert_eq!(condition.expression, "attribute_not_exists(pk)");
    +    assert!(condition.names.is_none());
    +    assert!(condition.values.is_none());
    +}
    +
    +#[test]
    +fn expression_names_and_values_are_carried() {
    +    let line = concat!(
    +        r##"{"Item": {"pk": {"S": "c"}, "v": {"N": "2"}}, "##,
    +        r##""ConditionExpression": "#v < :max", "##,
    +        r##""ExpressionAttributeNames": {"#v": "v"}, "##,
    +        r##""ExpressionAttributeValues": {":max": {"N": "10"}}}"##
    +    );
    +    let row = rows::parse_row(line, 1 << 20).unwrap().expect("row");
    +    let condition = row.condition.expect("condition is carried");
    +    assert_eq!(condition.expression, "#v < :max");
    +    let names = condition.names.expect("names are carried");
    +    assert_eq!(names, HashMap::from([("#v".to_owned(), "v".to_owned())]));
    +    let values = condition.values.expect("values are carried");
    +    assert_eq!(
    +        values,
    +        HashMap::from([(":max".to_owned(), AttributeValue::N("10".to_owned()))])
    +    );
    +}
    +
    +// Measured on the service: a top-level key outside the four the record shape
    +// allows abandons the file, and the check runs before the missing-`Item`
    +// one, so a misspelled `Item` answers the unknown-field message. The
    +// September reading that such keys are ignored no longer holds.
    +#[test]
    +fn an_unknown_top_level_key_abandons_the_file() {
    +    let error = rows::parse_row(
    +        r#"{"Item": {"pk": {"S": "a"}}, "SizeBytes": 42, "Comment": "x"}"#,
    +        1 << 20,
    +    )
    +    .unwrap_err();
    +    assert_eq!(
    +        error,
    +        rows::RowError::Parse(rows::UNKNOWN_FIELD_MESSAGE.to_owned())
    +    );
    +}
    +
    +#[test]
    +fn a_misspelled_item_key_answers_the_unknown_field_message_not_the_missing_item_one() {
    +    let error = rows::parse_row(r#"{"Itme": {"pk": {"S": "a"}}}"#, 1 << 20).unwrap_err();
    +    assert_eq!(
    +        error,
    +        rows::RowError::Parse(rows::UNKNOWN_FIELD_MESSAGE.to_owned())
    +    );
    +    // With no stray key the missing-`Item` message stands.
    +    let error = rows::parse_row(
    +        r#"{"ConditionExpression": "attribute_not_exists(pk)"}"#,
    +        1 << 20,
    +    )
    +    .unwrap_err();
    +    assert_eq!(
    +        error,
    +        rows::RowError::Parse(rows::ITEM_MISSING_MESSAGE.to_owned())
    +    );
    +}
    +
    +#[test]
    +fn the_four_record_keys_together_are_accepted() {
    +    let row = rows::parse_row(
    +        r##"{"Item": {"pk": {"S": "a"}}, "ConditionExpression": "#v = :v", "ExpressionAttributeNames": {"#v": "v"}, "ExpressionAttributeValues": {":v": {"S": "x"}}}"##,
    +        1 << 20,
    +    )
    +    .unwrap()
    +    .expect("a row with every allowed key parses");
    +    assert!(row.condition.is_some());
    +}
    +
    +// Measured on the service: 63 map levels parse (the row then fails alone at the
    +// item nesting limit); 64 and 65 abandon the file with the long nesting
    +// message. The parser reports the bound before the JSON is read, so the
    +// recursion the parser would otherwise refuse at 128 brackets never runs.
    +#[test]
    +fn nesting_at_sixty_three_levels_parses_and_sixty_four_abandons_the_file() {
    +    assert!(
    +        rows::parse_row(&nested_item_line(63), 1 << 20)
    +            .unwrap()
    +            .is_some()
    +    );
    +    for depth in [64, 65, 200] {
    +        let error = rows::parse_row(&nested_item_line(depth), 1 << 20).unwrap_err();
    +        assert_eq!(
    +            error,
    +            rows::RowError::Parse(rows::NESTING_ABANDON_MESSAGE.to_owned()),
    +            "depth {depth}"
    +        );
    +    }
    +}
    +
    +#[test]
    +fn brackets_inside_string_literals_do_not_count_toward_the_nesting_bound() {
    +    let brackets = "{[".repeat(200);
    +    let line = json!({"Item": {"pk": {"S": brackets}, "q": {"S": "a\"{[b"}}}).to_string();
    +    let row = rows::parse_row(&line, 1 << 20).unwrap().expect("a row");
    +    assert_eq!(row.item["pk"], AttributeValue::S(brackets));
    +}
    +
    +#[test]
    +fn blank_line_is_skipped() {
    +    assert!(rows::parse_row("", 1 << 20).unwrap().is_none());
    +}
    +
    +#[test]
    +fn whitespace_only_line_is_skipped() {
    +    assert!(rows::parse_row(" \t  ", 1 << 20).unwrap().is_none());
    +}
    +
    +// Row parsing: every parse-level fault carries the one abandon message.
    +
    +#[test]
    +fn invalid_json_abandons_the_file() {
    +    // Measured on the service: text that is not JSON answers the tokenizer's
    +    // message, not the type-tag one.
    +    assert_eq!(abandon_message("not json {"), rows::CORRUPT_STREAM_MESSAGE);
    +}
    +
    +#[test]
    +fn truncated_json_abandons_the_file() {
    +    assert_eq!(
    +        abandon_message(r#"{"Item": {"pk": {"S": "a"#),
    +        rows::CORRUPT_STREAM_MESSAGE
    +    );
    +}
    +
    +#[test]
    +fn line_without_an_item_key_abandons_the_file_with_its_own_message() {
    +    // Measured: a missing Item carries its own message and still follows
    +    // the parse-level path, so the file is abandoned.
    +    assert_eq!(
    +        abandon_message(r#"{"ConditionExpression": "attribute_not_exists(pk)"}"#),
    +        "Expected 'Item' top level container. Remainder of the file will not be processed."
    +    );
    +}
    +
    +#[test]
    +fn item_as_an_array_abandons_the_file_with_its_own_message() {
    +    // Measured: a non-object Item carries its own message and still
    +    // follows the parse-level path.
    +    assert_eq!(
    +        abandon_message(r#"{"Item": [{"S": "a"}]}"#),
    +        "Expected character {. Remainder of the file will not be processed."
    +    );
    +}
    +
    +#[test]
    +fn item_as_a_string_abandons_the_file_with_its_own_message() {
    +    assert_eq!(
    +        abandon_message(r#"{"Item": "pk"}"#),
    +        "Expected character {. Remainder of the file will not be processed."
    +    );
    +}
    +
    +#[test]
    +fn unknown_type_tag_abandons_the_file() {
    +    assert_eq!(
    +        abandon_message(r#"{"Item": {"pk": {"BOOM": true}}}"#),
    +        ABANDON
    +    );
    +}
    +
    +#[test]
    +fn two_type_tags_abandon_the_file() {
    +    // Measured on the service: a value carrying two tags abandons
    +    // the file with the message naming the closing brace, not the type-tag
    +    // text this case used to answer.
    +    assert_eq!(
    +        abandon_message(r#"{"Item": {"pk": {"S": "a", "N": "1"}}}"#),
    +        rows::REPEATED_MEMBER_MESSAGE
    +    );
    +}
    +
    +#[test]
    +fn empty_attribute_value_abandons_the_file() {
    +    assert_eq!(abandon_message(r#"{"Item": {"pk": {}}}"#), ABANDON);
    +}
    +
    +#[test]
    +fn a_binary_value_that_is_not_base64_abandons_the_file_with_its_own_message() {
    +    // Measured on the service: the value did not decode and the file is
    +    // abandoned, with a message naming the base64 fault.
    +    assert_eq!(
    +        abandon_message(r#"{"Item": {"pk": {"S": "a"}, "b": {"B": "!!!notbase64"}}}"#),
    +        rows::BINARY_NOT_BASE64_MESSAGE
    +    );
    +}
    +
    +#[test]
    +fn an_empty_string_set_fails_the_row_with_the_put_item_message() {
    +    assert_eq!(
    +        item_message(r#"{"Item": {"pk": {"S": "a"}, "ss": {"SS": []}}}"#),
    +        "One or more parameter values were invalid: An string set  may not be empty"
    +    );
    +}
    +
    +#[test]
    +fn an_empty_number_set_fails_the_row_with_the_put_item_message() {
    +    assert_eq!(
    +        item_message(r#"{"Item": {"pk": {"S": "a"}, "ns": {"NS": []}}}"#),
    +        "One or more parameter values were invalid: An number set  may not be empty"
    +    );
    +}
    +
    +#[test]
    +fn a_duplicate_set_member_is_accepted_and_stored_deduplicated() {
    +    // Measured on the service: the bulk path writes `["a", "a"]` as `["a"]`,
    +    // processed and not failed, where PutItem refuses the same value.
    +    let row = rows::parse_row(
    +        r#"{"Item": {"pk": {"S": "a"}, "ss": {"SS": ["a", "a"]}, "ns": {"NS": ["1", "2", "1"]}, "m": {"M": {"bs": {"BS": ["AA==", "AA=="]}}}}}"#,
    +        1 << 20,
    +    )
    +    .unwrap()
    +    .expect("a repeated member is not a fault");
    +    assert_eq!(
    +        row.item["ss"],
    +        AttributeValue::SS(["a".to_owned()].into_iter().collect())
    +    );
    +    assert_eq!(
    +        row.item["ns"],
    +        AttributeValue::NS(["1".to_owned(), "2".to_owned()].into_iter().collect())
    +    );
    +    match &row.item["m"] {
    +        AttributeValue::M(map) => assert_eq!(
    +            map["bs"],
    +            AttributeValue::BS([vec![0u8]].into_iter().collect())
    +        ),
    +        other => panic!("map expected, got {other:?}"),
    +    }
    +}
    +
    +#[test]
    +fn a_set_under_an_attribute_named_like_a_type_tag_is_still_deduplicated() {
    +    // Measured on the service: the service stores a
    +    // set held by an attribute literally named `SS`, `NS` or `BS`, and a set
    +    // nested under a map named `SS`, deduplicated like any other set.
    +    let row = rows::parse_row(
    +        r#"{"Item": {"pk": {"S": "a"}, "SS": {"SS": ["x", "x"]}, "NS": {"NS": ["1", "1"]}, "BS": {"BS": ["AQ==", "AQ=="]}, "nested": {"M": {"SS": {"M": {"inner": {"SS": ["y", "y"]}}}}}, "list": {"L": [{"SS": ["z", "z"]}, {"M": {"BS": {"BS": ["AQ==", "AQ=="]}}}]}}}"#,
    +        1 << 20,
    +    )
    +    .unwrap()
    +    .expect("a repeated member under a tag-named attribute is not a fault");
    +    assert_eq!(
    +        row.item["SS"],
    +        AttributeValue::SS(["x".to_owned()].into_iter().collect())
    +    );
    +    assert_eq!(
    +        row.item["NS"],
    +        AttributeValue::NS(["1".to_owned()].into_iter().collect())
    +    );
    +    assert_eq!(
    +        row.item["BS"],
    +        AttributeValue::BS([vec![1u8]].into_iter().collect())
    +    );
    +    let AttributeValue::M(nested) = &row.item["nested"] else {
    +        panic!("map expected")
    +    };
    +    let AttributeValue::M(under_ss) = &nested["SS"] else {
    +        panic!("map expected under the attribute named SS")
    +    };
    +    assert_eq!(
    +        under_ss["inner"],
    +        AttributeValue::SS(["y".to_owned()].into_iter().collect())
    +    );
    +    let AttributeValue::L(list) = &row.item["list"] else {
    +        panic!("list expected")
    +    };
    +    assert_eq!(
    +        list[0],
    +        AttributeValue::SS(["z".to_owned()].into_iter().collect())
    +    );
    +    let AttributeValue::M(in_list) = &list[1] else {
    +        panic!("map expected inside the list")
    +    };
    +    assert_eq!(
    +        in_list["BS"],
    +        AttributeValue::BS([vec![1u8]].into_iter().collect())
    +    );
    +}
    +
    +#[test]
    +fn a_string_value_containing_nul_fails_the_row() {
    +    assert_eq!(
    +        item_message(r#"{"Item": {"pk": {"S": "a\u0000b"}}}"#),
    +        rows::NUL_IN_STRING_MESSAGE
    +    );
    +}
    +
    +#[test]
    +fn an_attribute_name_containing_nul_fails_the_row() {
    +    assert_eq!(
    +        item_message(r#"{"Item": {"pk": {"S": "a"}, "x\u0000y": {"N": "1"}}}"#),
    +        rows::NUL_IN_STRING_MESSAGE
    +    );
    +}
    +
    +#[test]
    +fn nul_nested_in_a_map_inside_a_list_fails_the_row() {
    +    assert_eq!(
    +        item_message(
    +            r#"{"Item": {"pk": {"S": "a"}, "l": {"L": [{"M": {"k": {"SS": ["ok", "no\u0000"]}}}]}}}"#
    +        ),
    +        rows::NUL_IN_STRING_MESSAGE
    +    );
    +}
    +
    +#[test]
    +fn a_binary_value_decoding_to_a_zero_byte_parses() {
    +    // U+0000 is refused in text; a zero byte in a binary value is bytes
    +    // and is stored as such.
    +    let row = rows::parse_row(
    +        r#"{"Item": {"pk": {"S": "a"}, "b": {"B": "AAA="}}}"#,
    +        1 << 20,
    +    )
    +    .unwrap()
    +    .expect("binary zero bytes parse");
    +    assert_eq!(row.item["b"], AttributeValue::B(vec![0, 0]));
    +}
    +
    +#[test]
    +fn line_one_byte_over_the_cap_abandons_the_file() {
    +    let line = r#"{"Item": {"pk": {"S": "aaaaaaaa"}}}"#;
    +    match rows::parse_row(line, line.len() - 1) {
    +        Err(rows::RowError::Parse(message)) => assert_eq!(message, ABANDON),
    +        other => panic!("expected a parse-level error, got {other:?}"),
    +    }
    +}
    +
    +#[test]
    +fn line_exactly_at_the_cap_parses() {
    +    let line = r#"{"Item": {"pk": {"S": "aaaaaaaa"}}}"#;
    +    let row = rows::parse_row(line, line.len())
    +        .unwrap()
    +        .expect("a line exactly at the cap is not over it");
    +    assert_eq!(row.item["pk"], AttributeValue::S("aaaaaaaa".to_owned()));
    +}
    +
    +// Row parsing: item-level faults are not the parser's business.
    +
    +#[test]
    +fn row_missing_a_key_attribute_still_parses() {
    +    // The parser has no table schema; key checks belong to the runner.
    +    let row = rows::parse_row(r#"{"Item": {"other": {"S": "a"}}}"#, 1 << 20)
    +        .unwrap()
    +        .expect("key validation is not the parser's");
    +    assert_eq!(row.item.len(), 1);
    +}
    +
    +#[test]
    +fn row_with_an_oversized_string_still_parses() {
    +    // 500 KB exceeds the 400 KB item cap; size checks belong to the runner.
    +    let big = "a".repeat(500 * 1024);
    +    let line = format!(r#"{{"Item": {{"pk": {{"S": "{big}"}}}}}}"#);
    +    let row = rows::parse_row(&line, 1 << 20)
    +        .unwrap()
    +        .expect("item size validation is not the parser's");
    +    assert_eq!(row.item["pk"], AttributeValue::S(big));
    +}
    +
    +// Row parsing: expression maps without a condition are an item-level
    +// fault. The row fails alone with the PutItem message for the same case
    +// and the file continues, unlike a parse-level fault.
    +
    +#[test]
    +fn names_without_an_expression_is_an_item_fault() {
    +    let line = r##"{"Item": {"pk": {"S": "a"}}, "ExpressionAttributeNames": {"#v": "v"}}"##;
    +    match rows::parse_row(line, 1 << 20) {
    +        Err(rows::RowError::Item(message)) => assert_eq!(
    +            message,
    +            "ExpressionAttributeNames can only be specified when using expressions"
    +        ),
    +        other => panic!("expected an item-level error, got {other:?}"),
    +    }
    +}
    +
    +#[test]
    +fn values_without_an_expression_is_an_item_fault() {
    +    let line =
    +        r#"{"Item": {"pk": {"S": "a"}}, "ExpressionAttributeValues": {":max": {"N": "10"}}}"#;
    +    match rows::parse_row(line, 1 << 20) {
    +        Err(rows::RowError::Item(message)) => assert_eq!(
    +            message,
    +            "ExpressionAttributeValues can only be specified when using expressions: \
    +             ConditionExpression is null"
    +        ),
    +        other => panic!("expected an item-level error, got {other:?}"),
    +    }
    +}
    +
    +#[test]
    +fn both_maps_without_an_expression_answer_the_names_message() {
    +    let line = concat!(
    +        r##"{"Item": {"pk": {"S": "a"}}, "ExpressionAttributeNames": {"#v": "v"}, "##,
    +        r##""ExpressionAttributeValues": {":max": {"N": "10"}}}"##
    +    );
    +    match rows::parse_row(line, 1 << 20) {
    +        Err(rows::RowError::Item(message)) => assert_eq!(
    +            message,
    +            "ExpressionAttributeNames can only be specified when using expressions"
    +        ),
    +        other => panic!("expected an item-level error, got {other:?}"),
    +    }
    +}
    +
    +#[test]
    +fn empty_names_map_without_an_expression_parses_unconditional() {
    +    let line = r#"{"Item": {"pk": {"S": "a"}}, "ExpressionAttributeNames": {}}"#;
    +    let row = rows::parse_row(line, 1 << 20)
    +        .unwrap()
    +        .expect("an empty map references nothing and does not trip the check");
    +    assert!(row.condition.is_none());
    +}
    +
    +#[test]
    +fn empty_condition_expression_with_a_names_map_fails_the_row() {
    +    // handle_put_item treats an empty expression as absent, so the maps
    +    // have nothing to reference and the row fails item-level exactly as it
    +    // would with no ConditionExpression key at all.
    +    let line = concat!(
    +        r##"{"Item": {"pk": {"S": "a"}}, "ConditionExpression": "", "##,
    +        r##""ExpressionAttributeNames": {"#v": "v"}}"##
    +    );
    +    match rows::parse_row(line, 1 << 20) {
    +        Err(rows::RowError::Item(message)) => assert_eq!(
    +            message,
    +            "ExpressionAttributeNames can only be specified when using expressions"
    +        ),
    +        other => panic!("expected an item-level error, got {other:?}"),
    +    }
    +}
    +
    +#[test]
    +fn empty_condition_expression_alone_parses_unconditional() {
    +    let line = r#"{"Item": {"pk": {"S": "a"}}, "ConditionExpression": ""}"#;
    +    let row = rows::parse_row(line, 1 << 20)
    +        .unwrap()
    +        .expect("an empty expression is no expression, matching PutItem");
    +    assert!(row.condition.is_none());
    +}
    +
    +#[test]
    +fn whitespace_padded_line_over_the_cap_abandons_the_file() {
    +    // The cap applies to the raw byte length of the line, before trimming,
    +    // matching the spec's "a line longer than max_bulk_line_bytes".
    +    let json_part = r#"{"Item": {"pk": {"S": "a"}}}"#;
    +    let line = format!("{json_part}{}", " ".repeat(30));
    +    let cap = json_part.len() + 10;
    +    assert!(line.len() > cap && json_part.len() < cap);
    +    match rows::parse_row(&line, cap) {
    +        Err(rows::RowError::Parse(message)) => assert_eq!(message, ABANDON),
    +        other => panic!("expected a parse-level error, got {other:?}"),
    +    }
    +}
    +
    +#[test]
    +fn non_string_condition_expression_abandons_the_file() {
    +    // A condition field of the wrong JSON type is parse-level: the tree has
    +    // no PutItem message for a malformed field, only for a missing or
    +    // unreferenced one.
    +    assert_eq!(
    +        abandon_message(r#"{"Item": {"pk": {"S": "a"}}, "ConditionExpression": 5}"#),
    +        ABANDON
    +    );
    +}
    +
    +#[test]
    +fn non_object_expression_names_abandons_the_file() {
    +    assert_eq!(
    +        abandon_message(r#"{"Item": {"pk": {"S": "a"}}, "ExpressionAttributeNames": "x"}"#),
    +        ABANDON
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/tests/row_parsing/records.rs b/crates/engine/src/bulk_put/tests/row_parsing/records.rs
    new file mode 100644
    index 000000000..1cdc5070f
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/row_parsing/records.rs
    @@ -0,0 +1,491 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Record structure and expression-map faults, measured on the hosted
    +//! service.
    +
    +use super::super::*;
    +
    +// A fault decoding one `ExpressionAttributeValues`
    +// entry fails the row alone and the file continues, with the single-item
    +// text for the fault wrapped in the map's own message and the entry's key
    +// named.
    +
    +#[test]
    +fn a_null_false_expression_value_fails_the_row_with_the_wrapped_message() {
    +    let line = concat!(
    +        r#"{"Item": {"pk": {"S": "a"}}, "#,
    +        r#""ConditionExpression": "attribute_not_exists(pk)", "#,
    +        r#""ExpressionAttributeValues": {":v": {"NULL": false}}}"#
    +    );
    +    assert_eq!(
    +        item_message(line),
    +        "ExpressionAttributeValues contains invalid value: One or more parameter values were \
    +         invalid: Null attribute value types must have the value of true for key :v"
    +    );
    +}
    +
    +#[test]
    +fn an_unknown_type_tag_in_an_expression_value_fails_the_row_with_the_type_tag_text() {
    +    let line = concat!(
    +        r#"{"Item": {"pk": {"S": "a"}}, "#,
    +        r#""ConditionExpression": "attribute_not_exists(pk)", "#,
    +        r#""ExpressionAttributeValues": {":v": {"XX": "1"}}}"#
    +    );
    +    assert_eq!(
    +        item_message(line),
    +        "ExpressionAttributeValues contains invalid value: Expected DynamoDB type to be one of \
    +         [S, N, B, BOOL, NULL, L, M, SS, NS, BS] for key :v"
    +    );
    +}
    +
    +#[test]
    +fn a_non_numeric_expression_value_fails_the_row_with_the_conversion_text() {
    +    let line = concat!(
    +        r#"{"Item": {"pk": {"S": "a"}}, "#,
    +        r#""ConditionExpression": "attribute_not_exists(pk)", "#,
    +        r#""ExpressionAttributeValues": {":v": {"N": "abc"}}}"#
    +    );
    +    assert_eq!(
    +        item_message(line),
    +        "ExpressionAttributeValues contains invalid value: The parameter cannot be converted to \
    +         a numeric value: abc for key :v"
    +    );
    +}
    +
    +#[test]
    +fn a_valid_expression_value_beside_a_bad_one_does_not_change_the_reported_key() {
    +    let line = concat!(
    +        r#"{"Item": {"pk": {"S": "a"}}, "#,
    +        r#""ConditionExpression": "attribute_not_exists(pk)", "#,
    +        r#""ExpressionAttributeValues": {":a": {"S": "ok"}, ":b": {"N": "abc"}}}"#
    +    );
    +    assert_eq!(
    +        item_message(line),
    +        "ExpressionAttributeValues contains invalid value: The parameter cannot be converted to \
    +         a numeric value: abc for key :b"
    +    );
    +}
    +
    +#[test]
    +fn the_type_tag_text_is_the_abandon_message_without_its_suffix() {
    +    assert_eq!(
    +        format!(
    +            "{}. Remainder of the file will not be processed.",
    +            rows::TYPE_TAG_MESSAGE
    +        ),
    +        rows::PARSE_ABANDON_MESSAGE
    +    );
    +}
    +
    +// An `N` value inside Item whose text is not a number abandons the
    +// file. ExtendDB answered the item-level conversion text and kept reading.
    +
    +#[test]
    +fn a_non_numeric_number_in_the_item_abandons_the_file() {
    +    assert_eq!(
    +        abandon_message(r#"{"Item": {"pk": {"S": "a"}, "sk": {"N": "not-a-number"}}}"#),
    +        "Expected a valid numeric. Remainder of the file will not be processed."
    +    );
    +}
    +
    +#[test]
    +fn a_non_numeric_number_nested_in_a_map_abandons_the_file() {
    +    assert_eq!(
    +        abandon_message(r#"{"Item": {"pk": {"S": "a"}, "m": {"M": {"n": {"N": "x"}}}}}"#),
    +        "Expected a valid numeric. Remainder of the file will not be processed."
    +    );
    +}
    +
    +// The same text inside ExpressionAttributeValues stays item-level (covered
    +// above): the two classes are kept apart.
    +#[test]
    +fn a_number_outside_the_supported_range_is_still_the_rows_own_fault() {
    +    // Unmeasured: a number of valid syntax that no magnitude accepts is
    +    // left to the item validators the runner runs, which report it as a row
    +    // fault, so the file is not abandoned at it.
    +    let row = rows::parse_row(
    +        r#"{"Item": {"pk": {"S": "a"}, "n": {"N": "1e1000"}}}"#,
    +        1 << 20,
    +    )
    +    .expect("a magnitude fault is not a parse-level fault");
    +    assert!(row.is_some());
    +}
    +
    +#[test]
    +fn valid_numbers_in_the_item_still_parse() {
    +    let row = rows::parse_row(
    +        r#"{"Item": {"pk": {"S": "a"}, "n": {"N": "-0.5e3"}, "l": {"L": [{"N": "7"}]}}}"#,
    +        1 << 20,
    +    )
    +    .unwrap()
    +    .expect("row");
    +    assert_eq!(row.item["n"], AttributeValue::N("-500".to_owned()));
    +}
    +
    +// An attribute name repeated inside the Item attribute
    +// map fails the row alone and names the path, at the top level and inside a
    +// nested M. The JSON parser kept the last value silently and the row was
    +// written.
    +
    +#[test]
    +fn a_repeated_top_level_attribute_name_fails_the_row() {
    +    assert_eq!(
    +        item_message(r#"{"Item": {"pk": {"S": "a"}, "a": {"S": "1"}, "a": {"S": "2"}}}"#),
    +        "One or more parameter values were invalid: Value for Item.a is ambiguous"
    +    );
    +}
    +
    +#[test]
    +fn a_repeated_name_inside_a_map_attribute_fails_the_row_with_the_typed_path() {
    +    assert_eq!(
    +        item_message(
    +            r#"{"Item": {"pk": {"S": "a"}, "m": {"M": {"k": {"S": "1"}, "k": {"S": "2"}}}}}"#
    +        ),
    +        "One or more parameter values were invalid: Value for Item.m.M.k is ambiguous"
    +    );
    +}
    +
    +#[test]
    +fn a_repeated_attribute_name_with_the_same_value_fails_the_row_too() {
    +    // ExtendDB's reading of the repeat rule: the repeat itself is the
    +    // fault, as it is for a repeated record member. An attribute
    +    // name repeated with equal values was not measured.
    +    assert_eq!(
    +        item_message(r#"{"Item": {"pk": {"S": "a"}, "a": {"S": "1"}, "a": {"S": "1"}}}"#),
    +        "One or more parameter values were invalid: Value for Item.a is ambiguous"
    +    );
    +}
    +
    +// A member given twice where one was
    +// expected abandons the file with the message naming the closing brace,
    +// and a set tag whose value is not an array names the opening
    +// bracket. ExtendDB answered the type-tag text for the two-tag shape and
    +// kept the last value for the repeats.
    +
    +#[test]
    +fn a_record_carrying_item_twice_abandons_the_file() {
    +    assert_eq!(
    +        abandon_message(r#"{"Item": {"pk": {"S": "a"}}, "Item": {"pk": {"S": "b"}}}"#),
    +        "Expected character }. Remainder of the file will not be processed."
    +    );
    +}
    +
    +#[test]
    +fn a_record_carrying_item_twice_with_equal_values_abandons_the_file() {
    +    assert_eq!(
    +        abandon_message(r#"{"Item": {"pk": {"S": "a"}}, "Item": {"pk": {"S": "a"}}}"#),
    +        "Expected character }. Remainder of the file will not be processed."
    +    );
    +}
    +
    +#[test]
    +fn a_typed_value_with_its_tag_repeated_abandons_the_file() {
    +    assert_eq!(
    +        abandon_message(r#"{"Item": {"pk": {"S": "1", "S": "2"}}}"#),
    +        "Expected character }. Remainder of the file will not be processed."
    +    );
    +}
    +
    +#[test]
    +fn a_typed_value_with_two_different_tags_abandons_the_file() {
    +    assert_eq!(
    +        abandon_message(r#"{"Item": {"pk": {"S": "1", "N": "2"}}}"#),
    +        "Expected character }. Remainder of the file will not be processed."
    +    );
    +}
    +
    +#[test]
    +fn a_set_tag_whose_value_is_not_an_array_abandons_the_file() {
    +    assert_eq!(
    +        abandon_message(r#"{"Item": {"pk": {"S": "a"}, "ss": {"SS": "notarray"}}}"#),
    +        "Expected character [. Remainder of the file will not be processed."
    +    );
    +}
    +
    +#[test]
    +fn a_line_that_is_an_array_wrapping_a_record_still_answers_the_token_message() {
    +    // The tokenizer's message, not the brace one.
    +    assert_eq!(
    +        abandon_message(r#"[ {"Item": {"pk": {"S": "a"}}} ]"#),
    +        rows::CORRUPT_STREAM_MESSAGE
    +    );
    +}
    +
    +// An empty expression map supplied with a
    +// ConditionExpression fails the row alone; the empty map was exempt and the
    +// row was written.
    +
    +#[test]
    +fn an_empty_values_map_with_a_condition_fails_the_row() {
    +    let line = concat!(
    +        r#"{"Item": {"pk": {"S": "a"}}, "#,
    +        r#""ConditionExpression": "attribute_not_exists(pk)", "#,
    +        r#""ExpressionAttributeValues": {}}"#
    +    );
    +    assert_eq!(
    +        item_message(line),
    +        "ExpressionAttributeValues must not be empty"
    +    );
    +}
    +
    +#[test]
    +fn an_empty_names_map_with_a_condition_fails_the_row() {
    +    let line = concat!(
    +        r#"{"Item": {"pk": {"S": "a"}}, "#,
    +        r#""ConditionExpression": "attribute_not_exists(pk)", "#,
    +        r#""ExpressionAttributeNames": {}}"#
    +    );
    +    assert_eq!(
    +        item_message(line),
    +        "ExpressionAttributeNames must not be empty"
    +    );
    +}
    +
    +// Unmeasured and left as it was: with no expression to reference, an empty
    +// map is exempt and the row is written unconditional.
    +#[test]
    +fn an_empty_values_map_without_a_condition_still_parses_unconditional() {
    +    let line = r#"{"Item": {"pk": {"S": "a"}}, "ExpressionAttributeValues": {}}"#;
    +    let row = rows::parse_row(line, 1 << 20)
    +        .unwrap()
    +        .expect("an empty map references nothing and does not trip the check");
    +    assert!(row.condition.is_none());
    +}
    +
    +/// The compact JSON overhead of a one-entry values map holding an empty
    +/// `S`: `{":v":{"S":""}}`.
    +fn values_map_overhead() -> usize {
    +    serde_json::to_string(&json!({":v": {"S": ""}}))
    +        .expect("the empty-value map serializes")
    +        .len()
    +}
    +
    +/// A record with one expression value whose compact values map is
    +/// `values_serialized` bytes and, when `names` is given, that names map;
    +/// the condition references the value (and the placeholder when present).
    +fn line_with_maps(values_serialized: usize, names: Option<&str>) -> String {
    +    let value = "a".repeat(values_serialized - values_map_overhead());
    +    match names {
    +        None => format!(
    +            "{{\"Item\": {{\"pk\": {{\"S\": \"a\"}}}}, \
    +             \"ConditionExpression\": \"attribute_not_exists(pk) OR v = :v\", \
    +             \"ExpressionAttributeValues\": {{\":v\": {{\"S\": \"{value}\"}}}}}}"
    +        ),
    +        Some(placeholder) => format!(
    +            "{{\"Item\": {{\"pk\": {{\"S\": \"a\"}}}}, \
    +             \"ConditionExpression\": \"attribute_not_exists(pk) OR {placeholder} = :v\", \
    +             \"ExpressionAttributeNames\": {{\"{placeholder}\": \"v\"}}, \
    +             \"ExpressionAttributeValues\": {{\":v\": {{\"S\": \"{value}\"}}}}}}"
    +        ),
    +    }
    +}
    +
    +fn parse(line: &str) -> Result, rows::RowError> {
    +    rows::parse_row(line, 4 * 1024 * 1024)
    +}
    +
    +fn expect_item_fault(line: &str, expected: &str, label: &str) {
    +    match parse(line) {
    +        Err(rows::RowError::Item(message)) => assert_eq!(message, expected, "{label}"),
    +        other => panic!("{label}: expected an item-level fault, got {other:?}"),
    +    }
    +}
    +
    +fn expect_abandon(line: &str, label: &str) {
    +    match parse(line) {
    +        Err(rows::RowError::Parse(message)) => {
    +            assert_eq!(message, rows::VALUES_TOO_LARGE_MESSAGE, "{label}");
    +        }
    +        other => panic!("{label}: expected the abandon, got {other:?}"),
    +    }
    +}
    +
    +// The abandon class (measured on the service, and
    +// ): a values map over 2,097,152 bytes of compact JSON
    +// abandons the file, with or without a names map, and the bound is on the
    +// whole map.
    +#[test]
    +fn a_values_map_over_two_mebibytes_abandons_the_file() {
    +    let line = line_with_maps(rows::MAX_EXPRESSION_VALUES_BYTES + 100, Some("#v"));
    +    assert!(
    +        line.len() < 4 * 1024 * 1024,
    +        "the row sits under the service's line cap"
    +    );
    +    expect_abandon(&line, "100 bytes over");
    +    expect_abandon(
    +        &line_with_maps(rows::MAX_EXPRESSION_VALUES_BYTES + 1, Some("#v")),
    +        "one byte over",
    +    );
    +    expect_abandon(
    +        &line_with_maps(rows::MAX_EXPRESSION_VALUES_BYTES + 1, None),
    +        "one byte over with no names map",
    +    );
    +}
    +
    +#[test]
    +fn two_values_abandon_together_when_their_map_passes_the_bound() {
    +    // Two values of 1,100,000 bytes, each under the bound, the
    +    // map over it.
    +    let value = "a".repeat(1_100_000);
    +    let line = format!(
    +        "{{\"Item\": {{\"pk\": {{\"S\": \"a\"}}}}, \
    +         \"ConditionExpression\": \"attribute_not_exists(pk) OR #v = :v OR #v = :w\", \
    +         \"ExpressionAttributeNames\": {{\"#v\": \"v\"}}, \
    +         \"ExpressionAttributeValues\": {{\":v\": {{\"S\": \"{value}\"}}, \":w\": {{\"S\": \"{value}\"}}}}}}"
    +    );
    +    expect_abandon(&line, "two values over the bound together");
    +}
    +
    +// The combined class (measured on the service): a values map of
    +// exactly 2,097,152 bytes beside `{"#v": "v"}` is not an abandon; the two
    +// maps together, the names map counted by its raw bytes (3 here), pass the
    +// combined bound and the row fails with the combined-size text.
    +#[test]
    +fn the_two_maps_together_over_the_combined_bound_fail_the_row() {
    +    expect_item_fault(
    +        &line_with_maps(rows::MAX_EXPRESSION_VALUES_BYTES, Some("#v")),
    +        rows::MAPS_TOO_LARGE_MESSAGE,
    +        "values at the abandon bound beside a names map",
    +    );
    +    // The edge: values plus raw names bytes exactly at the bound is not
    +    // this class.
    +    let names_raw = "#v".len() + "v".len();
    +    assert_eq!(names_raw, 3);
    +    match parse(&line_with_maps(
    +        rows::MAX_EXPRESSION_MAPS_BYTES - names_raw,
    +        Some("#v"),
    +    )) {
    +        Err(rows::RowError::Item(message)) => {
    +            assert_ne!(
    +                message,
    +                rows::MAPS_TOO_LARGE_MESSAGE,
    +                "at the bound is not the combined class"
    +            );
    +        }
    +        other => panic!("expected the expression-size fault at the combined edge, got {other:?}"),
    +    }
    +}
    +
    +// Measurements on the service fix the names map's contribution as its raw bytes:
    +// values maps of 2,097,148 beside `{"#v": "v"}` and 2,097,140 beside
    +// `{"#vvvvvvvvv": "v"}` were refused with the expression-size text, not the
    +// combined-size text, which the names map's compact JSON length (10 and
    +// 18 bytes) would have produced.
    +#[test]
    +fn the_names_map_counts_its_raw_bytes_toward_the_combined_bound() {
    +    expect_item_fault(
    +        &line_with_maps(2_097_148, Some("#v")),
    +        rows::EXPRESSION_SIZE_MESSAGE,
    +        "a two-byte placeholder",
    +    );
    +    expect_item_fault(
    +        &line_with_maps(2_097_140, Some("#vvvvvvvvv")),
    +        rows::EXPRESSION_SIZE_MESSAGE,
    +        "a ten-byte placeholder",
    +    );
    +}
    +
    +// The expression-size class (measured on the service): the condition text
    +// plus the content bytes of its values reaching 1 MiB fails the row with the
    +// single-item expression-size text, under the other two bounds. Below that
    +// the condition is evaluated.
    +#[test]
    +fn a_values_map_over_the_expression_size_edge_fails_the_row() {
    +    expect_item_fault(
    +        &line_with_maps(1_048_591, Some("#v")),
    +        rows::EXPRESSION_SIZE_MESSAGE,
    +        "over the edge",
    +    );
    +    // The edge: condition `attribute_not_exists(pk) OR #v = :v` (35 bytes) plus a
    +    // value of 1,048,541 bytes, 1,048,576 together.
    +    let cond_len = "attribute_not_exists(pk) OR #v = :v".len();
    +    assert_eq!(cond_len, 35);
    +    let edge_value = rows::MAX_EXPRESSION_SIZE_BYTES - cond_len;
    +    assert_eq!(edge_value, 1_048_541);
    +    expect_item_fault(
    +        &line_with_maps(edge_value + values_map_overhead(), Some("#v")),
    +        rows::EXPRESSION_SIZE_MESSAGE,
    +        "at the edge",
    +    );
    +}
    +
    +#[test]
    +fn a_values_map_under_the_expression_size_edge_parses() {
    +    let cond_len = "attribute_not_exists(pk) OR #v = :v".len();
    +    for (value_len, label) in [
    +        (
    +            rows::MAX_EXPRESSION_SIZE_BYTES - cond_len - 1,
    +            "1,048,575 together",
    +        ),
    +        (1_048_539, "1,048,539 bytes"),
    +        (1_000_000, "1,000,000 bytes"),
    +        (100_000, "100,000 bytes"),
    +        (4_000, "4,000 bytes"),
    +    ] {
    +        assert!(
    +            parse(&line_with_maps(
    +                value_len + values_map_overhead(),
    +                Some("#v")
    +            ))
    +            .unwrap_or_else(|e| panic!("{label}: {e:?}"))
    +            .is_some(),
    +            "{label}"
    +        );
    +    }
    +}
    +
    +// The bound counts the condition text: the same value beside a longer
    +// condition fails the row one byte sooner.
    +#[test]
    +fn the_expression_size_bound_counts_the_condition_text() {
    +    let short = "attribute_not_exists(pk) OR #v = :v";
    +    let long = "attribute_not_exists(pk) OR #v = :v OR #v = :v";
    +    let value_len = rows::MAX_EXPRESSION_SIZE_BYTES - long.len();
    +    let value = "a".repeat(value_len);
    +    let line = |cond: &str| {
    +        format!(
    +            "{{\"Item\": {{\"pk\": {{\"S\": \"a\"}}}}, \
    +             \"ConditionExpression\": \"{cond}\", \
    +             \"ExpressionAttributeNames\": {{\"#v\": \"v\"}}, \
    +             \"ExpressionAttributeValues\": {{\":v\": {{\"S\": \"{value}\"}}}}}}"
    +        )
    +    };
    +    expect_item_fault(
    +        &line(long),
    +        rows::EXPRESSION_SIZE_MESSAGE,
    +        "long condition at 1 MiB",
    +    );
    +    assert!(
    +        parse(&line(short))
    +            .expect("the short condition stays under the bound")
    +            .is_some()
    +    );
    +}
    +
    +// The names map counts the placeholder AND the
    +// attribute name it maps to toward the combined 2 MiB bound on both
    +// expression maps. A values map of 2,097,140 serialized bytes beside a
    +// names map `{"#v": <20-byte attribute name>}` (placeholder 2 bytes, name
    +// 20 bytes, raw sum 22) and a condition over both exceeds the bound
    +// (2,097,162 over 2,097,152) and fails the row with the combined-size text.
    +// Counting only the placeholder (2 bytes) would leave 2,097,142, under the
    +// bound, and the row would fall to the expression-size text instead. The
    +// existing raw-bytes test uses a one-byte attribute name, which both the
    +// placeholder-only and the placeholder-plus-name rules leave under the
    +// bound, so it does not decide the attribute-name side; this one does.
    +#[test]
    +fn the_names_map_attribute_name_counts_toward_the_combined_bound() {
    +    let value = "a".repeat(2_097_140 - values_map_overhead());
    +    let line = format!(
    +        "{{\"Item\": {{\"pk\": {{\"S\": \"a\"}}}}, \
    +         \"ConditionExpression\": \"attribute_not_exists(pk) OR #v = :v\", \
    +         \"ExpressionAttributeNames\": {{\"#v\": \"vvvvvvvvvvvvvvvvvvvv\"}}, \
    +         \"ExpressionAttributeValues\": {{\":v\": {{\"S\": \"{value}\"}}}}}}"
    +    );
    +    expect_item_fault(
    +        &line,
    +        rows::MAPS_TOO_LARGE_MESSAGE,
    +        "the placeholder and the attribute name both count",
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/tests/row_parsing/repeats.rs b/crates/engine/src/bulk_put/tests/row_parsing/repeats.rs
    new file mode 100644
    index 000000000..90b4a3ded
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/row_parsing/repeats.rs
    @@ -0,0 +1,113 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The set members the parser removes as repeats, and the item size they
    +//! carry to the runner's size check. The service stores a set with one copy
    +//! of each member and sizes it as submitted (measured): the item keeps one
    +//! copy and the row records the size of the copies removed.
    +
    +use base64::Engine as _;
    +use extenddb_core::types::attribute_value_size;
    +
    +use super::super::*;
    +
    +fn row(item: serde_json::Value) -> rows::Row {
    +    rows::parse_row(&json!({ "Item": item }).to_string(), 4 * 1024 * 1024)
    +        .unwrap()
    +        .expect("a row")
    +}
    +
    +fn number_size(number: &str) -> usize {
    +    attribute_value_size(&AttributeValue::N(number.to_owned()))
    +}
    +
    +/// A number set member that is not a JSON string is refused by the
    +/// deserializer whatever its position: the dedupe keys it apart from the
    +/// normalized numbers, so `NS ["1", 1]` is not taken for a set with a
    +/// repeat of `"1"` and stored as `NS ["1"]`.
    +#[test]
    +fn a_number_set_member_that_is_not_a_string_fails_the_row_in_either_position() {
    +    let parse = |set: serde_json::Value| {
    +        rows::parse_row(
    +            &json!({ "Item": {"pk": {"S": "a"}, "ns": {"NS": set}} }).to_string(),
    +            4 * 1024 * 1024,
    +        )
    +    };
    +    let first = parse(json!([1, "1"])).expect_err("a number member first fails the row");
    +    let second = parse(json!(["1", 1])).expect_err("a number member second fails the row");
    +    assert_eq!(format!("{first:?}"), format!("{second:?}"));
    +}
    +
    +#[test]
    +fn a_string_set_records_the_bytes_of_each_repeated_member() {
    +    let big = "x".repeat(250_000);
    +    let parsed = row(json!({"pk": {"S": "a"}, "ss": {"SS": [big, big]}}));
    +    assert_eq!(parsed.repeated_member_bytes, 250_000);
    +    assert_eq!(
    +        parsed.item["ss"],
    +        AttributeValue::SS([big].into_iter().collect())
    +    );
    +}
    +
    +#[test]
    +fn a_number_set_records_each_removed_member_at_its_number_size() {
    +    let parsed = row(json!({"pk": {"S": "a"}, "ns": {"NS": ["1", "1.0", "25", "25", "7"]}}));
    +    assert_eq!(
    +        parsed.repeated_member_bytes,
    +        number_size("1") + number_size("25")
    +    );
    +    assert_eq!(
    +        parsed.item["ns"],
    +        AttributeValue::NS(["1", "25", "7"].map(str::to_owned).into_iter().collect())
    +    );
    +}
    +
    +#[test]
    +fn a_binary_set_records_the_decoded_bytes_of_each_repeated_member() {
    +    let encode = |bytes: &[u8]| base64::engine::general_purpose::STANDARD.encode(bytes);
    +    let repeated = encode(&[7; 1000]);
    +    let other = encode(&[8; 10]);
    +    let parsed = row(json!({"pk": {"S": "a"}, "bs": {"BS": [repeated, other, repeated]}}));
    +    assert_eq!(parsed.repeated_member_bytes, 1000);
    +    assert_eq!(
    +        parsed.item["bs"],
    +        AttributeValue::BS([vec![7; 1000], vec![8; 10]].into_iter().collect())
    +    );
    +}
    +
    +#[test]
    +fn repeats_inside_maps_and_lists_are_counted_with_the_ones_at_the_top() {
    +    let parsed = row(json!({
    +        "pk": {"S": "a"},
    +        "ss": {"SS": ["ab", "ab"]},
    +        "m": {"M": {"inner": {"SS": ["cde", "cde", "cde"]}}},
    +        "l": {"L": [{"SS": ["f", "f"]}, {"L": [{"NS": ["10", "1e1"]}]}]}
    +    }));
    +    assert_eq!(
    +        parsed.repeated_member_bytes,
    +        2 + 2 * 3 + 1 + number_size("10")
    +    );
    +}
    +
    +#[test]
    +fn a_row_with_no_repeated_member_records_none() {
    +    let parsed = row(json!({
    +        "pk": {"S": "a"},
    +        "ss": {"SS": ["a", "b"]},
    +        "ns": {"NS": ["1", "2"]},
    +        "m": {"M": {"inner": {"SS": ["a"]}}}
    +    }));
    +    assert_eq!(parsed.repeated_member_bytes, 0);
    +}
    +
    +#[test]
    +fn a_conditional_row_records_its_repeated_members_too() {
    +    let line = json!({
    +        "Item": {"pk": {"S": "a"}, "ss": {"SS": ["abc", "abc"]}},
    +        "ConditionExpression": "attribute_not_exists(pk)"
    +    })
    +    .to_string();
    +    let parsed = rows::parse_row(&line, 1 << 20).unwrap().expect("a row");
    +    assert!(parsed.condition.is_some());
    +    assert_eq!(parsed.repeated_member_bytes, 3);
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/abandon_cut_restart.rs b/crates/engine/src/bulk_put/tests/runner/abandon_cut_restart.rs
    new file mode 100644
    index 000000000..1302616f2
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/abandon_cut_restart.rs
    @@ -0,0 +1,109 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! An abandon that cut its file short ends the operation the same way
    +//! after a kill as without one, however far the run had read into the next
    +//! file when it was killed.
    +
    +use super::*;
    +
    +/// One row of exactly `bytes` bytes, line terminator excluded; `fault`
    +/// gives its attribute an unknown type tag, a parse-level fault that
    +/// abandons the rest of the file.
    +fn padded_row(index: usize, bytes: usize, fault: bool) -> String {
    +    let tag = if fault { "X" } else { "S" };
    +    let head = format!(r#"{{"Item":{{"pk":{{"S":"k{index}"}},"sk":{{"S":"s"}},"p":{{"{tag}":""#);
    +    let tail = r#""}}}"#;
    +    let pad = bytes - head.len() - tail.len();
    +    format!("{head}{}{tail}", "x".repeat(pad))
    +}
    +
    +/// `lines` rows of 160 bytes, terminator included, the first `faults` of
    +/// them carrying the parse-level fault.
    +fn file_of_160_byte_rows(lines: usize, first: usize, faults: usize) -> Vec {
    +    let mut out = String::new();
    +    for line in 0..lines {
    +        out.push_str(&padded_row(first + line, 159, line < faults));
    +        out.push('\n');
    +    }
    +    out.into_bytes()
    +}
    +
    +/// File 0, 23,840 bytes, is abandoned at its first line, a cut at the end
    +/// of the 8,000-byte read that held the fault (measured on the service);
    +/// file 1 holds 600 rows of 160 bytes.
    +fn stage(h: &Harness) {
    +    h.write_input("part-000.json", &file_of_160_byte_rows(149, 0, 1));
    +    h.write_input("part-001.json", &file_of_160_byte_rows(600, 1000, 0));
    +}
    +
    +async fn a_cut_file_still_fails_the_operation_after_a_kill_inside_the_next_file_case(
    +    backend: Backend,
    +) {
    +    let fresh = Harness::on(backend).await;
    +    stage(&fresh);
    +    let files = fresh.enumerate().await;
    +    assert_eq!(files[0].size, 23_840);
    +    let expected = fresh
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +    assert_eq!(expected.status, "FAILED");
    +    assert_eq!(
    +        expected.failure_code.as_deref(),
    +        Some("ValidationException")
    +    );
    +    assert_eq!(expected.processed_input_bytes, 8_000 + 600 * 160);
    +    fresh.cleanup().await;
    +
    +    let h = Harness::on(backend).await;
    +    stage(&h);
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +    // The faulting line of file 0, then 300 rows of file 1: the checkpoint
    +    // at row 300 of file 1 has persisted more bytes than file 0's size.
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 301,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let deps =
    +        h.with_short_store_lease(h.deps_with("runner-1", config(), source, Arc::new(h.fs_sink())));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +    let mid = h.record(&op).await;
    +    assert_eq!((mid.cursor_file_index, mid.cursor_row_index), (1, 300));
    +    assert!(
    +        mid.processed_input_bytes > 23_840,
    +        "the persisted figure has passed file 0's size: {}",
    +        mid.processed_input_bytes
    +    );
    +
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-2", config()), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, expected.status);
    +    assert_eq!(record.failure_code, expected.failure_code);
    +    assert_eq!(record.failure_message, expected.failure_message);
    +    assert_eq!(record.processed_item_count, expected.processed_item_count);
    +    assert_eq!(record.failed_item_count, expected.failed_item_count);
    +    assert_eq!(
    +        record.processed_input_bytes, expected.processed_input_bytes,
    +        "file 0 counts its cut, not its size"
    +    );
    +    h.cleanup().await;
    +}
    +
    +on_both_backends!(
    +    a_cut_file_still_fails_the_operation_after_a_kill_inside_the_next_file_case,
    +    a_cut_file_still_fails_the_operation_after_a_kill_inside_the_next_file,
    +    a_cut_file_still_fails_the_operation_after_a_kill_inside_the_next_file_on_postgres
    +);
    diff --git a/crates/engine/src/bulk_put/tests/runner/batched.rs b/crates/engine/src/bulk_put/tests/runner/batched.rs
    new file mode 100644
    index 000000000..069913e71
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/batched.rs
    @@ -0,0 +1,550 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// Durability boundaries.
    +
    +// Batched writes.
    +
    +#[tokio::test]
    +async fn rows_staged_at_a_crash_are_unwritten_and_written_once_on_resume() {
    +    // Checkpoints every 5 rows flush the write batch, so when the reader
    +    // pends after handing 8 lines, rows 5 to 7 are staged and not written.
    +    // The persisted cursor is the checkpoint (5), not past the staged rows,
    +    // so a resume re-reads and writes them exactly once.
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(12)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 8,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.cursor_row_index, 5, "the cursor is the last checkpoint");
    +    assert_eq!(mid.processed_item_count, 5);
    +    assert_eq!(
    +        h.count_items().await,
    +        5,
    +        "rows 0 to 4 were flushed at the checkpoint"
    +    );
    +    assert!(
    +        h.get("k5").await.is_none(),
    +        "a staged row is not written until its batch flushes"
    +    );
    +    assert!(h.get("k7").await.is_none());
    +
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let mut cfg2 = config();
    +    cfg2.batch_size = 5;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-2", cfg2), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(
    +        record.processed_item_count, 12,
    +        "every row counts exactly once"
    +    );
    +    assert_eq!(h.count_items().await, 12);
    +    assert_eq!(
    +        record.usage["BulkPutBytes"],
    +        json!(12_288),
    +        "usage counts every written row exactly once"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn the_write_batch_and_the_checkpoint_both_bound_a_storage_call() {
    +    // Ten plain rows. With three rows per write batch and a checkpoint far
    +    // away, storage sees batches of 3, 3, 3, 1 (the last at end of file).
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(10)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let faulting = FaultingStorage::new(&h.storage);
    +    let sizes = Arc::clone(&faulting.batch_sizes);
    +    let mut cfg = config();
    +    cfg.batch_size = 25;
    +    cfg.write_batch_rows = 3;
    +    let deps = h.deps_with_storage("runner-1", cfg, Arc::new(faulting));
    +    runner::run(&deps, record).await.expect("runner run");
    +    assert_eq!(h.record(&op).await.status, "COMPLETED");
    +    assert_eq!(*sizes.lock().expect("recorder"), vec![3, 3, 3, 1]);
    +    assert_eq!(h.count_items().await, 10);
    +
    +    // The same rows with a checkpoint every 4 rows and a write batch of 25:
    +    // the checkpoint flushes, so storage sees 4, 4, 2.
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(10)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let faulting = FaultingStorage::new(&h.storage);
    +    let sizes = Arc::clone(&faulting.batch_sizes);
    +    let mut cfg = config();
    +    cfg.batch_size = 4;
    +    cfg.write_batch_rows = 25;
    +    let deps = h.deps_with_storage("runner-1", cfg, Arc::new(faulting));
    +    runner::run(&deps, record).await.expect("runner run");
    +    assert_eq!(h.record(&op).await.status, "COMPLETED");
    +    assert_eq!(*sizes.lock().expect("recorder"), vec![4, 4, 2]);
    +    assert_eq!(h.count_items().await, 10);
    +}
    +
    +#[tokio::test]
    +async fn a_pending_output_check_flushes_the_staged_rows_first() {
    +    // Row 2's admission fault is the first report record. The two rows
    +    // staged before it are written before the output configuration is
    +    // checked, so the check's verdict (here, the bucket exists) is reached
    +    // with those writes committed and the batch recorder shows a flush of
    +    // exactly two rows at that point.
    +    let h = Harness::new().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[row(0), row(1), row_missing_sk(2), row(3)]),
    +    );
    +    let files = h.enumerate().await;
    +    let op = h
    +        .start(
    +            files,
    +            input_config(false),
    +            output_config(Some(OUT_BUCKET), &["ERROR"]),
    +        )
    +        .await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let faulting = FaultingStorage::new(&h.storage);
    +    let sizes = Arc::clone(&faulting.batch_sizes);
    +    let deps = h.deps_with_storage("runner-1", config(), Arc::new(faulting));
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 4);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert_eq!(
    +        *sizes.lock().expect("recorder"),
    +        vec![2, 1],
    +        "the first flush is forced by the pending output check, the second is end of file"
    +    );
    +    assert_eq!(h.count_items().await, 3);
    +}
    +
    +/// The central durability claim of batching: a write batch LARGER than one
    +/// row commits in one storage transaction, the runner crashes before the
    +/// checkpoint that would cover it, and the resume replays the whole
    +/// committed batch, counting every row exactly once. Plain rows re-put
    +/// idempotently; a condition row inside the committed batch answers from the
    +/// in-transaction ledger rather than being re-evaluated against the
    +/// operation's own writes.
    +///
    +/// `write_batch_rows = 3` with a far checkpoint (`batch_size = 25`) makes the
    +/// flush fire mid-read-loop, so batches of three rows commit with no
    +/// checkpoint behind them. The reader pends after two batches have
    +/// committed, so the crash lands with committed-but-uncheckpointed batches
    +/// and a persisted cursor still at the start. The existing crash tests use
    +/// either the default write batch (the staged rows never commit) or
    +/// `write_batch_rows = 1` (one row per transaction), so neither exercises a
    +/// multi-row committed batch replayed through the ledger. This does.
    +#[tokio::test]
    +async fn a_committed_multi_row_batch_crashing_before_the_checkpoint_replays_once() {
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(9);
    +    // A passing condition row inside the first committed batch (row index 1,
    +    // key `c`), so the replay must take the ledger path, not re-evaluation.
    +    rows[1] = condition_row("1", "attribute_not_exists(pk)");
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    // Rows 0,1,2 commit as one batch, then 3,4,5 as a second; row 6 is read
    +    // and staged, then the next read pends. No checkpoint has run.
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 7,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 25;
    +    cfg.write_batch_rows = 3;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.status, "IN_PROGRESS", "the operation is not terminal");
    +    assert_eq!(
    +        mid.cursor_row_index, 0,
    +        "no checkpoint committed before the crash"
    +    );
    +    assert_eq!(
    +        mid.processed_item_count, 0,
    +        "the persisted counters are the start"
    +    );
    +    assert_eq!(
    +        h.count_items().await,
    +        6,
    +        "two batches of three committed to the table with no checkpoint behind them"
    +    );
    +    assert!(
    +        h.get("c").await.is_some(),
    +        "the committed condition row is on disk before the resume"
    +    );
    +    assert_eq!(
    +        h.storage
    +            .bulk_row_outcome(ACCOUNT, &op, 0, 1)
    +            .await
    +            .expect("ledger read"),
    +        Some(RowOutcome::Applied),
    +        "the condition row's marker rode its batch transaction"
    +    );
    +
    +    // The lease expires; the resume re-reads from cursor zero. Plain rows are
    +    // idempotent; the condition row at index 1 answers Applied from the
    +    // ledger and is not re-evaluated.
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let mut cfg2 = config();
    +    cfg2.batch_size = 25;
    +    cfg2.write_batch_rows = 3;
    +    runner::run(&h.deps("runner-2", cfg2), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(
    +        record.processed_item_count, 9,
    +        "every row counts exactly once across the crash"
    +    );
    +    assert_eq!(record.failed_item_count, 0);
    +    assert_eq!(h.count_items().await, 9);
    +    assert_eq!(
    +        record.usage["BulkPutBytes"],
    +        json!(9216),
    +        "nine rows under 1 KB each report 9 x 1024; the replayed committed \
    +         batch is charged once, not twice"
    +    );
    +    assert!(
    +        h.read_report(&op, ReportKind::ConditionCheckFailure)
    +            .is_none(),
    +        "the replayed condition row is answered from the ledger, not re-evaluated"
    +    );
    +    assert!(h.read_report(&op, ReportKind::Error).is_none());
    +    let cond = h.get("c").await.expect("the condition row is present");
    +    assert_eq!(cond.get("v"), Some(&AttributeValue::N("1".to_owned())));
    +}
    +
    +/// Rows a crashed incarnation committed with no checkpoint behind them are
    +/// found in the ledger on resume and charged from what their writes
    +/// replaced, so the crashed run charges the table and the index what an
    +/// uninterrupted run of the same input does: nine one-kilobyte inserts on
    +/// each.
    +#[tokio::test]
    +async fn a_crashed_run_charges_the_index_as_an_uninterrupted_run_does() {
    +    let h = Harness::new_with_gsi().await;
    +    let rows: Vec = (0..9)
    +        .map(|i| {
    +            json!({"Item": {
    +                "pk": {"S": format!("k{i}")}, "sk": {"S": "s"}, "g": {"S": format!("g{i}")}
    +            }})
    +            .to_string()
    +        })
    +        .collect();
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    // Rows 0 to 5 commit as two batches of three with no checkpoint behind
    +    // them; the read of row 7 pends and the first incarnation is abandoned.
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 7,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 25;
    +    cfg.write_batch_rows = 3;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +    assert_eq!(
    +        h.count_items().await,
    +        6,
    +        "two batches committed, no checkpoint"
    +    );
    +
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let mut cfg2 = config();
    +    cfg2.batch_size = 25;
    +    cfg2.write_batch_rows = 3;
    +    runner::run(&h.deps("runner-2", cfg2), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 9);
    +    assert_eq!(h.count_items().await, 9);
    +    assert_eq!(record.usage["Table"]["BulkPutBytes"], json!(9216));
    +    assert_eq!(
    +        record.usage["GlobalSecondaryIndexes"]["gsi1"]["BulkPutBytes"],
    +        json!(9216),
    +        "the six rows applied before the crash are charged as the inserts they were"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_blank_line_only_file_checkpoints_and_observes_a_cancel() {
    +    let h = Harness::new().await;
    +    let blanks: Vec = (0..30).map(|_| String::new()).collect();
    +    h.write_input("part-000.json", &file_of(&blanks));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +
    +    let storage = Arc::clone(&h.storage);
    +    let op_for_action = op.clone();
    +    let action: LineAction = Arc::new(move || {
    +        let storage = Arc::clone(&storage);
    +        let op = op_for_action.clone();
    +        Box::pin(async move {
    +            storage
    +                .request_cancel(ACCOUNT, &op)
    +                .await
    +                .expect("request cancel");
    +        })
    +    });
    +    let handed = Arc::new(AtomicU64::new(0));
    +    let source = Arc::new(ActAtSource {
    +        inner: h.fs_source(),
    +        at: 12,
    +        handed: Arc::clone(&handed),
    +        fired: Arc::new(AtomicBool::new(false)),
    +        action,
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "CANCELLED");
    +    assert_eq!(
    +        record.processed_item_count, 0,
    +        "blank lines touch no counter"
    +    );
    +    assert_eq!(record.failed_item_count, 0);
    +    assert_eq!(h.count_items().await, 0);
    +    assert_eq!(
    +        handed.load(Ordering::SeqCst),
    +        15,
    +        "the cancel is observed at the first boundary after it lands, so \
    +         reading stops at 15 physical lines, not at end of file"
    +    );
    +    assert_eq!(
    +        record.cursor_row_index, 15,
    +        "the boundary committed its checkpoint: the cursor is the 15 blank \
    +         lines consumed, not merely that the cancel was observed"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_fault_inside_the_resumed_skip_region_abandons_the_file_without_records() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(30)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    // First runner checkpoints at row 10 and dies.
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 12,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.cursor_row_index, 10);
    +    assert_eq!(mid.processed_item_count, 10);
    +
    +    // The resumed runner reads with a line cap below every line, so the
    +    // fault fires inside the skip region: rows a committed checkpoint
    +    // already counted. The file is abandoned with no record and no counter
    +    // change.
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let mut cfg2 = config();
    +    cfg2.batch_size = 5;
    +    cfg2.max_line_bytes = 8;
    +    runner::run(&h.deps("runner-2", cfg2), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(
    +        record.processed_item_count, 10,
    +        "no counter change from the fault inside the skip region"
    +    );
    +    assert_eq!(record.failed_item_count, 0);
    +    assert!(
    +        h.read_report(&op, ReportKind::Error).is_none(),
    +        "no abandon record for a fault inside the skip region"
    +    );
    +    assert_eq!(
    +        Some(record.processed_input_bytes),
    +        record.total_input_bytes,
    +        "the abandoned file's bytes are consumed"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_resume_at_a_file_boundary_opens_the_next_file_without_double_count() {
    +    let h = Harness::new().await;
    +    h.write_input("a.json", &file_of(&plain_rows(10)));
    +    h.write_input("b.json", &file_of(&(10..15).map(row).collect::>()));
    +    let files = h.enumerate().await;
    +    let op = h
    +        .start(files.clone(), input_config(false), both_reports())
    +        .await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    // The runner finishes the first file (its end-of-file checkpoint
    +    // commits) and dies opening the second.
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAtOpenSource {
    +        inner: h.fs_source(),
    +        pend_at: 2,
    +        opens: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the open pends"),
    +        () = notify.notified() => {}
    +    }
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.status, "IN_PROGRESS");
    +    assert_eq!(
    +        mid.processed_item_count, 10,
    +        "the first file's rows are all checkpointed"
    +    );
    +    assert_eq!(
    +        mid.cursor_file_index, 1,
    +        "end_of_file advanced the cursor to the second file before the \
    +         checkpoint"
    +    );
    +    assert_eq!(
    +        mid.cursor_row_index, 0,
    +        "the row cursor resets at a file boundary"
    +    );
    +
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let mut cfg2 = config();
    +    cfg2.batch_size = 5;
    +    runner::run(&h.deps("runner-2", cfg2), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 15, "no double count");
    +    assert_eq!(h.count_items().await, 15);
    +    assert_eq!(Some(record.processed_input_bytes), record.total_input_bytes);
    +}
    +
    +#[tokio::test]
    +async fn a_resumed_run_reports_full_input_bytes_and_usage() {
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(26);
    +    rows[2] = row_missing_sk(2);
    +    rows[12] = row_missing_sk(12);
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 13,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let mut cfg2 = config();
    +    cfg2.batch_size = 5;
    +    runner::run(&h.deps("runner-2", cfg2), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(
    +        Some(record.processed_input_bytes),
    +        record.total_input_bytes,
    +        "ProcessedInputBytes equals TotalInputBytes on a resumed terminal run"
    +    );
    +    assert_eq!(
    +        record.usage["BulkPutBytes"],
    +        json!(24_576),
    +        "24 written rows below 1 KB round up to 24 x 1024 bytes, replays \
    +         counted once"
    +    );
    +    assert_eq!(record.usage["Table"]["BulkPutBytes"], json!(24_576));
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/cancellation.rs b/crates/engine/src/bulk_put/tests/runner/cancellation.rs
    new file mode 100644
    index 000000000..7213d86bc
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/cancellation.rs
    @@ -0,0 +1,134 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// Cancellation.
    +
    +#[tokio::test]
    +async fn a_cancel_between_batches_ends_cancelled_with_earlier_items_present() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(15)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +
    +    let storage = Arc::clone(&h.storage);
    +    let op_for_action = op.clone();
    +    let action: LineAction = Arc::new(move || {
    +        let storage = Arc::clone(&storage);
    +        let op = op_for_action.clone();
    +        Box::pin(async move {
    +            storage
    +                .request_cancel(ACCOUNT, &op)
    +                .await
    +                .expect("request cancel");
    +        })
    +    });
    +    let source = Arc::new(ActAtSource {
    +        inner: h.fs_source(),
    +        at: 7,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        fired: Arc::new(AtomicBool::new(false)),
    +        action,
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "CANCELLED");
    +    assert!(record.cancel_requested);
    +    assert_eq!(
    +        record.processed_item_count, 10,
    +        "the batch in flight finishes; reading stops at its boundary"
    +    );
    +    assert!(h.get("k0").await.is_some(), "earlier items stay written");
    +    assert!(
    +        h.get("k9").await.is_some(),
    +        "the in-flight batch is written"
    +    );
    +    assert!(h.get("k10").await.is_none(), "no row past the boundary");
    +    // Measured on the service: a cancelled operation carries this pair, and
    +    // its input bytes are what was read before the stop, not zero; here the
    +    // reader had consumed the whole 15-row file by the boundary, so the
    +    // figure is the file's size.
    +    assert_eq!(record.failure_code.as_deref(), Some("RequestCancelled"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("The request was cancelled")
    +    );
    +    let file_bytes: i64 = plain_rows(15).iter().map(|r| r.len() as i64 + 1).sum();
    +    assert_eq!(record.processed_input_bytes, file_bytes);
    +}
    +
    +#[tokio::test]
    +async fn a_cancelling_operation_with_no_live_runner_is_finished_without_reading_input() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(5)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    h.storage
    +        .request_cancel(ACCOUNT, &op)
    +        .await
    +        .expect("request cancel");
    +
    +    // The claim predicate includes CANCELLING, so a worker picks it up.
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    assert_eq!(record.status, "CANCELLING");
    +
    +    let source = Arc::new(CountingSource::new(h.fs_source()));
    +    let lists = Arc::clone(&source.lists);
    +    let opens = Arc::clone(&source.opens);
    +    let deps = h.deps_with("runner-2", config(), source, Arc::new(h.fs_sink()));
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "CANCELLED");
    +    assert_eq!(
    +        record.processed_item_count, 0,
    +        "the persisted counters stand"
    +    );
    +    assert_eq!(opens.load(Ordering::SeqCst), 0, "no input file is opened");
    +    assert_eq!(lists.load(Ordering::SeqCst), 0, "no enumeration happens");
    +    assert_eq!(h.count_items().await, 0, "nothing is written");
    +}
    +
    +// Cancellation, continued.
    +
    +#[tokio::test]
    +async fn a_cancel_after_the_last_boundary_still_ends_cancelled() {
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(5);
    +    rows[2] = row_missing_sk(2);
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +
    +    // The cancel lands during rendering, after the runner passed its last
    +    // batch boundary; only the terminal transition can observe it.
    +    let sink = Arc::new(CancelOnOpenSink {
    +        inner: h.fs_sink(),
    +        storage: Arc::clone(&h.storage),
    +        operation_id: op.clone(),
    +        fired: Arc::new(AtomicBool::new(false)),
    +    });
    +    let deps = h.deps_with("runner-1", config(), Arc::new(h.fs_source()), sink);
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(
    +        record.status, "CANCELLED",
    +        "finish writes CANCELLED when the row is CANCELLING, whatever the \
    +         runner computed"
    +    );
    +    assert!(record.cancel_requested);
    +    assert_eq!(
    +        record.processed_item_count, 5,
    +        "every row was processed before the cancel landed"
    +    );
    +    assert_eq!(record.failed_item_count, 1);
    +    assert_eq!(h.count_items().await, 4);
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/caps.rs b/crates/engine/src/bulk_put/tests/runner/caps.rs
    new file mode 100644
    index 000000000..a7840fa3f
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/caps.rs
    @@ -0,0 +1,226 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// Usage.
    +
    +#[tokio::test]
    +async fn twenty_five_small_items_report_25600_bulk_put_bytes() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(25)));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(
    +        record.usage["TableName"],
    +        json!("orders"),
    +        "the usage entry names the table by its bare name, as the service does"
    +    );
    +    assert_eq!(
    +        record.usage["BulkPutBytes"],
    +        json!(25_600),
    +        "25 items below 1 KB round up to 25 x 1024 bytes"
    +    );
    +    assert_eq!(record.usage["Table"]["BulkPutBytes"], json!(25_600));
    +}
    +
    +// Configured caps.
    +
    +#[tokio::test]
    +async fn a_line_over_the_byte_cap_abandons_the_file() {
    +    let h = Harness::new().await;
    +    let long = format!(
    +        "{{\"Item\": {{\"pk\": {{\"S\": \"long\"}}, \"sk\": {{\"S\": \"s\"}}, \"v\": {{\"S\": \"{}\"}}}}}}",
    +        "y".repeat(400)
    +    );
    +    h.write_input("part-000.json", &file_of(&[row(0), long, row(2)]));
    +    let files = h.enumerate().await;
    +    let mut cfg = config();
    +    cfg.max_line_bytes = 256;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), cfg)
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 2);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert!(h.get("k0").await.is_some());
    +    assert!(h.get("long").await.is_none());
    +    assert!(h.get("k2").await.is_none(), "the file is abandoned");
    +    assert_eq!(Some(record.processed_input_bytes), record.total_input_bytes);
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(report[0]["errorMessages"], json!([ABANDON]));
    +}
    +
    +#[tokio::test]
    +async fn rows_past_the_item_count_cap_fail_the_operation() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(12)));
    +    let files = h.enumerate().await;
    +    let mut cfg = config();
    +    cfg.max_item_count = 10;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), cfg)
    +        .await;
    +
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("ValidationException"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("Operation exceeded max_bulk_item_count (10 rows)")
    +    );
    +    assert!(
    +        h.get("k11").await.is_none(),
    +        "no row past the cap is written"
    +    );
    +}
    +
    +// Configured caps, continued.
    +
    +#[tokio::test]
    +async fn a_parse_fault_on_the_row_past_the_item_count_cap_fails_the_operation() {
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(10);
    +    rows.push("this is not json {{{".to_owned());
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let mut cfg = config();
    +    cfg.max_item_count = 10;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), cfg)
    +        .await;
    +
    +    assert_eq!(
    +        record.status, "FAILED",
    +        "the cap fires on a parse-fault row too, not an abandon"
    +    );
    +    assert_eq!(record.failure_code.as_deref(), Some("ValidationException"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("Operation exceeded max_bulk_item_count (10 rows)")
    +    );
    +}
    +
    +#[tokio::test]
    +async fn the_item_count_cap_stops_the_run_at_exactly_the_cap() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(12)));
    +    let files = h.enumerate().await;
    +    let mut cfg = config();
    +    cfg.max_item_count = 10;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), cfg)
    +        .await;
    +
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(
    +        record.processed_item_count, 10,
    +        "the line that crosses the cap is consumed but never processed"
    +    );
    +    assert_eq!(
    +        record.failed_item_count, 0,
    +        "the cap check returns before any row is failed or recorded"
    +    );
    +    assert_eq!(h.count_items().await, 10, "exactly the cap is written");
    +    assert!(
    +        h.get("k10").await.is_none(),
    +        "the row that trips the cap is never written"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn records_past_the_report_cap_are_dropped_from_the_rendered_file() {
    +    let h = Harness::new().await;
    +    let mut rows: Vec = (0..4).map(row_missing_sk).collect();
    +    rows.push(row(4));
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let mut cfg = config();
    +    cfg.max_report_records = 2;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), cfg)
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 5);
    +    assert_eq!(
    +        record.failed_item_count, 4,
    +        "the counters are unaffected by the report cap"
    +    );
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 2, "records past the cap are dropped");
    +    let mut indexes: Vec = report
    +        .iter()
    +        .map(|r| r["itemLocation"]["itemIndex"].as_i64().unwrap())
    +        .collect();
    +    indexes.sort_unstable();
    +    assert_eq!(indexes, [0, 1], "the earliest records are the kept ones");
    +}
    +
    +#[tokio::test]
    +async fn a_crash_resume_continues_the_record_cap_from_the_persisted_count() {
    +    let h = Harness::new().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&(0..4).map(row_missing_sk).collect::>()),
    +    );
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    // Rows 0 and 1 checkpoint with two kept records, exhausting the cap;
    +    // the runner dies with rows 2 and 3 unread.
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 2,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 2;
    +    cfg.max_report_records = 2;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.cursor_row_index, 2);
    +    assert_eq!(mid.failed_item_count, 2);
    +
    +    // The resumed runner restores the kept count from the record instead
    +    // of starting from zero, so rows 2 and 3 are dropped from the report.
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let mut cfg2 = config();
    +    cfg2.batch_size = 2;
    +    cfg2.max_report_records = 2;
    +    runner::run(&h.deps("runner-2", cfg2), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.failed_item_count, 4, "counters are unaffected");
    +    let report = h
    +        .read_report(&op, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 2, "the cap holds across the resume");
    +    let mut indexes: Vec = report
    +        .iter()
    +        .map(|r| r["itemLocation"]["itemIndex"].as_i64().unwrap())
    +        .collect();
    +    indexes.sort_unstable();
    +    assert_eq!(indexes, [0, 1]);
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/compressed_cut.rs b/crates/engine/src/bulk_put/tests/runner/compressed_cut.rs
    new file mode 100644
    index 000000000..6cea204d0
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/compressed_cut.rs
    @@ -0,0 +1,236 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// The abandon cut on a compressed object, measured on the hosted service
    +// the object is read through its decoder in
    +// one header read then 65,536-byte reads of the compressed stream, and an
    +// abandon leaves ProcessedInputBytes at the end of the read that held the
    +// fault. Bytes beyond that read are never counted and the operation ends
    +// FAILED; a fault whose read reaches the end of the object counts every
    +// byte and ends COMPLETED_WITH_ERRORS.
    +
    +const PARSE_ABANDON: &str = "Expected DynamoDB type to be one of [S, N, B, BOOL, NULL, L, M, SS, \
    +                             NS, BS]. Remainder of the file will not be processed.";
    +const ITEMS_FAILED_VALIDATION: &str =
    +    "Some of the items failed validation checks and were not ingested";
    +
    +/// 128 hex characters from a fixed-seed xorshift sequence, so the rows do
    +/// not compress away and a 2,000-row file stays well over one decoder read
    +/// when compressed.
    +fn noise(seed: &mut u64) -> String {
    +    let mut out = String::with_capacity(128);
    +    for _ in 0..8 {
    +        *seed ^= *seed << 13;
    +        *seed ^= *seed >> 7;
    +        *seed ^= *seed << 17;
    +        out.push_str(&format!("{:016x}", *seed));
    +    }
    +    out
    +}
    +
    +/// `rows` padded rows with the parse-level fault (an unknown type tag) at
    +/// `fault_at`, newline-terminated.
    +fn noisy_file_with_fault(rows: usize, fault_at: usize) -> Vec {
    +    let mut seed = 0x9e37_79b9_7f4a_7c15_u64;
    +    let mut out = String::new();
    +    for index in 0..rows {
    +        let pad = noise(&mut seed);
    +        if index == fault_at {
    +            out.push_str(r#"{"Item":{"pk":{"S":"x"},"v":{"XX":"1"}}}"#);
    +        } else {
    +            out.push_str(&format!(
    +                r#"{{"Item":{{"pk":{{"S":"k{index}"}},"sk":{{"S":"s"}},"p":{{"S":"{pad}"}}}}}}"#
    +            ));
    +        }
    +        out.push('\n');
    +    }
    +    out.into_bytes()
    +}
    +
    +#[tokio::test]
    +async fn a_gzip_abandon_on_line_one_stops_the_counter_after_the_first_decoder_read() {
    +    // A fault on line 1 of a 204,490-byte gzip object leaves
    +    // ProcessedInputBytes at 65,546 (the 10-byte header read plus one
    +    // 65,536-byte read) and the operation FAILED.
    +    let h = Harness::new().await;
    +    let compressed = gzip_bytes(&noisy_file_with_fault(2_000, 0));
    +    let size = i64::try_from(compressed.len()).unwrap();
    +    assert!(size > 2 * 65_536, "the fixture spans several decoder reads");
    +    h.write_input("part-000.json.gz", &compressed);
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(true), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.processed_input_bytes, 10 + 65_536);
    +    assert_eq!(record.total_input_bytes, Some(size));
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("ValidationException"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some(ITEMS_FAILED_VALIDATION)
    +    );
    +    assert_eq!(record.processed_item_count, 1);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert_eq!(
    +        h.count_items().await,
    +        0,
    +        "nothing past the fault is written"
    +    );
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report[0]["errorMessages"], json!([PARSE_ABANDON]));
    +}
    +
    +#[tokio::test]
    +async fn a_gzip_abandon_after_one_decoder_read_stops_the_counter_after_the_second() {
    +    // A fault after 1,000 rows (about 100 KB of compressed input
    +    // consumed) of a 204,488-byte gzip object leaves the counter at 131,082.
    +    let h = Harness::new().await;
    +    let compressed = gzip_bytes(&noisy_file_with_fault(2_000, 1_000));
    +    let size = i64::try_from(compressed.len()).unwrap();
    +    h.write_input("part-000.json.gz", &compressed);
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(true), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.processed_input_bytes, 10 + 2 * 65_536);
    +    assert_eq!(record.total_input_bytes, Some(size));
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.processed_item_count, 1_001);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert_eq!(
    +        h.count_items().await,
    +        1_000,
    +        "the rows before the fault are written"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_gzip_abandon_inside_the_first_decoder_read_counts_every_byte_and_completes_with_errors()
    +{
    +    // A fault on line 1 of a 30,758-byte gzip object, more than
    +    // one plain read long and under one decoder read, counts the whole
    +    // object and ends COMPLETED_WITH_ERRORS.
    +    let h = Harness::new().await;
    +    let compressed = gzip_bytes(&noisy_file_with_fault(300, 0));
    +    let size = i64::try_from(compressed.len()).unwrap();
    +    assert!(
    +        size > 8_000 && size < 65_536,
    +        "between the plain and decoder read sizes"
    +    );
    +    h.write_input("part-000.json.gz", &compressed);
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(true), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.processed_input_bytes, size);
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.failure_code, None);
    +    assert_eq!(record.failed_item_count, 1);
    +}
    +
    +#[tokio::test]
    +async fn a_gzip_abandon_on_the_last_line_counts_every_byte_and_completes_with_errors() {
    +    // A fault on the last line of a 204,561-byte gzip object
    +    // counts the whole object, writes the 2,000 rows before it, and ends
    +    // COMPLETED_WITH_ERRORS.
    +    let h = Harness::new().await;
    +    let compressed = gzip_bytes(&noisy_file_with_fault(2_001, 2_000));
    +    let size = i64::try_from(compressed.len()).unwrap();
    +    h.write_input("part-000.json.gz", &compressed);
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(true), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.processed_input_bytes, size);
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 2_001);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert_eq!(h.count_items().await, 2_000);
    +}
    +
    +#[tokio::test]
    +async fn a_zstd_abandon_on_line_one_stops_the_counter_at_the_read_that_held_its_block() {
    +    // A fault on line 1 of a 198,728-byte zstd object leaves the
    +    // counter at 131,075, one zstd read (the largest block plus its header),
    +    // and the operation FAILED.
    +    let h = Harness::new().await;
    +    let compressed = zstd_bytes(&noisy_file_with_fault(2_000, 0));
    +    let size = i64::try_from(compressed.len()).unwrap();
    +    assert!(size > 131_075, "the fixture spans more than one zstd read");
    +    h.write_input("part-000.json.zst", &compressed);
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, zstd_input_config(), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.processed_input_bytes, 131_075);
    +    assert_eq!(record.total_input_bytes, Some(size));
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("ValidationException"));
    +    assert_eq!(record.failed_item_count, 1);
    +}
    +
    +#[tokio::test]
    +async fn a_zstd_abandon_inside_the_second_read_counts_every_byte_and_completes_with_errors() {
    +    // A fault after 1,000 rows of a 198,831-byte zstd object sits
    +    // in the frame's second block, which the second 131,075-byte read
    +    // holds along with the end of the object: every byte counts and the
    +    // operation ends COMPLETED_WITH_ERRORS with the 1,000 rows written.
    +    let h = Harness::new().await;
    +    let compressed = zstd_bytes(&noisy_file_with_fault(2_000, 1_000));
    +    let size = i64::try_from(compressed.len()).unwrap();
    +    assert!(
    +        size > 131_075 && size < 2 * 131_075,
    +        "the fixture spans two zstd reads"
    +    );
    +    h.write_input("part-000.json.zst", &compressed);
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, zstd_input_config(), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.processed_input_bytes, size);
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.failure_code, None);
    +    assert_eq!(record.processed_item_count, 1_001);
    +    assert_eq!(h.count_items().await, 1_000);
    +}
    +
    +// A zstd frame declaring a window larger than the
    +// decoder's 128 MiB maximum does not load. ExtendDB abandons the file with
    +// the decompression message and the operation completes with errors (the
    +// row is processed and failed). The hosted service instead leaves the
    +// operation non-terminal with the table locked for a frame needing more
    +// than a 128 MiB window, a stated divergence. A frame declaring a 256 MiB
    +// window (descriptor 0x90) is the case; the 128 MiB edge is pinned at the
    +// decoder in the codec tests.
    +#[tokio::test]
    +async fn a_zstd_frame_over_the_window_bound_is_abandoned_with_the_decompression_message() {
    +    let h = Harness::new().await;
    +    // zstd magic, a frame header descriptor with a window descriptor
    +    // following, then a 256 MiB window descriptor.
    +    let frame = vec![0x28u8, 0xb5, 0x2f, 0xfd, 0x00, 0x90];
    +    h.write_input("part-000.json.zst", &frame);
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, zstd_input_config(), both_reports(), config())
    +        .await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 1);
    +    assert_eq!(record.failed_item_count, 1);
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!(["The S3 object could not be decompressed"])
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/disk_full.rs b/crates/engine/src/bulk_put/tests/runner/disk_full.rs
    new file mode 100644
    index 000000000..fbbdc3118
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/disk_full.rs
    @@ -0,0 +1,73 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// A write batch that fails because the database ran out of disk (SQLSTATE
    +// 53100, as the PostgreSQL storage layer renders it) rolled back, so the
    +// operation stays in progress and a later claim finishes it from the last
    +// checkpoint, the same path a connection loss takes.
    +//
    +// The storage double fails the first write of k3 with that internal error.
    +// The first claim's run returns it as a runner storage error with the
    +// operation still IN_PROGRESS and no terminal failure; a second claim after
    +// the lease expires finishes it with the same rows, counters and usage as
    +// an uninterrupted run.
    +#[tokio::test]
    +async fn a_batch_that_meets_a_full_disk_leaves_the_operation_to_resume() {
    +    let truth_h = Harness::new().await;
    +    truth_h.write_input("part-000.json", &file_of(&plain_rows(10)));
    +    let truth_files = truth_h.enumerate().await;
    +    let truth = truth_h
    +        .run_to_end(truth_files, input_config(false), both_reports(), config())
    +        .await;
    +    assert_eq!(truth.status, "COMPLETED");
    +
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(10)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +
    +    let mut faulting = FaultingStorage::new(&h.storage);
    +    faulting.fail_put_pk = Some("k3".to_owned());
    +    faulting.fail_put_pk_internal = Some(
    +        "SQLSTATE 53100: error returned from database: could not extend file: \
    +         No space left on device"
    +            .to_owned(),
    +    );
    +    let storage: Arc = Arc::new(faulting);
    +
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +    let deps = h.deps_with_storage("runner-1", config(), Arc::clone(&storage));
    +    let result = runner::run(&deps, record).await;
    +    assert!(
    +        matches!(result, Err(runner::RunnerError::Storage(_))),
    +        "a full disk on a write batch is a runner storage error, not a FAILED operation"
    +    );
    +    let mid = h.record(&op).await;
    +    assert_eq!(
    +        mid.status, "IN_PROGRESS",
    +        "the operation stays non-terminal after a full disk"
    +    );
    +    assert!(
    +        mid.failure_code.is_none(),
    +        "no terminal failure was written"
    +    );
    +
    +    tokio::time::sleep(Duration::from_millis(150)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let deps = h.deps_with_storage("runner-2", config(), Arc::clone(&storage));
    +    runner::run(&deps, record)
    +        .await
    +        .expect("the resume finishes");
    +
    +    let final_record = h.record(&op).await;
    +    assert_eq!(final_record.status, "COMPLETED");
    +    assert_eq!(h.count_items().await, 10);
    +    assert_eq!(
    +        final_record.processed_item_count,
    +        truth.processed_item_count
    +    );
    +    assert_eq!(final_record.failed_item_count, truth.failed_item_count);
    +    assert_eq!(final_record.usage, truth.usage);
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/durability.rs b/crates/engine/src/bulk_put/tests/runner/durability.rs
    new file mode 100644
    index 000000000..be96e2e8f
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/durability.rs
    @@ -0,0 +1,328 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// Durability and restart.
    +
    +#[tokio::test]
    +async fn a_resumed_run_matches_an_uninterrupted_run() {
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(26);
    +    rows[2] = row_missing_sk(2);
    +    rows[12] = row_missing_sk(12);
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h
    +        .start(files.clone(), input_config(false), both_reports())
    +        .await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    // First runner: checkpoints at rows 5 and 10, then dies while rows 10
    +    // to 12 sit in an uncommitted batch (row 12 is a failed row whose
    +    // report record must not be lost or duplicated by the replay).
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 13,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.status, "IN_PROGRESS", "the operation is not terminal");
    +    assert_eq!(
    +        mid.cursor_row_index, 10,
    +        "the cursor is the last checkpoint"
    +    );
    +    assert_eq!(mid.processed_item_count, 10);
    +    assert_eq!(
    +        mid.failed_item_count, 1,
    +        "row 12's record was not persisted"
    +    );
    +
    +    // The lease expires; a new worker claims and resumes from the cursor.
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let mut cfg2 = config();
    +    cfg2.batch_size = 5;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-2", cfg2), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 26, "no double count on replay");
    +    assert_eq!(record.failed_item_count, 2);
    +    assert_eq!(h.count_items().await, 24);
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(
    +        report.len(),
    +        2,
    +        "the report record count equals FailedItemCount"
    +    );
    +    let mut indexes: Vec = report
    +        .iter()
    +        .map(|r| r["itemLocation"]["itemIndex"].as_i64().unwrap())
    +        .collect();
    +    indexes.sort_unstable();
    +    assert_eq!(indexes, [2, 12], "one record per failed row, no duplicates");
    +}
    +
    +#[tokio::test]
    +async fn a_replayed_passing_condition_row_is_not_re_evaluated_on_resume() {
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(12);
    +    rows[10] =
    +        "{\"Item\": {\"pk\": {\"S\": \"c\"}, \"sk\": {\"S\": \"s\"}, \"v\": {\"N\": \"1\"}}, \
    +                \"ConditionExpression\": \"attribute_not_exists(pk)\"}"
    +            .to_owned();
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    // The condition row at index 10 is applied (write plus ledger marker in
    +    // one data transaction), then the runner dies before the checkpoint
    +    // that would cover it.
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 11,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    // One row per storage transaction, so the row's write commits as
    +    // soon as it is read and the crash lands between that commit and
    +    // the checkpoint that would cover it.
    +    cfg.write_batch_rows = 1;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.cursor_row_index, 10, "row 10 is past the cursor");
    +    assert!(
    +        h.get("c").await.is_some(),
    +        "the conditional write committed"
    +    );
    +    assert_eq!(
    +        h.storage
    +            .bulk_row_outcome(ACCOUNT, &op, 0, 10)
    +            .await
    +            .expect("ledger read"),
    +        Some(RowOutcome::Applied),
    +        "the ledger marker rode the write transaction"
    +    );
    +
    +    // On resume the row is replayed. Its condition would now be false
    +    // against the operation's own write; the ledger must answer instead.
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let mut cfg2 = config();
    +    cfg2.batch_size = 5;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-2", cfg2), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(
    +        record.processed_item_count, 12,
    +        "the row counts exactly once"
    +    );
    +    assert_eq!(record.failed_item_count, 0);
    +    assert!(
    +        h.read_report(&record.operation_id, ReportKind::ConditionCheckFailure)
    +            .is_none(),
    +        "no false condition-failure record from the replay"
    +    );
    +    assert!(
    +        h.read_report(&record.operation_id, ReportKind::Error)
    +            .is_none()
    +    );
    +    let stored = h.get("c").await.expect("item present");
    +    assert_eq!(stored.get("v"), Some(&AttributeValue::N("1".to_owned())));
    +    assert_eq!(
    +        h.storage
    +            .bulk_row_outcome(ACCOUNT, &op, 0, 10)
    +            .await
    +            .expect("ledger read"),
    +        None,
    +        "finish deletes the operation's ledger rows"
    +    );
    +}
    +
    +/// An Applied-outcome replay must not re-evaluate the condition or touch the
    +/// table, even when the item it wrote has since been deleted. This isolates
    +/// the pre-write ledger consult: with that guard present the replay follows
    +/// the recorded Applied outcome and the deleted item stays deleted; without
    +/// it the row's `attribute_not_exists(pk)` is re-evaluated against the now
    +/// absent item, passes, and the row is resurrected. The symmetric
    +/// ConditionFailed-after-delete case already has a test; this is its Applied
    +/// twin, and it fails if the pre-write Applied branch is removed, which the
    +/// existing passing-condition resume test does not (the post-ConditionFailed
    +/// re-read masks that single reversion).
    +#[tokio::test]
    +async fn a_replayed_applied_condition_row_does_not_resurrect_an_externally_deleted_item() {
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(12);
    +    rows[10] =
    +        "{\"Item\": {\"pk\": {\"S\": \"c\"}, \"sk\": {\"S\": \"s\"}, \"v\": {\"N\": \"1\"}}, \
    +                \"ConditionExpression\": \"attribute_not_exists(pk)\"}"
    +            .to_owned();
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    // Row 10 is applied (write plus Applied marker in one transaction),
    +    // then the runner dies before the covering checkpoint.
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 11,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    // One row per storage transaction, so the row's write commits as
    +    // soon as it is read and the crash lands between that commit and
    +    // the checkpoint that would cover it.
    +    cfg.write_batch_rows = 1;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +
    +    assert!(
    +        h.get("c").await.is_some(),
    +        "the conditional write committed"
    +    );
    +    assert_eq!(
    +        h.storage
    +            .bulk_row_outcome(ACCOUNT, &op, 0, 10)
    +            .await
    +            .expect("ledger read"),
    +        Some(RowOutcome::Applied),
    +        "the marker rode the write transaction"
    +    );
    +
    +    // The item is deleted between the crash and the resume. A re-evaluation
    +    // of attribute_not_exists(pk) would now pass and re-insert it; the
    +    // ledger must answer Applied instead and leave the table alone.
    +    {
    +        let info = h.key_info().await;
    +        h.storage
    +            .delete_item(
    +                &info,
    +                &key("c"),
    +                false,
    +                None,
    +                &ExpressionMaps::default(),
    +                None,
    +            )
    +            .await
    +            .expect("delete the applied item");
    +    }
    +    assert!(h.get("c").await.is_none(), "the item is gone before resume");
    +
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let mut cfg2 = config();
    +    cfg2.batch_size = 5;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-2", cfg2), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(
    +        record.processed_item_count, 12,
    +        "the row counts exactly once"
    +    );
    +    assert_eq!(record.failed_item_count, 0);
    +    assert!(
    +        h.get("c").await.is_none(),
    +        "an Applied replay must not resurrect the externally deleted item"
    +    );
    +    assert!(
    +        h.read_report(&op, ReportKind::ConditionCheckFailure)
    +            .is_none(),
    +        "no condition-failure record from the replay"
    +    );
    +    assert!(h.read_report(&op, ReportKind::Error).is_none());
    +}
    +
    +#[tokio::test]
    +async fn a_re_render_after_a_crash_between_rendering_and_finish_replaces_the_file() {
    +    let h = Harness::new().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[row(0), row_missing_sk(1), row(2)]),
    +    );
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    // The first runner renders the error report, then dies before finish.
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let sink = Arc::new(PendAfterRenderSink {
    +        inner: h.fs_sink(),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, Arc::new(h.fs_source()), sink);
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the sink pends"),
    +        () = notify.notified() => {}
    +    }
    +
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.status, "IN_PROGRESS", "finish never ran");
    +    let first = h
    +        .read_report(&op, ReportKind::Error)
    +        .expect("the file is already on disk");
    +    assert_eq!(first.len(), 1);
    +
    +    // A new worker claims the still-running record and re-renders; the
    +    // rename replaces the earlier file rather than colliding with it.
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-2", config()), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 3);
    +    assert_eq!(record.failed_item_count, 1);
    +    let report = h
    +        .read_report(&op, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(
    +        report.len(),
    +        1,
    +        "the replaced file holds exactly one record"
    +    );
    +    assert_eq!(report[0]["errorMessages"], json!([MISSING_KEY]));
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/exactly_once.rs b/crates/engine/src/bulk_put/tests/runner/exactly_once.rs
    new file mode 100644
    index 000000000..412cd619a
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/exactly_once.rs
    @@ -0,0 +1,399 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Every row is applied once. After a kill past a checkpoint, a transient
    +//! batch fault inside a checkpoint window, or a run of faults that never
    +//! clears, the table, the counters, the usage and the reports are those of
    +//! an uninterrupted run of the same input, and a client write that landed
    +//! after a row's batch committed is never overwritten by that row again.
    +
    +use super::*;
    +
    +fn kv(pk: &str, v: &str) -> String {
    +    format!(
    +        "{{\"Item\": {{\"pk\": {{\"S\": \"{pk}\"}}, \"sk\": {{\"S\": \"s\"}}, \"v\": {{\"N\": \"{v}\"}}}}}}"
    +    )
    +}
    +
    +fn kv_if(pk: &str, v: &str, condition: &str) -> String {
    +    format!(
    +        "{{\"Item\": {{\"pk\": {{\"S\": \"{pk}\"}}, \"sk\": {{\"S\": \"s\"}}, \"v\": {{\"N\": \"{v}\"}}}}, \
    +         \"ConditionExpression\": \"{condition}\"}}"
    +    )
    +}
    +
    +/// Every stored item, ordered by partition key.
    +async fn all_items(h: &Harness) -> Vec {
    +    let info = h.key_info().await;
    +    let mut items = Vec::new();
    +    let mut start: Option = None;
    +    loop {
    +        let (page, next) = h
    +            .storage
    +            .scan(&info, None, start.as_ref(), None, None, None)
    +            .await
    +            .expect("scan");
    +        items.extend(page);
    +        match next {
    +            Some(k) => start = Some(k),
    +            None => break,
    +        }
    +    }
    +    items.sort_by_key(|item| format!("{:?}", item.get("pk")));
    +    items
    +}
    +
    +/// What a run leaves behind, compared field by field with an uninterrupted
    +/// run of the same input.
    +#[derive(Debug, PartialEq)]
    +struct Outcome {
    +    status: String,
    +    processed: i64,
    +    failed: i64,
    +    processed_bytes: i64,
    +    usage: Value,
    +    items: Vec,
    +    conditions: Option>,
    +    errors: Option>,
    +}
    +
    +async fn outcome(h: &Harness, op: &str) -> Outcome {
    +    let record = h.record(op).await;
    +    Outcome {
    +        status: record.status,
    +        processed: record.processed_item_count,
    +        failed: record.failed_item_count,
    +        processed_bytes: record.processed_input_bytes,
    +        usage: record.usage,
    +        items: all_items(h).await,
    +        conditions: h.read_report(op, ReportKind::ConditionCheckFailure),
    +        errors: h.read_report(op, ReportKind::Error),
    +    }
    +}
    +
    +fn window(batch_size: usize, write_batch_rows: usize) -> RunnerConfig {
    +    RunnerConfig {
    +        batch_size,
    +        write_batch_rows,
    +        ..config()
    +    }
    +}
    +
    +/// The run of `rows` with no interruption, from a table seeded with `seed`.
    +async fn uninterrupted(
    +    backend: Backend,
    +    rows: &[String],
    +    seed: &[Item],
    +    cfg: RunnerConfig,
    +) -> Outcome {
    +    let h = Harness::on(backend).await;
    +    for item in seed {
    +        h.put_direct(item.clone()).await;
    +    }
    +    h.write_input("part-000.json", &file_of(rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-1", cfg), record)
    +        .await
    +        .expect("uninterrupted run");
    +    let out = outcome(&h, &op).await;
    +    h.cleanup().await;
    +    out
    +}
    +
    +/// Run `rows` until `after` lines were handed out, then drop the runner, as
    +/// a killed process stops; the lease runs out before this returns.
    +async fn kill_after(h: &Harness, after: u64, cfg: RunnerConfig) {
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let deps =
    +        h.with_short_store_lease(h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink())));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +}
    +
    +async fn resume(h: &Harness, owner: &str, cfg: RunnerConfig) {
    +    let record = h.claim(owner, Duration::from_secs(60)).await;
    +    runner::run(&h.deps(owner, cfg), record)
    +        .await
    +        .expect("resumed run");
    +}
    +
    +/// Twelve plain rows with a plain put of `dup` at row 6 and a put of `dup`
    +/// conditional on the item existing at row 8.
    +fn plain_then_conditional() -> Vec {
    +    let mut rows = plain_rows(12);
    +    rows[6] = kv("dup", "1");
    +    rows[8] = kv_if("dup", "2", "attribute_exists(pk)");
    +    rows
    +}
    +
    +async fn a_kill_past_the_checkpoint_leaves_the_later_image_of_a_key_written_twice_case(
    +    backend: Backend,
    +) {
    +    let rows = plain_then_conditional();
    +    let cfg = window(5, 1);
    +    let expected = uninterrupted(backend, &rows, &[], window(5, 1)).await;
    +
    +    let h = Harness::on(backend).await;
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    // Rows 0 to 8 are written one batch each; the checkpoint at row 5 is
    +    // the last one, so rows 5 to 8, the plain and the conditional put of
    +    // `dup` among them, are applied past it when the process is killed.
    +    kill_after(&h, 9, window(5, 1)).await;
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.cursor_row_index, 5, "the last checkpoint is at row 5");
    +    resume(&h, "runner-2", cfg).await;
    +
    +    let dup = h.get("dup").await.expect("dup is stored");
    +    assert_eq!(
    +        dup.get("v"),
    +        Some(&AttributeValue::N("2".to_owned())),
    +        "the conditional put, applied after the plain one, stands"
    +    );
    +    assert_eq!(outcome(&h, &op).await, expected);
    +    h.cleanup().await;
    +}
    +
    +async fn a_client_write_after_a_killed_batch_is_not_undone_by_the_resume_case(backend: Backend) {
    +    let rows = plain_rows(12);
    +    let h = Harness::on(backend).await;
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    kill_after(&h, 9, window(5, 1)).await;
    +    assert_eq!(h.record(&op).await.cursor_row_index, 5);
    +
    +    // A client overwrites k6 and deletes k7 after their bulk writes
    +    // committed and before the operation resumes.
    +    h.put_direct(item("k6", "100")).await;
    +    let info = h.key_info().await;
    +    h.storage
    +        .delete_item(
    +            &info,
    +            &key("k7"),
    +            false,
    +            None,
    +            &ExpressionMaps::default(),
    +            None,
    +        )
    +        .await
    +        .expect("client delete");
    +    resume(&h, "runner-2", window(5, 1)).await;
    +
    +    let k6 = h.get("k6").await.expect("k6 is stored");
    +    assert_eq!(
    +        k6.get("v"),
    +        Some(&AttributeValue::N("100".to_owned())),
    +        "the client's put stands"
    +    );
    +    assert!(h.get("k7").await.is_none(), "the client's delete stands");
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 12);
    +    let expected = uninterrupted(backend, &rows, &[], window(5, 1)).await;
    +    assert_eq!(
    +        record.usage, expected.usage,
    +        "the rows applied before the kill are charged as the uninterrupted run charged them"
    +    );
    +    h.cleanup().await;
    +}
    +
    +async fn a_transient_fault_on_a_later_write_batch_of_the_window_applies_no_row_twice_case(
    +    backend: Backend,
    +) {
    +    let mut rows = plain_rows(12);
    +    rows[1] = kv("dup", "1");
    +    rows[2] = kv_if("dup", "2", "attribute_exists(pk)");
    +    let expected = uninterrupted(backend, &rows, &[], window(10, 2)).await;
    +
    +    let h = Harness::on(backend).await;
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +    // The checkpoint window is ten rows and a write batch two: the batches
    +    // of rows 0 and 1 and of rows 2 and 3 commit, and the batch of rows 4
    +    // and 5 fails with a transient fault before the window's checkpoint.
    +    let mut faulting = FaultingStorage::new(&h.storage);
    +    faulting.fail_put_pk = Some("k5".to_owned());
    +    faulting.checkpoint_lease = Some(Duration::from_millis(100));
    +    let deps = h.deps_with_storage("runner-1", window(10, 2), Arc::new(faulting));
    +    assert!(
    +        runner::run(&deps, record).await.is_err(),
    +        "the transient fault leaves the operation to resume"
    +    );
    +    assert_eq!(h.record(&op).await.cursor_row_index, 0);
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    resume(&h, "runner-2", window(10, 2)).await;
    +
    +    let dup = h.get("dup").await.expect("dup is stored");
    +    assert_eq!(dup.get("v"), Some(&AttributeValue::N("2".to_owned())));
    +    assert_eq!(outcome(&h, &op).await, expected);
    +    h.cleanup().await;
    +}
    +
    +async fn a_condition_failure_applied_before_a_kill_is_reported_as_the_uninterrupted_run_reports_it_case(
    +    backend: Backend,
    +) {
    +    // Row 3 fails its condition against the seeded item, row 4 then
    +    // replaces that item, and the kill comes before the checkpoint that
    +    // covers both.
    +    let mut rows = plain_rows(10);
    +    rows[3] = condition_row("5", "attribute_not_exists(pk)");
    +    rows[4] = kv("c", "7");
    +    let seed = [item("c", "1")];
    +    let expected = uninterrupted(backend, &rows, &seed, window(6, 1)).await;
    +
    +    let h = Harness::on(backend).await;
    +    h.put_direct(item("c", "1")).await;
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    kill_after(&h, 5, window(6, 1)).await;
    +    assert_eq!(h.record(&op).await.cursor_row_index, 0);
    +    resume(&h, "runner-2", window(6, 1)).await;
    +
    +    let got = outcome(&h, &op).await;
    +    let report = got.conditions.as_ref().expect("condition report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["oldImage"]["Item"]["v"]["N"],
    +        json!("1"),
    +        "the record carries the item the condition was evaluated against"
    +    );
    +    assert_eq!(got, expected);
    +    h.cleanup().await;
    +}
    +
    +async fn ten_resumes_without_progress_end_the_operation_failed_case(backend: Backend) {
    +    let h = Harness::on(backend).await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(6)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +
    +    // Every attempt's first batch fails with a transient fault, the way a
    +    // statement timeout shorter than one batch fails every attempt, so no
    +    // checkpoint ever moves.
    +    let mut claims = 0;
    +    loop {
    +        let owner = format!("runner-{claims}");
    +        let record = h.claim(&owner, Duration::from_millis(100)).await;
    +        claims += 1;
    +        let mut faulting = FaultingStorage::new(&h.storage);
    +        faulting.fail_put_pk = Some("k0".to_owned());
    +        faulting.checkpoint_lease = Some(Duration::from_millis(100));
    +        let deps = h.deps_with_storage(&owner, window(5, 5), Arc::new(faulting));
    +        let result = runner::run(&deps, record).await;
    +        let status = h.record(&op).await.status;
    +        if status != "IN_PROGRESS" || claims > 11 {
    +            assert!(
    +                result.is_ok(),
    +                "the run that ends the operation returns cleanly"
    +            );
    +            break;
    +        }
    +        assert!(
    +            result.is_err(),
    +            "claim {claims} leaves the operation to resume"
    +        );
    +        tokio::time::sleep(Duration::from_millis(150)).await;
    +    }
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(
    +        claims, 11,
    +        "the first claim and ten resumes ran; the tenth resume without progress ends it"
    +    );
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("InternalServerError"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("Internal server error")
    +    );
    +    assert_eq!(h.count_items().await, 0);
    +    h.cleanup().await;
    +}
    +
    +on_both_backends!(
    +    a_kill_past_the_checkpoint_leaves_the_later_image_of_a_key_written_twice_case,
    +    a_kill_past_the_checkpoint_leaves_the_later_image_of_a_key_written_twice,
    +    a_kill_past_the_checkpoint_leaves_the_later_image_of_a_key_written_twice_on_postgres
    +);
    +
    +on_both_backends!(
    +    a_client_write_after_a_killed_batch_is_not_undone_by_the_resume_case,
    +    a_client_write_after_a_killed_batch_is_not_undone_by_the_resume,
    +    a_client_write_after_a_killed_batch_is_not_undone_by_the_resume_on_postgres
    +);
    +
    +on_both_backends!(
    +    a_transient_fault_on_a_later_write_batch_of_the_window_applies_no_row_twice_case,
    +    a_transient_fault_on_a_later_write_batch_of_the_window_applies_no_row_twice,
    +    a_transient_fault_on_a_later_write_batch_of_the_window_applies_no_row_twice_on_postgres
    +);
    +
    +on_both_backends!(
    +    a_condition_failure_applied_before_a_kill_is_reported_as_the_uninterrupted_run_reports_it_case,
    +    a_condition_failure_applied_before_a_kill_is_reported_as_the_uninterrupted_run_reports_it,
    +    a_condition_failure_applied_before_a_kill_is_reported_as_the_uninterrupted_run_reports_it_on_postgres
    +);
    +
    +on_both_backends!(
    +    ten_resumes_without_progress_end_the_operation_failed_case,
    +    ten_resumes_without_progress_end_the_operation_failed,
    +    ten_resumes_without_progress_end_the_operation_failed_on_postgres
    +);
    +
    +/// A checkpoint window of 400 rows written in batches of ten, killed after
    +/// 300 rows: the ledger holds about 300 rows past the checkpoint. The
    +/// resumed run reads them a page at a time as its flushes reach them, never
    +/// more than one page per read, and ends as an uninterrupted run does.
    +async fn a_resume_past_more_recorded_rows_than_a_page_reads_them_a_page_at_a_time_case(
    +    backend: Backend,
    +) {
    +    let rows = plain_rows(400);
    +    let expected = uninterrupted(backend, &rows, &[], window(400, 10)).await;
    +
    +    let h = Harness::on(backend).await;
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    kill_after(&h, 300, window(400, 10)).await;
    +    assert_eq!(h.record(&op).await.cursor_row_index, 0);
    +
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let faulting = FaultingStorage::new(&h.storage);
    +    let reads = Arc::clone(&faulting.recorded_reads);
    +    let deps = h.deps_with_storage("runner-2", window(400, 10), Arc::new(faulting));
    +    runner::run(&deps, record).await.expect("resumed run");
    +
    +    let reads = reads.lock().unwrap().clone();
    +    let page = usize::try_from(runner::RECORDED_PAGE).unwrap();
    +    assert!(
    +        reads.len() >= 3,
    +        "the recorded rows took several pages: {reads:?}"
    +    );
    +    assert!(reads.iter().all(|&n| n <= page), "{reads:?}");
    +    assert!(reads.iter().sum::() >= 290, "{reads:?}");
    +    assert_eq!(outcome(&h, &op).await, expected);
    +    h.cleanup().await;
    +}
    +
    +on_both_backends!(
    +    a_resume_past_more_recorded_rows_than_a_page_reads_them_a_page_at_a_time_case,
    +    a_resume_past_more_recorded_rows_than_a_page_reads_them_a_page_at_a_time,
    +    a_resume_past_more_recorded_rows_than_a_page_reads_them_a_page_at_a_time_on_postgres
    +);
    diff --git a/crates/engine/src/bulk_put/tests/runner/fake_authorizer.rs b/crates/engine/src/bulk_put/tests/runner/fake_authorizer.rs
    new file mode 100644
    index 000000000..800ab852d
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/fake_authorizer.rs
    @@ -0,0 +1,128 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use std::sync::Mutex;
    +
    +use super::*;
    +
    +use crate::bulk_put::authorize::{
    +    AuthorizeError, BulkAuthorizer, BulkIdentity, BulkPrincipal, S3Action,
    +};
    +
    +// A stand-in for the server's authorizer: it allows every check except the
    +// one rule it carries, and records each check it was asked.
    +
    +/// The caller the tests record on their operations.
    +pub(crate) const CALLER: &str = "loader";
    +
    +/// Deny `action` on every resource ending with `resource_suffix`, after the
    +/// first `allow_first` such checks were allowed.
    +pub(crate) struct DenyRule {
    +    pub(crate) action: S3Action,
    +    pub(crate) resource_suffix: String,
    +    pub(crate) explicit: bool,
    +    pub(crate) allow_first: usize,
    +}
    +
    +#[derive(Default)]
    +pub(crate) struct FakeAuthorizer {
    +    pub(crate) rule: Option,
    +    /// Answer every check as one that cannot be evaluated.
    +    pub(crate) unavailable: bool,
    +    pub(crate) matched: Mutex,
    +    /// Every check asked: the action, the resource and `s3:prefix`.
    +    pub(crate) calls: Mutex)>>,
    +}
    +
    +impl FakeAuthorizer {
    +    pub(crate) fn denying(action: S3Action, resource_suffix: &str) -> Self {
    +        Self {
    +            rule: Some(DenyRule {
    +                action,
    +                resource_suffix: resource_suffix.to_owned(),
    +                explicit: false,
    +                allow_first: 0,
    +            }),
    +            ..Self::default()
    +        }
    +    }
    +
    +    pub(crate) fn checks(&self) -> Vec<(S3Action, String, Option)> {
    +        self.calls.lock().unwrap().clone()
    +    }
    +
    +    pub(crate) fn count(&self, action: S3Action) -> usize {
    +        self.checks()
    +            .iter()
    +            .filter(|(a, _, _)| *a == action)
    +            .count()
    +    }
    +}
    +
    +impl BulkAuthorizer for FakeAuthorizer {
    +    fn authorize<'a>(
    +        &'a self,
    +        _principal: &'a BulkPrincipal,
    +        action: S3Action,
    +        resource_arn: &'a str,
    +        s3_prefix: Option<&'a str>,
    +    ) -> BoxFuture<'a, Result<(), AuthorizeError>> {
    +        self.calls.lock().unwrap().push((
    +            action,
    +            resource_arn.to_owned(),
    +            s3_prefix.map(ToOwned::to_owned),
    +        ));
    +        let answer = if self.unavailable {
    +            Err(AuthorizeError::Unavailable("test".to_owned()))
    +        } else {
    +            match &self.rule {
    +                Some(rule)
    +                    if rule.action == action && resource_arn.ends_with(&rule.resource_suffix) =>
    +                {
    +                    let mut matched = self.matched.lock().unwrap();
    +                    *matched += 1;
    +                    if *matched > rule.allow_first {
    +                        Err(AuthorizeError::Denied {
    +                            explicit: rule.explicit,
    +                        })
    +                    } else {
    +                        Ok(())
    +                    }
    +                }
    +                _ => Ok(()),
    +            }
    +        };
    +        Box::pin(async move { answer })
    +    }
    +}
    +
    +/// The recorded caller: an IAM user in the test account.
    +pub(crate) fn caller() -> BulkPrincipal {
    +    BulkPrincipal {
    +        identity: BulkIdentity::User {
    +            account_id: ACCOUNT.to_owned(),
    +            user_name: CALLER.to_owned(),
    +        },
    +        session: None,
    +        source_ip: None,
    +        user_agent: None,
    +        secure_transport: false,
    +    }
    +}
    +
    +/// The run context Start records for the test caller on the filesystem
    +/// store.
    +pub(crate) fn run_context_with_caller() -> Value {
    +    json!({
    +        "store": "filesystem",
    +        "principal": serde_json::to_value(caller()).unwrap(),
    +    })
    +}
    +
    +/// The denial text for the test caller.
    +pub(crate) fn denied(action: &str, resource: &str) -> String {
    +    format!(
    +        "User: arn:aws:iam::{ACCOUNT}:user/{CALLER} is not authorized to perform: {action} on \
    +         resource: \"{resource}\" because no identity-based policy allows the {action} action"
    +    )
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/faults.rs b/crates/engine/src/bulk_put/tests/runner/faults.rs
    new file mode 100644
    index 000000000..6ae934cf4
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/faults.rs
    @@ -0,0 +1,388 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +/// Wraps the real storage engine and injects controlled faults on the bulk
    +/// write path; everything else delegates to the wrapped engine.
    +///
    +/// - `fail_put_pk`: the first write, or the first batch, carrying an item
    +///   with this partition key fails with a transient storage error and writes
    +///   nothing, then later attempts pass.
    +/// - `fail_every_batch`: every batch fails with a transient storage error
    +///   and writes nothing, the way a statement timeout shorter than one batch
    +///   fails every attempt.
    +/// - `hold_first_batch`: the first batch signals the first `Notify` and
    +///   waits for the second before it reaches storage, the way a batch of a
    +///   process that stalled past its lease is still in flight when another
    +///   process takes the operation over.
    +/// - `pend_finish`: the first `finish` signals and pends forever; dropping
    +///   the runner there models a crash after the final checkpoint and before
    +///   the terminal transition.
    +/// - `commit_then_error_row`: the batch carrying the marked row at this
    +///   `(file_index, row_index)` commits, its items and ledger rows in one
    +///   transaction, and then answers a storage error, the way a data write
    +///   can commit and its acknowledgement be lost.
    +pub(crate) struct FaultingStorage {
    +    pub(crate) inner: Arc,
    +    pub(crate) fail_put_pk: Option,
    +    /// When set, the `fail_put_pk` write fails with an internal storage
    +    /// error carrying this message instead of a transient one.
    +    pub(crate) fail_put_pk_internal: Option,
    +    pub(crate) fail_fired: AtomicBool,
    +    pub(crate) fail_every_batch: bool,
    +    pub(crate) hold_first_batch: Option<(Arc, Arc)>,
    +    pub(crate) hold_fired: AtomicBool,
    +    pub(crate) commit_then_error_row: Option<(i64, i64)>,
    +    pub(crate) commit_then_error_fired: AtomicBool,
    +    pub(crate) pend_finish: Option>,
    +    pub(crate) finish_fired: AtomicBool,
    +    /// When set, the first `checkpoint` signals and pends forever, holding
    +    /// its runner (and, under the worker, its slot) mid-run while other
    +    /// operations' checkpoints pass; the worker tests observe overlapping
    +    /// runs through it.
    +    pub(crate) pend_checkpoint: Option>,
    +    pub(crate) checkpoint_pend_fired: AtomicBool,
    +    /// When true, the first `checkpoint` fails with a transient storage
    +    /// error, so the run ends in a runner storage error and the operation
    +    /// stays non-terminal, leased until its lease expires.
    +    pub(crate) fail_checkpoint_once: bool,
    +    pub(crate) checkpoint_fail_fired: AtomicBool,
    +    /// When set, every `checkpoint` asks the store for this lease instead
    +    /// of the runner's, so the store's lease can run out while the runner's
    +    /// own deadline is still far off.
    +    pub(crate) checkpoint_lease: Option,
    +    /// When set, `get_bulk_operation` rewrites the returned record's status
    +    /// to this string, modelling a stored status outside the known set.
    +    pub(crate) doctor_status: Option,
    +    /// The `n`th `get_bulk_operation` call (1-based) answers a storage
    +    /// error once; later calls pass through.
    +    pub(crate) fail_get_on_call: Option,
    +    pub(crate) get_calls: std::sync::atomic::AtomicUsize,
    +    /// When true, the first `delete_bulk_row_outcomes` fails with a
    +    /// transient storage error, the way a retention pass can lose one
    +    /// pair's ledger delete.
    +    pub(crate) fail_ledger_delete_once: bool,
    +    pub(crate) ledger_delete_fail_fired: AtomicBool,
    +    /// When set, the first `delete_bulk_row_outcomes` completes, then
    +    /// signals and pends forever, holding its caller between the ledger
    +    /// delete and whatever follows; aborting the caller there models a
    +    /// crash after the delete committed.
    +    pub(crate) pend_after_ledger_delete: Option>,
    +    pub(crate) ledger_delete_pend_fired: AtomicBool,
    +    /// Completed `purge_operations` calls, so a test can pin that a
    +    /// retention pass ran to its end.
    +    pub(crate) purge_calls: Arc,
    +    /// The row count of every `put_items_batch` call, in order, so a test
    +    /// can pin the runner's flush policy.
    +    pub(crate) batch_sizes: Arc>>,
    +    /// The row count of every `bulk_rows_recorded` answer, in order, so a
    +    /// test can pin how many recorded rows a resumed run holds at once.
    +    pub(crate) recorded_reads: Arc>>,
    +}
    +
    +impl FaultingStorage {
    +    pub(crate) fn new(inner: &Arc) -> Self {
    +        Self {
    +            inner: Arc::clone(inner),
    +            fail_put_pk: None,
    +            fail_put_pk_internal: None,
    +            fail_fired: AtomicBool::new(false),
    +            fail_every_batch: false,
    +            hold_first_batch: None,
    +            hold_fired: AtomicBool::new(false),
    +            commit_then_error_row: None,
    +            commit_then_error_fired: AtomicBool::new(false),
    +            pend_finish: None,
    +            finish_fired: AtomicBool::new(false),
    +            pend_checkpoint: None,
    +            checkpoint_pend_fired: AtomicBool::new(false),
    +            fail_checkpoint_once: false,
    +            checkpoint_fail_fired: AtomicBool::new(false),
    +            checkpoint_lease: None,
    +            doctor_status: None,
    +            fail_get_on_call: None,
    +            get_calls: std::sync::atomic::AtomicUsize::new(0),
    +            fail_ledger_delete_once: false,
    +            ledger_delete_fail_fired: AtomicBool::new(false),
    +            pend_after_ledger_delete: None,
    +            ledger_delete_pend_fired: AtomicBool::new(false),
    +            purge_calls: Arc::new(AtomicU64::new(0)),
    +            batch_sizes: Arc::new(std::sync::Mutex::new(Vec::new())),
    +            recorded_reads: Arc::new(std::sync::Mutex::new(Vec::new())),
    +        }
    +    }
    +}
    +
    +impl DataEngine for FaultingStorage {
    +    fn scan_key_in_segment(
    +        &self,
    +        key_info: &TableKeyInfo,
    +        key: &Item,
    +        segment: i64,
    +        total_segments: i64,
    +        index_name: Option<&str>,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner
    +            .scan_key_in_segment(key_info, key, segment, total_segments, index_name)
    +    }
    +
    +    fn as_vector_search(&self) -> Option<&dyn extenddb_storage::VectorSearchEngine> {
    +        self.inner.as_vector_search()
    +    }
    +
    +    fn put_item(
    +        &self,
    +        key_info: &TableKeyInfo,
    +        item: Item,
    +        return_old: bool,
    +        condition: Option<&Expr>,
    +        maps: &ExpressionMaps,
    +        stream: Option<&StreamCapture>,
    +        marker: Option<&BulkRowMarker>,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        if let Some(pk) = &self.fail_put_pk
    +            && item.get("pk") == Some(&AttributeValue::S(pk.clone()))
    +            && !self.fail_fired.swap(true, Ordering::SeqCst)
    +        {
    +            let error = match &self.fail_put_pk_internal {
    +                Some(message) => StorageError::Internal(message.clone()),
    +                None => StorageError::Transient("injected write failure".to_owned()),
    +            };
    +            return Box::pin(async move { Err(error) });
    +        }
    +        self.inner
    +            .put_item(key_info, item, return_old, condition, maps, stream, marker)
    +    }
    +
    +    fn bulk_row_outcome(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        file_index: i64,
    +        row_index: i64,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        self.inner
    +            .bulk_row_outcome(account_id, operation_id, file_index, row_index)
    +    }
    +
    +    /// Records the batch's row count, applies the injected batch faults,
    +    /// and hands the batch to the wrapped engine, which applies it in one
    +    /// transaction.
    +    fn put_items_batch(
    +        &self,
    +        key_info: &TableKeyInfo,
    +        rows: Vec,
    +        stream: Option<&StreamCapture>,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        self.batch_sizes
    +            .lock()
    +            .expect("batch size recorder")
    +            .push(rows.len());
    +        let fails = self.fail_every_batch
    +            || self.fail_put_pk.as_ref().is_some_and(|pk| {
    +                rows.iter()
    +                    .any(|row| row.item.get("pk") == Some(&AttributeValue::S(pk.clone())))
    +                    && !self.fail_fired.swap(true, Ordering::SeqCst)
    +            });
    +        if fails {
    +            let error = match &self.fail_put_pk_internal {
    +                Some(message) if !self.fail_every_batch => StorageError::Internal(message.clone()),
    +                _ => StorageError::Transient("injected write failure".to_owned()),
    +            };
    +            return Box::pin(async move { Err(error) });
    +        }
    +        let commit_then_error = self.commit_then_error_row.is_some_and(|(file, row)| {
    +            rows.iter().any(|r| {
    +                r.marker
    +                    .as_ref()
    +                    .is_some_and(|m| m.file_index == file && m.row_index == row)
    +            }) && !self.commit_then_error_fired.swap(true, Ordering::SeqCst)
    +        });
    +        let hold = self
    +            .hold_first_batch
    +            .as_ref()
    +            .filter(|_| !self.hold_fired.swap(true, Ordering::SeqCst))
    +            .cloned();
    +        let key_info = key_info.clone();
    +        let stream = stream.cloned();
    +        Box::pin(async move {
    +            if let Some((entered, release)) = hold {
    +                entered.notify_one();
    +                release.notified().await;
    +            }
    +            let results = self
    +                .inner
    +                .put_items_batch(&key_info, rows, stream.as_ref())
    +                .await?;
    +            if commit_then_error {
    +                return Err(StorageError::Internal(
    +                    "injected error after a committed batch".to_owned(),
    +                ));
    +            }
    +            Ok(results)
    +        })
    +    }
    +
    +    fn bulk_write_takeover(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        cursor: (i64, i64),
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner
    +            .bulk_write_takeover(account_id, operation_id, owner, cursor)
    +    }
    +
    +    fn bulk_rows_recorded(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        file_index: i64,
    +        from_row: i64,
    +        limit: i64,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        let reads = Arc::clone(&self.recorded_reads);
    +        let read =
    +            self.inner
    +                .bulk_rows_recorded(account_id, operation_id, file_index, from_row, limit);
    +        Box::pin(async move {
    +            let rows = read.await?;
    +            reads.lock().unwrap().push(rows.len());
    +            Ok(rows)
    +        })
    +    }
    +
    +    fn delete_bulk_row_outcomes(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        if self.fail_ledger_delete_once
    +            && !self.ledger_delete_fail_fired.swap(true, Ordering::SeqCst)
    +        {
    +            return Box::pin(async {
    +                Err(StorageError::Transient(
    +                    "injected ledger delete failure".to_owned(),
    +                ))
    +            });
    +        }
    +        let pend = self
    +            .pend_after_ledger_delete
    +            .as_ref()
    +            .filter(|_| !self.ledger_delete_pend_fired.swap(true, Ordering::SeqCst))
    +            .map(Arc::clone);
    +        let fut = self
    +            .inner
    +            .delete_bulk_row_outcomes(account_id, operation_id);
    +        Box::pin(async move {
    +            let deleted = fut.await?;
    +            if let Some(notify) = pend {
    +                notify.notify_one();
    +                std::future::pending::<()>().await;
    +            }
    +            Ok(deleted)
    +        })
    +    }
    +
    +    fn get_item(
    +        &self,
    +        key_info: &TableKeyInfo,
    +        key: &Item,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        self.inner.get_item(key_info, key)
    +    }
    +
    +    fn delete_item(
    +        &self,
    +        key_info: &TableKeyInfo,
    +        key: &Item,
    +        return_old: bool,
    +        condition: Option<&Expr>,
    +        maps: &ExpressionMaps,
    +        stream: Option<&StreamCapture>,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        self.inner
    +            .delete_item(key_info, key, return_old, condition, maps, stream)
    +    }
    +
    +    fn update_item(
    +        &self,
    +        key_info: &TableKeyInfo,
    +        key: &Item,
    +        actions: &[UpdateAction],
    +        return_old: bool,
    +        return_new: bool,
    +        condition: Option<&Expr>,
    +        maps: &ExpressionMaps,
    +        stream: Option<&StreamCapture>,
    +    ) -> BoxFuture<'_, ItemPairResult> {
    +        self.inner.update_item(
    +            key_info, key, actions, return_old, return_new, condition, maps, stream,
    +        )
    +    }
    +
    +    fn query(
    +        &self,
    +        key_info: &TableKeyInfo,
    +        key_condition: &KeyCondition,
    +        maps: &ExpressionMaps,
    +        forward: bool,
    +        limit: Option,
    +        exclusive_start_key: Option<&Item>,
    +        index_name: Option<&str>,
    +    ) -> BoxFuture<'_, QueryResult> {
    +        self.inner.query(
    +            key_info,
    +            key_condition,
    +            maps,
    +            forward,
    +            limit,
    +            exclusive_start_key,
    +            index_name,
    +        )
    +    }
    +
    +    fn scan(
    +        &self,
    +        key_info: &TableKeyInfo,
    +        limit: Option,
    +        exclusive_start_key: Option<&Item>,
    +        segment: Option,
    +        total_segments: Option,
    +        index_name: Option<&str>,
    +    ) -> BoxFuture<'_, QueryResult> {
    +        self.inner.scan(
    +            key_info,
    +            limit,
    +            exclusive_start_key,
    +            segment,
    +            total_segments,
    +            index_name,
    +        )
    +    }
    +
    +    fn transact_get_items(
    +        &self,
    +        ops: &[TransactGetOp<'_>],
    +    ) -> BoxFuture<'_, Result>, StorageError>> {
    +        self.inner.transact_get_items(ops)
    +    }
    +
    +    fn transact_write_items(
    +        &self,
    +        ops: &[TransactWriteOp<'_>],
    +        idempotency: Option>,
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        self.inner.transact_write_items(ops, idempotency)
    +    }
    +
    +    fn cleanup_expired_idempotency_tokens(
    +        &self,
    +        max_age_seconds: i64,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner
    +            .cleanup_expired_idempotency_tokens(max_age_seconds)
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/faults_engines.rs b/crates/engine/src/bulk_put/tests/runner/faults_engines.rs
    new file mode 100644
    index 000000000..1c8cc7a93
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/faults_engines.rs
    @@ -0,0 +1,623 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +impl BulkOperationStore for FaultingStorage {
    +    fn start_bulk_operation(
    +        &self,
    +        account_id: &str,
    +        req: NewBulkOperation,
    +        account_limit: u32,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner
    +            .start_bulk_operation(account_id, req, account_limit)
    +    }
    +
    +    fn get_bulk_operation(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        let doctored = self.doctor_status.clone();
    +        let call = self.get_calls.fetch_add(1, Ordering::SeqCst) + 1;
    +        let fail_now = self.fail_get_on_call == Some(call);
    +        let fut = self.inner.get_bulk_operation(account_id, operation_id);
    +        Box::pin(async move {
    +            if fail_now {
    +                return Err(StorageError::Internal(
    +                    "injected read failure at the lease re-read".to_owned(),
    +                ));
    +            }
    +            let mut record = fut.await?;
    +            if let (Some(status), Some(r)) = (doctored, record.as_mut()) {
    +                r.status = status;
    +            }
    +            Ok(record)
    +        })
    +    }
    +
    +    fn list_bulk_operations(
    +        &self,
    +        account_id: &str,
    +        limit: u32,
    +        after: Option,
    +    ) -> BoxFuture<'_, BulkOperationListResult> {
    +        self.inner.list_bulk_operations(account_id, limit, after)
    +    }
    +
    +    fn count_non_terminal(&self, account_id: &str) -> BoxFuture<'_, Result> {
    +        self.inner.count_non_terminal(account_id)
    +    }
    +
    +    fn count_non_terminal_for_table(
    +        &self,
    +        account_id: &str,
    +        table_id: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner
    +            .count_non_terminal_for_table(account_id, table_id)
    +    }
    +
    +    fn count_terminal_born_since(
    +        &self,
    +        account_id: &str,
    +        since_ms: i64,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.count_terminal_born_since(account_id, since_ms)
    +    }
    +
    +    fn request_cancel(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.request_cancel(account_id, operation_id)
    +    }
    +
    +    fn claim_runnable(
    +        &self,
    +        owner: &str,
    +        lease: Duration,
    +        max: usize,
    +        exclude_accounts: &[String],
    +        exclude_operations: &[String],
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        self.inner
    +            .claim_runnable(owner, lease, max, exclude_accounts, exclude_operations)
    +    }
    +
    +    fn checkpoint(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        lease: Duration,
    +        progress: Progress,
    +    ) -> BoxFuture<'_, Result> {
    +        if self.fail_checkpoint_once && !self.checkpoint_fail_fired.swap(true, Ordering::SeqCst) {
    +            return Box::pin(async {
    +                Err(StorageError::Transient(
    +                    "injected checkpoint failure".to_owned(),
    +                ))
    +            });
    +        }
    +        if let Some(notify) = &self.pend_checkpoint
    +            && !self.checkpoint_pend_fired.swap(true, Ordering::SeqCst)
    +        {
    +            let notify = Arc::clone(notify);
    +            return Box::pin(async move {
    +                notify.notify_one();
    +                std::future::pending::<()>().await;
    +                unreachable!("pending never resolves")
    +            });
    +        }
    +        let lease = self.checkpoint_lease.unwrap_or(lease);
    +        self.inner
    +            .checkpoint(account_id, operation_id, owner, lease, progress)
    +    }
    +
    +    fn report_records_page(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        kind: ReportKind,
    +        after: Option<(i64, i64)>,
    +        limit: usize,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        self.inner
    +            .report_records_page(account_id, operation_id, kind, after, limit)
    +    }
    +
    +    fn finish(
    +        &self,
    +        account_id: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        outcome: Terminal,
    +    ) -> BoxFuture<'_, Result> {
    +        if let Some(notify) = &self.pend_finish
    +            && !self.finish_fired.swap(true, Ordering::SeqCst)
    +        {
    +            let notify = Arc::clone(notify);
    +            return Box::pin(async move {
    +                notify.notify_one();
    +                std::future::pending::<()>().await;
    +                unreachable!("pending never resolves")
    +            });
    +        }
    +        self.inner.finish(account_id, operation_id, owner, outcome)
    +    }
    +
    +    fn list_purgeable_before(
    +        &self,
    +        cutoff: SystemTime,
    +        limit: usize,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        self.inner.list_purgeable_before(cutoff, limit)
    +    }
    +
    +    fn purge_operations(
    +        &self,
    +        pairs: &[(String, String)],
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        let fut = self.inner.purge_operations(pairs);
    +        let calls = Arc::clone(&self.purge_calls);
    +        Box::pin(async move {
    +            let result = fut.await;
    +            calls.fetch_add(1, Ordering::SeqCst);
    +            result
    +        })
    +    }
    +}
    +
    +impl TableEngine for FaultingStorage {
    +    fn create_table(
    +        &self,
    +        account_id: &str,
    +        input: CreateTableInput,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.create_table(account_id, input)
    +    }
    +
    +    fn delete_table(
    +        &self,
    +        account_id: &str,
    +        input: DeleteTableInput,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.delete_table(account_id, input)
    +    }
    +
    +    fn describe_table(
    +        &self,
    +        account_id: &str,
    +        input: DescribeTableInput,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.describe_table(account_id, input)
    +    }
    +
    +    fn list_tables(
    +        &self,
    +        account_id: &str,
    +        input: ListTablesInput,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.list_tables(account_id, input)
    +    }
    +
    +    fn update_table(
    +        &self,
    +        account_id: &str,
    +        input: UpdateTableInput,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.update_table(account_id, input)
    +    }
    +
    +    fn table_key_info(
    +        &self,
    +        account_id: &str,
    +        table_name: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.table_key_info(account_id, table_name)
    +    }
    +
    +    fn index_info(
    +        &self,
    +        account_id: &str,
    +        table_name: &str,
    +        index_name: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.index_info(account_id, table_name, index_name)
    +    }
    +
    +    fn index_info_by_table_id(
    +        &self,
    +        table_id: &str,
    +        index_name: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.index_info_by_table_id(table_id, index_name)
    +    }
    +}
    +
    +impl MetadataEngine for FaultingStorage {
    +    fn describe_ttl(
    +        &self,
    +        account_id: &str,
    +        table_name: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.describe_ttl(account_id, table_name)
    +    }
    +
    +    fn update_ttl(
    +        &self,
    +        account_id: &str,
    +        table_name: &str,
    +        attribute_name: &str,
    +        enabled: bool,
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        self.inner
    +            .update_ttl(account_id, table_name, attribute_name, enabled)
    +    }
    +
    +    fn tag_resource(&self, arn: &str, tags: &[Tag]) -> BoxFuture<'_, Result<(), StorageError>> {
    +        self.inner.tag_resource(arn, tags)
    +    }
    +
    +    fn untag_resource(
    +        &self,
    +        arn: &str,
    +        tag_keys: &[String],
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        self.inner.untag_resource(arn, tag_keys)
    +    }
    +
    +    fn list_tags(&self, arn: &str) -> BoxFuture<'_, Result, StorageError>> {
    +        self.inner.list_tags(arn)
    +    }
    +
    +    fn tables_with_ttl(
    +        &self,
    +        account_id: &str,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        self.inner.tables_with_ttl(account_id)
    +    }
    +
    +    fn all_tables_with_ttl(&self) -> BoxFuture<'_, Result, StorageError>> {
    +        self.inner.all_tables_with_ttl()
    +    }
    +
    +    fn all_tables_with_ttl_index_ready(
    +        &self,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        self.inner.all_tables_with_ttl_index_ready()
    +    }
    +
    +    fn create_ttl_index(
    +        &self,
    +        account_id: &str,
    +        table_name: &str,
    +        ttl_attribute: &str,
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        self.inner
    +            .create_ttl_index(account_id, table_name, ttl_attribute)
    +    }
    +
    +    fn drop_ttl_index(
    +        &self,
    +        account_id: &str,
    +        table_name: &str,
    +        ttl_attribute: &str,
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        self.inner
    +            .drop_ttl_index(account_id, table_name, ttl_attribute)
    +    }
    +
    +    fn find_expired_items_indexed(
    +        &self,
    +        account_id: &str,
    +        table_name: &str,
    +        ttl_attribute: &str,
    +        limit: usize,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        self.inner
    +            .find_expired_items_indexed(account_id, table_name, ttl_attribute, limit)
    +    }
    +
    +    fn refresh_table_size(
    +        &self,
    +        account_id: &str,
    +        table_name: &str,
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        self.inner.refresh_table_size(account_id, table_name)
    +    }
    +
    +    fn list_active_table_names(
    +        &self,
    +        account_id: &str,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        self.inner.list_active_table_names(account_id)
    +    }
    +
    +    fn all_active_tables(&self) -> BoxFuture<'_, Result, StorageError>> {
    +        self.inner.all_active_tables()
    +    }
    +}
    +
    +impl StreamEngine for FaultingStorage {
    +    fn write_stream_record(
    +        &self,
    +        account_id: &str,
    +        record: &StreamRecord,
    +        shard_id: &str,
    +        table_name: &str,
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        self.inner
    +            .write_stream_record(account_id, record, shard_id, table_name)
    +    }
    +
    +    fn get_stream_records(
    +        &self,
    +        account_id: &str,
    +        stream_arn: &str,
    +        shard_id: &str,
    +        after_sequence: Option<&str>,
    +        limit: i64,
    +    ) -> BoxFuture<'_, StreamRecordsResult> {
    +        self.inner
    +            .get_stream_records(account_id, stream_arn, shard_id, after_sequence, limit)
    +    }
    +
    +    fn describe_stream(
    +        &self,
    +        account_id: &str,
    +        input: &DescribeStreamInput,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.describe_stream(account_id, input)
    +    }
    +
    +    fn list_streams(
    +        &self,
    +        account_id: &str,
    +        table_name: Option<&str>,
    +        limit: i64,
    +        exclusive_start_stream_arn: Option<&str>,
    +    ) -> BoxFuture<'_, StreamListResult> {
    +        self.inner
    +            .list_streams(account_id, table_name, limit, exclusive_start_stream_arn)
    +    }
    +
    +    fn cleanup_expired_stream_records(
    +        &self,
    +        retention_hours: i64,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.cleanup_expired_stream_records(retention_hours)
    +    }
    +
    +    fn assign_shard(
    +        &self,
    +        account_id: &str,
    +        table_name: &str,
    +        partition_key: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner
    +            .assign_shard(account_id, table_name, partition_key)
    +    }
    +
    +    fn next_sequence_number(&self, shard_id: &str) -> BoxFuture<'_, Result> {
    +        self.inner.next_sequence_number(shard_id)
    +    }
    +
    +    fn validate_shard(
    +        &self,
    +        account_id: &str,
    +        stream_arn: &str,
    +        shard_id: &str,
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        self.inner.validate_shard(account_id, stream_arn, shard_id)
    +    }
    +
    +    fn latest_sequence_number(
    +        &self,
    +        shard_id: &str,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        self.inner.latest_sequence_number(shard_id)
    +    }
    +}
    +
    +impl extenddb_storage::BackupCatalog for FaultingStorage {
    +    fn catalog_id(&self) -> BoxFuture<'_, Result> {
    +        extenddb_storage::BackupCatalog::catalog_id(&*self.inner)
    +    }
    +
    +    fn claim_backup<'a>(
    +        &'a self,
    +        row: &'a extenddb_storage::backup_catalog::BackupRow,
    +    ) -> BoxFuture<'a, Result, StorageError>> {
    +        extenddb_storage::BackupCatalog::claim_backup(&*self.inner, row)
    +    }
    +
    +    fn list_backup_rows<'a>(
    +        &'a self,
    +        status: Option,
    +        store: &'a extenddb_storage::backup_catalog::StoreIdentity,
    +    ) -> futures::stream::BoxStream<
    +        'a,
    +        Result,
    +    > {
    +        extenddb_storage::BackupCatalog::list_backup_rows(&*self.inner, status, store)
    +    }
    +
    +    fn list_account_backup_rows<'a>(
    +        &'a self,
    +        account_id: &'a str,
    +        table_name: Option<&'a str>,
    +        bounds: &'a extenddb_storage::backup_catalog::TimeBounds,
    +    ) -> futures::stream::BoxStream<
    +        'a,
    +        Result,
    +    > {
    +        extenddb_storage::BackupCatalog::list_account_backup_rows(
    +            &*self.inner,
    +            account_id,
    +            table_name,
    +            bounds,
    +        )
    +    }
    +
    +    fn read_backup_row<'a>(
    +        &'a self,
    +        arn: &'a extenddb_core::backup_format::BackupArn,
    +    ) -> BoxFuture<'a, Result, StorageError>>
    +    {
    +        extenddb_storage::BackupCatalog::read_backup_row(&*self.inner, arn)
    +    }
    +
    +    fn start_backup_delete<'a>(
    +        &'a self,
    +        key: &'a extenddb_storage::backup_catalog::BackupKey,
    +    ) -> BoxFuture<'a, Result> {
    +        extenddb_storage::BackupCatalog::start_backup_delete(&*self.inner, key)
    +    }
    +
    +    fn set_backup_deleted<'a>(
    +        &'a self,
    +        key: &'a extenddb_storage::backup_catalog::BackupKey,
    +    ) -> BoxFuture<'a, Result> {
    +        extenddb_storage::BackupCatalog::set_backup_deleted(&*self.inner, key)
    +    }
    +
    +    fn remove_backup_row<'a>(
    +        &'a self,
    +        key: &'a extenddb_storage::backup_catalog::BackupKey,
    +    ) -> BoxFuture<'a, Result> {
    +        extenddb_storage::BackupCatalog::remove_backup_row(&*self.inner, key)
    +    }
    +
    +    fn backup_in_use<'a>(
    +        &'a self,
    +        key: &'a extenddb_storage::backup_catalog::BackupKey,
    +    ) -> BoxFuture<'a, Result, StorageError>> {
    +        extenddb_storage::BackupCatalog::backup_in_use(&*self.inner, key)
    +    }
    +
    +    fn remove_restore_target<'a>(
    +        &'a self,
    +        key: &'a extenddb_storage::backup_catalog::RestoreTargetKey,
    +    ) -> BoxFuture<'a, Result> {
    +        extenddb_storage::BackupCatalog::remove_restore_target(&*self.inner, key)
    +    }
    +
    +    fn list_dead_restore_jobs(
    +        &self,
    +        stale_after: Duration,
    +    ) -> BoxFuture<'_, Result, StorageError>>
    +    {
    +        extenddb_storage::BackupCatalog::list_dead_restore_jobs(&*self.inner, stale_after)
    +    }
    +
    +    fn fail_restore_job<'a>(
    +        &'a self,
    +        key: &'a extenddb_storage::backup_catalog::RestoreTargetKey,
    +        owner_generation: u64,
    +        heartbeat_at: f64,
    +        reason: &'a str,
    +    ) -> BoxFuture<'a, Result> {
    +        extenddb_storage::BackupCatalog::fail_restore_job(
    +            &*self.inner,
    +            key,
    +            owner_generation,
    +            heartbeat_at,
    +            reason,
    +        )
    +    }
    +
    +    fn check_maintenance_claim(&self) -> BoxFuture<'_, Result> {
    +        extenddb_storage::BackupCatalog::check_maintenance_claim(&*self.inner)
    +    }
    +}
    +
    +impl extenddb_storage::LegacyBackupReader for FaultingStorage {
    +    fn create(
    +        &self,
    +        account_id: &str,
    +        table_name: &str,
    +        backup_name: &str,
    +        permit: extenddb_storage::JobPermit,
    +    ) -> BoxFuture<'_, Result> {
    +        extenddb_storage::LegacyBackupReader::create(
    +            &*self.inner,
    +            account_id,
    +            table_name,
    +            backup_name,
    +            permit,
    +        )
    +    }
    +
    +    fn describe(
    +        &self,
    +        account_id: &str,
    +        backup_arn: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        extenddb_storage::LegacyBackupReader::describe(&*self.inner, account_id, backup_arn)
    +    }
    +
    +    fn list(
    +        &self,
    +        account_id: &str,
    +        table_name: Option<&str>,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        extenddb_storage::LegacyBackupReader::list(&*self.inner, account_id, table_name)
    +    }
    +
    +    fn delete(
    +        &self,
    +        account_id: &str,
    +        backup_arn: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        extenddb_storage::LegacyBackupReader::delete(&*self.inner, account_id, backup_arn)
    +    }
    +
    +    fn restore_source(
    +        &self,
    +        account_id: &str,
    +        backup_arn: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        extenddb_storage::LegacyBackupReader::restore_source(&*self.inner, account_id, backup_arn)
    +    }
    +
    +    fn restore(
    +        &self,
    +        account_id: &str,
    +        request: extenddb_storage::RestoreRequest,
    +        permit: extenddb_storage::JobPermit,
    +    ) -> BoxFuture<'_, Result> {
    +        extenddb_storage::LegacyBackupReader::restore(&*self.inner, account_id, request, permit)
    +    }
    +
    +    fn abandoned_creating_backups(
    +        &self,
    +        stale_after: Duration,
    +    ) -> BoxFuture<
    +        '_,
    +        Result, StorageError>,
    +    > {
    +        extenddb_storage::LegacyBackupReader::abandoned_creating_backups(&*self.inner, stale_after)
    +    }
    +}
    +
    +impl extenddb_storage::SnapshotEngine for FaultingStorage {
    +    fn open_table_snapshot(
    +        &self,
    +        account_id: &str,
    +        table_name: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.open_table_snapshot(account_id, table_name)
    +    }
    +}
    +
    +impl WorkerStore for FaultingStorage {
    +    fn process_control_plane_transitions(
    +        &self,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        self.inner.process_control_plane_transitions()
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/harness.rs b/crates/engine/src/bulk_put/tests/runner/harness.rs
    new file mode 100644
    index 000000000..0120db4a5
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/harness.rs
    @@ -0,0 +1,444 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +mod postgres;
    +mod tables;
    +
    +pub(crate) use postgres::{Backend, on_both_backends, postgres_configured, postgres_permit};
    +
    +/// A fresh directory acting as a configured import or export root.
    +pub(crate) struct Root(pub(crate) PathBuf);
    +
    +impl Root {
    +    pub(crate) fn new() -> Self {
    +        let dir = std::env::temp_dir().join(format!("eb-bulk-run-{}", uuid::Uuid::new_v4()));
    +        std::fs::create_dir_all(&dir).unwrap();
    +        Self(dir.canonicalize().unwrap())
    +    }
    +
    +    pub(crate) fn root(&self) -> Arc {
    +        Arc::new(self.0.clone())
    +    }
    +
    +    /// Create `//` the way a caller stages a
    +    /// bucket directory before naming it in a request.
    +    pub(crate) fn bucket_dir(&self, account: &str, bucket: &str) {
    +        std::fs::create_dir_all(self.0.join(account).join(bucket)).unwrap();
    +    }
    +
    +    /// Create `///` with content.
    +    pub(crate) fn file(&self, account: &str, bucket: &str, rel: &str, contents: &[u8]) -> PathBuf {
    +        let path = self.0.join(account).join(bucket).join(rel);
    +        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    +        std::fs::write(&path, contents).unwrap();
    +        path
    +    }
    +}
    +
    +impl Drop for Root {
    +    fn drop(&mut self) {
    +        let _ = std::fs::remove_dir_all(&self.0);
    +    }
    +}
    +
    +/// A store root with the two bucket directories the tests name staged for
    +/// the main account, matching what a caller does before its first request:
    +/// Start refuses a missing input or report bucket with a terminal FAILED
    +/// operation, and the sink opens the report bucket without creating it.
    +pub(crate) fn store_with_buckets() -> Root {
    +    let store = Root::new();
    +    store.bucket_dir(ACCOUNT, IN_BUCKET);
    +    store.bucket_dir(ACCOUNT, OUT_BUCKET);
    +    store
    +}
    +
    +/// Storage engine, the store root, and a real table: everything one
    +/// runner test needs.
    +pub(crate) struct Harness {
    +    pub(crate) storage: Arc,
    +    pub(crate) store: Root,
    +    pub(crate) table_id: String,
    +    pub(crate) table_arn: String,
    +    /// Catalog handle for the handler tests: account creation and the
    +    /// control-plane delay setting that keeps a table in `CREATING`.
    +    pub(crate) catalog: Arc,
    +    /// The scratch database of a PostgreSQL harness, dropped by
    +    /// `cleanup`; `None` on `SQLite`.
    +    pub(crate) pg: Option,
    +    /// The directory of a `SQLite` harness's database file, removed when
    +    /// the harness drops; `None` on PostgreSQL.
    +    pub(crate) _db_dir: Option,
    +}
    +
    +/// Which table shape a harness is built with.
    +enum TableShape {
    +    Plain,
    +    WithGsi,
    +    WithGsiHashKey,
    +    WithGsiKeysOnlyHashKey,
    +    WithStream,
    +    WithVector,
    +}
    +
    +impl Harness {
    +    pub(crate) fn write_input(&self, rel: &str, contents: &[u8]) {
    +        self.store.file(ACCOUNT, IN_BUCKET, rel, contents);
    +    }
    +
    +    pub(crate) fn fs_source(&self) -> FsSource {
    +        FsSource {
    +            root: self.store.root(),
    +        }
    +    }
    +
    +    pub(crate) fn fs_sink(&self) -> FsSink {
    +        FsSink {
    +            root: self.store.root(),
    +        }
    +    }
    +
    +    /// Enumerate the input bucket the way the start handler will: through
    +    /// `BulkSource::list`, so the stored file list carries real sizes.
    +    pub(crate) async fn enumerate(&self) -> Vec {
    +        self.fs_source()
    +            .list(&BucketRef::new(ACCOUNT, IN_BUCKET, None), "", u64::MAX)
    +            .await
    +            .expect("enumerate input files")
    +            .into_iter()
    +            .map(|f: SourceFile| InputFile {
    +                path: f.path,
    +                size: i64::try_from(f.size).unwrap(),
    +                rows: None,
    +                etag: None,
    +                abandon: None,
    +            })
    +            .collect()
    +    }
    +
    +    /// Create the operation record the real way and return its id.
    +    pub(crate) async fn start(&self, files: Vec, input: Value, output: Value) -> String {
    +        self.start_with_units(files, input, output, None).await
    +    }
    +
    +    /// `start` with the request's `MaxWriteRequestUnits` recorded, so a
    +    /// test can drive the operation's own write ceiling.
    +    pub(crate) async fn start_with_units(
    +        &self,
    +        files: Vec,
    +        input: Value,
    +        output: Value,
    +        max_write_request_units: Option,
    +    ) -> String {
    +        self.start_with(files, input, output, max_write_request_units, None)
    +            .await
    +    }
    +
    +    /// `start` with the run context the Start handler would record.
    +    pub(crate) async fn start_with_run_context(
    +        &self,
    +        files: Vec,
    +        input: Value,
    +        output: Value,
    +        run_context: Option,
    +    ) -> String {
    +        self.start_with(files, input, output, None, run_context)
    +            .await
    +    }
    +
    +    async fn start_with(
    +        &self,
    +        files: Vec,
    +        input: Value,
    +        output: Value,
    +        max_write_request_units: Option,
    +        run_context: Option,
    +    ) -> String {
    +        let operation_id = arn::generate_operation_id();
    +        let total: i64 = files.iter().map(|f| f.size).sum();
    +        let outcome = self
    +            .storage
    +            .start_bulk_operation(
    +                ACCOUNT,
    +                NewBulkOperation {
    +                    operation_id: operation_id.clone(),
    +                    table_id: self.table_id.clone(),
    +                    table_name: TABLE.to_owned(),
    +                    table_arn: self.table_arn.clone(),
    +                    client_token: None,
    +                    client_token_hash: None,
    +                    request_fingerprint: "runner-test".to_owned(),
    +                    input_config: input,
    +                    output_config: output,
    +                    input_files: files,
    +                    max_write_request_units,
    +                    performance_tier: None,
    +                    start_time_ms: now_ms(),
    +                    total_input_bytes: Some(total),
    +                    initial_failure: None,
    +                    run_context,
    +                },
    +                50,
    +            )
    +            .await
    +            .expect("start bulk operation");
    +        assert!(
    +            matches!(outcome, StartOutcome::Created(_)),
    +            "the operation must be created"
    +        );
    +        operation_id
    +    }
    +
    +    pub(crate) async fn claim(&self, owner: &str, lease: Duration) -> BulkOperationRecord {
    +        let mut claimed = self
    +            .storage
    +            .claim_runnable(owner, lease, 10, &[], &[])
    +            .await
    +            .expect("claim");
    +        assert_eq!(claimed.len(), 1, "exactly one operation must be claimable");
    +        claimed.remove(0)
    +    }
    +
    +    pub(crate) fn deps_with(
    +        &self,
    +        owner: &str,
    +        config: RunnerConfig,
    +        source: Arc,
    +        sink: Arc,
    +    ) -> RunnerDeps {
    +        RunnerDeps {
    +            storage: Arc::clone(&self.storage),
    +            source,
    +            sink,
    +            limits: Arc::new(LimitsConfig::default()),
    +            region: Arc::from(REGION),
    +            owner: owner.to_owned(),
    +            config,
    +            metrics: Arc::new(MetricsCollector::new()),
    +            pacer: Arc::new(WritePacer::unbounded()),
    +            store_kind: STORE_FILESYSTEM,
    +            authorizer: None,
    +            shutdown: CancellationToken::new(),
    +        }
    +    }
    +
    +    pub(crate) fn deps(&self, owner: &str, config: RunnerConfig) -> RunnerDeps {
    +        self.deps_with(
    +            owner,
    +            config,
    +            Arc::new(self.fs_source()),
    +            Arc::new(self.fs_sink()),
    +        )
    +    }
    +
    +    /// Deps whose storage engine is substituted; source and sink stay real.
    +    pub(crate) fn deps_with_storage(
    +        &self,
    +        owner: &str,
    +        config: RunnerConfig,
    +        storage: Arc,
    +    ) -> RunnerDeps {
    +        RunnerDeps {
    +            storage,
    +            source: Arc::new(self.fs_source()),
    +            sink: Arc::new(self.fs_sink()),
    +            limits: Arc::new(LimitsConfig::default()),
    +            region: Arc::from(REGION),
    +            owner: owner.to_owned(),
    +            config,
    +            metrics: Arc::new(MetricsCollector::new()),
    +            pacer: Arc::new(WritePacer::unbounded()),
    +            store_kind: STORE_FILESYSTEM,
    +            authorizer: None,
    +            shutdown: CancellationToken::new(),
    +        }
    +    }
    +
    +    /// `deps` with every checkpoint asking the store for a 100 ms lease, so
    +    /// the operation is claimable soon after its run stops, while the
    +    /// runner's own lease, from `deps.config`, forces no renewal and no stop
    +    /// inside the test however slowly the host runs it.
    +    pub(crate) fn with_short_store_lease(&self, mut deps: RunnerDeps) -> RunnerDeps {
    +        let mut faulting = FaultingStorage::new(&deps.storage);
    +        faulting.checkpoint_lease = Some(Duration::from_millis(100));
    +        deps.storage = Arc::new(faulting);
    +        deps
    +    }
    +
    +    /// Start, claim, and run one operation to a terminal state, returning
    +    /// the final record.
    +    pub(crate) async fn run_to_end(
    +        &self,
    +        files: Vec,
    +        input: Value,
    +        output: Value,
    +        config: RunnerConfig,
    +    ) -> BulkOperationRecord {
    +        let op = self.start(files, input, output).await;
    +        let record = self.claim("runner-1", Duration::from_secs(60)).await;
    +        runner::run(&self.deps("runner-1", config), record)
    +            .await
    +            .expect("runner run");
    +        self.record(&op).await
    +    }
    +
    +    pub(crate) async fn record(&self, operation_id: &str) -> BulkOperationRecord {
    +        self.storage
    +            .get_bulk_operation(ACCOUNT, operation_id)
    +            .await
    +            .expect("get bulk operation")
    +            .expect("operation record exists")
    +    }
    +
    +    pub(crate) async fn key_info(&self) -> TableKeyInfo {
    +        self.storage
    +            .table_key_info(ACCOUNT, TABLE)
    +            .await
    +            .expect("table key info")
    +    }
    +
    +    pub(crate) async fn get(&self, pk: &str) -> Option {
    +        let info = self.key_info().await;
    +        self.storage
    +            .get_item(&info, &key(pk))
    +            .await
    +            .expect("get item")
    +    }
    +
    +    pub(crate) async fn put_direct(&self, item: Item) {
    +        let info = self.key_info().await;
    +        self.storage
    +            .put_item(
    +                &info,
    +                item,
    +                false,
    +                None,
    +                &extenddb_core::expression::ExpressionMaps::default(),
    +                None,
    +                None,
    +            )
    +            .await
    +            .expect("direct put");
    +    }
    +
    +    pub(crate) async fn count_items(&self) -> usize {
    +        let info = self.key_info().await;
    +        let mut count = 0;
    +        let mut start: Option = None;
    +        loop {
    +            let (items, next) = self
    +                .storage
    +                .scan(&info, None, start.as_ref(), None, None, None)
    +                .await
    +                .expect("scan");
    +            count += items.len();
    +            match next {
    +                Some(k) => start = Some(k),
    +                None => return count,
    +            }
    +        }
    +    }
    +
    +    pub(crate) fn report_path(&self, operation_id: &str, kind: ReportKind) -> PathBuf {
    +        let dir = match kind {
    +            ReportKind::Error => "errors",
    +            ReportKind::ConditionCheckFailure => "condition-check-failures",
    +        };
    +        self.store
    +            .0
    +            .join(ACCOUNT)
    +            .join(OUT_BUCKET)
    +            .join(OUT_PREFIX)
    +            .join("AWSDynamoDB")
    +            .join("BulkOperation")
    +            .join(format!("{ACCOUNT}:{operation_id}-0"))
    +            .join("output")
    +            .join(dir)
    +            .join("output-00000.json.gz")
    +    }
    +
    +    /// The sorted names of the rendered report objects of one kind
    +    /// (`output-00000.json.gz` onward); empty when none was rendered.
    +    pub(crate) fn report_objects(&self, operation_id: &str, kind: ReportKind) -> Vec {
    +        let dir = self.report_path(operation_id, kind);
    +        let Some(dir) = dir.parent() else {
    +            return Vec::new();
    +        };
    +        let Ok(entries) = std::fs::read_dir(dir) else {
    +            return Vec::new();
    +        };
    +        let mut names: Vec = entries
    +            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
    +            .filter(|name| {
    +                name.strip_prefix("output-")
    +                    .and_then(|rest| rest.strip_suffix(".json.gz"))
    +                    .is_some_and(|digits| {
    +                        digits.len() == 5 && digits.bytes().all(|b| b.is_ascii_digit())
    +                    })
    +            })
    +            .collect();
    +        names.sort();
    +        names
    +    }
    +
    +    /// Read one rendered report, every object in name order concatenated;
    +    /// `None` when it was not rendered.
    +    pub(crate) fn read_report(&self, operation_id: &str, kind: ReportKind) -> Option> {
    +        let dir = self.report_path(operation_id, kind).parent()?.to_path_buf();
    +        let names = self.report_objects(operation_id, kind);
    +        if names.is_empty() {
    +            return None;
    +        }
    +        let mut records = Vec::new();
    +        for name in names {
    +            let bytes = std::fs::read(dir.join(name)).ok()?;
    +            records.extend(gunzip_lines(&bytes));
    +        }
    +        Some(records)
    +    }
    +
    +    /// The manifest's path: the operation directory, no shard suffix.
    +    pub(crate) fn manifest_path(&self, operation_id: &str) -> PathBuf {
    +        self.store
    +            .0
    +            .join(ACCOUNT)
    +            .join(OUT_BUCKET)
    +            .join(OUT_PREFIX)
    +            .join("AWSDynamoDB")
    +            .join("BulkOperation")
    +            .join(format!("{ACCOUNT}:{operation_id}"))
    +            .join("manifest-files.json")
    +    }
    +
    +    /// Read the manifest's lines; `None` when none was written.
    +    pub(crate) fn read_manifest(&self, operation_id: &str) -> Option> {
    +        let text = std::fs::read_to_string(self.manifest_path(operation_id)).ok()?;
    +        Some(
    +            text.lines()
    +                .filter(|l| !l.trim().is_empty())
    +                .map(|l| serde_json::from_str(l).expect("manifest line is JSON"))
    +                .collect(),
    +        )
    +    }
    +
    +    /// The lowercase hex MD5 of one staged input file, what the manifest
    +    /// reports as its etag.
    +    pub(crate) fn input_md5(&self, rel: &str) -> String {
    +        use md5::Digest;
    +        let bytes = std::fs::read(self.store.0.join(ACCOUNT).join(IN_BUCKET).join(rel)).unwrap();
    +        format!("{:x}", md5::Md5::digest(bytes))
    +    }
    +}
    +
    +pub(crate) fn config() -> RunnerConfig {
    +    RunnerConfig {
    +        batch_size: 25,
    +        write_batch_rows: 25,
    +        max_line_bytes: 4 << 20,
    +        max_item_count: 1_000_000,
    +        max_report_records: 10_000,
    +        max_report_bytes: 64 << 20,
    +        lease: Duration::from_secs(60),
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/harness/postgres.rs b/crates/engine/src/bulk_put/tests/runner/harness/postgres.rs
    new file mode 100644
    index 000000000..20cfc9361
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/harness/postgres.rs
    @@ -0,0 +1,205 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The harness on a throwaway PostgreSQL database, for the runner cases
    +//! that must hold on both backends. Each harness builds its own database
    +//! from the shipped catalog and data migrations and drops it in
    +//! [`Harness::cleanup`], which a case calls once it has passed, so a
    +//! failing case leaves its database, named `eddb_run_*`, for inspection.
    +//!
    +//! Requires `EXTENDDB_TEST_PG_CONNECTION_STRING`, a base URL with no
    +//! database component, pointing at a server whose role may create and drop
    +//! databases. Without it the PostgreSQL cases report a skip and pass,
    +//! unless `EXTENDDB_TEST_PG_REQUIRED` is set.
    +
    +use super::*;
    +
    +use sqlx::PgPool;
    +use sqlx::postgres::PgPoolOptions;
    +
    +/// The database a PostgreSQL harness runs on.
    +pub(crate) struct PgScratch {
    +    admin: PgPool,
    +    catalog: PgPool,
    +    db_name: String,
    +}
    +
    +/// Which backend a case that runs on both builds its harnesses on.
    +#[derive(Clone, Copy, Debug)]
    +pub(crate) enum Backend {
    +    Sqlite,
    +    Postgres,
    +}
    +
    +fn base_conn() -> Option {
    +    let conn = std::env::var("EXTENDDB_TEST_PG_CONNECTION_STRING").ok()?;
    +    (!conn.trim().is_empty()).then(|| conn.trim_end_matches('/').to_owned())
    +}
    +
    +/// Whether the PostgreSQL leg of `test` can run; reports a skip otherwise.
    +pub(crate) fn postgres_configured(test: &str) -> bool {
    +    if base_conn().is_some() {
    +        return true;
    +    }
    +    assert!(
    +        std::env::var("EXTENDDB_TEST_PG_REQUIRED").map_or(true, |v| v.is_empty()),
    +        "{test}: EXTENDDB_TEST_PG_REQUIRED is set but EXTENDDB_TEST_PG_CONNECTION_STRING is not"
    +    );
    +    eprintln!("SKIP {test}: EXTENDDB_TEST_PG_CONNECTION_STRING is not set");
    +    false
    +}
    +
    +/// One PostgreSQL case at a time across the test binary: each harness holds
    +/// an engine pool of ten connections, and the server is shared with every
    +/// other suite that runs against it.
    +pub(crate) async fn postgres_permit() -> tokio::sync::OwnedSemaphorePermit {
    +    static PERMITS: std::sync::OnceLock> = std::sync::OnceLock::new();
    +    Arc::clone(PERMITS.get_or_init(|| Arc::new(tokio::sync::Semaphore::new(1))))
    +        .acquire_owned()
    +        .await
    +        .expect("the permit semaphore is never closed")
    +}
    +
    +/// The shipped migration files of one directory of the PostgreSQL crate,
    +/// in name order.
    +fn migration_files(subdir: &str) -> Vec {
    +    let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
    +        .join("../storage-postgres")
    +        .join(subdir);
    +    let mut files: Vec<_> = std::fs::read_dir(&dir)
    +        .unwrap_or_else(|e| panic!("list {}: {e}", dir.display()))
    +        .map(|entry| entry.expect("read a directory entry").path())
    +        .filter(|p| p.extension().is_some_and(|ext| ext == "sql"))
    +        .collect();
    +    files.sort();
    +    files
    +}
    +
    +impl Harness {
    +    /// A harness on `backend` with the plain table.
    +    pub(crate) async fn on(backend: Backend) -> Self {
    +        match backend {
    +            Backend::Sqlite => Self::new().await,
    +            Backend::Postgres => Self::new_postgres().await,
    +        }
    +    }
    +
    +    /// A harness on a fresh PostgreSQL database with the plain table.
    +    pub(crate) async fn new_postgres() -> Self {
    +        let base = base_conn().expect("postgres_configured was checked");
    +        let db_name = format!("eddb_run_{}", uuid::Uuid::new_v4().simple())[..24].to_owned();
    +        let admin = PgPoolOptions::new()
    +            .max_connections(1)
    +            .connect(&format!("{base}/postgres"))
    +            .await
    +            .expect("connect to the postgres maintenance database");
    +        sqlx::query(&format!("CREATE DATABASE \"{db_name}\""))
    +            .execute(&admin)
    +            .await
    +            .expect("create the scratch database");
    +        let url = format!("{base}/{db_name}");
    +        let catalog = PgPoolOptions::new()
    +            .max_connections(2)
    +            .connect(&url)
    +            .await
    +            .expect("connect to the scratch database");
    +        for file in migration_files("migrations")
    +            .into_iter()
    +            .chain(migration_files("data_migrations"))
    +        {
    +            let sql = std::fs::read_to_string(&file)
    +                .unwrap_or_else(|e| panic!("read {}: {e}", file.display()));
    +            sqlx::raw_sql(&sql)
    +                .execute(&catalog)
    +                .await
    +                .unwrap_or_else(|e| panic!("apply {}: {e}", file.display()));
    +        }
    +        // The shipped migrations create neither row, so each is inserted.
    +        for key in ["control_plane_delay_seconds", "index_propagation_delay_ms"] {
    +            sqlx::query(
    +                "INSERT INTO settings (key, value) VALUES ($1, '0') \
    +                 ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value",
    +            )
    +            .bind(key)
    +            .execute(&catalog)
    +            .await
    +            .expect("pin a delay setting to zero");
    +        }
    +        sqlx::query("INSERT INTO accounts (account_id, account_name) VALUES ($1, $2)")
    +            .bind(ACCOUNT)
    +            .bind(format!("runner-tests-{db_name}"))
    +            .execute(&catalog)
    +            .await
    +            .expect("seed the account");
    +        let engine = extenddb_storage_postgres::PostgresEngine::new(
    +            &extenddb_storage_postgres::PostgresConfig {
    +                connection_string: url,
    +                pool_size: 10,
    +                max_item_size_bytes: 400_000,
    +            },
    +            REGION,
    +        )
    +        .await
    +        .expect("open a PostgresEngine on the scratch database");
    +        let storage: Arc = Arc::new(engine);
    +        let table_id = Self::create_table(&storage, TABLE).await;
    +        let catalog_store: Arc = Arc::new(
    +            extenddb_storage_postgres::PostgresCatalogStore::new(catalog.clone()),
    +        );
    +        Self {
    +            storage,
    +            store: store_with_buckets(),
    +            table_id,
    +            table_arn: format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/{TABLE}"),
    +            catalog: catalog_store,
    +            pg: Some(PgScratch {
    +                admin,
    +                catalog,
    +                db_name,
    +            }),
    +            _db_dir: None,
    +        }
    +    }
    +
    +    /// Drop a PostgreSQL harness's database; nothing for `SQLite`.
    +    pub(crate) async fn cleanup(self) {
    +        let Some(PgScratch {
    +            admin,
    +            catalog,
    +            db_name,
    +        }) = self.pg
    +        else {
    +            return;
    +        };
    +        drop(self.storage);
    +        catalog.close().await;
    +        sqlx::query(&format!(
    +            "DROP DATABASE IF EXISTS \"{db_name}\" WITH (FORCE)"
    +        ))
    +        .execute(&admin)
    +        .await
    +        .expect("drop the scratch database");
    +        admin.close().await;
    +    }
    +}
    +
    +/// Run one case on `SQLite` and, when a server is configured, on
    +/// PostgreSQL, as two tests.
    +macro_rules! on_both_backends {
    +    ($case:ident, $sqlite:ident, $postgres:ident) => {
    +        #[tokio::test]
    +        async fn $sqlite() {
    +            $case(Backend::Sqlite).await;
    +        }
    +
    +        #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +        async fn $postgres() {
    +            if !postgres_configured(stringify!($postgres)) {
    +                return;
    +            }
    +            let _permit = postgres_permit().await;
    +            $case(Backend::Postgres).await;
    +        }
    +    };
    +}
    +pub(crate) use on_both_backends;
    diff --git a/crates/engine/src/bulk_put/tests/runner/harness/tables.rs b/crates/engine/src/bulk_put/tests/runner/harness/tables.rs
    new file mode 100644
    index 000000000..6bf9a187d
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/harness/tables.rs
    @@ -0,0 +1,280 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +impl Harness {
    +    pub(crate) async fn new() -> Self {
    +        Self::new_inner(TableShape::Plain).await
    +    }
    +
    +    /// A harness whose table carries one global secondary index.
    +    pub(crate) async fn new_with_gsi() -> Self {
    +        Self::new_inner(TableShape::WithGsi).await
    +    }
    +
    +    /// A harness whose table has a single hash key `pk` and one
    +    /// ALL-projection global secondary index on `gsk`, the shape the
    +    /// overwrite index-usage measurements used: an item
    +    /// `{pk, gsk, v}` with a 1,500-byte `v` is 1,508 bytes.
    +    pub(crate) async fn new_with_gsi_hash_key() -> Self {
    +        Self::new_inner(TableShape::WithGsiHashKey).await
    +    }
    +
    +    /// Like `new_with_gsi_hash_key` but the GSI projects KEYS_ONLY, so a
    +    /// change to a non-projected attribute leaves the projected entry
    +    /// unchanged under the same index key.
    +    pub(crate) async fn new_with_gsi_keys_only_hash_key() -> Self {
    +        Self::new_inner(TableShape::WithGsiKeysOnlyHashKey).await
    +    }
    +
    +    /// A harness whose table streams `NEW_AND_OLD_IMAGES`.
    +    pub(crate) async fn new_with_stream() -> Self {
    +        Self::new_inner(TableShape::WithStream).await
    +    }
    +
    +    /// A harness whose table carries one vector index over `emb`.
    +    pub(crate) async fn new_with_vector() -> Self {
    +        Self::new_inner(TableShape::WithVector).await
    +    }
    +
    +    async fn new_inner(shape: TableShape) -> Self {
    +        // A database file with a pool of several connections, as a server
    +        // runs. An in-memory database pins the pool to one connection, and
    +        // a test that stops polling a run, or drops one, while a statement
    +        // of that run holds the connection leaves the next claim waiting
    +        // for it until the pool's acquire timeout.
    +        let db_dir = Root::new();
    +        let config = extenddb_storage_sqlite::SqliteConfig {
    +            path: db_dir
    +                .0
    +                .join("runner.sqlite")
    +                .to_string_lossy()
    +                .into_owned(),
    +            pool_size: 4,
    +        };
    +        let mut options = ServerComponentsOptions::default();
    +        options.bootstrap_if_uninitialized = true;
    +        let components =
    +            (extenddb_storage_sqlite::backend().server_components)(&config, REGION, options)
    +                .await
    +                .expect("server components");
    +        // Tables must reach ACTIVE synchronously: no control-plane worker
    +        // runs in these tests to mature a CREATING row.
    +        components
    +            .catalog_store
    +            .set_setting("control_plane_delay_seconds", "0")
    +            .await
    +            .expect("set control plane delay");
    +        components
    +            .catalog_store
    +            .create_account(ACCOUNT, "runner-tests")
    +            .await
    +            .expect("create account");
    +        let catalog = Arc::clone(&components.catalog_store);
    +        let storage = components.engine;
    +        let table_id = match shape {
    +            TableShape::Plain => Self::create_table(&storage, TABLE).await,
    +            TableShape::WithGsi => Self::create_table_with_gsi(&storage, TABLE).await,
    +            TableShape::WithGsiHashKey => {
    +                Self::create_table_with_gsi_hash_key(&storage, TABLE).await
    +            }
    +            TableShape::WithGsiKeysOnlyHashKey => {
    +                Self::create_table_with_gsi_keys_only_hash_key(&storage, TABLE).await
    +            }
    +            TableShape::WithStream => Self::create_table_with_stream(&storage, TABLE).await,
    +            TableShape::WithVector => Self::create_table_with_vector(&storage, TABLE).await,
    +        };
    +        Self {
    +            storage,
    +            store: store_with_buckets(),
    +            table_id,
    +            table_arn: format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/{TABLE}"),
    +            catalog,
    +            pg: None,
    +            _db_dir: Some(db_dir),
    +        }
    +    }
    +
    +    pub(crate) async fn create_table(storage: &Arc, name: &str) -> String {
    +        let input: CreateTableInput = serde_json::from_value(json!({
    +            "TableName": name,
    +            "KeySchema": [
    +                {"AttributeName": "pk", "KeyType": "HASH"},
    +                {"AttributeName": "sk", "KeyType": "RANGE"}
    +            ],
    +            "AttributeDefinitions": [
    +                {"AttributeName": "pk", "AttributeType": "S"},
    +                {"AttributeName": "sk", "AttributeType": "S"}
    +            ],
    +            "BillingMode": "PAY_PER_REQUEST"
    +        }))
    +        .expect("create table input");
    +        let description = storage
    +            .create_table(ACCOUNT, input)
    +            .await
    +            .expect("create table");
    +        description.table_id
    +    }
    +
    +    /// A table with one global secondary index keyed on `g`, so a write
    +    /// charges the index by the old-versus-new delta. Matures to ACTIVE
    +    /// synchronously like `create_table`, since no control-plane worker
    +    /// runs in these tests.
    +    pub(crate) async fn create_table_with_gsi(
    +        storage: &Arc,
    +        name: &str,
    +    ) -> String {
    +        let input: CreateTableInput = serde_json::from_value(json!({
    +            "TableName": name,
    +            "KeySchema": [
    +                {"AttributeName": "pk", "KeyType": "HASH"},
    +                {"AttributeName": "sk", "KeyType": "RANGE"}
    +            ],
    +            "AttributeDefinitions": [
    +                {"AttributeName": "pk", "AttributeType": "S"},
    +                {"AttributeName": "sk", "AttributeType": "S"},
    +                {"AttributeName": "g", "AttributeType": "S"}
    +            ],
    +            "GlobalSecondaryIndexes": [{
    +                "IndexName": "gsi1",
    +                "KeySchema": [{"AttributeName": "g", "KeyType": "HASH"}],
    +                "Projection": {"ProjectionType": "ALL"}
    +            }],
    +            "BillingMode": "PAY_PER_REQUEST"
    +        }))
    +        .expect("create table input");
    +        let description = storage
    +            .create_table(ACCOUNT, input)
    +            .await
    +            .expect("create table with gsi");
    +        description.table_id
    +    }
    +
    +    /// A single-hash-key table (`pk`) with one ALL-projection GSI on
    +    /// `gsk`, the shape the overwrite index-usage measurements used. No sort key,
    +    /// so an item `{pk, gsk, v}` carries only the two keys and the payload:
    +    /// `{pk: "a", gsk: "g", v: <1,500 bytes>}` is 1,508 bytes and with a
    +    /// 2,000-byte `v` is 2,008.
    +    pub(crate) async fn create_table_with_gsi_hash_key(
    +        storage: &Arc,
    +        name: &str,
    +    ) -> String {
    +        let input: CreateTableInput = serde_json::from_value(json!({
    +            "TableName": name,
    +            "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
    +            "AttributeDefinitions": [
    +                {"AttributeName": "pk", "AttributeType": "S"},
    +                {"AttributeName": "gsk", "AttributeType": "S"}
    +            ],
    +            "GlobalSecondaryIndexes": [{
    +                "IndexName": "gsi1",
    +                "KeySchema": [{"AttributeName": "gsk", "KeyType": "HASH"}],
    +                "Projection": {"ProjectionType": "ALL"}
    +            }],
    +            "BillingMode": "PAY_PER_REQUEST"
    +        }))
    +        .expect("create table input");
    +        let description = storage
    +            .create_table(ACCOUNT, input)
    +            .await
    +            .expect("create table with gsi hash key");
    +        description.table_id
    +    }
    +
    +    /// Like `create_table_with_gsi_hash_key` but the GSI projects
    +    /// KEYS_ONLY, so only `pk` and `gsk` are in the index entry and a
    +    /// change to `v` leaves the projection unchanged.
    +    pub(crate) async fn create_table_with_gsi_keys_only_hash_key(
    +        storage: &Arc,
    +        name: &str,
    +    ) -> String {
    +        let input: CreateTableInput = serde_json::from_value(json!({
    +            "TableName": name,
    +            "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
    +            "AttributeDefinitions": [
    +                {"AttributeName": "pk", "AttributeType": "S"},
    +                {"AttributeName": "gsk", "AttributeType": "S"}
    +            ],
    +            "GlobalSecondaryIndexes": [{
    +                "IndexName": "gsi1",
    +                "KeySchema": [{"AttributeName": "gsk", "KeyType": "HASH"}],
    +                "Projection": {"ProjectionType": "KEYS_ONLY"}
    +            }],
    +            "BillingMode": "PAY_PER_REQUEST"
    +        }))
    +        .expect("create table input");
    +        let description = storage
    +            .create_table(ACCOUNT, input)
    +            .await
    +            .expect("create table with keys-only gsi hash key");
    +        description.table_id
    +    }
    +
    +    /// A table with one vector index over `emb` (four dimensions), so a
    +    /// write that carries the attribute charges the index in
    +    /// `BulkPutVectorBytes`. The inline create path leaves the vector index
    +    /// ACTIVE, and the table matures to ACTIVE synchronously like
    +    /// `create_table`, since no control-plane worker runs in these tests.
    +    pub(crate) async fn create_table_with_vector(
    +        storage: &Arc,
    +        name: &str,
    +    ) -> String {
    +        let input: CreateTableInput = serde_json::from_value(json!({
    +            "TableName": name,
    +            "KeySchema": [
    +                {"AttributeName": "pk", "KeyType": "HASH"},
    +                {"AttributeName": "sk", "KeyType": "RANGE"}
    +            ],
    +            "AttributeDefinitions": [
    +                {"AttributeName": "pk", "AttributeType": "S"},
    +                {"AttributeName": "sk", "AttributeType": "S"}
    +            ],
    +            "VectorIndexes": [{
    +                "IndexName": "vidx",
    +                "Dimensions": 4,
    +                "DistanceFunction": "COSINE",
    +                "VectorAttribute": {"AttributeName": "emb"},
    +                "Projection": {"ProjectionType": "ALL"}
    +            }],
    +            "BillingMode": "PAY_PER_REQUEST"
    +        }))
    +        .expect("create table input");
    +        let description = storage
    +            .create_table(ACCOUNT, input)
    +            .await
    +            .expect("create table with vector index");
    +        description.table_id
    +    }
    +
    +    /// A table streaming `NEW_AND_OLD_IMAGES`, so a bulk write's stream
    +    /// capture is observable. Matures to ACTIVE synchronously like
    +    /// `create_table`, since no control-plane worker runs in these tests.
    +    pub(crate) async fn create_table_with_stream(
    +        storage: &Arc,
    +        name: &str,
    +    ) -> String {
    +        let input: CreateTableInput = serde_json::from_value(json!({
    +            "TableName": name,
    +            "KeySchema": [
    +                {"AttributeName": "pk", "KeyType": "HASH"},
    +                {"AttributeName": "sk", "KeyType": "RANGE"}
    +            ],
    +            "AttributeDefinitions": [
    +                {"AttributeName": "pk", "AttributeType": "S"},
    +                {"AttributeName": "sk", "AttributeType": "S"}
    +            ],
    +            "StreamSpecification": {
    +                "StreamEnabled": true,
    +                "StreamViewType": "NEW_AND_OLD_IMAGES"
    +            },
    +            "BillingMode": "PAY_PER_REQUEST"
    +        }))
    +        .expect("create table input");
    +        let description = storage
    +            .create_table(ACCOUNT, input)
    +            .await
    +            .expect("create table with stream");
    +        description.table_id
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/held_location.rs b/crates/engine/src/bulk_put/tests/runner/held_location.rs
    new file mode 100644
    index 000000000..f3e6ec593
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/held_location.rs
    @@ -0,0 +1,270 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// The output location fault found at the first report record is held while
    +// the run reads on to the end of the input (measured on the service for a
    +// report write the output bucket refuses, a missing output bucket and an
    +// output owner that is not the caller). The operation then ends FAILED with
    +// the fault, ProcessedItemCount the rows written, FailedItemCount 0,
    +// ProcessedInputBytes 0, zero usage, and no report and no manifest. A cancel
    +// or another fault met before the end still decides the ending.
    +
    +fn assert_zero_usage(record: &BulkOperationRecord) {
    +    assert_eq!(record.usage["BulkPutBytes"], json!(0), "{}", record.usage);
    +    assert_eq!(
    +        record.usage["Table"]["BulkPutBytes"],
    +        json!(0),
    +        "{}",
    +        record.usage
    +    );
    +}
    +
    +fn cancel_at(h: &Harness, op: &str, at: u64) -> Arc {
    +    let storage = Arc::clone(&h.storage);
    +    let op = op.to_owned();
    +    let action: LineAction = Arc::new(move || {
    +        let storage = Arc::clone(&storage);
    +        let op = op.clone();
    +        Box::pin(async move {
    +            storage
    +                .request_cancel(ACCOUNT, &op)
    +                .await
    +                .expect("request cancel");
    +        })
    +    });
    +    Arc::new(ActAtSource {
    +        inner: h.fs_source(),
    +        at,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        fired: Arc::new(AtomicBool::new(false)),
    +        action,
    +    })
    +}
    +
    +#[tokio::test]
    +async fn every_good_row_before_and_after_the_faulting_row_is_written() {
    +    // Five good rows, the faulting row, four good rows.
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(10);
    +    rows[5] = row_missing_sk(5);
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let total = files[0].size;
    +    let mut cfg = config();
    +    cfg.batch_size = 3;
    +    let record = h
    +        .run_to_end(
    +            files,
    +            input_config(false),
    +            output_config(Some("absent-reports"), &["ERROR"]),
    +            cfg,
    +        )
    +        .await;
    +
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3NoSuchBucket"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("The specified bucket does not exist")
    +    );
    +    assert_eq!(record.processed_item_count, 9);
    +    assert_eq!(record.failed_item_count, 0);
    +    assert_eq!(record.processed_input_bytes, 0);
    +    assert_eq!(record.total_input_bytes, Some(total));
    +    assert_zero_usage(&record);
    +    assert_eq!(record.report_record_count, 0, "no record is kept");
    +    assert_eq!(h.count_items().await, 9);
    +    assert!(h.get("k4").await.is_some());
    +    assert!(h.get("k9").await.is_some(), "the last row is written");
    +    assert!(
    +        !h.store.0.join(ACCOUNT).join("absent-reports").exists(),
    +        "nothing renders"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn later_files_are_read_and_later_failing_rows_keep_no_record() {
    +    let h = Harness::new().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[row(0), row_missing_sk(1), row(2)]),
    +    );
    +    h.write_input(
    +        "part-001.json",
    +        &file_of(&[row(10), row_missing_sk(11), row(12), row(13)]),
    +    );
    +    let files = h.enumerate().await;
    +    let mut output = both_reports();
    +    output["S3BucketOwner"] = json!("999999999999");
    +    let mut cfg = config();
    +    cfg.batch_size = 2;
    +    let record = h.run_to_end(files, input_config(false), output, cfg).await;
    +
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3AccessDenied"));
    +    assert_eq!(record.failure_message.as_deref(), Some("Access Denied"));
    +    assert_eq!(record.processed_item_count, 5, "the five rows written");
    +    assert_eq!(record.failed_item_count, 0);
    +    assert_eq!(record.processed_input_bytes, 0);
    +    assert_zero_usage(&record);
    +    assert_eq!(record.report_record_count, 0);
    +    assert_eq!(record.cursor_file_index, 2, "both files were read");
    +    assert_eq!(h.count_items().await, 5);
    +    assert!(h.get("k13").await.is_some());
    +    assert!(
    +        h.read_report(&record.operation_id, ReportKind::Error)
    +            .is_none()
    +    );
    +    assert!(h.read_manifest(&record.operation_id).is_none());
    +}
    +
    +#[tokio::test]
    +async fn a_cancel_after_the_fault_is_held_ends_cancelled() {
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(15);
    +    rows[1] = row_missing_sk(1);
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h
    +        .start(
    +            files,
    +            input_config(false),
    +            output_config(Some("absent-reports"), &["ERROR"]),
    +        )
    +        .await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    let deps = h.deps_with(
    +        "runner-1",
    +        cfg,
    +        cancel_at(&h, &op, 7),
    +        Arc::new(h.fs_sink()),
    +    );
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "CANCELLED");
    +    assert_eq!(record.failure_code.as_deref(), Some("RequestCancelled"));
    +    assert_eq!(
    +        record.processed_item_count, 10,
    +        "the counters a cancel reports, the faulting row included"
    +    );
    +    assert_eq!(record.failed_item_count, 1);
    +    assert!(
    +        record.processed_input_bytes > 0,
    +        "the bytes read before the stop"
    +    );
    +    assert!(
    +        !h.store.0.join(ACCOUNT).join("absent-reports").exists(),
    +        "nothing renders into the location the check refused"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn another_fault_after_the_fault_is_held_decides_the_ending() {
    +    // The second file is gone when the runner reaches it: that fault ends
    +    // the operation, with the counters as they stand.
    +    let h = Harness::new().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[row(0), row_missing_sk(1), row(2)]),
    +    );
    +    h.write_input("part-001.json", &file_of(&plain_rows(2)));
    +    let files = h.enumerate().await;
    +    let op = h
    +        .start(
    +            files,
    +            input_config(false),
    +            output_config(Some("absent-reports"), &["ERROR"]),
    +        )
    +        .await;
    +    std::fs::remove_file(
    +        h.store
    +            .0
    +            .join(ACCOUNT)
    +            .join(IN_BUCKET)
    +            .join("part-001.json"),
    +    )
    +    .unwrap();
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-1", config()), record)
    +        .await
    +        .expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3NoSuchKey"));
    +    assert_eq!(record.processed_item_count, 3);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert!(h.read_manifest(&op).is_none());
    +    assert!(!h.store.0.join(ACCOUNT).join("absent-reports").exists());
    +}
    +
    +#[tokio::test]
    +async fn a_claim_that_resumes_after_a_crash_holds_the_fault_again() {
    +    // The first claim holds the fault at row 1, meets another failing row at
    +    // row 3, checkpoints at rows 5 and 10 and dies. No record was kept, so
    +    // the resumed claim checks the output location again at its own first
    +    // record (row 15) and holds the fault again; the ending is the one an
    +    // uninterrupted run reaches.
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(20);
    +    rows[1] = row_missing_sk(1);
    +    rows[3] = row_missing_sk(3);
    +    rows[15] = row_missing_sk(15);
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h
    +        .start(
    +            files,
    +            input_config(false),
    +            output_config(Some("absent-reports"), &["ERROR"]),
    +        )
    +        .await;
    +
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 12,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.cursor_row_index, 10);
    +    assert_eq!(mid.processed_item_count, 10);
    +    assert_eq!(mid.failed_item_count, 2);
    +    assert_eq!(mid.report_record_count, 0, "the held fault keeps no record");
    +
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    runner::run(&h.deps("runner-2", cfg), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3NoSuchBucket"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("The specified bucket does not exist")
    +    );
    +    assert_eq!(record.processed_item_count, 17);
    +    assert_eq!(record.failed_item_count, 0);
    +    assert_eq!(record.processed_input_bytes, 0);
    +    assert_zero_usage(&record);
    +    assert_eq!(h.count_items().await, 17);
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/held_resume.rs b/crates/engine/src/bulk_put/tests/runner/held_resume.rs
    new file mode 100644
    index 000000000..58a77e778
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/held_resume.rs
    @@ -0,0 +1,284 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +use crate::bulk_put::authorize::S3Action;
    +
    +// A held output location fault is stored with the checkpoints. A claim that
    +// resumes after a crash, with no failing row left to read, ends the
    +// operation as the run without the crash does: status, FailureCode,
    +// FailureMessage, the counters, usage, and no report and no manifest.
    +
    +/// Twenty rows; only row 1 fails, so every failing row is behind the crash.
    +fn rows_failing_at_one() -> Vec {
    +    let mut rows = plain_rows(20);
    +    rows[1] = row_missing_sk(1);
    +    rows
    +}
    +
    +fn runner_cfg(lease: Duration) -> RunnerConfig {
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    cfg.lease = lease;
    +    cfg
    +}
    +
    +/// What a client and the output bucket see of a finished operation, the
    +/// operation id in the message replaced by `{op}`.
    +#[derive(Debug, PartialEq)]
    +struct Outcome {
    +    status: String,
    +    code: Option,
    +    message: Option,
    +    processed: i64,
    +    failed: i64,
    +    bytes: i64,
    +    usage: Value,
    +    items: usize,
    +    report: bool,
    +    manifest: bool,
    +}
    +
    +async fn outcome(h: &Harness, op: &str) -> Outcome {
    +    let record = h.record(op).await;
    +    Outcome {
    +        status: record.status,
    +        code: record.failure_code,
    +        message: record
    +            .failure_message
    +            .map(|message| message.replace(op, "{op}")),
    +        processed: record.processed_item_count,
    +        failed: record.failed_item_count,
    +        bytes: record.processed_input_bytes,
    +        usage: record.usage,
    +        items: h.count_items().await,
    +        report: h.read_report(op, ReportKind::Error).is_some(),
    +        manifest: h.read_manifest(op).is_some(),
    +    }
    +}
    +
    +/// Start the operation over the twenty rows with `output`, recording the
    +/// test caller.
    +async fn start_rows(h: &Harness, output: Value) -> String {
    +    h.write_input("part-000.json", &file_of(&rows_failing_at_one()));
    +    let files = h.enumerate().await;
    +    h.start_with_run_context(
    +        files,
    +        input_config(false),
    +        output,
    +        Some(run_context_with_caller()),
    +    )
    +    .await
    +}
    +
    +/// Deps for `owner` with `source`, the filesystem sink, `authorizer` and
    +/// `storage`.
    +fn deps_for(
    +    h: &Harness,
    +    owner: &str,
    +    cfg: RunnerConfig,
    +    source: Arc,
    +    authorizer: &Option>,
    +    storage: Arc,
    +) -> RunnerDeps {
    +    let mut deps = h.deps_with(owner, cfg, source, Arc::new(h.fs_sink()));
    +    deps.storage = storage;
    +    deps.authorizer = authorizer
    +        .as_ref()
    +        .map(|fake| Arc::clone(fake) as Arc);
    +    deps
    +}
    +
    +/// The run without a crash.
    +async fn uninterrupted(output: Value, authorizer: Option>) -> Outcome {
    +    let h = Harness::new().await;
    +    let op = start_rows(&h, output).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let deps = deps_for(
    +        &h,
    +        "runner-1",
    +        runner_cfg(Duration::from_secs(60)),
    +        Arc::new(h.fs_source()),
    +        &authorizer,
    +        Arc::clone(&h.storage),
    +    );
    +    runner::run(&deps, record).await.expect("runner run");
    +    outcome(&h, &op).await
    +}
    +
    +/// The same run, stopped after the checkpoint at row 10 and resumed by a
    +/// second claim from the record.
    +async fn crashed_mid_input(output: Value, authorizer: Option>) -> Outcome {
    +    let h = Harness::new().await;
    +    let op = start_rows(&h, output).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 12,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let deps = deps_for(
    +        &h,
    +        "runner-1",
    +        runner_cfg(Duration::from_millis(100)),
    +        source,
    +        &authorizer,
    +        Arc::clone(&h.storage),
    +    );
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +    drop(deps);
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.cursor_row_index, 10, "the crash follows a checkpoint");
    +    assert_eq!(mid.status, "IN_PROGRESS");
    +
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let deps = deps_for(
    +        &h,
    +        "runner-2",
    +        runner_cfg(Duration::from_secs(60)),
    +        Arc::new(h.fs_source()),
    +        &authorizer,
    +        Arc::clone(&h.storage),
    +    );
    +    runner::run(&deps, record).await.expect("resumed run");
    +    outcome(&h, &op).await
    +}
    +
    +/// The same run, stopped after the final checkpoint and before the terminal
    +/// transition, then resumed.
    +async fn crashed_before_finish(output: Value, authorizer: Option>) -> Outcome {
    +    let h = Harness::new().await;
    +    let op = start_rows(&h, output).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let mut faulting = FaultingStorage::new(&h.storage);
    +    faulting.pend_finish = Some(Arc::clone(¬ify));
    +    let deps = deps_for(
    +        &h,
    +        "runner-1",
    +        runner_cfg(Duration::from_millis(100)),
    +        Arc::new(h.fs_source()),
    +        &authorizer,
    +        Arc::new(faulting),
    +    );
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while finish pends"),
    +        () = notify.notified() => {}
    +    }
    +    drop(deps);
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    assert_eq!(h.record(&op).await.status, "IN_PROGRESS");
    +
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let deps = deps_for(
    +        &h,
    +        "runner-2",
    +        runner_cfg(Duration::from_secs(60)),
    +        Arc::new(h.fs_source()),
    +        &authorizer,
    +        Arc::clone(&h.storage),
    +    );
    +    runner::run(&deps, record).await.expect("resumed run");
    +    outcome(&h, &op).await
    +}
    +
    +fn assert_held_shape(outcome: &Outcome, code: &str) {
    +    assert_eq!(outcome.status, "FAILED");
    +    assert_eq!(outcome.code.as_deref(), Some(code));
    +    assert_eq!(outcome.processed, 19);
    +    assert_eq!(outcome.failed, 0);
    +    assert_eq!(outcome.bytes, 0);
    +    assert_eq!(outcome.usage["BulkPutBytes"], json!(0), "{}", outcome.usage);
    +    assert!(
    +        outcome.usage.get(held_member()).is_none(),
    +        "the terminal usage carries no held fault: {}",
    +        outcome.usage
    +    );
    +    assert_eq!(outcome.items, 19);
    +    assert!(!outcome.report && !outcome.manifest, "nothing is written");
    +}
    +
    +fn held_member() -> &'static str {
    +    "HeldOutputLocationFault"
    +}
    +
    +#[tokio::test]
    +async fn a_missing_output_bucket_held_before_a_crash_ends_as_without_the_crash() {
    +    let output = || output_config(Some("absent-reports"), &["ERROR"]);
    +    let expected = uninterrupted(output(), None).await;
    +    assert_held_shape(&expected, "S3NoSuchBucket");
    +    assert_eq!(
    +        expected.message.as_deref(),
    +        Some("The specified bucket does not exist")
    +    );
    +    assert_eq!(crashed_mid_input(output(), None).await, expected);
    +    assert_eq!(crashed_before_finish(output(), None).await, expected);
    +}
    +
    +#[tokio::test]
    +async fn a_denied_report_write_held_before_a_crash_ends_as_without_the_crash() {
    +    let output = || output_config(Some(OUT_BUCKET), &["ERROR"]);
    +    let deny = || {
    +        Some(Arc::new(FakeAuthorizer::denying(
    +            S3Action::PutObject,
    +            "/output/errors/output-00000.json.gz",
    +        )))
    +    };
    +    let expected = uninterrupted(output(), deny()).await;
    +    assert_held_shape(&expected, "S3AccessDenied");
    +    assert!(
    +        expected
    +            .message
    +            .as_deref()
    +            .is_some_and(|message| message.contains("s3:PutObject")),
    +        "{:?}",
    +        expected.message
    +    );
    +    assert_eq!(crashed_mid_input(output(), deny()).await, expected);
    +    assert_eq!(crashed_before_finish(output(), deny()).await, expected);
    +}
    +
    +#[tokio::test]
    +async fn the_checkpoints_store_the_held_fault_beside_the_usage() {
    +    let h = Harness::new().await;
    +    let op = start_rows(&h, output_config(Some("absent-reports"), &["ERROR"])).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 12,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let deps = h.deps_with(
    +        "runner-1",
    +        runner_cfg(Duration::from_millis(100)),
    +        source,
    +        Arc::new(h.fs_sink()),
    +    );
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +    let mid = h.record(&op).await;
    +    assert_eq!(
    +        mid.usage[held_member()],
    +        json!({
    +            "FailureCode": "S3NoSuchBucket",
    +            "FailureMessage": "The specified bucket does not exist",
    +        })
    +    );
    +    assert!(
    +        mid.usage["BulkPutBytes"].as_u64().unwrap() > 0,
    +        "the usage accumulates while the fault is held: {}",
    +        mid.usage
    +    );
    +    assert_eq!(mid.failure_code, None, "nothing is reported while it runs");
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/input.rs b/crates/engine/src/bulk_put/tests/runner/input.rs
    new file mode 100644
    index 000000000..c26cbb697
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/input.rs
    @@ -0,0 +1,326 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// Gzip input.
    +
    +#[tokio::test]
    +async fn gzip_input_is_decompressed_and_counted_in_compressed_bytes() {
    +    let h = Harness::new().await;
    +    let plain = file_of(&plain_rows(5));
    +    let compressed = gzip_bytes(&plain);
    +    let compressed_len = i64::try_from(compressed.len()).unwrap();
    +    h.write_input("part-000.json.gz", &compressed);
    +    let files = h.enumerate().await;
    +    assert_eq!(files[0].size, compressed_len, "enumeration sees gz size");
    +    let record = h
    +        .run_to_end(files, input_config(true), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 5);
    +    assert_eq!(h.count_items().await, 5);
    +    assert_eq!(record.total_input_bytes, Some(compressed_len));
    +    assert_eq!(
    +        Some(record.processed_input_bytes),
    +        record.total_input_bytes,
    +        "byte counters are compressed bytes for GZIP input"
    +    );
    +}
    +
    +/// A zstd input is decompressed while it is read; the byte counters and the
    +/// manifest etag describe the compressed object (measured on the service: a
    +/// 65-byte zstd file of three rows ends COMPLETED
    +/// with TotalInputBytes 65 and the manifest etag of the compressed bytes).
    +#[tokio::test]
    +async fn zstd_input_is_decompressed_and_counted_in_compressed_bytes() {
    +    let h = Harness::new().await;
    +    let plain = file_of(&plain_rows(5));
    +    let compressed = zstd_bytes(&plain);
    +    let compressed_len = i64::try_from(compressed.len()).unwrap();
    +    h.write_input("part-000.json.zst", &compressed);
    +    let files = h.enumerate().await;
    +    assert_eq!(files[0].size, compressed_len, "enumeration sees zst size");
    +    let record = h
    +        .run_to_end(files, zstd_input_config(), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 5);
    +    assert_eq!(h.count_items().await, 5);
    +    assert_eq!(record.total_input_bytes, Some(compressed_len));
    +    assert_eq!(
    +        Some(record.processed_input_bytes),
    +        record.total_input_bytes,
    +        "byte counters are compressed bytes for ZSTD input"
    +    );
    +    let manifest = h
    +        .read_manifest(&record.operation_id)
    +        .expect("manifest written");
    +    assert_eq!(manifest[0]["processedRanges"], json!([[0, 4]]));
    +    assert_eq!(manifest[0]["etag"], json!(h.input_md5("part-000.json.zst")));
    +}
    +
    +/// A zstd frame that does not decode is one failed row with the
    +/// decompression message, its bytes count as processed input, the run
    +/// continues with the next file, and the operation ends by its other rows
    +/// (measured on the service: a truncated 32-byte frame beside a good 65-byte
    +/// file ends COMPLETED_WITH_ERRORS, 4 processed, 1 failed, ProcessedInputBytes
    +/// 97 of 97, the truncated file PARTIALLY_PROCESSED with no ranges).
    +#[tokio::test]
    +async fn a_truncated_zstd_frame_fails_its_file_and_the_operation_completes_with_errors() {
    +    let h = Harness::new().await;
    +    let whole = zstd_bytes(&file_of(&plain_rows(3)));
    +    h.write_input("part-000.json.zst", &whole[..whole.len() / 2]);
    +    h.write_input(
    +        "part-001.json.zst",
    +        &zstd_bytes(&file_of(&(3..7).map(row).collect::>())),
    +    );
    +    let files = h.enumerate().await;
    +    let total: i64 = files.iter().map(|f| f.size).sum();
    +    let record = h
    +        .run_to_end(files, zstd_input_config(), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.failure_code, None);
    +    assert_eq!(
    +        record.processed_item_count, 5,
    +        "one for the undecodable file, four rows"
    +    );
    +    assert_eq!(record.failed_item_count, 1);
    +    assert_eq!(h.count_items().await, 4);
    +    assert_eq!(
    +        record.processed_input_bytes, total,
    +        "the undecodable file's bytes are processed input"
    +    );
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!([crate::bulk_put::rows::NOT_COMPRESSED_MESSAGE])
    +    );
    +    assert!(report[0]["itemLocation"].get("itemIndex").is_none());
    +    let manifest = h
    +        .read_manifest(&record.operation_id)
    +        .expect("manifest written");
    +    assert_eq!(
    +        manifest[0]["fileProcessingStatus"],
    +        json!("PARTIALLY_PROCESSED")
    +    );
    +    assert_eq!(manifest[0]["processedRanges"], json!([]));
    +    assert_eq!(
    +        manifest[1]["fileProcessingStatus"],
    +        json!("FULLY_PROCESSED")
    +    );
    +    assert_eq!(manifest[1]["processedRanges"], json!([[0, 3]]));
    +}
    +
    +/// A frame of many blocks cut short: the rows of the blocks before the cut
    +/// are applied, the cut row is the one failed row with the decompression
    +/// message, and the manifest range ends at the row before it (measured on
    +/// the service: a 20,000-row frame cut at 90 percent of its bytes ends
    +/// COMPLETED_WITH_ERRORS with 16,990 rows on the table, 16,991 processed,
    +/// 1 failed, processedRanges [[0, 16989]], every byte counted).
    +#[tokio::test]
    +async fn a_zstd_frame_cut_mid_way_applies_the_rows_before_the_cut_and_fails_the_cut_row() {
    +    let h = Harness::new().await;
    +    // 20,000 rows compress to about ten 128 KiB blocks (see the source
    +    // tests), so the cut lands after several complete blocks.
    +    let rows: Vec = (0..20000).map(row).collect();
    +    let whole = zstd_bytes(&file_of(&rows));
    +    let truncated = &whole[..whole.len() * 9 / 10];
    +    h.write_input("part-000.json.zst", truncated);
    +    let files = h.enumerate().await;
    +    let size = files[0].size;
    +    let record = h
    +        .run_to_end(files, zstd_input_config(), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    let applied = i64::try_from(h.count_items().await).unwrap();
    +    assert!(
    +        applied > 0 && applied < 20000,
    +        "the complete blocks' rows are applied ({applied}), the rest are not"
    +    );
    +    assert_eq!(record.processed_item_count, applied + 1);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert_eq!(record.processed_input_bytes, size);
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!([crate::bulk_put::rows::NOT_COMPRESSED_MESSAGE])
    +    );
    +    let manifest = h
    +        .read_manifest(&record.operation_id)
    +        .expect("manifest written");
    +    assert_eq!(
    +        manifest[0]["fileProcessingStatus"],
    +        json!("PARTIALLY_PROCESSED")
    +    );
    +    assert_eq!(manifest[0]["processedRanges"], json!([[0, applied - 1]]));
    +}
    +
    +/// Under `ZSTD`, a file without the zstd magic is not a compressed object
    +/// and takes the same path as a plain file under `GZIP`: FAILED with the
    +/// validation pair, bytes excluded (measured on the service for a plain file
    +/// and for gzip bytes under ZSTD, and for zstd bytes under GZIP).
    +#[tokio::test]
    +async fn gzip_bytes_under_zstd_are_refused_as_not_compressed() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json.gz", &gzip_bytes(&file_of(&plain_rows(3))));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, zstd_input_config(), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("ValidationException"));
    +    assert_eq!(record.processed_item_count, 1);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert_eq!(record.processed_input_bytes, 0);
    +    assert_eq!(h.count_items().await, 0);
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!([crate::bulk_put::rows::NOT_COMPRESSED_MESSAGE])
    +    );
    +}
    +
    +// Input enumeration and format.
    +
    +#[tokio::test]
    +async fn a_file_grown_after_enumeration_contributes_only_the_enumerated_rows() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(5)));
    +    let files = h.enumerate().await;
    +    let enumerated_size = files[0].size;
    +
    +    // The file grows after enumeration; the stored list is authoritative.
    +    {
    +        use std::io::Write;
    +        let path = h
    +            .store
    +            .0
    +            .join(ACCOUNT)
    +            .join(IN_BUCKET)
    +            .join("part-000.json");
    +        let mut file = std::fs::OpenOptions::new()
    +            .append(true)
    +            .open(&path)
    +            .unwrap();
    +        file.write_all(&file_of(&(5..10).map(row).collect::>()))
    +            .unwrap();
    +    }
    +
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(
    +        record.processed_item_count, 5,
    +        "only the enumerated rows are read"
    +    );
    +    assert!(h.get("k4").await.is_some());
    +    assert!(h.get("k5").await.is_none(), "appended rows are never read");
    +    assert_eq!(h.count_items().await, 5);
    +    assert_eq!(record.total_input_bytes, Some(enumerated_size));
    +    assert_eq!(record.processed_input_bytes, enumerated_size);
    +}
    +
    +#[tokio::test]
    +async fn a_two_member_gzip_file_is_read_whole() {
    +    let h = Harness::new().await;
    +    let mut bytes = gzip_bytes(&file_of(&plain_rows(5)));
    +    bytes.extend(gzip_bytes(&file_of(&(5..10).map(row).collect::>())));
    +    h.write_input("part-000.json.gz", &bytes);
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(true), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(
    +        record.processed_item_count, 10,
    +        "both concatenated gzip members are read"
    +    );
    +    assert_eq!(h.count_items().await, 10);
    +    assert_eq!(Some(record.processed_input_bytes), record.total_input_bytes);
    +}
    +
    +/// A gzip file cut before its trailer is abandoned at the cut with its own
    +/// measured message, counted as one processed and failed row, and the
    +/// operation continues with the next file (measured on the service: a half
    +/// file alone ends COMPLETED_WITH_ERRORS 1/1; followed by a whole file,
    +/// 4 processed, 1 failed). The rows decoded before the cut are written.
    +#[tokio::test]
    +async fn a_truncated_gzip_file_is_abandoned_and_the_next_file_is_read() {
    +    let h = Harness::new().await;
    +    let whole = gzip_bytes(&file_of(&plain_rows(50)));
    +    let cut = &whole[..whole.len() / 2];
    +    h.write_input("part-000.json.gz", cut);
    +    h.write_input(
    +        "part-001.json.gz",
    +        &gzip_bytes(&file_of(&(50..53).map(row).collect::>())),
    +    );
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(true), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(
    +        record.failed_item_count, 1,
    +        "the cut counts as one failed row"
    +    );
    +    assert!(
    +        record.processed_item_count >= 4,
    +        "rows before the cut and the next file count"
    +    );
    +    assert!(
    +        h.get("k50").await.is_some(),
    +        "the next file is read after the abandon"
    +    );
    +    assert!(h.get("k52").await.is_some());
    +    assert!(
    +        h.get("k49").await.is_none(),
    +        "nothing past the cut is written"
    +    );
    +    assert_eq!(Some(record.processed_input_bytes), record.total_input_bytes);
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!([crate::bulk_put::rows::CORRUPT_STREAM_MESSAGE])
    +    );
    +    assert!(report[0]["itemLocation"].get("itemIndex").is_none());
    +}
    +
    +#[tokio::test]
    +async fn an_empty_input_file_is_finished_and_the_run_continues() {
    +    let h = Harness::new().await;
    +    h.write_input("a.json", &file_of(&plain_rows(3)));
    +    h.write_input("b.json", b"");
    +    h.write_input("c.json", &file_of(&(3..6).map(row).collect::>()));
    +    let files = h.enumerate().await;
    +    assert_eq!(files.len(), 3, "the empty file is enumerated");
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 6);
    +    assert_eq!(record.failed_item_count, 0);
    +    assert_eq!(h.count_items().await, 6);
    +    assert_eq!(Some(record.processed_input_bytes), record.total_input_bytes);
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/jail.rs b/crates/engine/src/bulk_put/tests/runner/jail.rs
    new file mode 100644
    index 000000000..facb680bd
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/jail.rs
    @@ -0,0 +1,117 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// Filesystem jail.
    +
    +#[tokio::test]
    +async fn enumeration_skips_symlinked_files_and_directories() {
    +    let h = Harness::new().await;
    +    h.write_input("a.json", &file_of(&[row(0)]));
    +    h.write_input("b.json", &file_of(&[row(1)]));
    +
    +    // Content outside the bucket that the symlinks point at. Writing any of
    +    // it would be an escape from the enumerated set.
    +    let outside = std::env::temp_dir().join(format!("eb-bulk-outside-{}", uuid::Uuid::new_v4()));
    +    let outside_dir = outside.join("dir");
    +    std::fs::create_dir_all(&outside_dir).unwrap();
    +    let evil = "{\"Item\": {\"pk\": {\"S\": \"evil\"}, \"sk\": {\"S\": \"s\"}}}\n";
    +    std::fs::write(outside.join("linked.json"), evil).unwrap();
    +    std::fs::write(outside_dir.join("nested.json"), evil).unwrap();
    +    let bucket_dir = h.store.0.join(ACCOUNT).join(IN_BUCKET);
    +    std::os::unix::fs::symlink(outside.join("linked.json"), bucket_dir.join("c.json")).unwrap();
    +    std::os::unix::fs::symlink(&outside_dir, bucket_dir.join("sub")).unwrap();
    +
    +    let files = h.enumerate().await;
    +    let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
    +    assert_eq!(
    +        paths,
    +        ["a.json", "b.json"],
    +        "the symlinked file and directory are not enumerated"
    +    );
    +
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 2);
    +    assert!(
    +        h.get("evil").await.is_none(),
    +        "no linked content is written"
    +    );
    +
    +    let _ = std::fs::remove_dir_all(&outside);
    +}
    +
    +#[tokio::test]
    +async fn a_symlink_planted_at_awsdynamodb_fails_the_operation_and_creates_nothing() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&[row_missing_sk(0)]));
    +    let files = h.enumerate().await;
    +
    +    // The attacker owns the export prefix directory and plants a symlink
    +    // where the report tree would be created.
    +    let foreign = std::env::temp_dir().join(format!("eb-bulk-foreign-{}", uuid::Uuid::new_v4()));
    +    std::fs::create_dir_all(&foreign).unwrap();
    +    let prefix_dir = h.store.0.join(ACCOUNT).join(OUT_BUCKET).join(OUT_PREFIX);
    +    std::fs::create_dir_all(&prefix_dir).unwrap();
    +    std::os::unix::fs::symlink(&foreign, prefix_dir.join("AWSDynamoDB")).unwrap();
    +
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(
    +        record.failure_code.as_deref(),
    +        Some("S3AccessDenied"),
    +        "a refused report path is the caller's to fix, not a server fault"
    +    );
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("Access Denied"),
    +        "no path detail reaches the client"
    +    );
    +    let created: Vec<_> = std::fs::read_dir(&foreign).unwrap().collect();
    +    assert!(
    +        created.is_empty(),
    +        "nothing is created behind the planted symlink"
    +    );
    +
    +    let _ = std::fs::remove_dir_all(&foreign);
    +}
    +
    +/// An input file swapped for a symlink after Start is refused when the
    +/// runner opens it. The refusal is the caller's tree, not a server fault,
    +/// so the operation fails with the access-denied code and nothing is
    +/// written; the symlink target is never read.
    +#[tokio::test]
    +async fn an_input_file_swapped_for_a_symlink_after_start_fails_with_access_denied() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(3)));
    +    let files = h.enumerate().await;
    +
    +    let elsewhere =
    +        std::env::temp_dir().join(format!("eb-bulk-elsewhere-{}", uuid::Uuid::new_v4()));
    +    std::fs::create_dir_all(&elsewhere).unwrap();
    +    std::fs::write(elsewhere.join("part-000.json"), file_of(&plain_rows(3))).unwrap();
    +    let input = h
    +        .store
    +        .0
    +        .join(ACCOUNT)
    +        .join(IN_BUCKET)
    +        .join("part-000.json");
    +    std::fs::remove_file(&input).unwrap();
    +    std::os::unix::fs::symlink(elsewhere.join("part-000.json"), &input).unwrap();
    +
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3AccessDenied"));
    +    assert_eq!(record.failure_message.as_deref(), Some("Access Denied"));
    +    assert_eq!(record.processed_item_count, 0);
    +    assert_eq!(h.count_items().await, 0, "the symlink target is never read");
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/lease_in_place.rs b/crates/engine/src/bulk_put/tests/runner/lease_in_place.rs
    new file mode 100644
    index 000000000..acc00e951
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/lease_in_place.rs
    @@ -0,0 +1,283 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +use tokio::sync::Notify;
    +
    +// The output check at the first report record, the report writes and the
    +// manifest write wait on the output store for up to its bound, longer than
    +// one lease. While one of them waits, the runner renews the lease without
    +// moving the cursor, so no other process claims the operation, and a cancel
    +// seen during the wait ends the run. These tests use a 2-second lease and
    +// calls held for over two lease periods. With a 300 ms lease and 5-second
    +// bounds they failed on a loaded host: the runner finished before the held
    +// call, or the run took longer than 5 seconds to end after it.
    +
    +const LEASE: Duration = Duration::from_secs(2);
    +const HELD: Duration = Duration::from_millis(4500);
    +/// How long a step a test waits on may take before it counts as a hang.
    +const BOUND: Duration = Duration::from_secs(30);
    +
    +/// Which output call a `HoldingSink` holds, once.
    +#[derive(Clone, Copy, PartialEq)]
    +enum HoldAt {
    +    /// The first location check, the one at the first report record.
    +    FirstCheck,
    +    /// The first report page write.
    +    ReportPage,
    +    /// The manifest write.
    +    Manifest,
    +}
    +
    +/// The filesystem sink with one call held: it signals `reached`, then
    +/// answers only after `release`.
    +struct HoldingSink {
    +    inner: FsSink,
    +    at: HoldAt,
    +    reached: Arc,
    +    release: Arc,
    +    fired: Arc,
    +}
    +
    +struct HoldingWriter {
    +    inner: Box,
    +    hold: Option<(Arc, Arc)>,
    +}
    +
    +impl HoldingSink {
    +    fn new(h: &Harness, at: HoldAt) -> Self {
    +        Self {
    +            inner: h.fs_sink(),
    +            at,
    +            reached: Arc::new(Notify::new()),
    +            release: Arc::new(Notify::new()),
    +            fired: Arc::new(AtomicBool::new(false)),
    +        }
    +    }
    +
    +    /// The hold for a call at `at`, the first time only.
    +    fn hold(&self, at: HoldAt) -> Option<(Arc, Arc)> {
    +        (self.at == at && !self.fired.swap(true, Ordering::SeqCst))
    +            .then(|| (Arc::clone(&self.reached), Arc::clone(&self.release)))
    +    }
    +}
    +
    +async fn held(hold: Option<(Arc, Arc)>) {
    +    if let Some((reached, release)) = hold {
    +        reached.notify_one();
    +        release.notified().await;
    +    }
    +}
    +
    +impl BulkSink for HoldingSink {
    +    fn check_location(
    +        &self,
    +        at: &BucketRef<'_>,
    +        key: &str,
    +        sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result<(), SinkError>> {
    +        let hold = self.hold(HoldAt::FirstCheck);
    +        let fut = self.inner.check_location(at, key, sse);
    +        Box::pin(async move {
    +            held(hold).await;
    +            fut.await
    +        })
    +    }
    +
    +    fn open(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        operation_id: &str,
    +        kind: ReportKind,
    +        owner: &str,
    +        sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result, SinkError>> {
    +        let hold = self.hold(HoldAt::ReportPage);
    +        let fut = self.inner.open(at, prefix, operation_id, kind, owner, sse);
    +        Box::pin(async move {
    +            let inner = fut.await?;
    +            Ok(Box::new(HoldingWriter { inner, hold }) as Box)
    +        })
    +    }
    +
    +    fn write_manifest(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        sse: &OutputSse,
    +        lines: Vec,
    +    ) -> BoxFuture<'_, Result> {
    +        let hold = self.hold(HoldAt::Manifest);
    +        let fut = self
    +            .inner
    +            .write_manifest(at, prefix, operation_id, owner, sse, lines);
    +        Box::pin(async move {
    +            held(hold).await;
    +            fut.await
    +        })
    +    }
    +}
    +
    +impl ReportWriter for HoldingWriter {
    +    fn write_page(&mut self, records: Vec) -> BoxFuture<'_, Result<(), SinkError>> {
    +        Box::pin(async move {
    +            held(self.hold.take()).await;
    +            self.inner.write_page(records).await
    +        })
    +    }
    +
    +    fn finish(self: Box) -> BoxFuture<'static, Result> {
    +        let this = *self;
    +        this.inner.finish()
    +    }
    +
    +    fn object_index(&self) -> u32 {
    +        self.inner.object_index()
    +    }
    +}
    +
    +/// Five rows, the third without its sort key, so the run has one error
    +/// record, an error report and a manifest; a 300 ms lease.
    +async fn five_rows_one_error(h: &Harness) -> (String, BulkOperationRecord, RunnerConfig) {
    +    let mut rows = plain_rows(5);
    +    rows[2] = row_missing_sk(2);
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", LEASE).await;
    +    let mut cfg = config();
    +    cfg.lease = LEASE;
    +    (op, record, cfg)
    +}
    +
    +/// Hold the call at `at` for over two lease periods while the runner is
    +/// polled, check that no other process can claim the operation and that
    +/// runner-1 still holds it, then release the call and let the run finish.
    +async fn held_call_keeps_the_lease(at: HoldAt) -> (Harness, String) {
    +    let h = Harness::new().await;
    +    let (op, record, cfg) = five_rows_one_error(&h).await;
    +    let sink = Arc::new(HoldingSink::new(&h, at));
    +    let reached = Arc::clone(&sink.reached);
    +    let release = Arc::clone(&sink.release);
    +    let deps = h.deps_with("runner-1", cfg, Arc::new(h.fs_source()), sink);
    +    let run = runner::run(&deps, record);
    +    tokio::pin!(run);
    +    tokio::time::timeout(BOUND, async {
    +        tokio::select! {
    +            ended = &mut run => panic!(
    +                "the runner must not finish before the call is held: {ended:?}, {:?}",
    +                h.record(&op).await
    +            ),
    +            () = reached.notified() => {}
    +        }
    +    })
    +    .await
    +    .expect("the runner reaches the held call");
    +
    +    // The runner is polled through the hold, so it renews its lease.
    +    let probe = async {
    +        tokio::time::sleep(HELD).await;
    +        let rivals = h
    +            .storage
    +            .claim_runnable("runner-2", Duration::from_secs(60), 10, &[], &[])
    +            .await
    +            .expect("claim");
    +        let mid = h.record(&op).await;
    +        (rivals.len(), mid.lease_owner, mid.status)
    +    };
    +    let (rivals, owner, status) = tokio::select! {
    +        _ = &mut run => panic!("the runner must not finish while the call is held"),
    +        probed = probe => probed,
    +    };
    +    assert_eq!(
    +        rivals, 0,
    +        "no second owner claims the operation during the hold"
    +    );
    +    assert_eq!(owner.as_deref(), Some("runner-1"));
    +    assert_eq!(status, "IN_PROGRESS");
    +
    +    release.notify_one();
    +    tokio::time::timeout(BOUND, &mut run)
    +        .await
    +        .expect("the run ends once the call answers")
    +        .expect("runner run");
    +    (h, op)
    +}
    +
    +#[tokio::test]
    +async fn a_report_write_held_past_two_leases_keeps_the_lease() {
    +    let (h, op) = held_call_keeps_the_lease(HoldAt::ReportPage).await;
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS", "{record:?}");
    +    let report = h.read_report(&op, ReportKind::Error).expect("error report");
    +    assert_eq!(report.len(), 1);
    +    assert!(h.read_manifest(&op).is_some(), "the manifest is written");
    +}
    +
    +#[tokio::test]
    +async fn a_manifest_write_held_past_two_leases_keeps_the_lease() {
    +    let (h, op) = held_call_keeps_the_lease(HoldAt::Manifest).await;
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS", "{record:?}");
    +    let manifest = h.read_manifest(&op).expect("the manifest is written");
    +    assert_eq!(manifest.len(), 1);
    +}
    +
    +#[tokio::test]
    +async fn an_output_check_held_past_two_leases_keeps_the_lease() {
    +    let (h, op) = held_call_keeps_the_lease(HoldAt::FirstCheck).await;
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS", "{record:?}");
    +    assert_eq!(record.processed_item_count, 5);
    +    assert_eq!(h.count_items().await, 4);
    +}
    +
    +#[tokio::test]
    +async fn a_cancel_during_a_held_report_write_still_writes_the_outputs() {
    +    let h = Harness::new().await;
    +    let (op, record, cfg) = five_rows_one_error(&h).await;
    +    let sink = Arc::new(HoldingSink::new(&h, HoldAt::ReportPage));
    +    let reached = Arc::clone(&sink.reached);
    +    let release = Arc::clone(&sink.release);
    +    let deps = h.deps_with("runner-1", cfg, Arc::new(h.fs_source()), sink);
    +    let run = runner::run(&deps, record);
    +    tokio::pin!(run);
    +    tokio::time::timeout(BOUND, async {
    +        tokio::select! {
    +            ended = &mut run => panic!(
    +                "the runner must not finish before the write is held: {ended:?}, {:?}",
    +                h.record(&op).await
    +            ),
    +            () = reached.notified() => {}
    +        }
    +    })
    +    .await
    +    .expect("the runner reaches the held report write");
    +    h.storage
    +        .request_cancel(ACCOUNT, &op)
    +        .await
    +        .expect("request cancel");
    +    // The renewals during the hold see the cancel and must not end the
    +    // write: the run read all its input before the cancel arrived.
    +    tokio::select! {
    +        _ = &mut run => panic!("a cancel must not end a held report write"),
    +        () = tokio::time::sleep(HELD) => {}
    +    }
    +    release.notify_one();
    +    tokio::time::timeout(BOUND, &mut run)
    +        .await
    +        .expect("the run ends once the write answers")
    +        .expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "CANCELLED", "{record:?}");
    +    assert_eq!(record.processed_item_count, 5);
    +    assert_eq!(h.count_items().await, 4);
    +    let report = h.read_report(&op, ReportKind::Error).expect("error report");
    +    assert_eq!(report.len(), 1);
    +    assert!(h.read_manifest(&op).is_some(), "the manifest is written");
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/lease_keeper.rs b/crates/engine/src/bulk_put/tests/runner/lease_keeper.rs
    new file mode 100644
    index 000000000..8c71270b2
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/lease_keeper.rs
    @@ -0,0 +1,360 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +use tokio::sync::Notify;
    +
    +// A source call can wait longer than one lease: an S3 request that gets no
    +// answer is retried for up to 120 seconds against a 60-second lease. While a
    +// call waits, the runner runs a boundary every third of the lease, so the
    +// lease is kept, the rows read before the call are written, and a cancel is
    +// seen. These tests use a 300 ms lease and calls that wait over a second.
    +
    +/// What a `WaitSource` does at its chosen point.
    +#[derive(Clone)]
    +pub(super) enum Wait {
    +    /// Signal `frozen`, then answer only after `resume`. A test that stops
    +    /// polling the runner future once `frozen` fires models a paused or
    +    /// swapped process: nothing in the runner runs during the freeze, the
    +    /// lease keeper included, so the lease runs out as it would under a
    +    /// stopped process.
    +    Freeze {
    +        frozen: Arc,
    +        resume: Arc,
    +    },
    +    /// Signal `waiting` and never answer, as an S3 request held open.
    +    Pend { waiting: Arc },
    +    /// Answer an I/O error after `hold`, as an S3 call retried until its
    +    /// bound.
    +    FailAfter { hold: Duration },
    +}
    +
    +impl Wait {
    +    pub(super) fn freeze() -> (Self, Arc, Arc) {
    +        let frozen = Arc::new(Notify::new());
    +        let resume = Arc::new(Notify::new());
    +        let wait = Self::Freeze {
    +            frozen: Arc::clone(&frozen),
    +            resume: Arc::clone(&resume),
    +        };
    +        (wait, frozen, resume)
    +    }
    +
    +    fn pend() -> (Self, Arc) {
    +        let waiting = Arc::new(Notify::new());
    +        (
    +            Self::Pend {
    +                waiting: Arc::clone(&waiting),
    +            },
    +            waiting,
    +        )
    +    }
    +
    +    async fn run(&self) -> Result<(), SourceError> {
    +        match self {
    +            Self::Freeze { frozen, resume } => {
    +                frozen.notify_one();
    +                resume.notified().await;
    +                Ok(())
    +            }
    +            Self::Pend { waiting } => {
    +                waiting.notify_one();
    +                std::future::pending::<()>().await;
    +                Ok(())
    +            }
    +            Self::FailAfter { hold } => {
    +                tokio::time::sleep(*hold).await;
    +                Err(SourceError::Io(std::io::Error::other(
    +                    "the call was retried until its bound",
    +                )))
    +            }
    +        }
    +    }
    +}
    +
    +/// The filesystem source with one `Wait`: at every open when `at_open` is
    +/// set, and once, before handing out line index `at_line.0` (counted over
    +/// the whole run), when `at_line` is set.
    +pub(super) struct WaitSource {
    +    pub(super) inner: FsSource,
    +    pub(super) at_open: Option,
    +    pub(super) at_line: Option<(u64, Wait)>,
    +    pub(super) handed: Arc,
    +    pub(super) fired: Arc,
    +}
    +
    +impl WaitSource {
    +    pub(super) fn at_line(inner: FsSource, at: u64, wait: Wait) -> Self {
    +        Self {
    +            inner,
    +            at_open: None,
    +            at_line: Some((at, wait)),
    +            handed: Arc::new(AtomicU64::new(0)),
    +            fired: Arc::new(AtomicBool::new(false)),
    +        }
    +    }
    +}
    +
    +struct WaitReader {
    +    inner: Box,
    +    at_line: Option<(u64, Wait)>,
    +    handed: Arc,
    +    fired: Arc,
    +}
    +
    +impl BulkSource for WaitSource {
    +    fn check_bucket(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +    ) -> BoxFuture<'_, Result<(), SourceError>> {
    +        self.inner.check_bucket(bucket, prefix)
    +    }
    +
    +    fn etag(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.etag(bucket, file)
    +    }
    +
    +    fn line_count(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.line_count(bucket, file, compression)
    +    }
    +
    +    fn list(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +        max_files: u64,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        self.inner.list(bucket, prefix, max_files)
    +    }
    +
    +    fn open(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        let fut = self.inner.open(bucket, file, compression);
    +        let at_open = self.at_open.clone();
    +        let at_line = self.at_line.clone();
    +        let handed = Arc::clone(&self.handed);
    +        let fired = Arc::clone(&self.fired);
    +        Box::pin(async move {
    +            if let Some(wait) = at_open {
    +                wait.run().await?;
    +            }
    +            let inner = fut.await?;
    +            Ok(Box::new(WaitReader {
    +                inner,
    +                at_line,
    +                handed,
    +                fired,
    +            }) as Box)
    +        })
    +    }
    +}
    +
    +impl LineReader for WaitReader {
    +    fn next_line(
    +        &mut self,
    +        max_line_bytes: usize,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        Box::pin(async move {
    +            if let Some((at, wait)) = &self.at_line
    +                && self.handed.load(Ordering::SeqCst) >= *at
    +                && !self.fired.swap(true, Ordering::SeqCst)
    +            {
    +                wait.run().await?;
    +            }
    +            let line = self.inner.next_line(max_line_bytes).await;
    +            if matches!(&line, Ok(Some(_))) {
    +                self.handed.fetch_add(1, Ordering::SeqCst);
    +            }
    +            line
    +        })
    +    }
    +
    +    fn bytes_consumed(&self) -> u64 {
    +        self.inner.bytes_consumed()
    +    }
    +
    +    fn last_line_end(&self) -> u64 {
    +        self.inner.last_line_end()
    +    }
    +
    +    fn etag(&self) -> Option {
    +        self.inner.etag()
    +    }
    +}
    +
    +const LEASE: Duration = Duration::from_millis(300);
    +
    +/// A run with one file of 12 rows, a 300 ms lease, and no write batch or
    +/// checkpoint due inside the file, so the only boundary while a call waits
    +/// is the one the wait itself runs.
    +async fn twelve_rows(h: &Harness) -> (String, BulkOperationRecord, RunnerConfig) {
    +    h.write_input("part-000.json", &file_of(&plain_rows(12)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", LEASE).await;
    +    let mut cfg = config();
    +    cfg.batch_size = 100;
    +    cfg.write_batch_rows = 100;
    +    cfg.lease = LEASE;
    +    (op, record, cfg)
    +}
    +
    +#[tokio::test]
    +async fn a_read_that_waits_past_the_lease_keeps_it_and_writes_the_failure() {
    +    let h = Harness::new().await;
    +    let (op, record, cfg) = twelve_rows(&h).await;
    +    let wait = Wait::FailAfter {
    +        hold: Duration::from_millis(1200),
    +    };
    +    let source = Arc::new(WaitSource::at_line(h.fs_source(), 5, wait));
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::time::timeout(Duration::from_secs(10), runner::run(&deps, record))
    +        .await
    +        .expect("the run ends")
    +        .expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED", "{record:?}");
    +    assert_eq!(record.failure_code.as_deref(), Some("InternalServerError"));
    +    assert_eq!(record.processed_item_count, 5);
    +    assert_eq!(record.failed_item_count, 0);
    +    assert_eq!(
    +        h.count_items().await,
    +        5,
    +        "the rows read before the call are written, not dropped"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_cancel_is_seen_while_a_read_waits() {
    +    let h = Harness::new().await;
    +    let (op, record, cfg) = twelve_rows(&h).await;
    +    let (wait, waiting) = Wait::pend();
    +    let source = Arc::new(WaitSource::at_line(h.fs_source(), 5, wait));
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    let run = runner::run(&deps, record);
    +    tokio::pin!(run);
    +    tokio::select! {
    +        _ = &mut run => panic!("the runner must not finish before the read waits"),
    +        () = waiting.notified() => {}
    +    }
    +    h.storage
    +        .request_cancel(ACCOUNT, &op)
    +        .await
    +        .expect("request cancel");
    +    tokio::time::timeout(Duration::from_secs(5), &mut run)
    +        .await
    +        .expect("the cancel is seen while the read waits")
    +        .expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "CANCELLED", "{record:?}");
    +    assert_eq!(record.processed_item_count, 5);
    +    assert_eq!(h.count_items().await, 5);
    +}
    +
    +#[tokio::test]
    +async fn a_cancel_is_seen_while_an_open_waits() {
    +    let h = Harness::new().await;
    +    let (op, record, cfg) = twelve_rows(&h).await;
    +    let (wait, waiting) = Wait::pend();
    +    let source = Arc::new(WaitSource {
    +        inner: h.fs_source(),
    +        at_open: Some(wait),
    +        at_line: None,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        fired: Arc::new(AtomicBool::new(false)),
    +    });
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    let run = runner::run(&deps, record);
    +    tokio::pin!(run);
    +    tokio::select! {
    +        _ = &mut run => panic!("the runner must not finish before the open waits"),
    +        () = waiting.notified() => {}
    +    }
    +    h.storage
    +        .request_cancel(ACCOUNT, &op)
    +        .await
    +        .expect("request cancel");
    +    tokio::time::timeout(Duration::from_secs(5), &mut run)
    +        .await
    +        .expect("the cancel is seen while the open waits")
    +        .expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "CANCELLED", "{record:?}");
    +    assert_eq!(record.processed_item_count, 0);
    +    let manifest = h.read_manifest(&op).expect("the manifest is written");
    +    assert_eq!(manifest[0]["fileProcessingStatus"], json!("NOT_PROCESSED"));
    +}
    +
    +#[tokio::test]
    +async fn a_cancel_is_seen_while_a_resumed_claim_reads_the_covered_rows_again() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(12)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +
    +    // The first claim checkpoints 5 rows and stops.
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +    let notify = Arc::new(Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 7,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    assert_eq!(h.record(&op).await.cursor_row_index, 5);
    +
    +    // The resumed claim's read of the covered rows waits at row 2.
    +    let record = h.claim("runner-2", LEASE).await;
    +    let (wait, waiting) = Wait::pend();
    +    let source = Arc::new(WaitSource::at_line(h.fs_source(), 2, wait));
    +    let mut cfg = config();
    +    cfg.batch_size = 100;
    +    cfg.lease = LEASE;
    +    let deps = h.deps_with("runner-2", cfg, source, Arc::new(h.fs_sink()));
    +    let run = runner::run(&deps, record);
    +    tokio::pin!(run);
    +    tokio::select! {
    +        _ = &mut run => panic!("the runner must not finish before the read waits"),
    +        () = waiting.notified() => {}
    +    }
    +    h.storage
    +        .request_cancel(ACCOUNT, &op)
    +        .await
    +        .expect("request cancel");
    +    tokio::time::timeout(Duration::from_secs(5), &mut run)
    +        .await
    +        .expect("the cancel is seen while the covered rows are read again")
    +        .expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "CANCELLED", "{record:?}");
    +    assert_eq!(record.processed_item_count, 5);
    +    assert_eq!(h.count_items().await, 5);
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/lifecycle_cells.rs b/crates/engine/src/bulk_put/tests/runner/lifecycle_cells.rs
    new file mode 100644
    index 000000000..526d3aa8a
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/lifecycle_cells.rs
    @@ -0,0 +1,621 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// Runner lifecycle behaviour measured on the hosted service:
    +// the terminal status and byte accounting after an abandon, an input
    +// object that becomes unreadable mid-run, the four manifest statuses,
    +// the inclusive row-index ranges, and the two input quota classes.
    +
    +/// The abandon message a row with an unknown attribute type tag carries.
    +const PARSE_ABANDON: &str = "Expected DynamoDB type to be one of [S, N, B, BOOL, NULL, L, M, SS, \
    +                             NS, BS]. Remainder of the file will not be processed.";
    +const ITEMS_FAILED_VALIDATION: &str =
    +    "Some of the items failed validation checks and were not ingested";
    +
    +/// One well-formed row of exactly `bytes` characters, padded with a filler
    +/// attribute, so a file of them has an exact byte size and a row index
    +/// maps to a fixed byte offset.
    +fn sized_row(index: usize, bytes: usize) -> String {
    +    let head = format!(r#"{{"Item":{{"pk":{{"S":"k{index}"}},"sk":{{"S":"s"}},"p":{{"S":""#);
    +    let tail = r#""}}}"#;
    +    let pad = bytes
    +        .checked_sub(head.len() + tail.len())
    +        .expect("the row does not fit the requested length");
    +    format!("{head}{}{tail}", "x".repeat(pad))
    +}
    +
    +/// One row whose attribute value carries an unknown type tag, padded the
    +/// same way: a parse-level fault that abandons the rest of the file.
    +fn sized_fault_row(index: usize, bytes: usize) -> String {
    +    let head = format!(r#"{{"Item":{{"pk":{{"S":"k{index}"}},"sk":{{"S":"s"}},"p":{{"X":""#);
    +    let tail = r#""}}}"#;
    +    let pad = bytes
    +        .checked_sub(head.len() + tail.len())
    +        .expect("the row does not fit the requested length");
    +    format!("{head}{}{tail}", "x".repeat(pad))
    +}
    +
    +/// A file of `lines` rows of `line_bytes` bytes each, terminator
    +/// included, with the row at `fault_at` carrying the parse-level fault.
    +/// The file's size is exactly `lines * line_bytes`.
    +fn file_with_fault(lines: usize, line_bytes: usize, fault_at: usize) -> Vec {
    +    let mut out = String::new();
    +    for index in 0..lines {
    +        if index == fault_at {
    +            out.push_str(&sized_fault_row(index, line_bytes - 1));
    +        } else {
    +            out.push_str(&sized_row(index, line_bytes - 1));
    +        }
    +        out.push('\n');
    +    }
    +    assert_eq!(out.len(), lines * line_bytes, "file size must be exact");
    +    out.into_bytes()
    +}
    +
    +/// Hands out lines from the real reader until `at` lines have been
    +/// yielded, then answers every later call with an I/O error of `kind`:
    +/// the input object has become unreadable mid-run.
    +struct UnreadableAfterSource {
    +    inner: FsSource,
    +    at: u64,
    +    kind: std::io::ErrorKind,
    +    handed: Arc,
    +}
    +
    +struct UnreadableAfterReader {
    +    inner: Box,
    +    at: u64,
    +    kind: std::io::ErrorKind,
    +    handed: Arc,
    +}
    +
    +impl BulkSource for UnreadableAfterSource {
    +    fn check_bucket(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +    ) -> BoxFuture<'_, Result<(), SourceError>> {
    +        self.inner.check_bucket(bucket, prefix)
    +    }
    +
    +    fn etag(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.etag(bucket, file)
    +    }
    +
    +    fn line_count(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.line_count(bucket, file, compression)
    +    }
    +
    +    fn list(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +        max_files: u64,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        self.inner.list(bucket, prefix, max_files)
    +    }
    +
    +    fn open(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        let fut = self.inner.open(bucket, file, compression);
    +        let at = self.at;
    +        let kind = self.kind;
    +        let handed = Arc::clone(&self.handed);
    +        Box::pin(async move {
    +            let inner = fut.await?;
    +            Ok(Box::new(UnreadableAfterReader {
    +                inner,
    +                at,
    +                kind,
    +                handed,
    +            }) as Box)
    +        })
    +    }
    +}
    +
    +impl LineReader for UnreadableAfterReader {
    +    fn next_line(
    +        &mut self,
    +        max_line_bytes: usize,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        Box::pin(async move {
    +            if self.handed.load(Ordering::SeqCst) >= self.at {
    +                return Err(SourceError::Io(std::io::Error::new(
    +                    self.kind,
    +                    "input object is no longer readable",
    +                )));
    +            }
    +            let line = self.inner.next_line(max_line_bytes).await;
    +            if matches!(&line, Ok(Some(_))) {
    +                self.handed.fetch_add(1, Ordering::SeqCst);
    +            }
    +            line
    +        })
    +    }
    +
    +    fn bytes_consumed(&self) -> u64 {
    +        self.inner.bytes_consumed()
    +    }
    +
    +    fn last_line_end(&self) -> u64 {
    +        self.inner.last_line_end()
    +    }
    +}
    +
    +/// Deps with the input quota limits replaced, so a record enumerated under
    +/// wider caps meets the narrower ones the runner enforces.
    +fn deps_with_limits(h: &Harness, owner: &str, limits: LimitsConfig) -> RunnerDeps {
    +    let mut deps = h.deps(owner, config());
    +    deps.limits = Arc::new(limits);
    +    deps
    +}
    +
    +fn manifest_status(line: &Value) -> &str {
    +    line["fileProcessingStatus"]
    +        .as_str()
    +        .expect("the manifest line carries a status")
    +}
    +
    +// The terminal status and byte accounting after an abandon.
    +
    +#[tokio::test]
    +async fn an_abandon_more_than_one_read_from_the_end_cuts_the_file_and_fails() {
    +    // A fault on line 1 of a 23,840-byte file leaves
    +    // ProcessedInputBytes at 8,000 and the operation FAILED.
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_with_fault(149, 160, 0));
    +    let files = h.enumerate().await;
    +    assert_eq!(files[0].size, 23_840);
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.processed_input_bytes, 8_000);
    +    assert_eq!(record.total_input_bytes, Some(23_840));
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("ValidationException"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some(ITEMS_FAILED_VALIDATION)
    +    );
    +    assert_eq!(record.processed_item_count, 1);
    +    assert_eq!(record.failed_item_count, 1);
    +}
    +
    +#[tokio::test]
    +async fn an_abandon_in_a_file_inside_one_read_counts_every_byte_and_completes_with_errors() {
    +    // A fault on line 1 of a 1,980-byte file counts the whole
    +    // file and ends COMPLETED_WITH_ERRORS.
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_with_fault(11, 180, 0));
    +    let files = h.enumerate().await;
    +    assert_eq!(files[0].size, 1_980);
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.processed_input_bytes, 1_980);
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.failure_code, None);
    +    assert_eq!(record.failed_item_count, 1);
    +}
    +
    +#[tokio::test]
    +async fn an_abandon_in_the_last_read_of_a_file_counts_every_byte_and_completes_with_errors() {
    +    // A fault on the last line of a 23,840-byte file counts the
    +    // whole file and ends COMPLETED_WITH_ERRORS.
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_with_fault(149, 160, 148));
    +    let files = h.enumerate().await;
    +    assert_eq!(files[0].size, 23_840);
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.processed_input_bytes, 23_840);
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.failure_code, None);
    +    assert_eq!(
    +        record.processed_item_count, 149,
    +        "every line of the file was consumed"
    +    );
    +    assert_eq!(record.failed_item_count, 1);
    +}
    +
    +#[tokio::test]
    +async fn an_abandon_early_in_a_two_read_file_stops_the_counter_at_the_first_read() {
    +    // A fault at about byte 1,100 of a 9,990-byte file leaves
    +    // ProcessedInputBytes at 8,000 and the operation FAILED.
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_with_fault(90, 111, 9));
    +    let files = h.enumerate().await;
    +    assert_eq!(files[0].size, 9_990);
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.processed_input_bytes, 8_000);
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("ValidationException"));
    +    assert_eq!(record.processed_item_count, 10);
    +    assert_eq!(record.failed_item_count, 1);
    +}
    +
    +#[tokio::test]
    +async fn a_cut_file_carries_its_abandon_record_and_partial_manifest_entry() {
    +    // The abandon record and the manifest entry are the same whether the
    +    // file was cut or not.
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_with_fault(149, 160, 0));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-1", config()), record)
    +        .await
    +        .expect("runner run");
    +
    +    let report = h
    +        .read_report(&op, ReportKind::Error)
    +        .expect("the abandon record is rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(report[0]["errorMessages"], json!([PARSE_ABANDON]));
    +    assert!(
    +        report[0]["itemLocation"].get("itemIndex").is_none(),
    +        "an abandon record carries no itemIndex"
    +    );
    +    let manifest = h.read_manifest(&op).expect("the manifest is written");
    +    assert_eq!(manifest_status(&manifest[0]), "PARTIALLY_PROCESSED");
    +    assert_eq!(manifest[0]["processedRanges"], json!([]));
    +}
    +
    +#[tokio::test]
    +async fn a_cut_file_stays_cut_across_a_restart() {
    +    // The cut is part of the file's finished state: a resumed run neither
    +    // re-counts the bytes beyond the fault's read nor loses the terminal
    +    // failure.
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_with_fault(149, 160, 0));
    +    h.write_input("part-001.json", &file_of(&plain_rows(4)));
    +    let files = h.enumerate().await;
    +    let second_size = files[1].size;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    // The first runner cuts file 0, checkpoints its end of file, and dies
    +    // inside the open of file 1.
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAtOpenSource {
    +        inner: h.fs_source(),
    +        pend_at: 2,
    +        opens: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the open pends"),
    +        () = notify.notified() => {}
    +    }
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.cursor_file_index, 1);
    +    assert_eq!(
    +        mid.processed_input_bytes, 8_000,
    +        "the checkpoint persisted the cut, not the file's size"
    +    );
    +
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-2", config()), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(
    +        record.processed_input_bytes,
    +        8_000 + second_size,
    +        "the resumed run adds the second file's bytes to the cut total"
    +    );
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("ValidationException"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some(ITEMS_FAILED_VALIDATION),
    +        "the cut survives the restart and still ends the operation"
    +    );
    +}
    +
    +// An input object that becomes unreadable after Start.
    +
    +#[tokio::test]
    +async fn an_input_object_deleted_mid_run_fails_with_the_no_such_key_code() {
    +    // The object is deleted mid-run; the operation ends FAILED
    +    // with S3NoSuchKey, keeps the rows processed before the fault, and the
    +    // manifest marks the file NOT_AVAILABLE with no processed range.
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(30)));
    +    h.write_input("part-001.json", &file_of(&plain_rows(4)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let source = Arc::new(UnreadableAfterSource {
    +        inner: h.fs_source(),
    +        at: 10,
    +        kind: std::io::ErrorKind::NotFound,
    +        handed: Arc::new(AtomicU64::new(0)),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3NoSuchKey"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("The specified key does not exist.")
    +    );
    +    assert_eq!(
    +        record.processed_item_count, 10,
    +        "the rows read before the fault stand"
    +    );
    +    assert_eq!(h.count_items().await, 10, "every row written stays written");
    +
    +    let manifest = h.read_manifest(&op).expect("the manifest is written");
    +    assert_eq!(manifest.len(), 2);
    +    assert_eq!(manifest_status(&manifest[0]), "NOT_AVAILABLE");
    +    assert_eq!(manifest[0]["processedRanges"], json!([]));
    +    assert_eq!(
    +        manifest_status(&manifest[1]),
    +        "NOT_PROCESSED",
    +        "a file the operation never opened is not reported as partially processed"
    +    );
    +    assert_eq!(manifest[1]["processedRanges"], json!([]));
    +    assert_eq!(
    +        manifest[1]["etag"],
    +        json!(h.input_md5("part-001.json")),
    +        "an unreached file is still hashed"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_input_object_that_loses_read_permission_mid_run_fails_with_access_denied() {
    +    // The read is denied mid-run; the operation ends FAILED with
    +    // S3AccessDenied and the file is NOT_AVAILABLE.
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(30)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let source = Arc::new(UnreadableAfterSource {
    +        inner: h.fs_source(),
    +        at: 10,
    +        kind: std::io::ErrorKind::PermissionDenied,
    +        handed: Arc::new(AtomicU64::new(0)),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3AccessDenied"));
    +    assert_eq!(record.failure_message.as_deref(), Some("Access Denied"));
    +    assert_eq!(record.processed_item_count, 10);
    +    let usage = record.usage.to_string();
    +    assert!(
    +        usage.contains("10240"),
    +        "usage keeps the ten rows already written: {usage}"
    +    );
    +
    +    let manifest = h.read_manifest(&op).expect("the manifest is written");
    +    assert_eq!(manifest_status(&manifest[0]), "NOT_AVAILABLE");
    +    assert_eq!(manifest[0]["processedRanges"], json!([]));
    +}
    +
    +// The processed ranges are inclusive row indexes.
    +
    +#[tokio::test]
    +async fn a_processed_range_ends_at_the_last_row_index_the_table_holds() {
    +    // A range is an inclusive pair of row indexes
    +    // starting at 0, and the table holds exactly rows 0 to the range end.
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(7)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-1", config()), record)
    +        .await
    +        .expect("runner run");
    +
    +    let manifest = h.read_manifest(&op).expect("the manifest is written");
    +    assert_eq!(manifest_status(&manifest[0]), "FULLY_PROCESSED");
    +    assert_eq!(
    +        manifest[0]["processedRanges"],
    +        json!([[0, 6]]),
    +        "seven rows end at inclusive row index six"
    +    );
    +    assert!(h.get("k6").await.is_some(), "the range end row is present");
    +    assert!(
    +        h.get("k7").await.is_none(),
    +        "no row beyond the range end is present"
    +    );
    +    assert_eq!(h.count_items().await, 7);
    +}
    +
    +// The per-operation input quotas.
    +
    +#[tokio::test]
    +async fn more_input_objects_than_the_count_quota_fails_the_operation() {
    +    // The message interpolates the prefix as the request gave it
    +    // and the cap as a bare number, and nothing is written under the
    +    // output prefix.
    +    let h = Harness::new().await;
    +    h.write_input("h1/o-000000.json", &file_of(&plain_rows(2)));
    +    h.write_input("h1/o-000001.json", &file_of(&plain_rows(2)));
    +    let files = h.enumerate().await;
    +    let input = json!({
    +        "S3Bucket": IN_BUCKET,
    +        "S3KeyPrefix": "h1/",
    +        "InputFormat": "DYNAMODB_JSON",
    +        "InputCompressionType": "NONE",
    +    });
    +    let op = h.start(files, input, both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let limits = LimitsConfig {
    +        max_bulk_input_files: 1,
    +        ..LimitsConfig::default()
    +    };
    +    runner::run(&deps_with_limits(&h, "runner-1", limits), record)
    +        .await
    +        .expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(
    +        record.failure_code.as_deref(),
    +        Some("S3ObjectsCountLimitExceeded")
    +    );
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some(
    +            "You have reached the maximum number of objects allowed in a single bulk operation \
    +             for the specified prefix h1/, which is 1. You can reach out to AWS Support to \
    +             request a quota increase."
    +        )
    +    );
    +    assert_eq!(record.processed_item_count, 0);
    +    assert_eq!(record.processed_input_bytes, 0);
    +    assert_eq!(h.count_items().await, 0, "no file was read");
    +    assert!(
    +        h.read_manifest(&op).is_none(),
    +        "the count class writes nothing under the output prefix"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_input_object_above_the_byte_quota_fails_the_operation_and_writes_the_manifest() {
    +    // The per-object class reports TotalInputBytes from the
    +    // listing, ProcessedInputBytes 0, and a manifest marking the file
    +    // NOT_PROCESSED.
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(2)));
    +    // The quota is decided on the enumerated size, before any open, so the
    +    // record carries the measured size over a small staged file.
    +    let files = vec![InputFile {
    +        path: "part-000.json".to_owned(),
    +        size: 5_369_000_000,
    +        rows: None,
    +        etag: None,
    +        abandon: None,
    +    }];
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let limits = LimitsConfig {
    +        max_bulk_input_bytes: 5_368_709_120,
    +        ..LimitsConfig::default()
    +    };
    +    runner::run(&deps_with_limits(&h, "runner-1", limits), record)
    +        .await
    +        .expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("InputObjectTooLarge"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some(
    +            "This bulk operation contains one or more input objects larger than the allowed \
    +             per-object size of 5.0 GB. Split those objects into smaller parts and retry."
    +        )
    +    );
    +    assert_eq!(record.total_input_bytes, Some(5_369_000_000));
    +    assert_eq!(record.processed_input_bytes, 0);
    +    assert_eq!(record.processed_item_count, 0);
    +    assert_eq!(h.count_items().await, 0, "no file was read");
    +
    +    let manifest = h.read_manifest(&op).expect("the manifest is written");
    +    assert_eq!(manifest.len(), 1);
    +    assert_eq!(manifest_status(&manifest[0]), "NOT_PROCESSED");
    +    assert_eq!(manifest[0]["processedRanges"], json!([]));
    +}
    +
    +// The object-count quota admits exactly the cap
    +// (the comparison is `>`, not `>=`). The hosted service's exact edge is
    +// unmeasured (the measured case was over the quota); this pins the code's edge. Two
    +// objects under a cap of two are read.
    +#[tokio::test]
    +async fn exactly_the_count_quota_of_objects_is_admitted() {
    +    let h = Harness::new().await;
    +    h.write_input("q/o-0.json", &file_of(&plain_rows(2)));
    +    h.write_input("q/o-1.json", &file_of(&(2..4).map(row).collect::>()));
    +    let input = json!({
    +        "S3Bucket": IN_BUCKET,
    +        "S3KeyPrefix": "q/",
    +        "InputFormat": "DYNAMODB_JSON",
    +        "InputCompressionType": "NONE",
    +    });
    +    let files = h.enumerate().await;
    +    assert_eq!(files.len(), 2);
    +    let op = h.start(files, input, both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let limits = LimitsConfig {
    +        max_bulk_input_files: 2,
    +        ..LimitsConfig::default()
    +    };
    +    runner::run(&deps_with_limits(&h, "runner-1", limits), record)
    +        .await
    +        .expect("runner run");
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED", "exactly the cap is admitted");
    +    assert_ne!(
    +        record.failure_code.as_deref(),
    +        Some("S3ObjectsCountLimitExceeded")
    +    );
    +    assert_eq!(h.count_items().await, 4);
    +}
    +
    +// The per-object size quota admits a file exactly
    +// at the cap (the comparison is `>`, not `>=`). Unmeasured on the service at
    +// the edge; this pins the code. The cap is set to the file's own enumerated
    +// size.
    +#[tokio::test]
    +async fn an_input_object_exactly_at_the_byte_quota_is_admitted() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(2)));
    +    let files = h.enumerate().await;
    +    let size = u64::try_from(files[0].size).unwrap();
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let limits = LimitsConfig {
    +        max_bulk_input_object_bytes: size,
    +        ..LimitsConfig::default()
    +    };
    +    runner::run(&deps_with_limits(&h, "runner-1", limits), record)
    +        .await
    +        .expect("runner run");
    +    let record = h.record(&op).await;
    +    assert_eq!(
    +        record.status, "COMPLETED",
    +        "a file exactly at the cap is admitted"
    +    );
    +    assert_ne!(record.failure_code.as_deref(), Some("InputObjectTooLarge"));
    +    assert_eq!(h.count_items().await, 2);
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/manifest.rs b/crates/engine/src/bulk_put/tests/runner/manifest.rs
    new file mode 100644
    index 000000000..56a537779
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/manifest.rs
    @@ -0,0 +1,492 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// The manifest (measured on the service): one line
    +// per input file in enumeration order, written after the reports under the
    +// operation directory with no shard suffix; FULLY_PROCESSED with
    +// processedRanges [[0, last]] for a file read to its end, PARTIALLY_PROCESSED
    +// for an abandoned, cut-short or never-reached file, with the range of the
    +// rows counted before the stop and [] when none; etag is the S3 ETag, the
    +// MD5 of the object.
    +
    +#[tokio::test]
    +async fn the_manifest_lists_each_file_fully_processed_with_its_range_and_etag() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(3)));
    +    h.write_input(
    +        "part-001.json",
    +        &file_of(&(3..7).map(row).collect::>()),
    +    );
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +    assert_eq!(record.status, "COMPLETED");
    +    let manifest = h
    +        .read_manifest(&record.operation_id)
    +        .expect("manifest written");
    +    assert_eq!(
    +        manifest,
    +        vec![
    +            json!({
    +                "inputSourceArn": format!("arn:aws:s3:::{IN_BUCKET}/part-000.json"),
    +                "fileProcessingStatus": "FULLY_PROCESSED",
    +                "processedRanges": [[0, 2]],
    +                "etag": h.input_md5("part-000.json"),
    +            }),
    +            json!({
    +                "inputSourceArn": format!("arn:aws:s3:::{IN_BUCKET}/part-001.json"),
    +                "fileProcessingStatus": "FULLY_PROCESSED",
    +                "processedRanges": [[0, 3]],
    +                "etag": h.input_md5("part-001.json"),
    +            }),
    +        ]
    +    );
    +    // The members are written in the service's order (measured), which the
    +    // parsed values above cannot see: read the first line's raw text.
    +    let raw = std::fs::read_to_string(h.manifest_path(&record.operation_id)).unwrap();
    +    let first = raw.lines().next().unwrap();
    +    assert_eq!(
    +        first,
    +        format!(
    +            "{{\"inputSourceArn\":\"arn:aws:s3:::{IN_BUCKET}/part-000.json\",\
    +             \"fileProcessingStatus\":\"FULLY_PROCESSED\",\"processedRanges\":[[0,2]],\
    +             \"etag\":\"{}\"}}",
    +            h.input_md5("part-000.json")
    +        )
    +    );
    +}
    +
    +#[tokio::test]
    +async fn the_manifest_marks_an_abandoned_file_partially_processed_up_to_the_row_before_the_fault() {
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(5);
    +    rows.push(row_bad_type_tag(5));
    +    rows.extend((6..9).map(row));
    +    h.write_input("part-000.json", &file_of(&rows));
    +    h.write_input("part-001.json", &file_of(&[row_bad_type_tag(0), row(1)]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    let manifest = h
    +        .read_manifest(&record.operation_id)
    +        .expect("manifest written");
    +    assert_eq!(
    +        manifest[0]["fileProcessingStatus"],
    +        json!("PARTIALLY_PROCESSED")
    +    );
    +    assert_eq!(manifest[0]["processedRanges"], json!([[0, 4]]));
    +    // Abandoned at its first row: no row was counted before the fault.
    +    assert_eq!(
    +        manifest[1]["fileProcessingStatus"],
    +        json!("PARTIALLY_PROCESSED")
    +    );
    +    assert_eq!(manifest[1]["processedRanges"], json!([]));
    +}
    +
    +#[tokio::test]
    +async fn the_manifest_after_a_cancel_marks_the_cut_file_partial_and_the_unreached_file_unprocessed()
    +{
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(20)));
    +    h.write_input(
    +        "part-001.json",
    +        &file_of(&(20..23).map(row).collect::>()),
    +    );
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let storage = Arc::clone(&h.storage);
    +    let op_for_action = op.clone();
    +    let action: LineAction = Arc::new(move || {
    +        let storage = Arc::clone(&storage);
    +        let op = op_for_action.clone();
    +        Box::pin(async move {
    +            storage
    +                .request_cancel(ACCOUNT, &op)
    +                .await
    +                .expect("request cancel");
    +        })
    +    });
    +    let source = Arc::new(ActAtSource {
    +        inner: h.fs_source(),
    +        at: 7,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        fired: Arc::new(AtomicBool::new(false)),
    +        action,
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    runner::run(&deps, record).await.expect("runner run");
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "CANCELLED");
    +    assert_eq!(record.processed_item_count, 10);
    +    let manifest = h
    +        .read_manifest(&record.operation_id)
    +        .expect("manifest written");
    +    assert_eq!(manifest.len(), 2);
    +    assert_eq!(
    +        manifest[0]["fileProcessingStatus"],
    +        json!("PARTIALLY_PROCESSED")
    +    );
    +    assert_eq!(manifest[0]["processedRanges"], json!([[0, 9]]));
    +    assert_eq!(
    +        manifest[1]["fileProcessingStatus"],
    +        json!("NOT_PROCESSED"),
    +        "the cancel landed before this file was opened, which is its own \
    +         status (measured on the service for an input object the \
    +         operation never opened)"
    +    );
    +    assert_eq!(manifest[1]["processedRanges"], json!([]));
    +    assert_eq!(manifest[1]["etag"], json!(h.input_md5("part-001.json")));
    +}
    +
    +#[tokio::test]
    +async fn the_manifest_etag_of_a_gzip_file_is_the_md5_of_the_compressed_bytes() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json.gz", &gzip_bytes(&file_of(&plain_rows(4))));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(true), both_reports(), config())
    +        .await;
    +    assert_eq!(record.status, "COMPLETED");
    +    let manifest = h
    +        .read_manifest(&record.operation_id)
    +        .expect("manifest written");
    +    assert_eq!(manifest[0]["processedRanges"], json!([[0, 3]]));
    +    assert_eq!(manifest[0]["etag"], json!(h.input_md5("part-000.json.gz")));
    +}
    +
    +#[tokio::test]
    +async fn the_manifest_after_a_resume_counts_a_file_the_earlier_incarnation_finished() {
    +    // The first incarnation finishes part-000 and crashes inside part-001
    +    // before any checkpoint of that file; the resumed incarnation never
    +    // reads part-000. Its row count and entity tag come from the figures
    +    // the end-of-file checkpoint stored on the record, so the manifest
    +    // reads no file a second time: neither `line_count` nor `etag` is
    +    // called on the source, for either file.
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(4)));
    +    h.write_input(
    +        "part-001.json",
    +        &file_of(&(4..12).map(row).collect::>()),
    +    );
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 6,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 25;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +    // The end-of-file checkpoint of part-000 stored its figures.
    +    let persisted = h.record(&op).await;
    +    assert_eq!(persisted.cursor_file_index, 1);
    +    assert_eq!(persisted.input_files[0].rows, Some(4));
    +    assert_eq!(
    +        persisted.input_files[0].etag.as_deref(),
    +        Some(h.input_md5("part-000.json").as_str())
    +    );
    +    assert_eq!(persisted.input_files[1].rows, None);
    +    assert_eq!(persisted.input_files[1].etag, None);
    +
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let source = Arc::new(CountingSource::new(h.fs_source()));
    +    let counts = Arc::clone(&source.counts);
    +    let etags = Arc::clone(&source.etags);
    +    let deps = h.deps_with("runner-2", config(), source, Arc::new(h.fs_sink()));
    +    runner::run(&deps, record).await.expect("resumed run");
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 12);
    +    let manifest = h
    +        .read_manifest(&record.operation_id)
    +        .expect("manifest written");
    +    assert_eq!(
    +        manifest[0]["fileProcessingStatus"],
    +        json!("FULLY_PROCESSED")
    +    );
    +    assert_eq!(manifest[0]["processedRanges"], json!([[0, 3]]));
    +    assert_eq!(manifest[0]["etag"], json!(h.input_md5("part-000.json")));
    +    assert_eq!(
    +        manifest[1]["fileProcessingStatus"],
    +        json!("FULLY_PROCESSED")
    +    );
    +    assert_eq!(manifest[1]["processedRanges"], json!([[0, 7]]));
    +    assert_eq!(manifest[1]["etag"], json!(h.input_md5("part-001.json")));
    +    assert_eq!(
    +        counts.load(Ordering::SeqCst),
    +        0,
    +        "no finished file is counted again for the manifest"
    +    );
    +    assert_eq!(
    +        etags.load(Ordering::SeqCst),
    +        0,
    +        "no finished file is hashed again for the manifest"
    +    );
    +}
    +
    +/// A plain file under GZIP is not a compressed object: it counts one
    +/// processed and failed row with its own message, the run continues with
    +/// the next file, and the operation ends FAILED with the validation pair.
    +/// The refused file's bytes are not counted as processed input (measured
    +/// on the service: a plain 150-byte file alone ends FAILED, 1 processed, 1
    +/// failed, ProcessedInputBytes 12 of 150; followed by a 69-byte gzip file
    +/// of four rows, 5 processed, 1 failed, 81 of 219).
    +#[tokio::test]
    +async fn a_plain_file_under_gzip_is_refused_and_the_operation_ends_failed_after_the_rest() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(3)));
    +    h.write_input(
    +        "part-001.json.gz",
    +        &gzip_bytes(&file_of(&(3..7).map(row).collect::>())),
    +    );
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(true), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("ValidationException"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("Some of the items failed validation checks and were not ingested")
    +    );
    +    assert_eq!(
    +        record.processed_item_count, 5,
    +        "one for the refused file, four rows"
    +    );
    +    assert_eq!(record.failed_item_count, 1);
    +    assert_eq!(
    +        h.count_items().await,
    +        4,
    +        "the gzip file after it is fully written"
    +    );
    +    assert!(h.get("k0").await.is_none());
    +    let gz_size = i64::try_from(
    +        std::fs::metadata(
    +            h.store
    +                .0
    +                .join(ACCOUNT)
    +                .join(IN_BUCKET)
    +                .join("part-001.json.gz"),
    +        )
    +        .unwrap()
    +        .len(),
    +    )
    +    .unwrap();
    +    assert_eq!(
    +        record.processed_input_bytes, gz_size,
    +        "the refused file's bytes are not processed input"
    +    );
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!([crate::bulk_put::rows::NOT_COMPRESSED_MESSAGE])
    +    );
    +    assert!(report[0]["itemLocation"].get("itemIndex").is_none());
    +    let manifest = h
    +        .read_manifest(&record.operation_id)
    +        .expect("manifest written");
    +    assert_eq!(
    +        manifest[0]["fileProcessingStatus"],
    +        json!("PARTIALLY_PROCESSED")
    +    );
    +    assert_eq!(manifest[0]["processedRanges"], json!([]));
    +    assert_eq!(
    +        manifest[1]["fileProcessingStatus"],
    +        json!("FULLY_PROCESSED")
    +    );
    +    assert_eq!(manifest[1]["processedRanges"], json!([[0, 3]]));
    +}
    +
    +/// Gzip bytes read as text (compression NONE) are not JSON: the file is
    +/// abandoned at its first line with the corrupt-stream message (measured
    +/// "Unexpected token." for gzip bytes under NONE and under an
    +/// absent compression type).
    +#[tokio::test]
    +async fn gzip_bytes_under_no_compression_abandon_the_file_with_the_unexpected_token_message() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json.gz", &gzip_bytes(&file_of(&plain_rows(3))));
    +    h.write_input(
    +        "part-001.json",
    +        &file_of(&(3..5).map(row).collect::>()),
    +    );
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 3);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert_eq!(h.count_items().await, 2);
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!([crate::bulk_put::rows::CORRUPT_STREAM_MESSAGE])
    +    );
    +}
    +
    +/// Where the first invalid byte sits decides the message (measured
    +/// ): inside a string literal it is the UTF-8 message; at the
    +/// start of the line, between tokens, or in plain non-JSON text it is the
    +/// unexpected-token message.
    +#[tokio::test]
    +async fn an_invalid_byte_outside_a_string_literal_is_an_unexpected_token() {
    +    for (label, bad_line) in [
    +        (
    +            "leading bytes",
    +            b"\xff\xfe{\"Item\":{\"pk\":{\"S\":\"b\"},\"sk\":{\"S\":\"s\"}}}".to_vec(),
    +        ),
    +        (
    +            "between tokens",
    +            b"{\"Item\":{\"pk\":{\"S\":\"b\"},\"sk\":{\"S\":\"s\"}},\"x\":\xff\xfe}".to_vec(),
    +        ),
    +        ("plain text", b"nonsense text here".to_vec()),
    +        // Measured on the service: an odd count of
    +        // quotes before the invalid byte does not make it a string-literal
    +        // fault when the prefix is not a well-formed JSON prefix.
    +        ("csv-ish with one quote", b"name,\"value\xff\xfe".to_vec()),
    +        ("bareword then quote", b"plain junk x\"y\xff".to_vec()),
    +        (
    +            "complete object then stray quote",
    +            b"{\"Item\":{\"pk\":{\"S\":\"b\"},\"sk\":{\"S\":\"s\"}}}\"\xff".to_vec(),
    +        ),
    +    ] {
    +        let h = Harness::new().await;
    +        let mut bytes = file_of(&[row(0)]);
    +        bytes.extend_from_slice(&bad_line);
    +        bytes.push(b'\n');
    +        bytes.extend_from_slice(&file_of(&[row(2)]));
    +        h.write_input("part-000.json", &bytes);
    +        let files = h.enumerate().await;
    +        let record = h
    +            .run_to_end(files, input_config(false), both_reports(), config())
    +            .await;
    +        assert_eq!(record.status, "COMPLETED_WITH_ERRORS", "{label}");
    +        assert_eq!(record.processed_item_count, 2, "{label}");
    +        let report = h
    +            .read_report(&record.operation_id, ReportKind::Error)
    +            .expect("error report rendered");
    +        assert_eq!(
    +            report[0]["errorMessages"],
    +            json!([crate::bulk_put::rows::CORRUPT_STREAM_MESSAGE]),
    +            "{label}"
    +        );
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_truncated_utf8_sequence_inside_a_string_is_the_utf8_fault() {
    +    let h = Harness::new().await;
    +    let mut bytes = file_of(&[row(0)]);
    +    bytes.extend_from_slice(b"{\"Item\":{\"pk\":{\"S\":\"b\xc3\"},\"sk\":{\"S\":\"s\"}}}\n");
    +    bytes.extend_from_slice(&file_of(&[row(2)]));
    +    h.write_input("part-000.json", &bytes);
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!([crate::bulk_put::rows::INVALID_UTF8_MESSAGE])
    +    );
    +}
    +
    +/// A binary value that is not base64 did not decode; it abandons the file
    +/// like a bad type tag, the class `PutItem`'s `SerializationException`
    +/// answer places it in.
    +#[tokio::test]
    +async fn a_binary_value_that_is_not_base64_abandons_the_file() {
    +    let h = Harness::new().await;
    +    let rows = [
    +        row(0),
    +        r#"{"Item": {"pk": {"S": "k1"}, "sk": {"S": "s"}, "b": {"B": "!!!notbase64"}}}"#.to_owned(),
    +        row(2),
    +    ];
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 2);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert_eq!(h.count_items().await, 1);
    +    assert!(
    +        h.get("k2").await.is_none(),
    +        "the rest of the file is not read"
    +    );
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!([crate::bulk_put::rows::BINARY_NOT_BASE64_MESSAGE])
    +    );
    +}
    +
    +// A file the run opened but ended inside before
    +// counting any row (cursor_row == 0) is PARTIALLY_PROCESSED with an empty
    +// range, told apart from a file the run never opened (NOT_PROCESSED) by the
    +// current_file_opened flag. The single input file's first read fails with an
    +// I/O error, which opens the file and fails the operation before any row is
    +// counted; the manifest marks it PARTIALLY_PROCESSED []. An I/O error is not
    +// a changed-file signal, so the file is not abandoned, and it is not an
    +// unavailable object, so it is not NOT_AVAILABLE.
    +#[tokio::test]
    +async fn a_file_opened_but_ended_before_its_first_row_is_partially_processed_with_no_range() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(5)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let source = Arc::new(IoAtSource {
    +        inner: h.fs_source(),
    +        at: 0,
    +        handed: Arc::new(AtomicU64::new(0)),
    +    });
    +    let deps = h.deps_with("runner-1", config(), source, Arc::new(h.fs_sink()));
    +    runner::run(&deps, record)
    +        .await
    +        .expect("the io failure is recorded as a terminal state, not returned");
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("InternalServerError"));
    +    assert_eq!(record.cursor_row_index, 0, "no row was counted");
    +    let manifest = h.read_manifest(&op).expect("manifest written");
    +    assert_eq!(
    +        manifest[0]["fileProcessingStatus"],
    +        json!("PARTIALLY_PROCESSED"),
    +        "a file opened with no row counted is partially processed, not unprocessed"
    +    );
    +    assert_eq!(manifest[0]["processedRanges"], json!([]));
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/mod.rs b/crates/engine/src/bulk_put/tests/runner/mod.rs
    new file mode 100644
    index 000000000..ed2524468
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/mod.rs
    @@ -0,0 +1,268 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Behaviour tests for the bulk put runner, written from the specification.
    +//!
    +//! Every test drives `runner::run` against a real in-memory `SQLite` storage
    +//! engine, with temporary directories as the import and export roots, a
    +//! table created through the storage engine, and operations created through
    +//! `start_bulk_operation` and claimed through `claim_runnable`, so the
    +//! record the runner receives is the record a worker would receive.
    +//! Assertions read terminal state through `get_bulk_operation`, item state
    +//! through `get_item` and `scan`, the row-outcome ledger through
    +//! `bulk_row_outcome`, and the rendered report files by decompressing and
    +//! parsing them.
    +
    +use std::path::PathBuf;
    +use std::sync::Arc;
    +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
    +use std::time::{Duration, SystemTime};
    +
    +use serde_json::{Value, json};
    +
    +use extenddb_core::expression::{Expr, ExpressionMaps, KeyCondition, UpdateAction};
    +use extenddb_core::limits::LimitsConfig;
    +use extenddb_core::metrics::{Dimension, MetricName, MetricsCollector, MetricsQuery, TimeWindow};
    +use extenddb_core::types::{
    +    AttributeValue, CreateTableInput, DeleteTableInput, DescribeStreamInput, DescribeTableInput,
    +    IndexInfo, Item, ListTablesInput, ListTablesOutput, StreamDescription, StreamEventName,
    +    StreamRecord, TableDescription, TableKeyInfo, Tag, TimeToLiveDescription, UpdateTableInput,
    +};
    +use extenddb_storage::error::StorageError;
    +use extenddb_storage::server_components::ServerComponentsOptions;
    +use extenddb_storage::{
    +    BoxedFuture as BoxFuture, BulkOperationListResult, BulkOperationRecord, BulkOperationStore,
    +    BulkRowMarker, CancelOutcome, CancellationToken, DataEngine, FinishState, IdempotencyKey,
    +    InputFile, ItemPairResult, LeaseState, ListCursor, MetadataEngine, NewBulkOperation, Progress,
    +    QueryResult, ReportKind, ReportRecord, RowOutcome, StartOutcome, StorageEngine, StreamCapture,
    +    StreamEngine, StreamListResult, StreamRecordsResult, TableEngine, Terminal, TransactGetOp,
    +    TransactWriteOp, TtlTableInfo, WorkerStore,
    +};
    +
    +use crate::bulk_put::arn;
    +use crate::bulk_put::authorize::BulkAuthorizer;
    +use crate::bulk_put::pacer::WritePacer;
    +use crate::bulk_put::report;
    +use crate::bulk_put::run_context::STORE_FILESYSTEM;
    +use crate::bulk_put::runner::{self, RunnerConfig, RunnerDeps};
    +use crate::bulk_put::source::{
    +    BucketRef, BulkSink, BulkSource, FsSink, FsSource, InputCodec, LineReader, OutputSse,
    +    ReportWriter, SinkError, SourceError, SourceFile,
    +};
    +use crate::expression_helpers::resolve_condition;
    +
    +const REGION: &str = "us-east-1";
    +const ACCOUNT: &str = "123456789012";
    +const IN_BUCKET: &str = "loads";
    +const OUT_BUCKET: &str = "reports";
    +const OUT_PREFIX: &str = "run";
    +const TABLE: &str = "orders";
    +
    +/// The one message every parse-level row fault carries.
    +const ABANDON: &str = "Expected DynamoDB type to be one of [S, N, B, BOOL, NULL, L, M, SS, NS, \
    +                       BS]. Remainder of the file will not be processed.";
    +const MISSING_KEY: &str =
    +    "One or more parameter values were invalid: Missing the key sk in the item";
    +const TYPE_MISMATCH: &str =
    +    "One or more parameter values were invalid: Type mismatch for key pk expected: S actual: N";
    +const OVERSIZED: &str = "Item size has exceeded the maximum allowed size";
    +const CONDITION_FAILED: &str = "The conditional request failed";
    +const TABLE_NOT_FOUND: &str = "Requested resource not found";
    +
    +// Input rows.
    +
    +/// One plain input row keyed `k{i}`, sort key `s`, small enough that its
    +/// write rounds up to the 1 KB minimum.
    +pub(super) fn row(i: usize) -> String {
    +    format!(
    +        "{{\"Item\": {{\"pk\": {{\"S\": \"k{i}\"}}, \"sk\": {{\"S\": \"s\"}}, \"v\": {{\"N\": \"{i}\"}}}}}}"
    +    )
    +}
    +
    +/// A row missing the `sk` key attribute: an item-level fault.
    +pub(super) fn row_missing_sk(i: usize) -> String {
    +    format!("{{\"Item\": {{\"pk\": {{\"S\": \"k{i}\"}}, \"v\": {{\"N\": \"0\"}}}}}}")
    +}
    +
    +/// A row whose attribute value carries an unknown type tag: a parse-level
    +/// fault that abandons the rest of the file.
    +fn row_bad_type_tag(i: usize) -> String {
    +    format!(
    +        "{{\"Item\": {{\"pk\": {{\"S\": \"k{i}\"}}, \"sk\": {{\"S\": \"s\"}}, \"v\": {{\"X\": \"1\"}}}}}}"
    +    )
    +}
    +
    +/// Join rows into file contents, one per line, newline-terminated.
    +pub(super) fn file_of(rows: &[String]) -> Vec {
    +    let mut out = String::new();
    +    for r in rows {
    +        out.push_str(r);
    +        out.push('\n');
    +    }
    +    out.into_bytes()
    +}
    +
    +pub(super) fn plain_rows(n: usize) -> Vec {
    +    (0..n).map(row).collect()
    +}
    +
    +fn gzip_bytes(content: &[u8]) -> Vec {
    +    use std::io::Write;
    +    let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
    +    enc.write_all(content).unwrap();
    +    enc.finish().unwrap()
    +}
    +
    +fn gunzip_lines(bytes: &[u8]) -> Vec {
    +    use std::io::Read;
    +    let mut decoder = flate2::read::GzDecoder::new(bytes);
    +    let mut plain = String::new();
    +    decoder.read_to_string(&mut plain).unwrap();
    +    plain
    +        .lines()
    +        .map(|line| serde_json::from_str(line).unwrap())
    +        .collect()
    +}
    +
    +fn item(pk: &str, v: &str) -> Item {
    +    let mut item = Item::new();
    +    item.insert("pk".to_owned(), AttributeValue::S(pk.to_owned()));
    +    item.insert("sk".to_owned(), AttributeValue::S("s".to_owned()));
    +    item.insert("v".to_owned(), AttributeValue::N(v.to_owned()));
    +    item
    +}
    +
    +fn key(pk: &str) -> Item {
    +    let mut key = Item::new();
    +    key.insert("pk".to_owned(), AttributeValue::S(pk.to_owned()));
    +    key.insert("sk".to_owned(), AttributeValue::S("s".to_owned()));
    +    key
    +}
    +
    +fn now_ms() -> i64 {
    +    i64::try_from(
    +        SystemTime::now()
    +            .duration_since(SystemTime::UNIX_EPOCH)
    +            .unwrap()
    +            .as_millis(),
    +    )
    +    .unwrap()
    +}
    +
    +fn input_config(gzip: bool) -> Value {
    +    json!({
    +        "S3Bucket": IN_BUCKET,
    +        "InputFormat": "DYNAMODB_JSON",
    +        "InputCompressionType": if gzip { "GZIP" } else { "NONE" },
    +    })
    +}
    +
    +fn zstd_input_config() -> Value {
    +    json!({
    +        "S3Bucket": IN_BUCKET,
    +        "InputFormat": "DYNAMODB_JSON",
    +        "InputCompressionType": "ZSTD",
    +    })
    +}
    +
    +fn zstd_bytes(content: &[u8]) -> Vec {
    +    ruzstd::encoding::compress_to_vec(content, ruzstd::encoding::CompressionLevel::Fastest)
    +}
    +
    +fn output_config(bucket: Option<&str>, types: &[&str]) -> Value {
    +    let mut config = json!({ "S3KeyPrefix": OUT_PREFIX, "OutputType": types });
    +    if let Some(bucket) = bucket {
    +        config["S3Bucket"] = json!(bucket);
    +    }
    +    config
    +}
    +
    +fn both_reports() -> Value {
    +    output_config(Some(OUT_BUCKET), &["ERROR", "CONDITION_CHECK_FAILURE"])
    +}
    +
    +/// One row whose attribute value nests `depth` map levels.
    +fn row_nested(index: usize, depth: usize) -> String {
    +    let mut value = String::from(r#"{"S":"leaf"}"#);
    +    for _ in 0..depth {
    +        value = format!(r#"{{"M":{{"k":{value}}}}}"#);
    +    }
    +    format!(r#"{{"Item":{{"pk":{{"S":"k{index}"}},"sk":{{"S":"s"}},"deep":{value}}}}}"#)
    +}
    +
    +// Replay and the row-outcome ledger.
    +
    +/// A small conditional row against key `c`.
    +fn condition_row(v: &str, expression: &str) -> String {
    +    format!(
    +        "{{\"Item\": {{\"pk\": {{\"S\": \"c\"}}, \"sk\": {{\"S\": \"s\"}}, \"v\": {{\"N\": \"{v}\"}}}}, \
    +         \"ConditionExpression\": \"{expression}\"}}"
    +    )
    +}
    +
    +// Report record fidelity at runner level.
    +
    +/// The serialized size of one report record from a rendered file, used to
    +/// size a byte cap exactly. All records of the calibration run must be the
    +/// same size, so a cap of two sizes keeps exactly two records.
    +fn calibrated_record_bytes(report: &[Value]) -> u64 {
    +    let sizes: Vec = report.iter().map(report::record_bytes).collect();
    +    assert!(
    +        sizes.windows(2).all(|w| w[0] == w[1]),
    +        "calibration records differ in size: {sizes:?}"
    +    );
    +    sizes[0]
    +}
    +
    +mod abandon_cut_restart;
    +mod batched;
    +mod cancellation;
    +mod caps;
    +mod compressed_cut;
    +mod disk_full;
    +mod durability;
    +mod exactly_once;
    +mod fake_authorizer;
    +mod faults;
    +mod faults_engines;
    +mod harness;
    +mod held_location;
    +mod held_resume;
    +mod input;
    +mod jail;
    +mod lease_in_place;
    +mod lease_keeper;
    +mod lifecycle_cells;
    +mod manifest;
    +mod open_refusal;
    +mod output_faults;
    +mod outputs_after_outcome;
    +mod overwritten;
    +mod pacing_lease;
    +mod parsing;
    +mod pins;
    +mod recorded_store;
    +mod render;
    +mod replay;
    +mod reports;
    +mod restart_abandons;
    +mod resume;
    +mod resume_bound;
    +mod rotation;
    +mod rows;
    +mod rows_faults;
    +mod s3_actions;
    +mod service_etags;
    +mod sink_faults;
    +mod source_faults;
    +mod store_outage;
    +mod stored_key;
    +mod table_binding;
    +mod takeover;
    +mod writes;
    +
    +pub(crate) use fake_authorizer::*;
    +pub(crate) use faults::*;
    +pub(crate) use harness::*;
    +use sink_faults::*;
    +use source_faults::*;
    diff --git a/crates/engine/src/bulk_put/tests/runner/open_refusal.rs b/crates/engine/src/bulk_put/tests/runner/open_refusal.rs
    new file mode 100644
    index 000000000..b5bba6f2b
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/open_refusal.rs
    @@ -0,0 +1,340 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +use crate::bulk_put::source::StoreFault;
    +
    +// An input file refused when the runner opens it. Measured on the service:
    +// an object the operation could not read before any of its rows was counted
    +// is `NOT_PROCESSED` in the manifest, with no processed range and its entity
    +// tag; an object refused after rows of it were counted is `NOT_AVAILABLE`.
    +
    +/// Refuses every open of the input file at `path` with an I/O error of
    +/// `kind`; every other call goes to the real source, so the refused file can
    +/// still be hashed for the manifest.
    +struct RefuseAtOpenSource {
    +    inner: FsSource,
    +    path: &'static str,
    +    kind: std::io::ErrorKind,
    +}
    +
    +impl BulkSource for RefuseAtOpenSource {
    +    fn check_bucket(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +    ) -> BoxFuture<'_, Result<(), SourceError>> {
    +        self.inner.check_bucket(bucket, prefix)
    +    }
    +
    +    fn etag(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.etag(bucket, file)
    +    }
    +
    +    fn line_count(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.line_count(bucket, file, compression)
    +    }
    +
    +    fn list(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +        max_files: u64,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        self.inner.list(bucket, prefix, max_files)
    +    }
    +
    +    fn open(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        if file.path == self.path {
    +            let kind = self.kind;
    +            return Box::pin(async move {
    +                Err(SourceError::Io(std::io::Error::new(
    +                    kind,
    +                    "input object refused at its open",
    +                )))
    +            });
    +        }
    +        self.inner.open(bucket, file, compression)
    +    }
    +}
    +
    +fn status_of(line: &Value) -> &str {
    +    line["fileProcessingStatus"]
    +        .as_str()
    +        .expect("the manifest line carries a status")
    +}
    +
    +#[tokio::test]
    +async fn a_file_refused_at_its_open_is_not_processed_and_keeps_its_entity_tag() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(5)));
    +    h.write_input("part-001.json", &file_of(&plain_rows(3)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let source = Arc::new(RefuseAtOpenSource {
    +        inner: h.fs_source(),
    +        path: "part-000.json",
    +        kind: std::io::ErrorKind::PermissionDenied,
    +    });
    +    let deps = h.deps_with("runner-1", config(), source, Arc::new(h.fs_sink()));
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3AccessDenied"));
    +    assert_eq!(record.failure_message.as_deref(), Some("Access Denied"));
    +    assert_eq!(record.processed_item_count, 0);
    +    assert_eq!(record.failed_item_count, 0);
    +    assert_eq!(record.processed_input_bytes, 0);
    +    assert_eq!(h.count_items().await, 0);
    +
    +    let manifest = h.read_manifest(&op).expect("the manifest is written");
    +    assert_eq!(manifest.len(), 2);
    +    assert_eq!(
    +        status_of(&manifest[0]),
    +        "NOT_PROCESSED",
    +        "a file refused before any of its rows was counted is not processed"
    +    );
    +    assert_eq!(manifest[0]["processedRanges"], json!([]));
    +    assert_eq!(manifest[0]["etag"], json!(h.input_md5("part-000.json")));
    +    assert_eq!(status_of(&manifest[1]), "NOT_PROCESSED");
    +    assert_eq!(manifest[1]["etag"], json!(h.input_md5("part-001.json")));
    +}
    +
    +#[tokio::test]
    +async fn a_file_deleted_before_its_open_is_not_processed_with_no_entity_tag() {
    +    // The first file is read to its end; the second is gone by the time the
    +    // runner reaches it. None of the second file's rows was counted, so it is
    +    // NOT_PROCESSED, and since it cannot be hashed its line carries an empty
    +    // tag rather than the render failing and dropping the manifest.
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(4)));
    +    h.write_input("part-001.json", &file_of(&plain_rows(3)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    std::fs::remove_file(
    +        h.store
    +            .0
    +            .join(ACCOUNT)
    +            .join(IN_BUCKET)
    +            .join("part-001.json"),
    +    )
    +    .unwrap();
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-1", config()), record)
    +        .await
    +        .expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3NoSuchKey"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("The specified key does not exist.")
    +    );
    +    assert_eq!(
    +        record.processed_item_count, 4,
    +        "the first file's rows stand"
    +    );
    +    assert_eq!(h.count_items().await, 4);
    +
    +    let manifest = h.read_manifest(&op).expect("the manifest is written");
    +    assert_eq!(manifest.len(), 2);
    +    assert_eq!(status_of(&manifest[0]), "FULLY_PROCESSED");
    +    assert_eq!(manifest[0]["processedRanges"], json!([[0, 3]]));
    +    assert_eq!(status_of(&manifest[1]), "NOT_PROCESSED");
    +    assert_eq!(manifest[1]["processedRanges"], json!([]));
    +    assert_eq!(manifest[1]["etag"], json!(""));
    +}
    +
    +#[tokio::test]
    +async fn a_file_refused_at_a_resumed_open_after_rows_of_it_were_counted_is_not_available() {
    +    // The first claim checkpoints five rows of the file and dies; the file is
    +    // then deleted. The resumed claim's open fails, after rows of the file
    +    // were counted, so the file is NOT_AVAILABLE.
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(12)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 7,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    assert_eq!(h.record(&op).await.cursor_row_index, 5);
    +
    +    std::fs::remove_file(
    +        h.store
    +            .0
    +            .join(ACCOUNT)
    +            .join(IN_BUCKET)
    +            .join("part-000.json"),
    +    )
    +    .unwrap();
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-2", config()), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3NoSuchKey"));
    +    assert_eq!(
    +        record.processed_item_count, 5,
    +        "the checkpointed rows stand"
    +    );
    +
    +    let manifest = h.read_manifest(&op).expect("the manifest is written");
    +    assert_eq!(manifest.len(), 1);
    +    assert_eq!(status_of(&manifest[0]), "NOT_AVAILABLE");
    +    assert_eq!(manifest[0]["processedRanges"], json!([]));
    +    assert_eq!(manifest[0]["etag"], json!(""));
    +}
    +
    +/// Answers the open of the input file at `path` with the store fault the S3
    +/// store renders for a GET of the first range (`after_first_byte` false);
    +/// every other call goes to the real source.
    +struct StoreFaultAtOpenSource {
    +    inner: FsSource,
    +    path: &'static str,
    +}
    +
    +impl BulkSource for StoreFaultAtOpenSource {
    +    fn check_bucket(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +    ) -> BoxFuture<'_, Result<(), SourceError>> {
    +        self.inner.check_bucket(bucket, prefix)
    +    }
    +
    +    fn etag(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.etag(bucket, file)
    +    }
    +
    +    fn line_count(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.line_count(bucket, file, compression)
    +    }
    +
    +    fn list(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +        max_files: u64,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        self.inner.list(bucket, prefix, max_files)
    +    }
    +
    +    fn open(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        if file.path == self.path {
    +            return Box::pin(async {
    +                Err(SourceError::Store(StoreFault {
    +                    code: "S3NoSuchKey",
    +                    message: S3_GONE.to_owned(),
    +                    after_first_byte: false,
    +                }))
    +            });
    +        }
    +        self.inner.open(bucket, file, compression)
    +    }
    +}
    +
    +const S3_GONE: &str = "The specified key does not exist. (Service: Amazon S3; Status Code: 404; \
    +                       Error Code: NoSuchKey; Request ID: 4KQS8G3V7XJ2M1PA; S3 Extended Request \
    +                       ID: null; Proxy: null)";
    +
    +#[tokio::test]
    +async fn a_store_fault_at_a_resumed_open_after_rows_of_it_were_counted_is_not_available() {
    +    // As above, with the fault the S3 store renders for the first range of
    +    // the resumed open: a GET that reports itself before the first byte.
    +    // Rows of the file were counted by the first claim, so the fault is
    +    // after the file's first byte and the file is NOT_AVAILABLE.
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(12)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 7,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    assert_eq!(h.record(&op).await.cursor_row_index, 5);
    +
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let source = Arc::new(StoreFaultAtOpenSource {
    +        inner: h.fs_source(),
    +        path: "part-000.json",
    +    });
    +    let deps = h.deps_with("runner-2", config(), source, Arc::new(h.fs_sink()));
    +    runner::run(&deps, record).await.expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3NoSuchKey"));
    +    assert_eq!(record.failure_message.as_deref(), Some(S3_GONE));
    +    assert_eq!(
    +        record.processed_item_count, 5,
    +        "the checkpointed rows stand"
    +    );
    +
    +    let manifest = h.read_manifest(&op).expect("the manifest is written");
    +    assert_eq!(manifest.len(), 1);
    +    assert_eq!(status_of(&manifest[0]), "NOT_AVAILABLE");
    +    assert_eq!(manifest[0]["processedRanges"], json!([]));
    +    assert_eq!(manifest[0]["etag"], json!(h.input_md5("part-000.json")));
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/output_faults.rs b/crates/engine/src/bulk_put/tests/runner/output_faults.rs
    new file mode 100644
    index 000000000..866fd8454
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/output_faults.rs
    @@ -0,0 +1,163 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +use crate::bulk_put::authorize::S3Action;
    +use crate::bulk_put::source::StoreFault;
    +
    +// Output faults found after the output location check passed.
    +
    +const NO_BUCKET: &str = "The specified bucket does not exist (Service: Amazon S3; Status \
    +     Code: 404; Error Code: NoSuchBucket; Request ID: R1; S3 Extended Request ID: X1; Proxy: \
    +     null)";
    +
    +/// The filesystem sink, except that the manifest write answers `fault`, as
    +/// an S3 PutObject does for a bucket removed after its check.
    +struct ManifestWriteFails {
    +    inner: FsSink,
    +    fault: StoreFault,
    +}
    +
    +impl BulkSink for ManifestWriteFails {
    +    fn check_location(
    +        &self,
    +        at: &BucketRef<'_>,
    +        key: &str,
    +        sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result<(), SinkError>> {
    +        self.inner.check_location(at, key, sse)
    +    }
    +
    +    fn open(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        operation_id: &str,
    +        kind: ReportKind,
    +        owner: &str,
    +        sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result, SinkError>> {
    +        self.inner.open(at, prefix, operation_id, kind, owner, sse)
    +    }
    +
    +    fn write_manifest(
    +        &self,
    +        _at: &BucketRef<'_>,
    +        _prefix: &str,
    +        _operation_id: &str,
    +        _owner: &str,
    +        _sse: &OutputSse,
    +        _lines: Vec,
    +    ) -> BoxFuture<'_, Result> {
    +        let fault = self.fault.clone();
    +        Box::pin(async move { Err(SinkError::Store(fault)) })
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_manifest_write_refused_after_its_check_keeps_the_manifest_prefix() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(2)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let sink = Arc::new(ManifestWriteFails {
    +        inner: h.fs_sink(),
    +        fault: StoreFault {
    +            code: "S3NoSuchBucket",
    +            message: NO_BUCKET.to_owned(),
    +            after_first_byte: false,
    +        },
    +    });
    +    let deps = h.deps_with("runner-1", config(), Arc::new(h.fs_source()), sink);
    +    runner::run(&deps, record).await.expect("runner run");
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3NoSuchBucket"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some(format!("Manifest is not available. {NO_BUCKET}").as_str())
    +    );
    +    assert_eq!(record.processed_item_count, 2);
    +}
    +
    +/// A good row, a row with no sort key (the first report record, of the
    +/// error kind), a conditional row whose condition fails (the first record
    +/// of the condition kind) and a good row, run with checkpoints of
    +/// `batch_size` rows. Only the condition report's write is denied.
    +async fn run_denying_the_condition_report(batch_size: usize) -> BulkOperationRecord {
    +    let h = Harness::new().await;
    +    let rows = vec![
    +        row(0),
    +        row_missing_sk(1),
    +        condition_row("1", "attribute_exists(pk)"),
    +        row(3),
    +    ];
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h
    +        .start_with_run_context(
    +            files,
    +            input_config(false),
    +            both_reports(),
    +            Some(run_context_with_caller()),
    +        )
    +        .await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let mut cfg = config();
    +    cfg.batch_size = batch_size;
    +    let mut deps = h.deps("runner-1", cfg);
    +    let condition_key = "/condition-check-failures/output-00000.json.gz";
    +    let fake = Arc::new(FakeAuthorizer::denying(S3Action::PutObject, condition_key));
    +    deps.authorizer = Some(Arc::clone(&fake) as Arc);
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    let key = report::report_key(
    +        OUT_PREFIX,
    +        ACCOUNT,
    +        &op,
    +        ReportKind::ConditionCheckFailure,
    +        0,
    +    );
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3AccessDenied"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some(denied("s3:PutObject", &format!("arn:aws:s3:::{OUT_BUCKET}/{key}")).as_str())
    +    );
    +    assert_eq!(
    +        (
    +            record.processed_item_count,
    +            record.failed_item_count,
    +            record.processed_input_bytes
    +        ),
    +        (3, 0, 0),
    +        "{record:?}"
    +    );
    +    assert!(h.read_report(&op, ReportKind::Error).is_none());
    +    assert!(
    +        h.read_report(&op, ReportKind::ConditionCheckFailure)
    +            .is_none()
    +    );
    +    assert!(h.read_manifest(&op).is_none(), "no manifest is written");
    +    record
    +}
    +
    +#[tokio::test]
    +async fn a_denied_condition_report_is_found_at_its_own_first_record_and_held() {
    +    let record = run_denying_the_condition_report(25).await;
    +    assert_eq!(record.report_record_count, 0);
    +}
    +
    +#[tokio::test]
    +async fn an_error_record_checkpointed_before_the_hold_is_not_rendered() {
    +    // With a checkpoint after every row the error record is persisted before
    +    // the condition kind is checked; the hold still renders nothing.
    +    let record = run_denying_the_condition_report(1).await;
    +    assert_eq!(
    +        record.report_record_count, 1,
    +        "the error record is persisted"
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/outputs_after_outcome.rs b/crates/engine/src/bulk_put/tests/runner/outputs_after_outcome.rs
    new file mode 100644
    index 000000000..c8a5a368a
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/outputs_after_outcome.rs
    @@ -0,0 +1,172 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! A run that was cancelled, or lost its table, while it read input still
    +//! writes its reports and its manifest when the writes outlast a lease
    +//! renewal: the renewals during the writes act only on a lost lease.
    +
    +use super::source_faults::{ActAtSource, LineAction};
    +use super::*;
    +
    +const LEASE: Duration = Duration::from_millis(300);
    +
    +/// Longer than one lease, so every write spans at least one renewal.
    +const SLOW: Duration = Duration::from_millis(800);
    +
    +/// The filesystem sink with every report page write and the manifest
    +/// write answering after `SLOW`.
    +struct SlowSink {
    +    inner: FsSink,
    +}
    +
    +struct SlowWriter {
    +    inner: Box,
    +}
    +
    +impl BulkSink for SlowSink {
    +    fn check_location(
    +        &self,
    +        at: &BucketRef<'_>,
    +        key: &str,
    +        sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result<(), SinkError>> {
    +        self.inner.check_location(at, key, sse)
    +    }
    +
    +    fn open(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        operation_id: &str,
    +        kind: ReportKind,
    +        owner: &str,
    +        sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result, SinkError>> {
    +        let opened = self.inner.open(at, prefix, operation_id, kind, owner, sse);
    +        Box::pin(async move {
    +            let inner = opened.await?;
    +            Ok(Box::new(SlowWriter { inner }) as Box)
    +        })
    +    }
    +
    +    fn write_manifest(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        sse: &OutputSse,
    +        lines: Vec,
    +    ) -> BoxFuture<'_, Result> {
    +        let written = self
    +            .inner
    +            .write_manifest(at, prefix, operation_id, owner, sse, lines);
    +        Box::pin(async move {
    +            tokio::time::sleep(SLOW).await;
    +            written.await
    +        })
    +    }
    +}
    +
    +impl ReportWriter for SlowWriter {
    +    fn write_page(&mut self, records: Vec) -> BoxFuture<'_, Result<(), SinkError>> {
    +        Box::pin(async move {
    +            tokio::time::sleep(SLOW).await;
    +            self.inner.write_page(records).await
    +        })
    +    }
    +
    +    fn finish(self: Box) -> BoxFuture<'static, Result> {
    +        self.inner.finish()
    +    }
    +
    +    fn object_index(&self) -> u32 {
    +        self.inner.object_index()
    +    }
    +}
    +
    +#[derive(Clone, Copy)]
    +enum During {
    +    Cancel,
    +    DeleteTable,
    +}
    +
    +/// Ten rows with row 2 missing its sort key, batch size 5; `during` runs
    +/// before line 7 is handed to the runner. Runs to the end.
    +async fn run_with(during: During) -> (Harness, String) {
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(10);
    +    rows[2] = row_missing_sk(2);
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", LEASE).await;
    +    let storage = Arc::clone(&h.storage);
    +    let acted_on = op.clone();
    +    let action: LineAction = Arc::new(move || {
    +        let storage = Arc::clone(&storage);
    +        let op = acted_on.clone();
    +        Box::pin(async move {
    +            match during {
    +                During::Cancel => {
    +                    storage
    +                        .request_cancel(ACCOUNT, &op)
    +                        .await
    +                        .expect("request cancel");
    +                }
    +                During::DeleteTable => {
    +                    storage
    +                        .delete_table(
    +                            ACCOUNT,
    +                            DeleteTableInput {
    +                                table_name: TABLE.to_owned(),
    +                            },
    +                        )
    +                        .await
    +                        .expect("delete table");
    +                }
    +            }
    +        })
    +    });
    +    let source = Arc::new(ActAtSource {
    +        inner: h.fs_source(),
    +        at: 7,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        fired: Arc::new(AtomicBool::new(false)),
    +        action,
    +    });
    +    let mut cfg = config();
    +    cfg.lease = LEASE;
    +    cfg.batch_size = 5;
    +    let sink = Arc::new(SlowSink { inner: h.fs_sink() });
    +    let deps = h.deps_with("runner-1", cfg, source, sink);
    +    tokio::time::timeout(Duration::from_secs(20), runner::run(&deps, record))
    +        .await
    +        .expect("the run ends")
    +        .expect("runner run");
    +    (h, op)
    +}
    +
    +#[tokio::test]
    +async fn a_run_cancelled_while_it_read_writes_its_reports_and_manifest() {
    +    let (h, op) = run_with(During::Cancel).await;
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "CANCELLED", "{record:?}");
    +    let report = h.read_report(&op, ReportKind::Error).expect("error report");
    +    assert_eq!(report.len(), 1);
    +    assert!(h.read_manifest(&op).is_some(), "the manifest is written");
    +}
    +
    +#[tokio::test]
    +async fn a_run_whose_table_was_deleted_writes_its_reports_and_manifest() {
    +    let (h, op) = run_with(During::DeleteTable).await;
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED", "{record:?}");
    +    assert_eq!(
    +        record.failure_code.as_deref(),
    +        Some("DynamoDBResourceNotFoundException")
    +    );
    +    let report = h.read_report(&op, ReportKind::Error).expect("error report");
    +    assert_eq!(report.len(), 1);
    +    assert!(h.read_manifest(&op).is_some(), "the manifest is written");
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/overwritten.rs b/crates/engine/src/bulk_put/tests/runner/overwritten.rs
    new file mode 100644
    index 000000000..99a9efdc8
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/overwritten.rs
    @@ -0,0 +1,289 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// An input object overwritten after the operation listed it. Measured on the
    +// service for an object replaced after the listing and before its read: the
    +// file counts one processed and failed row with the overwritten message and
    +// no `itemIndex`, none of its bytes are processed input, the manifest marks
    +// it `PARTIALLY_PROCESSED` with no range and its listed entity tag, the run
    +// goes on with the next file, and the operation ends `FAILED` with
    +// `ValidationException`. An object replaced during its read, or between a
    +// crash and the resume, is ExtendDB's extension of the same rule: the rows
    +// it delivered stand.
    +
    +const OVERWRITTEN: &str = "The S3 object could not be imported because it was overwritten";
    +const ITEMS_FAILED: &str = "Some of the items failed validation checks and were not ingested";
    +
    +/// Answers `SourceError::Overwritten` for the input file at `path`: at its
    +/// open when `after` is `None`, otherwise once `after` of its lines were
    +/// handed out. Every other call goes to the real source.
    +struct OverwrittenSource {
    +    inner: FsSource,
    +    path: &'static str,
    +    after: Option,
    +}
    +
    +struct OverwrittenAfterReader {
    +    inner: Box,
    +    after: u64,
    +    handed: u64,
    +}
    +
    +impl BulkSource for OverwrittenSource {
    +    fn check_bucket(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +    ) -> BoxFuture<'_, Result<(), SourceError>> {
    +        self.inner.check_bucket(bucket, prefix)
    +    }
    +
    +    fn etag(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.etag(bucket, file)
    +    }
    +
    +    fn line_count(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.line_count(bucket, file, compression)
    +    }
    +
    +    fn list(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +        max_files: u64,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        self.inner.list(bucket, prefix, max_files)
    +    }
    +
    +    fn open(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        if file.path != self.path {
    +            return self.inner.open(bucket, file, compression);
    +        }
    +        let Some(after) = self.after else {
    +            return Box::pin(async { Err(SourceError::Overwritten) });
    +        };
    +        let fut = self.inner.open(bucket, file, compression);
    +        Box::pin(async move {
    +            let inner = fut.await?;
    +            Ok(Box::new(OverwrittenAfterReader {
    +                inner,
    +                after,
    +                handed: 0,
    +            }) as Box)
    +        })
    +    }
    +}
    +
    +impl LineReader for OverwrittenAfterReader {
    +    fn next_line(
    +        &mut self,
    +        max_line_bytes: usize,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        Box::pin(async move {
    +            if self.handed >= self.after {
    +                return Err(SourceError::Overwritten);
    +            }
    +            let line = self.inner.next_line(max_line_bytes).await;
    +            if matches!(&line, Ok(Some(_))) {
    +                self.handed += 1;
    +            }
    +            line
    +        })
    +    }
    +
    +    fn bytes_consumed(&self) -> u64 {
    +        self.inner.bytes_consumed()
    +    }
    +
    +    fn last_line_end(&self) -> u64 {
    +        self.inner.last_line_end()
    +    }
    +}
    +
    +fn size_of(rows: &[String]) -> i64 {
    +    i64::try_from(file_of(rows).len()).unwrap()
    +}
    +
    +/// The one error record of `path`: its location names the object alone,
    +/// with no `itemIndex`, and it carries the overwritten message.
    +fn assert_overwritten_record(records: &[Value], path: &str) {
    +    let mine: Vec<_> = records
    +        .iter()
    +        .filter(|r| {
    +            r["itemLocation"]["inputSourceArn"]
    +                .as_str()
    +                .is_some_and(|arn| arn.ends_with(&format!("/{path}")))
    +        })
    +        .collect();
    +    assert_eq!(mine.len(), 1, "{records:?}");
    +    assert!(
    +        mine[0]["itemLocation"].get("itemIndex").is_none(),
    +        "{mine:?}"
    +    );
    +    assert_eq!(mine[0]["errorMessages"], json!([OVERWRITTEN]));
    +}
    +
    +#[tokio::test]
    +async fn an_object_overwritten_before_its_read_counts_one_failed_row_and_the_run_goes_on() {
    +    let h = Harness::new().await;
    +    let (first, second, third) = (plain_rows(5), plain_rows(3), plain_rows(4));
    +    h.write_input("part-000.json", &file_of(&first));
    +    h.write_input("part-001.json", &file_of(&second));
    +    h.write_input("part-002.json", &file_of(&third));
    +    let mut files = h.enumerate().await;
    +    // The tag an S3 listing gives; the manifest line of the overwritten
    +    // object carries it.
    +    files[1].etag = Some(h.input_md5("part-001.json"));
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let source = Arc::new(OverwrittenSource {
    +        inner: h.fs_source(),
    +        path: "part-001.json",
    +        after: None,
    +    });
    +    let deps = h.deps_with("runner-1", config(), source, Arc::new(h.fs_sink()));
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("ValidationException"));
    +    assert_eq!(record.failure_message.as_deref(), Some(ITEMS_FAILED));
    +    assert_eq!(record.processed_item_count, 5 + 1 + 4);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert_eq!(
    +        record.processed_input_bytes,
    +        size_of(&first) + size_of(&third),
    +        "none of the overwritten object's bytes are processed input"
    +    );
    +
    +    let errors = h.read_report(&op, ReportKind::Error).expect("error report");
    +    assert_overwritten_record(&errors, "part-001.json");
    +    let manifest = h.read_manifest(&op).expect("the manifest is written");
    +    assert_eq!(manifest.len(), 3);
    +    assert_eq!(
    +        manifest[0]["fileProcessingStatus"],
    +        json!("FULLY_PROCESSED")
    +    );
    +    assert_eq!(
    +        manifest[1]["fileProcessingStatus"],
    +        json!("PARTIALLY_PROCESSED")
    +    );
    +    assert_eq!(manifest[1]["processedRanges"], json!([]));
    +    assert_eq!(manifest[1]["etag"], json!(h.input_md5("part-001.json")));
    +    assert_eq!(
    +        manifest[2]["fileProcessingStatus"],
    +        json!("FULLY_PROCESSED")
    +    );
    +    assert_eq!(manifest[2]["processedRanges"], json!([[0, 3]]));
    +}
    +
    +#[tokio::test]
    +async fn an_object_overwritten_during_its_read_keeps_the_rows_it_delivered() {
    +    let h = Harness::new().await;
    +    let rows = plain_rows(8);
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let source = Arc::new(OverwrittenSource {
    +        inner: h.fs_source(),
    +        path: "part-000.json",
    +        after: Some(3),
    +    });
    +    let deps = h.deps_with("runner-1", config(), source, Arc::new(h.fs_sink()));
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("ValidationException"));
    +    assert_eq!(record.failure_message.as_deref(), Some(ITEMS_FAILED));
    +    assert_eq!(record.processed_item_count, 3 + 1);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert_eq!(h.count_items().await, 3, "the delivered rows stand");
    +    assert_eq!(
    +        record.processed_input_bytes,
    +        size_of(&rows[..3]),
    +        "the bytes of the delivered rows"
    +    );
    +    let errors = h.read_report(&op, ReportKind::Error).expect("error report");
    +    assert_overwritten_record(&errors, "part-000.json");
    +    let manifest = h.read_manifest(&op).expect("the manifest is written");
    +    assert_eq!(
    +        manifest[0]["fileProcessingStatus"],
    +        json!("PARTIALLY_PROCESSED")
    +    );
    +    assert_eq!(manifest[0]["processedRanges"], json!([[0, 2]]));
    +}
    +
    +#[tokio::test]
    +async fn an_object_overwritten_before_a_resumed_open_keeps_the_counted_rows() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(12)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +
    +    // The first claim checkpoints 5 rows and stops.
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 7,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let checkpointed = h.record(&op).await;
    +    assert_eq!(checkpointed.cursor_row_index, 5);
    +
    +    // The resumed claim finds the object replaced at its open.
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let source = Arc::new(OverwrittenSource {
    +        inner: h.fs_source(),
    +        path: "part-000.json",
    +        after: None,
    +    });
    +    let deps = h.deps_with("runner-2", config(), source, Arc::new(h.fs_sink()));
    +    runner::run(&deps, record).await.expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("ValidationException"));
    +    assert_eq!(record.processed_item_count, 5 + 1);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert_eq!(
    +        record.processed_input_bytes, checkpointed.processed_input_bytes,
    +        "the counted bytes stand"
    +    );
    +    let errors = h.read_report(&op, ReportKind::Error).expect("error report");
    +    assert_overwritten_record(&errors, "part-000.json");
    +    let manifest = h.read_manifest(&op).expect("the manifest is written");
    +    assert_eq!(
    +        manifest[0]["fileProcessingStatus"],
    +        json!("PARTIALLY_PROCESSED")
    +    );
    +    assert_eq!(manifest[0]["processedRanges"], json!([[0, 4]]));
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/pacing_lease.rs b/crates/engine/src/bulk_put/tests/runner/pacing_lease.rs
    new file mode 100644
    index 000000000..591aae1db
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/pacing_lease.rs
    @@ -0,0 +1,378 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +use super::lease_keeper::{Wait, WaitSource};
    +
    +// Write pacing. Each row's write units (1 KB rounded up, so one per test
    +// row) come out of the process bucket and the operation bucket right before
    +// the write. A bucket holds one second of capacity and starts full, so a
    +// run of 30 one-unit rows against a 20-per-second bucket pays 10 units of
    +// deficit: half a second of sleep. Lower bounds only; an upper bound on wall
    +// time is not asserted.
    +
    +#[tokio::test]
    +async fn the_process_pacer_bounds_plain_row_writes() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(30)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +
    +    let pacer = Arc::new(WritePacer::new(20));
    +    let mut deps = h.deps("runner-1", config());
    +    deps.pacer = Arc::clone(&pacer);
    +    let before = std::time::Instant::now();
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(h.count_items().await, 30);
    +    assert!(
    +        pacer.waited() >= Duration::from_millis(400),
    +        "30 units against a 20/s bucket wait about 500 ms, waited {:?}",
    +        pacer.waited()
    +    );
    +    assert!(before.elapsed() >= Duration::from_millis(400));
    +}
    +
    +#[tokio::test]
    +async fn the_process_pacer_bounds_conditional_row_writes_too() {
    +    let h = Harness::new().await;
    +    // Thirty distinct keys, each with a passing condition, so every row
    +    // reaches the conditional write site rather than the plain one.
    +    let rows: Vec = (0..30)
    +        .map(|i| {
    +            format!(
    +                "{{\"Item\": {{\"pk\": {{\"S\": \"k{i}\"}}, \"sk\": {{\"S\": \"s\"}}, \"v\": {{\"N\": \"{i}\"}}}}, \
    +                 \"ConditionExpression\": \"attribute_not_exists(pk)\"}}"
    +            )
    +        })
    +        .collect();
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +
    +    let pacer = Arc::new(WritePacer::new(20));
    +    let mut deps = h.deps("runner-1", config());
    +    deps.pacer = Arc::clone(&pacer);
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(h.count_items().await, 30);
    +    assert!(
    +        pacer.waited() >= Duration::from_millis(400),
    +        "conditional writes are paced like plain ones, waited {:?}",
    +        pacer.waited()
    +    );
    +}
    +
    +#[tokio::test]
    +async fn max_write_request_units_paces_the_operation_on_its_own() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(30)));
    +    let files = h.enumerate().await;
    +    let op = h
    +        .start_with_units(files, input_config(false), both_reports(), Some(20))
    +        .await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +
    +    // The process bucket is unbounded; only the request's ceiling applies.
    +    let before = std::time::Instant::now();
    +    runner::run(&h.deps("runner-1", config()), record)
    +        .await
    +        .expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(h.count_items().await, 30);
    +    assert!(
    +        before.elapsed() >= Duration::from_millis(400),
    +        "30 units at MaxWriteRequestUnits 20 take about 500 ms of pacing, took {:?}",
    +        before.elapsed()
    +    );
    +}
    +
    +// A runner whose lease another owner has taken stops at its next boundary.
    +//
    +// The storage layer refuses a stale owner's checkpoint and finish (contract
    +// cases), so the stale runner can never persist anything; what this pins is
    +// the runner-side half: on `LeaseState::Lost` at a checkpoint it stops
    +// reading and writing instead of driving the rest of the input a second
    +// time behind the new owner.
    +//
    +// Runner-1 keeps a 60-second lease by its own clock, so the deadline check
    +// before a write batch never fires here, while its storage asks the store
    +// for 100 ms at every checkpoint, so the store's lease runs out. Runner-1
    +// (batch 5) checkpoints at rows 5 and 10 and is frozen whole before it is
    +// handed row 13, as a paused process is. The store's lease runs out and a
    +// second owner runs to completion. Runner-1 then reads rows 13 and 14,
    +// reaches the boundary at row 15, writes its staged rows (its own deadline
    +// has not passed), and its checkpoint answers `Lost`: it stops there, 15
    +// lines handed, not 26.
    +#[tokio::test]
    +async fn a_runner_that_lost_its_lease_stops_at_the_next_boundary() {
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(26);
    +    rows[2] = row_missing_sk(2);
    +    rows[12] = row_missing_sk(12);
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h
    +        .start(files.clone(), input_config(false), both_reports())
    +        .await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    let (wait, frozen, resume) = Wait::freeze();
    +    let source = Arc::new(WaitSource::at_line(h.fs_source(), 13, wait));
    +    let handed = Arc::clone(&source.handed);
    +    let mut faulting = FaultingStorage::new(&h.storage);
    +    faulting.checkpoint_lease = Some(Duration::from_millis(100));
    +    let batch_sizes = Arc::clone(&faulting.batch_sizes);
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    cfg.lease = Duration::from_secs(60);
    +    let mut deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    deps.storage = Arc::new(faulting);
    +    let run = runner::run(&deps, record);
    +    tokio::pin!(run);
    +    tokio::select! {
    +        _ = &mut run => panic!("runner-1 must reach the freeze"),
    +        () = frozen.notified() => {}
    +    }
    +
    +    // Runner-1 is not polled from here until it is resumed. Its last
    +    // checkpoint (row 10) renewed the store's lease for 100 ms; wait it
    +    // out, then a second owner claims and finishes.
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    let deps2 = h.deps_with(
    +        "runner-2",
    +        cfg,
    +        Arc::new(h.fs_source()),
    +        Arc::new(h.fs_sink()),
    +    );
    +    runner::run(&deps2, record)
    +        .await
    +        .expect("the new owner's run");
    +    let batches_before_resume = batch_sizes.lock().expect("batch size recorder").len();
    +    resume.notify_one();
    +    run.await
    +        .expect("a lost lease is a clean stop, not an error");
    +
    +    assert_eq!(
    +        handed.load(Ordering::SeqCst),
    +        15,
    +        "runner-1 reads to the boundary after its lease was taken and no further"
    +    );
    +    // One more batch reached storage after the resume, the one at row 15,
    +    // so the deadline check before the write passed and the stop came from
    +    // the checkpoint's answer.
    +    assert_eq!(
    +        batch_sizes.lock().expect("batch size recorder").len(),
    +        batches_before_resume + 1,
    +        "runner-1 wrote its row 15 batch and stopped at the checkpoint after it"
    +    );
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 26);
    +    assert_eq!(record.failed_item_count, 2);
    +    assert_eq!(h.count_items().await, 24);
    +    assert_eq!(
    +        record.lease_owner, None,
    +        "finish cleared the new owner's lease"
    +    );
    +}
    +
    +/// The lease was once checked only at the checkpoint,
    +/// so a runner stalled past its lease with rows staged wrote them on resume,
    +/// after a new owner had replayed the same rows and gone on, and a key
    +/// written twice in file order could end holding the earlier image. The
    +/// runner now compares its local lease deadline before every write batch.
    +///
    +/// Runner-1 checkpoints at row 10 (lease 1 s), stages rows 10 to 14 and
    +/// is frozen whole at line 13 with three of them staged, as a paused
    +/// process is. Runner-2 claims the expired lease, finishes the operation,
    +/// and then k12 to k14 are overwritten with a marker. When runner-1 resumes
    +/// its staged rows must be dropped: the markers survive and the table ends
    +/// in the state the new owner left.
    +///
    +/// The lease is long next to the time from the checkpoint to line 13, so
    +/// no renewal is due when the read of line 13 starts and none is under way
    +/// when polling stops. A renewal stopped inside a storage call could hold
    +/// the engine's write lock, and runner-2's claim would wait for it without
    +/// end.
    +#[tokio::test]
    +async fn a_runner_stalled_past_its_lease_drops_its_staged_rows_instead_of_writing_them() {
    +    let h = Harness::new().await;
    +    let rows = plain_rows(26);
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h
    +        .start(files.clone(), input_config(false), both_reports())
    +        .await;
    +    let record = h.claim("runner-1", Duration::from_secs(1)).await;
    +
    +    let (wait, frozen, resume) = Wait::freeze();
    +    let source = Arc::new(WaitSource::at_line(h.fs_source(), 13, wait));
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    cfg.write_batch_rows = 5;
    +    cfg.lease = Duration::from_secs(1);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    let run = runner::run(&deps, record);
    +    tokio::pin!(run);
    +    tokio::select! {
    +        _ = &mut run => panic!("runner-1 must reach the freeze"),
    +        () = frozen.notified() => {}
    +    }
    +
    +    // Runner-1 is not polled from here until it is resumed.
    +    tokio::time::sleep(Duration::from_millis(1250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    cfg.write_batch_rows = 5;
    +    let deps2 = h.deps_with(
    +        "runner-2",
    +        cfg,
    +        Arc::new(h.fs_source()),
    +        Arc::new(h.fs_sink()),
    +    );
    +    runner::run(&deps2, record)
    +        .await
    +        .expect("the new owner's run");
    +    // A later write to the keys runner-1 still holds staged. The stale
    +    // runner must not overwrite it.
    +    let info = h.key_info().await;
    +    for i in 12..15 {
    +        h.storage
    +            .put_item(
    +                &info,
    +                item(&format!("k{i}"), "marker"),
    +                false,
    +                None,
    +                &extenddb_core::expression::ExpressionMaps::default(),
    +                None,
    +                None,
    +            )
    +            .await
    +            .expect("marker put");
    +    }
    +    resume.notify_one();
    +    run.await
    +        .expect("a lapsed lease is a clean stop, not an error");
    +
    +    for i in 12..15 {
    +        let got = h.get(&format!("k{i}")).await.expect("item present");
    +        assert_eq!(
    +            got.get("v"),
    +            Some(&AttributeValue::N("marker".to_owned())),
    +            "k{i}: the stale runner's staged write must not land after the new owner's"
    +        );
    +    }
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 26);
    +    assert_eq!(h.count_items().await, 26);
    +}
    +
    +// An overwrite that moves an item to another GSI key is a delete plus an
    +// insert on the index (`capacity_helpers::index_write_units`), so the index
    +// usage for one sub-1 KB item is two write units, 2048 bytes. The figure
    +// depends on the runner asking storage for the old image on an indexed table
    +// (`return_old: has_indexes`); without the old image the write is charged as
    +// an insert, 1024 bytes, and the existing `> 0` assertion cannot tell the two
    +// apart.
    +#[tokio::test]
    +async fn a_gsi_key_change_on_overwrite_charges_delete_plus_insert() {
    +    let h = Harness::new_with_gsi().await;
    +    let mut seeded = Item::new();
    +    seeded.insert("pk".to_owned(), AttributeValue::S("i".to_owned()));
    +    seeded.insert("sk".to_owned(), AttributeValue::S("s".to_owned()));
    +    seeded.insert("g".to_owned(), AttributeValue::S("old".to_owned()));
    +    h.put_direct(seeded).await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[json!({"Item": {
    +            "pk": {"S": "i"}, "sk": {"S": "s"}, "g": {"S": "new"}
    +        }})
    +        .to_string()]),
    +    );
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(
    +        record.usage["GlobalSecondaryIndexes"]["gsi1"]["BulkPutBytes"],
    +        json!(2048),
    +        "a changed index key is one delete plus one insert on the index"
    +    );
    +    assert_eq!(record.usage["Table"]["BulkPutBytes"], json!(1024));
    +}
    +
    +// The lease fence deadline is set from the instant taken
    +// before the checkpoint round trip plus one lease (`runner/write.rs`), so a
    +// runner stalled past one lease drops its staged rows. Crediting the round
    +// trip, or using two leases, would let a runner stalled between one and two
    +// leases still write its staged batch after a new owner had moved on. The
    +// existing drop test stalls 250 ms against a 100 ms lease, which a deadline
    +// of up to two leases (200 ms) also passes, so it does not pin the
    +// arithmetic. This one sits the stall between one lease and two: lease
    +// 200 ms, stall 300 ms. With the one-lease deadline the flush after the
    +// stall finds it passed and drops the staged rows; a two-lease deadline
    +// (400 ms) would not have passed and the rows would be written.
    +#[tokio::test]
    +async fn the_lease_fence_deadline_is_one_lease_not_two() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(26)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(200)).await;
    +
    +    // Just before line 13 is handed (after the checkpoint at row 10 renewed
    +    // the lease and rows 10 onward are staging), freeze the whole runner for
    +    // 300 ms: longer than one lease, shorter than two.
    +    let (wait, frozen, resume) = Wait::freeze();
    +    let source = Arc::new(WaitSource::at_line(h.fs_source(), 13, wait));
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    cfg.write_batch_rows = 5;
    +    cfg.lease = Duration::from_millis(200);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    let run = runner::run(&deps, record);
    +    tokio::pin!(run);
    +    tokio::select! {
    +        _ = &mut run => panic!("runner-1 must reach the freeze"),
    +        () = frozen.notified() => {}
    +    }
    +    tokio::time::sleep(Duration::from_millis(300)).await;
    +    resume.notify_one();
    +    run.await
    +        .expect("a lapsed lease is a clean stop, not an error");
    +
    +    // Rows 0 to 9 were checkpointed and written; the batch staged past the
    +    // lease (rows 10 to 14) was dropped at the flush, not written.
    +    assert_eq!(
    +        h.count_items().await,
    +        10,
    +        "the batch staged past one lease was dropped, not written"
    +    );
    +    for i in 10..15 {
    +        assert!(
    +            h.get(&format!("k{i}")).await.is_none(),
    +            "k{i}: a batch staged more than one lease after the checkpoint must not be written"
    +        );
    +    }
    +    let record = h.record(&op).await;
    +    assert_eq!(
    +        record.status, "IN_PROGRESS",
    +        "the runner stopped at the fence without a terminal transition"
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/parsing.rs b/crates/engine/src/bulk_put/tests/runner/parsing.rs
    new file mode 100644
    index 000000000..9c0df2535
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/parsing.rs
    @@ -0,0 +1,446 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// Row parsing acceptance.
    +
    +// Measured on the service: a top-level key outside the record shape abandons
    +// the file with its own message, the bad row counted processed and failed,
    +// the rows after it never read.
    +#[tokio::test]
    +async fn a_row_with_an_unknown_top_level_key_abandons_the_file() {
    +    let h = Harness::new().await;
    +    let extra =
    +        "{\"Item\": {\"pk\": {\"S\": \"k1\"}, \"sk\": {\"S\": \"s\"}}, \"Unknown\": 1}".to_owned();
    +    h.write_input("part-000.json", &file_of(&[row(0), extra, row(2)]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 2);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert!(h.get("k0").await.is_some());
    +    assert!(h.get("k1").await.is_none());
    +    assert!(
    +        h.get("k2").await.is_none(),
    +        "the row after the abandon is never read"
    +    );
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!([crate::bulk_put::rows::UNKNOWN_FIELD_MESSAGE])
    +    );
    +    assert!(report[0]["itemLocation"].get("itemIndex").is_none());
    +}
    +
    +// Measured on the service: 32 and 33 levels fail the row alone with the short
    +// message "Nesting Levels have exceeded supported limits" and the submitted
    +// image, and the file continues; 31 levels is stored.
    +#[tokio::test]
    +async fn a_row_nested_past_the_item_limit_fails_alone_with_the_short_message_and_image() {
    +    let h = Harness::new().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[
    +            row_nested(0, 31),
    +            row_nested(1, 32),
    +            row_nested(2, 33),
    +            row(3),
    +        ]),
    +    );
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 4);
    +    assert_eq!(record.failed_item_count, 2);
    +    assert!(h.get("k0").await.is_some(), "31 levels is stored");
    +    assert!(h.get("k1").await.is_none());
    +    assert!(h.get("k2").await.is_none());
    +    assert!(h.get("k3").await.is_some(), "the file continues");
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 2);
    +    for (record, index) in report.iter().zip([1, 2]) {
    +        assert_eq!(
    +            record["errorMessages"],
    +            json!(["Nesting Levels have exceeded supported limits"])
    +        );
    +        assert_eq!(record["itemLocation"]["itemIndex"], json!(index));
    +        assert_eq!(
    +            record["newImage"]["Item"]["pk"],
    +            json!({"S": format!("k{index}")})
    +        );
    +    }
    +}
    +
    +// Measured on the service: 64 levels abandons the file with the long nesting
    +// message and no item index; the rows after it are never read.
    +#[tokio::test]
    +async fn a_row_nested_sixty_four_levels_abandons_the_file() {
    +    let h = Harness::new().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[row(0), row_nested(1, 64), row(2)]),
    +    );
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 2);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert!(h.get("k0").await.is_some());
    +    assert!(h.get("k2").await.is_none());
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!([crate::bulk_put::rows::NESTING_ABANDON_MESSAGE])
    +    );
    +    assert!(report[0]["itemLocation"].get("itemIndex").is_none());
    +    assert!(report[0].get("newImage").is_none());
    +}
    +
    +#[tokio::test]
    +async fn a_non_parseable_line_abandons_the_file_with_the_unexpected_token_message() {
    +    let h = Harness::new().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[row(0), "this is not json {{{".to_owned(), row(2)]),
    +    );
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 2);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert!(h.get("k2").await.is_none(), "the file is abandoned");
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!([crate::bulk_put::rows::CORRUPT_STREAM_MESSAGE]),
    +        "measured on the service: text that is not JSON is an unexpected token"
    +    );
    +}
    +
    +/// A row whose attribute value decoded but failed a rule (an empty string
    +/// set) fails alone with the `PutItem` message and the file continues; a
    +/// repeated set member is not a fault and the row is written with the set
    +/// deduplicated (both measured on the service).
    +#[tokio::test]
    +async fn a_row_with_an_invalid_set_fails_alone_and_the_file_continues() {
    +    let h = Harness::new().await;
    +    let rows = [
    +        row(0),
    +        r#"{"Item": {"pk": {"S": "k1"}, "sk": {"S": "s"}, "ss": {"SS": []}}}"#.to_owned(),
    +        row(2),
    +        r#"{"Item": {"pk": {"S": "k3"}, "sk": {"S": "s"}, "ss": {"SS": ["a", "a"]}}}"#.to_owned(),
    +        row(4),
    +    ];
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 5);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert_eq!(h.count_items().await, 4);
    +    let k3 = h.get("k3").await.expect("the deduplicated row is written");
    +    assert_eq!(
    +        k3.get("ss"),
    +        Some(&AttributeValue::SS(["a".to_owned()].into_iter().collect()))
    +    );
    +    assert!(
    +        h.get("k4").await.is_some(),
    +        "the row after the faults is written"
    +    );
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!(["One or more parameter values were invalid: An string set  may not be empty"])
    +    );
    +    assert_eq!(report[0]["itemLocation"]["itemIndex"], json!(1));
    +}
    +
    +/// A number set with numerically equal members written
    +/// differently (`NS ["1", "1.0"]`) is deduplicated by number and the row is
    +/// written with the set collapsed, where the service stores it as `NS ["1"]`
    +/// with the operation COMPLETED (measured on the service). The deserializer
    +/// normalizes each member and refuses a set whose members collapse to fewer,
    +/// so without the number-aware dedupe the row would fail; the bulk dedupe
    +/// keeps the first member and the set stores as the single normalized value.
    +#[tokio::test]
    +async fn a_number_set_with_numerically_equal_members_is_written_deduplicated() {
    +    let h = Harness::new().await;
    +    let rows = [
    +        row(0),
    +        r#"{"Item": {"pk": {"S": "k1"}, "sk": {"S": "s"}, "ns": {"NS": ["1", "1.0"]}}}"#.to_owned(),
    +        row(2),
    +    ];
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 3);
    +    assert_eq!(record.failed_item_count, 0);
    +    assert_eq!(h.count_items().await, 3);
    +    let k1 = h
    +        .get("k1")
    +        .await
    +        .expect("the deduplicated number set is written");
    +    assert_eq!(
    +        k1.get("ns"),
    +        Some(&AttributeValue::NS(["1".to_owned()].into_iter().collect())),
    +        "NS [1, 1.0] stores as the single normalized member 1"
    +    );
    +    assert!(
    +        h.read_report(&record.operation_id, ReportKind::Error)
    +            .is_none(),
    +        "no row failed"
    +    );
    +}
    +
    +/// Number set members store in their
    +/// normalized forms. NS ["1.0", "1"] stores as NS ["1"] (the two collapse
    +/// to one), and NS ["1.0", "2"] stores as NS ["1", "2"] (distinct numbers
    +/// each normalized); the operation COMPLETED with no failed row. Measured
    +/// on the service.
    +#[tokio::test]
    +async fn number_set_members_store_in_their_normalized_forms() {
    +    let h = Harness::new().await;
    +    let rows = [
    +        r#"{"Item": {"pk": {"S": "a"}, "sk": {"S": "s"}, "ns": {"NS": ["1.0", "1"]}}}"#.to_owned(),
    +        r#"{"Item": {"pk": {"S": "b"}, "sk": {"S": "s"}, "ns": {"NS": ["1.0", "2"]}}}"#.to_owned(),
    +    ];
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.failed_item_count, 0);
    +    let a = h.get("a").await.expect("row a written");
    +    assert_eq!(
    +        a.get("ns"),
    +        Some(&AttributeValue::NS(["1".to_owned()].into_iter().collect())),
    +        "NS [1.0, 1] stores as the single normalized member [1]"
    +    );
    +    let b = h.get("b").await.expect("row b written");
    +    assert_eq!(
    +        b.get("ns"),
    +        Some(&AttributeValue::NS(
    +            ["1".to_owned(), "2".to_owned()].into_iter().collect()
    +        )),
    +        "NS [1.0, 2] stores as the normalized members [1, 2]"
    +    );
    +}
    +
    +/// The item size counts a set as submitted, repeated members included,
    +/// though the set is stored with one copy of each member. Measured on the
    +/// service: an `SS` of two identical 250,000-byte strings (about 250 KB
    +/// once deduplicated) fails the row with the item size message, and so
    +/// does a 410,000-byte string beside `SS ["a", "a"]`. The row holding the
    +/// string once is ExtendDB's control: the same set within the limit is
    +/// written.
    +#[tokio::test]
    +async fn repeated_set_members_count_toward_the_item_size_as_submitted() {
    +    let h = Harness::new().await;
    +    let big = "x".repeat(250_000);
    +    let wide = "y".repeat(410_000);
    +    let rows = [
    +        row(0),
    +        format!(
    +            r#"{{"Item": {{"pk": {{"S": "k1"}}, "sk": {{"S": "s"}}, "ss": {{"SS": ["{big}", "{big}"]}}}}}}"#
    +        ),
    +        format!(
    +            r#"{{"Item": {{"pk": {{"S": "k2"}}, "sk": {{"S": "s"}}, "ss": {{"SS": ["{big}"]}}}}}}"#
    +        ),
    +        format!(
    +            r#"{{"Item": {{"pk": {{"S": "k3"}}, "sk": {{"S": "s"}}, "v": {{"S": "{wide}"}}, "ss": {{"SS": ["a", "a"]}}}}}}"#
    +        ),
    +        row(4),
    +    ];
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 5);
    +    assert_eq!(record.failed_item_count, 2);
    +    assert!(
    +        h.get("k1").await.is_none(),
    +        "the doubled member is counted twice"
    +    );
    +    let k2 = h.get("k2").await.expect("the single member is written");
    +    assert_eq!(
    +        k2.get("ss"),
    +        Some(&AttributeValue::SS([big].into_iter().collect()))
    +    );
    +    assert!(h.get("k3").await.is_none());
    +    assert!(h.get("k4").await.is_some(), "the file continues");
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 2);
    +    for (record, index) in report.iter().zip([1, 3]) {
    +        assert_eq!(record["errorMessages"], json!([OVERSIZED]));
    +        assert_eq!(record["itemLocation"]["itemIndex"], json!(index));
    +        assert!(
    +            record.get("newImage").is_none(),
    +            "oversized records carry no newImage"
    +        );
    +    }
    +}
    +
    +/// A missing-key fault carries the item as `newImage` only when the item as
    +/// submitted is within the item size limit, the size the service counts: a
    +/// row with no `sk` whose `SS` holds two identical 250,000-byte strings gets
    +/// the record without an image, and the same row with the string once gets
    +/// it with the image.
    +#[tokio::test]
    +async fn a_key_fault_over_the_limit_only_through_repeats_carries_no_image() {
    +    let h = Harness::new().await;
    +    let big = "x".repeat(250_000);
    +    let rows = [
    +        format!(r#"{{"Item": {{"pk": {{"S": "k0"}}, "ss": {{"SS": ["{big}", "{big}"]}}}}}}"#),
    +        format!(r#"{{"Item": {{"pk": {{"S": "k1"}}, "ss": {{"SS": ["{big}"]}}}}}}"#),
    +    ];
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.failed_item_count, 2);
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 2);
    +    for record in &report {
    +        let message = record["errorMessages"][0].as_str().unwrap_or_default();
    +        assert!(message.contains("Missing the key sk"), "{message}");
    +    }
    +    assert!(
    +        report[0].get("newImage").is_none(),
    +        "the submitted item is over the limit"
    +    );
    +    assert!(
    +        report[1].get("newImage").is_some(),
    +        "the submitted item is within the limit"
    +    );
    +}
    +
    +/// A string or attribute name containing U+0000 fails the row alone with
    +/// a fixed message; the storage write never sees the row and the rows
    +/// after it are written.
    +#[tokio::test]
    +async fn a_row_with_nul_in_a_string_fails_alone_and_the_file_continues() {
    +    let h = Harness::new().await;
    +    let rows = [
    +        row(0),
    +        r#"{"Item": {"pk": {"S": "k1"}, "sk": {"S": "s"}, "v": {"S": "a\u0000b"}}}"#.to_owned(),
    +        r#"{"Item": {"pk": {"S": "k2"}, "sk": {"S": "s"}, "x\u0000y": {"N": "1"}}}"#.to_owned(),
    +        row(3),
    +    ];
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 4);
    +    assert_eq!(record.failed_item_count, 2);
    +    assert_eq!(h.count_items().await, 2);
    +    assert!(h.get("k1").await.is_none());
    +    assert!(h.get("k2").await.is_none());
    +    assert!(
    +        h.get("k3").await.is_some(),
    +        "the row after the faults is written"
    +    );
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 2);
    +    for (i, record) in report.iter().enumerate() {
    +        assert_eq!(
    +            record["errorMessages"],
    +            json!([crate::bulk_put::rows::NUL_IN_STRING_MESSAGE])
    +        );
    +        assert_eq!(record["itemLocation"]["itemIndex"], json!(i + 1));
    +    }
    +}
    +
    +/// A line that is not valid UTF-8 is not well-formed JSON: the file is
    +/// abandoned at that line with the parse-level message, and the bytes are
    +/// never decoded lossily into a written row.
    +#[tokio::test]
    +async fn a_line_that_is_not_valid_utf8_abandons_the_file_and_writes_nothing_altered() {
    +    let h = Harness::new().await;
    +    let mut bytes = file_of(&[row(0)]);
    +    bytes.extend_from_slice(br#"{"Item": {"pk": {"S": "k1"}, "sk": {"S": "s"}, "v": {"S": ""#);
    +    bytes.extend_from_slice(&[0xff, 0xfe]);
    +    bytes.extend_from_slice(b"\"}}}\n");
    +    bytes.extend_from_slice(&file_of(&[row(2)]));
    +    h.write_input("part-000.json", &bytes);
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 2);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert_eq!(h.count_items().await, 1);
    +    assert!(
    +        h.get("k1").await.is_none(),
    +        "the undecodable row is not written"
    +    );
    +    assert!(
    +        h.get("k2").await.is_none(),
    +        "the rest of the file is not read"
    +    );
    +    assert_eq!(
    +        Some(record.processed_input_bytes),
    +        record.total_input_bytes,
    +        "ProcessedInputBytes advances to the end of the abandoned file"
    +    );
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!([crate::bulk_put::rows::INVALID_UTF8_MESSAGE]),
    +        "measured on the service"
    +    );
    +    assert!(report[0]["itemLocation"].get("itemIndex").is_none());
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/pins.rs b/crates/engine/src/bulk_put/tests/runner/pins.rs
    new file mode 100644
    index 000000000..374cd1603
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/pins.rs
    @@ -0,0 +1,458 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// Three runner properties, one test each: each test
    +// fails when its property is removed from the runner and passes on the tip.
    +//
    +// - no write units for rows found in the ledger on resume (`flush_staged`);
    +// - `output_checked` restored true on resume (`RunState::restore`);
    +// - `skip_render` on the held output location fault path
    +//   (`hold_location_fault`, honoured by `finalize`).
    +
    +/// A conditional row keyed `k{i}` whose condition passes against an empty
    +/// table, so the row takes the marked write path and its outcome lands in
    +/// the row-outcome ledger. A distinct key per row keeps every row writable.
    +fn marked_row(i: usize) -> String {
    +    format!(
    +        "{{\"Item\": {{\"pk\": {{\"S\": \"k{i}\"}}, \"sk\": {{\"S\": \"s\"}}, \"v\": {{\"N\": \"{i}\"}}}}, \
    +         \"ConditionExpression\": \"attribute_not_exists(pk)\"}}"
    +    )
    +}
    +
    +/// Run one incarnation that dies after `after` lines were handed out, the
    +/// crash falling between the checkpoint at `checkpoint_rows` and the next
    +/// one. Each row is its own storage transaction, so rows read after the
    +/// checkpoint are committed (with their ledger markers) and not covered.
    +async fn crash_after(h: &Harness, after: u64, checkpoint_rows: usize) {
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = checkpoint_rows;
    +    cfg.write_batch_rows = 1;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +    // The lease expires before a second owner can claim.
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +}
    +
    +/// Rows an earlier incarnation already wrote are found in the ledger on
    +/// resume and are not sent again, so they take no write units.
    +/// Runner-1 checkpoints at row 10 and dies with rows 10 to 12 written under
    +/// their markers. The resume accounts for those three from the ledger and
    +/// writes rows 13 and 14, so a bucket of ten units ends with two spent:
    +/// eight left. Were the three charged, five would be left. Refill between
    +/// the acquires adds at most ten units per second of the resume's wall
    +/// time, so the balance is read as settled and the refill bound is taken
    +/// from the measured duration; a host too slow to keep the two outcomes
    +/// apart fails the precondition rather than passing by refill.
    +#[tokio::test]
    +async fn rows_found_in_the_ledger_on_resume_take_no_write_units() {
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(10);
    +    rows.extend((10..15).map(marked_row));
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +
    +    crash_after(&h, 13, 5).await;
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.cursor_row_index, 10, "the checkpoint before the crash");
    +    for row in 10..13 {
    +        assert_eq!(
    +            h.storage
    +                .bulk_row_outcome(ACCOUNT, &op, 0, row)
    +                .await
    +                .expect("ledger read"),
    +            Some(RowOutcome::Applied),
    +            "row {row} was written and marked before the crash"
    +        );
    +    }
    +
    +    let rate = 10;
    +    let pacer = Arc::new(WritePacer::new(rate));
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    cfg.write_batch_rows = 1;
    +    let mut deps = h.deps("runner-2", cfg);
    +    deps.pacer = Arc::clone(&pacer);
    +    let started = std::time::Instant::now();
    +    runner::run(&deps, record).await.expect("resumed run");
    +    let elapsed = started.elapsed();
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 15);
    +    assert_eq!(h.count_items().await, 15);
    +    assert_eq!(
    +        pacer.waited(),
    +        Duration::ZERO,
    +        "five one-unit rows never put a ten-unit bucket in deficit"
    +    );
    +    // Refill over the whole resume bounds what the bucket could have gained
    +    // between its first and last acquire. Under 0.2 s that is under two
    +    // units, which keeps "eight plus refill" apart from "five plus refill".
    +    let refill_bound = elapsed.as_secs_f64() * rate as f64;
    +    assert!(
    +        refill_bound < 2.0,
    +        "the resume took {elapsed:?}; too slow to tell the refund apart from refill"
    +    );
    +    let left = pacer.settled_tokens();
    +    assert!(
    +        (8.0..8.0 + refill_bound).contains(&left) || left == 8.0,
    +        "the resume is charged for the two rows it wrote, not the three it \
    +         replayed: eight units of ten remain (plus at most {refill_bound:.2} of \
    +         refill), got {left}"
    +    );
    +}
    +
    +/// A resumed run does not check the output location again when the
    +/// first incarnation already produced a report record: `output_checked`
    +/// is restored from `report_record_count > 0`. Runner-1 records two
    +/// faulting rows, checkpoints at row 10 and dies; the report bucket is
    +/// then removed. With the property, the resume reads to the end of the
    +/// input and fails only at render time (every row processed). Without it
    +/// the check runs again at the first resumed row and the operation fails
    +/// there with the first-record shape, `S3NoSuchBucket` and `The specified
    +/// bucket does not exist`, at the checkpoint's counters.
    +#[tokio::test]
    +async fn a_resume_after_a_recorded_fault_does_not_check_the_output_location_again() {
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(15);
    +    rows[2] = row_missing_sk(2);
    +    rows[7] = row_missing_sk(7);
    +    rows[12] = row_missing_sk(12);
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +
    +    crash_after(&h, 12, 5).await;
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.cursor_row_index, 10);
    +    assert_eq!(mid.processed_item_count, 10);
    +    assert_eq!(
    +        mid.report_record_count, 2,
    +        "the first incarnation checkpointed two error records"
    +    );
    +
    +    std::fs::remove_dir_all(h.store.0.join(ACCOUNT).join(OUT_BUCKET))
    +        .expect("remove the report bucket");
    +
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    runner::run(&h.deps("runner-2", cfg), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(
    +        record.processed_item_count, 15,
    +        "the resume read every remaining row; it did not fail at its first row"
    +    );
    +    assert_eq!(record.failed_item_count, 3);
    +    assert_eq!(h.count_items().await, 12, "every clean row is written");
    +    // Measured on the service: with a report object already written,
    +    // the bucket removed at row 987 of 1,500, the hosted service finished
    +    // every row and failed at the manifest write with the location code and
    +    // the manifest prefix, not with an internal error and not with the
    +    // first-record text.
    +    assert_eq!(
    +        record.status, "FAILED",
    +        "the missing bucket fails the operation at the manifest write"
    +    );
    +    assert_eq!(record.failure_code.as_deref(), Some("S3NoSuchBucket"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("Manifest is not available. The specified bucket does not exist")
    +    );
    +}
    +
    +/// An operation whose output location fault is held renders no report:
    +/// the records were never validated against the location. The hold drops the records waiting for a
    +/// checkpoint and keeps no later one. Here row 0's condition fails inside
    +/// the boundary's write batch (batch size 1), and the check runs on that
    +/// batch's record, before the checkpoint, and finds an output owner that is
    +/// not the caller. `skip_render` covering a record checkpointed before a
    +/// later kind's check held a fault is pinned in `output_faults`.
    +#[tokio::test]
    +async fn a_held_output_location_fault_renders_no_report_even_for_a_checkpointed_record() {
    +    let h = Harness::new().await;
    +    h.put_direct(item("c", "0")).await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[
    +            condition_row("9", "attribute_not_exists(pk)"),
    +            row(1),
    +            row(2),
    +        ]),
    +    );
    +    let files = h.enumerate().await;
    +    let mut output = both_reports();
    +    output["S3BucketOwner"] = json!("999999999999");
    +    let mut cfg = config();
    +    cfg.batch_size = 1;
    +    let record = h.run_to_end(files, input_config(false), output, cfg).await;
    +    let op = record.operation_id.clone();
    +
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3AccessDenied"));
    +    assert_eq!(record.failure_message.as_deref(), Some("Access Denied"));
    +    assert_eq!(
    +        record.processed_item_count, 3,
    +        "processed minus failed: the condition row counts as processed"
    +    );
    +    assert_eq!(
    +        record.report_record_count, 0,
    +        "the condition record is checked before its checkpoint"
    +    );
    +    assert!(
    +        h.read_report(&op, ReportKind::ConditionCheckFailure)
    +            .is_none(),
    +        "no condition report for an unvalidated output configuration"
    +    );
    +    assert!(
    +        h.read_report(&op, ReportKind::Error).is_none(),
    +        "no error report either"
    +    );
    +    assert!(
    +        h.read_manifest(&op).is_none(),
    +        "no manifest on the held location fault path"
    +    );
    +}
    +
    +///  (measured on the service): a cancel sent while the operation runs,
    +/// with report records already written and the report bucket removed in the
    +/// same moment, ends `CANCELLED` with the cancel pair and the input bytes
    +/// read before the stop; the missing bucket is not reported. Here the first incarnation records two faults and dies, the
    +/// bucket is removed, the cancel is requested, and the resume finds both.
    +#[tokio::test]
    +async fn a_cancel_outranks_a_missing_report_bucket() {
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(15);
    +    rows[2] = row_missing_sk(2);
    +    rows[7] = row_missing_sk(7);
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +
    +    crash_after(&h, 12, 5).await;
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.report_record_count, 2);
    +    assert!(mid.processed_input_bytes > 0);
    +    std::fs::remove_dir_all(h.store.0.join(ACCOUNT).join(OUT_BUCKET))
    +        .expect("remove the report bucket");
    +    h.storage
    +        .request_cancel(ACCOUNT, &op)
    +        .await
    +        .expect("request cancel");
    +
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    assert_eq!(record.status, "CANCELLING");
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    runner::run(&h.deps("runner-2", cfg), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "CANCELLED");
    +    assert_eq!(record.failure_code.as_deref(), Some("RequestCancelled"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("The request was cancelled")
    +    );
    +    // The bytes read before the stop stand (on the service, equal to the
    +    // total where every row had been processed; here the checkpoint's
    +    // figure).
    +    assert_eq!(record.processed_input_bytes, mid.processed_input_bytes);
    +}
    +
    +// The index charge on an overwrite. Measurements on the service fix the rule for an ALL-projection GSI, on the
    +// shape they used: a single hash key `pk` and the index key `gsk`, so
    +// `{pk: "a", gsk: "g", v: <1,500 bytes>}` is 1,508 bytes and with a
    +// 2,000-byte `v` is 2,008.
    +fn v_of(n: usize) -> String {
    +    "x".repeat(n)
    +}
    +fn seed_item(gsk: &str, vbytes: usize) -> Item {
    +    let mut it = Item::new();
    +    it.insert("pk".to_owned(), AttributeValue::S("a".to_owned()));
    +    it.insert("gsk".to_owned(), AttributeValue::S(gsk.to_owned()));
    +    it.insert("v".to_owned(), AttributeValue::S(v_of(vbytes)));
    +    it
    +}
    +fn overwrite_row(gsk: &str, vbytes: usize) -> String {
    +    json!({"Item": {"pk": {"S": "a"}, "gsk": {"S": gsk}, "v": {"S": v_of(vbytes)}}}).to_string()
    +}
    +/// Seed one item, then bulk-write one row that overwrites key `a`, and
    +/// return the operation's `BulkOperationUsage`.
    +async fn overwrite_usage(seed_gsk: &str, seed_v: usize, new_gsk: &str, new_v: usize) -> Value {
    +    let h = Harness::new_with_gsi_hash_key().await;
    +    h.put_direct(seed_item(seed_gsk, seed_v)).await;
    +    h.write_input("part-000.json", &file_of(&[overwrite_row(new_gsk, new_v)]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +    assert_eq!(record.status, "COMPLETED");
    +    record.usage
    +}
    +
    +// A changed projection under the same index key is charged the larger
    +// of the old and new projected bytes. Seed v 1,500 B (1,508), overwrite
    +// v 2,000 B (2,008), same gsk: the index is charged max(1,508, 2,008) =
    +// 2,008, matching the service.
    +#[tokio::test]
    +async fn an_overwrite_changing_the_projection_under_the_same_index_key_charges_the_larger() {
    +    let usage = overwrite_usage("g", 1500, "g", 2000).await;
    +    assert_eq!(
    +        usage["GlobalSecondaryIndexes"]["gsi1"]["BulkPutBytes"],
    +        json!(2008),
    +        "a changed projection under the same key is charged max(old, new)"
    +    );
    +    assert_eq!(usage["Table"]["BulkPutBytes"], json!(2008));
    +    assert_eq!(usage["BulkPutBytes"], json!(4016));
    +}
    +
    +// A moved index key is a delete of the old projected entry plus an
    +// insert of the new, so the index is charged old plus new. Seed
    +// {gsk: g, v 2,000 B} (2,008), overwrite {gsk: h, v 2,000 B} (2,008): the
    +// index is charged 2,008 + 2,008 = 4,016, matching the service.
    +#[tokio::test]
    +async fn an_overwrite_moving_the_index_key_charges_old_plus_new() {
    +    let usage = overwrite_usage("g", 2000, "h", 2000).await;
    +    assert_eq!(
    +        usage["GlobalSecondaryIndexes"]["gsi1"]["BulkPutBytes"],
    +        json!(4016),
    +        "a moved index key is charged old plus new"
    +    );
    +    assert_eq!(usage["Table"]["BulkPutBytes"], json!(2008));
    +    assert_eq!(usage["BulkPutBytes"], json!(6024));
    +}
    +
    +// An identical overwrite is charged the index nothing and reports no
    +// index member; the table is charged the larger of old and new (equal
    +// here), 1,508. Matches the service. A plain row met again after
    +// a crash is charged the same way, so a crashed run undercharges the index
    +// for such rows against a crash-free run (see the resume batched test).
    +#[tokio::test]
    +async fn an_identical_overwrite_charges_the_index_nothing() {
    +    let usage = overwrite_usage("g", 1500, "g", 1500).await;
    +    assert!(
    +        usage["GlobalSecondaryIndexes"]["gsi1"].is_null(),
    +        "an unchanged projection charges the index nothing and reports no member"
    +    );
    +    assert_eq!(usage["Table"]["BulkPutBytes"], json!(1508));
    +    assert_eq!(usage["BulkPutBytes"], json!(1508));
    +}
    +
    +// A shrinking overwrite under the same index key. Seed v 2,000 B
    +// (2,008), overwrite v 1,500 B (1,508): the index is charged the larger,
    +// 2,008, AND the table is charged the larger, 2,008 (total 4,016). Matches
    +// the service. The table larger-of rule is what distinguishes this from
    +// charging the new item alone (which would report table 1,508).
    +#[tokio::test]
    +async fn a_shrinking_overwrite_under_the_same_index_key_charges_the_larger() {
    +    let usage = overwrite_usage("g", 2000, "g", 1500).await;
    +    assert_eq!(
    +        usage["GlobalSecondaryIndexes"]["gsi1"]["BulkPutBytes"],
    +        json!(2008),
    +        "the index is charged max(old, new)"
    +    );
    +    assert_eq!(
    +        usage["Table"]["BulkPutBytes"],
    +        json!(2008),
    +        "the table is charged max(old, new) too"
    +    );
    +    assert_eq!(usage["BulkPutBytes"], json!(4016));
    +}
    +
    +// A moved index key with unequal sizes. Seed {gsk g, v 1,500 B}
    +// (1,508), overwrite {gsk h, v 2,000 B} (2,008): the index is charged old
    +// plus new (1,508 + 2,008 = 3,516), the table the larger (2,008), total
    +// 5,524. Matches the service.
    +#[tokio::test]
    +async fn a_moved_index_key_with_unequal_sizes_charges_old_plus_new() {
    +    let usage = overwrite_usage("g", 1500, "h", 2000).await;
    +    assert_eq!(
    +        usage["GlobalSecondaryIndexes"]["gsi1"]["BulkPutBytes"],
    +        json!(3516),
    +        "a moved index key is charged old plus new"
    +    );
    +    assert_eq!(usage["Table"]["BulkPutBytes"], json!(2008));
    +    assert_eq!(usage["BulkPutBytes"], json!(5524));
    +}
    +
    +// A KEYS_ONLY GSI whose projected entry is unchanged under the same
    +// index key is charged nothing and reports no member, even when a
    +// non-projected attribute changed. seed {gsk g, v 1,500 B}, overwrite
    +// {gsk g, v 2,000 B}: the index projects only pk and gsk, both unchanged,
    +// so no member; the table is the larger, 2,008. Matches the service, and
    +// reaches the same-key-unchanged-projection branch an ALL projection
    +// cannot (its projection changes whenever any attribute does).
    +#[tokio::test]
    +async fn an_overwrite_under_a_keys_only_index_with_an_unchanged_key_charges_nothing() {
    +    let h = Harness::new_with_gsi_keys_only_hash_key().await;
    +    h.put_direct(seed_item("g", 1500)).await;
    +    h.write_input("part-000.json", &file_of(&[overwrite_row("g", 2000)]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert!(
    +        record.usage["GlobalSecondaryIndexes"]["gsi1"].is_null(),
    +        "an unchanged KEYS_ONLY projection charges the index nothing and reports no member"
    +    );
    +    assert_eq!(record.usage["Table"]["BulkPutBytes"], json!(2008));
    +}
    +
    +// The larger-of table rule applies to a table with no index too.
    +// Seed {pk a, sk row, v 2,000 B} (2,009), overwrite v 1,500 B (1,509): the
    +// table is charged max(2,009, 1,509) = 2,009. This needs the replaced
    +// item's size on an unindexed table, so the runner requests the old image
    +// on every write. Matches the service.
    +#[tokio::test]
    +async fn an_overwrite_on_a_plain_table_charges_the_table_the_larger() {
    +    let h = Harness::new().await;
    +    let mut seeded = Item::new();
    +    seeded.insert("pk".to_owned(), AttributeValue::S("a".to_owned()));
    +    seeded.insert("sk".to_owned(), AttributeValue::S("row".to_owned()));
    +    seeded.insert("v".to_owned(), AttributeValue::S(v_of(2000)));
    +    h.put_direct(seeded).await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[json!({"Item": {
    +            "pk": {"S": "a"}, "sk": {"S": "row"}, "v": {"S": v_of(1500)}
    +        }})
    +        .to_string()]),
    +    );
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(
    +        record.usage["Table"]["BulkPutBytes"],
    +        json!(2009),
    +        "the table is charged max(old, new) with no index present"
    +    );
    +    assert_eq!(record.usage["BulkPutBytes"], json!(2009));
    +    assert!(
    +        record.usage["GlobalSecondaryIndexes"]["gsi1"].is_null(),
    +        "a plain table reports no index member"
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/recorded_store.rs b/crates/engine/src/bulk_put/tests/runner/recorded_store.rs
    new file mode 100644
    index 000000000..c2733c7d7
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/recorded_store.rs
    @@ -0,0 +1,130 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// The store an operation started on, recorded in its run context. A record
    +// started on another store than the runner's is neither read nor rendered:
    +// it ends FAILED with the internal failure. A record with no run context
    +// was started before the column existed, on the filesystem store.
    +
    +fn counting(h: &Harness) -> (Arc, Arc) {
    +    let source = Arc::new(CountingSource::new(h.fs_source()));
    +    let opens = Arc::clone(&source.opens);
    +    (source, opens)
    +}
    +
    +#[tokio::test]
    +async fn a_record_started_on_another_store_fails_unread() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&[row(0), row_missing_sk(1)]));
    +    let files = h.enumerate().await;
    +    let op = h
    +        .start_with_run_context(
    +            files,
    +            input_config(false),
    +            both_reports(),
    +            Some(json!({"store": "s3"})),
    +        )
    +        .await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let (source, opens) = counting(&h);
    +    let deps = h.deps_with("runner-1", config(), source, Arc::new(h.fs_sink()));
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("InternalServerError"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("Internal server error")
    +    );
    +    assert_eq!(opens.load(Ordering::SeqCst), 0, "no input is opened");
    +    assert_eq!(record.processed_item_count, 0);
    +    assert_eq!(h.count_items().await, 0);
    +    assert!(h.read_manifest(&op).is_none(), "nothing is rendered");
    +    assert!(h.read_report(&op, ReportKind::Error).is_none());
    +}
    +
    +#[tokio::test]
    +async fn a_cancelling_record_started_on_another_store_is_cancelled_with_nothing_rendered() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(2)));
    +    let files = h.enumerate().await;
    +    let op = h
    +        .start_with_run_context(
    +            files,
    +            input_config(false),
    +            both_reports(),
    +            Some(json!({"store": "s3"})),
    +        )
    +        .await;
    +    h.storage
    +        .request_cancel(ACCOUNT, &op)
    +        .await
    +        .expect("request cancel");
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-1", config()), record)
    +        .await
    +        .expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "CANCELLED");
    +    assert!(
    +        h.read_manifest(&op).is_none(),
    +        "no manifest is written to a store the operation did not start on"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_record_naming_the_runners_store_or_none_is_read() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(3)));
    +    let files = h.enumerate().await;
    +    for (run_context, owner) in [
    +        (Some(json!({"store": "filesystem"})), "runner-1"),
    +        (None, "runner-2"),
    +    ] {
    +        let op = h
    +            .start_with_run_context(
    +                files.clone(),
    +                input_config(false),
    +                both_reports(),
    +                run_context.clone(),
    +            )
    +            .await;
    +        let record = h.claim(owner, Duration::from_secs(60)).await;
    +        runner::run(&h.deps(owner, config()), record)
    +            .await
    +            .expect("runner run");
    +        let record = h.record(&op).await;
    +        assert_eq!(record.status, "COMPLETED", "{run_context:?}");
    +        assert_eq!(record.processed_item_count, 3, "{run_context:?}");
    +        assert!(h.read_manifest(&op).is_some(), "{run_context:?}");
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_run_context_that_does_not_parse_fails_unread() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(2)));
    +    let files = h.enumerate().await;
    +    let op = h
    +        .start_with_run_context(
    +            files,
    +            input_config(false),
    +            both_reports(),
    +            Some(json!({"store": 7})),
    +        )
    +        .await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let (source, opens) = counting(&h);
    +    let deps = h.deps_with("runner-1", config(), source, Arc::new(h.fs_sink()));
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("InternalServerError"));
    +    assert_eq!(opens.load(Ordering::SeqCst), 0);
    +    assert!(h.read_manifest(&op).is_none());
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/render.rs b/crates/engine/src/bulk_put/tests/runner/render.rs
    new file mode 100644
    index 000000000..b29ee5c5c
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/render.rs
    @@ -0,0 +1,239 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// The stored terminal status in metrics, a render that outlives its
    +// lease, and the dead-runner temp sweep.
    +
    +#[tokio::test]
    +async fn a_cancel_after_the_last_boundary_records_finished_with_cancelled() {
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(5);
    +    rows[2] = row_missing_sk(2);
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +
    +    // The cancel lands during rendering, after the runner passed its last
    +    // batch boundary, so the runner computes COMPLETED_WITH_ERRORS while
    +    // `finish` turns the CANCELLING row into CANCELLED.
    +    let metrics = Arc::new(MetricsCollector::new());
    +    let sink = Arc::new(CancelOnOpenSink {
    +        inner: h.fs_sink(),
    +        storage: Arc::clone(&h.storage),
    +        operation_id: op.clone(),
    +        fired: Arc::new(AtomicBool::new(false)),
    +    });
    +    let deps = RunnerDeps {
    +        storage: Arc::clone(&h.storage),
    +        source: Arc::new(h.fs_source()),
    +        sink,
    +        limits: Arc::new(LimitsConfig::default()),
    +        region: Arc::from(REGION),
    +        owner: "runner-1".to_owned(),
    +        config: config(),
    +        metrics: Arc::clone(&metrics),
    +        pacer: Arc::new(WritePacer::unbounded()),
    +        store_kind: STORE_FILESYSTEM,
    +        authorizer: None,
    +        shutdown: CancellationToken::new(),
    +    };
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "CANCELLED");
    +
    +    // The status dimension is the status `finish` stored, not the status
    +    // the runner computed. Exactly one Finished series exists, so no
    +    // COMPLETED series was recorded beside the CANCELLED one.
    +    let mut snaps = metrics.query(&MetricsQuery {
    +        metric: Some(MetricName::BulkOperationsFinished),
    +        window: Some(TimeWindow::AllTime),
    +        ..MetricsQuery::default()
    +    });
    +    assert_eq!(snaps.len(), 1, "exactly one Finished series: {snaps:?}");
    +    let finished = snaps.remove(0);
    +    assert!(
    +        finished
    +            .dimensions
    +            .contains(&Dimension::Operation("CANCELLED".to_owned())),
    +        "the Finished dimension is the stored CANCELLED: {:?}",
    +        finished.dimensions
    +    );
    +    assert!(
    +        !finished
    +            .dimensions
    +            .contains(&Dimension::Operation("COMPLETED".to_owned())),
    +        "not the COMPLETED the runner computed: {:?}",
    +        finished.dimensions
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_render_that_outlives_its_lease_does_not_replace_the_new_owners_report() {
    +    let h = Harness::new().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[row(0), row_missing_sk(1), row(2)]),
    +    );
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    // The first runner reaches rendering and pends inside `write_page`
    +    // with its temp file already created, and is then not polled, as a
    +    // paused process is: a render that outlives the lease. A runner that is
    +    // polled keeps its lease while a write waits. Its own lease is 60
    +    // seconds, so no renewal is due while the page waits and none is under
    +    // way when polling stops; a renewal stopped inside a storage call could
    +    // hold the engine's write lock, and the next claim would wait for it
    +    // without end. Its checkpoints ask the store for 100 ms, so the store's
    +    // lease runs out behind it.
    +    let reached = Arc::new(tokio::sync::Notify::new());
    +    let release = Arc::new(tokio::sync::Notify::new());
    +    let sink = Arc::new(PendInWritePageSink {
    +        inner: h.fs_sink(),
    +        reached: Arc::clone(&reached),
    +        release: Arc::clone(&release),
    +    });
    +    let deps =
    +        h.with_short_store_lease(h.deps_with("runner-1", config(), Arc::new(h.fs_source()), sink));
    +    let stale = runner::run(&deps, record);
    +    tokio::pin!(stale);
    +    tokio::time::timeout(Duration::from_secs(5), async {
    +        tokio::select! {
    +            _ = &mut stale => panic!("the first runner must not finish before its render"),
    +            () = reached.notified() => {}
    +        }
    +    })
    +    .await
    +    .expect("the first runner must reach its render");
    +
    +    // The lease expires; a new worker claims, re-renders, and finishes.
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-2", config()), record)
    +        .await
    +        .expect("the second run finishes");
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    let report = h
    +        .read_report(&op, ReportKind::Error)
    +        .expect("the second runner rendered the report");
    +    assert_eq!(report.len(), 1);
    +
    +    // Distinct bytes stand in for the new owner's rendering: both runners
    +    // would encode the same records, so byte equality after the release is
    +    // what proves the stale runner replaced nothing.
    +    let final_path = h.report_path(&op, ReportKind::Error);
    +    let sentinel = b"the new owners rendering".to_vec();
    +    std::fs::write(&final_path, &sentinel).unwrap();
    +
    +    // Released, the stale runner writes its page, re-reads the record,
    +    // observes the lease is no longer its own, drops its writer, and
    +    // stops cleanly with no rename.
    +    release.notify_one();
    +    tokio::time::timeout(Duration::from_secs(5), stale)
    +        .await
    +        .expect("the stale runner must stop once released")
    +        .expect("a lost lease is a clean stop, not an error");
    +
    +    assert_eq!(
    +        std::fs::read(&final_path).unwrap(),
    +        sentinel,
    +        "the stale runner must not replace the new owner's report"
    +    );
    +    let leftovers: Vec = std::fs::read_dir(final_path.parent().unwrap())
    +        .unwrap()
    +        .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
    +        .filter(|name| name.contains(".tmp-"))
    +        .collect();
    +    assert!(
    +        leftovers.is_empty(),
    +        "the stale runner's temp file is gone: {leftovers:?}"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_dead_runners_temp_file_is_removed_when_the_next_runner_opens_the_report_directory() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&[row(0), row_missing_sk(1)]));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +
    +    // A hard-killed runner's leftover: the temp name embeds the dead
    +    // owner's pid and a random suffix, so no successor ever computes the
    +    // same name and only the open sweep can remove it.
    +    let final_path = h.report_path(&op, ReportKind::Error);
    +    let report_dir = final_path.parent().unwrap();
    +    std::fs::create_dir_all(report_dir).unwrap();
    +    let dead = report_dir.join("output-00000.json.gz.tmp-dead-owner");
    +    std::fs::write(&dead, b"half-rendered leftovers").unwrap();
    +
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-1", config()), record)
    +        .await
    +        .expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert!(
    +        !dead.exists(),
    +        "opening the report directory removes the dead owner's temp file"
    +    );
    +    let report = h
    +        .read_report(&op, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(report[0]["errorMessages"], json!([MISSING_KEY]));
    +}
    +
    +#[tokio::test]
    +async fn a_read_error_at_the_render_lease_check_leaves_the_operation_in_progress() {
    +    // The lease re-read before a report rename is a storage read like the
    +    // final checkpoint's; when it fails nothing is known about ownership, so
    +    // the runner stops without a terminal transition and the next claim
    +    // renders again. Terminally failing the operation here would turn a
    +    // transient read error into a FAILED record whose report a retry would
    +    // have written.
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&[row(0), row_missing_sk(1)]));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    // The runner reads the record twice before the first rename: the
    +    // boundary check at end of file, then the lease re-read. The second
    +    // read fails. The runner's own lease is 60 seconds, so no renewal
    +    // during an output call adds a read before the re-read; its
    +    // checkpoints ask the store for 100 ms, so the lease runs out for the
    +    // later claim.
    +    let mut faulting = FaultingStorage::new(&h.storage);
    +    faulting.fail_get_on_call = Some(2);
    +    faulting.checkpoint_lease = Some(Duration::from_millis(100));
    +    let deps = h.deps_with_storage("runner-1", config(), Arc::new(faulting));
    +    let outcome = runner::run(&deps, record).await;
    +    assert!(
    +        matches!(outcome, Err(runner::RunnerError::Storage(_))),
    +        "the runner surfaces the read error instead of finishing: {outcome:?}"
    +    );
    +
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.status, "IN_PROGRESS", "no terminal transition was made");
    +    assert!(
    +        h.read_report(&op, ReportKind::Error).is_none(),
    +        "no report was renamed into place"
    +    );
    +
    +    // The lease expires; a later claim renders and finishes normally.
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-2", config()), record)
    +        .await
    +        .expect("resumed run");
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert!(h.read_report(&op, ReportKind::Error).is_some());
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/replay.rs b/crates/engine/src/bulk_put/tests/runner/replay.rs
    new file mode 100644
    index 000000000..d9b98874f
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/replay.rs
    @@ -0,0 +1,154 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +#[tokio::test]
    +async fn a_replayed_failed_condition_row_reemits_the_record_without_evaluation() {
    +    let h = Harness::new().await;
    +    h.put_direct(item("c", "1")).await;
    +    let mut rows = plain_rows(12);
    +    rows[10] = condition_row("2", "attribute_not_exists(pk)");
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    // The condition at row 10 fails against the seeded item; its marker
    +    // commits in its own transaction, then the runner dies before the
    +    // checkpoint that would cover the row's report record.
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 11,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    // One row per storage transaction, so the row's write commits as
    +    // soon as it is read and the crash lands between that commit and
    +    // the checkpoint that would cover it.
    +    cfg.write_batch_rows = 1;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.cursor_row_index, 10, "row 10 is past the cursor");
    +    assert_eq!(
    +        h.storage
    +            .bulk_row_outcome(ACCOUNT, &op, 0, 10)
    +            .await
    +            .expect("ledger read"),
    +        Some(RowOutcome::ConditionFailed),
    +        "the failure outcome was recorded in its own transaction"
    +    );
    +
    +    // On resume the row is replayed; the ledger answers and the record is
    +    // re-emitted with the stored item as oldImage, without an evaluation.
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let mut cfg2 = config();
    +    cfg2.batch_size = 5;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-2", cfg2), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(
    +        record.processed_item_count, 12,
    +        "the row counts exactly once"
    +    );
    +    assert_eq!(
    +        record.failed_item_count, 0,
    +        "a condition failure is not a failed row"
    +    );
    +    let stored = h.get("c").await.expect("target item present");
    +    assert_eq!(
    +        stored.get("v"),
    +        Some(&AttributeValue::N("1".to_owned())),
    +        "the target item is untouched"
    +    );
    +    let report = h
    +        .read_report(&op, ReportKind::ConditionCheckFailure)
    +        .expect("condition report rendered");
    +    assert_eq!(report.len(), 1, "exactly one record, not duplicated");
    +    assert_eq!(report[0]["errorMessages"], json!([CONDITION_FAILED]));
    +    assert_eq!(report[0]["itemLocation"]["itemIndex"], json!(10));
    +    assert_eq!(report[0]["newImage"]["Item"]["v"]["N"], json!("2"));
    +    assert_eq!(
    +        report[0]["oldImage"]["Item"]["v"]["N"],
    +        json!("1"),
    +        "oldImage is the currently stored item"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_storage_error_row_resumes_at_that_row_and_counts_it_once() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(12)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    // Row 7's write fails with a transient storage error. The batch it was
    +    // in does not commit, so the operation stays non-terminal at the last
    +    // checkpoint (row 5) and the next claim resumes it: a transient write
    +    // fault is a runner storage error, not a terminal
    +    // transition.
    +    let mut faulting = FaultingStorage::new(&h.storage);
    +    faulting.fail_put_pk = Some("k7".to_owned());
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with_storage("runner-1", cfg, Arc::new(faulting));
    +    assert!(
    +        matches!(
    +            runner::run(&deps, record).await,
    +            Err(runner::RunnerError::Storage(_))
    +        ),
    +        "a transient write fault leaves the operation to resume, not a terminal transition"
    +    );
    +
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.status, "IN_PROGRESS", "the operation is not terminal");
    +    assert_eq!(
    +        mid.cursor_row_index, 5,
    +        "the cursor is the last checkpoint: nothing from the failed batch committed"
    +    );
    +    assert_eq!(
    +        mid.processed_item_count, 5,
    +        "counters exclude the batch that did not commit"
    +    );
    +    assert_eq!(mid.failed_item_count, 0);
    +    assert!(h.get("k7").await.is_none(), "row 7 was never written");
    +
    +    // The lease expires; a new worker resumes at the checkpoint, re-reads
    +    // the window (plain rows are idempotent), and the retry succeeds.
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let mut cfg2 = config();
    +    cfg2.batch_size = 5;
    +    runner::run(&h.deps("runner-2", cfg2), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 12, "no double count on replay");
    +    assert_eq!(record.failed_item_count, 0);
    +    assert!(h.get("k7").await.is_some(), "the retried row is written");
    +    assert_eq!(h.count_items().await, 12);
    +    assert_eq!(Some(record.processed_input_bytes), record.total_input_bytes);
    +    assert_eq!(
    +        record.usage["BulkPutBytes"],
    +        json!(12_288),
    +        "usage counts every written row exactly once"
    +    );
    +    assert!(h.read_report(&op, ReportKind::Error).is_none());
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/reports.rs b/crates/engine/src/bulk_put/tests/runner/reports.rs
    new file mode 100644
    index 000000000..bd27a43a0
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/reports.rs
    @@ -0,0 +1,333 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// Empty input.
    +
    +#[tokio::test]
    +async fn an_empty_input_file_list_completes_with_zero_counters() {
    +    let h = Harness::new().await;
    +    let record = h
    +        .run_to_end(Vec::new(), input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 0);
    +    assert_eq!(record.failed_item_count, 0);
    +    assert_eq!(record.processed_input_bytes, 0);
    +    assert_eq!(record.total_input_bytes, Some(0));
    +    assert!(record.end_time_ms.is_some(), "the operation is terminal");
    +}
    +
    +// Report layout.
    +
    +#[tokio::test]
    +async fn report_files_land_under_a_separator_inserted_before_awsdynamodb() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&[row_missing_sk(0)]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    // OUT_PREFIX carries no trailing slash, so the separator between it and
    +    // AWSDynamoDB exists only because the layout inserts one.
    +    let path = h.report_path(&record.operation_id, ReportKind::Error);
    +    assert!(
    +        path.is_file(),
    +        "report file at {}: the key inserts a separator after the prefix",
    +        path.display()
    +    );
    +}
    +
    +#[tokio::test]
    +async fn item_index_is_zero_based_and_absent_on_abandon_records() {
    +    let h = Harness::new().await;
    +    h.write_input("a.json", &file_of(&[row(0), row(1), row_missing_sk(2)]));
    +    h.write_input("b.json", &file_of(&["garbage".to_owned()]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 2);
    +    let item_level = report
    +        .iter()
    +        .find(|r| {
    +            r["itemLocation"]["inputSourceArn"] == json!(format!("arn:aws:s3:::{IN_BUCKET}/a.json"))
    +        })
    +        .expect("record for the item-level fault");
    +    assert_eq!(
    +        item_level["itemLocation"]["itemIndex"],
    +        json!(2),
    +        "itemIndex is the zero-based line index"
    +    );
    +    let abandon = report
    +        .iter()
    +        .find(|r| {
    +            r["itemLocation"]["inputSourceArn"] == json!(format!("arn:aws:s3:::{IN_BUCKET}/b.json"))
    +        })
    +        .expect("record for the abandoned file");
    +    assert!(
    +        abandon["itemLocation"].get("itemIndex").is_none(),
    +        "abandon records carry no itemIndex"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn records_past_the_byte_cap_are_dropped_while_counters_stand() {
    +    let h = Harness::new().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&(0..4).map(row_missing_sk).collect::>()),
    +    );
    +
    +    // Calibration: the same input under a roomy cap yields the record size.
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("calibration report rendered");
    +    assert_eq!(report.len(), 4);
    +    let size = calibrated_record_bytes(&report);
    +
    +    // The capped run keeps exactly two records' worth of bytes.
    +    let files = h.enumerate().await;
    +    let mut cfg = config();
    +    cfg.max_report_bytes = 2 * size;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), cfg)
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(
    +        record.failed_item_count, 4,
    +        "the counters are unaffected by the byte cap"
    +    );
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 2, "records past the byte cap are dropped");
    +    let mut indexes: Vec = report
    +        .iter()
    +        .map(|r| r["itemLocation"]["itemIndex"].as_i64().unwrap())
    +        .collect();
    +    indexes.sort_unstable();
    +    assert_eq!(indexes, [0, 1], "the earliest records are the kept ones");
    +}
    +
    +#[tokio::test]
    +async fn a_crash_resume_continues_the_byte_cap_from_the_persisted_total() {
    +    let h = Harness::new().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&(0..4).map(row_missing_sk).collect::>()),
    +    );
    +
    +    // Calibration run under a roomy cap.
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("calibration report rendered");
    +    let size = calibrated_record_bytes(&report);
    +
    +    // Rows 0 and 1 checkpoint with two kept records, exhausting the byte
    +    // cap; the runner dies with rows 2 and 3 unread.
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 2,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 2;
    +    cfg.max_report_bytes = 2 * size;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.cursor_row_index, 2);
    +    assert_eq!(mid.failed_item_count, 2);
    +
    +    // The resumed runner restores the kept byte total from the record
    +    // instead of starting from zero, so rows 2 and 3 are dropped.
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let mut cfg2 = config();
    +    cfg2.batch_size = 2;
    +    cfg2.max_report_bytes = 2 * size;
    +    runner::run(&h.deps("runner-2", cfg2), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.failed_item_count, 4, "counters are unaffected");
    +    let report = h
    +        .read_report(&op, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 2, "the byte cap holds across the resume");
    +    let mut indexes: Vec = report
    +        .iter()
    +        .map(|r| r["itemLocation"]["itemIndex"].as_i64().unwrap())
    +        .collect();
    +    indexes.sort_unstable();
    +    assert_eq!(indexes, [0, 1]);
    +}
    +
    +#[tokio::test]
    +async fn a_blank_line_keeps_the_physical_item_index() {
    +    let h = Harness::new().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[row(0), String::new(), row_missing_sk(2)]),
    +    );
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(
    +        record.processed_item_count, 2,
    +        "the blank line touches no counter"
    +    );
    +    assert_eq!(record.failed_item_count, 1);
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["itemLocation"]["itemIndex"],
    +        json!(2),
    +        "the blank line consumed a physical line index"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn attribute_names_without_an_expression_fail_the_row_alone() {
    +    let h = Harness::new().await;
    +    let bad = "{\"Item\": {\"pk\": {\"S\": \"k1\"}, \"sk\": {\"S\": \"s\"}}, \
    +               \"ExpressionAttributeNames\": {\"#v\": \"v\"}}"
    +        .to_owned();
    +    h.write_input("part-000.json", &file_of(&[row(0), bad, row(2)]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 3);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert!(
    +        h.get("k1").await.is_none(),
    +        "the faulted row is not written"
    +    );
    +    assert!(h.get("k2").await.is_some(), "the file continues");
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!(["ExpressionAttributeNames can only be specified when using expressions"])
    +    );
    +    assert_eq!(report[0]["itemLocation"]["itemIndex"], json!(1));
    +    assert!(
    +        report[0].get("newImage").is_none(),
    +        "condition-field faults carry no newImage"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn attribute_values_without_an_expression_fail_the_row_alone() {
    +    let h = Harness::new().await;
    +    let bad = "{\"Item\": {\"pk\": {\"S\": \"k1\"}, \"sk\": {\"S\": \"s\"}}, \
    +               \"ExpressionAttributeValues\": {\":m\": {\"N\": \"1\"}}}"
    +        .to_owned();
    +    h.write_input("part-000.json", &file_of(&[row(0), bad, row(2)]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.failed_item_count, 1);
    +    assert!(h.get("k2").await.is_some(), "the file continues");
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!([
    +            "ExpressionAttributeValues can only be specified when using expressions: \
    +             ConditionExpression is null"
    +        ])
    +    );
    +    assert!(report[0].get("newImage").is_none());
    +}
    +
    +#[tokio::test]
    +async fn an_invalid_condition_expression_fails_the_row_with_the_put_item_message() {
    +    let h = Harness::new().await;
    +    let bad = "{\"Item\": {\"pk\": {\"S\": \"k1\"}, \"sk\": {\"S\": \"s\"}}, \
    +               \"ConditionExpression\": \"v <\"}"
    +        .to_owned();
    +    h.write_input("part-000.json", &file_of(&[row(0), bad, row(2)]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    // The expected message is what PutItem produces for the same fault
    +    // (the row goes through the same parsing functions) without the
    +    // `Invalid ConditionExpression: ` prefix, which the bulk record does
    +    // not carry (measured on the service).
    +    let put_item = resolve_condition(
    +        Some("v <"),
    +        None,
    +        None,
    +        None,
    +        None,
    +        &LimitsConfig::default(),
    +    )
    +    .expect_err("the condition expression is invalid")
    +    .to_string();
    +    let expected = put_item
    +        .strip_prefix("Invalid ConditionExpression: ")
    +        .expect("the single-item path prefixes the condition fault")
    +        .to_owned();
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 3);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert!(
    +        h.get("k1").await.is_none(),
    +        "the faulted row is not written"
    +    );
    +    assert!(h.get("k2").await.is_some(), "the file continues");
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(report[0]["errorMessages"], json!([expected]));
    +    assert_eq!(report[0]["itemLocation"]["itemIndex"], json!(1));
    +    assert!(report[0].get("newImage").is_none());
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/restart_abandons.rs b/crates/engine/src/bulk_put/tests/runner/restart_abandons.rs
    new file mode 100644
    index 000000000..2c09f38bc
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/restart_abandons.rs
    @@ -0,0 +1,314 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// A file the runner abandoned keeps its outcome across a restart. The
    +// checkpoint that moves the cursor past an abandoned file stores the
    +// abandon on the file's record entry, so a later claim knows the file's
    +// last counted row and whether the abandon ends the operation `FAILED`,
    +// whatever the file's size and whatever report kinds were requested.
    +
    +const ITEMS_FAILED: &str = "Some of the items failed validation checks and were not ingested";
    +
    +/// The first claim's source: answers `SourceError::Overwritten` at the open
    +/// of `overwritten` and hands out the other files' lines until `after` of
    +/// them were handed, then pends. Dropping the runner at that point models a
    +/// crash after the checkpoint that passed the overwritten file.
    +struct OverwrittenThenPend {
    +    pend: PendAfterSource,
    +    overwritten: &'static str,
    +}
    +
    +impl BulkSource for OverwrittenThenPend {
    +    fn check_bucket(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +    ) -> BoxFuture<'_, Result<(), SourceError>> {
    +        self.pend.check_bucket(bucket, prefix)
    +    }
    +
    +    fn etag(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +    ) -> BoxFuture<'_, Result> {
    +        self.pend.etag(bucket, file)
    +    }
    +
    +    fn line_count(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result> {
    +        self.pend.line_count(bucket, file, compression)
    +    }
    +
    +    fn list(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +        max_files: u64,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        self.pend.list(bucket, prefix, max_files)
    +    }
    +
    +    fn open(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        if file.path == self.overwritten {
    +            return Box::pin(async { Err(SourceError::Overwritten) });
    +        }
    +        self.pend.open(bucket, file, compression)
    +    }
    +}
    +
    +fn pend_after(h: &Harness, after: u64) -> (PendAfterSource, Arc) {
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = PendAfterSource {
    +        inner: h.fs_source(),
    +        after,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    };
    +    (source, notify)
    +}
    +
    +/// Run the first claim with checkpoints every 5 rows and a 100 ms lease
    +/// until `notify` fires, drop it there as a crash would, and wait for the
    +/// lease to run out. Returns the record as the crash left it.
    +async fn crash_first_claim(
    +    h: &Harness,
    +    op: &str,
    +    source: Arc,
    +    notify: &tokio::sync::Notify,
    +) -> BulkOperationRecord {
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    h.record(op).await
    +}
    +
    +/// Claim the operation again with the plain source and run it to its end.
    +async fn resume_to_end(h: &Harness, op: &str) -> BulkOperationRecord {
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let deps = h.deps_with(
    +        "runner-2",
    +        config(),
    +        Arc::new(h.fs_source()),
    +        Arc::new(h.fs_sink()),
    +    );
    +    runner::run(&deps, record).await.expect("resumed run");
    +    h.record(op).await
    +}
    +
    +#[tokio::test]
    +async fn an_object_listed_empty_and_overwritten_fails_the_operation_after_a_restart() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", b"");
    +    h.write_input("part-001.json", &file_of(&plain_rows(12)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let (pend, notify) = pend_after(&h, 7);
    +    let source = Arc::new(OverwrittenThenPend {
    +        pend,
    +        overwritten: "part-000.json",
    +    });
    +    let crashed = crash_first_claim(&h, &op, source, ¬ify).await;
    +    assert_eq!(
    +        crashed.cursor_file_index, 1,
    +        "the checkpoint passed the file"
    +    );
    +    assert_eq!(crashed.cursor_row_index, 5);
    +
    +    let record = resume_to_end(&h, &op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("ValidationException"));
    +    assert_eq!(record.failure_message.as_deref(), Some(ITEMS_FAILED));
    +    assert_eq!(record.processed_item_count, 1 + 12);
    +    assert_eq!(record.failed_item_count, 1);
    +    let manifest = h.read_manifest(&op).expect("the manifest is written");
    +    assert_eq!(
    +        manifest[0]["fileProcessingStatus"],
    +        json!("PARTIALLY_PROCESSED")
    +    );
    +    assert_eq!(manifest[0]["processedRanges"], json!([]));
    +    assert_eq!(
    +        manifest[1]["fileProcessingStatus"],
    +        json!("FULLY_PROCESSED")
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_empty_object_refused_as_not_compressed_fails_the_operation_after_a_restart() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json.gz", b"");
    +    h.write_input("part-001.json.gz", &gzip_bytes(&file_of(&plain_rows(12))));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(true), both_reports()).await;
    +    let (pend, notify) = pend_after(&h, 7);
    +    let crashed = crash_first_claim(&h, &op, Arc::new(pend), ¬ify).await;
    +    assert_eq!(
    +        crashed.cursor_file_index, 1,
    +        "the checkpoint passed the file"
    +    );
    +
    +    let record = resume_to_end(&h, &op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("ValidationException"));
    +    assert_eq!(record.failure_message.as_deref(), Some(ITEMS_FAILED));
    +    assert_eq!(record.processed_item_count, 1 + 12);
    +    assert_eq!(record.failed_item_count, 1);
    +    let manifest = h.read_manifest(&op).expect("the manifest is written");
    +    assert_eq!(
    +        manifest[0]["fileProcessingStatus"],
    +        json!("PARTIALLY_PROCESSED")
    +    );
    +    assert_eq!(manifest[0]["processedRanges"], json!([]));
    +}
    +
    +#[tokio::test]
    +async fn an_abandon_keeps_its_manifest_line_after_a_restart_without_an_error_report() {
    +    let h = Harness::new().await;
    +    let bad_item = r#"{"Item": "pk"}"#.to_owned();
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[row(0), row(1), bad_item, row(3), row(4)]),
    +    );
    +    h.write_input("part-001.json", &file_of(&plain_rows(12)));
    +    let files = h.enumerate().await;
    +    // Only condition records are requested, so the abandon's error record
    +    // is written nowhere, here or in a report.
    +    let op = h
    +        .start(
    +            files,
    +            input_config(false),
    +            output_config(Some(OUT_BUCKET), &["CONDITION_CHECK_FAILURE"]),
    +        )
    +        .await;
    +    let (pend, notify) = pend_after(&h, 9);
    +    let crashed = crash_first_claim(&h, &op, Arc::new(pend), ¬ify).await;
    +    assert_eq!(
    +        crashed.cursor_file_index, 1,
    +        "the checkpoint passed the file"
    +    );
    +
    +    let record = resume_to_end(&h, &op).await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 3 + 12);
    +    assert_eq!(record.failed_item_count, 1);
    +    let manifest = h.read_manifest(&op).expect("the manifest is written");
    +    assert_eq!(
    +        manifest[0]["fileProcessingStatus"],
    +        json!("PARTIALLY_PROCESSED")
    +    );
    +    assert_eq!(manifest[0]["processedRanges"], json!([[0, 1]]));
    +    assert_eq!(
    +        manifest[1]["fileProcessingStatus"],
    +        json!("FULLY_PROCESSED")
    +    );
    +    assert_eq!(manifest[1]["processedRanges"], json!([[0, 11]]));
    +}
    +
    +#[tokio::test]
    +async fn the_manifest_names_an_overwritten_object_by_its_listed_entity_tag_or_by_none() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(3)));
    +    h.write_input("part-001.json", &file_of(&plain_rows(4)));
    +    let mut files = h.enumerate().await;
    +    // The first object's listing gave an entity tag and the second's gave
    +    // none, as an S3-compatible service may answer. Both objects are
    +    // replaced before their read, so the object now at each key is not the
    +    // one the operation listed.
    +    files[0].etag = Some("0123456789abcdef0123456789abcdef".to_owned());
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let source = Arc::new(BothOverwritten {
    +        inner: h.fs_source(),
    +    });
    +    let deps = h.deps_with("runner-1", config(), source, Arc::new(h.fs_sink()));
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("ValidationException"));
    +    assert_eq!(record.processed_item_count, 2);
    +    assert_eq!(record.failed_item_count, 2);
    +    let manifest = h.read_manifest(&op).expect("the manifest is written");
    +    assert_eq!(
    +        manifest[0]["etag"],
    +        json!("0123456789abcdef0123456789abcdef"),
    +        "the listed tag, not {REPLACEMENT_ETAG}"
    +    );
    +    assert_eq!(
    +        manifest[1]["etag"],
    +        json!(""),
    +        "no tag, not {REPLACEMENT_ETAG}"
    +    );
    +}
    +
    +/// The entity tag of the object now at each key, the replacement.
    +const REPLACEMENT_ETAG: &str = "ffffffffffffffffffffffffffffffff";
    +
    +/// Answers `SourceError::Overwritten` at every open, and the replacement's
    +/// tag for any request for an entity tag.
    +struct BothOverwritten {
    +    inner: FsSource,
    +}
    +
    +impl BulkSource for BothOverwritten {
    +    fn check_bucket(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +    ) -> BoxFuture<'_, Result<(), SourceError>> {
    +        self.inner.check_bucket(bucket, prefix)
    +    }
    +
    +    fn etag(
    +        &self,
    +        _bucket: &BucketRef<'_>,
    +        _file: &InputFile,
    +    ) -> BoxFuture<'_, Result> {
    +        Box::pin(async { Ok(REPLACEMENT_ETAG.to_owned()) })
    +    }
    +
    +    fn line_count(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.line_count(bucket, file, compression)
    +    }
    +
    +    fn list(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +        max_files: u64,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        self.inner.list(bucket, prefix, max_files)
    +    }
    +
    +    fn open(
    +        &self,
    +        _bucket: &BucketRef<'_>,
    +        _file: &InputFile,
    +        _compression: InputCodec,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        Box::pin(async { Err(SourceError::Overwritten) })
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/resume.rs b/crates/engine/src/bulk_put/tests/runner/resume.rs
    new file mode 100644
    index 000000000..b9e9befa8
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/resume.rs
    @@ -0,0 +1,561 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// Resume read errors, ledger retention, the line cap, and index charges.
    +
    +#[tokio::test]
    +async fn an_io_error_inside_the_resumed_skip_region_fails_the_operation() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(30)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    // First runner checkpoints at row 10 and dies.
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 12,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.cursor_row_index, 10);
    +    assert_eq!(mid.processed_item_count, 10);
    +
    +    // The resumed runner's reader returns an I/O error at line 5, inside
    +    // the skip region the checkpoint at row 10 already covered. An I/O
    +    // error is not a changed-file signal, so it fails the operation
    +    // exactly as it would on a first read, rather than abandoning the file
    +    // the way an early end of file or an over-cap line does.
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let source = Arc::new(IoAtSource {
    +        inner: h.fs_source(),
    +        at: 5,
    +        handed: Arc::new(AtomicU64::new(0)),
    +    });
    +    let mut cfg2 = config();
    +    cfg2.batch_size = 5;
    +    let deps = h.deps_with("runner-2", cfg2, source, Arc::new(h.fs_sink()));
    +    runner::run(&deps, record).await.expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("InternalServerError"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("Internal server error"),
    +        "no read detail reaches the client"
    +    );
    +    assert_eq!(
    +        record.processed_item_count, 10,
    +        "the checkpoint's counters stand; the fault dropped no rows silently"
    +    );
    +    assert_eq!(record.failed_item_count, 0);
    +    assert!(
    +        h.read_report(&op, ReportKind::Error).is_none(),
    +        "an I/O fault inside the skip region records nothing"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_crash_between_the_final_checkpoint_and_finish_keeps_the_retried_rows_marker() {
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(3);
    +    rows[1] = condition_row("2", "attribute_not_exists(pk)");
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    // Row 1's marked write commits the item and its Applied ledger marker
    +    // in one data transaction, then reports a storage error, the way an
    +    // acknowledged commit can be lost on the wire. The run reverts to the
    +    // last checkpoint (none yet, so row 0), and the runner pends inside
    +    // finish after the final checkpoint, modelling a crash between the
    +    // checkpoint and the terminal transition.
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let mut faulting = FaultingStorage::new(&h.storage);
    +    faulting.commit_then_error_row = Some((0, 1));
    +    faulting.pend_finish = Some(Arc::clone(¬ify));
    +    let mut cfg = config();
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with_storage("runner-1", cfg, Arc::new(faulting));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the terminal write pends"),
    +        () = notify.notified() => {}
    +    }
    +
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.status, "IN_PROGRESS", "finish never ran");
    +    assert_eq!(
    +        mid.cursor_row_index, 0,
    +        "the cursor is the last checkpoint, before the batch that errored"
    +    );
    +    assert_eq!(
    +        h.storage
    +            .bulk_row_outcome(ACCOUNT, &op, 0, 1)
    +            .await
    +            .expect("ledger read"),
    +        Some(RowOutcome::Applied),
    +        "the marker survived the crash before finish, so the retry does not \
    +         re-evaluate the condition against the operation's own write"
    +    );
    +    assert!(
    +        h.get("c").await.is_some(),
    +        "the committed write is on disk before the retry"
    +    );
    +
    +    // The lease expires; a new worker resumes at the retried row. The
    +    // ledger answers Applied, so the condition is not re-evaluated, the row
    +    // is counted once, and no false condition-failure record is produced.
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-2", config()), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(
    +        record.processed_item_count, 3,
    +        "the retried row counts exactly once"
    +    );
    +    assert_eq!(record.failed_item_count, 0);
    +    assert!(
    +        h.read_report(&op, ReportKind::ConditionCheckFailure)
    +            .is_none(),
    +        "no condition-failure record for a row the ledger says was applied"
    +    );
    +    assert!(h.read_report(&op, ReportKind::Error).is_none());
    +    let stored = h.get("c").await.expect("item present once");
    +    assert_eq!(stored.get("v"), Some(&AttributeValue::N("2".to_owned())));
    +    assert_eq!(
    +        h.storage
    +            .bulk_row_outcome(ACCOUNT, &op, 0, 1)
    +            .await
    +            .expect("ledger read"),
    +        None,
    +        "finish deletes the ledger rows only after the terminal transition"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_crash_after_the_cap_trips_resumes_at_the_tripping_line() {
    +    let h = Harness::new().await;
    +    // Ten rows in the first file fit the cap; the first line of the second
    +    // file trips it. Files behind the cursor are never re-read, so a
    +    // resume re-counts only the second file, which fits the cap. If the
    +    // tripping line had been consumed, the resume would skip it and end
    +    // COMPLETED with that line never written.
    +    h.write_input("part-000.json", &file_of(&plain_rows(10)));
    +    let tail: Vec = (10..12).map(row).collect();
    +    h.write_input("part-001.json", &file_of(&tail));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    // The runner pends inside finish after the final checkpoint, modelling
    +    // a crash between the checkpoint and the terminal transition.
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let mut faulting = FaultingStorage::new(&h.storage);
    +    faulting.pend_finish = Some(Arc::clone(¬ify));
    +    let mut cfg = config();
    +    cfg.max_item_count = 10;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with_storage("runner-1", cfg, Arc::new(faulting));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the terminal write pends"),
    +        () = notify.notified() => {}
    +    }
    +
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.status, "IN_PROGRESS", "finish never ran");
    +    assert_eq!(mid.cursor_file_index, 1);
    +    assert_eq!(
    +        mid.cursor_row_index, 0,
    +        "the cursor points at the line that tripped the cap, not past it"
    +    );
    +    assert_eq!(mid.processed_item_count, 10);
    +    assert_eq!(h.count_items().await, 10);
    +
    +    // The lease expires; a new worker resumes at the tripping line with a
    +    // fresh per-claim count. The second file's two lines fit the cap, so
    +    // the line the first run stopped on is applied rather than skipped.
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let mut cfg = config();
    +    cfg.max_item_count = 10;
    +    runner::run(&h.deps("runner-2", cfg), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(
    +        record.processed_item_count, 12,
    +        "every line is applied or reported once across the crash"
    +    );
    +    assert_eq!(h.count_items().await, 12);
    +    assert!(
    +        h.get("k10").await.is_some(),
    +        "the line the first run stopped on is written by the resume"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn blank_lines_count_toward_the_item_cap() {
    +    let h = Harness::new().await;
    +    let blanks: Vec = (0..12).map(|_| String::new()).collect();
    +    h.write_input("part-000.json", &file_of(&blanks));
    +    let files = h.enumerate().await;
    +    let mut cfg = config();
    +    cfg.max_item_count = 10;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), cfg)
    +        .await;
    +
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("ValidationException"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("Operation exceeded max_bulk_item_count (10 rows)"),
    +        "blank lines are consumed physical lines and count toward the cap"
    +    );
    +    assert_eq!(
    +        record.processed_item_count, 0,
    +        "blank lines touch no processed counter even as they trip the cap"
    +    );
    +    assert_eq!(record.failed_item_count, 0);
    +}
    +
    +#[tokio::test]
    +async fn an_item_level_fault_on_the_row_past_the_cap_fails_the_operation() {
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(10);
    +    rows.push(row_missing_sk(10));
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let mut cfg = config();
    +    cfg.max_item_count = 10;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), cfg)
    +        .await;
    +
    +    assert_eq!(
    +        record.status, "FAILED",
    +        "the cap fires on an item-level fault row too, not a per-row record"
    +    );
    +    assert_eq!(record.failure_code.as_deref(), Some("ValidationException"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("Operation exceeded max_bulk_item_count (10 rows)")
    +    );
    +    assert_eq!(
    +        record.failed_item_count, 0,
    +        "the cap check returns before the row is validated or recorded"
    +    );
    +    assert!(
    +        h.read_report(&record.operation_id, ReportKind::Error)
    +            .is_none(),
    +        "no record for the row that trips the cap"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_replayed_failed_condition_row_is_reemitted_even_after_the_item_is_deleted() {
    +    let h = Harness::new().await;
    +    h.put_direct(item("c", "1")).await;
    +    let mut rows = plain_rows(12);
    +    rows[10] = condition_row("2", "attribute_not_exists(pk)");
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    // The condition at row 10 fails against the seeded item; its marker
    +    // commits in its own transaction, then the runner dies before the
    +    // covering checkpoint.
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 11,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    // One row per storage transaction, so the row's write commits as
    +    // soon as it is read and the crash lands between that commit and
    +    // the checkpoint that would cover it.
    +    cfg.write_batch_rows = 1;
    +    cfg.lease = Duration::from_millis(100);
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +
    +    assert_eq!(
    +        h.storage
    +            .bulk_row_outcome(ACCOUNT, &op, 0, 10)
    +            .await
    +            .expect("ledger read"),
    +        Some(RowOutcome::ConditionFailed),
    +        "the failure outcome was recorded in its own transaction"
    +    );
    +
    +    // Between the crash and the resume the seeded item is deleted, so a
    +    // re-evaluation of attribute_not_exists(pk) would now pass and write.
    +    // The ledger must answer instead: the failure record is re-emitted and
    +    // the item is not written, so the resume follows the recorded outcome,
    +    // not a fresh evaluation.
    +    {
    +        let info = h.key_info().await;
    +        h.storage
    +            .delete_item(
    +                &info,
    +                &key("c"),
    +                false,
    +                None,
    +                &ExpressionMaps::default(),
    +                None,
    +            )
    +            .await
    +            .expect("delete the seeded item");
    +    }
    +    assert!(h.get("c").await.is_none(), "the item is gone before resume");
    +
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let mut cfg2 = config();
    +    cfg2.batch_size = 5;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-2", cfg2), record)
    +        .await
    +        .expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 12, "the row counts once");
    +    assert_eq!(record.failed_item_count, 0);
    +    assert!(
    +        h.get("c").await.is_none(),
    +        "the ledger replay does not re-evaluate the condition or write the row"
    +    );
    +    let report = h
    +        .read_report(&op, ReportKind::ConditionCheckFailure)
    +        .expect("condition report rendered");
    +    assert_eq!(report.len(), 1, "the recorded failure is still emitted");
    +    assert_eq!(report[0]["errorMessages"], json!([CONDITION_FAILED]));
    +    assert_eq!(report[0]["itemLocation"]["itemIndex"], json!(10));
    +    assert_eq!(report[0]["newImage"]["Item"]["v"]["N"], json!("2"));
    +    assert_eq!(
    +        report[0]["oldImage"]["Item"]["v"]["N"],
    +        json!("1"),
    +        "the record carries the item the condition was evaluated against, as an \
    +         uninterrupted run's does, not the item stored at the resume"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_condition_overwrite_charges_the_index_like_a_plain_overwrite() {
    +    // A row keyed `pk`/`sk` with a GSI hash attribute `g`, either plain or
    +    // carrying a passing condition.
    +    fn indexed_row(pk: &str, g: &str, condition: Option<&str>) -> String {
    +        let base = format!(
    +            "\"Item\": {{\"pk\": {{\"S\": \"{pk}\"}}, \"sk\": {{\"S\": \"s\"}}, \
    +             \"g\": {{\"S\": \"{g}\"}}}}"
    +        );
    +        match condition {
    +            Some(expr) => format!("{{{base}, \"ConditionExpression\": \"{expr}\"}}"),
    +            None => format!("{{{base}}}"),
    +        }
    +    }
    +    fn seed(pk: &str, g: &str) -> Item {
    +        let mut item = Item::new();
    +        item.insert("pk".to_owned(), AttributeValue::S(pk.to_owned()));
    +        item.insert("sk".to_owned(), AttributeValue::S("s".to_owned()));
    +        item.insert("g".to_owned(), AttributeValue::S(g.to_owned()));
    +        item
    +    }
    +
    +    // Run one operation that overwrites a seeded item and return the GSI
    +    // usage map. The row optionally carries attribute_exists(pk), which
    +    // passes against the seeded item, so the passing-condition path and the
    +    // plain path apply the same overwrite.
    +    async fn gsi_usage(condition: Option<&str>) -> Value {
    +        let h = Harness::new_with_gsi().await;
    +        h.put_direct(seed("i", "old")).await;
    +        h.write_input(
    +            "part-000.json",
    +            &file_of(&[indexed_row("i", "new", condition)]),
    +        );
    +        let files = h.enumerate().await;
    +        let record = h
    +            .run_to_end(files, input_config(false), both_reports(), config())
    +            .await;
    +        assert_eq!(record.status, "COMPLETED");
    +        assert_eq!(record.processed_item_count, 1);
    +        assert_eq!(record.failed_item_count, 0);
    +        record.usage["GlobalSecondaryIndexes"].clone()
    +    }
    +
    +    let plain = gsi_usage(None).await;
    +    let conditional = gsi_usage(Some("attribute_exists(pk)")).await;
    +
    +    assert!(
    +        plain["gsi1"]["BulkPutBytes"]
    +            .as_u64()
    +            .is_some_and(|b| b > 0),
    +        "the overwrite charges the index"
    +    );
    +    assert_eq!(
    +        conditional, plain,
    +        "a passing-condition overwrite charges the index by the old-versus-new \
    +         delta, the same figure a plain overwrite reports"
    +    );
    +}
    +
    +/// A bulk run into a table with a vector index charges the index in
    +/// `BulkPutVectorBytes` under `VectorIndexes`, and the top-level
    +/// `BulkPutBytes` excludes the vector figure: two items under 1 KB report
    +/// 2048 at the top level whatever the index consumed. Every other usage
    +/// case runs on plain or GSI tables, so the vector plumbing from the write
    +/// loop through `capacity_helpers` is otherwise unobserved.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn a_bulk_run_over_a_vector_indexed_table_charges_vector_bytes_separately() {
    +    fn vector_row(pk: &str) -> String {
    +        json!({"Item": {
    +            "pk": {"S": pk},
    +            "sk": {"S": "1"},
    +            "emb": {"L": [
    +                {"N": "0.1"}, {"N": "0.2"}, {"N": "0.3"}, {"N": "0.4"}
    +            ]}
    +        }})
    +        .to_string()
    +    }
    +
    +    let h = Harness::new_with_vector().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[vector_row("a"), vector_row("b")]),
    +    );
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 2);
    +    assert_eq!(record.failed_item_count, 0);
    +
    +    let vector_bytes = record.usage["VectorIndexes"]["vidx"]["BulkPutVectorBytes"]
    +        .as_u64()
    +        .expect("the vector index must appear in the usage map");
    +    assert!(
    +        vector_bytes > 0,
    +        "a write carrying the vector attribute charges the index"
    +    );
    +    assert_eq!(
    +        record.usage["BulkPutBytes"].as_u64(),
    +        Some(2048),
    +        "two items under 1 KB round to 1 KB each; the vector figure stays \
    +         out of the top-level bytes"
    +    );
    +    assert_eq!(
    +        record.usage["Table"]["BulkPutBytes"].as_u64(),
    +        Some(2048),
    +        "the table member carries the same figure as the top level"
    +    );
    +}
    +
    +// One transient storage fault on a write batch (a
    +// connection reset, an admin or crash shutdown, a serialization or deadlock
    +// abort, a lock timeout, a busy or locked database, a pool timeout) left
    +// the whole operation FAILED InternalServerError, while the same class of
    +// fault on a checkpoint left the operation non-terminal and the next claim
    +// resumed it. The two now take the same resumable path: the batch rolled
    +// back, nothing since the last checkpoint committed, so the operation stays
    +// in progress and a later claim finishes it from the last checkpoint.
    +//
    +// The storage double fails the first write of k3 with a transient storage
    +// error. The first claim's run returns that as a runner storage error with
    +// the operation still IN_PROGRESS and no terminal record; a second claim
    +// after the lease expires finishes it with the same rows, counters, usage
    +// and reports as an uninterrupted run.
    +#[tokio::test]
    +async fn a_transient_write_batch_fault_leaves_the_operation_to_resume() {
    +    // Uninterrupted truth on a clean harness.
    +    let truth_h = Harness::new().await;
    +    truth_h.write_input("part-000.json", &file_of(&plain_rows(10)));
    +    let truth_files = truth_h.enumerate().await;
    +    let truth = truth_h
    +        .run_to_end(truth_files, input_config(false), both_reports(), config())
    +        .await;
    +    assert_eq!(truth.status, "COMPLETED");
    +
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(10)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +
    +    let mut faulting = FaultingStorage::new(&h.storage);
    +    faulting.fail_put_pk = Some("k3".to_owned());
    +    let storage: Arc = Arc::new(faulting);
    +
    +    // First claim: the transient fault on the write batch surfaces as a
    +    // runner storage error, not a terminal transition.
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +    let deps = h.deps_with_storage("runner-1", config(), Arc::clone(&storage));
    +    let result = runner::run(&deps, record).await;
    +    assert!(
    +        matches!(result, Err(runner::RunnerError::Storage(_))),
    +        "a transient write fault is a runner storage error, not a FAILED operation"
    +    );
    +    let mid = h.record(&op).await;
    +    assert_eq!(
    +        mid.status, "IN_PROGRESS",
    +        "the operation stays non-terminal after a transient write fault"
    +    );
    +    assert!(
    +        mid.failure_code.is_none(),
    +        "no terminal failure was written"
    +    );
    +
    +    // Second claim after the lease expires: the fault does not fire again
    +    // and the resume finishes the operation.
    +    tokio::time::sleep(Duration::from_millis(150)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let deps = h.deps_with_storage("runner-2", config(), Arc::clone(&storage));
    +    runner::run(&deps, record)
    +        .await
    +        .expect("the resume finishes");
    +
    +    let final_record = h.record(&op).await;
    +    assert_eq!(final_record.status, "COMPLETED");
    +    assert_eq!(h.count_items().await, 10);
    +    assert_eq!(
    +        final_record.processed_item_count, truth.processed_item_count,
    +        "the resumed run processed the same rows as the uninterrupted one"
    +    );
    +    assert_eq!(final_record.failed_item_count, truth.failed_item_count);
    +    assert_eq!(
    +        final_record.usage, truth.usage,
    +        "usage matches the uninterrupted run"
    +    );
    +    assert_eq!(
    +        h.read_manifest(&op),
    +        truth_h.read_manifest(&truth.operation_id),
    +        "the manifest matches the uninterrupted run"
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/resume_bound.rs b/crates/engine/src/bulk_put/tests/runner/resume_bound.rs
    new file mode 100644
    index 000000000..6a07c0a43
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/resume_bound.rs
    @@ -0,0 +1,97 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The resume bound against the other ways an operation ends. The claim
    +//! that spends the bound reads no input; when it finds the table deleted,
    +//! the operation ends with the deleted-table failure, as every other claim
    +//! after the deletion would, and when it finds a cancel pending, the
    +//! operation ends `CANCELLED` with the cancel pair.
    +
    +use super::*;
    +
    +/// Checkpoints and write batches of five rows, the exactly-once cases'
    +/// window, so the first batch holds the faulted row.
    +fn window() -> RunnerConfig {
    +    RunnerConfig {
    +        batch_size: 5,
    +        write_batch_rows: 5,
    +        ..config()
    +    }
    +}
    +
    +/// Run `claims` claims whose first batch fails transiently, so the
    +/// checkpoint never moves: the first claim and `claims - 1` resumes. Each
    +/// leaves the operation `IN_PROGRESS` for the next.
    +async fn stall(h: &Harness, op: &str, claims: usize) {
    +    for n in 0..claims {
    +        let owner = format!("runner-{n}");
    +        let record = h.claim(&owner, Duration::from_millis(100)).await;
    +        let mut faulting = FaultingStorage::new(&h.storage);
    +        faulting.fail_put_pk = Some("k0".to_owned());
    +        faulting.checkpoint_lease = Some(Duration::from_millis(100));
    +        let deps = h.deps_with_storage(&owner, window(), Arc::new(faulting));
    +        assert!(
    +            runner::run(&deps, record).await.is_err(),
    +            "claim {n} leaves the operation to resume"
    +        );
    +        assert_eq!(h.record(op).await.status, "IN_PROGRESS");
    +        tokio::time::sleep(Duration::from_millis(150)).await;
    +    }
    +}
    +
    +/// The claim that spends the bound: the first claim and nine resumes ran,
    +/// so this is the tenth resume without progress.
    +async fn spend_the_bound(h: &Harness) {
    +    let record = h.claim("runner-last", Duration::from_millis(100)).await;
    +    let deps = h.deps("runner-last", window());
    +    runner::run(&deps, record)
    +        .await
    +        .expect("the run that ends the operation returns cleanly");
    +}
    +
    +async fn stalled_operation(h: &Harness) -> String {
    +    h.write_input("part-000.json", &file_of(&plain_rows(6)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    stall(h, &op, 10).await;
    +    op
    +}
    +
    +#[tokio::test]
    +async fn a_table_deleted_while_resumes_stall_ends_with_the_deleted_table_failure() {
    +    let h = Harness::new().await;
    +    let op = stalled_operation(&h).await;
    +    h.storage
    +        .delete_table(
    +            ACCOUNT,
    +            DeleteTableInput {
    +                table_name: TABLE.to_owned(),
    +            },
    +        )
    +        .await
    +        .expect("delete table");
    +    spend_the_bound(&h).await;
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(
    +        record.failure_code.as_deref(),
    +        Some("DynamoDBResourceNotFoundException")
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_cancel_while_resumes_stall_ends_cancelled() {
    +    let h = Harness::new().await;
    +    let op = stalled_operation(&h).await;
    +    h.storage
    +        .request_cancel(ACCOUNT, &op)
    +        .await
    +        .expect("request cancel");
    +    spend_the_bound(&h).await;
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "CANCELLED");
    +    assert_eq!(record.failure_code.as_deref(), Some("RequestCancelled"));
    +    assert_eq!(h.count_items().await, 0);
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/rotation.rs b/crates/engine/src/bulk_put/tests/runner/rotation.rs
    new file mode 100644
    index 000000000..66e962e78
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/rotation.rs
    @@ -0,0 +1,286 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Report object rotation through the runner: a run whose error report
    +//! spans more than one render page rolls to `output-00001.json.gz` once
    +//! the compressed bytes of the first object reach the threshold,
    +//! and a resumed render after a crash mid-roll leaves exactly the expected
    +//! objects and no temp file.
    +
    +use super::*;
    +
    +/// Renders through the real sink with the writer's rotation threshold
    +/// lowered to `threshold` bytes, so a run of a few thousand records rolls.
    +/// With `pend_after_page` set, the writer signals `reached` after that many
    +/// `write_page` calls have completed and then waits for `release`, holding
    +/// the runner mid-render with its temp files in place.
    +pub(super) struct RollingSink {
    +    pub(super) inner: FsSink,
    +    pub(super) threshold: u64,
    +    pub(super) pend_after_page: Option,
    +    pub(super) reached: Arc,
    +    pub(super) release: Arc,
    +}
    +
    +impl RollingSink {
    +    pub(super) fn new(inner: FsSink, threshold: u64) -> Self {
    +        Self {
    +            inner,
    +            threshold,
    +            pend_after_page: None,
    +            reached: Arc::new(tokio::sync::Notify::new()),
    +            release: Arc::new(tokio::sync::Notify::new()),
    +        }
    +    }
    +}
    +
    +struct RollingWriter {
    +    inner: Box,
    +    pages: usize,
    +    pend_after_page: Option,
    +    reached: Arc,
    +    release: Arc,
    +}
    +
    +impl BulkSink for RollingSink {
    +    fn write_manifest(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        sse: &OutputSse,
    +        lines: Vec,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner
    +            .write_manifest(at, prefix, operation_id, owner, sse, lines)
    +    }
    +
    +    fn check_location(
    +        &self,
    +        at: &BucketRef<'_>,
    +        key: &str,
    +        sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result<(), SinkError>> {
    +        self.inner.check_location(at, key, sse)
    +    }
    +
    +    fn open(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        operation_id: &str,
    +        kind: ReportKind,
    +        owner: &str,
    +        sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result, SinkError>> {
    +        let fut = self.inner.open(at, prefix, operation_id, kind, owner, sse);
    +        let threshold = self.threshold;
    +        let pend_after_page = self.pend_after_page;
    +        let reached = Arc::clone(&self.reached);
    +        let release = Arc::clone(&self.release);
    +        Box::pin(async move {
    +            let mut inner = fut.await?;
    +            inner.set_rotation_threshold(threshold);
    +            Ok(Box::new(RollingWriter {
    +                inner,
    +                pages: 0,
    +                pend_after_page,
    +                reached,
    +                release,
    +            }) as Box)
    +        })
    +    }
    +}
    +
    +impl ReportWriter for RollingWriter {
    +    fn write_page(&mut self, records: Vec) -> BoxFuture<'_, Result<(), SinkError>> {
    +        Box::pin(async move {
    +            self.inner.write_page(records).await?;
    +            self.pages += 1;
    +            if self.pend_after_page == Some(self.pages) {
    +                self.reached.notify_one();
    +                self.release.notified().await;
    +            }
    +            Ok(())
    +        })
    +    }
    +
    +    fn finish(self: Box) -> BoxFuture<'static, Result> {
    +        let this = *self;
    +        this.inner.finish()
    +    }
    +
    +    fn object_index(&self) -> u32 {
    +        self.inner.object_index()
    +    }
    +}
    +
    +/// Rows that each produce one item-level error record.
    +fn error_rows(n: usize) -> Vec {
    +    (0..n).map(row_missing_sk).collect()
    +}
    +
    +/// The render pages records out of storage 1,000 at a time, so 1,500
    +/// error records arrive as a page of 1,000 and a page of 500. With the
    +/// threshold at one byte the first page trips it and the second page
    +/// opens the next object.
    +#[tokio::test]
    +async fn an_error_report_past_the_threshold_spans_numbered_objects() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&error_rows(1500)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let sink = Arc::new(RollingSink::new(h.fs_sink(), 1));
    +    let deps = h.deps_with("runner-1", config(), Arc::new(h.fs_source()), sink);
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.failed_item_count, 1500);
    +    assert_eq!(
    +        h.report_objects(&op, ReportKind::Error),
    +        vec!["output-00000.json.gz", "output-00001.json.gz"],
    +        "two objects and nothing else under the kind directory"
    +    );
    +    let report = h
    +        .read_report(&op, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(
    +        report.len(),
    +        1500,
    +        "every record lands in exactly one object"
    +    );
    +    let dir = h.report_path(&op, ReportKind::Error);
    +    let dir = dir.parent().unwrap();
    +    let first = gunzip_lines(&std::fs::read(dir.join("output-00000.json.gz")).unwrap());
    +    let second = gunzip_lines(&std::fs::read(dir.join("output-00001.json.gz")).unwrap());
    +    assert_eq!(
    +        first.len(),
    +        1000,
    +        "the page that tripped the threshold stays in the first object"
    +    );
    +    assert_eq!(second.len(), 500);
    +    assert!(
    +        report
    +            .iter()
    +            .all(|r| r["errorMessages"] == json!([MISSING_KEY])),
    +        "every record is the missing-key fault"
    +    );
    +    let indexes: Vec = report
    +        .iter()
    +        .map(|r| r["itemLocation"]["itemIndex"].as_i64().unwrap())
    +        .collect();
    +    assert_eq!(
    +        indexes,
    +        (0..1500).collect::>(),
    +        "records keep their stored order across the objects"
    +    );
    +    assert!(
    +        std::fs::read_dir(dir).unwrap().all(|entry| !entry
    +            .unwrap()
    +            .file_name()
    +            .to_string_lossy()
    +            .contains(".tmp-")),
    +        "no temp file remains"
    +    );
    +}
    +
    +/// The first runner rolls, writes its second page into the second
    +/// object's temp, and is frozen there, as a paused process is. The next
    +/// owner's open sweeps both temps, its render writes both objects, and the
    +/// stale runner, once released, observes the lost lease and installs
    +/// nothing.
    +#[tokio::test]
    +async fn a_resumed_render_after_a_crash_mid_roll_leaves_exactly_the_expected_objects() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&error_rows(1500)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    let mut sink = RollingSink::new(h.fs_sink(), 1);
    +    sink.pend_after_page = Some(2);
    +    let reached = Arc::clone(&sink.reached);
    +    let release = Arc::clone(&sink.release);
    +    // Not polled once its page is written, as a paused process is: a
    +    // runner that is polled keeps its lease while a write waits. Its own
    +    // lease is 60 seconds, so no renewal is due while the page waits and
    +    // none is under way when polling stops; a renewal stopped inside a
    +    // storage call could hold the engine's write lock, and the next claim
    +    // would wait for it without end. Its checkpoints ask the store for
    +    // 100 ms, so the store's lease runs out behind it.
    +    let deps = h.with_short_store_lease(h.deps_with(
    +        "runner-1",
    +        config(),
    +        Arc::new(h.fs_source()),
    +        Arc::new(sink),
    +    ));
    +    let stale = runner::run(&deps, record);
    +    tokio::pin!(stale);
    +    tokio::time::timeout(Duration::from_secs(10), async {
    +        tokio::select! {
    +            _ = &mut stale => panic!("the first runner must not finish before its second page"),
    +            () = reached.notified() => {}
    +        }
    +    })
    +    .await
    +    .expect("the first runner must reach its second page");
    +
    +    let dir = h.report_path(&op, ReportKind::Error);
    +    let dir = dir.parent().unwrap().to_path_buf();
    +    let temps: Vec = std::fs::read_dir(&dir)
    +        .unwrap()
    +        .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
    +        .filter(|name| name.contains(".tmp-"))
    +        .collect();
    +    assert_eq!(
    +        temps.len(),
    +        2,
    +        "the first object's closed temp and the second object's open temp: {temps:?}"
    +    );
    +    assert!(
    +        h.report_objects(&op, ReportKind::Error).is_empty(),
    +        "nothing installed yet"
    +    );
    +
    +    // The lease expires; the next owner claims, renders both objects, and
    +    // finishes.
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let sink = Arc::new(RollingSink::new(h.fs_sink(), 1));
    +    let deps = h.deps_with("runner-2", config(), Arc::new(h.fs_source()), sink);
    +    runner::run(&deps, record).await.expect("resumed run");
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +
    +    let names = |dir: &std::path::Path| {
    +        let mut names: Vec = std::fs::read_dir(dir)
    +            .unwrap()
    +            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
    +            .collect();
    +        names.sort();
    +        names
    +    };
    +    assert_eq!(
    +        names(&dir),
    +        vec!["output-00000.json.gz", "output-00001.json.gz"],
    +        "exactly the expected objects and no temp"
    +    );
    +    assert_eq!(h.read_report(&op, ReportKind::Error).unwrap().len(), 1500);
    +
    +    // Released, the stale runner re-reads the lease, finds it lost, drops
    +    // its writer, and leaves the directory as the new owner wrote it.
    +    release.notify_one();
    +    tokio::time::timeout(Duration::from_secs(10), stale)
    +        .await
    +        .expect("the stale runner must stop once released")
    +        .expect("a lost lease is a clean stop, not an error");
    +    assert_eq!(
    +        names(&dir),
    +        vec!["output-00000.json.gz", "output-00001.json.gz"],
    +        "the stale runner installed nothing and left no temp"
    +    );
    +    assert_eq!(h.read_report(&op, ReportKind::Error).unwrap().len(), 1500);
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/rows.rs b/crates/engine/src/bulk_put/tests/runner/rows.rs
    new file mode 100644
    index 000000000..4a715f907
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/rows.rs
    @@ -0,0 +1,383 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// Row classes and counters.
    +
    +#[tokio::test]
    +async fn a_bad_last_row_leaves_25_written_and_ends_completed_with_errors() {
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(25);
    +    rows.push(row_missing_sk(25));
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 26);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert_eq!(h.count_items().await, 25);
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(report[0]["errorMessages"], json!([MISSING_KEY]));
    +}
    +
    +#[tokio::test]
    +async fn a_parse_fault_at_line_11_abandons_the_rest_of_the_file() {
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(10);
    +    rows.push(row_bad_type_tag(10));
    +    rows.extend((11..31).map(row));
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 11);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert_eq!(h.count_items().await, 10);
    +    assert!(h.get("k9").await.is_some(), "the 10th row is written");
    +    assert!(h.get("k11").await.is_none(), "row 12 is never read");
    +    assert!(h.get("k30").await.is_none(), "row 31 is never read");
    +    assert_eq!(
    +        Some(record.processed_input_bytes),
    +        record.total_input_bytes,
    +        "ProcessedInputBytes advances to the end of the abandoned file"
    +    );
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(report[0]["errorMessages"], json!([ABANDON]));
    +}
    +
    +#[tokio::test]
    +async fn a_missing_output_bucket_found_at_the_first_record_is_held_to_the_end_of_the_input() {
    +    // Measured on the service: the output configuration is checked when the
    +    // first report record is produced, the fault is held, and the run reads
    +    // on to the end of the input. It ends FAILED with the fault, the rows
    +    // written as ProcessedItemCount, no failed row, no processed byte, no
    +    // usage, and nothing rendered.
    +    let h = Harness::new().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[row(0), row_missing_sk(1), row(2)]),
    +    );
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(
    +            files,
    +            input_config(false),
    +            output_config(Some("absent-reports"), &["ERROR"]),
    +            config(),
    +        )
    +        .await;
    +
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3NoSuchBucket"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("The specified bucket does not exist")
    +    );
    +    assert_eq!(
    +        record.processed_item_count, 2,
    +        "the rows written, before and after the faulting row"
    +    );
    +    assert_eq!(record.failed_item_count, 0);
    +    assert_eq!(record.processed_input_bytes, 0);
    +    assert_eq!(record.usage["BulkPutBytes"], json!(0));
    +    assert_eq!(record.report_record_count, 0);
    +    assert!(h.get("k0").await.is_some());
    +    assert!(
    +        h.get("k2").await.is_some(),
    +        "the row after the fault is written"
    +    );
    +    assert!(
    +        !h.store.0.join(ACCOUNT).join("absent-reports").exists(),
    +        "nothing renders on this path"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_mismatched_output_owner_found_at_the_first_record_is_held_to_the_end_of_the_input() {
    +    // The output bucket exists; only the recorded owner differs from the
    +    // account. Ownership is decided before existence, as at Start.
    +    let h = Harness::new().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[row(0), row_missing_sk(1), row(2)]),
    +    );
    +    let files = h.enumerate().await;
    +    let mut output = output_config(Some(OUT_BUCKET), &["ERROR"]);
    +    output["S3BucketOwner"] = json!("999999999999");
    +    let record = h
    +        .run_to_end(files, input_config(false), output, config())
    +        .await;
    +
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3AccessDenied"));
    +    assert_eq!(record.failure_message.as_deref(), Some("Access Denied"));
    +    assert_eq!(record.processed_item_count, 2);
    +    assert_eq!(record.failed_item_count, 0);
    +    assert_eq!(record.processed_input_bytes, 0);
    +    assert_eq!(h.count_items().await, 2);
    +    assert!(
    +        h.read_report(&record.operation_id, ReportKind::Error)
    +            .is_none(),
    +        "no report is written for an unvalidated output configuration"
    +    );
    +    assert!(h.read_manifest(&record.operation_id).is_none());
    +}
    +
    +#[tokio::test]
    +async fn a_location_fault_found_in_a_later_batch_is_held_to_the_end_of_the_input() {
    +    // The first report record lands in the second batch (batch size 25),
    +    // after a checkpoint at row 25. The fault is held as in the first
    +    // batch: every good row is written and counted.
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(30);
    +    rows.push(row_missing_sk(30));
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(
    +            files,
    +            input_config(false),
    +            output_config(Some("absent-reports"), &["ERROR"]),
    +            config(),
    +        )
    +        .await;
    +
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3NoSuchBucket"));
    +    assert_eq!(record.processed_item_count, 30, "the rows written");
    +    assert_eq!(record.failed_item_count, 0);
    +    assert_eq!(record.processed_input_bytes, 0);
    +    assert_eq!(h.count_items().await, 30, "written rows stay written");
    +}
    +
    +#[tokio::test]
    +async fn a_missing_output_bucket_with_clean_rows_fails_at_the_manifest() {
    +    // Measured on the service: with no failing rows the output bucket
    +    // is first touched by the manifest write, and a missing bucket ends the
    +    // operation FAILED with the bucket code and the manifest prefix; the
    +    // rows written stay written and counted.
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(3)));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(
    +            files,
    +            input_config(false),
    +            output_config(Some("absent-reports"), &["ERROR"]),
    +            config(),
    +        )
    +        .await;
    +
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3NoSuchBucket"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("Manifest is not available. The specified bucket does not exist")
    +    );
    +    assert_eq!(record.processed_item_count, 3);
    +    assert_eq!(record.failed_item_count, 0);
    +    assert_eq!(h.count_items().await, 3);
    +    assert!(
    +        !h.store.0.join(ACCOUNT).join("absent-reports").exists(),
    +        "nothing creates the missing report bucket"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_mismatched_output_owner_with_clean_rows_fails_at_the_manifest() {
    +    // Measured on the service: an output owner that is not the caller
    +    // fails the operation at the manifest write with S3AccessDenied and the
    +    // manifest prefix.
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(2)));
    +    let files = h.enumerate().await;
    +    let mut output = output_config(Some(OUT_BUCKET), &["ERROR"]);
    +    output["S3BucketOwner"] = json!("111122223333");
    +    let record = h
    +        .run_to_end(files, input_config(false), output, config())
    +        .await;
    +
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3AccessDenied"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("Manifest is not available. Access Denied")
    +    );
    +    assert_eq!(record.processed_item_count, 2);
    +    assert_eq!(h.count_items().await, 2);
    +}
    +
    +#[tokio::test]
    +async fn a_row_without_an_item_abandons_the_file_with_its_own_message() {
    +    let h = Harness::new().await;
    +    let no_item = r#"{"ConditionExpression": "attribute_not_exists(pk)"}"#.to_owned();
    +    h.write_input("part-000.json", &file_of(&[row(0), no_item, row(2)]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 2, "row 3 is never read");
    +    assert_eq!(record.failed_item_count, 1);
    +    assert_eq!(h.count_items().await, 1);
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!([
    +            "Expected 'Item' top level container. Remainder of the file will not be processed."
    +        ])
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_non_object_item_abandons_the_file_with_its_own_message() {
    +    let h = Harness::new().await;
    +    let bad_item = r#"{"Item": "pk"}"#.to_owned();
    +    h.write_input("part-000.json", &file_of(&[row(0), bad_item, row(2)]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 2, "row 3 is never read");
    +    assert_eq!(record.failed_item_count, 1);
    +    assert_eq!(h.count_items().await, 1);
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!(["Expected character {. Remainder of the file will not be processed."])
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_abandoned_first_file_does_not_stop_the_second_file() {
    +    let h = Harness::new().await;
    +    h.write_input("a.json", &file_of(&[row(0), row_bad_type_tag(1), row(2)]));
    +    h.write_input("b.json", &file_of(&[row(10), row(11), row(12)]));
    +    let files = h.enumerate().await;
    +    assert_eq!(files.len(), 2, "lexical order: a.json then b.json");
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 5);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert!(h.get("k0").await.is_some(), "row before the fault written");
    +    assert!(h.get("k2").await.is_none(), "rest of the first file unread");
    +    assert!(h.get("k10").await.is_some(), "second file fully processed");
    +    assert!(h.get("k12").await.is_some(), "second file fully processed");
    +    assert_eq!(Some(record.processed_input_bytes), record.total_input_bytes);
    +}
    +
    +// Item-level classes, one operation each.
    +
    +#[tokio::test]
    +async fn a_row_missing_a_key_attribute_fails_alone_with_the_put_item_message() {
    +    let h = Harness::new().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[row(0), row_missing_sk(1), row(2)]),
    +    );
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 3);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert!(h.get("k0").await.is_some());
    +    assert!(h.get("k2").await.is_some(), "the file continues");
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(report[0]["errorMessages"], json!([MISSING_KEY]));
    +    assert_eq!(report[0]["itemLocation"]["itemIndex"], json!(1));
    +    assert_eq!(
    +        report[0]["newImage"]["Item"]["pk"]["S"],
    +        json!("k1"),
    +        "the missing-key record carries the submitted item"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_row_with_a_wrong_key_type_fails_alone_with_the_put_item_message() {
    +    let h = Harness::new().await;
    +    let bad = "{\"Item\": {\"pk\": {\"N\": \"7\"}, \"sk\": {\"S\": \"s\"}}}".to_owned();
    +    h.write_input("part-000.json", &file_of(&[row(0), bad, row(2)]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 3);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert!(h.get("k2").await.is_some(), "the file continues");
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(report[0]["errorMessages"], json!([TYPE_MISMATCH]));
    +    assert!(
    +        report[0].get("newImage").is_none(),
    +        "type-mismatch records carry no newImage"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_oversized_item_fails_alone_with_the_put_item_message() {
    +    let h = Harness::new().await;
    +    // The item size limit is 409,600 bytes (400 KiB); this blob alone
    +    // exceeds it.
    +    let blob = "x".repeat(410_000);
    +    let oversized = format!(
    +        "{{\"Item\": {{\"pk\": {{\"S\": \"big\"}}, \"sk\": {{\"S\": \"s\"}}, \"v\": {{\"S\": \"{blob}\"}}}}}}"
    +    );
    +    h.write_input("part-000.json", &file_of(&[row(0), oversized, row(2)]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 3);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert!(
    +        h.get("big").await.is_none(),
    +        "the oversized row not written"
    +    );
    +    assert!(h.get("k2").await.is_some(), "the file continues");
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(report[0]["errorMessages"], json!([OVERSIZED]));
    +    assert!(
    +        report[0].get("newImage").is_none(),
    +        "oversized records carry no newImage"
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/rows_faults.rs b/crates/engine/src/bulk_put/tests/runner/rows_faults.rs
    new file mode 100644
    index 000000000..c7bd152c0
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/rows_faults.rs
    @@ -0,0 +1,396 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Row faults measured on the hosted service, driven end to end
    +//! through the runner: the key texts a bad key attribute answers, the file
    +//! abandon a record fault produces, and the two item shapes the service
    +//! accepts.
    +
    +use super::*;
    +
    +/// A row whose hash key is a `NULL` value.
    +fn row_null_hash_key() -> String {
    +    "{\"Item\": {\"pk\": {\"NULL\": true}, \"sk\": {\"S\": \"s\"}}}".to_owned()
    +}
    +
    +/// A row whose hash key is an empty string.
    +fn row_empty_hash_key() -> String {
    +    "{\"Item\": {\"pk\": {\"S\": \"\"}, \"sk\": {\"S\": \"s\"}}}".to_owned()
    +}
    +
    +/// A row whose sort key carries an `N` value that is not a number.
    +fn row_non_numeric_sort_key() -> String {
    +    "{\"Item\": {\"pk\": {\"S\": \"k1\"}, \"sk\": {\"N\": \"not-a-number\"}}}".to_owned()
    +}
    +
    +// A NULL hash key fails the row alone with the type-mismatch text
    +// and the file continues.
    +#[tokio::test]
    +async fn a_null_hash_key_fails_the_row_with_the_type_mismatch_text() {
    +    let h = Harness::new().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[row(0), row_null_hash_key(), row(2)]),
    +    );
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 3);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert!(h.get("k0").await.is_some());
    +    assert!(h.get("k2").await.is_some(), "the file continues");
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!([
    +            "One or more parameter values were invalid: Type mismatch for key pk expected: S \
    +             actual: NULL"
    +        ])
    +    );
    +    assert_eq!(report[0]["itemLocation"]["itemIndex"], json!(1));
    +}
    +
    +// An empty-string hash key fails the row alone with the key text,
    +// and the record carries the submitted image.
    +#[tokio::test]
    +async fn an_empty_string_hash_key_fails_the_row_with_the_key_text_and_the_image() {
    +    let h = Harness::new().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[row(0), row_empty_hash_key(), row(2)]),
    +    );
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.failed_item_count, 1);
    +    assert!(h.get("k2").await.is_some(), "the file continues");
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!([
    +            "One or more parameter values are not valid. The AttributeValue for a key attribute \
    +             cannot contain an empty string value. Key: pk"
    +        ])
    +    );
    +    assert_eq!(report[0]["itemLocation"]["itemIndex"], json!(1));
    +    assert_eq!(report[0]["newImage"]["Item"]["sk"], json!({"S": "s"}));
    +}
    +
    +// The non-numeric sort key abandons the file. The rows before it
    +// are written and counted, the abandon counts one failed row with no item
    +// index, the rows after it are never read, and the manifest marks the file
    +// partially processed up to the row before it.
    +#[tokio::test]
    +async fn a_non_numeric_sort_key_abandons_the_file_and_keeps_the_rows_before_it() {
    +    let h = Harness::new().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[row(0), row_non_numeric_sort_key(), row(2)]),
    +    );
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 2);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert!(h.get("k0").await.is_some());
    +    assert!(
    +        h.get("k2").await.is_none(),
    +        "the row after the abandon is never read"
    +    );
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!([crate::bulk_put::rows::INVALID_NUMERIC_MESSAGE])
    +    );
    +    assert!(report[0]["itemLocation"].get("itemIndex").is_none());
    +    let manifest = h
    +        .read_manifest(&record.operation_id)
    +        .expect("manifest written");
    +    assert_eq!(
    +        manifest[0]["fileProcessingStatus"],
    +        json!("PARTIALLY_PROCESSED")
    +    );
    +    assert_eq!(manifest[0]["processedRanges"], json!([[0, 0]]));
    +}
    +
    +// The oversized values map abandons the file the same way, with
    +// its own message, and the rows before it are written.
    +#[tokio::test]
    +async fn an_oversized_expression_values_map_abandons_the_file() {
    +    let h = Harness::new().await;
    +    let value = "a".repeat(crate::bulk_put::rows::MAX_EXPRESSION_VALUES_BYTES + 100);
    +    let oversized = format!(
    +        "{{\"Item\": {{\"pk\": {{\"S\": \"k1\"}}, \"sk\": {{\"S\": \"s\"}}}}, \
    +         \"ConditionExpression\": \"#v = :v\", \
    +         \"ExpressionAttributeNames\": {{\"#v\": \"v\"}}, \
    +         \"ExpressionAttributeValues\": {{\":v\": {{\"S\": \"{value}\"}}}}}}"
    +    );
    +    h.write_input("part-000.json", &file_of(&[row(0), oversized, row(2)]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 2);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert!(h.get("k0").await.is_some());
    +    assert!(h.get("k2").await.is_none());
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!([crate::bulk_put::rows::VALUES_TOO_LARGE_MESSAGE])
    +    );
    +    assert!(report[0]["itemLocation"].get("itemIndex").is_none());
    +    let manifest = h
    +        .read_manifest(&record.operation_id)
    +        .expect("manifest written");
    +    assert_eq!(
    +        manifest[0]["fileProcessingStatus"],
    +        json!("PARTIALLY_PROCESSED")
    +    );
    +    assert_eq!(manifest[0]["processedRanges"], json!([[0, 0]]));
    +}
    +
    +// An item of 400 attributes and an attribute name of 256
    +// bytes are written, with no failure. No ExtendDB bound refuses either.
    +#[tokio::test]
    +async fn an_item_of_four_hundred_attributes_and_a_long_attribute_name_are_written() {
    +    let h = Harness::new().await;
    +    let mut wide = String::from("{\"Item\": {\"pk\": {\"S\": \"k0\"}, \"sk\": {\"S\": \"s\"}");
    +    for index in 0..398 {
    +        wide.push_str(&format!(", \"a{index}\": {{\"S\": \"v\"}}"));
    +    }
    +    wide.push_str("}}");
    +    let long_name = "n".repeat(256);
    +    let named = format!(
    +        "{{\"Item\": {{\"pk\": {{\"S\": \"k1\"}}, \"sk\": {{\"S\": \"s\"}}, \
    +         \"{long_name}\": {{\"S\": \"v\"}}}}}}"
    +    );
    +    h.write_input("part-000.json", &file_of(&[wide, named]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 2);
    +    assert_eq!(record.failed_item_count, 0);
    +    let written = h.get("k0").await.expect("the wide row is written");
    +    assert_eq!(written.len(), 400);
    +    let named = h.get("k1").await.expect("the long-name row is written");
    +    assert!(named.contains_key(&long_name));
    +}
    +
    +/// One byte over the attribute name limit.
    +const LONG_NAME_BYTES: usize = 65_536;
    +
    +/// The `PutItem` text for an item attribute name over the limit.
    +const NAME_TOO_LARGE: &str =
    +    "1 validation error detected: Attribute name is too large, must be less than 65536 bytes";
    +
    +// A row whose condition maps `#n` to a 65,536-byte name fails with the text
    +// `PutItem` answers for the same condition, and is not written.
    +#[tokio::test]
    +async fn a_condition_name_over_the_limit_fails_the_row_with_the_put_item_text() {
    +    let h = Harness::new().await;
    +    let long_name = "n".repeat(LONG_NAME_BYTES);
    +    let named = format!(
    +        "{{\"Item\": {{\"pk\": {{\"S\": \"c\"}}, \"sk\": {{\"S\": \"s\"}}}}, \
    +         \"ConditionExpression\": \"attribute_not_exists(#n)\", \
    +         \"ExpressionAttributeNames\": {{\"#n\": \"{long_name}\"}}}}"
    +    );
    +    h.write_input("part-000.json", &file_of(&[row(0), named, row(2)]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 3);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert!(
    +        h.get("c").await.is_none(),
    +        "the conditional row is not written"
    +    );
    +    assert!(h.get("k2").await.is_some(), "the file continues");
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!([format!("{NAME_TOO_LARGE} for key #n")])
    +    );
    +    assert_eq!(report[0]["itemLocation"]["itemIndex"], json!(1));
    +}
    +
    +// A row whose only fault is a 65,536-byte attribute name, and a row with
    +// that name and no `sk`, get the same record: the `PutItem` name text and
    +// no image. The name check runs ahead of the key checks, so the second row
    +// does not report its missing key.
    +#[tokio::test]
    +async fn a_long_attribute_name_fails_the_row_ahead_of_a_missing_key() {
    +    let h = Harness::new().await;
    +    let long_name = "n".repeat(LONG_NAME_BYTES);
    +    let named = format!(
    +        "{{\"Item\": {{\"pk\": {{\"S\": \"k1\"}}, \"sk\": {{\"S\": \"s\"}}, \
    +         \"{long_name}\": {{\"S\": \"v\"}}}}}}"
    +    );
    +    let named_without_sk =
    +        format!("{{\"Item\": {{\"pk\": {{\"S\": \"k2\"}}, \"{long_name}\": {{\"S\": \"v\"}}}}}}");
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[row(0), named, named_without_sk, row(3)]),
    +    );
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 4);
    +    assert_eq!(record.failed_item_count, 2);
    +    assert!(
    +        h.get("k1").await.is_none(),
    +        "the long-name row is not written"
    +    );
    +    assert!(h.get("k3").await.is_some(), "the file continues");
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 2);
    +    for (entry, item_index) in report.iter().zip([1, 2]) {
    +        assert_eq!(entry["errorMessages"], json!([NAME_TOO_LARGE]));
    +        assert_eq!(entry["itemLocation"]["itemIndex"], json!(item_index));
    +        assert!(
    +            entry.get("newImage").is_none(),
    +            "row {item_index}: the name fault carries no image"
    +        );
    +    }
    +}
    +
    +/// A row against key `c` whose condition is `attribute_not_exists(pk)`
    +/// padded with spaces to `len` bytes.
    +fn row_with_condition_of(len: usize) -> String {
    +    let base = "attribute_not_exists(pk)";
    +    let expression = format!("{base}{}", " ".repeat(len - base.len()));
    +    assert_eq!(expression.len(), len);
    +    format!(
    +        "{{\"Item\": {{\"pk\": {{\"S\": \"c\"}}, \"sk\": {{\"S\": \"s\"}}}}, \
    +         \"ConditionExpression\": \"{expression}\"}}"
    +    )
    +}
    +
    +//  (measured on the service): the condition text alone is bound
    +// at 4,096 bytes on the bulk path as on `PutItem`; 4,096 is written and
    +// 4,097 fails the row with the size text and the byte count. The record
    +// carries the message without the `Invalid ConditionExpression: ` prefix
    +// the single-item path puts on it.
    +#[tokio::test]
    +async fn a_condition_over_four_kib_fails_the_row_with_the_unprefixed_size_text() {
    +    let h = Harness::new().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[row(0), row_with_condition_of(4097), row(2)]),
    +    );
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 3);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert!(
    +        h.get("c").await.is_none(),
    +        "the conditional row is not written"
    +    );
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(
    +        report[0]["errorMessages"],
    +        json!(["Expression size has exceeded the maximum allowed size; expression size: 4097"])
    +    );
    +    assert_eq!(report[0]["itemLocation"]["itemIndex"], json!(1));
    +}
    +
    +#[tokio::test]
    +async fn a_condition_of_exactly_four_kib_is_written() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&[row_with_condition_of(4096)]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED", "a condition of 4,096 bytes");
    +    assert_eq!(record.failed_item_count, 0);
    +    assert!(h.get("c").await.is_some());
    +}
    +
    +//  (measured on the service): a syntax error in the condition fails the
    +// row with the parser text and no prefix. The hosted parser's text is
    +// `Syntax error; token: "=", near: "= = :v"`; the shared parser words its
    +// syntax faults differently on every path, which is outside this feature,
    +// so the test holds the prefix rule and not the wording.
    +#[tokio::test]
    +async fn a_condition_syntax_error_fails_the_row_with_the_unprefixed_parser_text() {
    +    let h = Harness::new().await;
    +    let bad = "{\"Item\": {\"pk\": {\"S\": \"c\"}, \"sk\": {\"S\": \"s\"}}, \
    +               \"ConditionExpression\": \"attribute_not_exists(pk) OR #v = = :v\", \
    +               \"ExpressionAttributeNames\": {\"#v\": \"v\"}, \
    +               \"ExpressionAttributeValues\": {\":v\": {\"S\": \"x\"}}}"
    +        .to_owned();
    +    h.write_input("part-000.json", &file_of(&[row(0), bad, row(2)]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.failed_item_count, 1);
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::Error)
    +        .expect("error report rendered");
    +    let names = std::collections::HashMap::from([("#v".to_owned(), "v".to_owned())]);
    +    let values =
    +        std::collections::HashMap::from([(":v".to_owned(), AttributeValue::S("x".to_owned()))]);
    +    let put_item = resolve_condition(
    +        Some("attribute_not_exists(pk) OR #v = = :v"),
    +        Some(&names),
    +        Some(&values),
    +        None,
    +        None,
    +        &LimitsConfig::default(),
    +    )
    +    .expect_err("the condition expression is invalid")
    +    .to_string();
    +    let expected = put_item
    +        .strip_prefix("Invalid ConditionExpression: ")
    +        .expect("the single-item path prefixes the condition fault");
    +    assert_eq!(report[0]["errorMessages"], json!([expected]));
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/s3_actions.rs b/crates/engine/src/bulk_put/tests/runner/s3_actions.rs
    new file mode 100644
    index 000000000..c46b1cec0
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/s3_actions.rs
    @@ -0,0 +1,410 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::rotation::RollingSink;
    +use super::*;
    +
    +use crate::bulk_put::authorize::S3Action;
    +
    +// The S3 actions a run performs as the operation's caller, checked through
    +// the authorizer: s3:GetObject on an input object before it is opened and at
    +// every boundary while it is read, s3:PutObject on each report object and on
    +// the manifest (measured on the service for bucket policies denying each).
    +
    +async fn run_checked(
    +    h: &Harness,
    +    files: Vec,
    +    output: Value,
    +    authorizer: Arc,
    +    cfg: RunnerConfig,
    +) -> BulkOperationRecord {
    +    let op = h
    +        .start_with_run_context(
    +            files,
    +            input_config(false),
    +            output,
    +            Some(run_context_with_caller()),
    +        )
    +        .await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let mut deps = h.deps("runner-1", cfg);
    +    deps.authorizer = Some(authorizer);
    +    runner::run(&deps, record).await.expect("runner run");
    +    h.record(&op).await
    +}
    +
    +fn input_arn(path: &str) -> String {
    +    format!("arn:aws:s3:::{IN_BUCKET}/{path}")
    +}
    +
    +fn status_of(line: &Value) -> &str {
    +    line["fileProcessingStatus"].as_str().expect("a status")
    +}
    +
    +#[tokio::test]
    +async fn get_object_denied_at_open_fails_and_the_file_is_not_processed() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(10)));
    +    let files = h.enumerate().await;
    +    let total = files[0].size;
    +    let fake = Arc::new(FakeAuthorizer::denying(
    +        S3Action::GetObject,
    +        "part-000.json",
    +    ));
    +    let record = run_checked(&h, files, both_reports(), Arc::clone(&fake), config()).await;
    +
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3AccessDenied"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some(denied("s3:GetObject", &input_arn("part-000.json")).as_str())
    +    );
    +    assert_eq!(record.processed_item_count, 0);
    +    assert_eq!(record.total_input_bytes, Some(total));
    +    assert_eq!(record.processed_input_bytes, 0);
    +    assert_eq!(h.count_items().await, 0);
    +    assert_eq!(
    +        fake.checks()[0],
    +        (S3Action::GetObject, input_arn("part-000.json"), None),
    +        "the object is checked before the open"
    +    );
    +    assert_eq!(fake.count(S3Action::GetObject), 1);
    +    assert_eq!(
    +        fake.count(S3Action::PutObject),
    +        1,
    +        "the manifest write is checked"
    +    );
    +    let manifest = h
    +        .read_manifest(&record.operation_id)
    +        .expect("the manifest is written");
    +    assert_eq!(status_of(&manifest[0]), "NOT_PROCESSED");
    +    assert_eq!(manifest[0]["processedRanges"], json!([]));
    +    assert_eq!(manifest[0]["etag"], json!(h.input_md5("part-000.json")));
    +}
    +
    +#[tokio::test]
    +async fn an_explicit_deny_is_reported_in_its_own_form() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(2)));
    +    let files = h.enumerate().await;
    +    let mut fake = FakeAuthorizer::denying(S3Action::GetObject, "part-000.json");
    +    fake.rule.as_mut().unwrap().explicit = true;
    +    let record = run_checked(&h, files, both_reports(), Arc::new(fake), config()).await;
    +
    +    let resource = input_arn("part-000.json");
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some(
    +            format!(
    +                "User: arn:aws:iam::{ACCOUNT}:user/{CALLER} is not authorized to perform: \
    +                 s3:GetObject on resource: \"{resource}\" with an explicit deny in an \
    +                 identity-based policy"
    +            )
    +            .as_str()
    +        )
    +    );
    +}
    +
    +#[tokio::test]
    +async fn get_object_denied_at_a_boundary_after_rows_were_written_is_not_available() {
    +    // The open is allowed; the check at the first boundary (row 5) is not.
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(12)));
    +    let files = h.enumerate().await;
    +    let mut fake = FakeAuthorizer::denying(S3Action::GetObject, "part-000.json");
    +    fake.rule.as_mut().unwrap().allow_first = 1;
    +    let fake = Arc::new(fake);
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    let record = run_checked(&h, files, both_reports(), Arc::clone(&fake), cfg).await;
    +
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3AccessDenied"));
    +    assert_eq!(record.processed_item_count, 5, "the rows read before stand");
    +    assert_eq!(h.count_items().await, 5, "and stay written");
    +    assert_eq!(
    +        fake.count(S3Action::GetObject),
    +        2,
    +        "the open and one boundary"
    +    );
    +    let manifest = h
    +        .read_manifest(&record.operation_id)
    +        .expect("the manifest is written");
    +    assert_eq!(status_of(&manifest[0]), "NOT_AVAILABLE");
    +    assert_eq!(manifest[0]["processedRanges"], json!([]));
    +}
    +
    +#[tokio::test]
    +async fn a_later_file_denied_at_its_open_is_not_processed() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(3)));
    +    h.write_input("part-001.json", &file_of(&plain_rows(3)));
    +    let files = h.enumerate().await;
    +    let fake = Arc::new(FakeAuthorizer::denying(
    +        S3Action::GetObject,
    +        "part-001.json",
    +    ));
    +    let record = run_checked(&h, files, both_reports(), Arc::clone(&fake), config()).await;
    +
    +    assert_eq!(record.failure_code.as_deref(), Some("S3AccessDenied"));
    +    assert_eq!(record.processed_item_count, 3);
    +    let manifest = h
    +        .read_manifest(&record.operation_id)
    +        .expect("the manifest is written");
    +    assert_eq!(status_of(&manifest[0]), "FULLY_PROCESSED");
    +    assert_eq!(status_of(&manifest[1]), "NOT_PROCESSED");
    +    assert_eq!(
    +        fake.count(S3Action::GetObject),
    +        2,
    +        "the end-of-file boundary checks no file"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn put_object_denied_on_the_first_report_object_is_held_to_the_end_of_the_input() {
    +    // Five good rows, the faulting row, four good rows.
    +    let h = Harness::new().await;
    +    let mut rows = plain_rows(10);
    +    rows[5] = row_missing_sk(5);
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let fake = Arc::new(FakeAuthorizer::denying(
    +        S3Action::PutObject,
    +        "/output/errors/output-00000.json.gz",
    +    ));
    +    let record = run_checked(&h, files, both_reports(), Arc::clone(&fake), config()).await;
    +    let op = &record.operation_id;
    +
    +    let key = report::report_key(OUT_PREFIX, ACCOUNT, op, ReportKind::Error, 0);
    +    let resource = format!("arn:aws:s3:::{OUT_BUCKET}/{key}");
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3AccessDenied"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some(denied("s3:PutObject", &resource).as_str()),
    +        "the message names the first error report object's full key"
    +    );
    +    assert_eq!(record.processed_item_count, 9);
    +    assert_eq!(record.failed_item_count, 0);
    +    assert_eq!(record.processed_input_bytes, 0);
    +    assert_eq!(record.usage["BulkPutBytes"], json!(0));
    +    assert_eq!(h.count_items().await, 9);
    +    assert!(h.read_report(op, ReportKind::Error).is_none());
    +    assert!(h.read_manifest(op).is_none());
    +    assert_eq!(
    +        fake.count(S3Action::PutObject),
    +        1,
    +        "no later write is checked"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn put_object_denied_on_a_later_report_object_fails_the_render() {
    +    // 1,500 error records render as two pages; with the rotation threshold at
    +    // one byte the second page opens the second object.
    +    let h = Harness::new().await;
    +    let rows: Vec = (0..1500).map(row_missing_sk).collect();
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h
    +        .start_with_run_context(
    +            files,
    +            input_config(false),
    +            both_reports(),
    +            Some(run_context_with_caller()),
    +        )
    +        .await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let sink = Arc::new(RollingSink::new(h.fs_sink(), 1));
    +    let mut deps = h.deps_with("runner-1", config(), Arc::new(h.fs_source()), sink);
    +    let fake = Arc::new(FakeAuthorizer::denying(
    +        S3Action::PutObject,
    +        "/output/errors/output-00001.json.gz",
    +    ));
    +    deps.authorizer = Some(Arc::clone(&fake) as Arc);
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    let key = report::report_key(OUT_PREFIX, ACCOUNT, &op, ReportKind::Error, 1);
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some(denied("s3:PutObject", &format!("arn:aws:s3:::{OUT_BUCKET}/{key}")).as_str())
    +    );
    +    assert_eq!(record.failed_item_count, 1500, "the run itself completed");
    +    assert!(
    +        h.report_objects(&op, ReportKind::Error).is_empty(),
    +        "no report object is installed"
    +    );
    +    let dir = h.report_path(&op, ReportKind::Error);
    +    let leftover: Vec = std::fs::read_dir(dir.parent().unwrap())
    +        .map(|entries| {
    +            entries
    +                .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
    +                .collect()
    +        })
    +        .unwrap_or_default();
    +    assert!(
    +        leftover.is_empty(),
    +        "the dropped writer leaves nothing: {leftover:?}"
    +    );
    +    assert!(
    +        h.read_manifest(&op).is_some(),
    +        "the manifest follows as usual"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn put_object_denied_on_the_manifest_fails_with_the_manifest_prefix() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(3)));
    +    let files = h.enumerate().await;
    +    let fake = Arc::new(FakeAuthorizer::denying(
    +        S3Action::PutObject,
    +        "/manifest-files.json",
    +    ));
    +    let record = run_checked(&h, files, both_reports(), fake, config()).await;
    +    let op = &record.operation_id;
    +
    +    let key = report::manifest_key(OUT_PREFIX, ACCOUNT, op);
    +    let resource = format!("arn:aws:s3:::{OUT_BUCKET}/{key}");
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3AccessDenied"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some(
    +            format!(
    +                "Manifest is not available. {}",
    +                denied("s3:PutObject", &resource)
    +            )
    +            .as_str()
    +        )
    +    );
    +    assert_eq!(record.processed_item_count, 3);
    +    assert!(h.read_manifest(op).is_none());
    +}
    +
    +#[tokio::test]
    +async fn a_record_with_no_caller_fails_closed() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(2)));
    +    let files = h.enumerate().await;
    +    let op = h
    +        .start_with_run_context(
    +            files,
    +            input_config(false),
    +            both_reports(),
    +            Some(json!({"store": "filesystem"})),
    +        )
    +        .await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let fake = Arc::new(FakeAuthorizer::default());
    +    let mut deps = h.deps("runner-1", config());
    +    deps.authorizer = Some(Arc::clone(&fake) as Arc);
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3AccessDenied"));
    +    assert_eq!(record.failure_message.as_deref(), Some("Access Denied"));
    +    assert!(fake.checks().is_empty(), "there is no caller to evaluate");
    +    assert_eq!(h.count_items().await, 0);
    +}
    +
    +#[tokio::test]
    +async fn with_no_authorizer_no_check_runs() {
    +    // Developer mode: the same operation the denying authorizer fails runs to
    +    // its end.
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(3)));
    +    let files = h.enumerate().await;
    +    let op = h
    +        .start_with_run_context(
    +            files,
    +            input_config(false),
    +            both_reports(),
    +            Some(run_context_with_caller()),
    +        )
    +        .await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-1", config()), record)
    +        .await
    +        .expect("runner run");
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 3);
    +}
    +
    +#[tokio::test]
    +async fn a_check_that_cannot_be_evaluated_fails_internal() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(2)));
    +    let files = h.enumerate().await;
    +    let fake = Arc::new(FakeAuthorizer {
    +        unavailable: true,
    +        ..FakeAuthorizer::default()
    +    });
    +    let record = run_checked(&h, files, both_reports(), fake, config()).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("InternalServerError"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("Internal server error")
    +    );
    +    assert_eq!(h.count_items().await, 0);
    +}
    +
    +#[tokio::test]
    +async fn a_resumed_open_is_checked_and_a_denial_there_is_not_available() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(12)));
    +    let files = h.enumerate().await;
    +    let op = h
    +        .start_with_run_context(
    +            files,
    +            input_config(false),
    +            both_reports(),
    +            Some(run_context_with_caller()),
    +        )
    +        .await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let source = Arc::new(PendAfterSource {
    +        inner: h.fs_source(),
    +        after: 7,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        notify: Arc::clone(¬ify),
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    cfg.lease = Duration::from_millis(100);
    +    let mut deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    deps.authorizer = Some(Arc::new(FakeAuthorizer::default()));
    +    tokio::select! {
    +        _ = runner::run(&deps, record) => panic!("the runner must not finish while the reader pends"),
    +        () = notify.notified() => {}
    +    }
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    assert_eq!(h.record(&op).await.cursor_row_index, 5);
    +
    +    let fake = Arc::new(FakeAuthorizer::denying(
    +        S3Action::GetObject,
    +        "part-000.json",
    +    ));
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    let mut deps = h.deps("runner-2", config());
    +    deps.authorizer = Some(Arc::clone(&fake) as Arc);
    +    runner::run(&deps, record).await.expect("resumed run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.failure_code.as_deref(), Some("S3AccessDenied"));
    +    assert_eq!(record.processed_item_count, 5);
    +    assert_eq!(
    +        fake.checks()[0],
    +        (S3Action::GetObject, input_arn("part-000.json"), None),
    +        "the resumed claim checks before it reopens the file"
    +    );
    +    assert_eq!(fake.count(S3Action::GetObject), 1);
    +    let manifest = h.read_manifest(&op).expect("the manifest is written");
    +    assert_eq!(status_of(&manifest[0]), "NOT_AVAILABLE");
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/service_etags.rs b/crates/engine/src/bulk_put/tests/runner/service_etags.rs
    new file mode 100644
    index 000000000..72b206d9c
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/service_etags.rs
    @@ -0,0 +1,301 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// The manifest line of a file listed without an entity tag, on a store
    +// whose tags the service assigns (an S3-compatible service whose listing
    +// leaves the ETag out). The line names the tag of the first answer the run
    +// accepted for the file, or none when the run accepted no answer for it. It
    +// never names the object at the key when the manifest is written, which may
    +// be a replacement the run did not read.
    +
    +/// The tag the store asks for when the manifest is written, standing for
    +/// the object now at the key: a replacement.
    +const REPLACEMENT: &str = "replacement-etag";
    +
    +/// How the source treats one input file.
    +#[derive(Clone, Copy)]
    +enum Answer {
    +    /// The first answer is at another size than the listed one, which the
    +    /// S3 store reports as an overwritten object.
    +    OverwrittenAtOpen,
    +    /// The open fails.
    +    RefuseOpen,
    +    /// The open is accepted and the read fails as a deleted object does
    +    /// once this many lines were handed out.
    +    GoneAfter(u64),
    +}
    +
    +/// A filesystem source that reports its tags the way the S3 store does:
    +/// the service assigns them, each accepted open carries the tag of its
    +/// first answer (`first-`), and `etag` answers the replacement's tag
    +/// for a file listed without one and counts the calls.
    +struct ServiceTagSource {
    +    inner: FsSource,
    +    answers: Vec<(&'static str, Answer)>,
    +    etag_calls: Arc,
    +}
    +
    +struct ServiceTagReader {
    +    inner: Box,
    +    tag: String,
    +    gone_after: Option,
    +    handed: u64,
    +}
    +
    +impl ServiceTagSource {
    +    fn new(h: &Harness, answers: Vec<(&'static str, Answer)>) -> Self {
    +        Self {
    +            inner: h.fs_source(),
    +            answers,
    +            etag_calls: Arc::new(AtomicU64::new(0)),
    +        }
    +    }
    +
    +    fn answer(&self, path: &str) -> Option {
    +        self.answers
    +            .iter()
    +            .find(|(p, _)| *p == path)
    +            .map(|(_, answer)| *answer)
    +    }
    +}
    +
    +impl BulkSource for ServiceTagSource {
    +    fn check_bucket(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +    ) -> BoxFuture<'_, Result<(), SourceError>> {
    +        self.inner.check_bucket(bucket, prefix)
    +    }
    +
    +    fn list(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +        max_files: u64,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        self.inner.list(bucket, prefix, max_files)
    +    }
    +
    +    fn open(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        let answer = self.answer(&file.path);
    +        if matches!(answer, Some(Answer::OverwrittenAtOpen)) {
    +            return Box::pin(async { Err(SourceError::Overwritten) });
    +        }
    +        if matches!(answer, Some(Answer::RefuseOpen)) {
    +            return Box::pin(async {
    +                Err(SourceError::Io(std::io::Error::other(
    +                    "the open could not complete",
    +                )))
    +            });
    +        }
    +        let gone_after = match answer {
    +            Some(Answer::GoneAfter(n)) => Some(n),
    +            _ => None,
    +        };
    +        let tag = format!("first-{}", file.path);
    +        let fut = self.inner.open(bucket, file, compression);
    +        Box::pin(async move {
    +            let inner = fut.await?;
    +            Ok(Box::new(ServiceTagReader {
    +                inner,
    +                tag,
    +                gone_after,
    +                handed: 0,
    +            }) as Box)
    +        })
    +    }
    +
    +    fn etag(
    +        &self,
    +        _bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +    ) -> BoxFuture<'_, Result> {
    +        let answer = match &file.etag {
    +            Some(listed) => listed.clone(),
    +            None => {
    +                self.etag_calls.fetch_add(1, Ordering::SeqCst);
    +                REPLACEMENT.to_owned()
    +            }
    +        };
    +        Box::pin(async move { Ok(answer) })
    +    }
    +
    +    fn line_count(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.line_count(bucket, file, compression)
    +    }
    +
    +    fn service_assigns_etags(&self) -> bool {
    +        true
    +    }
    +}
    +
    +impl LineReader for ServiceTagReader {
    +    fn next_line(
    +        &mut self,
    +        max_line_bytes: usize,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        Box::pin(async move {
    +            if self.gone_after.is_some_and(|n| self.handed >= n) {
    +                return Err(SourceError::Io(std::io::Error::from(
    +                    std::io::ErrorKind::NotFound,
    +                )));
    +            }
    +            let line = self.inner.next_line(max_line_bytes).await;
    +            if matches!(&line, Ok(Some(_))) {
    +                self.handed += 1;
    +            }
    +            line
    +        })
    +    }
    +
    +    fn bytes_consumed(&self) -> u64 {
    +        self.inner.bytes_consumed()
    +    }
    +
    +    fn last_line_end(&self) -> u64 {
    +        self.inner.last_line_end()
    +    }
    +
    +    fn etag(&self) -> Option {
    +        self.inner.etag().map(|_| self.tag.clone())
    +    }
    +
    +    fn opened_etag(&self) -> Option {
    +        Some(self.tag.clone())
    +    }
    +}
    +
    +/// Run one operation over `source` to its end and return the final record
    +/// and its manifest lines as `(path, status, etag)`.
    +async fn run_with(
    +    h: &Harness,
    +    source: Arc,
    +    storage: Option>,
    +) -> (BulkOperationRecord, Vec<(String, String, String)>) {
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let mut deps = h.deps_with("runner-1", config(), source, Arc::new(h.fs_sink()));
    +    if let Some(storage) = storage {
    +        deps.storage = storage;
    +    }
    +    runner::run(&deps, record).await.expect("runner run");
    +    let record = h.record(&op).await;
    +    let lines = h
    +        .read_manifest(&op)
    +        .expect("manifest written")
    +        .into_iter()
    +        .map(|line| {
    +            let arn = line["inputSourceArn"].as_str().unwrap_or_default();
    +            let path = arn.rsplit('/').next().unwrap_or_default().to_owned();
    +            (
    +                path,
    +                line["fileProcessingStatus"]
    +                    .as_str()
    +                    .unwrap_or_default()
    +                    .to_owned(),
    +                line["etag"].as_str().unwrap_or_default().to_owned(),
    +            )
    +        })
    +        .collect();
    +    (record, lines)
    +}
    +
    +fn line(path: &str, status: &str, etag: &str) -> (String, String, String) {
    +    (path.to_owned(), status.to_owned(), etag.to_owned())
    +}
    +
    +#[tokio::test]
    +async fn a_file_whose_open_accepted_no_answer_carries_no_tag() {
    +    // The second file was replaced by a longer object before its open and
    +    // the whole answer is found at another size (the S3 store reports it
    +    // overwritten); the third file's open failed. The run accepted no
    +    // answer for either.
    +    let h = Harness::new().await;
    +    h.write_input("0.json", &file_of(&plain_rows(3)));
    +    h.write_input("1.json", &file_of(&(3..6).map(row).collect::>()));
    +    h.write_input("2.json", &file_of(&(6..9).map(row).collect::>()));
    +    let source = Arc::new(ServiceTagSource::new(
    +        &h,
    +        vec![
    +            ("1.json", Answer::OverwrittenAtOpen),
    +            ("2.json", Answer::RefuseOpen),
    +        ],
    +    ));
    +    let calls = Arc::clone(&source.etag_calls);
    +    let (record, lines) = run_with(&h, source, None).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(
    +        lines,
    +        vec![
    +            line("0.json", "FULLY_PROCESSED", "first-0.json"),
    +            line("1.json", "PARTIALLY_PROCESSED", ""),
    +            line("2.json", "NOT_PROCESSED", ""),
    +        ]
    +    );
    +    assert_eq!(
    +        calls.load(Ordering::SeqCst),
    +        0,
    +        "the manifest never asks for the object now at the key"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_file_whose_read_failed_mid_file_carries_its_first_answers_tag() {
    +    // The first file's read failed after three lines, as a replaced or
    +    // deleted object does; the next file was never reached.
    +    let h = Harness::new().await;
    +    h.write_input("0.json", &file_of(&plain_rows(6)));
    +    h.write_input("1.json", &file_of(&(6..9).map(row).collect::>()));
    +    let source = Arc::new(ServiceTagSource::new(
    +        &h,
    +        vec![("0.json", Answer::GoneAfter(3))],
    +    ));
    +    let calls = Arc::clone(&source.etag_calls);
    +    let (record, lines) = run_with(&h, source, None).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(
    +        lines,
    +        vec![
    +            line("0.json", "NOT_AVAILABLE", "first-0.json"),
    +            line("1.json", "NOT_PROCESSED", ""),
    +        ]
    +    );
    +    assert_eq!(
    +        calls.load(Ordering::SeqCst),
    +        0,
    +        "the manifest never asks for the object now at the key"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_file_whose_first_batch_failed_keeps_its_first_answers_tag() {
    +    // A batch that fails ends the run with its state reverted to the last
    +    // checkpoint, here before the file's first row, so the file reads as
    +    // not processed; the tag accepted at its open is kept through the
    +    // revert.
    +    let h = Harness::new().await;
    +    h.write_input("0.json", &file_of(&plain_rows(6)));
    +    let source = Arc::new(ServiceTagSource::new(&h, Vec::new()));
    +    let calls = Arc::clone(&source.etag_calls);
    +    let mut faulting = FaultingStorage::new(&h.storage);
    +    faulting.fail_put_pk = Some("k3".to_owned());
    +    faulting.fail_put_pk_internal = Some("SQLSTATE 23505: duplicate key".to_owned());
    +    let (record, lines) = run_with(&h, source, Some(Arc::new(faulting))).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(lines, vec![line("0.json", "NOT_PROCESSED", "first-0.json")]);
    +    assert_eq!(calls.load(Ordering::SeqCst), 0);
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/sink_faults.rs b/crates/engine/src/bulk_put/tests/runner/sink_faults.rs
    new file mode 100644
    index 000000000..d137b4474
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/sink_faults.rs
    @@ -0,0 +1,301 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +/// Renders through the real sink, then signals and pends forever once the
    +/// first report file is finished, modelling a crash between rendering and
    +/// the terminal transition: the file is on disk, the record is still
    +/// non-terminal, and the report records are still in the catalog.
    +pub(super) struct PendAfterRenderSink {
    +    pub(super) inner: FsSink,
    +    pub(super) notify: Arc,
    +}
    +
    +struct PendAfterFinishWriter {
    +    inner: Box,
    +    notify: Arc,
    +}
    +
    +impl BulkSink for PendAfterRenderSink {
    +    fn write_manifest(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        sse: &OutputSse,
    +        lines: Vec,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner
    +            .write_manifest(at, prefix, operation_id, owner, sse, lines)
    +    }
    +
    +    fn check_location(
    +        &self,
    +        at: &BucketRef<'_>,
    +        key: &str,
    +        sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result<(), SinkError>> {
    +        self.inner.check_location(at, key, sse)
    +    }
    +
    +    fn open(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        operation_id: &str,
    +        kind: ReportKind,
    +        owner: &str,
    +        sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result, SinkError>> {
    +        let fut = self.inner.open(at, prefix, operation_id, kind, owner, sse);
    +        let notify = Arc::clone(&self.notify);
    +        Box::pin(async move {
    +            let inner = fut.await?;
    +            Ok(Box::new(PendAfterFinishWriter { inner, notify }) as Box)
    +        })
    +    }
    +}
    +
    +impl ReportWriter for PendAfterFinishWriter {
    +    fn write_page(&mut self, records: Vec) -> BoxFuture<'_, Result<(), SinkError>> {
    +        self.inner.write_page(records)
    +    }
    +
    +    fn finish(self: Box) -> BoxFuture<'static, Result> {
    +        let this = *self;
    +        Box::pin(async move {
    +            let _key = this.inner.finish().await?;
    +            this.notify.notify_one();
    +            std::future::pending::<()>().await;
    +            unreachable!("pending never resolves")
    +        })
    +    }
    +
    +    fn object_index(&self) -> u32 {
    +        self.inner.object_index()
    +    }
    +}
    +
    +/// Delegates rendering to the real sink, but the first `write_page` call
    +/// signals `reached` and then waits for `release` before writing, holding
    +/// the runner mid-render with its temp file already created. The runner
    +/// future stays alive, so a test can let the lease expire, drive a second
    +/// runner to a terminal state, and only then let the first runner's render
    +/// continue as a stale writer. The other wrappers pend inside `open` or
    +/// `finish`; the lease check under test sits between `write_page` and
    +/// `finish`, so this hook is the point that exercises it.
    +pub(super) struct PendInWritePageSink {
    +    pub(super) inner: FsSink,
    +    pub(super) reached: Arc,
    +    pub(super) release: Arc,
    +}
    +
    +struct PendInWritePageWriter {
    +    inner: Box,
    +    reached: Arc,
    +    release: Arc,
    +    fired: AtomicBool,
    +}
    +
    +impl BulkSink for PendInWritePageSink {
    +    fn write_manifest(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        sse: &OutputSse,
    +        lines: Vec,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner
    +            .write_manifest(at, prefix, operation_id, owner, sse, lines)
    +    }
    +
    +    fn check_location(
    +        &self,
    +        at: &BucketRef<'_>,
    +        key: &str,
    +        sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result<(), SinkError>> {
    +        self.inner.check_location(at, key, sse)
    +    }
    +
    +    fn open(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        operation_id: &str,
    +        kind: ReportKind,
    +        owner: &str,
    +        sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result, SinkError>> {
    +        let fut = self.inner.open(at, prefix, operation_id, kind, owner, sse);
    +        let reached = Arc::clone(&self.reached);
    +        let release = Arc::clone(&self.release);
    +        Box::pin(async move {
    +            let inner = fut.await?;
    +            Ok(Box::new(PendInWritePageWriter {
    +                inner,
    +                reached,
    +                release,
    +                fired: AtomicBool::new(false),
    +            }) as Box)
    +        })
    +    }
    +}
    +
    +impl ReportWriter for PendInWritePageWriter {
    +    fn write_page(&mut self, records: Vec) -> BoxFuture<'_, Result<(), SinkError>> {
    +        Box::pin(async move {
    +            if !self.fired.swap(true, Ordering::SeqCst) {
    +                self.reached.notify_one();
    +                self.release.notified().await;
    +            }
    +            self.inner.write_page(records).await
    +        })
    +    }
    +
    +    fn finish(self: Box) -> BoxFuture<'static, Result> {
    +        let this = *self;
    +        this.inner.finish()
    +    }
    +
    +    fn object_index(&self) -> u32 {
    +        self.inner.object_index()
    +    }
    +}
    +
    +/// Requests a cancel of the operation inside the first `BulkSink::open`,
    +/// then renders through the real sink. Rendering runs after the runner's
    +/// last batch boundary, so the cancel lands after every boundary check and
    +/// only the terminal transition can observe it.
    +pub(super) struct CancelOnOpenSink {
    +    pub(super) inner: FsSink,
    +    pub(super) storage: Arc,
    +    pub(super) operation_id: String,
    +    pub(super) fired: Arc,
    +}
    +
    +impl BulkSink for CancelOnOpenSink {
    +    fn write_manifest(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        sse: &OutputSse,
    +        lines: Vec,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner
    +            .write_manifest(at, prefix, operation_id, owner, sse, lines)
    +    }
    +
    +    fn check_location(
    +        &self,
    +        at: &BucketRef<'_>,
    +        key: &str,
    +        sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result<(), SinkError>> {
    +        self.inner.check_location(at, key, sse)
    +    }
    +
    +    fn open(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        operation_id: &str,
    +        kind: ReportKind,
    +        owner: &str,
    +        sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result, SinkError>> {
    +        let fut = self.inner.open(at, prefix, operation_id, kind, owner, sse);
    +        let storage = Arc::clone(&self.storage);
    +        let operation = self.operation_id.clone();
    +        let fired = Arc::clone(&self.fired);
    +        Box::pin(async move {
    +            if !fired.swap(true, Ordering::SeqCst) {
    +                storage
    +                    .request_cancel(ACCOUNT, &operation)
    +                    .await
    +                    .expect("request cancel");
    +            }
    +            fut.await
    +        })
    +    }
    +}
    +
    +// A regular file where a report-path directory
    +// component must be, and a directory the server may not write into, end the
    +// operation S3AccessDenied, the same class as the existing symlink and jail
    +// refusals; before this they ended InternalServerError. The location class
    +// for every other local-path fault is already S3AccessDenied.
    +#[tokio::test]
    +async fn a_regular_file_on_the_report_path_fails_with_s3_access_denied() {
    +    let h = Harness::new().await;
    +    // A regular file where the AWSDynamoDB directory component must be
    +    // created, under the output prefix.
    +    h.store.file(
    +        ACCOUNT,
    +        OUT_BUCKET,
    +        &format!("{OUT_PREFIX}/AWSDynamoDB"),
    +        b"not a directory",
    +    );
    +    // One failing row so an error report is rendered and the sink opens the
    +    // report path, hitting the regular file.
    +    let rows = [row(0), row_missing_sk(1), row(2)];
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(
    +        record.failure_code.as_deref(),
    +        Some("S3AccessDenied"),
    +        "a regular file on the report path is the caller's location fault"
    +    );
    +    assert_eq!(record.failure_message.as_deref(), Some("Access Denied"));
    +}
    +
    +#[tokio::test]
    +async fn an_unwritable_report_directory_fails_with_s3_access_denied() {
    +    use std::os::unix::fs::PermissionsExt;
    +    let h = Harness::new().await;
    +    // The AWSDynamoDB component exists as a directory the server may not
    +    // write into, so the next component cannot be created.
    +    let awsddb = h
    +        .store
    +        .0
    +        .join(ACCOUNT)
    +        .join(OUT_BUCKET)
    +        .join(OUT_PREFIX)
    +        .join("AWSDynamoDB");
    +    std::fs::create_dir_all(&awsddb).unwrap();
    +    std::fs::set_permissions(&awsddb, std::fs::Permissions::from_mode(0o500)).unwrap();
    +    // Root ignores the mode bits; if a create still succeeds the guard does
    +    // not apply and the case cannot be driven here.
    +    let probe = awsddb.join(".probe");
    +    if std::fs::create_dir(&probe).is_ok() {
    +        let _ = std::fs::remove_dir(&probe);
    +        std::fs::set_permissions(&awsddb, std::fs::Permissions::from_mode(0o700)).unwrap();
    +        return;
    +    }
    +
    +    let rows = [row(0), row_missing_sk(1), row(2)];
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +    // Let the Root drop clean up the tree.
    +    std::fs::set_permissions(&awsddb, std::fs::Permissions::from_mode(0o700)).ok();
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(
    +        record.failure_code.as_deref(),
    +        Some("S3AccessDenied"),
    +        "a report directory the server cannot write into is the caller's location fault"
    +    );
    +    assert_eq!(record.failure_message.as_deref(), Some("Access Denied"));
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/source_faults.rs b/crates/engine/src/bulk_put/tests/runner/source_faults.rs
    new file mode 100644
    index 000000000..aac243702
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/source_faults.rs
    @@ -0,0 +1,458 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// Test doubles around the filesystem source and sink. Each wraps the real
    +// implementation and adds one controlled behaviour at a chosen line index,
    +// so the interleavings the spec describes (a cancel landing mid-batch, a
    +// table deleted mid-run, a process dying between two persists) happen at a
    +// deterministic point in the row stream.
    +
    +/// Hands out lines from the real reader until `after` lines have been
    +/// yielded, then signals `notify` and pends forever. Dropping the runner
    +/// future at that point models a process crash: everything checkpointed is
    +/// durable, everything since the last checkpoint is not.
    +pub(super) struct PendAfterSource {
    +    pub(super) inner: FsSource,
    +    pub(super) after: u64,
    +    pub(super) handed: Arc,
    +    pub(super) notify: Arc,
    +}
    +
    +struct PendAfterReader {
    +    inner: Box,
    +    after: u64,
    +    handed: Arc,
    +    notify: Arc,
    +}
    +
    +impl BulkSource for PendAfterSource {
    +    fn check_bucket(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +    ) -> BoxFuture<'_, Result<(), SourceError>> {
    +        self.inner.check_bucket(bucket, prefix)
    +    }
    +
    +    fn etag(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.etag(bucket, file)
    +    }
    +
    +    fn line_count(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.line_count(bucket, file, compression)
    +    }
    +
    +    fn list(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +        max_files: u64,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        self.inner.list(bucket, prefix, max_files)
    +    }
    +
    +    fn open(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        let fut = self.inner.open(bucket, file, compression);
    +        let after = self.after;
    +        let handed = Arc::clone(&self.handed);
    +        let notify = Arc::clone(&self.notify);
    +        Box::pin(async move {
    +            let inner = fut.await?;
    +            Ok(Box::new(PendAfterReader {
    +                inner,
    +                after,
    +                handed,
    +                notify,
    +            }) as Box)
    +        })
    +    }
    +}
    +
    +impl LineReader for PendAfterReader {
    +    fn next_line(
    +        &mut self,
    +        max_line_bytes: usize,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        Box::pin(async move {
    +            if self.handed.load(Ordering::SeqCst) >= self.after {
    +                self.notify.notify_one();
    +                std::future::pending::<()>().await;
    +            }
    +            let line = self.inner.next_line(max_line_bytes).await;
    +            if matches!(&line, Ok(Some(_))) {
    +                self.handed.fetch_add(1, Ordering::SeqCst);
    +            }
    +            line
    +        })
    +    }
    +
    +    fn bytes_consumed(&self) -> u64 {
    +        self.inner.bytes_consumed()
    +    }
    +
    +    fn last_line_end(&self) -> u64 {
    +        self.inner.last_line_end()
    +    }
    +
    +    fn etag(&self) -> Option {
    +        self.inner.etag()
    +    }
    +}
    +
    +pub(super) type LineAction = Arc BoxFuture<'static, ()> + Send + Sync>;
    +
    +/// Runs `action` once, just before handing out line index `at`. The action
    +/// completes before the runner sees the line, so its effect (a cancel, a
    +/// table deletion) lands mid-batch at a fixed row.
    +pub(super) struct ActAtSource {
    +    pub(super) inner: FsSource,
    +    pub(super) at: u64,
    +    pub(super) handed: Arc,
    +    pub(super) fired: Arc,
    +    pub(super) action: LineAction,
    +}
    +
    +struct ActAtReader {
    +    inner: Box,
    +    at: u64,
    +    handed: Arc,
    +    fired: Arc,
    +    action: LineAction,
    +}
    +
    +impl BulkSource for ActAtSource {
    +    fn check_bucket(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +    ) -> BoxFuture<'_, Result<(), SourceError>> {
    +        self.inner.check_bucket(bucket, prefix)
    +    }
    +
    +    fn etag(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.etag(bucket, file)
    +    }
    +
    +    fn line_count(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.line_count(bucket, file, compression)
    +    }
    +
    +    fn list(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +        max_files: u64,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        self.inner.list(bucket, prefix, max_files)
    +    }
    +
    +    fn open(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        let fut = self.inner.open(bucket, file, compression);
    +        let at = self.at;
    +        let handed = Arc::clone(&self.handed);
    +        let fired = Arc::clone(&self.fired);
    +        let action = Arc::clone(&self.action);
    +        Box::pin(async move {
    +            let inner = fut.await?;
    +            Ok(Box::new(ActAtReader {
    +                inner,
    +                at,
    +                handed,
    +                fired,
    +                action,
    +            }) as Box)
    +        })
    +    }
    +}
    +
    +impl LineReader for ActAtReader {
    +    fn next_line(
    +        &mut self,
    +        max_line_bytes: usize,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        Box::pin(async move {
    +            if self.handed.load(Ordering::SeqCst) >= self.at
    +                && !self.fired.swap(true, Ordering::SeqCst)
    +            {
    +                (self.action)().await;
    +            }
    +            let line = self.inner.next_line(max_line_bytes).await;
    +            if matches!(&line, Ok(Some(_))) {
    +                self.handed.fetch_add(1, Ordering::SeqCst);
    +            }
    +            line
    +        })
    +    }
    +
    +    fn bytes_consumed(&self) -> u64 {
    +        self.inner.bytes_consumed()
    +    }
    +
    +    fn etag(&self) -> Option {
    +        self.inner.etag()
    +    }
    +}
    +
    +/// Counts `list` and `open` calls, to prove a claimed CANCELLING operation
    +/// reads no input, and `line_count` and `etag` calls, to prove the
    +/// manifest reads no finished file a second time.
    +pub(super) struct CountingSource {
    +    pub(super) inner: FsSource,
    +    pub(super) lists: Arc,
    +    pub(super) opens: Arc,
    +    pub(super) counts: Arc,
    +    pub(super) etags: Arc,
    +}
    +
    +impl CountingSource {
    +    pub(super) fn new(inner: FsSource) -> Self {
    +        Self {
    +            inner,
    +            lists: Arc::new(AtomicU64::new(0)),
    +            opens: Arc::new(AtomicU64::new(0)),
    +            counts: Arc::new(AtomicU64::new(0)),
    +            etags: Arc::new(AtomicU64::new(0)),
    +        }
    +    }
    +}
    +
    +impl BulkSource for CountingSource {
    +    fn check_bucket(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +    ) -> BoxFuture<'_, Result<(), SourceError>> {
    +        self.inner.check_bucket(bucket, prefix)
    +    }
    +
    +    fn etag(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +    ) -> BoxFuture<'_, Result> {
    +        self.etags.fetch_add(1, Ordering::SeqCst);
    +        self.inner.etag(bucket, file)
    +    }
    +
    +    fn line_count(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result> {
    +        self.counts.fetch_add(1, Ordering::SeqCst);
    +        self.inner.line_count(bucket, file, compression)
    +    }
    +
    +    fn list(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +        max_files: u64,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        self.lists.fetch_add(1, Ordering::SeqCst);
    +        self.inner.list(bucket, prefix, max_files)
    +    }
    +
    +    fn open(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        self.opens.fetch_add(1, Ordering::SeqCst);
    +        self.inner.open(bucket, file, compression)
    +    }
    +}
    +
    +/// Serves lines from the real reader until `at` lines have been handed
    +/// out, then returns `SourceError::Io` on every later call. On a resume
    +/// whose cursor is above `at`, the fault fires inside the skip region: a
    +/// read error that is not a changed-file signal must fail the operation,
    +/// not abandon the file the way an early end of file or an over-cap line
    +/// does.
    +pub(super) struct IoAtSource {
    +    pub(super) inner: FsSource,
    +    pub(super) at: u64,
    +    pub(super) handed: Arc,
    +}
    +
    +struct IoAtReader {
    +    inner: Box,
    +    at: u64,
    +    handed: Arc,
    +}
    +
    +impl BulkSource for IoAtSource {
    +    fn check_bucket(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +    ) -> BoxFuture<'_, Result<(), SourceError>> {
    +        self.inner.check_bucket(bucket, prefix)
    +    }
    +
    +    fn etag(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.etag(bucket, file)
    +    }
    +
    +    fn line_count(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.line_count(bucket, file, compression)
    +    }
    +
    +    fn list(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +        max_files: u64,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        self.inner.list(bucket, prefix, max_files)
    +    }
    +
    +    fn open(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        let fut = self.inner.open(bucket, file, compression);
    +        let at = self.at;
    +        let handed = Arc::clone(&self.handed);
    +        Box::pin(async move {
    +            let inner = fut.await?;
    +            Ok(Box::new(IoAtReader { inner, at, handed }) as Box)
    +        })
    +    }
    +}
    +
    +impl LineReader for IoAtReader {
    +    fn next_line(
    +        &mut self,
    +        max_line_bytes: usize,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        Box::pin(async move {
    +            if self.handed.load(Ordering::SeqCst) >= self.at {
    +                return Err(SourceError::Io(std::io::Error::other(
    +                    "injected read error inside the covered region",
    +                )));
    +            }
    +            let line = self.inner.next_line(max_line_bytes).await;
    +            if matches!(&line, Ok(Some(_))) {
    +                self.handed.fetch_add(1, Ordering::SeqCst);
    +            }
    +            line
    +        })
    +    }
    +
    +    fn bytes_consumed(&self) -> u64 {
    +        self.inner.bytes_consumed()
    +    }
    +
    +    fn etag(&self) -> Option {
    +        self.inner.etag()
    +    }
    +}
    +
    +/// Delegates `open` until `pend_at` opens have been requested, then signals
    +/// `notify` and pends inside that open. Dropping the runner there models a
    +/// crash whose last committed state is the previous file's end-of-file
    +/// checkpoint: the cursor already points at the next file.
    +pub(super) struct PendAtOpenSource {
    +    pub(super) inner: FsSource,
    +    pub(super) pend_at: u64,
    +    pub(super) opens: Arc,
    +    pub(super) notify: Arc,
    +}
    +
    +impl BulkSource for PendAtOpenSource {
    +    fn check_bucket(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +    ) -> BoxFuture<'_, Result<(), SourceError>> {
    +        self.inner.check_bucket(bucket, prefix)
    +    }
    +
    +    fn etag(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.etag(bucket, file)
    +    }
    +
    +    fn line_count(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.line_count(bucket, file, compression)
    +    }
    +
    +    fn list(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +        max_files: u64,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        self.inner.list(bucket, prefix, max_files)
    +    }
    +
    +    fn open(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        let count = self.opens.fetch_add(1, Ordering::SeqCst) + 1;
    +        if count >= self.pend_at {
    +            let notify = Arc::clone(&self.notify);
    +            return Box::pin(async move {
    +                notify.notify_one();
    +                std::future::pending::<()>().await;
    +                unreachable!("pending never resolves")
    +            });
    +        }
    +        self.inner.open(bucket, file, compression)
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/store_outage.rs b/crates/engine/src/bulk_put/tests/runner/store_outage.rs
    new file mode 100644
    index 000000000..452c9e3b5
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/store_outage.rs
    @@ -0,0 +1,335 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! A store unavailable past its retries leaves the operation in progress:
    +//! the next claim resumes it from its checkpoint, as after a transient
    +//! database fault, under the bound on resumes without progress.
    +
    +use super::*;
    +use crate::bulk_put::source::StoreUnavailable;
    +
    +fn outage() -> std::io::Error {
    +    StoreUnavailable::io("S3 read failed".to_owned())
    +}
    +
    +/// Serves lines from the real reader until `at` lines have been handed
    +/// out, then answers the next read with the store's failure past its
    +/// retries, once.
    +struct OutageSource {
    +    inner: FsSource,
    +    at: u64,
    +    handed: Arc,
    +    fired: Arc,
    +}
    +
    +struct OutageReader {
    +    inner: Box,
    +    at: u64,
    +    handed: Arc,
    +    fired: Arc,
    +}
    +
    +impl BulkSource for OutageSource {
    +    fn check_bucket(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +    ) -> BoxFuture<'_, Result<(), SourceError>> {
    +        self.inner.check_bucket(bucket, prefix)
    +    }
    +
    +    fn etag(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.etag(bucket, file)
    +    }
    +
    +    fn line_count(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner.line_count(bucket, file, compression)
    +    }
    +
    +    fn list(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        prefix: &str,
    +        max_files: u64,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        self.inner.list(bucket, prefix, max_files)
    +    }
    +
    +    fn open(
    +        &self,
    +        bucket: &BucketRef<'_>,
    +        file: &InputFile,
    +        compression: InputCodec,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        let fut = self.inner.open(bucket, file, compression);
    +        let (at, handed, fired) = (self.at, Arc::clone(&self.handed), Arc::clone(&self.fired));
    +        Box::pin(async move {
    +            let inner = fut.await?;
    +            Ok(Box::new(OutageReader {
    +                inner,
    +                at,
    +                handed,
    +                fired,
    +            }) as Box)
    +        })
    +    }
    +}
    +
    +impl LineReader for OutageReader {
    +    fn next_line(
    +        &mut self,
    +        max_line_bytes: usize,
    +    ) -> BoxFuture<'_, Result, SourceError>> {
    +        Box::pin(async move {
    +            if self.handed.load(Ordering::SeqCst) >= self.at
    +                && !self.fired.swap(true, Ordering::SeqCst)
    +            {
    +                return Err(SourceError::Io(outage()));
    +            }
    +            let line = self.inner.next_line(max_line_bytes).await;
    +            if matches!(&line, Ok(Some(_))) {
    +                self.handed.fetch_add(1, Ordering::SeqCst);
    +            }
    +            line
    +        })
    +    }
    +
    +    fn bytes_consumed(&self) -> u64 {
    +        self.inner.bytes_consumed()
    +    }
    +
    +    fn last_line_end(&self) -> u64 {
    +        self.inner.last_line_end()
    +    }
    +
    +    fn etag(&self) -> Option {
    +        self.inner.etag()
    +    }
    +}
    +
    +/// Answers the first `failures` report opens with the store's failure past
    +/// its retries, then renders through the real sink.
    +struct OutageSink {
    +    inner: FsSink,
    +    failures: Arc,
    +}
    +
    +impl BulkSink for OutageSink {
    +    fn write_manifest(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        operation_id: &str,
    +        owner: &str,
    +        sse: &OutputSse,
    +        lines: Vec,
    +    ) -> BoxFuture<'_, Result> {
    +        self.inner
    +            .write_manifest(at, prefix, operation_id, owner, sse, lines)
    +    }
    +
    +    fn check_location(
    +        &self,
    +        at: &BucketRef<'_>,
    +        key: &str,
    +        sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result<(), SinkError>> {
    +        self.inner.check_location(at, key, sse)
    +    }
    +
    +    fn open(
    +        &self,
    +        at: &BucketRef<'_>,
    +        prefix: &str,
    +        operation_id: &str,
    +        kind: ReportKind,
    +        owner: &str,
    +        sse: &OutputSse,
    +    ) -> BoxFuture<'_, Result, SinkError>> {
    +        let failing = self
    +            .failures
    +            .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
    +                left.checked_sub(1)
    +            })
    +            .is_ok();
    +        if failing {
    +            return Box::pin(async { Err(SinkError::Io(outage())) });
    +        }
    +        self.inner.open(at, prefix, operation_id, kind, owner, sse)
    +    }
    +}
    +
    +/// Twenty rows, two of them failed rows with error records.
    +fn rows_with_two_errors() -> Vec {
    +    let mut rows = plain_rows(20);
    +    rows[3] = row_missing_sk(3);
    +    rows[14] = row_missing_sk(14);
    +    rows
    +}
    +
    +fn checkpoint_every_five() -> RunnerConfig {
    +    RunnerConfig {
    +        batch_size: 5,
    +        ..config()
    +    }
    +}
    +
    +#[tokio::test]
    +async fn an_input_store_outage_past_the_retries_leaves_the_operation_to_resume() {
    +    let rows = rows_with_two_errors();
    +    let fresh = Harness::new().await;
    +    fresh.write_input("part-000.json", &file_of(&rows));
    +    let files = fresh.enumerate().await;
    +    let expected = fresh
    +        .run_to_end(
    +            files,
    +            input_config(false),
    +            both_reports(),
    +            checkpoint_every_five(),
    +        )
    +        .await;
    +
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +    let source = Arc::new(OutageSource {
    +        inner: h.fs_source(),
    +        at: 12,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        fired: Arc::new(AtomicBool::new(false)),
    +    });
    +    let deps = h.with_short_store_lease(h.deps_with(
    +        "runner-1",
    +        checkpoint_every_five(),
    +        source,
    +        Arc::new(h.fs_sink()),
    +    ));
    +    runner::run(&deps, record)
    +        .await
    +        .expect("the outage stops the run cleanly");
    +
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.status, "IN_PROGRESS", "the outage is no outcome");
    +    assert_eq!(mid.failure_code, None);
    +    assert_eq!(
    +        mid.cursor_row_index, 12,
    +        "the rows read before the outage are written and checkpointed"
    +    );
    +    assert_eq!(h.count_items().await, 11);
    +    assert!(h.read_manifest(&op).is_none(), "no output is written yet");
    +
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-2", checkpoint_every_five()), record)
    +        .await
    +        .expect("resumed run");
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, expected.status);
    +    assert_eq!(record.processed_item_count, expected.processed_item_count);
    +    assert_eq!(record.failed_item_count, expected.failed_item_count);
    +    assert_eq!(record.processed_input_bytes, expected.processed_input_bytes);
    +    assert_eq!(record.usage, expected.usage);
    +    assert_eq!(h.count_items().await, 18);
    +    assert_eq!(
    +        h.read_report(&op, ReportKind::Error).map(|r| r.len()),
    +        Some(2)
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_report_store_outage_past_the_retries_leaves_the_operation_to_resume() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&rows_with_two_errors()));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +    let sink = Arc::new(OutageSink {
    +        inner: h.fs_sink(),
    +        failures: Arc::new(AtomicU64::new(1)),
    +    });
    +    let deps = h.with_short_store_lease(h.deps_with(
    +        "runner-1",
    +        checkpoint_every_five(),
    +        Arc::new(h.fs_source()),
    +        sink,
    +    ));
    +    runner::run(&deps, record)
    +        .await
    +        .expect("the outage stops the run cleanly");
    +
    +    let mid = h.record(&op).await;
    +    assert_eq!(mid.status, "IN_PROGRESS", "the outage is no outcome");
    +    assert_eq!(mid.failure_code, None);
    +    assert_eq!(mid.processed_item_count, 20, "every row was read");
    +    assert_eq!(h.count_items().await, 18, "every row was written");
    +    assert!(h.read_report(&op, ReportKind::Error).is_none());
    +
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-2", checkpoint_every_five()), record)
    +        .await
    +        .expect("resumed run");
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 20);
    +    assert_eq!(record.failed_item_count, 2);
    +    assert_eq!(
    +        h.read_report(&op, ReportKind::Error).map(|r| r.len()),
    +        Some(2),
    +        "the resumed run renders the report"
    +    );
    +    assert!(h.read_manifest(&op).is_some());
    +}
    +
    +#[tokio::test]
    +async fn an_outage_that_outlasts_the_resume_bound_ends_the_operation_failed() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&rows_with_two_errors()));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let failures = Arc::new(AtomicU64::new(u64::MAX));
    +    let mut claims = 0;
    +    while h.record(&op).await.status == "IN_PROGRESS" && claims < 13 {
    +        let owner = format!("runner-{claims}");
    +        let record = h.claim(&owner, Duration::from_millis(100)).await;
    +        claims += 1;
    +        let sink = Arc::new(OutageSink {
    +            inner: h.fs_sink(),
    +            failures: Arc::clone(&failures),
    +        });
    +        let deps = h.with_short_store_lease(h.deps_with(
    +            &owner,
    +            checkpoint_every_five(),
    +            Arc::new(h.fs_source()),
    +            sink,
    +        ));
    +        runner::run(&deps, record)
    +            .await
    +            .expect("each run stops cleanly");
    +        tokio::time::sleep(Duration::from_millis(150)).await;
    +    }
    +    let record = h.record(&op).await;
    +    assert_eq!(
    +        claims, 12,
    +        "the first claim reads every row, the first resume finds the checkpoint it \
    +         left, and ten more resumes find that checkpoint unmoved"
    +    );
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("InternalServerError"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some("Internal server error")
    +    );
    +    assert_eq!(record.processed_item_count, 20);
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/stored_key.rs b/crates/engine/src/bulk_put/tests/runner/stored_key.rs
    new file mode 100644
    index 000000000..cf2ca8ccf
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/stored_key.rs
    @@ -0,0 +1,231 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use std::sync::Mutex;
    +
    +use aws_sdk_s3::config::retry::RetryConfig;
    +use aws_sdk_s3::config::{BehaviorVersion, Credentials, Region};
    +use aws_smithy_http_client::test_util::StaticReplayClient;
    +
    +use super::*;
    +
    +use crate::bulk_put::authorize::{AuthorizeError, BulkPrincipal, S3Action};
    +use crate::bulk_put::s3::{S3Clients, S3Sink};
    +
    +// s3:PutObject is evaluated on the output key as the request spells it and on
    +// the key the store writes the object under. The filesystem store drops
    +// empty and `.` segments; an S3-compatible endpoint also resolves `..`. A
    +// Deny on one spelling of an output location holds for every spelling the
    +// store writes to the same place.
    +
    +/// Deny `s3:PutObject` on every object under `arn:aws:s3:::{OUT_BUCKET}/
    +/// {denied}/`, allow everything else, and record each PutObject resource.
    +struct DenyUnder {
    +    denied: String,
    +    put_checks: Mutex>,
    +}
    +
    +impl DenyUnder {
    +    fn new(denied: &str) -> Arc {
    +        Arc::new(Self {
    +            denied: format!("arn:aws:s3:::{OUT_BUCKET}/{denied}/"),
    +            put_checks: Mutex::new(Vec::new()),
    +        })
    +    }
    +}
    +
    +impl BulkAuthorizer for DenyUnder {
    +    fn authorize<'a>(
    +        &'a self,
    +        _principal: &'a BulkPrincipal,
    +        action: S3Action,
    +        resource_arn: &'a str,
    +        _s3_prefix: Option<&'a str>,
    +    ) -> BoxFuture<'a, Result<(), AuthorizeError>> {
    +        let answer = if action == S3Action::PutObject {
    +            self.put_checks
    +                .lock()
    +                .unwrap()
    +                .push(resource_arn.to_owned());
    +            if resource_arn.starts_with(&self.denied) {
    +                Err(AuthorizeError::Denied { explicit: true })
    +            } else {
    +                Ok(())
    +            }
    +        } else {
    +            Ok(())
    +        };
    +        Box::pin(async move { answer })
    +    }
    +}
    +
    +/// The explicit denial text for the test caller on `key` of the output
    +/// bucket.
    +fn explicit_put_denial(key: &str) -> String {
    +    format!(
    +        "User: arn:aws:iam::{ACCOUNT}:user/{CALLER} is not authorized to perform: s3:PutObject \
    +         on resource: \"arn:aws:s3:::{OUT_BUCKET}/{key}\" with an explicit deny in an \
    +         identity-based policy"
    +    )
    +}
    +
    +/// Run three rows, the second without its sort key, with the output prefix
    +/// `prefix`, through `sink` and the authorizer `deny`.
    +async fn run_with_prefix(
    +    h: &Harness,
    +    prefix: &str,
    +    sink: Arc,
    +    deny: Arc,
    +) -> BulkOperationRecord {
    +    let mut rows = plain_rows(3);
    +    rows[1] = row_missing_sk(1);
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let output = json!({ "S3Bucket": OUT_BUCKET, "S3KeyPrefix": prefix, "OutputType": ["ERROR"] });
    +    let op = h
    +        .start_with_run_context(
    +            files,
    +            input_config(false),
    +            output,
    +            Some(run_context_with_caller()),
    +        )
    +        .await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    let mut deps = h.deps_with("runner-1", config(), Arc::new(h.fs_source()), sink);
    +    deps.authorizer = Some(deny);
    +    runner::run(&deps, record).await.expect("runner run");
    +    h.record(&op).await
    +}
    +
    +/// The held denial shape, with the message naming `key`, and nothing
    +/// written to the output bucket.
    +fn assert_held_denial(h: &Harness, record: &BulkOperationRecord, key: &str) {
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(record.failure_code.as_deref(), Some("S3AccessDenied"));
    +    assert_eq!(
    +        record.failure_message.as_deref(),
    +        Some(explicit_put_denial(key).as_str())
    +    );
    +    assert_eq!(record.processed_item_count, 2);
    +    assert_eq!(record.failed_item_count, 0);
    +    assert_eq!(record.processed_input_bytes, 0);
    +    let out = h.store.0.join(ACCOUNT).join(OUT_BUCKET);
    +    let written: Vec<_> = std::fs::read_dir(&out).unwrap().collect();
    +    assert!(written.is_empty(), "nothing is written: {written:?}");
    +}
    +
    +fn error_key(prefix: &str, operation_id: &str) -> String {
    +    report::report_key(prefix, ACCOUNT, operation_id, ReportKind::Error, 0)
    +}
    +
    +#[tokio::test]
    +async fn a_deny_on_an_output_prefix_holds_for_every_spelling_the_filesystem_store_writes_there() {
    +    for (prefix, denied) in [
    +        ("protected", "protected"),
    +        ("./protected", "protected"),
    +        (".//protected", "protected"),
    +        ("x/./protected", "x/protected"),
    +    ] {
    +        let h = Harness::new().await;
    +        let deny = DenyUnder::new(denied);
    +        let record = run_with_prefix(&h, prefix, Arc::new(h.fs_sink()), Arc::clone(&deny)).await;
    +        let literal = error_key(prefix, &record.operation_id);
    +        let stored = error_key(denied, &record.operation_id);
    +        // The literal key is evaluated first; where it passes, the key the
    +        // store writes is evaluated and named in the denial.
    +        assert_held_denial(&h, &record, &stored);
    +        let checks = deny.put_checks.lock().unwrap().clone();
    +        assert_eq!(
    +            checks.first().map(String::as_str),
    +            Some(format!("arn:aws:s3:::{OUT_BUCKET}/{literal}").as_str()),
    +            "{prefix}: the literal key first"
    +        );
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_denial_of_the_literal_key_names_the_literal_key() {
    +    let h = Harness::new().await;
    +    let deny = DenyUnder::new(".");
    +    let record = run_with_prefix(&h, "./protected", Arc::new(h.fs_sink()), Arc::clone(&deny)).await;
    +    let literal = error_key("./protected", &record.operation_id);
    +    assert_held_denial(&h, &record, &literal);
    +    assert_eq!(
    +        deny.put_checks.lock().unwrap().len(),
    +        1,
    +        "the stored key is not evaluated after a denial"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_spelling_the_filesystem_store_writes_elsewhere_is_allowed() {
    +    let h = Harness::new().await;
    +    let deny = DenyUnder::new("protected");
    +    let record = run_with_prefix(&h, "x/./protected", Arc::new(h.fs_sink()), deny).await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert!(
    +        h.store
    +            .0
    +            .join(ACCOUNT)
    +            .join(OUT_BUCKET)
    +            .join("x/protected/AWSDynamoDB/BulkOperation")
    +            .is_dir(),
    +        "the report lands under x/protected"
    +    );
    +}
    +
    +/// An S3 sink whose clients reach no network: a replay client with no
    +/// answers, so any request fails. `endpoint` is whether the store has a
    +/// configured endpoint.
    +fn offline_s3_sink(endpoint: bool) -> S3Sink {
    +    let mut config = aws_sdk_s3::Config::builder()
    +        .behavior_version(BehaviorVersion::latest())
    +        .region(Region::new(REGION))
    +        .credentials_provider(Credentials::new("AKIDTEST", "secret", None, None, "test"))
    +        .http_client(StaticReplayClient::new(Vec::new()))
    +        .retry_config(RetryConfig::disabled());
    +    if endpoint {
    +        config = config.endpoint_url("http://127.0.0.1:9");
    +    }
    +    S3Sink {
    +        clients: Arc::new(S3Clients::new(config.build(), !endpoint, endpoint)),
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_deny_holds_for_a_parent_segment_an_s3_compatible_endpoint_resolves() {
    +    let h = Harness::new().await;
    +    let deny = DenyUnder::new("protected");
    +    let sink: Arc = Arc::new(offline_s3_sink(true));
    +    let record = run_with_prefix(&h, "x/../protected", sink, deny).await;
    +    let stored = error_key("protected", &record.operation_id);
    +    assert_held_denial(&h, &record, &stored);
    +}
    +
    +#[test]
    +fn on_aws_s3_the_key_as_spelled_is_the_key_written() {
    +    let aws = offline_s3_sink(false);
    +    for key in ["x/../protected/k", "./protected/k", "a//b"] {
    +        assert_eq!(aws.stored_key(key), key);
    +    }
    +    let endpoint = offline_s3_sink(true);
    +    assert_eq!(endpoint.stored_key("x/../protected/k"), "protected/k");
    +    assert_eq!(endpoint.stored_key(".//a/./b"), "a/b");
    +}
    +
    +#[tokio::test]
    +async fn on_aws_s3_a_parent_segment_names_another_object() {
    +    // The literal key is the only one evaluated; the Deny under `protected`
    +    // does not reach `x/../protected`, so the run goes on to the location
    +    // check, which this offline sink cannot answer.
    +    let h = Harness::new().await;
    +    let deny = DenyUnder::new("protected");
    +    let sink: Arc = Arc::new(offline_s3_sink(false));
    +    let record = run_with_prefix(&h, "x/../protected", sink, Arc::clone(&deny)).await;
    +    assert_ne!(record.failure_code.as_deref(), Some("S3AccessDenied"));
    +    let literal = error_key("x/../protected", &record.operation_id);
    +    assert_eq!(
    +        deny.put_checks.lock().unwrap().clone(),
    +        vec![format!("arn:aws:s3:::{OUT_BUCKET}/{literal}")]
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/table_binding.rs b/crates/engine/src/bulk_put/tests/runner/table_binding.rs
    new file mode 100644
    index 000000000..9af2bd5f9
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/table_binding.rs
    @@ -0,0 +1,156 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// Table binding.
    +
    +#[tokio::test]
    +async fn a_table_deleted_mid_run_fails_with_resource_not_found() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(15)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +
    +    let storage = Arc::clone(&h.storage);
    +    let action: LineAction = Arc::new(move || {
    +        let storage = Arc::clone(&storage);
    +        Box::pin(async move {
    +            storage
    +                .delete_table(
    +                    ACCOUNT,
    +                    DeleteTableInput {
    +                        table_name: TABLE.to_owned(),
    +                    },
    +                )
    +                .await
    +                .expect("delete table");
    +        })
    +    });
    +    let source = Arc::new(ActAtSource {
    +        inner: h.fs_source(),
    +        at: 7,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        fired: Arc::new(AtomicBool::new(false)),
    +        action,
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(
    +        record.failure_code.as_deref(),
    +        Some("DynamoDBResourceNotFoundException")
    +    );
    +    assert_eq!(record.failure_message.as_deref(), Some(TABLE_NOT_FOUND));
    +}
    +
    +// Table binding, continued.
    +
    +#[tokio::test]
    +async fn a_table_deleted_between_batches_is_caught_at_the_boundary() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(15)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +
    +    // The delete fires on the end-of-file read: every row is already
    +    // written and no write ever fails, so only the boundary check can
    +    // notice the table is gone.
    +    let storage = Arc::clone(&h.storage);
    +    let action: LineAction = Arc::new(move || {
    +        let storage = Arc::clone(&storage);
    +        Box::pin(async move {
    +            storage
    +                .delete_table(
    +                    ACCOUNT,
    +                    DeleteTableInput {
    +                        table_name: TABLE.to_owned(),
    +                    },
    +                )
    +                .await
    +                .expect("delete table");
    +        })
    +    });
    +    let source = Arc::new(ActAtSource {
    +        inner: h.fs_source(),
    +        at: 15,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        fired: Arc::new(AtomicBool::new(false)),
    +        action,
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(
    +        record.failure_code.as_deref(),
    +        Some("DynamoDBResourceNotFoundException")
    +    );
    +    assert_eq!(record.failure_message.as_deref(), Some(TABLE_NOT_FOUND));
    +    assert_eq!(
    +        record.processed_item_count, 15,
    +        "every row was written before the delete; the boundary caught it"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_table_recreated_under_the_same_name_is_never_written() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(15)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +
    +    // Mid-run the table is deleted and a new one is created under the same
    +    // name. The binding is by table id, so the operation must fail rather
    +    // than write into the impostor.
    +    let storage = Arc::clone(&h.storage);
    +    let action: LineAction = Arc::new(move || {
    +        let storage = Arc::clone(&storage);
    +        Box::pin(async move {
    +            storage
    +                .delete_table(
    +                    ACCOUNT,
    +                    DeleteTableInput {
    +                        table_name: TABLE.to_owned(),
    +                    },
    +                )
    +                .await
    +                .expect("delete table");
    +            Harness::create_table(&storage, TABLE).await;
    +        })
    +    });
    +    let source = Arc::new(ActAtSource {
    +        inner: h.fs_source(),
    +        at: 7,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        fired: Arc::new(AtomicBool::new(false)),
    +        action,
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "FAILED");
    +    assert_eq!(
    +        record.failure_code.as_deref(),
    +        Some("DynamoDBResourceNotFoundException")
    +    );
    +    assert_eq!(record.failure_message.as_deref(), Some(TABLE_NOT_FOUND));
    +    assert_eq!(
    +        h.count_items().await,
    +        0,
    +        "the recreated table holds nothing: the operation never wrote to it"
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/tests/runner/takeover.rs b/crates/engine/src/bulk_put/tests/runner/takeover.rs
    new file mode 100644
    index 000000000..0ed8bbae6
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/takeover.rs
    @@ -0,0 +1,187 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! A claim that lost the operation commits nothing, and a server shutdown
    +//! stops a run at the next row with its rows written, a checkpoint and the
    +//! lease released.
    +
    +use super::*;
    +
    +fn kv(pk: &str, v: &str) -> String {
    +    format!(
    +        "{{\"Item\": {{\"pk\": {{\"S\": \"{pk}\"}}, \"sk\": {{\"S\": \"s\"}}, \"v\": {{\"N\": \"{v}\"}}}}}}"
    +    )
    +}
    +
    +fn window(batch_size: usize, write_batch_rows: usize) -> RunnerConfig {
    +    RunnerConfig {
    +        batch_size,
    +        write_batch_rows,
    +        ..config()
    +    }
    +}
    +
    +/// The counters, usage and stored values of a finished operation.
    +async fn summary(h: &Harness, op: &str) -> (String, i64, i64, Value, Vec>) {
    +    let record = h.record(op).await;
    +    let mut items = Vec::new();
    +    for pk in ["dup", "k1", "k5", "k9", "k20", "k29"] {
    +        items.push(h.get(pk).await);
    +    }
    +    (
    +        record.status,
    +        record.processed_item_count,
    +        record.processed_input_bytes,
    +        record.usage,
    +        items,
    +    )
    +}
    +
    +async fn uninterrupted(
    +    backend: Backend,
    +    rows: &[String],
    +    cfg: RunnerConfig,
    +) -> (String, i64, i64, Value, Vec>) {
    +    let h = Harness::on(backend).await;
    +    h.write_input("part-000.json", &file_of(rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-1", cfg), record)
    +        .await
    +        .expect("uninterrupted run");
    +    let out = summary(&h, &op).await;
    +    h.cleanup().await;
    +    out
    +}
    +
    +async fn a_batch_still_in_flight_when_another_claim_takes_over_writes_nothing_case(
    +    backend: Backend,
    +) {
    +    let mut rows = plain_rows(10);
    +    rows[0] = kv("dup", "1");
    +    rows[6] = kv("dup", "2");
    +    let expected = uninterrupted(backend, &rows, window(5, 1)).await;
    +
    +    let h = Harness::on(backend).await;
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_millis(100)).await;
    +
    +    // Runner-1's first batch, row 0's put of `dup`, is held between the
    +    // runner and storage, past runner-1's own lease check, the way a batch
    +    // of a process that stalled is still in flight when its lease runs out.
    +    let entered = Arc::new(tokio::sync::Notify::new());
    +    let release = Arc::new(tokio::sync::Notify::new());
    +    let mut faulting = FaultingStorage::new(&h.storage);
    +    faulting.hold_first_batch = Some((Arc::clone(&entered), Arc::clone(&release)));
    +    faulting.checkpoint_lease = Some(Duration::from_millis(100));
    +    let deps = h.deps_with_storage("runner-1", window(5, 1), Arc::new(faulting));
    +    let run = runner::run(&deps, record);
    +    tokio::pin!(run);
    +    tokio::select! {
    +        _ = &mut run => panic!("runner-1 must reach its held batch"),
    +        () = entered.notified() => {}
    +    }
    +
    +    // The lease runs out; runner-2 takes the operation over and finishes
    +    // it, writing row 6's later put of `dup`.
    +    tokio::time::sleep(Duration::from_millis(250)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-2", window(5, 1)), record)
    +        .await
    +        .expect("runner-2's run");
    +    assert_eq!(h.record(&op).await.status, "COMPLETED");
    +
    +    // Runner-1's batch now reaches storage and is refused.
    +    release.notify_one();
    +    let late = run.await;
    +    assert!(
    +        late.is_ok(),
    +        "a claim that lost the operation stops cleanly, got {late:?}"
    +    );
    +    let dup = h.get("dup").await.expect("dup is stored");
    +    assert_eq!(
    +        dup.get("v"),
    +        Some(&AttributeValue::N("2".to_owned())),
    +        "the late batch of the earlier claim wrote nothing"
    +    );
    +    assert_eq!(summary(&h, &op).await, expected);
    +    h.cleanup().await;
    +}
    +
    +async fn a_shutdown_checkpoints_at_the_next_row_and_releases_the_lease_case(backend: Backend) {
    +    let mut rows = plain_rows(30);
    +    rows[6] = kv("dup", "1");
    +    rows[14] = kv("dup", "2");
    +    let expected = uninterrupted(backend, &rows, window(25, 3)).await;
    +
    +    let h = Harness::on(backend).await;
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +
    +    // The server's shutdown token is cancelled just before line 12 is read.
    +    let shutdown = CancellationToken::new();
    +    let token = shutdown.clone();
    +    let action: LineAction = Arc::new(move || {
    +        let token = token.clone();
    +        Box::pin(async move { token.cancel() })
    +    });
    +    let source = Arc::new(ActAtSource {
    +        inner: h.fs_source(),
    +        at: 12,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        fired: Arc::new(AtomicBool::new(false)),
    +        action,
    +    });
    +    let mut deps = h.deps_with("runner-1", window(25, 3), source, Arc::new(h.fs_sink()));
    +    deps.shutdown = shutdown;
    +    runner::run(&deps, record)
    +        .await
    +        .expect("a shutdown is a clean stop");
    +
    +    let mid = h.record(&op).await;
    +    assert_eq!(
    +        mid.status, "IN_PROGRESS",
    +        "a shutdown is not a terminal transition"
    +    );
    +    assert_eq!(
    +        mid.cursor_row_index, 13,
    +        "the run stopped after row 12 and checkpointed every row it read"
    +    );
    +    assert_eq!(mid.processed_item_count, 13);
    +    assert_eq!(
    +        h.count_items().await,
    +        13,
    +        "the staged rows were written first"
    +    );
    +    assert!(
    +        mid.lease_until_ms.is_some_and(|until| until <= now_ms()),
    +        "the checkpoint released the lease, got {:?}",
    +        mid.lease_until_ms
    +    );
    +
    +    // The next process claims at once, without waiting out a lease.
    +    tokio::time::sleep(Duration::from_millis(5)).await;
    +    let record = h.claim("runner-2", Duration::from_secs(60)).await;
    +    runner::run(&h.deps("runner-2", window(25, 3)), record)
    +        .await
    +        .expect("resumed run");
    +    assert_eq!(summary(&h, &op).await, expected);
    +    h.cleanup().await;
    +}
    +
    +on_both_backends!(
    +    a_batch_still_in_flight_when_another_claim_takes_over_writes_nothing_case,
    +    a_batch_still_in_flight_when_another_claim_takes_over_writes_nothing,
    +    a_batch_still_in_flight_when_another_claim_takes_over_writes_nothing_on_postgres
    +);
    +
    +on_both_backends!(
    +    a_shutdown_checkpoints_at_the_next_row_and_releases_the_lease_case,
    +    a_shutdown_checkpoints_at_the_next_row_and_releases_the_lease,
    +    a_shutdown_checkpoints_at_the_next_row_and_releases_the_lease_on_postgres
    +);
    diff --git a/crates/engine/src/bulk_put/tests/runner/writes.rs b/crates/engine/src/bulk_put/tests/runner/writes.rs
    new file mode 100644
    index 000000000..dff919f47
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/runner/writes.rs
    @@ -0,0 +1,471 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// Write semantics.
    +
    +#[tokio::test]
    +async fn a_bulk_row_replaces_the_stored_item_in_full() {
    +    let h = Harness::new().await;
    +    let mut stored = item("k0", "1");
    +    stored.insert(
    +        "legacy".to_owned(),
    +        AttributeValue::S("kept-before".to_owned()),
    +    );
    +    h.put_direct(stored).await;
    +
    +    h.write_input("part-000.json", &file_of(&[row(0)]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED");
    +    let after = h.get("k0").await.expect("item present");
    +    assert!(
    +        !after.contains_key("legacy"),
    +        "attributes absent from the input row are gone after the write"
    +    );
    +    assert_eq!(after.get("v"), Some(&AttributeValue::N("0".to_owned())));
    +}
    +
    +// Stream emission.
    +
    +#[tokio::test]
    +async fn a_bulk_run_over_a_stream_enabled_table_emits_one_insert_per_applied_row() {
    +    let h = Harness::new_with_stream().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[row(0), row_missing_sk(1), row(2)]),
    +    );
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 3);
    +    assert_eq!(record.failed_item_count, 1);
    +
    +    // Each record lands on the shard its partition key hashes to, so
    +    // collect across every shard, the way apply_put's unit test does.
    +    let (streams, _) = h
    +        .storage
    +        .list_streams(ACCOUNT, Some(TABLE), 10, None)
    +        .await
    +        .expect("list streams");
    +    let stream_arn = streams.first().expect("one stream").stream_arn.clone();
    +    let description = h
    +        .storage
    +        .describe_stream(
    +            ACCOUNT,
    +            &DescribeStreamInput {
    +                stream_arn,
    +                limit: None,
    +                exclusive_start_shard_id: None,
    +            },
    +        )
    +        .await
    +        .expect("describe stream");
    +    let mut records = Vec::new();
    +    for shard in &description.shards {
    +        let (shard_records, _) = h
    +            .storage
    +            .get_stream_records(ACCOUNT, &description.stream_arn, &shard.shard_id, None, 100)
    +            .await
    +            .expect("get records");
    +        records.extend(shard_records);
    +    }
    +
    +    assert_eq!(
    +        records.len(),
    +        2,
    +        "one record per applied row, none for the failed row"
    +    );
    +    let mut keys = Vec::new();
    +    for record in &records {
    +        assert_eq!(record.event_name, StreamEventName::Insert);
    +        assert_eq!(record.aws_region, REGION);
    +        assert!(record.user_identity.is_none(), "no user identity");
    +        let new_image = record
    +            .dynamodb
    +            .new_image
    +            .as_ref()
    +            .expect("NewImage populated");
    +        assert!(
    +            record.dynamodb.old_image.is_none(),
    +            "fresh keys carry no old image"
    +        );
    +        keys.push(match new_image.get("pk") {
    +            Some(AttributeValue::S(pk)) => pk.clone(),
    +            other => panic!("string pk expected, got {other:?}"),
    +        });
    +    }
    +    keys.sort_unstable();
    +    assert_eq!(keys, ["k0", "k2"], "exactly the two applied rows streamed");
    +}
    +
    +/// The runner re-reads the table's key metadata at every checkpoint
    +/// boundary before writing the rows staged since the last one, so a stream
    +/// enabled while the operation runs records every row from the next
    +/// boundary on, as it would for a `PutItem` issued after the enable. With
    +/// the metadata read once at the start, the rows after the enable would
    +/// produce no records at all.
    +#[tokio::test]
    +async fn a_stream_enabled_during_the_run_records_every_row_written_after_it() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(20)));
    +    let files = h.enumerate().await;
    +    let op = h.start(files, input_config(false), both_reports()).await;
    +    let record = h.claim("runner-1", Duration::from_secs(60)).await;
    +
    +    // Rows 0 to 9 are written across two boundaries with no stream; the
    +    // enable lands just before line 10 is handed to the runner, so rows 10
    +    // to 19 are staged after it and flushed at the two boundaries that
    +    // follow.
    +    let storage = Arc::clone(&h.storage);
    +    let action: LineAction = Arc::new(move || {
    +        let storage = Arc::clone(&storage);
    +        Box::pin(async move {
    +            let input: UpdateTableInput = serde_json::from_value(json!({
    +                "TableName": TABLE,
    +                "StreamSpecification": {
    +                    "StreamEnabled": true,
    +                    "StreamViewType": "NEW_AND_OLD_IMAGES"
    +                }
    +            }))
    +            .expect("update table input");
    +            storage
    +                .update_table(ACCOUNT, input)
    +                .await
    +                .expect("enable the stream");
    +        })
    +    });
    +    let source = Arc::new(ActAtSource {
    +        inner: h.fs_source(),
    +        at: 10,
    +        handed: Arc::new(AtomicU64::new(0)),
    +        fired: Arc::new(AtomicBool::new(false)),
    +        action,
    +    });
    +    let mut cfg = config();
    +    cfg.batch_size = 5;
    +    let deps = h.deps_with("runner-1", cfg, source, Arc::new(h.fs_sink()));
    +    runner::run(&deps, record).await.expect("runner run");
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 20);
    +    for i in 0..20 {
    +        assert!(h.get(&format!("k{i}")).await.is_some(), "k{i} written");
    +    }
    +
    +    let (streams, _) = h
    +        .storage
    +        .list_streams(ACCOUNT, Some(TABLE), 10, None)
    +        .await
    +        .expect("list streams");
    +    let stream_arn = streams.first().expect("one stream").stream_arn.clone();
    +    let description = h
    +        .storage
    +        .describe_stream(
    +            ACCOUNT,
    +            &DescribeStreamInput {
    +                stream_arn,
    +                limit: None,
    +                exclusive_start_shard_id: None,
    +            },
    +        )
    +        .await
    +        .expect("describe stream");
    +    let mut keys = Vec::new();
    +    for shard in &description.shards {
    +        let (shard_records, _) = h
    +            .storage
    +            .get_stream_records(ACCOUNT, &description.stream_arn, &shard.shard_id, None, 100)
    +            .await
    +            .expect("get records");
    +        for record in shard_records {
    +            assert_eq!(record.event_name, StreamEventName::Insert);
    +            let new_image = record.dynamodb.new_image.expect("NewImage populated");
    +            keys.push(match new_image.get("pk") {
    +                Some(AttributeValue::S(pk)) => pk.clone(),
    +                other => panic!("string pk expected, got {other:?}"),
    +            });
    +        }
    +    }
    +    keys.sort_unstable();
    +    let expected: Vec = (10..20).map(|i| format!("k{i}")).collect();
    +    assert_eq!(
    +        keys, expected,
    +        "one record for every row staged after the enable, none before it"
    +    );
    +}
    +
    +// Conditions.
    +
    +#[tokio::test]
    +async fn a_condition_failed_row_emits_no_stream_record() {
    +    let h = Harness::new_with_stream().await;
    +    // The seed goes through the harness's direct put, which passes no
    +    // stream capture, so the stream starts empty: the run adds exactly one
    +    // INSERT (row 0) and nothing for the condition row, whose write never
    +    // reaches the table.
    +    h.put_direct(item("c", "1")).await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[row(0), condition_row("2", "attribute_not_exists(pk)")]),
    +    );
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 2);
    +    assert_eq!(record.failed_item_count, 0);
    +    let stored = h.get("c").await.expect("seed present");
    +    assert_eq!(stored.get("v"), Some(&AttributeValue::N("1".to_owned())));
    +
    +    let (streams, _) = h
    +        .storage
    +        .list_streams(ACCOUNT, Some(TABLE), 10, None)
    +        .await
    +        .expect("list streams");
    +    let stream_arn = streams.first().expect("one stream").stream_arn.clone();
    +    let description = h
    +        .storage
    +        .describe_stream(
    +            ACCOUNT,
    +            &DescribeStreamInput {
    +                stream_arn,
    +                limit: None,
    +                exclusive_start_shard_id: None,
    +            },
    +        )
    +        .await
    +        .expect("describe stream");
    +    let mut records = Vec::new();
    +    for shard in &description.shards {
    +        let (shard_records, _) = h
    +            .storage
    +            .get_stream_records(ACCOUNT, &description.stream_arn, &shard.shard_id, None, 100)
    +            .await
    +            .expect("get records");
    +        records.extend(shard_records);
    +    }
    +    let keys: Vec = records
    +        .iter()
    +        .map(|r| match r.dynamodb.keys.get("pk") {
    +            Some(AttributeValue::S(s)) => s.clone(),
    +            other => panic!("unexpected key {other:?}"),
    +        })
    +        .collect();
    +    assert_eq!(
    +        keys,
    +        vec!["k0".to_owned()],
    +        "one record for the applied row; none for the condition-failed row"
    +    );
    +}
    +#[tokio::test]
    +async fn a_true_condition_writes_the_row_and_leaves_no_report() {
    +    let h = Harness::new().await;
    +    let conditional =
    +        "{\"Item\": {\"pk\": {\"S\": \"c\"}, \"sk\": {\"S\": \"s\"}, \"v\": {\"N\": \"1\"}}, \
    +                       \"ConditionExpression\": \"attribute_not_exists(pk)\"}"
    +            .to_owned();
    +    h.write_input("part-000.json", &file_of(&[conditional]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 1);
    +    assert_eq!(record.failed_item_count, 0);
    +    assert!(h.get("c").await.is_some(), "the condition passed and wrote");
    +    assert!(
    +        h.read_report(&record.operation_id, ReportKind::Error)
    +            .is_none()
    +    );
    +    assert!(
    +        h.read_report(&record.operation_id, ReportKind::ConditionCheckFailure)
    +            .is_none()
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_false_condition_with_ccf_requested_routes_to_the_condition_report() {
    +    let h = Harness::new().await;
    +    h.put_direct(item("c", "1")).await;
    +    let conditional =
    +        "{\"Item\": {\"pk\": {\"S\": \"c\"}, \"sk\": {\"S\": \"s\"}, \"v\": {\"N\": \"2\"}}, \
    +                       \"ConditionExpression\": \"attribute_not_exists(pk)\"}"
    +            .to_owned();
    +    h.write_input("part-000.json", &file_of(&[conditional]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED", "condition failures do not fail");
    +    assert_eq!(record.processed_item_count, 1);
    +    assert_eq!(record.failed_item_count, 0);
    +    let stored = h.get("c").await.expect("target item present");
    +    assert_eq!(
    +        stored.get("v"),
    +        Some(&AttributeValue::N("1".to_owned())),
    +        "the target item is untouched"
    +    );
    +    let report = h
    +        .read_report(&record.operation_id, ReportKind::ConditionCheckFailure)
    +        .expect("condition report rendered");
    +    assert_eq!(report.len(), 1);
    +    assert_eq!(report[0]["errorMessages"], json!([CONDITION_FAILED]));
    +    assert_eq!(report[0]["newImage"]["Item"]["v"]["N"], json!("2"));
    +    assert_eq!(report[0]["oldImage"]["Item"]["v"]["N"], json!("1"));
    +    assert!(
    +        h.read_report(&record.operation_id, ReportKind::Error)
    +            .is_none(),
    +        "nothing lands in the error report"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_false_condition_on_a_large_row_is_charged_the_floor_alone() {
    +    // Measured on the service: 1,000 condition-failed rows of about
    +    // 382,300 logical bytes charged 1,024,000 in total, so a rejected row
    +    // costs the one kilobyte floor whatever its size, and no index.
    +    let h = Harness::new_with_gsi().await;
    +    h.put_direct(item("c", "1")).await;
    +    let pad = "p".repeat(300_000);
    +    let conditional = format!(
    +        "{{\"Item\": {{\"pk\": {{\"S\": \"c\"}}, \"sk\": {{\"S\": \"s\"}}, \"g\": {{\"S\": \"x\"}}, \"pad\": {{\"S\": \"{pad}\"}}}}, \
    +         \"ConditionExpression\": \"attribute_not_exists(pk)\"}}"
    +    );
    +    h.write_input("part-000.json", &file_of(&[conditional]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(files, input_config(false), both_reports(), config())
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 1);
    +    assert_eq!(record.usage["Table"]["BulkPutBytes"], json!(1024));
    +    assert_eq!(record.usage["BulkPutBytes"], json!(1024));
    +    assert!(
    +        record
    +            .usage
    +            .get("GlobalSecondaryIndexes")
    +            .is_none_or(|m| m.as_object().is_some_and(serde_json::Map::is_empty)),
    +        "a rejected row charges no index"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_false_condition_with_no_report_bucket_keeps_counters_and_writes_no_report() {
    +    let h = Harness::new().await;
    +    h.put_direct(item("c", "1")).await;
    +    let conditional =
    +        "{\"Item\": {\"pk\": {\"S\": \"c\"}, \"sk\": {\"S\": \"s\"}, \"v\": {\"N\": \"2\"}}, \
    +                       \"ConditionExpression\": \"attribute_not_exists(pk)\"}"
    +            .to_owned();
    +    h.write_input("part-000.json", &file_of(&[conditional]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(
    +            files,
    +            input_config(false),
    +            output_config(None, &["ERROR", "CONDITION_CHECK_FAILURE"]),
    +            config(),
    +        )
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 1);
    +    assert_eq!(record.failed_item_count, 0);
    +    assert!(
    +        h.read_report(&record.operation_id, ReportKind::Error)
    +            .is_none()
    +    );
    +    assert!(
    +        h.read_report(&record.operation_id, ReportKind::ConditionCheckFailure)
    +            .is_none()
    +    );
    +    let report_tree = h.store.0.join(ACCOUNT).join(OUT_BUCKET).join(OUT_PREFIX);
    +    assert!(
    +        !report_tree.exists(),
    +        "no report bucket configured: no report tree is created"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_false_condition_with_only_error_requested_is_written_nowhere() {
    +    // Measured on the service: with only ERROR requested the condition
    +    // record appears in no output file (the September re-route into
    +    // errors/ is gone from the service); the counters are unchanged by it.
    +    let h = Harness::new().await;
    +    h.put_direct(item("c", "1")).await;
    +    let conditional =
    +        "{\"Item\": {\"pk\": {\"S\": \"c\"}, \"sk\": {\"S\": \"s\"}, \"v\": {\"N\": \"2\"}}, \
    +                       \"ConditionExpression\": \"attribute_not_exists(pk)\"}"
    +            .to_owned();
    +    h.write_input("part-000.json", &file_of(&[conditional]));
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(
    +            files,
    +            input_config(false),
    +            output_config(Some(OUT_BUCKET), &["ERROR"]),
    +            config(),
    +        )
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 1);
    +    assert_eq!(record.failed_item_count, 0);
    +    assert!(
    +        h.read_report(&record.operation_id, ReportKind::Error)
    +            .is_none(),
    +        "the condition record is not re-routed into errors/"
    +    );
    +    assert!(
    +        h.read_report(&record.operation_id, ReportKind::ConditionCheckFailure)
    +            .is_none(),
    +        "no condition-check-failures file when the type was not requested"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_error_record_with_only_ccf_requested_is_written_nowhere_but_still_counts() {
    +    // Measured on the service: with only CONDITION_CHECK_FAILURE
    +    // requested an item-level error record appears in no output file, and
    +    // the row still counts as failed.
    +    let h = Harness::new().await;
    +    h.write_input(
    +        "part-000.json",
    +        &file_of(&[row(0), row_missing_sk(1), row(2)]),
    +    );
    +    let files = h.enumerate().await;
    +    let record = h
    +        .run_to_end(
    +            files,
    +            input_config(false),
    +            output_config(Some(OUT_BUCKET), &["CONDITION_CHECK_FAILURE"]),
    +            config(),
    +        )
    +        .await;
    +
    +    assert_eq!(record.status, "COMPLETED_WITH_ERRORS");
    +    assert_eq!(record.processed_item_count, 3);
    +    assert_eq!(record.failed_item_count, 1);
    +    assert!(
    +        h.read_report(&record.operation_id, ReportKind::ConditionCheckFailure)
    +            .is_none(),
    +        "the error record is not re-routed into condition-check-failures/"
    +    );
    +    assert!(
    +        h.read_report(&record.operation_id, ReportKind::Error)
    +            .is_none(),
    +        "no errors file when ERROR was not requested"
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/tests/worker/lifecycle.rs b/crates/engine/src/bulk_put/tests/worker/lifecycle.rs
    new file mode 100644
    index 000000000..57aa14a83
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/worker/lifecycle.rs
    @@ -0,0 +1,187 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// End to end through the worker entry point.
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn a_started_operation_reaches_completed_with_items_written_and_the_report_rendered() {
    +    let h = Harness::new().await;
    +    // Three clean rows plus one conditional row against a pre-existing
    +    // item, so the run stays COMPLETED (condition failures do not count as
    +    // failed items) while the condition report has a record to render.
    +    h.put_direct(item("c", "1")).await;
    +    let mut rows = plain_rows(3);
    +    rows.push(
    +        "{\"Item\": {\"pk\": {\"S\": \"c\"}, \"sk\": {\"S\": \"s\"}, \"v\": {\"N\": \"2\"}}, \
    +         \"ConditionExpression\": \"attribute_not_exists(pk)\"}"
    +            .to_owned(),
    +    );
    +    h.write_input("part-000.json", &file_of(&rows));
    +    let files = h.enumerate().await;
    +    let op = start_on(
    +        &h.storage,
    +        TABLE,
    +        &h.table_id,
    +        files,
    +        input_config(""),
    +        both_reports(),
    +    )
    +    .await;
    +
    +    let (token, handle) = spawn_worker(
    +        &h,
    +        Arc::clone(&h.storage),
    +        LimitsConfig::default(),
    +        Arc::new(MetricsCollector::new()),
    +    );
    +    let status = wait_terminal(&h, &op, Duration::from_secs(5)).await;
    +    assert_eq!(
    +        status.as_deref(),
    +        Some("COMPLETED"),
    +        "the worker must claim the operation and run it to COMPLETED"
    +    );
    +
    +    let record = h.record(&op).await;
    +    assert_eq!(record.processed_item_count, 4);
    +    assert_eq!(record.failed_item_count, 0);
    +    assert!(h.get("k0").await.is_some(), "the first row is written");
    +    assert!(h.get("k2").await.is_some(), "the last clean row is written");
    +    let stored = h.get("c").await.expect("the conditional target exists");
    +    assert_eq!(
    +        stored.get("v"),
    +        Some(&AttributeValue::N("1".to_owned())),
    +        "the failed condition leaves the target untouched"
    +    );
    +    assert_eq!(h.count_items().await, 4);
    +    let report = h
    +        .read_report(&op, ReportKind::ConditionCheckFailure)
    +        .expect("the condition report file is on disk");
    +    assert_eq!(report.len(), 1);
    +    stop_worker(token, handle).await;
    +}
    +
    +// Concurrency, observed through the real runner rather than the claim
    +// loop's bookkeeping.
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn two_operations_on_two_tables_overlap_under_concurrency_two() {
    +    let h = Harness::new().await;
    +    let second_table_id = Harness::create_table(&h.storage, "invoices").await;
    +    h.write_input("a/part-000.json", &file_of(&plain_rows(2)));
    +    h.write_input("b/part-000.json", &file_of(&plain_rows(2)));
    +    let split = |prefix: &str, files: Vec| -> Vec {
    +        files
    +            .into_iter()
    +            .filter(|f| f.path.starts_with(prefix))
    +            .collect()
    +    };
    +    let all = h.enumerate().await;
    +    let op_a = start_on(
    +        &h.storage,
    +        TABLE,
    +        &h.table_id,
    +        split("a/", all.clone()),
    +        input_config("a/"),
    +        both_reports(),
    +    )
    +    .await;
    +    let op_b = start_on(
    +        &h.storage,
    +        "invoices",
    +        &second_table_id,
    +        split("b/", all),
    +        input_config("b/"),
    +        both_reports(),
    +    )
    +    .await;
    +
    +    // The first checkpoint of whichever run reaches one first pends
    +    // forever, holding its slot mid-run; the other operation can only
    +    // finish if both runs are in flight at once.
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let mut faulting = FaultingStorage::new(&h.storage);
    +    faulting.pend_checkpoint = Some(Arc::clone(¬ify));
    +    let faulting: Arc = Arc::new(faulting);
    +
    +    let limits = LimitsConfig {
    +        bulk_operation_worker_concurrency: 2,
    +        ..LimitsConfig::default()
    +    };
    +    let (token, handle) = spawn_worker(&h, faulting, limits, Arc::new(MetricsCollector::new()));
    +
    +    tokio::time::timeout(Duration::from_secs(5), notify.notified())
    +        .await
    +        .expect("one run must reach its first checkpoint");
    +    // The pended run holds one slot forever; only an overlapping second
    +    // run can terminate.
    +    let one_completed = {
    +        let start = std::time::Instant::now();
    +        let mut done = None;
    +        while start.elapsed() < Duration::from_secs(5) && done.is_none() {
    +            for op in [&op_a, &op_b] {
    +                if h.record(op).await.status == "COMPLETED" {
    +                    done = Some(op.clone());
    +                    break;
    +                }
    +            }
    +            tokio::time::sleep(Duration::from_millis(20)).await;
    +        }
    +        done
    +    };
    +    let completed = one_completed
    +        .expect("with two slots, the second operation must complete while the first pends");
    +    let pended = if completed == op_a { &op_b } else { &op_a };
    +    assert_eq!(
    +        h.record(pended).await.status,
    +        "IN_PROGRESS",
    +        "the pended operation stays mid-run"
    +    );
    +    stop_worker(token, handle).await;
    +}
    +
    +// Lease-based recovery of a failed run, through the real runner.
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn an_operation_whose_run_errors_is_reclaimed_after_the_lease_expires_and_finishes() {
    +    let h = Harness::new().await;
    +    h.write_input("part-000.json", &file_of(&plain_rows(3)));
    +    let files = h.enumerate().await;
    +    let op = start_on(
    +        &h.storage,
    +        TABLE,
    +        &h.table_id,
    +        files,
    +        input_config(""),
    +        both_reports(),
    +    )
    +    .await;
    +
    +    // The first checkpoint fails with a transient storage error, so the
    +    // first run returns an error and the operation stays non-terminal,
    +    // leased for the configured second. One slot: a worker that leaked
    +    // the failed run's slot could never claim again.
    +    let mut faulting = FaultingStorage::new(&h.storage);
    +    faulting.fail_checkpoint_once = true;
    +    let faulting: Arc = Arc::new(faulting);
    +    // One slot, and the smallest lease the configuration can express;
    +    // the re-claim happens on the first tick after the lease expires.
    +    let limits = LimitsConfig {
    +        bulk_operation_worker_concurrency: 1,
    +        bulk_operation_lease_seconds: 1,
    +        ..LimitsConfig::default()
    +    };
    +    let (token, handle) = spawn_worker(&h, faulting, limits, Arc::new(MetricsCollector::new()));
    +
    +    let status = wait_terminal(&h, &op, Duration::from_secs(10)).await;
    +    assert_eq!(
    +        status.as_deref(),
    +        Some("COMPLETED"),
    +        "the operation must be re-claimed after its lease expires and finish"
    +    );
    +    let record = h.record(&op).await;
    +    assert_eq!(record.processed_item_count, 3, "no double count on resume");
    +    assert_eq!(h.count_items().await, 3);
    +    stop_worker(token, handle).await;
    +}
    diff --git a/crates/engine/src/bulk_put/tests/worker/mod.rs b/crates/engine/src/bulk_put/tests/worker/mod.rs
    new file mode 100644
    index 000000000..4a2c3b27c
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/worker/mod.rs
    @@ -0,0 +1,292 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! End-to-end behaviour tests for the bulk operation worker, written from
    +//! the specification: operations started the real way (through storage or
    +//! through `dispatch`) are claimed and driven to terminal states by the
    +//! worker entry points, against the same in-memory `SQLite` harness the
    +//! runner behaviour tests use. The worker's own unit tests pin the claim
    +//! loop's bookkeeping in isolation; these tests pin the wired path from a
    +//! started operation to items in the table, a rendered report, a purged
    +//! record, and the recorded metrics.
    +
    +use std::sync::Arc;
    +use std::sync::atomic::{AtomicU64, Ordering};
    +use std::time::Duration;
    +
    +use serde_json::{Value, json};
    +
    +use extenddb_auth::AuthCacheRegistry;
    +use extenddb_core::expression::ExpressionMaps;
    +use extenddb_core::limits::LimitsConfig;
    +use extenddb_core::metrics::{
    +    Dimension, MetricName, MetricSnapshot, MetricsCollector, MetricsQuery, TimeWindow,
    +};
    +use extenddb_core::types::{AttributeValue, Item};
    +use extenddb_storage::{
    +    BulkRowMarker, CancellationToken, FinishState, InputFile, NewBulkOperation, ReportKind,
    +    StartOutcome, StorageEngine, Terminal,
    +};
    +
    +use super::runner::{FaultingStorage, Harness, file_of, plain_rows};
    +use crate::OperationContext;
    +use crate::bulk_put::arn;
    +use crate::bulk_put::store::BulkStore;
    +use crate::bulk_put::worker::{
    +    bulk_operation_retention_worker, bulk_operation_worker_with_interval,
    +};
    +
    +const REGION: &str = "us-east-1";
    +const ACCOUNT: &str = "123456789012";
    +const IN_BUCKET: &str = "loads";
    +/// Must match the bucket and prefix the harness's report readers assume.
    +const OUT_BUCKET: &str = "reports";
    +const OUT_PREFIX: &str = "run";
    +const TABLE: &str = "orders";
    +
    +fn input_config(prefix: &str) -> Value {
    +    // An empty prefix is refused by the service (minimum of three
    +    // characters when present); the whole bucket is read when the member is
    +    // absent, so an empty argument omits it.
    +    let mut config = json!({
    +        "S3Bucket": IN_BUCKET,
    +        "InputFormat": "DYNAMODB_JSON",
    +        "InputCompressionType": "NONE",
    +    });
    +    if !prefix.is_empty() {
    +        config["S3KeyPrefix"] = json!(prefix);
    +    }
    +    config
    +}
    +
    +fn both_reports() -> Value {
    +    json!({
    +        "S3Bucket": OUT_BUCKET,
    +        "S3KeyPrefix": OUT_PREFIX,
    +        "OutputType": ["ERROR", "CONDITION_CHECK_FAILURE"],
    +    })
    +}
    +
    +fn item(pk: &str, v: &str) -> Item {
    +    Item::from([
    +        ("pk".to_owned(), AttributeValue::S(pk.to_owned())),
    +        ("sk".to_owned(), AttributeValue::S("s".to_owned())),
    +        ("v".to_owned(), AttributeValue::N(v.to_owned())),
    +    ])
    +}
    +
    +fn now_ms() -> i64 {
    +    i64::try_from(
    +        std::time::SystemTime::now()
    +            .duration_since(std::time::SystemTime::UNIX_EPOCH)
    +            .unwrap()
    +            .as_millis(),
    +    )
    +    .unwrap()
    +}
    +
    +/// Start an operation on any table of the harness's account, the way
    +/// `Harness::start` does for its own table.
    +async fn start_on(
    +    storage: &Arc,
    +    table_name: &str,
    +    table_id: &str,
    +    files: Vec,
    +    input: Value,
    +    output: Value,
    +) -> String {
    +    let operation_id = arn::generate_operation_id();
    +    let total: i64 = files.iter().map(|f| f.size).sum();
    +    let outcome = storage
    +        .start_bulk_operation(
    +            ACCOUNT,
    +            NewBulkOperation {
    +                operation_id: operation_id.clone(),
    +                table_id: table_id.to_owned(),
    +                table_name: table_name.to_owned(),
    +                table_arn: format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/{table_name}"),
    +                client_token: None,
    +                client_token_hash: None,
    +                request_fingerprint: format!("worker-e2e-{table_name}"),
    +                input_config: input,
    +                output_config: output,
    +                input_files: files,
    +                max_write_request_units: None,
    +                performance_tier: None,
    +                start_time_ms: now_ms(),
    +                total_input_bytes: Some(total),
    +                initial_failure: None,
    +                run_context: None,
    +            },
    +            50,
    +        )
    +        .await
    +        .expect("start bulk operation");
    +    assert!(
    +        matches!(outcome, StartOutcome::Created(_)),
    +        "the operation must be created"
    +    );
    +    operation_id
    +}
    +
    +/// Spawn the worker over `storage` with a 100 ms tick, returning the token
    +/// that stops it and its join handle.
    +fn spawn_worker(
    +    h: &Harness,
    +    storage: Arc,
    +    limits: LimitsConfig,
    +    metrics: Arc,
    +) -> (CancellationToken, tokio::task::JoinHandle<()>) {
    +    let token = CancellationToken::new();
    +    let handle = tokio::spawn(bulk_operation_worker_with_interval(
    +        storage,
    +        Arc::new(limits),
    +        Arc::from(REGION),
    +        Some(Arc::new(BulkStore::filesystem(h.store.root()))),
    +        None,
    +        metrics,
    +        token.clone(),
    +        Duration::from_millis(100),
    +    ));
    +    (token, handle)
    +}
    +
    +/// Poll the operation until it leaves the running statuses or the deadline
    +/// passes; answer its final status, or `None` on timeout.
    +async fn wait_terminal(h: &Harness, operation_id: &str, deadline: Duration) -> Option {
    +    let start = std::time::Instant::now();
    +    while start.elapsed() < deadline {
    +        let record = h.record(operation_id).await;
    +        if record.status != "IN_PROGRESS" && record.status != "CANCELLING" {
    +            return Some(record.status);
    +        }
    +        tokio::time::sleep(Duration::from_millis(20)).await;
    +    }
    +    None
    +}
    +
    +async fn stop_worker(token: CancellationToken, handle: tokio::task::JoinHandle<()>) {
    +    token.cancel();
    +    tokio::time::timeout(Duration::from_secs(2), handle)
    +        .await
    +        .expect("the worker stops at its next tick")
    +        .expect("the worker panicked");
    +}
    +
    +/// The terminal-transition metrics are recorded after `finish` stores the
    +/// terminal status and the ledger rows are deleted, and the worker does not
    +/// await in-flight runs at shutdown, so a test that observed the status
    +/// flip waits here for the metric to land rather than reading it the same
    +/// instant.
    +async fn wait_for_metric(metrics: &MetricsCollector, name: MetricName, deadline: Duration) {
    +    let start = std::time::Instant::now();
    +    while start.elapsed() < deadline {
    +        let snaps = metrics.query(&MetricsQuery {
    +            metric: Some(name),
    +            window: Some(TimeWindow::AllTime),
    +            ..MetricsQuery::default()
    +        });
    +        if !snaps.is_empty() {
    +            return;
    +        }
    +        tokio::time::sleep(Duration::from_millis(10)).await;
    +    }
    +    panic!("{name} was not recorded within {deadline:?}");
    +}
    +
    +fn one_snapshot(metrics: &MetricsCollector, name: MetricName) -> MetricSnapshot {
    +    let mut snaps = metrics.query(&MetricsQuery {
    +        metric: Some(name),
    +        window: Some(TimeWindow::AllTime),
    +        ..MetricsQuery::default()
    +    });
    +    assert_eq!(snaps.len(), 1, "exactly one {name} series, got {snaps:?}");
    +    snaps.remove(0)
    +}
    +
    +/// A counter snapshot's sum as the integer it accumulated, so the
    +/// assertions compare exact integers rather than floats. The workers only
    +/// ever add whole counts far below 2^53, so the sum is exact and the cast
    +/// cannot truncate.
    +fn count(snapshot: &MetricSnapshot) -> u64 {
    +    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
    +    let n = snapshot.sum as u64;
    +    n
    +}
    +
    +/// Start an operation on `table`, claim it, write one Applied ledger row
    +/// for `(operation, 0, 0)` through a marked put, and finish it COMPLETED
    +/// with the chosen end time. The terminal transition clears the table's
    +/// guard, so successive calls claim unambiguously.
    +async fn start_claim_mark_finish(
    +    h: &Harness,
    +    owner: &str,
    +    table: &str,
    +    table_id: &str,
    +    pk: &str,
    +    end_ms: i64,
    +) -> String {
    +    let op = start_on(
    +        &h.storage,
    +        table,
    +        table_id,
    +        Vec::new(),
    +        input_config(""),
    +        both_reports(),
    +    )
    +    .await;
    +    let claimed = h
    +        .storage
    +        .claim_runnable(owner, Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert_eq!(claimed.len(), 1, "exactly the new operation is runnable");
    +    assert_eq!(claimed[0].operation_id, op);
    +    let ki = h
    +        .storage
    +        .table_key_info(ACCOUNT, table)
    +        .await
    +        .expect("key info");
    +    let marker = BulkRowMarker {
    +        account_id: ACCOUNT.to_owned(),
    +        operation_id: op.clone(),
    +        file_index: 0,
    +        row_index: 0,
    +        owner: String::new(),
    +        keep_old: false,
    +        settled: (0, 0),
    +    };
    +    h.storage
    +        .put_item(
    +            &ki,
    +            item(pk, "1"),
    +            false,
    +            None,
    +            &ExpressionMaps::default(),
    +            None,
    +            Some(&marker),
    +        )
    +        .await
    +        .expect("marked put");
    +    let state = h
    +        .storage
    +        .finish(
    +            ACCOUNT,
    +            &op,
    +            owner,
    +            Terminal {
    +                status: "COMPLETED".to_owned(),
    +                end_time_ms: end_ms,
    +                failure_code: None,
    +                failure_message: None,
    +                usage: json!({}),
    +            },
    +        )
    +        .await
    +        .expect("finish");
    +    assert!(matches!(state, FinishState::Held(_)));
    +    op
    +}
    +
    +mod lifecycle;
    +mod retention;
    diff --git a/crates/engine/src/bulk_put/tests/worker/retention.rs b/crates/engine/src/bulk_put/tests/worker/retention.rs
    new file mode 100644
    index 000000000..b460a8a38
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/tests/worker/retention.rs
    @@ -0,0 +1,471 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +
    +// Retention: the cutoff separates an old terminal record from a young one.
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn retention_keeps_a_younger_terminal_operation_while_purging_an_older_one() {
    +    let h = Harness::new().await;
    +    let owner = "retention-e2e";
    +    let young_table_id = Harness::create_table(&h.storage, "young_table").await;
    +    let live_table_id = Harness::create_table(&h.storage, "live_table").await;
    +
    +    // Each operation is claimed, given one Applied ledger row through a
    +    // marked put, and finished with a chosen end time; the third stays
    +    // IN_PROGRESS. Terminal transitions clear each table's guard before
    +    // the next start, so the claims are unambiguous.
    +    // Forty days old: past the thirty day default window.
    +    let op_old = start_claim_mark_finish(
    +        &h,
    +        owner,
    +        TABLE,
    +        &h.table_id,
    +        "old",
    +        now_ms() - 40 * 86_400_000,
    +    )
    +    .await;
    +    // Terminal a moment ago: inside the window.
    +    let op_young =
    +        start_claim_mark_finish(&h, owner, "young_table", &young_table_id, "young", now_ms()).await;
    +    let op_live = start_on(
    +        &h.storage,
    +        "live_table",
    +        &live_table_id,
    +        Vec::new(),
    +        input_config(""),
    +        both_reports(),
    +    )
    +    .await;
    +
    +    let token = CancellationToken::new();
    +    let handle = tokio::spawn(bulk_operation_retention_worker(
    +        Arc::clone(&h.storage),
    +        Arc::new(LimitsConfig::default()),
    +        Arc::new(MetricsCollector::new()),
    +        token.clone(),
    +    ));
    +    let purged = {
    +        let start = std::time::Instant::now();
    +        let mut gone = false;
    +        while start.elapsed() < Duration::from_secs(3) && !gone {
    +            gone = h
    +                .storage
    +                .get_bulk_operation(ACCOUNT, &op_old)
    +                .await
    +                .expect("get")
    +                .is_none();
    +            if !gone {
    +                tokio::time::sleep(Duration::from_millis(20)).await;
    +            }
    +        }
    +        gone
    +    };
    +    assert!(
    +        purged,
    +        "the startup pass must purge the old terminal record"
    +    );
    +    assert!(
    +        h.storage
    +            .bulk_row_outcome(ACCOUNT, &op_old, 0, 0)
    +            .await
    +            .expect("ledger read")
    +            .is_none(),
    +        "the purged operation's ledger rows go with it"
    +    );
    +    assert!(
    +        h.storage
    +            .get_bulk_operation(ACCOUNT, &op_young)
    +            .await
    +            .expect("get")
    +            .is_some(),
    +        "the younger terminal operation survives"
    +    );
    +    assert!(
    +        h.storage
    +            .bulk_row_outcome(ACCOUNT, &op_young, 0, 0)
    +            .await
    +            .expect("ledger read")
    +            .is_some(),
    +        "the younger operation's ledger rows survive"
    +    );
    +    assert!(
    +        h.storage
    +            .get_bulk_operation(ACCOUNT, &op_live)
    +            .await
    +            .expect("get")
    +            .is_some(),
    +        "the in-progress operation survives"
    +    );
    +    token.cancel();
    +    tokio::time::timeout(Duration::from_secs(2), handle)
    +        .await
    +        .expect("the retention worker stops at its next tick")
    +        .expect("the retention worker panicked");
    +}
    +
    +// Metrics: one operation started through dispatch and finished by the
    +// worker leaves exactly the three counters the spec names.
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn the_collector_shows_started_finished_and_items_written_for_one_operation() {
    +    let h = Harness::new().await;
    +    let metrics = Arc::new(MetricsCollector::new());
    +    h.put_direct(item("c", "1")).await;
    +    let mut rows = plain_rows(3);
    +    rows.push(
    +        "{\"Item\": {\"pk\": {\"S\": \"c\"}, \"sk\": {\"S\": \"s\"}, \"v\": {\"N\": \"2\"}}, \
    +         \"ConditionExpression\": \"attribute_not_exists(pk)\"}"
    +            .to_owned(),
    +    );
    +    h.write_input("part-000.json", &file_of(&rows));
    +
    +    // Started is recorded by the start handler on the created outcome, so
    +    // the operation starts through dispatch with the collector in the
    +    // context.
    +    let ctx = OperationContext {
    +        storage: Arc::clone(&h.storage),
    +        backup: Arc::new(crate::BackupRuntime::new(4)),
    +        limits: Arc::new(LimitsConfig::default()),
    +        region: Arc::from(REGION),
    +        account_id: Arc::from(ACCOUNT),
    +        import_paths: Vec::new().into(),
    +        export_paths: Vec::new().into(),
    +        bulk: Some(Arc::new(BulkStore::filesystem(h.store.root()))),
    +        bulk_authorizer: None,
    +        bulk_principal: None,
    +        authorized_key_info: Default::default(),
    +        auth_cache: AuthCacheRegistry::empty(),
    +        table_key_info_lookup: None,
    +        metrics: Arc::clone(&metrics),
    +    };
    +    let body = json!({
    +        "TableName": TABLE,
    +        "InputS3Configuration": input_config(""),
    +        "OutputS3Configuration": both_reports(),
    +    });
    +    let response = crate::dispatch("StartBulkPutItems", body, &ctx, "127.0.0.1:0")
    +        .await
    +        .expect("the start must succeed");
    +    let op_arn = response.body["BulkOperationDescription"]["BulkOperationArn"]
    +        .as_str()
    +        .expect("the description carries an ARN")
    +        .to_owned();
    +    let op = op_arn
    +        .rsplit_once('/')
    +        .expect("the ARN carries the operation id")
    +        .1
    +        .to_owned();
    +
    +    let (token, handle) = spawn_worker(
    +        &h,
    +        Arc::clone(&h.storage),
    +        LimitsConfig::default(),
    +        Arc::clone(&metrics),
    +    );
    +    let status = wait_terminal(&h, &op, Duration::from_secs(5)).await;
    +    assert_eq!(status.as_deref(), Some("COMPLETED"));
    +    wait_for_metric(
    +        &metrics,
    +        MetricName::BulkOperationsFinished,
    +        Duration::from_secs(2),
    +    )
    +    .await;
    +    stop_worker(token, handle).await;
    +
    +    let started = one_snapshot(&metrics, MetricName::BulkOperationsStarted);
    +    assert_eq!(count(&started), 1, "one operation was started");
    +    assert!(
    +        started
    +            .dimensions
    +            .contains(&Dimension::TableName(TABLE.to_owned())),
    +        "Started carries the table dimension: {:?}",
    +        started.dimensions
    +    );
    +
    +    let finished = one_snapshot(&metrics, MetricName::BulkOperationsFinished);
    +    assert_eq!(count(&finished), 1, "one operation finished");
    +    assert!(
    +        finished
    +            .dimensions
    +            .contains(&Dimension::Operation("COMPLETED".to_owned())),
    +        "Finished carries the terminal status: {:?}",
    +        finished.dimensions
    +    );
    +    assert!(
    +        finished
    +            .dimensions
    +            .contains(&Dimension::TableName(TABLE.to_owned())),
    +        "Finished carries the table dimension: {:?}",
    +        finished.dimensions
    +    );
    +
    +    // Three of the four processed rows were applied; the condition-failed
    +    // row wrote nothing, so it is not an applied row.
    +    let written = one_snapshot(&metrics, MetricName::BulkPutItemsWritten);
    +    assert_eq!(
    +        count(&written),
    +        3,
    +        "BulkPutItemsWritten equals the applied rows"
    +    );
    +    assert!(
    +        written
    +            .dimensions
    +            .contains(&Dimension::TableName(TABLE.to_owned())),
    +        "ItemsWritten carries the table dimension: {:?}",
    +        written.dimensions
    +    );
    +}
    +
    +// The stored terminal status in metrics, and the ledger-first retention
    +// ordering.
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn a_failed_operation_records_finished_with_the_failed_status() {
    +    let h = Harness::new().await;
    +    let metrics = Arc::new(MetricsCollector::new());
    +    h.write_input("part-000.json", &file_of(&plain_rows(12)));
    +    let files = h.enumerate().await;
    +    let op = start_on(
    +        &h.storage,
    +        TABLE,
    +        &h.table_id,
    +        files,
    +        input_config(""),
    +        both_reports(),
    +    )
    +    .await;
    +
    +    // Twelve rows against a ten row cap: the run fails with
    +    // ValidationException, so `finish` stores FAILED.
    +    let limits = LimitsConfig {
    +        max_bulk_item_count: 10,
    +        ..LimitsConfig::default()
    +    };
    +    let (token, handle) = spawn_worker(&h, Arc::clone(&h.storage), limits, Arc::clone(&metrics));
    +    let status = wait_terminal(&h, &op, Duration::from_secs(5)).await;
    +    assert_eq!(
    +        status.as_deref(),
    +        Some("FAILED"),
    +        "the worker must run the operation to FAILED"
    +    );
    +    wait_for_metric(
    +        &metrics,
    +        MetricName::BulkOperationsFinished,
    +        Duration::from_secs(2),
    +    )
    +    .await;
    +    stop_worker(token, handle).await;
    +
    +    // Exactly one Finished series exists, so no COMPLETED series was
    +    // recorded beside it, and its status dimension is the stored FAILED.
    +    let finished = one_snapshot(&metrics, MetricName::BulkOperationsFinished);
    +    assert_eq!(count(&finished), 1, "one operation finished");
    +    assert!(
    +        finished
    +            .dimensions
    +            .contains(&Dimension::Operation("FAILED".to_owned())),
    +        "the Finished dimension is the stored FAILED: {:?}",
    +        finished.dimensions
    +    );
    +    assert!(
    +        !finished
    +            .dimensions
    +            .contains(&Dimension::Operation("COMPLETED".to_owned())),
    +        "no COMPLETED dimension on a failed operation: {:?}",
    +        finished.dimensions
    +    );
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn retention_that_fails_a_ledger_delete_keeps_the_record_for_the_next_pass() {
    +    let h = Harness::new().await;
    +    let owner = "retention-fail";
    +    let op = start_claim_mark_finish(
    +        &h,
    +        owner,
    +        TABLE,
    +        &h.table_id,
    +        "old",
    +        now_ms() - 40 * 86_400_000,
    +    )
    +    .await;
    +
    +    let purge_calls = Arc::new(AtomicU64::new(0));
    +    let mut faulting = FaultingStorage::new(&h.storage);
    +    faulting.fail_ledger_delete_once = true;
    +    faulting.purge_calls = Arc::clone(&purge_calls);
    +    let faulting: Arc = Arc::new(faulting);
    +
    +    // Pass one: the ledger delete fails, so the pair is excluded from the
    +    // purge and the record survives the pass.
    +    let token = CancellationToken::new();
    +    let handle = tokio::spawn(bulk_operation_retention_worker(
    +        Arc::clone(&faulting),
    +        Arc::new(LimitsConfig::default()),
    +        Arc::new(MetricsCollector::new()),
    +        token.clone(),
    +    ));
    +    let pass_done = {
    +        let start = std::time::Instant::now();
    +        while start.elapsed() < Duration::from_secs(3) && purge_calls.load(Ordering::SeqCst) == 0 {
    +            tokio::time::sleep(Duration::from_millis(10)).await;
    +        }
    +        purge_calls.load(Ordering::SeqCst) >= 1
    +    };
    +    assert!(pass_done, "the startup pass must run its purge");
    +    token.cancel();
    +    tokio::time::timeout(Duration::from_secs(2), handle)
    +        .await
    +        .expect("the retention worker stops at its next tick")
    +        .expect("the retention worker panicked");
    +    assert!(
    +        h.storage
    +            .get_bulk_operation(ACCOUNT, &op)
    +            .await
    +            .expect("get")
    +            .is_some(),
    +        "a failed ledger delete keeps the record for the next pass"
    +    );
    +    assert!(
    +        h.storage
    +            .bulk_row_outcome(ACCOUNT, &op, 0, 0)
    +            .await
    +            .expect("ledger read")
    +            .is_some(),
    +        "the ledger rows survive the failed delete"
    +    );
    +
    +    // Pass two: the injected failure is spent, so the ledger delete
    +    // succeeds and the pair is purged with its rows.
    +    let token = CancellationToken::new();
    +    let handle = tokio::spawn(bulk_operation_retention_worker(
    +        Arc::clone(&faulting),
    +        Arc::new(LimitsConfig::default()),
    +        Arc::new(MetricsCollector::new()),
    +        token.clone(),
    +    ));
    +    let purged = {
    +        let start = std::time::Instant::now();
    +        let mut gone = false;
    +        while start.elapsed() < Duration::from_secs(3) && !gone {
    +            gone = h
    +                .storage
    +                .get_bulk_operation(ACCOUNT, &op)
    +                .await
    +                .expect("get")
    +                .is_none();
    +            if !gone {
    +                tokio::time::sleep(Duration::from_millis(10)).await;
    +            }
    +        }
    +        gone
    +    };
    +    assert!(purged, "the next pass must purge the re-listed record");
    +    assert!(
    +        h.storage
    +            .bulk_row_outcome(ACCOUNT, &op, 0, 0)
    +            .await
    +            .expect("ledger read")
    +            .is_none(),
    +        "the ledger rows are gone once the purge lands"
    +    );
    +    token.cancel();
    +    tokio::time::timeout(Duration::from_secs(2), handle)
    +        .await
    +        .expect("the retention worker stops at its next tick")
    +        .expect("the retention worker panicked");
    +}
    +
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn a_crash_between_ledger_delete_and_purge_leaves_no_orphaned_ledger_rows() {
    +    let h = Harness::new().await;
    +    let owner = "retention-crash";
    +    let op = start_claim_mark_finish(
    +        &h,
    +        owner,
    +        TABLE,
    +        &h.table_id,
    +        "old",
    +        now_ms() - 40 * 86_400_000,
    +    )
    +    .await;
    +
    +    // Pass one dies between the ledger delete and the purge: the delete
    +    // commits, then the worker task is aborted while it pends.
    +    let notify = Arc::new(tokio::sync::Notify::new());
    +    let mut faulting = FaultingStorage::new(&h.storage);
    +    faulting.pend_after_ledger_delete = Some(Arc::clone(¬ify));
    +    let faulting: Arc = Arc::new(faulting);
    +    let token = CancellationToken::new();
    +    let handle = tokio::spawn(bulk_operation_retention_worker(
    +        Arc::clone(&faulting),
    +        Arc::new(LimitsConfig::default()),
    +        Arc::new(MetricsCollector::new()),
    +        token.clone(),
    +    ));
    +    tokio::time::timeout(Duration::from_secs(3), notify.notified())
    +        .await
    +        .expect("the pass must reach the ledger delete");
    +    handle.abort();
    +    let _ = handle.await;
    +
    +    assert!(
    +        h.storage
    +            .get_bulk_operation(ACCOUNT, &op)
    +            .await
    +            .expect("get")
    +            .is_some(),
    +        "the crash landed before the purge, so the record still exists"
    +    );
    +    assert!(
    +        h.storage
    +            .bulk_row_outcome(ACCOUNT, &op, 0, 0)
    +            .await
    +            .expect("ledger read")
    +            .is_none(),
    +        "the ledger delete committed before the crash"
    +    );
    +
    +    // The next pass re-lists the pair because the record still exists,
    +    // re-runs the idempotent ledger delete, and purges: nothing is
    +    // orphaned by the crash.
    +    let token = CancellationToken::new();
    +    let handle = tokio::spawn(bulk_operation_retention_worker(
    +        Arc::clone(&faulting),
    +        Arc::new(LimitsConfig::default()),
    +        Arc::new(MetricsCollector::new()),
    +        token.clone(),
    +    ));
    +    let purged = {
    +        let start = std::time::Instant::now();
    +        let mut gone = false;
    +        while start.elapsed() < Duration::from_secs(3) && !gone {
    +            gone = h
    +                .storage
    +                .get_bulk_operation(ACCOUNT, &op)
    +                .await
    +                .expect("get")
    +                .is_none();
    +            if !gone {
    +                tokio::time::sleep(Duration::from_millis(10)).await;
    +            }
    +        }
    +        gone
    +    };
    +    assert!(purged, "the next pass must purge the re-listed record");
    +    assert!(
    +        h.storage
    +            .bulk_row_outcome(ACCOUNT, &op, 0, 0)
    +            .await
    +            .expect("ledger read")
    +            .is_none(),
    +        "no ledger row remains after the recovery pass"
    +    );
    +    token.cancel();
    +    tokio::time::timeout(Duration::from_secs(2), handle)
    +        .await
    +        .expect("the retention worker stops at its next tick")
    +        .expect("the retention worker panicked");
    +}
    diff --git a/crates/engine/src/bulk_put/usage.rs b/crates/engine/src/bulk_put/usage.rs
    new file mode 100644
    index 000000000..a934c1103
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/usage.rs
    @@ -0,0 +1,343 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The stored usage JSON of one bulk operation: one vocabulary of member
    +//! names, one accumulator, and one reader.
    +//!
    +//! The runner writes the value with every checkpoint and the handlers read
    +//! it back for Describe. Absent members read as zero by design, so a member
    +//! name spelled differently by a writer and a reader would surface only as
    +//! silent zeros in the wire usage; keeping every spelling in this module is
    +//! what rules that failure out.
    +
    +use std::collections::HashMap;
    +
    +use serde_json::Value;
    +
    +use extenddb_core::types::{
    +    BulkOperationBytes, BulkOperationUsage, BulkOperationVectorBytes, IndexInfo, Item,
    +    TableKeyInfo, item_size_bytes,
    +};
    +
    +use crate::capacity_helpers;
    +
    +/// Member names of the stored usage JSON. The stored value is the wire
    +/// shape of one `BulkOperationUsage` entry, so these equal the serde
    +/// renames on that type.
    +const TABLE_NAME: &str = "TableName";
    +const BULK_PUT_BYTES: &str = "BulkPutBytes";
    +const TABLE: &str = "Table";
    +
    +/// The charge for a row whose condition evaluated false.
    +const CONDITION_FAILED_BYTES: u64 = 1024;
    +const GLOBAL_SECONDARY_INDEXES: &str = "GlobalSecondaryIndexes";
    +const LOCAL_SECONDARY_INDEXES: &str = "LocalSecondaryIndexes";
    +const VECTOR_INDEXES: &str = "VectorIndexes";
    +const BULK_PUT_VECTOR_BYTES: &str = "BulkPutVectorBytes";
    +
    +/// Running usage figures, restored from the record and persisted with
    +/// every checkpoint. All values are bytes.
    +#[derive(Debug, Default, PartialEq, Eq)]
    +pub(crate) struct UsageAccum {
    +    table_bytes: u64,
    +    gsi: HashMap,
    +    lsi: HashMap,
    +    vector: HashMap,
    +}
    +
    +impl UsageAccum {
    +    /// Restore from the stored usage JSON. The initial value is `{}`, which
    +    /// restores to zeroes.
    +    pub(crate) fn restore(value: &Value) -> Self {
    +        Self {
    +            table_bytes: table_member_u64(value),
    +            gsi: bytes_map(value, GLOBAL_SECONDARY_INDEXES, BULK_PUT_BYTES),
    +            lsi: bytes_map(value, LOCAL_SECONDARY_INDEXES, BULK_PUT_BYTES),
    +            vector: bytes_map(value, VECTOR_INDEXES, BULK_PUT_VECTOR_BYTES),
    +        }
    +    }
    +
    +    /// Charge one row that reached the write: the item's logical size in
    +    /// bytes with a one kilobyte floor (measured on the service: 512 bytes
    +    /// charge 1,024; 1,500 charge 1,500; 2,049 charge 2,049).
    +    /// The write-unit rounding `capacity_helpers::write_capacity_units`
    +    /// applies to `ConsumedCapacity` does not apply here.
    +    pub(crate) fn add_base(&mut self, item_bytes: usize) {
    +        self.table_bytes = self.table_bytes.saturating_add(charged_bytes(item_bytes));
    +    }
    +
    +    /// Charge one row whose condition evaluated false: the one kilobyte
    +    /// floor alone, whatever the item's size (measured on the service:
    +    /// 1,000 condition-failed rows of about 382,300 logical bytes
    +    /// charged 1,024,000 in total). No index is charged, since nothing was
    +    /// written.
    +    pub(crate) fn add_condition_failed(&mut self) {
    +        self.table_bytes = self.table_bytes.saturating_add(CONDITION_FAILED_BYTES);
    +    }
    +
    +    /// Charge the secondary and vector indexes for one applied write. Each
    +    /// secondary index is charged the bytes of the item its projection
    +    /// stores, with the same one kilobyte floor as the table (measured on
    +    /// the service: a KEYS_ONLY projection of a 1,500 byte row charges
    +    /// 1,024, an ALL projection of it charges 1,500). An index the row does
    +    /// not project into gets no entry, so an index no row reached is absent
    +    /// from the usage rather than reported as zero.
    +    pub(crate) fn add_index_writes(
    +        &mut self,
    +        old: Option<&Item>,
    +        new: Option<&Item>,
    +        info: &TableKeyInfo,
    +    ) {
    +        let base_keys: Vec<&str> = info
    +            .base_key_schema
    +            .iter()
    +            .map(|k| k.attribute_name.as_str())
    +            .collect();
    +        for index in &info.global_secondary_indexes {
    +            if let Some(bytes) = index_write_bytes(old, new, index, &base_keys) {
    +                let entry = self.gsi.entry(index.index_name.clone()).or_insert(0);
    +                *entry = entry.saturating_add(bytes);
    +            }
    +        }
    +        for index in &info.local_secondary_indexes {
    +            if let Some(bytes) = index_write_bytes(old, new, index, &base_keys) {
    +                let entry = self.lsi.entry(index.index_name.clone()).or_insert(0);
    +                *entry = entry.saturating_add(bytes);
    +            }
    +        }
    +        for (name, bytes) in capacity_helpers::vector_write_charges(old, new, info) {
    +            let entry = self.vector.entry(name).or_insert(0);
    +            *entry = entry.saturating_add(whole_f64_to_u64(bytes));
    +        }
    +    }
    +
    +    /// The top-level figure: the table's bytes plus every secondary index's
    +    /// (measured on the service: 102,400,000 for the
    +    /// table and 27,487,232 for one index report 129,887,232). Vector bytes
    +    /// are a different quantity and stay out of it.
    +    fn total_bytes(&self) -> u64 {
    +        self.gsi
    +            .values()
    +            .chain(self.lsi.values())
    +            .fold(self.table_bytes, |sum, bytes| sum.saturating_add(*bytes))
    +    }
    +
    +    /// The stored and wire shape: one `BulkOperationUsage` object.
    +    ///
    +    /// The runner passes the table ARN; the service carries the bare table
    +    /// name in the usage entry's `TableName` member (measured on the
    +    /// service: every recorded usage entry carries the bare name), so the
    +    /// stored value is the bare name.
    +    pub(crate) fn to_value(&self, table_arn: &str) -> Value {
    +        let usage = BulkOperationUsage {
    +            table_name: bare_table_name(table_arn).to_owned(),
    +            bulk_put_bytes: self.total_bytes(),
    +            table: BulkOperationBytes {
    +                bulk_put_bytes: self.table_bytes,
    +            },
    +            global_secondary_indexes: self
    +                .gsi
    +                .iter()
    +                .map(|(name, bytes)| {
    +                    (
    +                        name.clone(),
    +                        BulkOperationBytes {
    +                            bulk_put_bytes: *bytes,
    +                        },
    +                    )
    +                })
    +                .collect(),
    +            local_secondary_indexes: self
    +                .lsi
    +                .iter()
    +                .map(|(name, bytes)| {
    +                    (
    +                        name.clone(),
    +                        BulkOperationBytes {
    +                            bulk_put_bytes: *bytes,
    +                        },
    +                    )
    +                })
    +                .collect(),
    +            vector_indexes: self
    +                .vector
    +                .iter()
    +                .map(|(name, bytes)| {
    +                    (
    +                        name.clone(),
    +                        BulkOperationVectorBytes {
    +                            bulk_put_vector_bytes: *bytes,
    +                        },
    +                    )
    +                })
    +                .collect(),
    +        };
    +        // A struct of string-keyed maps cannot fail to serialize; Null
    +        // appears only if that invariant broke.
    +        serde_json::to_value(&usage).unwrap_or(Value::Null)
    +    }
    +}
    +
    +/// Rebuild the wire usage entry from the stored JSON. The runner stores the
    +/// full wire shape; absent members read as zero so a terminal record whose
    +/// run wrote nothing still renders a zeroed entry.
    +///
    +/// The service carries the bare table name in `TableName`. A record stored
    +/// before this fix holds the table ARN there, so a stored value that starts
    +/// with `arn:` renders as the bare name after the final `table/`; `table_name`
    +/// is the bare name the caller passes for a record whose `TableName` member is
    +/// absent.
    +pub(crate) fn usage_from_value(value: &Value, table_name: &str) -> BulkOperationUsage {
    +    let table_bytes = table_member_u64(value);
    +    BulkOperationUsage {
    +        table_name: value.get(TABLE_NAME).and_then(Value::as_str).map_or_else(
    +            || table_name.to_owned(),
    +            |stored| bare_table_name(stored).to_owned(),
    +        ),
    +        bulk_put_bytes: member_u64(value, BULK_PUT_BYTES).max(table_bytes),
    +        table: BulkOperationBytes {
    +            bulk_put_bytes: table_bytes,
    +        },
    +        global_secondary_indexes: bytes_map(value, GLOBAL_SECONDARY_INDEXES, BULK_PUT_BYTES)
    +            .into_iter()
    +            .map(|(name, bytes)| {
    +                (
    +                    name,
    +                    BulkOperationBytes {
    +                        bulk_put_bytes: bytes,
    +                    },
    +                )
    +            })
    +            .collect(),
    +        local_secondary_indexes: bytes_map(value, LOCAL_SECONDARY_INDEXES, BULK_PUT_BYTES)
    +            .into_iter()
    +            .map(|(name, bytes)| {
    +                (
    +                    name,
    +                    BulkOperationBytes {
    +                        bulk_put_bytes: bytes,
    +                    },
    +                )
    +            })
    +            .collect(),
    +        vector_indexes: bytes_map(value, VECTOR_INDEXES, BULK_PUT_VECTOR_BYTES)
    +            .into_iter()
    +            .map(|(name, bytes)| {
    +                (
    +                    name,
    +                    BulkOperationVectorBytes {
    +                        bulk_put_vector_bytes: bytes,
    +                    },
    +                )
    +            })
    +            .collect(),
    +    }
    +}
    +
    +/// Read one unsigned member from a usage JSON object; absent is zero.
    +fn member_u64(value: &Value, member: &str) -> u64 {
    +    value.get(member).and_then(Value::as_u64).unwrap_or(0)
    +}
    +
    +/// Read one `{name: {inner: n}}` map from a usage JSON object.
    +fn bytes_map(value: &Value, member: &str, inner: &str) -> HashMap {
    +    value
    +        .get(member)
    +        .and_then(Value::as_object)
    +        .map(|entries| {
    +            entries
    +                .iter()
    +                .map(|(name, entry)| (name.clone(), member_u64(entry, inner)))
    +                .collect()
    +        })
    +        .unwrap_or_default()
    +}
    +
    +/// The table figure of a stored usage value: the `Table` member's bytes.
    +/// A value written before the top-level figure became the total carries
    +/// the table figure at the top level too, so a missing `Table` member
    +/// falls back to it.
    +fn table_member_u64(value: &Value) -> u64 {
    +    value.get(TABLE).map_or_else(
    +        || member_u64(value, BULK_PUT_BYTES),
    +        |table| member_u64(table, BULK_PUT_BYTES),
    +    )
    +}
    +
    +/// The bare table name the service carries in a usage entry's `TableName`
    +/// member. The runner passes the table ARN; the service emits the bare
    +/// name, so a value spelled as an ARN renders as the name after the final
    +/// `:table/`, and a value already bare is returned unchanged.
    +fn bare_table_name(name_or_arn: &str) -> &str {
    +    if name_or_arn.starts_with("arn:") {
    +        name_or_arn
    +            .rsplit_once(":table/")
    +            .map_or(name_or_arn, |(_, name)| name)
    +    } else {
    +        name_or_arn
    +    }
    +}
    +
    +/// The bytes one written item is charged: its logical size with a one
    +/// kilobyte floor.
    +fn charged_bytes(item_bytes: usize) -> u64 {
    +    u64::try_from(item_bytes).unwrap_or(u64::MAX).max(1024)
    +}
    +
    +/// The bytes one index is charged for one write, or `None` when neither
    +/// image projects into the sparse index or the projection is unchanged
    +/// under the same index key. The decision mirrors
    +/// `capacity_helpers::index_write_units`; the charge is the projected
    +/// item's bytes with the one kilobyte floor instead of its write units.
    +fn index_write_bytes(
    +    old: Option<&Item>,
    +    new: Option<&Item>,
    +    index: &IndexInfo,
    +    base_keys: &[&str],
    +) -> Option {
    +    let project = |item: Option<&Item>| {
    +        item.and_then(|item| {
    +            capacity_helpers::sparse_index_projection(
    +                item,
    +                &index.key_schema,
    +                base_keys,
    +                &index.projection,
    +            )
    +        })
    +    };
    +    match (project(old), project(new)) {
    +        (None, None) => None,
    +        (Some(projected), None) | (None, Some(projected)) => {
    +            Some(charged_bytes(item_size_bytes(&projected)))
    +        }
    +        (Some(old_projected), Some(new_projected)) => {
    +            let same_key = old.zip(new).is_some_and(|(old, new)| {
    +                index
    +                    .key_schema
    +                    .iter()
    +                    .all(|key| old.get(&key.attribute_name) == new.get(&key.attribute_name))
    +            });
    +            if same_key && old_projected == new_projected {
    +                return None;
    +            }
    +            let old_bytes = charged_bytes(item_size_bytes(&old_projected));
    +            let new_bytes = charged_bytes(item_size_bytes(&new_projected));
    +            Some(if same_key {
    +                old_bytes.max(new_bytes)
    +            } else {
    +                old_bytes.saturating_add(new_bytes)
    +            })
    +        }
    +    }
    +}
    +
    +/// Convert a `capacity_helpers` figure to an integer. The figures are
    +/// non-negative whole numbers far below 2^53, so the conversion is exact.
    +fn whole_f64_to_u64(value: f64) -> u64 {
    +    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
    +    let converted = value.max(0.0) as u64;
    +    converted
    +}
    +
    +#[cfg(test)]
    +mod tests;
    diff --git a/crates/engine/src/bulk_put/usage/tests.rs b/crates/engine/src/bulk_put/usage/tests.rs
    new file mode 100644
    index 000000000..b906afc88
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/usage/tests.rs
    @@ -0,0 +1,341 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +use super::*;
    +use serde_json::json;
    +
    +use extenddb_core::types::{
    +    AttributeDefinition, AttributeValue, IndexInfo, IndexType, KeySchemaElement, KeyType,
    +    Projection, ProjectionType, ScalarAttributeType, item_size_bytes,
    +};
    +
    +fn key(name: &str) -> KeySchemaElement {
    +    KeySchemaElement {
    +        attribute_name: name.to_owned(),
    +        key_type: KeyType::Hash,
    +    }
    +}
    +
    +fn item(entries: &[(&str, &str)]) -> Item {
    +    let mut item = Item::new();
    +    for (name, value) in entries {
    +        item.insert((*name).to_owned(), AttributeValue::S((*value).to_owned()));
    +    }
    +    item
    +}
    +
    +#[test]
    +fn twenty_five_items_of_70_bytes_report_25600_bytes() {
    +    // The measured case: 25 items of 70 bytes each round to one
    +    // kilobyte apiece.
    +    let mut usage = UsageAccum::default();
    +    for _ in 0..25 {
    +        usage.add_base(70);
    +    }
    +    assert_eq!(usage.table_bytes, 25_600);
    +}
    +
    +#[test]
    +fn base_bytes_are_the_logical_size_with_a_one_kilobyte_floor() {
    +    // Measured on the service: 512 charges 1,024; 1,500
    +    // charges 1,500 (not 2,048); 2,048 charges 2,048; 2,049 charges
    +    // 2,049 (not 3,072).
    +    assert_eq!(charged_bytes(0), 1024);
    +    assert_eq!(charged_bytes(512), 1024);
    +    assert_eq!(charged_bytes(1024), 1024);
    +    assert_eq!(charged_bytes(1500), 1500);
    +    assert_eq!(charged_bytes(2048), 2048);
    +    assert_eq!(charged_bytes(2049), 2049);
    +    let mut usage = UsageAccum::default();
    +    for _ in 0..200 {
    +        usage.add_base(1500);
    +    }
    +    assert_eq!(usage.table_bytes, 300_000, "200 rows of 1,500 bytes");
    +}
    +
    +fn info_with_indexes(gsi_projection: ProjectionType, lsi: bool) -> TableKeyInfo {
    +    let projection = |projection_type| Projection {
    +        projection_type,
    +        non_key_attributes: None,
    +    };
    +    TableKeyInfo {
    +        table_name: "orders".to_owned(),
    +        account_id: "111122223333".to_owned(),
    +        table_id: "t-1".to_owned(),
    +        key_schema: vec![key("pk")],
    +        base_key_schema: vec![key("pk")],
    +        attribute_definitions: vec![AttributeDefinition {
    +            attribute_name: "pk".to_owned(),
    +            attribute_type: ScalarAttributeType::S,
    +        }],
    +        has_lsi: lsi,
    +        global_secondary_indexes: vec![IndexInfo {
    +            index_name: "gsi1".to_owned(),
    +            index_id: "i-1".to_owned(),
    +            index_type: IndexType::Gsi,
    +            key_schema: vec![key("g1")],
    +            projection: projection(gsi_projection),
    +        }],
    +        local_secondary_indexes: if lsi {
    +            vec![IndexInfo {
    +                index_name: "lsi1".to_owned(),
    +                index_id: "i-2".to_owned(),
    +                index_type: IndexType::Lsi,
    +                key_schema: vec![key("pk"), key("l1")],
    +                projection: projection(ProjectionType::All),
    +            }]
    +        } else {
    +            Vec::new()
    +        },
    +        stream_specification: None,
    +        vector_indexes: Vec::new(),
    +    }
    +}
    +
    +fn row_of_1500_bytes() -> Item {
    +    let mut row = item(&[("pk", "f7-0001"), ("g1", "g00001"), ("l1", "l00001")]);
    +    let fixed = item_size_bytes(&row) + "pad".len();
    +    row.insert(
    +        "pad".to_owned(),
    +        AttributeValue::S("p".repeat(1500 - fixed)),
    +    );
    +    assert_eq!(item_size_bytes(&row), 1500);
    +    row
    +}
    +
    +#[test]
    +fn keys_only_index_on_a_large_row_charges_the_floor_and_the_table_its_bytes() {
    +    // 1,500 byte rows, KEYS_ONLY index. Table 1,500 per row,
    +    // the index 1,024 per row, the top-level figure the sum.
    +    let info = info_with_indexes(ProjectionType::KeysOnly, false);
    +    let row = row_of_1500_bytes();
    +    let mut usage = UsageAccum::default();
    +    usage.add_base(item_size_bytes(&row));
    +    usage.add_index_writes(None, Some(&row), &info);
    +    assert_eq!(usage.table_bytes, 1500);
    +    assert_eq!(usage.gsi.get("gsi1"), Some(&1024));
    +    let value = usage.to_value("arn:aws:dynamodb:us-east-1:111122223333:table/orders");
    +    assert_eq!(
    +        value["BulkPutBytes"], 2524,
    +        "top level is table plus indexes"
    +    );
    +    assert_eq!(value["Table"]["BulkPutBytes"], 1500);
    +}
    +
    +#[test]
    +fn all_projection_indexes_on_a_large_row_charge_its_bytes() {
    +    // 1,500 byte rows with an ALL projection GSI and LSI charge
    +    // 1,500 per row on the table and on each index; the top level is
    +    // three times that.
    +    let info = info_with_indexes(ProjectionType::All, true);
    +    let row = row_of_1500_bytes();
    +    let mut usage = UsageAccum::default();
    +    usage.add_base(item_size_bytes(&row));
    +    usage.add_index_writes(None, Some(&row), &info);
    +    assert_eq!(usage.gsi.get("gsi1"), Some(&1500));
    +    assert_eq!(usage.lsi.get("lsi1"), Some(&1500));
    +    let value = usage.to_value("arn:aws:dynamodb:us-east-1:111122223333:table/orders");
    +    assert_eq!(value["BulkPutBytes"], 4500);
    +}
    +
    +#[test]
    +fn an_index_no_row_projects_into_is_absent_from_the_usage() {
    +    // A GSI whose key attribute no row carries has no entry at
    +    // all, so the wire value carries no GlobalSecondaryIndexes member.
    +    let info = info_with_indexes(ProjectionType::All, false);
    +    let row = item(&[("pk", "a")]);
    +    let mut usage = UsageAccum::default();
    +    usage.add_base(item_size_bytes(&row));
    +    usage.add_index_writes(None, Some(&row), &info);
    +    assert!(usage.gsi.is_empty());
    +    let value = usage.to_value("arn:aws:dynamodb:us-east-1:111122223333:table/orders");
    +    assert!(
    +        value
    +            .get("GlobalSecondaryIndexes")
    +            .is_none_or(|m| m.as_object().is_some_and(serde_json::Map::is_empty))
    +    );
    +    assert_eq!(value["BulkPutBytes"], 1024);
    +}
    +
    +#[test]
    +fn index_writes_land_in_the_index_maps_not_the_table_figure() {
    +    let info = TableKeyInfo {
    +        table_name: "orders".to_owned(),
    +        account_id: "111122223333".to_owned(),
    +        table_id: "t-1".to_owned(),
    +        key_schema: vec![key("pk")],
    +        base_key_schema: vec![key("pk")],
    +        attribute_definitions: vec![AttributeDefinition {
    +            attribute_name: "pk".to_owned(),
    +            attribute_type: ScalarAttributeType::S,
    +        }],
    +        has_lsi: false,
    +        global_secondary_indexes: vec![IndexInfo {
    +            index_name: "gsi1".to_owned(),
    +            index_id: "i-1".to_owned(),
    +            index_type: IndexType::Gsi,
    +            key_schema: vec![key("gsi_pk")],
    +            projection: Projection {
    +                projection_type: ProjectionType::KeysOnly,
    +                non_key_attributes: None,
    +            },
    +        }],
    +        local_secondary_indexes: Vec::new(),
    +        stream_specification: None,
    +        vector_indexes: Vec::new(),
    +    };
    +    let written = item(&[("pk", "a"), ("gsi_pk", "b")]);
    +
    +    let mut usage = UsageAccum::default();
    +    usage.add_base(item_size_bytes(&written));
    +    usage.add_index_writes(None, Some(&written), &info);
    +
    +    assert_eq!(usage.table_bytes, 1024, "the base charge alone");
    +    assert_eq!(usage.gsi.get("gsi1"), Some(&1024));
    +    assert!(usage.lsi.is_empty());
    +    assert!(usage.vector.is_empty());
    +
    +    // An item outside the sparse index charges the index nothing.
    +    let outside = item(&[("pk", "c")]);
    +    usage.add_index_writes(None, Some(&outside), &info);
    +    assert_eq!(usage.gsi.get("gsi1"), Some(&1024));
    +}
    +
    +#[test]
    +fn usage_round_trips_through_the_stored_json() {
    +    let mut usage = UsageAccum {
    +        table_bytes: 25_600,
    +        ..UsageAccum::default()
    +    };
    +    usage.gsi.insert("gsi1".to_owned(), 4096);
    +    usage.lsi.insert("lsi1".to_owned(), 2048);
    +    usage.vector.insert("vi1".to_owned(), 8192);
    +
    +    let value = usage.to_value("arn:aws:dynamodb:us-east-1:111122223333:table/orders");
    +    assert_eq!(UsageAccum::restore(&value), usage);
    +}
    +
    +#[test]
    +fn usage_value_carries_the_wire_shape() {
    +    let mut usage = UsageAccum {
    +        table_bytes: 25_600,
    +        ..UsageAccum::default()
    +    };
    +    usage.vector.insert("vi1".to_owned(), 8192);
    +
    +    let arn = "arn:aws:dynamodb:us-east-1:111122223333:table/orders";
    +    let value = usage.to_value(arn);
    +    assert_eq!(
    +        value["TableName"], "orders",
    +        "the service carries the bare table name, not the ARN"
    +    );
    +    assert_eq!(
    +        value["BulkPutBytes"], 25_600,
    +        "no secondary index: the total is the table figure"
    +    );
    +    assert_eq!(value["Table"]["BulkPutBytes"], 25_600);
    +    assert_eq!(
    +        value["VectorIndexes"]["vi1"]["BulkPutVectorBytes"], 8192,
    +        "vector bytes carry their own member name and stay out of the top-level figure"
    +    );
    +}
    +
    +#[test]
    +fn a_new_run_renders_the_bare_table_name() {
    +    // The runner passes the table ARN; the usage entry carries the bare
    +    // name the service emits (measured on the service).
    +    let usage = UsageAccum {
    +        table_bytes: 1024,
    +        ..UsageAccum::default()
    +    };
    +    let arn = "arn:aws:dynamodb:us-east-1:123456789012:table/wt1";
    +    let wire = usage_from_value(&usage.to_value(arn), "wt1");
    +    assert_eq!(wire.table_name, "wt1");
    +}
    +
    +#[test]
    +fn a_stored_record_holding_an_arn_renders_the_bare_name() {
    +    // A record written before this fix stored the ARN in TableName; the
    +    // renderer substitutes the bare name after the final `table/`.
    +    let stored = json!({
    +        "TableName": "arn:aws:dynamodb:us-east-1:123456789012:table/wt1",
    +        "BulkPutBytes": 1024,
    +        "Table": { "BulkPutBytes": 1024 }
    +    });
    +    let wire = usage_from_value(&stored, "wt1");
    +    assert_eq!(wire.table_name, "wt1");
    +}
    +
    +#[test]
    +fn a_no_index_run_has_exactly_table_name_bulk_put_bytes_and_table() {
    +    // A run on a table whose indexes received nothing carries no index
    +    // map (measured on the service): the usage entry is exactly
    +    // TableName, BulkPutBytes and Table.
    +    let usage = UsageAccum {
    +        table_bytes: 1024,
    +        ..UsageAccum::default()
    +    };
    +    let value = usage.to_value("arn:aws:dynamodb:us-east-1:123456789012:table/wt1");
    +    let mut keys: Vec<&str> = value
    +        .as_object()
    +        .expect("usage is an object")
    +        .keys()
    +        .map(String::as_str)
    +        .collect();
    +    keys.sort_unstable();
    +    assert_eq!(keys, vec!["BulkPutBytes", "Table", "TableName"]);
    +}
    +
    +#[test]
    +fn a_stored_record_holding_empty_index_maps_renders_without_them() {
    +    // A record stored with empty index maps renders without those
    +    // members: usage_from_value reads them as empty and the empty maps
    +    // are omitted on serialization.
    +    let stored = json!({
    +        "TableName": "wt1",
    +        "BulkPutBytes": 1024,
    +        "Table": { "BulkPutBytes": 1024 },
    +        "GlobalSecondaryIndexes": {},
    +        "LocalSecondaryIndexes": {},
    +        "VectorIndexes": {}
    +    });
    +    let wire = usage_from_value(&stored, "wt1");
    +    let value = serde_json::to_value(&wire).expect("usage serializes");
    +    let object = value.as_object().expect("usage is an object");
    +    assert!(!object.contains_key("GlobalSecondaryIndexes"));
    +    assert!(!object.contains_key("LocalSecondaryIndexes"));
    +    assert!(!object.contains_key("VectorIndexes"));
    +    assert_eq!(object.len(), 3, "only TableName, BulkPutBytes and Table");
    +}
    +
    +#[test]
    +fn usage_restores_zeroes_from_the_initial_empty_object() {
    +    let restored = UsageAccum::restore(&json!({}));
    +    assert_eq!(restored, UsageAccum::default());
    +}
    +
    +#[test]
    +fn the_reader_and_the_accumulator_agree_on_every_member_name() {
    +    // One value written by the accumulator, read back by the wire
    +    // reader: every figure must land in the same member it left from.
    +    let mut usage = UsageAccum {
    +        table_bytes: 25_600,
    +        ..UsageAccum::default()
    +    };
    +    usage.gsi.insert("gsi1".to_owned(), 4096);
    +    usage.lsi.insert("lsi1".to_owned(), 2048);
    +    usage.vector.insert("vi1".to_owned(), 8192);
    +
    +    let arn = "arn:aws:dynamodb:us-east-1:111122223333:table/orders";
    +    let wire = usage_from_value(&usage.to_value(arn), "orders");
    +    assert_eq!(wire.table_name, "orders");
    +    assert_eq!(
    +        wire.bulk_put_bytes,
    +        25_600 + 4096 + 2048,
    +        "the top level sums the table and every secondary index"
    +    );
    +    assert_eq!(wire.table.bulk_put_bytes, 25_600);
    +    assert_eq!(wire.global_secondary_indexes["gsi1"].bulk_put_bytes, 4096);
    +    assert_eq!(wire.local_secondary_indexes["lsi1"].bulk_put_bytes, 2048);
    +    assert_eq!(wire.vector_indexes["vi1"].bulk_put_vector_bytes, 8192);
    +}
    diff --git a/crates/engine/src/bulk_put/worker/claim.rs b/crates/engine/src/bulk_put/worker/claim.rs
    new file mode 100644
    index 000000000..f3012df1e
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/worker/claim.rs
    @@ -0,0 +1,304 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The claim loop that drives runnable operations through the runner, the
    +//! in-flight slot bookkeeping and its drop guard, and the no-store cancel
    +//! drain that finishes CANCELLING operations.
    +
    +use super::*;
    +
    +/// The two non-terminal statuses the cancel path reads and writes. Kept as
    +/// literals here, the same wire strings the store contract and the runner
    +/// use, so the no-store cancel drain does not reach into the runner.
    +const STATUS_CANCELLING: &str = "CANCELLING";
    +const STATUS_CANCELLED: &str = "CANCELLED";
    +
    +/// The claim sequence for this process. The owner id written by a claim is
    +/// the process prefix plus this counter, one value per claim, so two
    +/// incarnations of one operation never share an owner string even inside
    +/// one process, and every lease-conditional write (the checkpoint, `finish`,
    +/// the lease re-read before a report rename) distinguishes them.
    +static CLAIM_SEQUENCE: AtomicU64 = AtomicU64::new(0);
    +
    +/// Runs in flight in this process, shared between the claim loop and the
    +/// slot guards of the spawned runs: per-account counts for the per-account
    +/// bound, and the operation ids for the claim exclusion.
    +#[derive(Default)]
    +struct InFlightState {
    +    per_account: HashMap,
    +    operations: HashSet,
    +}
    +
    +type InFlight = Arc>;
    +
    +/// Releases one slot of its account and retires its operation id when
    +/// dropped, so a run frees both on success, error, and panic alike.
    +struct SlotGuard {
    +    in_flight: InFlight,
    +    account: String,
    +    operation: String,
    +}
    +
    +impl Drop for SlotGuard {
    +    fn drop(&mut self) {
    +        let Ok(mut state) = self.in_flight.lock() else {
    +            return;
    +        };
    +        match state.per_account.get_mut(&self.account) {
    +            Some(n) if *n > 1 => *n -= 1,
    +            _ => {
    +                state.per_account.remove(&self.account);
    +            }
    +        }
    +        state.operations.remove(&self.operation);
    +    }
    +}
    +
    +/// The claim loop: on every tick, fill the free slots one claim at a time.
    +///
    +/// Claims go one row at a time so the per-account bound holds inside a
    +/// single tick: each claim carries the exclusion lists rebuilt from the
    +/// bookkeeping the previous claim updated, so an account reaching its
    +/// share mid-tick is excluded from the very next claim, and a burst of one
    +/// account's operations cannot take every slot the way a single claim of
    +/// `free` rows could. An empty claim ends the tick.
    +///
    +/// Every claim also excludes the operation ids in flight in this process
    +/// and writes a fresh per-claim owner id (`run_one` receives it): without
    +/// both, a run that outlives its lease is re-claimed by its own process
    +/// one tick after expiry, the two incarnations share every lease guard,
    +/// and their report temp files share a name.
    +#[allow(clippy::too_many_arguments)] // one call site per entry point
    +pub(super) async fn claim_loop(
    +    store: Arc,
    +    concurrency: usize,
    +    per_account: usize,
    +    lease: Duration,
    +    interval: Duration,
    +    owner: String,
    +    metrics: Arc,
    +    token: CancellationToken,
    +    run_one: F,
    +) where
    +    F: Fn(BulkOperationRecord, String) -> Fut,
    +    Fut: Future> + Send + 'static,
    +{
    +    let in_flight: InFlight = Arc::new(Mutex::new(InFlightState::default()));
    +    // The runs this loop spawned. At shutdown the loop waits for them: each
    +    // run sees the same token, checkpoints at its next row and releases its
    +    // lease, so the next process resumes it at once from where it stopped.
    +    let mut runs: Vec> = Vec::new();
    +    while tick(&token, interval).await {
    +        runs.retain(|run| !run.is_finished());
    +        let pass_start = Instant::now();
    +        let mut errored = false;
    +        loop {
    +            let (free, exclude_accounts, exclude_operations) = {
    +                let Ok(state) = in_flight.lock() else {
    +                    tracing::error!(
    +                        "bulk operation claim loop stopping: the in-flight state lock is \
    +                         poisoned"
    +                    );
    +                    metrics.record_worker_error(QuerySource::BulkClaim);
    +                    return;
    +                };
    +                let total: usize = state.per_account.values().sum();
    +                let exclude_accounts: Vec = state
    +                    .per_account
    +                    .iter()
    +                    .filter(|(_, n)| **n >= per_account)
    +                    .map(|(account, _)| account.clone())
    +                    .collect();
    +                let exclude_operations: Vec = state.operations.iter().cloned().collect();
    +                (
    +                    concurrency.saturating_sub(total),
    +                    exclude_accounts,
    +                    exclude_operations,
    +                )
    +            };
    +            if free == 0 {
    +                break;
    +            }
    +            let claim_owner = format!("{owner}-{}", CLAIM_SEQUENCE.fetch_add(1, Ordering::Relaxed));
    +            let claimed = match store
    +                .claim_runnable(
    +                    &claim_owner,
    +                    lease,
    +                    1,
    +                    &exclude_accounts,
    +                    &exclude_operations,
    +                )
    +                .await
    +            {
    +                Ok(rows) => rows,
    +                Err(StorageError::Validation(_)) => {
    +                    tracing::info!(
    +                        "Bulk operations are not supported on this storage backend; the \
    +                         bulk operation worker stops"
    +                    );
    +                    return;
    +                }
    +                Err(error) => {
    +                    tracing::warn!(error = %error, "bulk operation claim failed");
    +                    metrics.record_worker_error(QuerySource::BulkClaim);
    +                    errored = true;
    +                    break;
    +                }
    +            };
    +            let Some(record) = claimed.into_iter().next() else {
    +                break;
    +            };
    +            let account = record.account_id.clone();
    +            let operation = record.operation_id.clone();
    +            if let Ok(mut state) = in_flight.lock() {
    +                *state.per_account.entry(account.clone()).or_insert(0) += 1;
    +                state.operations.insert(operation.clone());
    +            }
    +            let guard = SlotGuard {
    +                in_flight: Arc::clone(&in_flight),
    +                account: account.clone(),
    +                operation: operation.clone(),
    +            };
    +            let fut = run_one(record, claim_owner);
    +            runs.push(tokio::spawn(async move {
    +                let _slot = guard;
    +                if let Err(error) = fut.await {
    +                    // The operation stays non-terminal; the next claim after
    +                    // its lease expires resumes it from the last committed
    +                    // checkpoint.
    +                    tracing::error!(
    +                        account_id = %account,
    +                        operation_id = %operation,
    +                        error = ?error,
    +                        "bulk operation run failed"
    +                    );
    +                }
    +            }));
    +        }
    +        // One completed claim pass: record a worker success unless a claim
    +        // failed this pass (which recorded a worker error above). This is the
    +        // liveness signal the other background workers publish; a stalled or
    +        // crashed claim loop stops advancing WorkerLastSuccess for BulkClaim.
    +        if !errored {
    +            metrics.record_worker_success(QuerySource::BulkClaim, elapsed_us(pass_start));
    +        }
    +    }
    +    // Wait up to `RUN_DRAIN` for the runs, inside the server's worker drain.
    +    // A run still running at the deadline is aborted, and the loop awaits it,
    +    // so no run outlives the loop. An aborted run keeps its lease until the
    +    // lease lapses, and a later claim resumes it from its last checkpoint.
    +    let deadline = tokio::time::Instant::now() + RUN_DRAIN;
    +    let mut aborted = Vec::new();
    +    for mut run in runs {
    +        if tokio::time::timeout_at(deadline, &mut run).await.is_err() {
    +            run.abort();
    +            aborted.push(run);
    +        }
    +    }
    +    for run in aborted {
    +        let _ = run.await;
    +    }
    +}
    +
    +/// How long the claim loop waits at shutdown for its runs to checkpoint and
    +/// release their leases, inside the server's five-second worker drain. A
    +/// run at a row or waiting on its source stops within milliseconds. The
    +/// loop aborts a run still running when this passes, for example one inside
    +/// a store call; the run keeps its lease until the lease lapses, a later
    +/// claim resumes it from its last checkpoint, and its rows stay recorded in
    +/// the ledger.
    +pub(super) const RUN_DRAIN: Duration = Duration::from_secs(1);
    +
    +/// Drive CANCELLING operations to CANCELLED when no `[bulk]` store is
    +/// configured, until `token` is cancelled.
    +///
    +/// A cancel reads no input, so it needs no store. Each tick claims runnable
    +/// rows one at a time and finishes the CANCELLING ones through
    +/// [`finish_cancelled`]. A row that is `IN_PROGRESS` with no cancel is
    +/// leased by the claim and then left non-terminal, because it cannot run
    +/// without a store; its lease lapses and, if it is cancelled later, a
    +/// subsequent claim finishes it. The claim is lease conditional and the
    +/// finish binds on the per-claim owner, so once a store is configured and
    +/// the process restarts, the normal worker resumes every operation this
    +/// loop did not finish from its last checkpoint.
    +pub(super) async fn cancel_only_loop(
    +    storage: Arc,
    +    owner: String,
    +    lease: Duration,
    +    interval: Duration,
    +    token: CancellationToken,
    +) {
    +    while tick(&token, interval).await {
    +        loop {
    +            let claim_owner = format!("{owner}-{}", CLAIM_SEQUENCE.fetch_add(1, Ordering::Relaxed));
    +            let claimed = match storage
    +                .claim_runnable(&claim_owner, lease, 1, &[], &[])
    +                .await
    +            {
    +                Ok(rows) => rows,
    +                Err(StorageError::Validation(_)) => {
    +                    tracing::info!(
    +                        "Bulk operations are not supported on this storage backend; the \
    +                         bulk operation worker stops"
    +                    );
    +                    return;
    +                }
    +                Err(error) => {
    +                    tracing::warn!(error = %error, "bulk operation cancel claim failed");
    +                    break;
    +                }
    +            };
    +            let Some(record) = claimed.into_iter().next() else {
    +                break;
    +            };
    +            if record.status == STATUS_CANCELLING
    +                && let Err(error) = finish_cancelled(storage.as_ref(), &claim_owner, &record).await
    +            {
    +                tracing::warn!(
    +                    account_id = %record.account_id,
    +                    operation_id = %record.operation_id,
    +                    error = %error,
    +                    "bulk operation cancel finish failed"
    +                );
    +            }
    +        }
    +    }
    +}
    +
    +/// Finish one CANCELLING operation as CANCELLED through the store's terminal
    +/// transition, the shape the runner writes when it drains a cancel: the end
    +/// time is now, the counters and usage are the floors the record already
    +/// carries, and no report location is set because there is no sink to render
    +/// one. On a held transition the operation's ledger rows are deleted, as the
    +/// runner does after a terminal transition.
    +async fn finish_cancelled(
    +    storage: &dyn StorageEngine,
    +    owner: &str,
    +    record: &BulkOperationRecord,
    +) -> Result {
    +    // The store supplies this pair itself when it turns a CANCELLING row
    +    // into CANCELLED; sending it too keeps the record readable on its own.
    +    let outcome = Terminal {
    +        status: STATUS_CANCELLED.to_owned(),
    +        end_time_ms: epoch_millis(),
    +        failure_code: Some(extenddb_storage::CANCELLED_FAILURE_CODE.to_owned()),
    +        failure_message: Some(extenddb_storage::CANCELLED_FAILURE_MESSAGE.to_owned()),
    +        usage: record.usage.clone(),
    +    };
    +    let state = storage
    +        .finish(&record.account_id, &record.operation_id, owner, outcome)
    +        .await?;
    +    if matches!(state, FinishState::Held(_))
    +        && let Err(error) = storage
    +            .delete_bulk_row_outcomes(&record.account_id, &record.operation_id)
    +            .await
    +    {
    +        tracing::warn!(
    +            account_id = %record.account_id,
    +            operation_id = %record.operation_id,
    +            error = %error,
    +            "bulk row outcome ledger delete failed after a no-store cancel"
    +        );
    +    }
    +    Ok(state)
    +}
    diff --git a/crates/engine/src/bulk_put/worker/mod.rs b/crates/engine/src/bulk_put/worker/mod.rs
    new file mode 100644
    index 000000000..c05b10ff3
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/worker/mod.rs
    @@ -0,0 +1,348 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The bulk operation worker and the retention worker.
    +//!
    +//! [`bulk_operation_worker`] claims runnable operations from storage on a
    +//! fixed one second tick and drives each through [`runner::run`] in its own
    +//! task, bounded by `bulk_operation_worker_concurrency` slots overall and
    +//! `bulk_operation_worker_per_account` slots per account. The loops live in
    +//! this crate because the runner they drive is private to it; the server
    +//! spawns both functions next to its own workers with the same
    +//! cancellation-token pattern, and both stop at their next tick after the
    +//! token fires.
    +//!
    +//! [`bulk_operation_retention_worker`] purges terminal operation records
    +//! older than `bulk_operation_retention_days` once at startup and then
    +//! daily. Each pass lists the purgeable operations, deletes each one's
    +//! row-outcome ledger rows (which live in the data database, out of the
    +//! catalog purge's reach), and purges only the operations whose ledger
    +//! delete succeeded, so ledger rows die while their operation record
    +//! still exists and a crash at any point re-lists the same operations on
    +//! the next pass.
    +//!
    +//! A backend that answers `StorageError::Validation` from `claim_runnable`
    +//! or `list_purgeable_before` does not carry bulk operations; each loop
    +//! logs that once at info and stops rather than logging every tick.
    +
    +use std::collections::{HashMap, HashSet};
    +use std::future::Future;
    +use std::sync::atomic::{AtomicU64, Ordering};
    +use std::sync::{Arc, Mutex};
    +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
    +
    +use extenddb_core::limits::LimitsConfig;
    +use extenddb_core::metrics::{MetricsCollector, QuerySource};
    +use extenddb_storage::error::StorageError;
    +use extenddb_storage::{
    +    BulkOperationRecord, BulkOperationStore, CancellationToken, FinishState, StorageEngine,
    +    Terminal, sleep_or_shutdown as tick,
    +};
    +
    +use crate::bulk_put::authorize::BulkAuthorizer;
    +use crate::bulk_put::pacer::WritePacer;
    +use crate::bulk_put::runner::{self, RunnerConfig, RunnerDeps, RunnerError};
    +use crate::bulk_put::source::{BulkSink, BulkSource};
    +use crate::bulk_put::store::BulkStore;
    +
    +mod claim;
    +mod retention;
    +
    +#[cfg(test)]
    +mod tests;
    +
    +use claim::{cancel_only_loop, claim_loop};
    +
    +pub use retention::bulk_operation_retention_worker;
    +
    +/// The claim tick. Fixed rather than configured: the lease length is the
    +/// tunable that matters, and a saturated worker is paced by its slots, not
    +/// by the tick.
    +const CLAIM_INTERVAL: Duration = Duration::from_secs(1);
    +
    +/// Milliseconds since the Unix epoch, saturating into `i64`. The terminal
    +/// transition records this as the operation's end time.
    +fn epoch_millis() -> i64 {
    +    i64::try_from(
    +        SystemTime::now()
    +            .duration_since(UNIX_EPOCH)
    +            .unwrap_or_default()
    +            .as_millis(),
    +    )
    +    .unwrap_or(i64::MAX)
    +}
    +
    +/// Microseconds elapsed since `start`, for the worker cycle latency metric.
    +#[allow(clippy::cast_precision_loss)] // a cycle's microseconds fit f64 exactly
    +fn elapsed_us(start: Instant) -> f64 {
    +    start.elapsed().as_micros() as f64
    +}
    +
    +/// Worker tuning, read once from [`LimitsConfig`] at spawn.
    +pub(crate) struct BulkWorkerConfig {
    +    /// Operations running at once in this process.
    +    pub concurrency: usize,
    +    /// Of those, at most this many per account.
    +    pub per_account: usize,
    +    /// Lease length passed to every claim and checkpoint.
    +    pub lease: Duration,
    +    /// Rows per checkpoint.
    +    pub batch_size: usize,
    +    /// Rows per storage transaction.
    +    pub write_batch_rows: usize,
    +    /// Longest accepted input line in bytes.
    +    pub max_line_bytes: usize,
    +    /// Physical lines one operation may consume.
    +    pub max_item_count: u64,
    +    /// Report records kept per operation.
    +    pub max_report_records: u64,
    +    /// Serialized bytes of report records kept per operation.
    +    pub max_report_bytes: u64,
    +    /// Process-wide write ceiling in write request units per second; `0`
    +    /// means unbounded.
    +    pub write_units_per_second: u64,
    +}
    +
    +/// `value`, floored at 1, with a warn log naming the key when the floor
    +/// applies. The keys' documented meanings assume positive values: a zero
    +/// lease makes every claim instantly expired, zero concurrency or batch
    +/// size admits no work or never reaches a checkpoint boundary, and a zero
    +/// per-account share still admits one run, which the figure then misstates.
    +fn floored(value: T, key: &str) -> T
    +where
    +    T: Copy + Ord + From + std::fmt::Display,
    +{
    +    let one = T::from(1u8);
    +    if value >= one {
    +        return value;
    +    }
    +    tracing::warn!(key, configured = %value, "bulk worker setting floored at 1");
    +    one
    +}
    +
    +/// Upper bound for `bulk_put_batch_size`. The byte-bounded report flush
    +/// (`PENDING_FLUSH_BYTES`) already caps a runner's heap, so this bound is
    +/// about checkpoint cadence rather than memory: a batch far larger than
    +/// this only widens the window of rows a crash re-does, with no throughput
    +/// gain (the per-row cost is a storage round trip, not the checkpoint). A
    +/// value above the cap is clamped with a warn naming the key.
    +const MAX_BULK_PUT_BATCH_SIZE: usize = 10_000;
    +
    +/// Upper bound for `bulk_put_write_batch_rows`. A write batch holds its
    +/// row locks, and on SQLite the process write lock, for one transaction;
    +/// an online write to a key inside the batch waits for the batch's commit.
    +/// The cap bounds that wait and the staged items' memory (at most this
    +/// many items of at most the item size limit). A value above it is clamped
    +/// with a warn naming the key.
    +const MAX_BULK_PUT_WRITE_BATCH_ROWS: usize = 100;
    +
    +/// `value` clamped to `max`, with a warn log naming the key when the clamp
    +/// applies.
    +fn capped(value: usize, max: usize, key: &str) -> usize {
    +    if value <= max {
    +        return value;
    +    }
    +    tracing::warn!(key, configured = value, max, "bulk worker setting clamped");
    +    max
    +}
    +
    +impl BulkWorkerConfig {
    +    /// Read the worker tuning out of the limits configuration.
    +    /// `bulk_operation_lease_seconds`, `bulk_operation_worker_concurrency`,
    +    /// `bulk_operation_worker_per_account`, and `bulk_put_batch_size` are
    +    /// floored at 1.
    +    #[must_use]
    +    pub(crate) fn from_limits(limits: &LimitsConfig) -> Self {
    +        let batch_size = capped(
    +            floored(limits.bulk_put_batch_size, "bulk_put_batch_size"),
    +            MAX_BULK_PUT_BATCH_SIZE,
    +            "bulk_put_batch_size",
    +        );
    +        Self {
    +            concurrency: floored(
    +                limits.bulk_operation_worker_concurrency,
    +                "bulk_operation_worker_concurrency",
    +            ),
    +            per_account: floored(
    +                limits.bulk_operation_worker_per_account,
    +                "bulk_operation_worker_per_account",
    +            ),
    +            lease: Duration::from_secs(floored(
    +                limits.bulk_operation_lease_seconds,
    +                "bulk_operation_lease_seconds",
    +            )),
    +            batch_size,
    +            // The write batch is flushed at every checkpoint, so it can never
    +            // hold more rows than the checkpoint batch: a value above
    +            // `bulk_put_batch_size` would silently behave as that value. The
    +            // clamp says so at startup, naming both keys.
    +            write_batch_rows: capped(
    +                capped(
    +                    floored(
    +                        limits.bulk_put_write_batch_rows,
    +                        "bulk_put_write_batch_rows",
    +                    ),
    +                    MAX_BULK_PUT_WRITE_BATCH_ROWS,
    +                    "bulk_put_write_batch_rows",
    +                ),
    +                batch_size,
    +                "bulk_put_write_batch_rows (bounded by bulk_put_batch_size)",
    +            ),
    +            max_line_bytes: limits.max_bulk_line_bytes,
    +            max_item_count: limits.max_bulk_item_count,
    +            max_report_records: limits.max_bulk_report_records,
    +            max_report_bytes: limits.max_bulk_report_bytes,
    +            write_units_per_second: limits.bulk_put_write_units_per_second,
    +        }
    +    }
    +}
    +
    +/// The lease owner prefix for this process: host name, pid, and a random
    +/// suffix, the same shape the vector index maintenance owner label uses.
    +/// The suffix keeps a recycled pid on the same host from colliding with
    +/// the leases its predecessor left behind. Each claim appends a sequence
    +/// number to this prefix, so no two claims ever share an owner string.
    +fn process_owner_id() -> String {
    +    let host = std::env::var("HOSTNAME")
    +        .ok()
    +        .filter(|h| !h.trim().is_empty())
    +        .or_else(|| {
    +            std::fs::read_to_string("/etc/hostname")
    +                .ok()
    +                .map(|h| h.trim().to_owned())
    +                .filter(|h| !h.is_empty())
    +        })
    +        .unwrap_or_else(|| "unknown".to_owned());
    +    format!(
    +        "{host}-{}-{:08x}",
    +        std::process::id(),
    +        rand::random::()
    +    )
    +}
    +
    +/// Claim and run bulk operations until `token` is cancelled.
    +///
    +/// With no `[bulk]` store configured the worker idles: it keeps ticking
    +/// so it stops on the token like every other worker, but claims nothing,
    +/// and says so once at info. When the token fires, the worker waits one
    +/// second for its in-flight runs to checkpoint and release their leases,
    +/// then aborts each run still running and awaits it, so no run outlives
    +/// the worker. An aborted run keeps its lease until the lease lapses;
    +/// every checkpoint is transactional and lease-conditional, so a later
    +/// claim resumes the operation from its last committed checkpoint.
    +///
    +/// `authorizer` evaluates the S3 actions each run performs as its
    +/// operation's caller; the server passes `None` in developer mode, where no
    +/// such check runs.
    +#[allow(clippy::too_many_arguments)] // the worker's dependencies, passed once at startup
    +pub async fn bulk_operation_worker(
    +    storage: Arc,
    +    limits: Arc,
    +    region: Arc,
    +    bulk: Option>,
    +    authorizer: Option>,
    +    metrics: Arc,
    +    token: CancellationToken,
    +) {
    +    bulk_operation_worker_with_interval(
    +        storage,
    +        limits,
    +        region,
    +        bulk,
    +        authorizer,
    +        metrics,
    +        token,
    +        CLAIM_INTERVAL,
    +    )
    +    .await;
    +}
    +
    +#[allow(clippy::too_many_arguments)] // as `bulk_operation_worker`, plus the claim interval
    +pub(crate) async fn bulk_operation_worker_with_interval(
    +    storage: Arc,
    +    limits: Arc,
    +    region: Arc,
    +    bulk: Option>,
    +    authorizer: Option>,
    +    metrics: Arc,
    +    token: CancellationToken,
    +    interval: Duration,
    +) {
    +    let Some(bulk) = bulk else {
    +        // No [bulk] store: there is no input to read, so no IN_PROGRESS
    +        // operation can make progress. A cancel reads no input, so the
    +        // worker still drains CANCELLING rows to CANCELLED through the
    +        // store's terminal transition. Without it a customer's cancel after
    +        // an operator removes the section sits CANCELLING forever, holds its
    +        // table's one-in-flight slot, and never ages into retention. The
    +        // startup warning naming the stranded count is logged by the server
    +        // (serve.rs), which can enumerate accounts; the worker drives the
    +        // cancellations.
    +        tracing::info!(
    +            "Bulk operation worker: no [bulk] store configured, so only cancellations run; \
    +             other operations do not progress until a store is configured"
    +        );
    +        let owner = process_owner_id();
    +        let lease = Duration::from_secs(floored(
    +            limits.bulk_operation_lease_seconds,
    +            "bulk_operation_lease_seconds",
    +        ));
    +        cancel_only_loop(storage, owner, lease, interval, token).await;
    +        return;
    +    };
    +    let config = BulkWorkerConfig::from_limits(&limits);
    +    let owner = process_owner_id();
    +    let source: Arc = Arc::clone(&bulk.source);
    +    let sink: Arc = Arc::clone(&bulk.sink);
    +    let store_kind = crate::bulk_put::run_context::store_kind(&bulk);
    +    let store: Arc = storage.clone();
    +    // One pacer for the process: every runner takes its write units from
    +    // the same bucket, so the ceiling holds across concurrent operations.
    +    let pacer = Arc::new(WritePacer::new(config.write_units_per_second));
    +    if pacer.is_bounded() {
    +        tracing::info!(
    +            write_units_per_second = config.write_units_per_second,
    +            "bulk put writes paced per process"
    +        );
    +    }
    +    let shutdown = token.clone();
    +    claim_loop(
    +        store,
    +        config.concurrency,
    +        config.per_account,
    +        config.lease,
    +        interval,
    +        owner,
    +        Arc::clone(&metrics),
    +        token,
    +        // One `RunnerDeps` per run, because the owner is per claim: the
    +        // runner's checkpoints, `finish`, and the lease re-read before a
    +        // report rename must carry the owner string the claim wrote.
    +        move |record, claim_owner| {
    +            let deps = RunnerDeps {
    +                storage: Arc::clone(&storage),
    +                source: Arc::clone(&source),
    +                sink: Arc::clone(&sink),
    +                limits: Arc::clone(&limits),
    +                region: Arc::clone(®ion),
    +                owner: claim_owner,
    +                config: RunnerConfig {
    +                    batch_size: config.batch_size,
    +                    write_batch_rows: config.write_batch_rows,
    +                    max_line_bytes: config.max_line_bytes,
    +                    max_item_count: config.max_item_count,
    +                    max_report_records: config.max_report_records,
    +                    max_report_bytes: config.max_report_bytes,
    +                    lease: config.lease,
    +                },
    +                metrics: Arc::clone(&metrics),
    +                pacer: Arc::clone(&pacer),
    +                store_kind,
    +                authorizer: authorizer.clone(),
    +                shutdown: shutdown.clone(),
    +            };
    +            async move { runner::run(&deps, record).await }
    +        },
    +    )
    +    .await;
    +}
    diff --git a/crates/engine/src/bulk_put/worker/retention.rs b/crates/engine/src/bulk_put/worker/retention.rs
    new file mode 100644
    index 000000000..10dbc6478
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/worker/retention.rs
    @@ -0,0 +1,124 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The retention worker that purges terminal operation records past the
    +//! retention window, deleting each operation's ledger rows before its
    +//! record.
    +
    +use super::*;
    +
    +/// The retention tick.
    +const RETENTION_INTERVAL: Duration = Duration::from_secs(86_400);
    +
    +/// Rows one retention pass lists and purges per chunk. Each chunk is its
    +/// own purge transaction, so the writer is released between chunks and a
    +/// large backlog drains over successive bounded transactions rather than
    +/// one unbounded one.
    +const RETENTION_CHUNK: usize = 1_000;
    +
    +/// Purge terminal bulk operation records past the retention window, once
    +/// at startup and then daily, deleting each operation's ledger rows before
    +/// its record.
    +pub async fn bulk_operation_retention_worker(
    +    storage: Arc,
    +    limits: Arc,
    +    metrics: Arc,
    +    token: CancellationToken,
    +) {
    +    // Floored at 1: a zero window makes the cutoff `now`, so the first pass
    +    // deletes every terminal record, including operations that finished
    +    // moments earlier. One day is the smallest window that keeps a record
    +    // past its own run. The floor is applied here, where the value is read,
    +    // with the warn the other bulk worker settings use.
    +    let retention_days = floored(
    +        limits.bulk_operation_retention_days,
    +        "bulk_operation_retention_days",
    +    );
    +    loop {
    +        if !purge_once(storage.as_ref(), retention_days, metrics.as_ref(), &token).await {
    +            return;
    +        }
    +        if !tick(&token, RETENTION_INTERVAL).await {
    +            return;
    +        }
    +    }
    +}
    +
    +/// One retention pass. `false` stops the loop: the backend answered
    +/// `Validation`, meaning it does not carry bulk operations.
    +async fn purge_once(
    +    storage: &dyn StorageEngine,
    +    retention_days: u32,
    +    metrics: &MetricsCollector,
    +    token: &CancellationToken,
    +) -> bool {
    +    let pass_start = Instant::now();
    +    let cutoff =
    +        SystemTime::now() - Duration::from_secs(u64::from(retention_days).saturating_mul(86_400));
    +    // Drain in bounded chunks. Each chunk is a separately committed
    +    // purge transaction, so the writer (the SQLite engine write lock, a
    +    // PostgreSQL transaction) is released between chunks: a large backlog
    +    // after downtime or a shortened retention window cannot hold the
    +    // writer for the whole backlog in one transaction. A full pass drains
    +    // every eligible row over successive chunks; a chunk shorter than the
    +    // limit means the backlog is drained and the pass ends.
    +    loop {
    +        let pairs = match storage.list_purgeable_before(cutoff, RETENTION_CHUNK).await {
    +            Ok(pairs) => pairs,
    +            Err(StorageError::Validation(_)) => {
    +                tracing::info!(
    +                    "Bulk operations are not supported on this storage backend; the bulk \
    +                     operation retention worker stops"
    +                );
    +                return false;
    +            }
    +            Err(error) => {
    +                tracing::warn!(error = %error, "bulk operation retention list failed");
    +                metrics.record_worker_error(QuerySource::BulkRetention);
    +                return true;
    +            }
    +        };
    +        if pairs.is_empty() {
    +            metrics.record_worker_success(QuerySource::BulkRetention, elapsed_us(pass_start));
    +            return true;
    +        }
    +        let chunk_len = pairs.len();
    +        // Ledger rows die while their operation record still exists, never
    +        // after: the ledger lives in the data database, out of the catalog
    +        // purge's reach, and once the record is gone its rows could never
    +        // be addressed again. A pair is purged only once its ledger delete
    +        // succeeded; a failed delete keeps the record, so the next pass
    +        // lists the pair again, and a crash at any point between the two
    +        // steps does the same.
    +        let mut purgeable = Vec::with_capacity(chunk_len);
    +        for (account, operation) in pairs {
    +            match storage.delete_bulk_row_outcomes(&account, &operation).await {
    +                Ok(_deleted) => purgeable.push((account, operation)),
    +                Err(error) => tracing::warn!(
    +                    account_id = %account,
    +                    operation_id = %operation,
    +                    error = %error,
    +                    "bulk row outcome ledger delete failed during retention; the record is \
    +                     kept for the next pass"
    +                ),
    +            }
    +        }
    +        let purged = purgeable.len();
    +        if let Err(error) = storage.purge_operations(&purgeable).await {
    +            tracing::warn!(error = %error, "bulk operation retention purge failed");
    +            metrics.record_worker_error(QuerySource::BulkRetention);
    +            return true;
    +        }
    +        tracing::info!(purged, "bulk operation retention purge");
    +        // A short chunk means the backlog is drained. A full chunk means
    +        // more remains: continue draining this pass, but stop promptly if
    +        // the server is shutting down rather than hold up a drain.
    +        if chunk_len < RETENTION_CHUNK {
    +            metrics.record_worker_success(QuerySource::BulkRetention, elapsed_us(pass_start));
    +            return true;
    +        }
    +        if token.is_cancelled() {
    +            return false;
    +        }
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/worker/tests/cancel.rs b/crates/engine/src/bulk_put/worker/tests/cancel.rs
    new file mode 100644
    index 000000000..7d01d4bdf
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/worker/tests/cancel.rs
    @@ -0,0 +1,117 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Test for the no-store cancel drain: a CANCELLING operation reaches
    +//! CANCELLED with no bulk store configured while an uncancelled run stays
    +//! non-terminal.
    +
    +use super::super::bulk_operation_worker_with_interval;
    +use super::*;
    +
    +/// With no `[bulk]` store the worker finishes a CANCELLING operation as
    +/// CANCELLED within a bounded time, and leaves an `IN_PROGRESS`
    +/// operation that was never cancelled non-terminal. This is the off
    +/// switch path: an operator removes the section, a customer's cancel
    +/// still reaches a terminal state, and an operation that is running
    +/// with no cancel does not.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn with_no_store_the_worker_finishes_a_cancel_and_leaves_an_in_progress_row() {
    +    let storage = engine().await;
    +    let cancel_table = create_table(&storage, ACCOUNT_A, "cancel_me").await;
    +    let op_cancel = start_op(
    +        &storage,
    +        ACCOUNT_A,
    +        "cancel_me",
    +        &cancel_table,
    +        1000,
    +        Vec::new(),
    +    )
    +    .await;
    +    let live_table = create_table(&storage, ACCOUNT_A, "still_running").await;
    +    let op_live = start_op(
    +        &storage,
    +        ACCOUNT_A,
    +        "still_running",
    +        &live_table,
    +        2000,
    +        Vec::new(),
    +    )
    +    .await;
    +
    +    // Move the first to CANCELLING, the way a customer's
    +    // CancelBulkOperation does.
    +    let outcome = storage
    +        .request_cancel(ACCOUNT_A, &op_cancel)
    +        .await
    +        .expect("request cancel");
    +    assert!(
    +        matches!(outcome, CancelOutcome::Cancelling),
    +        "the cancel must put the row in CANCELLING"
    +    );
    +
    +    let tick = Duration::from_millis(100);
    +    let token = CancellationToken::new();
    +    let handle = tokio::spawn(bulk_operation_worker_with_interval(
    +        Arc::clone(&storage),
    +        Arc::new(LimitsConfig::default()),
    +        Arc::from(REGION),
    +        None, // no [bulk] store configured
    +        None,
    +        Arc::new(MetricsCollector::new()),
    +        token.clone(),
    +        tick,
    +    ));
    +
    +    let cancelled = wait_until(Duration::from_secs(3), || {
    +        let storage = Arc::clone(&storage);
    +        let op = op_cancel.clone();
    +        async move {
    +            storage
    +                .get_bulk_operation(ACCOUNT_A, &op)
    +                .await
    +                .expect("get")
    +                .is_some_and(|r| r.status == "CANCELLED")
    +        }
    +    })
    +    .await;
    +    assert!(
    +        cancelled,
    +        "the CANCELLING operation must reach CANCELLED with no store configured"
    +    );
    +
    +    let cancelled_record = storage
    +        .get_bulk_operation(ACCOUNT_A, &op_cancel)
    +        .await
    +        .expect("get")
    +        .expect("record");
    +    assert_eq!(cancelled_record.status, "CANCELLED");
    +    assert!(
    +        cancelled_record.end_time_ms.is_some(),
    +        "the cancelled record carries an end time"
    +    );
    +    assert_eq!(
    +        cancelled_record.failure_code.as_deref(),
    +        Some(extenddb_storage::CANCELLED_FAILURE_CODE),
    +        "a cancelled record carries the measured code"
    +    );
    +    assert_eq!(
    +        cancelled_record.failure_message.as_deref(),
    +        Some(extenddb_storage::CANCELLED_FAILURE_MESSAGE)
    +    );
    +
    +    let live = storage
    +        .get_bulk_operation(ACCOUNT_A, &op_live)
    +        .await
    +        .expect("get")
    +        .expect("record");
    +    assert_eq!(
    +        live.status, "IN_PROGRESS",
    +        "an operation with no cancel stays non-terminal with no store"
    +    );
    +
    +    token.cancel();
    +    tokio::time::timeout(Duration::from_secs(2), handle)
    +        .await
    +        .expect("worker stops at its next tick")
    +        .expect("worker panicked");
    +}
    diff --git a/crates/engine/src/bulk_put/worker/tests/claim.rs b/crates/engine/src/bulk_put/worker/tests/claim.rs
    new file mode 100644
    index 000000000..f2a0e8f71
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/worker/tests/claim.rs
    @@ -0,0 +1,536 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Tests for the claim loop: the concurrency and per-account bounds, the
    +//! self-process re-claim exclusion, slot release on error, the distinct
    +//! owner id, the claim liveness and error metrics, and the abort of a run
    +//! still running at the drain deadline.
    +
    +use std::sync::atomic::AtomicBool;
    +
    +use super::super::bulk_operation_worker_with_interval;
    +use super::super::claim::{RUN_DRAIN, claim_loop};
    +use super::*;
    +use crate::bulk_put::store::BulkStore;
    +
    +/// The public worker path end to end: a record inserted through
    +/// `start_bulk_operation` is claimed and run to a terminal state within
    +/// three ticks of the worker's interval.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn a_started_operation_is_claimed_and_run_to_terminal_within_three_ticks() {
    +    let storage = engine().await;
    +    let store = Root::new();
    +    let table_id = create_table(&storage, ACCOUNT_A, "orders").await;
    +    let contents = b"{\"Item\": {\"pk\": {\"S\": \"k0\"}}}\n\
    +                     {\"Item\": {\"pk\": {\"S\": \"k1\"}}}\n\
    +                     {\"Item\": {\"pk\": {\"S\": \"k2\"}}}\n";
    +    store.file(ACCOUNT_A, IN_BUCKET, "data.json", contents);
    +    let files = vec![InputFile {
    +        path: "data.json".to_owned(),
    +        size: i64::try_from(contents.len()).unwrap(),
    +        rows: None,
    +        etag: None,
    +        abandon: None,
    +    }];
    +    let op = start_op(&storage, ACCOUNT_A, "orders", &table_id, now_ms(), files).await;
    +
    +    let tick = Duration::from_millis(250);
    +    let token = CancellationToken::new();
    +    let handle = tokio::spawn(bulk_operation_worker_with_interval(
    +        Arc::clone(&storage),
    +        Arc::new(LimitsConfig::default()),
    +        Arc::from(REGION),
    +        Some(Arc::new(BulkStore::filesystem(store.root()))),
    +        None,
    +        Arc::new(MetricsCollector::new()),
    +        token.clone(),
    +        tick,
    +    ));
    +
    +    let done = wait_until(3 * tick, || {
    +        let storage = Arc::clone(&storage);
    +        let op = op.clone();
    +        async move {
    +            storage
    +                .get_bulk_operation(ACCOUNT_A, &op)
    +                .await
    +                .expect("get")
    +                .is_some_and(|r| r.status != "IN_PROGRESS" && r.status != "CANCELLING")
    +        }
    +    })
    +    .await;
    +    assert!(
    +        done,
    +        "the operation must reach a terminal state within three ticks"
    +    );
    +
    +    let record = storage
    +        .get_bulk_operation(ACCOUNT_A, &op)
    +        .await
    +        .expect("get")
    +        .expect("record");
    +    assert_eq!(record.status, "COMPLETED");
    +    assert_eq!(record.processed_item_count, 3);
    +    token.cancel();
    +    tokio::time::timeout(Duration::from_secs(2), handle)
    +        .await
    +        .expect("worker stops at its next tick")
    +        .expect("worker panicked");
    +}
    +
    +/// With three runnable operations and two slots, only two runs start
    +/// while both are pended; the third starts once a slot frees.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn the_concurrency_bound_holds_with_more_runnable_records_than_slots() {
    +    let storage = engine().await;
    +    for (i, t) in [1000_i64, 2000, 3000].iter().enumerate() {
    +        let name = format!("orders_{i}");
    +        let table_id = create_table(&storage, ACCOUNT_A, &name).await;
    +        start_op(&storage, ACCOUNT_A, &name, &table_id, *t, Vec::new()).await;
    +    }
    +    let probe = RunProbe::new();
    +    let token = CancellationToken::new();
    +    let store: Arc = storage.clone();
    +    let handle = tokio::spawn(claim_loop(
    +        store,
    +        2,
    +        3,
    +        Duration::from_secs(60),
    +        Duration::from_millis(25),
    +        "worker-test".to_owned(),
    +        Arc::new(MetricsCollector::new()),
    +        token.clone(),
    +        pending_run(Arc::clone(&probe)),
    +    ));
    +
    +    let two_started = wait_until(Duration::from_secs(2), || {
    +        let probe = Arc::clone(&probe);
    +        async move { probe.started().len() == 2 }
    +    })
    +    .await;
    +    assert!(two_started, "two runs must start");
    +    // Several more ticks pass; the pended runs hold both slots.
    +    tokio::time::sleep(Duration::from_millis(150)).await;
    +    assert_eq!(
    +        probe.started().len(),
    +        2,
    +        "the third run must not start while both slots are held"
    +    );
    +
    +    probe.release.add_permits(1);
    +    let third_started = wait_until(Duration::from_secs(2), || {
    +        let probe = Arc::clone(&probe);
    +        async move { probe.started().len() == 3 }
    +    })
    +    .await;
    +    assert!(third_started, "the freed slot must admit the third run");
    +
    +    probe.release.add_permits(2);
    +    token.cancel();
    +    tokio::time::timeout(Duration::from_secs(2), handle)
    +        .await
    +        .expect("loop stops at its next tick")
    +        .expect("loop panicked");
    +}
    +
    +/// Account A's four older operations cannot take every slot: once A
    +/// holds its per-account share, the next claim excludes A and takes
    +/// B's row, inside the first tick.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn the_per_account_bound_admits_the_second_account_in_the_first_tick() {
    +    let storage = engine().await;
    +    for i in 0..4_i64 {
    +        let name = format!("orders_a{i}");
    +        let table_id = create_table(&storage, ACCOUNT_A, &name).await;
    +        start_op(&storage, ACCOUNT_A, &name, &table_id, 1000 + i, Vec::new()).await;
    +    }
    +    let table_id = create_table(&storage, ACCOUNT_B, "orders_b").await;
    +    let op_b = start_op(&storage, ACCOUNT_B, "orders_b", &table_id, 5000, Vec::new()).await;
    +
    +    let probe = RunProbe::new();
    +    let token = CancellationToken::new();
    +    let store: Arc = storage.clone();
    +    let handle = tokio::spawn(claim_loop(
    +        store,
    +        4,
    +        2,
    +        Duration::from_secs(60),
    +        Duration::from_millis(50),
    +        "worker-test".to_owned(),
    +        Arc::new(MetricsCollector::new()),
    +        token.clone(),
    +        pending_run(Arc::clone(&probe)),
    +    ));
    +
    +    let filled = wait_until(Duration::from_secs(2), || {
    +        let probe = Arc::clone(&probe);
    +        async move { probe.started().len() == 3 }
    +    })
    +    .await;
    +    assert!(filled, "the first tick must claim three runs");
    +    let started = probe.started();
    +    let from_a = started.iter().filter(|(a, _)| a == ACCOUNT_A).count();
    +    assert_eq!(from_a, 2, "account A stops at its per-account share");
    +    assert!(
    +        started.iter().any(|(_, op)| op == &op_b),
    +        "account B's row is claimed in the first tick"
    +    );
    +    // A has two more runnable rows and a free slot exists, but A is at
    +    // its share: later ticks must not admit a fourth run.
    +    tokio::time::sleep(Duration::from_millis(200)).await;
    +    assert_eq!(probe.started().len(), 3);
    +
    +    probe.release.add_permits(8);
    +    token.cancel();
    +    tokio::time::timeout(Duration::from_secs(2), handle)
    +        .await
    +        .expect("loop stops at its next tick")
    +        .expect("loop panicked");
    +}
    +
    +/// One operation, one process, a run that stalls past its lease: the
    +/// claim loop must not start a second incarnation of an operation it is
    +/// already running. If it did, and the incarnations shared one owner id,
    +/// every lease-conditional write of the stalled incarnation (the
    +/// checkpoint, `finish`, the lease re-read before the report rename)
    +/// would pass the owner predicate as if it still held the lease, and
    +/// both incarnations' report temp files would carry the same name. The
    +/// claim exclusion keeps the second incarnation from starting at all;
    +/// the per-claim owner id keeps the guards discriminating if one ever
    +/// does.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn a_run_that_outlives_its_lease_is_not_claimed_again_by_its_own_process() {
    +    let storage = engine().await;
    +    let table_id = create_table(&storage, ACCOUNT_A, "orders").await;
    +    let op = start_op(&storage, ACCOUNT_A, "orders", &table_id, 1000, Vec::new()).await;
    +    let probe = RunProbe::new();
    +    let token = CancellationToken::new();
    +    let store: Arc = storage.clone();
    +    // The lease is several ticks shorter than the pended run, so the
    +    // row's lease expires while its first run is still in flight.
    +    let handle = tokio::spawn(claim_loop(
    +        store,
    +        4,
    +        2,
    +        Duration::from_millis(100),
    +        Duration::from_millis(25),
    +        "worker-test".to_owned(),
    +        Arc::new(MetricsCollector::new()),
    +        token.clone(),
    +        pending_run(Arc::clone(&probe)),
    +    ));
    +    let one_started = wait_until(Duration::from_secs(2), || {
    +        let probe = Arc::clone(&probe);
    +        async move { !probe.started().is_empty() }
    +    })
    +    .await;
    +    assert!(one_started, "the operation must be claimed once");
    +    // Several full lease lengths pass while the run is pended; a loop
    +    // that treats its own expired lease as another process's would
    +    // claim the row again here.
    +    tokio::time::sleep(Duration::from_millis(600)).await;
    +    let starts = probe.started().iter().filter(|(_, o)| o == &op).count();
    +    assert_eq!(
    +        starts, 1,
    +        "an operation whose run is still in flight in this process must \
    +         not be claimed a second time under the same owner id"
    +    );
    +    probe.release.add_permits(8);
    +    token.cancel();
    +    tokio::time::timeout(Duration::from_secs(2), handle)
    +        .await
    +        .expect("loop stops at its next tick")
    +        .expect("loop panicked");
    +}
    +
    +/// A run that answers `Err` frees its slot: with one slot, the second
    +/// operation is claimed on a later tick.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn a_run_that_returns_err_releases_its_slot() {
    +    let storage = engine().await;
    +    for (i, t) in [1000_i64, 2000].iter().enumerate() {
    +        let name = format!("orders_{i}");
    +        let table_id = create_table(&storage, ACCOUNT_A, &name).await;
    +        start_op(&storage, ACCOUNT_A, &name, &table_id, *t, Vec::new()).await;
    +    }
    +    let started: Arc>> = Arc::new(Mutex::new(Vec::new()));
    +    let run_started = Arc::clone(&started);
    +    let token = CancellationToken::new();
    +    let store: Arc = storage.clone();
    +    let handle = tokio::spawn(claim_loop(
    +        store,
    +        1,
    +        2,
    +        Duration::from_secs(60),
    +        Duration::from_millis(25),
    +        "worker-test".to_owned(),
    +        Arc::new(MetricsCollector::new()),
    +        token.clone(),
    +        move |record: BulkOperationRecord, _owner: String| {
    +            let started = Arc::clone(&run_started);
    +            Box::pin(async move {
    +                started.lock().expect("lock").push(record.operation_id);
    +                Err(RunnerError::Storage(StorageError::Internal(
    +                    "injected run failure".to_owned(),
    +                )))
    +            }) as BoxFuture<'static, Result<(), RunnerError>>
    +        },
    +    ));
    +
    +    let both = wait_until(Duration::from_secs(2), || {
    +        let started = Arc::clone(&started);
    +        async move { started.lock().expect("lock").len() == 2 }
    +    })
    +    .await;
    +    assert!(
    +        both,
    +        "the failed run's slot must admit the second operation"
    +    );
    +    token.cancel();
    +    tokio::time::timeout(Duration::from_secs(2), handle)
    +        .await
    +        .expect("loop stops at its next tick")
    +        .expect("loop panicked");
    +}
    +
    +/// The claim loop records a WorkerLastSuccess point under the BulkClaim
    +/// source on each completed pass, the liveness signal the other workers
    +/// publish.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn the_claim_loop_records_a_worker_success_metric() {
    +    let storage = engine().await;
    +    let table_id = create_table(&storage, ACCOUNT_A, "orders").await;
    +    start_op(&storage, ACCOUNT_A, "orders", &table_id, 1000, Vec::new()).await;
    +    let metrics = Arc::new(MetricsCollector::new());
    +    let token = CancellationToken::new();
    +    let store: Arc = storage.clone();
    +    let handle = tokio::spawn(claim_loop(
    +        store,
    +        2,
    +        2,
    +        Duration::from_secs(60),
    +        Duration::from_millis(25),
    +        "worker-test".to_owned(),
    +        Arc::clone(&metrics),
    +        token.clone(),
    +        |_record: BulkOperationRecord, _owner: String| {
    +            Box::pin(async { Ok(()) }) as BoxFuture<'static, Result<(), RunnerError>>
    +        },
    +    ));
    +    let published = wait_until(Duration::from_secs(2), || {
    +        let metrics = Arc::clone(&metrics);
    +        async move {
    +            worker_metric_present(
    +                &metrics,
    +                extenddb_core::metrics::MetricName::WorkerLastSuccess,
    +                "BulkClaim",
    +            )
    +        }
    +    })
    +    .await;
    +    assert!(
    +        published,
    +        "the claim loop must record WorkerLastSuccess for BulkClaim"
    +    );
    +    token.cancel();
    +    tokio::time::timeout(Duration::from_secs(2), handle)
    +        .await
    +        .expect("loop stops at its next tick")
    +        .expect("loop panicked");
    +}
    +
    +/// A claim failure records a WorkerErrorCount point under the BulkClaim
    +/// source, instead of the loop failing silently.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn the_claim_loop_records_a_worker_error_metric_on_a_claim_failure() {
    +    let store = Arc::new(UnsupportedStore {
    +        claims: AtomicUsize::new(0),
    +        claim_internal: true,
    +    });
    +    let metrics = Arc::new(MetricsCollector::new());
    +    let token = CancellationToken::new();
    +    let handle = tokio::spawn(claim_loop(
    +        Arc::clone(&store) as Arc,
    +        4,
    +        2,
    +        Duration::from_secs(60),
    +        Duration::from_millis(20),
    +        "worker-test".to_owned(),
    +        Arc::clone(&metrics),
    +        token.clone(),
    +        |_record: BulkOperationRecord, _owner: String| {
    +            Box::pin(async { Ok(()) }) as BoxFuture<'static, Result<(), RunnerError>>
    +        },
    +    ));
    +    let published = wait_until(Duration::from_secs(2), || {
    +        let metrics = Arc::clone(&metrics);
    +        async move {
    +            worker_metric_present(
    +                &metrics,
    +                extenddb_core::metrics::MetricName::WorkerErrorCount,
    +                "BulkClaim",
    +            )
    +        }
    +    })
    +    .await;
    +    assert!(
    +        published,
    +        "a claim failure must record WorkerErrorCount for BulkClaim"
    +    );
    +    token.cancel();
    +    tokio::time::timeout(Duration::from_secs(2), handle)
    +        .await
    +        .expect("loop stops at its next tick")
    +        .expect("loop panicked");
    +}
    +
    +/// A backend answering `Validation` from the claim stops the loop at
    +/// the first attempt: the loop returns on its own, without the token,
    +/// and never claims again.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn a_validation_answer_from_claim_stops_the_loop() {
    +    let store = Arc::new(UnsupportedStore {
    +        claims: AtomicUsize::new(0),
    +        claim_internal: false,
    +    });
    +    let token = CancellationToken::new();
    +    let handle = tokio::spawn(claim_loop(
    +        Arc::clone(&store) as Arc,
    +        4,
    +        2,
    +        Duration::from_secs(60),
    +        Duration::from_millis(20),
    +        "worker-test".to_owned(),
    +        Arc::new(MetricsCollector::new()),
    +        token,
    +        |_record: BulkOperationRecord, _owner: String| {
    +            Box::pin(async { Ok(()) }) as BoxFuture<'static, Result<(), RunnerError>>
    +        },
    +    ));
    +    tokio::time::timeout(Duration::from_secs(2), handle)
    +        .await
    +        .expect("the loop must stop on its own, with no cancellation")
    +        .expect("loop panicked");
    +    assert_eq!(
    +        store.claims.load(Ordering::SeqCst),
    +        1,
    +        "the loop must not claim again after the refusal"
    +    );
    +}
    +
    +/// Every claim carries its own owner id: the process prefix plus a
    +/// sequence number, so no two runs ever share the owner string their
    +/// lease-conditional writes bind on.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn each_claim_carries_a_distinct_owner_id_under_the_process_prefix() {
    +    let storage = engine().await;
    +    for (i, t) in [1000_i64, 2000].iter().enumerate() {
    +        let name = format!("orders_{i}");
    +        let table_id = create_table(&storage, ACCOUNT_A, &name).await;
    +        start_op(&storage, ACCOUNT_A, &name, &table_id, *t, Vec::new()).await;
    +    }
    +    let owners: Arc>> = Arc::new(Mutex::new(Vec::new()));
    +    let run_owners = Arc::clone(&owners);
    +    let token = CancellationToken::new();
    +    let store: Arc = storage.clone();
    +    let handle = tokio::spawn(claim_loop(
    +        store,
    +        4,
    +        4,
    +        Duration::from_secs(60),
    +        Duration::from_millis(25),
    +        "worker-test".to_owned(),
    +        Arc::new(MetricsCollector::new()),
    +        token.clone(),
    +        move |_record: BulkOperationRecord, owner: String| {
    +            let owners = Arc::clone(&run_owners);
    +            Box::pin(async move {
    +                owners.lock().expect("lock").push(owner);
    +                Ok(())
    +            }) as BoxFuture<'static, Result<(), RunnerError>>
    +        },
    +    ));
    +    let both = wait_until(Duration::from_secs(2), || {
    +        let owners = Arc::clone(&owners);
    +        async move { owners.lock().expect("lock").len() == 2 }
    +    })
    +    .await;
    +    assert!(both, "both operations must be claimed and run");
    +    let seen = owners.lock().expect("lock").clone();
    +    assert_ne!(
    +        seen[0], seen[1],
    +        "two claims must never share an owner string"
    +    );
    +    for owner in &seen {
    +        assert!(
    +            owner.starts_with("worker-test-"),
    +            "the claim owner must be the process prefix plus a sequence, got {owner}"
    +        );
    +    }
    +    token.cancel();
    +    tokio::time::timeout(Duration::from_secs(2), handle)
    +        .await
    +        .expect("loop stops at its next tick")
    +        .expect("loop panicked");
    +}
    +
    +/// Sets its flag when dropped, so a test can tell that a run's future was
    +/// dropped.
    +struct DropFlag(Arc);
    +
    +impl Drop for DropFlag {
    +    fn drop(&mut self) {
    +        self.0.store(true, Ordering::SeqCst);
    +    }
    +}
    +
    +/// A run that never returns and never reads the token is aborted at the
    +/// drain deadline: the claim loop returns within `RUN_DRAIN` and a margin,
    +/// and the run's future has been dropped by the time it does.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn a_run_still_running_at_the_drain_deadline_is_aborted() {
    +    let storage = engine().await;
    +    let table_id = create_table(&storage, ACCOUNT_A, "orders").await;
    +    start_op(&storage, ACCOUNT_A, "orders", &table_id, 1000, Vec::new()).await;
    +    let started = Arc::new(AtomicBool::new(false));
    +    let dropped = Arc::new(AtomicBool::new(false));
    +    let run_started = Arc::clone(&started);
    +    let run_dropped = Arc::clone(&dropped);
    +    let token = CancellationToken::new();
    +    let store: Arc = storage.clone();
    +    let handle = tokio::spawn(claim_loop(
    +        store,
    +        1,
    +        1,
    +        Duration::from_secs(60),
    +        Duration::from_millis(25),
    +        "worker-test".to_owned(),
    +        Arc::new(MetricsCollector::new()),
    +        token.clone(),
    +        move |_record: BulkOperationRecord, _owner: String| {
    +            let started = Arc::clone(&run_started);
    +            let flag = DropFlag(Arc::clone(&run_dropped));
    +            Box::pin(async move {
    +                let _flag = flag;
    +                started.store(true, Ordering::SeqCst);
    +                std::future::pending::<()>().await;
    +                Ok(())
    +            }) as BoxFuture<'static, Result<(), RunnerError>>
    +        },
    +    ));
    +    let running = wait_until(Duration::from_secs(2), || {
    +        let started = Arc::clone(&started);
    +        async move { started.load(Ordering::SeqCst) }
    +    })
    +    .await;
    +    assert!(running, "the run must start");
    +    token.cancel();
    +    tokio::time::timeout(RUN_DRAIN + Duration::from_secs(2), handle)
    +        .await
    +        .expect("the loop returns within the drain and a margin")
    +        .expect("loop panicked");
    +    assert!(
    +        dropped.load(Ordering::SeqCst),
    +        "the held run's future must be dropped before the loop returns"
    +    );
    +}
    diff --git a/crates/engine/src/bulk_put/worker/tests/config.rs b/crates/engine/src/bulk_put/worker/tests/config.rs
    new file mode 100644
    index 000000000..7c6456af8
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/worker/tests/config.rs
    @@ -0,0 +1,88 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Tests for BulkWorkerConfig::from_limits: the floors on lease,
    +//! concurrency, per-account share, and batch size, and the write-batch
    +//! bounds.
    +
    +use super::super::{BulkWorkerConfig, MAX_BULK_PUT_WRITE_BATCH_ROWS};
    +use super::*;
    +
    +/// The four settings whose documented meanings assume positive values
    +/// are floored at 1; a configured positive value passes through.
    +#[test]
    +fn from_limits_floors_the_lease_concurrency_share_and_batch_size_at_one() {
    +    let zeroed = LimitsConfig {
    +        bulk_operation_lease_seconds: 0,
    +        bulk_operation_worker_concurrency: 0,
    +        bulk_operation_worker_per_account: 0,
    +        bulk_put_batch_size: 0,
    +        ..LimitsConfig::default()
    +    };
    +    let config = BulkWorkerConfig::from_limits(&zeroed);
    +    assert_eq!(config.lease, Duration::from_secs(1));
    +    assert_eq!(config.concurrency, 1);
    +    assert_eq!(config.per_account, 1);
    +    assert_eq!(config.batch_size, 1);
    +
    +    let defaults = LimitsConfig::default();
    +    let config = BulkWorkerConfig::from_limits(&defaults);
    +    assert_eq!(
    +        config.lease,
    +        Duration::from_secs(defaults.bulk_operation_lease_seconds)
    +    );
    +    assert_eq!(
    +        config.concurrency,
    +        defaults.bulk_operation_worker_concurrency
    +    );
    +    assert_eq!(
    +        config.per_account,
    +        defaults.bulk_operation_worker_per_account
    +    );
    +    assert_eq!(config.batch_size, defaults.bulk_put_batch_size);
    +}
    +
    +/// The write batch is floored at 1, capped at 100, and never larger than
    +/// the checkpoint batch, since the checkpoint flushes it: a value above
    +/// `bulk_put_batch_size` would silently act as that value, so it is
    +/// clamped there (and logged) instead.
    +#[test]
    +fn from_limits_bounds_the_write_batch_by_the_cap_and_the_checkpoint_batch() {
    +    let defaults = LimitsConfig::default();
    +    let config = BulkWorkerConfig::from_limits(&defaults);
    +    assert_eq!(config.write_batch_rows, defaults.bulk_put_write_batch_rows);
    +
    +    let zeroed = LimitsConfig {
    +        bulk_put_write_batch_rows: 0,
    +        ..LimitsConfig::default()
    +    };
    +    assert_eq!(BulkWorkerConfig::from_limits(&zeroed).write_batch_rows, 1);
    +
    +    // Above the cap, with a checkpoint batch large enough not to bind.
    +    let huge = LimitsConfig {
    +        bulk_put_write_batch_rows: 5_000,
    +        bulk_put_batch_size: 5_000,
    +        ..LimitsConfig::default()
    +    };
    +    assert_eq!(
    +        BulkWorkerConfig::from_limits(&huge).write_batch_rows,
    +        MAX_BULK_PUT_WRITE_BATCH_ROWS
    +    );
    +
    +    // Raised alone above the default checkpoint batch: bound by it.
    +    let alone = LimitsConfig {
    +        bulk_put_write_batch_rows: 100,
    +        ..LimitsConfig::default()
    +    };
    +    let config = BulkWorkerConfig::from_limits(&alone);
    +    assert_eq!(config.write_batch_rows, config.batch_size);
    +    assert_eq!(config.write_batch_rows, defaults.bulk_put_batch_size);
    +
    +    // Raised together: the write batch reaches the configured value.
    +    let both = LimitsConfig {
    +        bulk_put_write_batch_rows: 100,
    +        bulk_put_batch_size: 100,
    +        ..LimitsConfig::default()
    +    };
    +    assert_eq!(BulkWorkerConfig::from_limits(&both).write_batch_rows, 100);
    +}
    diff --git a/crates/engine/src/bulk_put/worker/tests/mod.rs b/crates/engine/src/bulk_put/worker/tests/mod.rs
    new file mode 100644
    index 000000000..07be2b89e
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/worker/tests/mod.rs
    @@ -0,0 +1,458 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Shared test fixtures for the worker tests: the store root, the in
    +//! memory engine, the operation and ledger builders, the run probe, the
    +//! warning capture subscriber, and the unsupported store double.
    +
    +mod cancel;
    +mod claim;
    +mod config;
    +mod retention;
    +
    +use std::collections::HashMap;
    +use std::path::PathBuf;
    +use std::sync::atomic::{AtomicUsize, Ordering};
    +use std::sync::{Arc, Mutex};
    +use std::time::{Duration, SystemTime, UNIX_EPOCH};
    +
    +use serde_json::{Value, json};
    +
    +use extenddb_core::expression::ExpressionMaps;
    +use extenddb_core::limits::LimitsConfig;
    +use extenddb_core::metrics::MetricsCollector;
    +use extenddb_core::types::{AttributeValue, CreateTableInput, Item, TableKeyInfo};
    +use extenddb_storage::error::StorageError;
    +use extenddb_storage::server_components::ServerComponentsOptions;
    +use extenddb_storage::{
    +    BoxedFuture as BoxFuture, BulkOperationListResult, BulkOperationRecord, BulkOperationStore,
    +    BulkRowMarker, CancelOutcome, CancellationToken, FinishState, InputFile, LeaseState,
    +    ListCursor, NewBulkOperation, Progress, ReportKind, ReportRecord, StartOutcome, StorageEngine,
    +    Terminal,
    +};
    +
    +use crate::bulk_put::arn;
    +use crate::bulk_put::runner::RunnerError;
    +
    +const REGION: &str = "us-east-1";
    +const ACCOUNT_A: &str = "111111111111";
    +const ACCOUNT_B: &str = "222222222222";
    +const IN_BUCKET: &str = "loads";
    +
    +/// A fresh directory acting as a configured bulk store root, removed
    +/// when the test drops it.
    +struct Root(PathBuf);
    +
    +impl Root {
    +    fn new() -> Self {
    +        let dir = std::env::temp_dir().join(format!("eb-bulk-worker-{}", uuid::Uuid::new_v4()));
    +        std::fs::create_dir_all(&dir).unwrap();
    +        Self(dir.canonicalize().unwrap())
    +    }
    +
    +    fn root(&self) -> Arc {
    +        Arc::new(self.0.clone())
    +    }
    +
    +    fn file(&self, account: &str, bucket: &str, rel: &str, contents: &[u8]) {
    +        let path = self.0.join(account).join(bucket).join(rel);
    +        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    +        std::fs::write(&path, contents).unwrap();
    +    }
    +}
    +
    +impl Drop for Root {
    +    fn drop(&mut self) {
    +        let _ = std::fs::remove_dir_all(&self.0);
    +    }
    +}
    +
    +/// In-memory `SQLite` engine with both test accounts seeded, the same
    +/// arrangement the runner behaviour tests use.
    +async fn engine() -> Arc {
    +    let config = extenddb_storage_sqlite::SqliteConfig {
    +        path: ":memory:".to_owned(),
    +        pool_size: 2,
    +    };
    +    let components = (extenddb_storage_sqlite::backend().server_components)(
    +        &config,
    +        REGION,
    +        ServerComponentsOptions::default(),
    +    )
    +    .await
    +    .expect("server components");
    +    components
    +        .catalog_store
    +        .set_setting("control_plane_delay_seconds", "0")
    +        .await
    +        .expect("set control plane delay");
    +    for (id, name) in [(ACCOUNT_A, "worker-a"), (ACCOUNT_B, "worker-b")] {
    +        components
    +            .catalog_store
    +            .create_account(id, name)
    +            .await
    +            .expect("create account");
    +    }
    +    components.engine
    +}
    +
    +async fn create_table(storage: &Arc, account: &str, name: &str) -> String {
    +    let input: CreateTableInput = serde_json::from_value(json!({
    +        "TableName": name,
    +        "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
    +        "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
    +        "BillingMode": "PAY_PER_REQUEST"
    +    }))
    +    .expect("create table input");
    +    storage
    +        .create_table(account, input)
    +        .await
    +        .expect("create table")
    +        .table_id
    +}
    +
    +fn input_config() -> Value {
    +    json!({
    +        "S3Bucket": IN_BUCKET,
    +        "S3KeyPrefix": "",
    +        "InputFormat": "DYNAMODB_JSON",
    +        "InputCompressionType": "NONE",
    +    })
    +}
    +
    +/// No report bucket, so the runner renders nothing beneath the store
    +/// root.
    +fn output_config() -> Value {
    +    json!({ "S3KeyPrefix": "run", "OutputType": ["ERROR"] })
    +}
    +
    +#[allow(clippy::too_many_arguments)]
    +async fn start_op(
    +    storage: &Arc,
    +    account: &str,
    +    table_name: &str,
    +    table_id: &str,
    +    start_time_ms: i64,
    +    files: Vec,
    +) -> String {
    +    let operation_id = arn::generate_operation_id();
    +    let total: i64 = files.iter().map(|f| f.size).sum();
    +    let outcome = storage
    +        .start_bulk_operation(
    +            account,
    +            NewBulkOperation {
    +                operation_id: operation_id.clone(),
    +                table_id: table_id.to_owned(),
    +                table_name: table_name.to_owned(),
    +                table_arn: format!("arn:aws:dynamodb:{REGION}:{account}:table/{table_name}"),
    +                client_token: None,
    +                client_token_hash: None,
    +                request_fingerprint: "worker-test".to_owned(),
    +                input_config: input_config(),
    +                output_config: output_config(),
    +                input_files: files,
    +                max_write_request_units: None,
    +                performance_tier: None,
    +                start_time_ms,
    +                total_input_bytes: Some(total),
    +                initial_failure: None,
    +                run_context: None,
    +            },
    +            50,
    +        )
    +        .await
    +        .expect("start bulk operation");
    +    assert!(
    +        matches!(outcome, StartOutcome::Created(_)),
    +        "the operation must be created"
    +    );
    +    operation_id
    +}
    +
    +fn now_ms() -> i64 {
    +    i64::try_from(
    +        SystemTime::now()
    +            .duration_since(UNIX_EPOCH)
    +            .unwrap()
    +            .as_millis(),
    +    )
    +    .unwrap()
    +}
    +
    +async fn key_info(storage: &Arc, account: &str, table: &str) -> TableKeyInfo {
    +    storage
    +        .table_key_info(account, table)
    +        .await
    +        .expect("key info")
    +}
    +
    +/// Write one item with an Applied ledger marker for `(operation, 0, 0)`,
    +/// the way a runner's marked condition write populates the ledger.
    +async fn write_ledger_row(
    +    storage: &Arc,
    +    account: &str,
    +    table: &str,
    +    operation_id: &str,
    +) {
    +    let ki = key_info(storage, account, table).await;
    +    let maps = ExpressionMaps::new(HashMap::new(), HashMap::new());
    +    let item = Item::from([("pk".to_owned(), AttributeValue::S("a".to_owned()))]);
    +    let marker = BulkRowMarker {
    +        account_id: account.to_owned(),
    +        operation_id: operation_id.to_owned(),
    +        file_index: 0,
    +        row_index: 0,
    +        owner: String::new(),
    +        keep_old: false,
    +        settled: (0, 0),
    +    };
    +    storage
    +        .put_item(&ki, item, false, None, &maps, None, Some(&marker))
    +        .await
    +        .expect("marked put");
    +}
    +
    +/// Poll `f` every 10 ms until it answers `true` or the deadline passes.
    +async fn wait_until(deadline: Duration, mut f: F) -> bool
    +where
    +    F: FnMut() -> Fut,
    +    Fut: std::future::Future,
    +{
    +    let start = std::time::Instant::now();
    +    while start.elapsed() < deadline {
    +        if f().await {
    +            return true;
    +        }
    +        tokio::time::sleep(Duration::from_millis(10)).await;
    +    }
    +    false
    +}
    +
    +/// Bookkeeping shared by the claim-loop tests: which operations have
    +/// started, and a semaphore whose permits release pended runs.
    +struct RunProbe {
    +    started: Mutex>,
    +    release: tokio::sync::Semaphore,
    +}
    +
    +impl RunProbe {
    +    fn new() -> Arc {
    +        Arc::new(Self {
    +            started: Mutex::new(Vec::new()),
    +            release: tokio::sync::Semaphore::new(0),
    +        })
    +    }
    +
    +    fn started(&self) -> Vec<(String, String)> {
    +        self.started.lock().expect("lock").clone()
    +    }
    +}
    +
    +/// A `run_one` whose runs record themselves and pend until released.
    +fn pending_run(
    +    probe: Arc,
    +) -> impl Fn(BulkOperationRecord, String) -> BoxFuture<'static, Result<(), RunnerError>> + 'static {
    +    move |record: BulkOperationRecord, _owner: String| {
    +        let probe = Arc::clone(&probe);
    +        Box::pin(async move {
    +            probe
    +                .started
    +                .lock()
    +                .expect("lock")
    +                .push((record.account_id.clone(), record.operation_id.clone()));
    +            let permit = probe.release.acquire().await.expect("semaphore open");
    +            permit.forget();
    +            Ok(())
    +        })
    +    }
    +}
    +
    +/// A minimal subscriber that keeps the formatted fields of every WARN
    +/// event, so a test can assert the clamp warning fired without a
    +/// subscriber crate.
    +#[derive(Clone)]
    +struct CaptureWarnings {
    +    lines: Arc>>,
    +}
    +
    +struct FieldText<'a>(&'a mut Vec);
    +
    +impl tracing::field::Visit for FieldText<'_> {
    +    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
    +        self.0.push(format!("{}={value:?}", field.name()));
    +    }
    +}
    +
    +impl tracing::Subscriber for CaptureWarnings {
    +    fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
    +        true
    +    }
    +    fn new_span(&self, _attrs: &tracing::span::Attributes<'_>) -> tracing::span::Id {
    +        tracing::span::Id::from_u64(1)
    +    }
    +    fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
    +    fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {}
    +    fn event(&self, event: &tracing::Event<'_>) {
    +        if *event.metadata().level() == tracing::Level::WARN {
    +            let mut fields = Vec::new();
    +            event.record(&mut FieldText(&mut fields));
    +            if let Ok(mut lines) = self.lines.lock() {
    +                lines.push(fields.join(" "));
    +            }
    +        }
    +    }
    +    fn enter(&self, _span: &tracing::span::Id) {}
    +    fn exit(&self, _span: &tracing::span::Id) {}
    +}
    +
    +/// True when the metric carries a point in the last minute under the
    +/// given worker source dimension.
    +fn worker_metric_present(
    +    metrics: &MetricsCollector,
    +    metric: extenddb_core::metrics::MetricName,
    +    source: &str,
    +) -> bool {
    +    use extenddb_core::metrics::{Dimension, MetricsQuery, TimeWindow};
    +    let snapshots = metrics.query(&MetricsQuery {
    +        metric: Some(metric),
    +        window: Some(TimeWindow::LastMinute),
    +        ..MetricsQuery::default()
    +    });
    +    snapshots.iter().any(|snapshot| {
    +        snapshot.count > 0
    +            && snapshot
    +                .dimensions
    +                .iter()
    +                .any(|d| matches!(d, Dimension::Operation(op) if op == source))
    +    })
    +}
    +
    +/// Refuses every method the way the backend without bulk operations
    +/// does, and counts the claims so a test can pin how many happened.
    +struct UnsupportedStore {
    +    claims: AtomicUsize,
    +    /// When true, `claim_runnable` answers a non-Validation error
    +    /// (`Internal`) instead of the unsupported Validation, so a test can
    +    /// exercise the claim loop's error path rather than its stop path.
    +    claim_internal: bool,
    +}
    +
    +fn unsupported() -> BoxFuture<'static, Result> {
    +    Box::pin(async {
    +        Err(StorageError::Validation(
    +            "Bulk operations are not supported on this storage backend".to_owned(),
    +        ))
    +    })
    +}
    +
    +impl BulkOperationStore for UnsupportedStore {
    +    fn start_bulk_operation(
    +        &self,
    +        _account_id: &str,
    +        _req: NewBulkOperation,
    +        _account_limit: u32,
    +    ) -> BoxFuture<'_, Result> {
    +        unsupported()
    +    }
    +
    +    fn get_bulk_operation(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        unsupported()
    +    }
    +
    +    fn count_non_terminal(&self, _account_id: &str) -> BoxFuture<'_, Result> {
    +        unsupported()
    +    }
    +
    +    fn count_terminal_born_since(
    +        &self,
    +        _account_id: &str,
    +        _since_ms: i64,
    +    ) -> BoxFuture<'_, Result> {
    +        unsupported()
    +    }
    +
    +    fn list_bulk_operations(
    +        &self,
    +        _account_id: &str,
    +        _limit: u32,
    +        _after: Option,
    +    ) -> BoxFuture<'_, BulkOperationListResult> {
    +        unsupported()
    +    }
    +
    +    fn request_cancel(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +    ) -> BoxFuture<'_, Result> {
    +        unsupported()
    +    }
    +
    +    fn claim_runnable(
    +        &self,
    +        _owner: &str,
    +        _lease: Duration,
    +        _max: usize,
    +        _exclude_accounts: &[String],
    +        _exclude_operations: &[String],
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        self.claims.fetch_add(1, Ordering::SeqCst);
    +        if self.claim_internal {
    +            return Box::pin(async {
    +                Err(StorageError::Internal("injected claim failure".to_owned()))
    +            });
    +        }
    +        unsupported()
    +    }
    +
    +    fn checkpoint(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +        _owner: &str,
    +        _lease: Duration,
    +        _progress: Progress,
    +    ) -> BoxFuture<'_, Result> {
    +        unsupported()
    +    }
    +
    +    fn report_records_page(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +        _kind: ReportKind,
    +        _after: Option<(i64, i64)>,
    +        _limit: usize,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        unsupported()
    +    }
    +
    +    fn finish(
    +        &self,
    +        _account_id: &str,
    +        _operation_id: &str,
    +        _owner: &str,
    +        _outcome: Terminal,
    +    ) -> BoxFuture<'_, Result> {
    +        unsupported()
    +    }
    +
    +    fn list_purgeable_before(
    +        &self,
    +        _cutoff: SystemTime,
    +        _limit: usize,
    +    ) -> BoxFuture<'_, Result, StorageError>> {
    +        unsupported()
    +    }
    +
    +    fn purge_operations(
    +        &self,
    +        _pairs: &[(String, String)],
    +    ) -> BoxFuture<'_, Result<(), StorageError>> {
    +        unsupported()
    +    }
    +}
    diff --git a/crates/engine/src/bulk_put/worker/tests/retention.rs b/crates/engine/src/bulk_put/worker/tests/retention.rs
    new file mode 100644
    index 000000000..ed93b6596
    --- /dev/null
    +++ b/crates/engine/src/bulk_put/worker/tests/retention.rs
    @@ -0,0 +1,299 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Tests for the retention worker: purging an old terminal operation and
    +//! its ledger rows, the retention-days floor, and the retention liveness
    +//! metric.
    +
    +use super::super::bulk_operation_retention_worker;
    +use super::super::floored;
    +use super::*;
    +
    +/// The retention worker's startup pass deletes a terminal operation
    +/// past the window together with its ledger rows, and leaves an
    +/// in-progress operation and its ledger untouched.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn retention_purges_an_old_terminal_operation_and_its_ledger_rows() {
    +    let storage = engine().await;
    +    let owner = "retention-test";
    +
    +    let table_id = create_table(&storage, ACCOUNT_A, "old_table").await;
    +    let old_op = start_op(
    +        &storage,
    +        ACCOUNT_A,
    +        "old_table",
    +        &table_id,
    +        1000,
    +        Vec::new(),
    +    )
    +    .await;
    +    let table_id = create_table(&storage, ACCOUNT_A, "live_table").await;
    +    let live_op = start_op(
    +        &storage,
    +        ACCOUNT_A,
    +        "live_table",
    +        &table_id,
    +        2000,
    +        Vec::new(),
    +    )
    +    .await;
    +    let claimed = storage
    +        .claim_runnable(owner, Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert_eq!(claimed.len(), 2);
    +    write_ledger_row(&storage, ACCOUNT_A, "old_table", &old_op).await;
    +    write_ledger_row(&storage, ACCOUNT_A, "live_table", &live_op).await;
    +    // Terminal forty days ago, past the thirty day default window. The
    +    // live operation stays IN_PROGRESS.
    +    let old_end = now_ms() - 40 * 86_400_000;
    +    let state = storage
    +        .finish(
    +            ACCOUNT_A,
    +            &old_op,
    +            owner,
    +            Terminal {
    +                status: "COMPLETED".to_owned(),
    +                end_time_ms: old_end,
    +                failure_code: None,
    +                failure_message: None,
    +                usage: json!({}),
    +            },
    +        )
    +        .await
    +        .expect("finish");
    +    assert!(matches!(state, FinishState::Held(_)));
    +
    +    let token = CancellationToken::new();
    +    let handle = tokio::spawn(bulk_operation_retention_worker(
    +        Arc::clone(&storage),
    +        Arc::new(LimitsConfig::default()),
    +        Arc::new(MetricsCollector::new()),
    +        token.clone(),
    +    ));
    +    let purged = wait_until(Duration::from_secs(2), || {
    +        let storage = Arc::clone(&storage);
    +        let old_op = old_op.clone();
    +        async move {
    +            storage
    +                .get_bulk_operation(ACCOUNT_A, &old_op)
    +                .await
    +                .expect("get")
    +                .is_none()
    +        }
    +    })
    +    .await;
    +    assert!(
    +        purged,
    +        "the startup pass must purge the old terminal record"
    +    );
    +
    +    assert!(
    +        storage
    +            .bulk_row_outcome(ACCOUNT_A, &old_op, 0, 0)
    +            .await
    +            .expect("ledger read")
    +            .is_none(),
    +        "the purged operation's ledger rows must be deleted"
    +    );
    +    assert!(
    +        storage
    +            .get_bulk_operation(ACCOUNT_A, &live_op)
    +            .await
    +            .expect("get")
    +            .is_some(),
    +        "the in-progress operation must survive"
    +    );
    +    assert!(
    +        storage
    +            .bulk_row_outcome(ACCOUNT_A, &live_op, 0, 0)
    +            .await
    +            .expect("ledger read")
    +            .is_some(),
    +        "the in-progress operation's ledger rows must survive"
    +    );
    +    token.cancel();
    +    tokio::time::timeout(Duration::from_secs(2), handle)
    +        .await
    +        .expect("retention worker stops at its next tick")
    +        .expect("retention worker panicked");
    +}
    +
    +/// A retention window configured to zero is floored to one day, so the
    +/// startup pass keeps a record that finished moments ago. An operation
    +/// that finished long ago is purged whatever the window, so it is the
    +/// sign the pass ran.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn retention_floors_zero_days_and_keeps_a_recent_terminal_operation() {
    +    let storage = engine().await;
    +    let owner = "retention-floor-test";
    +
    +    let old_table = create_table(&storage, ACCOUNT_A, "old_table").await;
    +    let old_op = start_op(
    +        &storage,
    +        ACCOUNT_A,
    +        "old_table",
    +        &old_table,
    +        1000,
    +        Vec::new(),
    +    )
    +    .await;
    +    let recent_table = create_table(&storage, ACCOUNT_A, "recent_table").await;
    +    let recent_op = start_op(
    +        &storage,
    +        ACCOUNT_A,
    +        "recent_table",
    +        &recent_table,
    +        2000,
    +        Vec::new(),
    +    )
    +    .await;
    +
    +    let claimed = storage
    +        .claim_runnable(owner, Duration::from_secs(60), 10, &[], &[])
    +        .await
    +        .expect("claim");
    +    assert_eq!(claimed.len(), 2);
    +
    +    let old_end = now_ms() - 40 * 86_400_000;
    +    storage
    +        .finish(
    +            ACCOUNT_A,
    +            &old_op,
    +            owner,
    +            Terminal {
    +                status: "COMPLETED".to_owned(),
    +                end_time_ms: old_end,
    +                failure_code: None,
    +                failure_message: None,
    +                usage: json!({}),
    +            },
    +        )
    +        .await
    +        .expect("finish old");
    +    // One hour ago: inside a one day window (kept once zero is floored to
    +    // one), outside a zero window (its cutoff is now, so it would be
    +    // purged). The margin keeps the test off the cutoff boundary.
    +    let recent_end = now_ms() - 3_600_000;
    +    storage
    +        .finish(
    +            ACCOUNT_A,
    +            &recent_op,
    +            owner,
    +            Terminal {
    +                status: "COMPLETED".to_owned(),
    +                end_time_ms: recent_end,
    +                failure_code: None,
    +                failure_message: None,
    +                usage: json!({}),
    +            },
    +        )
    +        .await
    +        .expect("finish recent");
    +
    +    let limits = Arc::new(LimitsConfig {
    +        bulk_operation_retention_days: 0,
    +        ..LimitsConfig::default()
    +    });
    +    let token = CancellationToken::new();
    +    let handle = tokio::spawn(bulk_operation_retention_worker(
    +        Arc::clone(&storage),
    +        limits,
    +        Arc::new(MetricsCollector::new()),
    +        token.clone(),
    +    ));
    +
    +    let old_purged = wait_until(Duration::from_secs(2), || {
    +        let storage = Arc::clone(&storage);
    +        let old_op = old_op.clone();
    +        async move {
    +            storage
    +                .get_bulk_operation(ACCOUNT_A, &old_op)
    +                .await
    +                .expect("get")
    +                .is_none()
    +        }
    +    })
    +    .await;
    +    assert!(old_purged, "the old terminal record must be purged");
    +
    +    assert!(
    +        storage
    +            .get_bulk_operation(ACCOUNT_A, &recent_op)
    +            .await
    +            .expect("get")
    +            .is_some(),
    +        "a record that finished moments ago must survive a zero window floored to one day"
    +    );
    +
    +    token.cancel();
    +    tokio::time::timeout(Duration::from_secs(2), handle)
    +        .await
    +        .expect("retention worker stops at its next tick")
    +        .expect("retention worker panicked");
    +}
    +
    +/// The retention floor clamps a zero window to one and emits the shared
    +/// clamp warning naming the key; a configured value passes through with
    +/// no warning.
    +#[test]
    +fn the_retention_floor_clamps_zero_to_one_and_warns() {
    +    assert_eq!(floored(0u32, "bulk_operation_retention_days"), 1);
    +    assert_eq!(floored(30u32, "bulk_operation_retention_days"), 30);
    +
    +    let captured: Arc>> = Arc::new(Mutex::new(Vec::new()));
    +    let subscriber = CaptureWarnings {
    +        lines: Arc::clone(&captured),
    +    };
    +    tracing::subscriber::with_default(subscriber, || {
    +        let _ = floored(0u32, "bulk_operation_retention_days");
    +        let _ = floored(30u32, "bulk_operation_retention_days");
    +    });
    +    let lines = captured.lock().expect("lock").clone();
    +    assert_eq!(lines.len(), 1, "only the zero value warns");
    +    assert!(
    +        lines[0].contains("bulk worker setting floored at 1"),
    +        "the warn carries the shared clamp message: {}",
    +        lines[0]
    +    );
    +    assert!(
    +        lines[0].contains("bulk_operation_retention_days"),
    +        "the warn names the key: {}",
    +        lines[0]
    +    );
    +}
    +
    +/// The retention worker records a WorkerLastSuccess point under the
    +/// BulkRetention source on a completed pass.
    +#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    +async fn the_retention_worker_records_a_worker_success_metric() {
    +    let storage = engine().await;
    +    let metrics = Arc::new(MetricsCollector::new());
    +    let token = CancellationToken::new();
    +    let handle = tokio::spawn(bulk_operation_retention_worker(
    +        Arc::clone(&storage),
    +        Arc::new(LimitsConfig::default()),
    +        Arc::clone(&metrics),
    +        token.clone(),
    +    ));
    +    let published = wait_until(Duration::from_secs(2), || {
    +        let metrics = Arc::clone(&metrics);
    +        async move {
    +            worker_metric_present(
    +                &metrics,
    +                extenddb_core::metrics::MetricName::WorkerLastSuccess,
    +                "BulkRetention",
    +            )
    +        }
    +    })
    +    .await;
    +    assert!(
    +        published,
    +        "the retention worker must record WorkerLastSuccess for BulkRetention"
    +    );
    +    token.cancel();
    +    tokio::time::timeout(Duration::from_secs(2), handle)
    +        .await
    +        .expect("retention worker stops at its next tick")
    +        .expect("retention worker panicked");
    +}
    diff --git a/crates/engine/src/capacity_helpers.rs b/crates/engine/src/capacity_helpers.rs
    index 73b8c04fb..f27a39eda 100755
    --- a/crates/engine/src/capacity_helpers.rs
    +++ b/crates/engine/src/capacity_helpers.rs
    @@ -281,7 +281,7 @@ fn one_index_write_units(
     
     /// Project an item into one index, or return `None` when the item is not a
     /// member of the sparse index.
    -fn sparse_index_projection(
    +pub(crate) fn sparse_index_projection(
         item: &Item,
         index_key_schema: &[KeySchemaElement],
         base_keys: &[&str],
    diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
    index 187f4f59f..d5fb45c7c 100755
    --- a/crates/engine/src/lib.rs
    +++ b/crates/engine/src/lib.rs
    @@ -11,6 +11,7 @@ mod backup;
     pub use backup::{BackupRuntime, reconciler};
     mod batch_get_item;
     mod batch_write_item;
    +pub mod bulk_put;
     pub mod capacity_helpers;
     mod create_table;
     mod delete_item;
    @@ -46,6 +47,7 @@ mod vector_gate;
     
     pub use batch_get_item::handle_batch_get_item;
     pub use batch_write_item::handle_batch_write_item;
    +pub use bulk_put::worker::{bulk_operation_retention_worker, bulk_operation_worker};
     pub use create_table::handle_create_table;
     pub use create_table::storage_err_to_dynamo;
     pub use delete_item::handle_delete_item;
    @@ -118,6 +120,10 @@ pub fn is_known_operation(operation: &str) -> bool {
                 | "GetRecords"
                 | "ImportTable"
                 | "ExportTableToPointInTime"
    +            | "StartBulkPutItems"
    +            | "DescribeBulkOperation"
    +            | "ListBulkOperations"
    +            | "CancelBulkOperation"
                 | "CreateBackup"
                 | "DescribeBackup"
                 | "ListBackups"
    @@ -355,6 +361,20 @@ pub struct OperationContext {
         /// Allowed directories for export file operations. Empty means exports
         /// are disabled (secure default).
         pub export_paths: Arc<[Arc]>,
    +    /// The `[bulk]` store, resolved once at startup. Bulk operations read
    +    /// inputs and write reports through it; on the filesystem store they
    +    /// live beneath `{root}/{account id}/{S3Bucket}/...`. `None` means bulk
    +    /// operations are disabled (secure default). Independent of
    +    /// `import_paths` and `export_paths`, which serve `ImportTable` and
    +    /// `ExportTableToPointInTime` only.
    +    pub bulk: Option>,
    +    /// Evaluates the S3 actions a bulk operation performs as its caller.
    +    /// `None` in developer mode, where every such check is skipped.
    +    pub bulk_authorizer: Option>,
    +    /// The caller of a `StartBulkPutItems` request, captured by the server
    +    /// and recorded on the operation; `None` for other operations and in
    +    /// developer mode.
    +    pub bulk_principal: Option,
         /// The `TableKeyInfo` the auth layer read for this request (P118
         /// optimization #2). Populated for single-table item operations, `Query`,
         /// `Scan`, and the batch and transaction operations; empty for table-level
    @@ -370,6 +390,10 @@ pub struct OperationContext {
         /// read. When unset (e.g. unit tests), the engine falls back to direct
         /// storage lookups.
         pub table_key_info_lookup: Option>,
    +    /// Metrics collector for handler-recorded counters that the server layer
    +    /// cannot derive from the response body, such as `BulkOperationsStarted`,
    +    /// which only the start handler can tell apart from an idempotent replay.
    +    pub metrics: Arc,
     }
     
     impl OperationContext {
    @@ -505,6 +529,18 @@ pub async fn dispatch(
             "ExportTableToPointInTime" => handle_export_table(body, ctx)
                 .await
                 .map(DispatchResult::body_only),
    +        "StartBulkPutItems" => bulk_put::handlers::handle_start_bulk_put_items(body, ctx)
    +            .await
    +            .map(DispatchResult::body_only),
    +        "DescribeBulkOperation" => bulk_put::handlers::handle_describe_bulk_operation(body, ctx)
    +            .await
    +            .map(DispatchResult::body_only),
    +        "ListBulkOperations" => bulk_put::handlers::handle_list_bulk_operations(body, ctx)
    +            .await
    +            .map(DispatchResult::body_only),
    +        "CancelBulkOperation" => bulk_put::handlers::handle_cancel_bulk_operation(body, ctx)
    +            .await
    +            .map(DispatchResult::body_only),
             "CreateBackup" => backup::handle_create_backup(body, ctx)
                 .await
                 .map(DispatchResult::body_only),
    diff --git a/crates/engine/src/update_table.rs b/crates/engine/src/update_table.rs
    index 6db763f82..a387edcbf 100755
    --- a/crates/engine/src/update_table.rs
    +++ b/crates/engine/src/update_table.rs
    @@ -171,6 +171,44 @@ pub async fn handle_update_table(
         }
     
         let table_name = input.table_name.clone();
    +
    +    // An index creation is refused while a bulk operation on the table is
    +    // in progress or cancelling (measured on the hosted service,
    +    // ResourceInUseException with this text and the table name).
    +    // Index deletion and the other table updates are accepted during a run
    +    //  and are not checked here.
    +    let creates_index = input
    +        .global_secondary_index_updates
    +        .as_ref()
    +        .is_some_and(|updates| updates.iter().any(|update| update.create.is_some()));
    +    if creates_index {
    +        // Counted by the table's current id, so an operation still running
    +        // on a deleted table of the same name does not refuse an index on
    +        // the new one. A table that does not exist answers here as the
    +        // backend call below would answer it.
    +        let table_id = ctx
    +            .storage
    +            .describe_table(
    +                &ctx.account_id,
    +                extenddb_core::types::DescribeTableInput {
    +                    table_name: table_name.clone(),
    +                },
    +            )
    +            .await
    +            .map_err(|e| update_table_err_to_dynamo(e, &table_name))?
    +            .table_id;
    +        let running = ctx
    +            .storage
    +            .count_non_terminal_for_table(&ctx.account_id, &table_id)
    +            .await
    +            .map_err(|e| update_table_err_to_dynamo(e, &table_name))?;
    +        if running > 0 {
    +            return Err(DynamoDbError::ResourceInUseException(
    +                extenddb_storage::bulk_operation_store::index_creation_refused_message(&table_name),
    +            ));
    +        }
    +    }
    +
         // Kept for the post-condition check below, since `input` is moved into the
         // backend call.
         let vector_index_updates = input.vector_index_updates.clone();
    diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml
    index 716cf07a7..33b437a3d 100755
    --- a/crates/server/Cargo.toml
    +++ b/crates/server/Cargo.toml
    @@ -51,3 +51,9 @@ libc = { workspace = true }
     
     [dev-dependencies]
     extenddb-cache = { workspace = true, features = ["test-util"] }
    +# The bulk authorization tests drive `authorize_request` against a real
    +# `AppState`. The server crate is backend-agnostic at runtime (it holds an
    +# `Arc`), so a concrete backend appears here only as a
    +# test dependency; the sqlite in-memory database gives each test an
    +# isolated engine with no file on disk, matching the engine crate's tests.
    +extenddb-storage-sqlite = { workspace = true, features = ["memory"] }
    diff --git a/crates/server/src/authorization.rs b/crates/server/src/authorization.rs
    index a45dbda40..1e4a78f71 100755
    --- a/crates/server/src/authorization.rs
    +++ b/crates/server/src/authorization.rs
    @@ -27,6 +27,8 @@ use extenddb_storage::management_store::OpError;
     use crate::authz_cache::{CachedAuthzStore, PolicyList, TagMap};
     use crate::authz_resource::AuthzTarget;
     
    +pub(crate) mod full_action;
    +
     /// Evaluate whether the authenticated identity is authorized for the given
     /// `DynamoDB` operation on the given resource.
     ///
    @@ -46,11 +48,17 @@ pub async fn check_authorization(
         is_scan: bool,
         params: RequestParams,
     ) -> Result<(), DynamoDbError> {
    +    let action = format!("dynamodb:{}", target.action);
         match identity {
             AuthIdentity::User {
                 account_id,
                 user_name,
    -        } => check_user_authorization(cache, account_id, user_name, target, is_scan, params).await,
    +        } => {
    +            check_user_authorization(
    +                cache, account_id, user_name, &action, target, is_scan, params,
    +            )
    +            .await
    +        }
             AuthIdentity::RoleSession {
                 account_id,
                 role_name,
    @@ -61,6 +69,8 @@ pub async fn check_authorization(
                     account_id,
                     role_name,
                     session_name,
    +                None,
    +                &action,
                     target,
                     is_scan,
                     params,
    @@ -74,11 +84,11 @@ async fn check_user_authorization(
         cache: &CachedAuthzStore,
         account_id: &str,
         user_name: &str,
    +    action: &str,
         target: &AuthzTarget,
         is_scan: bool,
         params: RequestParams,
     ) -> Result<(), DynamoDbError> {
    -    let action = format!("dynamodb:{}", target.action);
         let resource_arn = target.policy_resource();
     
         // Resource tags apply only to a table, and to an index through its base
    @@ -125,9 +135,9 @@ async fn check_user_authorization(
                 .map(String::as_str)
                 .unwrap_or_default(),
         ));
    -    // The target table's owner account. ExtendDB builds the resource ARN with
    -    // the caller's account, so the resource account is that account.
    -    params.resource_account = Some(account_id.to_owned());
    +    // The target resource's owner account: the caller's for a DynamoDB
    +    // resource, the one in `params` for another service's.
    +    full_action::set_resource_account(&mut params, action, account_id);
         let context = RequestContext::build(
             (*principal_tags).clone(),
             (*resource_tags).clone(),
    @@ -139,7 +149,7 @@ async fn check_user_authorization(
             &identity_policies,
             boundary.as_deref(),
             None,
    -        &action,
    +        action,
             resource_arn,
             &context,
         );
    @@ -147,40 +157,42 @@ async fn check_user_authorization(
         if decision == AuthzDecision::Allow {
             Ok(())
         } else {
    +        let principal = format!("arn:aws:iam::{account_id}:user/{user_name}");
             tracing::warn!(
    -            principal = format!("arn:aws:iam::{account_id}:user/{user_name}"),
    +            principal = principal,
                 action = action,
                 resource = resource_arn,
                 "Authorization denied"
             );
    -        let principal = format!("arn:aws:iam::{account_id}:user/{user_name}");
             let explicit = explicit_deny_matches_arc(
                 &identity_policies,
                 boundary.as_deref(),
                 None,
    -            &action,
    +            action,
                 resource_arn,
                 &context,
             );
             Err(DynamoDbError::AccessDeniedException(deny_message(
                 &principal,
    -            &action,
    +            action,
                 &target.supplied,
                 explicit,
             )))
         }
     }
     
    +#[allow(clippy::too_many_arguments)]
     async fn check_role_authorization(
         cache: &CachedAuthzStore,
         account_id: &str,
         role_name: &str,
         session_name: &str,
    +    session: Option<&full_action::SessionSnapshot>,
    +    action: &str,
         target: &AuthzTarget,
         is_scan: bool,
         params: RequestParams,
     ) -> Result<(), DynamoDbError> {
    -    let action = format!("dynamodb:{}", target.action);
         let resource_arn = target.policy_resource();
     
         // Resource tags for a table or index only, read under the canonical
    @@ -196,7 +208,7 @@ async fn check_role_authorization(
         ) = tokio::try_join!(
             wrap_policies(cache.fetch_role_policies(account_id, role_name)),
             wrap_boundary(cache.fetch_role_boundary(account_id, role_name)),
    -        fetch_session_data_and_tags(cache, account_id, role_name, session_name),
    +        full_action::session_and_tags(cache, account_id, role_name, session_name, session),
             wrap_tags(fetch_authz_resource_tags(cache, tag_arn.as_deref())),
             wrap_created_at(cache.fetch_role_created_at(account_id, role_name)),
         )?;
    @@ -218,7 +230,7 @@ async fn check_role_authorization(
                 .map(String::as_str)
                 .unwrap_or_default(),
         ));
    -    params.resource_account = Some(account_id.to_owned());
    +    full_action::set_resource_account(&mut params, action, account_id);
         // aws:TokenIssueTime for a role session is the session's issue time.
         params.token_issue_time = token_issue_time;
         // A role session is assumed without MFA, so aws:MultiFactorAuthPresent is
    @@ -235,7 +247,7 @@ async fn check_role_authorization(
             &identity_slice,
             boundary.as_deref(),
             session_policy.as_deref(),
    -        &action,
    +        action,
             resource_arn,
             &context,
         );
    @@ -243,26 +255,25 @@ async fn check_role_authorization(
         if decision == AuthzDecision::Allow {
             Ok(())
         } else {
    +        let principal =
    +            format!("arn:aws:iam::{account_id}:assumed-role/{role_name}/{session_name}");
             tracing::warn!(
    -            principal =
    -                format!("arn:aws:iam::{account_id}:assumed-role/{role_name}/{session_name}"),
    +            principal = principal,
                 action = action,
                 resource = resource_arn,
                 "Authorization denied"
             );
    -        let principal =
    -            format!("arn:aws:iam::{account_id}:assumed-role/{role_name}/{session_name}");
             let explicit = explicit_deny_matches_arc(
                 &identity_slice,
                 boundary.as_deref(),
                 session_policy.as_deref(),
    -            &action,
    +            action,
                 resource_arn,
                 &context,
             );
             Err(DynamoDbError::AccessDeniedException(deny_message(
                 &principal,
    -            &action,
    +            action,
                 &target.supplied,
                 explicit,
             )))
    @@ -274,12 +285,12 @@ async fn check_role_authorization(
     // ---------------------------------------------------------------------------
     
     /// Build the AccessDeniedException message in the form the service uses, which
    -/// depends on why the request was denied. `action` already carries the
    -/// `dynamodb:` prefix; `resource_arn` is the resource as the caller supplied
    -/// it, partition, service and region included. An explicit Deny statement yields "with an explicit deny
    -/// in an identity-based policy"; any other denial yields "because no
    -/// identity-based policy allows the  action", measured against live
    -/// DynamoDB.
    +/// depends on why the request was denied. `action` is the full action
    +/// string, service prefix included; `resource_arn` is the resource as the
    +/// caller supplied it, partition, service and region included. An explicit
    +/// Deny statement yields "with an explicit deny in an identity-based
    +/// policy"; any other denial yields "because no identity-based policy allows
    +/// the  action", measured against live DynamoDB.
     pub(crate) fn deny_message(
         principal: &str,
         action: &str,
    @@ -513,6 +524,7 @@ mod resource_tag_tests {
                 cache,
                 ACCOUNT,
                 "u",
    +            &format!("dynamodb:{op}"),
                 &AuthzTarget::new(op, arn.to_owned()),
                 false,
                 RequestParams::default(),
    @@ -540,6 +552,7 @@ mod resource_tag_tests {
                 &cache,
                 ACCOUNT,
                 "u",
    +            "dynamodb:DescribeStream",
                 &target,
                 false,
                 RequestParams::default(),
    @@ -562,6 +575,8 @@ mod resource_tag_tests {
                 ACCOUNT,
                 "r",
                 "s",
    +            None,
    +            "dynamodb:DescribeStream",
                 &target,
                 false,
                 RequestParams::default(),
    @@ -608,15 +623,20 @@ mod resource_tag_tests {
                     ACCOUNT,
                     "r",
                     "s",
    +                None,
    +                op,
                     &AuthzTarget::new(op, arn.to_owned()),
                     false,
                     RequestParams::default(),
                 )
                 .await
             };
    -        assert!(role("DescribeTable", TABLE).await.is_ok());
    +        assert!(role("dynamodb:DescribeTable", TABLE).await.is_ok());
             let fetched = inner.resource_tags_calls.load(Ordering::SeqCst);
    -        for (op, arn) in [("DescribeStream", STREAM), ("DescribeBackup", BACKUP)] {
    +        for (op, arn) in [
    +            ("dynamodb:DescribeStream", STREAM),
    +            ("dynamodb:DescribeBackup", BACKUP),
    +        ] {
                 assert!(
                     matches!(
                         role(op, arn).await,
    diff --git a/crates/server/src/authorization/full_action.rs b/crates/server/src/authorization/full_action.rs
    new file mode 100644
    index 000000000..587f1934f
    --- /dev/null
    +++ b/crates/server/src/authorization/full_action.rs
    @@ -0,0 +1,405 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Authorization of an action named by its full action string.
    +//!
    +//! `check_authorization` evaluates a `DynamoDB` operation and builds its
    +//! `dynamodb:` action from the operation name. [`authorize_action`] takes the
    +//! action as given, so an action of another service, such as `s3:GetObject`
    +//! on an object ARN, is evaluated by the same user and role evaluators: the
    +//! identity and group policies, the permissions boundary, a role session's
    +//! session policy, the principal's tags, and the condition keys the caller
    +//! passes.
    +
    +use std::collections::HashMap;
    +use std::sync::Arc;
    +
    +use extenddb_auth::AuthIdentity;
    +use extenddb_auth::policy::context::RequestParams;
    +use extenddb_auth::policy::document::PolicyDocument;
    +use extenddb_core::error::DynamoDbError;
    +
    +use crate::authz_cache::CachedAuthzStore;
    +use crate::authz_resource::AuthzTarget;
    +
    +/// A role session's data as recorded earlier (a bulk operation records it at
    +/// Start), evaluated in place of the session's current data: its session
    +/// policy, its session tags and its issue time. The role's own policies,
    +/// boundary and tags are still read when the evaluation runs.
    +#[derive(Debug, Clone, Default)]
    +pub(crate) struct SessionSnapshot {
    +    pub(crate) policy: Option>,
    +    pub(crate) tags: HashMap,
    +    pub(crate) token_issue_time: Option,
    +}
    +
    +/// Evaluate whether `identity` may perform `action` on `resource_arn`.
    +///
    +/// `action` is the full action string, for example `s3:ListBucket`, and is
    +/// matched against each statement's `Action` the way a `dynamodb:` action is.
    +/// `params` carries the request's condition keys, such as `s3_prefix` for an
    +/// `s3:ListBucket`; the principal's keys are filled from `identity` as for
    +/// every other request. `dynamodb:FullTableScan` is absent, and
    +/// `aws:ResourceAccount` for an action of another service is the value in
    +/// `params` (see [`set_resource_account`]). For a role session, `session`
    +/// replaces the session's current policy, tags and issue time when given;
    +/// it is ignored for an IAM user.
    +///
    +/// # Errors
    +///
    +/// `AccessDeniedException` when the policies do not allow the action (see
    +/// [`denial_is_explicit`]), and the errors `check_authorization` returns when
    +/// a policy cannot be read.
    +pub(crate) async fn authorize_action(
    +    cache: &CachedAuthzStore,
    +    identity: &AuthIdentity,
    +    session: Option<&SessionSnapshot>,
    +    action: &str,
    +    resource_arn: &str,
    +    params: RequestParams,
    +) -> Result<(), DynamoDbError> {
    +    // The evaluators read the resource fields of the target and take the
    +    // action as passed, so the target's own action field is not read. The
    +    // resource is matched and named in a denial as given.
    +    let target = AuthzTarget::new(action, resource_arn.to_owned());
    +    match identity {
    +        AuthIdentity::User {
    +            account_id,
    +            user_name,
    +        } => {
    +            super::check_user_authorization(
    +                cache, account_id, user_name, action, &target, false, params,
    +            )
    +            .await
    +        }
    +        AuthIdentity::RoleSession {
    +            account_id,
    +            role_name,
    +            session_name,
    +        } => {
    +            super::check_role_authorization(
    +                cache,
    +                account_id,
    +                role_name,
    +                session_name,
    +                session,
    +                action,
    +                &target,
    +                false,
    +                params,
    +            )
    +            .await
    +        }
    +    }
    +}
    +
    +/// Whether `message`, the text of an `AccessDeniedException` that
    +/// [`authorize_action`] returned for `identity`, `action` and
    +/// `resource_arn`, reports a matching Deny statement. Both evaluators render
    +/// the text with `deny_message`, so it is compared with that function's
    +/// explicit form for the principal ARN the evaluators name; any other denial
    +/// text, such as the fail-closed text of a policy that does not parse, is not
    +/// explicit.
    +pub(crate) fn denial_is_explicit(
    +    identity: &AuthIdentity,
    +    action: &str,
    +    resource_arn: &str,
    +    message: &str,
    +) -> bool {
    +    let principal = match identity {
    +        AuthIdentity::User {
    +            account_id,
    +            user_name,
    +        } => format!("arn:aws:iam::{account_id}:user/{user_name}"),
    +        AuthIdentity::RoleSession {
    +            account_id,
    +            role_name,
    +            session_name,
    +        } => format!("arn:aws:iam::{account_id}:assumed-role/{role_name}/{session_name}"),
    +    };
    +    message == super::deny_message(&principal, action, resource_arn, true)
    +}
    +
    +/// The session policy, the role and session tags merged (session tags win on
    +/// a key both carry, as on the live path) and the issue time, for a role
    +/// session evaluation: from `snapshot` when one is given, the role's tags
    +/// read live; otherwise all read live.
    +pub(super) async fn session_and_tags(
    +    cache: &CachedAuthzStore,
    +    account_id: &str,
    +    role_name: &str,
    +    session_name: &str,
    +    snapshot: Option<&SessionSnapshot>,
    +) -> Result<
    +    (
    +        Option>,
    +        HashMap,
    +        Option,
    +    ),
    +    DynamoDbError,
    +> {
    +    let Some(snapshot) = snapshot else {
    +        return super::fetch_session_data_and_tags(cache, account_id, role_name, session_name)
    +            .await;
    +    };
    +    let role_tags = super::wrap_tags(cache.fetch_role_tags(account_id, role_name)).await?;
    +    let mut tags = (*role_tags).clone();
    +    for (key, value) in &snapshot.tags {
    +        tags.insert(key.clone(), value.clone());
    +    }
    +    Ok((
    +        snapshot.policy.clone(),
    +        tags,
    +        snapshot.token_issue_time.clone(),
    +    ))
    +}
    +
    +/// Set the `aws:ResourceAccount` value for `action`, evaluated for a caller
    +/// in `account_id`. ExtendDB builds every `DynamoDB` resource ARN with the
    +/// caller's account, so a `dynamodb:` action's resource account is that
    +/// account. Another service's resource names no account (an S3 bucket or
    +/// object ARN carries none), so for its actions the value the caller of the
    +/// evaluation passed stands: the bulk authorizer passes the caller's
    +/// account on the filesystem store, where every bucket is in it, and none
    +/// on the S3 store.
    +pub(super) fn set_resource_account(params: &mut RequestParams, action: &str, account_id: &str) {
    +    if action.starts_with("dynamodb:") {
    +        params.resource_account = Some(account_id.to_owned());
    +    }
    +}
    +
    +#[cfg(test)]
    +mod tests {
    +    use std::sync::Arc;
    +
    +    use extenddb_auth::AuthIdentity;
    +    use extenddb_auth::policy::context::RequestParams;
    +    use extenddb_core::error::DynamoDbError;
    +    use serde_json::{Value, json};
    +
    +    use super::authorize_action;
    +    use crate::authorization::check_authorization;
    +    use crate::authz_cache::CachedAuthzStore;
    +    use crate::authz_cache::tests::{CountingAuthzStore, fast_authz_cfg};
    +    use crate::authz_resource::AuthzTarget;
    +
    +    const ACCOUNT: &str = "123456789012";
    +
    +    fn user() -> AuthIdentity {
    +        AuthIdentity::User {
    +            account_id: ACCOUNT.to_owned(),
    +            user_name: "u".to_owned(),
    +        }
    +    }
    +
    +    fn role_session() -> AuthIdentity {
    +        AuthIdentity::RoleSession {
    +            account_id: ACCOUNT.to_owned(),
    +            role_name: "r".to_owned(),
    +            session_name: "s".to_owned(),
    +        }
    +    }
    +
    +    /// A cache over a store in which user `u` and role `r` both hold
    +    /// `policies` as their identity policies.
    +    fn cache_with(policies: &[Value]) -> CachedAuthzStore {
    +        let store = CountingAuthzStore::default();
    +        let docs: Vec = policies.iter().map(Value::to_string).collect();
    +        store
    +            .user_policies
    +            .lock()
    +            .unwrap()
    +            .insert((ACCOUNT.to_owned(), "u".to_owned()), docs.clone());
    +        store
    +            .role_policies
    +            .lock()
    +            .unwrap()
    +            .insert((ACCOUNT.to_owned(), "r".to_owned()), docs);
    +        CachedAuthzStore::new(Arc::new(store), fast_authz_cfg())
    +    }
    +
    +    fn statement(effect: &str, action: &str, resource: &str) -> Value {
    +        json!({"Effect": effect, "Action": action, "Resource": resource})
    +    }
    +
    +    fn policy(statements: Vec) -> Value {
    +        json!({"Version": "2012-10-17", "Statement": statements})
    +    }
    +
    +    fn listing(prefix: Option<&str>) -> RequestParams {
    +        RequestParams {
    +            s3_prefix: prefix.map(ToOwned::to_owned),
    +            ..Default::default()
    +        }
    +    }
    +
    +    /// The decision `authorize_action` reaches: `true` for an allow, `false`
    +    /// for an `AccessDeniedException`. Any other error fails the test.
    +    async fn allowed(
    +        cache: &CachedAuthzStore,
    +        who: &AuthIdentity,
    +        action: &str,
    +        resource: &str,
    +        params: RequestParams,
    +    ) -> bool {
    +        match authorize_action(cache, who, None, action, resource, params).await {
    +            Ok(()) => true,
    +            Err(DynamoDbError::AccessDeniedException(_)) => false,
    +            Err(other) => panic!("{action} on {resource}: expected a decision, got {other:?}"),
    +        }
    +    }
    +
    +    #[tokio::test]
    +    async fn get_object_is_allowed_only_on_keys_in_the_granted_bucket() {
    +        let cache = cache_with(&[policy(vec![statement(
    +            "Allow",
    +            "s3:GetObject",
    +            "arn:aws:s3:::b/*",
    +        )])]);
    +        for who in [user(), role_session()] {
    +            let decide = |action: &'static str, resource: &'static str| {
    +                allowed(&cache, &who, action, resource, RequestParams::default())
    +            };
    +            assert!(decide("s3:GetObject", "arn:aws:s3:::b/k").await, "{who:?}");
    +            assert!(
    +                decide("s3:GetObject", "arn:aws:s3:::b/a/b/c").await,
    +                "{who:?}"
    +            );
    +            // A key in another bucket, including one whose name starts with
    +            // the granted bucket's name, is denied.
    +            assert!(
    +                !decide("s3:GetObject", "arn:aws:s3:::other/k").await,
    +                "{who:?}"
    +            );
    +            assert!(
    +                !decide("s3:GetObject", "arn:aws:s3:::bb/k").await,
    +                "{who:?}"
    +            );
    +            // The bucket ARN is not an object in it, and the grant is for one
    +            // action only.
    +            assert!(!decide("s3:GetObject", "arn:aws:s3:::b").await, "{who:?}");
    +            assert!(!decide("s3:PutObject", "arn:aws:s3:::b/k").await, "{who:?}");
    +        }
    +    }
    +
    +    #[tokio::test]
    +    async fn list_bucket_under_a_string_like_prefix_condition() {
    +        let cache = cache_with(&[policy(vec![json!({
    +            "Effect": "Allow",
    +            "Action": "s3:ListBucket",
    +            "Resource": "arn:aws:s3:::b",
    +            "Condition": {"StringLike": {"s3:prefix": "loads/*"}}
    +        })])]);
    +        for who in [user(), role_session()] {
    +            let decide = |prefix: Option<&'static str>| {
    +                allowed(
    +                    &cache,
    +                    &who,
    +                    "s3:ListBucket",
    +                    "arn:aws:s3:::b",
    +                    listing(prefix),
    +                )
    +            };
    +            assert!(decide(Some("loads/")).await, "{who:?}");
    +            assert!(decide(Some("loads/2026/")).await, "{who:?}");
    +            assert!(!decide(Some("other/")).await, "{who:?}");
    +            // The whole bucket (an empty prefix) and a listing with no prefix
    +            // key at all do not match the pattern.
    +            assert!(!decide(Some("")).await, "{who:?}");
    +            assert!(!decide(None).await, "{who:?}");
    +            // The grant names the bucket: another bucket is denied with a
    +            // matching prefix.
    +            assert!(
    +                !allowed(
    +                    &cache,
    +                    &who,
    +                    "s3:ListBucket",
    +                    "arn:aws:s3:::c",
    +                    listing(Some("loads/"))
    +                )
    +                .await,
    +                "{who:?}"
    +            );
    +        }
    +    }
    +
    +    #[tokio::test]
    +    async fn an_explicit_deny_wins_over_an_allow() {
    +        // The Allow and the Deny sit in separate policies, so the Deny binds
    +        // across the principal's policy set.
    +        let cache = cache_with(&[
    +            policy(vec![statement("Allow", "s3:*", "*")]),
    +            policy(vec![statement(
    +                "Deny",
    +                "s3:GetObject",
    +                "arn:aws:s3:::b/private/*",
    +            )]),
    +        ]);
    +        for who in [user(), role_session()] {
    +            let decide = |action: &'static str, resource: &'static str| {
    +                allowed(&cache, &who, action, resource, RequestParams::default())
    +            };
    +            assert!(
    +                !decide("s3:GetObject", "arn:aws:s3:::b/private/k").await,
    +                "{who:?}"
    +            );
    +            assert!(
    +                decide("s3:GetObject", "arn:aws:s3:::b/public/k").await,
    +                "{who:?}"
    +            );
    +            // The Deny names one action; the Allow still grants the others on
    +            // the same key.
    +            assert!(
    +                decide("s3:PutObject", "arn:aws:s3:::b/private/k").await,
    +                "{who:?}"
    +            );
    +        }
    +    }
    +
    +    #[tokio::test]
    +    async fn an_s3_action_has_no_resource_account_and_a_dynamodb_action_keeps_it() {
    +        let absent = json!({"Null": {"aws:ResourceAccount": "true"}});
    +        let own = json!({"StringEquals": {"aws:ResourceAccount": ACCOUNT}});
    +        let cache = cache_with(&[policy(vec![
    +            json!({"Effect": "Allow", "Action": "s3:GetObject", "Resource": "*",
    +                   "Condition": absent.clone()}),
    +            json!({"Effect": "Allow", "Action": "s3:PutObject", "Resource": "*",
    +                   "Condition": own.clone()}),
    +            json!({"Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*",
    +                   "Condition": absent}),
    +            json!({"Effect": "Allow", "Action": "dynamodb:PutItem", "Resource": "*",
    +                   "Condition": own}),
    +        ])]);
    +        let table = format!("arn:aws:dynamodb:us-east-1:{ACCOUNT}:table/t");
    +        for who in [user(), role_session()] {
    +            let s3 = |action: &'static str| {
    +                allowed(
    +                    &cache,
    +                    &who,
    +                    action,
    +                    "arn:aws:s3:::b/k",
    +                    RequestParams::default(),
    +                )
    +            };
    +            // An S3 ARN names no account, so the key is absent for S3 actions.
    +            assert!(s3("s3:GetObject").await, "{who:?}");
    +            assert!(!s3("s3:PutObject").await, "{who:?}");
    +            // A DynamoDB operation still carries the caller's account.
    +            let dynamodb = |operation: &'static str| {
    +                let target = AuthzTarget::new(operation, table.clone());
    +                let (cache, who) = (&cache, &who);
    +                async move {
    +                    check_authorization(cache, who, &target, false, RequestParams::default()).await
    +                }
    +            };
    +            assert!(dynamodb("PutItem").await.is_ok(), "{who:?}");
    +            assert!(
    +                matches!(
    +                    dynamodb("GetItem").await,
    +                    Err(DynamoDbError::AccessDeniedException(_))
    +                ),
    +                "{who:?}"
    +            );
    +        }
    +    }
    +}
    diff --git a/crates/server/src/authz_resource/cross_account.rs b/crates/server/src/authz_resource/cross_account.rs
    index 148b0b92e..f4c7a9d79 100644
    --- a/crates/server/src/authz_resource/cross_account.rs
    +++ b/crates/server/src/authz_resource/cross_account.rs
    @@ -107,6 +107,11 @@ pub(crate) fn find(operation: &str, input: &Value, account_id: &str) -> Option None,
    +        // `request_helpers/bulk.rs` answers another account's ARN itself, before any policy.
    +        "StartBulkPutItems"
    +        | "DescribeBulkOperation"
    +        | "CancelBulkOperation"
    +        | "ListBulkOperations" => None,
             // An index query names the index ARN, built from the table ARN as
             // written (measured: `.../table//index/`).
             "Query" | "Scan" => {
    diff --git a/crates/server/src/bulk_authorization_tests/arn.rs b/crates/server/src/bulk_authorization_tests/arn.rs
    new file mode 100644
    index 000000000..306bca83a
    --- /dev/null
    +++ b/crates/server/src/bulk_authorization_tests/arn.rs
    @@ -0,0 +1,167 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Authorization of Describe, Cancel and List against bulk operation ARNs:
    +//! foreign accounts and regions, the two invalid-ARN layers, and the wildcard
    +//! resource.
    +
    +use super::*;
    +
    +#[tokio::test]
    +async fn a_describe_with_a_foreign_account_arn_is_denied_with_no_policy_evaluated() {
    +    // The policy allows everything, so the denial can only come from the
    +    // account check ahead of evaluation; the fetch counter proves no
    +    // policy and no storage row was consulted, so the answer does not
    +    // depend on whether the foreign operation exists.
    +    let (state, fetches) = state_with_policies(vec![allow("dynamodb:*", "*")], false).await;
    +    let foreign = operation_arn(REGION, OTHER_ACCOUNT);
    +    match authorize(&state, "DescribeBulkOperation", &arn_body(&foreign)).await {
    +        Err(DynamoDbError::AccessDeniedException(message)) => {
    +            assert_eq!(
    +                message,
    +                format!(
    +                    "User: arn:aws:iam::{ACCOUNT}:user/{USER} is not authorized \
    +                     to perform: dynamodb:DescribeBulkOperation on resource: {foreign}"
    +                )
    +            );
    +        }
    +        other => panic!("expected the denial, got {other:?}"),
    +    }
    +    assert_eq!(
    +        fetches.load(Ordering::SeqCst),
    +        0,
    +        "a foreign ARN must not be evaluated against any policy"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_cancel_with_a_foreign_region_arn_is_not_found_with_no_policy_evaluated() {
    +    let (state, fetches) = state_with_policies(vec![allow("dynamodb:*", "*")], false).await;
    +    let foreign = operation_arn("eu-west-1", ACCOUNT);
    +    match authorize(&state, "CancelBulkOperation", &arn_body(&foreign)).await {
    +        Err(DynamoDbError::ResourceNotFoundException(message)) => {
    +            assert_eq!(
    +                message,
    +                format!("Bulk operation with arn: {foreign} does not exist")
    +            );
    +        }
    +        other => panic!("expected the not-found answer, got {other:?}"),
    +    }
    +    assert_eq!(fetches.load(Ordering::SeqCst), 0);
    +}
    +
    +#[tokio::test]
    +async fn a_describe_with_a_layer_1_invalid_arn_is_rejected_before_any_evaluation() {
    +    let (state, fetches) = state_with_policies(vec![allow("dynamodb:*", "*")], false).await;
    +    let message = validation_message(
    +        authorize(&state, "DescribeBulkOperation", &arn_body("orders")).await,
    +        "a plain word in BulkOperationArn",
    +    );
    +    assert_eq!(message, NOT_AN_ARN);
    +    assert_eq!(fetches.load(Ordering::SeqCst), 0);
    +}
    +
    +#[tokio::test]
    +async fn a_describe_with_a_layer_2_invalid_arn_is_rejected_before_any_evaluation() {
    +    let (state, fetches) = state_with_policies(vec![allow("dynamodb:*", "*")], false).await;
    +    let uuid_shaped = format!(
    +        "arn:aws:dynamodb:{REGION}:{ACCOUNT}:bulk-operation/f81d4fae-7dec-11d0-a765-00a0c91e6bf6"
    +    );
    +    let message = validation_message(
    +        authorize(&state, "DescribeBulkOperation", &arn_body(&uuid_shaped)).await,
    +        "a uuid-shaped operation id",
    +    );
    +    assert_eq!(message, BAD_ID);
    +    assert_eq!(fetches.load(Ordering::SeqCst), 0);
    +}
    +
    +#[tokio::test]
    +async fn a_deny_on_the_bulk_operation_wildcard_cannot_be_evaded_by_respelling_the_arn() {
    +    // Policy resource matching is literal, so a Deny written against the
    +    // documented ARN shape can never match a partition or service
    +    // respelling. The parse therefore refuses a respelling at layer 2,
    +    // before any policy is fetched; only the canonical spelling reaches
    +    // evaluation, where the Deny binds.
    +    let wildcard = format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:bulk-operation/*");
    +    let (state, fetches) =
    +        state_with_policies(vec![allow_all_with_deny("dynamodb:*", &wildcard)], false).await;
    +
    +    for respelled in [
    +        format!("arn:aws-cn:dynamodb:{REGION}:{ACCOUNT}:bulk-operation/{OPERATION_ID}"),
    +        format!("arn:aws:s3:{REGION}:{ACCOUNT}:bulk-operation/{OPERATION_ID}"),
    +    ] {
    +        for operation in ["DescribeBulkOperation", "CancelBulkOperation"] {
    +            let message = validation_message(
    +                authorize(&state, operation, &arn_body(&respelled)).await,
    +                "a respelled bulk operation ARN",
    +            );
    +            assert_eq!(message, BAD_ID);
    +        }
    +    }
    +    assert_eq!(
    +        fetches.load(Ordering::SeqCst),
    +        0,
    +        "a respelled ARN must be refused before any policy fetch"
    +    );
    +
    +    let canonical = operation_arn(REGION, ACCOUNT);
    +    assert_denied(
    +        authorize(&state, "DescribeBulkOperation", &arn_body(&canonical)).await,
    +        "the Deny binds against the canonical Describe spelling",
    +    );
    +    assert_denied(
    +        authorize(&state, "CancelBulkOperation", &arn_body(&canonical)).await,
    +        "the Deny binds against the canonical Cancel spelling",
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_describe_with_the_callers_own_arn_is_evaluated_against_that_arn() {
    +    // Allowed on the exact operation ARN: the resource evaluated must be
    +    // the ARN itself, so this passes; a wildcard-table resource derivation
    +    // would deny it.
    +    let own = operation_arn(REGION, ACCOUNT);
    +    let (state, _fetches) =
    +        state_with_policies(vec![allow("dynamodb:DescribeBulkOperation", &own)], false).await;
    +    authorize(&state, "DescribeBulkOperation", &arn_body(&own))
    +        .await
    +        .expect("the caller's own ARN is the evaluated resource");
    +
    +    // The same policy does not grant Cancel.
    +    let (state, _fetches) =
    +        state_with_policies(vec![allow("dynamodb:DescribeBulkOperation", &own)], false).await;
    +    assert_denied(
    +        authorize(&state, "CancelBulkOperation", &arn_body(&own)).await,
    +        "Describe alone must not grant Cancel",
    +    );
    +}
    +
    +// ListBulkOperations: the resource is the account's bulk-operation wildcard.
    +
    +#[tokio::test]
    +async fn list_is_evaluated_against_the_wildcard_bulk_operation_resource() {
    +    let wildcard = format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:bulk-operation/*");
    +    let (state, _fetches) =
    +        state_with_policies(vec![allow("dynamodb:ListBulkOperations", &wildcard)], false).await;
    +    authorize(&state, "ListBulkOperations", &json!({}))
    +        .await
    +        .expect("an allow on the bulk-operation wildcard grants List");
    +
    +    // An allow on every table does not reach the bulk-operation resource,
    +    // so a derivation that still evaluates List against `table/*` fails
    +    // here.
    +    let (state, _fetches) = state_with_policies(
    +        vec![allow(
    +            "dynamodb:ListBulkOperations",
    +            &format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/*"),
    +        )],
    +        false,
    +    )
    +    .await;
    +    assert_denied(
    +        authorize(&state, "ListBulkOperations", &json!({})).await,
    +        "a table wildcard must not grant List over bulk operations",
    +    );
    +}
    +
    +// Developer mode: the early return sits above every bulk branch.
    diff --git a/crates/server/src/bulk_authorization_tests/conditions.rs b/crates/server/src/bulk_authorization_tests/conditions.rs
    new file mode 100644
    index 000000000..6915f923c
    --- /dev/null
    +++ b/crates/server/src/bulk_authorization_tests/conditions.rs
    @@ -0,0 +1,90 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Condition keys on the bulk operations. A bulk-operation resource never
    +//! carries tags, and a table may carry none, so a resource tag key is absent
    +//! from every Describe, Cancel and List request and from a Start on an
    +//! untagged table. Under IAM's rule a negated operator on an absent key
    +//! evaluates true, so a Deny gated on one applies; a positive operator on an
    +//! absent key does not match, so a Deny gated on one does not.
    +
    +use super::*;
    +
    +/// Allow every action, and Deny `deny_action` on every resource when the
    +/// condition block `condition` holds.
    +fn allow_all_with_conditional_deny(deny_action: &str, condition: Value) -> String {
    +    json!({
    +        "Version": "2012-10-17",
    +        "Statement": [
    +            {"Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"},
    +            {"Effect": "Deny", "Action": deny_action, "Resource": "*", "Condition": condition}
    +        ]
    +    })
    +    .to_string()
    +}
    +
    +#[tokio::test]
    +async fn a_deny_with_a_negated_operator_on_an_absent_tag_binds_on_describe() {
    +    let policy = allow_all_with_conditional_deny(
    +        "dynamodb:DescribeBulkOperation",
    +        json!({"StringNotEquals": {"aws:ResourceTag/env": "prod"}}),
    +    );
    +    let (state, _) = state_with_policies(vec![policy], false).await;
    +    let own = operation_arn(REGION, ACCOUNT);
    +    assert_denied(
    +        authorize(&state, "DescribeBulkOperation", &arn_body(&own)).await,
    +        "StringNotEquals on a tag the operation cannot carry",
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_deny_with_each_negated_operator_on_an_absent_key_binds_on_cancel_and_list() {
    +    let conditions = [
    +        json!({"StringNotEquals": {"aws:ResourceTag/env": "prod"}}),
    +        json!({"StringNotLike": {"aws:ResourceTag/env": "pr*"}}),
    +        json!({"StringNotEqualsIgnoreCase": {"aws:ResourceTag/env": "PROD"}}),
    +        json!({"StringNotEqualsIfExists": {"aws:ResourceTag/env": "prod"}}),
    +    ];
    +    let own = operation_arn(REGION, ACCOUNT);
    +    for condition in conditions {
    +        let policy = allow_all_with_conditional_deny("dynamodb:*", condition.clone());
    +        let (state, _) = state_with_policies(vec![policy], false).await;
    +        assert_denied(
    +            authorize(&state, "CancelBulkOperation", &arn_body(&own)).await,
    +            &format!("Cancel under {condition}"),
    +        );
    +        assert_denied(
    +            authorize(&state, "ListBulkOperations", &json!({})).await,
    +            &format!("List under {condition}"),
    +        );
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_deny_with_a_negated_operator_on_an_absent_tag_binds_on_start() {
    +    let policy = allow_all_with_conditional_deny(
    +        "dynamodb:BulkPutItems",
    +        json!({"StringNotEquals": {"aws:ResourceTag/env": "prod"}}),
    +    );
    +    let (state, _) = state_with_policies(vec![policy], false).await;
    +    assert_denied(
    +        authorize(&state, "StartBulkPutItems", &start_body(TABLE)).await,
    +        "StringNotEquals on a tag the untagged table does not carry",
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_deny_with_a_positive_operator_on_an_absent_tag_does_not_bind() {
    +    let policy = allow_all_with_conditional_deny(
    +        "dynamodb:*",
    +        json!({"StringEquals": {"aws:ResourceTag/env": "prod"}}),
    +    );
    +    let (state, _) = state_with_policies(vec![policy], false).await;
    +    let own = operation_arn(REGION, ACCOUNT);
    +    authorize(&state, "DescribeBulkOperation", &arn_body(&own))
    +        .await
    +        .expect("StringEquals on an absent key does not match, so the Deny does not apply");
    +    authorize(&state, "StartBulkPutItems", &start_body(TABLE))
    +        .await
    +        .expect("the same holds for a Start on an untagged table");
    +}
    diff --git a/crates/server/src/bulk_authorization_tests/mod.rs b/crates/server/src/bulk_authorization_tests/mod.rs
    new file mode 100644
    index 000000000..4d686aa06
    --- /dev/null
    +++ b/crates/server/src/bulk_authorization_tests/mod.rs
    @@ -0,0 +1,356 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Behaviour tests for the bulk operation authorization rules, written from
    +//! the specification's Authorization table.
    +//!
    +//! Every test drives `authorize_request` itself against a real `AppState`
    +//! (in-memory `SQLite` storage, a fresh authorization cache per test), so
    +//! the resource derivation, the pre-evaluation ARN checks, and the dev-mode
    +//! bypass are exercised through the same path a request takes. The
    +//! authorization store underneath the cache is a test double that serves
    +//! one canned policy set for the test principal and counts every fetch, so
    +//! "no policy was evaluated" is asserted as zero fetches against a cache
    +//! built fresh for the test.
    +//!
    +//! The cases live in `start` (StartBulkPutItems grants, table ARN spellings,
    +//! dev mode) and `arn` (Describe, Cancel and List against operation ARNs).
    +
    +use std::sync::Arc;
    +use std::sync::atomic::{AtomicU64, Ordering};
    +
    +use axum::http::HeaderMap;
    +use extenddb_auth::{AuthCacheRegistry, AuthIdentity, AuthProvider};
    +use extenddb_cache::SwrCacheConfig;
    +use extenddb_core::error::DynamoDbError;
    +use extenddb_core::limits::LimitsConfig;
    +use extenddb_core::metrics::MetricsCollector;
    +use extenddb_core::throttle::ThrottleManager;
    +use extenddb_storage::authorization_store::{AuthorizationStore, SessionData};
    +use extenddb_storage::management_store::OpResult;
    +use extenddb_storage::server_components::ServerComponentsOptions;
    +use futures::future::BoxFuture;
    +use serde_json::{Value, json};
    +
    +use crate::AppState;
    +use crate::authz_cache::{AuthzCacheConfig, CachedAuthzStore};
    +use crate::key_info_cache::CachedTableKeyInfoStore;
    +use crate::request_helpers::authorize_request;
    +
    +mod arn;
    +mod conditions;
    +mod s3_actions;
    +mod start;
    +
    +pub(super) const REGION: &str = "us-east-1";
    +
    +pub(super) const ACCOUNT: &str = "123456789012";
    +
    +pub(super) const OTHER_ACCOUNT: &str = "999999999999";
    +
    +pub(super) const USER: &str = "tester";
    +
    +pub(super) const TABLE: &str = "orders";
    +
    +pub(super) const OTHER_TABLE: &str = "invoices";
    +
    +/// A well-formed operation id, so foreign-account ARNs pass layers 1 and 2.
    +pub(super) const OPERATION_ID: &str = "01789060589767-da9ddd4f";
    +
    +/// Layer 1 of the operation ARN validation.
    +pub(super) const NOT_AN_ARN: &str = "Invalid Bulk operation ARN";
    +/// Layer 2 of the operation ARN validation.
    +pub(super) const BAD_ID: &str = "Invalid Request: Bulk operation ARN is invalid.";
    +
    +pub(super) fn table_arn(account: &str, table: &str) -> String {
    +    format!("arn:aws:dynamodb:{REGION}:{account}:table/{table}")
    +}
    +
    +pub(super) fn operation_arn(region: &str, account: &str) -> String {
    +    format!("arn:aws:dynamodb:{region}:{account}:bulk-operation/{OPERATION_ID}")
    +}
    +
    +/// Authentication is upstream of everything these tests drive; the provider
    +/// only fills the `AppState` field.
    +pub(super) struct NeverAuth;
    +
    +#[async_trait::async_trait]
    +
    +impl AuthProvider for NeverAuth {
    +    async fn authenticate(
    +        &self,
    +        _headers: &HeaderMap,
    +        _body: &[u8],
    +    ) -> Result {
    +        unreachable!("authorization tests never authenticate")
    +    }
    +}
    +
    +/// Serves one canned policy list for the test principal and counts every
    +/// fetch. The cache above it is built fresh per test, so a fetch count of
    +/// zero after a call means no policy evaluation path ran at all.
    +pub(super) struct CountingAuthzStore {
    +    policies: Vec,
    +    fetches: Arc,
    +}
    +
    +impl CountingAuthzStore {
    +    fn count(&self) -> u64 {
    +        self.fetches.fetch_add(1, Ordering::SeqCst)
    +    }
    +}
    +
    +impl AuthorizationStore for CountingAuthzStore {
    +    fn fetch_user_policies(
    +        &self,
    +        _account_id: &str,
    +        _user_name: &str,
    +    ) -> BoxFuture<'_, OpResult>> {
    +        self.count();
    +        let policies = self.policies.clone();
    +        Box::pin(async move { Ok(policies) })
    +    }
    +
    +    fn fetch_user_group_policies(
    +        &self,
    +        _account_id: &str,
    +        _user_name: &str,
    +    ) -> BoxFuture<'_, OpResult>> {
    +        self.count();
    +        Box::pin(async { Ok(Vec::new()) })
    +    }
    +
    +    fn fetch_user_boundary(
    +        &self,
    +        _account_id: &str,
    +        _user_name: &str,
    +    ) -> BoxFuture<'_, OpResult>> {
    +        self.count();
    +        Box::pin(async { Ok(None) })
    +    }
    +
    +    fn fetch_role_policies(
    +        &self,
    +        _account_id: &str,
    +        _role_name: &str,
    +    ) -> BoxFuture<'_, OpResult>> {
    +        self.count();
    +        Box::pin(async { Ok(Vec::new()) })
    +    }
    +
    +    fn fetch_role_boundary(
    +        &self,
    +        _account_id: &str,
    +        _role_name: &str,
    +    ) -> BoxFuture<'_, OpResult>> {
    +        self.count();
    +        Box::pin(async { Ok(None) })
    +    }
    +
    +    fn fetch_session_data(
    +        &self,
    +        _account_id: &str,
    +        _role_name: &str,
    +        _session_name: &str,
    +    ) -> BoxFuture<'_, OpResult>> {
    +        self.count();
    +        Box::pin(async { Ok(None) })
    +    }
    +
    +    fn fetch_user_tags(
    +        &self,
    +        _account_id: &str,
    +        _user_name: &str,
    +    ) -> BoxFuture<'_, OpResult>> {
    +        self.count();
    +        Box::pin(async { Ok(Vec::new()) })
    +    }
    +
    +    fn fetch_role_tags(
    +        &self,
    +        _account_id: &str,
    +        _role_name: &str,
    +    ) -> BoxFuture<'_, OpResult>> {
    +        self.count();
    +        Box::pin(async { Ok(Vec::new()) })
    +    }
    +
    +    fn fetch_resource_tags(&self, _arn: &str) -> BoxFuture<'_, OpResult>> {
    +        self.count();
    +        Box::pin(async { Ok(Vec::new()) })
    +    }
    +
    +    fn fetch_user_created_at(
    +        &self,
    +        _account_id: &str,
    +        _user_name: &str,
    +    ) -> BoxFuture<'_, OpResult>> {
    +        self.count();
    +        Box::pin(async { Ok(None) })
    +    }
    +
    +    fn fetch_role_created_at(
    +        &self,
    +        _account_id: &str,
    +        _role_name: &str,
    +    ) -> BoxFuture<'_, OpResult>> {
    +        self.count();
    +        Box::pin(async { Ok(None) })
    +    }
    +}
    +
    +/// An `AppState` whose authorization cache serves exactly `policies` for the
    +/// test principal, plus the fetch counter that proves whether any policy
    +/// evaluation path ran.
    +pub(super) async fn state_with_policies(
    +    policies: Vec,
    +    dev_mode: bool,
    +) -> (AppState, Arc) {
    +    let config = extenddb_storage_sqlite::SqliteConfig {
    +        path: ":memory:".to_owned(),
    +        pool_size: 2,
    +    };
    +    let components = (extenddb_storage_sqlite::backend().server_components)(
    +        &config,
    +        REGION,
    +        ServerComponentsOptions::default(),
    +    )
    +    .await
    +    .expect("server components");
    +    let storage = components.engine;
    +
    +    let fetches = Arc::new(AtomicU64::new(0));
    +    let counting = Arc::new(CountingAuthzStore {
    +        policies,
    +        fetches: Arc::clone(&fetches),
    +    });
    +    let authz_cache = Arc::new(CachedAuthzStore::new(counting, AuthzCacheConfig::default()));
    +    let table_key_info_cache = Arc::new(CachedTableKeyInfoStore::new(
    +        Arc::clone(&storage),
    +        SwrCacheConfig::default(),
    +    ));
    +
    +    let limits = Arc::new(LimitsConfig::default());
    +    let state = AppState {
    +        storage,
    +        backup_runtime: Arc::new(extenddb_engine::BackupRuntime::new(4)),
    +        auth: Arc::new(NeverAuth),
    +        limits: Arc::clone(&limits),
    +        region: Arc::from(REGION),
    +        server_addr: "localhost:0".to_owned(),
    +        catalog_store: Some(components.catalog_store),
    +        version_info: Arc::from("test"),
    +        metrics: Arc::new(MetricsCollector::new()),
    +        tls_enabled: false,
    +        dev_mode,
    +        import_paths: Vec::new().into(),
    +        export_paths: Vec::new().into(),
    +        bulk: None,
    +        throttle: Arc::new(ThrottleManager::new(
    +            limits.per_account_max_rcu,
    +            limits.per_account_max_wcu,
    +            false,
    +        )),
    +        auth_cache: AuthCacheRegistry::empty(),
    +        authz_cache,
    +        table_key_info_cache,
    +        config_entries: Vec::new(),
    +        docs_store: None,
    +        admission: crate::registration::Admission::default(),
    +    };
    +    (state, fetches)
    +}
    +
    +pub(super) fn identity() -> AuthIdentity {
    +    AuthIdentity::User {
    +        account_id: ACCOUNT.to_owned(),
    +        user_name: USER.to_owned(),
    +    }
    +}
    +
    +pub(super) async fn authorize(
    +    state: &AppState,
    +    operation: &str,
    +    body: &Value,
    +) -> Result<(), DynamoDbError> {
    +    // The authorize_request future is around 20 KB; boxing it here keeps
    +    // every await in this file under clippy's large_futures bar.
    +    Box::pin(authorize_request(
    +        state,
    +        &identity(),
    +        body,
    +        operation,
    +        ACCOUNT,
    +        None,
    +        None,
    +    ))
    +    .await
    +    .map(|_key_info| ())
    +}
    +
    +pub(super) fn allow(action: &str, resource: &str) -> String {
    +    json!({
    +        "Version": "2012-10-17",
    +        "Statement": [{"Effect": "Allow", "Action": action, "Resource": resource}]
    +    })
    +    .to_string()
    +}
    +
    +pub(super) fn allow_all_with_deny(deny_action: &str, deny_resource: &str) -> String {
    +    json!({
    +        "Version": "2012-10-17",
    +        "Statement": [
    +            {"Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"},
    +            {"Effect": "Deny", "Action": deny_action, "Resource": deny_resource}
    +        ]
    +    })
    +    .to_string()
    +}
    +
    +pub(super) fn start_body(table_name: &str) -> Value {
    +    json!({ "TableName": table_name })
    +}
    +
    +pub(super) fn arn_body(operation_arn: &str) -> Value {
    +    json!({ "BulkOperationArn": operation_arn })
    +}
    +
    +pub(super) fn assert_denied(result: Result<(), DynamoDbError>, what: &str) {
    +    match result {
    +        Err(DynamoDbError::AccessDeniedException(_)) => {}
    +        other => panic!("{what}: expected AccessDeniedException, got {other:?}"),
    +    }
    +}
    +
    +pub(super) fn validation_message(result: Result<(), DynamoDbError>, what: &str) -> String {
    +    match result {
    +        Err(DynamoDbError::ValidationException(message)) => message,
    +        other => panic!("{what}: expected ValidationException, got {other:?}"),
    +    }
    +}
    +
    +// StartBulkPutItems: two actions, both required. The account-scoped
    +// dynamodb:StartBulkOperation is evaluated against the bulk-operation
    +// wildcard, then the table-scoped dynamodb:BulkPutItems against the table
    +// ARN, with the own-ARN spelling of TableName resolved to the bare name
    +// before evaluation.
    +
    +/// A policy carrying both Start grants: the control-plane action on the
    +/// account's bulk-operation wildcard and the data-plane action on `table`.
    +pub(super) fn start_grants(table: &str) -> Vec {
    +    vec![
    +        allow("dynamodb:StartBulkOperation", &operation_wildcard()),
    +        allow("dynamodb:BulkPutItems", &table_arn(ACCOUNT, table)),
    +    ]
    +}
    +
    +pub(super) fn operation_wildcard() -> String {
    +    format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:bulk-operation/*")
    +}
    +
    +pub(super) fn denial_message(result: Result<(), DynamoDbError>, what: &str) -> String {
    +    match result {
    +        Err(DynamoDbError::AccessDeniedException(message)) => message,
    +        other => panic!("{what}: expected AccessDeniedException, got {other:?}"),
    +    }
    +}
    diff --git a/crates/server/src/bulk_authorization_tests/s3_actions.rs b/crates/server/src/bulk_authorization_tests/s3_actions.rs
    new file mode 100644
    index 000000000..d79094d1c
    --- /dev/null
    +++ b/crates/server/src/bulk_authorization_tests/s3_actions.rs
    @@ -0,0 +1,630 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The server's bulk operation authorizer: an S3 action evaluated for a
    +//! caller recorded at Start, against the caller's identity policies and
    +//! permissions boundary read through the cache, with a role session's policy,
    +//! tags and issue time taken from the recording.
    +
    +use std::collections::BTreeMap;
    +use std::path::PathBuf;
    +use std::sync::Arc;
    +
    +use extenddb_auth::AuthIdentity;
    +use extenddb_engine::bulk_put::authorize::{
    +    AuthorizeError, BulkAuthorizer, BulkIdentity, BulkPrincipal, S3Action, SessionSnapshot,
    +};
    +use extenddb_engine::bulk_put::store::BulkStore;
    +use extenddb_storage::authorization_store::{AuthorizationStore, SessionData};
    +use extenddb_storage::management_store::OpResult;
    +use futures::future::BoxFuture;
    +use serde_json::{Value, json};
    +
    +use super::{ACCOUNT, state_with_policies};
    +use crate::authz_cache::{AuthzCacheConfig, CachedAuthzStore};
    +use crate::bulk_authorizer::{ServerBulkAuthorizer, capture_principal, start_caller};
    +
    +/// Serves one policy set for every principal: the identity policies, an
    +/// optional permissions boundary, the role's tags, and the session's current
    +/// data (which an evaluation of a recorded caller must not read).
    +#[derive(Default)]
    +struct Store {
    +    policies: Vec,
    +    boundary: Option,
    +    role_tags: Vec<(String, String)>,
    +    live_session: Option,
    +}
    +
    +fn ready(value: T) -> BoxFuture<'static, OpResult> {
    +    Box::pin(async move { Ok(value) })
    +}
    +
    +impl AuthorizationStore for Store {
    +    fn fetch_user_policies(&self, _: &str, _: &str) -> BoxFuture<'_, OpResult>> {
    +        ready(self.policies.clone())
    +    }
    +
    +    fn fetch_user_group_policies(&self, _: &str, _: &str) -> BoxFuture<'_, OpResult>> {
    +        ready(Vec::new())
    +    }
    +
    +    fn fetch_user_boundary(&self, _: &str, _: &str) -> BoxFuture<'_, OpResult>> {
    +        ready(self.boundary.clone())
    +    }
    +
    +    fn fetch_role_policies(&self, _: &str, _: &str) -> BoxFuture<'_, OpResult>> {
    +        ready(self.policies.clone())
    +    }
    +
    +    fn fetch_role_boundary(&self, _: &str, _: &str) -> BoxFuture<'_, OpResult>> {
    +        ready(self.boundary.clone())
    +    }
    +
    +    fn fetch_session_data(
    +        &self,
    +        _: &str,
    +        _: &str,
    +        _: &str,
    +    ) -> BoxFuture<'_, OpResult>> {
    +        ready(self.live_session.clone())
    +    }
    +
    +    fn fetch_user_tags(&self, _: &str, _: &str) -> BoxFuture<'_, OpResult>> {
    +        ready(Vec::new())
    +    }
    +
    +    fn fetch_role_tags(&self, _: &str, _: &str) -> BoxFuture<'_, OpResult>> {
    +        ready(self.role_tags.clone())
    +    }
    +
    +    fn fetch_resource_tags(&self, _: &str) -> BoxFuture<'_, OpResult>> {
    +        ready(Vec::new())
    +    }
    +
    +    fn fetch_user_created_at(&self, _: &str, _: &str) -> BoxFuture<'_, OpResult>> {
    +        ready(None)
    +    }
    +
    +    fn fetch_role_created_at(&self, _: &str, _: &str) -> BoxFuture<'_, OpResult>> {
    +        ready(None)
    +    }
    +}
    +
    +fn authorizer(store: Store) -> ServerBulkAuthorizer {
    +    let cache = CachedAuthzStore::new(Arc::new(store), AuthzCacheConfig::default());
    +    ServerBulkAuthorizer::new(Arc::new(cache), Arc::from("us-east-1"))
    +}
    +
    +fn policy(statements: &[Value]) -> String {
    +    json!({"Version": "2012-10-17", "Statement": statements}).to_string()
    +}
    +
    +fn allow(action: &str, resource: &str) -> Value {
    +    json!({"Effect": "Allow", "Action": action, "Resource": resource})
    +}
    +
    +fn user() -> BulkPrincipal {
    +    BulkPrincipal {
    +        identity: BulkIdentity::User {
    +            account_id: ACCOUNT.to_owned(),
    +            user_name: "u".to_owned(),
    +        },
    +        session: None,
    +        source_ip: None,
    +        user_agent: None,
    +        secure_transport: false,
    +    }
    +}
    +
    +fn role(session: Option) -> BulkPrincipal {
    +    BulkPrincipal {
    +        identity: BulkIdentity::RoleSession {
    +            account_id: ACCOUNT.to_owned(),
    +            role_name: "r".to_owned(),
    +            session_name: "s".to_owned(),
    +        },
    +        session,
    +        source_ip: None,
    +        user_agent: None,
    +        secure_transport: false,
    +    }
    +}
    +
    +async fn decide(
    +    authorizer: &ServerBulkAuthorizer,
    +    principal: &BulkPrincipal,
    +    action: S3Action,
    +    resource: &str,
    +) -> Result<(), AuthorizeError> {
    +    authorizer
    +        .authorize(principal, action, resource, None)
    +        .await
    +}
    +
    +const IMPLICIT: Result<(), AuthorizeError> = Err(AuthorizeError::Denied { explicit: false });
    +const EXPLICIT: Result<(), AuthorizeError> = Err(AuthorizeError::Denied { explicit: true });
    +
    +#[tokio::test]
    +async fn get_object_is_allowed_in_the_granted_bucket_only() {
    +    let a = authorizer(Store {
    +        policies: vec![policy(&[allow("s3:GetObject", "arn:aws:s3:::b/*")])],
    +        ..Store::default()
    +    });
    +    for who in [user(), role(None)] {
    +        let get = |resource: &'static str| decide(&a, &who, S3Action::GetObject, resource);
    +        assert_eq!(get("arn:aws:s3:::b/k").await, Ok(()), "{who:?}");
    +        assert_eq!(get("arn:aws:s3:::other/k").await, IMPLICIT, "{who:?}");
    +        assert_eq!(
    +            decide(&a, &who, S3Action::PutObject, "arn:aws:s3:::b/k").await,
    +            IMPLICIT,
    +            "{who:?}"
    +        );
    +    }
    +}
    +
    +/// In a `2012-10-17` document a policy variable in `Resource` is expanded
    +/// from the recorded caller, as for the DynamoDB actions, so the statement
    +/// grants the user the folder named after it, report keys with a `:`
    +/// included. A role session has no `aws:username`, so it gets nothing. A
    +/// `2008-10-17` document matches the pattern as written.
    +#[tokio::test]
    +async fn a_resource_policy_variable_names_the_callers_own_folder() {
    +    async fn get(a: &ServerBulkAuthorizer, resource: &str) -> Result<(), AuthorizeError> {
    +        decide(a, &user(), S3Action::GetObject, resource).await
    +    }
    +    let statement = allow("s3:GetObject", "arn:aws:s3:::b/home/${aws:username}/*");
    +    let expanding = authorizer(Store {
    +        policies: vec![policy(&[
    +            statement.clone(),
    +            allow("s3:PutObject", "arn:aws:s3:::b/home/${aws:username}/*"),
    +        ])],
    +        ..Store::default()
    +    });
    +    assert_eq!(get(&expanding, "arn:aws:s3:::b/home/u/k").await, Ok(()));
    +    assert_eq!(
    +        get(&expanding, "arn:aws:s3:::b/home/other/k").await,
    +        IMPLICIT
    +    );
    +    assert_eq!(
    +        get(&expanding, "arn:aws:s3:::b/home/${aws:username}/k").await,
    +        IMPLICIT
    +    );
    +    assert_eq!(
    +        decide(
    +            &expanding,
    +            &role(None),
    +            S3Action::GetObject,
    +            "arn:aws:s3:::b/home/u/k"
    +        )
    +        .await,
    +        IMPLICIT
    +    );
    +    // A failed-row report key: its operation folder holds a `:`.
    +    let report = "AWSDynamoDB/BulkOperation/123456789012:op-0/output/errors/output-00000.json.gz";
    +    for (folder, expected) in [("u", Ok(())), ("other", IMPLICIT)] {
    +        let resource = format!("arn:aws:s3:::b/home/{folder}/{report}");
    +        assert_eq!(
    +            decide(&expanding, &user(), S3Action::PutObject, &resource).await,
    +            expected,
    +            "{resource}"
    +        );
    +    }
    +
    +    let literal = authorizer(Store {
    +        policies: vec![json!({"Version": "2008-10-17", "Statement": [statement]}).to_string()],
    +        ..Store::default()
    +    });
    +    assert_eq!(get(&literal, "arn:aws:s3:::b/home/u/k").await, IMPLICIT);
    +    assert_eq!(
    +        get(&literal, "arn:aws:s3:::b/home/${aws:username}/k").await,
    +        Ok(())
    +    );
    +}
    +
    +#[tokio::test]
    +async fn list_bucket_is_decided_on_the_s3_prefix() {
    +    let a = authorizer(Store {
    +        policies: vec![policy(&[json!({
    +            "Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::b",
    +            "Condition": {"StringLike": {"s3:prefix": "loads/*"}}
    +        })])],
    +        ..Store::default()
    +    });
    +    for who in [user(), role(None)] {
    +        let list = |prefix: &'static str| {
    +            a.authorize(&who, S3Action::ListBucket, "arn:aws:s3:::b", Some(prefix))
    +        };
    +        assert_eq!(list("loads/").await, Ok(()), "{who:?}");
    +        assert_eq!(list("other/").await, IMPLICIT, "{who:?}");
    +        assert_eq!(list("").await, IMPLICIT, "{who:?}");
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_denial_reports_whether_a_deny_statement_matched() {
    +    let a = authorizer(Store {
    +        policies: vec![policy(&[
    +            allow("s3:*", "*"),
    +            json!({"Effect": "Deny", "Action": "s3:PutObject", "Resource": "arn:aws:s3:::b/private/*"}),
    +        ])],
    +        ..Store::default()
    +    });
    +    for who in [user(), role(None)] {
    +        let put = |resource: &'static str| decide(&a, &who, S3Action::PutObject, resource);
    +        assert_eq!(put("arn:aws:s3:::b/private/k").await, EXPLICIT, "{who:?}");
    +        assert_eq!(put("arn:aws:s3:::b/public/k").await, Ok(()), "{who:?}");
    +    }
    +    let none = authorizer(Store::default());
    +    assert_eq!(
    +        decide(&none, &user(), S3Action::GetObject, "arn:aws:s3:::b/k").await,
    +        IMPLICIT
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_recorded_session_policy_narrows_the_role() {
    +    let a = authorizer(Store {
    +        policies: vec![policy(&[allow("s3:*", "*")])],
    +        ..Store::default()
    +    });
    +    let narrowed = role(Some(SessionSnapshot {
    +        policy: Some(policy(&[allow("s3:GetObject", "arn:aws:s3:::b/*")])),
    +        ..SessionSnapshot::default()
    +    }));
    +    let get = decide(&a, &narrowed, S3Action::GetObject, "arn:aws:s3:::b/k").await;
    +    assert_eq!(get, Ok(()));
    +    let put = decide(&a, &narrowed, S3Action::PutObject, "arn:aws:s3:::b/k").await;
    +    assert_eq!(put, IMPLICIT);
    +    let other = decide(&a, &narrowed, S3Action::GetObject, "arn:aws:s3:::c/k").await;
    +    assert_eq!(other, IMPLICIT);
    +}
    +
    +#[tokio::test]
    +async fn the_recorded_session_is_evaluated_in_place_of_the_current_one() {
    +    // The session's current data carries a policy that allows nothing; the
    +    // recording, made at Start, carries none. The recording decides.
    +    let a = authorizer(Store {
    +        policies: vec![policy(&[allow("s3:*", "*")])],
    +        live_session: Some(SessionData {
    +            session_policy: Some(policy(&[allow("dynamodb:GetItem", "*")])),
    +            session_tags: Vec::new(),
    +            created_at: None,
    +        }),
    +        ..Store::default()
    +    });
    +    let recorded = role(Some(SessionSnapshot::default()));
    +    let get = decide(&a, &recorded, S3Action::GetObject, "arn:aws:s3:::b/k").await;
    +    assert_eq!(
    +        get,
    +        Ok(()),
    +        "the recorded session has no policy to narrow it"
    +    );
    +    // A role recorded with no session data evaluates with none as well.
    +    assert_eq!(
    +        decide(&a, &role(None), S3Action::GetObject, "arn:aws:s3:::b/k").await,
    +        Ok(())
    +    );
    +}
    +
    +#[tokio::test]
    +async fn recorded_session_tags_are_the_principal_tags() {
    +    let a = authorizer(Store {
    +        policies: vec![policy(&[json!({
    +            "Effect": "Allow", "Action": "s3:GetObject", "Resource": "*",
    +            "Condition": {"StringEquals": {"aws:PrincipalTag/team": "a"}}
    +        })])],
    +        role_tags: vec![("team".to_owned(), "b".to_owned())],
    +        live_session: Some(SessionData {
    +            session_policy: None,
    +            session_tags: vec![("team".to_owned(), "b".to_owned())],
    +            created_at: None,
    +        }),
    +        ..Store::default()
    +    });
    +    let tagged = |team: &str| {
    +        role(Some(SessionSnapshot {
    +            tags: BTreeMap::from([("team".to_owned(), team.to_owned())]),
    +            ..SessionSnapshot::default()
    +        }))
    +    };
    +    let resource = "arn:aws:s3:::b/k";
    +    assert_eq!(
    +        decide(&a, &tagged("a"), S3Action::GetObject, resource).await,
    +        Ok(()),
    +        "the recorded session tag wins over the role's tag"
    +    );
    +    assert_eq!(
    +        decide(&a, &tagged("b"), S3Action::GetObject, resource).await,
    +        IMPLICIT
    +    );
    +    assert_eq!(
    +        decide(&a, &role(None), S3Action::GetObject, resource).await,
    +        IMPLICIT,
    +        "with no recorded tag the role's own tag applies"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_permissions_boundary_limits_the_caller() {
    +    let a = authorizer(Store {
    +        policies: vec![policy(&[allow("s3:*", "*")])],
    +        boundary: Some(policy(&[allow("s3:GetObject", "*")])),
    +        ..Store::default()
    +    });
    +    for who in [user(), role(None)] {
    +        let get = decide(&a, &who, S3Action::GetObject, "arn:aws:s3:::b/k").await;
    +        assert_eq!(get, Ok(()), "{who:?}");
    +        let put = decide(&a, &who, S3Action::PutObject, "arn:aws:s3:::b/k").await;
    +        assert_eq!(put, IMPLICIT, "{who:?}");
    +    }
    +}
    +
    +#[tokio::test]
    +async fn a_recorded_session_policy_that_does_not_parse_denies() {
    +    let a = authorizer(Store {
    +        policies: vec![policy(&[allow("s3:*", "*")])],
    +        ..Store::default()
    +    });
    +    let broken = role(Some(SessionSnapshot {
    +        policy: Some("{not a policy".to_owned()),
    +        ..SessionSnapshot::default()
    +    }));
    +    let get = decide(&a, &broken, S3Action::GetObject, "arn:aws:s3:::b/k").await;
    +    assert_eq!(get, IMPLICIT);
    +}
    +
    +#[tokio::test]
    +async fn request_level_keys_are_the_recorded_start_requests() {
    +    let a = authorizer(Store {
    +        policies: vec![policy(&[json!({
    +            "Effect": "Allow", "Action": "s3:GetObject", "Resource": "*",
    +            "Condition": {
    +                "IpAddress": {"aws:SourceIp": "10.0.0.0/8"},
    +                "Bool": {"aws:SecureTransport": "true"},
    +                "StringEquals": {"aws:UserAgent": "loader/1"}
    +            }
    +        })])],
    +        ..Store::default()
    +    });
    +    let from = |ip: &str, secure: bool, agent: &str| BulkPrincipal {
    +        source_ip: Some(ip.to_owned()),
    +        user_agent: Some(agent.to_owned()),
    +        secure_transport: secure,
    +        ..user()
    +    };
    +    let resource = "arn:aws:s3:::b/k";
    +    for (principal, expected) in [
    +        (from("10.1.2.3", true, "loader/1"), Ok(())),
    +        (from("192.168.1.1", true, "loader/1"), IMPLICIT),
    +        (from("10.1.2.3", false, "loader/1"), IMPLICIT),
    +        (from("10.1.2.3", true, "other"), IMPLICIT),
    +    ] {
    +        let decision = decide(&a, &principal, S3Action::GetObject, resource).await;
    +        assert_eq!(decision, expected, "{principal:?}");
    +    }
    +}
    +
    +#[tokio::test]
    +async fn start_records_a_user_with_the_requests_keys() {
    +    let (state, _) = state_with_policies(Vec::new(), false).await;
    +    let identity = AuthIdentity::User {
    +        account_id: ACCOUNT.to_owned(),
    +        user_name: "u".to_owned(),
    +    };
    +    let principal = capture_principal(&state, &identity, Some("10.1.2.3"), Some("ua"))
    +        .await
    +        .expect("capture");
    +    assert_eq!(
    +        principal,
    +        BulkPrincipal {
    +            source_ip: Some("10.1.2.3".to_owned()),
    +            user_agent: Some("ua".to_owned()),
    +            secure_transport: state.tls_enabled,
    +            ..user()
    +        }
    +    );
    +}
    +
    +#[tokio::test]
    +async fn only_a_start_outside_developer_mode_carries_a_caller() {
    +    let identity = AuthIdentity::User {
    +        account_id: ACCOUNT.to_owned(),
    +        user_name: "u".to_owned(),
    +    };
    +    let (state, _) = state_with_policies(Vec::new(), false).await;
    +    let caller = start_caller(&state, &identity, "StartBulkPutItems", None, None)
    +        .await
    +        .expect("capture");
    +    assert!(caller.is_some_and(|(_, principal)| principal == user()));
    +    let other = start_caller(&state, &identity, "DescribeBulkOperation", None, None)
    +        .await
    +        .expect("capture");
    +    assert!(other.is_none(), "only Start records a caller");
    +
    +    let (dev, _) = state_with_policies(Vec::new(), true).await;
    +    assert!(ServerBulkAuthorizer::for_state(&dev).is_none());
    +    let caller = start_caller(&dev, &identity, "StartBulkPutItems", None, None)
    +        .await
    +        .expect("capture");
    +    assert!(caller.is_none(), "developer mode runs no S3 check");
    +}
    +
    +#[tokio::test]
    +async fn start_records_a_role_sessions_stored_policy_and_tags() {
    +    let (state, _) = state_with_policies(Vec::new(), false).await;
    +    let catalog = state.catalog_store.clone().expect("a catalog");
    +    catalog.create_account(ACCOUNT, "s3-actions").await.unwrap();
    +    catalog
    +        .create_role(
    +            ACCOUNT,
    +            "r",
    +            &json!({"Version": "2012-10-17", "Statement": []}),
    +        )
    +        .await
    +        .unwrap();
    +    let session_policy =
    +        json!({"Version": "2012-10-17", "Statement": [allow("s3:GetObject", "*")]});
    +    catalog
    +        .store_session(
    +            "token-s3-actions",
    +            "ASIAS3ACTIONSTEST001",
    +            b"secret",
    +            ACCOUNT,
    +            "r",
    +            "s",
    +            &Some(json!({"team": "a"})),
    +            &Some(session_policy.clone()),
    +            time::OffsetDateTime::now_utc() + time::Duration::hours(1),
    +        )
    +        .await
    +        .unwrap();
    +    let identity = AuthIdentity::RoleSession {
    +        account_id: ACCOUNT.to_owned(),
    +        role_name: "r".to_owned(),
    +        session_name: "s".to_owned(),
    +    };
    +    let principal = capture_principal(&state, &identity, None, None)
    +        .await
    +        .expect("capture");
    +    let session = principal.session.expect("a role session records its data");
    +    let recorded: Value =
    +        serde_json::from_str(session.policy.as_deref().expect("a policy")).unwrap();
    +    assert_eq!(recorded, session_policy);
    +    assert_eq!(
    +        session.tags,
    +        BTreeMap::from([("team".to_owned(), "a".to_owned())])
    +    );
    +    assert!(session.token_issue_time.is_some());
    +}
    +
    +/// The data perimeter Deny: any S3 action on a resource whose account is not
    +/// the caller's.
    +fn perimeter_policy() -> String {
    +    policy(&[
    +        allow("s3:*", "*"),
    +        json!({
    +            "Effect": "Deny",
    +            "Action": "s3:*",
    +            "Resource": "*",
    +            "Condition": {"StringNotEquals": {"aws:ResourceAccount": "${aws:PrincipalAccount}"}}
    +        }),
    +    ])
    +}
    +
    +#[tokio::test]
    +async fn on_the_filesystem_store_the_resource_account_is_the_callers() {
    +    let fs = authorizer(Store {
    +        policies: vec![perimeter_policy()],
    +        ..Store::default()
    +    })
    +    .with_buckets_in_caller_account();
    +    for action in [
    +        S3Action::ListBucket,
    +        S3Action::GetObject,
    +        S3Action::PutObject,
    +    ] {
    +        assert_eq!(
    +            fs.authorize(&user(), action, "arn:aws:s3:::b/k", Some(""))
    +                .await,
    +            Ok(()),
    +            "{action:?}"
    +        );
    +    }
    +    // A Null check sees the key present.
    +    let present = authorizer(Store {
    +        policies: vec![policy(&[json!({
    +            "Effect": "Allow",
    +            "Action": "s3:GetObject",
    +            "Resource": "*",
    +            "Condition": {"Null": {"aws:ResourceAccount": "false"}}
    +        })])],
    +        ..Store::default()
    +    })
    +    .with_buckets_in_caller_account();
    +    assert_eq!(
    +        present
    +            .authorize(&role(None), S3Action::GetObject, "arn:aws:s3:::b/k", None)
    +            .await,
    +        Ok(())
    +    );
    +}
    +
    +#[tokio::test]
    +async fn on_the_s3_store_the_resource_account_stays_absent() {
    +    let s3 = authorizer(Store {
    +        policies: vec![perimeter_policy()],
    +        ..Store::default()
    +    });
    +    assert_eq!(
    +        s3.authorize(&user(), S3Action::GetObject, "arn:aws:s3:::b/k", None)
    +            .await,
    +        Err(AuthorizeError::Denied { explicit: true })
    +    );
    +}
    +
    +/// On the S3 store `aws:ResourceAccount` is absent, so a `Resource` or
    +/// `NotResource` pattern that names it matches no resource: a Deny with it in
    +/// `Resource` never applies, an Allow never grants, and a Deny with it in
    +/// `NotResource` applies to every check. On the filesystem store the key is
    +/// the caller's account, and the same pattern names the caller's buckets.
    +#[tokio::test]
    +async fn a_resource_account_variable_in_a_resource_matches_nothing_on_the_s3_store() {
    +    let pattern = "arn:aws:s3:::${aws:ResourceAccount}-*";
    +    let deny_in = policy(&[
    +        allow("s3:*", "*"),
    +        json!({"Effect": "Deny", "Action": "s3:*", "Resource": pattern}),
    +    ]);
    +    let allow_in = policy(&[allow("s3:GetObject", pattern)]);
    +    let deny_not_in = policy(&[
    +        allow("s3:*", "*"),
    +        json!({"Effect": "Deny", "Action": "s3:*", "NotResource": pattern}),
    +    ]);
    +    let resource = format!("arn:aws:s3:::{ACCOUNT}-b/k");
    +    for (document, on_s3, on_filesystem) in [
    +        (deny_in, Ok(()), EXPLICIT),
    +        (allow_in, IMPLICIT, Ok(())),
    +        (deny_not_in, EXPLICIT, Ok(())),
    +    ] {
    +        let s3 = authorizer(Store {
    +            policies: vec![document.clone()],
    +            ..Store::default()
    +        });
    +        let filesystem = authorizer(Store {
    +            policies: vec![document.clone()],
    +            ..Store::default()
    +        })
    +        .with_buckets_in_caller_account();
    +        assert_eq!(
    +            decide(&s3, &user(), S3Action::GetObject, &resource).await,
    +            on_s3,
    +            "S3 store: {document}"
    +        );
    +        assert_eq!(
    +            decide(&filesystem, &user(), S3Action::GetObject, &resource).await,
    +            on_filesystem,
    +            "filesystem store: {document}"
    +        );
    +    }
    +}
    +
    +#[tokio::test]
    +async fn the_server_sets_the_resource_account_only_for_the_filesystem_store() {
    +    let (mut state, _) = state_with_policies(vec![perimeter_policy()], false).await;
    +    let get = |authorizer: Arc| async move {
    +        authorizer
    +            .authorize(&user(), S3Action::GetObject, "arn:aws:s3:::b/k", None)
    +            .await
    +    };
    +    // A store with no directory tree (the S3 store has none) leaves the key
    +    // absent.
    +    let no_tree = ServerBulkAuthorizer::for_state(&state).expect("not developer mode");
    +    assert_eq!(
    +        get(no_tree).await,
    +        Err(AuthorizeError::Denied { explicit: true })
    +    );
    +    state.bulk = Some(Arc::new(BulkStore::filesystem(Arc::new(PathBuf::from(
    +        "/nonexistent-bulk-root",
    +    )))));
    +    let fs = ServerBulkAuthorizer::for_state(&state).expect("not developer mode");
    +    assert_eq!(get(fs).await, Ok(()));
    +}
    diff --git a/crates/server/src/bulk_authorization_tests/start.rs b/crates/server/src/bulk_authorization_tests/start.rs
    new file mode 100644
    index 000000000..27a041b63
    --- /dev/null
    +++ b/crates/server/src/bulk_authorization_tests/start.rs
    @@ -0,0 +1,333 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Authorization of StartBulkPutItems: the two required actions, table ARN
    +//! spellings and accounts, and the dev-mode bypass.
    +
    +use super::*;
    +
    +#[tokio::test]
    +async fn a_user_allowed_only_put_item_is_denied_start_bulk_put_items() {
    +    let (state, _fetches) = state_with_policies(
    +        vec![allow("dynamodb:PutItem", &table_arn(ACCOUNT, TABLE))],
    +        false,
    +    )
    +    .await;
    +    assert_denied(
    +        authorize(&state, "StartBulkPutItems", &start_body(TABLE)).await,
    +        "PutItem alone must not grant StartBulkPutItems",
    +    );
    +}
    +
    +#[tokio::test]
    +async fn start_requires_both_the_control_plane_and_the_data_plane_action() {
    +    // The data-plane grant alone: the control-plane check runs first and
    +    // its denial names the account-scoped action and the wildcard.
    +    let (state, _fetches) = state_with_policies(
    +        vec![allow("dynamodb:BulkPutItems", &table_arn(ACCOUNT, TABLE))],
    +        false,
    +    )
    +    .await;
    +    let message = denial_message(
    +        authorize(&state, "StartBulkPutItems", &start_body(TABLE)).await,
    +        "BulkPutItems alone",
    +    );
    +    assert!(
    +        message.contains("dynamodb:StartBulkOperation") && message.contains(&operation_wildcard()),
    +        "the denial names the control-plane action and the wildcard: {message}"
    +    );
    +
    +    // The control-plane grant alone: the data-plane check fails and its
    +    // denial names the table-scoped action and the table ARN.
    +    let (state, _fetches) = state_with_policies(
    +        vec![allow("dynamodb:StartBulkOperation", &operation_wildcard())],
    +        false,
    +    )
    +    .await;
    +    let message = denial_message(
    +        authorize(&state, "StartBulkPutItems", &start_body(TABLE)).await,
    +        "StartBulkOperation alone",
    +    );
    +    assert!(
    +        message.contains("dynamodb:BulkPutItems") && message.contains(&table_arn(ACCOUNT, TABLE)),
    +        "the denial names the data-plane action and the table: {message}"
    +    );
    +
    +    // Both grants pass.
    +    let (state, _fetches) = state_with_policies(start_grants(TABLE), false).await;
    +    authorize(&state, "StartBulkPutItems", &start_body(TABLE))
    +        .await
    +        .expect("both grants authorize the start");
    +}
    +
    +#[tokio::test]
    +async fn a_grant_spelled_as_the_operation_name_does_not_authorize_a_start() {
    +    // `dynamodb:StartBulkPutItems` is not an action the service defines.
    +    let (state, _fetches) = state_with_policies(
    +        vec![
    +            allow("dynamodb:StartBulkPutItems", &table_arn(ACCOUNT, TABLE)),
    +            allow("dynamodb:StartBulkPutItems", &operation_wildcard()),
    +        ],
    +        false,
    +    )
    +    .await;
    +    assert_denied(
    +        authorize(&state, "StartBulkPutItems", &start_body(TABLE)).await,
    +        "the operation name is not an IAM action for this feature",
    +    );
    +}
    +
    +#[tokio::test]
    +async fn a_deny_on_the_control_plane_action_blocks_every_start_in_the_account() {
    +    let (state, _fetches) = state_with_policies(
    +        vec![allow_all_with_deny(
    +            "dynamodb:StartBulkOperation",
    +            &operation_wildcard(),
    +        )],
    +        false,
    +    )
    +    .await;
    +    assert_denied(
    +        authorize(&state, "StartBulkPutItems", &start_body(TABLE)).await,
    +        "a wildcard deny on the control-plane action",
    +    );
    +    assert_denied(
    +        authorize(&state, "StartBulkPutItems", &start_body(OTHER_TABLE)).await,
    +        "the deny is account scoped, so the other table is denied too",
    +    );
    +}
    +
    +#[tokio::test]
    +async fn start_allowed_on_table_a_is_denied_on_table_b_for_both_spellings() {
    +    let (state, fetches) = state_with_policies(start_grants(TABLE), false).await;
    +
    +    authorize(&state, "StartBulkPutItems", &start_body(TABLE))
    +        .await
    +        .expect("the bare name of the allowed table passes");
    +    // The positive half of the fetch counter: an evaluated request fetches
    +    // policies at least once. The zero-fetch assertions elsewhere in this
    +    // file prove absence with the same counter, and would pass vacuously
    +    // if evaluation ever stopped routing through the counting store.
    +    assert!(
    +        fetches.load(Ordering::SeqCst) > 0,
    +        "an evaluated request fetches policies at least once"
    +    );
    +    authorize(
    +        &state,
    +        "StartBulkPutItems",
    +        &start_body(&table_arn(ACCOUNT, TABLE)),
    +    )
    +    .await
    +    .expect("the own-ARN spelling of the allowed table passes");
    +
    +    assert_denied(
    +        authorize(&state, "StartBulkPutItems", &start_body(OTHER_TABLE)).await,
    +        "the bare name of the other table",
    +    );
    +    assert_denied(
    +        authorize(
    +            &state,
    +            "StartBulkPutItems",
    +            &start_body(&table_arn(ACCOUNT, OTHER_TABLE)),
    +        )
    +        .await,
    +        "the own-ARN spelling of the other table",
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_explicit_deny_on_the_table_binds_against_the_own_arn_spelling() {
    +    let (state, _fetches) = state_with_policies(
    +        vec![allow_all_with_deny(
    +            "dynamodb:BulkPutItems",
    +            &table_arn(ACCOUNT, TABLE),
    +        )],
    +        false,
    +    )
    +    .await;
    +    assert_denied(
    +        authorize(
    +            &state,
    +            "StartBulkPutItems",
    +            &start_body(&table_arn(ACCOUNT, TABLE)),
    +        )
    +        .await,
    +        "the ARN spelling must resolve to the denied table's resource",
    +    );
    +}
    +
    +#[tokio::test]
    +async fn table_name_as_another_accounts_arn_is_denied_before_any_authorization_call() {
    +    // The account and region checks of validation step 2 run ahead of
    +    // policy evaluation, so a foreign table ARN never reaches a policy.
    +    // The policy here allows everything and the fetch counter stays at
    +    // zero, so the denial can only come from the check itself; the
    +    // message is the engine's own denial string (measured), not the
    +    // evaluated-denial format.
    +    let (state, fetches) = state_with_policies(vec![allow("dynamodb:*", "*")], false).await;
    +    let foreign = table_arn(OTHER_ACCOUNT, TABLE);
    +    match authorize(&state, "StartBulkPutItems", &start_body(&foreign)).await {
    +        Err(DynamoDbError::AccessDeniedException(message)) => {
    +            assert_eq!(message, "Access is denied");
    +        }
    +        other => panic!("expected AccessDeniedException, got {other:?}"),
    +    }
    +    assert_eq!(
    +        fetches.load(Ordering::SeqCst),
    +        0,
    +        "no policy fetch may run for a foreign table ARN"
    +    );
    +}
    +
    +/// Insert `OTHER_ACCOUNT` into the test's catalog, so the account a foreign
    +/// ARN names exists in this deployment.
    +async fn add_other_account(state: &AppState) {
    +    state
    +        .catalog_store
    +        .as_ref()
    +        .expect("a catalog")
    +        .create_account(OTHER_ACCOUNT, "other")
    +        .await
    +        .expect("create the other account");
    +}
    +
    +#[tokio::test]
    +async fn table_name_as_an_existing_accounts_arn_is_denied_before_any_authorization_call() {
    +    // The same answer when the other account exists in this deployment: the
    +    // bulk derivation answers the ARN, not the cross-account refusal of the
    +    // other operations, which would name `dynamodb:StartBulkPutItems`.
    +    let (state, fetches) = state_with_policies(vec![allow("dynamodb:*", "*")], false).await;
    +    add_other_account(&state).await;
    +    let foreign = table_arn(OTHER_ACCOUNT, TABLE);
    +    match authorize(&state, "StartBulkPutItems", &start_body(&foreign)).await {
    +        Err(DynamoDbError::AccessDeniedException(message)) => {
    +            assert_eq!(message, "Access is denied");
    +        }
    +        other => panic!("expected AccessDeniedException, got {other:?}"),
    +    }
    +    assert_eq!(
    +        fetches.load(Ordering::SeqCst),
    +        0,
    +        "no policy fetch may run for a table ARN of an existing account"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn another_accounts_index_arn_fails_validation_when_the_account_exists() {
    +    // An index ARN is not a table ARN, so the derivation refuses it as an
    +    // invalid spelling before it compares accounts.
    +    let (state, fetches) = state_with_policies(vec![allow("dynamodb:*", "*")], false).await;
    +    add_other_account(&state).await;
    +    let index = format!("{}/index/gsi", table_arn(OTHER_ACCOUNT, TABLE));
    +    let message = validation_message(
    +        authorize(&state, "StartBulkPutItems", &start_body(&index)).await,
    +        "another account's index ARN",
    +    );
    +    assert_eq!(message, format!("Invalid table ARN: {index}"));
    +    assert_eq!(fetches.load(Ordering::SeqCst), 0);
    +}
    +
    +#[tokio::test]
    +async fn table_name_as_another_regions_arn_fails_validation_before_any_authorization_call() {
    +    // Measured: another region on the caller's own table ARN is a step 2
    +    // validation failure; the zero fetch count proves it is answered
    +    // before any policy is fetched, like the foreign-account denial.
    +    let (state, fetches) = state_with_policies(vec![allow("dynamodb:*", "*")], false).await;
    +    let foreign = format!("arn:aws:dynamodb:eu-west-1:{ACCOUNT}:table/{TABLE}");
    +    let message = validation_message(
    +        authorize(&state, "StartBulkPutItems", &start_body(&foreign)).await,
    +        "a foreign-region table ARN",
    +    );
    +    assert_eq!(
    +        message,
    +        format!("1 validation error detected: Invalid AWS region in '{foreign}'")
    +    );
    +    assert_eq!(
    +        fetches.load(Ordering::SeqCst),
    +        0,
    +        "no policy fetch may run for a foreign-region table ARN"
    +    );
    +}
    +
    +#[tokio::test]
    +async fn an_own_index_arn_answers_the_measured_format_text_before_any_authorization_call() {
    +    // Measured: the service answers an own index ARN with the
    +    // resource-name format clause, the text the handler carries; the
    +    // authorization layer runs first on the request path, so it must
    +    // answer the same text, before any policy is fetched.
    +    let (state, fetches) = state_with_policies(vec![allow("dynamodb:*", "*")], false).await;
    +    let index = format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/{TABLE}/index/gsi");
    +    let message = validation_message(
    +        authorize(&state, "StartBulkPutItems", &start_body(&index)).await,
    +        "an own index ARN",
    +    );
    +    assert_eq!(
    +        message,
    +        format!(
    +            "1 validation error detected: Value '{index}' at 'tableName' failed to satisfy \
    +             constraint: Valid ARN format is \
    +             'arn:::::resourceType/resourceName', \
    +             where Resource name must have length less than or equal to 255, Resource name \
    +             must have length greater than or equal to 3, Resource name must satisfy regular \
    +             expression pattern: [a-zA-Z0-9_.-]+"
    +        )
    +    );
    +    assert_eq!(fetches.load(Ordering::SeqCst), 0);
    +}
    +
    +#[tokio::test]
    +async fn an_own_arn_whose_name_breaks_the_rule_fails_validation_before_any_authorization_call() {
    +    // Measured: one `Table name of ARN` clause, length before pattern.
    +    let (state, fetches) = state_with_policies(vec![allow("dynamodb:*", "*")], false).await;
    +    for (name, rule) in [
    +        ("ab", "must have length greater than or equal to 3"),
    +        ("a*", "must have length greater than or equal to 3"),
    +        (
    +            "deni*",
    +            "must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
    +        ),
    +    ] {
    +        let value = format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/{name}");
    +        let message = validation_message(
    +            authorize(&state, "StartBulkPutItems", &start_body(&value)).await,
    +            name,
    +        );
    +        assert_eq!(
    +            message,
    +            format!(
    +                "1 validation error detected: Value '{value}' at 'tableName' failed to satisfy \
    +                 constraint: Table name of ARN {rule}"
    +            )
    +        );
    +    }
    +    assert_eq!(fetches.load(Ordering::SeqCst), 0);
    +}
    +
    +// DescribeBulkOperation and CancelBulkOperation: the resource is the body's
    +// operation ARN, with the two parse layers and the account check answered
    +// before any policy evaluation.
    +
    +#[tokio::test]
    +async fn dev_mode_passes_all_four_operations_without_evaluation() {
    +    let (state, fetches) = state_with_policies(Vec::new(), true).await;
    +    let own = operation_arn(REGION, ACCOUNT);
    +
    +    authorize(&state, "StartBulkPutItems", &start_body(TABLE))
    +        .await
    +        .expect("dev mode passes Start");
    +    authorize(&state, "DescribeBulkOperation", &arn_body(&own))
    +        .await
    +        .expect("dev mode passes Describe");
    +    authorize(&state, "CancelBulkOperation", &arn_body(&own))
    +        .await
    +        .expect("dev mode passes Cancel");
    +    authorize(&state, "ListBulkOperations", &json!({}))
    +        .await
    +        .expect("dev mode passes List");
    +
    +    assert_eq!(
    +        fetches.load(Ordering::SeqCst),
    +        0,
    +        "dev mode evaluates no policy for any of the four"
    +    );
    +}
    diff --git a/crates/server/src/bulk_authorizer.rs b/crates/server/src/bulk_authorizer.rs
    new file mode 100644
    index 000000000..8f6689cf8
    --- /dev/null
    +++ b/crates/server/src/bulk_authorizer.rs
    @@ -0,0 +1,290 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The server's side of the S3 permission checks of bulk operations
    +//! (`extenddb_engine::bulk_put::authorize`): the caller captured when a
    +//! `StartBulkPutItems` request is dispatched, and the evaluation of an S3
    +//! action for a recorded caller over the authorization cache.
    +
    +use std::sync::Arc;
    +
    +use extenddb_auth::AuthIdentity;
    +use extenddb_auth::policy::context::RequestParams;
    +use extenddb_auth::policy::document::PolicyDocument;
    +use extenddb_core::error::DynamoDbError;
    +use extenddb_engine::bulk_put::authorize::{
    +    AuthorizeError, BulkAuthorizer, BulkIdentity, BulkPrincipal, S3Action, SessionSnapshot,
    +};
    +use extenddb_storage::authorization_store::AuthorizationStore;
    +use futures::future::BoxFuture;
    +
    +use crate::AppState;
    +use crate::authorization::full_action::{self, authorize_action, denial_is_explicit};
    +use crate::authz_cache::CachedAuthzStore;
    +
    +/// Evaluates an S3 action for a recorded bulk operation caller. The
    +/// identity and group policies, the permissions boundary, the principal tags
    +/// and the creation times are read through the authorization cache when the
    +/// check runs, so a policy changed during a load takes effect at the next
    +/// check. A role session's session policy, session tags and issue time come
    +/// from the snapshot recorded at Start.
    +pub(crate) struct ServerBulkAuthorizer {
    +    cache: Arc,
    +    region: Arc,
    +    /// Whether every bucket a caller names is in the caller's account, as on
    +    /// the filesystem store (`{root}/{account}/{bucket}`): then
    +    /// `aws:ResourceAccount` is the caller's account. On the S3 store the
    +    /// bucket owner is not known, and the key stays absent.
    +    buckets_in_caller_account: bool,
    +}
    +
    +impl ServerBulkAuthorizer {
    +    pub(crate) fn new(cache: Arc, region: Arc) -> Self {
    +        Self {
    +            cache,
    +            region,
    +            buckets_in_caller_account: false,
    +        }
    +    }
    +
    +    /// This authorizer for a store whose buckets are all in the caller's
    +    /// account.
    +    pub(crate) fn with_buckets_in_caller_account(mut self) -> Self {
    +        self.buckets_in_caller_account = true;
    +        self
    +    }
    +
    +    /// The authorizer for `state`; `None` in developer mode, where no S3
    +    /// permission check runs.
    +    pub(crate) fn for_state(state: &AppState) -> Option> {
    +        (!state.dev_mode).then(|| {
    +            let authorizer = Self::new(Arc::clone(&state.authz_cache), Arc::clone(&state.region));
    +            let filesystem = state
    +                .bulk
    +                .as_ref()
    +                .is_some_and(|store| store.filesystem_root().is_some());
    +            Arc::new(if filesystem {
    +                authorizer.with_buckets_in_caller_account()
    +            } else {
    +                authorizer
    +            }) as Arc
    +        })
    +    }
    +}
    +
    +impl BulkAuthorizer for ServerBulkAuthorizer {
    +    fn authorize<'a>(
    +        &'a self,
    +        principal: &'a BulkPrincipal,
    +        action: S3Action,
    +        resource_arn: &'a str,
    +        s3_prefix: Option<&'a str>,
    +    ) -> BoxFuture<'a, Result<(), AuthorizeError>> {
    +        Box::pin(async move {
    +            let identity = auth_identity(&principal.identity);
    +            let session = match &identity {
    +                AuthIdentity::RoleSession { .. } => {
    +                    Some(session_snapshot(principal.session.as_ref())?)
    +                }
    +                AuthIdentity::User { .. } => None,
    +            };
    +            // The request-level keys are the Start request's; `s3:prefix` is
    +            // the listing's; `aws:ResourceAccount` is the caller's account
    +            // where every bucket is in it. The other S3 keys stay absent.
    +            let params = RequestParams {
    +                region: Some(self.region.to_string()),
    +                secure_transport: principal.secure_transport,
    +                source_ip: principal.source_ip.clone(),
    +                user_agent: principal.user_agent.clone(),
    +                s3_prefix: s3_prefix.map(ToOwned::to_owned),
    +                resource_account: self
    +                    .buckets_in_caller_account
    +                    .then(|| account_of(&principal.identity).to_owned()),
    +                ..Default::default()
    +            };
    +            let action_name = action.as_str();
    +            let decision = Box::pin(authorize_action(
    +                &self.cache,
    +                &identity,
    +                session.as_ref(),
    +                action_name,
    +                resource_arn,
    +                params,
    +            ))
    +            .await;
    +            match decision {
    +                Ok(()) => Ok(()),
    +                Err(DynamoDbError::AccessDeniedException(message)) => Err(AuthorizeError::Denied {
    +                    explicit: denial_is_explicit(&identity, action_name, resource_arn, &message),
    +                }),
    +                Err(other) => Err(AuthorizeError::Unavailable(format!("{other:?}"))),
    +            }
    +        })
    +    }
    +}
    +
    +/// The account a recorded caller belongs to.
    +fn account_of(identity: &BulkIdentity) -> &str {
    +    match identity {
    +        BulkIdentity::User { account_id, .. } | BulkIdentity::RoleSession { account_id, .. } => {
    +            account_id
    +        }
    +    }
    +}
    +
    +fn auth_identity(identity: &BulkIdentity) -> AuthIdentity {
    +    match identity {
    +        BulkIdentity::User {
    +            account_id,
    +            user_name,
    +        } => AuthIdentity::User {
    +            account_id: account_id.clone(),
    +            user_name: user_name.clone(),
    +        },
    +        BulkIdentity::RoleSession {
    +            account_id,
    +            role_name,
    +            session_name,
    +        } => AuthIdentity::RoleSession {
    +            account_id: account_id.clone(),
    +            role_name: role_name.clone(),
    +            session_name: session_name.clone(),
    +        },
    +    }
    +}
    +
    +/// The recorded session data in the evaluator's form. A role session
    +/// recorded without data evaluates with none, as the live path does for a
    +/// session whose data is gone. A recorded session policy that does not parse
    +/// denies, the rule the cache applies to a stored policy it cannot parse.
    +fn session_snapshot(
    +    recorded: Option<&SessionSnapshot>,
    +) -> Result {
    +    let Some(recorded) = recorded else {
    +        return Ok(full_action::SessionSnapshot::default());
    +    };
    +    let policy = match &recorded.policy {
    +        None => None,
    +        Some(text) => match PolicyDocument::from_json(text) {
    +            Ok(document) => Some(Arc::new(document)),
    +            Err(error) => {
    +                tracing::error!(
    +                    error = %error,
    +                    "recorded session policy of a bulk operation does not parse; the check denies"
    +                );
    +                return Err(AuthorizeError::Denied { explicit: false });
    +            }
    +        },
    +    };
    +    Ok(full_action::SessionSnapshot {
    +        policy,
    +        tags: recorded
    +            .tags
    +            .iter()
    +            .map(|(key, value)| (key.clone(), value.clone()))
    +            .collect(),
    +        token_issue_time: recorded.token_issue_time.clone(),
    +    })
    +}
    +
    +/// The authorizer and the captured caller for a `StartBulkPutItems`
    +/// request; `None` for every other operation and in developer mode.
    +///
    +/// # Errors
    +///
    +/// `InternalServerError` when a role session's data cannot be read.
    +pub(crate) async fn start_caller(
    +    state: &AppState,
    +    identity: &AuthIdentity,
    +    operation: &str,
    +    source_ip: Option<&str>,
    +    user_agent: Option<&str>,
    +) -> Result, BulkPrincipal)>, DynamoDbError> {
    +    if operation != "StartBulkPutItems" {
    +        return Ok(None);
    +    }
    +    let Some(authorizer) = ServerBulkAuthorizer::for_state(state) else {
    +        return Ok(None);
    +    };
    +    let principal = capture_principal(state, identity, source_ip, user_agent).await?;
    +    Ok(Some((authorizer, principal)))
    +}
    +
    +/// The caller as the operation records it: the identity, a role session's
    +/// session policy text, session tags and issue time as stored, and the
    +/// request's `aws:SourceIp`, `aws:UserAgent` and `aws:SecureTransport`.
    +pub(crate) async fn capture_principal(
    +    state: &AppState,
    +    identity: &AuthIdentity,
    +    source_ip: Option<&str>,
    +    user_agent: Option<&str>,
    +) -> Result {
    +    let (identity, session) = match identity {
    +        AuthIdentity::User {
    +            account_id,
    +            user_name,
    +        } => (
    +            BulkIdentity::User {
    +                account_id: account_id.clone(),
    +                user_name: user_name.clone(),
    +            },
    +            None,
    +        ),
    +        AuthIdentity::RoleSession {
    +            account_id,
    +            role_name,
    +            session_name,
    +        } => (
    +            BulkIdentity::RoleSession {
    +                account_id: account_id.clone(),
    +                role_name: role_name.clone(),
    +                session_name: session_name.clone(),
    +            },
    +            Some(read_session(state, account_id, role_name, session_name).await?),
    +        ),
    +    };
    +    Ok(BulkPrincipal {
    +        identity,
    +        session,
    +        source_ip: source_ip.map(ToOwned::to_owned),
    +        user_agent: user_agent.map(ToOwned::to_owned),
    +        secure_transport: state.tls_enabled,
    +    })
    +}
    +
    +/// A role session's stored data, from the store the authorization cache
    +/// reads: the cache keeps the session policy parsed, and the operation
    +/// records the document text.
    +async fn read_session(
    +    state: &AppState,
    +    account_id: &str,
    +    role_name: &str,
    +    session_name: &str,
    +) -> Result {
    +    let internal = || DynamoDbError::InternalServerError("Internal server error".to_owned());
    +    let Some(catalog) = state.catalog_store.as_ref() else {
    +        tracing::error!("bulk operation caller capture: no catalog store");
    +        return Err(internal());
    +    };
    +    let store: &dyn AuthorizationStore = catalog.as_ref();
    +    match store
    +        .fetch_session_data(account_id, role_name, session_name)
    +        .await
    +    {
    +        Ok(Some(data)) => Ok(SessionSnapshot {
    +            policy: data.session_policy,
    +            tags: data.session_tags.into_iter().collect(),
    +            token_issue_time: data.created_at,
    +        }),
    +        Ok(None) => Ok(SessionSnapshot::default()),
    +        Err(error) => {
    +            tracing::error!(
    +                account_id = %account_id,
    +                role_name = %role_name,
    +                error = ?error,
    +                "bulk operation caller capture: the role session could not be read"
    +            );
    +            Err(internal())
    +        }
    +    }
    +}
    diff --git a/crates/server/src/bulk_store.rs b/crates/server/src/bulk_store.rs
    new file mode 100644
    index 000000000..097308289
    --- /dev/null
    +++ b/crates/server/src/bulk_store.rs
    @@ -0,0 +1,338 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Bulk store resolution at startup: the `[bulk]` section, the dev-mode
    +//! default, the startup pass over the accounts, and the count of the
    +//! operations recorded on a store other than the configured one.
    +
    +use std::collections::BTreeMap;
    +use std::sync::Arc;
    +
    +use extenddb_auth::CredentialStore;
    +use extenddb_config as config;
    +use extenddb_engine::bulk_put::store::{
    +    BulkStore, S3_STORE_LABEL, S3StoreSettings, recorded_store_kind,
    +};
    +use extenddb_storage::{CatalogStore, StorageEngine};
    +
    +/// Operations read per page while the startup pass counts them.
    +const COUNT_PAGE: u32 = 100;
    +
    +/// The bulk store the server serves bulk operations from, or `None` when
    +/// bulk operations are disabled. When the S3 store's credentials are an
    +/// access key this server also accepts from its clients, in
    +/// `credential_store` or as the dev-mode pair, a warning says so.
    +///
    +/// # Errors
    +///
    +/// A `[bulk]` section the server cannot serve: a `[bulk.s3]` section that
    +/// fails its checks, a missing `[bulk.filesystem] path`, or a root that
    +/// cannot be resolved.
    +pub(crate) async fn resolve_bulk_store(
    +    app_config: &config::AppConfig,
    +    dev_mode: bool,
    +    catalog_store: &Arc,
    +    storage: &Arc,
    +    credential_store: &Arc,
    +) -> anyhow::Result>> {
    +    resolve_checked(
    +        app_config.bulk.as_ref(),
    +        &app_config.server.region,
    +        dev_mode,
    +        catalog_store,
    +        storage,
    +        credential_store,
    +    )
    +    .await
    +}
    +
    +/// [`resolve`], then the check of the S3 store's key against the keys the
    +/// server accepts from its clients.
    +async fn resolve_checked(
    +    bulk: Option<&config::BulkConfig>,
    +    server_region: &str,
    +    dev_mode: bool,
    +    catalog_store: &Arc,
    +    storage: &Arc,
    +    credential_store: &Arc,
    +) -> anyhow::Result>> {
    +    let store = resolve(bulk, server_region, dev_mode, catalog_store, storage).await?;
    +    if let Some(access_key_id) = store.as_deref().and_then(BulkStore::s3_access_key_id) {
    +        crate::s3_client_key::warn_of_a_client_key(
    +            S3_STORE_LABEL,
    +            access_key_id,
    +            credential_store.as_ref(),
    +            dev_mode,
    +        )
    +        .await;
    +    }
    +    Ok(store)
    +}
    +
    +/// [`resolve_bulk_store`] over the `[bulk]` section and `[server] region`.
    +async fn resolve(
    +    bulk: Option<&config::BulkConfig>,
    +    server_region: &str,
    +    dev_mode: bool,
    +    catalog_store: &Arc,
    +    storage: &Arc,
    +) -> anyhow::Result>> {
    +    // Resolve the bulk store ([bulk] section). Bulk put reads its input
    +    // files and writes its error reports through this one store: the
    +    // filesystem store beneath one root, namespaced per account, or the S3
    +    // store; it is independent of the [import] and [export] roots. Absent
    +    // means the feature is disabled, except in dev mode, which substitutes
    +    // a local directory so zero-config serve has a working store.
    +    let dev_default_bulk;
    +    let effective_bulk = match (bulk, dev_mode) {
    +        (Some(cfg), _) => Some((cfg, false)),
    +        (None, true) => {
    +            dev_default_bulk = config::BulkConfig {
    +                store: config::BulkStoreKind::Filesystem,
    +                filesystem: Some(config::BulkFilesystemConfig {
    +                    path: config::DEV_DEFAULT_BULK_PATH.to_owned(),
    +                }),
    +                s3: None,
    +            };
    +            Some((&dev_default_bulk, true))
    +        }
    +        (None, false) => None,
    +    };
    +    let bulk_store: Option> = match effective_bulk {
    +        None => {
    +            tracing::info!("Bulk operations disabled: no [bulk] section");
    +            // With no store the worker cannot read input, so an IN_PROGRESS
    +            // operation cannot progress: it stays non-terminal, counts
    +            // against the one-in-flight-per-table guard and the per-account
    +            // limit, and never ages into retention. Count what the catalog
    +            // still holds non-terminal and warn once, so an operator who
    +            // removed the section sees the operations are frozen.
    +            // Cancellations are the exception: the bulk operation worker
    +            // still finishes CANCELLING rows (see bulk_operation_worker).
    +            match catalog_store.list_all_accounts().await {
    +                Ok(accounts) => {
    +                    let mut non_terminal: u64 = 0;
    +                    for (account_id, _) in accounts {
    +                        match storage.count_non_terminal(&account_id).await {
    +                            Ok(count) => non_terminal = non_terminal.saturating_add(count),
    +                            // A backend with no bulk support answers
    +                            // Validation; there is nothing to count.
    +                            Err(extenddb_storage::error::StorageError::Validation(_)) => break,
    +                            Err(e) => tracing::warn!(
    +                                "Cannot count non-terminal bulk operations for account \
    +                                 {account_id}: {e}"
    +                            ),
    +                        }
    +                    }
    +                    if non_terminal > 0 {
    +                        tracing::warn!(
    +                            non_terminal_bulk_operations = non_terminal,
    +                            "Bulk operations are disabled but {non_terminal} operation(s) are \
    +                             non-terminal in the catalog; they will not progress until a [bulk] \
    +                             store is configured, except cancellations, which the bulk operation \
    +                             worker still finishes"
    +                        );
    +                    }
    +                }
    +                Err(e) => tracing::warn!(
    +                    "Cannot list accounts to count non-terminal bulk operations: {e:?}"
    +                ),
    +            }
    +            None
    +        }
    +        Some((bulk_cfg, is_dev_default)) => {
    +            match bulk_cfg.store {
    +                config::BulkStoreKind::S3 => {
    +                    // No account directories exist on this store: every
    +                    // account names S3 buckets directly.
    +                    let settings = s3_settings(bulk_cfg, server_region)?;
    +                    let store = BulkStore::s3(&settings).await;
    +                    let described = bulk_cfg
    +                        .s3
    +                        .clone()
    +                        .unwrap_or_default()
    +                        .describe(server_region);
    +                    tracing::info!("Bulk store: {described}");
    +                    warn_of_other_store_operations(catalog_store, storage, &store).await;
    +                    return Ok(Some(Arc::new(store)));
    +                }
    +                config::BulkStoreKind::Filesystem => {
    +                    // The section is checked whatever the store, so a
    +                    // credential or a URL with user information in it is
    +                    // refused before a later `store = "s3"` reads it.
    +                    if let Some(s3) = &bulk_cfg.s3 {
    +                        s3.validate().map_err(anyhow::Error::msg)?;
    +                    }
    +                }
    +            }
    +            let Some(fs_cfg) = &bulk_cfg.filesystem else {
    +                if bulk_cfg.s3.is_some() {
    +                    anyhow::bail!(
    +                        "[bulk.filesystem] path is required when store = \"filesystem\", the \
    +                         default; to use the [bulk.s3] section, set store = \"s3\" in [bulk], \
    +                         or add [bulk.filesystem]"
    +                    );
    +                }
    +                anyhow::bail!("[bulk.filesystem] path is required when store = \"filesystem\"");
    +            };
    +            if bulk_cfg.s3.is_some() {
    +                tracing::info!("[bulk.s3] is ignored: store = \"filesystem\"");
    +            }
    +            let expanded = config::expand_tilde(&fs_cfg.path);
    +            let canonical = extenddb_engine::bulk_put::store_root::resolve_store_root(
    +                std::path::Path::new(&expanded),
    +            )
    +            .map_err(|e| anyhow::anyhow!("Cannot resolve bulk store path {expanded}: {e}"))?;
    +            if is_dev_default {
    +                // The dev-mode banner names the configured value; this line
    +                // carries the canonical path, at warn so it sits with the
    +                // banner's visibility.
    +                tracing::warn!("Bulk store: {}", canonical.display());
    +            } else {
    +                tracing::info!("Bulk store: filesystem at {}", canonical.display());
    +            }
    +            // Create every account's directory so a caller can place files
    +            // without a first Start. A failure is per account and not fatal:
    +            // Start creates the directory again when it is missing.
    +            match catalog_store.list_all_accounts().await {
    +                Ok(accounts) => {
    +                    for (account_id, _) in accounts {
    +                        if let Err(e) = extenddb_engine::bulk_put::store_root::ensure_account_dir(
    +                            &canonical,
    +                            &account_id,
    +                        ) {
    +                            tracing::warn!(
    +                                "Cannot create bulk store directory for account {account_id}: {e}"
    +                            );
    +                        }
    +                    }
    +                }
    +                Err(e) => {
    +                    tracing::warn!("Cannot list accounts to create bulk store directories: {e:?}");
    +                }
    +            }
    +            let store = BulkStore::filesystem(Arc::new(canonical));
    +            warn_of_other_store_operations(catalog_store, storage, &store).await;
    +            Some(Arc::new(store))
    +        }
    +    };
    +    Ok(bulk_store)
    +}
    +
    +/// The S3 store's settings from `[bulk.s3]`, after its startup checks. An
    +/// absent section takes every default, and the region defaults to
    +/// `[server] region`. The checks' notices are logged at warn, and a
    +/// `[bulk.filesystem]` section beside `store = "s3"` is ignored with an info
    +/// line.
    +fn s3_settings(
    +    bulk_cfg: &config::BulkConfig,
    +    server_region: &str,
    +) -> anyhow::Result {
    +    let s3 = bulk_cfg.s3.clone().unwrap_or_default();
    +    for notice in s3.validate().map_err(anyhow::Error::msg)? {
    +        tracing::warn!("{notice}");
    +    }
    +    if bulk_cfg.filesystem.is_some() {
    +        tracing::info!("[bulk.filesystem] is ignored: store = \"s3\"");
    +    }
    +    Ok(S3StoreSettings {
    +        region: s3.region.unwrap_or_else(|| server_region.to_owned()),
    +        endpoint_url: s3.endpoint_url,
    +        force_path_style: s3.force_path_style,
    +    })
    +}
    +
    +/// Warn once per store kind of the non-terminal operations that were
    +/// started on a store of another kind than `store`, as an operator who
    +/// switched `[bulk] store` with operations in flight needs to know: the bulk
    +/// operation worker fails each of them with `InternalServerError` without
    +/// reading their input, and finishes a cancellation as `CANCELLED`.
    +async fn warn_of_other_store_operations(
    +    catalog_store: &Arc,
    +    storage: &Arc,
    +    store: &BulkStore,
    +) {
    +    let configured = store.kind_name();
    +    let counts = other_store_operations(catalog_store, storage, configured, COUNT_PAGE).await;
    +    for (recorded, count) in counts {
    +        tracing::warn!(
    +            other_store_bulk_operations = count,
    +            recorded_store = %recorded,
    +            configured_store = configured,
    +            "{count} non-terminal bulk operation(s) were started on the {recorded} store, but \
    +             the {configured} store is configured; the bulk operation worker fails them with \
    +             InternalServerError without reading their input, except cancellations, which it \
    +             finishes"
    +        );
    +    }
    +}
    +
    +/// The non-terminal (`IN_PROGRESS` or `CANCELLING`) operations the catalog
    +/// holds whose recorded store is not `configured`, counted by the store
    +/// they recorded. An operation with no recorded run context was started on
    +/// the filesystem store; one whose run context does not parse is not
    +/// counted (the worker fails it and logs the detail). Only an account with
    +/// non-terminal operations is listed, `page` operations at a time, and only
    +/// until all of them were seen.
    +async fn other_store_operations(
    +    catalog_store: &Arc,
    +    storage: &Arc,
    +    configured: &str,
    +    page: u32,
    +) -> BTreeMap {
    +    let mut counts = BTreeMap::new();
    +    let accounts = match catalog_store.list_all_accounts().await {
    +        Ok(accounts) => accounts,
    +        Err(e) => {
    +            tracing::warn!(
    +                "Cannot list accounts to count bulk operations started on another store: {e:?}"
    +            );
    +            return counts;
    +        }
    +    };
    +    'accounts: for (account_id, _) in accounts {
    +        let mut unseen = match storage.count_non_terminal(&account_id).await {
    +            Ok(0) => continue,
    +            Ok(count) => count,
    +            // A backend with no bulk support answers Validation; there is
    +            // nothing to count.
    +            Err(extenddb_storage::error::StorageError::Validation(_)) => break,
    +            Err(e) => {
    +                tracing::warn!(
    +                    "Cannot count non-terminal bulk operations for account {account_id}: {e}"
    +                );
    +                continue;
    +            }
    +        };
    +        let mut after = None;
    +        while unseen > 0 {
    +            let (records, next) = match storage.list_bulk_operations(&account_id, page, after).await
    +            {
    +                Ok(listed) => listed,
    +                Err(e) => {
    +                    tracing::warn!("Cannot list bulk operations for account {account_id}: {e}");
    +                    continue 'accounts;
    +                }
    +            };
    +            for record in &records {
    +                if !matches!(record.status.as_str(), "IN_PROGRESS" | "CANCELLING") {
    +                    continue;
    +                }
    +                unseen = unseen.saturating_sub(1);
    +                if let Ok(recorded) = recorded_store_kind(record)
    +                    && recorded != configured
    +                {
    +                    *counts.entry(recorded).or_insert(0) += 1;
    +                }
    +            }
    +            match next {
    +                Some(cursor) => after = Some(cursor),
    +                None => break,
    +            }
    +        }
    +    }
    +    counts
    +}
    +
    +#[cfg(test)]
    +mod tests;
    diff --git a/crates/server/src/bulk_store/tests.rs b/crates/server/src/bulk_store/tests.rs
    new file mode 100644
    index 000000000..f217cffdf
    --- /dev/null
    +++ b/crates/server/src/bulk_store/tests.rs
    @@ -0,0 +1,543 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Tests for the bulk store resolution at startup.
    +
    +use std::future::Future;
    +use std::path::PathBuf;
    +use std::process::{Command, Stdio};
    +use std::sync::atomic::{AtomicU64, Ordering};
    +
    +use serde_json::{Value, json};
    +
    +use extenddb_storage::bulk_operation_store::{
    +    CancelOutcome, InitialFailure, NewBulkOperation, StartOutcome,
    +};
    +use extenddb_storage::server_components::ServerComponentsOptions;
    +
    +use super::*;
    +use crate::test_logs::logged;
    +
    +/// The `[server] region` of every case, so a region taken from it is told
    +/// apart from the SDK's and the configuration's.
    +const SERVER_REGION: &str = "eu-west-3";
    +
    +/// A `[bulk]` section, written as the configuration file would hold it.
    +fn bulk(section: Value) -> config::BulkConfig {
    +    serde_json::from_value(section).expect("a [bulk] section")
    +}
    +
    +/// An in-memory catalog and storage engine.
    +async fn components() -> (Arc, Arc) {
    +    let config = extenddb_storage_sqlite::SqliteConfig {
    +        path: ":memory:".to_owned(),
    +        pool_size: 2,
    +    };
    +    let components = (extenddb_storage_sqlite::backend().server_components)(
    +        &config,
    +        SERVER_REGION,
    +        ServerComponentsOptions::default(),
    +    )
    +    .await
    +    .expect("server components");
    +    (components.catalog_store, components.engine)
    +}
    +
    +/// A fresh directory under the system temp directory.
    +fn scratch() -> PathBuf {
    +    let dir = std::env::temp_dir().join(format!("eb-bulk-store-{}", uuid::Uuid::new_v4()));
    +    std::fs::create_dir_all(&dir).unwrap();
    +    dir
    +}
    +
    +#[test]
    +fn the_s3_settings_take_the_server_region_when_the_section_names_none() {
    +    for section in [json!({"store": "s3"}), json!({"store": "s3", "s3": {}})] {
    +        let settings = s3_settings(&bulk(section), SERVER_REGION).unwrap();
    +        assert_eq!(settings.region, SERVER_REGION);
    +        assert_eq!(settings.endpoint_url, None);
    +        assert!(!settings.force_path_style);
    +    }
    +}
    +
    +#[test]
    +fn the_s3_settings_carry_every_key_of_the_section() {
    +    let section = json!({
    +        "store": "s3",
    +        "s3": {
    +            "region": "ap-south-2",
    +            "endpoint_url": "http://127.0.0.1:7070",
    +            "force_path_style": true
    +        },
    +        "filesystem": {"path": "/unused"}
    +    });
    +    let settings = s3_settings(&bulk(section), SERVER_REGION).unwrap();
    +    assert_eq!(settings.region, "ap-south-2");
    +    assert_eq!(
    +        settings.endpoint_url.as_deref(),
    +        Some("http://127.0.0.1:7070")
    +    );
    +    assert!(settings.force_path_style);
    +}
    +
    +/// The variable that tells a child process of this test binary to run the
    +/// case's body, holding the case's scratch directory.
    +const CHILD: &str = "EXTENDDB_TEST_BULK_STORE_RESOLUTION_CHILD";
    +
    +/// Static keys the S3 store's credential chain resolves.
    +const KEYS: &[(&str, &str)] = &[
    +    ("AWS_ACCESS_KEY_ID", "AKIDRESOLUTIONTEST"),
    +    ("AWS_SECRET_ACCESS_KEY", "resolution-test-secret"),
    +];
    +
    +/// Run `body` for the test `test` in a child process of this test binary
    +/// whose environment holds fixed values only: the variables `keys` names,
    +/// no config or credentials file, instance metadata off. A case that hands
    +/// an S3 section to the resolution runs there, because building the S3
    +/// store reaches the standard AWS credential chain, which must stay away
    +/// from a developer's profiles and from any instance metadata service. A
    +/// case that expects a refusal runs there with no keys, so a refusal that
    +/// stops working builds a store whose credentials do not resolve, and no
    +/// identity check starts. Every S3 section of a case that builds a store
    +/// names an endpoint, which is never contacted: building the store sends no
    +/// request. Returns the child's output, its log lines included, to the
    +/// parent, and `None` in the child.
    +fn in_fixed_aws_environment>(
    +    test: &str,
    +    keys: &[(&str, &str)],
    +    body: impl FnOnce(PathBuf) -> F,
    +) -> Option {
    +    if let Some(dir) = std::env::var_os(CHILD) {
    +        let _ = tracing_subscriber::fmt()
    +            .with_ansi(false)
    +            .with_max_level(tracing::Level::INFO)
    +            .with_writer(std::io::stdout)
    +            .try_init();
    +        tokio::runtime::Builder::new_current_thread()
    +            .enable_all()
    +            .build()
    +            .unwrap()
    +            .block_on(body(PathBuf::from(dir)));
    +        return None;
    +    }
    +    let dir = scratch();
    +    let module = module_path!().split_once("::").map_or("", |(_, rest)| rest);
    +    let output = Command::new(std::env::current_exe().unwrap())
    +        .args([
    +            format!("{module}::{test}").as_str(),
    +            "--exact",
    +            "--nocapture",
    +            "--test-threads",
    +            "1",
    +        ])
    +        .env_clear()
    +        .env(CHILD, &dir)
    +        .env("HOME", &dir)
    +        .envs(keys.iter().copied())
    +        .env("AWS_CONFIG_FILE", dir.join("no-config"))
    +        .env("AWS_SHARED_CREDENTIALS_FILE", dir.join("no-credentials"))
    +        .env("AWS_EC2_METADATA_DISABLED", "true")
    +        .stdin(Stdio::null())
    +        .output()
    +        .unwrap();
    +    let _ = std::fs::remove_dir_all(&dir);
    +    let stdout = String::from_utf8_lossy(&output.stdout);
    +    let stderr = String::from_utf8_lossy(&output.stderr);
    +    assert!(output.status.success(), "{stdout}\n{stderr}");
    +    assert!(stdout.contains("1 passed"), "{stdout}\n{stderr}");
    +    Some(stdout.into_owned())
    +}
    +
    +#[test]
    +fn an_s3_section_that_fails_its_checks_stops_the_server() {
    +    in_fixed_aws_environment(
    +        "an_s3_section_that_fails_its_checks_stops_the_server",
    +        &[],
    +        |_| refusals(),
    +    );
    +}
    +
    +async fn refusals() {
    +    let (catalog, storage) = components().await;
    +    for (s3, refusal) in [
    +        (
    +            json!({"secret_access_key": "never-read"}),
    +            "[bulk.s3] secret_access_key is not accepted: the S3 store reads its credentials \
    +             from the standard AWS credential chain",
    +        ),
    +        (
    +            json!({"region": "us east-1"}),
    +            "[bulk.s3] region must not contain whitespace",
    +        ),
    +        (
    +            json!({"endpoint_url": "ftp://objects.example"}),
    +            "[bulk.s3] endpoint_url must be an absolute http or https URL with a host",
    +        ),
    +        (
    +            json!({"endpoint_url": "https://key:secret@objects.example"}),
    +            "[bulk.s3] endpoint_url must not carry user information",
    +        ),
    +        (
    +            json!({"endpoint_url": "http://objects.example:9000", "force_path_style": true}),
    +            "[bulk.s3] endpoint_url uses http:// to a host that is not a recognised loopback \
    +             form",
    +        ),
    +    ] {
    +        let section = bulk(json!({"store": "s3", "s3": s3}));
    +        for dev_mode in [false, true] {
    +            let error = resolve(Some(§ion), SERVER_REGION, dev_mode, &catalog, &storage)
    +                .await
    +                .err()
    +                .unwrap_or_else(|| panic!("{s3} must stop the server"));
    +            let message = error.to_string();
    +            assert!(message.starts_with(refusal), "{message}");
    +            assert!(!message.contains("never-read") && !message.contains("secret@"));
    +        }
    +    }
    +}
    +
    +#[tokio::test]
    +async fn an_s3_section_beside_the_filesystem_store_is_checked() {
    +    // The filesystem branch builds no S3 client, so no AWS environment is
    +    // read here.
    +    let (catalog, storage) = components().await;
    +    let path = scratch();
    +    for (s3, refusal) in [
    +        (
    +            json!({"secret_access_key": "never-read"}),
    +            "[bulk.s3] secret_access_key is not accepted",
    +        ),
    +        (
    +            json!({"endpoint_url": "http://key:secret@127.0.0.1:9"}),
    +            "[bulk.s3] endpoint_url must not carry user information",
    +        ),
    +        (
    +            json!({"endpoint_url": "http://objects.example:9"}),
    +            "[bulk.s3] endpoint_url uses http:// to a host that is not a recognised loopback \
    +             form",
    +        ),
    +    ] {
    +        let section = bulk(json!({"store": "filesystem", "filesystem": {"path": path}, "s3": s3}));
    +        let message = resolve(Some(§ion), SERVER_REGION, false, &catalog, &storage)
    +            .await
    +            .err()
    +            .unwrap_or_else(|| panic!("{s3} must stop the server"))
    +            .to_string();
    +        assert!(message.starts_with(refusal), "{message}");
    +        assert!(!message.contains("never-read") && !message.contains("secret@"));
    +    }
    +    let section = bulk(json!({
    +        "filesystem": {"path": path},
    +        "s3": {"endpoint_url": "http://127.0.0.1:9"}
    +    }));
    +    let store = resolve(Some(§ion), SERVER_REGION, false, &catalog, &storage)
    +        .await
    +        .unwrap()
    +        .expect("the filesystem store");
    +    assert!(store.filesystem_root().is_some());
    +    assert_eq!(store.s3_access_key_id(), None);
    +}
    +
    +#[tokio::test]
    +async fn an_s3_section_with_no_store_and_no_filesystem_section_names_both_ways_out() {
    +    let (catalog, storage) = components().await;
    +    let section = bulk(json!({"s3": {"endpoint_url": "http://127.0.0.1:9"}}));
    +    let message = resolve(Some(§ion), SERVER_REGION, false, &catalog, &storage)
    +        .await
    +        .err()
    +        .expect("no store can be built")
    +        .to_string();
    +    assert_eq!(
    +        message,
    +        "[bulk.filesystem] path is required when store = \"filesystem\", the default; to use \
    +         the [bulk.s3] section, set store = \"s3\" in [bulk], or add [bulk.filesystem]"
    +    );
    +    let bare = bulk(json!({}));
    +    let message = resolve(Some(&bare), SERVER_REGION, false, &catalog, &storage)
    +        .await
    +        .err()
    +        .expect("no store can be built")
    +        .to_string();
    +    assert_eq!(
    +        message,
    +        "[bulk.filesystem] path is required when store = \"filesystem\""
    +    );
    +}
    +
    +#[test]
    +fn the_s3_branch_builds_the_s3_store() {
    +    let Some(output) =
    +        in_fixed_aws_environment("the_s3_branch_builds_the_s3_store", KEYS, s3_branch)
    +    else {
    +        return;
    +    };
    +    // The two operations recorded on the filesystem store, once per
    +    // resolution.
    +    let warning = "2 non-terminal bulk operation(s) were started on the filesystem store, but \
    +                   the s3 store is configured";
    +    assert_eq!(output.matches(warning).count(), 2, "{output}");
    +}
    +
    +/// `store = "s3"` with a `[bulk.filesystem]` section beside it, in and out
    +/// of dev mode, gives the S3 store, which has no directory tree, and the
    +/// filesystem section's path is never created.
    +async fn s3_branch(dir: PathBuf) {
    +    let (catalog, storage) = catalog_with_operations().await;
    +    let unused = dir.join("filesystem-store");
    +    let section = bulk(json!({
    +        "store": "s3",
    +        "s3": {"endpoint_url": "http://127.0.0.1:9", "force_path_style": true},
    +        "filesystem": {"path": unused}
    +    }));
    +    for dev_mode in [false, true] {
    +        let store = resolve(Some(§ion), SERVER_REGION, dev_mode, &catalog, &storage)
    +            .await
    +            .unwrap()
    +            .expect("the S3 store");
    +        assert!(store.filesystem_root().is_none(), "dev mode {dev_mode}");
    +        // The key the environment gives the store, for the startup check
    +        // against the keys the server accepts.
    +        assert_eq!(
    +            store.s3_access_key_id(),
    +            Some("AKIDRESOLUTIONTEST"),
    +            "dev mode {dev_mode}"
    +        );
    +    }
    +    assert!(!unused.exists(), "the [bulk.filesystem] path is ignored");
    +}
    +
    +#[test]
    +fn an_insecure_endpoint_the_section_allows_builds_the_s3_store_with_a_warning() {
    +    let Some(output) = in_fixed_aws_environment(
    +        "an_insecure_endpoint_the_section_allows_builds_the_s3_store_with_a_warning",
    +        KEYS,
    +        insecure_endpoint,
    +    ) else {
    +        return;
    +    };
    +    let warning = "[bulk.s3] endpoint_url uses plain http to a host that is not loopback; S3 \
    +                   requests and responses cross the network unencrypted";
    +    assert_eq!(output.matches(warning).count(), 2, "{output}");
    +}
    +
    +/// `allow_insecure_endpoint = true` with a plain `http` endpoint on a host
    +/// that is not loopback gives the S3 store, in and out of dev mode, and
    +/// each resolution logs the warning once.
    +async fn insecure_endpoint(_dir: PathBuf) {
    +    let (catalog, storage) = components().await;
    +    let section = bulk(json!({
    +        "store": "s3",
    +        "s3": {
    +            "endpoint_url": "http://objects.example:9",
    +            "allow_insecure_endpoint": true,
    +            "force_path_style": true
    +        }
    +    }));
    +    for dev_mode in [false, true] {
    +        let store = resolve(Some(§ion), SERVER_REGION, dev_mode, &catalog, &storage)
    +            .await
    +            .unwrap()
    +            .expect("the S3 store");
    +        assert!(store.filesystem_root().is_none(), "dev mode {dev_mode}");
    +    }
    +}
    +
    +const ACCOUNT_A: &str = "111111111111";
    +const ACCOUNT_B: &str = "222222222222";
    +const ACCOUNT_C: &str = "333333333333";
    +
    +/// The run context Start records for an operation on the store `store`.
    +fn on(store: &str) -> Option {
    +    Some(json!({"store": store}))
    +}
    +
    +/// Creates `table` for `account` and returns its id: the start transaction
    +/// refuses a table whose row is absent.
    +async fn create_table(storage: &Arc, account: &str, table: &str) -> String {
    +    let input: extenddb_core::types::CreateTableInput = serde_json::from_value(json!({
    +        "TableName": table,
    +        "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
    +        "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
    +        "BillingMode": "PAY_PER_REQUEST"
    +    }))
    +    .expect("create table input");
    +    storage
    +        .create_table(account, input)
    +        .await
    +        .expect("create table")
    +        .table_id
    +}
    +
    +/// Start one operation of `account` on `table`, recording `run_context`,
    +/// born terminal when `terminal`; returns its id.
    +async fn start(
    +    storage: &Arc,
    +    account: &str,
    +    table: &str,
    +    run_context: Option,
    +    terminal: bool,
    +) -> String {
    +    static NEXT: AtomicU64 = AtomicU64::new(0);
    +    let n = NEXT.fetch_add(1, Ordering::Relaxed);
    +    let start_time_ms = 1_789_060_589_767 + i64::try_from(n).unwrap();
    +    let operation_id = format!("{start_time_ms:014}-{n:08x}");
    +    let initial_failure = terminal.then(|| InitialFailure {
    +        code: "S3NoSuchBucket".to_owned(),
    +        message: "The specified bucket does not exist".to_owned(),
    +        cap: 1_000,
    +        window_start_ms: 0,
    +    });
    +    let operation = NewBulkOperation {
    +        operation_id: operation_id.clone(),
    +        table_id: create_table(storage, account, table).await,
    +        table_name: table.to_owned(),
    +        table_arn: format!("arn:aws:dynamodb:{SERVER_REGION}:{account}:table/{table}"),
    +        client_token: None,
    +        client_token_hash: None,
    +        request_fingerprint: "resolution".to_owned(),
    +        input_config: json!({}),
    +        output_config: json!({}),
    +        input_files: Vec::new(),
    +        max_write_request_units: None,
    +        performance_tier: None,
    +        start_time_ms,
    +        total_input_bytes: None,
    +        initial_failure,
    +        run_context,
    +    };
    +    let outcome = storage
    +        .start_bulk_operation(account, operation, 50)
    +        .await
    +        .unwrap();
    +    assert!(matches!(outcome, StartOutcome::Created(_)), "{table}");
    +    operation_id
    +}
    +
    +/// A catalog of three accounts. A holds, each on its own table, operations
    +/// in progress recorded on S3, on the filesystem store, and with no run
    +/// context (started before run contexts were recorded, so on the
    +/// filesystem store); one recorded on S3 and cancelled, so `CANCELLING`; one
    +/// recorded on S3 and born terminal; and one in progress whose run context
    +/// does not parse. B holds one in progress on S3 and one terminal on the
    +/// filesystem store; C holds one terminal on S3.
    +async fn catalog_with_operations() -> (Arc, Arc) {
    +    let (catalog, storage) = components().await;
    +    for account in [ACCOUNT_A, ACCOUNT_B, ACCOUNT_C] {
    +        catalog.create_account(account, account).await.unwrap();
    +    }
    +    start(&storage, ACCOUNT_A, "t1", on("s3"), false).await;
    +    start(&storage, ACCOUNT_A, "t2", on("filesystem"), false).await;
    +    start(&storage, ACCOUNT_A, "t3", None, false).await;
    +    let cancelled = start(&storage, ACCOUNT_A, "t4", on("s3"), false).await;
    +    let outcome = storage.request_cancel(ACCOUNT_A, &cancelled).await.unwrap();
    +    assert!(matches!(outcome, CancelOutcome::Cancelling));
    +    start(&storage, ACCOUNT_A, "t5", on("s3"), true).await;
    +    start(&storage, ACCOUNT_A, "t6", Some(json!({"store": 7})), false).await;
    +    start(&storage, ACCOUNT_B, "t1", on("s3"), false).await;
    +    start(&storage, ACCOUNT_B, "t2", on("filesystem"), true).await;
    +    start(&storage, ACCOUNT_C, "t1", on("s3"), true).await;
    +    (catalog, storage)
    +}
    +
    +#[tokio::test]
    +async fn the_operations_recorded_on_the_other_store_are_counted_by_the_store_they_name() {
    +    let (catalog, storage) = catalog_with_operations().await;
    +    for page in [1, 2, 100] {
    +        let on_s3 = other_store_operations(&catalog, &storage, "filesystem", page).await;
    +        assert_eq!(on_s3, BTreeMap::from([("s3".to_owned(), 3)]), "page {page}");
    +        let on_filesystem = other_store_operations(&catalog, &storage, "s3", page).await;
    +        assert_eq!(
    +            on_filesystem,
    +            BTreeMap::from([("filesystem".to_owned(), 2)]),
    +            "page {page}"
    +        );
    +    }
    +}
    +
    +#[test]
    +fn startup_on_the_filesystem_store_warns_of_the_operations_recorded_on_s3() {
    +    let dir = scratch();
    +    let section = bulk(json!({"store": "filesystem", "filesystem": {"path": dir.join("buckets")}}));
    +    let (resolved, log) = logged(async {
    +        let (catalog, storage) = catalog_with_operations().await;
    +        resolve(Some(§ion), SERVER_REGION, false, &catalog, &storage).await
    +    });
    +    let _ = std::fs::remove_dir_all(&dir);
    +    let store = resolved.unwrap().expect("the filesystem store");
    +    assert!(store.filesystem_root().is_some());
    +    let warning = "3 non-terminal bulk operation(s) were started on the s3 store, but the \
    +                   filesystem store is configured; the bulk operation worker fails them with \
    +                   InternalServerError without reading their input, except cancellations, which \
    +                   it finishes";
    +    assert!(log.contains(warning), "{log}");
    +    assert_eq!(
    +        log.matches("bulk operation(s) were started on").count(),
    +        1,
    +        "{log}"
    +    );
    +}
    +
    +/// An AWS key imported into the server for one of its users, as an operator
    +/// imports a client's key.
    +const IMPORTED: &str = "AKIAIMPORTEDTEST0001";
    +
    +#[test]
    +fn startup_warns_when_the_s3_store_signs_with_a_key_the_server_accepts() {
    +    let Some(output) = in_fixed_aws_environment(
    +        "startup_warns_when_the_s3_store_signs_with_a_key_the_server_accepts",
    +        &[
    +            ("AWS_ACCESS_KEY_ID", IMPORTED),
    +            ("AWS_SECRET_ACCESS_KEY", "imported-test-secret"),
    +        ],
    +        signing_with_an_imported_key,
    +    ) else {
    +        return;
    +    };
    +    let warning = "The S3 bulk store credentials are an access key this server also accepts \
    +                   from its clients. S3 refuses a key ExtendDB created, and accepts an imported \
    +                   AWS key as that key's identity. Start the server without the clients' AWS \
    +                   variables or profile";
    +    assert_eq!(output.matches(warning).count(), 1, "{output}");
    +}
    +
    +/// The resolution and the startup check of an S3 store whose credentials,
    +/// from this process's environment, are a key imported into the catalog.
    +async fn signing_with_an_imported_key(_dir: PathBuf) {
    +    let config = extenddb_storage_sqlite::SqliteConfig {
    +        path: ":memory:".to_owned(),
    +        pool_size: 2,
    +    };
    +    let components = (extenddb_storage_sqlite::backend().server_components)(
    +        &config,
    +        SERVER_REGION,
    +        ServerComponentsOptions::default(),
    +    )
    +    .await
    +    .expect("server components");
    +    let catalog = components.catalog_store;
    +    catalog.create_account(ACCOUNT_A, ACCOUNT_A).await.unwrap();
    +    catalog
    +        .create_user(ACCOUNT_A, "loader", None)
    +        .await
    +        .unwrap();
    +    catalog
    +        .import_access_key(ACCOUNT_A, "loader", IMPORTED, "imported-test-secret")
    +        .await
    +        .unwrap();
    +    let section = bulk(json!({
    +        "store": "s3",
    +        "s3": {"endpoint_url": "http://127.0.0.1:9", "force_path_style": true}
    +    }));
    +    let store = resolve_checked(
    +        Some(§ion),
    +        SERVER_REGION,
    +        false,
    +        &catalog,
    +        &components.engine,
    +        &components.credential_store,
    +    )
    +    .await
    +    .unwrap()
    +    .expect("the S3 store");
    +    assert_eq!(store.s3_access_key_id(), Some(IMPORTED));
    +}
    diff --git a/crates/server/src/handler.rs b/crates/server/src/handler.rs
    index aeef4ccd1..a9c82b5ca 100755
    --- a/crates/server/src/handler.rs
    +++ b/crates/server/src/handler.rs
    @@ -108,6 +108,7 @@ pub(crate) async fn handle_request(
         // --- Authz segment ---
         let authz_start = std::time::Instant::now();
         let authorized_key_info;
    +    let bulk_caller;
         {
             if state.catalog_store.is_none() {
                 tracing::error!("Authorization required but catalog_store is not configured");
    @@ -140,7 +141,21 @@ pub(crate) async fn handle_request(
                 Ok(ki) => authorized_key_info = ki,
                 Err(e) => return error_response(&e, &request_id),
             }
    +        // The caller a bulk operation records, captured for Start only.
    +        bulk_caller = match crate::bulk_authorizer::start_caller(
    +            &state,
    +            &identity,
    +            &operation,
    +            Some(source_ip.as_str()),
    +            user_agent.as_deref(),
    +        )
    +        .await
    +        {
    +            Ok(caller) => caller,
    +            Err(e) => return error_response(&e, &request_id),
    +        };
         }
    +    let (bulk_authorizer, bulk_principal) = bulk_caller.unzip();
         #[allow(clippy::cast_precision_loss)]
         let authz_us = authz_start.elapsed().as_micros() as f64;
     
    @@ -153,11 +168,15 @@ pub(crate) async fn handle_request(
             account_id,
             import_paths: state.import_paths.clone(),
             export_paths: state.export_paths.clone(),
    +        bulk: state.bulk.clone(),
    +        bulk_authorizer,
    +        bulk_principal,
             authorized_key_info,
             auth_cache: state.auth_cache.clone(),
             table_key_info_lookup: Some(
                 state.table_key_info_cache.clone() as Arc
             ),
    +        metrics: state.metrics.clone(),
         };
     
         let table_name = extract_table_name(&input);
    diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs
    index 73e1a1fe9..ec6a421b4 100755
    --- a/crates/server/src/lib.rs
    +++ b/crates/server/src/lib.rs
    @@ -11,6 +11,10 @@
     pub(crate) mod authorization;
     pub mod authz_cache;
     pub(crate) mod authz_resource;
    +#[cfg(test)]
    +mod bulk_authorization_tests;
    +mod bulk_authorizer;
    +mod bulk_store;
     pub mod console;
     pub mod dev_credentials;
     mod handler;
    @@ -24,7 +28,10 @@ mod registration;
     mod request_helpers;
     mod request_tags;
     mod response;
    +mod s3_client_key;
     mod serve;
    +#[cfg(test)]
    +mod test_logs;
     mod throttle_helpers;
     mod workers;
     
    @@ -80,6 +87,11 @@ pub struct AppState {
         /// Allowed directories for export file operations. Empty means exports
         /// are disabled (secure default).
         pub export_paths: Arc<[Arc]>,
    +    /// The bulk store (`[bulk]`). Bulk put reads inputs and writes reports
    +    /// through it; on the filesystem store (`[bulk.filesystem] path`) they
    +    /// live beneath `{root}/{account id}/{S3Bucket}/...`. `None` means bulk
    +    /// operations are disabled (secure default).
    +    pub bulk: Option>,
         /// Token bucket throttle manager for provisioned throughput enforcement.
         pub throttle: Arc,
         /// Auth/authz cache handles. Used by the management API to issue
    @@ -142,6 +154,7 @@ pub async fn start_server(
         let metrics_for_console = state.metrics.clone();
         let authz_cache_for_mgmt = state.authz_cache.clone();
         let table_key_info_cache_for_mgmt = state.table_key_info_cache.clone();
    +    let bulk_for_mgmt = state.bulk.clone();
         let mgmt_region = state.region.clone();
         let version_info = state.version_info.clone();
         let docs_store = state.docs_store.clone();
    @@ -191,6 +204,7 @@ pub async fn start_server(
                 auth_cache: auth_cache_for_mgmt,
                 authz_cache: authz_cache_for_mgmt,
                 table_key_info_cache: table_key_info_cache_for_mgmt,
    +            bulk: bulk_for_mgmt,
                 region: mgmt_region,
                 secure_transport: tls_enabled,
             });
    diff --git a/crates/server/src/management/account.rs b/crates/server/src/management/account.rs
    index 09b1ae8b6..6967efa05 100755
    --- a/crates/server/src/management/account.rs
    +++ b/crates/server/src/management/account.rs
    @@ -74,6 +74,28 @@ pub async fn create_account(
             .await
         {
             Ok(()) => {
    +            // Create the account's bulk store directory so a caller can
    +            // place files without a first StartBulkPutItems. A failure is
    +            // logged and the request still succeeds: Start creates the
    +            // directory again when it is missing.
    +            if let Some(root) = state
    +                .bulk
    +                .as_ref()
    +                .and_then(|store| store.filesystem_root())
    +                .cloned()
    +            {
    +                let dir_account = account_id.clone();
    +                let created = tokio::task::spawn_blocking(move || {
    +                    extenddb_engine::bulk_put::store_root::ensure_account_dir(&root, &dir_account)
    +                })
    +                .await
    +                .unwrap_or_else(|join_error| Err(std::io::Error::other(join_error)));
    +                if let Err(e) = created {
    +                    tracing::warn!(
    +                        "Cannot create bulk store directory for account {account_id}: {e}"
    +                    );
    +                }
    +            }
                 let resp =
                     serde_json::json!({ "account_id": account_id, "message": "Account created" });
                 (StatusCode::CREATED, axum::Json(resp)).into_response()
    diff --git a/crates/server/src/management/mod.rs b/crates/server/src/management/mod.rs
    index ec3968766..02ead628d 100755
    --- a/crates/server/src/management/mod.rs
    +++ b/crates/server/src/management/mod.rs
    @@ -52,6 +52,10 @@ pub struct ManagementState {
         /// Concrete `TableKeyInfo` cache handle, used by the auth-cache-metrics
         /// endpoint. Same instance is held in `auth_cache.table_key_info`.
         pub table_key_info_cache: Arc,
    +    /// The bulk store, when one is configured. Account creation makes the
    +    /// new account's directory beneath the filesystem store's root so a
    +    /// caller can place files without a first `StartBulkPutItems`.
    +    pub bulk: Option>,
         /// Server region, resolved into `aws:RequestedRegion` during trust policy
         /// evaluation.
         pub region: Arc,
    diff --git a/crates/server/src/request_helpers.rs b/crates/server/src/request_helpers.rs
    index 8c0d6e39c..ab3ebaee2 100755
    --- a/crates/server/src/request_helpers.rs
    +++ b/crates/server/src/request_helpers.rs
    @@ -12,6 +12,8 @@ use crate::AppState;
     use crate::authorization;
     use crate::authz_resource;
     
    +mod bulk;
    +
     /// Extract operation name from X-Amz-Target header.
     /// Accepts both `DynamoDB_20120810` and `DynamoDBStreams_20120810` wire-format prefixes.
     pub(crate) fn extract_operation(headers: &HeaderMap) -> Result {
    @@ -348,6 +350,22 @@ pub(crate) async fn authorize_request(
             return Ok(authorized_key_info);
         }
     
    +    // Bulk operations evaluate operation-specific resources; see
    +    // `bulk::authorize_bulk`. Every other operation falls through.
    +    if bulk::authorize_bulk(
    +        state,
    +        identity,
    +        operation,
    +        input,
    +        account_id,
    +        table_name.as_deref(),
    +        &|| base_params(request_tags.clone(), request_tag_keys.clone()),
    +    )
    +    .await?
    +    {
    +        return Ok(authorized_key_info);
    +    }
    +
         // Derive the targets the service authorizes this operation against. The
         // resource comes from the field the operation actually carries it in
         // (ResourceArn, StreamArn, BackupArn, TableArn, IndexName, or a table ARN
    diff --git a/crates/server/src/request_helpers/bulk.rs b/crates/server/src/request_helpers/bulk.rs
    new file mode 100644
    index 000000000..9f786e94d
    --- /dev/null
    +++ b/crates/server/src/request_helpers/bulk.rs
    @@ -0,0 +1,527 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Policy resource derivation for the bulk operations.
    +
    +use extenddb_auth::AuthIdentity;
    +use extenddb_auth::policy::context::RequestParams;
    +use extenddb_core::error::DynamoDbError;
    +use serde_json::Value;
    +
    +use crate::AppState;
    +use crate::authorization;
    +use crate::authz_resource::AuthzTarget;
    +
    +/// Build a `DynamoDB` table ARN for authorization.
    +///
    +/// If no table name is available, uses `*` as the resource.
    +pub(super) fn build_resource_arn(
    +    region: &str,
    +    account_id: &str,
    +    table_name: Option<&str>,
    +) -> String {
    +    match table_name {
    +        Some(name) => format!("arn:aws:dynamodb:{region}:{account_id}:table/{name}"),
    +        None => format!("arn:aws:dynamodb:{region}:{account_id}:table/*"),
    +    }
    +}
    +
    +/// The account's bulk-operation wildcard resource.
    +pub(super) fn bulk_operation_wildcard(region: &str, account_id: &str) -> String {
    +    format!("arn:aws:dynamodb:{region}:{account_id}:bulk-operation/*")
    +}
    +
    +/// The control-plane action `StartBulkPutItems` requires, in the
    +/// operation-name form `check_authorization` prefixes with `dynamodb:`.
    +/// Account scoped; evaluated against the bulk-operation wildcard.
    +pub(crate) const START_BULK_OPERATION_ACTION: &str = "StartBulkOperation";
    +
    +/// The data-plane action `StartBulkPutItems` requires, in the same form.
    +/// Table scoped; evaluated against the table ARN.
    +pub(crate) const BULK_PUT_ITEMS_ACTION: &str = "BulkPutItems";
    +
    +/// The policy resource for a bulk operation request; `None` for every
    +/// other operation, which the caller authorizes on the generic table path.
    +///
    +/// `StartBulkPutItems` derives the table ARN, which the caller evaluates
    +/// for the data-plane action after the account-scoped control-plane
    +/// action has been allowed. A `TableName` spelled as
    +/// the caller's own table ARN resolves to its bare name through
    +/// `extenddb_core::arn::own_table_arn_name`, the same parse the handler
    +/// applies, or the ARN spelling would be evaluated embedded inside another
    +/// ARN and an explicit Deny written against the table would not bind. A
    +/// well-formed table ARN naming another account is refused with
    +/// `AccessDeniedException`, `Access is denied` (measured), one of the
    +/// caller's account naming another region with `ValidationException`,
    +/// `1 validation error detected: Invalid AWS region in '{arn}'`
    +/// (measured), and any other ARN spelling with the handler's `Invalid
    +/// table ARN` message, all before any policy is fetched or evaluated.
    +///
    +/// `DescribeBulkOperation` and `CancelBulkOperation` evaluate the body's
    +/// `BulkOperationArn` once it passes every check, in the order
    +/// `extenddb_core::arn::BulkOperationArnError` documents: the shape
    +/// (layer 1), then the account and the region, then the partition, the
    +/// service and the operation id (layer 2). A layer 1 or layer 2 failure
    +/// returns that `ValidationException`. An ARN of that shape naming
    +/// another account returns the denial the authorization layer would
    +/// produce (measured), even when its id is malformed, and one naming
    +/// another region returns the handler's not-found error, each with no
    +/// resource derived, no policy fetched, and no storage read, so neither
    +/// answer depends on whether the foreign operation exists.
    +///
    +/// `ListBulkOperations` evaluates the account's bulk-operation wildcard.
    +///
    +/// A body whose `TableName` or `BulkOperationArn` is absent or not a
    +/// string evaluates the operation's wildcard resource, as the generic
    +/// path does for a missing `TableName`; the handler rejects such a body
    +/// after authorization.
    +///
    +/// # Errors
    +///
    +/// Returns the two parse-layer `ValidationException`s, the
    +/// foreign-account `AccessDeniedException`, and the foreign-region
    +/// `ResourceNotFoundException` described above; the caller returns them
    +/// without evaluating any policy.
    +pub(super) fn bulk_operation_authz_resource(
    +    operation: &str,
    +    input: &Value,
    +    region: &str,
    +    account_id: &str,
    +    identity: &AuthIdentity,
    +) -> Result, DynamoDbError> {
    +    match operation {
    +        "StartBulkPutItems" => {
    +            let table_name = match input.get("TableName").and_then(Value::as_str) {
    +                Some(value) if value.starts_with("arn:") => {
    +                    // The account and region checks of validation step 2 run
    +                    // here, ahead of policy evaluation: a table ARN of
    +                    // another account or region is answered with the
    +                    // handler's own message and never reaches a policy. The
    +                    // tableName rules (an index ARN's format message, the
    +                    // name rule on an own ARN) run here too, so a
    +                    // validation fault is answered before any policy, with
    +                    // the texts the handler shares through
    +                    // `extenddb_core::arn`.
    +                    let name = extenddb_core::arn::own_table_arn_name(value, region, account_id)
    +                        .map_err(|error| {
    +                            extenddb_core::arn::table_arn_refusal(value, region, account_id, error)
    +                        })?;
    +                    extenddb_core::arn::check_table_arn_name(value, &name)?;
    +                    Some(name)
    +                }
    +                other => other.map(ToOwned::to_owned),
    +            };
    +            Ok(Some(build_resource_arn(
    +                region,
    +                account_id,
    +                table_name.as_deref(),
    +            )))
    +        }
    +        "DescribeBulkOperation" | "CancelBulkOperation" => {
    +            let Some(value) = input.get("BulkOperationArn").and_then(Value::as_str) else {
    +                return Ok(Some(bulk_operation_wildcard(region, account_id)));
    +            };
    +            // The account comparison runs ahead of the operation id check
    +            // (measured): a foreign-account ARN is denied whatever shape
    +            // its id has, so a caller cannot probe another account's id
    +            // shapes through the validation message.
    +            match extenddb_core::arn::own_bulk_operation_arn(value, region, account_id) {
    +                Ok(_) => Ok(Some(value.to_owned())),
    +                Err(extenddb_core::arn::BulkOperationArnError::ForeignAccount) => Err(denial(
    +                    &principal_arn(identity),
    +                    &format!("dynamodb:{operation}"),
    +                    value,
    +                )),
    +                Err(extenddb_core::arn::BulkOperationArnError::ForeignRegion) => {
    +                    Err(extenddb_core::arn::bulk_operation_does_not_exist(value))
    +                }
    +                Err(extenddb_core::arn::BulkOperationArnError::Shape) => {
    +                    Err(extenddb_core::arn::bulk_operation_arn_shape_error())
    +                }
    +                Err(extenddb_core::arn::BulkOperationArnError::InvalidId) => {
    +                    Err(extenddb_core::arn::bulk_operation_arn_id_error())
    +                }
    +            }
    +        }
    +        "ListBulkOperations" => Ok(Some(bulk_operation_wildcard(region, account_id))),
    +        _ => Ok(None),
    +    }
    +}
    +
    +/// The ARN of the authenticated principal, as denial messages name it.
    +pub(crate) fn principal_arn(identity: &AuthIdentity) -> String {
    +    match identity {
    +        AuthIdentity::User {
    +            account_id,
    +            user_name,
    +        } => format!("arn:aws:iam::{account_id}:user/{user_name}"),
    +        AuthIdentity::RoleSession {
    +            account_id,
    +            role_name,
    +            session_name,
    +        } => format!("arn:aws:iam::{account_id}:assumed-role/{role_name}/{session_name}"),
    +    }
    +}
    +
    +/// The denial a bulk operation ARN naming another account answers before any
    +/// policy is evaluated: the service's text for that case carries no
    +/// explanation suffix, where a denial decided by policy evaluation does (see
    +/// `authorization::deny_message`).
    +pub(crate) fn denial(principal_arn: &str, action: &str, resource_arn: &str) -> DynamoDbError {
    +    DynamoDbError::AccessDeniedException(format!(
    +        "User: {principal_arn} is not authorized to perform: {action} on resource: {resource_arn}"
    +    ))
    +}
    +
    +/// Authorize a bulk operation against its operation-specific resources.
    +/// Returns `Ok(true)` when `operation` is one of the four bulk operations
    +/// and every check allowed it, and `Ok(false)` for any other operation,
    +/// which the caller then authorizes on its generic path.
    +pub(super) async fn authorize_bulk(
    +    state: &AppState,
    +    identity: &AuthIdentity,
    +    operation: &str,
    +    input: &Value,
    +    account_id: &str,
    +    table_name: Option<&str>,
    +    base_params: &(dyn Fn() -> RequestParams + Sync),
    +) -> Result {
    +    // Bulk operations evaluate operation-specific resources: the table ARN
    +    // for StartBulkPutItems (with an own-ARN TableName spelling resolved to
    +    // its bare name so a table-scoped Deny binds on both spellings), the
    +    // body's BulkOperationArn for Describe and Cancel, and the account's
    +    // bulk-operation wildcard for List. Describe and Cancel errors from the
    +    // derivation return before any policy evaluation: a layer 1 or layer 2
    +    // invalid ARN answers its ValidationException, a well-formed
    +    // foreign-account ARN answers the denial (measured on the service), and a
    +    // well-formed foreign-region ARN answers the handler's not-found error, so
    +    // a foreign ARN is never evaluated against any policy and neither answer
    +    // depends on whether the foreign operation exists. Each check carries the
    +    // same request-level condition keys as every other operation, through
    +    // `base_params`.
    +    let bulk_resource =
    +        bulk_operation_authz_resource(operation, input, &state.region, account_id, identity)?;
    +
    +    // StartBulkPutItems is gated by two IAM actions on the hosted service: the
    +    // account-scoped control-plane action `dynamodb:StartBulkOperation`,
    +    // evaluated against the account's bulk-operation wildcard because the
    +    // operation ARN does not exist until the row is inserted, and the
    +    // table-scoped data-plane action `dynamodb:BulkPutItems`, evaluated against
    +    // the table ARN the derivation resolved. Both must allow. The control-plane
    +    // check runs first so a caller with neither grant is told about the
    +    // account-scoped action. A grant spelled `dynamodb:StartBulkPutItems` is
    +    // not an action the service defines and does not authorize a start.
    +    if operation == "StartBulkPutItems" {
    +        let table_arn = bulk_resource
    +            .unwrap_or_else(|| build_resource_arn(&state.region, account_id, table_name));
    +        let checks = [
    +            (
    +                START_BULK_OPERATION_ACTION,
    +                bulk_operation_wildcard(&state.region, account_id),
    +            ),
    +            (BULK_PUT_ITEMS_ACTION, table_arn),
    +        ];
    +        for (action, resource_arn) in checks {
    +            authorization::check_authorization(
    +                state.authz_cache.as_ref(),
    +                identity,
    +                &AuthzTarget::new(action, resource_arn),
    +                false,
    +                base_params(),
    +            )
    +            .await?;
    +        }
    +        return Ok(true);
    +    }
    +
    +    // DescribeBulkOperation, CancelBulkOperation and ListBulkOperations evaluate
    +    // the resource the derivation produced (the operation ARN, or the account's
    +    // bulk-operation wildcard) under the operation's own action. The derivation
    +    // returns `None` for every non-bulk operation, which answers `Ok(false)` so
    +    // the caller authorizes it on its generic path.
    +    if let Some(resource_arn) = bulk_resource {
    +        authorization::check_authorization(
    +            state.authz_cache.as_ref(),
    +            identity,
    +            &AuthzTarget::new(operation, resource_arn),
    +            false,
    +            base_params(),
    +        )
    +        .await?;
    +        return Ok(true);
    +    }
    +    Ok(false)
    +}
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::*;
    +    use serde_json::json;
    +
    +    const REGION: &str = "us-east-1";
    +    const ACCOUNT: &str = "123456789012";
    +    const OPERATION_ARN: &str =
    +        "arn:aws:dynamodb:us-east-1:123456789012:bulk-operation/01789060589767-da9ddd4f";
    +
    +    /// The resource `authorize_request` evaluates for a bulk operation
    +    /// body, from the same derivation function it calls. `authorize_request`
    +    /// itself takes an `AppState`, which these tests cannot build (it needs
    +    /// a storage engine the server crate's tests do not have), so the tests
    +    /// pin the derivation function; the function's answer is everything the
    +    /// bulk branch adds before the shared `check_authorization` call.
    +    fn test_identity() -> AuthIdentity {
    +        AuthIdentity::User {
    +            account_id: ACCOUNT.to_owned(),
    +            user_name: "tester".to_owned(),
    +        }
    +    }
    +
    +    fn derived_resource(operation: &str, body: &serde_json::Value) -> String {
    +        bulk_operation_authz_resource(operation, body, REGION, ACCOUNT, &test_identity())
    +            .expect("this body derives a resource")
    +            .expect("this operation is a bulk operation")
    +    }
    +
    +    fn derived_error(operation: &str, body: &serde_json::Value) -> DynamoDbError {
    +        bulk_operation_authz_resource(operation, body, REGION, ACCOUNT, &test_identity())
    +            .expect_err("this body must be refused before evaluation")
    +    }
    +
    +    /// Evaluate one action against one resource under the given policy.
    +    fn decide(
    +        policy: &str,
    +        action: &str,
    +        resource: &str,
    +    ) -> extenddb_auth::policy::evaluator::AuthzDecision {
    +        use extenddb_auth::policy::context::{RequestContext, RequestParams};
    +        use extenddb_auth::policy::document::PolicyDocument;
    +        use extenddb_auth::policy::evaluator::evaluate_policies_arc;
    +        use std::collections::HashMap;
    +
    +        let policies = vec![std::sync::Arc::new(
    +            PolicyDocument::from_json(policy).expect("the policy parses"),
    +        )];
    +        let context = RequestContext::build(
    +            HashMap::new(),
    +            HashMap::new(),
    +            false,
    +            RequestParams::default(),
    +        );
    +        evaluate_policies_arc(&policies, None, None, action, resource, &context)
    +    }
    +
    +    #[test]
    +    fn a_deny_on_the_table_arn_binds_when_table_name_is_the_arn() {
    +        use extenddb_auth::policy::evaluator::AuthzDecision;
    +
    +        let arn = format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/orders");
    +        let policy = format!(
    +            r#"{{
    +                "Version": "2012-10-17",
    +                "Statement": [
    +                    {{"Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"}},
    +                    {{"Effect": "Deny", "Action": "dynamodb:BulkPutItems",
    +                      "Resource": "{arn}"}}
    +                ]
    +            }}"#
    +        );
    +        let evaluate = |table_name: &str| {
    +            decide(
    +                &policy,
    +                "dynamodb:BulkPutItems",
    +                &derived_resource("StartBulkPutItems", &json!({ "TableName": table_name })),
    +            )
    +        };
    +
    +        // The ARN spelling resolves to the bare name, so the resource the
    +        // policy is evaluated against is the table's ARN and the explicit
    +        // Deny binds.
    +        assert_eq!(evaluate(&arn), AuthzDecision::Deny);
    +        // The bare spelling reaches the same decision.
    +        assert_eq!(evaluate("orders"), AuthzDecision::Deny);
    +        // Without the resolution the ARN would be embedded inside another
    +        // ARN, missing the Deny; this is the gap the resolution closes.
    +        assert_eq!(
    +            decide(
    +                &policy,
    +                "dynamodb:BulkPutItems",
    +                &build_resource_arn(REGION, ACCOUNT, Some(&arn)),
    +            ),
    +            AuthzDecision::Allow
    +        );
    +    }
    +
    +    #[test]
    +    fn start_allowed_on_table_a_is_denied_on_table_b_for_both_spellings() {
    +        use extenddb_auth::policy::evaluator::AuthzDecision;
    +
    +        let policy = format!(
    +            r#"{{
    +                "Version": "2012-10-17",
    +                "Statement": [
    +                    {{"Effect": "Allow", "Action": "dynamodb:BulkPutItems",
    +                      "Resource": "arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/alpha"}}
    +                ]
    +            }}"#
    +        );
    +        let evaluate = |table_name: &str| {
    +            decide(
    +                &policy,
    +                "dynamodb:BulkPutItems",
    +                &derived_resource("StartBulkPutItems", &json!({ "TableName": table_name })),
    +            )
    +        };
    +
    +        assert_eq!(evaluate("alpha"), AuthzDecision::Allow);
    +        assert_eq!(
    +            evaluate(&format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/alpha")),
    +            AuthzDecision::Allow
    +        );
    +        assert_eq!(evaluate("beta"), AuthzDecision::Deny);
    +        assert_eq!(
    +            evaluate(&format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/beta")),
    +            AuthzDecision::Deny
    +        );
    +    }
    +
    +    #[test]
    +    fn describe_and_cancel_evaluate_the_operation_arn_itself() {
    +        for operation in ["DescribeBulkOperation", "CancelBulkOperation"] {
    +            assert_eq!(
    +                derived_resource(operation, &json!({ "BulkOperationArn": OPERATION_ARN })),
    +                OPERATION_ARN
    +            );
    +        }
    +    }
    +
    +    #[test]
    +    fn a_foreign_account_arn_is_denied_with_no_resource_derived() {
    +        // The derivation answers the denial itself (measured) and produces
    +        // no resource, so the caller has nothing to evaluate a policy
    +        // against and the answer never depends on whether the foreign
    +        // operation exists.
    +        let foreign_account =
    +            "arn:aws:dynamodb:us-east-1:999999999999:bulk-operation/01789060589767-da9ddd4f";
    +        for operation in ["DescribeBulkOperation", "CancelBulkOperation"] {
    +            match derived_error(operation, &json!({ "BulkOperationArn": foreign_account })) {
    +                DynamoDbError::AccessDeniedException(message) => assert_eq!(
    +                    message,
    +                    format!(
    +                        "User: arn:aws:iam::{ACCOUNT}:user/tester is not authorized \
    +                         to perform: dynamodb:{operation} on resource: {foreign_account}"
    +                    )
    +                ),
    +                other => panic!("expected AccessDeniedException, got {other:?}"),
    +            }
    +        }
    +    }
    +
    +    #[test]
    +    fn a_foreign_region_arn_is_not_found_with_no_resource_derived() {
    +        let foreign_region =
    +            "arn:aws:dynamodb:eu-west-1:123456789012:bulk-operation/01789060589767-da9ddd4f";
    +        for operation in ["DescribeBulkOperation", "CancelBulkOperation"] {
    +            match derived_error(operation, &json!({ "BulkOperationArn": foreign_region })) {
    +                DynamoDbError::ResourceNotFoundException(message) => assert_eq!(
    +                    message,
    +                    format!("Bulk operation with arn: {foreign_region} does not exist")
    +                ),
    +                other => panic!("expected ResourceNotFoundException, got {other:?}"),
    +            }
    +        }
    +    }
    +
    +    #[test]
    +    fn a_layer_one_or_layer_two_invalid_arn_answers_its_message() {
    +        let layer_two =
    +            "arn:aws:dynamodb:us-east-1:123456789012:bulk-operation/not-a-valid-id-here";
    +        for operation in ["DescribeBulkOperation", "CancelBulkOperation"] {
    +            match derived_error(operation, &json!({ "BulkOperationArn": "orders" })) {
    +                DynamoDbError::ValidationException(message) => {
    +                    assert_eq!(message, "Invalid Bulk operation ARN");
    +                }
    +                other => panic!("expected ValidationException, got {other:?}"),
    +            }
    +            match derived_error(operation, &json!({ "BulkOperationArn": layer_two })) {
    +                DynamoDbError::ValidationException(message) => {
    +                    assert_eq!(message, "Invalid Request: Bulk operation ARN is invalid.");
    +                }
    +                other => panic!("expected ValidationException, got {other:?}"),
    +            }
    +        }
    +    }
    +
    +    #[test]
    +    fn list_is_evaluated_against_the_account_bulk_operation_wildcard() {
    +        use extenddb_auth::policy::evaluator::AuthzDecision;
    +
    +        let resource = derived_resource("ListBulkOperations", &json!({}));
    +        assert_eq!(
    +            resource,
    +            format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:bulk-operation/*")
    +        );
    +
    +        // A policy granting the account's bulk-operation wildcard covers the
    +        // resource; a policy granting one operation ARN does not, so a List
    +        // grant has to be written against the wildcard.
    +        let wildcard_grant = format!(
    +            r#"{{"Version": "2012-10-17", "Statement": [
    +                {{"Effect": "Allow", "Action": "dynamodb:ListBulkOperations",
    +                  "Resource": "arn:aws:dynamodb:{REGION}:{ACCOUNT}:bulk-operation/*"}}]}}"#
    +        );
    +        let single_grant = format!(
    +            r#"{{"Version": "2012-10-17", "Statement": [
    +                {{"Effect": "Allow", "Action": "dynamodb:ListBulkOperations",
    +                  "Resource": "{OPERATION_ARN}"}}]}}"#
    +        );
    +        assert_eq!(
    +            decide(&wildcard_grant, "dynamodb:ListBulkOperations", &resource),
    +            AuthzDecision::Allow
    +        );
    +        assert_eq!(
    +            decide(&single_grant, "dynamodb:ListBulkOperations", &resource),
    +            AuthzDecision::Deny
    +        );
    +    }
    +
    +    #[test]
    +    fn a_body_without_the_field_evaluates_the_operation_wildcard() {
    +        // The handler rejects such a body after authorization; the
    +        // derivation follows the generic path's convention for a missing
    +        // TableName and evaluates the wildcard rather than refusing.
    +        assert_eq!(
    +            derived_resource("StartBulkPutItems", &json!({})),
    +            format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/*")
    +        );
    +        for operation in ["DescribeBulkOperation", "CancelBulkOperation"] {
    +            assert_eq!(
    +                derived_resource(operation, &json!({ "BulkOperationArn": 7 })),
    +                format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:bulk-operation/*")
    +            );
    +        }
    +    }
    +
    +    #[test]
    +    fn other_operations_derive_no_bulk_resource() {
    +        for operation in [
    +            "GetItem",
    +            "PutItem",
    +            "Query",
    +            "ListTables",
    +            "BatchWriteItem",
    +        ] {
    +            assert_eq!(
    +                bulk_operation_authz_resource(
    +                    operation,
    +                    &json!({ "TableName": "orders" }),
    +                    REGION,
    +                    ACCOUNT,
    +                    &test_identity()
    +                )
    +                .expect("no error for non-bulk operations"),
    +                None,
    +                "{operation} must stay on its existing path"
    +            );
    +        }
    +    }
    +}
    diff --git a/crates/server/src/s3_client_key.rs b/crates/server/src/s3_client_key.rs
    new file mode 100644
    index 000000000..2e5e9df2c
    --- /dev/null
    +++ b/crates/server/src/s3_client_key.rs
    @@ -0,0 +1,45 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The startup check of an S3 store's credentials against the access keys
    +//! this server accepts from its clients, which the wiring of every S3
    +//! store calls with the store's name and the key its clients resolved.
    +
    +use extenddb_auth::CredentialStore;
    +use tracing::instrument::WithSubscriber;
    +use tracing::subscriber::NoSubscriber;
    +
    +/// Warn when the S3 store named `label` signs with an access key this
    +/// server also accepts from its clients: a key in the credential store,
    +/// created or imported, or the dev-mode pair when dev mode is on. AWS S3
    +/// refuses a key ExtendDB created, and takes an imported AWS key as the
    +/// identity of that key, so the store signs as one of the clients. The key
    +/// is never logged: a credential store logs the key of a lookup that
    +/// fails, so the lookup runs with its log lines dropped, and a lookup
    +/// error counts as no match.
    +pub(crate) async fn warn_of_a_client_key(
    +    label: &str,
    +    access_key_id: &str,
    +    credential_store: &dyn CredentialStore,
    +    dev_mode: bool,
    +) {
    +    let lookup = credential_store
    +        .lookup_credential(access_key_id)
    +        .with_subscriber(NoSubscriber::default())
    +        .await;
    +    let in_store = matches!(lookup, Ok(Some(_)));
    +    let dev_pair = dev_mode
    +        && (access_key_id == crate::dev_credentials::EXAMPLE_ACCESS_KEY_ID
    +            || crate::dev_credentials::resolve()
    +                .is_ok_and(|pairs| pairs.iter().any(|pair| pair.access_key_id == access_key_id)));
    +    if in_store || dev_pair {
    +        tracing::warn!(
    +            "The {label} credentials are an access key this server also accepts from its \
    +             clients. S3 refuses a key ExtendDB created, and accepts an imported AWS key as \
    +             that key's identity. Start the server without the clients' AWS variables or profile"
    +        );
    +    }
    +}
    +
    +#[cfg(test)]
    +mod tests;
    diff --git a/crates/server/src/s3_client_key/tests.rs b/crates/server/src/s3_client_key/tests.rs
    new file mode 100644
    index 000000000..cf47a3e6f
    --- /dev/null
    +++ b/crates/server/src/s3_client_key/tests.rs
    @@ -0,0 +1,141 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! Tests for the check of an S3 store's key against the keys the server
    +//! accepts from its clients.
    +
    +use async_trait::async_trait;
    +use extenddb_auth::StoredCredential;
    +use extenddb_core::error::DynamoDbError;
    +use extenddb_storage::server_components::ServerComponentsOptions;
    +
    +use super::*;
    +use crate::test_logs::logged;
    +
    +/// How the store of these tests names itself.
    +const LABEL: &str = "S3 test store";
    +
    +/// What the check warns when the store's key is one the server accepts
    +/// from its clients.
    +const CLIENT_KEY: &str = "The S3 test store credentials are an access key this server also \
    +                          accepts from its clients. S3 refuses a key ExtendDB created, and \
    +                          accepts an imported AWS key as that key's identity. Start the server \
    +                          without the clients' AWS variables or profile";
    +
    +const ACCOUNT: &str = "111111111111";
    +
    +/// An imported key, as an operator imports a client's AWS key.
    +const IMPORTED: &str = "AKIAIMPORTEDTEST0001";
    +
    +/// Which key the S3 store's credentials are in one case.
    +#[derive(Clone, Copy, Debug)]
    +enum StoreKey {
    +    /// A key the server created for one of its users.
    +    Created,
    +    /// A key imported into the server for one of its users.
    +    Imported,
    +    /// The dev-mode example pair, which this credential store does not hold.
    +    DevPair,
    +    /// A key the server does not know.
    +    Unknown,
    +}
    +
    +/// The check's log for the S3 store signing with `key`, over a credential
    +/// store that holds a created key and an imported one. The log never
    +/// carries the key.
    +fn client_key_log(key: StoreKey, dev_mode: bool) -> String {
    +    let (signing, log) = logged(async move {
    +        let config = extenddb_storage_sqlite::SqliteConfig {
    +            path: ":memory:".to_owned(),
    +            pool_size: 2,
    +        };
    +        let components = (extenddb_storage_sqlite::backend().server_components)(
    +            &config,
    +            "eu-west-3",
    +            ServerComponentsOptions::default(),
    +        )
    +        .await
    +        .expect("server components");
    +        let catalog = components.catalog_store;
    +        catalog.create_account(ACCOUNT, ACCOUNT).await.unwrap();
    +        catalog.create_user(ACCOUNT, "loader", None).await.unwrap();
    +        let created = catalog
    +            .create_access_key(ACCOUNT, "loader")
    +            .await
    +            .unwrap()
    +            .access_key_id;
    +        catalog
    +            .import_access_key(ACCOUNT, "loader", IMPORTED, "imported-test-secret")
    +            .await
    +            .unwrap();
    +        let signing = match key {
    +            StoreKey::Created => created,
    +            StoreKey::Imported => IMPORTED.to_owned(),
    +            StoreKey::DevPair => crate::dev_credentials::EXAMPLE_ACCESS_KEY_ID.to_owned(),
    +            StoreKey::Unknown => "AKIAUNKNOWNTEST00001".to_owned(),
    +        };
    +        warn_of_a_client_key(
    +            LABEL,
    +            &signing,
    +            components.credential_store.as_ref(),
    +            dev_mode,
    +        )
    +        .await;
    +        signing
    +    });
    +    assert!(!log.contains(&signing), "{key:?}: {log}");
    +    log
    +}
    +
    +#[test]
    +fn an_s3_store_key_the_server_accepts_from_its_clients_is_a_warning() {
    +    for (key, dev_mode) in [
    +        (StoreKey::Created, false),
    +        (StoreKey::Imported, false),
    +        (StoreKey::Created, true),
    +        (StoreKey::DevPair, true),
    +    ] {
    +        let log = client_key_log(key, dev_mode);
    +        assert_eq!(
    +            log.matches(CLIENT_KEY).count(),
    +            1,
    +            "{key:?} dev mode {dev_mode}: {log}"
    +        );
    +    }
    +}
    +
    +#[test]
    +fn an_s3_store_key_the_server_does_not_accept_is_no_warning() {
    +    for (key, dev_mode) in [
    +        (StoreKey::Unknown, false),
    +        (StoreKey::Unknown, true),
    +        (StoreKey::DevPair, false),
    +    ] {
    +        let log = client_key_log(key, dev_mode);
    +        assert!(!log.contains(LABEL), "{key:?} dev mode {dev_mode}: {log}");
    +    }
    +}
    +
    +/// A credential store whose lookup fails and logs the key it was asked
    +/// for, as every backend does on a database or decryption error.
    +struct FailingStore;
    +
    +#[async_trait]
    +impl CredentialStore for FailingStore {
    +    async fn lookup_credential(
    +        &self,
    +        access_key_id: &str,
    +    ) -> Result, DynamoDbError> {
    +        tracing::error!("Failed to look up credential {access_key_id}: database is locked");
    +        Err(DynamoDbError::InternalServerError(
    +            "database is locked".to_owned(),
    +        ))
    +    }
    +}
    +
    +#[test]
    +fn a_lookup_that_fails_logs_nothing_and_is_no_warning() {
    +    let ((), log) = logged(warn_of_a_client_key(LABEL, IMPORTED, &FailingStore, false));
    +    assert!(!log.contains(IMPORTED), "{log}");
    +    assert!(!log.contains(LABEL), "{log}");
    +}
    diff --git a/crates/server/src/serve.rs b/crates/server/src/serve.rs
    index 64ad37527..3837425b7 100644
    --- a/crates/server/src/serve.rs
    +++ b/crates/server/src/serve.rs
    @@ -272,9 +272,9 @@ async fn serve_inner(params: ServeParams, port: u16) -> anyhow::Result<()> {
         // Filter with: `journalctl -t extenddb | grep -v sqlx` (exclude) or
         // `journalctl -t extenddb | grep sqlx` (include only).
         //
    -    // The EnvFilter encodes both levels: `{app_level},sqlx={sqlx_level}`.
    +    // The EnvFilter encodes both levels (`workers::log_filter`).
         // The poll_log_level worker reloads the filter when either setting changes.
    -    let filter_str = format!("{},sqlx=warn", app_config.logging.level);
    +    let filter_str = crate::workers::log_filter(&app_config.logging.level, "warn");
         // CB-29: Always use the config file log level, never RUST_LOG. The runtime
         // settings poller handles dynamic level changes. RUST_LOG silently
         // overriding the config is an operational surprise.
    @@ -390,6 +390,8 @@ async fn serve_inner(params: ServeParams, port: u16) -> anyhow::Result<()> {
         let storage = components.engine;
         let catalog_store = components.catalog_store;
         let cred_store = components.credential_store;
    +    // The bulk store's startup check looks its own key up past the cache.
    +    let bulk_cred_store = Arc::clone(&cred_store);
         let runtime_hooks = components.runtime_hooks;
         let registrar = runtime_hooks
             .as_ref()
    @@ -599,6 +601,15 @@ async fn serve_inner(params: ServeParams, port: u16) -> anyhow::Result<()> {
             }
         }
     
    +    let bulk_store = crate::bulk_store::resolve_bulk_store(
    +        &app_config,
    +        dev_mode,
    +        &catalog_store,
    +        &storage,
    +        &bulk_cred_store,
    +    )
    +    .await?;
    +
         // D9: Build static config entries for the console settings page.
         // Must be called before `app_config.limits` is moved.
         let config_entries = config::build_config_entries(&app_config);
    @@ -664,6 +675,7 @@ async fn serve_inner(params: ServeParams, port: u16) -> anyhow::Result<()> {
             dev_mode,
             import_paths,
             export_paths,
    +        bulk: bulk_store,
             throttle: throttle.clone(),
             auth_cache,
             authz_cache,
    @@ -697,6 +709,12 @@ async fn serve_inner(params: ServeParams, port: u16) -> anyhow::Result<()> {
             let reload_handle = reload_handle.clone();
             let config_log_level = app_config.logging.level.clone();
             let metrics = metrics.clone();
    +        // The bulk put workers' dependencies, taken from the state once.
    +        let bulk_storage = state.storage.clone();
    +        let bulk_limits = state.limits.clone();
    +        let bulk_region = state.region.clone();
    +        let bulk_store = state.bulk.clone();
    +        let bulk_authorizer = crate::bulk_authorizer::ServerBulkAuthorizer::for_state(&state);
             let storage = storage.clone();
             Box::new(move |token: CancellationToken| {
                 let catalog_store = catalog_store.clone();
    @@ -705,6 +723,11 @@ async fn serve_inner(params: ServeParams, port: u16) -> anyhow::Result<()> {
                 let throttle = throttle.clone();
                 let metrics = metrics.clone();
                 let hooks = hooks.clone();
    +            let bulk_storage = bulk_storage.clone();
    +            let bulk_limits = bulk_limits.clone();
    +            let bulk_region = bulk_region.clone();
    +            let bulk_store = bulk_store.clone();
    +            let bulk_authorizer = bulk_authorizer.clone();
                 let storage = storage.clone();
                 let reconciler_state = reconciler_state.clone();
                 futures::FutureExt::boxed(async move {
    @@ -740,6 +763,25 @@ async fn serve_inner(params: ServeParams, port: u16) -> anyhow::Result<()> {
                         )),
                         // Phase 11a: Warn about approximate consumed capacity.
                         tokio::spawn(workers::capacity_warning_worker(token.clone())),
    +                    // Claim and run bulk put operations, and purge terminal
    +                    // records past the retention window. The loops live in the
    +                    // engine crate because the runner they drive is private
    +                    // to it.
    +                    tokio::spawn(extenddb_engine::bulk_operation_worker(
    +                        bulk_storage.clone(),
    +                        bulk_limits.clone(),
    +                        bulk_region,
    +                        bulk_store,
    +                        bulk_authorizer,
    +                        metrics.clone(),
    +                        token.clone(),
    +                    )),
    +                    tokio::spawn(extenddb_engine::bulk_operation_retention_worker(
    +                        bulk_storage,
    +                        bulk_limits,
    +                        metrics.clone(),
    +                        token.clone(),
    +                    )),
                         // The backup reconciler: backend-agnostic, so it runs
                         // here beside the backends' workers rather than in each
                         // backend's `spawn_workers`, and stops and starts with
    diff --git a/crates/server/src/test_logs.rs b/crates/server/src/test_logs.rs
    new file mode 100644
    index 000000000..828ed2c4b
    --- /dev/null
    +++ b/crates/server/src/test_logs.rs
    @@ -0,0 +1,48 @@
    +// Copyright 2026 ExtendDB contributors
    +// SPDX-License-Identifier: Apache-2.0
    +
    +//! The log lines a future writes, for the tests that assert them.
    +
    +use std::future::Future;
    +use std::sync::{Arc, Mutex, PoisonError};
    +
    +/// Collects what a `tracing` subscriber writes.
    +#[derive(Clone, Default)]
    +struct Logged(Arc>>);
    +
    +impl std::io::Write for Logged {
    +    fn write(&mut self, bytes: &[u8]) -> std::io::Result {
    +        self.0
    +            .lock()
    +            .unwrap_or_else(PoisonError::into_inner)
    +            .extend_from_slice(bytes);
    +        Ok(bytes.len())
    +    }
    +
    +    fn flush(&mut self) -> std::io::Result<()> {
    +        Ok(())
    +    }
    +}
    +
    +/// Run `future` on this thread and return its output with the log lines it
    +/// wrote at info and above.
    +pub(crate) fn logged(future: F) -> (F::Output, String) {
    +    let logged = Logged::default();
    +    let writer = logged.clone();
    +    let subscriber = tracing_subscriber::fmt()
    +        .with_ansi(false)
    +        .with_max_level(tracing::Level::INFO)
    +        .with_writer(move || writer.clone())
    +        .finish();
    +    let runtime = tokio::runtime::Builder::new_current_thread()
    +        .enable_all()
    +        .build()
    +        .unwrap();
    +    let output = tracing::subscriber::with_default(subscriber, || runtime.block_on(future));
    +    let lines = logged
    +        .0
    +        .lock()
    +        .unwrap_or_else(PoisonError::into_inner)
    +        .clone();
    +    (output, String::from_utf8(lines).unwrap())
    +}
    diff --git a/crates/server/src/workers.rs b/crates/server/src/workers.rs
    index 58e3a7133..a34040d99 100755
    --- a/crates/server/src/workers.rs
    +++ b/crates/server/src/workers.rs
    @@ -25,9 +25,18 @@ use extenddb_storage::management_store::{MetricsStore, RateLimitStore, SettingsS
     use extenddb_storage::{CancellationToken, sleep_or_shutdown as tick};
     use tracing_subscriber::{EnvFilter, reload};
     
    +/// The tracing filter for the application level `level` and the sqlx level
    +/// `sqlx_level`. The AWS SDK's profile credential loader logs the S3 bulk
    +/// store's access key ID at info on each load, so its target stays at warn,
    +/// which keeps its warnings (a credential load that failed); a target
    +/// directive outranks the bare level, so `debug` leaves it at warn too.
    +pub(crate) fn log_filter(level: &str, sqlx_level: &str) -> String {
    +    format!("{level},sqlx={sqlx_level},aws_config=warn")
    +}
    +
     /// Poll the `log_level` and `sqlx_log_level` settings from the database
     /// and reload the tracing filter when either changes (D-22, D-3).
    -/// The combined filter is `{log_level},sqlx={sqlx_log_level}`.
    +/// The combined filter is [`log_filter`].
     /// Falls back to `config_level` when `log_level` is absent from the DB.
     /// Runs until `token` is cancelled.
     pub(crate) async fn poll_log_level(
    @@ -69,7 +78,7 @@ pub(crate) async fn poll_log_level(
             }
     
             // D-3: Combined filter encodes both levels.
    -        let filter_str = format!("{new_level},sqlx={new_sqlx_level}");
    +        let filter_str = log_filter(&new_level, &new_sqlx_level);
     
             match EnvFilter::try_new(&filter_str) {
                 Ok(new_filter) => {
    @@ -285,6 +294,48 @@ pub(crate) async fn login_attempt_cleanup_worker(
     
     #[cfg(test)]
     mod tests {
    +    #[test]
    +    fn the_sdk_credential_loader_stays_at_warn_whatever_the_levels() {
    +        use std::sync::{Arc, Mutex, PoisonError};
    +
    +        #[derive(Clone, Default)]
    +        struct Captured(Arc>>);
    +        impl std::io::Write for Captured {
    +            fn write(&mut self, bytes: &[u8]) -> std::io::Result {
    +                let mut buffer = self.0.lock().unwrap_or_else(PoisonError::into_inner);
    +                buffer.extend_from_slice(bytes);
    +                Ok(bytes.len())
    +            }
    +            fn flush(&mut self) -> std::io::Result<()> {
    +                Ok(())
    +            }
    +        }
    +
    +        for (level, sqlx_level) in [("info", "warn"), ("debug", "info"), ("trace", "debug")] {
    +            let captured = Captured::default();
    +            let writer = captured.clone();
    +            let subscriber = tracing_subscriber::fmt()
    +                .with_env_filter(super::EnvFilter::new(super::log_filter(level, sqlx_level)))
    +                .with_writer(move || writer.clone())
    +                .finish();
    +            tracing::subscriber::with_default(subscriber, || {
    +                tracing::info!(target: "aws_config::profile::credentials", "loaded base credentials");
    +                tracing::warn!(target: "aws_config::profile::credentials", "credential load failed");
    +                tracing::info!(target: "extenddb_server::serve", "an application line");
    +            });
    +            let logged = String::from_utf8(captured.0.lock().unwrap().clone()).unwrap();
    +            assert!(
    +                !logged.contains("loaded base credentials"),
    +                "{level}: {logged}"
    +            );
    +            assert!(
    +                logged.contains("credential load failed"),
    +                "{level}: {logged}"
    +            );
    +            assert!(logged.contains("an application line"), "{level}: {logged}");
    +        }
    +    }
    +
         use super::{login_attempt_cleanup_worker, metrics_flush_worker};
         use extenddb_core::metrics::MetricsCollector;
         use extenddb_storage::CancellationToken;
    
    From a0e589e135efec389e25c3a9ff8255766b22c939 Mon Sep 17 00:00:00 2001
    From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com>
    Date: Thu, 5 Nov 2026 22:10:04 +0000
    Subject: [PATCH 5/6] test: add the bulk put suite and the S3 bulk store job
    
    tests/bulk_put sends the four bulk operations to a running server.
    It covers validation, the lifecycle, idempotency, listing, the store
    rules, authorization and the S3 permissions an operation checks for its
    caller. The suite writes its inputs to and reads its reports from the
    store that EXTENDDB_TEST_BULK_STORE names, and devtools/run-tests sets
    it from its new --bulk-store option. On MongoDB and Cassandra the suite
    checks that the server refuses the bulk requests, then skips. The new
    integration-s3.yml workflow starts an S3-compatible service in a
    container and runs the engine's store contract tests, then the suite,
    against it. integration.yml calls it as a job, and the integration gate
    requires that job to pass. The provisioned test user
    may call s3:ListBucket, s3:GetObject and s3:PutObject. A CLI lifecycle
    test checks that a daemonized serve resolves relative bulk, import and
    export paths against its start directory.
    ---
     .github/workflows/integration-s3.yml       | 144 ++++++++
     .github/workflows/integration.yml          |  16 +-
     devtools/provision-test-credentials        |  11 +-
     devtools/run-tests                         |  29 +-
     devtools/run-tests-bulk-store.sh           |  90 +++++
     tests/bulk_put/__init__.py                 |   2 +
     tests/bulk_put/bulk_helpers.py             | 400 +++++++++++++++++++++
     tests/bulk_put/bulk_store.py               | 148 ++++++++
     tests/bulk_put/conftest.py                 |  92 +++++
     tests/bulk_put/test_authorization.py       | 259 +++++++++++++
     tests/bulk_put/test_idempotency.py         | 138 +++++++
     tests/bulk_put/test_lifecycle.py           | 218 +++++++++++
     tests/bulk_put/test_list.py                | 190 ++++++++++
     tests/bulk_put/test_s3_permissions.py      | 229 ++++++++++++
     tests/bulk_put/test_store_rules.py         | 206 +++++++++++
     tests/bulk_put/test_validation.py          | 249 +++++++++++++
     tests/lifecycle_helpers.py                 |   6 +-
     tests/test_cli_lifecycle.py                |  30 ++
     tests/test_cli_vector_catalog_migration.py |   2 +-
     19 files changed, 2448 insertions(+), 11 deletions(-)
     create mode 100644 .github/workflows/integration-s3.yml
     create mode 100644 devtools/run-tests-bulk-store.sh
     create mode 100644 tests/bulk_put/__init__.py
     create mode 100644 tests/bulk_put/bulk_helpers.py
     create mode 100644 tests/bulk_put/bulk_store.py
     create mode 100644 tests/bulk_put/conftest.py
     create mode 100644 tests/bulk_put/test_authorization.py
     create mode 100644 tests/bulk_put/test_idempotency.py
     create mode 100644 tests/bulk_put/test_lifecycle.py
     create mode 100644 tests/bulk_put/test_list.py
     create mode 100644 tests/bulk_put/test_s3_permissions.py
     create mode 100644 tests/bulk_put/test_store_rules.py
     create mode 100644 tests/bulk_put/test_validation.py
    
    diff --git a/.github/workflows/integration-s3.yml b/.github/workflows/integration-s3.yml
    new file mode 100644
    index 000000000..da6b032ff
    --- /dev/null
    +++ b/.github/workflows/integration-s3.yml
    @@ -0,0 +1,144 @@
    +# The bulk store on an S3-compatible service. integration.yml calls this
    +# workflow, so its integration gate job requires the result.
    +name: Integration (S3 bulk store)
    +
    +on:
    +  workflow_call:
    +
    +permissions:
    +  contents: read
    +
    +jobs:
    +  # The bulk store on an S3-compatible service: the engine's store contract
    +  # tests, then the Python bulk suite against a server whose [bulk] store is
    +  # s3. The service is versitygw, which selects its backend by command
    +  # (posix, on the container's /tmp); a services: container takes no
    +  # command, so a step starts it. PostgreSQL is the backend, as in
    +  # run-integration.
    +  run-integration-s3:
    +    runs-on: extenddb_ubuntu-2404_4-core
    +    timeout-minutes: 40
    +    services:
    +      postgres:
    +        image: pgvector/pgvector:pg16
    +        env:
    +          POSTGRES_PASSWORD: devpass
    +        options: >-
    +          --health-cmd "pg_isready -U postgres"
    +          --health-interval 5s
    +          --health-timeout 5s
    +          --health-retries 5
    +        ports:
    +          - 127.0.0.1:5432:5432
    +    env:
    +      # The container's root keys. They exist only inside this job's
    +      # container and grant nothing anywhere else.
    +      S3_ACCESS_KEY: extenddb-ci
    +      S3_SECRET_KEY: extenddb-ci-secret
    +
    +    steps:
    +      - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
    +
    +      - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
    +        with:
    +          toolchain: stable
    +
    +      - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
    +        with:
    +          cache-on-failure: true
    +          # Only pushes to main save, so other runs restore main's cache.
    +          save-if: ${{ github.ref == 'refs/heads/main' }}
    +
    +      - name: Start the S3-compatible service
    +        run: |
    +          # versity/versitygw v1.0.18, pinned by digest.
    +          docker run -d --name s3 -p 127.0.0.1:7070:7070 \
    +            -e ROOT_ACCESS_KEY="$S3_ACCESS_KEY" -e ROOT_SECRET_KEY="$S3_SECRET_KEY" \
    +            versity/versitygw@sha256:90f8280610a889d705ae93ae4b4064d74ffa9f3b565aacf2e3372b0614d7a796 \
    +            posix /tmp
    +          for i in $(seq 1 30); do
    +            # Any HTTP answer means it is serving: an unsigned request is
    +            # answered 403.
    +            if curl -s -o /dev/null http://127.0.0.1:7070/; then
    +              echo "S3-compatible service ready"
    +              exit 0
    +            fi
    +            sleep 1
    +          done
    +          docker logs s3
    +          echo "S3-compatible service failed to start"
    +          exit 1
    +
    +      - name: Build release
    +        run: cargo build --release
    +
    +      # Every contract case runs once per store; EXTENDDB_TEST_S3_REQUIRED
    +      # makes a missing endpoint a failure rather than a skip. The smoke
    +      # test reads, writes a multipart report and checks a dropped writer
    +      # against the same service. The keys reach the store through the
    +      # standard AWS credential variables, as they reach the server.
    +      - name: Run the bulk store tests against the S3-compatible service
    +        env:
    +          EXTENDDB_TEST_S3_ENDPOINT: http://127.0.0.1:7070
    +          EXTENDDB_TEST_S3_REQUIRED: "1"
    +          EXTENDDB_TEST_S3_SMOKE_ENDPOINT: http://127.0.0.1:7070
    +          AWS_ACCESS_KEY_ID: ${{ env.S3_ACCESS_KEY }}
    +          AWS_SECRET_ACCESS_KEY: ${{ env.S3_SECRET_KEY }}
    +          AWS_REGION: us-east-1
    +          AWS_EC2_METADATA_DISABLED: "true"
    +        run: >-
    +          cargo test --release -p extenddb-engine --lib --
    +          bulk_put::source::contract bulk_put::s3::tests::smoke
    +
    +      - name: Initialize ExtendDB
    +        id: init
    +        run: |
    +          output=$(./target/release/extenddb init --config extenddb.toml \
    +            --pg-host 127.0.0.1 --pg-port 5432 --pg-user postgres --pg-pass devpass 2>&1)
    +          echo "$output"
    +          password=$(echo "$output" | grep -oP 'Password: \K\S+')
    +          echo "admin_password=$password" >> "$GITHUB_OUTPUT"
    +
    +      - name: Start ExtendDB
    +        run: |
    +          # --write-pid-file so devtools/run-tests can restart the server
    +          # with the S3 store configured and the S3 keys in its environment.
    +          ./target/release/extenddb serve --config extenddb.toml --foreground --write-pid-file &
    +          for i in $(seq 1 30); do
    +            if curl -sk https://127.0.0.1:18443/health | grep -q healthy; then
    +              echo "Server ready"
    +              exit 0
    +            fi
    +            sleep 1
    +          done
    +          echo "Server failed to start"
    +          exit 1
    +
    +      - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
    +        with:
    +          python-version: "3.12"
    +
    +      - name: Install Python dependencies
    +        run: pip install -r requirements.txt
    +
    +      # run-tests writes the [bulk] s3 store into extenddb.toml and restarts
    +      # the server with the S3 keys as its AWS credentials; the suite reaches
    +      # the same service with the same keys to create its buckets and read
    +      # the reports.
    +      - name: Run the bulk suite on the S3 store
    +        env:
    +          EXTENDDB_TEST_ENDPOINT: https://127.0.0.1:18443
    +          EXTENDDB_ADMIN_USER: admin
    +          EXTENDDB_ADMIN_PASSWORD: ${{ steps.init.outputs.admin_password }}
    +          EXTENDDB_TEST_S3_ENDPOINT: http://127.0.0.1:7070
    +          EXTENDDB_TEST_S3_ACCESS_KEY_ID: ${{ env.S3_ACCESS_KEY }}
    +          EXTENDDB_TEST_S3_SECRET_ACCESS_KEY: ${{ env.S3_SECRET_KEY }}
    +        run: devtools/run-tests --extenddb --pytest --bulk-store s3 --parallel --filter bulk_put
    +
    +      - name: Dump server syslog on failure
    +        if: failure()
    +        run: sudo journalctl -t extenddb --no-pager -n 500 || true
    +
    +      - name: Dump the S3-compatible service's log on failure
    +        if: failure()
    +        run: docker logs s3 || true
    diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
    index 7fd08685e..fabc4c867 100644
    --- a/.github/workflows/integration.yml
    +++ b/.github/workflows/integration.yml
    @@ -9,7 +9,7 @@ permissions:
       contents: read
     
     # Supersede an in-flight run of the SAME pull request when a new push arrives,
    -# so two pushes do not each hold six slots on a capped pool. Everything that is
    +# so two pushes do not each hold slots on a capped pool. Everything that is
     # not a pull request gets a group unique to the run (run_id): merge-group runs
     # can share a ref (gh-readonly-queue/main/pr-N- is reused across
     # re-queues of the same PR), and GitHub concurrency replaces a PENDING run in
    @@ -157,9 +157,9 @@ jobs:
             run: devtools/run-tests --extenddb --pytest --comprehensive --parallel --filter "not import_export"
     
           # run-tests restarts the server daemonized to apply the import/export
    -      # config, and a daemonized server logs to syslog, not the job log. Without
    -      # this dump, the storage error behind a client-visible 500 leaves no
    -      # evidence anywhere in CI.
    +      # and bulk store config, and a daemonized server logs to syslog, not the
    +      # job log. Without this dump, the storage error behind a client-visible
    +      # 500 leaves no evidence anywhere in CI.
           - name: Dump server syslog on failure
             if: failure()
             run: sudo journalctl -t extenddb --no-pager -n 500 || true
    @@ -593,6 +593,10 @@ jobs:
             if: failure()
             run: sudo journalctl -t extenddb --no-pager -n 500 || true
     
    +  # The bulk store on an S3-compatible service (integration-s3.yml).
    +  run-integration-s3:
    +    uses: ./.github/workflows/integration-s3.yml
    +
       integration:
         runs-on: extenddb_ubuntu-2404_4-core
         timeout-minutes: 40
    @@ -604,6 +608,7 @@ jobs:
             run-rust-integration,
             run-rust-integration-postgres-novector,
             run-rust-integration-sqlite,
    +        run-integration-s3,
           ]
         if: always()
         steps:
    @@ -613,6 +618,7 @@ jobs:
                  [ "${{ needs.run-integration-dev-mode.result }}" != "success" ] || \
                  [ "${{ needs.run-rust-integration.result }}" != "success" ] || \
                  [ "${{ needs.run-rust-integration-postgres-novector.result }}" != "success" ] || \
    -             [ "${{ needs.run-rust-integration-sqlite.result }}" != "success" ]; then
    +             [ "${{ needs.run-rust-integration-sqlite.result }}" != "success" ] || \
    +             [ "${{ needs.run-integration-s3.result }}" != "success" ]; then
                 exit 1
               fi
    diff --git a/devtools/provision-test-credentials b/devtools/provision-test-credentials
    index 189fea3dd..443c491f8 100755
    --- a/devtools/provision-test-credentials
    +++ b/devtools/provision-test-credentials
    @@ -35,7 +35,16 @@ USER_NAME = "test-user"
     POLICY_NAME = "full-access"
     FULL_ACCESS_POLICY = {
         "Version": "2012-10-17",
    -    "Statement": [{"Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"}],
    +    "Statement": [
    +        {"Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"},
    +        # A bulk put operation reads its input and writes its reports with
    +        # these S3 actions, evaluated for the caller.
    +        {
    +            "Effect": "Allow",
    +            "Action": ["s3:ListBucket", "s3:GetObject", "s3:PutObject"],
    +            "Resource": "*",
    +        },
    +    ],
     }
     # Credentials used by the external Java test suite (hardcoded in DynamoDBTestBase.java).
     # These don't pass AKIA format validation, so we create proper keys instead.
    diff --git a/devtools/run-tests b/devtools/run-tests
    index ae7984896..854526e42 100755
    --- a/devtools/run-tests
    +++ b/devtools/run-tests
    @@ -33,6 +33,8 @@
     #                       postgres (default) or mongodb.
     #                       Gates postgres-only paths (pg connection-string
     #                       extraction, test_cli_lifecycle.py).
    +#   --bulk-store NAME bulk store for the bulk tests: filesystem (default)
    +#                       or s3 (see devtools/run-tests-bulk-store.sh).
     #   --release         use release build for Rust tests
     #   --filter PATTERN  pass to cargo test / pytest -k
     #   --parallel[=N]    run pytest in parallel (default: 1/3 of CPU cores)
    @@ -45,6 +47,8 @@ set -euo pipefail
     SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
     PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
     cd "$PROJECT_DIR"
    +# shellcheck source=devtools/run-tests-bulk-store.sh
    +. "$SCRIPT_DIR/run-tests-bulk-store.sh"
     
     # Never prompt for input — redirect stdin from /dev/null for the entire script.
     # Any command that would block on stdin will get EOF immediately.
    @@ -73,6 +77,7 @@ FILTER=""
     CONFIG_PATH=""
     PARALLEL=""
     BACKEND="postgres"
    +BULK_STORE="filesystem"
     
     usage() {
         cat <<'EOF'
    @@ -94,6 +99,10 @@ Suites (at least one required):
     Options:
       --backend NAME    storage backend the running server uses:
                           postgres (default) or mongodb.
    +  --bulk-store NAME bulk store for the bulk tests: filesystem (default)
    +                      or s3 (needs EXTENDDB_TEST_S3_ENDPOINT,
    +                      EXTENDDB_TEST_S3_ACCESS_KEY_ID and
    +                      EXTENDDB_TEST_S3_SECRET_ACCESS_KEY)
       --release         release build for Rust tests
       --filter PATTERN  filter for cargo test / pytest -k
       --parallel[=N]    run pytest in parallel (default: 1/3 of CPU cores)
    @@ -121,6 +130,7 @@ while [[ $# -gt 0 ]]; do
             --catalog-check)  RUN_CATALOG_CHECK=true; shift ;;
             --config)         CONFIG_PATH="$2"; shift 2 ;;
             --backend)        BACKEND="$2"; BACKEND_GIVEN=1; shift 2 ;;
    +        --bulk-store)     BULK_STORE="$2"; shift 2 ;;
             --parallel)
                 # --parallel (no argument) or --parallel=N
                 PARALLEL="auto"; shift
    @@ -149,6 +159,9 @@ if [[ -n "${BACKEND_GIVEN:-}" ]]; then
         export EXTENDDB_TEST_BACKEND="$BACKEND"
     fi
     
    +# --- Validate: bulk store ---
    +bulk_store_check_name
    +
     # --- Validate: target flag required ---
     if [[ -z "$TARGET" ]]; then
         echo "error: target flag is required (--extenddb or --real-dynamodb)"
    @@ -230,6 +243,9 @@ if $NEEDS_INTEGRATION; then
         esac
     fi
     
    +# --- Validate: the S3 store's endpoint and keys ---
    +bulk_store_check_s3
    +
     # --- Echo target info (D3: always, before any tests) ---
     echo "=== Test target: $TARGET ==="
     echo "  commit:              $HASH"
    @@ -238,6 +254,7 @@ if [[ "$TARGET" == "real-dynamodb" ]]; then
         echo "  AWS_ACCESS_KEY_ID  = ${AWS_ACCESS_KEY_ID:-(from ~/.aws)}"
     else
         echo "  backend            = $BACKEND"
    +    echo "  bulk store         = $BULK_STORE"
         echo "  EXTENDDB_TEST_ENDPOINT = ${EXTENDDB_TEST_ENDPOINT:-}"
         echo "  AWS_ACCESS_KEY_ID  = ${AWS_ACCESS_KEY_ID:-(will be provisioned)}"
     fi
    @@ -357,6 +374,8 @@ if $NEEDS_INTEGRATION && [[ "$TARGET" != "real-dynamodb" ]]; then
                 NEED_RESTART=true
                 echo "  ✓ [export] paths configured for $TMP_ALLOWED"
             fi
    +        bulk_store_configure "$CONFIG_FOR_SETTINGS" "$TMP_ALLOWED"
    +        bulk_store_serve_env
             if $NEED_RESTART && [[ -x "$BINARY" ]]; then
                 echo "  Restarting server to pick up import/export config..."
                 # Report failures instead of hiding them. A health check alone is not
    @@ -371,7 +390,7 @@ if $NEEDS_INTEGRATION && [[ "$TARGET" != "real-dynamodb" ]]; then
                     exit 1
                 fi
                 sleep 1
    -            if ! "$BINARY" serve --config "$CONFIG_FOR_SETTINGS"; then
    +            if ! "${SERVE_ENV[@]}" "$BINARY" serve --config "$CONFIG_FOR_SETTINGS"; then
                     echo "  ✗ Server failed to start after config change"
                     exit 1
                 fi
    @@ -564,7 +583,13 @@ if $RUN_PYTEST \
         if [[ -n "$FILTER" ]]; then
             CLI_ARGS+=(-k "$FILTER")
         fi
    -    if "${CLI_ARGS[@]}" 2>&1 | tee "$CLI_OUTFILE"; then
    +    # pytest exits 5 when the filter selects none of these tests, which
    +    # leaves this stage nothing to run rather than failing it.
    +    CLI_STATUS=0
    +    "${CLI_ARGS[@]}" 2>&1 | tee "$CLI_OUTFILE" || CLI_STATUS=$?
    +    if [[ $CLI_STATUS -eq 5 && -n "$FILTER" ]]; then
    +        echo "  ✓ CLI lifecycle tests: none selected by the filter"
    +    elif [[ $CLI_STATUS -eq 0 ]]; then
             echo "  ✓ CLI lifecycle tests passed"
         else
             echo "  ✗ CLI lifecycle tests failed"
    diff --git a/devtools/run-tests-bulk-store.sh b/devtools/run-tests-bulk-store.sh
    new file mode 100644
    index 000000000..f6146b347
    --- /dev/null
    +++ b/devtools/run-tests-bulk-store.sh
    @@ -0,0 +1,90 @@
    +# Copyright 2026 ExtendDB contributors
    +# SPDX-License-Identifier: Apache-2.0
    +#
    +# The bulk store of devtools/run-tests (--bulk-store). run-tests sources
    +# this file; it is not run on its own. The functions read BULK_STORE,
    +# TARGET, BINARY and the EXTENDDB_TEST_S3_* variables, and set
    +# NEED_RESTART and SERVE_ENV.
    +
    +# Check the --bulk-store name, then export EXTENDDB_TEST_BULK_STORE: the
    +# bulk tests write their inputs to and read their reports from the store it
    +# names (tests/bulk_put/bulk_store.py).
    +bulk_store_check_name() {
    +    case "$BULK_STORE" in
    +        filesystem|s3) ;;
    +        *)
    +            echo "error: --bulk-store must be 'filesystem' or 's3' (got: $BULK_STORE)"
    +            exit 1
    +            ;;
    +    esac
    +    export EXTENDDB_TEST_BULK_STORE="$BULK_STORE"
    +}
    +
    +# For the S3 store, check the target, the endpoint and its keys.
    +bulk_store_check_s3() {
    +    [[ "$BULK_STORE" == "s3" ]] || return 0
    +    # The bulk tests create and delete buckets on the endpoint, so it must
    +    # be a local service, as the extenddb endpoint must.
    +    if [[ "$TARGET" != "extenddb" ]]; then
    +        echo "error: --bulk-store s3 applies to --extenddb only."
    +        exit 1
    +    fi
    +    if [[ ! "${EXTENDDB_TEST_S3_ENDPOINT:-}" =~ ^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?(/.*)?$ ]]; then
    +        echo "error: --bulk-store s3 requires EXTENDDB_TEST_S3_ENDPOINT to point to an S3-compatible service on localhost."
    +        echo "  Got: ${EXTENDDB_TEST_S3_ENDPOINT:-(not set)}"
    +        exit 1
    +    fi
    +    if [[ -z "${EXTENDDB_TEST_S3_ACCESS_KEY_ID:-}" || -z "${EXTENDDB_TEST_S3_SECRET_ACCESS_KEY:-}" ]]; then
    +        echo "error: --bulk-store s3 requires EXTENDDB_TEST_S3_ACCESS_KEY_ID and EXTENDDB_TEST_S3_SECRET_ACCESS_KEY."
    +        exit 1
    +    fi
    +}
    +
    +# Configure the bulk store in the config file $1, and set NEED_RESTART when
    +# the server must restart to use it. The filesystem store is one directory,
    +# $2, holding bulk inputs and reports as {root}/{account}/{S3Bucket}/...;
    +# the bulk tests write fixtures through tempfile, so it is the same TMPDIR
    +# the import and export paths allow. The S3 store reads and writes buckets
    +# on EXTENDDB_TEST_S3_ENDPOINT with path-style addressing.
    +bulk_store_configure() {
    +    local config=$1 root=$2
    +    if [[ "$BULK_STORE" == "s3" ]]; then
    +        if ! grep -A2 '^\[bulk\.s3\]' "$config" 2>/dev/null | grep -qF "\"$EXTENDDB_TEST_S3_ENDPOINT\""; then
    +            if grep -q '^\[bulk' "$config"; then
    +                echo "  ✗ $config already configures a bulk store; remove its [bulk] tables to switch to the S3 store."
    +                exit 1
    +            fi
    +            printf '\n[bulk]\nstore = "s3"\n\n[bulk.s3]\nendpoint_url = "%s"\nforce_path_style = true\n' "$EXTENDDB_TEST_S3_ENDPOINT" >> "$config"
    +            echo "  ✓ [bulk] S3 store configured at $EXTENDDB_TEST_S3_ENDPOINT"
    +        fi
    +        # The server takes its S3 keys from its environment, which a
    +        # running server may have been started without, so an S3 run
    +        # always restarts it with them.
    +        if [[ ! -x "$BINARY" ]]; then
    +            echo "  ✗ An S3 store run restarts the server with its S3 keys, which needs $BINARY."
    +            exit 1
    +        fi
    +        NEED_RESTART=true
    +    elif ! grep -A1 '^\[bulk\.filesystem\]' "$config" 2>/dev/null | grep -qF "$root"; then
    +        if grep -q '^\[bulk' "$config"; then
    +            echo "  ✗ $config already configures a bulk store; remove its [bulk] tables to switch to the filesystem store at $root."
    +            exit 1
    +        fi
    +        printf '\n[bulk]\n\n[bulk.filesystem]\npath = "%s"\n' "$root" >> "$config"
    +        NEED_RESTART=true
    +        echo "  ✓ [bulk] store configured at $root"
    +    fi
    +}
    +
    +# Set SERVE_ENV, the command prefix the server is restarted under. The S3
    +# keys go to the server's environment only: the test process keeps the
    +# extenddb keys provisioned for it as its AWS credentials.
    +bulk_store_serve_env() {
    +    SERVE_ENV=(env)
    +    if [[ "$BULK_STORE" == "s3" ]]; then
    +        SERVE_ENV=(env -u AWS_SESSION_TOKEN -u AWS_PROFILE
    +            "AWS_ACCESS_KEY_ID=$EXTENDDB_TEST_S3_ACCESS_KEY_ID"
    +            "AWS_SECRET_ACCESS_KEY=$EXTENDDB_TEST_S3_SECRET_ACCESS_KEY"
    +            AWS_EC2_METADATA_DISABLED=true)
    +    fi
    +}
    diff --git a/tests/bulk_put/__init__.py b/tests/bulk_put/__init__.py
    new file mode 100644
    index 000000000..bae455e30
    --- /dev/null
    +++ b/tests/bulk_put/__init__.py
    @@ -0,0 +1,2 @@
    +# Copyright 2026 ExtendDB contributors
    +# SPDX-License-Identifier: Apache-2.0
    diff --git a/tests/bulk_put/bulk_helpers.py b/tests/bulk_put/bulk_helpers.py
    new file mode 100644
    index 000000000..8b5cdc0bf
    --- /dev/null
    +++ b/tests/bulk_put/bulk_helpers.py
    @@ -0,0 +1,400 @@
    +# Copyright 2026 ExtendDB contributors
    +# SPDX-License-Identifier: Apache-2.0
    +"""Shared fixtures and helpers for the bulk operations suite."""
    +from __future__ import annotations
    +
    +import gzip
    +import json
    +import os
    +import re
    +import sys
    +import time
    +import uuid
    +
    +import pytest
    +import requests
    +from botocore.auth import SigV4Auth
    +from botocore.awsrequest import AWSRequest
    +from botocore.credentials import Credentials
    +
    +from bulk_put import bulk_store
    +
    +# The bulk suite is the package tests/bulk_put, one directory below the main
    +# suite. Its own conftest is bulk_put.conftest, so the top-level tests/conftest.py
    +# keeps the module name conftest and is imported here by name once tests/ is on
    +# the path.
    +_TESTS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    +if _TESTS_DIR not in sys.path:
    +    sys.path.append(_TESTS_DIR)
    +
    +from conftest import wait_for_active, wait_for_deleted  # noqa: E402
    +from management_helpers import ManagementClient  # noqa: E402
    +
    +ENDPOINT = os.environ.get("EXTENDDB_TEST_ENDPOINT", "http://localhost:18443").strip()
    +REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
    +ACCOUNT_ID = os.environ.get("EXTENDDB_TEST_ACCOUNT_ID", "123456789012")
    +
    +# Messages the server answers with, asserted verbatim.
    +MISSING_KEY_MESSAGE = (
    +    "One or more parameter values were invalid: Missing the key sk in the item"
    +)
    +CONDITION_FAILED_MESSAGE = "The conditional request failed"
    +IDEMPOTENT_MISMATCH_MESSAGE = (
    +    "Specified idempotent token was already used with different request "
    +    "parameters within the idempotency window"
    +)
    +TABLE_BUSY_MESSAGE = "Table has ongoing bulk operations, please wait and try again"
    +MONGODB_UNSUPPORTED_MESSAGE = (
    +    "Bulk operations are not supported on this storage backend"
    +)
    +ARN_SHAPE_MESSAGE = "Invalid Bulk operation ARN"
    +ARN_ID_MESSAGE = "Invalid Request: Bulk operation ARN is invalid."
    +
    +OPERATION_ARN_PATTERN = re.compile(
    +    rf"^arn:aws:dynamodb:{re.escape(REGION)}:(\d{{12}}):bulk-operation/"
    +    r"[0-9]{14}-[0-9a-f]{8}$"
    +)
    +
    +TERMINAL_STATUSES = {"COMPLETED", "COMPLETED_WITH_ERRORS", "FAILED", "CANCELLED"}
    +
    +def _signed_post(operation: str, body: dict, creds: tuple[str, str] | None = None):
    +    """Send a raw DynamoDB-format request with SigV4 authentication.
    +
    +    Returns ``(status_code, parsed body)`` without raising, so error tests can
    +    assert the exact error type and message.
    +    """
    +    body_bytes = json.dumps(body).encode("utf-8")
    +    headers = {
    +        "X-Amz-Target": f"DynamoDB_20120810.{operation}",
    +        "Content-Type": "application/x-amz-json-1.0",
    +    }
    +
    +    if creds is None:
    +        access_key = os.environ.get("AWS_ACCESS_KEY_ID", "")
    +        secret_key = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
    +    else:
    +        access_key, secret_key = creds
    +    if access_key and secret_key:
    +        credentials = Credentials(access_key, secret_key)
    +        aws_req = AWSRequest(method="POST", url=ENDPOINT, data=body_bytes, headers=headers)
    +        SigV4Auth(credentials, "dynamodb", REGION).add_auth(aws_req)
    +        headers = dict(aws_req.headers)
    +
    +    resp = requests.post(
    +        ENDPOINT,
    +        data=body_bytes,
    +        headers=headers,
    +        verify=not ENDPOINT.startswith("https://"),
    +    )
    +    return resp.status_code, resp.json()
    +
    +
    +def extenddb_request(operation: str, body: dict,
    +                     creds: tuple[str, str] | None = None) -> dict:
    +    """Send a raw request and return the response body, raising on errors."""
    +    status_code, result = _signed_post(operation, body, creds)
    +    if status_code >= 400:
    +        error_type = result.get("__type", "Unknown")
    +        error_msg = result.get("message", result.get("Message", ""))
    +        raise RuntimeError(f"{error_type}: {error_msg} (HTTP {status_code})")
    +    return result
    +
    +
    +def expect_error(operation: str, body: dict, error_type: str, message: str,
    +                 creds: tuple[str, str] | None = None) -> None:
    +    """Assert a request fails with exactly this error type and message."""
    +    status_code, result = _signed_post(operation, body, creds)
    +    assert status_code >= 400, (
    +        f"{operation} unexpectedly succeeded: {result}"
    +    )
    +    got_type = result.get("__type", "").split("#")[-1]
    +    got_message = result.get("message", result.get("Message", ""))
    +    assert got_type == error_type, (
    +        f"{operation}: expected {error_type}, got {got_type}: {got_message}"
    +    )
    +    assert got_message == message, (
    +        f"{operation}: expected message {message!r}, got {got_message!r}"
    +    )
    +
    +
    +# ---------------------------------------------------------------------------
    +# Bucket and object helpers
    +#
    +# Inputs are objects under a bucket and reports are written to the output
    +# bucket under the operation's keys, on whichever store bulk_store selects.
    +# On the filesystem store a bucket is {root}/{account}/{S3Bucket}.
    +# ---------------------------------------------------------------------------
    +
    +BULK_STORE = bulk_store.STORE
    +
    +filesystem_store_only = pytest.mark.skipif(
    +    BULK_STORE != bulk_store.FILESYSTEM,
    +    reason="holds on the filesystem store only (EXTENDDB_TEST_BULK_STORE)",
    +)
    +s3_store_only = pytest.mark.skipif(
    +    BULK_STORE != bulk_store.S3,
    +    reason="holds on the S3 store only (EXTENDDB_TEST_BULK_STORE=s3)",
    +)
    +
    +
    +def bucket_dir(bucket: str, account: str = ACCOUNT_ID) -> str:
    +    """Return (creating if needed) a bucket directory in an account subtree.
    +
    +    Filesystem store only; create_bucket serves both stores.
    +    """
    +    return bulk_store.bucket_dir(bucket, account)
    +
    +
    +def create_bucket(bucket: str, account: str = ACCOUNT_ID) -> None:
    +    """Create a bucket on the configured store."""
    +    bulk_store.create_bucket(bucket, account)
    +
    +
    +def write_object(bucket: str, key: str, payload: bytes,
    +                 account: str = ACCOUNT_ID) -> int:
    +    """Write one object under a bucket; return its size in bytes."""
    +    bulk_store.put_object(bucket, key, payload, account)
    +    return len(payload)
    +
    +
    +def write_rows(bucket: str, key: str, rows: list[dict],
    +               account: str = ACCOUNT_ID, compress: bool = False) -> int:
    +    """Write rows as newline-delimited JSON under a bucket; return the
    +    object's size in bytes."""
    +    payload = "".join(json.dumps(row) + "\n" for row in rows).encode("utf-8")
    +    if compress:
    +        payload = gzip.compress(payload)
    +    return write_object(bucket, key, payload, account)
    +
    +
    +def discard_bucket(bucket: str, account: str = ACCOUNT_ID) -> None:
    +    """Remove a bucket and its objects; the bucket may or may not exist."""
    +    bulk_store.discard_bucket(bucket, account)
    +
    +
    +def _output_prefix(prefix: str) -> str:
    +    # The configured output prefix as it precedes AWSDynamoDB/ in every
    +    # output key: an empty prefix contributes nothing, a prefix ending in
    +    # "/" is used as it is, any other prefix gets one "/" appended.
    +    return prefix if prefix == "" or prefix.endswith("/") else f"{prefix}/"
    +
    +
    +def report_key(prefix: str, operation_arn: str, kind: str,
    +               account: str = ACCOUNT_ID, index: int = 0) -> str:
    +    """The key of one report object of an operation, in the output bucket."""
    +    operation_id = operation_arn.rsplit("/", 1)[1]
    +    return (
    +        f"{_output_prefix(prefix)}AWSDynamoDB/BulkOperation/"
    +        f"{account}:{operation_id}-0/output/{kind}/output-{index:05d}.json.gz"
    +    )
    +
    +
    +def manifest_key(prefix: str, operation_arn: str,
    +                 account: str = ACCOUNT_ID) -> str:
    +    """The key of an operation's manifest, in the output bucket."""
    +    operation_id = operation_arn.rsplit("/", 1)[1]
    +    return (
    +        f"{_output_prefix(prefix)}AWSDynamoDB/BulkOperation/"
    +        f"{account}:{operation_id}/manifest-files.json"
    +    )
    +
    +
    +def read_report(bucket: str, prefix: str, operation_arn: str, kind: str,
    +                account: str = ACCOUNT_ID) -> list[dict] | None:
    +    """The records of an operation's first report object of one kind, or
    +    None when the object was not written."""
    +    payload = bulk_store.get_object(
    +        bucket, report_key(prefix, operation_arn, kind, account), account
    +    )
    +    if payload is None:
    +        return None
    +    text = gzip.decompress(payload).decode("utf-8")
    +    return [json.loads(line) for line in text.splitlines() if line.strip()]
    +
    +
    +def read_manifest(bucket: str, prefix: str, operation_arn: str,
    +                  account: str = ACCOUNT_ID) -> list[dict] | None:
    +    """The lines of an operation's manifest, or None when it was not written."""
    +    payload = bulk_store.get_object(
    +        bucket, manifest_key(prefix, operation_arn, account), account
    +    )
    +    if payload is None:
    +        return None
    +    text = payload.decode("utf-8")
    +    return [json.loads(line) for line in text.splitlines() if line.strip()]
    +
    +
    +# ---------------------------------------------------------------------------
    +# Request and polling helpers
    +# ---------------------------------------------------------------------------
    +
    +
    +def start_body(table: str, bucket: str, in_prefix: str = "in/",
    +               out_prefix: str = "reports",
    +               output_types: list[str] | None = None, **extra) -> dict:
    +    """A StartBulkPutItems body reading one bucket and reporting to it."""
    +    if output_types is None:
    +        output_types = ["ERROR", "CONDITION_CHECK_FAILURE"]
    +    body = {
    +        "TableName": table,
    +        "InputS3Configuration": {
    +            "S3Bucket": bucket,
    +            "S3KeyPrefix": in_prefix,
    +            "InputFormat": "DYNAMODB_JSON",
    +        },
    +        "OutputS3Configuration": {
    +            "S3Bucket": bucket,
    +            "S3KeyPrefix": out_prefix,
    +            "OutputType": output_types,
    +        },
    +    }
    +    body.update(extra)
    +    return body
    +
    +
    +def wait_until_terminal(operation_arn: str, timeout: float = 30.0,
    +                        creds: tuple[str, str] | None = None) -> dict:
    +    """Poll DescribeBulkOperation until a terminal status; return it."""
    +    deadline = time.monotonic() + timeout
    +    while time.monotonic() < deadline:
    +        desc = extenddb_request(
    +            "DescribeBulkOperation", {"BulkOperationArn": operation_arn}, creds
    +        )["BulkOperationDescription"]
    +        if desc["BulkOperationStatus"] in TERMINAL_STATUSES:
    +            return desc
    +        time.sleep(0.05)
    +    raise TimeoutError(
    +        f"operation {operation_arn} not terminal within {timeout}s: "
    +        f"{desc['BulkOperationStatus']}"
    +    )
    +
    +
    +def list_all_operations(creds: tuple[str, str] | None = None) -> list[dict]:
    +    """Every operation summary the caller can list, across all pages."""
    +    summaries: list[dict] = []
    +    token = None
    +    while True:
    +        body: dict = {}
    +        if token is not None:
    +            body["NextToken"] = token
    +        resp = extenddb_request("ListBulkOperations", body, creds)
    +        summaries.extend(resp["BulkOperationsSummaryList"])
    +        token = resp.get("NextToken")
    +        if token is None:
    +            return summaries
    +
    +
    +def row(pk: str, sk: str | None = None, **attrs) -> dict:
    +    """One input row with string key attributes."""
    +    item: dict = {"pk": {"S": pk}}
    +    if sk is not None:
    +        item["sk"] = {"S": sk}
    +    item.update(attrs)
    +    return {"Item": item}
    +
    +
    +# ---------------------------------------------------------------------------
    +# Lifecycle
    +# ---------------------------------------------------------------------------
    +
    +def _require_admin_env() -> ManagementClient:
    +    admin_user = os.environ.get("EXTENDDB_ADMIN_USER", "").strip()
    +    admin_pass = os.environ.get("EXTENDDB_ADMIN_PASSWORD", "").strip()
    +    if not admin_user or not admin_pass:
    +        pytest.fail(
    +            "MISCONFIGURED: account tests require EXTENDDB_ADMIN_USER and "
    +            "EXTENDDB_ADMIN_PASSWORD. These must be set by devtools/run-tests "
    +            "before test execution."
    +        )
    +    return ManagementClient(ENDPOINT, admin_user, admin_pass)
    +
    +S3_ACTIONS = ["s3:ListBucket", "s3:GetObject", "s3:PutObject"]
    +
    +
    +def _provision_account(mgmt: ManagementClient, user: str):
    +    """Create an account with one full-access user; return (account, creds).
    +
    +    The user holds dynamodb:* in the policy named "full" and, in the policy
    +    named "s3", the three S3 actions a bulk operation performs as its caller.
    +    """
    +    account_id = f"{uuid.uuid4().int % 10**12:012d}"
    +    assert mgmt.create_account(account_id, f"{user}-acct-{account_id}").status_code == 201
    +    assert mgmt.create_user(account_id, user, f"{user.title()}Pass123!").status_code == 201
    +    key = mgmt.create_access_key(account_id, user).json()
    +    policy = {
    +        "Version": "2012-10-17",
    +        "Statement": [{"Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"}],
    +    }
    +    assert mgmt.put_user_policy(account_id, user, "full", policy).status_code == 204
    +    s3 = {
    +        "Version": "2012-10-17",
    +        "Statement": [{"Effect": "Allow", "Action": S3_ACTIONS, "Resource": "*"}],
    +    }
    +    assert mgmt.put_user_policy(account_id, user, "s3", s3).status_code == 204
    +    return account_id, (key["access_key_id"], key["secret_access_key"])
    +
    +def _release_account(mgmt: ManagementClient, account_id: str, user: str) -> None:
    +    mgmt.delete_user(account_id, user)
    +    mgmt.delete_account(account_id)
    +
    +def _boto_client(creds: tuple[str, str]):
    +    import boto3
    +    from botocore.config import Config as BotoConfig
    +
    +    kwargs: dict = dict(
    +        service_name="dynamodb",
    +        endpoint_url=ENDPOINT,
    +        aws_access_key_id=creds[0],
    +        aws_secret_access_key=creds[1],
    +        region_name=REGION,
    +        config=BotoConfig(retries={"max_attempts": 0}),
    +    )
    +    if ENDPOINT.startswith("https://"):
    +        kwargs["verify"] = False
    +    return boto3.client(**kwargs)
    +
    +
    +# ---------------------------------------------------------------------------
    +# Listing
    +# ---------------------------------------------------------------------------
    +
    +__all__ = [
    +    'ENDPOINT',
    +    'REGION',
    +    'ACCOUNT_ID',
    +    'MISSING_KEY_MESSAGE',
    +    'CONDITION_FAILED_MESSAGE',
    +    'IDEMPOTENT_MISMATCH_MESSAGE',
    +    'TABLE_BUSY_MESSAGE',
    +    'MONGODB_UNSUPPORTED_MESSAGE',
    +    'ARN_SHAPE_MESSAGE',
    +    'ARN_ID_MESSAGE',
    +    'OPERATION_ARN_PATTERN',
    +    'TERMINAL_STATUSES',
    +    '_signed_post',
    +    'extenddb_request',
    +    'expect_error',
    +    'BULK_STORE',
    +    'filesystem_store_only',
    +    's3_store_only',
    +    'bucket_dir',
    +    'create_bucket',
    +    'write_object',
    +    'write_rows',
    +    'discard_bucket',
    +    'report_key',
    +    'manifest_key',
    +    'read_report',
    +    'read_manifest',
    +    'start_body',
    +    'wait_until_terminal',
    +    'list_all_operations',
    +    'row',
    +    '_require_admin_env',
    +    '_provision_account',
    +    '_release_account',
    +    '_boto_client',
    +    'wait_for_active',
    +    'wait_for_deleted',
    +    'ManagementClient',
    +]
    diff --git a/tests/bulk_put/bulk_store.py b/tests/bulk_put/bulk_store.py
    new file mode 100644
    index 000000000..bef150e99
    --- /dev/null
    +++ b/tests/bulk_put/bulk_store.py
    @@ -0,0 +1,148 @@
    +# Copyright 2026 ExtendDB contributors
    +# SPDX-License-Identifier: Apache-2.0
    +"""The bulk store the suite writes its inputs to and reads its reports from.
    +
    +EXTENDDB_TEST_BULK_STORE names it, and must name the store the server under
    +test is configured with (devtools/run-tests --bulk-store sets both):
    +
    +- ``filesystem`` (the default): a bucket is the directory
    +  ``{root}/{account}/{bucket}``, where the root is the tempfile directory,
    +  the layout of the server's ``[bulk.filesystem]`` store rooted there.
    +- ``s3``: a bucket on the S3-compatible service at EXTENDDB_TEST_S3_ENDPOINT,
    +  reached with path-style addressing and the keys
    +  EXTENDDB_TEST_S3_ACCESS_KEY_ID and EXTENDDB_TEST_S3_SECRET_ACCESS_KEY.
    +  Buckets are created and emptied through the S3 API. Bucket names are
    +  global there, so the account names no namespace; the suite's bucket names
    +  are unique per test.
    +
    +Objects are named by their bucket and their key within it.
    +"""
    +from __future__ import annotations
    +
    +import os
    +import shutil
    +import tempfile
    +
    +import pytest
    +
    +FILESYSTEM = "filesystem"
    +S3 = "s3"
    +
    +STORE = os.environ.get("EXTENDDB_TEST_BULK_STORE", "").strip() or FILESYSTEM
    +if STORE not in (FILESYSTEM, S3):
    +    raise ValueError(
    +        f"EXTENDDB_TEST_BULK_STORE must be {FILESYSTEM!r} or {S3!r}, got {STORE!r}"
    +    )
    +
    +_client = None
    +
    +
    +def s3_client():
    +    """The S3 client of the ``s3`` store, built on first use."""
    +    global _client
    +    if _client is None:
    +        import boto3
    +        from botocore.config import Config
    +
    +        endpoint = os.environ.get("EXTENDDB_TEST_S3_ENDPOINT", "").strip()
    +        access_key = os.environ.get("EXTENDDB_TEST_S3_ACCESS_KEY_ID", "")
    +        secret_key = os.environ.get("EXTENDDB_TEST_S3_SECRET_ACCESS_KEY", "")
    +        if not (endpoint and access_key and secret_key):
    +            pytest.fail(
    +                "MISCONFIGURED: EXTENDDB_TEST_BULK_STORE=s3 requires "
    +                "EXTENDDB_TEST_S3_ENDPOINT, EXTENDDB_TEST_S3_ACCESS_KEY_ID and "
    +                "EXTENDDB_TEST_S3_SECRET_ACCESS_KEY. devtools/run-tests "
    +                "--bulk-store s3 reads the same three."
    +            )
    +        _client = boto3.client(
    +            "s3",
    +            endpoint_url=endpoint,
    +            aws_access_key_id=access_key,
    +            aws_secret_access_key=secret_key,
    +            region_name=os.environ.get("AWS_DEFAULT_REGION", "us-east-1"),
    +            config=Config(s3={"addressing_style": "path"}),
    +        )
    +    return _client
    +
    +
    +def _error_code(error) -> str:
    +    return error.response.get("Error", {}).get("Code", "")
    +
    +
    +def _bucket_path(bucket: str, account: str) -> str:
    +    return os.path.join(tempfile.gettempdir(), account, bucket)
    +
    +
    +def bucket_dir(bucket: str, account: str) -> str:
    +    """Return (creating if needed) a bucket directory. Filesystem store only."""
    +    assert STORE == FILESYSTEM, "a bucket directory exists on the filesystem store only"
    +    path = _bucket_path(bucket, account)
    +    os.makedirs(path, exist_ok=True)
    +    return path
    +
    +
    +def create_bucket(bucket: str, account: str) -> None:
    +    """Create the bucket; an existing bucket is kept as it is."""
    +    if STORE == FILESYSTEM:
    +        bucket_dir(bucket, account)
    +        return
    +    from botocore.exceptions import ClientError
    +
    +    try:
    +        s3_client().create_bucket(Bucket=bucket)
    +    except ClientError as error:
    +        if _error_code(error) not in ("BucketAlreadyOwnedByYou", "BucketAlreadyExists"):
    +            raise
    +
    +
    +def put_object(bucket: str, key: str, payload: bytes, account: str) -> None:
    +    """Write one object, creating the bucket when it does not exist."""
    +    create_bucket(bucket, account)
    +    if STORE == FILESYSTEM:
    +        path = os.path.join(_bucket_path(bucket, account), key)
    +        os.makedirs(os.path.dirname(path), exist_ok=True)
    +        with open(path, "wb") as f:
    +            f.write(payload)
    +        return
    +    s3_client().put_object(Bucket=bucket, Key=key, Body=payload)
    +
    +
    +def get_object(bucket: str, key: str, account: str) -> bytes | None:
    +    """The object's bytes, or None when the object or its bucket is absent."""
    +    if STORE == FILESYSTEM:
    +        path = os.path.join(_bucket_path(bucket, account), key)
    +        if not os.path.isfile(path):
    +            return None
    +        with open(path, "rb") as f:
    +            return f.read()
    +    from botocore.exceptions import ClientError
    +
    +    try:
    +        return s3_client().get_object(Bucket=bucket, Key=key)["Body"].read()
    +    except ClientError as error:
    +        if _error_code(error) in ("NoSuchKey", "NoSuchBucket", "404"):
    +            return None
    +        raise
    +
    +
    +def discard_bucket(bucket: str, account: str) -> None:
    +    """Remove a bucket and everything in it; an absent bucket is fine."""
    +    if STORE == FILESYSTEM:
    +        shutil.rmtree(_bucket_path(bucket, account), ignore_errors=True)
    +        return
    +    from botocore.exceptions import ClientError
    +
    +    client = s3_client()
    +    try:
    +        for page in client.get_paginator("list_objects_v2").paginate(Bucket=bucket):
    +            for listed in page.get("Contents", []):
    +                client.delete_object(Bucket=bucket, Key=listed["Key"])
    +        uploads = client.list_multipart_uploads(Bucket=bucket).get("Uploads", [])
    +        for upload in uploads:
    +            client.abort_multipart_upload(
    +                Bucket=bucket, Key=upload["Key"], UploadId=upload["UploadId"]
    +            )
    +        client.delete_bucket(Bucket=bucket)
    +    except ClientError as error:
    +        if _error_code(error) != "NoSuchBucket":
    +            raise
    diff --git a/tests/bulk_put/conftest.py b/tests/bulk_put/conftest.py
    new file mode 100644
    index 000000000..cc1e470b7
    --- /dev/null
    +++ b/tests/bulk_put/conftest.py
    @@ -0,0 +1,92 @@
    +# Copyright 2026 ExtendDB contributors
    +# SPDX-License-Identifier: Apache-2.0
    +
    +"""Fixtures for the bulk put suite. Shared constants and request helpers live in
    +bulk_helpers.py so this module's name does not shadow the top-level
    +tests/conftest.py for the rest of the suite."""
    +
    +import uuid
    +
    +import pytest
    +
    +from bulk_put.bulk_helpers import *  # noqa: F401,F403
    +from bulk_put.bulk_helpers import (  # noqa: F401
    +    _boto_client,
    +    _provision_account,
    +    _release_account,
    +    _require_admin_env,
    +)
    +
    +@pytest.fixture(scope="module", autouse=True)
    +def skip_on_unsupported_backend():
    +    """On a backend that refuses bulk operations (MongoDB, Cassandra), assert
    +    the client-visible refusal before skipping the module.
    +
    +    Asserting before the skip turns a regression that answered
    +    InternalServerError (a 500) or a different message into a failure instead
    +    of a silent skip. ListBulkOperations, DescribeBulkOperation and
    +    CancelBulkOperation reach the bulk store directly, so their refusal is
    +    asserted here. StartBulkPutItems reaches the store only after the [bulk]
    +    configuration and table-lookup steps, so its refusal is pinned at the
    +    handler level in the engine tests rather than assumed from the server's
    +    configuration here.
    +    """
    +    status_code, result = _signed_post("ListBulkOperations", {})
    +    if status_code < 400:
    +        return
    +    message = result.get("message", result.get("Message", ""))
    +    if message != MONGODB_UNSUPPORTED_MESSAGE:
    +        return
    +    arn = (
    +        f"arn:aws:dynamodb:{REGION}:{ACCOUNT_ID}:bulk-operation/"
    +        "00000000000001-deadbeef"
    +    )
    +    expect_error(
    +        "ListBulkOperations", {},
    +        "ValidationException", MONGODB_UNSUPPORTED_MESSAGE,
    +    )
    +    expect_error(
    +        "DescribeBulkOperation", {"BulkOperationArn": arn},
    +        "ValidationException", MONGODB_UNSUPPORTED_MESSAGE,
    +    )
    +    expect_error(
    +        "CancelBulkOperation", {"BulkOperationArn": arn},
    +        "ValidationException", MONGODB_UNSUPPORTED_MESSAGE,
    +    )
    +    pytest.skip(MONGODB_UNSUPPORTED_MESSAGE)
    +
    +@pytest.fixture()
    +def bulk_table(dynamodb_client):
    +    """A composite-key table, deleted after the test."""
    +    name = f"extenddb-bulk-{uuid.uuid4().hex[:8]}"
    +    dynamodb_client.create_table(
    +        TableName=name,
    +        AttributeDefinitions=[
    +            {"AttributeName": "pk", "AttributeType": "S"},
    +            {"AttributeName": "sk", "AttributeType": "S"},
    +        ],
    +        KeySchema=[
    +            {"AttributeName": "pk", "KeyType": "HASH"},
    +            {"AttributeName": "sk", "KeyType": "RANGE"},
    +        ],
    +        BillingMode="PAY_PER_REQUEST",
    +    )
    +    wait_for_active(dynamodb_client, name)
    +    yield name
    +    try:
    +        dynamodb_client.delete_table(TableName=name)
    +        wait_for_deleted(dynamodb_client, name)
    +    except Exception:
    +        pass
    +
    +@pytest.fixture()
    +def bulk_bucket():
    +    """A unique bucket on the configured store, in the caller's account."""
    +    bucket = f"bulk-{uuid.uuid4().hex[:8]}"
    +    create_bucket(bucket)
    +    yield bucket
    +    discard_bucket(bucket)
    +
    +@pytest.fixture(scope="module")
    +def mgmt() -> ManagementClient:
    +    return _require_admin_env()
    diff --git a/tests/bulk_put/test_authorization.py b/tests/bulk_put/test_authorization.py
    new file mode 100644
    index 000000000..bd5a34fc2
    --- /dev/null
    +++ b/tests/bulk_put/test_authorization.py
    @@ -0,0 +1,259 @@
    +# Copyright 2026 ExtendDB contributors
    +# SPDX-License-Identifier: Apache-2.0
    +"""Authorization timing and dev-mode service."""
    +
    +from __future__ import annotations
    +
    +import gzip
    +import json
    +import os
    +import re
    +import shutil
    +import tempfile
    +import time
    +import uuid
    +
    +import pytest
    +import requests
    +
    +from bulk_put.bulk_helpers import *  # noqa: F401,F403
    +
    +pytestmark = pytest.mark.skipif(
    +    not os.environ.get("EXTENDDB_TEST_ENDPOINT", "").strip(),
    +    reason="bulk operations tests require extenddb (EXTENDDB_TEST_ENDPOINT)",
    +)
    +
    +
    +class TestAuthorizationTiming:
    +    """The DynamoDB actions of a request are authorized when the request
    +    arrives, so a dynamodb policy change after Start stops the principal's
    +    future requests without stopping the operation the principal already
    +    started; cancellation, which the admin can restore access for or perform
    +    on the caller's behalf, stops a running load. The S3 actions the
    +    operation performs as its caller are evaluated while it runs: an
    +    s3:GetObject deny added after Start fails the load at the next check.
    +    """
    +
    +    USER = "revoked"
    +    RUN_ROWS = 20000
    +
    +    def test_a_policy_revoked_after_start_does_not_stop_the_operation(self, mgmt):
    +        account_id, creds = _provision_account(mgmt, self.USER)
    +        client = _boto_client(creds)
    +        bucket = f"bulk-revoke-{uuid.uuid4().hex[:8]}"
    +        table = "bulk-revoke"
    +        allow = {
    +            "Version": "2012-10-17",
    +            "Statement": [
    +                {"Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"}
    +            ],
    +        }
    +        deny = {
    +            "Version": "2012-10-17",
    +            "Statement": [
    +                {"Effect": "Deny", "Action": "dynamodb:*", "Resource": "*"}
    +            ],
    +        }
    +        try:
    +            client.create_table(
    +                TableName=table,
    +                AttributeDefinitions=[
    +                    {"AttributeName": "pk", "AttributeType": "S"},
    +                    {"AttributeName": "sk", "AttributeType": "S"},
    +                ],
    +                KeySchema=[
    +                    {"AttributeName": "pk", "KeyType": "HASH"},
    +                    {"AttributeName": "sk", "KeyType": "RANGE"},
    +                ],
    +                BillingMode="PAY_PER_REQUEST",
    +            )
    +            wait_for_active(client, table)
    +            rows = [
    +                row("k", str(i), f={"S": "x" * 40}) for i in range(self.RUN_ROWS)
    +            ]
    +            write_rows(bucket, "in/rows.json", rows, account_id)
    +
    +            arn = extenddb_request(
    +                "StartBulkPutItems", start_body(table, bucket), creds
    +            )["BulkOperationDescription"]["BulkOperationArn"]
    +
    +            # The revocation lands while the operation is running: the same
    +            # row volume keeps the per-table-guard test's second request
    +            # inside the run window on every attempt.
    +            before = extenddb_request(
    +                "DescribeBulkOperation", {"BulkOperationArn": arn}, creds
    +            )["BulkOperationDescription"]
    +            assert before["BulkOperationStatus"] == "IN_PROGRESS"
    +            assert (
    +                mgmt.put_user_policy(account_id, self.USER, "full", deny).status_code
    +                == 204
    +            )
    +
    +            # The principal itself is refused at once (write-through cache
    +            # invalidation), so the deny is live while the operation runs.
    +            status_code, result = _signed_post(
    +                "DescribeBulkOperation", {"BulkOperationArn": arn}, creds
    +            )
    +            assert status_code >= 400
    +            assert (
    +                result.get("__type", "").split("#")[-1] == "AccessDeniedException"
    +            ), result
    +
    +            # The operation itself runs to a clean completion regardless.
    +            assert (
    +                mgmt.put_user_policy(account_id, self.USER, "full", allow).status_code
    +                == 204
    +            )
    +            terminal = wait_until_terminal(arn, timeout=120.0, creds=creds)
    +            assert terminal["BulkOperationStatus"] == "COMPLETED"
    +            assert terminal["ProcessedItemCount"] == self.RUN_ROWS
    +            assert terminal["FailedItemCount"] == 0
    +        finally:
    +            try:
    +                mgmt.put_user_policy(account_id, self.USER, "full", allow)
    +                client.delete_table(TableName=table)
    +                wait_for_deleted(client, table)
    +            except Exception:
    +                pass
    +            discard_bucket(bucket, account_id)
    +            _release_account(mgmt, account_id, self.USER)
    +
    +
    +    def test_an_s3_get_object_deny_after_start_fails_the_operation(self, mgmt):
    +        user = "s3revoked"
    +        account_id, creds = _provision_account(mgmt, user)
    +        client = _boto_client(creds)
    +        bucket = f"bulk-s3-revoke-{uuid.uuid4().hex[:8]}"
    +        table = "bulk-s3-revoke"
    +        deny = {
    +            "Version": "2012-10-17",
    +            "Statement": [
    +                {
    +                    "Effect": "Deny",
    +                    "Action": "s3:GetObject",
    +                    "Resource": f"arn:aws:s3:::{bucket}/*",
    +                }
    +            ],
    +        }
    +        try:
    +            client.create_table(
    +                TableName=table,
    +                AttributeDefinitions=[
    +                    {"AttributeName": "pk", "AttributeType": "S"},
    +                    {"AttributeName": "sk", "AttributeType": "S"},
    +                ],
    +                KeySchema=[
    +                    {"AttributeName": "pk", "KeyType": "HASH"},
    +                    {"AttributeName": "sk", "KeyType": "RANGE"},
    +                ],
    +                BillingMode="PAY_PER_REQUEST",
    +            )
    +            wait_for_active(client, table)
    +            rows = [
    +                row("k", str(i), f={"S": "x" * 40}) for i in range(self.RUN_ROWS)
    +            ]
    +            write_rows(bucket, "in/rows.json", rows, account_id)
    +
    +            arn = extenddb_request(
    +                "StartBulkPutItems", start_body(table, bucket), creds
    +            )["BulkOperationDescription"]["BulkOperationArn"]
    +            before = extenddb_request(
    +                "DescribeBulkOperation", {"BulkOperationArn": arn}, creds
    +            )["BulkOperationDescription"]
    +            assert before["BulkOperationStatus"] == "IN_PROGRESS"
    +            assert (
    +                mgmt.put_user_policy(account_id, user, "deny-read", deny).status_code
    +                == 204
    +            )
    +
    +            terminal = wait_until_terminal(arn, timeout=120.0, creds=creds)
    +            assert terminal["BulkOperationStatus"] == "FAILED", terminal
    +            assert terminal["FailureCode"] == "S3AccessDenied"
    +            assert terminal["FailureMessage"] == (
    +                f"User: arn:aws:iam::{account_id}:user/{user} is not authorized to "
    +                f'perform: s3:GetObject on resource: "arn:aws:s3:::{bucket}/in/rows.json" '
    +                "with an explicit deny in an identity-based policy"
    +            )
    +            assert terminal["ProcessedItemCount"] < self.RUN_ROWS
    +            assert terminal["FailedItemCount"] == 0
    +        finally:
    +            try:
    +                mgmt.delete_user_policy(account_id, user, "deny-read")
    +                client.delete_table(TableName=table)
    +                wait_for_deleted(client, table)
    +            except Exception:
    +                pass
    +            discard_bucket(bucket, account_id)
    +            _release_account(mgmt, account_id, user)
    +
    +
    +# ---------------------------------------------------------------------------
    +# Dev mode
    +# ---------------------------------------------------------------------------
    +
    +
    +class TestDevMode:
    +    """All four operations are served in dev mode without policies.
    +
    +    The seeded dev user holds no dynamodb policy, so a denial here means an
    +    operation bypasses the dev-mode gate. Requires a dev-mode server; gated
    +    the way tests/test_dev_mode_authorization.py is.
    +    """
    +
    +    pytestmark = pytest.mark.skipif(
    +        os.environ.get("EXTENDDB_TEST_DEV_MODE", "").strip() != "1",
    +        reason="requires a dev-mode server (set EXTENDDB_TEST_DEV_MODE=1)",
    +    )
    +
    +    def test_all_four_operations_are_served_without_policies(
    +        self, dynamodb_client
    +    ):
    +        name = f"bulk-dev-{uuid.uuid4().hex[:8]}"
    +        dynamodb_client.create_table(
    +            TableName=name,
    +            AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}],
    +            KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}],
    +            BillingMode="PAY_PER_REQUEST",
    +        )
    +        wait_for_active(dynamodb_client, name)
    +        table_arn = dynamodb_client.describe_table(TableName=name)["Table"]["TableArn"]
    +        account = table_arn.split(":")[4]
    +        bucket = f"bulk-dev-{uuid.uuid4().hex[:8]}"
    +
    +        try:
    +            write_rows(bucket, "in/rows.json", [row("dev1"), row("dev2")],
    +                       account=account)
    +            resp = extenddb_request(
    +                "StartBulkPutItems", start_body(name, bucket)
    +            )
    +            desc = resp["BulkOperationDescription"]
    +            assert desc["BulkOperationStatus"] == "IN_PROGRESS"
    +            arn = desc["BulkOperationArn"]
    +
    +            described = extenddb_request(
    +                "DescribeBulkOperation", {"BulkOperationArn": arn}
    +            )["BulkOperationDescription"]
    +            assert described["BulkOperationArn"] == arn
    +
    +            listed = list_all_operations()
    +            assert arn in [s["BulkOperationArn"] for s in listed]
    +
    +            status_code, result = _signed_post(
    +                "CancelBulkOperation", {"BulkOperationArn": arn}
    +            )
    +            if status_code >= 400:
    +                got_type = result.get("__type", "").split("#")[-1]
    +                assert got_type == "ValidationException", (
    +                    f"cancel was denied in dev mode: {result}"
    +                )
    +                message = result.get("message", result.get("Message", ""))
    +                assert message.startswith(
    +                    "Invalid Request: Operation already in terminal state:"
    +                )
    +            wait_until_terminal(arn)
    +        finally:
    +            discard_bucket(bucket, account)
    +            try:
    +                dynamodb_client.delete_table(TableName=name)
    +            except Exception:
    +                pass
    diff --git a/tests/bulk_put/test_idempotency.py b/tests/bulk_put/test_idempotency.py
    new file mode 100644
    index 000000000..99b12a08c
    --- /dev/null
    +++ b/tests/bulk_put/test_idempotency.py
    @@ -0,0 +1,138 @@
    +# Copyright 2026 ExtendDB contributors
    +# SPDX-License-Identifier: Apache-2.0
    +"""Idempotency tokens and the per-table concurrency guard."""
    +
    +from __future__ import annotations
    +
    +import gzip
    +import json
    +import os
    +import re
    +import shutil
    +import tempfile
    +import time
    +import uuid
    +
    +import pytest
    +import requests
    +
    +from bulk_put.bulk_helpers import *  # noqa: F401,F403
    +
    +pytestmark = pytest.mark.skipif(
    +    not os.environ.get("EXTENDDB_TEST_ENDPOINT", "").strip(),
    +    reason="bulk operations tests require extenddb (EXTENDDB_TEST_ENDPOINT)",
    +)
    +
    +
    +class TestIdempotency:
    +    """ClientToken replay and mismatch."""
    +
    +    def test_a_replayed_token_returns_the_same_operation_and_creates_no_second(
    +        self, bulk_table, bulk_bucket
    +    ):
    +        write_rows(bulk_bucket, "in/rows.json", [row("i", "1")])
    +        token = f"token-{uuid.uuid4().hex}"
    +        body = start_body(bulk_table, bulk_bucket, ClientToken=token)
    +
    +        before = {s["BulkOperationArn"] for s in list_all_operations()}
    +        first = extenddb_request("StartBulkPutItems", body)
    +        first_arn = first["BulkOperationDescription"]["BulkOperationArn"]
    +        second = extenddb_request("StartBulkPutItems", body)
    +        second_arn = second["BulkOperationDescription"]["BulkOperationArn"]
    +
    +        assert first_arn == second_arn
    +        # Compare ARN sets rather than counts so another test starting an
    +        # operation on this account at the same time cannot mask a second
    +        # operation created by the replay: any new operation other than the
    +        # first must belong to some other table.
    +        after = {s["BulkOperationArn"] for s in list_all_operations()}
    +        new_arns = after - before
    +        assert first_arn in new_arns
    +        table_id = first["BulkOperationDescription"]["TableDetails"][0]["TableId"]
    +        for arn in new_arns - {first_arn}:
    +            other = extenddb_request(
    +                "DescribeBulkOperation", {"BulkOperationArn": arn}
    +            )["BulkOperationDescription"]
    +            assert (
    +                other["TableDetails"][0]["TableId"] != table_id
    +            ), "the replay created a second operation"
    +        wait_until_terminal(first_arn)
    +
    +    def test_a_reused_token_with_different_parameters_is_rejected(
    +        self, bulk_table, bulk_bucket
    +    ):
    +        write_rows(bulk_bucket, "in/rows.json", [row("j", "1")])
    +        token = f"token-{uuid.uuid4().hex}"
    +        first = extenddb_request(
    +            "StartBulkPutItems",
    +            start_body(bulk_table, bulk_bucket, ClientToken=token,
    +                       MaxWriteRequestUnits=1024),
    +        )
    +        expect_error(
    +            "StartBulkPutItems",
    +            start_body(bulk_table, bulk_bucket, ClientToken=token,
    +                       MaxWriteRequestUnits=2048),
    +            "IdempotentParameterMismatchException",
    +            IDEMPOTENT_MISMATCH_MESSAGE,
    +        )
    +        wait_until_terminal(first["BulkOperationDescription"]["BulkOperationArn"])
    +
    +
    +# ---------------------------------------------------------------------------
    +# Per-table guard
    +# ---------------------------------------------------------------------------
    +
    +
    +class TestPerTableGuard:
    +    """At most one non-terminal operation per table."""
    +
    +    # Enough rows that the operation is still running when the second start
    +    # arrives a few milliseconds later, on every attempt.
    +    LONG_RUN_ROWS = 20000
    +
    +    def test_a_second_start_on_a_busy_table_is_rejected_on_every_attempt(
    +        self, bulk_table, bulk_bucket
    +    ):
    +        rows = [row("k", str(i), f={"S": "x" * 40}) for i in range(self.LONG_RUN_ROWS)]
    +        write_rows(bulk_bucket, "in/rows.json", rows)
    +        body = start_body(bulk_table, bulk_bucket)
    +
    +        acceptances = 0
    +        for attempt in range(20):
    +            first = extenddb_request("StartBulkPutItems", body)
    +            arn = first["BulkOperationDescription"]["BulkOperationArn"]
    +
    +            status_code, result = _signed_post("StartBulkPutItems", body)
    +            if status_code < 400:
    +                acceptances += 1
    +            else:
    +                got_type = result.get("__type", "").split("#")[-1]
    +                got_message = result.get("message", result.get("Message", ""))
    +                assert got_type == "ResourceInUseException", (
    +                    f"attempt {attempt}: {got_type}: {got_message}"
    +                )
    +                assert got_message == TABLE_BUSY_MESSAGE
    +
    +            # End the operation so the next attempt starts fresh.
    +            extenddb_request("CancelBulkOperation", {"BulkOperationArn": arn})
    +            wait_until_terminal(arn)
    +
    +        assert acceptances == 0, f"{acceptances} of 20 second starts were accepted"
    +
    +    def test_a_start_after_the_previous_operation_is_terminal_is_accepted(
    +        self, bulk_table, bulk_bucket
    +    ):
    +        write_rows(bulk_bucket, "in/rows.json", [row("m", "1")])
    +        body = start_body(bulk_table, bulk_bucket)
    +        first = extenddb_request("StartBulkPutItems", body)
    +        wait_until_terminal(first["BulkOperationDescription"]["BulkOperationArn"])
    +
    +        second = extenddb_request("StartBulkPutItems", body)
    +        desc = second["BulkOperationDescription"]
    +        assert desc["BulkOperationStatus"] == "IN_PROGRESS"
    +        wait_until_terminal(desc["BulkOperationArn"])
    +
    +
    +# ---------------------------------------------------------------------------
    +# Validation
    +# ---------------------------------------------------------------------------
    diff --git a/tests/bulk_put/test_lifecycle.py b/tests/bulk_put/test_lifecycle.py
    new file mode 100644
    index 000000000..7f063f497
    --- /dev/null
    +++ b/tests/bulk_put/test_lifecycle.py
    @@ -0,0 +1,218 @@
    +# Copyright 2026 ExtendDB contributors
    +# SPDX-License-Identifier: Apache-2.0
    +"""Lifecycle of a bulk operation: rows, reports, compression."""
    +
    +from __future__ import annotations
    +
    +import gzip
    +import json
    +import os
    +import re
    +import shutil
    +import tempfile
    +import time
    +import uuid
    +
    +import pytest
    +import requests
    +
    +from bulk_put.bulk_helpers import *  # noqa: F401,F403
    +
    +pytestmark = pytest.mark.skipif(
    +    not os.environ.get("EXTENDDB_TEST_ENDPOINT", "").strip(),
    +    reason="bulk operations tests require extenddb (EXTENDDB_TEST_ENDPOINT)",
    +)
    +
    +
    +class TestLifecycle:
    +    """A full run: start, poll to terminal, read items, read the report."""
    +
    +    def test_the_description_carries_table_details_and_resolved_members(
    +        self, bulk_table, bulk_bucket
    +    ):
    +        # Measured on the service: the Start response and
    +        # every Describe carry TableDetails as a one-entry list of the
    +        # bare table name and the table id; an absent S3BucketOwner is
    +        # echoed as the caller's account id on both configurations and an
    +        # absent InputCompressionType as NONE.
    +        body = start_body(bulk_table, bulk_bucket)
    +        resp = extenddb_request("StartBulkPutItems", body)
    +        desc = resp["BulkOperationDescription"]
    +        details = desc["TableDetails"]
    +        assert len(details) == 1
    +        assert details[0]["TableName"] == bulk_table
    +        assert details[0]["TableId"]
    +        assert desc["InputS3Configuration"]["S3BucketOwner"] == ACCOUNT_ID
    +        assert desc["OutputS3Configuration"]["S3BucketOwner"] == ACCOUNT_ID
    +        assert desc["InputS3Configuration"]["InputCompressionType"] == "NONE"
    +
    +        described = extenddb_request(
    +            "DescribeBulkOperation",
    +            {"BulkOperationArn": desc["BulkOperationArn"]},
    +        )["BulkOperationDescription"]
    +        assert described["TableDetails"] == details
    +        assert described["InputS3Configuration"]["S3BucketOwner"] == ACCOUNT_ID
    +
    +    def test_a_run_with_one_bad_row_completes_with_errors_and_a_report(
    +        self, dynamodb_client, bulk_table, bulk_bucket
    +    ):
    +        rows = [
    +            row("a", "1", n={"N": "1"}),
    +            row("b", "1", n={"N": "2"}),
    +            row("c", None, n={"N": "3"}),  # index 2: missing the sort key
    +            row("d", "1", n={"N": "4"}),
    +            row("e", "1", n={"N": "5"}),
    +        ]
    +        input_size = write_rows(bulk_bucket, "in/rows.json", rows)
    +
    +        resp = extenddb_request(
    +            "StartBulkPutItems", start_body(bulk_table, bulk_bucket)
    +        )
    +        desc = resp["BulkOperationDescription"]
    +        assert desc["BulkOperationStatus"] == "IN_PROGRESS"
    +        assert desc["TotalInputBytes"] == input_size
    +        arn = desc["BulkOperationArn"]
    +        match = OPERATION_ARN_PATTERN.match(arn)
    +        assert match, f"unexpected operation ARN shape: {arn}"
    +        assert match.group(1) == ACCOUNT_ID
    +
    +        terminal = wait_until_terminal(arn)
    +        assert terminal["BulkOperationStatus"] == "COMPLETED_WITH_ERRORS"
    +        assert terminal["ProcessedItemCount"] == 5
    +        assert terminal["FailedItemCount"] == 1
    +        assert terminal["ProcessedInputBytes"] == terminal["TotalInputBytes"]
    +        assert "EndTime" in terminal
    +        usage = terminal["BulkOperationUsage"]
    +        assert len(usage) == 1
    +        assert "BulkPutBytes" in usage[0]
    +
    +        for pk in ("a", "b", "d", "e"):
    +            item = dynamodb_client.get_item(
    +                TableName=bulk_table, Key={"pk": {"S": pk}, "sk": {"S": "1"}}
    +            )
    +            assert "Item" in item, f"item {pk} was not written"
    +
    +        records = read_report(bulk_bucket, "reports", arn, "errors")
    +        assert records is not None, report_key("reports", arn, "errors")
    +        assert len(records) == 1
    +        record = records[0]
    +        assert record["itemLocation"]["inputSourceArn"] == (
    +            f"arn:aws:s3:::{bulk_bucket}/in/rows.json"
    +        )
    +        assert record["itemLocation"]["itemIndex"] == 2
    +        assert record["errorMessages"][0] == MISSING_KEY_MESSAGE
    +        assert record["newImage"] == {"Item": rows[2]["Item"]}
    +
    +    def test_gzip_input_is_read_and_counted_in_compressed_bytes(
    +        self, dynamodb_client, bulk_table, bulk_bucket
    +    ):
    +        rows = [row("g", str(i)) for i in range(5)]
    +        input_size = write_rows(bulk_bucket, "in/rows.json.gz", rows, compress=True)
    +
    +        body = start_body(bulk_table, bulk_bucket)
    +        body["InputS3Configuration"]["InputCompressionType"] = "GZIP"
    +        resp = extenddb_request("StartBulkPutItems", body)
    +        desc = resp["BulkOperationDescription"]
    +        assert desc["TotalInputBytes"] == input_size
    +
    +        terminal = wait_until_terminal(desc["BulkOperationArn"])
    +        assert terminal["BulkOperationStatus"] == "COMPLETED"
    +        assert terminal["ProcessedItemCount"] == 5
    +        assert terminal["FailedItemCount"] == 0
    +        for i in range(5):
    +            item = dynamodb_client.get_item(
    +                TableName=bulk_table, Key={"pk": {"S": "g"}, "sk": {"S": str(i)}}
    +            )
    +            assert "Item" in item
    +
    +    def test_a_false_condition_is_recorded_but_not_counted_as_failed(
    +        self, dynamodb_client, bulk_table, bulk_bucket
    +    ):
    +        stored = {"pk": {"S": "held"}, "sk": {"S": "1"}, "v": {"S": "before"}}
    +        dynamodb_client.put_item(TableName=bulk_table, Item=stored)
    +
    +        true_row = {
    +            "Item": {"pk": {"S": "fresh"}, "sk": {"S": "1"}},
    +            "ConditionExpression": "attribute_not_exists(pk)",
    +        }
    +        false_row = {
    +            "Item": {"pk": {"S": "held"}, "sk": {"S": "1"}, "v": {"S": "after"}},
    +            "ConditionExpression": "attribute_not_exists(pk)",
    +        }
    +        write_rows(bulk_bucket, "in/rows.json", [true_row, false_row])
    +
    +        resp = extenddb_request(
    +            "StartBulkPutItems", start_body(bulk_table, bulk_bucket)
    +        )
    +        arn = resp["BulkOperationDescription"]["BulkOperationArn"]
    +        terminal = wait_until_terminal(arn)
    +        assert terminal["BulkOperationStatus"] == "COMPLETED"
    +        assert terminal["ProcessedItemCount"] == 2
    +        assert terminal["FailedItemCount"] == 0
    +
    +        # The guarded item keeps its stored value.
    +        kept = dynamodb_client.get_item(
    +            TableName=bulk_table, Key={"pk": {"S": "held"}, "sk": {"S": "1"}}
    +        )["Item"]
    +        assert kept["v"]["S"] == "before"
    +
    +        records = read_report(
    +            bulk_bucket, "reports", arn, "condition-check-failures"
    +        )
    +        assert records is not None, report_key(
    +            "reports", arn, "condition-check-failures"
    +        )
    +        assert len(records) == 1
    +        record = records[0]
    +        assert record["errorMessages"][0] == CONDITION_FAILED_MESSAGE
    +        assert record["newImage"] == {"Item": false_row["Item"]}
    +        assert record["oldImage"] == {"Item": stored}
    +
    +    def test_an_input_matching_no_files_completes_with_zero_counters(
    +        self, bulk_table, bulk_bucket
    +    ):
    +        resp = extenddb_request(
    +            "StartBulkPutItems",
    +            start_body(bulk_table, bulk_bucket, in_prefix="nothing-here/"),
    +        )
    +        desc = resp["BulkOperationDescription"]
    +        assert desc["TotalInputBytes"] == 0
    +
    +        arn = desc["BulkOperationArn"]
    +        terminal = wait_until_terminal(arn)
    +        assert terminal["BulkOperationStatus"] == "COMPLETED"
    +        assert terminal["ProcessedItemCount"] == 0
    +        assert terminal["FailedItemCount"] == 0
    +        assert terminal["ProcessedInputBytes"] == 0
    +        assert read_report(bulk_bucket, "reports", arn, "errors") is None
    +
    +    def test_a_bucket_that_does_not_exist_fails_the_operation_at_start(
    +        self, bulk_table
    +    ):
    +        absent = f"absent-{uuid.uuid4().hex[:12]}"
    +        resp = extenddb_request(
    +            "StartBulkPutItems", start_body(bulk_table, absent)
    +        )
    +        desc = resp["BulkOperationDescription"]
    +        assert desc["BulkOperationStatus"] == "FAILED"
    +        assert desc["FailureCode"] == "S3NoSuchBucket"
    +        # The message is the store's own: test_store_rules.py pins each.
    +        assert desc["ProcessedItemCount"] == 0
    +        # Born terminal before enumeration: the hosted service omits
    +        # TotalInputBytes here while keeping the counters at zero.
    +        assert "TotalInputBytes" not in desc
    +        assert "EndTime" in desc
    +
    +        # Describe answers the same terminal row: the operation was
    +        # inserted already finished, with no worker involvement.
    +        arn = desc["BulkOperationArn"]
    +        described = extenddb_request(
    +            "DescribeBulkOperation", {"BulkOperationArn": arn}
    +        )["BulkOperationDescription"]
    +        assert described["BulkOperationStatus"] == "FAILED"
    +        assert described["FailureCode"] == "S3NoSuchBucket"
    +
    +
    +# ---------------------------------------------------------------------------
    +# Idempotency
    +# ---------------------------------------------------------------------------
    diff --git a/tests/bulk_put/test_list.py b/tests/bulk_put/test_list.py
    new file mode 100644
    index 000000000..4fed9f35f
    --- /dev/null
    +++ b/tests/bulk_put/test_list.py
    @@ -0,0 +1,190 @@
    +# Copyright 2026 ExtendDB contributors
    +# SPDX-License-Identifier: Apache-2.0
    +"""ListBulkOperations paging and cross-account isolation."""
    +
    +from __future__ import annotations
    +
    +import gzip
    +import json
    +import os
    +import re
    +import shutil
    +import tempfile
    +import time
    +import uuid
    +
    +import pytest
    +import requests
    +
    +from bulk_put.bulk_helpers import *  # noqa: F401,F403
    +
    +pytestmark = pytest.mark.skipif(
    +    not os.environ.get("EXTENDDB_TEST_ENDPOINT", "").strip(),
    +    reason="bulk operations tests require extenddb (EXTENDDB_TEST_ENDPOINT)",
    +)
    +
    +
    +class TestList:
    +    """Pagination, ordering, and account scoping of ListBulkOperations."""
    +
    +    OPERATIONS = 27
    +
    +    @pytest.fixture(scope="class")
    +    def listed_operations(self, mgmt):
    +        """A fresh account holding exactly 27 operations; yields their ARNs."""
    +        account_id, creds = _provision_account(mgmt, "lister")
    +        client = _boto_client(creds)
    +        bucket = f"bulk-list-{uuid.uuid4().hex[:8]}"
    +        create_bucket(bucket, account_id)
    +
    +        tables = []
    +        arns = []
    +        try:
    +            for i in range(self.OPERATIONS):
    +                name = f"bulk-list-{i:02d}"
    +                client.create_table(
    +                    TableName=name,
    +                    AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}],
    +                    KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}],
    +                    BillingMode="PAY_PER_REQUEST",
    +                )
    +                tables.append(name)
    +            for name in tables:
    +                wait_for_active(client, name)
    +            for name in tables:
    +                resp = extenddb_request(
    +                    "StartBulkPutItems",
    +                    start_body(name, bucket, in_prefix="empty/"),
    +                    creds,
    +                )
    +                arns.append(resp["BulkOperationDescription"]["BulkOperationArn"])
    +            yield account_id, creds, arns
    +        finally:
    +            for arn in arns:
    +                try:
    +                    wait_until_terminal(arn, creds=creds)
    +                except Exception:
    +                    pass
    +            for name in tables:
    +                try:
    +                    client.delete_table(TableName=name)
    +                except Exception:
    +                    pass
    +            for name in tables:
    +                try:
    +                    wait_for_deleted(client, name)
    +                except Exception:
    +                    pass
    +            discard_bucket(bucket, account_id)
    +            _release_account(mgmt, account_id, "lister")
    +
    +    def test_the_default_page_holds_10_and_tokens_fetch_the_rest(
    +        self, listed_operations
    +    ):
    +        _, creds, arns = listed_operations
    +        pages = []
    +        body: dict = {}
    +        while True:
    +            resp = extenddb_request("ListBulkOperations", body, creds)
    +            pages.append(resp["BulkOperationsSummaryList"])
    +            if "NextToken" not in resp:
    +                break
    +            body = {"NextToken": resp["NextToken"]}
    +        assert [len(p) for p in pages] == [10, 10, self.OPERATIONS - 20]
    +
    +        listed = [s["BulkOperationArn"] for page in pages for s in page]
    +        assert len(set(listed)) == self.OPERATIONS
    +        assert set(listed) == set(arns)
    +        starts = [s["StartTime"] for page in pages for s in page]
    +        assert starts == sorted(starts, reverse=True)
    +
    +    def test_the_listing_is_ordered_newest_first(self, listed_operations):
    +        _, creds, _ = listed_operations
    +        summaries = list_all_operations(creds)
    +        start_times = [s["StartTime"] for s in summaries]
    +        assert start_times == sorted(start_times, reverse=True)
    +
    +    def test_only_the_callers_operations_are_listed(
    +        self, listed_operations, bulk_table, bulk_bucket
    +    ):
    +        account_id, creds, _ = listed_operations
    +        # Plant an operation on the default account so the listing has
    +        # something foreign to leak; without it this test could only fail
    +        # if an earlier test happened to leave foreign operations behind.
    +        write_rows(bulk_bucket, "in/rows.json", [row("leak", "1")])
    +        foreign = extenddb_request(
    +            "StartBulkPutItems", start_body(bulk_table, bulk_bucket)
    +        )["BulkOperationDescription"]["BulkOperationArn"]
    +        listed = [s["BulkOperationArn"] for s in list_all_operations(creds)]
    +        assert foreign not in listed
    +        for arn in listed:
    +            assert f":{account_id}:" in arn
    +        wait_until_terminal(foreign)
    +
    +
    +# ---------------------------------------------------------------------------
    +# Account isolation
    +# ---------------------------------------------------------------------------
    +
    +
    +class TestAccountIsolation:
    +    """A second account cannot see or touch the first account's operations."""
    +
    +    @pytest.fixture(scope="class")
    +    def other_account(self, mgmt):
    +        account_id, creds = _provision_account(mgmt, "outsider")
    +        yield account_id, creds
    +        _release_account(mgmt, account_id, "outsider")
    +
    +    @pytest.fixture()
    +    def own_operation(self, bulk_table, bulk_bucket):
    +        write_rows(bulk_bucket, "in/rows.json", [row("iso", "1")])
    +        resp = extenddb_request(
    +            "StartBulkPutItems", start_body(bulk_table, bulk_bucket)
    +        )
    +        arn = resp["BulkOperationDescription"]["BulkOperationArn"]
    +        yield arn
    +        wait_until_terminal(arn)
    +
    +    def test_another_account_cannot_describe_the_operation(
    +        self, other_account, own_operation
    +    ):
    +        _, creds = other_account
    +        status_code, result = _signed_post(
    +            "DescribeBulkOperation", {"BulkOperationArn": own_operation}, creds
    +        )
    +        assert status_code == 400
    +        assert result["__type"].split("#")[-1] == "AccessDeniedException"
    +        message = result.get("message", result.get("Message", ""))
    +        assert message.startswith("User: ")
    +        assert message.endswith(
    +            f"is not authorized to perform: dynamodb:DescribeBulkOperation "
    +            f"on resource: {own_operation}"
    +        )
    +
    +    def test_another_account_cannot_cancel_the_operation(
    +        self, other_account, own_operation
    +    ):
    +        _, creds = other_account
    +        status_code, result = _signed_post(
    +            "CancelBulkOperation", {"BulkOperationArn": own_operation}, creds
    +        )
    +        assert status_code == 400
    +        assert result["__type"].split("#")[-1] == "AccessDeniedException"
    +        message = result.get("message", result.get("Message", ""))
    +        assert message.startswith("User: ")
    +        assert message.endswith(
    +            f"is not authorized to perform: dynamodb:CancelBulkOperation "
    +            f"on resource: {own_operation}"
    +        )
    +
    +    def test_a_fresh_account_lists_no_operations(self, other_account):
    +        _, creds = other_account
    +        resp = extenddb_request("ListBulkOperations", {}, creds)
    +        assert resp["BulkOperationsSummaryList"] == []
    +        assert "NextToken" not in resp
    +
    +
    +# ---------------------------------------------------------------------------
    +# Authorization timing
    +# ---------------------------------------------------------------------------
    diff --git a/tests/bulk_put/test_s3_permissions.py b/tests/bulk_put/test_s3_permissions.py
    new file mode 100644
    index 000000000..f769f40b0
    --- /dev/null
    +++ b/tests/bulk_put/test_s3_permissions.py
    @@ -0,0 +1,229 @@
    +# Copyright 2026 ExtendDB contributors
    +# SPDX-License-Identifier: Apache-2.0
    +"""The S3 actions a load performs as its caller, decided by the caller's
    +ExtendDB policies on whichever store the server is configured with:
    +s3:ListBucket on the input bucket at Start, s3:GetObject on each input
    +object, and s3:PutObject on each report object and the manifest."""
    +
    +from __future__ import annotations
    +
    +import hashlib
    +import json
    +import os
    +import uuid
    +from dataclasses import dataclass
    +
    +import pytest
    +
    +from bulk_put.bulk_helpers import *  # noqa: F401,F403
    +from bulk_put.bulk_helpers import (
    +    _boto_client,
    +    _provision_account,
    +    _release_account,
    +)
    +
    +pytestmark = pytest.mark.skipif(
    +    not os.environ.get("EXTENDDB_TEST_ENDPOINT", "").strip(),
    +    reason="bulk operations tests require extenddb (EXTENDDB_TEST_ENDPOINT)",
    +)
    +
    +USER = "s3caller"
    +TABLE = "bulk-s3-permissions"
    +
    +
    +@dataclass
    +class Caller:
    +    """A fresh account's only user, its table and its empty bucket."""
    +
    +    account: str
    +    creds: tuple[str, str]
    +    client: object
    +    bucket: str
    +
    +    def arn(self) -> str:
    +        return f"arn:aws:iam::{self.account}:user/{USER}"
    +
    +
    +@pytest.fixture()
    +def caller(mgmt):
    +    """A fresh account whose user holds dynamodb:* and the three S3 actions,
    +    with a composite-key table and an empty bucket."""
    +    account, creds = _provision_account(mgmt, USER)
    +    client = _boto_client(creds)
    +    bucket = f"bulk-perm-{uuid.uuid4().hex[:8]}"
    +    try:
    +        client.create_table(
    +            TableName=TABLE,
    +            AttributeDefinitions=[
    +                {"AttributeName": "pk", "AttributeType": "S"},
    +                {"AttributeName": "sk", "AttributeType": "S"},
    +            ],
    +            KeySchema=[
    +                {"AttributeName": "pk", "KeyType": "HASH"},
    +                {"AttributeName": "sk", "KeyType": "RANGE"},
    +            ],
    +            BillingMode="PAY_PER_REQUEST",
    +        )
    +        wait_for_active(client, TABLE)
    +        create_bucket(bucket, account)
    +        yield Caller(account, creds, client, bucket)
    +    finally:
    +        try:
    +            client.delete_table(TableName=TABLE)
    +            wait_for_deleted(client, TABLE)
    +        except Exception:
    +            pass
    +        discard_bucket(bucket, account)
    +        _release_account(mgmt, account, USER)
    +
    +
    +def put_policy(mgmt, caller: Caller, name: str, statements: list[dict]) -> None:
    +    policy = {"Version": "2012-10-17", "Statement": statements}
    +    assert mgmt.put_user_policy(caller.account, USER, name, policy).status_code == 204
    +
    +
    +def write_input(caller: Caller, key: str, rows: list[dict]) -> bytes:
    +    """Write rows as one input object and return its bytes."""
    +    payload = "".join(json.dumps(r) + "\n" for r in rows).encode("utf-8")
    +    write_object(caller.bucket, key, payload, caller.account)
    +    return payload
    +
    +
    +def start(caller: Caller, **kwargs) -> dict:
    +    return extenddb_request(
    +        "StartBulkPutItems", start_body(TABLE, caller.bucket, **kwargs), caller.creds
    +    )["BulkOperationDescription"]
    +
    +
    +def stored(caller: Caller, pk: str, sk: str) -> bool:
    +    item = caller.client.get_item(
    +        TableName=TABLE, Key={"pk": {"S": pk}, "sk": {"S": sk}}
    +    )
    +    return "Item" in item
    +
    +
    +class TestS3Permissions:
    +    """Each denial on its own S3 action, with the counters, reports and
    +    manifest the operation ends with."""
    +
    +    def test_a_listing_no_policy_allows_fails_the_operation_at_start(
    +        self, mgmt, caller
    +    ):
    +        # s3:ListBucket is allowed for listings under other/ only, so no
    +        # statement allows the load's listing under in/.
    +        put_policy(mgmt, caller, "s3", [
    +            {
    +                "Effect": "Allow",
    +                "Action": ["s3:GetObject", "s3:PutObject"],
    +                "Resource": "*",
    +            },
    +            {
    +                "Effect": "Allow",
    +                "Action": "s3:ListBucket",
    +                "Resource": f"arn:aws:s3:::{caller.bucket}",
    +                "Condition": {"StringLike": {"s3:prefix": "other/*"}},
    +            },
    +        ])
    +        write_input(caller, "in/rows.json", [row("a", "1")])
    +        write_input(caller, "other/rows.json", [row("b", "1")])
    +
    +        desc = start(caller)
    +        assert desc["BulkOperationStatus"] == "FAILED"
    +        assert desc["FailureCode"] == "S3AccessDenied"
    +        assert desc["FailureMessage"] == (
    +            f"User: {caller.arn()} is not authorized to perform: s3:ListBucket "
    +            f'on resource: "arn:aws:s3:::{caller.bucket}" because no '
    +            "identity-based policy allows the s3:ListBucket action"
    +        )
    +        # Born terminal before enumeration: neither byte member is present.
    +        assert desc["ProcessedItemCount"] == 0
    +        assert "TotalInputBytes" not in desc
    +        assert "ProcessedInputBytes" not in desc
    +        arn = desc["BulkOperationArn"]
    +        assert read_manifest(caller.bucket, "reports", arn, caller.account) is None
    +        assert not stored(caller, "a", "1")
    +
    +        # The listing under other/ satisfies the condition and the load runs.
    +        allowed = start(caller, in_prefix="other/")
    +        assert allowed["BulkOperationStatus"] == "IN_PROGRESS"
    +        terminal = wait_until_terminal(allowed["BulkOperationArn"], creds=caller.creds)
    +        assert terminal["BulkOperationStatus"] == "COMPLETED"
    +        assert terminal["ProcessedItemCount"] == 1
    +        assert stored(caller, "b", "1")
    +
    +    def test_an_object_read_a_policy_denies_leaves_the_file_not_processed(
    +        self, mgmt, caller
    +    ):
    +        put_policy(mgmt, caller, "deny-read", [{
    +            "Effect": "Deny",
    +            "Action": "s3:GetObject",
    +            "Resource": f"arn:aws:s3:::{caller.bucket}/in/*",
    +        }])
    +        payload = write_input(
    +            caller, "in/rows.json", [row("r", str(i)) for i in range(5)]
    +        )
    +
    +        desc = start(caller)
    +        assert desc["BulkOperationStatus"] == "IN_PROGRESS"
    +        assert desc["TotalInputBytes"] == len(payload)
    +        arn = desc["BulkOperationArn"]
    +        terminal = wait_until_terminal(arn, creds=caller.creds)
    +        assert terminal["BulkOperationStatus"] == "FAILED"
    +        assert terminal["FailureCode"] == "S3AccessDenied"
    +        object_arn = f"arn:aws:s3:::{caller.bucket}/in/rows.json"
    +        assert terminal["FailureMessage"] == (
    +            f"User: {caller.arn()} is not authorized to perform: s3:GetObject "
    +            f'on resource: "{object_arn}" with an explicit deny in an '
    +            "identity-based policy"
    +        )
    +        assert terminal["TotalInputBytes"] == len(payload)
    +        assert terminal["ProcessedInputBytes"] == 0
    +        assert terminal["ProcessedItemCount"] == 0
    +        assert terminal["FailedItemCount"] == 0
    +        assert not stored(caller, "r", "0")
    +
    +        # The file was refused before any of its rows was read.
    +        assert read_manifest(caller.bucket, "reports", arn, caller.account) == [{
    +            "inputSourceArn": object_arn,
    +            "fileProcessingStatus": "NOT_PROCESSED",
    +            "processedRanges": [],
    +            "etag": hashlib.md5(payload).hexdigest(),
    +        }]
    +
    +    def test_a_report_write_a_policy_denies_is_held_to_the_end_of_the_input(
    +        self, mgmt, caller
    +    ):
    +        put_policy(mgmt, caller, "deny-write", [{
    +            "Effect": "Deny",
    +            "Action": "s3:PutObject",
    +            "Resource": f"arn:aws:s3:::{caller.bucket}/reports/*",
    +        }])
    +        rows = [row("w", str(i)) for i in range(10)]
    +        rows[4] = row("w", None)  # missing the sort key: the first error record
    +        write_input(caller, "in/rows.json", rows)
    +
    +        desc = start(caller)
    +        assert desc["BulkOperationStatus"] == "IN_PROGRESS"
    +        arn = desc["BulkOperationArn"]
    +        terminal = wait_until_terminal(arn, creds=caller.creds)
    +        assert terminal["BulkOperationStatus"] == "FAILED"
    +        assert terminal["FailureCode"] == "S3AccessDenied"
    +        key = report_key("reports", arn, "errors", caller.account)
    +        assert terminal["FailureMessage"] == (
    +            f"User: {caller.arn()} is not authorized to perform: s3:PutObject "
    +            f'on resource: "arn:aws:s3:::{caller.bucket}/{key}" with an explicit '
    +            "deny in an identity-based policy"
    +        )
    +        # The denial found at the first report record is held while the
    +        # rest of the input is written, and the operation then ends with
    +        # the rows written, no failed row, no processed bytes and no usage.
    +        assert terminal["ProcessedItemCount"] == 9
    +        assert terminal["FailedItemCount"] == 0
    +        assert terminal["ProcessedInputBytes"] == 0
    +        usage = terminal["BulkOperationUsage"]
    +        assert [entry["BulkPutBytes"] for entry in usage] == [0]
    +        assert usage[0]["Table"]["BulkPutBytes"] == 0
    +        for i in (0, 1, 2, 3, 5, 6, 7, 8, 9):
    +            assert stored(caller, "w", str(i)), f"row {i} was not written"
    +        assert read_report(caller.bucket, "reports", arn, "errors", caller.account) is None
    +        assert read_manifest(caller.bucket, "reports", arn, caller.account) is None
    diff --git a/tests/bulk_put/test_store_rules.py b/tests/bulk_put/test_store_rules.py
    new file mode 100644
    index 000000000..31a317913
    --- /dev/null
    +++ b/tests/bulk_put/test_store_rules.py
    @@ -0,0 +1,206 @@
    +# Copyright 2026 ExtendDB contributors
    +# SPDX-License-Identifier: Apache-2.0
    +"""Behaviour that belongs to one bulk store: the rules the filesystem
    +store's directory layout decides, and the text each store renders for a
    +bucket that does not exist. Each class runs on its own store only
    +(EXTENDDB_TEST_BULK_STORE)."""
    +
    +from __future__ import annotations
    +
    +import os
    +import re
    +import tempfile
    +import uuid
    +
    +import pytest
    +
    +from bulk_put import bulk_store
    +from bulk_put.bulk_helpers import *  # noqa: F401,F403
    +from bulk_put.bulk_helpers import (
    +    _boto_client,
    +    _provision_account,
    +    _release_account,
    +)
    +
    +pytestmark = pytest.mark.skipif(
    +    not os.environ.get("EXTENDDB_TEST_ENDPOINT", "").strip(),
    +    reason="bulk operations tests require extenddb (EXTENDDB_TEST_ENDPOINT)",
    +)
    +
    +# The tail the S3 store renders after the S3 service's message for the
    +# Start-time listing, and the one it renders for a report write.
    +LISTING_TAIL = re.compile(
    +    r"(?P.+) \(Service: Amazon S3; Status Code: 404; Error Code: "
    +    r"NoSuchBucket; Request ID: [^;]+; S3 Extended Request ID: [^;]+; "
    +    r"Proxy: null\)"
    +)
    +REPORT_WRITE_TAIL = re.compile(
    +    r"(?P.+) \(Service: S3, Status Code: 404, Request ID: [^,]+, "
    +    r"Extended Request ID: [^)]+\) \(SDK Attempt Count: 1\)"
    +)
    +
    +
    +def start_with_a_missing_input_bucket(bulk_table) -> tuple[dict, str]:
    +    absent = f"absent-{uuid.uuid4().hex[:12]}"
    +    desc = extenddb_request(
    +        "StartBulkPutItems", start_body(bulk_table, absent)
    +    )["BulkOperationDescription"]
    +    assert desc["BulkOperationStatus"] == "FAILED"
    +    assert desc["FailureCode"] == "S3NoSuchBucket"
    +    return desc, absent
    +
    +
    +def run_into_a_missing_output_bucket(dynamodb_client, bulk_table, bulk_bucket):
    +    """A load whose output bucket does not exist and whose third row fails.
    +
    +    The fault found at the first report record is held to the end of the
    +    input on both stores: every good row is written and the operation ends
    +    with them counted, no failed row, no processed bytes and no usage.
    +    Returns the terminal description and the output bucket's name.
    +    """
    +    rows = [row("h", str(i)) for i in range(6)]
    +    rows[2] = row("h", None)
    +    write_rows(bulk_bucket, "in/rows.json", rows)
    +    absent = f"absent-{uuid.uuid4().hex[:12]}"
    +    body = start_body(bulk_table, bulk_bucket)
    +    body["OutputS3Configuration"]["S3Bucket"] = absent
    +    desc = extenddb_request("StartBulkPutItems", body)["BulkOperationDescription"]
    +    assert desc["BulkOperationStatus"] == "IN_PROGRESS"
    +    terminal = wait_until_terminal(desc["BulkOperationArn"])
    +    assert terminal["BulkOperationStatus"] == "FAILED"
    +    assert terminal["FailureCode"] == "S3NoSuchBucket"
    +    assert terminal["ProcessedItemCount"] == 5
    +    assert terminal["FailedItemCount"] == 0
    +    assert terminal["ProcessedInputBytes"] == 0
    +    assert terminal["BulkOperationUsage"][0]["BulkPutBytes"] == 0
    +    for i in (0, 1, 3, 4, 5):
    +        item = dynamodb_client.get_item(
    +            TableName=bulk_table, Key={"pk": {"S": "h"}, "sk": {"S": str(i)}}
    +        )
    +        assert "Item" in item, f"row {i} was not written"
    +    return terminal, absent
    +
    +
    +class TestFilesystemStore:
    +    """A bucket is the directory {root}/{account}/{S3Bucket}."""
    +
    +    pytestmark = filesystem_store_only
    +
    +    def test_a_missing_input_bucket_answers_the_stores_sentence(self, bulk_table):
    +        desc, _ = start_with_a_missing_input_bucket(bulk_table)
    +        assert desc["FailureMessage"] == "The specified bucket does not exist"
    +
    +    def test_a_missing_output_bucket_answers_the_stores_sentence(
    +        self, dynamodb_client, bulk_table, bulk_bucket
    +    ):
    +        terminal, _ = run_into_a_missing_output_bucket(
    +            dynamodb_client, bulk_table, bulk_bucket
    +        )
    +        assert terminal["FailureMessage"] == "The specified bucket does not exist"
    +
    +    def test_a_symlinked_file_under_the_prefix_is_not_an_input(
    +        self, dynamodb_client, bulk_table, bulk_bucket
    +    ):
    +        size = write_rows(bulk_bucket, "in/rows.json", [row("kept", "1")])
    +        outside = os.path.join(
    +            tempfile.gettempdir(), f"outside-{uuid.uuid4().hex[:8]}.json"
    +        )
    +        with open(outside, "w") as f:
    +            f.write('{"Item": {"pk": {"S": "linked"}, "sk": {"S": "1"}}}\n')
    +        try:
    +            os.symlink(outside, os.path.join(bucket_dir(bulk_bucket), "in", "link.json"))
    +            desc = extenddb_request(
    +                "StartBulkPutItems", start_body(bulk_table, bulk_bucket)
    +            )["BulkOperationDescription"]
    +            assert desc["TotalInputBytes"] == size
    +            terminal = wait_until_terminal(desc["BulkOperationArn"])
    +            assert terminal["BulkOperationStatus"] == "COMPLETED"
    +            assert terminal["ProcessedItemCount"] == 1
    +            linked = dynamodb_client.get_item(
    +                TableName=bulk_table, Key={"pk": {"S": "linked"}, "sk": {"S": "1"}}
    +            )
    +            assert "Item" not in linked
    +        finally:
    +            os.remove(outside)
    +
    +    def test_a_bucket_under_another_accounts_directory_is_not_found(
    +        self, mgmt, bulk_bucket
    +    ):
    +        write_rows(bulk_bucket, "in/rows.json", [row("mine", "1")])
    +        account_id, creds = _provision_account(mgmt, "neighbour")
    +        client = _boto_client(creds)
    +        table = "bulk-neighbour"
    +        try:
    +            client.create_table(
    +                TableName=table,
    +                AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}],
    +                KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}],
    +                BillingMode="PAY_PER_REQUEST",
    +            )
    +            wait_for_active(client, table)
    +            desc = extenddb_request(
    +                "StartBulkPutItems", start_body(table, bulk_bucket), creds
    +            )["BulkOperationDescription"]
    +            assert desc["BulkOperationStatus"] == "FAILED"
    +            assert desc["FailureCode"] == "S3NoSuchBucket"
    +            assert desc["FailureMessage"] == "The specified bucket does not exist"
    +        finally:
    +            try:
    +                client.delete_table(TableName=table)
    +                wait_for_deleted(client, table)
    +            except Exception:
    +                pass
    +            _release_account(mgmt, account_id, "neighbour")
    +
    +    def test_an_input_owner_other_than_the_caller_is_refused_at_start(
    +        self, bulk_table, bulk_bucket
    +    ):
    +        write_rows(bulk_bucket, "in/rows.json", [row("o", "1")])
    +        body = start_body(bulk_table, bulk_bucket)
    +        body["InputS3Configuration"]["S3BucketOwner"] = "999999999999"
    +        desc = extenddb_request("StartBulkPutItems", body)["BulkOperationDescription"]
    +        assert desc["BulkOperationStatus"] == "FAILED"
    +        assert desc["FailureCode"] == "S3AccessDenied"
    +        assert desc["FailureMessage"] == "Access Denied"
    +        assert "TotalInputBytes" not in desc
    +
    +
    +class TestS3Store:
    +    """A bucket is a bucket on the S3-compatible service, and a fault the
    +    service answers is rendered with its own message."""
    +
    +    pytestmark = s3_store_only
    +
    +    @staticmethod
    +    def service_message(call) -> str:
    +        """The message the S3 service itself answers for a missing bucket."""
    +        from botocore.exceptions import ClientError
    +
    +        with pytest.raises(ClientError) as raised:
    +            call(bulk_store.s3_client())
    +        assert raised.value.response["Error"]["Code"] == "NoSuchBucket"
    +        return raised.value.response["Error"]["Message"]
    +
    +    def test_a_missing_input_bucket_carries_the_services_message_and_tail(
    +        self, bulk_table
    +    ):
    +        desc, absent = start_with_a_missing_input_bucket(bulk_table)
    +        expected = self.service_message(
    +            lambda s3: s3.list_objects_v2(Bucket=absent, MaxKeys=1)
    +        )
    +        match = LISTING_TAIL.fullmatch(desc["FailureMessage"])
    +        assert match, desc["FailureMessage"]
    +        assert match.group("message") == expected
    +
    +    def test_a_missing_output_bucket_carries_the_services_message_and_tail(
    +        self, dynamodb_client, bulk_table, bulk_bucket
    +    ):
    +        terminal, absent = run_into_a_missing_output_bucket(
    +            dynamodb_client, bulk_table, bulk_bucket
    +        )
    +        expected = self.service_message(
    +            lambda s3: s3.create_multipart_upload(Bucket=absent, Key="probe")
    +        )
    +        match = REPORT_WRITE_TAIL.fullmatch(terminal["FailureMessage"])
    +        assert match, terminal["FailureMessage"]
    +        assert match.group("message") == expected
    diff --git a/tests/bulk_put/test_validation.py b/tests/bulk_put/test_validation.py
    new file mode 100644
    index 000000000..41d48dc6e
    --- /dev/null
    +++ b/tests/bulk_put/test_validation.py
    @@ -0,0 +1,249 @@
    +# Copyright 2026 ExtendDB contributors
    +# SPDX-License-Identifier: Apache-2.0
    +"""Request validation, ARN parsing, and cancel outcomes."""
    +
    +from __future__ import annotations
    +
    +import gzip
    +import json
    +import os
    +import re
    +import shutil
    +import tempfile
    +import time
    +import uuid
    +
    +import pytest
    +import requests
    +
    +from bulk_put.bulk_helpers import *  # noqa: F401,F403
    +
    +pytestmark = pytest.mark.skipif(
    +    not os.environ.get("EXTENDDB_TEST_ENDPOINT", "").strip(),
    +    reason="bulk operations tests require extenddb (EXTENDDB_TEST_ENDPOINT)",
    +)
    +
    +
    +class TestValidation:
    +    """Field constraints, unsupported features, and the ARN layers."""
    +
    +    def test_input_format_ion_is_refused(self, bulk_bucket):
    +        body = start_body("orders", bulk_bucket)
    +        body["InputS3Configuration"]["InputFormat"] = "ION"
    +        expect_error(
    +            "StartBulkPutItems", body,
    +            "ValidationException",
    +            "InputFormat ION is not supported by this server",
    +        )
    +
    +    def test_zstd_input_is_read_and_counted_in_compressed_bytes(
    +        self, dynamodb_client, bulk_table, bulk_bucket
    +    ):
    +        # One zstd frame of five rows {"Item": {"pk": {"S": "z"}, "sk":
    +        # {"S": ""}}} for i in 0..5, written by the reference encoder
    +        # at level 19 without a checksum; embedded so the suite needs no
    +        # zstd module. Measured on the service: a
    +        # zstd input is read and TotalInputBytes is the compressed size.
    +        frame = bytes.fromhex(
    +            "28b52ffd0068cd010082420910a06d0cf43a257e7dd5819e62523f6b60a0eda6"
    +            "9001da2edc193a3ccf69b5639e997ae8180600035306e60d4c462a086b1aadab"
    +            "7c01"
    +        )
    +        write_object(bulk_bucket, "in/rows.json.zst", frame)
    +
    +        body = start_body(bulk_table, bulk_bucket)
    +        body["InputS3Configuration"]["InputCompressionType"] = "ZSTD"
    +        resp = extenddb_request("StartBulkPutItems", body)
    +        desc = resp["BulkOperationDescription"]
    +        assert desc["InputS3Configuration"]["InputCompressionType"] == "ZSTD"
    +        assert desc["TotalInputBytes"] == len(frame)
    +
    +        terminal = wait_until_terminal(desc["BulkOperationArn"])
    +        assert terminal["BulkOperationStatus"] == "COMPLETED"
    +        assert terminal["ProcessedItemCount"] == 5
    +        assert terminal["FailedItemCount"] == 0
    +        assert terminal["ProcessedInputBytes"] == len(frame)
    +        for i in range(5):
    +            item = dynamodb_client.get_item(
    +                TableName=bulk_table, Key={"pk": {"S": "z"}, "sk": {"S": str(i)}}
    +            )
    +            assert "Item" in item
    +
    +    def test_max_results_above_the_ceiling_is_refused(self):
    +        expect_error(
    +            "ListBulkOperations", {"MaxResults": 26},
    +            "ValidationException",
    +            "1 validation error detected: Value '26' at 'maxResults' failed to "
    +            "satisfy constraint: Member must have value less than or equal to 25",
    +        )
    +
    +    def test_max_write_request_units_zero_is_refused(self, bulk_bucket):
    +        expect_error(
    +            "StartBulkPutItems",
    +            start_body("orders", bulk_bucket, MaxWriteRequestUnits=0),
    +            "ValidationException",
    +            "1 validation error detected: Value '0' at 'maxWriteRequestUnits' "
    +            "failed to satisfy constraint: Member must have value greater than "
    +            "or equal to 1024",
    +        )
    +
    +    def test_an_empty_output_type_list_is_refused(self, bulk_bucket):
    +        expect_error(
    +            "StartBulkPutItems",
    +            start_body("orders", bulk_bucket, output_types=[]),
    +            "ValidationException",
    +            "Invalid Request: OutputS3Configuration.OutputType cannot be empty",
    +        )
    +
    +    def test_an_output_configuration_without_a_bucket_is_refused(self, bulk_bucket):
    +        # Measured on the service: the output bucket member is
    +        # checked at Start, not at the first report record.
    +        body = start_body("orders", bulk_bucket)
    +        body["OutputS3Configuration"].pop("S3Bucket", None)
    +        expect_error(
    +            "StartBulkPutItems", body,
    +            "ValidationException",
    +            "Invalid Request: OutputS3Configuration.S3Bucket is invalid",
    +        )
    +
    +    def test_a_dotted_ip_bucket_name_is_refused(self, bulk_bucket):
    +        # Measured on the service: a bucket name spelled as a dotted
    +        # IPv4 literal is refused even though it satisfies the name
    +        # pattern and both length bounds.
    +        body = start_body("orders", bulk_bucket)
    +        body["InputS3Configuration"]["S3Bucket"] = "10.20.30.40"
    +        expect_error(
    +            "StartBulkPutItems", body,
    +            "ValidationException",
    +            "Invalid Request: InputS3Configuration.S3Bucket is invalid",
    +        )
    +
    +    @pytest.mark.parametrize("bucket", ["s3://loads/orders", "loads/orders"])
    +    def test_a_bucket_given_as_a_uri_or_with_a_key_path_is_refused(self, bucket):
    +        # Measured on the service: S3Bucket names a bucket only, and a
    +        # URI or a bucket followed by a key path is refused at once with
    +        # the model's pattern clause for the member.
    +        body = start_body("orders", "loads")
    +        body["InputS3Configuration"]["S3Bucket"] = bucket
    +        expect_error(
    +            "StartBulkPutItems", body,
    +            "ValidationException",
    +            f"1 validation error detected: Value '{bucket}' at "
    +            "'inputS3Configuration.s3Bucket' failed to satisfy constraint: "
    +            r"Member must satisfy regular expression pattern: "
    +            r"^[a-z0-9A-Z]+[\.\-\w]*[a-z0-9A-Z]+$",
    +        )
    +
    +    @filesystem_store_only
    +    def test_a_prefix_with_a_parent_component_is_refused(self, bulk_bucket):
    +        expect_error(
    +            "StartBulkPutItems",
    +            start_body("orders", bulk_bucket, in_prefix="in/../out/"),
    +            "ValidationException",
    +            "S3KeyPrefix contains an invalid path component",
    +        )
    +
    +    @s3_store_only
    +    def test_the_s3_store_accepts_a_prefix_the_path_rules_refuse(
    +        self, bulk_table, bulk_bucket
    +    ):
    +        # The path rules are the filesystem store's; an S3 key prefix may
    +        # hold a backslash.
    +        body = start_body(bulk_table, bulk_bucket, in_prefix="in\\x/")
    +        desc = extenddb_request("StartBulkPutItems", body)["BulkOperationDescription"]
    +        assert desc["InputS3Configuration"]["S3KeyPrefix"] == "in\\x/"
    +        final = wait_until_terminal(desc["BulkOperationArn"])
    +        assert final["BulkOperationStatus"] == "COMPLETED", final
    +        assert final["ProcessedItemCount"] == 0
    +
    +    def test_a_foreign_account_table_arn_is_refused(self, bulk_bucket):
    +        foreign = f"arn:aws:dynamodb:{REGION}:999999999999:table/orders"
    +        expect_error(
    +            "StartBulkPutItems",
    +            start_body(foreign, bulk_bucket),
    +            "AccessDeniedException",
    +            "Access is denied",
    +        )
    +
    +    def test_a_foreign_region_table_arn_is_refused(self, bulk_bucket):
    +        foreign = f"arn:aws:dynamodb:eu-west-1:{ACCOUNT_ID}:table/orders"
    +        expect_error(
    +            "StartBulkPutItems",
    +            start_body(foreign, bulk_bucket),
    +            "ValidationException",
    +            f"1 validation error detected: Invalid AWS region in '{foreign}'",
    +        )
    +
    +    def test_a_string_that_is_not_an_operation_arn_is_refused(self):
    +        expect_error(
    +            "DescribeBulkOperation", {"BulkOperationArn": "orders"},
    +            "ValidationException", ARN_SHAPE_MESSAGE,
    +        )
    +
    +    def test_an_operation_arn_with_a_malformed_id_is_refused(self):
    +        arn = (
    +            f"arn:aws:dynamodb:{REGION}:{ACCOUNT_ID}:bulk-operation/"
    +            f"{uuid.uuid4()}"
    +        )
    +        expect_error(
    +            "DescribeBulkOperation", {"BulkOperationArn": arn},
    +            "ValidationException", ARN_ID_MESSAGE,
    +        )
    +
    +    def test_a_well_formed_arn_for_no_operation_answers_not_found(self):
    +        arn = (
    +            f"arn:aws:dynamodb:{REGION}:{ACCOUNT_ID}:bulk-operation/"
    +            "00000000000001-deadbeef"
    +        )
    +        expect_error(
    +            "DescribeBulkOperation", {"BulkOperationArn": arn},
    +            "ResourceNotFoundException",
    +            f"Bulk operation with arn: {arn} does not exist",
    +        )
    +
    +    def test_a_cancel_on_a_terminal_operation_names_the_status(
    +        self, bulk_table, bulk_bucket
    +    ):
    +        resp = extenddb_request(
    +            "StartBulkPutItems", start_body(bulk_table, bulk_bucket)
    +        )
    +        arn = resp["BulkOperationDescription"]["BulkOperationArn"]
    +        terminal = wait_until_terminal(arn)
    +        assert terminal["BulkOperationStatus"] == "COMPLETED"
    +        expect_error(
    +            "CancelBulkOperation", {"BulkOperationArn": arn},
    +            "ValidationException",
    +            "Invalid Request: Operation already in terminal state: COMPLETED",
    +        )
    +
    +    def test_a_cancel_on_a_running_operation_ends_it_cancelled(
    +        self, bulk_table, bulk_bucket
    +    ):
    +        rows = [row("n", str(i), f={"S": "x" * 40}) for i in range(20000)]
    +        write_rows(bulk_bucket, "in/rows.json", rows)
    +        resp = extenddb_request(
    +            "StartBulkPutItems", start_body(bulk_table, bulk_bucket)
    +        )
    +        arn = resp["BulkOperationDescription"]["BulkOperationArn"]
    +
    +        cancel = extenddb_request("CancelBulkOperation", {"BulkOperationArn": arn})
    +        assert cancel["BulkOperationStatus"] == "CANCELLING"
    +        assert cancel["BulkOperationArn"] == arn
    +        assert set(cancel.keys()) == {"BulkOperationArn", "BulkOperationStatus"}
    +
    +        terminal = wait_until_terminal(arn)
    +        assert terminal["BulkOperationStatus"] == "CANCELLED"
    +        # Measured on the hosted service.
    +        assert terminal["FailureCode"] == "RequestCancelled"
    +        assert terminal["FailureMessage"] == "The request was cancelled"
    +        assert terminal["ProcessedInputBytes"] == 0
    +        # The cancel lands within milliseconds of the start and the run
    +        # takes seconds, so a worker that stopped at the next boundary has
    +        # processed far fewer than all rows. A server that finished the
    +        # whole file and relabelled the outcome would fail here.
    +        assert terminal["ProcessedItemCount"] < 20000
    +
    +
    +# ---------------------------------------------------------------------------
    +# Second accounts, for listing and isolation
    +# ---------------------------------------------------------------------------
    diff --git a/tests/lifecycle_helpers.py b/tests/lifecycle_helpers.py
    index 74693f6ff..4a5c55c52 100644
    --- a/tests/lifecycle_helpers.py
    +++ b/tests/lifecycle_helpers.py
    @@ -134,11 +134,12 @@ def _init_args(cli_env):
         return args
     
     
    -def _run_extenddb(*args, config=None, timeout=30, check=True, env_override=None):
    +def _run_extenddb(*args, config=None, timeout=30, check=True, env_override=None, cwd=None):
         """Run a extenddb CLI command and return the CompletedProcess.
     
         Never passes stdin — extenddb commands must be fully non-interactive.
    -    Use env_override to pass EXTENDDB_ADMIN_PASSWORD etc.
    +    Use env_override to pass EXTENDDB_ADMIN_PASSWORD etc., and cwd to start
    +    the command in another directory.
         """
         cmd = [EXTENDDB_BINARY]
         cmd.extend(args)
    @@ -156,6 +157,7 @@ def _run_extenddb(*args, config=None, timeout=30, check=True, env_override=None)
             check=check,
             stdin=subprocess.DEVNULL,
             env=env,
    +        cwd=cwd,
         )
     
     
    diff --git a/tests/test_cli_lifecycle.py b/tests/test_cli_lifecycle.py
    index b4fbbcdd8..c8fb04a1e 100644
    --- a/tests/test_cli_lifecycle.py
    +++ b/tests/test_cli_lifecycle.py
    @@ -279,6 +279,36 @@ def test_init_serve_stop_destroy(self, cli_env):
             result = _run_extenddb("destroy", "--yes", *_pg_args(), config=cli_env["config_path"])
             assert result.returncode == 0
     
    +    def test_daemon_resolves_relative_paths_against_its_start_directory(self, cli_env):
    +        """A daemonized serve resolves relative bulk store, import and export
    +        paths against the directory it was started in, as in the foreground."""
    +        _run_extenddb(
    +            "init", *_init_args(cli_env),
    +            config=cli_env["config_path"],
    +            env_override={"EXTENDDB_ADMIN_PASSWORD": "TestPass1!"},
    +        )
    +        _patch_config_port(cli_env["config_path"], cli_env["port"])
    +        start_dir = os.path.dirname(cli_env["config_path"])
    +        names = ("relative-buckets", "relative-imports", "relative-exports")
    +        with open(cli_env["config_path"], "a") as f:
    +            f.write(
    +                f'\n[bulk]\n\n[bulk.filesystem]\npath = "{names[0]}"\n'
    +                f'\n[import]\npaths = ["{names[1]}"]\n'
    +                f'\n[export]\npaths = ["{names[2]}"]\n'
    +            )
    +
    +        # Serve (daemonizes), started in the directory that holds the config.
    +        result = _run_extenddb(
    +            "serve", config=cli_env["config_path"], cwd=start_dir, check=False,
    +        )
    +        assert result.returncode == 0, result.stdout + result.stderr
    +        assert _wait_for_server(cli_env["port"]), "Server did not become healthy"
    +        for name in names:
    +            assert os.path.isdir(os.path.join(start_dir, name)), name
    +
    +        result = _run_extenddb("stop", config=cli_env["config_path"])
    +        assert result.returncode == 0
    +
         def test_serve_without_init_fails(self, cli_env):
             """extenddb serve without init should fail."""
             result = _run_extenddb("serve", config=cli_env["config_path"], check=False)
    diff --git a/tests/test_cli_vector_catalog_migration.py b/tests/test_cli_vector_catalog_migration.py
    index 718b07675..d85e1b2db 100644
    --- a/tests/test_cli_vector_catalog_migration.py
    +++ b/tests/test_cli_vector_catalog_migration.py
    @@ -259,7 +259,7 @@ def test_init_creates_the_catalog_at_the_current_migration(self, cli_env):
             assert _ids_in("catalog") == list(range(1, _latest() + 1))
             data_ids = _ledger_ids(_data_db(cli_env))
             assert data_ids == _ids_in("data")
    -        # Migrations 1, 4 and 7 have bodies in the data database.
    +        # Migrations 1, 4, 7 and 8 have bodies in the data database.
             assert data_ids[:2] == [1, 4], data_ids
     
             # Migration 1 also writes the legacy ledger rows, so a release before
    
    From 7db352595997abe1ab5bb709906923957db2dcc3 Mon Sep 17 00:00:00 2001
    From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com>
    Date: Thu, 5 Nov 2026 22:10:05 +0000
    Subject: [PATCH 6/6] docs: document bulk put items
    
    The usage guide gains a Bulk Put Items chapter covering the request, the
    input on each store, the four operations, the error report and
    idempotency. The admin guide documents the [bulk] section, the S3
    store's credentials, its rule for plain http endpoints and the bulk
    limits. The security model lists the S3 actions an operation checks for
    its caller. The differences page and the troubleshooting guide gain
    bulk sections. The upgrade manual covers backup and restore during a
    bulk operation. Its behaviour changes gain two entries. A daemonized
    serve keeps the directory it started in, and PutItem checks the nesting
    depth of ExpressionAttributeValues before the table lookup. The guides
    that print the catalog migration show migration 8. The CONTRIBUTING
    worked example moves to migration 9. The backup design reserves
    PostgreSQL migration 9 for restore_progress, because stream_pending and
    bulk_operations hold 7 and 8. REQ-DEPLOY-006 in
    the requirements document raises the container image limit from 50 MB
    to 60 MB. The README lists the four operations, and the sample
    configuration has the [bulk] sections and the bulk limits. The design
    guide lists the bulk catalog tables, and the storage and server design
    documents cover a batch's index checks and the bulk metrics.
    ---
     AGENTS.md                                 |   1 +
     CONTRIBUTING.md                           |  47 +-
     README.md                                 |   3 +
     docs/design/01-requirements.md            |   2 +-
     docs/design/04-component-storage.md       |  41 +-
     docs/design/06-component-server.md        |   3 +
     docs/design/08-component-config.md        |   2 +-
     docs/design/15-backup-restore.md          |   2 +-
     docs/differences-from-dynamodb.md         |  29 +
     docs/getting-started.md                   |   6 +-
     docs/manuals/02-design-guide.md           |   3 +
     docs/manuals/03-usage-guide.md            | 707 ++++++++++++++++++++++
     docs/manuals/04-quickstart-setup-guide.md |   4 +-
     docs/manuals/05-admin-guide.md            | 625 ++++++++++++++++++-
     docs/manuals/07-upgrade-manual.md         |  56 +-
     docs/manuals/08-install-linux.md          |   2 +-
     docs/manuals/09-install-macos.md          |   2 +-
     docs/manuals/10-security-model.md         | 182 +++++-
     docs/troubleshooting.md                   |  91 ++-
     extenddb.sample.toml                      |  66 ++
     20 files changed, 1833 insertions(+), 41 deletions(-)
    
    diff --git a/AGENTS.md b/AGENTS.md
    index 83c7ac296..bff895599 100644
    --- a/AGENTS.md
    +++ b/AGENTS.md
    @@ -474,6 +474,7 @@ Activate when the user asks about installing, configuring, running, or debugging
     | `crates/storage/src/lib.rs` | Storage trait definitions |
     | `crates/core/src/types.rs` | DynamoDB type definitions |
     | `crates/engine/src/` | Operation handlers |
    +| `crates/s3/src/lib.rs` | S3 client building and the startup checks of the server's AWS credentials, for every S3 store |
     | `crates/bin/src/main.rs` | CLI entry point |
     
     ## Environment Variables
    diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
    index c0037018b..15a642898 100755
    --- a/CONTRIBUTING.md
    +++ b/CONTRIBUTING.md
    @@ -341,28 +341,28 @@ SQLite, PostgreSQL, Cassandra and MongoDB migrate through this framework.
     
     ### Example: a nullable column on `tables`
     
    -Migration 8, `table_note` (8 being the next free id at the time of writing;
    -migrations 6 and 7 are `backup_catalog_index` and `stream_pending`), adds a
    -nullable `note TEXT` column to the catalog's `tables` table on SQLite and
    -PostgreSQL.
    +Migration 9, `table_note` (9 being the next free id at the time of writing;
    +migrations 6, 7 and 8 are `backup_catalog_index`, `stream_pending` and
    +`bulk_operations`), adds a nullable `note TEXT` column to the catalog's
    +`tables` table on SQLite and PostgreSQL.
     
     In `crates/storage/src/migrations/registry.rs`, at the end of the
     `migration_ids!` list:
     
     ```rust
         /// Adds a nullable `note` column to the `tables` catalog table.
    -    TableNote = 8, "table_note",
    +    TableNote = 9, "table_note",
             "add a nullable note column to the tables catalog";
     ```
     
     PostgreSQL has `ADD COLUMN IF NOT EXISTS`, so its body is a file,
    -`crates/storage-postgres/migrations/0008_table_note.sql`:
    +`crates/storage-postgres/migrations/0009_table_note.sql`:
     
     ```sql
     -- Copyright 2026 ExtendDB contributors
     -- SPDX-License-Identifier: Apache-2.0
     --
    --- Migration 8, table_note, PostgreSQL catalog database: a nullable note
    +-- Migration 9, table_note, PostgreSQL catalog database: a nullable note
     -- column on tables. IF NOT EXISTS makes a second run change nothing.
     
     ALTER TABLE tables ADD COLUMN IF NOT EXISTS note TEXT;
    @@ -377,7 +377,7 @@ In `crates/storage-postgres/src/migrations/mod.rs`, the arm in `migration()`:
                 bodies: vec![file_body!(
                     StoreKind::Catalog,
                     "migrations",
    -                "0008_table_note.sql"
    +                "0009_table_note.sql"
                 )],
             },
     ```
    @@ -391,19 +391,20 @@ and the id in the transactional arm of `runs_in()`:
             | MigrationId::DropContinuousBackups
             | MigrationId::BackupCatalogIndex
             | MigrationId::StreamPending
    +        | MigrationId::BulkOperations
             | MigrationId::TableNote => RunsIn::Transaction,
     ```
     
     SQLite has no `ADD COLUMN IF NOT EXISTS`, and a file with a plain
     `ADD COLUMN` fails when it runs a second time, so the SQLite body is code
     that checks for the column first.
    -`crates/storage-sqlite/src/migrations/m0008_table_note.rs`:
    +`crates/storage-sqlite/src/migrations/m0009_table_note.rs`:
     
     ```rust
     // Copyright 2026 ExtendDB contributors
     // SPDX-License-Identifier: Apache-2.0
     
    -//! Migration 8, `table_note`, SQLite: a nullable `note` column on `tables`.
    +//! Migration 9, `table_note`, SQLite: a nullable `note` column on `tables`.
     //!
     //! SQLite has no `ADD COLUMN IF NOT EXISTS`, so the body reads the table's
     //! columns first and adds the column only when it is missing. A second run
    @@ -415,10 +416,10 @@ use extenddb_storage::migrations::{CodeBody, Done, MigrationError};
     use super::SqliteStores;
     
     /// This file, whose SHA-256 the checksum test pins.
    -pub(crate) const SOURCE: &str = include_str!("m0008_table_note.rs");
    +pub(crate) const SOURCE: &str = include_str!("m0009_table_note.rs");
     
     /// Recorded as this migration's checksum. Changes only with a new migration.
    -const FINGERPRINT: &str = "0008_table_note/sqlite/1";
    +const FINGERPRINT: &str = "0009_table_note/sqlite/1";
     
     pub(crate) struct TableNote;
     
    @@ -457,7 +458,7 @@ beside the others:
     ```rust
     #[rustfmt::skip]
     #[allow(clippy::all, deprecated)]
    -mod m0008_table_note;
    +mod m0009_table_note;
     ```
     
     and the arm in `migration()`:
    @@ -468,8 +469,8 @@ and the arm in `migration()`:
                 convergent: true,
                 bodies: vec![StoreBody::code(
                     StoreKind::Catalog,
    -                &m0008_table_note::TableNote,
    -                m0008_table_note::SOURCE,
    +                &m0009_table_note::TableNote,
    +                m0009_table_note::SOURCE,
                 )],
             },
     ```
    @@ -481,8 +482,8 @@ rows of `tables`:
     
     ```rust
     #[tokio::test]
    -async fn migration_8_adds_the_note_column_to_tables() {
    -    let db = temp_db("m8");
    +async fn migration_9_adds_the_note_column_to_tables() {
    +    let db = temp_db("m9");
         load_dump(&db, DEV_0_1_14).await;
         run(&db).await.expect("migrate");
         let mut text = dump(
    @@ -492,12 +493,12 @@ async fn migration_8_adds_the_note_column_to_tables() {
         )
         .await;
         text.push_str(&dump(&db, "SELECT table_name, note FROM tables ORDER BY table_name").await);
    -    golden("0008_table_note", &text);
    +    golden("0009_table_note", &text);
     }
     ```
     
    -`EXTENDDB_BLESS_EXPECTED=1 cargo test -p extenddb-storage-sqlite --lib migration_8`
    -writes `crates/storage-sqlite/tests/fixtures/golden/0008_table_note.txt`, one
    +`EXTENDDB_BLESS_EXPECTED=1 cargo test -p extenddb-storage-sqlite --lib migration_9`
    +writes `crates/storage-sqlite/tests/fixtures/golden/0009_table_note.txt`, one
     tab-separated line per row under each query:
     
     ```
    @@ -512,15 +513,15 @@ The PostgreSQL body is a file, so it has no golden test.
     The example declares `Kind::Breaking`. A nullable column can be additive by
     the rule in step 4, but declaring it additive needs the old-binary job, which
     this example does not add. As a breaking migration it makes a 1.x binary
    -without migration 8 refuse a catalog that has it, and a rollback restores the
    +without migration 9 refuse a catalog that has it, and a rollback restores the
     backup.
     
     The same PR changes the code that reads and writes `tables.note` in each
     backend's catalog store, and commits the `docs/schema/sqlite.txt` and
     `docs/schema/postgres.txt` that the integration jobs regenerate. It changes
     no per-table DDL, because `tables` is a catalog table, and adds no `RELEASED`
    -entry. `extenddb migrate --list` then shows migration 8, and on a catalog at
    -migration 7 `extenddb migrate` lists migration 8 as pending and exits 3.
    +entry. `extenddb migrate --list` then shows migration 9, and on a catalog at
    +migration 8 `extenddb migrate` lists migration 9 as pending and exits 3.
     
     ### Running the migration tests
     
    diff --git a/README.md b/README.md
    index f5cf0ff55..f7ac4eaed 100755
    --- a/README.md
    +++ b/README.md
    @@ -219,6 +219,9 @@ Query, Scan (key conditions, filters, projections, pagination, index selection)
     ### Batch & Transactions
     BatchGetItem (100 keys), BatchWriteItem (25 ops), TransactGetItems (100 items), TransactWriteItems (100 ops)
     
    +### Bulk Operations
    +StartBulkPutItems, DescribeBulkOperation, ListBulkOperations, CancelBulkOperation (newline-delimited DynamoDB JSON read from the configured `[bulk]` store, S3 buckets or a filesystem root, with error reports written to the same store; not available on the MongoDB or Cassandra backends)
    +
     ### Vector Search
     SearchVectors, and vector indexes on CreateTable and UpdateTable (cosine, Euclidean and dot product; on-demand tables only). On PostgreSQL this requires the pgvector extension on the data database; without it every vector operation is refused and nothing else is affected.
     
    diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md
    index 47f5e2f68..b12ed8aad 100755
    --- a/docs/design/01-requirements.md
    +++ b/docs/design/01-requirements.md
    @@ -501,7 +501,7 @@ The catalog database stores extenddb metadata: table definitions, indexes, tags,
     - REQ-DEPLOY-003: Graceful shutdown on SIGTERM (drain in-flight requests, close connections)
     - REQ-DEPLOY-004: Kubernetes-ready: health/readiness probes, configurable bind address, env var config
     - REQ-DEPLOY-005: VM-ready: systemd-compatible, config file based, log to stdout/file
    -- REQ-DEPLOY-006: Container image < 50 MB (static musl build)
    +- REQ-DEPLOY-006: Container image < 60 MB (static musl build)
     
     ### 10.5 Rust-Specific Requirements
     
    diff --git a/docs/design/04-component-storage.md b/docs/design/04-component-storage.md
    index f9ac92dab..b5c035379 100755
    --- a/docs/design/04-component-storage.md
    +++ b/docs/design/04-component-storage.md
    @@ -693,7 +693,14 @@ When `UpdateTable` adds a new GSI to a table with existing data, the PostgreSQL
        from its catalog read re-reads it and queues a repair for any GSI it missed; a write with no transaction
        queues that repair after its own statement, and loses it if the process dies in between) together cover
        writes that read the catalog just before the index row committed: the catalog and the data tables are
    -   separate databases, so no transaction can tell those writes the index exists.
    +   separate databases, so no transaction can tell those writes the index exists. A bulk put batch reads the
    +   catalog once for all its rows and makes the same check once, before it commits: when more than 500 ms have
    +   passed since that read, it re-reads the catalog and, for each GSI the re-read finds that the first read did
    +   not, queues a repair for every row it wrote. A batch retried after a deadlock keeps the index set and the
    +   timestamp of its first read. With PostgreSQL's default `deadlock_timeout` of one second, a retry comes more
    +   than 500 ms after that read and re-reads the catalog before it commits; a retry that comes sooner, under a
    +   shorter `deadlock_timeout`, does not re-read and, like a batch that commits within 500 ms, is covered by the
    +   `LOCK TABLE` in this step, which waits for the table lock the batch holds until it commits.
     3. Scans the base table in keyset pages (256 rows, `FOR SHARE SKIP LOCKED`, skipped rows taken one at a time
        afterwards), writing each item's current state into the index, then deletes index rows whose item is gone.
     4. Sets the status to `ACTIVE`. A build that fails for a transient reason leaves the index `CREATING`, and a
    @@ -710,6 +717,34 @@ refused with LimitExceededException. A Delete of the building GSI and a throughp
     claim query honours) and deletes its pending rows before it drops any data table, so a worker holding a claimed row
     and waiting on the base table cannot deadlock with the drop.
     
    +A GSI Delete commits the catalog change and then drops the index's data table. A write whose catalog read came
    +before the Delete writes each GSI under a savepoint, so a GSI whose table is gone is rolled back to the savepoint
    +and skipped. A Delete that comes after the write's first statement on the index table waits for the write to commit,
    +because the drop needs the lock the write holds.
    +
    +A bulk put Start and an `UpdateTable` that creates a GSI on the same table are checked against each other inside the
    +transaction that acts. On PostgreSQL the Start reads the `tables` row `FOR SHARE` and counts the table's indexes that
    +are not `ACTIVE`, and `UpdateTable` reads it `FOR UPDATE` and counts the table's non-terminal bulk operations; both
    +rows live in the catalog database. On SQLite both checks run inside `BEGIN IMMEDIATE` under the engine's write lock.
    +A GSI build in the same process is also counted in memory, so a Start called during a build, or waiting behind one,
    +is refused. On PostgreSQL the build is counted from just before its catalog commit, while `UpdateTable` still holds
    +the `tables` row, so a creation that is refused or fails before its commit refuses no Start. A Start whose `tables`
    +row is gone, because `DeleteTable` removed it after the handler's lookup, is refused as for a `DELETING` table.
    +
    +On PostgreSQL an `UpdateTable` that creates a GSI takes a second connection for the index's data table while it
    +holds the `tables` row, and each Start holds a connection while it waits for its locks. The Start therefore waits
    +for its account's advisory lock under a `lock_timeout` of 200 ms, and then takes the row `FOR SHARE NOWAIT`. A wait
    +past the timeout, or a row another transaction holds, rolls the attempt back, which returns its connection to the
    +pool, and the Start tries again after a pause of 10 ms, doubling to at most 200 ms. The row's other holders are
    +`UpdateTable`, `DeleteTable`, `UpdateTimeToLive`, the TTL index build and the size refresh, each for a few statements;
    +a `NOWAIT` lock does not queue, so a statement that updated a `tables` row back to back would keep a Start on that
    +table retrying until its deadline. After 30 seconds the Start
    +answers `ServiceUnavailable`, which SDKs retry. The advisory lock is never held while a Start waits for a row, so
    +Starts waiting on one table's row do not delay the account's Starts on other tables; and only the Start holding the
    +advisory lock holds a table's row, so a stream of Starts on a table does not keep `UpdateTable` or `DeleteTable` on
    +it waiting. The statements after the two locks, the read of the table's index states among them, run with no
    +`lock_timeout`.
    +
     ## 7. Pagination Token Encoding
     
     `ExclusiveStartKey` and `LastEvaluatedKey` use the same format: a map of key attribute names to `AttributeValue`s,
    @@ -938,6 +973,10 @@ fn server_components_factory(config: &StorageConfig, region: &str) -> /* ... */
                     // Verify catalog version
                     engine.check_catalog_version().await?;
                     
    +                // Verify every embedded data migration is recorded in the
    +                // data database (the catalog version does not cover it)
    +                engine.check_data_migrations().await?;
    +                
                     // Recover control plane state
                     engine.process_control_plane_transitions().await?;
                     
    diff --git a/docs/design/06-component-server.md b/docs/design/06-component-server.md
    index 37e2529b6..de364799b 100755
    --- a/docs/design/06-component-server.md
    +++ b/docs/design/06-component-server.md
    @@ -360,6 +360,9 @@ ExtendDB-internal:
     - `StorageQueryCount`, `StorageQueryLatency` (dimensions: source, category)
     - `PoolActiveConnections`, `PoolIdleConnections`, `PoolAcquireLatency`
     - `WorkerLastSuccess`, `WorkerCycleLatency`, `WorkerErrorCount`
    +- `BulkOperationsStarted` (dimension: `TableName`)
    +- `BulkOperationsFinished` (dimensions: `TableName`, and `Operation` carrying the terminal status)
    +- `BulkPutItemsWritten` (dimension: `TableName`; rows the committing process wrote, excluding replayed rows and condition failures)
     
     **Dimensions** (from the `Dimension` enum):
     
    diff --git a/docs/design/08-component-config.md b/docs/design/08-component-config.md
    index 8e7e59f15..6fa62b027 100755
    --- a/docs/design/08-component-config.md
    +++ b/docs/design/08-component-config.md
    @@ -488,7 +488,7 @@ spec:
     ### 9.3 Docker Build
     
     ```dockerfile
    -# Static musl build for minimal container image (REQ-DEPLOY-006: < 50 MB)
    +# Static musl build for minimal container image (REQ-DEPLOY-006: < 60 MB)
     FROM rust:1.77-alpine AS builder
     RUN apk add --no-cache musl-dev
     RUN rustup target add x86_64-unknown-linux-musl
    diff --git a/docs/design/15-backup-restore.md b/docs/design/15-backup-restore.md
    index 06c13ece0..33b3dc47e 100644
    --- a/docs/design/15-backup-restore.md
    +++ b/docs/design/15-backup-restore.md
    @@ -370,7 +370,7 @@ Kill points (testing summary): between a data file upload and the manifest; betw
     ### PR 9a: restore job
     
     Goal: the restore job of 5.3 on the same backend over a filesystem store. Its line names the interfaces and carries the acceptance:
    -- PR 9a: restore job per 5.3, same backend, filesystem store: request creates table and `restore_jobs` row atomically in a detached task and returns CREATING (`TableAlreadyExistsException` or `TableInUseException` on a name race, as #384 returns them; `LimitExceededException` over the job limit); Cassandra claim and heartbeat by lightweight transaction; job verifies every file before loading any, with `.verified` spool markers; `bulk_load(target_table_id, claim, file_index, start_at, items)` per backend with the progress record as 5.3 step 5 specifies for each (PostgreSQL migration 7 for `restore_progress`, reserved here) and the `BackupCatalog` restore methods of section 3; the MongoDB terminal sequence of 5.3 step 6 (DONE and `restore_backfill_pending` in one transaction, the worker's flip conditioned on the flag); `spool_host` as host and spool path; the item validator PR 8 adds, bound with the target's `max_item_size_bytes` (`N` normalization included); items read through `ExtenddbManifest::reader_prehashed`; the restore's vector refusal narrowed to a target without vector support, with `vector_index_backups_are_refused_in_the_engine` rewritten to that case; duplicate rejection across and within files (#394's per-file key-range check on key-ordered backups, the count after the last file on unordered ones, the MongoDB post-resume count check); the manifest and sidecar spooled for a resume; a PostgreSQL resume or target deletion only after the stale window; conditional ACTIVE flip before the progress rows are deleted, the job's target removal (`remove_restore_target`, not DeleteTable) gated on a matched FAILED write, DeleteTable on a restore target refused as #384 refuses it, the in-use rule of 5.2 checked in step 1 before the permit and again in step 4's registration together with `status = AVAILABLE` under the backup row's lock (MongoDB's `$inc`, Cassandra's `active_restores` lightweight transaction), multi-part-key tables restored; `bulk_load` writing index rows in the batch's transaction, with the base rows, on PostgreSQL and SQLite and the ACTIVE flip setting every index ACTIVE there; download transport errors retried for 15 minutes; a restore stopped by shutdown left RUNNING; the store and `kind` recorded on the job row and a takeover writing its `spool_host`; a PostgreSQL resume with no progress row only into an empty target. Acceptance: round trip preserves items, keys, GSIs, LSIs, throughput, SSE, vector where supported, and restores the table `STANDARD` whatever its class (#384's `table_class_is_not_restored` passes unchanged); tampering file 2's sha256 leaves a table that was never writable and is gone after the job fails, with no item ever written; SIGKILL after file 1's commit then restart writes exactly the backup's item count on every backend (MongoDB via duplicate-key suppression); SIGKILL at each kill point of the testing summary leaves a table that either finishes with the right count or is gone (on MongoDB, killed between the DONE write and the worker's flip, the restarted worker finishes it and the reconciler leaves it alone); an unordered backup carrying one key in two files fails into every target, Cassandra included, and a key-ordered backup whose file leaves its `key_range` fails; a second restore of a backup while a target restored from it is CREATING, and a DeleteBackup of that backup, return `BackupInUseException` on every backend, on MongoDB also between the job's DONE write and the worker's ACTIVE flip, and #384's `test_restore_into_a_creating_target_and_a_second_restore_of_its_backup_are_refused` passes unchanged against the new path; the two orderings over an off-host backup (a `backup_index` row), gated on the `backup_index` row lock, run here for the first time, deferred from PR 6 (which has no restore of such a row): a DeleteBackup that wins leaves the registration BackupNotFound and no target, a registration that wins leaves DeleteBackup BackupInUse; on MongoDB two concurrent registrations of one backup leave one target and return `BackupInUseException` to the other; a restore of an ARN of account B that carries the id of account A's backup returns BackupNotFound; on SQLite and PostgreSQL a Query on each GSI and SearchVectors return the restored items as soon as the target reports ACTIVE; a SIGTERM during a restore leaves its row RUNNING and the restore finishes after the restart; a store outage shorter than the retry bound mid-download leaves the restore RUNNING, and it completes once the store answers. Depends on PR 8.
    +- PR 9a: restore job per 5.3, same backend, filesystem store: request creates table and `restore_jobs` row atomically in a detached task and returns CREATING (`TableAlreadyExistsException` or `TableInUseException` on a name race, as #384 returns them; `LimitExceededException` over the job limit); Cassandra claim and heartbeat by lightweight transaction; job verifies every file before loading any, with `.verified` spool markers; `bulk_load(target_table_id, claim, file_index, start_at, items)` per backend with the progress record as 5.3 step 5 specifies for each (PostgreSQL migration 9 for `restore_progress`, reserved here) and the `BackupCatalog` restore methods of section 3; the MongoDB terminal sequence of 5.3 step 6 (DONE and `restore_backfill_pending` in one transaction, the worker's flip conditioned on the flag); `spool_host` as host and spool path; the item validator PR 8 adds, bound with the target's `max_item_size_bytes` (`N` normalization included); items read through `ExtenddbManifest::reader_prehashed`; the restore's vector refusal narrowed to a target without vector support, with `vector_index_backups_are_refused_in_the_engine` rewritten to that case; duplicate rejection across and within files (#394's per-file key-range check on key-ordered backups, the count after the last file on unordered ones, the MongoDB post-resume count check); the manifest and sidecar spooled for a resume; a PostgreSQL resume or target deletion only after the stale window; conditional ACTIVE flip before the progress rows are deleted, the job's target removal (`remove_restore_target`, not DeleteTable) gated on a matched FAILED write, DeleteTable on a restore target refused as #384 refuses it, the in-use rule of 5.2 checked in step 1 before the permit and again in step 4's registration together with `status = AVAILABLE` under the backup row's lock (MongoDB's `$inc`, Cassandra's `active_restores` lightweight transaction), multi-part-key tables restored; `bulk_load` writing index rows in the batch's transaction, with the base rows, on PostgreSQL and SQLite and the ACTIVE flip setting every index ACTIVE there; download transport errors retried for 15 minutes; a restore stopped by shutdown left RUNNING; the store and `kind` recorded on the job row and a takeover writing its `spool_host`; a PostgreSQL resume with no progress row only into an empty target. Acceptance: round trip preserves items, keys, GSIs, LSIs, throughput, SSE, vector where supported, and restores the table `STANDARD` whatever its class (#384's `table_class_is_not_restored` passes unchanged); tampering file 2's sha256 leaves a table that was never writable and is gone after the job fails, with no item ever written; SIGKILL after file 1's commit then restart writes exactly the backup's item count on every backend (MongoDB via duplicate-key suppression); SIGKILL at each kill point of the testing summary leaves a table that either finishes with the right count or is gone (on MongoDB, killed between the DONE write and the worker's flip, the restarted worker finishes it and the reconciler leaves it alone); an unordered backup carrying one key in two files fails into every target, Cassandra included, and a key-ordered backup whose file leaves its `key_range` fails; a second restore of a backup while a target restored from it is CREATING, and a DeleteBackup of that backup, return `BackupInUseException` on every backend, on MongoDB also between the job's DONE write and the worker's ACTIVE flip, and #384's `test_restore_into_a_creating_target_and_a_second_restore_of_its_backup_are_refused` passes unchanged against the new path; the two orderings over an off-host backup (a `backup_index` row), gated on the `backup_index` row lock, run here for the first time, deferred from PR 6 (which has no restore of such a row): a DeleteBackup that wins leaves the registration BackupNotFound and no target, a registration that wins leaves DeleteBackup BackupInUse; on MongoDB two concurrent registrations of one backup leave one target and return `BackupInUseException` to the other; a restore of an ARN of account B that carries the id of account A's backup returns BackupNotFound; on SQLite and PostgreSQL a Query on each GSI and SearchVectors return the restored items as soon as the target reports ACTIVE; a SIGTERM during a restore leaves its row RUNNING and the restore finishes after the restart; a store outage shorter than the retry bound mid-download leaves the restore RUNNING, and it completes once the store answers. Depends on PR 8.
     
     Invariants honoured: 4(b) (the restore record and its transitions), 4(d) (every target by `RestoreTargetKey`), 4(e) (the claim, the FAILED-write gate before the target is removed, the PostgreSQL stale window), 4(f) (the job checks the maintenance claim after its row exists), 5.2 (the registration under the backup row's lock), 5.3 as written, section 1's duplicate rule and vector refusal, and the Goals' fidelity list.
     Interfaces it adds: `bulk_load` (section 3) and the restore methods of `BackupCatalog`:
    diff --git a/docs/differences-from-dynamodb.md b/docs/differences-from-dynamodb.md
    index 0a09f691a..11e78183b 100755
    --- a/docs/differences-from-dynamodb.md
    +++ b/docs/differences-from-dynamodb.md
    @@ -46,6 +46,35 @@ from a request sent to that service or backend.
     | Import execution | Asynchronous (background job) | Synchronous (completes before returning) |
     | Export execution | Point-in-time snapshot | Current snapshot, synchronous |
     
    +## Bulk Operations
    +
    +The four bulk operation APIs (`StartBulkPutItems`, `DescribeBulkOperation`,
    +`ListBulkOperations`, `CancelBulkOperation`) are implemented. The usage guide
    +describes the behaviour in full; the differences are:
    +
    +| Area | DynamoDB | ExtendDB |
    +|------|----------|------|
    +| Input and report location | S3 buckets, read and written as the caller | The store the server configuration selects with `[bulk] store`. The S3 store reads and writes S3 buckets in AWS S3 or an S3-compatible service with the server's own AWS credentials. The filesystem store uses one directory tree: `S3Bucket` names a directory under the caller's account subtree of the `[bulk.filesystem]` store root, one root for inputs and reports. On both stores report records and the manifest keep the `arn:aws:s3:::` spelling in `inputSourceArn`, so tooling that parses the report works unchanged. |
    +| Input failures decided at Start: a missing input bucket, an input `S3BucketOwner` the bucket does not match, a denied `s3:ListBucket`, and an output KMS key that cannot be used | Start answers `IN_PROGRESS`, and the operation ends `FAILED` seconds later with `S3NoSuchBucket`, `S3AccessDenied`, or the KMS code and no byte members (measured: 2.5 s for a denied `s3:ListBucket`, which wrote no manifest, and 6 s for a missing bucket) | The operation is created already terminal: the Start response itself carries `FAILED`, with the same code, an `EndTime` equal to `StartTime`, no byte members, and no manifest. The filesystem store has no KMS, so it checks no output KMS key. These terminal-born starts are capped at `max_bulk_failed_starts_per_hour` per account per hour (default 100); at or above the cap Start answers `ThrottlingException`, `Rate of requests exceeds the allowed throughput.`, and records nothing. On a table with an operation running, such a Start answers `ResourceInUseException` with the busy text and creates nothing, as the service does, whether or not the cap is reached. |
    +| Output location that cannot be used | Not checked at Start. At the first report record a missing bucket, an owner mismatch, or a refused `s3:PutObject` is held: the service writes every good row to the end of the input, then ends `FAILED` with `ProcessedItemCount` the rows written, `FailedItemCount` 0, `ProcessedInputBytes` 0, no report, and no manifest (measured for all three faults), and zero usage (measured for the refused `s3:PutObject`). An operation with no report record meets the bucket and owner faults at the manifest write, with the message prefixed `Manifest is not available. ` (measured). The service also writes report objects during the run, and one that fails there stops the operation at once (measured) | The same rules on both stores. The manifest-time check also answers a refused `s3:PutObject` with the prefixed message; the service's answer there is not measured. ExtendDB writes reports only when the operation ends, so on an input whose report output passes the service's first mid-run write, ExtendDB writes more rows than the service before it fails. Under the held fault a row whose condition evaluated false still counts in `ProcessedItemCount`; the service's count for such rows is not measured. |
    +| `S3BucketOwner` | An owner other than the bucket's fails the operation with `S3AccessDenied`, `Access Denied` (measured for the input at Start and the output at the first report record) | Filesystem store: any account other than the caller's fails it with `S3AccessDenied`, `Access Denied`, since the caller's account is the only owner a bucket directory can have. S3 store: an owner other than the caller's ExtendDB account id is sent to S3 as `x-amz-expected-bucket-owner`, and S3 decides; an owner equal to that id sends no expectation. |
    +| S3 permissions of the caller | The service performs `s3:ListBucket`, `s3:GetObject`, and `s3:PutObject` as the caller, so the caller's identity policies, the bucket policies, and the KMS key policies all apply, and a denial names the caller with S3's request ids (measured) | Both stores evaluate the three actions against the caller's ExtendDB identity policies. ExtendDB has no bucket policies, and evaluates no `kms:` action for the caller. Its denial names the caller in S3's identity-based wording, and carries no request-id tail because no request reached S3. On the S3 store, S3 then evaluates the server's own request, and a denial S3 returns names the principal the server signs as. In developer mode no S3 action is evaluated. |
    +| `FailureMessage` of an S3 fault | S3's message followed by `(Service: Amazon S3; Status Code: ...; Proxy: null)` for the input bucket listing, the input reads, and the manifest, and by `(Service: S3, Status Code: ...) (SDK Attempt Count: 1)` for report writes (measured) | S3 store: the same two tails with S3's own request ids, and `null` for an extended request id an S3-compatible service does not send. Filesystem store: the message without a tail, for example `The specified bucket does not exist`. |
    +| Second start on a table with a running operation | A conflicting start is rejected synchronously with `ResourceInUseException`, or accepted and later failed with `FailureCode: "ResourceInUseException"` | Always the synchronous rejection, on every attempt. A client written to handle both forms works unchanged, because a terminal `FAILED` with a `FailureCode` is already in its handling. |
    +| `TotalInputBytes` in the Start response | Omitted until the service has computed it | Present immediately; the input is enumerated before the start returns. |
    +| `MaxWriteRequestUnits`, `PerformanceTier` | Request parameters governing the operation's write throughput | `MaxWriteRequestUnits` is honoured as a ceiling: the runner paces the operation's writes at that many 1 KB write units per second, under the server's own `bulk_put_write_units_per_second` ceiling. `PerformanceTier` is validated, stored, and returned; it does not change how fast the operation runs. `ACCELERATED` is refused on a table with a vector index, as on the hosted service. |
    +| `InputFormat: ION` | In the API model | Refused with a `ValidationException` naming the value. |
    +| `OutputS3Configuration.S3SseAlgorithm`, `S3SseKmsKeyId` | Server-side encryption settings for S3 | S3 store: applied to every report object and the manifest, as `AES256`, or as `aws:kms` with the key id when one is given. Filesystem store: accepted and stored, echoed in the description, with no effect on the files. |
    +| AWS KMS keys on the input objects, the report objects and the table | Reads and writes go through the key. An input object under a key the caller may not use ends the operation `FAILED` with `S3AccessDenied`, and one under a disabled key with `KmsDisabledException`. A disabled or asymmetric report key ends it `FAILED` with `KmsDisabledException` or `KmsInvalidKeyUsageException` before any row is read. A table whose key is disabled or pending deletion refuses Start with a `ValidationException` naming the KMS fault. | S3 store: S3 decrypts the input objects and encrypts the reports and the manifest with the server's KMS permissions, and ExtendDB evaluates no `kms:` action for the caller. An input object under a key the server may not use fails with `S3AccessDenied` naming the server's principal, and one under a disabled key with `KmsDisabledException`. A disabled or asymmetric report key fails the operation at Start with `KmsDisabledException` or `KmsInvalidKeyUsageException`, with no byte members. The `FailureMessage` of a KMS fault is S3's text with S3's tail, where the service's carries the text of its own KMS call. Filesystem store: no KMS; input and report files are plain files, and none of these faults occurs. On both stores the table's encryption settings are recorded only, and no table key fault refuses Start. |
    +| Report files per kind | Numbered files (`output-00000`, `output-00001`, ...) within one partition directory per input hash partition, each closed once its compressed bytes pass 200 MiB | One partition directory; files roll to the next number at the same 200 MiB compressed threshold, and the overall record and byte caps on a report still apply. |
    +| Report objects left incomplete | Managed by the service | S3 store: a report object is a multipart upload until the reports are written at the end of the operation. A server that stops while it writes them, or an abort that fails, leaves an incomplete multipart upload in the output bucket until a lifecycle rule aborts it (see the admin guide). |
    +| Order of the input objects | Not one at a time in key order: in two measured runs the service read the second object, by key order, in full before it finished the first | One object at a time, in the byte order of the keys, so a row in a later object overwrites a row with the same key in an earlier one. |
    +| Operation limits | Service-defined quotas | Configurable `[limits]` keys (`max_bulk_input_bytes`, `max_bulk_input_files`, `max_bulk_item_count`, and the rest); the admin guide lists them. |
    +| Operation record retention | Service-managed | `bulk_operation_retention_days` (default 30). A purged operation answers `ResourceNotFoundException`; report files are not deleted. |
    +| Bulk operations on the MongoDB and Cassandra backends | n/a | Not supported: the four operations return `ValidationException`, `Bulk operations are not supported on this storage backend`. |
    +| U+0000 in an attribute name or string value | Stored | The row fails as an item-level record with `One or more parameter values were invalid: Attribute names and string values may not contain U+0000`, and the load continues. PostgreSQL's `jsonb` cannot store the character. Binary values containing zero bytes are stored. |
    +| Manifest entry of a file being read when a cancel lands | `NOT_AVAILABLE` with no range | `PARTIALLY_PROCESSED` with the range of rows the operation counted. The runner checkpoints at row boundaries, so it knows the range. |
    +
     ## Control Plane
     
     | Area | DynamoDB | ExtendDB |
    diff --git a/docs/getting-started.md b/docs/getting-started.md
    index c5e2ea3fb..8c845a089 100755
    --- a/docs/getting-started.md
    +++ b/docs/getting-started.md
    @@ -269,7 +269,7 @@ Config:           extenddb.toml
     Catalog database: extenddb_catalog
     
     --- Checking catalog migrations...
    -  OK: Current: the catalog is at migration 7
    +  OK: Current: the catalog is at migration 8
     --- Checking catalog connection...
       OK: Connected to catalog.
     --- Checking data database...
    @@ -287,7 +287,7 @@ extenddb runs as a daemon (background process) and logs to syslog. On startup it
     
     ```bash
     ./target/release/extenddb serve --config extenddb.toml
    -# extenddb 0.1.14 (catalog migration 7) starting on 127.0.0.1:18443
    +# extenddb 0.1.14 (catalog migration 8) starting on 127.0.0.1:18443
     #   storage: postgres (postgresql://extenddb:***@localhost:5432/extenddb_catalog)
     ```
     
    @@ -1367,7 +1367,7 @@ Each runner requires its tools to be installed. The runner checks prerequisites
     ```bash
     ./target/release/extenddb version
     # extenddb 0.1.14
    -# catalog migration 7 (postgres)
    +# catalog migration 8 (postgres)
     # commit abc1234
     # built 2026-04-17T12:00:00Z
     ```
    diff --git a/docs/manuals/02-design-guide.md b/docs/manuals/02-design-guide.md
    index 571e7014b..894848853 100755
    --- a/docs/manuals/02-design-guide.md
    +++ b/docs/manuals/02-design-guide.md
    @@ -41,6 +41,9 @@ On PostgreSQL, the data database connection string is stored in the catalog's `s
     | `stream_shards` | Stream shard metadata (parent shard, sequence range). |
     | `stream_records` | Stream change records with 24-hour retention. |
     | `stream_pending` | Stream records that wait for their sequence number. A write stores its record here, and the record moves to `stream_records` once the write has committed. |
    +| `bulk_operations` | One row per bulk put items operation: status, configuration, counters, the resume cursor and the lease. |
    +| `bulk_operation_input_files` | The input files of a bulk operation, one row per file, with the progress a checkpoint records for each. |
    +| `bulk_operation_report_records` | Failed-row report records a checkpoint persisted and the report renderer has not yet written out. |
     
     ### Data Tables
     
    diff --git a/docs/manuals/03-usage-guide.md b/docs/manuals/03-usage-guide.md
    index c165b0dc5..4e6fb1774 100755
    --- a/docs/manuals/03-usage-guide.md
    +++ b/docs/manuals/03-usage-guide.md
    @@ -447,6 +447,712 @@ table.
     component magnitudes, and tie ordering is deterministic here where the service's is
     measured unstable. Both are in `docs/differences-from-dynamodb.md`.
     
    +## Bulk Put Items
    +
    +extenddb implements the four bulk operation APIs: `StartBulkPutItems`,
    +`DescribeBulkOperation`, `ListBulkOperations`, and `CancelBulkOperation`. A
    +bulk put reads newline-delimited DynamoDB JSON objects from the bucket and
    +prefix its `InputS3Configuration` names and writes every item into an
    +existing table, asynchronously, with `PutItem` semantics. Writes go through
    +the same code path as `PutItem`, so secondary indexes, vector indexes, TTL
    +bookkeeping, and stream records are maintained the way they are for a client
    +write. Rows that fail are reported to objects under the bucket and prefix its
    +`OutputS3Configuration` names.
    +
    +These operations are not in the AWS CLI or SDK service models, so a
    +model-driven client cannot call them by name. Send a SigV4-signed POST whose
    +`X-Amz-Target` header names the operation
    +(`DynamoDB_20120810.StartBulkPutItems` and so on), the framing every other
    +operation uses. The examples below show the JSON request and response bodies.
    +
    +The two configurations carry the members the service defines:
    +
    +| Member | Required | Rule |
    +|---|---|---|
    +| `InputS3Configuration.S3Bucket` | yes | The input bucket: 3 to 255 characters, starting and ending with a letter or digit, holding only letters, digits, `.`, `-`, and `_`. |
    +| `InputS3Configuration.S3KeyPrefix` | no | 3 to 1024 characters. Every object whose key starts with it is an input; absent, every object in the bucket is. |
    +| `InputS3Configuration.S3BucketOwner` | no | Twelve digits: the account the input bucket must belong to. Absent, the description carries the caller's account id. |
    +| `InputS3Configuration.InputFormat` | yes | `DYNAMODB_JSON`. `ION` is in the model and is refused with `InputFormat ION is not supported by this server`. |
    +| `InputS3Configuration.InputCompressionType` | no | `NONE`, `GZIP`, or `ZSTD`. Absent is read as `NONE` and echoed as `NONE`. |
    +| `OutputS3Configuration.S3Bucket` | yes | The report bucket, under the input bucket's name rules. |
    +| `OutputS3Configuration.S3KeyPrefix` | no | At most 1024 characters, and at least 3 when present. The report keys start with it. |
    +| `OutputS3Configuration.S3BucketOwner` | no | Twelve digits: the account the report bucket must belong to. Absent, the description carries the caller's account id. |
    +| `OutputS3Configuration.OutputType` | yes | The report kinds to write: `ERROR`, `CONDITION_CHECK_FAILURE`, or both. |
    +| `OutputS3Configuration.S3SseAlgorithm` | no | `AES256` or `KMS`. |
    +| `OutputS3Configuration.S3SseKmsKeyId` | no | 1 to 2048 characters: the KMS key for `KMS`. |
    +
    +The operator chooses where those buckets resolve, with `[bulk] store` in the
    +server configuration (see the [Admin Guide](05-admin-guide.md)): AWS S3, an
    +S3-compatible service at a configured endpoint, or the filesystem store, one
    +directory tree beneath a root on the server. On the S3 store `S3Bucket` is
    +the S3 bucket of that name, read and written with the server's own AWS
    +credentials once the caller's ExtendDB policies allow the S3 actions the
    +operation performs (see Permissions). On the filesystem store `S3Bucket`
    +names a directory under the caller's account subtree of the root. The
    +request and response shapes and the report keys are the same on both
    +stores; the sections below say where the two behave differently.
    +
    +An operator must configure the `[bulk]` store before the operations accept
    +requests; without it `StartBulkPutItems` returns `Bulk operations are
    +disabled. Configure [bulk] in extenddb.toml to enable.` On the MongoDB and
    +Cassandra backends the four operations return `ValidationException` with the
    +message `Bulk operations are not supported on this storage backend`. On the
    +filesystem store `StartBulkPutItems` is available on unix targets only;
    +elsewhere it is refused with `Bulk operations are not supported on this
    +platform`. The S3 store has no such restriction. `DescribeBulkOperation`,
    +`ListBulkOperations`, and `CancelBulkOperation` answer on every target,
    +since they touch no input or report files.
    +
    +### Input files
    +
    +The input is every object in `InputS3Configuration.S3Bucket` whose key
    +starts with `S3KeyPrefix`, compared byte-wise; an absent prefix selects
    +every object in the bucket. A bucket name spelled as a dotted IPv4 literal,
    +four dot-separated groups of one to three digits such as `10.20.30.40`, is
    +refused with `Invalid Request: InputS3Configuration.S3Bucket is invalid`
    +even though it satisfies the name rules, as on the hosted service; the same
    +applies to the output bucket, reported as `OutputS3Configuration.S3Bucket`.
    +On the filesystem store neither prefix may contain a `..` component, `\`, or
    +control characters, and an output prefix must not begin with `/`; such a
    +prefix is refused with `S3KeyPrefix contains an invalid path component`. The
    +S3 store applies only the length rules and the rule every store applies to
    +U+0000 (`S3 configuration contains an invalid character`), and uses both
    +prefixes as given. With an `endpoint_url`, the S3 store also refuses a
    +prefix whose `..` segments outnumber the segments before them, such as
    +`../other/` or `a/../../other/`, with `S3KeyPrefix contains an invalid path
    +component`: an S3-compatible service resolves `..`, and such a prefix
    +would name a key above the bucket. An
    +input prefix may carry doubled separators and a leading `/` (`orders//2026/`,
    +`/orders/2026/`), as on the hosted service: it is compared with the keys as
    +given.
    +
    +The input bucket is checked when the operation starts. A bucket that does
    +not exist fails the operation with `FailureCode` `S3NoSuchBucket`. A caller
    +whose policies do not allow `s3:ListBucket` on the bucket (see Permissions),
    +or an `InputS3Configuration.S3BucketOwner` the bucket does not match, fails
    +it with `S3AccessDenied`. The `s3:ListBucket` check comes first. Such an
    +operation is created already `FAILED`, before any row is read: the Start
    +response and every later description carry `FAILED`, an `EndTime` equal to
    +`StartTime`, zero item counters, and no `TotalInputBytes` or
    +`ProcessedInputBytes`. Its `ClientToken`
    +is consumed like any other, and a retry with the same token returns the same
    +failed operation. These failed-at-Start operations are capped at
    +`max_bulk_failed_starts_per_hour` per account per hour (default 100); at or
    +above the cap, Start answers `ThrottlingException` with `Rate of requests
    +exceeds the allowed throughput.` and records nothing. A prefix that matches
    +nothing in an existing bucket is not a failure: the operation completes with
    +every counter at zero.
    +
    +Of the output configuration, Start checks the `S3Bucket` member's presence
    +and its name, and on the S3 store a `KMS` encryption key (see the error
    +report section). A report bucket that does not exist, or a mismatched output
    +`S3BucketOwner`, does not stop the operation from starting: both are checked
    +when the operation's first report record is produced.
    +
    +The object list and `TotalInputBytes` are fixed when the operation starts:
    +an object added under the prefix afterwards is not read, and neither are
    +bytes appended to an object afterwards. Objects are processed one at a time
    +in the byte order of their keys, and lines in order within an object, so a
    +later row with the same key overwrites an earlier one.
    +
    +#### On the S3 store
    +
    +`S3Bucket` is the S3 bucket of that name and `S3KeyPrefix` a key prefix.
    +Keys are used as S3 lists them: a key holding `//`, a leading `/`, `.` or
    +`..` segments, or `\` is read by that key. Every object under the prefix is
    +an input, zero-byte objects whose keys end in `/` included; they hold no
    +rows. The objects are listed at Start with ListObjectsV2, 1,000 keys per
    +page.
    +
    +The bucket check is a ListObjectsV2 call for at most one key under the
    +prefix, so S3 decides whether the bucket exists, whether it has the expected
    +owner, and whether the server may list it. S3's code and message reach the
    +description: a missing bucket is `S3NoSuchBucket` with `The specified bucket
    +does not exist` followed by S3's request ids (see Failure messages).
    +
    +An `S3BucketOwner`, on either configuration, that is absent or equal to the
    +caller's ExtendDB account id sets no expectation. Any other value is sent as
    +the `x-amz-expected-bucket-owner` header on every request for that bucket,
    +and S3 refuses a request to a bucket another AWS account owns with
    +`S3AccessDenied`, `Access Denied`. ExtendDB assigns its account ids itself,
    +and they need not match any AWS account, so name the bucket's AWS account
    +for S3 to check it.
    +
    +An object is read with ranged GETs of 8 MiB, up to the size the listing
    +gave: bytes appended after Start are not read, and an object that shrank
    +ends early. An object listed at 0 bytes is read as one byte, `bytes=0-0`,
    +and gives no rows whatever it holds by then. Every GET carries `If-Match`
    +with the ETag the listing gave at Start, on the first range and on a read
    +resumed after a restart, so a read takes bytes only from the object
    +listed. An object overwritten after Start is recorded as the service
    +records it: the object counts one processed and failed item, its error
    +record names the object with no `itemIndex` and carries `The S3 object could
    +not be imported because it was overwritten`, the run goes on with the next
    +object, and the operation ends `FAILED` with `ValidationException`, `Some of
    +the items failed validation checks and were not ingested`. The service
    +records this for an object overwritten after its listing and before its read
    +(measured), counting none of the object's bytes as `ProcessedInputBytes` and
    +marking it `PARTIALLY_PROCESSED` in the manifest with no range and the ETag
    +listed at Start. ExtendDB records an object overwritten during its read, or
    +between a stop and the resumed read, the same way, with the rows it had
    +delivered kept and their range in the manifest. `ProcessedInputBytes` keeps
    +the bytes of those rows for a replacement found during the read, and the
    +figure the last checkpoint counted for the object for one found at the
    +resumed read; the service's answer for those two cases is not measured. A
    +service other than AWS S3 may list an object without an
    +ETag. Such an object's first range is read without `If-Match` and its
    +later ranges carry the first answer's ETag, so a replacement during one
    +read is detected. A first answer that gives a size other than the listed
    +one is recorded as overwritten too, so a replacement of another size is
    +detected at the open and at a resumed read, and one of the same size
    +between a stop and the resumed read is not. A service that ignores `Range`
    +and answers with the whole object gives the size by the answer's length.
    +The manifest line of an object listed without an ETag carries the ETag of
    +the first answer the operation accepted for it, an answer at the listed
    +size. It carries an empty ETag when the operation accepted no answer for
    +the object: the object was replaced at another size before its read, its
    +open failed, or the operation ended before it. The store is not asked for
    +the ETag of the object now at the key, which can be a replacement. After a
    +restart, an object abandoned before the restart, or one whose reopen
    +accepted no answer, carries an empty ETag.
    +
    +S3 decrypts an object under SSE-KMS with the server's `kms:Decrypt`
    +permission. A key S3 cannot use fails the operation with S3's code:
    +`KmsDisabledException` for a disabled key, `S3AccessDenied` when the
    +server's credentials may not use the key.
    +
    +On AWS S3 an input or report bucket in a region other than the store's is
    +reached in the bucket's region: S3's redirect names the region, and the
    +store sends the request there and remembers the region for the bucket.
    +
    +#### On the filesystem store
    +
    +`S3Bucket` names a directory under the caller's account subtree of the bulk
    +store root, and `S3KeyPrefix` selects files inside it by their paths
    +relative to the bucket directory:
    +
    +```
    +{store root}/{account id}/{S3Bucket}/{S3KeyPrefix}...
    +```
    +
    +The server creates `{store root}/{account id}` at startup for every account,
    +when an account is created, and at Start when it is missing, so the account
    +directory is always there to stage buckets into. The bucket directory itself
    +is the caller's to create. A directory whose name breaks the bucket name
    +rules cannot be referenced.
    +
    +Every regular file whose path relative to the bucket directory starts with
    +the prefix is an input file. Symbolic links are not followed and not
    +included. Relative paths hold no doubled or leading `/`, so an input prefix
    +carrying one matches no file. An input bucket directory that does not exist,
    +or is not a directory, is `S3NoSuchBucket` with `The specified bucket does
    +not exist`. An `InputS3Configuration.S3BucketOwner` that is not the caller's
    +account id is `S3AccessDenied` with `Access Denied`, decided before
    +existence: on the filesystem store the caller's account is the only owner a
    +bucket directory can have.
    +
    +#### Input format
    +
    +Each input object, a file on the filesystem store, holds one JSON object per
    +line. `Item` is required and is a DynamoDB JSON item. `ConditionExpression`,
    +`ExpressionAttributeNames`, and `ExpressionAttributeValues` are optional and
    +carry the semantics they have on `PutItem`. Any other top-level key abandons
    +the rest of the object with `Unexpected top-level field. Expected one of
    +[Item, ConditionExpression, ExpressionAttributeNames,
    +ExpressionAttributeValues]. Remainder of the file will not be processed.`,
    +as on the hosted service; the check runs before the missing-`Item` check,
    +so a misspelled `Item` answers this message. Blank lines are skipped.
    +
    +```json
    +{"Item": {"pk": {"S": "a"}, "sk": {"S": "1"}, "n": {"N": "1"}}}
    +{"Item": {"pk": {"S": "b"}, "sk": {"S": "1"}}, "ConditionExpression": "attribute_not_exists(pk)"}
    +{"Item": {"pk": {"S": "c"}, "sk": {"S": "1"}, "v": {"N": "2"}}, "ConditionExpression": "#v < :max", "ExpressionAttributeNames": {"#v": "v"}, "ExpressionAttributeValues": {":max": {"N": "10"}}}
    +```
    +
    +With `InputCompressionType: GZIP` each object is a gzip stream and with
    +`ZSTD` each object is a zstd stream, decompressed while reading; an object
    +holding several concatenated gzip members or zstd frames is read in full.
    +An object that does not begin with the named codec's magic bytes counts one
    +failed row with `The S3 object could not be decompressed` and the
    +operation ends `FAILED`; a zstd frame that stops decoding part way counts
    +the rows of its complete blocks, then one failed row with the same
    +message, and the operation continues. A zstd frame that declares a
    +decompression window larger than 128 MiB is not read: the object counts one
    +failed row with the same message. `InputFormat` must be
    +`DYNAMODB_JSON`: `ION` returns `InputFormat ION is not supported by this
    +server`.
    +
    +### StartBulkPutItems
    +
    +Request body (`X-Amz-Target: DynamoDB_20120810.StartBulkPutItems`):
    +
    +```json
    +{
    +  "TableName": "orders",
    +  "ClientToken": "load-2026-09-10-a",
    +  "InputS3Configuration": {
    +    "S3Bucket": "loads",
    +    "S3KeyPrefix": "orders/2026-09-10/",
    +    "InputFormat": "DYNAMODB_JSON",
    +    "InputCompressionType": "GZIP"
    +  },
    +  "OutputS3Configuration": {
    +    "S3Bucket": "loads",
    +    "S3KeyPrefix": "reports/",
    +    "OutputType": ["ERROR", "CONDITION_CHECK_FAILURE"]
    +  },
    +  "MaxWriteRequestUnits": 2000,
    +  "PerformanceTier": "STANDARD"
    +}
    +```
    +
    +`TableName` accepts a bare name or a table ARN in the caller's account and
    +region. A table ARN naming another account returns `AccessDeniedException`
    +with `Access is denied`; one naming another region returns
    +`ValidationException` with `1 validation error detected: Invalid AWS region
    +in '{arn}'`. Both are answered before the table is looked up. The table
    +must exist and be `ACTIVE` or `UPDATING`. A table in `DELETING` answers
    +`ValidationException` with `Invalid Request: Table not found`, as on the
    +hosted service; a table in `CREATING` answers `ResourceInUseException`.
    +`OutputS3Configuration` is required, and so are its `OutputType`, which
    +lists the report kinds to write (`ERROR`, `CONDITION_CHECK_FAILURE`), and
    +its `S3Bucket`. An output configuration carrying `OutputType` with no
    +`S3Bucket` is refused at Start with `Invalid Request:
    +OutputS3Configuration.S3Bucket is invalid`. `S3KeyPrefix` stays optional;
    +without it the report tree is written at the bucket root. `MaxWriteRequestUnits`
    +is at least 1,024 and is a ceiling on the operation's write rate: when present, the runner paces
    +the operation's writes at that many write request units per second (1 KB of
    +item, rounded up, per unit). The server's own
    +`bulk_put_write_units_per_second` ceiling applies on top of it.
    +`PerformanceTier` is validated, stored, and returned; it does not change how
    +fast the operation runs. The description carries `TableDetails`, a one-entry
    +list of the target table's bare name and its id, on the Start response and
    +on every Describe. Two members are echoed with resolved values rather than
    +as submitted: an absent `S3BucketOwner` is echoed as the caller's account id
    +on both configurations, and an absent `InputS3Configuration.InputCompressionType`
    +is echoed as `NONE`. A retry that supplies one of those resolved values, or a
    +`PerformanceTier` of `STANDARD`, is the same request as one that omits it and
    +replays the operation. A retry that adds an `S3SseAlgorithm` of `AES256` is a
    +different request and returns `IdempotentParameterMismatchException`. `ACCELERATED` is refused with a `ValidationException`
    +on a table that has a vector index, as on the hosted service; `STANDARD` is
    +accepted on every table.
    +
    +Response:
    +
    +```json
    +{
    +  "BulkOperationDescription": {
    +    "BulkOperationArn": "arn:aws:dynamodb:us-east-1:111122223333:bulk-operation/01789060589767-da9ddd4f",
    +    "BulkOperationStatus": "IN_PROGRESS",
    +    "TableDetails": [{ "TableName": "orders", "TableId": "..." }],
    +    "ClientToken": "load-2026-09-10-a",
    +    "InputS3Configuration": { "...": "as submitted, resolved values filled in" },
    +    "OutputS3Configuration": { "...": "as submitted, resolved values filled in" },
    +    "StartTime": 1789060589.767,
    +    "ProcessedItemCount": 0,
    +    "FailedItemCount": 0,
    +    "ProcessedInputBytes": 0,
    +    "TotalInputBytes": 4096,
    +    "MaxWriteRequestUnits": 2000,
    +    "PerformanceTier": "STANDARD"
    +  }
    +}
    +```
    +
    +The status is `IN_PROGRESS`, even for empty input; poll
    +`DescribeBulkOperation` for the terminal status. The one exception is an
    +operation created already `FAILED` by a check at Start (see Input files and
    +Permissions), whose Start response carries `FAILED`. `TotalInputBytes` is
    +the sum of the enumerated object sizes (compressed sizes for `GZIP` and
    +`ZSTD`) and is present in this first response.
    +
    +### Permissions
    +
    +Besides `dynamodb:StartBulkOperation` and `dynamodb:BulkPutItems` (see the
    +[Security Model](10-security-model.md)), an operation performs three S3
    +actions as its caller, and ExtendDB evaluates each against the caller's
    +ExtendDB policies, on both stores:
    +
    +| Action | Resource | Evaluated | Denied |
    +|---|---|---|---|
    +| `s3:ListBucket` | `arn:aws:s3:::{input bucket}`, with `s3:prefix` the request's `InputS3Configuration.S3KeyPrefix`, or the empty string when it has none, and, on the S3 store with an `endpoint_url`, again with the prefix as the service resolves it when that differs | At Start, before the input bucket check | The operation is created already `FAILED` with `S3AccessDenied`: no byte members, no manifest |
    +| `s3:GetObject` | `arn:aws:s3:::{input bucket}/{key}` | Before each input object is opened, and at every checkpoint boundary (`bulk_put_batch_size` rows) while it is read | `FAILED` with `S3AccessDenied`. Rows already written stay written. The manifest marks the object `NOT_PROCESSED` when none of its rows was counted and `NOT_AVAILABLE` when some were |
    +| `s3:PutObject` | `arn:aws:s3:::{output bucket}/{key}` | At the first record of each report kind, on report object 0 of that kind; when the reports are written, on each report object; and on the manifest's key | At a kind's first record, the fault is held to the end of the input (see Error report). When the reports are written, `FAILED` with `S3AccessDenied`: that report kind is not installed, and the manifest is still written. On the manifest's key, `FAILED` with `S3AccessDenied` and the message prefixed `Manifest is not available. ` |
    +
    +A denial names the caller, the action and the resource, in S3's spelling:
    +
    +```
    +User: arn:aws:iam::111122223333:user/loader is not authorized to perform: s3:GetObject on resource: "arn:aws:s3:::loads/orders/2026-09-10/part-000.json" because no identity-based policy allows the s3:GetObject action
    +```
    +
    +A matching Deny statement gives `... with an explicit deny in an
    +identity-based policy` in place of the `because` clause, and a role session
    +is named by its `arn:aws:sts::{account}:assumed-role/{role}/{session}` ARN.
    +A denial that a session policy or a permissions boundary decides has the
    +same identity-based wording. The message carries no request-id tail, since
    +no request reached S3.
    +
    +On the filesystem store, and on the S3 store with an `endpoint_url`, a key
    +is checked as composed from the request and, when it differs, as the store
    +writes it: the filesystem store drops empty and `.` segments, and an
    +S3-compatible service can also resolve `..`. Both must be allowed. The
    +denial names the key that was denied, and the composed key is checked
    +first. A Deny on `arn:aws:s3:::{output bucket}/protected/*` therefore also
    +refuses the output prefixes `./protected` and `.//protected` on the
    +filesystem store. On the S3 store with an `endpoint_url` the input prefix
    +is checked for `s3:ListBucket` the same way. The filesystem store compares
    +the input prefix as given with the paths it finds, so `./orders/` lists
    +nothing there and is checked once. On AWS S3 the key as composed is the
    +key S3 stores, and it is checked alone.
    +
    +Start records the caller on the operation, and the runner evaluates the
    +`s3:GetObject` and `s3:PutObject` checks for the recorded caller with the
    +policies in force when each check runs, so a permission removed during a
    +load takes effect at the next check. In developer mode no S3 action is
    +evaluated. On the S3 store each check that passes is followed by the
    +server's request to S3, which S3 decides for the server's credentials; a
    +denial S3 returns carries S3's text, which names the principal the server
    +signs as, and S3's request ids.
    +
    +### DescribeBulkOperation
    +
    +Request `{"BulkOperationArn": "arn:aws:dynamodb:...:bulk-operation/..."}`.
    +The response carries the same `BulkOperationDescription` with the current
    +counters. Once the operation is terminal it also carries `EndTime` and
    +`BulkOperationUsage`, and for `FAILED`, `FailureCode` and `FailureMessage`.
    +An ARN naming another account returns `AccessDeniedException` with `User:
    +{caller arn} is not authorized to perform: dynamodb:DescribeBulkOperation
    +on resource: {arn}`, decided before any policy or stored operation is read.
    +An ARN that names no operation in the caller's account, including an ARN in
    +another region, returns `ResourceNotFoundException` with `Bulk operation
    +with arn: {arn} does not exist`. Neither answer depends on whether the
    +foreign operation exists.
    +
    +Terminal records are kept for `bulk_operation_retention_days` (default 30)
    +and then purged; a purged operation answers as not found. Reports and
    +manifests are not deleted.
    +
    +### ListBulkOperations
    +
    +Request `{"MaxResults": 25, "NextToken": "..."}`. `MaxResults` is 1 to 25;
    +absent means 10. Only the caller's account is listed, newest first. The
    +response holds one summary per operation and a `NextToken` when more exist:
    +
    +```json
    +{
    +  "BulkOperationsSummaryList": [
    +    {"BulkOperationArn": "...", "BulkOperationStatus": "COMPLETED",
    +     "StartTime": 1789060589.767, "EndTime": 1789060650.634}
    +  ],
    +  "NextToken": "..."
    +}
    +```
    +
    +### CancelBulkOperation
    +
    +Request `{"BulkOperationArn": "..."}`. Response:
    +
    +```json
    +{"BulkOperationArn": "...", "BulkOperationStatus": "CANCELLING"}
    +```
    +
    +The operation moves to `CANCELLING` and ends `CANCELLED`. The runner reads
    +the cancel at each checkpoint, and while the read of an input object waits
    +on S3 it checkpoints every third of the lease (20 seconds by default), so a
    +cancel also takes effect during such a wait and the S3 request the read
    +waited on is dropped. Items already
    +written stay written; there is no rollback. The reports are written
    +from the records accumulated up to the cancel, so a cancelled load still
    +has its error report. Cancelling an operation that is
    +already terminal returns `ValidationException` with `Invalid Request:
    +Operation already in terminal state: {STATUS}`. Cancelling an operation
    +already `CANCELLING` returns `CANCELLING` again.
    +
    +### Status machine
    +
    +```
    +IN_PROGRESS --> COMPLETED               (no failed rows)
    +IN_PROGRESS --> COMPLETED_WITH_ERRORS   (one or more failed rows)
    +IN_PROGRESS --> FAILED                  (the operation could not continue)
    +IN_PROGRESS --> CANCELLING --> CANCELLED
    +```
    +
    +`COMPLETED`, `COMPLETED_WITH_ERRORS`, `FAILED`, and `CANCELLED` are terminal.
    +Failed rows move the terminal status to `COMPLETED_WITH_ERRORS`; condition
    +failures do not, so an operation whose only problems are false conditions
    +ends `COMPLETED`. `FAILED` means the operation could not continue: the target
    +table was deleted or left the `ACTIVE`/`UPDATING` states, the consumed line
    +count passed `max_bulk_item_count`, the input or output location could not
    +be used (`S3NoSuchBucket`, `S3AccessDenied`, `S3NoSuchKey`, and the other
    +S3 and KMS codes below), or an internal error stopped it; `FailureCode` and
    +`FailureMessage` say which. A failure decided at Start creates the operation
    +already `FAILED` (see Input files). A cancel ends the operation
    +`CANCELLED`, including a cancel that lands after every row was processed.
    +
    +### Failure messages
    +
    +On the filesystem store the location failures carry fixed messages: `The
    +specified bucket does not exist`, `Access Denied`, and `The specified key
    +does not exist.` for an input file removed after Start.
    +
    +On the S3 store a failure S3 answered carries S3's message, followed by the
    +tail the hosted service renders for the call that failed. The input bucket
    +check and listing, the input reads, the Start-time encryption check, and
    +the manifest carry this form:
    +
    +```
    +The specified bucket does not exist (Service: Amazon S3; Status Code: 404; Error Code: NoSuchBucket; Request ID: {request id}; S3 Extended Request ID: {extended request id}; Proxy: null)
    +```
    +
    +Report writes, the check at the first report record included, carry this
    +one:
    +
    +```
    +Access Denied (Service: S3, Status Code: 403, Request ID: {request id}, Extended Request ID: {extended request id}) (SDK Attempt Count: 1)
    +```
    +
    +A service that sends no extended request id gives `null` in its place. The
    +`FailureCode` is `S3` followed by S3's error code (`S3NoSuchBucket`,
    +`S3AccessDenied`, `S3NoSuchKey`, `S3InvalidObjectState`), or `Kms` followed
    +by the exception name for a `KMS.` code (`KmsDisabledException`,
    +`KmsInvalidKeyUsageException`). A call that meets a 5xx or 429 answer
    +(`SlowDown` among them), `RequestTimeout`, a timeout, or a transport failure
    +is sent again for up to 120 seconds, and a call that gets no answer at all
    +takes about three minutes (see the Admin Guide, S3 store). A running
    +operation ends with `InternalServerError` when the failure lasts past that.
    +An S3 error code ExtendDB does not map, an S3 code about the server's own
    +credentials, account, or clock (`InvalidAccessKeyId`,
    +`SignatureDoesNotMatch`, `ExpiredToken`, `RequestTimeTooSkewed`, and the
    +others the Admin Guide lists), or credentials the server cannot resolve
    +end a running operation with `InternalServerError` at once. At Start each
    +of these answers the request itself with `InternalServerError`, and no
    +operation is created. S3's code and message are in the server log.
    +
    +### Counters
    +
    +| Field | Meaning |
    +|---|---|
    +| `ProcessedItemCount` | Rows read and attempted, including failed rows and condition failures. Rows after a parse-level abandon are not included. |
    +| `FailedItemCount` | Rows that failed a parse-level or item-level check. Condition failures are not counted. |
    +| `ProcessedInputBytes` | Input bytes consumed, advanced to the end of each object as it finishes or is abandoned. Compressed bytes for `GZIP` and `ZSTD`. A `CANCELLED` or `FAILED` operation reports what was read before it stopped, except one that failed on its output location, which reports 0 (see Error report). |
    +| `TotalInputBytes` | Sum of the enumerated input object sizes, fixed at start. |
    +| `BulkOperationUsage` | Present once terminal: one entry for the table. The entry's `TableName` is the bare table name. `Table.BulkPutBytes` is the sum, over rows that were written, of the item's logical size in bytes with a 1,024 byte floor and no rounding above it; a row that replaced an item is charged the larger of the replaced item's size and its own. A row whose condition evaluated false is charged the 1,024 byte floor alone; rows that failed a parse-level or item-level check are not charged. Each secondary index entry is the same sum over the bytes its projection stores, and an index no row reached has no entry. A row that replaced an item and left an index's projection unchanged under the same index key charges that index nothing; one that changed the projection under the same key charges the larger of the old and new projections; one that moved the index key charges the old and the new projection. The top-level `BulkPutBytes` is the table figure plus every secondary index figure. Vector index entries carry `BulkPutVectorBytes` and are not included in the top-level figure. |
    +
    +### Error report
    +
    +Reports are written once the operation ends, under these keys in the output
    +bucket:
    +
    +```
    +{S3KeyPrefix}/AWSDynamoDB/BulkOperation/{account}:{operation id}-0/output/errors/output-00000.json.gz
    +{S3KeyPrefix}/AWSDynamoDB/BulkOperation/{account}:{operation id}-0/output/condition-check-failures/output-00000.json.gz
    +```
    +
    +The keys are the same on both stores. One `/` separates the prefix from
    +`AWSDynamoDB/` unless the prefix is empty or already ends with `/`; with no
    +prefix the keys start at `AWSDynamoDB/`. On the S3 store they are object
    +keys in the output bucket, written as composed, doubled or `.` segments of
    +the prefix included. On the filesystem store they are paths beneath
    +`{store root}/{account}/{S3Bucket}/`: the report bucket directory is the
    +caller's to create, the tree beneath it is created as needed, and empty and
    +`.` components of the prefix are dropped.
    +
    +The output configuration is checked as report records are produced. At the
    +operation's first report record the bucket and its owner are checked against
    +report object 0 of that record's kind, and the caller's `s3:PutObject` is
    +checked at the first record of each kind, on report object 0 of that kind. A
    +report bucket that does not exist fails the operation with `FailureCode`
    +`S3NoSuchBucket`. An output `S3BucketOwner` the bucket does not match, or a
    +caller whose policies do not allow `s3:PutObject` on the report object (see
    +Permissions), fails it with `S3AccessDenied`. On the S3 store the check
    +starts a multipart upload on that key, with the expected owner and the
    +encryption settings, and aborts it at once, so S3 reports a missing bucket,
    +an owner mismatch, or a write the server's credentials may not make with
    +S3's code and message, and nothing is written.
    +On the filesystem store a missing directory is `The specified bucket does
    +not exist` and an owner other than the caller's account is `Access Denied`.
    +
    +The fault is held: the operation reads on to the end of its input and
    +writes the rows that pass their checks, then ends `FAILED` with the fault,
    +as the hosted service does. `ProcessedItemCount` is then the processed rows
    +less the failed ones, `FailedItemCount` 0, `ProcessedInputBytes` 0, and
    +`BulkOperationUsage` zero, and neither a report nor a manifest is written.
    +The held fault is saved with each checkpoint, so an operation that another
    +process resumes after a restart ends as it would have without the restart.
    +A cancel, or another failure met before the end of the input, ends the
    +operation instead, with the counters as they stand. An operation that
    +produces no report record is checked when the manifest is written instead,
    +and fails with the same codes and the message prefixed
    +`Manifest is not available. `. A manifest write that the store refuses after
    +that check passed, because the bucket was removed or its policy changed in
    +between, fails with the same prefix on its message.
    +
    +On the S3 store an output configuration with `S3SseAlgorithm` `KMS` is also
    +checked at Start: the same write check runs on error report object 0 with
    +the key. A key S3 cannot use creates the operation already `FAILED` with
    +S3's KMS code, `KmsDisabledException` for a disabled key and
    +`KmsInvalidKeyUsageException` for a key of the wrong usage, and no byte
    +members. A bucket or owner fault that check meets is left to the first
    +report record. So is an S3 `AccessDenied` for the key, which S3 returns
    +when the server's credentials may not use it, with a message that names
    +the `kms:` action. The operation then fails with `S3AccessDenied` at its
    +first report record, held to the end of the input as other faults found
    +there are, or at the manifest when it produces no report record.
    +
    +On the S3 store every report object and the manifest are written with the
    +requested encryption: `AES256` sends `x-amz-server-side-encryption:
    +AES256`, and `KMS` sends `aws:kms` with `S3SseKmsKeyId` when one is given.
    +On the filesystem store both settings are recorded and returned and have no
    +effect on the files.
    +
    +An object is written only when it has at least one record. Each object is
    +gzip-compressed newline-delimited JSON, one record per row. Once the
    +compressed bytes of an object reach 200 MiB the writer closes it and the next
    +records go to `output-00001.json.gz`, then `output-00002.json.gz`, and so on
    +under the same prefix, as the hosted service does; the object that passed the
    +threshold keeps the records that took it there, so it closes a little above
    +200 MiB. Read every `output-NNNNN.json.gz` under the prefix to get the whole
    +report. On the S3 store an object whose compressed bytes reach 8 MiB is sent
    +as a multipart upload in 8 MiB parts and completed when the reports are
    +written; a smaller one is sent as a single upload at that point. No report
    +object is visible before then.
    +
    +```json
    +{"itemLocation": {"inputSourceArn": "arn:aws:s3:::loads/orders/2026-09-10/part-000.json", "itemIndex": 2},
    + "errorMessages": ["One or more parameter values were invalid: Missing the key sk in the item"],
    + "newImage": {"Item": {"pk": {"S": "a"}, "n": {"N": "3"}}}}
    +```
    +
    +`itemLocation.inputSourceArn` names the input object as an S3 object ARN,
    +`arn:aws:s3:::{bucket}/{key}`, on both stores (the key is the
    +bucket-relative path on the filesystem store), so tooling written for the
    +DynamoDB report format parses it unchanged. `itemLocation.itemIndex` is the
    +zero-based line index within the object; it is absent on the record for a
    +parse-level abandon.
    +`errorMessages` holds one message. `newImage` is the submitted item, wrapped
    +in `{"Item": ...}`; it is present on missing-key records whose submitted
    +item is within the item size limit, and on condition-failure records.
    +`oldImage` appears only on condition-failure records and holds the stored
    +item the condition was evaluated against.
    +
    +A record goes to its own kind's prefix when that kind was requested in
    +`OutputType`, and otherwise to the one kind that was requested. With only
    +`ERROR` requested, condition-failure records go to `errors/`; with only
    +`CONDITION_CHECK_FAILURE` requested, parse-level and item-level error
    +records go to `condition-check-failures/`; with both requested, each kind
    +has its own prefix.
    +
    +The records kept per operation are capped by `max_bulk_report_records` and
    +their serialized size by `max_bulk_report_bytes`; records beyond either cap
    +are dropped and the drop is logged. The counters are unaffected.
    +
    +After the reports, every operation that read input writes a manifest to
    +`{S3KeyPrefix}/AWSDynamoDB/BulkOperation/{account}:{operation id}/manifest-files.json`,
    +with the reports' encryption: one line per input object, carrying its
    +`inputSourceArn`, its `fileProcessingStatus` (`FULLY_PROCESSED`,
    +`PARTIALLY_PROCESSED`, `NOT_PROCESSED`, or `NOT_AVAILABLE`), its
    +`processedRanges`, and its `etag`. On the S3 store the `etag` is S3's ETag,
    +without quotes: of the version read for an object read to its end, and the
    +one the listing gave otherwise. On the filesystem store it is the MD5 of the
    +enumerated bytes. A file that is refused or deleted before its open cannot
    +be hashed, and its line has an empty `etag`.
    +
    +### Row errors
    +
    +Three classes of row problem behave differently.
    +
    +A parse-level error abandons the rest of the file: a line that is not
    +well-formed JSON, a missing or non-object `Item`, an attribute value whose
    +type tag is not one of the ten DynamoDB types, a line longer than
    +`max_bulk_line_bytes`, or a condition field of the wrong JSON type. The
    +offending row is counted as processed and failed, the remaining rows of
    +that file are not read and appear in no counter, and processing continues
    +with the next file. `ProcessedInputBytes` advances to the end of the
    +abandoned file. The report record carries `Expected 'Item' top level
    +container. Remainder of the file will not be processed.` for a missing
    +`Item`, `Expected character {. Remainder of the file will not be
    +processed.` for an `Item` that is not a JSON object, and `Expected
    +DynamoDB type to be one of [S, N, B, BOOL, NULL, L, M, SS, NS, BS].
    +Remainder of the file will not be processed.` for every other cause.
    +
    +An item-level error fails the row alone: a missing or wrongly typed key
    +attribute, an item over the size limit, a condition expression that does not
    +parse, expression maps supplied without a `ConditionExpression`, or any
    +other item check `PutItem` applies. The row is counted as processed and
    +failed, a record carrying the message `PutItem` returns for the same fault
    +goes to the error report, and the next row is read.
    +
    +A `ConditionExpression` that evaluates false leaves the item unwritten. The
    +row is counted as processed and is not counted as failed. A record with the
    +message `The conditional request failed`, the submitted `newImage`, and the
    +stored `oldImage` is written to the condition report.
    +
    +### One operation per table
    +
    +At most one operation per table is non-terminal at a time. A second
    +`StartBulkPutItems` on that table returns `ResourceInUseException` with
    +`Table has ongoing bulk operations, please wait and try again`, and nothing
    +is created, whatever its input: a Start whose input bucket does not exist
    +or whose prefix matches no object gets the same answer, as on the hosted
    +service. Starting
    +again after the first operation is terminal succeeds. A Start on a table
    +that is being deleted answers `ValidationException` `Invalid Request: Table
    +not found`, and so does a Start whose table a `DeleteTable` removed while
    +the Start was being handled. A Start and an
    +`UpdateTable` that creates an index on the same table are never both
    +accepted: each side checks the other inside the transaction that acts, so
    +when the two arrive together one of them answers `ResourceInUseException`
    +with its usual message. An account may have at
    +most `max_concurrent_bulk_operations` non-terminal operations (default 50);
    +above that, `StartBulkPutItems` returns `LimitExceededException` with
    +`Subscriber limit exceededService quota exceeded You have reached the
    +maximum number of concurrent bulk operations for the selected
    +PerformanceTier, which is {limit}. Wait until other jobs finish, and try
    +again. You can also reach out to AWS Support to request a quota
    +increase.`, the configured limit substituted. The text matches the
    +DynamoDB message, missing separators included.
    +
    +### Idempotency
    +
    +`ClientToken` makes a start retryable. The token is any non-empty string
    +without `$`, at most `max_bulk_client_token_bytes` bytes. A repeated
    +request with the same token and the same parameters returns the existing
    +operation's current
    +description with HTTP 200 and creates nothing. The same token with different
    +parameters returns `IdempotentParameterMismatchException` with `Specified
    +idempotent token was already used with different request parameters within
    +the idempotency window`. A token replays its operation for eight hours from
    +the operation's start. After that, the same request starts a new operation.
    +The token is looked up before any call to the store, so a replay or a
    +mismatch gets the same answer while the store is unreachable, after the
    +input prefix has grown past `max_bulk_input_bytes`, and while the account is
    +at `max_bulk_failed_starts_per_hour`.
    +
    +### Configuration
    +
    +Inputs are read and reports are written through the one store `[bulk] store`
    +selects, independent of the `[import]` and `[export]` sections that serve
    +`ImportTable` and `ExportTableToPointInTime`: `s3` for AWS S3 or an
    +S3-compatible service (`[bulk.s3]`), or `filesystem` for one directory tree
    +beneath `[bulk.filesystem] path`. The [Admin Guide](05-admin-guide.md)
    +describes both. An `http` `[bulk.s3] endpoint_url` must name a loopback host
    +(`127.x.y.z`, `::1` or `localhost`); any other `http` endpoint refuses
    +startup unless `[bulk.s3] allow_insecure_endpoint = true`. In dev mode with
    +no `[bulk]` section the filesystem store is enabled at `./.extenddb/buckets`.
    +The
    +`[limits]` keys governing bulk operations
    +(`max_concurrent_bulk_operations`, `max_bulk_input_bytes`,
    +`max_bulk_input_files`, `max_bulk_input_object_bytes`, `max_bulk_client_token_bytes`,
    +`max_bulk_line_bytes`, `max_bulk_item_count`, `max_bulk_report_records`,
    +`max_bulk_report_bytes`, `bulk_put_batch_size`, `bulk_put_write_batch_rows`,
    +`bulk_operation_lease_seconds`, `bulk_operation_worker_concurrency`,
    +`bulk_operation_worker_per_account`, `bulk_put_write_units_per_second`,
    +`bulk_operation_retention_days`) are
    +listed in the [Admin Guide](05-admin-guide.md) and in
    +`extenddb.sample.toml`. These limits are configuration of this server, not
    +properties of the DynamoDB API; the values in force are whatever the
    +deployment sets.
    +
     ## Other Operations
     
     ### DescribeEndpoints
    @@ -568,6 +1274,7 @@ extenddb reproduces DynamoDB error responses exactly. Common errors:
     | SigV4 Authentication | ✓ |
     | IAM Policy Evaluation | ✓ |
     | Legacy API (Expected, AttributeUpdates, AttributesToGet) | ✓ |
    +| Bulk operations (StartBulkPutItems, DescribeBulkOperation, ListBulkOperations, CancelBulkOperation) | ✓ (on an S3 or filesystem store; not on the MongoDB or Cassandra backends) |
     | PartiQL | ✗ (not planned) |
     | DAX | ✗ (not applicable) |
     | Global Tables | ✗ (future) |
    diff --git a/docs/manuals/04-quickstart-setup-guide.md b/docs/manuals/04-quickstart-setup-guide.md
    index 70b3e95e3..c97704494 100755
    --- a/docs/manuals/04-quickstart-setup-guide.md
    +++ b/docs/manuals/04-quickstart-setup-guide.md
    @@ -130,7 +130,7 @@ Check the version:
     ```bash
     ./target/release/extenddb version
     # extenddb 0.1.14
    -# catalog migration 7 (postgres)
    +# catalog migration 8 (postgres)
     # commit abc1234
     # built 2026-04-17T12:00:00Z
     ```
    @@ -172,7 +172,7 @@ Config:           extenddb.toml
     Catalog database: extenddb_catalog
     
     --- Checking catalog migrations...
    -  OK: Current: the catalog is at migration 7
    +  OK: Current: the catalog is at migration 8
     --- Checking catalog connection...
       OK: Connected to catalog.
     --- Checking data database...
    diff --git a/docs/manuals/05-admin-guide.md b/docs/manuals/05-admin-guide.md
    index aa2b9b10b..82b55f6d6 100755
    --- a/docs/manuals/05-admin-guide.md
    +++ b/docs/manuals/05-admin-guide.md
    @@ -19,7 +19,14 @@ extenddb always runs as a daemon. On startup it:
     5. Connects to PostgreSQL (catalog + data databases)
     6. Verifies catalog version matches the binary
     7. Starts the HTTP server
    -8. Spawns background tasks (log level polling, stream cleanup, TTL expiry)
    +8. Spawns background tasks (log level polling, stream cleanup, TTL expiry, bulk operation workers)
    +
    +The daemon keeps the directory `serve` was started in as its working
    +directory, so a relative path in the configuration or in the environment
    +resolves against that directory, as it does with `--foreground`.
    +`extenddb stop` and `extenddb status` resolve a relative `run_dir` against
    +the directory they run in, where they look for the PID file; give `run_dir`
    +as an absolute path, or run them from the directory `serve` was started in.
     
     ### Checking Status
     
    @@ -42,6 +49,14 @@ kill 
     
     extenddb handles SIGTERM and SIGINT gracefully — it drains active connections for up to 5 seconds before exiting.
     
    +A bulk operation running at that moment writes the rows it has staged,
    +checkpoints, and releases its lease, so the next process to claim it resumes
    +it at once. The server waits 1 second for its runs to do this. A run that
    +has not stopped by then, such as one inside a database or bulk store call
    +or one held by the write pacer, keeps its lease until the lease runs out,
    +and the next claim then resumes it from its last checkpoint. The rows it
    +committed are recorded, so the resume does not write them again.
    +
     Note that `extenddb stop` reads the PID file, which a server started with
     `serve --foreground` does not write by default. Stop a foreground server through
     whatever supervises it (container runtime, systemd, your shell) or send it
    @@ -108,6 +123,8 @@ These settings require a server restart to take effect.
     
     Backup and restore jobs are bounded per server process by `[backup] max_concurrent_jobs` (default 4, below), not per account; a request that finds every slot held waits up to 30 s for one and is then refused with `LimitExceededException`. The server raises `pool_size` and `catalog_pool_size` to at least `2 * max_concurrent_jobs + 10` and logs the raise with both numbers, so every job the bound admits has a connection. Each running restore holds one connection from this pool for its whole copy, plus one dedicated connection outside the pool for its claim.
     
    +A `StartBulkPutItems` on PostgreSQL waits at most 200 ms at a time for its account's lock and does not wait for its table's row: when another transaction holds the row, the start returns its connection to this pool and tries again. `UpdateTable`, `DeleteTable`, `UpdateTimeToLive`, the TTL index build and the size refresh every 300 seconds each hold the row for a few statements. Starts that wait behind such a request therefore do not hold every connection it needs, do not delay the account's starts on other tables, and do not keep the request waiting. A start that has not got both locks after 30 seconds answers `ServiceUnavailable`, which SDKs retry.
    +
     `RestoreTableFromBackup` on PostgreSQL holds a session-level advisory lock (the job's claim) for the whole copy, so ExtendDB must reach the catalog database directly or through a session-mode pooler. Behind a transaction-mode pooler (for example PgBouncer with `pool_mode = transaction`) the lock lands on a pooled server connection and is not released with the job, so later jobs can be refused as already claimed; do not run backups or restores through one.
     
     #### [storage.sqlite]
    @@ -171,6 +188,496 @@ Statistics are exposed at `/management/auth-cache-metrics` (JSON, admin-authenti
     
     All defaults match real DynamoDB limits. Override only for testing edge cases.
     
    +The bulk operation keys below are the exception: they are extenddb's own,
    +DynamoDB exposes no equivalent, and they govern `StartBulkPutItems` and the
    +workers that drive it.
    +
    +| Key | Default | Description |
    +|-----|---------|-------------|
    +| `max_concurrent_bulk_operations` | `50` | Non-terminal bulk operations one account may have. `StartBulkPutItems` rejects above it. |
    +| `max_bulk_input_bytes` | `10737418240` | Enumerated (compressed) input bytes per operation. `StartBulkPutItems` rejects above it. |
    +| `max_bulk_input_files` | `50000` | Enumerated input object count per operation. Above it the Start is accepted and the operation ends `FAILED` with `S3ObjectsCountLimitExceeded`, as the hosted service does at its 50,000 quota. The enumeration stores up to one object past the cap, one catalog row per object, and a checkpoint updates only the rows of the files it passes, so a lower value bounds the rows one operation holds. On the filesystem store the walk examines only the entries whose path can hold a key under the prefix, and when it examines more than four times this value of them, `StartBulkPutItems` answers `ValidationException` `Input exceeds max_bulk_input_files ({value} files)` and creates no operation. |
    +| `max_bulk_input_object_bytes` | `5368709120` | Size of one enumerated input object. Above it the Start is accepted and the operation ends `FAILED` with `InputObjectTooLarge`, as the hosted service does at 5 GiB. |
    +| `max_bulk_client_token_bytes` | `1024` | Longest accepted `ClientToken` in bytes. The API model puts no length cap on the token; this bounds what one request can store in the catalog. |
    +| `max_bulk_failed_starts_per_hour` | `100` | Terminal-born starts per account per hour. At or above it `StartBulkPutItems` answers `ThrottlingException`, except on a table with an operation running, which answers `ResourceInUseException`. Bounds catalog growth from requests naming absent buckets. |
    +| `max_bulk_line_bytes` | `4194304` | Longest accepted input line in bytes. A longer line abandons the rest of its file. |
    +| `max_bulk_item_count` | `100000000` | Physical lines one operation may consume, blank lines included. Exceeding it fails the operation. |
    +| `max_bulk_report_records` | `1000000` | Report records kept per operation. Records beyond it are dropped and the drop is logged. On SQLite the records of an operation that has ended are deleted after its terminal transition, 2,000 per statement, so other writes wait for one statement at a time. |
    +| `max_bulk_report_bytes` | `1073741824` | Serialized bytes of report records kept per operation. Records beyond it are dropped and the drop is logged. |
    +| `bulk_put_batch_size` | `25` | Rows per checkpoint. The runner persists its cursor, counters, and report records after this many consumed lines. Floored at 1 and capped at 10,000; a value outside that range is adjusted at startup and the server logs the adjustment naming the key. |
    +| `bulk_put_write_batch_rows` | `25` | Rows per storage transaction. The runner hands this many admitted rows to storage in one batch, so one commit covers them instead of one per row. The batch also flushes at every checkpoint, so this value is bounded by `bulk_put_batch_size`: raising it alone above the checkpoint size has no effect (the server logs the clamp at startup); raise both to widen the transaction. Capped at 100. Measured on PostgreSQL, a 100-row transaction runs about 30 to 40 percent faster than a 25-row one on plain input and about 28 percent faster on an indexed table, and it holds its row locks about four times as long: online `PutItem` to a key inside an in-flight batch waits for the batch's commit, and its p99.9 roughly doubled from 25 to 100 in measurement. On SQLite the batch holds the process write lock for its whole transaction, so every online write waits for it whatever its key; measured, the default of 25 raised online write p99 from about 1 ms to about 15 ms while a bulk load ran, and the default write pacer (80,000 units per second) is above SQLite's write capacity so it does not hold the load back. A SQLite deployment serving latency-sensitive online writes during bulk loads should lower this key (1 to 10) or `bulk_put_write_units_per_second`. |
    +| `bulk_operation_lease_seconds` | `60` | Lease a runner holds on its operation, renewed at every checkpoint, and every third of the lease while an input object's open or read, the output check at the first report record, a report write or the manifest write waits. After a crash, another process claims the operation once the lease expires and resumes it from the last checkpoint, and no row is written twice. A resume that finds the checkpoint where the previous claim found it logs a WARN line; the tenth in a row ends the operation without reading input and logs an ERROR line naming the outcome: `FAILED` with `InternalServerError`, `FAILED` with the deleted-table failure when the table is gone, or `CANCELLED` when a cancel is pending. On PostgreSQL the lease is read and written on the database server's clock, so a difference between the clocks of ExtendDB processes does not shorten it, and the database ends a bulk operation transaction or bulk write batch left idle for 10 seconds, so a stopped process releases the operation row, and the row that names the claim allowed to write, within that time. Floored at 1. |
    +| `bulk_operation_worker_concurrency` | `4` | Bulk operations one process runs at once. Floored at 1. Each running operation that reads a zstd input can hold up to 128 MiB of decoder memory, the largest decompression window a frame may declare and still be read, so the worst case is this value times 128 MiB above the process's baseline. |
    +| `bulk_operation_worker_per_account` | `2` | Of the concurrent runs, at most this many per account, so one account cannot hold every slot. Floored at 1. |
    +| `bulk_put_write_units_per_second` | `80000` | Ceiling on bulk writes in one process, in write request units per second (1 KB of item, rounded up, per unit). Shared by every operation the process runs; bulk loads are held to this rate so request traffic on the same server keeps the rest of the storage write path. The default equals `per_account_max_wcu`. Lower it on a server that also serves latency-sensitive traffic; `0` disables the ceiling. A request's own `MaxWriteRequestUnits` paces that operation on top of it. |
    +| `bulk_operation_retention_days` | `30` | Days a terminal operation record is kept before the retention worker purges it. Floored at 1 with a log line naming the key. A purge pass runs at startup and daily after that. The record and its ledger rows are removed; report files are not, see the reclaim note below. |
    +
    +#### [import] and [export]
    +
    +| Key | Default | Description |
    +|-----|---------|-------------|
    +| `paths` | (empty) | Directories files may be read from (`[import]`) or written to (`[export]`) |
    +
    +Both sections are empty by default, which disables the operations that use
    +them: `ImportTable` and `ExportTableToPointInTime`. Files are namespaced per
    +account beneath each root (`//...`), every path is
    +canonicalized and must resolve under a configured root, and symbolic links
    +that escape a root are rejected.
    +
    +#### AWS credentials for S3
    +
    +Every S3 store the server opens, such as the bulk store with
    +`[bulk] store = "s3"`, signs its requests with the server's own AWS
    +credentials, whoever the caller is. Each store's own section lists the
    +permissions it needs and what its callers see when S3 refuses a request.
    +
    +The server reads the credentials from the standard credential chain of the
    +AWS SDK for Rust, in this order: the `AWS_ACCESS_KEY_ID`,
    +`AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN` environment variables; the
    +shared config and credentials files (`AWS_PROFILE`, `AWS_CONFIG_FILE`,
    +`AWS_SHARED_CREDENTIALS_FILE`), including profiles that assume a role, sign
    +in through IAM Identity Center, use a login session from `aws login`, or run
    +a `credential_process`; web identity (`AWS_WEB_IDENTITY_TOKEN_FILE` with
    +`AWS_ROLE_ARN`); container credentials
    +(`AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` or
    +`AWS_CONTAINER_CREDENTIALS_FULL_URI`); and the EC2 instance metadata
    +service, which `AWS_EC2_METADATA_DISABLED=true` turns off. The first source
    +that holds credentials is used. The configuration file holds no
    +credentials: an `access_key_id`, `secret_access_key`, or `session_token` key
    +in an S3 section such as `[bulk.s3]`, or the same key with an `aws_` prefix,
    +refuses startup with a message naming the credential chain, even when that
    +section's store is not selected.
    +
    +A store signs for the `region` of its section, or `[server] region` when
    +that is unset; `AWS_REGION` and a profile's region do not change it. It
    +sends its requests to the `endpoint_url` of its section, or to AWS S3 for
    +the region when that is unset. An endpoint set in `AWS_ENDPOINT_URL`,
    +`AWS_ENDPOINT_URL_S3`, or a profile is ignored, so the endpoint the clients
    +use for ExtendDB never redirects a store.
    +
    +At startup the server resolves the credentials once, waiting up to five
    +seconds, and logs the outcome: `S3 bulk store credentials resolved`, or a
    +warning that they cannot be resolved or were not resolved within five
    +seconds. The server starts either way, and the store's requests fail until
    +the credentials resolve. Keys in the environment variables or the shared
    +files resolve without a request to AWS, so resolving does not show whether
    +AWS accepts them; a role, web identity, IAM Identity Center, `aws login`, and
    +container or instance credentials resolve through a request. On AWS S3,
    +with no `endpoint_url`, the server then asks AWS STS whose credentials they
    +are, waiting up to five seconds without holding up startup, and logs one of
    +these lines:
    +
    +- `S3 bulk store signs as {arn} (AWS account {account})`
    +- `AWS refused the S3 bulk store credentials ({code}): {message}. Its S3 requests fail until the server restarts with credentials AWS accepts`,
    +  with `InvalidClientTokenId` for an access key ID AWS does not know or a
    +  session token altered or left out, `SignatureDoesNotMatch` for a wrong
    +  secret key, or `ExpiredToken` for a session token past its expiry
    +- `S3 bulk store credentials could not be checked with AWS STS: the server clock is more than 15 minutes off ({code}): {message}`,
    +  when STS refuses the time the request was signed at
    +- `S3 bulk store credentials could not be checked with AWS STS: {cause}`,
    +  when STS gives no answer within five seconds, as on a network with no path
    +  to STS, or answers with any other code, such as a server error or a
    +  throttling error; the store then works as it would without the check
    +
    +The STS call, GetCallerIdentity, needs no IAM permission. Against a
    +configured `endpoint_url` the server makes no STS call. The call goes to
    +STS's regional hostname for the store's region, or to the endpoint named in
    +`AWS_ENDPOINT_URL_STS` or in a profile's `services` entry for `sts`, such as
    +an STS interface endpoint. The global endpoint, `AWS_ENDPOINT_URL` or a
    +profile's `endpoint_url`, is never used, and an STS endpoint equal to it is
    +ignored. With `AWS_ENDPOINT_URL` set, the SDK reads it before a profile's
    +`services` section, so the profile's `sts` entry has no effect.
    +
    +The AWS SDK reads the shared config and credentials files once, at startup,
    +so a file created or changed after that is read at the next restart. Until
    +the credentials resolve, the environment, web identity, container, and
    +instance metadata providers are asked again for each request that needs
    +credentials. Once they resolve, the SDK keeps them until they expire, or for
    +15 minutes when they carry no expiry.
    +
    +The store signs with the first credentials the chain finds, so the server
    +must start without the ExtendDB access key the clients sign with. The chain
    +asks the environment variables first: an ExtendDB key exported as
    +`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`, as the Getting Started guide
    +exports it for the AWS CLI, reaches the store even when `AWS_PROFILE` names a
    +profile with the server's AWS credentials. With no key in the environment, an
    +`AWS_PROFILE` that selects the clients' profile gives the store the key that
    +profile holds. S3 answers `InvalidAccessKeyId` for a key ExtendDB created, so
    +the store's requests fail. A key imported with
    +`extenddb manage import-access-key` that is active in AWS is accepted by S3,
    +and the store then reads and writes as the AWS identity the key belongs to.
    +In both cases the server logs at startup
    +`The S3 bulk store credentials are an access key this server also accepts from its clients. S3 refuses a key ExtendDB created, and accepts an imported AWS key as that key's identity. Start the server without the clients' AWS variables or profile`.
    +To start the server from such a shell, remove the clients' variables from
    +the server's environment. With the server's credentials in a profile:
    +
    +```bash
    +env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \
    +    AWS_PROFILE={server profile} ./target/release/extenddb serve --config extenddb.toml
    +```
    +
    +When the server's credentials come from web identity, a container, or the
    +instance, remove `AWS_PROFILE` as well. The chain reads the `default` profile
    +before those sources, so keys in that profile are used first:
    +
    +```bash
    +env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \
    +    -u AWS_PROFILE ./target/release/extenddb serve --config extenddb.toml
    +```
    +
    +A relative path in `AWS_CONFIG_FILE`, `AWS_SHARED_CREDENTIALS_FILE`, or
    +`AWS_WEB_IDENTITY_TOKEN_FILE` resolves against the directory `serve` was
    +started in, with or without `--foreground`.
    +
    +The SDK renews credentials before they expire, for as long as their source
    +stays valid, when they come from web identity, from container or instance
    +credentials, or from a profile that assumes a role, signs in through IAM
    +Identity Center or `aws login`, or runs a `credential_process`. It does not
    +renew keys given in the environment variables or written into the shared
    +config or credentials files, such as temporary keys copied from the AWS
    +access portal or written by a credential tool. Once those expire, S3 answers
    +`ExpiredToken` and the store's requests fail until the server restarts with
    +new keys. Credentials that carry a session token and no expiry are such
    +keys, and the server warns about them at startup:
    +`The S3 bulk store credentials are temporary keys the AWS SDK cannot renew. When they expire, its S3 requests fail until the server restarts with new keys. Give the server a role, web identity, IAM Identity Center or a credential_process profile instead`.
    +Keys a `credential_process` prints without an expiry get no warning: the SDK
    +keeps them for 15 minutes and then runs the process again.
    +
    +The AWS SDK's credential loader logs under the target `aws_config`, whose
    +level the server sets to `warn` whatever `log_level` is: at `info` the
    +loader writes the access key ID on each credential load, and for a profile
    +that assumes a role, the role ARN and the session name. Its warnings, such
    +as a credential load that failed, are still logged.
    +
    +#### [bulk]
    +
    +The bulk put operations read their input and write their error reports
    +through one store, independent of `[import]` and `[export]`: the S3 store,
    +which reads and writes the buckets the requests name in AWS S3 or an
    +S3-compatible service, or the filesystem store, one directory tree beneath a
    +root on the server.
    +
    +| Key | Default | Description | Environment variable |
    +|-----|---------|-------------|----------------------|
    +| `store` | `filesystem` | `filesystem` or `s3`, in any letter case. | `EXTENDDB__BULK__STORE` |
    +| `filesystem.path` | (none) | The filesystem store's root. Required when `store = "filesystem"`. | `EXTENDDB__BULK__FILESYSTEM__PATH` |
    +| `s3.region` | `[server] region` | The region the S3 store signs for, and the first region a bucket is tried in. One host label: lowercase letters, digits, and hyphens, beginning and ending with a letter or a digit. | `EXTENDDB__BULK__S3__REGION` |
    +| `s3.endpoint_url` | AWS S3 for the region | An S3-compatible service: an absolute `http` or `https` URL with a host, an optional port, and an optional path. A URL with user information, a query, or a fragment refuses startup. So does an `http` URL whose host is not loopback, unless `s3.allow_insecure_endpoint` is `true`. | `EXTENDDB__BULK__S3__ENDPOINT_URL` |
    +| `s3.allow_insecure_endpoint` | `false` | Accept an `http` `endpoint_url` whose host is not loopback; the server then logs a warning at startup. | `EXTENDDB__BULK__S3__ALLOW_INSECURE_ENDPOINT` (`true` or `false`) |
    +| `s3.force_path_style` | `false` | Address buckets in the URL path instead of the host name. | `EXTENDDB__BULK__S3__FORCE_PATH_STYLE` (`true` or `false`) |
    +
    +`store = "s3"` with no `[bulk.s3]` section takes every default. A
    +`[bulk.s3]` section is checked whatever `store` selects, so a credential key
    +or an `endpoint_url` the checks refuse stops startup beside the filesystem
    +store too, before a later `store = "s3"` would read it. Otherwise the
    +section of the store not selected is ignored, with one line in the log, so
    +`EXTENDDB__BULK__STORE` switches stores without an edit to the file. An
    +unknown key in either section refuses startup. `store` defaults to
    +`filesystem`, so a `[bulk.s3]` section with no `store` line and no
    +`[bulk.filesystem]` section refuses startup with a message that names both
    +fixes: set `store = "s3"` in `[bulk]`, or add `[bulk.filesystem]`.
    +
    +With no `[bulk]` section the feature is disabled: `StartBulkPutItems`
    +returns `Bulk operations are disabled. Configure [bulk] in extenddb.toml to
    +enable.` and the bulk operation worker idles. An idle worker checks for an
    +operation to claim with one read and takes no write transaction (on SQLite,
    +not the write lock), with or without a `[bulk]` section. In dev mode with no `[bulk]`
    +section, the filesystem store is enabled at `./.extenddb/buckets`, relative
    +to the directory `serve` was started in; the dev-mode banner and the startup log
    +name the path. An explicit `[bulk]` section is honoured in dev mode,
    +`store = "s3"` included, and the banner then names the S3 store. The banner
    +prints after the `[bulk.s3]` checks pass; a section they refuse stops
    +startup before the banner, with a message that does not repeat the value.
    +
    +An operation is read and written only through the store it started on.
    +After `store` changes with operations in flight, a worker that claims an
    +operation started on the other store ends it without reading it: `FAILED`
    +with `InternalServerError` when it was `IN_PROGRESS`, and `CANCELLED` when it
    +was `CANCELLING`. The log line names the store the operation started on.
    +Let the operations finish, or cancel them, before changing `store`.
    +
    +On the filesystem store the layout beneath the root is
    +`{path}/{account id}/{S3Bucket}/...`. The
    +`S3Bucket` field of a request names a directory inside the caller's account
    +subtree, and the `S3KeyPrefix` fields name paths beneath that directory.
    +Every path is canonicalized and must resolve inside the account subtree, and
    +symbolic links that escape it are rejected. At startup the server creates
    +`path` if it is missing, logs its canonical form, and creates
    +`{path}/{account id}` for every account. If `path` names an existing regular
    +file the server refuses to start with `Cannot resolve bulk store path
    +{path}: File exists (os error 17)`. `POST
    +/management/accounts` creates the new account's directory, and
    +`StartBulkPutItems` creates it if it is missing. The caller creates the
    +bucket directories: a request naming a bucket directory that does not exist
    +fails the operation with `FailureCode` `S3NoSuchBucket`. Account deletion
    +leaves the directory and its files in place.
    +
    +Whoever can write beneath `{path}/{account id}` can feed data into any
    +table that account starts a load on, so an account's subtree is as trusted
    +as the mechanism that populates it. The symlink defenses do not cover hard
    +links: a writer able to hard-link a file into its subtree on the same
    +filesystem gets that file's rows written into its table and echoed into its
    +report. Keep the root on its own filesystem or populate the subtrees
    +through a trusted transfer mechanism, and leave the kernel's
    +`fs.protected_hardlinks` setting at `1`.
    +
    +The filesystem store writes report files without encryption.
    +`S3SseAlgorithm` and `S3SseKmsKeyId` are recorded and returned in the
    +description and have no effect on the files. The S3 store applies them to
    +every report object and the manifest.
    +
    +Report files hold item values. The filesystem store creates report objects
    +and the manifest with mode 0600, and the directories it creates for them,
    +from the first `S3KeyPrefix` segment down, with mode 0700, owned by the
    +user the server runs as, so reading them takes that user or root. A
    +directory that already exists keeps its mode, and so does the bucket
    +directory, which the caller creates. Files that `ExportTableToPointInTime`
    +writes keep the modes the umask gives them.
    +
    +#### S3 store
    +
    +`store = "s3"` reads the input objects, and writes the reports and the
    +manifest, in the buckets the requests name, with the server's own AWS
    +credentials. No account directories exist on this store, and
    +`POST /management/accounts` creates none. The startup log names the store
    +it built, for example `Bulk store: s3 (region us-east-1, endpoint AWS S3,
    +virtual-hosted-style)`. A configured endpoint is named by its scheme, host,
    +and port, such as `endpoint https://objects.example.internal:9000`, in the
    +log line and in the dev-mode banner.
    +
    +The store signs with the server's AWS credentials and takes its signing
    +region and endpoint from `[bulk.s3]`.
    +[AWS credentials for S3](#aws-credentials-for-s3) describes where the
    +credentials come from and the lines the server logs about them at startup,
    +which this store logs under the name `S3 bulk store`. Until the credentials
    +resolve, `StartBulkPutItems` answers `InternalServerError`; what a Start and
    +a running operation see when S3 refuses the credentials is described below.
    +
    +The server's credentials need these permissions:
    +
    +| Permission | Resource |
    +|------------|----------|
    +| `s3:ListBucket` | Each input bucket |
    +| `s3:GetObject` | The input objects, and the report keys in each output bucket for the check after a lost completion answer (see below) |
    +| `s3:PutObject` | The report and manifest keys in each output bucket. It covers the multipart upload calls as well as PutObject |
    +| `s3:AbortMultipartUpload` | The report and manifest keys in each output bucket |
    +| `kms:Decrypt` | The KMS keys of SSE-KMS input objects, and the key an output configuration names with `KMS` |
    +| `kms:GenerateDataKey` | The key an output configuration names with `KMS` |
    +
    +S3 evaluates these for the server's principal, whoever the caller is. A
    +denial S3 returns names that principal, for example
    +`arn:aws:sts::{AWS account}:assumed-role/{server role}/{session}`, and its
    +text reaches the caller in `FailureMessage` with S3's request ids. A bucket
    +owner needs that ARN to write a bucket policy for the server, and every
    +caller who meets such a denial learns it.
    +
    +S3 error codes about the server's own credentials, account, or clock are
    +answered as an internal error: `SignatureDoesNotMatch`,
    +`InvalidAccessKeyId`, `ExpiredToken`, `InvalidToken`,
    +`TokenRefreshRequired`, `RequestTimeTooSkewed`, `CredentialsNotSupported`,
    +`InvalidSecurity`, `MissingSecurityHeader`, `AccountProblem`, and
    +`NotSignedUp`. A Start that meets one answers `InternalServerError` and
    +creates no operation, and a running operation ends `FAILED` with
    +`InternalServerError`. The server logs an ERROR line with S3's code and
    +message, `S3 {call} failed: S3 refused the server's own credentials,
    +account or clock; the caller is answered InternalServerError`.
    +`AllAccessDisabled` reaches the caller as `S3AllAccessDisabled`. An S3 4xx
    +code the store does not map to a `FailureCode` is answered the same way,
    +with `InternalServerError` and `Internal server error` for the caller, and
    +S3's code and message appear only in the server's ERROR line,
    +`S3 {call} failed`.
    +
    +Only two controls separate ExtendDB accounts on this store: ExtendDB
    +evaluates `s3:ListBucket`, `s3:GetObject`, and `s3:PutObject` against each
    +caller's ExtendDB policies (see the [Security Model](10-security-model.md)),
    +and the server's credentials reach only the buckets they are granted. The
    +store has no per-account namespace, and an `S3BucketOwner` names an AWS
    +account, which an ExtendDB account id is not. A caller whose policies allow
    +those actions on a bucket can load from it and write reports to it,
    +whatever ExtendDB account the caller is in, when the server's credentials
    +reach that bucket. A policy that grants the S3 actions on `*` keeps
    +accounts apart on the filesystem store, where each account's buckets are
    +directories in its own subtree, and on the S3 store reaches every bucket
    +the server's credentials reach. Grant each caller the S3 actions on its own
    +account's buckets only, for example by a bucket name prefix per account
    +such as `arn:aws:s3:::acct-111122223333-*`, and grant the server's
    +credentials the buckets the deployment serves and no others. Developer mode
    +evaluates no policy, so there every caller reaches every bucket the
    +server's credentials reach.
    +
    +For an S3-compatible service, set `endpoint_url` to the service's URL, and
    +`force_path_style = true` for a service that does not serve buckets as host
    +names. Against a configured endpoint the store computes and checks
    +checksums only when an operation requires them, and its multipart uploads
    +declare no checksum algorithm, because several S3-compatible services refuse
    +the trailing checksums the SDK sends by default; it follows no region
    +redirect. A request naming another bucket owner carries
    +`x-amz-expected-bucket-owner` to every endpoint; whether a service other
    +than AWS S3 checks it, and whether its error codes and messages match AWS
    +S3's, depends on that service. A service that sends no `x-amz-id-2` header
    +leaves `null` in the extended request id of a `FailureMessage`.
    +
    +An `http` endpoint sends requests, signed but not encrypted, in clear
    +text. The server accepts an `http` endpoint only when its host is one of
    +the loopback forms `127.x.y.z` (`127.0.0.0/8` in dotted-quad form), an
    +IPv6 address equal to `::1` (`[::1]`), or `localhost` (any case). Other
    +spellings of a loopback address (`127.1`, `localhost.`,
    +`[::ffff:127.0.0.1]`) are refused. Any other `http` endpoint refuses
    +startup, whatever `store` selects, unless `allow_insecure_endpoint = true`,
    +which accepts it and logs a warning at startup. The store's requests and
    +responses, which carry the input objects and the reports, then cross the
    +network unencrypted.
    +
    +The store's endpoint comes from `endpoint_url` alone. With it unset, the
    +store sends its requests to AWS S3 for the region, and an endpoint named
    +by `AWS_ENDPOINT_URL_S3`, `AWS_ENDPOINT_URL`, a profile's `endpoint_url`,
    +or a profile's `services` section is not used. A server started from a
    +shell that sets `AWS_ENDPOINT_URL` to point DynamoDB clients at ExtendDB
    +still sends the store's requests to AWS S3.
    +
    +A service that ignores `Range` and answers a GET with the whole object is
    +read only when the object fits in the first range, 8 MiB. For a larger
    +object the operation ends `FAILED` with `InternalServerError`, and the
    +server logs `S3 read failed`.
    +
    +On AWS S3 a bucket outside `s3.region` answers the first request with a
    +redirect naming its region (a 301 or 307, or a 400
    +`AuthorizationHeaderMalformed`, carrying `x-amz-bucket-region`). The store
    +sends the request once more to that region and remembers the region for the
    +bucket, for up to 1,024 buckets, after which it starts over. Input and
    +output buckets can be in different regions from each other and from the
    +server.
    +
    +Each attempt of an S3 request has a connect timeout of 3.1 seconds and
    +waits up to 60 seconds for the response's status and headers. A response
    +body that stops arriving is ended by the AWS SDK's stalled-stream
    +protection once it has made no progress for 5 seconds. The SDK makes up to
    +three attempts. A call that still fails with a connection failure, a
    +timeout, HTTP 429, 500, 502, 503, or 504, or the code `SlowDown`,
    +`RequestTimeout`, `InternalError`, or `ServiceUnavailable` is sent again by
    +the store after 1 second, the wait doubling up to 30 seconds, until 120
    +seconds have passed since the call began. A ranged GET whose body is cut
    +off is requested again for the same range with the same `If-Match`, and
    +those requests count toward the call's 120 seconds. Each retry logs a WARN
    +line with its wait. A retry that starts before the 120 seconds have passed
    +runs all three attempts, so a call that gets no answer at all ends after
    +about three minutes. The S3 calls a Start makes (the bucket check, each page
    +of the listing, and the encryption check of an SSE-KMS output) end within
    +10 seconds each, retries included. A Start lists the whole input prefix
    +before it answers, so its time grows with the number of listing pages, and
    +a long listing on a slow endpoint can outlast a client's read timeout. When
    +a Start's call has not ended after
    +10 seconds, the Start answers `InternalServerError` and creates no
    +operation. When a running operation's call still fails after 120 seconds,
    +the operation stays `IN_PROGRESS` and the next claim resumes it from its
    +last checkpoint, under the limit of ten resumes without progress described
    +under `bulk_operation_lease_seconds`. In both cases the
    +server logs an ERROR line naming the call and the bucket. Other 4xx
    +answers are not sent again.
    +
    +While an input object's open or read waits on S3, the runner checkpoints
    +every third of `bulk_operation_lease_seconds` (20 seconds by default), so
    +the operation keeps its lease through the wait and a cancel takes effect
    +within that time. When such a checkpoint ends the read (a cancel, a lost
    +lease, the table deleted or the caller's permission removed), the GET in
    +flight is dropped and its retries are not sent. The check at the first
    +report record, the report writes and the manifest write renew the lease the
    +same way while they wait on S3. A cancel or a deleted table seen during the
    +check at the first report record ends the check within a third of the
    +lease. The report and manifest writes run after the operation has read its
    +input, so their renewals act only on a lost lease: a cancel that arrives
    +during them is recorded when the operation ends, and every report and the
    +manifest are still written.
    +
    +When the answer to a `CompleteMultipartUpload` that S3 carried out is lost,
    +the request is sent again and S3 answers `NoSuchUpload`. The store then
    +looks for the report object with a `HeadObject` and, when an object of the
    +size the upload sent is in place, logs a WARN line and goes on. An object
    +of another size at the key is not taken as the report, and the operation
    +ends `FAILED` with `S3NoSuchUpload`. The server's credentials need
    +`s3:GetObject` on the report keys for that check; without it the
    +operation ends `FAILED` with `S3NoSuchUpload`. A `HeadObject` that still
    +fails with a retryable error after 120 seconds leaves the operation to
    +resume, as other calls do.
    +
    +A report object is a multipart upload until the reports are written at the
    +end of the operation, and the check at the first report record starts an
    +upload and aborts it. A server that stops between that check's start and
    +its abort leaves one empty upload, and one that stops while it writes the
    +reports leaves up to one upload per report object, holding the parts sent
    +so far, up to 200 MiB each. An abort that fails (logged as a warning) also
    +leaves its upload. S3 keeps the parts of an incomplete multipart upload, and
    +charges for their storage, until it is aborted. Give every output bucket a
    +lifecycle rule that aborts incomplete multipart uploads:
    +
    +```json
    +{
    +  "Rules": [
    +    {
    +      "ID": "abort-incomplete-multipart-uploads",
    +      "Status": "Enabled",
    +      "Filter": { "Prefix": "" },
    +      "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
    +    }
    +  ]
    +}
    +```
    +
    +```bash
    +aws s3api put-bucket-lifecycle-configuration --bucket {output bucket} \
    +  --lifecycle-configuration file://lifecycle.json
    +```
    +
    +The runner that holds an operation's lease writes its reports and its
    +manifest, and it keeps the lease while each write waits. A runner that
    +stops, for example a paused process, can outlast its lease during a write
    +and then complete a report object and write the manifest after the process
    +that claimed the operation next has written them and ended the operation.
    +Both render the same checkpointed records, so the content is the same, but
    +the objects' `LastModified` changes after the operation is terminal, a
    +versioned bucket keeps a second version of each key written twice, and the
    +bucket's event notifications fire twice.
    +
    +#### Reclaiming bulk report files
    +
    +On the filesystem store an operation that names an output bucket writes its
    +error report beneath the caller's subtree:
    +
    +```
    +{path}/{account}/{S3Bucket}/{S3KeyPrefix}/AWSDynamoDB/BulkOperation/{account}:{operation id}-0/output/errors/output-00000.json.gz
    +{path}/{account}/{S3Bucket}/{S3KeyPrefix}/AWSDynamoDB/BulkOperation/{account}:{operation id}-0/output/condition-check-failures/output-00000.json.gz
    +{path}/{account}/{S3Bucket}/{S3KeyPrefix}/AWSDynamoDB/BulkOperation/{account}:{operation id}/manifest-files.json
    +```
    +
    +A report whose compressed output passes 200 MiB continues in `output-00001.json.gz`
    +and so on within the same directory, so one operation can leave several objects
    +per kind.
    +
    +The retention worker purges the operation record and its ledger rows after
    +`bulk_operation_retention_days`. It does not remove these files. The files
    +stay under the account subtree with no record the server tracks, so disk use
    +grows with the number of operations that produced a report. There is no
    +per-account or aggregate bound on the total.
    +
    +Reclaim the files with a find by age under the store root. On the filesystem
    +store each report lives in a directory named `{account}:{operation id}-0`,
    +and its modification time is when the report was written:
    +
    +```bash
    +find "{path}" -type d -path '*/AWSDynamoDB/BulkOperation/*-0' -mtime +45 -print
    +```
    +
    +Inspect the list, then remove the directories it names once the operations
    +are past use. A customer can still read a report through `DescribeBulkOperation`
    +output paths after the operation record is purged, so set the age well beyond
    +`bulk_operation_retention_days` and beyond how long a customer keeps reading a
    +finished operation's report.
    +
    +On the S3 store the reports and the manifest stay in the output bucket under
    +`{S3KeyPrefix}/AWSDynamoDB/BulkOperation/`, and the retention worker does not
    +remove them either. Reclaim them with an S3 lifecycle rule whose `Expiration`
    +applies to that prefix, with the age chosen on the same grounds.
    +
     #### [logging]
     
     | Key | Default | Description |
    @@ -621,6 +1128,14 @@ upgrading:
       on `table/` therefore does not cover them. An operator who means to
       fence a whole table adds `table//*` to the `Deny` alongside
       `table/`.
    +- **Bulk operations and resource tags.** A bulk-operation resource (the
    +  `bulk-operation/*` wildcard and every operation ARN) carries no tags, so
    +  `aws:ResourceTag/*` is absent for DescribeBulkOperation,
    +  CancelBulkOperation, ListBulkOperations, and the
    +  `dynamodb:StartBulkOperation` check of StartBulkPutItems. A tag-conditioned
    +  `Allow` never grants them, and a `Deny` with a negated operator or `Null`
    +  true on a tag key always applies to them, on a tagged table too. Only the
    +  `dynamodb:BulkPutItems` check on the table ARN sees the table's tags.
     - **Index reads and resource tags.** An index Query or Scan evaluates the base
       table's resource tags (`aws:ResourceTag/*`; `dynamodb:ResourceTag/*` is
       absent for every resource),
    @@ -885,6 +1400,22 @@ curl --cacert ~/.extenddb/tls/cert.pem https://127.0.0.1:18443/metrics
     
     JSON metrics endpoint with DynamoDB CloudWatch-style metric names and dimensions. The response shape is `{ metrics, buckets, segments, source }`. See `docs/design/06-component-server.md` §7.2 for the full schema and metric list.
     
    +Bulk operations publish three metrics of their own. Query them with the
    +`metric` parameter as with any other, for example
    +`?metric=BulkOperationsFinished&window=LastHour`.
    +
    +| Metric | Dimensions | What the number counts |
    +|--------|------------|------------------------|
    +| `BulkOperationsStarted` | `TableName` | Bulk operations created by `StartBulkPutItems`. An idempotent replay of an existing operation is not counted again. |
    +| `BulkOperationsFinished` | `TableName`, `Operation` | Bulk operations that reached a terminal status, counted once per operation on the transition that held the lease. The `Operation` dimension carries the terminal status: `COMPLETED`, `COMPLETED_WITH_ERRORS`, `FAILED`, or `CANCELLED`. |
    +| `BulkPutItemsWritten` | `TableName` | Rows written to the table by the process whose write committed them. Rows an earlier process wrote and this process replayed after a crash are excluded, and rows whose condition evaluated false are excluded. |
    +
    +The bulk operation worker and the retention worker report `WorkerLastSuccess`
    +and `WorkerErrorCount` each cycle, like the other background workers, under
    +the worker names `BulkClaim` and `BulkRetention`. A claim loop that stops on
    +an internal fault logs an error and stops advancing `WorkerLastSuccess` for
    +`BulkClaim`.
    +
     ### Health Check
     
     ```bash
    @@ -892,6 +1423,92 @@ curl --cacert ~/.extenddb/tls/cert.pem https://127.0.0.1:18443/health
     # {"status":"healthy"}
     ```
     
    +## Bulk Operations
    +
    +### Cancelling a bulk operation as the operator
    +
    +There is no management endpoint or CLI subcommand that cancels a bulk
    +operation. The customer can cancel one with `CancelBulkOperation` under that
    +account's credentials. An operator who needs to stop a runaway or stuck load
    +without those credentials does it with one SQL statement against the catalog
    +database, the one that holds the `bulk_operations` table. It is not in the
    +data database.
    +
    +List the non-terminal operations first:
    +
    +```sql
    +SELECT account_id, operation_id, table_name, status, processed_item_count
    +  FROM bulk_operations
    + WHERE status IN ('IN_PROGRESS', 'CANCELLING');
    +```
    +
    +The `operation_id` is the trailing segment of the `BulkOperationArn`
    +(`bulk-operation/{operation id}`) and `account_id` is the ARN's account
    +field. Request the cancel by setting the operation to `CANCELLING`. On
    +PostgreSQL:
    +
    +```sql
    +UPDATE bulk_operations
    +   SET cancel_requested = TRUE, status = 'CANCELLING'
    + WHERE account_id = '' AND operation_id = ''
    +   AND status IN ('IN_PROGRESS', 'CANCELLING');
    +```
    +
    +On SQLite the boolean is an integer:
    +
    +```sql
    +UPDATE bulk_operations
    +   SET cancel_requested = 1, status = 'CANCELLING'
    + WHERE account_id = '' AND operation_id = ''
    +   AND status IN ('IN_PROGRESS', 'CANCELLING');
    +```
    +
    +The `status IN ('IN_PROGRESS', 'CANCELLING')` guard is the same one
    +`CancelBulkOperation` uses, so the statement never touches a terminal
    +operation.
    +
    +What happens next depends on whether a runner holds the operation:
    +
    +- A live runner notices the request at its next checkpoint boundary
    +  (`bulk_put_batch_size` rows) and ends the operation `CANCELLED`. The
    +  counters it reports are floors on the work already done; rows written
    +  before the cancel stay written.
    +- An operation with no live runner, because the `[bulk]` store is disabled
    +  or the operation is unclaimed, is moved from `CANCELLING` to `CANCELLED`
    +  by the bulk operation worker on its next pass.
    +
    +Set the status to `CANCELLING` only. Do not write `CANCELLED` or any other
    +terminal status by hand. The lease release and the report rendering run on
    +the `CANCELLING` to `CANCELLED` transition, so a hand-written terminal
    +status bypasses both: it leaves the report unrendered and the lease still
    +held by whichever process last claimed the operation.
    +
    +### Operations the server cannot read
    +
    +A `bulk_operations` row the server cannot decode, for example one edited by
    +hand or written by a newer build, does not stop other operations. When a
    +runner claims such a row, it ends the row `FAILED` with `InternalServerError`
    +and the message `Internal server error`, or `CANCELLED` with the cancel pair
    +when a cancel was requested, deletes its report records in the same
    +transaction, and logs an
    +ERROR line with the account and operation id; the other claimed operations
    +run. `ListBulkOperations` skips the row with the same ERROR line, so a page
    +can hold fewer operations than its limit and still carry a `NextToken`.
    +`DescribeBulkOperation` of the row answers `InternalServerError`.
    +
    +### Faults during a bulk load
    +
    +A batch write that fails for a reason a later attempt can clear leaves the
    +operation `IN_PROGRESS`, and the next claim resumes it from its last
    +checkpoint. These are a lost database connection, a server shutdown, a
    +serialization or deadlock abort, a lock or statement timeout, and on
    +PostgreSQL a full disk or too many connections (SQLSTATE 53100 and 53300).
    +The code is read from the batch's own statements and its secondary index
    +and stream writes; the same fault met on a vector index write, or when the
    +batch opens its catalog connection, ends the operation `FAILED` with
    +`InternalServerError`. On SQLite a full disk ends the operation `FAILED` with
    +`InternalServerError`.
    +
     ## Troubleshooting
     
     ### Vector Index Support (PostgreSQL)
    @@ -981,6 +1598,12 @@ psql -f catalog_backup.sql extenddb_catalog
     psql -f data_backup.sql extenddb_catalog_data
     ```
     
    +A backup taken while a bulk operation is running holds whatever rows were
    +committed when the copy began. The catalog and data databases hold two
    +halves of an in-flight operation, so restore both to the same point, or
    +cancel every non-terminal operation before the backup. See the Upgrade
    +Manual, Bulk Operations During Backup and Restore.
    +
     ---
     
     ## License
    diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md
    index 9c6c90bfd..87223a3e5 100755
    --- a/docs/manuals/07-upgrade-manual.md
    +++ b/docs/manuals/07-upgrade-manual.md
    @@ -4,7 +4,7 @@
     
     ## Current Status
     
    -This release versions the PostgreSQL, SQLite, Cassandra and MongoDB catalogs by a migration ledger and carries catalog migrations 1 to 7, which `extenddb migrate --list` prints. **Every deployment created by a 0.1.x release runs `extenddb migrate --yes` once** before a server of this release starts on it: the server refuses a catalog with pending migrations. Migration 1 brings the catalog of every 0.1.x release to the v1.0 schema, migration 5 drops `continuous_backups`, a table and on MongoDB a collection (see [Catalog 0.0.5](#catalog-005)), and migration 6 adds the backup catalog index and the restore job records ([below](#migration-6-the-backup-catalog-index)). Migration 7 adds the `stream_pending` table to the PostgreSQL data database, where a write stores its stream record until the record gets its sequence number after the write commits; it does not apply to the other backends.
    +This release versions the PostgreSQL, SQLite, Cassandra and MongoDB catalogs by a migration ledger and carries catalog migrations 1 to 8, which `extenddb migrate --list` prints. **Every deployment created by a 0.1.x release runs `extenddb migrate --yes` once** before a server of this release starts on it: the server refuses a catalog with pending migrations. Migration 1 brings the catalog of every 0.1.x release to the v1.0 schema, migration 5 drops `continuous_backups`, a table and on MongoDB a collection (see [Catalog 0.0.5](#catalog-005)), and migration 6 adds the backup catalog index and the restore job records ([below](#migration-6-the-backup-catalog-index)). Migration 7 adds the `stream_pending` table to the PostgreSQL data database, where a write stores its stream record until the record gets its sequence number after the write commits; it does not apply to the other backends. Migration 8 adds the bulk put items tables on PostgreSQL and SQLite; Cassandra and MongoDB record it as not applicable.
     
     A backup that a 0.1.x release took can restore without its global and local secondary indexes: a SQLite backup taken by 0.1.14 records only the table's key schema and attribute definitions, and its restore creates the table with its items and no index. Take a new backup of each table after upgrading to v1.0, and keep the 0.1.x backups for their items only.
     
    @@ -39,7 +39,7 @@ Exit status 6 means the migration lock, or a running server, was still held when
     
     `verify` also checks the connection to the data database. When that check fails, `verify` exits 1 on a Behind catalog instead of 3, because `migrate --yes` cannot apply the pending migrations while the data database cannot be reached. It still exits 4 or 5 for the statuses that need a person.
     
    -A migration is additive when the previous release serves the catalog correctly with it applied, and breaking otherwise. Migrations 1 to 7 are breaking: do not run a 0.1.x server against a migrated catalog. A 0.1.x binary that writes to a migrated catalog (its `migrate --yes`, or a SQLite dev server) leaves it WrittenByOlderRelease. `migrate --yes --reconverge` repairs it by running the convergent migrations again. Migration 2, which rewrites stored policies, is not convergent: the repair skips it and prints a line that says so (see the v0.1.14 notes below).
    +A migration is additive when the previous release serves the catalog correctly with it applied, and breaking otherwise. Migrations 1 to 8 are breaking: do not run a 0.1.x server against a migrated catalog. A 0.1.x binary that writes to a migrated catalog (its `migrate --yes`, or a SQLite dev server) leaves it WrittenByOlderRelease. `migrate --yes --reconverge` repairs it by running the convergent migrations again. Migration 2, which rewrites stored policies, is not convergent: the repair skips it and prints a line that says so (see the v0.1.14 notes below).
     
     ### Migration 6: the backup catalog index
     
    @@ -179,6 +179,46 @@ This manual has no tested rollback for Cassandra. Before migrating, with every s
     
     Data written after the upgrade in schema shapes the previous version cannot represent is lost with the restore.
     
    +## Bulk Operations During Backup and Restore
    +
    +A bulk operation's progress lives in both databases. The catalog holds its
    +row in `bulk_operations` and its input files in `bulk_operation_input_files`:
    +status, counters, resume cursor, and lease. The data database holds the rows
    +the operation wrote, the `bulk_row_outcomes` ledger, which records each row
    +written past the last checkpoint with its outcome and what it replaced, and
    +the `bulk_write_owners` record that names the claim allowed to write. The
    +two are a matched pair: the cursor names how far the data rows and the
    +ledger have advanced.
    +
    +A backup taken while an operation is running holds whatever rows were
    +committed when each `pg_dump` read began, which is some prefix of the load
    +unrelated to any checkpoint, and the catalog dump holds the cursor as of its
    +own read. If the two dumps are not taken at one quiet moment they are
    +already a mismatched pair.
    +
    +On restore, bring both databases back to the same point. Restoring them to
    +different points leaves a runner to resume against a mismatched pair. A
    +resumed runner reads the input from the cursor forward and never re-reads the
    +rows behind it:
    +
    +- Catalog behind the data (the rollback procedure above restores only the
    +  catalog). The cursor moves back, so the runner applies the input rows
    +  after it again. The ledger keeps only the rows past the latest checkpoint,
    +  so it does not cover them. A plain put writes the same item again. A
    +  condition row is evaluated again against the item now in the table, so it
    +  can fail where it passed before, and the operation's counters and reports
    +  come from the second evaluation.
    +- Data behind the catalog. The cursor stays ahead of the rows the data
    +  restore removed, so the runner starts past them and never writes them
    +  again. Those rows are missing from the table while the catalog counters
    +  report them as processed. This loses data with no error.
    +
    +Before a backup you intend to restore from, quiesce bulk operations first.
    +Cancel every non-terminal operation (see the Admin Guide, Cancelling a bulk
    +operation as the operator), or lower `max_concurrent_bulk_operations` to stop
    +new starts and let the running operations finish. Then back up both
    +databases, and on restore bring both back to the same point.
    +
     ## Version History
     
     The catalog versions below are those of the 0.1.x releases, and the commands, output and procedures in them are those of the 0.1.x binaries. This release upgrades the catalog of any 0.1.x release with the [general upgrade procedure](#general-upgrade-procedure): its migration 1 applies the changes catalogs 0.0.2 to 0.0.4 made, and its migration 5 the change catalog 0.0.5 made. 
    @@ -802,6 +842,18 @@ This change needs no catalog migration of its own. Before upgrading a deployment
     
     **Who is affected.** A policy whose pattern uses `?` against a request value with a character outside ASCII. A character up to U+FFFF, such as `é` or `日`, is one unit and now takes one `?`; before this release it took one `?` per byte (two for `é`, three for `日`). A character above U+FFFF, such as an emoji, is two units and takes `??`, where it took four. A pattern written with one `?` per byte stops matching such a value and a pattern with one `?` per unit starts matching it, so a `Deny` or an `Allow` that relies on such a pattern can change its decision. Review policies that use `?` on condition keys that carry caller data, such as `dynamodb:LeadingKeys` and `dynamodb:Attributes`. No catalog migration is required.
     
    +### v0.1.15: a daemonized `serve` keeps the directory it started in
    +
    +**What changed.** `extenddb serve` without `--foreground` keeps the directory it was started in as the daemon's working directory. Before this release the daemon changed to `/`. A relative path in the configuration or the environment now resolves against the start directory, as it does in the foreground: the `[import]` and `[export]` roots, the bulk store's filesystem root, the run directory and PID file, and the files the AWS SDK reads from its environment. When the start directory cannot be read, the daemon changes to `/` as before.
    +
    +**Who is affected.** A daemonized deployment that gives one of those paths as a relative path. Before this release the path resolved against `/`; now it resolves against the directory `serve` started in. Absolute paths resolve as before. No catalog migration is required.
    +
    +### v0.1.15: `PutItem` checks the nesting depth of `ExpressionAttributeValues` before the table lookup
    +
    +**What changed.** `PutItem` checks the nesting depth of every `ExpressionAttributeValues` value with its other condition checks, before it looks up the table and checks the item. Before this release that check ran after the table lookup and the item checks. The bulk put runner uses the same sequence for each row, so a row's condition fault carries the message `PutItem` returns.
    +
    +**Who is affected.** A `PutItem` request that carries a value nested deeper than the limit, and also names a table that does not exist or carries an item that fails its checks, now gets the nesting-depth `ValidationException`. Before this release it got the error of the table lookup or of the item. A request with one of those faults alone gets the answer it got before. No catalog migration is required.
    +
     ---
     
     ### v0.1.15: the console metrics dashboard is admin only, and admin-only console pages redirect IAM users
    diff --git a/docs/manuals/08-install-linux.md b/docs/manuals/08-install-linux.md
    index c892006fc..9db81d23b 100755
    --- a/docs/manuals/08-install-linux.md
    +++ b/docs/manuals/08-install-linux.md
    @@ -118,7 +118,7 @@ Expected:
     ```
     === extenddb verify ===
     ...
    -  OK: Current: the catalog is at migration 7
    +  OK: Current: the catalog is at migration 8
     ...
     === HEALTHY: All checks passed ===
     ```
    diff --git a/docs/manuals/09-install-macos.md b/docs/manuals/09-install-macos.md
    index eb44653fd..274e1a4cf 100755
    --- a/docs/manuals/09-install-macos.md
    +++ b/docs/manuals/09-install-macos.md
    @@ -96,7 +96,7 @@ Expected:
     ```
     === extenddb verify ===
     ...
    -  OK: Current: the catalog is at migration 7
    +  OK: Current: the catalog is at migration 8
     ...
     === HEALTHY: All checks passed ===
     ```
    diff --git a/docs/manuals/10-security-model.md b/docs/manuals/10-security-model.md
    index e8b663d28..7a6fb83b1 100755
    --- a/docs/manuals/10-security-model.md
    +++ b/docs/manuals/10-security-model.md
    @@ -172,6 +172,7 @@ Set operators and `IfExists` can be combined: `ForAllValues:StringEqualsIfExists
     | `dynamodb:ReturnConsumedCapacity` | String | The `ReturnConsumedCapacity` parameter value |
     | `dynamodb:FullTableScan` | Boolean | `true` for Scan operations |
     | `dynamodb:EnclosingOperation` | String | The enclosing operation for batch/transact sub-operations |
    +| `s3:prefix` | String | The `InputS3Configuration.S3KeyPrefix` of a bulk operation, in its `s3:ListBucket` evaluation |
     
     Policy variables (e.g., `${aws:PrincipalTag/Team}`) are expanded in condition values in a `2012-10-17` document; a `2008-10-17` document, or one with no `Version`, compares the value as written, so `${aws:username}` is the literal text, as IAM does. In a `2012-10-17` document `${*}`, `${?}` and `${$}` stand for the literal characters `*`, `?` and `$`; in `StringLike`, `ArnLike` and `ArnEquals` the escaped character is matched literally while a `*` or `?` written in the policy is a wildcard (`a${*}` matches `a*` and not `abc`), and the result of an escape is not expanded again (`a${$}{x}` is the text `a${x}`). The `${key, 'default'}` form expands to `default` when the request does not carry `key`, and code points up to U+0020 around the key name, around the comma of the default form and before the closing brace are ignored, as in IAM. A variable with no default whose key the request does not carry makes that value match nothing: a positive operator such as `StringEquals` does not match it and a negated operator such as `StringNotEquals` does, except that every `Arn*` operator is false against a request value that is not an ARN. A multivalued key such as `dynamodb:LeadingKeys` is not expanded; when the request carries it the value matches nothing, and a written default is used only when the request does not carry the key. Only the string and ARN operators (with or without `ForAnyValue:`, `ForAllValues:` or `IfExists`) expand variables. A numeric, date or address value written as a variable is compared as written. A `Bool` or `Null` value written as one is read as the boolean `false`, as IAM does, so `Null` then holds when the key is present and `Bool` holds when the request value is `false`. In an `Arn*` operator, a value that contains `${` needs the five `:` that separate an ARN's six fields written in the policy, as in `arn:aws:iam::123456789012:user/${aws:username}`; the IAM policy simulator refuses a policy whose value has fewer, such as `${aws:ResourceTag/owner}`, and ExtendDB fails that condition closed in a document of any `Version`, so a `Deny` applies and an `Allow` does not grant. A role trust policy follows its own `Version`. Because a negated operator holds for an unresolved variable, an `Allow` written as `StringNotEquals` against `${aws:PrincipalTag/team}` grants to a caller without that tag; add a `Null` condition on the tag to require it.
     
    @@ -193,10 +194,183 @@ extenddb supports the same access control patterns as real AWS IAM:
     
     Resources are identified by ARN: `arn:aws:dynamodb:::table/`. Wildcard matching (`*`, `?`) in policy Resource fields follows AWS IAM conventions. `NotResource` is also supported.
     
    +### Bulk Operations
    +
    +`StartBulkPutItems` is gated by two IAM actions, both of which must allow
    +the request, and the operation it starts performs three S3 actions as its
    +caller (see S3 actions of a bulk operation below). The three control-plane
    +operations each have one action, and the resource each is evaluated against
    +differs per operation:
    +
    +| Action | Resource evaluated |
    +|--------|--------------------|
    +| `dynamodb:StartBulkOperation` (required by `StartBulkPutItems`) | the account's wildcard: `arn:aws:dynamodb:::bulk-operation/*` |
    +| `dynamodb:BulkPutItems` (required by `StartBulkPutItems`) | the table ARN: `arn:aws:dynamodb:::table/` |
    +| `dynamodb:DescribeBulkOperation` | the operation ARN from the request body: `arn:aws:dynamodb:::bulk-operation/` |
    +| `dynamodb:CancelBulkOperation` | the operation ARN from the request body |
    +| `dynamodb:ListBulkOperations` | the account's wildcard: `arn:aws:dynamodb:::bulk-operation/*` |
    +
    +The five actions and their resources are the ones the hosted service
    +evaluates: the account-scoped control-plane action and the table-scoped
    +data-plane action for `StartBulkPutItems`, and one action per operation,
    +named for the operation, for Describe, Cancel, and List. The control-plane
    +check runs first, so a caller with neither grant receives a denial naming
    +`dynamodb:StartBulkOperation`. A statement granting
    +`dynamodb:StartBulkPutItems` grants nothing; that is the operation name,
    +not an IAM action.
    +
    +`ListBulkOperations` is evaluated against the `bulk-operation/*` wildcard,
    +so an Allow that names a single operation ARN does not cover List; the
    +policy's Resource must match the wildcard form.
    +
    +A bulk-operation resource carries no tags: the `bulk-operation/*`
    +wildcard and every operation ARN. For `DescribeBulkOperation`,
    +`CancelBulkOperation`, `ListBulkOperations`, and the
    +`dynamodb:StartBulkOperation` check of `StartBulkPutItems`,
    +`aws:ResourceTag/*` is absent. A condition on that key evaluates as for
    +any absent key: `StringEquals` and `StringLike` do not match, so a
    +tag-conditioned Allow never grants these actions, while a negated operator
    +(`StringNotEquals`, `StringNotLike`), an `IfExists` form and `Null` true
    +match, so a Deny written with one of them always applies to them. Only the
    +`dynamodb:BulkPutItems` check on the table ARN evaluates the table's tags.
    +
    +#### S3 actions of a bulk operation
    +
    +A bulk operation reads its input and writes its reports through three S3
    +actions, which the hosted service performs as the caller. ExtendDB evaluates
    +the same three actions for the caller against the caller's ExtendDB
    +policies, on both bulk stores, with the evaluation every other request gets:
    +identity and group policies, the permissions boundary, a role session's
    +session policy, and an explicit Deny first.
    +
    +| Action | Resource evaluated | When |
    +|--------|--------------------|------|
    +| `s3:ListBucket` | the input bucket: `arn:aws:s3:::` | Once, at Start, before the input bucket is checked |
    +| `s3:GetObject` | each input object: `arn:aws:s3:::/` | Before the object is opened, on a resumed run too, and at every checkpoint boundary while it is read |
    +| `s3:PutObject` | each report object and the manifest: `arn:aws:s3:::/` | At the first record of each report kind, on report object 0 of that kind; before each report object is written; and before the manifest is written |
    +
    +The report keys are
    +`/AWSDynamoDB/BulkOperation/:-0/output/errors/output-.json.gz`,
    +the same under `condition-check-failures/`, and the manifest's is
    +`/AWSDynamoDB/BulkOperation/:/manifest-files.json`,
    +so a statement on `arn:aws:s3:::/*` covers all of them.
    +The Usage Guide lists what each denial does to the operation.
    +
    +The filesystem store writes a key with its empty and `.` segments removed,
    +and an S3-compatible service with a file backend can also resolve `..`, so
    +on the filesystem store and on the S3 store with `endpoint_url` set, the key
    +a write lands under can differ from the key composed from the request's
    +prefix. There `s3:PutObject` is evaluated on the key as composed and, when
    +it differs, on the key the store writes, and the write is allowed only when
    +both evaluations allow it. On the S3 store with `endpoint_url` set,
    +`s3:ListBucket` is evaluated the same way on the input prefix as given and
    +as the service resolves it. The filesystem store compares the input prefix
    +as given with the paths it finds, so it evaluates `s3:ListBucket` once. A
    +denial names the key whose evaluation denied; the composed key is evaluated
    +first. On AWS S3 the key as composed is the key S3 stores, and it is
    +evaluated alone. With `endpoint_url` set, Start refuses a prefix whose `..`
    +segments outnumber the segments before them (`../other/`), since the
    +service could resolve it into another bucket while the evaluations name a
    +key inside this one.
    +
    +Condition keys in these evaluations:
    +
    +- `s3:prefix`, on `s3:ListBucket`: the request's
    +  `InputS3Configuration.S3KeyPrefix` as given, or the empty string when the
    +  request has none, and in the second evaluation described above, the
    +  prefix as the service resolves it.
    +- `aws:SourceIp`, `aws:UserAgent`, and `aws:SecureTransport`: the values of
    +  the `StartBulkPutItems` request, recorded with the caller and used for
    +  every check of the operation.
    +- The principal's keys, such as `aws:PrincipalArn`, `aws:PrincipalTag/*`,
    +  and `aws:username`, resolve as on every request.
    +- `aws:ResourceAccount`: on the filesystem store, the caller's account,
    +  which owns every bucket directory there, so a Deny of `s3:*` with
    +  `StringNotEquals` `aws:ResourceAccount` `${aws:PrincipalAccount}` does
    +  not apply to the caller's own buckets. On the S3 store the key is absent:
    +  an S3 ARN carries no account, and ExtendDB does not know a bucket's owner.
    +  The same Deny there denies every S3 check of a bulk operation. A
    +  `Resource` or `NotResource` pattern that names `${aws:ResourceAccount}`
    +  matches no resource on the S3 store. There, a Deny with such a pattern in
    +  `Resource` does not apply, and an Allow with one does not grant. A Deny
    +  with one in `NotResource` applies to every S3 check.
    +- Absent: `s3:delimiter`, `s3:max-keys`,
    +  `s3:x-amz-server-side-encryption`,
    +  `s3:x-amz-server-side-encryption-aws-kms-key-id`, the S3 object tag keys,
    +  `aws:ResourceTag/*`, and every `dynamodb:` key. A condition on one of
    +  them evaluates as for any absent key.
    +
    +In a `2012-10-17` document, policy variables in a statement's `Resource`
    +are expanded as for the DynamoDB actions (see Supported Policy Elements).
    +An Allow on `arn:aws:s3:::/home/${aws:username}/*` then grants each
    +IAM user the keys under the folder named after that user. A role session has
    +no `aws:username`, so the same Allow grants it nothing. A `2008-10-17`
    +document, or one with no `Version`, matches the pattern as written, so the
    +same Allow there grants only the folder literally named `${aws:username}`.
    +
    +A denial is `S3AccessDenied` with a message in S3's spelling, naming the
    +caller and the resource:
    +
    +```
    +User: arn:aws:iam:::user/ is not authorized to perform: s3:PutObject on resource: "arn:aws:s3:::/" because no identity-based policy allows the s3:PutObject action
    +```
    +
    +A matching Deny statement gives `with an explicit deny in an identity-based
    +policy` in place of the `because` clause. A role session is named by its
    +`arn:aws:sts:::assumed-role//` ARN. ExtendDB has
    +no bucket policies, so the resource-based forms of S3's text do not occur,
    +and the message carries no request-id tail because no request reached S3.
    +
    +A denial that a role session's session policy or a permissions boundary
    +decides has the same identity-based wording: a missing Allow in either gives
    +`because no identity-based policy allows`, and a Deny in a session policy
    +gives `with an explicit deny in an identity-based policy`.
    +
    +`StartBulkPutItems` records its caller on the operation: the identity, a
    +role session's session policy, session tags, and token issue time as they
    +were at Start, and the request-level keys above. No response renders the
    +record. The runner evaluates `s3:GetObject` and `s3:PutObject` for the
    +recorded caller. Identity policies, group policies, the permissions
    +boundary, and principal tags are read when each check runs, so a policy
    +changed during a load takes effect at the next check. The recorded session
    +data is evaluated in place of the session's current data, so a session that
    +expires or is removed during a load neither fails it nor changes its access.
    +An operation recorded with no caller, such as one started in developer mode
    +and resumed by a server outside it, fails its first S3 check with
    +`S3AccessDenied`, `Access Denied`. In developer mode no S3 action is
    +evaluated, as no other action is.
    +
    +On the S3 store a check that passes is followed by the server's request to
    +S3, which S3 evaluates for the server's credentials. A denial S3 returns
    +carries S3's text and request ids, and the text reaches the caller in
    +`FailureMessage`. On AWS S3 an `AccessDenied` from S3 can name the principal
    +the server signs as and that principal's AWS account. The Admin Guide lists
    +the permissions those credentials need. An S3 code about the server's own
    +credentials, account, or clock, such as `InvalidAccessKeyId`,
    +`ExpiredToken`, or `RequestTimeTooSkewed`, reaches the caller as
    +`InternalServerError` without S3's text, and the server logs it. The store
    +sends its requests to AWS S3, or to `[bulk.s3] endpoint_url` when it is
    +set; endpoints named in the server's environment or in an AWS profile are
    +ignored.
    +
    +On the S3 store the caller's ExtendDB policies and the buckets the server's
    +credentials reach are the only separation between ExtendDB accounts: a
    +caller whose policies allow the S3 actions on `*` reaches every bucket the
    +server's credentials reach, the buckets other ExtendDB accounts load from
    +included. Grant each account's callers the S3 actions on that account's
    +buckets only (see the Admin Guide).
    +
    +The DynamoDB actions are evaluated when a request arrives: revoking
    +`dynamodb:StartBulkOperation` or `dynamodb:BulkPutItems` stops future
    +starts, and a load already running continues. The S3 actions are evaluated
    +while the load runs, so revoking `s3:GetObject` on its input fails a running
    +load `S3AccessDenied` at its next object or checkpoint boundary.
    +`CancelBulkOperation` stops a running load.
    +
     ### Supported Policy Elements
     
     - `Effect`: Allow, Deny
    -- `Action` / `NotAction`: DynamoDB actions (e.g., `dynamodb:PutItem`, `dynamodb:*`)
    +- `Action` / `NotAction`: DynamoDB actions (e.g., `dynamodb:PutItem`, `dynamodb:*`), and the three S3 actions a bulk operation performs (`s3:ListBucket`, `s3:GetObject`, `s3:PutObject`)
     - `Resource` / `NotResource`: ARN patterns with wildcards. In a `2012-10-17` document, `${key}` policy variables in the pattern are expanded from the request before matching (for example `arn:aws:dynamodb:*:*:table/${aws:username}`); a wildcard written after a variable still matches; `${key, 'default'}` falls back to `default`; `${*}`, `${?}` and `${$}` stand for literal characters; a variable whose key the request does not carry, or that names a multivalued key such as `dynamodb:LeadingKeys` that the request carries, makes the pattern match nothing (a written default is used only when the key is absent); a `:` inside a substituted value does not separate ARN fields. A `2008-10-17` document, or one with no `Version`, matches the pattern as written, as IAM does. Code points up to U+0020 around the key name, around the comma of the default form and before the closing brace are ignored, as in IAM, and a space inside the quotes of a default is kept. A tag whose value is the empty string expands to no characters, as in IAM: `table/${aws:PrincipalTag/team}*` matches every table for a caller tagged `team=""`, while with no `team` tag it matches nothing. To refuse such a caller, add a condition such as `StringLike` on `aws:PrincipalTag/team` with the value `?*` to the `Allow` statement (measured with the IAM policy simulator). A `Deny NotResource` statement on such a pattern spares every table for that caller (also measured), and that condition on it does not change this.
     - `Condition`: Full condition block support (see operators and keys above)
     
    @@ -283,7 +457,8 @@ Input validation is layered:
     
     ### Path Traversal Protection
     
    -Import/export file paths are validated:
    +Import and export file paths, and bulk operation paths beneath the
    +`[bulk.filesystem]` store root, are validated:
     - `..` components rejected
     - Paths namespaced per account: a caller in account `A` may only resolve paths
       under `/A` for each configured root. Containment in the bare root is not
    @@ -297,12 +472,13 @@ Import/export file paths are validated:
     - Symlinks detected and rejected, including one planted at the export filename
     - Export refuses to overwrite an existing file, so it cannot truncate a file it
       did not create
    -- Both operations record the acting account, the operation and the resolved path
    +- Every file operation records the acting account, the operation and the resolved path
     - Error messages use generic text (no raw paths leaked)
     
     ## Error Message Security
     
     - DynamoDB-fidelity error messages reproduce real DynamoDB exactly (no additional information)
    +- On the bulk S3 store, a `FailureMessage` S3 answered carries S3's text and S3's request ids, and an S3 denial names the principal the server signs as (see the Admin Guide, S3 store)
     - Internal errors (database connection failures, SQL errors, I/O errors) are logged server-side but return generic messages to clients
     - Stack traces, file paths, and SQL text are never exposed to clients
     
    diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
    index df803d1cd..3f92f3197 100755
    --- a/docs/troubleshooting.md
    +++ b/docs/troubleshooting.md
    @@ -153,6 +153,95 @@ paths = ["/path/to/imports"]
     paths = ["/path/to/exports"]
     ```
     
    +### `Bulk operations are disabled. Configure [bulk] in extenddb.toml to enable.`
    +
    +**Cause:** A `StartBulkPutItems` request was made, but no `[bulk]` section is configured. Bulk operations are disabled by default for security. The `[import]` and `[export]` sections do not enable them; the bulk store has its own section.
    +
    +**Fix:** Add a `[bulk]` section to `extenddb.toml`, for the filesystem store:
    +```toml
    +[bulk]
    +store = "filesystem"
    +
    +[bulk.filesystem]
    +path = "/path/to/buckets"
    +```
    +
    +or for the S3 store, which takes its AWS credentials from the standard credential chain (see the Admin Guide):
    +```toml
    +[bulk]
    +store = "s3"
    +```
    +
    +### `[bulk.filesystem] path is required when store = "filesystem", the default; to use the [bulk.s3] section, set store = "s3" in [bulk], or add [bulk.filesystem]`
    +
    +**Cause:** The configuration has a `[bulk.s3]` section, no `[bulk.filesystem]` section, and no `store` line in `[bulk]`. `store` defaults to `filesystem`, and the filesystem store needs a path.
    +
    +**Fix:** For the S3 store, set `store = "s3"` in `[bulk]` or `EXTENDDB__BULK__STORE=s3`. For the filesystem store, add `[bulk.filesystem]` with a `path`.
    +
    +### `[bulk.s3] region must be lowercase letters, digits and hyphens, beginning and ending with a letter or a digit`
    +
    +**Cause:** `[bulk.s3] region`, or `EXTENDDB__BULK__S3__REGION`, is not one host label, for example `US-EAST-1` or `us-east-1/x`. The section is checked whatever `store` selects, so the refusal also stops a server on the filesystem store. The message does not repeat the value.
    +
    +**Fix:** Set a region code such as `us-east-1`, or remove the key to use `[server] region`.
    +
    +### `The specified bucket does not exist` (`FailureCode: S3NoSuchBucket`)
    +
    +**Cause:** The bucket a `StartBulkPutItems` request named does not exist. The input bucket is checked at start: the operation is inserted already terminal, `FAILED`, `FailureCode` `S3NoSuchBucket`, `EndTime` equal to `StartTime`, zero item counters. On a table with an operation running, the Start answers `ResourceInUseException` instead and nothing is inserted. The report bucket is checked when the operation's first report record is produced, or when the manifest is written if no record was produced; the operation then ends `FAILED` with the same code, and at the manifest, at its check or at the write itself, the message starts `Manifest is not available. `. On the filesystem store the bucket is a directory beneath the caller's account subtree of the bulk store root, and one that is not a directory counts as missing. On the S3 store the message carries S3's request ids after the sentence.
    +
    +**Fix:** On the filesystem store, create the directory `<[bulk.filesystem] path>//` on the server and stage the input files beneath it. On the S3 store, create the bucket where the server's credentials reach it, on the configured `endpoint_url` for an S3-compatible service, or correct `S3Bucket`. Then retry. A retry with the same `ClientToken` returns the failed operation; send the retry with a new token or none.
    +
    +### `is not authorized to perform: s3:...` or `Access Denied` (`FailureCode: S3AccessDenied`)
    +
    +**Cause:** One of four sources:
    +
    +- ExtendDB's check of the caller's policies denied `s3:ListBucket`, `s3:GetObject`, or `s3:PutObject`. The message names the caller and the resource, `User:  is not authorized to perform: s3:GetObject on resource: "arn:aws:s3:::/" because no identity-based policy allows the s3:GetObject action`, and ends without request ids. On the filesystem store, and on the S3 store with `endpoint_url`, the resource can be the key the store writes rather than the key composed from the request, for example a key under `reports/` for an output prefix of `./reports`.
    +- On the S3 store, a Deny conditioned on `aws:ResourceAccount`, such as `StringNotEquals` with `${aws:PrincipalAccount}`, applies to every one of ExtendDB's S3 checks, because the key is absent from them there.
    +- On the S3 store, S3 refused the server's request. The message is S3's text followed by `(Service: Amazon S3; ...)` or `(Service: S3, ...)` with S3's request ids, and a policy denial names the principal the server signs as.
    +- The bucket does not match `S3BucketOwner` (`Access Denied`). On the filesystem store any owner other than the caller's account is refused, and so is a symbolic link or a non-directory component in the caller's tree.
    +
    +**Fix:** For ExtendDB's denial, grant the caller the named action on the named resource in its ExtendDB policies (see the Security Model, S3 actions of a bulk operation). For a Deny on `aws:ResourceAccount` on the S3 store, add `"Null": {"aws:ResourceAccount": "false"}` to its condition so it applies only where the key is present, and limit the caller's S3 grants to its own buckets. For S3's, grant the server's credentials the S3 and KMS permissions the Admin Guide lists, or allow the server's principal in the bucket policy. If S3's denial names the AWS identity of a key imported into ExtendDB, the server is signing with that key: start it without the clients' variables (see the Admin Guide, AWS credentials for S3). For an owner mismatch, correct `S3BucketOwner` or leave it out.
    +
    +### `InternalServerError` from `StartBulkPutItems` or a bulk operation on the S3 store
    +
    +**Cause:** An S3 call failed for a reason on the server's side. The server log has an ERROR line, `S3  failed`, naming the bucket, with S3's code and message when S3 answered. One of four sources:
    +
    +- S3 refused the server's own credentials, account, or clock: `InvalidAccessKeyId`, `SignatureDoesNotMatch`, `ExpiredToken`, `InvalidToken`, `TokenRefreshRequired`, `RequestTimeTooSkewed`, or another code the Admin Guide lists. The line reads `S3  failed: S3 refused the server's own credentials, account or clock; the caller is answered InternalServerError`.
    +- The server's credentials did not resolve. The startup log has the warning `S3 bulk store credentials cannot be resolved`, or `S3 bulk store credentials were not resolved within five seconds`.
    +- S3 or the network kept failing: for 10 seconds on a call `StartBulkPutItems` makes, or, for a running operation, through ten resumes in a row that found its checkpoint unmoved, each after 120 seconds of retries (about three minutes when S3 gave no answer at all). The failures are 5xx or 429 answers, `SlowDown`, `RequestTimeout`, timeouts, or connection failures. Each retry before that is logged at WARN with its wait, and each such resume logs a WARN line.
    +- S3 answered a 4xx code ExtendDB does not map to a `FailureCode`. S3's message in the ERROR line names the cause.
    +
    +**Fix:** Correct the server's credentials or its clock. A server started from a shell that exports the ExtendDB access key as `AWS_ACCESS_KEY_ID`, or selects the clients' profile with `AWS_PROFILE`, signs its S3 requests with that key; S3 answers `InvalidAccessKeyId` for a key ExtendDB created, and the startup log has the warning `The S3 bulk store credentials are an access key this server also accepts from its clients`. Start the server without the clients' variables (see the Admin Guide, AWS credentials for S3). S3 answers `ExpiredToken` once temporary keys given in the environment or written into the shared config or credentials files expire; restart the server with new keys, or give it credentials the SDK renews (see the Admin Guide, AWS credentials for S3). The AWS SDK reads the shared config and credentials files once, at startup, so restart the server after changing them; until the credentials resolve, the environment, web identity, container, and instance metadata providers are asked again for each request that needs credentials. For 5xx answers or network failures, check the S3 endpoint and the network path from the server. Then start the load again. A retry with the same `ClientToken` returns an operation that ended `FAILED`, so send it with a new token or none.
    +
    +### `AWS refused the S3 bulk store credentials ()`
    +
    +**Cause:** At startup AWS STS refused the credentials the server found for the S3 store: `InvalidClientTokenId` for an access key ID AWS does not know, such as one ExtendDB created, or a session token altered or left out; `SignatureDoesNotMatch` for a wrong secret key; `ExpiredToken` for a session token past its expiry. The store's S3 requests are refused the same way.
    +
    +**Fix:** Give the server credentials AWS accepts and restart it (see the Admin Guide, AWS credentials for S3). If the server started from a shell that holds the clients' ExtendDB key, start it without the clients' variables.
    +
    +### `The S3 bulk store credentials are an access key this server also accepts from its clients`
    +
    +**Cause:** The server started with an access key its own clients sign with, in its environment or in the profile it selected, for example after exporting the key for the AWS CLI as the Getting Started guide does. S3 refuses a key ExtendDB created. An imported AWS key makes the store read and write as that key's AWS identity.
    +
    +**Fix:** Start the server without the clients' variables and profile; the Admin Guide, AWS credentials for S3, gives the `env -u` commands.
    +
    +### `The S3 bulk store credentials are temporary keys the AWS SDK cannot renew`
    +
    +**Cause:** The credentials carry a session token and no expiry, as temporary keys given in environment variables or written into a credentials file do. The SDK cannot renew them, and once they expire S3 answers `ExpiredToken`. Keys a `credential_process` prints get no warning, since the SDK runs the process again.
    +
    +**Fix:** Give the server credentials the SDK renews: an instance or container role, web identity, IAM Identity Center, or a profile with a `credential_process`. Until then, restart the server with new keys before the old ones expire.
    +
    +### `S3 bulk store credentials could not be checked with AWS STS`
    +
    +**Cause:** AWS STS gave no answer within five seconds, the request could not be sent (for example from a network with no route to STS), or STS answered with a code that says nothing about the credentials, such as a server error, a throttling error, or `IncompleteSignature`. The check only reports; the store works as it would without it.
    +
    +**Fix:** The store needs nothing. For the check to run, give the server a network path to STS: an STS interface endpoint with private DNS on, or one named in `AWS_ENDPOINT_URL_STS` or in a profile's `services` entry for `sts`.
    +
    +### `S3 bulk store credentials could not be checked with AWS STS: the server clock is more than 15 minutes off`
    +
    +**Cause:** AWS STS refused the time the request was signed at. Its message, which the line repeats, starts with `Signature expired` when the server's clock is behind and `Signature not yet current` when it is ahead, and names the signed time and STS's time.
    +
    +**Fix:** Correct the server's clock, for example with NTP. S3 refuses requests signed that far off with `RequestTimeTooSkewed`.
    +
     ### `Path must resolve under one of the configured allowed paths`
     
     **Cause:** An import or export file path does not resolve under the calling
    @@ -876,7 +965,7 @@ The error log line names the exact relations for one leaked table and gives this
     
     **Cause:** The `FileSource.Path` in an `ImportTable` request points to a file or directory that does not exist on the server's filesystem.
     
    -**Fix:** Verify the path is correct and accessible to the extenddb process. Paths are relative to the server's working directory.
    +**Fix:** Verify the path is correct and accessible to the extenddb process. A relative path resolves against the directory `extenddb serve` was started in.
     
     ### `Cannot open source file: `
     
    diff --git a/extenddb.sample.toml b/extenddb.sample.toml
    index 2443ccd3d..0da52d4ff 100755
    --- a/extenddb.sample.toml
    +++ b/extenddb.sample.toml
    @@ -165,6 +165,26 @@
     # Preview extension: allow multi-part (composite) keys on base tables.
     # GSIs always support multi-part keys regardless of this setting.
     # allow_multipart_table_keys = false
    +#
    +# Bulk operations (StartBulkPutItems and the workers that drive it).
    +# These settings are ExtendDB's own; DynamoDB does not expose them.
    +# max_concurrent_bulk_operations = 50        # per account
    +# bulk_operation_retention_days = 30
    +# bulk_operation_worker_concurrency = 4      # operations running at once per process
    +# bulk_operation_worker_per_account = 2      # of those, at most this many per account
    +# bulk_operation_lease_seconds = 60
    +# bulk_put_batch_size = 25                   # rows per checkpoint
    +# bulk_put_write_batch_rows = 25             # rows per storage transaction; capped at 100 and by bulk_put_batch_size
    +# max_bulk_input_bytes = 10737418240         # enumerated (compressed) total per operation; Start rejects above it
    +# max_bulk_input_files = 50000               # enumerated object count per operation; above it the operation fails
    +# max_bulk_input_object_bytes = 5368709120   # one enumerated input object; above it the operation fails
    +# max_bulk_client_token_bytes = 1024         # longest accepted ClientToken on StartBulkPutItems, in bytes
    +# max_bulk_line_bytes = 4194304              # one input line; longer abandons the file
    +# max_bulk_item_count = 100000000            # rows read per operation; more fails the operation
    +# max_bulk_report_records = 1000000          # records kept per operation; more are dropped and logged
    +# max_bulk_report_bytes = 1073741824         # serialized bytes of records kept per operation; more are dropped and logged
    +# max_bulk_failed_starts_per_hour = 100      # terminal-born starts per account per hour; above it Start answers ThrottlingException
    +# bulk_put_write_units_per_second = 80000    # ceiling on bulk writes per process, in 1 KB write units per second; 0 disables
     
     [logging]
     # level = "info"               # Log level: trace, debug, info, warn, error
    @@ -219,6 +239,52 @@
     # the server would refuse to start with "unknown field import_export_root".
     #     import_export_root = "/var/lib/extenddb/data"
     
    +# Bulk operations ([bulk]) are disabled by default (secure default). The bulk
    +# put operations read their input files and write their error reports through
    +# one store, independent of [import] and [export]. store selects it:
    +# "filesystem" or "s3".
    +#
    +# The filesystem store reads and writes beneath one root. The layout is
    +# {path}/{account id}/{S3Bucket}/...: the server creates {path} and the
    +# per-account directories; the caller creates the bucket directories beneath
    +# its own subtree and stages files there.
    +#
    +# Whoever can write beneath {path}/{account id} can feed data into any table
    +# that account starts a load on, so an account's subtree is as trusted as the
    +# mechanism that populates it.
    +#
    +# [bulk]
    +# store = "filesystem"
    +#
    +# [bulk.filesystem]
    +# path = "/var/lib/extenddb/buckets"
    +#
    +# The S3 store reads and writes the buckets the requests name, in AWS S3 or in
    +# an S3-compatible service at endpoint_url, with the credentials of the
    +# standard AWS credential chain (environment variables, the shared config and
    +# credentials files, web identity, container and instance credentials). This
    +# file holds no credentials, and [bulk.s3] is checked at startup whatever
    +# store selects. region defaults to [server] region and is one host label of
    +# lowercase letters, digits and hyphens; most S3-compatible services need
    +# force_path_style = true. An http:// endpoint_url must be loopback
    +# (127.x.y.z, ::1 or localhost) unless allow_insecure_endpoint = true, which
    +# accepts it and logs a warning at startup. Requests and responses to such an
    +# endpoint cross the network unencrypted.
    +#
    +# Every caller reaches the buckets the server's credentials reach, as far as
    +# its ExtendDB policies allow s3:ListBucket, s3:GetObject and s3:PutObject on
    +# them; nothing else separates ExtendDB accounts on this store, and dev mode
    +# evaluates no policy.
    +#
    +# [bulk]
    +# store = "s3"
    +#
    +# [bulk.s3]
    +# region = "us-east-1"
    +# endpoint_url = "https://objects.example.internal:9000"
    +# allow_insecure_endpoint = false
    +# force_path_style = true
    +
     # Runtime settings are managed via `extenddb settings set  `, not this file.
     # See docs/getting-started.md for available runtime settings and their defaults.