From 79f28f842df59f4754415490d402dd8fcf2e8e4e Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Fri, 31 Jul 2026 17:45:20 -0600 Subject: [PATCH] Checkpoint U2 BlobDeps contract rework --- PROGRESS.md | 19 ++- .../solstone-core-spl/src/blob_receive.rs | 128 ++++++++++++++---- 2 files changed, 114 insertions(+), 33 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index e601251d5..98207b9cb 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -154,12 +154,12 @@ the seam's PKCS#8/SPKI boundary. This was a brief defect, not code rework. close-to-local-EOF behavior but cannot classify those two causes without changing an accepted U3 seam. This is logged rather than silently expanded; no owner-visible behavior requires a distinction today. -- **U4↔U2 blob dependencies — unresolved frozen seam:** `receive_blob` correctly requires a - ledger, home upload private key, and observer-ingest implementation through `BlobDeps`, but - the frozen `RelayClientConfig`/constructor lists no journal root, upload-key source, or ingest - seam. U4 cannot truthfully dispatch `SBO1` without silently extending that constructor or - guessing file/process ownership. The transport adapter is accepted; full relay-client dispatch - is stopped at this explicit contract gap pending supervisor direction. +- **U4↔U2 blob dependencies — resolved by supervisor:** U5 builds the owned three-field + `BlobDeps` once at service start and passes it to `RelayClient::new` as the fourth argument. + `RelayClient::new` parses `cfg.instance_id` once, retaining its string form for the relay URL + and passing the resulting `[u8; 16]` explicitly to `receive_blob`; the ID is not copied into + `BlobDeps`. The U2 trait/adapter rework is checkpointed but remains unaccepted until its + receiver signature, concrete adapters, and tests are complete. ### Contract rework @@ -179,6 +179,13 @@ the seam's PKCS#8/SPKI boundary. This was a brief defect, not code rework. the lane stopped rather than inventing an escape hatch or wire format. Both are contract rework (ledger row 3), not delegate rejections; the U4 concurrent pipe makes the split independently necessary. +- **BlobDeps ownership and instance binding:** the supervisor supplied the previously missing + owned `BlobDeps` traits and `RelayClient::new` supplier. A subsequent contradiction between + its literal three-field shape and U2's required HPKE instance ID was resolved explicitly: + `receive_blob` accepts `[u8; 16]` from `RelayClient`, which parses the configuration UUID at + construction once. U2 now has an unaccepted recovery checkpoint for the owned dependencies, + load-only key/error vocabulary, and unknown-ingest-status mapping; it does not add an ID to + `BlobDeps` or re-read `LinkState`. ### Surgical direct fixes diff --git a/core/crates/solstone-core-spl/src/blob_receive.rs b/core/crates/solstone-core-spl/src/blob_receive.rs index 225c5a101..6ba22d010 100644 --- a/core/crates/solstone-core-spl/src/blob_receive.rs +++ b/core/crates/solstone-core-spl/src/blob_receive.rs @@ -16,7 +16,12 @@ //! receiver outcomes. This preserves that known U2 health-observability hole: //! the sole event here is the required `admission_saturated` accounting event. -use std::{future::Future, pin::Pin, time::Duration}; +use std::{ + future::Future, + pin::Pin, + sync::Arc, + time::Duration, +}; use bytes::Bytes; use serde_json::{Value, json}; @@ -24,9 +29,8 @@ use solstone_core_spl_hpke::{BlobFrameError, OFFER_LEN, P256Secret, ack, parse_o use thiserror::Error; use crate::{ - AuthorizedClientLedger, BlobAdmissionGate, BrowserLedgerLookup, BufferedWsReader, - PreparedAuthenticatedBlob, ValidatedBlobArchive, WsByteSink, WsByteSource, - prepare_authenticated_blob, + BlobAdmissionGate, BufferedWsReader, PreparedAuthenticatedBlob, ValidatedBlobArchive, + WsByteSink, WsByteSource, prepare_authenticated_blob, }; const ENC_LEN: usize = 65; @@ -60,39 +64,89 @@ impl Default for BlobReceiveTiming { } } -/// Immutable dependencies held by the U2 blob-receive boundary. -pub struct BlobDeps<'a> { - /// Fresh, fail-closed authorization ledger reader. - pub ledger: &'a AuthorizedClientLedger, - /// P-256 recipient key used only to authenticate an accepted offer. - pub recipient_private_key: &'a P256Secret, - /// The exact 16-byte home instance ID bound into the HPKE info string. - pub instance_id: [u8; 16], - /// The only side-effecting ingest seam after archive validation. - pub ingestor: &'a dyn BlobIngest, - /// Receiver timing policy, defaulting to the Python contract values. - pub timing: BlobReceiveTiming, +/// Immutable, owned dependencies held by the U2 blob-receive boundary. +/// +/// These trait objects are constructed once by the service and transferred to +/// the relay client. The authorization ledger itself performs its required +/// freshness check on every lookup; putting it in an [`Arc`] is not a cache. +pub struct BlobDeps { + /// Fresh, fail-closed browser authorization ledger reader. + pub ledger: Arc, + /// Load-only home upload HPKE key source. + pub upload_key: Arc, + /// The only side-effecting observer-ingest seam after archive validation. + pub ingest: Arc, } -impl<'a> BlobDeps<'a> { - /// Builds dependencies with the contract's default timing policy. +impl BlobDeps { + /// Builds owned U2 dependencies for relay-client construction. #[must_use] pub fn new( - ledger: &'a AuthorizedClientLedger, - recipient_private_key: &'a P256Secret, - instance_id: [u8; 16], - ingestor: &'a dyn BlobIngest, + ledger: Arc, + upload_key: Arc, + ingest: Arc, ) -> Self { Self { ledger, - recipient_private_key, - instance_id, - ingestor, - timing: BlobReceiveTiming::default(), + upload_key, + ingest, } } } +/// One freshly read browser authorization record. +/// +/// `None` from [`BrowserLedger::lookup`] means no browser authorizes that +/// fingerprint. A present row deliberately retains independently-null fields: +/// Python writes the observer handle in a second pass after registration. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LedgerRow { + /// Hex-encoded browser sender SPKI, if the registration has written it. + pub pubkey_spki_hex: Option, + /// Observer-side ingest handle, attached by Python after registration. + pub observer_handle: Option, +} + +/// Class-only failure from an authorization ledger lookup. +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum LedgerError { + /// The backing ledger could not be statted or read. + #[error("browser authorization ledger unavailable")] + Unavailable, + /// The backing ledger was not a valid JSON list. + #[error("browser authorization ledger malformed")] + Malformed, +} + +/// Fresh, fail-closed browser ledger access. +pub trait BrowserLedger: Send + Sync { + /// Re-checks the backing file mtime and reads any changed content on every + /// lookup. `None` is absent or non-browser; a row with empty fields is + /// intentionally distinct and denotes an incomplete Python registration. + fn lookup(&self, fingerprint: &str) -> Result, LedgerError>; +} + +/// Class-only failure from load-only upload-key access. +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum KeyError { + /// The configured upload key could not be read. + #[error("home upload private key unavailable")] + Unavailable, + /// The configured upload key was not an unencrypted P-256 PKCS#8 PEM key. + #[error("home upload private key invalid")] + Invalid, +} + +/// Load-only access to the existing home upload HPKE private key. +/// +/// This seam intentionally has no generation operation. Browser pairing owns +/// the distinct load-or-generate path; receiving a blob must fail if its key is +/// absent rather than minting an incompatible replacement. +pub trait UploadKeySource: Send + Sync { + /// Loads the provisioned P-256 upload key without creating any file. + fn private_key(&self) -> Result; +} + /// The stable response categories returned by observer ingestion. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum BlobIngestStatus { @@ -109,9 +163,15 @@ pub enum BlobIngestStatus { /// It intentionally carries no remote response, URL, token, or plaintext. #[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] pub enum BlobIngestError { - /// The observer ingest endpoint did not produce a recognized result. + /// The observer ingest endpoint failed before it returned a response. #[error("blob ingestion failed")] Failed, + /// Convey returned a status outside `ok`, `duplicate`, or `collision`. + /// + /// This is deliberately an error rather than a default acknowledgement: + /// the receiver closes without an ACK just as Python's `_ack_status` does. + #[error("blob ingestion returned an unexpected status")] + UnexpectedStatus, } /// The object-safe asynchronous observer-ingestion seam. @@ -128,6 +188,20 @@ pub trait BlobIngest: Send + Sync { pub type BlobIngestFuture<'a> = Pin> + Send + 'a>>; +/// Maps an observer ingest response onto the closed acknowledgement vocabulary. +/// +/// Concrete convey adapters must use this rather than defaulting unknown JSON +/// to [`BlobIngestStatus::Ok`]. Keeping the unknown case as an error preserves +/// the no-ack failure path in Python's `_ack_status`. +pub fn parse_convey_ingest_status(response: &Value) -> Result { + match response.get("status").and_then(Value::as_str) { + Some("ok") => Ok(BlobIngestStatus::Ok), + Some("duplicate") => Ok(BlobIngestStatus::Duplicate), + Some("collision") => Ok(BlobIngestStatus::Collision), + _ => Err(BlobIngestError::UnexpectedStatus), + } +} + /// Synchronous Callosum emission needed for the admission-saturation event. pub trait CallosumEmit: Send + Sync { /// Emits one named event with a JSON object payload. -- 2.51.2