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 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git

Revision diff

40e567f1 e5b422b1
diff --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.
Served at tenant.openagents/omega Member data and write actions are omitted.