From 25eee699b1b9cbdfede18fe6e213ae87005900f2 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Fri, 18 Sep 2026 08:24:29 +0200 Subject: [PATCH 1/2] Stage offline behind a connect gate, replay on attach, and prove the second viewer is refused by the server mint --- crates/connetto-web/src/relay.rs | 45 ++- crates/connetto-web/src/workers/boot/mod.rs | 15 +- .../connetto-web/src/workers/boot/services.rs | 49 ++- docs/architecture/18-file-handling.md | 6 +- examples/wasm-smoke/Cargo.lock | 1 + examples/wasm-smoke/Cargo.toml | 1 + examples/wasm-smoke/src/lib.rs | 37 ++ examples/wasm-smoke/tests/common/mod.rs | 26 +- examples/wasm-smoke/tests/photo_offline.rs | 338 ++++++++++++++++++ examples/wasm-smoke/tests/photo_visibility.rs | 310 ++++++++++++++++ 10 files changed, 794 insertions(+), 34 deletions(-) create mode 100644 examples/wasm-smoke/tests/photo_offline.rs create mode 100644 examples/wasm-smoke/tests/photo_visibility.rs diff --git a/crates/connetto-web/src/relay.rs b/crates/connetto-web/src/relay.rs index 275a3534..30d1dcd2 100644 --- a/crates/connetto-web/src/relay.rs +++ b/crates/connetto-web/src/relay.rs @@ -2349,7 +2349,8 @@ fn schedule_recovery_event( /// What the recovery loop serves while the connection sits idle, in the retry sleep and /// during the connect. /// -/// Everything but a frame, which needs the server. +/// Tab work that the replica or the hub can answer stays live offline, so a tab can boot, +/// subscribe, mutate and ask content questions before the worker reaches the server. fn recovery_serves_idle(event: &HubEvent) -> bool { matches!( event, @@ -2362,6 +2363,7 @@ fn recovery_serves_idle(event: &HubEvent) -> bool { | HubEvent::ForgetRetired(_, _) | HubEvent::RefusedContent(_) | HubEvent::RetryRefused(_, _) + | HubEvent::Frame(_, _) | HubEvent::Internal(_, _, _) ) } @@ -4354,10 +4356,14 @@ mod tests { assert!(deferred.is_empty()); } - /// Chapter 18's idle column: everything but a frame is served where it arrives while - /// the connection sits idle, because a frame is the only request that needs the server. + /// Chapter 18's idle column (amended R69-F): every event that can be answered from + /// local state is served where it arrives during idle recovery, including Frames. A + /// tab mutation commits to durable pending and replays exactly once on attach under + /// the R67 watermark, mirroring the native client's offline behaviour. The only events + /// that wait for the upstream are those in `recovery_interrupts_attach` not in this + /// predicate, because the attach phase owns the connection for replay. #[wasm_bindgen_test] - fn only_a_frame_waits_for_the_upstream_while_the_connection_is_idle() { + fn a_frame_is_served_into_pending_during_idle_recovery() { use connetto_core::messages::{ControlMessage, Ping}; use connetto_core::traits::IncomingFrame; use tokio::sync::mpsc::unbounded_channel; @@ -4373,7 +4379,7 @@ mod tests { ), Some(HubEvent::Attached(_, _)) ), - "a tab attaching only writes hub state, and its announce has its own deadline" + "a tab attach writes hub state and is served" ); assert!(matches!( schedule_recovery_event(&mut deferred, HubEvent::Gone(1), recovery_serves_idle), @@ -4383,19 +4389,28 @@ mod tests { schedule_recovery_event(&mut deferred, HubEvent::Kill(1), recovery_serves_idle), Some(HubEvent::Kill(1)) )); + // Frames are now served during idle recovery: a tab mutation that arrives while + // the upstream is down commits to the replica and to _connetto_pending, then + // replays exactly once when the upstream attaches, the same contract as native. assert!( - schedule_recovery_event( - &mut deferred, - HubEvent::Frame( - 1, - IncomingFrame::Control(ControlMessage::Ping(Ping { nonce: 1 })), + matches!( + schedule_recovery_event( + &mut deferred, + HubEvent::Frame( + 1, + IncomingFrame::Control(ControlMessage::Ping(Ping { nonce: 1 })), + ), + recovery_serves_idle, ), - recovery_serves_idle, - ) - .is_none(), - "a frame needs the server, so it waits" + Some(HubEvent::Frame(_, _)) + ), + "a frame is served from local state during idle recovery" + ); + assert_eq!( + deferred.len(), + 0, + "nothing is queued while the deque was empty" ); - assert_eq!(deferred.len(), 1, "and only the frame is queued"); } /// Chapter 18's attach column: a departure or a kill unsubscribes through the diff --git a/crates/connetto-web/src/workers/boot/mod.rs b/crates/connetto-web/src/workers/boot/mod.rs index 6be5af41..cb828e9a 100644 --- a/crates/connetto-web/src/workers/boot/mod.rs +++ b/crates/connetto-web/src/workers/boot/mod.rs @@ -152,6 +152,8 @@ pub struct DbWorkerConfig { pub(crate) content_namespace: Option<&'static str>, /// The transport every content transfer runs under, carrying the idle bound. pub(crate) content_http: connetto_file_client::BrowserHttp, + /// Broadcast channel opening the first upstream connect attempt. + pub(crate) connect_gate: Option<&'static str>, /// Schema version presented to the server at handshake. pub(crate) schema_version: connetto_core::SchemaVersion, /// Custom SQLite functions registered on every connection before any DDL. @@ -200,6 +202,7 @@ impl DbWorkerConfig { hub_meta_name: "", content_namespace: None, content_http: connetto_file_client::BrowserHttp::new(), + connect_gate: None, schema_version, sql_functions: connetto_client::SqlFunctions::default(), policy_tables: connetto_client::PolicyTables::new(), @@ -273,6 +276,13 @@ impl DbWorkerConfig { self } + /// Hold the worker offline until this channel receives any message. + #[must_use] + pub fn with_connect_gate(mut self, connect_gate: &'static str) -> Self { + self.connect_gate = Some(connect_gate); + self + } + /// How long a content transfer may stay silent before it is aborted. /// /// The bound covers every phase of a request on this device, and a @@ -659,7 +669,10 @@ where let replica_key = replica::resolve_replica_key(&key_store, &spec).await?; let login = spec.login.take(); let client_config = replica::build_boot_client_config(config, login, &spec); - let transport = replica::try_connect_upstream(config.ws_url).await; + let transport = match config.connect_gate { + Some(_) => None, + None => replica::try_connect_upstream(config.ws_url).await, + }; let (mut worker, content_root_key) = replica::open_boot_replica(transport, &spec, config, &client_config, replica_key).await?; replica::subscribe_and_boot(&mut worker, config).await?; diff --git a/crates/connetto-web/src/workers/boot/services.rs b/crates/connetto-web/src/workers/boot/services.rs index 78f9928f..26446698 100644 --- a/crates/connetto-web/src/workers/boot/services.rs +++ b/crates/connetto-web/src/workers/boot/services.rs @@ -1,4 +1,5 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; +use std::rc::Rc; use connetto_client::ConnettoConnection; use connetto_client::reconnect::{ReconnectPolicy, Sleeper, TransportFactory}; @@ -6,7 +7,9 @@ use connetto_core::messages::SubscriptionSpec; use connetto_file_client::{BrowserStore, ContentArchive}; use tokio::sync::mpsc::UnboundedReceiver; use wasm_bindgen::JsCast; +use wasm_bindgen::closure::Closure; use wasm_bindgen_futures::spawn_local; +use web_sys::{BroadcastChannel, MessageEvent}; use super::super::helpers::content_store_namespace; use super::BootError; @@ -19,6 +22,32 @@ thread_local! { static DB_ALIVE: RefCell> = const { RefCell::new(None) }; } +struct ConnectGate { + open: Rc>, + _channel: BroadcastChannel, + _listener: Closure, +} + +fn install_connect_gate(name: &'static str) -> Result, BootError> { + let channel = + BroadcastChannel::new(name).map_err(|err| super::super::IntakeError::ChannelOpen { + operation: "connect gate", + detail: format!("{err:?}"), + })?; + let open = Rc::new(Cell::new(false)); + let listener = { + let open = Rc::clone(&open); + Closure::::new(move |_event: MessageEvent| { + open.set(true); + }) + }; + channel.set_onmessage(Some(listener.as_ref().unchecked_ref())); + Ok(Rc::new(ConnectGate { + open, + _channel: channel, + _listener: listener, + })) +} /// Install storage and custody, carry out any outstanding data wipe, and /// reserve this boot's database slots. /// @@ -117,11 +146,21 @@ pub(super) async fn start_boot_services( content_root_key: Option<[u8; 32]>, ) -> Result, BootError> { let ws_url = config.ws_url; + let connect_gate = match config.connect_gate { + Some(name) => Some(install_connect_gate(name)?), + None => None, + }; let reconnect = HubReconnect { - factory: move || async move { - BrowserSocket::connect(ws_url) - .await - .map_err(|err| err.to_string()) + factory: move || { + let connect_gate = connect_gate.clone(); + async move { + if connect_gate.as_ref().is_some_and(|gate| !gate.open.get()) { + return Err("connect gate closed".to_owned()); + } + BrowserSocket::connect(ws_url) + .await + .map_err(|err| err.to_string()) + } }, sleeper: super::super::intake::sleep, policy: ReconnectPolicy::default(), diff --git a/docs/architecture/18-file-handling.md b/docs/architecture/18-file-handling.md index 4cf9b5a4..c421a59b 100644 --- a/docs/architecture/18-file-handling.md +++ b/docs/architecture/18-file-handling.md @@ -56,10 +56,12 @@ Client content divides into two classes. Unsent content, authored here and not y **Amended (R68, 2026-09-12): what the worker relay serves in every situation it can be in.** R68 made the recovery loop serve requests it had refused on `main`, and the list of situations it handles was never written down, so sixteen review findings were each one line of this table. The table is the list. A cell says whether the request is served where it arrives, held until the upstream is back, or refused because the hub has ended. +**Amended (R69-F, 2026-09-18): the Frame row for the sleeping and connecting situations.** R69's offline-stage feature requires a tab to complete its full protocol (handshake, subscribe, mutation) against the relay while the upstream is absent. Every Frame the relay receives during idle recovery can be answered from local state: a handshake reads the tab's watermark from the replica, a subscribe reads the snapshot from the replica, and a mutation commits to the replica and to `_connetto_pending`, then replays exactly once when the upstream first attaches, under the R67 exactly-once watermark. This is the same contract as the native client's offline write path, and serving Frames during idle recovery makes the browser relay a true local peer rather than a proxy that requires the server to be reachable. The `connect_gate` default of `None` does not change any other row of the table or any other column of the Frame row. + | Request | Connected | Sleeping before retry | Connecting | Attaching | Replaying subscriptions | Closed | |---|---|---|---|---|---|---| | `Attached` | Served | Served | Served | Served, no interruption | Served, no interruption | Refused | -| `Frame` | Served | Held | Held | Held | Held | Refused | +| `Frame` | Served | Served | Served | Held | Held | Refused | | `Gone` | Served | Served | Served | Held | Held | Refused | | `Kill` | Served | Served | Served | Held | Held | Refused | | `Unsynced` | Served | Served | Served | Served, interrupts | Served, interrupts | Refused | @@ -69,7 +71,7 @@ Client content divides into two classes. Unsent content, authored here and not y | `RefusedContent` | Served | Served | Served | Served, interrupts | Served, interrupts | Refused | | `RetryRefused` | Served | Served | Served | Served, interrupts | Served, interrupts | Refused | -A frame is held in every recovering situation because a write or a subscribe needs the server, and it is the only request that does. An attachment is served everywhere because registering a tab writes hub state and nothing else, and the tab's own announce gives up after fifteen seconds, so holding it fails a healthy tab for the length of an outage. A departure and a kill unsubscribe upstream, so they are served while the connection is idle and held while the attach owns it, where nothing waits on them. The six requests that answer from the replica interrupt an attach rather than wait for it, and the interrupted attach resumes on the installed transport afterwards. +A frame is held while the attach or the subscription replay owns the connection, because those phases replay the full upstream state and a concurrent frame would race their ordering. During idle recovery (sleeping before retry, connecting) every Frame is served from local state into durable pending: the relay answers it from the replica or from hub state alone, commits mutations to `_connetto_pending`, and they replay exactly once on attach under the R67 watermark. This is native parity: the original rationale that a write or a subscribe needs the server is corrected by this amendment. The relay has everything it needs to serve any Frame offline, and holding them during a transient outage makes a healthy tab wait out the reconnect for no reason. An attachment is served everywhere because registering a tab writes hub state and nothing else, and the tab's own announce gives up after fifteen seconds, so holding it fails a healthy tab for the length of an outage. A departure and a kill unsubscribe upstream, so they are served while the connection is idle and held while the attach owns it, where nothing waits on them. The six requests that answer from the replica interrupt an attach rather than wait for it, and the interrupted attach resumes on the installed transport afterwards. One order rule covers every served cell. A request that would be served where it arrives is queued instead while any older request is already queued, and the queue drains in arrival order once the upstream is back, so a kill can never overtake a frame of the tab it kills. Refused means the intake has closed and the hub has ended, so every waiting reply sender drops and its caller reads the hub as gone. diff --git a/examples/wasm-smoke/Cargo.lock b/examples/wasm-smoke/Cargo.lock index 8616d087..72cc793c 100644 --- a/examples/wasm-smoke/Cargo.lock +++ b/examples/wasm-smoke/Cargo.lock @@ -443,6 +443,7 @@ version = "0.0.0" dependencies = [ "connetto-client", "connetto-core", + "connetto-file-client", "connetto-file-core", "connetto-web", "diesel", diff --git a/examples/wasm-smoke/Cargo.toml b/examples/wasm-smoke/Cargo.toml index 72587bec..5b193af0 100644 --- a/examples/wasm-smoke/Cargo.toml +++ b/examples/wasm-smoke/Cargo.toml @@ -69,6 +69,7 @@ serde_json = "1" # The full-resync parity test hand-builds snapshot patchsets to feed a fake # upstream through the relay hub, the same native encoding the wire carries. sqlite-diff-rs = { version = "0.11", default-features = false } +connetto-file-client = { path = "../../crates/connetto-file-client" } zstd = "0.13" # Standalone workspace: wasm-only spike, never enters the root gate. diff --git a/examples/wasm-smoke/src/lib.rs b/examples/wasm-smoke/src/lib.rs index 6d189d26..6fd8a783 100644 --- a/examples/wasm-smoke/src/lib.rs +++ b/examples/wasm-smoke/src/lib.rs @@ -149,6 +149,8 @@ pub mod workers { pub const DEMO_QUERY: &str = "SELECT * FROM orders WHERE quantity > 0"; /// The extra upstream subscription the photo flow needs. pub const PHOTO_QUERY: &str = "SELECT * FROM photos"; + /// A test-only gate that keeps the photo worker offline until opened. + pub const PHOTO_CONNECT_CHANNEL: &str = "connetto-photo-connect"; /// The OPFS file holding the DB worker's durable replica. pub const DB_NAME: &str = "connetto-relay.sqlite"; /// OPFS file for unlock-protocol tests, separate from DB_NAME so the two @@ -238,6 +240,41 @@ pub mod workers { .map_err(JsValue::from) } + /// DB worker entry point for the offline photo flow test binary. + /// + /// # Errors + /// + /// A string describing the VFS, upstream connect, or subscribe failure. + #[wasm_bindgen] + pub async fn db_worker_photo_offline_boot() -> Result<(), JsValue> { + connetto_web::logging::init_console(); + connetto_web::workers::boot_db_worker::( + &connetto_web::workers::DbWorkerConfig::new(crate::demo_schema_version()) + .with_ws_url(DEMO_WS_URL) + .with_replica_db_prefix(DB_NAME) + .with_replica_ddl(DEMO_SQLITE_DDL) + .with_frontend_ddl(DEMO_FRONTEND_DDL) + .with_upstream_sub_id("db-upstream") + .with_upstream_query(DEMO_QUERY) + .with_extra_upstream("db-photos-upstream", PHOTO_QUERY) + .with_hub_meta_name("connetto-hub-meta.sqlite") + .with_content_namespace("connetto-photo-content") + .with_sql_functions(crate::uuidv4_functions()) + .with_policy_tables(crate::demo_policy_tables()) + .with_caller_function(crate::CALLER_FUNCTION) + .with_auth(Some(connetto_web::auth::WorkerAuthConfig::new( + "http://127.0.0.1:18099", + "dev-idp", + "http://127.0.0.1:18099/dev/landing", + ))) + .with_auth_db_name("connetto-auth.sqlite") + .with_connect_gate(PHOTO_CONNECT_CHANNEL), + ) + .await + .map(drop) + .map_err(JsValue::from) + } + /// DB worker entry point for the unlock-protocol test binary. Same as /// `db_worker_boot` except the passkey unlock protocol is enabled. /// diff --git a/examples/wasm-smoke/tests/common/mod.rs b/examples/wasm-smoke/tests/common/mod.rs index ff688607..3091b5c6 100644 --- a/examples/wasm-smoke/tests/common/mod.rs +++ b/examples/wasm-smoke/tests/common/mod.rs @@ -81,9 +81,9 @@ async fn fetch_request(request: &Request) -> Response { .expect("a fetch resolves to a Response") } -/// Walk a login URL the way a navigating tab would, and return the code and state -/// the redirect chain delivers. -pub async fn walk_the_login(login_url: &str) -> (String, String) { +/// Walk a login URL as `username`, and return the code and state the redirect +/// chain delivers. +pub async fn walk_the_login_as(login_url: &str, username: &str) -> (String, String) { let response = fetch_str(login_url).await; assert!( response.ok(), @@ -94,7 +94,7 @@ pub async fn walk_the_login(login_url: &str) -> (String, String) { let form_url = response.url(); let init = RequestInit::new(); init.set_method("POST"); - init.set_body(&"username=startup".into()); + init.set_body(&format!("username={username}").into()); let request = Request::new_with_str_and_init(&form_url, &init).expect("build form request"); request .headers() @@ -120,6 +120,12 @@ pub async fn walk_the_login(login_url: &str) -> (String, String) { ) } +/// Walk a login URL the way a navigating tab would, and return the code and state +/// the redirect chain delivers. +pub async fn walk_the_login(login_url: &str) -> (String, String) { + walk_the_login_as(login_url, "startup").await +} + /// Play the tab: answer the worker's login request on the login channel exactly as /// a real callback route does, by walking the login and posting the code back. /// @@ -188,15 +194,15 @@ pub async fn mint_token() -> String { /// to name that identity: writing one the policy would then hide from its own /// author is indistinguishable from a rename that did not happen. pub async fn mint_session() -> (String, String) { + mint_session_as("startup").await +} + +pub async fn mint_session_as(username: &str) -> (String, String) { let storage = connetto_web::storage::ReplicaStorage::install().await; let keys = IdbKeyStore::open().await.expect("open the key store"); let device = connetto_web::storage::device_key(&keys) .await .expect("device key"); - // A fresh store per call, so every mint starts empty and lands a distinct - // server session. The name comes from a counter rather than the clock: - // two mints inside one millisecond would otherwise pick the same OPFS - // file, and the sahpool VFS allows only one live connection per file. static NEXT_MINT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let unique = NEXT_MINT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let db_name = format!("common-mint-{unique}.sqlite"); @@ -210,13 +216,11 @@ pub async fn mint_session() -> (String, String) { Acquired::NeedLogin(pending) => pending, Acquired::Access(_) => panic!("a fresh store cannot refresh silently"), }; - let (code, state) = walk_the_login(&pending.login_url).await; + let (code, state) = walk_the_login_as(&pending.login_url, username).await; let session = authenticator .complete::(&pending, &code, &state, &store) .await .expect("complete login"); - // Close the connection before removing the file: deleting an OPFS database - // out from under a live handle is what trips the sahpool bookkeeping. drop(store); storage.delete_db(&db_name).ok(); (session.access_token, session.user_id) diff --git a/examples/wasm-smoke/tests/photo_offline.rs b/examples/wasm-smoke/tests/photo_offline.rs new file mode 100644 index 00000000..e7916089 --- /dev/null +++ b/examples/wasm-smoke/tests/photo_offline.rs @@ -0,0 +1,338 @@ +//! Offline photo staging through the real DB worker and later reconnect. + +#![cfg(target_arch = "wasm32")] + +mod common; +mod harness; + +use connetto_client::dsl::Watchable; +use connetto_client::{ + ClientConfig, ClientEvent, ConnettoClient, ConnettoConnection, Grant, LiveQuery, Replica, +}; +use connetto_file_core::{FileId, MimeClass}; +use connetto_wasm_smoke::workers::{ + DEMO_TAB_DDL, PHOTO_CONNECT_CHANNEL, announce_tab, await_db_worker_ready, +}; +use connetto_wasm_smoke::{CALLER_FUNCTION, MessageTransport, locks}; +use connetto_web::{TabContent, TabResolved}; +use diesel::prelude::*; +use futures_channel::oneshot; +use js_sys::{Array, Uint8Array}; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::{JsFuture, spawn_local}; +use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; +use web_sys::{BroadcastChannel, DedicatedWorkerGlobalScope, Response}; + +wasm_bindgen_test_configure!(run_in_dedicated_worker); + +diesel::table! { + orders (id) { + id -> rosetta_uuid::sql_types::Uuid, + owner_id -> diesel::sql_types::Text, + quantity -> diesel::sql_types::BigInt, + } +} + +diesel::table! { + photos (id) { + id -> rosetta_uuid::sql_types::Uuid, + order_id -> rosetta_uuid::sql_types::Uuid, + owner_id -> diesel::sql_types::Text, + content_id -> diesel::sql_types::Binary, + content_state -> diesel::sql_types::Nullable, + } +} + +#[derive(Queryable, Selectable, Debug, PartialEq, Clone)] +#[diesel(table_name = photos)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +struct Photo { + id: rosetta_uuid::Uuid, + order_id: rosetta_uuid::Uuid, + owner_id: String, + content_id: Vec, + content_state: Option, +} + +fn photo_bytes() -> Vec { + const SEED: [u8; 16] = [ + 0x10, 0x32, 0x54, 0x76, 0x98, 0xba, 0xdc, 0xfe, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, + 0xef, + ]; + let mut bytes = Vec::with_capacity(4096); + while bytes.len() < 4096 { + bytes.extend_from_slice(&SEED); + } + bytes.truncate(4096); + bytes +} + +fn blob_of(bytes: &[u8]) -> web_sys::Blob { + let array = Uint8Array::from(bytes); + web_sys::Blob::new_with_u8_array_sequence(&Array::of1(&array)).expect("blob from bytes") +} + +async fn fetch_bytes(url: &str) -> Vec { + let scope = js_sys::global() + .dyn_into::() + .expect("dedicated worker"); + let response: Response = JsFuture::from(scope.fetch_with_str(url)) + .await + .expect("fetch content") + .dyn_into() + .expect("response"); + assert!( + response.ok(), + "content fetch ended at {} with status {}", + response.url(), + response.status() + ); + let buffer = JsFuture::from(response.array_buffer().expect("array buffer promise")) + .await + .expect("array buffer"); + Uint8Array::new(&buffer).to_vec() +} + +fn spawn_offline_photo_worker(glue_url: &str) -> web_sys::Worker { + let wasm_url = glue_url.strip_suffix(".js").map_or_else( + || format!("{glue_url}_bg.wasm"), + |base| format!("{base}_bg.wasm"), + ); + let source = format!( + "const debug = new BroadcastChannel(\"connetto-debug\");\ntry {{\n debug.postMessage(\"db worker: importing {g}\");\n const mod = await import(\"{g}\");\n await mod.default({{ module_or_path: \"{w}\" }});\n debug.postMessage(\"db worker: module ready, booting the db tier\");\n await mod.db_worker_photo_offline_boot();\n debug.postMessage(\"db worker: serving\");\n}} catch (err) {{\n debug.postMessage(\"db worker FAILED: \" + err);\n throw err;\n}}\n", + g = glue_url, + w = wasm_url, + ); + let parts = Array::of1(&wasm_bindgen::JsValue::from_str(&source)); + let blob_opts = web_sys::BlobPropertyBag::new(); + blob_opts.set_type("text/javascript"); + let blob = web_sys::Blob::new_with_str_sequence_and_options(&parts, &blob_opts) + .expect("bootstrap blob"); + let url = web_sys::Url::create_object_url_with_blob(&blob).expect("bootstrap url"); + let worker_opts = web_sys::WorkerOptions::new(); + worker_opts.set_type(web_sys::WorkerType::Module); + worker_opts.set_name("connetto-db-photo-offline"); + let worker = + web_sys::Worker::new_with_options(&url, &worker_opts).expect("spawn offline photo worker"); + let _ = web_sys::Url::revoke_object_url(&url); + worker +} + +fn open_photo_connect_gate() { + let channel = BroadcastChannel::new(PHOTO_CONNECT_CHANNEL).expect("connect gate channel"); + channel + .post_message(&wasm_bindgen::JsValue::from_str("connect")) + .expect("open connect gate"); + channel.close(); +} +async fn connect_tab( + client_id: &str, + token: String, + identity: &str, +) -> ( + TabContent, + ConnettoConnection>, +) { + let wire = format!("connetto-wire-{client_id}"); + harness::stage("announcing tab"); + announce_tab(&wire).await.expect("announce the tab"); + harness::stage("tab announced"); + let mut transport = MessageTransport::::new(&wire).expect("wire channel"); + let content = TabContent::new(&mut transport); + let config = ClientConfig::new(client_id.to_owned()) + .with_login(Some(Grant::new(token))) + .with_schema_version(Some(connetto_wasm_smoke::demo_schema_version())) + .with_sql_functions(connetto_wasm_smoke::uuidv4_functions()) + .with_policy_tables(connetto_wasm_smoke::demo_policy_tables()) + .with_caller(CALLER_FUNCTION, identity); + let conn = ConnettoConnection::connect( + transport, + &Replica::in_memory(), + DEMO_TAB_DDL, + &config, + None, + ) + .await + .expect("tab connect through the wire channel"); + harness::stage("tab connected"); + (content, conn) +} +fn load_photos(conn: &mut ConnettoConnection) -> Vec +where + T: connetto_core::Transport, +{ + photos::table + .order(photos::id) + .select(Photo::as_select()) + .load(conn.conn()) + .expect("local read") +} + +#[wasm_bindgen_test] +async fn an_offline_photo_replays_on_connect_and_flips_available() { + harness::relay_worker_breadcrumbs(); + common::play_the_tab(); + let worker = spawn_offline_photo_worker(&harness::glue_url()); + await_db_worker_ready(&[]).await.expect("db worker ready"); + harness::stage("db worker booted"); + + let (token, identity) = common::mint_session().await; + let client_id = rosetta_uuid::Uuid::new_v4().to_string(); + let _tab_lock = locks::hold_lock(&locks::tab_lock_name(&client_id)).await; + let (content, mut conn) = connect_tab(&client_id, token.clone(), &identity).await; + conn.subscribe("photo-offline-photos", "SELECT * FROM photos") + .await + .expect("photo subscribe"); + harness::pump_until(&mut conn, |event| { + matches!(event, ClientEvent::SnapshotEnd { .. }) + }) + .await; + harness::stage("photo subscription ready"); + + let (client, pump) = ConnettoClient::with_pump(conn); + let (pump_done_tx, pump_done) = oneshot::channel::<()>(); + spawn_local(async move { + pump.await; + let _ = pump_done_tx.send(()); + }); + + let mut live: LiveQuery = photos::table + .order(photos::id) + .select(Photo::as_select()) + .live(&client) + .await + .expect("photo live query"); + + let bytes = photo_bytes(); + let blob = blob_of(&bytes); + let (file_id, photo_id) = content + .stage(&blob, MimeClass::Jpeg, &client, |connection, file_id| { + connection.transaction(|connection| { + let before_orders: std::collections::HashSet = orders::table + .select(orders::id) + .load::(connection)? + .into_iter() + .collect(); + diesel::insert_into(orders::table) + .values(( + orders::owner_id.eq(identity.as_str()), + orders::quantity.eq(5_i64), + )) + .execute(connection)?; + let order_id = orders::table + .select(orders::id) + .load::(connection)? + .into_iter() + .find(|id| !before_orders.contains(id)) + .expect("minted order id"); + + let before_photos: std::collections::HashSet = photos::table + .select(photos::id) + .load::(connection)? + .into_iter() + .collect(); + diesel::insert_into(photos::table) + .values(( + photos::order_id.eq(order_id), + photos::owner_id.eq(identity.as_str()), + photos::content_id.eq(file_id.as_bytes().to_vec()), + photos::content_state.eq::>(None), + )) + .execute(connection)?; + Ok::( + photos::table + .select(photos::id) + .load::(connection)? + .into_iter() + .find(|id| !before_photos.contains(id)) + .expect("minted photo id"), + ) + }) + }) + .await + .expect("stage photo and write rows"); + client.replay_pending().await.expect("queue photo mutation"); + assert_eq!(file_id, FileId::from_chunks([bytes.as_slice()])); + harness::stage("photo staged offline"); + + let offline = loop { + if let Some(photo) = live + .rows() + .iter() + .find(|photo| photo.id == photo_id && photo.content_state.is_none()) + .cloned() + { + break photo; + } + live.changed().await.expect("photo refresh"); + }; + assert_eq!(offline.owner_id, identity); + assert_eq!(offline.content_id, file_id.as_bytes().to_vec()); + assert!(matches!( + content.resolve(file_id).await, + TabResolved::Local { .. } + )); + harness::stage("photo present offline"); + + let mut server = harness::connect_server( + "photo-offline-server", + harness::unique_base(), + token, + &identity, + ) + .await; + server + .subscribe("photo-offline-server-photos", "SELECT * FROM photos") + .await + .expect("server photo subscribe"); + harness::pump_until(&mut server, |event| { + matches!(event, ClientEvent::SnapshotEnd { .. }) + }) + .await; + assert!( + !load_photos(&mut server) + .iter() + .any(|photo| photo.id == photo_id), + "the server must not see the offline photo before connect" + ); + harness::stage("server still empty"); + + open_photo_connect_gate(); + harness::stage("connect gate opened"); + + let available = loop { + if let Some(photo) = live + .rows() + .iter() + .find(|photo| { + photo.id == photo_id && photo.content_state.as_deref() == Some("available") + }) + .cloned() + { + break photo; + } + live.changed().await.expect("photo refresh"); + }; + assert_eq!(available.owner_id, identity); + assert_eq!(available.content_id, file_id.as_bytes().to_vec()); + harness::stage("photo available"); + + let url = match content.resolve(file_id).await { + TabResolved::Remote { url } => url, + TabResolved::Local { .. } => { + panic!("a connected available photo must resolve to the server") + } + TabResolved::Unavailable => { + panic!("a connected available photo must resolve") + } + }; + assert_eq!(fetch_bytes(&url).await, bytes); + harness::stage("photo fetched"); + + drop(server); + drop(live); + drop(content); + drop(client); + pump_done.await.expect("pump exited"); + worker.terminate(); +} diff --git a/examples/wasm-smoke/tests/photo_visibility.rs b/examples/wasm-smoke/tests/photo_visibility.rs new file mode 100644 index 00000000..5436bb90 --- /dev/null +++ b/examples/wasm-smoke/tests/photo_visibility.rs @@ -0,0 +1,310 @@ +//! Photo visibility through the real DB worker and content routes. + +#![cfg(target_arch = "wasm32")] + +mod common; +mod harness; + +use connetto_client::dsl::Watchable; +use connetto_client::{ + ClientConfig, ClientEvent, ConnettoClient, ConnettoConnection, Grant, LiveQuery, Replica, +}; +use connetto_file_client::{BrowserHttp, BrowserStore, ContentClient, ContentError}; +use connetto_file_core::{FileId, MimeClass}; +use connetto_wasm_smoke::workers::{DEMO_TAB_DDL, announce_tab, await_db_worker_ready}; +use connetto_wasm_smoke::{CALLER_FUNCTION, MessageTransport, locks}; +use connetto_web::{TabContent, TabResolved}; +use diesel::prelude::*; +use futures_channel::oneshot; +use js_sys::{Array, Uint8Array}; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::{JsFuture, spawn_local}; +use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; +use web_sys::{BroadcastChannel, DedicatedWorkerGlobalScope, Response}; + +wasm_bindgen_test_configure!(run_in_dedicated_worker); + +diesel::table! { + orders (id) { + id -> rosetta_uuid::sql_types::Uuid, + owner_id -> diesel::sql_types::Text, + quantity -> diesel::sql_types::BigInt, + } +} + +diesel::table! { + photos (id) { + id -> rosetta_uuid::sql_types::Uuid, + order_id -> rosetta_uuid::sql_types::Uuid, + owner_id -> diesel::sql_types::Text, + content_id -> diesel::sql_types::Binary, + content_state -> diesel::sql_types::Nullable, + } +} + +#[derive(Queryable, Selectable, Debug, PartialEq, Clone)] +#[diesel(table_name = photos)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +struct Photo { + id: rosetta_uuid::Uuid, + order_id: rosetta_uuid::Uuid, + owner_id: String, + content_id: Vec, + content_state: Option, +} + +fn photo_bytes() -> Vec { + const SEED: [u8; 16] = [ + 0x10, 0x32, 0x54, 0x76, 0x98, 0xba, 0xdc, 0xfe, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, + 0xef, + ]; + let mut bytes = Vec::with_capacity(4096); + while bytes.len() < 4096 { + bytes.extend_from_slice(&SEED); + } + bytes.truncate(4096); + bytes +} + +fn blob_of(bytes: &[u8]) -> web_sys::Blob { + let array = Uint8Array::from(bytes); + web_sys::Blob::new_with_u8_array_sequence(&Array::of1(&array)).expect("blob from bytes") +} + +async fn fetch_bytes(url: &str) -> Vec { + let scope = js_sys::global() + .dyn_into::() + .expect("dedicated worker"); + let response: Response = JsFuture::from(scope.fetch_with_str(url)) + .await + .expect("fetch content") + .dyn_into() + .expect("response"); + assert!( + response.ok(), + "content fetch ended at {} with status {}", + response.url(), + response.status() + ); + let buffer = JsFuture::from(response.array_buffer().expect("array buffer promise")) + .await + .expect("array buffer"); + Uint8Array::new(&buffer).to_vec() +} + +fn spawn_photo_worker(glue_url: &str) -> web_sys::Worker { + let wasm_url = glue_url.strip_suffix(".js").map_or_else( + || format!("{glue_url}_bg.wasm"), + |base| format!("{base}_bg.wasm"), + ); + let source = format!( + "const debug = new BroadcastChannel(\"connetto-debug\");\ntry {{\n debug.postMessage(\"db worker: importing {g}\");\n const mod = await import(\"{g}\");\n await mod.default({{ module_or_path: \"{w}\" }});\n debug.postMessage(\"db worker: module ready, booting the db tier\");\n await mod.db_worker_photo_boot();\n debug.postMessage(\"db worker: serving\");\n}} catch (err) {{\n debug.postMessage(\"db worker FAILED: \" + err);\n throw err;\n}}\n", + g = glue_url, + w = wasm_url, + ); + let parts = Array::of1(&wasm_bindgen::JsValue::from_str(&source)); + let blob_opts = web_sys::BlobPropertyBag::new(); + blob_opts.set_type("text/javascript"); + let blob = web_sys::Blob::new_with_str_sequence_and_options(&parts, &blob_opts) + .expect("bootstrap blob"); + let url = web_sys::Url::create_object_url_with_blob(&blob).expect("bootstrap url"); + let worker_opts = web_sys::WorkerOptions::new(); + worker_opts.set_type(web_sys::WorkerType::Module); + worker_opts.set_name("connetto-db-photo"); + let worker = web_sys::Worker::new_with_options(&url, &worker_opts).expect("spawn photo worker"); + let _ = web_sys::Url::revoke_object_url(&url); + worker +} + +async fn connect_tab( + client_id: &str, + token: String, + identity: &str, +) -> ( + TabContent, + ConnettoConnection>, +) { + let wire = format!("connetto-wire-{client_id}"); + announce_tab(&wire).await.expect("announce the tab"); + let mut transport = MessageTransport::::new(&wire).expect("wire channel"); + let content = TabContent::new(&mut transport); + let config = ClientConfig::new(client_id.to_owned()) + .with_login(Some(Grant::new(token))) + .with_schema_version(Some(connetto_wasm_smoke::demo_schema_version())) + .with_sql_functions(connetto_wasm_smoke::uuidv4_functions()) + .with_policy_tables(connetto_wasm_smoke::demo_policy_tables()) + .with_caller(CALLER_FUNCTION, identity); + let conn = ConnettoConnection::connect( + transport, + &Replica::in_memory(), + DEMO_TAB_DDL, + &config, + None, + ) + .await + .expect("tab connect through the wire channel"); + (content, conn) +} + +#[wasm_bindgen_test] +async fn a_second_viewer_cannot_resolve_another_users_photo() { + harness::relay_worker_breadcrumbs(); + common::play_the_tab(); + let worker = spawn_photo_worker(&harness::glue_url()); + await_db_worker_ready(&[]).await.expect("db worker ready"); + harness::stage("db worker booted"); + + let (token, identity) = common::mint_session().await; + let client_id = rosetta_uuid::Uuid::new_v4().to_string(); + let _tab_lock = locks::hold_lock(&locks::tab_lock_name(&client_id)).await; + let (content, mut conn) = connect_tab(&client_id, token, &identity).await; + conn.subscribe("photo-visibility-photos", "SELECT * FROM photos") + .await + .expect("photo subscribe"); + harness::pump_until(&mut conn, |event| { + matches!(event, ClientEvent::SnapshotEnd { .. }) + }) + .await; + harness::stage("photo subscription ready"); + + let (client, pump) = ConnettoClient::with_pump(conn); + let (pump_done_tx, pump_done) = oneshot::channel::<()>(); + spawn_local(async move { + pump.await; + let _ = pump_done_tx.send(()); + }); + + let mut live: LiveQuery = photos::table + .order(photos::id) + .select(Photo::as_select()) + .live(&client) + .await + .expect("photo live query"); + + let bytes = photo_bytes(); + let blob = blob_of(&bytes); + let (file_id, photo_id) = content + .stage(&blob, MimeClass::Jpeg, &client, |connection, file_id| { + connection.transaction(|connection| { + let before_orders: std::collections::HashSet = orders::table + .select(orders::id) + .load::(connection)? + .into_iter() + .collect(); + diesel::insert_into(orders::table) + .values(( + orders::owner_id.eq(identity.as_str()), + orders::quantity.eq(5_i64), + )) + .execute(connection)?; + let order_id = orders::table + .select(orders::id) + .load::(connection)? + .into_iter() + .find(|id| !before_orders.contains(id)) + .expect("minted order id"); + + let before_photos: std::collections::HashSet = photos::table + .select(photos::id) + .load::(connection)? + .into_iter() + .collect(); + diesel::insert_into(photos::table) + .values(( + photos::order_id.eq(order_id), + photos::owner_id.eq(identity.as_str()), + photos::content_id.eq(file_id.as_bytes().to_vec()), + photos::content_state.eq::>(None), + )) + .execute(connection)?; + Ok::( + photos::table + .select(photos::id) + .load::(connection)? + .into_iter() + .find(|id| !before_photos.contains(id)) + .expect("minted photo id"), + ) + }) + }) + .await + .expect("stage photo and write rows"); + client.replay_pending().await.expect("send photo mutation"); + assert_eq!(file_id, FileId::from_chunks([bytes.as_slice()])); + harness::stage("photo staged"); + + let available = loop { + if let Some(photo) = live + .rows() + .iter() + .find(|photo| { + photo.id == photo_id && photo.content_state.as_deref() == Some("available") + }) + .cloned() + { + break photo; + } + live.changed().await.expect("photo refresh"); + }; + assert_eq!(available.owner_id, identity); + assert_eq!(available.content_id, file_id.as_bytes().to_vec()); + harness::stage("photo available"); + + // Owner resolves first, then the viewer gets Unavailable, then the owner + // resolves again. That order proves policy refusal rather than timing. + let owner_url = match content.resolve(file_id).await { + TabResolved::Remote { url } => url, + TabResolved::Local { .. } => { + panic!("an uploaded available photo must resolve to the server") + } + TabResolved::Unavailable => panic!("an available photo must resolve"), + }; + assert_eq!(fetch_bytes(&owner_url).await, bytes); + harness::stage("owner fetched"); + + let (viewer_token, viewer_identity) = common::mint_session_as("viewer").await; + let viewer_conn = harness::connect_server( + "photo-visibility-viewer", + harness::unique_base(), + viewer_token, + &viewer_identity, + ) + .await; + let (viewer_client, viewer_pump) = ConnettoClient::with_pump(viewer_conn); + let (viewer_done_tx, viewer_done) = oneshot::channel::<()>(); + spawn_local(async move { + viewer_pump.await; + let _ = viewer_done_tx.send(()); + }); + let viewer_content = ContentClient::attach( + viewer_client, + BrowserStore::ephemeral(), + [7; 32], + BrowserHttp::new(), + ) + .await + .expect("attach viewer content client"); + match viewer_content.resolve(file_id).await { + Err(ContentError::TicketRefused { file_id: refused }) if refused == file_id => {} + other => { + panic!("the second viewer must be refused another user's photo, got {other:?}") + } + } + harness::stage("viewer refused"); + drop(viewer_content); + viewer_done.await.expect("viewer pump exited"); + + let owner_url = match content.resolve(file_id).await { + TabResolved::Remote { url } => url, + TabResolved::Local { .. } => panic!("owner access must survive the viewer refusal"), + TabResolved::Unavailable => panic!("owner access must survive the viewer refusal"), + }; + assert_eq!(fetch_bytes(&owner_url).await, bytes); + harness::stage("owner fetched again"); + + drop(live); + drop(content); + drop(client); + pump_done.await.expect("pump exited"); + worker.terminate(); +} From 3eefe9efbbcf37121b9fda57a3ed1d6d26f6a222 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Fri, 18 Sep 2026 08:25:24 +0200 Subject: [PATCH 2/2] Sync the R69 status row and prose with F landing --- docs/architecture/18-file-handling.md | 2 +- plans/master-implementation-plan.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/architecture/18-file-handling.md b/docs/architecture/18-file-handling.md index c421a59b..509590c7 100644 --- a/docs/architecture/18-file-handling.md +++ b/docs/architecture/18-file-handling.md @@ -1,6 +1,6 @@ # 18: File handling -**Status**: normative for the decisions it records. R64 the file core, R65 the file server, R66 the connetto seam, R67 the native client and R68 the browser client are built. R69 the demos is in progress, its executable half, demo schemas, tab and worker content protocol and browser-stack wiring built 2026-09-17 as pull requests #28 to #31, with the demo surfaces and the offline and two-viewer proofs open. R79 the peer link and R87 the quotas are not built. Every statement carries **Decided (RN)** or an **Amended (RN)** beside it, where `RN` is the phase in `plans/master-implementation-plan.md` that owns it, and that phase's section records each decision with its rejected alternatives. Chapter 07 is the historical record of the thinking that preceded these decisions and defers to this chapter wherever the two disagree. +**Status**: normative for the decisions it records. R64 the file core, R65 the file server, R66 the connetto seam, R67 the native client and R68 the browser client are built. R69 the demos is in progress, its executable half, demo schemas, tab and worker content protocol and browser-stack wiring built 2026-09-17 as pull requests #28 to #31, and its offline stage, reconnect upload and two-viewer refusal proofs built 2026-09-18 with the recovery table's Frame row amended to match, leaving the demo surfaces open. R79 the peer link and R87 the quotas are not built. Every statement carries **Decided (RN)** or an **Amended (RN)** beside it, where `RN` is the phase in `plans/master-implementation-plan.md` that owns it, and that phase's section records each decision with its rejected alternatives. Chapter 07 is the historical record of the thinking that preceded these decisions and defers to this chapter wherever the two disagree. --- diff --git a/plans/master-implementation-plan.md b/plans/master-implementation-plan.md index 73388509..a9610064 100644 --- a/plans/master-implementation-plan.md +++ b/plans/master-implementation-plan.md @@ -249,7 +249,7 @@ Execution order and nothing else. Status, blockers, landing dates and what each | R87 storage quotas and deployment ceilings | NOT STARTED, raised 2026-09-08 | R65, which is done | no | | R67 native file client | **DONE** (2026-09-08) | nothing. `connetto-file-client`: the `std::fs` encrypted chunk store, the manifests and outbox in the replica committed with the entry row, the outbox walk with a boot integrity pass, the resolver over a `LocalContentSource` list, the query-shaped pin surface with a whole-file fetch, and `tidy_content`. Thirteen decisions recorded above, three of them defects found by grounding: the tier cannot be atomic with the replica, `MemStore` answered empty bytes for an absent chunk, and a double-quoted pin column silently became a string literal. 28 tests, the offline photo case among them, end to end against a real Postgres, a real file server on a socket and two real devices | no | | R68 browser file client | **DONE** (2026-09-10) | nothing. Worker-owned encrypted OPFS with memory fallback, browser fetch, reference-counted object URLs, and version 3 archives that restore unsent content through the production worker relay. The offline photo survives export, import under another key, local display and later upload. The browser stack passed and the full release suite passed 738 tests with 3 skipped | no | -| R69 files in every demo | IN PROGRESS (A, B, C and the browser-stack wiring done 2026-09-17), designed (2026-09-12) | nothing. A is #28, B is #29, C is #30, and the browser stack boots the executable's file half with the photo flow proven by `examples/wasm-smoke/tests/photo_flow.rs`. D, E and F remain | no | +| R69 files in every demo | IN PROGRESS (A, B, C, browser-stack wiring and F done by 2026-09-18), designed (2026-09-12) | nothing. A is #28, B is #29, C is #30, and the browser stack boots the executable's file half with the online photo flow proven by `photo_flow.rs` and F's offline and two-viewer proofs by `photo_offline.rs` and `photo_visibility.rs` in this pull request. D and E remain | no | | R70 backup and restore story | NOT STARTED | nothing | no | | R71 Linux key custody survives reboot | NOT STARTED | nothing for grounding. One custody decision to take with the maintainer at execution | no | | R72 clock discipline (X6) | NOT STARTED | nothing | no | @@ -4733,7 +4733,7 @@ The offline photo case runs in headless Chrome end to end, and an export taken o ## R69: files in every demo -**Status.** IN PROGRESS (2026-09-17), **designed 2026-09-12** with the maintainer. A is #28, B is #29 and C is #30, and #31 landed the browser-stack half of A with `examples/wasm-smoke/tests/photo_flow.rs` driving a photo through stage, commit, resolve and fetch in headless Chrome, so the executable's file half now boots for real and the online round trip is proven in CI. D, E and F remain, and F is next, the offline stage that uploads on reconnect and the second viewer refused without the grant. The phase as first written assumed three things that did not exist, found by reading every module of the file stack against the two steps: no `main` ran the file server's router (only `crates/connetto-file-client/tests/it/offline_photo.rs` binds one), the shipped `connetto-server` executable passed `NoSigner` (`bin/connetto-server.rs`) so no deployment could mint a ticket, and no demo schema declared the metadata table or the two SQL functions the file server's preflight requires. Six decisions below close them, each with what was rejected, and the steps are seven pull requests with their dependencies stated so three of them run at once. +**Status.** IN PROGRESS (2026-09-17), **designed 2026-09-12** with the maintainer. A is #28, B is #29 and C is #30, and #31 landed the browser-stack half of A with `examples/wasm-smoke/tests/photo_flow.rs` driving a photo through stage, commit, resolve and fetch in headless Chrome, so the executable's file half now boots for real and the online round trip is proven in CI. F is built in the pull request that adds `examples/wasm-smoke/tests/photo_offline.rs` and `photo_visibility.rs`, the stage that completes behind a connect gate and uploads on attach, the second viewer refused by the server's mint under the owner-only policy, and the recovery table's Frame row amended to match. D and E remain. The phase as first written assumed three things that did not exist, found by reading every module of the file stack against the two steps: no `main` ran the file server's router (only `crates/connetto-file-client/tests/it/offline_photo.rs` binds one), the shipped `connetto-server` executable passed `NoSigner` (`bin/connetto-server.rs`) so no deployment could mint a ticket, and no demo schema declared the metadata table or the two SQL functions the file server's preflight requires. Six decisions below close them, each with what was rejected, and the steps are seven pull requests with their dependencies stated so three of them run at once. **Blocked on nothing.** R64 to R68 are done, and the twelve questions raised before this phase (`plans/open-questions-before-r69.md`) were settled, and the nine needing code were built 2026-09-14 to 2026-09-16 as pull requests #18 to #27: the teardown list, the anonymous boot, the two permanent upload outcomes, the silence-bounded transfer, the streaming archive, the memory fallback, the keyring index, the span-chained log capture and the cursor-bounded silence assertion, each recorded in chapters 13, 14, 18 or `open-questions.md`.