lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit ac392da942896a6b676f063929af16971e201b0e
parent d3332ff1c68245a232f093434ee35fe2a342ed38
Author: triesap <tyson@radroots.org>
Date:   Sat, 12 Sep 2026 17:02:34 +0000

storage: Capture waiting authored intents atomically

- Add explicit waiting construction with unchanged ready admission
- Preserve complete request replay and source revision comparison
- Qualify prerequisite progress and SQLite rollback recovery
- Pass current API coverage generator and workspace gates

Diffstat:
Mcontracts/api_baselines/radroots_storage.txt | 1+
Acontracts/architecture/decisions/authored_draft_submission.v2.json | 20++++++++++++++++++++
Mcontracts/architecture/deviations.toml | 21+++++++++++++++++++++
Mcrates/storage/src/authored_draft_submission.rs | 56++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/storage/tests/authored_atomic/draft_submission.rs | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/storage_sqlite/src/authored_draft_submission_tests.rs | 100++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
6 files changed, 383 insertions(+), 14 deletions(-)

diff --git a/contracts/api_baselines/radroots_storage.txt b/contracts/api_baselines/radroots_storage.txt @@ -500,6 +500,7 @@ pub fn radroots_storage::authored_draft_submission::PrepareFromDraft::commit_id( pub fn radroots_storage::authored_draft_submission::PrepareFromDraft::commit_id_for(&[u8; 32], radroots_storage::atomic::AtomicCommitId) -> radroots_storage::atomic::AtomicCommitId pub const fn radroots_storage::authored_draft_submission::PrepareFromDraft::intent(&self) -> &radroots_storage::authored_draft::AuthoredDraft pub fn radroots_storage::authored_draft_submission::PrepareFromDraft::new(radroots_storage::atomic::AtomicCommitId, radroots_storage::authored_draft_submission::AuthoredDraftSource, radroots_storage::authored_draft::AuthoredDraft, radroots_storage::authored_atomic::PrepareAuthoredOperation) -> core::result::Result<Self, radroots_storage::Error> +pub fn radroots_storage::authored_draft_submission::PrepareFromDraft::new_waiting(radroots_storage::atomic::AtomicCommitId, radroots_storage::authored_draft_submission::AuthoredDraftSource, radroots_storage::authored_draft::AuthoredDraft, radroots_storage::authored_atomic::PrepareAuthoredOperation) -> core::result::Result<Self, radroots_storage::Error> pub const fn radroots_storage::authored_draft_submission::PrepareFromDraft::preparation(&self) -> &radroots_storage::authored_atomic::PrepareAuthoredOperation pub const fn radroots_storage::authored_draft_submission::PrepareFromDraft::source(&self) -> &radroots_storage::authored_draft_submission::AuthoredDraftSource pub fn radroots_storage::authored_draft_submission::PrepareFromDraft::validate(&self) -> core::result::Result<(), radroots_storage::Error> diff --git a/contracts/architecture/decisions/authored_draft_submission.v2.json b/contracts/architecture/decisions/authored_draft_submission.v2.json @@ -0,0 +1,20 @@ +{ + "schema": "radroots.authored-draft-submission.v2", + "status": "implemented", + "supersedes_without_mutation": "contracts/architecture/decisions/authored_draft_submission.v1.json", + "owners": ["radroots_storage", "radroots_storage_sqlite"], + "ready_constructor": "PrepareFromDraft::new retains its exact ReadyToSign/Queued admission and operation association requirements.", + "waiting_constructor": "PrepareFromDraft::new_waiting explicitly accepts an initial Draft, MediaPreparing or MediaUploading intent with no draft operation ID. The composite receipt immutably associates its complete captured request with the prepared operation. This is not signing or delivery authority.", + "invariants": "Both constructors require the same distinct intent identity, source author and scope, initial revisions, fixed capture time, initial unsent planned artifacts and delivery plans. No queued-payload freezing rule changes. The full intent snapshot and preparation remain immutable in the composite receipt even while the application's waiting draft head records prerequisite progress.", + "transaction": "Reuse the existing atomic source-head CAS, intent/operation/artifact/delivery insertion and both receipts. No SQL/schema/connection/transaction API changes. Exact replay precedes current-head CAS and compares every captured field independently of caller digests.", + "application_boundary": "The application validates prerequisite progress, preserves captured semantic content and policy, and associates the original prepared operation when it becomes ready. Shared storage does not interpret media, form, user-presence or target policy and does not start signing or delivery.", + "compatibility": "The existing serialized fields and ready-request bytes are unchanged. Validated decoding additionally admits waiting requests. Old readers reject these previously invalid states; there is no schema migration or rewrite. Stable command, intent, operation and receipt IDs and existing ReadyToSign/Queued transitions remain unchanged.", + "verification": [ + "Ready constructor still rejects every waiting stage; waiting constructor rejects ready, queued and cancelled intents.", + "Waiting requests preserve all author/scope/time/revision/artifact checks and exact semantic conflict detection.", + "Memory and SQLite replay after prerequisite progress and later source edits returns the original immutable receipt.", + "SQLite faults before/after every record and receipt roll back; reopen recovers a lost callback without duplicating work.", + "Old ready serialization and queued payload immutability are unchanged; portable profiles, API, coverage and workspace preflight pass." + ], + "non_goals": ["application prerequisite policy", "signing authorization", "new journal or database schema", "deferred platform or release qualification"] +} diff --git a/contracts/architecture/deviations.toml b/contracts/architecture/deviations.toml @@ -2,6 +2,27 @@ schema_version = 1 architecture_id = "radroots.crates.release.v1" [[deviation]] +id = "RCRV1-DEV-019" +date = "2026-09-12" +status = "closed" +approval = "Explicit user authorization covers necessary shared-owner prerequisites, verified checkpoints and non-force origin/master publication." +affected_steps = ["158", "163", "173", "178"] +spec_anchors = ["contracts/crates/release_v1/radroots_crates_release_v1.toml#package.radroots_storage", "contracts/crates/release_v1/radroots_crates_release_v1.toml#package.radroots_storage_sqlite"] +source_evidence = ["Composite submission accepts only ready/queued drafts, whose payload is correctly immutable.", "A caller must durably capture an operation before its prerequisites complete without falsely marking the draft ready or relaxing queued-payload freezing."] +replacement_action = "Implement the explicit waiting constructor and compatibility contract in contracts/architecture/decisions/authored_draft_submission.v2.json using the existing transaction and receipt owners." +verification = ["Preserve the original constructor, exact ready serialization, all capture invariants and queued-payload freezing.", "Qualify waiting-state replay, source CAS, every transaction fault, prerequisite progress and reopened receipt against Memory and SQLite.", "Pass affected profiles, API freshness, unchanged coverage thresholds and workspace/release preflight before publication."] +unresolved_risk = "Owner, SDK, exact API, unchanged coverage, full workspace, generator and release-preflight qualification pass. Each application still owns readiness policy and exact adoption; deferred device and signed-release scope is unclaimed." +normative_architecture_change = false +adr_required = false +closure_evidence = [ + "The original ready constructor and wire fields are unchanged. Explicit waiting construction shares all source, author, scope, time, revision and unsent-operation validation; there is one additive public constructor and no API removals.", + "Memory and SQLite prove replay after prerequisite progress, later source edits and lost callbacks; changed full requests conflict even when the composite digest is unchanged. Fresh requests still obey source CAS.", + "The real SQLite harness covers64 before/after record windows across queued and all three waiting stages, plus actual COMMIT failure, abandoned precommit, SQLITE_FULL and concurrent save/submission. No new schema, connection, transaction or queued-payload mutation is introduced.", + "Affected owner tests303, actual SDK tests119 and explicit memory/serde tests146 pass without ignored tests. Full workspace check/test/clippy, Rustdoc, contracts, architecture, DTO/API boundaries, dependency graph, governed portable targets and preflight pass.", + "All45 required coverage reports pass unchanged90% thresholds. Storage, SQLite, Sync and SDK were freshly measured;41 source-identical measurements retain their original evidence identities. Both SDK generator opt-ins were explicitly selected and passed current frozen-output/freshness assertions.", +] + +[[deviation]] id = "RCRV1-DEV-018" date = "2026-09-10" status = "closed" diff --git a/crates/storage/src/authored_draft_submission.rs b/crates/storage/src/authored_draft_submission.rs @@ -96,12 +96,51 @@ pub struct PrepareFromDraft { preparation: PrepareAuthoredOperation, } impl PrepareFromDraft { + /// Captures an intent whose prerequisites are complete and operation is associated. pub fn new( command_id: AtomicCommitId, source: AuthoredDraftSource, intent: AuthoredDraft, preparation: PrepareAuthoredOperation, ) -> Result<Self, Error> { + if !matches!( + intent.stage(), + AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued + ) { + return Err(Error::AtomicWorkflowMismatch); + } + Self::from_parts(command_id, source, intent, preparation) + } + + /// Captures an operation before caller-owned prerequisites are complete. + /// + /// The waiting draft has no operation association or signing authority. Its + /// original complete snapshot and prepared operation are bound immutably in + /// the composite receipt. The caller owns prerequisite progression and must + /// later associate that same operation without changing its captured plan. + pub fn new_waiting( + command_id: AtomicCommitId, + source: AuthoredDraftSource, + intent: AuthoredDraft, + preparation: PrepareAuthoredOperation, + ) -> Result<Self, Error> { + if !matches!( + intent.stage(), + AuthoredDraftStage::Draft + | AuthoredDraftStage::MediaPreparing + | AuthoredDraftStage::MediaUploading + ) { + return Err(Error::AtomicWorkflowMismatch); + } + Self::from_parts(command_id, source, intent, preparation) + } + + fn from_parts( + command_id: AtomicCommitId, + source: AuthoredDraftSource, + intent: AuthoredDraft, + preparation: PrepareAuthoredOperation, + ) -> Result<Self, Error> { let value = Self { command_id, source, @@ -117,15 +156,20 @@ impl PrepareFromDraft { self.intent.validate()?; let operation = self.preparation.operation(); let at = self.preparation.requested_at_unix_ms(); + let operation_matches = match self.intent.stage() { + AuthoredDraftStage::Draft + | AuthoredDraftStage::MediaPreparing + | AuthoredDraftStage::MediaUploading => self.intent.operation_id().is_none(), + AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued => { + self.intent.operation_id() == Some(operation.operation_id()) + } + AuthoredDraftStage::Cancelled => false, + }; if self.intent.draft_id() == self.source.draft_id || self.intent.author() != &self.source.author || self.intent.scope() != self.source.scope || self.intent.revision() != AuthoredDraftRevision::INITIAL - || !matches!( - self.intent.stage(), - AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued - ) - || self.intent.operation_id() != Some(operation.operation_id()) + || !operation_matches || self.intent.created_at_unix_ms() != at || self.intent.updated_at_unix_ms() != at || at < self.source.updated_at_unix_ms @@ -263,7 +307,7 @@ struct SubmissionWire { impl TryFrom<SubmissionWire> for PrepareFromDraft { type Error = Error; fn try_from(v: SubmissionWire) -> Result<Self, Error> { - Self::new(v.command_id, v.source, v.intent, v.preparation) + Self::from_parts(v.command_id, v.source, v.intent, v.preparation) } } #[cfg(feature = "serde")] diff --git a/crates/storage/tests/authored_atomic/draft_submission.rs b/crates/storage/tests/authored_atomic/draft_submission.rs @@ -530,3 +530,202 @@ fn submission_cannot_adopt_unassociated_existing_intent_or_operation() { } }); } + +#[test] +fn waiting_submission_requires_explicit_construction_and_retains_all_bindings() { + let source = source(); + let ready = request(&source, 1, 2); + for stage in [ + AuthoredDraftStage::Draft, + AuthoredDraftStage::MediaPreparing, + AuthoredDraftStage::MediaUploading, + AuthoredDraftStage::ReadyToSign, + AuthoredDraftStage::Queued, + AuthoredDraftStage::Cancelled, + ] { + let waiting = matches!( + stage, + AuthoredDraftStage::Draft + | AuthoredDraftStage::MediaPreparing + | AuthoredDraftStage::MediaUploading + ); + let intent = AuthoredDraft::reconstruct( + ready.intent().draft_id(), + AuthoredDraftRevision::INITIAL, + *source.author(), + ready.intent().payload_schema(), + ready.intent().payload().to_vec(), + *ready.intent().payload_sha256(), + stage, + (!waiting).then_some(ready.preparation().operation().operation_id()), + 10, + 10, + ) + .unwrap() + .with_scope(source.scope().unwrap()) + .unwrap(); + let result = PrepareFromDraft::new_waiting( + ready.command_id(), + ready.source().clone(), + intent.clone(), + ready.preparation().clone(), + ); + if !waiting { + assert_eq!(result, Err(Error::AtomicWorkflowMismatch)); + continue; + } + assert_eq!( + PrepareFromDraft::new( + ready.command_id(), + ready.source().clone(), + intent, + ready.preparation().clone() + ), + Err(Error::AtomicWorkflowMismatch) + ); + let request = result.unwrap(); + assert_eq!(request.intent().operation_id(), None); + assert_eq!(request.commit_id(), ready.commit_id()); + let wire = serde_json::to_value(&request).unwrap(); + assert_eq!( + serde_json::from_value::<PrepareFromDraft>(wire.clone()).unwrap(), + request + ); + for (pointer, value) in [ + ("/intent/operation_id", serde_json::json!([2; 16].to_vec())), + ("/intent/revision", serde_json::json!(2)), + ("/intent/author", serde_json::json!([1; 32].to_vec())), + ("/intent/scope", serde_json::Value::Null), + ("/intent/updated_at_unix_ms", serde_json::json!(11)), + ("/intent/stage", serde_json::json!("cancelled")), + ("/preparation/artifacts", serde_json::json!([])), + ] { + let mut invalid = wire.clone(); + *invalid.pointer_mut(pointer).unwrap() = value; + assert!( + serde_json::from_value::<PrepareFromDraft>(invalid).is_err(), + "{stage:?} {pointer}" + ); + } + block_on(async { + let store = MemoryStorage::default(); + store + .append_authored_draft(source.clone(), None) + .await + .unwrap(); + let expected = store + .execute_authored(command(request.clone())) + .await + .unwrap(); + // The caller records prerequisite progress without rewriting the captured request. + let progress = request + .intent() + .successor( + b"prerequisite verified".to_vec(), + AuthoredDraftStage::ReadyToSign, + Some(request.preparation().operation().operation_id()), + 11, + ) + .unwrap(); + store + .append_authored_draft(progress.clone(), Some(request.intent().revision())) + .await + .unwrap(); + assert!( + progress + .successor( + b"changed semantic payload".to_vec(), + AuthoredDraftStage::Queued, + progress.operation_id(), + 12 + ) + .is_err() + ); + let queued = progress + .successor( + progress.payload().to_vec(), + AuthoredDraftStage::Queued, + progress.operation_id(), + 12, + ) + .unwrap(); + store + .append_authored_draft(queued.clone(), Some(progress.revision())) + .await + .unwrap(); + let edit = source + .successor( + b"later composer edit".to_vec(), + AuthoredDraftStage::Draft, + None, + 13, + ) + .unwrap(); + store + .append_authored_draft(edit.clone(), Some(source.revision())) + .await + .unwrap(); + let replay = store + .execute_authored(command(request.clone())) + .await + .unwrap(); + assert_eq!(replay.disposition(), AtomicCommitDisposition::Replay); + assert_eq!(replay.outcome(), expected.outcome()); + assert_eq!( + store.authored_draft_head(queued.draft_id()).await.unwrap(), + Some(queued) + ); + for changed_pointer in ["/intent/payload", "/source/payload_sha256", "/intent/stage"] { + let mut changed = wire.clone(); + if changed_pointer == "/intent/payload" { + let payload = b"different captured intent"; + changed["intent"]["payload"] = serde_json::json!(payload.to_vec()); + changed["intent"]["payload_sha256"] = + serde_json::json!(Sha256::digest(payload).to_vec()); + } else if changed_pointer == "/source/payload_sha256" { + changed["source"]["payload_sha256"] = serde_json::json!([7; 32].to_vec()); + } else { + changed["intent"]["stage"] = + serde_json::json!(if stage == AuthoredDraftStage::Draft { + "media_preparing" + } else { + "draft" + }); + } + let changed = serde_json::from_value::<PrepareFromDraft>(changed).unwrap(); + assert_eq!( + changed.preparation().input_digest(), + request.preparation().input_digest() + ); + if changed_pointer == "/intent/stage" { + assert_eq!( + command(changed.clone()).digest(), + command(request.clone()).digest() + ); + } + assert_eq!( + store.execute_authored(command(changed)).await, + Err(Error::AtomicCommitConflict) + ); + } + let mut fresh = wire.clone(); + fresh["command_id"] = serde_json::json!([3; 16].to_vec()); + let fresh = serde_json::from_value::<PrepareFromDraft>(fresh).unwrap(); + assert_eq!( + store.execute_authored(command(fresh.clone())).await, + Err(Error::DraftRevisionConflict) + ); + assert!( + store + .authored_receipt(fresh.commit_id()) + .await + .unwrap() + .is_none() + ); + assert_eq!( + store.authored_draft_head(source.draft_id()).await.unwrap(), + Some(edit) + ); + }); + } +} diff --git a/crates/storage_sqlite/src/authored_draft_submission_tests.rs b/crates/storage_sqlite/src/authored_draft_submission_tests.rs @@ -25,6 +25,9 @@ async fn open(temp: &TempDir, mode: OpenMode) -> SqliteStorage { SqliteStorage::open(options).await.unwrap() } fn fixture() -> (AuthoredDraft, PrepareFromDraft) { + fixture_stage(AuthoredDraftStage::Queued) +} +fn fixture_stage(stage: AuthoredDraftStage) -> (AuthoredDraft, PrepareFromDraft) { let (ordinary, plan) = super::tests::prepare(); let AuthoredAtomicCommand::Prepare(preparation) = ordinary else { unreachable!() @@ -50,15 +53,24 @@ fn fixture() -> (AuthoredDraft, PrepareFromDraft) { "fixture.intent.v1", payload.clone(), Sha256::digest(&payload).into(), - AuthoredDraftStage::Queued, - Some(preparation.operation().operation_id()), + stage, + matches!( + stage, + AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued + ) + .then_some(preparation.operation().operation_id()), 10, 10, ) .unwrap() .with_scope(scope) .unwrap(); - let request = PrepareFromDraft::new( + let constructor = if intent.operation_id().is_some() { + PrepareFromDraft::new + } else { + PrepareFromDraft::new_waiting + }; + let request = constructor( AtomicCommitId::new([4; 16]).unwrap(), AuthoredDraftSource::capture(&source).unwrap(), intent, @@ -109,10 +121,21 @@ async fn empty_submission( #[tokio::test] async fn submission_survives_lost_callback_reopen_and_matches_memory_after_later_save() { + for stage in [ + AuthoredDraftStage::Queued, + AuthoredDraftStage::ReadyToSign, + AuthoredDraftStage::Draft, + AuthoredDraftStage::MediaPreparing, + AuthoredDraftStage::MediaUploading, + ] { + replay_after_progress(stage).await; + } +} +async fn replay_after_progress(stage: AuthoredDraftStage) { let temp = TempDir::new().unwrap(); let store = open(&temp, OpenMode::Create).await; let memory = MemoryStorage::default(); - let (source, request) = fixture(); + let (source, request) = fixture_stage(stage); store .append_authored_draft(source.clone(), None) .await @@ -126,6 +149,23 @@ async fn submission_survives_lost_callback_reopen_and_matches_memory_after_later store.execute_authored(command(&request)).await.unwrap(), expected ); + if request.intent().operation_id().is_none() { + let progress = request + .intent() + .successor( + b"verified prerequisite status".to_vec(), + AuthoredDraftStage::ReadyToSign, + Some(request.preparation().operation().operation_id()), + 11, + ) + .unwrap(); + for target in [&store as &dyn AuthoredDraftStore, &memory] { + target + .append_authored_draft(progress.clone(), Some(request.intent().revision())) + .await + .unwrap(); + } + } let newer = source .successor(b"later edit".to_vec(), AuthoredDraftStage::Draft, None, 11) .unwrap(); @@ -146,6 +186,16 @@ async fn submission_survives_lost_callback_reopen_and_matches_memory_after_later ); assert_eq!(replay.disposition(), AtomicCommitDisposition::Replay); assert_eq!(replay.outcome(), expected.outcome()); + assert_eq!( + store + .authored_draft_head(request.intent().draft_id()) + .await + .unwrap(), + memory + .authored_draft_head(request.intent().draft_id()) + .await + .unwrap() + ); let ordinary = AuthoredAtomicCommand::Prepare(request.preparation().clone()); assert_eq!( store.execute_authored(ordinary.clone()).await.unwrap(), @@ -176,7 +226,17 @@ async fn submission_survives_lost_callback_reopen_and_matches_memory_after_later #[tokio::test] async fn submission_rolls_back_before_and_after_every_record_and_receipt_insert() { - let (source, request) = fixture(); + for stage in [ + AuthoredDraftStage::Queued, + AuthoredDraftStage::Draft, + AuthoredDraftStage::MediaPreparing, + AuthoredDraftStage::MediaUploading, + ] { + rollback_at_every_write(stage).await; + } +} +async fn rollback_at_every_write(stage: AuthoredDraftStage) { + let (source, request) = fixture_stage(stage); let ordinary = AuthoredAtomicCommand::Prepare(request.preparation().clone()); let hex = |id: AtomicCommitId| { id.as_bytes() @@ -241,9 +301,17 @@ async fn submission_rolls_back_before_and_after_every_record_and_receipt_insert( #[tokio::test] async fn abandoned_precommit_and_sqlite_full_preserve_source_without_success_association() { + for stage in [ + AuthoredDraftStage::Queued, + AuthoredDraftStage::MediaPreparing, + ] { + abandoned_and_full(stage).await; + } +} +async fn abandoned_and_full(stage: AuthoredDraftStage) { let temp = TempDir::new().unwrap(); let store = open(&temp, OpenMode::Create).await; - let (source, request) = fixture(); + let (source, request) = fixture_stage(stage); store .append_authored_draft(source.clone(), None) .await @@ -296,10 +364,18 @@ async fn abandoned_precommit_and_sqlite_full_preserve_source_without_success_ass #[tokio::test] async fn concurrent_duplicates_and_save_have_one_atomic_order() { + for stage in [ + AuthoredDraftStage::Queued, + AuthoredDraftStage::MediaPreparing, + ] { + concurrent_submit_and_save(stage).await; + } +} +async fn concurrent_submit_and_save(stage: AuthoredDraftStage) { for _ in 0..4 { let temp = TempDir::new().unwrap(); let store = open(&temp, OpenMode::Create).await; - let (source, request) = fixture(); + let (source, request) = fixture_stage(stage); store .append_authored_draft(source.clone(), None) .await @@ -365,9 +441,17 @@ async fn concurrent_duplicates_and_save_have_one_atomic_order() { #[tokio::test] async fn commit_failure_returns_no_success_and_rolls_back_every_authored_write() { + for stage in [ + AuthoredDraftStage::Queued, + AuthoredDraftStage::MediaPreparing, + ] { + commit_failure(stage).await; + } +} +async fn commit_failure(stage: AuthoredDraftStage) { let temp = TempDir::new().unwrap(); let store = open(&temp, OpenMode::Create).await; - let (source, request) = fixture(); + let (source, request) = fixture_stage(stage); store .append_authored_draft(source.clone(), None) .await