Skip to repository content
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T00:58:28.764Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitRevision diff
40e567f1 → e5b422b1diff --git a/Cargo.lock b/Cargo.lock
index 5727e53081..d5a3f25a0b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11983,6 +11983,7 @@ dependencies = [
1198311983 "thiserror 2.0.17",
1198411984 "url",
1198511985 "util",
11986+ "workroom_receipts",
1198611987 "zed_credentials_provider",
1198711988 ]
1198811989
diff --git a/crates/agent_ui/src/omega_host_bridge.rs b/crates/agent_ui/src/omega_host_bridge.rs
index 17ac363d36..60555bac20 100644
--- a/crates/agent_ui/src/omega_host_bridge.rs
+++ b/crates/agent_ui/src/omega_host_bridge.rs
@@ -425,6 +425,12 @@ fn production_sarah_conversation() -> Result<SarahConversationClient, HostRespon
425425 conversation.set_issue31_host_projection_source(
426426 full_auto_ui::issue31_host_projection_source(),
427427 );
428+ // omega#91: how the host reads its own provider accounts when it decides
429+ // which one a connection handoff binds to. Without it no handoff can bind,
430+ // and every one the phone opens runs to its deadline and expires.
431+ conversation.set_issue31_provider_roster_source(
432+ full_auto_ui::issue31_provider_roster_source(),
433+ );
428434 Ok(conversation)
429435 }
430436
diff --git a/crates/full_auto_ui/src/full_auto_ui.rs b/crates/full_auto_ui/src/full_auto_ui.rs
index 7788e6a2db..3be14e7e7b 100644
--- a/crates/full_auto_ui/src/full_auto_ui.rs
+++ b/crates/full_auto_ui/src/full_auto_ui.rs
@@ -25,6 +25,7 @@ pub use issue31_adjunct::{
2525 };
2626 pub use issue31_delivery::{
2727 issue31_host_projection_documents, issue31_host_projection_source,
28+ issue31_provider_roster_source,
2829 latest_issue31_live_reading, set_issue31_live_reading, Issue31FullAutoReading,
2930 };
3031 pub use panel::FullAutoPanel;
diff --git a/crates/full_auto_ui/src/issue31_delivery.rs b/crates/full_auto_ui/src/issue31_delivery.rs
index 859564f405..3b30b71e55 100644
--- a/crates/full_auto_ui/src/issue31_delivery.rs
+++ b/crates/full_auto_ui/src/issue31_delivery.rs
@@ -24,6 +24,7 @@ use std::sync::{Arc, Mutex, OnceLock};
2424
2525 use omega_effectd::{
2626 Issue31HostProjectionDocuments, Issue31HostProjectionRequest, Issue31HostProjectionSource,
27+ Issue31ProviderRosterAccount, Issue31ProviderRosterSource,
2728 };
2829 use serde_json::Value;
2930 use sha2::{Digest, Sha256};
@@ -32,6 +33,7 @@ use crate::issue31_adjunct::{
3233 Issue31FullAutoLiveSources, Issue31HostIdentitySource, Issue31HostProjectionError,
3334 publish_issue31_host_snapshot,
3435 };
36+use crate::provider_roster::parse_provider_accounts;
3537
3638 /// One complete reading of the live Full Auto surface, owned rather than
3739 /// borrowed so it can be cached between the panel that reads it and the pump
@@ -50,7 +52,6 @@ pub struct Issue31FullAutoReading {
5052 pub run_details: Vec<Value>,
5153 /// The `get_capacity` record.
5254 pub capacity: Value,
53- pub handoffs: Vec<Value>,
5455 /// One `(get_report, get_receipt)` pair per run with evidence.
5556 pub evidence: Vec<(Value, Value)>,
5657 }
@@ -63,14 +64,21 @@ impl Issue31FullAutoReading {
6364 /// the observed state changes. Deriving it from the content means a pump
6465 /// pass that saw the same world republishes nothing, and a pass that saw a
6566 /// different world cannot reuse the old label.
66- pub fn snapshot_ref(&self) -> String {
67+ ///
68+ /// `handoffs` is passed in rather than held because the handoff ledger is
69+ /// durable host state owned by the pump, not part of the panel's reading
70+ /// of the daemon (omega#91). It is folded in here all the same: a handoff
71+ /// that opened, bound, or ended is a different world, and a snapshot
72+ /// reference that did not move would leave the phone holding two different
73+ /// details under one label.
74+ pub fn snapshot_ref(&self, handoffs: &[Value]) -> String {
6775 let mut hasher = Sha256::new();
6876 hasher.update(self.host_generation.to_string().as_bytes());
6977 for run in &self.run_details {
7078 hasher.update(run.to_string().as_bytes());
7179 }
7280 hasher.update(self.capacity.to_string().as_bytes());
73- for handoff in &self.handoffs {
81+ for handoff in handoffs {
7482 hasher.update(handoff.to_string().as_bytes());
7583 }
7684 for (report, receipt) in &self.evidence {
@@ -89,7 +97,12 @@ pub fn issue31_host_projection_documents(
8997 reading: &Issue31FullAutoReading,
9098 request: &Issue31HostProjectionRequest<'_>,
9199 ) -> Result<Issue31HostProjectionDocuments, Issue31HostProjectionError> {
92- let snapshot_ref = reading.snapshot_ref();
100+ // Projected against the exact stamp these documents will carry, not
101+ // against the pump's own clock. A handoff opened after this reading was
102+ // taken belongs to the next snapshot, and the contract says so: a row whose
103+ // request time is newer than `generatedAtMs` is refused (omega#91).
104+ let handoffs = request.handoffs.projected(reading.generated_at_ms).rows;
105+ let snapshot_ref = reading.snapshot_ref(&handoffs);
93106 let sources = Issue31FullAutoLiveSources {
94107 host_ref: request.host_ref,
95108 snapshot_ref: &snapshot_ref,
@@ -97,7 +110,7 @@ pub fn issue31_host_projection_documents(
97110 host_generation: reading.host_generation,
98111 run_details: &reading.run_details,
99112 capacity: &reading.capacity,
100- handoffs: &reading.handoffs,
113+ handoffs: &handoffs,
101114 evidence: &reading.evidence,
102115 };
103116 // The grant is what makes this reader's role active. Without one the
@@ -137,6 +150,33 @@ pub fn latest_issue31_live_reading() -> Option<Issue31FullAutoReading> {
137150 reading_cache().lock().ok().and_then(|cache| cache.clone())
138151 }
139152
153+/// How the Sarah host pump reads this host's provider roster (omega#91).
154+///
155+/// Routed through the exact `parse_provider_accounts` the desktop roster panel
156+/// renders, so the account a handoff binds to, and the lane that account
157+/// serves, are the same ones Omega shows on the desktop. A handoff that chose
158+/// an account the panel does not list would be a second opinion about the
159+/// host's own capacity.
160+///
161+/// `None` when the host has never read its Full Auto state at all — the same
162+/// silence `latest_issue31_live_reading` means, propagated rather than
163+/// flattened into "this host holds no accounts".
164+pub fn issue31_provider_roster_source() -> Issue31ProviderRosterSource {
165+ Arc::new(|| {
166+ latest_issue31_live_reading().map(|reading| {
167+ parse_provider_accounts(&reading.capacity)
168+ .into_iter()
169+ .map(|account| Issue31ProviderRosterAccount {
170+ account_ref: account.account_ref,
171+ provider: account.provider,
172+ lane_ref: account.lane,
173+ readiness: account.readiness,
174+ })
175+ .collect()
176+ })
177+ })
178+}
179+
140180 /// The source the Sarah host pump publishes from.
141181 pub fn issue31_host_projection_source() -> Issue31HostProjectionSource {
142182 Arc::new(|request| match latest_issue31_live_reading() {
@@ -150,6 +190,7 @@ pub fn issue31_host_projection_source() -> Issue31HostProjectionSource {
150190 #[cfg(test)]
151191 mod tests {
152192 use super::*;
193+ use omega_effectd::Issue31ProviderHandoffLedger;
153194 use serde_json::json;
154195
155196 /// Exactly what a running `omega-effectd` returned, captured on
@@ -169,7 +210,42 @@ mod tests {
169210 const HOST_KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
170211 const DEVICE_KEY: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
171212
172- fn request<'a>(host_ref: &'a str, grant_ref: &'a str) -> Issue31HostProjectionRequest<'a> {
213+ fn request<'a>(
214+ host_ref: &'a str,
215+ grant_ref: &'a str,
216+ handoffs: &'a Issue31ProviderHandoffLedger,
217+ ) -> Issue31HostProjectionRequest<'a> {
218+ request_with_handoffs(host_ref, grant_ref, handoffs)
219+ }
220+
221+ /// A host holding no provider connection handoff. Distinct, on the wire,
222+ /// from a host that holds one it cannot state (omega#91).
223+ fn no_handoffs() -> Issue31ProviderHandoffLedger {
224+ Issue31ProviderHandoffLedger::default()
225+ }
226+
227+ /// A ledger built from the exact durable bytes a restart would read back.
228+ fn ledger(rows: &[Value]) -> Issue31ProviderHandoffLedger {
229+ let entries: serde_json::Map<String, Value> = rows
230+ .iter()
231+ .map(|row| {
232+ (
233+ row.get("handoffRef")
234+ .and_then(Value::as_str)
235+ .expect("handoffRef")
236+ .to_string(),
237+ row.clone(),
238+ )
239+ })
240+ .collect();
241+ serde_json::from_value(json!({ "entries": entries })).expect("durable ledger bytes")
242+ }
243+
244+ fn request_with_handoffs<'a>(
245+ host_ref: &'a str,
246+ grant_ref: &'a str,
247+ handoffs: &'a Issue31ProviderHandoffLedger,
248+ ) -> Issue31HostProjectionRequest<'a> {
173249 Issue31HostProjectionRequest {
174250 host_ref,
175251 host_public_key_hex: HOST_KEY,
@@ -177,6 +253,7 @@ mod tests {
177253 grant_ref,
178254 expected_generation: 1,
179255 observed_at_ms: LIVE_GENERATED_AT_MS,
256+ handoffs,
180257 }
181258 }
182259
@@ -186,7 +263,6 @@ mod tests {
186263 host_generation: 19,
187264 run_details: vec![live("get_run", LIVE_RUN)],
188265 capacity: live("get_capacity", LIVE_CAPACITY),
189- handoffs: Vec::new(),
190266 evidence: Vec::new(),
191267 }
192268 }
@@ -195,7 +271,7 @@ mod tests {
195271 fn documents_carry_the_grant_host_and_one_shared_snapshot() {
196272 let documents = issue31_host_projection_documents(
197273 &reading(),
198- &request("omega.host.local", "grant.omega.device_1"),
274+ &request("omega.host.local", "grant.omega.device_1", &no_handoffs()),
199275 )
200276 .expect("live host state projects");
201277 assert_eq!(
@@ -223,7 +299,7 @@ mod tests {
223299 // it here would let a reading address itself to a device.
224300 let documents = issue31_host_projection_documents(
225301 &reading(),
226- &request("omega.host.local", "grant.omega.device_1"),
302+ &request("omega.host.local", "grant.omega.device_1", &no_handoffs()),
227303 )
228304 .expect("live host state projects");
229305 for document in [&documents.host, &documents.detail] {
@@ -244,10 +320,145 @@ mod tests {
244320 // The pump publishes only when the digest changes, so a stable
245321 // reference is what stops a device being re-sent the same snapshot
246322 // forever.
247- assert_eq!(reading().snapshot_ref(), reading().snapshot_ref());
248- let mut changed = reading();
249- changed.handoffs.push(json!({ "handoffRef": "handoff.omega.1" }));
250- assert_ne!(reading().snapshot_ref(), changed.snapshot_ref());
323+ assert_eq!(
324+ reading().snapshot_ref(&[]),
325+ reading().snapshot_ref(&[])
326+ );
327+ // A handoff change alone is a different world, and has to move the
328+ // reference the detail is published under (omega#91).
329+ let handoffs = [json!({ "handoffRef": "handoff.omega.1" })];
330+ assert_ne!(
331+ reading().snapshot_ref(&[]),
332+ reading().snapshot_ref(&handoffs)
333+ );
334+ }
335+
336+ /// A capacity record that reports accounts, the way omega#42's roster
337+ /// reads it. The captured live `get_capacity` reports lanes and no
338+ /// accounts, which is why a handoff on this machine cannot bind — that is
339+ /// the host's real state, not a gap in this test.
340+ fn capacity_with_accounts() -> Value {
341+ json!({
342+ "lanes": [{"lane": "claude-local", "state": "available", "activeRuns": 0}],
343+ "accounts": [{
344+ "accountRef": "account.claude.1",
345+ "provider": "anthropic",
346+ "label": "Claude",
347+ "state": "ready",
348+ "quotaState": "available",
349+ "lane": "lane.claude-local",
350+ }],
351+ })
352+ }
353+
354+ #[test]
355+ fn a_host_owned_handoff_reaches_the_detail_document_the_phone_reads() {
356+ let mut reading = reading();
357+ reading.capacity = capacity_with_accounts();
358+ let handoffs = ledger(&[json!({
359+ "handoffRef": "handoff.omega.0123456789abcdef01234567",
360+ "provider": "anthropic",
361+ "state": "completed",
362+ "requestedAtMs": LIVE_GENERATED_AT_MS - 60_000,
363+ "accountRef": "account.claude.1",
364+ "outcomeRef": "outcome.omega.handoff_connected",
365+ })]);
366+ let documents = issue31_host_projection_documents(
367+ &reading,
368+ &request_with_handoffs("omega.host.local", "grant.omega.device_1", &handoffs),
369+ )
370+ .expect("a bound handoff projects");
371+ let projected = documents
372+ .detail
373+ .get("handoffs")
374+ .and_then(Value::as_array)
375+ .expect("handoffs");
376+ assert_eq!(projected.len(), 1);
377+ assert_eq!(
378+ projected[0].get("state").and_then(Value::as_str),
379+ Some("completed")
380+ );
381+ // The account-to-lane relation is why this handoff chose this account,
382+ // and the phone can follow it without asking the host again.
383+ let accounts = documents
384+ .detail
385+ .get("accounts")
386+ .and_then(Value::as_array)
387+ .expect("accounts");
388+ assert_eq!(
389+ accounts[0].get("accountRef").and_then(Value::as_str),
390+ projected[0].get("accountRef").and_then(Value::as_str),
391+ );
392+ assert_eq!(
393+ accounts[0].get("laneRef").and_then(Value::as_str),
394+ Some("lane.claude-local"),
395+ );
396+ }
397+
398+ #[test]
399+ fn a_handoff_naming_an_account_this_host_does_not_carry_is_refused() {
400+ // The whole projection fails rather than shipping a handoff that points
401+ // at a row the phone cannot open.
402+ let handoffs = ledger(&[json!({
403+ "handoffRef": "handoff.omega.0123456789abcdef01234567",
404+ "provider": "anthropic",
405+ "state": "completed",
406+ "requestedAtMs": LIVE_GENERATED_AT_MS - 60_000,
407+ "accountRef": "account.claude.1",
408+ "outcomeRef": "outcome.omega.handoff_connected",
409+ })]);
410+ let error = issue31_host_projection_documents(
411+ &reading(),
412+ &request_with_handoffs("omega.host.local", "grant.omega.device_1", &handoffs),
413+ )
414+ .expect_err("a handoff must point at an account this snapshot carries");
415+ assert!(error.to_string().contains("unknown record"), "{error}");
416+ }
417+
418+ #[test]
419+ fn a_failed_handoff_and_no_handoff_are_different_documents() {
420+ // The distinction the omega#91 exit turns on, at the delivery boundary.
421+ let failed = ledger(&[json!({
422+ "handoffRef": "handoff.omega.0123456789abcdef01234567",
423+ "provider": "anthropic",
424+ "state": "failed",
425+ "requestedAtMs": LIVE_GENERATED_AT_MS - 60_000,
426+ "reasonClass": "reason.omega.handoff_host_restarted",
427+ "outcomeRef": "outcome.omega.handoff_interrupted",
428+ })]);
429+ let with_failure = issue31_host_projection_documents(
430+ &reading(),
431+ &request_with_handoffs("omega.host.local", "grant.omega.device_1", &failed),
432+ )
433+ .expect("a failed handoff projects");
434+ let without = issue31_host_projection_documents(
435+ &reading(),
436+ &request("omega.host.local", "grant.omega.device_1", &no_handoffs()),
437+ )
438+ .expect("a host holding no handoff projects");
439+
440+ assert_eq!(
441+ with_failure
442+ .detail
443+ .get("handoffs")
444+ .and_then(Value::as_array)
445+ .map(Vec::len),
446+ Some(1),
447+ );
448+ assert_eq!(
449+ without
450+ .detail
451+ .get("handoffs")
452+ .and_then(Value::as_array)
453+ .map(Vec::len),
454+ Some(0),
455+ );
456+ // And they are published under different snapshot references, so a
457+ // device cannot hold one while believing it holds the other.
458+ assert_ne!(
459+ with_failure.detail.get("snapshotRef"),
460+ without.detail.get("snapshotRef"),
461+ );
251462 }
252463
253464 /// The whole wire, end to end, against the DEPLOYED relay (omega#49).
@@ -326,7 +537,6 @@ mod tests {
326537 host_generation: 1,
327538 run_details: Vec::new(),
328539 capacity: json!({ "accounts": [] }),
329- handoffs: Vec::new(),
330540 evidence: Vec::new(),
331541 });
332542 client.set_issue31_host_projection_source(issue31_host_projection_source());
@@ -355,6 +565,558 @@ mod tests {
355565 );
356566 }
357567
568+ /// The omega#91 exit, against the DEPLOYED relay.
569+ ///
570+ /// A paired device holding `request_provider_handoff` publishes a real
571+ /// signed command intent to a real relay. The host reads it back off that
572+ /// relay through `sync_issue31_host` — the same entry point `bootstrap`
573+ /// calls — admits it through the shipped controller, opens a handoff, binds
574+ /// it against its own roster, completes it, and publishes each state in the
575+ /// `fullauto.v1` detail addressed to that device. The failure path runs on
576+ /// a second device in the same pass, and its record is asserted to be
577+ /// distinguishable from the third device, which never asked at all.
578+ ///
579+ /// Two substitutions, both named rather than hidden:
580+ ///
581+ /// - identity custody. The owner key is a keypair rather than
582+ /// `omega_identity::IdentityService`, because custody needs the GPUI app
583+ /// and this harness runs headless. A run proves the host protocol and the
584+ /// relay, not owner key custody.
585+ /// - the Full Auto reading. No `omega-effectd` daemon is attached to this
586+ /// process, so the host's roster reading is supplied here rather than
587+ /// polled. It is the host's own observation either way — the ledger reads
588+ /// it through the shipped `issue31_provider_roster_source`, and the
589+ /// binding decision is the shipped one.
590+ ///
591+ /// Nothing in this test runs a provider login, resolves a provider home, or
592+ /// touches a credential. A handoff record carries the fact of a connection
593+ /// and never the connection secret.
594+ ///
595+ /// ```sh
596+ /// OMEGA_LIVE_RELAY_URL=wss://relay.openagents.com \
597+ /// cargo test -p full_auto_ui --lib \
598+ /// a_provider_handoff_appears_binds_and_settles_on_a_live_relay -- --ignored --nocapture
599+ /// ```
600+ #[test]
601+ #[ignore = "requires a live relay; set OMEGA_LIVE_RELAY_URL"]
602+ fn a_provider_handoff_appears_binds_and_settles_on_a_live_relay() {
603+ let Ok(relay_url) = std::env::var("OMEGA_LIVE_RELAY_URL") else {
604+ eprintln!("OMEGA_LIVE_RELAY_URL unset; skipping");
605+ return;
606+ };
607+ let host_keys = nostr::Keys::generate();
608+ let sarah_keys = nostr::Keys::generate();
609+ let device_keys = nostr::Keys::generate();
610+ let device_public_key_hex = device_keys.public_key().to_hex();
611+ let signer = omega_effectd::SigningIdentity::from_keys(host_keys.clone());
612+ let owner_public_key_hex = signer.public_key_hex.clone();
613+
614+ let mut config = omega_effectd::SarahConversationConfig::mock_fixture();
615+ config.identity.owner_public_key_hex = owner_public_key_hex.clone();
616+ config.identity.sarah_public_key_hex = sarah_keys.public_key().to_hex();
617+ config.conversation_digest = owner_public_key_hex[..24].to_string();
618+ config.relay_url = Some(relay_url.clone());
619+ let conversation_ref = config.conversation_ref();
620+
621+ let relay = omega_effectd::WebSocketRelayAdapter::new_for_keys_with_policy(
622+ vec![relay_url.clone()],
623+ host_keys.clone(),
624+ sarah_keys.public_key().to_hex(),
625+ Vec::new(),
626+ Vec::new(),
627+ )
628+ .expect("host relay adapter");
629+ let controller = live_paired_controller_with_scopes(
630+ &owner_public_key_hex,
631+ &sarah_keys.public_key().to_hex(),
632+ &conversation_ref,
633+ &relay_url,
634+ &device_public_key_hex,
635+ &[
636+ omega_effectd::Issue31PairingScope::ObserveIssue31,
637+ omega_effectd::Issue31PairingScope::RequestProviderHandoff,
638+ ],
639+ );
640+ let grant = controller
641+ .active_grants(unix_seconds())
642+ .expect("grants")
643+ .first()
644+ .cloned()
645+ .expect("the paired device holds an active grant");
646+
647+ let mut client =
648+ omega_effectd::SarahConversationClient::with_relay(config, Box::new(relay), signer);
649+ client.attach_issue31_host_controller(controller);
650+ // The host's own reading, carrying one provider account. Everything the
651+ // handoff does with it goes through the shipped roster source. Refreshed
652+ // before each pass exactly as the desktop panel's poll refreshes it:
653+ // the snapshot's `generatedAtMs` is when the host last looked, and a
654+ // handoff stamped after that reading belongs to the next snapshot.
655+ let observe = || {
656+ set_issue31_live_reading(Issue31FullAutoReading {
657+ generated_at_ms: unix_seconds().saturating_mul(1_000) + 1_000,
658+ host_generation: 1,
659+ run_details: Vec::new(),
660+ capacity: capacity_with_accounts(),
661+ evidence: Vec::new(),
662+ });
663+ };
664+ observe();
665+ client.set_issue31_host_projection_source(issue31_host_projection_source());
666+ client.set_issue31_provider_roster_source(issue31_provider_roster_source());
667+
668+ // The device asks, on the relay, exactly as a phone would.
669+ let now = unix_seconds();
670+ let intent = serde_json::json!({
671+ "recordType": "command_intent",
672+ "schema": "openagents.omega.issue31.command.v1",
673+ "hostRef": grant.host_ref,
674+ "hostPublicKeyHex": owner_public_key_hex,
675+ "devicePublicKeyHex": device_public_key_hex,
676+ "grantRef": grant.grant_ref,
677+ "actionRef": "action.omega.provider_handoff",
678+ "idempotencyRef": format!("idempotency.issue31.handoff:{now}"),
679+ "expectedGeneration": grant.generation,
680+ "argumentsRef": "arguments.omega.provider_handoff.anthropic",
681+ "issuedAt": now,
682+ "expiresAt": now + 3_600,
683+ });
684+ publish_device_command(&relay_url, &device_keys, &host_keys, &intent);
685+
686+ // Pass one: the host reads the ask off the relay and the handoff
687+ // appears.
688+ client
689+ .sync_issue31_host()
690+ .expect("the shipped host pump runs against the live relay");
691+ let observed = |client: &omega_effectd::SarahConversationClient| -> Vec<Value> {
692+ client.issue31_projected_provider_handoffs(unix_seconds().saturating_mul(1_000) + 1_000)
693+ };
694+ let appeared = observed(&client);
695+ assert_eq!(
696+ appeared.len(),
697+ 1,
698+ "the scope the phone holds must produce exactly one host record",
699+ );
700+ let handoff_ref = appeared[0]
701+ .get("handoffRef")
702+ .and_then(Value::as_str)
703+ .expect("handoffRef")
704+ .to_string();
705+ eprintln!("live relay OK: handoff {handoff_ref} appeared as {:?}", appeared[0].get("state"));
706+
707+ // Pass two: it binds to the account the host's own roster reports.
708+ observe();
709+ client.sync_issue31_host().expect("second pump pass");
710+ let bound = observed(&client);
711+ assert_eq!(bound[0].get("state").and_then(Value::as_str), Some("active"));
712+ assert_eq!(
713+ bound[0].get("accountRef").and_then(Value::as_str),
714+ Some("account.claude.1"),
715+ );
716+ eprintln!("live relay OK: handoff {handoff_ref} bound to account.claude.1");
717+
718+ // Pass three: it reaches a terminal, host-owned outcome.
719+ observe();
720+ client.sync_issue31_host().expect("third pump pass");
721+ let settled = observed(&client);
722+ assert_eq!(
723+ settled[0].get("state").and_then(Value::as_str),
724+ Some("completed"),
725+ );
726+ assert_eq!(
727+ settled[0].get("outcomeRef").and_then(Value::as_str),
728+ Some("outcome.omega.handoff_connected"),
729+ );
730+
731+ // Every state crossed the relay: the outbox drains only when every
732+ // configured relay acknowledged every gift wrap.
733+ assert!(
734+ client.issue31_pending_private_publish_refs().is_empty(),
735+ "the live relay must acknowledge every owner-private record: {:?}",
736+ client.issue31_pending_private_publish_refs(),
737+ );
738+ assert!(
739+ client
740+ .issue31_published_host_adjunct_grants()
741+ .contains(&format!("{}:{}", grant.grant_ref, grant.generation)),
742+ "the pump must record the omega#47 publication it made for this grant",
743+ );
744+ eprintln!(
745+ "live relay OK: {relay_url} carried handoff {handoff_ref} through \
746+ requested -> active -> completed for grant {}",
747+ grant.grant_ref,
748+ );
749+ }
750+
751+ /// Pair one more device into an existing controller, through the real
752+ /// pairing state machine, with its own requested scope set.
753+ fn live_pair_device(
754+ controller: &mut omega_effectd::Issue31HostController,
755+ host_public_key_hex: &str,
756+ device_public_key_hex: &str,
757+ requested_scopes: &[omega_effectd::Issue31PairingScope],
758+ seed: char,
759+ ) -> String {
760+ use omega_effectd::{Issue31PairingEvent, Issue31PairingRecord};
761+ let host_ref = "omega.host.local".to_string();
762+ let now = unix_seconds();
763+ let event_id = |suffix: char| format!("{seed}{}", suffix.to_string().repeat(63));
764+ let request_event = event_id('a');
765+ let challenge_event = event_id('b');
766+ let challenge = controller
767+ .handle_pairing_event(
768+ Issue31PairingEvent {
769+ event_id: request_event,
770+ record: Issue31PairingRecord::PairingRequest {
771+ schema: "openagents.omega.issue31.pairing.v1".into(),
772+ host_ref: host_ref.clone(),
773+ host_public_key_hex: host_public_key_hex.to_string(),
774+ device_public_key_hex: device_public_key_hex.to_string(),
775+ issued_at: now,
776+ pairing_request_ref: format!("pairing_request.live.{seed}"),
777+ requested_scopes: requested_scopes.to_vec(),
778+ expires_at: now + 86_400,
779+ },
780+ },
781+ now,
782+ )
783+ .expect("pairing request")
784+ .expect("pairing challenge");
785+ let Issue31PairingRecord::PairingChallenge {
786+ challenge: challenge_value,
787+ ..
788+ } = &challenge
789+ else {
790+ panic!("expected a pairing challenge");
791+ };
792+ let challenge_value = challenge_value.clone();
793+ controller
794+ .record_emitted_pairing(challenge_event.clone(), challenge)
795+ .expect("record the challenge");
796+ let grant = controller
797+ .handle_pairing_event(
798+ Issue31PairingEvent {
799+ event_id: event_id('c'),
800+ record: Issue31PairingRecord::PairingResponse {
801+ schema: "openagents.omega.issue31.pairing.v1".into(),
802+ host_ref,
803+ host_public_key_hex: host_public_key_hex.to_string(),
804+ device_public_key_hex: device_public_key_hex.to_string(),
805+ issued_at: now + 1,
806+ pairing_response_ref: format!("pairing_response.live.{seed}"),
807+ pairing_challenge_event_id: challenge_event,
808+ challenge: challenge_value,
809+ expires_at: now + 86_400,
810+ },
811+ },
812+ now + 1,
813+ )
814+ .expect("pairing response")
815+ .expect("scoped grant");
816+ let Issue31PairingRecord::ScopedGrant { grant_ref, .. } = &grant else {
817+ panic!("expected a scoped grant");
818+ };
819+ let grant_ref = grant_ref.clone();
820+ controller
821+ .record_emitted_pairing(event_id('d'), grant)
822+ .expect("record the grant");
823+ grant_ref
824+ }
825+
826+ /// The omega#91 exit's second half, against the DEPLOYED relay.
827+ ///
828+ /// The failure path has to be visibly different from a request that never
829+ /// started, or a phone cannot tell "the host tried and could not" from "the
830+ /// host never heard you". Both happen here, in one run, on one relay:
831+ ///
832+ /// - a device without the scope asks. The host refuses the command and
833+ /// makes **no record**. The handoff list stays empty.
834+ /// - a device with the scope asks. The host opens a record, binds it to the
835+ /// account its own roster reports, and ends it `refused` because that
836+ /// account is revoked — with a host-owned reason and outcome the phone can
837+ /// read.
838+ ///
839+ /// The substitutions are the same two named on the success proof.
840+ ///
841+ /// ```sh
842+ /// OMEGA_LIVE_RELAY_URL=wss://relay.openagents.com \
843+ /// cargo test -p full_auto_ui --lib \
844+ /// a_refused_handoff_is_distinct_from_one_that_never_started_on_a_live_relay \
845+ /// -- --ignored --nocapture
846+ /// ```
847+ #[test]
848+ #[ignore = "requires a live relay; set OMEGA_LIVE_RELAY_URL"]
849+ fn a_refused_handoff_is_distinct_from_one_that_never_started_on_a_live_relay() {
850+ let Ok(relay_url) = std::env::var("OMEGA_LIVE_RELAY_URL") else {
851+ eprintln!("OMEGA_LIVE_RELAY_URL unset; skipping");
852+ return;
853+ };
854+ let host_keys = nostr::Keys::generate();
855+ let sarah_keys = nostr::Keys::generate();
856+ let scoped_device = nostr::Keys::generate();
857+ let unscoped_device = nostr::Keys::generate();
858+ let signer = omega_effectd::SigningIdentity::from_keys(host_keys.clone());
859+ let owner_public_key_hex = signer.public_key_hex.clone();
860+
861+ let mut config = omega_effectd::SarahConversationConfig::mock_fixture();
862+ config.identity.owner_public_key_hex = owner_public_key_hex.clone();
863+ config.identity.sarah_public_key_hex = sarah_keys.public_key().to_hex();
864+ config.conversation_digest = owner_public_key_hex[..24].to_string();
865+ config.relay_url = Some(relay_url.clone());
866+ let conversation_ref = config.conversation_ref();
867+
868+ let relay = omega_effectd::WebSocketRelayAdapter::new_for_keys_with_policy(
869+ vec![relay_url.clone()],
870+ host_keys.clone(),
871+ sarah_keys.public_key().to_hex(),
872+ Vec::new(),
873+ Vec::new(),
874+ )
875+ .expect("host relay adapter");
876+ let scopes = [
877+ omega_effectd::Issue31PairingScope::ObserveIssue31,
878+ omega_effectd::Issue31PairingScope::RequestProviderHandoff,
879+ ];
880+ let mut controller = live_paired_controller_with_scopes(
881+ &owner_public_key_hex,
882+ &sarah_keys.public_key().to_hex(),
883+ &conversation_ref,
884+ &relay_url,
885+ &scoped_device.public_key().to_hex(),
886+ &scopes,
887+ );
888+ // Both devices are admitted under one owner policy. The grant is the
889+ // intersection of that policy and what each device asked for, so the
890+ // second device holds a real grant without the handoff scope.
891+ controller
892+ .set_admitted_device_policy(
893+ vec![
894+ scoped_device.public_key().to_hex(),
895+ unscoped_device.public_key().to_hex(),
896+ ],
897+ scopes.to_vec(),
898+ )
899+ .expect("admit both devices");
900+ let unscoped_grant_ref = live_pair_device(
901+ &mut controller,
902+ &owner_public_key_hex,
903+ &unscoped_device.public_key().to_hex(),
904+ &[omega_effectd::Issue31PairingScope::ObserveIssue31],
905+ '1',
906+ );
907+ let grants = controller.active_grants(unix_seconds()).expect("grants");
908+ let scoped_grant = grants
909+ .iter()
910+ .find(|grant| grant.device_public_key_hex == scoped_device.public_key().to_hex())
911+ .cloned()
912+ .expect("the scoped device holds an active grant");
913+ let unscoped_grant = grants
914+ .iter()
915+ .find(|grant| grant.grant_ref == unscoped_grant_ref)
916+ .cloned()
917+ .expect("the unscoped device holds an active grant");
918+ assert!(
919+ !unscoped_grant
920+ .scopes
921+ .contains(&omega_effectd::Issue31PairingScope::RequestProviderHandoff),
922+ "the second device must not hold the handoff scope",
923+ );
924+
925+ let mut client =
926+ omega_effectd::SarahConversationClient::with_relay(config, Box::new(relay), signer);
927+ client.attach_issue31_host_controller(controller);
928+ // The host's roster reports the one anthropic account as revoked, which
929+ // is what makes the terminal outcome a refusal rather than a
930+ // connection.
931+ let observe = || {
932+ set_issue31_live_reading(Issue31FullAutoReading {
933+ generated_at_ms: unix_seconds().saturating_mul(1_000) + 1_000,
934+ host_generation: 1,
935+ run_details: Vec::new(),
936+ capacity: json!({
937+ "lanes": [{"lane": "claude-local", "state": "available", "activeRuns": 0}],
938+ "accounts": [{
939+ "accountRef": "account.claude.1",
940+ "provider": "anthropic",
941+ "label": "Claude",
942+ "state": "revoked",
943+ "quotaState": "depleted",
944+ "lane": "lane.claude-local",
945+ }],
946+ }),
947+ evidence: Vec::new(),
948+ });
949+ };
950+ observe();
951+ client.set_issue31_host_projection_source(issue31_host_projection_source());
952+ client.set_issue31_provider_roster_source(issue31_provider_roster_source());
953+
954+ // Both devices ask, on a real relay, before the host reads either.
955+ // (A NIP-59 gift wrap randomises its `created_at`, so publishing the
956+ // second ask after a pump pass could place it behind the control
957+ // cursor. Publishing both up front makes what this proves independent
958+ // of relay ordering.)
959+ let now = unix_seconds();
960+ let denied_idempotency = format!("idempotency.issue31.handoff-denied:{now}");
961+ let refused_idempotency = format!("idempotency.issue31.handoff-refused:{now}");
962+ publish_device_command(
963+ &relay_url,
964+ &unscoped_device,
965+ &host_keys,
966+ &device_handoff_intent(
967+ &unscoped_grant,
968+ &owner_public_key_hex,
969+ &denied_idempotency,
970+ ),
971+ );
972+ publish_device_command(
973+ &relay_url,
974+ &scoped_device,
975+ &host_keys,
976+ &device_handoff_intent(
977+ &scoped_grant,
978+ &owner_public_key_hex,
979+ &refused_idempotency,
980+ ),
981+ );
982+ for pass in 0..3 {
983+ observe();
984+ client
985+ .sync_issue31_host()
986+ .unwrap_or_else(|error| panic!("pump pass {pass}: {error}"));
987+ }
988+ let settled = client
989+ .issue31_projected_provider_handoffs(unix_seconds().saturating_mul(1_000) + 2_000);
990+ // Two asks crossed the relay and exactly one produced a record. The one
991+ // the host never admitted is absent by the reference it would have had,
992+ // so this is not merely a count.
993+ assert_eq!(settled.len(), 1, "one admitted ask, one record");
994+ assert_eq!(
995+ settled[0].get("handoffRef").and_then(Value::as_str),
996+ Some(
997+ Issue31ProviderHandoffLedger::handoff_ref_for(&refused_idempotency)
998+ .as_str()
999+ ),
1000+ );
1001+ assert!(
1002+ !client.issue31_provider_handoff_refs().contains(
1003+ &Issue31ProviderHandoffLedger::handoff_ref_for(&denied_idempotency)
1004+ ),
1005+ "a request the host never admitted must leave no handoff at all",
1006+ );
1007+ eprintln!("live relay OK: the scope-denied ask left no record");
1008+ assert_eq!(
1009+ settled[0].get("state").and_then(Value::as_str),
1010+ Some("refused"),
1011+ );
1012+ // A failure states both why it ended and what the host decided. That is
1013+ // the whole difference from the empty list above.
1014+ assert_eq!(
1015+ settled[0].get("reasonClass").and_then(Value::as_str),
1016+ Some("reason.omega.handoff_account_revoked"),
1017+ );
1018+ assert_eq!(
1019+ settled[0].get("outcomeRef").and_then(Value::as_str),
1020+ Some("outcome.omega.handoff_refused"),
1021+ );
1022+ assert!(
1023+ client.issue31_pending_private_publish_refs().is_empty(),
1024+ "the live relay must acknowledge every owner-private record: {:?}",
1025+ client.issue31_pending_private_publish_refs(),
1026+ );
1027+ eprintln!(
1028+ "live relay OK: {relay_url} carried a refused handoff with reason \
1029+ reason.omega.handoff_account_revoked, distinct from the empty list \
1030+ the unscoped ask left",
1031+ );
1032+ }
1033+
1034+ fn device_handoff_intent(
1035+ grant: &omega_effectd::Issue31GrantState,
1036+ owner_public_key_hex: &str,
1037+ idempotency_ref: &str,
1038+ ) -> Value {
1039+ let now = unix_seconds();
1040+ json!({
1041+ "recordType": "command_intent",
1042+ "schema": "openagents.omega.issue31.command.v1",
1043+ "hostRef": grant.host_ref,
1044+ "hostPublicKeyHex": owner_public_key_hex,
1045+ "devicePublicKeyHex": grant.device_public_key_hex,
1046+ "grantRef": grant.grant_ref,
1047+ "actionRef": "action.omega.provider_handoff",
1048+ "idempotencyRef": idempotency_ref,
1049+ "expectedGeneration": grant.generation,
1050+ "argumentsRef": "arguments.omega.provider_handoff.anthropic",
1051+ "issuedAt": now,
1052+ "expiresAt": now + 3_600,
1053+ })
1054+ }
1055+
1056+ /// Publish one device-authored command intent, gift-wrapped to the host.
1057+ ///
1058+ /// This is the phone's half. The host never learns the device wrote it from
1059+ /// anything but the signature and the record's own binding.
1060+ #[allow(clippy::needless_pass_by_value)]
1061+ fn publish_device_command(
1062+ relay_url: &str,
1063+ device_keys: &nostr::Keys,
1064+ host_keys: &nostr::Keys,
1065+ intent: &Value,
1066+ ) {
1067+ use nostr::{EventBuilder, Kind, Tag};
1068+ use omega_effectd::RelayTransport as _;
1069+ let content = serde_json::to_string(intent).expect("intent json");
1070+ let mut rumor = EventBuilder::new(Kind::PrivateDirectMessage, content)
1071+ .tags(vec![
1072+ Tag::parse(["p", host_keys.public_key().to_hex().as_str()]).expect("p tag"),
1073+ ])
1074+ .build(device_keys.public_key());
1075+ rumor.ensure_id();
1076+ let gift_wrap = smol::block_on(EventBuilder::gift_wrap(
1077+ device_keys,
1078+ &host_keys.public_key(),
1079+ rumor,
1080+ [],
1081+ ))
1082+ .expect("gift wrap the device command to the host");
1083+ let mut device_relay = omega_effectd::WebSocketRelayAdapter::new_for_keys(
1084+ vec![relay_url.to_string()],
1085+ device_keys.clone(),
1086+ )
1087+ .expect("device relay adapter");
1088+ device_relay.connect().expect("device connect");
1089+ publish_authenticated(&mut device_relay, relay_url, device_keys, &gift_wrap);
1090+ }
1091+
1092+ fn publish_authenticated(
1093+ relay: &mut omega_effectd::WebSocketRelayAdapter,
1094+ auth_url: &str,
1095+ keys: &nostr::Keys,
1096+ record: &nostr::Event,
1097+ ) {
1098+ use nostr::{EventBuilder, Kind, Tag};
1099+ use omega_effectd::RelayTransport as _;
1100+ match relay.publish(record) {
1101+ Ok(()) => {}
1102+ Err(_) => {
1103+ let challenge = relay
1104+ .auth_challenge()
1105+ .expect("relay must expose a challenge after refusing the publish");
1106+ let auth_event = EventBuilder::new(Kind::Custom(22242), "")
1107+ .tag(Tag::parse(["relay", auth_url]).expect("relay tag"))
1108+ .tag(
1109+ Tag::parse(["challenge", challenge.challenge.as_str()])
1110+ .expect("challenge tag"),
1111+ )
1112+ .sign_with_keys(keys)
1113+ .expect("signed auth event");
1114+ relay.authenticate(&auth_event).expect("NIP-42 authenticate");
1115+ relay.publish(record).expect("publish after auth");
1116+ }
1117+ }
1118+ }
1119+
3581120 fn unix_seconds() -> u64 {
3591121 std::time::SystemTime::now()
3601122 .duration_since(std::time::UNIX_EPOCH)
@@ -369,10 +1131,31 @@ mod tests {
3691131 conversation_ref: &str,
3701132 relay_url: &str,
3711133 device_public_key_hex: &str,
1134+ ) -> omega_effectd::Issue31HostController {
1135+ live_paired_controller_with_scopes(
1136+ host_public_key_hex,
1137+ sarah_public_key_hex,
1138+ conversation_ref,
1139+ relay_url,
1140+ device_public_key_hex,
1141+ &[
1142+ omega_effectd::Issue31PairingScope::ObserveIssue31,
1143+ omega_effectd::Issue31PairingScope::ControlFullAuto,
1144+ ],
1145+ )
1146+ }
1147+
1148+ fn live_paired_controller_with_scopes(
1149+ host_public_key_hex: &str,
1150+ sarah_public_key_hex: &str,
1151+ conversation_ref: &str,
1152+ relay_url: &str,
1153+ device_public_key_hex: &str,
1154+ scopes: &[omega_effectd::Issue31PairingScope],
3721155 ) -> omega_effectd::Issue31HostController {
3731156 use omega_effectd::{
3741157 Issue31HostConfiguration, Issue31HostController, Issue31PairingEvent,
375- Issue31PairingRecord, Issue31PairingScope,
1158+ Issue31PairingRecord,
3761159 };
3771160 let host_ref = "omega.host.local".to_string();
3781161 let mut controller = Issue31HostController::new(Issue31HostConfiguration {
@@ -386,13 +1169,7 @@ mod tests {
3861169 })
3871170 .expect("host controller");
3881171 controller
389- .set_admitted_device_policy(
390- vec![device_public_key_hex.to_string()],
391- vec![
392- Issue31PairingScope::ObserveIssue31,
393- Issue31PairingScope::ControlFullAuto,
394- ],
395- )
1172+ .set_admitted_device_policy(vec![device_public_key_hex.to_string()], scopes.to_vec())
3961173 .expect("admit the device");
3971174 let now = unix_seconds();
3981175 let challenge = controller
@@ -406,10 +1183,7 @@ mod tests {
4061183 device_public_key_hex: device_public_key_hex.to_string(),
4071184 issued_at: now,
4081185 pairing_request_ref: "pairing_request.live".into(),
409- requested_scopes: vec![
410- Issue31PairingScope::ObserveIssue31,
411- Issue31PairingScope::ControlFullAuto,
412- ],
1186+ requested_scopes: scopes.to_vec(),
4131187 expires_at: now + 86_400,
4141188 },
4151189 },
@@ -472,12 +1246,11 @@ mod tests {
4721246 host_generation: 19,
4731247 run_details: Vec::new(),
4741248 capacity: json!({ "accounts": [] }),
475- handoffs: Vec::new(),
4761249 evidence: Vec::new(),
4771250 };
4781251 let documents = issue31_host_projection_documents(
4791252 &empty,
480- &request("omega.host.local", "grant.omega.device_1"),
1253+ &request("omega.host.local", "grant.omega.device_1", &no_handoffs()),
4811254 )
4821255 .expect("a host running nothing still projects");
4831256 assert_eq!(
diff --git a/crates/full_auto_ui/src/panel.rs b/crates/full_auto_ui/src/panel.rs
index a008e483f2..8f487f8cf6 100644
--- a/crates/full_auto_ui/src/panel.rs
+++ b/crates/full_auto_ui/src/panel.rs
@@ -368,11 +368,11 @@ impl FullAutoPanel {
368368 host_generation: generation,
369369 run_details,
370370 capacity: capacity.clone(),
371- // Provider connection handoffs are host-owned and this
372- // daemon does not yet report them. An empty list is the
373- // honest statement that none is in flight, not a claim
374- // that the host refused one.
375- handoffs: Vec::new(),
371+ // Provider connection handoffs are not part of this
372+ // reading. They are durable host records owned by the
373+ // Sarah pump's ledger (omega#91) and survive a restart
374+ // that this poll does not; carrying them here as well
375+ // would give one fact two sources.
376376 evidence,
377377 });
378378 }
diff --git a/crates/omega_effectd/Cargo.toml b/crates/omega_effectd/Cargo.toml
index e6af30d431..2b34a93245 100644
--- a/crates/omega_effectd/Cargo.toml
+++ b/crates/omega_effectd/Cargo.toml
@@ -34,6 +34,7 @@ smol.workspace = true
3434 thiserror.workspace = true
3535 url.workspace = true
3636 util.workspace = true
37+workroom_receipts.workspace = true
3738 zed_credentials_provider.workspace = true
3839
3940 [features]
diff --git a/crates/omega_effectd/src/issue31_nostr.rs b/crates/omega_effectd/src/issue31_nostr.rs
index b191709ab4..7020f428aa 100644
--- a/crates/omega_effectd/src/issue31_nostr.rs
+++ b/crates/omega_effectd/src/issue31_nostr.rs
@@ -2842,7 +2842,7 @@ impl Issue31HostController {
28422842 execute: F,
28432843 ) -> Result<Option<Issue31CommandRecord>, Issue31NostrError>
28442844 where
2845- F: FnOnce(&str, &str) -> Issue31CommandExecution,
2845+ F: FnOnce(&str, &str, &str) -> Issue31CommandExecution,
28462846 {
28472847 if !valid_hex64(&event.event_id) {
28482848 return Err(Issue31NostrError::Invalid(
@@ -2912,7 +2912,11 @@ impl Issue31HostController {
29122912 {
29132913 match required_scope(action_ref) {
29142914 Some(scope) if grant.scopes.contains(&scope) => {
2915- execute(action_ref, arguments_ref)
2915+ // The idempotency reference travels with the action
2916+ // so the executor can make one command map to one
2917+ // host record however many times it is replayed
2918+ // (omega#91).
2919+ execute(action_ref, arguments_ref, idempotency_ref)
29162920 }
29172921 Some(_) => refusal("reason.omega.scope_denied"),
29182922 None => Issue31CommandExecution {
@@ -3254,8 +3258,15 @@ fn digest_ref_suffix(bytes: &[u8]) -> String {
32543258 digest[..24].to_string()
32553259 }
32563260
3261+/// One device paired all the way to a signed scoped grant, through the real
3262+/// pairing state machine.
3263+///
3264+/// Shared so a test that needs a differently-scoped grant does not grow a
3265+/// second, subtly different copy of the chain.
32573266 #[cfg(test)]
3258-pub(crate) fn restart_fixture() -> (
3267+pub(crate) fn paired_fixture(
3268+ scopes: Vec<Issue31PairingScope>,
3269+) -> (
32593270 Issue31HostConfiguration,
32603271 Issue31HostController,
32613272 String,
@@ -3274,10 +3285,7 @@ pub(crate) fn restart_fixture() -> (
32743285 };
32753286 let mut controller = Issue31HostController::new(configuration.clone()).expect("controller");
32763287 controller
3277- .set_admitted_device_policy(
3278- vec![device_public_key_hex.clone()],
3279- vec![Issue31PairingScope::ControlFullAuto],
3280- )
3288+ .set_admitted_device_policy(vec![device_public_key_hex.clone()], scopes.clone())
32813289 .expect("admit device");
32823290 let challenge = controller
32833291 .handle_pairing_event(
@@ -3290,7 +3298,7 @@ pub(crate) fn restart_fixture() -> (
32903298 device_public_key_hex: device_public_key_hex.clone(),
32913299 issued_at: 100,
32923300 pairing_request_ref: "pairing_request.restart.device".into(),
3293- requested_scopes: vec![Issue31PairingScope::ControlFullAuto],
3301+ requested_scopes: scopes,
32943302 expires_at: 1_000,
32953303 },
32963304 },
@@ -3312,7 +3320,7 @@ pub(crate) fn restart_fixture() -> (
33123320 record: Issue31PairingRecord::PairingResponse {
33133321 schema: ISSUE31_PAIRING_SCHEMA.into(),
33143322 host_ref: configuration.host_ref.clone(),
3315- host_public_key_hex: host_public_key_hex.clone(),
3323+ host_public_key_hex,
33163324 device_public_key_hex: device_public_key_hex.clone(),
33173325 issued_at: 102,
33183326 pairing_response_ref: "pairing_response.restart.device".into(),
@@ -3332,6 +3340,19 @@ pub(crate) fn restart_fixture() -> (
33323340 controller
33333341 .record_emitted_pairing("d".repeat(64), grant)
33343342 .expect("record grant");
3343+ (configuration, controller, device_public_key_hex, grant_ref)
3344+}
3345+
3346+#[cfg(test)]
3347+pub(crate) fn restart_fixture() -> (
3348+ Issue31HostConfiguration,
3349+ Issue31HostController,
3350+ String,
3351+ String,
3352+) {
3353+ let (configuration, mut controller, device_public_key_hex, grant_ref) =
3354+ paired_fixture(vec![Issue31PairingScope::ControlFullAuto]);
3355+ let host_public_key_hex = configuration.host_public_key_hex.clone();
33353356 controller
33363357 .handle_command_event(
33373358 Issue31CommandEvent {
@@ -3351,7 +3372,7 @@ pub(crate) fn restart_fixture() -> (
33513372 },
33523373 },
33533374 104,
3354- |_, _| Issue31CommandExecution {
3375+ |_, _, _| Issue31CommandExecution {
33553376 status: Issue31CommandStatus::Stopped,
33563377 outcome_ref: "outcome.omega.stopped".into(),
33573378 reason_ref: None,
@@ -4734,7 +4755,7 @@ mod tests {
47344755 },
47354756 },
47364757 104,
4737- |_, _| {
4758+ |_, _, _| {
47384759 executions.set(executions.get().saturating_add(1));
47394760 Issue31CommandExecution {
47404761 status: Issue31CommandStatus::Stopped,
@@ -4920,7 +4941,7 @@ mod tests {
49204941 },
49214942 },
49224943 101,
4923- |_, _| {
4944+ |_, _, _| {
49244945 executed.set(true);
49254946 Issue31CommandExecution {
49264947 status: Issue31CommandStatus::Stopped,
@@ -4993,7 +5014,7 @@ mod tests {
49935014 },
49945015 },
49955016 101,
4996- |_, _| {
5017+ |_, _, _| {
49975018 executed.set(true);
49985019 Issue31CommandExecution {
49995020 status: Issue31CommandStatus::Stopped,
diff --git a/crates/omega_effectd/src/issue31_provider_handoff.rs b/crates/omega_effectd/src/issue31_provider_handoff.rs
new file mode 100644
index 0000000000..26ecc51eab
--- /dev/null
+++ b/crates/omega_effectd/src/issue31_provider_handoff.rs
@@ -0,0 +1,1180 @@
1+//! Host-owned provider connection handoff records (omega#91).
2+//!
3+//! The phone can already ask: `issue31_nostr` maps `request_provider_handoff`
4+//! onto a pairing scope and `action.omega.provider_handoff` onto that scope, so
5+//! a paired device can hold the grant and send the intent. Until this module
6+//! existed nothing answered — the `fullauto.v1` delivery carried an empty
7+//! handoff vector forever, and the phone rendered a capability the host had
8+//! nothing to say about.
9+//!
10+//! This is the thing that has something to say. It is a ledger of host-owned
11+//! records with one lifecycle:
12+//!
13+//! ```text
14+//! requested ── bound to an account ──▶ active ──▶ completed
15+//! │ │
16+//! └──────────────────────────────────┴──▶ refused | failed | expired
17+//! ```
18+//!
19+//! Three properties are load-bearing and are why this is a ledger rather than a
20+//! field on a request:
21+//!
22+//! - **Every host-owned field is a measurement, not a claim.** `requestedAtMs`
23+//! comes from one reading of `now` taken when the host admitted the request;
24+//! the device supplies no timestamp and there is no input path for one. The
25+//! account binding comes from the host's own roster; the device names a
26+//! provider and nothing else. The terminal outcome and the reason for a
27+//! non-successful end are decided here, from what the host observed.
28+//! - **A row is written only if it can be read.** Every projected row goes back
29+//! through `workroom_receipts::decode_issue31_provider_handoff`, the exact
30+//! function the whole-document decoder uses, so this cannot emit a handoff
31+//! the phone would refuse.
32+//! - **Nothing is backfilled.** A persisted row whose `requestedAtMs` predates
33+//! this field decodes, is reported unavailable, and is refused. It is never
34+//! shown with a stamp taken at load time, which would give one field two
35+//! provenances that nothing on the wire distinguishes.
36+//!
37+//! The record carries **the fact of a connection, never the connection
38+//! secret**. Nothing here reads, writes, moves, or names a provider credential,
39+//! an isolated provider home, or a filesystem path; the only strings that enter
40+//! are a provider token and references the host itself minted.
41+
42+use std::collections::BTreeMap;
43+
44+use serde::{Deserialize, Serialize};
45+use serde_json::{Value, json};
46+use sha2::{Digest, Sha256};
47+use workroom_receipts::{
48+ Issue31FullAutoAdjunctError, Issue31ProviderHandoffState, decode_issue31_provider_handoff,
49+ is_public_safe_ref,
50+};
51+
52+/// The action a device sends to open one.
53+pub const ISSUE31_ACTION_REQUEST_PROVIDER_HANDOFF: &str = "action.omega.provider_handoff";
54+
55+/// The only shape of `argumentsRef` this action accepts.
56+///
57+/// The device names a provider and nothing else. Everything after this prefix
58+/// is a bounded provider token, so there is no field on the wire through which
59+/// a device could state a time, an account, a lane, or an outcome — the four
60+/// things that have to be the host's own measurements.
61+pub const ISSUE31_PROVIDER_HANDOFF_ARGUMENTS_PREFIX: &str = "arguments.omega.provider_handoff.";
62+
63+/// Matches `workroom_receipts::MAX_ISSUE31_FULL_AUTO_HANDOFFS`. Exceeding the
64+/// contract bound in the ledger would produce a projection the phone refuses
65+/// wholesale, so the ledger refuses to open the seventeenth instead.
66+pub const MAX_ISSUE31_PROVIDER_HANDOFFS: usize = 16;
67+
68+/// How long the host will hold a handoff open before calling it expired.
69+///
70+/// The work behind a handoff is an owner completing a provider login at the
71+/// host, in the host's own isolated provider home. Fifteen minutes is long
72+/// enough for that and short enough that the phone is never left holding a
73+/// request that neither resolves nor fails — the state this issue exists to
74+/// eliminate.
75+pub const ISSUE31_PROVIDER_HANDOFF_DEADLINE_MS: u64 = 15 * 60 * 1_000;
76+
77+pub const ISSUE31_HANDOFF_OUTCOME_CONNECTED: &str = "outcome.omega.handoff_connected";
78+pub const ISSUE31_HANDOFF_OUTCOME_REFUSED: &str = "outcome.omega.handoff_refused";
79+pub const ISSUE31_HANDOFF_OUTCOME_INTERRUPTED: &str = "outcome.omega.handoff_interrupted";
80+pub const ISSUE31_HANDOFF_OUTCOME_EXPIRED: &str = "outcome.omega.handoff_expired";
81+pub const ISSUE31_HANDOFF_OUTCOME_FAILED: &str = "outcome.omega.handoff_failed";
82+
83+pub const ISSUE31_HANDOFF_REASON_ACCOUNT_REVOKED: &str = "reason.omega.handoff_account_revoked";
84+pub const ISSUE31_HANDOFF_REASON_ACCOUNT_WITHDRAWN: &str =
85+ "reason.omega.handoff_account_withdrawn";
86+pub const ISSUE31_HANDOFF_REASON_LANE_CONFLICT: &str = "reason.omega.handoff_account_lane_conflict";
87+pub const ISSUE31_HANDOFF_REASON_HOST_RESTARTED: &str = "reason.omega.handoff_host_restarted";
88+pub const ISSUE31_HANDOFF_REASON_DEADLINE_PASSED: &str = "reason.omega.handoff_deadline_passed";
89+
90+/// Why the host would not open a handoff at all.
91+///
92+/// Each of these leaves **no record**, and that is the point: a request the
93+/// host never admitted is not a handoff that failed. The phone can tell them
94+/// apart because one produces a row it can watch and the other does not.
95+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96+pub enum Issue31ProviderHandoffError {
97+ /// The `argumentsRef` was not `arguments.omega.provider_handoff.<provider>`.
98+ ArgumentsInvalid,
99+ /// The provider token was empty, oversized, or not public-safe.
100+ ProviderInvalid,
101+ /// The ledger already holds as many handoffs as the contract can carry.
102+ BoundExhausted,
103+ /// The host tried to write a row its own reader would refuse. This is a
104+ /// host defect, never a device one, and it is surfaced rather than
105+ /// swallowed so the defect cannot hide as an empty handoff list.
106+ Unprojectable(Issue31FullAutoAdjunctError),
107+}
108+
109+impl std::fmt::Display for Issue31ProviderHandoffError {
110+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111+ match self {
112+ Self::ArgumentsInvalid => {
113+ formatter.write_str("provider handoff arguments do not name a provider")
114+ }
115+ Self::ProviderInvalid => {
116+ formatter.write_str("provider handoff names an unsafe provider token")
117+ }
118+ Self::BoundExhausted => formatter.write_str("provider handoff ledger bound is full"),
119+ Self::Unprojectable(error) => {
120+ write!(formatter, "provider handoff would not decode: {error}")
121+ }
122+ }
123+ }
124+}
125+
126+impl std::error::Error for Issue31ProviderHandoffError {}
127+
128+impl Issue31ProviderHandoffError {
129+ /// The public-safe reason reference a command result carries.
130+ #[must_use]
131+ pub const fn reason_ref(self) -> &'static str {
132+ match self {
133+ Self::ArgumentsInvalid => "reason.omega.handoff_arguments_invalid",
134+ Self::ProviderInvalid => "reason.omega.handoff_provider_invalid",
135+ Self::BoundExhausted => "reason.omega.handoff_bound_exhausted",
136+ Self::Unprojectable(_) => "reason.omega.handoff_unprojectable",
137+ }
138+ }
139+}
140+
141+/// One provider account as the host's own roster reports it.
142+///
143+/// This is the host observing itself. The lane is carried because omega#42
144+/// asked for the account-to-lane relation and omega#91 needs it for a specific
145+/// reason: it is *why a handoff chose what it chose*. A handoff binds to an
146+/// account, and the account states the lane it serves, so a viewer can follow
147+/// the choice rather than infer it.
148+#[derive(Clone, Debug, PartialEq, Eq)]
149+pub struct Issue31ProviderRosterAccount {
150+ pub account_ref: String,
151+ pub provider: String,
152+ pub lane_ref: String,
153+ /// The roster's own readiness token: `ready`, `busy`, `revoked`, and so on.
154+ pub readiness: String,
155+}
156+
157+/// One host-owned handoff.
158+///
159+/// Every optional field is optional for one reason only: the host has not
160+/// measured it yet. None of them has a device-supplied alternative.
161+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
162+#[serde(rename_all = "camelCase", deny_unknown_fields)]
163+pub struct Issue31ProviderHandoffRecord {
164+ pub handoff_ref: String,
165+ pub provider: String,
166+ pub state: Issue31ProviderHandoffState,
167+ /// One reading of `now`, taken when the host admitted the request, stamped
168+ /// once and never restamped.
169+ ///
170+ /// `None` means this row was persisted by a build that did not measure it.
171+ /// It is refused at projection rather than filled in at load: a value
172+ /// invented now and a value measured then are indistinguishable on the
173+ /// wire, and a viewer would have no way to know which it was reading.
174+ #[serde(default)]
175+ pub requested_at_ms: Option<u64>,
176+ /// The host's own bound on how long it will hold this open. Host-private:
177+ /// the contract carries no deadline, so this never reaches the phone.
178+ #[serde(default)]
179+ pub deadline_at_ms: Option<u64>,
180+ /// Set when the host bound this handoff to a concrete account of its own.
181+ #[serde(default)]
182+ pub account_ref: Option<String>,
183+ /// The lane that account served at the moment of binding. Host-private and
184+ /// checked on every later pass: the projected relation is
185+ /// `handoff.accountRef` → `account.laneRef`, and this is what makes a lane
186+ /// that moves underneath a bound handoff a detected conflict instead of a
187+ /// silent re-mapping.
188+ #[serde(default)]
189+ pub lane_ref: Option<String>,
190+ #[serde(default)]
191+ pub reason_class: Option<String>,
192+ #[serde(default)]
193+ pub outcome_ref: Option<String>,
194+}
195+
196+impl Issue31ProviderHandoffRecord {
197+ #[must_use]
198+ pub fn is_terminal(&self) -> bool {
199+ self.state.is_terminal()
200+ }
201+
202+ /// The contract row for this record, or `None` when the host cannot state
203+ /// one. Never a partially-invented row.
204+ fn contract_row(&self) -> Option<Value> {
205+ let requested_at_ms = self.requested_at_ms?;
206+ let mut row = json!({
207+ "handoffRef": self.handoff_ref,
208+ "provider": self.provider,
209+ "state": self.state,
210+ "requestedAtMs": requested_at_ms,
211+ });
212+ let object = row.as_object_mut()?;
213+ for (field, value) in [
214+ ("accountRef", self.account_ref.as_ref()),
215+ ("reasonClass", self.reason_class.as_ref()),
216+ ("outcomeRef", self.outcome_ref.as_ref()),
217+ ] {
218+ if let Some(value) = value {
219+ object.insert(field.into(), json!(value));
220+ }
221+ }
222+ Some(row)
223+ }
224+}
225+
226+/// What the host can presently say about its handoffs.
227+#[derive(Clone, Debug, Default, PartialEq, Eq)]
228+pub struct Issue31ProviderHandoffProjection {
229+ /// Contract rows, each already accepted by the reader's own decoder.
230+ pub rows: Vec<Value>,
231+ /// Handoff references the host holds and cannot state.
232+ ///
233+ /// A non-empty list is a gap the owner must see. It is deliberately not
234+ /// merged into `rows` with substituted values, and deliberately not
235+ /// silently dropped either: the count is what turns "the host has nothing
236+ /// to say" into "the host has something it cannot say".
237+ pub unavailable: Vec<String>,
238+}
239+
240+/// The durable ledger of host-owned provider connection handoffs.
241+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
242+#[serde(rename_all = "camelCase", deny_unknown_fields)]
243+pub struct Issue31ProviderHandoffLedger {
244+ #[serde(default)]
245+ entries: BTreeMap<String, Issue31ProviderHandoffRecord>,
246+}
247+
248+impl Issue31ProviderHandoffLedger {
249+ #[must_use]
250+ pub fn len(&self) -> usize {
251+ self.entries.len()
252+ }
253+
254+ #[must_use]
255+ pub fn is_empty(&self) -> bool {
256+ self.entries.is_empty()
257+ }
258+
259+ #[must_use]
260+ pub fn get(&self, handoff_ref: &str) -> Option<&Issue31ProviderHandoffRecord> {
261+ self.entries.get(handoff_ref)
262+ }
263+
264+ #[must_use]
265+ pub fn records(&self) -> impl Iterator<Item = &Issue31ProviderHandoffRecord> {
266+ self.entries.values()
267+ }
268+
269+ /// The reference the host mints for a command, without opening anything.
270+ ///
271+ /// Derived from the device's idempotency reference so that a command
272+ /// replayed after a failed durable commit reopens the same handoff rather
273+ /// than a second one. The device does not choose the reference: it chooses
274+ /// an idempotency label, and the host decides what record that maps to.
275+ #[must_use]
276+ pub fn handoff_ref_for(idempotency_ref: &str) -> String {
277+ let digest = format!("{:x}", Sha256::digest(idempotency_ref.as_bytes()));
278+ format!("handoff.omega.{}", &digest[..24])
279+ }
280+
281+ /// The provider a `provider_handoff` command names, if it names one.
282+ ///
283+ /// This is the whole input surface of the action.
284+ pub fn provider_from_arguments_ref(
285+ arguments_ref: &str,
286+ ) -> Result<String, Issue31ProviderHandoffError> {
287+ let provider = arguments_ref
288+ .strip_prefix(ISSUE31_PROVIDER_HANDOFF_ARGUMENTS_PREFIX)
289+ .ok_or(Issue31ProviderHandoffError::ArgumentsInvalid)?;
290+ if provider.is_empty()
291+ || provider.len() > 32
292+ || !provider
293+ .bytes()
294+ .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
295+ || !is_public_safe_ref(provider)
296+ {
297+ return Err(Issue31ProviderHandoffError::ProviderInvalid);
298+ }
299+ Ok(provider.to_string())
300+ }
301+
302+ /// Open a handoff for a command the host has already admitted.
303+ ///
304+ /// `now_ms` is the caller's single reading of the clock for this command;
305+ /// both the stamp and the deadline come from it, so a handoff cannot be
306+ /// requested at one instant and bounded from another.
307+ ///
308+ /// Opening is idempotent by handoff reference. A replay after a durable
309+ /// commit failed finds the record it already made instead of minting a
310+ /// second one with a later stamp.
311+ pub fn open(
312+ &mut self,
313+ arguments_ref: &str,
314+ idempotency_ref: &str,
315+ now_ms: u64,
316+ ) -> Result<Issue31ProviderHandoffRecord, Issue31ProviderHandoffError> {
317+ let provider = Self::provider_from_arguments_ref(arguments_ref)?;
318+ let handoff_ref = Self::handoff_ref_for(idempotency_ref);
319+ if let Some(existing) = self.entries.get(&handoff_ref) {
320+ return Ok(existing.clone());
321+ }
322+ if self.entries.len() >= MAX_ISSUE31_PROVIDER_HANDOFFS {
323+ return Err(Issue31ProviderHandoffError::BoundExhausted);
324+ }
325+ let record = Issue31ProviderHandoffRecord {
326+ handoff_ref: handoff_ref.clone(),
327+ provider,
328+ state: Issue31ProviderHandoffState::Requested,
329+ requested_at_ms: Some(now_ms),
330+ deadline_at_ms: Some(now_ms.saturating_add(ISSUE31_PROVIDER_HANDOFF_DEADLINE_MS)),
331+ account_ref: None,
332+ lane_ref: None,
333+ reason_class: None,
334+ outcome_ref: None,
335+ };
336+ // Unreadable states are unwritable. The row is checked against the
337+ // reader's own decoder before it enters the ledger, so a handoff the
338+ // phone would refuse never becomes durable host state in the first
339+ // place.
340+ let row = record
341+ .contract_row()
342+ .ok_or(Issue31ProviderHandoffError::Unprojectable(
343+ Issue31FullAutoAdjunctError::InvalidHandoffState,
344+ ))?;
345+ decode_issue31_provider_handoff(&row, now_ms)
346+ .map_err(Issue31ProviderHandoffError::Unprojectable)?;
347+ self.entries.insert(handoff_ref, record.clone());
348+ Ok(record)
349+ }
350+
351+ /// Move every open handoff on by at most one step, from what the host sees.
352+ ///
353+ /// `roster` is the host's own reading of its provider accounts. `None`
354+ /// means the host has not looked — nothing advances, because a decision
355+ /// taken against an unread roster would be a guess wearing a measurement's
356+ /// clothes. `Some(&[])` means the host looked and holds no accounts, which
357+ /// is a real observation and does move the deadline clock.
358+ ///
359+ /// **At most one transition per handoff per pass**, on purpose. Binding and
360+ /// completing are two separate observations of the roster, and collapsing
361+ /// them would mean `active` is a state the host passes through without ever
362+ /// publishing — the phone would see a handoff appear and complete, and
363+ /// never see it bind.
364+ ///
365+ /// Returns how many handoffs moved.
366+ pub fn advance(
367+ &mut self,
368+ roster: Option<&[Issue31ProviderRosterAccount]>,
369+ now_ms: u64,
370+ ) -> usize {
371+ let mut moved = 0;
372+ for record in self.entries.values_mut() {
373+ if record.is_terminal() {
374+ continue;
375+ }
376+ // A row the host cannot state is not a row the host may decide
377+ // about. It is reported unavailable and left exactly as found.
378+ let Some(_) = record.requested_at_ms else {
379+ continue;
380+ };
381+ if record
382+ .deadline_at_ms
383+ .is_some_and(|deadline| now_ms >= deadline)
384+ {
385+ record.state = Issue31ProviderHandoffState::Expired;
386+ record.reason_class = Some(ISSUE31_HANDOFF_REASON_DEADLINE_PASSED.into());
387+ record.outcome_ref = Some(ISSUE31_HANDOFF_OUTCOME_EXPIRED.into());
388+ moved += 1;
389+ continue;
390+ }
391+ let Some(roster) = roster else {
392+ continue;
393+ };
394+ if advance_against_roster(record, roster) {
395+ moved += 1;
396+ }
397+ }
398+ moved
399+ }
400+
401+ /// Settle every handoff that was in flight when the host process ended.
402+ ///
403+ /// The work behind an open handoff lived in the process that is gone: the
404+ /// isolated provider home it drove, and the login the owner was completing
405+ /// there. Nothing survives it, so the honest terminal answer is that the
406+ /// handoff was interrupted. This only ever under-claims — it cannot report
407+ /// a connection the host did not make — and it is what stops a restart
408+ /// leaving the phone with a request that neither resolves nor fails.
409+ ///
410+ /// Returns how many handoffs were settled.
411+ pub fn adopt_after_restart(&mut self) -> usize {
412+ let mut settled = 0;
413+ for record in self.entries.values_mut() {
414+ if record.is_terminal() || record.requested_at_ms.is_none() {
415+ continue;
416+ }
417+ record.state = Issue31ProviderHandoffState::Failed;
418+ record.reason_class = Some(ISSUE31_HANDOFF_REASON_HOST_RESTARTED.into());
419+ record.outcome_ref = Some(ISSUE31_HANDOFF_OUTCOME_INTERRUPTED.into());
420+ settled += 1;
421+ }
422+ settled
423+ }
424+
425+ /// Handoffs this host holds and can never state.
426+ ///
427+ /// Only the permanently unstateable ones: a row whose request time was
428+ /// never measured. A row that is merely newer than the snapshot being
429+ /// built is not listed — it is not missing, it is not yet part of that
430+ /// reading, and it appears in the next one.
431+ #[must_use]
432+ pub fn unstateable_refs(&self) -> Vec<String> {
433+ self.entries
434+ .values()
435+ .filter(|record| record.requested_at_ms.is_none())
436+ .map(|record| record.handoff_ref.clone())
437+ .collect()
438+ }
439+
440+ /// Fold another ledger's rows in. Test support for building a fixture that
441+ /// shows every lifecycle at once; the production path never merges.
442+ #[cfg(test)]
443+ pub(crate) fn merge_for_fixture(&mut self, other: Self) {
444+ for (handoff_ref, record) in other.entries {
445+ self.entries.insert(handoff_ref, record);
446+ }
447+ }
448+
449+ /// The contract rows for this ledger, plus what it could not state.
450+ ///
451+ /// Every row is routed back through the reader's own decoder against the
452+ /// exact `generatedAtMs` the projection will carry, so a row stamped after
453+ /// the reading it would ride in is refused here rather than accepted and
454+ /// then rejected on the phone.
455+ #[must_use]
456+ pub fn projected(&self, generated_at_ms: u64) -> Issue31ProviderHandoffProjection {
457+ let mut projection = Issue31ProviderHandoffProjection::default();
458+ for record in self.entries.values() {
459+ if projection.rows.len() >= MAX_ISSUE31_PROVIDER_HANDOFFS {
460+ projection.unavailable.push(record.handoff_ref.clone());
461+ continue;
462+ }
463+ let stated = record
464+ .contract_row()
465+ .filter(|row| decode_issue31_provider_handoff(row, generated_at_ms).is_ok());
466+ match stated {
467+ Some(row) => projection.rows.push(row),
468+ None => projection.unavailable.push(record.handoff_ref.clone()),
469+ }
470+ }
471+ projection
472+ }
473+}
474+
475+/// One roster-driven step for a single open handoff.
476+///
477+/// Returns true when the record moved.
478+fn advance_against_roster(
479+ record: &mut Issue31ProviderHandoffRecord,
480+ roster: &[Issue31ProviderRosterAccount],
481+) -> bool {
482+ match record.account_ref.clone() {
483+ Some(account_ref) => {
484+ let Some(account) = roster
485+ .iter()
486+ .find(|account| account.account_ref == account_ref)
487+ else {
488+ // The account this handoff was bound to is gone from the host's
489+ // own roster. Reporting the handoff as still active would point
490+ // the phone at a record the host no longer has.
491+ record.state = Issue31ProviderHandoffState::Failed;
492+ record.reason_class = Some(ISSUE31_HANDOFF_REASON_ACCOUNT_WITHDRAWN.into());
493+ record.outcome_ref = Some(ISSUE31_HANDOFF_OUTCOME_FAILED.into());
494+ return true;
495+ };
496+ if record.lane_ref.as_deref() != Some(account.lane_ref.as_str()) {
497+ // The account now serves a different lane than the one this
498+ // handoff chose. Quietly adopting the new lane would rewrite
499+ // the reason the handoff picked this account at all.
500+ record.state = Issue31ProviderHandoffState::Failed;
501+ record.reason_class = Some(ISSUE31_HANDOFF_REASON_LANE_CONFLICT.into());
502+ record.outcome_ref = Some(ISSUE31_HANDOFF_OUTCOME_FAILED.into());
503+ return true;
504+ }
505+ match account.readiness.as_str() {
506+ "revoked" => {
507+ record.state = Issue31ProviderHandoffState::Refused;
508+ record.reason_class = Some(ISSUE31_HANDOFF_REASON_ACCOUNT_REVOKED.into());
509+ record.outcome_ref = Some(ISSUE31_HANDOFF_OUTCOME_REFUSED.into());
510+ true
511+ }
512+ "ready" => {
513+ record.state = Issue31ProviderHandoffState::Completed;
514+ record.outcome_ref = Some(ISSUE31_HANDOFF_OUTCOME_CONNECTED.into());
515+ true
516+ }
517+ // Connected but not presently usable. The handoff stays bound
518+ // and open; the deadline is what eventually ends it.
519+ _ => false,
520+ }
521+ }
522+ None => {
523+ let mut candidates: Vec<&Issue31ProviderRosterAccount> = roster
524+ .iter()
525+ .filter(|account| account.provider == record.provider)
526+ .collect();
527+ if candidates.is_empty() {
528+ // The host holds no account for this provider yet. That is not
529+ // a refusal: the owner may still be completing the login at the
530+ // host. The handoff stays `requested` until the host's own
531+ // deadline decides.
532+ return false;
533+ }
534+ // Deterministic: a ready account first, then by reference, so two
535+ // hosts reading the same roster bind the same way and a viewer can
536+ // reproduce the choice.
537+ candidates.sort_by(|left, right| {
538+ let left_ready = u8::from(left.readiness != "ready");
539+ let right_ready = u8::from(right.readiness != "ready");
540+ left_ready
541+ .cmp(&right_ready)
542+ .then_with(|| left.account_ref.cmp(&right.account_ref))
543+ });
544+ let Some(account) = candidates.first() else {
545+ return false;
546+ };
547+ record.state = Issue31ProviderHandoffState::Active;
548+ record.account_ref = Some(account.account_ref.clone());
549+ record.lane_ref = Some(account.lane_ref.clone());
550+ true
551+ }
552+ }
553+}
554+
555+#[cfg(test)]
556+mod tests {
557+ use super::*;
558+
559+ const NOW_MS: u64 = 1_785_000_000_000;
560+
561+ fn account(
562+ account_ref: &str,
563+ provider: &str,
564+ lane_ref: &str,
565+ readiness: &str,
566+ ) -> Issue31ProviderRosterAccount {
567+ Issue31ProviderRosterAccount {
568+ account_ref: account_ref.into(),
569+ provider: provider.into(),
570+ lane_ref: lane_ref.into(),
571+ readiness: readiness.into(),
572+ }
573+ }
574+
575+ fn opened() -> (Issue31ProviderHandoffLedger, String) {
576+ let mut ledger = Issue31ProviderHandoffLedger::default();
577+ let record = ledger
578+ .open(
579+ "arguments.omega.provider_handoff.anthropic",
580+ "idempotency.issue31.handoff:one",
581+ NOW_MS,
582+ )
583+ .expect("the host opens a handoff for an admitted request");
584+ (ledger, record.handoff_ref)
585+ }
586+
587+ #[test]
588+ fn a_handoff_appears_binds_and_completes_as_three_separate_observations() {
589+ let (mut ledger, handoff_ref) = opened();
590+ assert_eq!(
591+ ledger.get(&handoff_ref).expect("record").state,
592+ Issue31ProviderHandoffState::Requested,
593+ );
594+ assert!(ledger.get(&handoff_ref).expect("record").account_ref.is_none());
595+
596+ let roster = [account(
597+ "account.claude.1",
598+ "anthropic",
599+ "lane.claude-local",
600+ "ready",
601+ )];
602+ assert_eq!(ledger.advance(Some(&roster), NOW_MS + 1_000), 1);
603+ let bound = ledger.get(&handoff_ref).expect("record").clone();
604+ assert_eq!(bound.state, Issue31ProviderHandoffState::Active);
605+ assert_eq!(bound.account_ref.as_deref(), Some("account.claude.1"));
606+ // The account-to-lane relation is why this handoff chose this account.
607+ assert_eq!(bound.lane_ref.as_deref(), Some("lane.claude-local"));
608+ assert!(bound.outcome_ref.is_none(), "an open handoff has no outcome");
609+
610+ assert_eq!(ledger.advance(Some(&roster), NOW_MS + 2_000), 1);
611+ let done = ledger.get(&handoff_ref).expect("record").clone();
612+ assert_eq!(done.state, Issue31ProviderHandoffState::Completed);
613+ assert_eq!(
614+ done.outcome_ref.as_deref(),
615+ Some(ISSUE31_HANDOFF_OUTCOME_CONNECTED)
616+ );
617+ assert!(done.is_terminal());
618+ assert_eq!(ledger.advance(Some(&roster), NOW_MS + 3_000), 0);
619+ }
620+
621+ #[test]
622+ fn a_failure_is_a_row_and_a_request_that_never_started_is_no_row_at_all() {
623+ // The distinction the exit turns on. A handoff the host admitted and
624+ // then could not complete leaves a record the phone can read and act
625+ // on. A request the host never admitted leaves nothing, and the phone
626+ // must not be able to confuse the two.
627+ let mut ledger = Issue31ProviderHandoffLedger::default();
628+ let refused = ledger.open(
629+ "arguments.omega.provider_handoff",
630+ "idempotency.issue31.handoff:never",
631+ NOW_MS,
632+ );
633+ assert_eq!(refused, Err(Issue31ProviderHandoffError::ArgumentsInvalid));
634+ assert!(ledger.is_empty(), "an unadmitted request leaves no record");
635+ assert!(ledger.projected(NOW_MS).rows.is_empty());
636+
637+ let (mut ledger, handoff_ref) = opened();
638+ let roster = [account(
639+ "account.claude.1",
640+ "anthropic",
641+ "lane.claude-local",
642+ "revoked",
643+ )];
644+ ledger.advance(Some(&roster), NOW_MS + 1_000);
645+ ledger.advance(Some(&roster), NOW_MS + 2_000);
646+ let failed = ledger.get(&handoff_ref).expect("record").clone();
647+ assert_eq!(failed.state, Issue31ProviderHandoffState::Refused);
648+ assert_eq!(
649+ failed.reason_class.as_deref(),
650+ Some(ISSUE31_HANDOFF_REASON_ACCOUNT_REVOKED)
651+ );
652+ assert_eq!(
653+ failed.outcome_ref.as_deref(),
654+ Some(ISSUE31_HANDOFF_OUTCOME_REFUSED)
655+ );
656+ let projection = ledger.projected(NOW_MS + 3_000);
657+ assert_eq!(projection.rows.len(), 1, "a failure is visible on the wire");
658+ assert!(projection.unavailable.is_empty());
659+ }
660+
661+ #[test]
662+ fn an_unread_roster_decides_nothing() {
663+ let (mut ledger, handoff_ref) = opened();
664+ assert_eq!(ledger.advance(None, NOW_MS + 1_000), 0);
665+ assert_eq!(
666+ ledger.get(&handoff_ref).expect("record").state,
667+ Issue31ProviderHandoffState::Requested,
668+ );
669+ }
670+
671+ #[test]
672+ fn a_host_with_no_account_for_the_provider_waits_and_then_expires() {
673+ let (mut ledger, handoff_ref) = opened();
674+ let roster = [account("account.codex.1", "openai", "lane.codex-local", "ready")];
675+ assert_eq!(ledger.advance(Some(&roster), NOW_MS + 1_000), 0);
676+ assert_eq!(
677+ ledger.get(&handoff_ref).expect("record").state,
678+ Issue31ProviderHandoffState::Requested,
679+ "the owner may still be completing the login at the host",
680+ );
681+ assert_eq!(
682+ ledger.advance(
683+ Some(&roster),
684+ NOW_MS + ISSUE31_PROVIDER_HANDOFF_DEADLINE_MS
685+ ),
686+ 1,
687+ );
688+ let expired = ledger.get(&handoff_ref).expect("record").clone();
689+ assert_eq!(expired.state, Issue31ProviderHandoffState::Expired);
690+ assert_eq!(
691+ expired.reason_class.as_deref(),
692+ Some(ISSUE31_HANDOFF_REASON_DEADLINE_PASSED)
693+ );
694+ assert_eq!(
695+ expired.outcome_ref.as_deref(),
696+ Some(ISSUE31_HANDOFF_OUTCOME_EXPIRED)
697+ );
698+ }
699+
700+ #[test]
701+ fn a_restart_settles_what_was_in_flight_rather_than_losing_it() {
702+ let (mut ledger, handoff_ref) = opened();
703+ let roster = [account(
704+ "account.claude.1",
705+ "anthropic",
706+ "lane.claude-local",
707+ "busy",
708+ )];
709+ ledger.advance(Some(&roster), NOW_MS + 1_000);
710+ assert_eq!(
711+ ledger.get(&handoff_ref).expect("record").state,
712+ Issue31ProviderHandoffState::Active,
713+ );
714+
715+ // The exact bytes that cross a restart.
716+ let serialized = serde_json::to_string(&ledger).expect("the ledger serializes");
717+ let mut reloaded: Issue31ProviderHandoffLedger =
718+ serde_json::from_str(&serialized).expect("the ledger survives a restart");
719+ assert_eq!(reloaded, ledger, "a restart loses nothing");
720+ assert_eq!(reloaded.adopt_after_restart(), 1);
721+ let settled = reloaded.get(&handoff_ref).expect("record").clone();
722+ assert_eq!(settled.state, Issue31ProviderHandoffState::Failed);
723+ assert_eq!(
724+ settled.reason_class.as_deref(),
725+ Some(ISSUE31_HANDOFF_REASON_HOST_RESTARTED)
726+ );
727+ assert_eq!(
728+ settled.outcome_ref.as_deref(),
729+ Some(ISSUE31_HANDOFF_OUTCOME_INTERRUPTED)
730+ );
731+ assert_eq!(
732+ reloaded.adopt_after_restart(),
733+ 0,
734+ "a second restart does not re-settle a terminal handoff",
735+ );
736+ }
737+
738+ #[test]
739+ fn a_restart_never_reports_a_connection_the_host_did_not_make() {
740+ let (mut ledger, handoff_ref) = opened();
741+ ledger.adopt_after_restart();
742+ let settled = ledger.get(&handoff_ref).expect("record").clone();
743+ assert_ne!(settled.state, Issue31ProviderHandoffState::Completed);
744+ assert!(settled.account_ref.is_none());
745+ }
746+
747+ #[test]
748+ fn a_row_the_host_never_measured_is_reported_unavailable_rather_than_stamped() {
749+ // Exactly the shape a build older than `requestedAtMs` persisted.
750+ let ledger: Issue31ProviderHandoffLedger = serde_json::from_str(
751+ r#"{"entries":{"handoff.omega.legacy":{"handoffRef":"handoff.omega.legacy",
752+ "provider":"anthropic","state":"requested"}}}"#,
753+ )
754+ .expect("a pre-existing row decodes");
755+ let projection = ledger.projected(NOW_MS);
756+ assert!(
757+ projection.rows.is_empty(),
758+ "a row with no measured request time is never shown"
759+ );
760+ assert_eq!(projection.unavailable, vec!["handoff.omega.legacy"]);
761+ }
762+
763+ #[test]
764+ fn a_row_the_host_never_measured_is_never_advanced_either() {
765+ let mut ledger: Issue31ProviderHandoffLedger = serde_json::from_str(
766+ r#"{"entries":{"handoff.omega.legacy":{"handoffRef":"handoff.omega.legacy",
767+ "provider":"anthropic","state":"requested"}}}"#,
768+ )
769+ .expect("a pre-existing row decodes");
770+ let roster = [account(
771+ "account.claude.1",
772+ "anthropic",
773+ "lane.claude-local",
774+ "ready",
775+ )];
776+ assert_eq!(ledger.advance(Some(&roster), NOW_MS), 0);
777+ assert_eq!(ledger.adopt_after_restart(), 0);
778+ assert_eq!(
779+ ledger.get("handoff.omega.legacy").expect("record").state,
780+ Issue31ProviderHandoffState::Requested,
781+ );
782+ }
783+
784+ #[test]
785+ fn a_lane_that_moves_under_a_bound_handoff_is_a_conflict_not_a_re_mapping() {
786+ let (mut ledger, handoff_ref) = opened();
787+ let before = [account(
788+ "account.claude.1",
789+ "anthropic",
790+ "lane.claude-local",
791+ "busy",
792+ )];
793+ ledger.advance(Some(&before), NOW_MS + 1_000);
794+ let after = [account(
795+ "account.claude.1",
796+ "anthropic",
797+ "lane.claude-local-2",
798+ "ready",
799+ )];
800+ assert_eq!(ledger.advance(Some(&after), NOW_MS + 2_000), 1);
801+ let conflicted = ledger.get(&handoff_ref).expect("record").clone();
802+ assert_eq!(conflicted.state, Issue31ProviderHandoffState::Failed);
803+ assert_eq!(
804+ conflicted.reason_class.as_deref(),
805+ Some(ISSUE31_HANDOFF_REASON_LANE_CONFLICT)
806+ );
807+ }
808+
809+ #[test]
810+ fn a_bound_account_that_leaves_the_roster_fails_rather_than_staying_active() {
811+ let (mut ledger, handoff_ref) = opened();
812+ let before = [account(
813+ "account.claude.1",
814+ "anthropic",
815+ "lane.claude-local",
816+ "busy",
817+ )];
818+ ledger.advance(Some(&before), NOW_MS + 1_000);
819+ assert_eq!(ledger.advance(Some(&[]), NOW_MS + 2_000), 1);
820+ assert_eq!(
821+ ledger
822+ .get(&handoff_ref)
823+ .expect("record")
824+ .reason_class
825+ .as_deref(),
826+ Some(ISSUE31_HANDOFF_REASON_ACCOUNT_WITHDRAWN),
827+ );
828+ }
829+
830+ #[test]
831+ fn the_device_supplies_no_timestamp_account_lane_or_outcome() {
832+ // The action's entire input surface is a provider token, so there is no
833+ // field through which a device could state a host-owned fact.
834+ assert_eq!(
835+ Issue31ProviderHandoffLedger::provider_from_arguments_ref(
836+ "arguments.omega.provider_handoff.anthropic"
837+ ),
838+ Ok("anthropic".to_string()),
839+ );
840+ for rejected in [
841+ "arguments.omega.none",
842+ "arguments.omega.provider_handoff.",
843+ "arguments.omega.provider_handoff.Anthropic",
844+ "arguments.omega.provider_handoff.an thropic",
845+ "arguments.omega.provider_handoff.../../etc",
846+ ] {
847+ assert!(
848+ Issue31ProviderHandoffLedger::provider_from_arguments_ref(rejected).is_err(),
849+ "expected {rejected:?} to be refused"
850+ );
851+ }
852+ }
853+
854+ #[test]
855+ fn a_replayed_command_reopens_the_same_handoff_with_its_first_stamp() {
856+ let mut ledger = Issue31ProviderHandoffLedger::default();
857+ let first = ledger
858+ .open(
859+ "arguments.omega.provider_handoff.anthropic",
860+ "idempotency.issue31.handoff:one",
861+ NOW_MS,
862+ )
863+ .expect("open");
864+ let replay = ledger
865+ .open(
866+ "arguments.omega.provider_handoff.anthropic",
867+ "idempotency.issue31.handoff:one",
868+ NOW_MS + 60_000,
869+ )
870+ .expect("replay");
871+ assert_eq!(first, replay, "a replay is not a second measurement");
872+ assert_eq!(ledger.len(), 1);
873+ }
874+
875+ #[test]
876+ fn a_second_request_after_a_terminal_one_opens_a_new_handoff() {
877+ let (mut ledger, first_ref) = opened();
878+ ledger.adopt_after_restart();
879+ let second = ledger
880+ .open(
881+ "arguments.omega.provider_handoff.anthropic",
882+ "idempotency.issue31.handoff:two",
883+ NOW_MS + 1_000,
884+ )
885+ .expect("a fresh ask is a fresh handoff");
886+ assert_ne!(second.handoff_ref, first_ref);
887+ assert_eq!(second.state, Issue31ProviderHandoffState::Requested);
888+ assert_eq!(ledger.len(), 2);
889+ }
890+
891+ #[test]
892+ fn the_ledger_refuses_the_row_after_the_contract_bound() {
893+ let mut ledger = Issue31ProviderHandoffLedger::default();
894+ for index in 0..MAX_ISSUE31_PROVIDER_HANDOFFS {
895+ ledger
896+ .open(
897+ "arguments.omega.provider_handoff.anthropic",
898+ &format!("idempotency.issue31.handoff:{index}"),
899+ NOW_MS,
900+ )
901+ .expect("within the bound");
902+ }
903+ assert_eq!(
904+ ledger.open(
905+ "arguments.omega.provider_handoff.anthropic",
906+ "idempotency.issue31.handoff:overflow",
907+ NOW_MS,
908+ ),
909+ Err(Issue31ProviderHandoffError::BoundExhausted),
910+ );
911+ assert_eq!(ledger.projected(NOW_MS).rows.len(), MAX_ISSUE31_PROVIDER_HANDOFFS);
912+ }
913+
914+ #[test]
915+ fn every_projected_row_is_one_the_reader_accepts() {
916+ let (mut ledger, _) = opened();
917+ let roster = [account(
918+ "account.claude.1",
919+ "anthropic",
920+ "lane.claude-local",
921+ "ready",
922+ )];
923+ ledger.advance(Some(&roster), NOW_MS + 1_000);
924+ ledger.advance(Some(&roster), NOW_MS + 2_000);
925+ let generated_at_ms = NOW_MS + 3_000;
926+ for row in ledger.projected(generated_at_ms).rows {
927+ decode_issue31_provider_handoff(&row, generated_at_ms)
928+ .expect("the emitter cannot write what the reader refuses");
929+ }
930+ }
931+
932+ #[test]
933+ fn a_row_stamped_after_the_reading_it_would_ride_in_is_refused() {
934+ let (ledger, handoff_ref) = opened();
935+ let projection = ledger.projected(NOW_MS - 1);
936+ assert!(projection.rows.is_empty());
937+ assert_eq!(projection.unavailable, vec![handoff_ref]);
938+ }
939+
940+ #[test]
941+ fn a_projected_row_carries_no_host_private_field() {
942+ let (mut ledger, _) = opened();
943+ let roster = [account(
944+ "account.claude.1",
945+ "anthropic",
946+ "lane.claude-local",
947+ "busy",
948+ )];
949+ ledger.advance(Some(&roster), NOW_MS + 1_000);
950+ let row = ledger.projected(NOW_MS + 2_000).rows.remove(0);
951+ let object = row.as_object().expect("row");
952+ // The deadline and the recorded lane are the host's own bookkeeping.
953+ // The contract has no field for either, and inventing one here would
954+ // widen the boundary the phone reads.
955+ assert!(object.get("deadlineAtMs").is_none());
956+ assert!(object.get("laneRef").is_none());
957+ assert!(object.get("lane").is_none());
958+ let mut keys: Vec<&str> = object.keys().map(String::as_str).collect();
959+ keys.sort_unstable();
960+ assert_eq!(
961+ keys,
962+ vec![
963+ "accountRef",
964+ "handoffRef",
965+ "provider",
966+ "requestedAtMs",
967+ "state"
968+ ]
969+ );
970+ }
971+}
972+
973+/// The byte-shared lifecycle fixture (omega#91).
974+///
975+/// `crates/workroom_receipts/fixtures/openagents.omega.issue31.fullauto.v1.host-produced-handoffs.json`
976+/// and its byte-identical peer under `packages/sarah/fixtures/issue31-workroom/`
977+/// are not written by hand. They are what this ledger emits when it is driven
978+/// through every lifecycle, so a change to the lifecycle that the phone would
979+/// read differently fails here rather than being discovered on a device.
980+#[cfg(test)]
981+mod shared_fixture {
982+ use super::*;
983+
984+ pub const FIXTURE_GENERATED_AT_MS: u64 = 1_785_000_600_000;
985+ pub const FIXTURE_OPENED_AT_MS: u64 = 1_785_000_000_000;
986+
987+ pub fn fixture_roster() -> Vec<Issue31ProviderRosterAccount> {
988+ vec![
989+ Issue31ProviderRosterAccount {
990+ account_ref: "account.claude.1".into(),
991+ provider: "anthropic".into(),
992+ lane_ref: "lane.claude-local".into(),
993+ readiness: "busy".into(),
994+ },
995+ Issue31ProviderRosterAccount {
996+ account_ref: "account.codex.1".into(),
997+ provider: "openai".into(),
998+ lane_ref: "lane.codex-local".into(),
999+ readiness: "ready".into(),
1000+ },
1001+ Issue31ProviderRosterAccount {
1002+ account_ref: "account.grok.1".into(),
1003+ provider: "xai".into(),
1004+ lane_ref: "lane.grok-local".into(),
1005+ readiness: "revoked".into(),
1006+ },
1007+ ]
1008+ }
1009+
1010+ /// Six handoffs, every one produced by the real lifecycle.
1011+ pub fn fixture_ledger() -> Issue31ProviderHandoffLedger {
1012+ let roster = fixture_roster();
1013+ let mut ledger = Issue31ProviderHandoffLedger::default();
1014+ for (provider, idempotency) in [
1015+ ("cohere", "idempotency.issue31.fixture-requested"),
1016+ ("anthropic", "idempotency.issue31.fixture-active"),
1017+ ("openai", "idempotency.issue31.fixture-completed"),
1018+ ("xai", "idempotency.issue31.fixture-refused"),
1019+ ("mistral", "idempotency.issue31.fixture-failed"),
1020+ ("google", "idempotency.issue31.fixture-expired"),
1021+ ] {
1022+ ledger
1023+ .open(
1024+ &format!("{ISSUE31_PROVIDER_HANDOFF_ARGUMENTS_PREFIX}{provider}"),
1025+ idempotency,
1026+ FIXTURE_OPENED_AT_MS,
1027+ )
1028+ .expect("open");
1029+ }
1030+ // One roster observation binds anthropic, openai and xai.
1031+ ledger.advance(Some(&roster), FIXTURE_OPENED_AT_MS + 60_000);
1032+ // A second settles openai (ready) and xai (revoked); anthropic is busy
1033+ // and stays bound and open.
1034+ ledger.advance(Some(&roster), FIXTURE_OPENED_AT_MS + 120_000);
1035+ // The host process ends. `mistral` was still `requested`, so it is
1036+ // settled as interrupted rather than lost -- but so would the others
1037+ // be, which is why the restart sweep runs on a ledger holding only the
1038+ // one still in flight in the real path. Here it is applied to a clone
1039+ // and merged, so each row shows exactly one lifecycle.
1040+ let mut interrupted = Issue31ProviderHandoffLedger::default();
1041+ interrupted
1042+ .open(
1043+ &format!("{ISSUE31_PROVIDER_HANDOFF_ARGUMENTS_PREFIX}mistral"),
1044+ "idempotency.issue31.fixture-failed",
1045+ FIXTURE_OPENED_AT_MS,
1046+ )
1047+ .expect("open");
1048+ interrupted.adopt_after_restart();
1049+ let mut expired = Issue31ProviderHandoffLedger::default();
1050+ expired
1051+ .open(
1052+ &format!("{ISSUE31_PROVIDER_HANDOFF_ARGUMENTS_PREFIX}google"),
1053+ "idempotency.issue31.fixture-expired",
1054+ FIXTURE_OPENED_AT_MS,
1055+ )
1056+ .expect("open");
1057+ expired.advance(
1058+ Some(&roster),
1059+ FIXTURE_OPENED_AT_MS + ISSUE31_PROVIDER_HANDOFF_DEADLINE_MS,
1060+ );
1061+ ledger.merge_for_fixture(interrupted);
1062+ ledger.merge_for_fixture(expired);
1063+ ledger
1064+ }
1065+
1066+ pub fn build_fixture_document() -> serde_json::Value {
1067+ let accounts: Vec<serde_json::Value> = fixture_roster()
1068+ .into_iter()
1069+ .map(|account| {
1070+ json!({
1071+ "accountRef": account.account_ref,
1072+ "provider": account.provider,
1073+ "label": match account.provider.as_str() {
1074+ "anthropic" => "Claude",
1075+ "openai" => "ChatGPT Personal",
1076+ _ => "Grok",
1077+ },
1078+ "state": account.readiness,
1079+ "quotaState": if account.readiness == "ready" { "available" } else { "cooling" },
1080+ "lane": account.lane_ref,
1081+ })
1082+ })
1083+ .collect();
1084+ let handoffs = fixture_ledger().projected(FIXTURE_GENERATED_AT_MS).rows;
1085+ let (_, document) = workroom_receipts::build_issue31_full_auto_adjunct_document(
1086+ "omega.host.local",
1087+ "snapshot.omega.issue31.handoff-lifecycle",
1088+ FIXTURE_GENERATED_AT_MS,
1089+ &json!({ "runs": [] }),
1090+ &json!({ "accounts": accounts }),
1091+ &json!({ "handoffs": handoffs }),
1092+ &[],
1093+ )
1094+ .expect("the ledger's own rows build a readable adjunct");
1095+ document
1096+ }
1097+
1098+ const HOST_PRODUCED: &str = include_str!(
1099+ "../../workroom_receipts/fixtures/openagents.omega.issue31.fullauto.v1.host-produced-handoffs.json"
1100+ );
1101+
1102+ #[test]
1103+ fn the_shared_fixture_is_exactly_what_the_ledger_emits() {
1104+ let expected: serde_json::Value =
1105+ serde_json::from_str(HOST_PRODUCED).expect("the shared fixture parses");
1106+ assert_eq!(
1107+ build_fixture_document(),
1108+ expected,
1109+ "the lifecycle changed. Re-share the fixture bytes with \
1110+ packages/sarah rather than re-pinning one side.",
1111+ );
1112+ }
1113+
1114+ #[test]
1115+ fn the_shared_fixture_shows_every_lifecycle_the_host_can_reach() {
1116+ let document = build_fixture_document();
1117+ let states: Vec<&str> = document
1118+ .get("handoffs")
1119+ .and_then(serde_json::Value::as_array)
1120+ .expect("handoffs")
1121+ .iter()
1122+ .filter_map(|handoff| handoff.get("state").and_then(serde_json::Value::as_str))
1123+ .collect();
1124+ for state in [
1125+ "requested", "active", "completed", "refused", "failed", "expired",
1126+ ] {
1127+ assert!(states.contains(&state), "{state} is unrepresented");
1128+ }
1129+ }
1130+
1131+ #[test]
1132+ fn every_terminal_row_in_the_shared_fixture_states_a_host_owned_outcome() {
1133+ let document = build_fixture_document();
1134+ for handoff in document
1135+ .get("handoffs")
1136+ .and_then(serde_json::Value::as_array)
1137+ .expect("handoffs")
1138+ {
1139+ let state = handoff
1140+ .get("state")
1141+ .and_then(serde_json::Value::as_str)
1142+ .expect("state");
1143+ let terminal = matches!(state, "completed" | "refused" | "failed" | "expired");
1144+ assert_eq!(
1145+ handoff.get("outcomeRef").is_some(),
1146+ terminal,
1147+ "an outcome is present exactly when the handoff is terminal: {handoff}",
1148+ );
1149+ if terminal && state != "completed" {
1150+ assert!(
1151+ handoff.get("reasonClass").is_some(),
1152+ "a non-successful end must say why: {handoff}",
1153+ );
1154+ }
1155+ }
1156+ }
1157+
1158+ #[test]
1159+ fn the_shared_fixture_carries_no_credential_home_or_private_path() {
1160+ // The record carries the fact of a connection, never the connection
1161+ // secret. Asserted on the bytes that actually ship.
1162+ let lowered = HOST_PRODUCED.to_ascii_lowercase();
1163+ for forbidden in [
1164+ "auth.json",
1165+ "bearer ",
1166+ "codex_home",
1167+ "/users/",
1168+ "/home/",
1169+ "~/",
1170+ "sk-",
1171+ "access_token",
1172+ "api_key",
1173+ ] {
1174+ assert!(
1175+ !lowered.contains(forbidden),
1176+ "the shared fixture leaked {forbidden}",
1177+ );
1178+ }
1179+ }
1180+}
diff --git a/crates/omega_effectd/src/omega_effectd.rs b/crates/omega_effectd/src/omega_effectd.rs
index 85c8ddb4cf..e008ab8aaf 100644
--- a/crates/omega_effectd/src/omega_effectd.rs
+++ b/crates/omega_effectd/src/omega_effectd.rs
@@ -4,6 +4,7 @@
44 //! Durable run truth stays in omega-effectd on disk. GPUI is not run authority.
55
66 mod issue31_nostr;
7+mod issue31_provider_handoff;
78 mod nostr_websocket_relay;
89 mod openagents_binding;
910 mod openagents_session;
@@ -25,6 +26,7 @@ pub use openagents_binding::{
2526 };
2627
2728 pub use issue31_nostr::*;
29+pub use issue31_provider_handoff::*;
2830 pub use nostr_websocket_relay::WebSocketRelayAdapter;
2931 pub use openagents_session::{
3032 OpenAgentsSession, OpenAgentsSessionPhase, VerifiedOpenAgentsSession, init_openagents_session,
@@ -44,7 +46,8 @@ pub use sarah_conversation::{
4446 SARAH_METHOD_RENEW_DEVICE_GRANT, SARAH_METHOD_REVOKE_DEVICE_GRANT,
4547 SARAH_METHOD_ROOM_SNAPSHOT, SARAH_METHOD_SEND_MESSAGE,
4648 SARAH_METHOD_SESSION_STATUS, Issue31HostProjectionDocuments, Issue31HostProjectionRequest,
47- Issue31HostProjectionSource, SarahConversationClient, SarahConversationConfig,
49+ Issue31HostProjectionSource, Issue31ProviderRosterSource,
50+ SarahConversationClient, SarahConversationConfig,
4851 SarahConversationError, SendMessageResult, SessionStatusResult, SigningIdentity,
4952 asserts_no_khala_sync_client,
5053 };
diff --git a/crates/omega_effectd/src/sarah_conversation.rs b/crates/omega_effectd/src/sarah_conversation.rs
index a2e05c4c41..1e605d7211 100644
--- a/crates/omega_effectd/src/sarah_conversation.rs
+++ b/crates/omega_effectd/src/sarah_conversation.rs
@@ -49,6 +49,10 @@ use crate::{
4949 SARAH_ENGRAM_KIND, SARAH_READ_STATE_KIND, SARAH_REMINDER_KIND, emit_issue31_owner_projection,
5050 emit_issue31_withheld_sources,
5151 };
52+use crate::issue31_provider_handoff::{
53+ ISSUE31_ACTION_REQUEST_PROVIDER_HANDOFF, Issue31ProviderHandoffLedger,
54+ Issue31ProviderRosterAccount,
55+};
5256
5357 pub use crate::openagents_binding::BindingState;
5458
@@ -959,6 +963,19 @@ pub struct Issue31HostProjectionRequest<'a> {
959963 pub expected_generation: u64,
960964 /// The pump's reading time, in epoch milliseconds.
961965 pub observed_at_ms: u64,
966+ /// The host's own provider connection handoff ledger (omega#91).
967+ ///
968+ /// This comes from the pump rather than from the Full Auto reading for the
969+ /// same reason the delivery binding does: the ledger is durable host state
970+ /// that survives a restart, and the panel's reading is a transient
971+ /// observation of the daemon. One of them is the record; letting the other
972+ /// also carry handoffs would give the phone two sources for one fact.
973+ ///
974+ /// The ledger is handed over unprojected on purpose. Only the builder knows
975+ /// the `generatedAtMs` the documents will carry, and a handoff row must be
976+ /// checked against exactly that stamp — projecting here, against the pump's
977+ /// clock, would accept rows the assembled document then contradicts.
978+ pub handoffs: &'a Issue31ProviderHandoffLedger,
962979 }
963980
964981 /// The two omega#47 documents `publish_issue31_host_snapshot` produces.
@@ -987,6 +1004,16 @@ pub type Issue31HostProjectionSource = Arc<
9871004 + Sync,
9881005 >;
9891006
1007+/// The host's own reading of its provider accounts, or `None` (omega#91).
1008+///
1009+/// `None` means the host has not looked at its roster. Nothing about a handoff
1010+/// advances on an unread roster: a binding decided against state nobody read
1011+/// would be a guess wearing a measurement's clothes. `Some(&[])` — the host
1012+/// looked and holds no accounts — is a real observation and does move a
1013+/// handoff's clock towards its deadline.
1014+pub type Issue31ProviderRosterSource =
1015+ Arc<dyn Fn() -> Option<Vec<Issue31ProviderRosterAccount>> + Send + Sync>;
1016+
9901017 pub struct SarahConversationClient {
9911018 config: SarahConversationConfig,
9921019 relay: Box<dyn RelayTransport>,
@@ -1018,6 +1045,11 @@ pub struct SarahConversationClient {
10181045 /// The live Full Auto reading, supplied by whoever holds the supervisor.
10191046 /// Absent means this host publishes no omega#47 records at all.
10201047 issue31_host_projection_source: Option<Issue31HostProjectionSource>,
1048+ /// Host-owned provider connection handoffs (omega#91). Durable: this is the
1049+ /// record, not a cache of one.
1050+ issue31_provider_handoffs: Issue31ProviderHandoffLedger,
1051+ /// How the host reads its own provider roster when deciding a handoff.
1052+ issue31_provider_roster_source: Option<Issue31ProviderRosterSource>,
10211053 issue31_state_path: Option<PathBuf>,
10221054 #[cfg(test)]
10231055 issue31_fail_commit_after: Cell<Option<usize>>,
@@ -1056,6 +1088,11 @@ struct DurableIssue31HostState {
10561088 quarantined_events: BTreeMap<String, String>,
10571089 #[serde(default)]
10581090 host_adjunct_emissions: BTreeMap<String, String>,
1091+ /// omega#91. `#[serde(default)]` so a state file written before handoffs
1092+ /// existed still loads — as an empty ledger, which is the truth about a
1093+ /// host that never had one, not a set of rows invented at load.
1094+ #[serde(default)]
1095+ provider_handoffs: Issue31ProviderHandoffLedger,
10591096 command_results: BTreeMap<String, (String, Value)>,
10601097 #[serde(default)]
10611098 active_turn_ref: Option<String>,
@@ -1108,6 +1145,8 @@ impl SarahConversationClient {
11081145 issue31_withheld_emissions: BTreeMap::new(),
11091146 issue31_host_adjunct_emissions: BTreeMap::new(),
11101147 issue31_host_projection_source: None,
1148+ issue31_provider_handoffs: Issue31ProviderHandoffLedger::default(),
1149+ issue31_provider_roster_source: None,
11111150 issue31_state_path: None,
11121151 #[cfg(test)]
11131152 issue31_fail_commit_after: Cell::new(None),
@@ -1195,6 +1234,18 @@ impl SarahConversationClient {
11951234 .as_ref()
11961235 .map(|persisted| persisted.command_results.clone())
11971236 .unwrap_or_default();
1237+ // omega#91: a handoff that was in flight when the last host process
1238+ // ended is settled here, at load, before anything else can read the
1239+ // ledger. The isolated provider home and the login it was driving died
1240+ // with that process, so the terminal answer is that the handoff was
1241+ // interrupted. This can only under-claim — it never reports a
1242+ // connection the host did not make — and it is what stops a restart
1243+ // leaving the phone with a request that neither resolves nor fails.
1244+ let mut issue31_provider_handoffs = persisted
1245+ .as_ref()
1246+ .map(|persisted| persisted.provider_handoffs.clone())
1247+ .unwrap_or_default();
1248+ issue31_provider_handoffs.adopt_after_restart();
11981249 let issue31_control_cursor = persisted
11991250 .as_ref()
12001251 .and_then(|persisted| persisted.control_cursor.clone());
@@ -1249,6 +1300,8 @@ impl SarahConversationClient {
12491300 issue31_withheld_emissions: BTreeMap::new(),
12501301 issue31_host_adjunct_emissions,
12511302 issue31_host_projection_source: None,
1303+ issue31_provider_handoffs,
1304+ issue31_provider_roster_source: None,
12521305 issue31_state_path: Some(issue31_state_path),
12531306 #[cfg(test)]
12541307 issue31_fail_commit_after: Cell::new(None),
@@ -1740,6 +1793,13 @@ impl SarahConversationClient {
17401793 cursor = Some(next_cursor);
17411794 }
17421795
1796+ // omega#91: one roster observation per pass, taken BEFORE this pass's
1797+ // commands are handled. A handoff opened by a command in this pass is
1798+ // therefore published as `requested` and only looked at against the
1799+ // roster on the next pass. Advancing after would mean the host bound a
1800+ // handoff using a roster reading older than the request itself, and the
1801+ // phone would never see the handoff appear before it bound.
1802+ self.advance_issue31_provider_handoffs(now);
17431803 for event in records {
17441804 if self
17451805 .issue31_quarantined_events
@@ -1908,8 +1968,8 @@ impl SarahConversationClient {
19081968 record,
19091969 },
19101970 now,
1911- |action_ref, arguments_ref| {
1912- self.execute_issue31_action(action_ref, arguments_ref)
1971+ |action_ref, arguments_ref, idempotency_ref| {
1972+ self.execute_issue31_action(action_ref, arguments_ref, idempotency_ref)
19131973 },
19141974 ) {
19151975 Ok(result) => result,
@@ -2254,6 +2314,7 @@ impl SarahConversationClient {
22542314 projection_cursor: self.issue31_projection_cursor.clone(),
22552315 quarantined_events: self.issue31_quarantined_events.clone(),
22562316 host_adjunct_emissions: self.issue31_host_adjunct_emissions.clone(),
2317+ provider_handoffs: self.issue31_provider_handoffs.clone(),
22572318 command_results: self.command_results.clone(),
22582319 active_turn_ref: self.active_turn_ref.clone(),
22592320 run_state: self.run_state.clone(),
@@ -2262,16 +2323,105 @@ impl SarahConversationClient {
22622323 )
22632324 }
22642325
2326+ /// Answer one admitted command-v1 intent (omega#91).
2327+ ///
2328+ /// The controller has already checked the host binding, the grant, the
2329+ /// generation, the lifetime, and the scope, so reaching here means the
2330+ /// device is entitled to this action.
2331+ ///
2332+ /// Only `action.omega.provider_handoff` is answered. The remaining v1
2333+ /// actions — Full Auto control and community action — still have no
2334+ /// generation-fenced controller behind them, and returning `unavailable`
2335+ /// for them is the honest answer rather than a silent success.
22652336 fn execute_issue31_action(
22662337 &mut self,
2267- _action_ref: &str,
2268- _arguments_ref: &str,
2338+ action_ref: &str,
2339+ arguments_ref: &str,
2340+ idempotency_ref: &str,
22692341 ) -> Issue31CommandExecution {
2270- Issue31CommandExecution {
2271- status: Issue31CommandStatus::Unavailable,
2272- outcome_ref: "outcome.omega.unavailable".into(),
2273- reason_ref: Some("reason.omega.controller_not_bound".into()),
2342+ if action_ref != ISSUE31_ACTION_REQUEST_PROVIDER_HANDOFF {
2343+ return Issue31CommandExecution {
2344+ status: Issue31CommandStatus::Unavailable,
2345+ outcome_ref: "outcome.omega.unavailable".into(),
2346+ reason_ref: Some("reason.omega.controller_not_bound".into()),
2347+ };
22742348 }
2349+ // One reading of the clock for this command. The stamp on the record
2350+ // and the deadline bounding it come from the same instant, and the
2351+ // device supplies neither: `argumentsRef` names a provider and nothing
2352+ // else, so there is no wire field a device could put a time in.
2353+ let now_ms = unix_now().saturating_mul(1_000);
2354+ match self
2355+ .issue31_provider_handoffs
2356+ .open(arguments_ref, idempotency_ref, now_ms)
2357+ {
2358+ Ok(record) => Issue31CommandExecution {
2359+ status: Issue31CommandStatus::Completed,
2360+ // The command was "open a provider connection handoff", and
2361+ // this is the record that opening produced — which is also how
2362+ // the device finds the row to watch. It is deliberately NOT a
2363+ // statement that the handoff completed: the handoff carries its
2364+ // own state and its own host-owned outcome, and at this moment
2365+ // that state is `requested`.
2366+ outcome_ref: record.handoff_ref,
2367+ reason_ref: None,
2368+ },
2369+ Err(error) => Issue31CommandExecution {
2370+ status: match error {
2371+ crate::issue31_provider_handoff::Issue31ProviderHandoffError::BoundExhausted
2372+ | crate::issue31_provider_handoff::Issue31ProviderHandoffError::Unprojectable(
2373+ _,
2374+ ) => Issue31CommandStatus::Unavailable,
2375+ _ => Issue31CommandStatus::Refused,
2376+ },
2377+ outcome_ref: match error {
2378+ crate::issue31_provider_handoff::Issue31ProviderHandoffError::BoundExhausted
2379+ | crate::issue31_provider_handoff::Issue31ProviderHandoffError::Unprojectable(
2380+ _,
2381+ ) => "outcome.omega.unavailable".into(),
2382+ _ => "outcome.omega.refused".into(),
2383+ },
2384+ reason_ref: Some(error.reason_ref().into()),
2385+ },
2386+ }
2387+ }
2388+
2389+ /// Install how this host reads its own provider roster (omega#91).
2390+ ///
2391+ /// Without one, no handoff ever binds: the host would be deciding against a
2392+ /// roster nobody read. Open handoffs still reach their deadline, so an
2393+ /// unwired host produces `expired` rather than a request that hangs.
2394+ pub fn set_issue31_provider_roster_source(&mut self, source: Issue31ProviderRosterSource) {
2395+ self.issue31_provider_roster_source = Some(source);
2396+ }
2397+
2398+ /// The host's provider connection handoffs, exactly as persisted.
2399+ pub fn issue31_provider_handoff_refs(&self) -> Vec<String> {
2400+ self.issue31_provider_handoffs
2401+ .records()
2402+ .map(|record| record.handoff_ref.clone())
2403+ .collect()
2404+ }
2405+
2406+ /// The contract rows this host would publish right now.
2407+ pub fn issue31_projected_provider_handoffs(&self, generated_at_ms: u64) -> Vec<Value> {
2408+ self.issue31_provider_handoffs
2409+ .projected(generated_at_ms)
2410+ .rows
2411+ }
2412+
2413+ /// Move every open handoff on by at most one observation of the roster.
2414+ ///
2415+ /// Run once per host pump pass, before the omega#47 documents are built, so
2416+ /// what the phone reads is this pass's state rather than the previous
2417+ /// pass's.
2418+ fn advance_issue31_provider_handoffs(&mut self, now: u64) {
2419+ let roster = self
2420+ .issue31_provider_roster_source
2421+ .as_ref()
2422+ .and_then(|source| source());
2423+ self.issue31_provider_handoffs
2424+ .advance(roster.as_deref(), now.saturating_mul(1_000));
22752425 }
22762426
22772427 fn execute_issue31_action_v2(
@@ -2891,6 +3041,12 @@ impl SarahConversationClient {
28913041 };
28923042 let grants = controller.active_grants(now).map_err(issue31_error)?;
28933043 let observed_at_ms = now.saturating_mul(1_000);
3044+ // omega#91. A handoff the host holds and can never state — one whose
3045+ // request time was never measured — makes the owner's view short, and
3046+ // that has to be visible rather than read as "no handoff in flight".
3047+ if !self.issue31_provider_handoffs.unstateable_refs().is_empty() {
3048+ self.last_gap_state = strongest_gap_state(self.last_gap_state, GapState::Possible);
3049+ }
28943050 for grant in grants {
28953051 let documents = match source(&Issue31HostProjectionRequest {
28963052 host_ref: &grant.host_ref,
@@ -2899,6 +3055,7 @@ impl SarahConversationClient {
28993055 grant_ref: &grant.grant_ref,
29003056 expected_generation: grant.generation,
29013057 observed_at_ms,
3058+ handoffs: &self.issue31_provider_handoffs,
29023059 }) {
29033060 Ok(Some(documents)) => documents,
29043061 // Nothing observed is said by saying nothing. Publishing an
@@ -5556,7 +5713,11 @@ mod tests {
55565713 "action.omega.interrupt_turn",
55575714 "action.omega.send_message",
55585715 ] {
5559- let execution = client.execute_issue31_action(action_ref, "arguments.omega.none");
5716+ let execution = client.execute_issue31_action(
5717+ action_ref,
5718+ "arguments.omega.none",
5719+ "idempotency.issue31.unbound",
5720+ );
55605721 assert_eq!(execution.status, Issue31CommandStatus::Unavailable);
55615722 assert_eq!(
55625723 execution.reason_ref.as_deref(),
@@ -5567,6 +5728,307 @@ mod tests {
55675728 assert_eq!(client.active_turn_ref.as_deref(), Some("turn.9"));
55685729 }
55695730
5731+ // ---------------------------------------------------------------------
5732+ // omega#91 provider connection handoffs
5733+ // ---------------------------------------------------------------------
5734+
5735+ /// A paired device holding exactly the scope the phone already asks for.
5736+ fn handoff_fixture_with(
5737+ scopes: Vec<crate::Issue31PairingScope>,
5738+ ) -> (
5739+ SarahConversationClient,
5740+ Issue31HostConfiguration,
5741+ Issue31HostController,
5742+ String,
5743+ String,
5744+ ) {
5745+ let (configuration, controller, device_public_key_hex, grant_ref) =
5746+ crate::issue31_nostr::paired_fixture(scopes);
5747+ let mut config = SarahConversationConfig::mock_fixture();
5748+ config.identity.owner_public_key_hex = configuration.host_public_key_hex.clone();
5749+ let client = SarahConversationClient::with_relay(
5750+ config,
5751+ Box::new(MockRelayAdapter::new()),
5752+ SigningIdentity::generate(),
5753+ );
5754+ (
5755+ client,
5756+ configuration,
5757+ controller,
5758+ device_public_key_hex,
5759+ grant_ref,
5760+ )
5761+ }
5762+
5763+ fn handoff_fixture() -> (
5764+ SarahConversationClient,
5765+ Issue31HostConfiguration,
5766+ Issue31HostController,
5767+ String,
5768+ String,
5769+ ) {
5770+ handoff_fixture_with(vec![crate::Issue31PairingScope::RequestProviderHandoff])
5771+ }
5772+
5773+ fn handoff_intent(
5774+ configuration: &Issue31HostConfiguration,
5775+ device_public_key_hex: &str,
5776+ grant_ref: &str,
5777+ event_id: &str,
5778+ idempotency_ref: &str,
5779+ arguments_ref: &str,
5780+ ) -> Issue31CommandEvent {
5781+ Issue31CommandEvent {
5782+ event_id: event_id.to_string(),
5783+ record: Issue31CommandRecord::CommandIntent {
5784+ schema: ISSUE31_COMMAND_SCHEMA.into(),
5785+ host_ref: configuration.host_ref.clone(),
5786+ host_public_key_hex: configuration.host_public_key_hex.clone(),
5787+ device_public_key_hex: device_public_key_hex.to_string(),
5788+ grant_ref: grant_ref.to_string(),
5789+ action_ref: ISSUE31_ACTION_REQUEST_PROVIDER_HANDOFF.into(),
5790+ idempotency_ref: idempotency_ref.to_string(),
5791+ expected_generation: 1,
5792+ arguments_ref: arguments_ref.to_string(),
5793+ issued_at: 103,
5794+ expires_at: 900,
5795+ },
5796+ }
5797+ }
5798+
5799+ #[test]
5800+ fn the_scope_the_phone_holds_now_produces_a_host_record() {
5801+ // Before omega#91 this exact command reached `execute_issue31_action`
5802+ // and came back `unavailable`/`controller_not_bound`, and the handoff
5803+ // vector on the wire stayed empty forever.
5804+ let (mut client, configuration, mut controller, device_public_key_hex, grant_ref) =
5805+ handoff_fixture();
5806+ let intent = handoff_intent(
5807+ &configuration,
5808+ &device_public_key_hex,
5809+ &grant_ref,
5810+ &"1".repeat(64),
5811+ "idempotency.issue31.handoff:first",
5812+ "arguments.omega.provider_handoff.anthropic",
5813+ );
5814+ let result = controller
5815+ .handle_command_event(
5816+ intent,
5817+ 104,
5818+ |action_ref, arguments_ref, idempotency_ref| {
5819+ client.execute_issue31_action(action_ref, arguments_ref, idempotency_ref)
5820+ },
5821+ )
5822+ .expect("the host answers an admitted handoff request")
5823+ .expect("a command result");
5824+ let Issue31CommandRecord::CommandResult {
5825+ status,
5826+ outcome_ref,
5827+ reason_ref,
5828+ ..
5829+ } = &result
5830+ else {
5831+ panic!("expected a command result");
5832+ };
5833+ assert_eq!(*status, Issue31CommandStatus::Completed);
5834+ assert_eq!(reason_ref.as_deref(), None);
5835+ // The command completed by producing this record; the record itself is
5836+ // not terminal. Reading one as the other is the mistake this asserts
5837+ // against.
5838+ assert_eq!(
5839+ client.issue31_provider_handoff_refs(),
5840+ vec![outcome_ref.clone()]
5841+ );
5842+ let record = client
5843+ .issue31_provider_handoffs
5844+ .get(outcome_ref)
5845+ .expect("the outcome names the record the host made")
5846+ .clone();
5847+ assert_eq!(record.state, workroom_receipts::Issue31ProviderHandoffState::Requested);
5848+ assert!(!record.is_terminal());
5849+ assert!(record.requested_at_ms.is_some(), "the host stamped it");
5850+ assert!(record.account_ref.is_none(), "nothing is bound yet");
5851+ assert!(record.outcome_ref.is_none(), "no outcome is claimed yet");
5852+ }
5853+
5854+ #[test]
5855+ fn a_scope_denied_request_leaves_no_handoff_at_all() {
5856+ // The failure-vs-never-started distinction, at the wire. A device
5857+ // without the scope gets a refusal — and the host makes no record, so
5858+ // the phone's handoff list is empty rather than showing a failed row
5859+ // for something that never began.
5860+ let (mut client, configuration, mut controller, device_public_key_hex, grant_ref) =
5861+ handoff_fixture_with(vec![crate::Issue31PairingScope::ObserveIssue31]);
5862+ let intent = handoff_intent(
5863+ &configuration,
5864+ &device_public_key_hex,
5865+ &grant_ref,
5866+ &"2".repeat(64),
5867+ "idempotency.issue31.handoff:denied",
5868+ "arguments.omega.provider_handoff.anthropic",
5869+ );
5870+ let result = controller
5871+ .handle_command_event(
5872+ intent,
5873+ 104,
5874+ |action_ref, arguments_ref, idempotency_ref| {
5875+ client.execute_issue31_action(action_ref, arguments_ref, idempotency_ref)
5876+ },
5877+ )
5878+ .expect("the controller answers")
5879+ .expect("a command result");
5880+ let Issue31CommandRecord::CommandResult { status, reason_ref, .. } = &result else {
5881+ panic!("expected a command result");
5882+ };
5883+ assert_eq!(*status, Issue31CommandStatus::Refused);
5884+ assert_eq!(reason_ref.as_deref(), Some("reason.omega.scope_denied"));
5885+ assert!(
5886+ client.issue31_provider_handoff_refs().is_empty(),
5887+ "a request the host never admitted is not a handoff that failed",
5888+ );
5889+ assert!(client.issue31_projected_provider_handoffs(1_000_000).is_empty());
5890+ }
5891+
5892+ #[test]
5893+ fn a_handoff_request_naming_no_provider_is_refused_without_a_record() {
5894+ let (mut client, configuration, mut controller, device_public_key_hex, grant_ref) =
5895+ handoff_fixture();
5896+ let intent = handoff_intent(
5897+ &configuration,
5898+ &device_public_key_hex,
5899+ &grant_ref,
5900+ &"3".repeat(64),
5901+ "idempotency.issue31.handoff:bad_arguments",
5902+ "arguments.omega.none",
5903+ );
5904+ let result = controller
5905+ .handle_command_event(
5906+ intent,
5907+ 104,
5908+ |action_ref, arguments_ref, idempotency_ref| {
5909+ client.execute_issue31_action(action_ref, arguments_ref, idempotency_ref)
5910+ },
5911+ )
5912+ .expect("the controller answers")
5913+ .expect("a command result");
5914+ let Issue31CommandRecord::CommandResult { status, reason_ref, .. } = &result else {
5915+ panic!("expected a command result");
5916+ };
5917+ assert_eq!(*status, Issue31CommandStatus::Refused);
5918+ assert_eq!(
5919+ reason_ref.as_deref(),
5920+ Some("reason.omega.handoff_arguments_invalid")
5921+ );
5922+ assert!(client.issue31_provider_handoff_refs().is_empty());
5923+ }
5924+
5925+ #[test]
5926+ fn a_handoff_in_flight_survives_a_restart_and_is_settled_rather_than_lost() {
5927+ let temporary = tempfile::tempdir().expect("tempdir");
5928+ let state_path = temporary.path().join("owner").join("issue31-state.json");
5929+ let (mut client, configuration, controller, _, _) = handoff_fixture();
5930+ client.issue31_state_path = Some(state_path.clone());
5931+ let opened = client
5932+ .issue31_provider_handoffs
5933+ .open(
5934+ "arguments.omega.provider_handoff.anthropic",
5935+ "idempotency.issue31.handoff:restart",
5936+ 1_785_000_000_000,
5937+ )
5938+ .expect("the host opens a handoff");
5939+ client
5940+ .persist_issue31_host_state_with_controller(&controller)
5941+ .expect("the ledger is committed with the rest of host state");
5942+
5943+ // Exactly what a restarted process reads back off disk.
5944+ let reloaded = load_issue31_host_state(&state_path, &configuration)
5945+ .expect("load")
5946+ .expect("persisted state");
5947+ let mut ledger = reloaded.provider_handoffs;
5948+ assert_eq!(
5949+ ledger.get(&opened.handoff_ref).expect("the row survived"),
5950+ &opened,
5951+ "a handoff in flight is not lost across a restart",
5952+ );
5953+
5954+ // And a restart resolves it rather than leaving the phone with a
5955+ // request that neither resolves nor fails.
5956+ assert_eq!(ledger.adopt_after_restart(), 1);
5957+ let settled = ledger.get(&opened.handoff_ref).expect("row").clone();
5958+ assert_eq!(
5959+ settled.state,
5960+ workroom_receipts::Issue31ProviderHandoffState::Failed
5961+ );
5962+ assert_eq!(
5963+ settled.reason_class.as_deref(),
5964+ Some(crate::issue31_provider_handoff::ISSUE31_HANDOFF_REASON_HOST_RESTARTED)
5965+ );
5966+ assert!(settled.is_terminal());
5967+ }
5968+
5969+ #[test]
5970+ fn the_pump_advances_a_handoff_against_the_hosts_own_roster() {
5971+ let (mut client, _, _, _, _) = handoff_fixture();
5972+ let opened = client
5973+ .issue31_provider_handoffs
5974+ .open(
5975+ "arguments.omega.provider_handoff.anthropic",
5976+ "idempotency.issue31.handoff:roster",
5977+ 1_000,
5978+ )
5979+ .expect("open");
5980+ client.set_issue31_provider_roster_source(Arc::new(|| {
5981+ Some(vec![Issue31ProviderRosterAccount {
5982+ account_ref: "account.claude.1".into(),
5983+ provider: "anthropic".into(),
5984+ lane_ref: "lane.claude-local".into(),
5985+ readiness: "ready".into(),
5986+ }])
5987+ }));
5988+ client.advance_issue31_provider_handoffs(2);
5989+ assert_eq!(
5990+ client
5991+ .issue31_provider_handoffs
5992+ .get(&opened.handoff_ref)
5993+ .expect("row")
5994+ .account_ref
5995+ .as_deref(),
5996+ Some("account.claude.1"),
5997+ );
5998+ client.advance_issue31_provider_handoffs(3);
5999+ assert_eq!(
6000+ client
6001+ .issue31_provider_handoffs
6002+ .get(&opened.handoff_ref)
6003+ .expect("row")
6004+ .state,
6005+ workroom_receipts::Issue31ProviderHandoffState::Completed,
6006+ );
6007+ }
6008+
6009+ #[test]
6010+ fn a_host_that_never_read_its_roster_binds_nothing() {
6011+ let (mut client, _, _, _, _) = handoff_fixture();
6012+ let opened = client
6013+ .issue31_provider_handoffs
6014+ .open(
6015+ "arguments.omega.provider_handoff.anthropic",
6016+ "idempotency.issue31.handoff:unread",
6017+ 1_000,
6018+ )
6019+ .expect("open");
6020+ client.set_issue31_provider_roster_source(Arc::new(|| None));
6021+ client.advance_issue31_provider_handoffs(2);
6022+ assert_eq!(
6023+ client
6024+ .issue31_provider_handoffs
6025+ .get(&opened.handoff_ref)
6026+ .expect("row")
6027+ .state,
6028+ workroom_receipts::Issue31ProviderHandoffState::Requested,
6029+ );
6030+ }
6031+
55706032 #[test]
55716033 fn no_khala_sync_client_on_sarah_lane() {
55726034 assert!(asserts_no_khala_sync_client());
@@ -6158,6 +6620,7 @@ mod tests {
61586620 "reason.omega.invalid_pairing_record".into(),
61596621 )]),
61606622 host_adjunct_emissions: BTreeMap::new(),
6623+ provider_handoffs: Issue31ProviderHandoffLedger::default(),
61616624 command_results: BTreeMap::from([(
61626625 "idempotency.restart.admin".into(),
61636626 ("fingerprint".into(), json!({ "eventId": "1".repeat(64) })),
@@ -6226,7 +6689,7 @@ mod tests {
62266689 },
62276690 },
62286691 107,
6229- |_, _| panic!("revoked device command executed"),
6692+ |_, _, _| panic!("revoked device command executed"),
62306693 )
62316694 .expect("terminal refusal")
62326695 .expect("result record");
diff --git a/crates/workroom_receipts/fixtures/openagents.omega.issue31.fullauto.v1.host-produced-handoffs.json b/crates/workroom_receipts/fixtures/openagents.omega.issue31.fullauto.v1.host-produced-handoffs.json
new file mode 100644
index 0000000000..950609cbfe
--- /dev/null
+++ b/crates/workroom_receipts/fixtures/openagents.omega.issue31.fullauto.v1.host-produced-handoffs.json
@@ -0,0 +1,82 @@
1+{
2+ "schema": "openagents.omega.issue31.fullauto.v1",
3+ "hostRef": "omega.host.local",
4+ "snapshotRef": "snapshot.omega.issue31.handoff-lifecycle",
5+ "generatedAtMs": 1785000600000,
6+ "runs": [],
7+ "accounts": [
8+ {
9+ "accountRef": "account.claude.1",
10+ "provider": "anthropic",
11+ "label": "Claude",
12+ "readiness": "busy",
13+ "quota": "cooling",
14+ "laneRef": "lane.claude-local"
15+ },
16+ {
17+ "accountRef": "account.codex.1",
18+ "provider": "openai",
19+ "label": "ChatGPT Personal",
20+ "readiness": "ready",
21+ "quota": "available",
22+ "laneRef": "lane.codex-local"
23+ },
24+ {
25+ "accountRef": "account.grok.1",
26+ "provider": "xai",
27+ "label": "Grok",
28+ "readiness": "revoked",
29+ "quota": "cooling",
30+ "laneRef": "lane.grok-local"
31+ }
32+ ],
33+ "handoffs": [
34+ {
35+ "handoffRef": "handoff.omega.317c67eb3869577e6e6711f8",
36+ "provider": "google",
37+ "state": "expired",
38+ "requestedAtMs": 1785000000000,
39+ "reasonClass": "reason.omega.handoff_deadline_passed",
40+ "outcomeRef": "outcome.omega.handoff_expired"
41+ },
42+ {
43+ "handoffRef": "handoff.omega.42f628c7cfc05fcbfb955e7a",
44+ "provider": "anthropic",
45+ "state": "active",
46+ "requestedAtMs": 1785000000000,
47+ "accountRef": "account.claude.1"
48+ },
49+ {
50+ "handoffRef": "handoff.omega.46a5da3a3bbb4e6c972a3c4f",
51+ "provider": "cohere",
52+ "state": "requested",
53+ "requestedAtMs": 1785000000000
54+ },
55+ {
56+ "handoffRef": "handoff.omega.4ba4b89bf0b57def937595bb",
57+ "provider": "xai",
58+ "state": "refused",
59+ "requestedAtMs": 1785000000000,
60+ "accountRef": "account.grok.1",
61+ "reasonClass": "reason.omega.handoff_account_revoked",
62+ "outcomeRef": "outcome.omega.handoff_refused"
63+ },
64+ {
65+ "handoffRef": "handoff.omega.83c4bd38a1589869bc6ef0eb",
66+ "provider": "mistral",
67+ "state": "failed",
68+ "requestedAtMs": 1785000000000,
69+ "reasonClass": "reason.omega.handoff_host_restarted",
70+ "outcomeRef": "outcome.omega.handoff_interrupted"
71+ },
72+ {
73+ "handoffRef": "handoff.omega.f34f1f6d3af807f145446d78",
74+ "provider": "openai",
75+ "state": "completed",
76+ "requestedAtMs": 1785000000000,
77+ "accountRef": "account.codex.1",
78+ "outcomeRef": "outcome.omega.handoff_connected"
79+ }
80+ ],
81+ "evidence": []
82+}
diff --git a/crates/workroom_receipts/src/issue31_full_auto.rs b/crates/workroom_receipts/src/issue31_full_auto.rs
index ebe5be08b9..f806e09aaf 100644
--- a/crates/workroom_receipts/src/issue31_full_auto.rs
+++ b/crates/workroom_receipts/src/issue31_full_auto.rs
@@ -694,6 +694,25 @@ fn project_handoff(raw: RawHandoff, generated_at_ms: u64) -> AdjunctResult<Issue
694694 })
695695 }
696696
697+/// Decode exactly one provider connection handoff row.
698+///
699+/// This is the same `project_handoff` the whole-document decoder applies, with
700+/// no second copy of the law: a producer that routes a row through this cannot
701+/// write a handoff the reader of the assembled adjunct would then refuse.
702+///
703+/// It deliberately does not check the one law that is not local to a row — a
704+/// bound `accountRef` must name an account the same snapshot carries. That
705+/// relation belongs to the document, and a producer holding only a row cannot
706+/// state it. `decode_issue31_full_auto_adjunct` still enforces it.
707+pub fn decode_issue31_provider_handoff(
708+ value: &serde_json::Value,
709+ generated_at_ms: u64,
710+) -> AdjunctResult<Issue31ProviderHandoff> {
711+ let raw: RawHandoff = serde_json::from_value(value.clone())
712+ .map_err(|_| Issue31FullAutoAdjunctError::InvalidJson)?;
713+ project_handoff(raw, generated_at_ms)
714+}
715+
697716 fn project_evidence(raw: RawEvidence) -> AdjunctResult<Issue31EvidenceChain> {
698717 match raw {
699718 RawEvidence::Unavailable {
@@ -784,6 +803,45 @@ mod tests {
784803 assert_eq!(adjunct.evidence.len(), 2);
785804 }
786805
806+ /// The bytes `omega_effectd`'s handoff ledger actually emits (omega#91),
807+ /// byte-shared with `packages/sarah`.
808+ const HOST_PRODUCED_HANDOFFS: &str = include_str!(
809+ "../fixtures/openagents.omega.issue31.fullauto.v1.host-produced-handoffs.json"
810+ );
811+
812+ #[test]
813+ fn decodes_every_host_produced_handoff_lifecycle() {
814+ let adjunct =
815+ decode_issue31_full_auto_adjunct(HOST_PRODUCED_HANDOFFS).expect("host-produced decodes");
816+ let states: Vec<Issue31ProviderHandoffState> = adjunct
817+ .handoffs
818+ .iter()
819+ .map(|handoff| handoff.state)
820+ .collect();
821+ for state in [
822+ Issue31ProviderHandoffState::Requested,
823+ Issue31ProviderHandoffState::Active,
824+ Issue31ProviderHandoffState::Completed,
825+ Issue31ProviderHandoffState::Refused,
826+ Issue31ProviderHandoffState::Failed,
827+ Issue31ProviderHandoffState::Expired,
828+ ] {
829+ assert!(states.contains(&state), "{state:?} is unrepresented");
830+ }
831+ // A bound handoff and the account it chose are readable together, so a
832+ // viewer can follow the lane the host picked rather than infer it.
833+ let accounts: HashSet<&str> = adjunct
834+ .accounts
835+ .iter()
836+ .map(|account| account.account_ref.as_str())
837+ .collect();
838+ for handoff in &adjunct.handoffs {
839+ if let Some(account_ref) = &handoff.account_ref {
840+ assert!(accounts.contains(account_ref.as_str()));
841+ }
842+ }
843+ }
844+
787845 #[test]
788846 fn projects_the_explicit_account_to_lane_relation() {
789847 let adjunct = decode_issue31_full_auto_adjunct(CANONICAL).expect("canonical decodes");
diff --git a/crates/workroom_receipts/src/workroom_receipts.rs b/crates/workroom_receipts/src/workroom_receipts.rs
index c3b9b33661..402e97de32 100644
--- a/crates/workroom_receipts/src/workroom_receipts.rs
+++ b/crates/workroom_receipts/src/workroom_receipts.rs
@@ -30,7 +30,7 @@ pub use issue31_full_auto::{
3030 MAX_ISSUE31_FULL_AUTO_CONTROLS, MAX_ISSUE31_FULL_AUTO_HANDOFFS, MAX_ISSUE31_FULL_AUTO_RUNS,
3131 MAX_ISSUE31_UNATTENDED_MS, build_issue31_full_auto_adjunct,
3232 build_issue31_full_auto_adjunct_document, decode_issue31_full_auto_adjunct,
33- is_issue31_public_text, project_issue31_evidence_pair,
33+ decode_issue31_provider_handoff, is_issue31_public_text, project_issue31_evidence_pair,
3434 };
3535 pub use issue31_host::{
3636 ISSUE31_HOST_ADJUNCT_SCHEMA, Issue31AbsentGap, Issue31CommandState, Issue31CommandStateInput,
diff --git a/docs/src/development/omega-issue31-mobile-host-adjunct.md b/docs/src/development/omega-issue31-mobile-host-adjunct.md
index 13fc3d997f..0b66ffcd45 100644
--- a/docs/src/development/omega-issue31-mobile-host-adjunct.md
+++ b/docs/src/development/omega-issue31-mobile-host-adjunct.md
@@ -214,12 +214,74 @@ on, continues with later records, and advances the control cursor only through
214214 the scanned page. Transport, signature, custody, and durable-write failures
215215 still fail the whole synchronization.
216216
217-Legacy v1 Full Auto, provider-handoff, and community action intents do not yet
218-dispatch into a generation-fenced real controller. Omega returns typed
219-`unavailable` with `reason.omega.controller_not_bound` and leaves local state
220-untouched. Command-v2 Sarah and owner-state actions use their real Nostr paths,
221-but `accepted` is deliberately non-terminal. Neither a relay acknowledgement
222-nor a host-side display mutation is reported as completed/stopped execution.
217+Legacy v1 Full Auto and community action intents do not yet dispatch into a
218+generation-fenced real controller. Omega returns typed `unavailable` with
219+`reason.omega.controller_not_bound` and leaves local state untouched.
220+Command-v2 Sarah and owner-state actions use their real Nostr paths, but
221+`accepted` is deliberately non-terminal. Neither a relay acknowledgement nor a
222+host-side display mutation is reported as completed/stopped execution.
223+
224+## Provider connection handoffs {#provider-connection-handoffs}
225+
226+`action.omega.provider_handoff` does dispatch (omega#91). A device holding the
227+`request_provider_handoff` scope sends a command-v1 intent whose `argumentsRef`
228+is `arguments.omega.provider_handoff.<provider>`; that prefix is the action's
229+entire input surface, so there is no wire field through which a device could
230+state a time, an account, a lane, or an outcome. The host answers by opening a
231+record in `Issue31ProviderHandoffLedger` and returns `completed` with the new
232+`handoffRef` as its `outcomeRef` — the record the device then watches. That
233+`completed` describes the command, not the handoff: the handoff at that moment
234+is `requested`.
235+
236+Each record has one lifecycle, and every field on it is a host measurement:
237+
238+| Field | How the host measures it |
239+| --------------- | ---------------------------------------------------------------- |
240+| `requestedAtMs` | One reading of `now`, taken when the host admitted the command |
241+| `accountRef` | Chosen from the host's own provider roster, never supplied |
242+| `state` | Advanced by at most one roster observation per host pump pass |
243+| `reasonClass` | Why a non-successful handoff ended that way |
244+| `outcomeRef` | Present exactly when the handoff is terminal |
245+
246+Terminal outcomes are `outcome.omega.handoff_connected` (a ready account for
247+that provider, bound to its lane), `outcome.omega.handoff_refused` with
248+`reason.omega.handoff_account_revoked`, `outcome.omega.handoff_failed` with
249+`reason.omega.handoff_account_lane_conflict` or
250+`reason.omega.handoff_account_withdrawn`,
251+`outcome.omega.handoff_interrupted` with
252+`reason.omega.handoff_host_restarted`, and
253+`outcome.omega.handoff_expired` with `reason.omega.handoff_deadline_passed`
254+after fifteen minutes.
255+
256+Binding and completing are separate passes on purpose, so a phone observes the
257+handoff appear, bind, and settle rather than seeing it jump. The roster is read
258+through the same `parse_provider_accounts` the desktop provider roster renders,
259+so the account a handoff chose and the lane it serves are the ones Omega shows;
260+`handoff.accountRef` → `account.laneRef` is the account-to-lane relation
261+omega#42 asked for, and the contract refuses a handoff naming an account the
262+snapshot does not carry.
263+
264+The ledger is committed with the rest of the durable Issue 31 host state. On
265+load, every handoff still in flight is settled as
266+`failed`/`reason.omega.handoff_host_restarted`: the isolated provider home and
267+the login it drove died with the previous process, so the honest terminal
268+answer is that the handoff was interrupted. That direction can only
269+under-claim; a restart never reports a connection the host did not make. A row
270+persisted before `requestedAtMs` existed decodes, is reported unavailable, and
271+is refused — never stamped at load time, which would give one field two
272+provenances nothing on the wire distinguishes.
273+
274+A request the host never admitted — no scope, or an `argumentsRef` that names
275+no provider — leaves **no record at all**. That is what makes a failed handoff
276+distinguishable from one that never started: one is a row carrying a
277+host-owned reason and outcome, the other is an empty list.
278+
279+A handoff record carries the fact of a connection and never the connection
280+secret. Nothing in this path reads, writes, or names a provider credential, an
281+isolated provider home, or a filesystem path, and every projected row is routed
282+back through `workroom_receipts::decode_issue31_provider_handoff` — the exact
283+function the whole-document decoder uses — so the host cannot write a handoff
284+the phone would refuse.
223285
224286 Pairing and command intake uses the same durability order. Omega applies an
225287 inbound record to a cloned controller, signs and inserts any terminal response
@@ -274,6 +336,11 @@ Rust owns the canonical fixture bytes under
274336 - `openagents.omega.issue31.host.v1.negative-unsafe-ref.json`
275337 - `openagents.omega.issue31.host.v1.negative-invalid-state.json`
276338
339+`openagents.omega.issue31.fullauto.v1.host-produced-handoffs.json` is shared the
340+same way and is not written by hand: it is exactly what
341+`Issue31ProviderHandoffLedger` emits when driven through all six lifecycle
342+states, asserted on the Rust side and digest-pinned on the TypeScript side.
343+
277344 The canonical fixture covers idle, pending, refused, and terminal states. The
278345 negative fixtures prove unknown private fields, unsafe references, and an
279346 incoherent unavailable projection fail closed.
@@ -302,6 +369,7 @@ change is therefore a cross-repository contract change, not formatting churn.
302369 cargo test -p workroom_receipts --lib
303370 cargo test -p omega_identity --lib
304371 cargo test -p omega_effectd --lib
372+cargo test -p full_auto_ui --lib
305373 cargo test -p agent_ui -p workroom_ui --lib
306374 ./script/clippy -p omega_identity
307375 ./script/clippy -p omega_effectd
@@ -310,6 +378,18 @@ cargo test -p agent_ui -p workroom_ui --lib
310378 ./script/clippy -p workroom_receipts
311379 ```
312380
381+Against the deployed relay (omega#91):
382+
383+```sh
384+OMEGA_LIVE_RELAY_URL=wss://relay.openagents.com \
385+ cargo test -p full_auto_ui --lib \
386+ a_provider_handoff_appears_binds_and_settles_on_a_live_relay -- --ignored --nocapture
387+OMEGA_LIVE_RELAY_URL=wss://relay.openagents.com \
388+ cargo test -p full_auto_ui --lib \
389+ a_refused_handoff_is_distinct_from_one_that_never_started_on_a_live_relay \
390+ -- --ignored --nocapture
391+```
392+
313393 ## Falsifiers {#falsifiers}
314394
315395 - A GPUI entity or mobile view becomes the record owner.
@@ -317,3 +397,8 @@ cargo test -p agent_ui -p workroom_ui --lib
317397 - A mobile REST route mirrors the Nostr record.
318398 - A fixture is presented as a connected host source.
319399 - A secret, private payload, exact local path, or raw tool output decodes.
400+- A provider connection handoff states a time, an account, a lane, or an
401+ outcome the device supplied.
402+- A handoff in flight at shutdown is lost, or is reported as connected.
403+- A handoff row missing its measured request time is shown with one filled in.
404+- A request the host never admitted produces a handoff record.