Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T00:56:22.246Z 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

460e04e5 1fe77eef
diff --git a/Cargo.lock b/Cargo.lock
index cd7dbad625..1a42900a4c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6605,12 +6605,14 @@ dependencies = [
66056605  "editor",
66066606  "futures 0.3.32",
66076607  "gpui",
6608+ "nostr",
66086609  "omega_effectd",
66096610  "omega_front_door",
66106611  "project",
66116612  "serde",
66126613  "serde_json",
66136614  "settings",
6615+ "sha2",
66146616  "smol",
66156617  "tempfile",
66166618  "theme",
diff --git a/crates/agent_ui/src/omega_host_bridge.rs b/crates/agent_ui/src/omega_host_bridge.rs
index f847f083bb..fb2b1a1683 100644
--- a/crates/agent_ui/src/omega_host_bridge.rs
+++ b/crates/agent_ui/src/omega_host_bridge.rs
@@ -322,8 +322,20 @@ fn production_sarah_conversation() -> Result<SarahConversationClient, HostRespon
322322         community_group_ids,
323323         community_public_key_hexes,
324324     };
325-    SarahConversationClient::new_production(config, relay_urls, identity_service)
326-        .map_err(|error| unavailable(format!("Sarah Nostr transport is unavailable: {error}")))
325+    let mut conversation = SarahConversationClient::new_production(
326+        config,
327+        relay_urls,
328+        identity_service,
329+    )
330+    .map_err(|error| unavailable(format!("Sarah Nostr transport is unavailable: {error}")))?;
331+    // omega#49: the host pump publishes the omega#47 snapshot and its Full Auto
332+    // detail to every admitted device. Without this the two documents are built
333+    // by the desktop panel and never leave the machine, and a paired phone
334+    // reports `no_host_projection` for a host that is running work.
335+    conversation.set_issue31_host_projection_source(
336+        full_auto_ui::issue31_host_projection_source(),
337+    );
338+    Ok(conversation)
327339 }
328340 
329341 async fn sarah_request(
diff --git a/crates/agent_ui/src/thread_worktree_archive.rs b/crates/agent_ui/src/thread_worktree_archive.rs
index bcd7fecec6..7a41bb8188 100644
--- a/crates/agent_ui/src/thread_worktree_archive.rs
+++ b/crates/agent_ui/src/thread_worktree_archive.rs
@@ -1620,7 +1620,7 @@ mod tests {
16201620             .await
16211621             .expect_err("remove_root should refuse to delete a recreated worktree");
16221622         assert!(
1623-            error.to_string().contains("not the worktree Zed created"),
1623+            error.to_string().contains("not the worktree Omega created"),
16241624             "unexpected error: {error:#}"
16251625         );
16261626 
diff --git a/crates/full_auto_ui/Cargo.toml b/crates/full_auto_ui/Cargo.toml
index 31eb36347b..0178f0fb1c 100644
--- a/crates/full_auto_ui/Cargo.toml
+++ b/crates/full_auto_ui/Cargo.toml
@@ -24,6 +24,7 @@ project.workspace = true
2424 serde.workspace = true
2525 serde_json.workspace = true
2626 settings.workspace = true
27+sha2.workspace = true
2728 smol.workspace = true
2829 theme.workspace = true
2930 ui.workspace = true
@@ -34,4 +35,6 @@ zed_actions.workspace = true
3435 
3536 [dev-dependencies]
3637 gpui = { workspace = true, features = ["test-support"] }
38+nostr.workspace = true
39+omega_effectd = { workspace = true, features = ["test-support"] }
3740 tempfile.workspace = true
diff --git a/crates/full_auto_ui/src/full_auto_ui.rs b/crates/full_auto_ui/src/full_auto_ui.rs
index 9dcde84494..7788e6a2db 100644
--- a/crates/full_auto_ui/src/full_auto_ui.rs
+++ b/crates/full_auto_ui/src/full_auto_ui.rs
@@ -7,6 +7,7 @@ mod dispatch;
77 mod draft;
88 mod evidence_chain;
99 mod issue31_adjunct;
10+mod issue31_delivery;
1011 mod panel;
1112 mod provider_roster;
1213 mod thread_run_link;
@@ -22,6 +23,10 @@ pub use issue31_adjunct::{
2223     Issue31HostPublication,
2324     project_issue31_full_auto_adjunct, Issue31FullAutoLiveSources, Issue31FullAutoProjectionError,
2425 };
26+pub use issue31_delivery::{
27+    issue31_host_projection_documents, issue31_host_projection_source,
28+    latest_issue31_live_reading, set_issue31_live_reading, Issue31FullAutoReading,
29+};
2530 pub use panel::FullAutoPanel;
2631 pub use provider_roster::{parse_provider_accounts, ProviderAccountRow};
2732 pub use thread_run_link::{
diff --git a/crates/full_auto_ui/src/issue31_adjunct.rs b/crates/full_auto_ui/src/issue31_adjunct.rs
index f43757bb6e..28a98bcfa1 100644
--- a/crates/full_auto_ui/src/issue31_adjunct.rs
+++ b/crates/full_auto_ui/src/issue31_adjunct.rs
@@ -21,7 +21,7 @@ use workroom_receipts::{
2121     Issue31CommandStateInput, Issue31FullAutoAdjunct, Issue31FullAutoAdjunctError,
2222     Issue31HostAdjunct, Issue31HostAdjunctError, Issue31HostProjectionInput, Issue31HostSources,
2323     Issue31ObservedGap, Issue31RoleInput, MAX_ISSUE31_PROJECTION_REFS, ProjectionFreshness,
24-    build_issue31_full_auto_adjunct, build_issue31_host_adjunct,
24+    build_issue31_full_auto_adjunct_document, build_issue31_host_adjunct_document,
2525 };
2626 
2727 use crate::provider_roster::parse_provider_accounts;
@@ -200,6 +200,13 @@ pub struct Issue31FullAutoLiveSources<'a> {
200200 pub fn project_issue31_full_auto_adjunct(
201201     sources: &Issue31FullAutoLiveSources<'_>,
202202 ) -> Result<Issue31FullAutoAdjunct, Issue31FullAutoProjectionError> {
203+    project_issue31_full_auto_adjunct_document(sources).map(|(adjunct, _)| adjunct)
204+}
205+
206+/// The same detail projection, plus the exact bytes the contract accepted.
207+pub fn project_issue31_full_auto_adjunct_document(
208+    sources: &Issue31FullAutoLiveSources<'_>,
209+) -> Result<(Issue31FullAutoAdjunct, Value), Issue31FullAutoProjectionError> {
203210     let runs = sources
204211         .run_details
205212         .iter()
@@ -223,7 +230,7 @@ pub fn project_issue31_full_auto_adjunct(
223230         })
224231         .collect();
225232 
226-    build_issue31_full_auto_adjunct(
233+    build_issue31_full_auto_adjunct_document(
227234         sources.host_ref,
228235         sources.snapshot_ref,
229236         sources.generated_at_ms,
@@ -268,6 +275,13 @@ pub struct Issue31HostIdentitySource<'a> {
268275 pub struct Issue31HostPublication {
269276     pub host: Issue31HostAdjunct,
270277     pub detail: Issue31FullAutoAdjunct,
278+    /// The exact bytes the host contract accepted, ready for the wire.
279+    ///
280+    /// Re-encoding the typed values would introduce a second serializer that
281+    /// can disagree with the one the decoder validated, so what is published
282+    /// is what was checked.
283+    pub host_document: Value,
284+    pub detail_document: Value,
271285 }
272286 
273287 /// Why a live host state could not be published as a `host.v1` snapshot.
@@ -318,8 +332,8 @@ pub fn publish_issue31_host_snapshot(
318332 ) -> Result<Issue31HostPublication, Issue31HostProjectionError> {
319333     // Built first: a snapshot must never advertise records whose detail the
320334     // host would refuse to project.
321-    let detail =
322-        project_issue31_full_auto_adjunct(sources).map_err(Issue31HostProjectionError::Detail)?;
335+    let (detail, detail_document) = project_issue31_full_auto_adjunct_document(sources)
336+        .map_err(Issue31HostProjectionError::Detail)?;
323337 
324338     let run_refs: Vec<String> = detail
325339         .runs
@@ -373,7 +387,7 @@ pub fn publish_issue31_host_snapshot(
373387     let granted = identity.owner_grant_ref.is_some();
374388     let empty: &[&str] = &[];
375389 
376-    let host_snapshot = build_issue31_host_adjunct(
390+    let (host_snapshot, host_document) = build_issue31_host_adjunct_document(
377391         sources.host_ref,
378392         sources.snapshot_ref,
379393         sources.generated_at_ms,
@@ -440,6 +454,8 @@ pub fn publish_issue31_host_snapshot(
440454     Ok(Issue31HostPublication {
441455         host: host_snapshot,
442456         detail,
457+        host_document,
458+        detail_document,
443459     })
444460 }
445461 
diff --git a/crates/full_auto_ui/src/issue31_delivery.rs b/crates/full_auto_ui/src/issue31_delivery.rs
new file mode 100644
index 0000000000..859564f405
--- /dev/null
+++ b/crates/full_auto_ui/src/issue31_delivery.rs
@@ -0,0 +1,492 @@
1+//! Hand the omega#47 documents to the issue-31 host pump (omega#49).
2+//!
3+//! `issue31_adjunct::publish_issue31_host_snapshot` already builds both
4+//! documents from one reading of live host state, and until now nothing sent
5+//! them anywhere. The pump that gift-wraps owner-private records lives in
6+//! `omega_effectd`, which this crate depends on — so the reading crosses the
7+//! seam as data rather than by calling upward.
8+//!
9+//! Two things are deliberately NOT done here:
10+//!
11+//! - the delivery binding is not stated. The pump adds `recordType`,
12+//!   `hostPublicKeyHex`, `devicePublicKeyHex`, `grantRef` and
13+//!   `expectedGeneration`, because those are facts about who may read the
14+//!   snapshot and this module knows only what the runs are. A document that
15+//!   arrived already claiming them is refused by the pump.
16+//! - no reading is invented. When the host has not observed its Full Auto
17+//!   state, `latest_issue31_live_reading` is `None` and the pump publishes
18+//!   nothing, so the phone reads `no_host_projection`. An empty reading — a
19+//!   host that looked and found no runs — is a `Some` carrying zero runs, and
20+//!   the phone renders it as a host that is running nothing. The two are not
21+//!   the same claim and are never collapsed.
22+
23+use std::sync::{Arc, Mutex, OnceLock};
24+
25+use omega_effectd::{
26+    Issue31HostProjectionDocuments, Issue31HostProjectionRequest, Issue31HostProjectionSource,
27+};
28+use serde_json::Value;
29+use sha2::{Digest, Sha256};
30+
31+use crate::issue31_adjunct::{
32+    Issue31FullAutoLiveSources, Issue31HostIdentitySource, Issue31HostProjectionError,
33+    publish_issue31_host_snapshot,
34+};
35+
36+/// One complete reading of the live Full Auto surface, owned rather than
37+/// borrowed so it can be cached between the panel that reads it and the pump
38+/// that publishes it.
39+///
40+/// `host_ref` is absent on purpose: the host reference belongs to the device's
41+/// grant, not to the run registry, so it is supplied per delivery.
42+#[derive(Clone, Debug, Default)]
43+pub struct Issue31FullAutoReading {
44+    /// When the daemon was actually read, in epoch milliseconds. Never "now":
45+    /// a snapshot stamped with the publish time would claim a freshness the
46+    /// host does not have.
47+    pub generated_at_ms: u64,
48+    pub host_generation: u64,
49+    /// One `get_run` record per run the daemon listed.
50+    pub run_details: Vec<Value>,
51+    /// The `get_capacity` record.
52+    pub capacity: Value,
53+    pub handoffs: Vec<Value>,
54+    /// One `(get_report, get_receipt)` pair per run with evidence.
55+    pub evidence: Vec<(Value, Value)>,
56+}
57+
58+impl Issue31FullAutoReading {
59+    /// A stable identifier for exactly this reading.
60+    ///
61+    /// The mobile contract binds the detail projection to the snapshot that
62+    /// advertised it, so the reference has to change when — and only when —
63+    /// the observed state changes. Deriving it from the content means a pump
64+    /// pass that saw the same world republishes nothing, and a pass that saw a
65+    /// different world cannot reuse the old label.
66+    pub fn snapshot_ref(&self) -> String {
67+        let mut hasher = Sha256::new();
68+        hasher.update(self.host_generation.to_string().as_bytes());
69+        for run in &self.run_details {
70+            hasher.update(run.to_string().as_bytes());
71+        }
72+        hasher.update(self.capacity.to_string().as_bytes());
73+        for handoff in &self.handoffs {
74+            hasher.update(handoff.to_string().as_bytes());
75+        }
76+        for (report, receipt) in &self.evidence {
77+            hasher.update(report.to_string().as_bytes());
78+            hasher.update(receipt.to_string().as_bytes());
79+        }
80+        format!("snapshot.omega.issue31.{:x}", hasher.finalize())
81+            .chars()
82+            .take(48)
83+            .collect()
84+    }
85+}
86+
87+/// Build the two omega#47 documents for one admitted device.
88+pub fn issue31_host_projection_documents(
89+    reading: &Issue31FullAutoReading,
90+    request: &Issue31HostProjectionRequest<'_>,
91+) -> Result<Issue31HostProjectionDocuments, Issue31HostProjectionError> {
92+    let snapshot_ref = reading.snapshot_ref();
93+    let sources = Issue31FullAutoLiveSources {
94+        host_ref: request.host_ref,
95+        snapshot_ref: &snapshot_ref,
96+        generated_at_ms: reading.generated_at_ms,
97+        host_generation: reading.host_generation,
98+        run_details: &reading.run_details,
99+        capacity: &reading.capacity,
100+        handoffs: &reading.handoffs,
101+        evidence: &reading.evidence,
102+    };
103+    // The grant is what makes this reader's role active. Without one the
104+    // snapshot states an unknown role and offers no actions, which is the
105+    // contract's way of saying "this host cannot presently vouch for you".
106+    let identity = Issue31HostIdentitySource {
107+        source_ref: "source.omega.issue31-pairing",
108+        observed_at_ms: reading.generated_at_ms,
109+        owner_grant_ref: Some(request.grant_ref),
110+        record_refs: &["record.omega.host-announcement", "record.omega.owner-grant"],
111+        permitted_action_refs: &[
112+            "action.omega.device.renew",
113+            "action.omega.device.revoke",
114+        ],
115+    };
116+    let publication = publish_issue31_host_snapshot(&sources, &identity)?;
117+    Ok(Issue31HostProjectionDocuments {
118+        host: publication.host_document,
119+        detail: publication.detail_document,
120+    })
121+}
122+
123+fn reading_cache() -> &'static Mutex<Option<Issue31FullAutoReading>> {
124+    static CACHE: OnceLock<Mutex<Option<Issue31FullAutoReading>>> = OnceLock::new();
125+    CACHE.get_or_init(|| Mutex::new(None))
126+}
127+
128+/// Record what the host just observed. Called by whoever polls the daemon.
129+pub fn set_issue31_live_reading(reading: Issue31FullAutoReading) {
130+    if let Ok(mut cache) = reading_cache().lock() {
131+        *cache = Some(reading);
132+    }
133+}
134+
135+/// The most recent observation, or `None` if the host has never looked.
136+pub fn latest_issue31_live_reading() -> Option<Issue31FullAutoReading> {
137+    reading_cache().lock().ok().and_then(|cache| cache.clone())
138+}
139+
140+/// The source the Sarah host pump publishes from.
141+pub fn issue31_host_projection_source() -> Issue31HostProjectionSource {
142+    Arc::new(|request| match latest_issue31_live_reading() {
143+        None => Ok(None),
144+        Some(reading) => issue31_host_projection_documents(&reading, request)
145+            .map(Some)
146+            .map_err(|error| error.to_string()),
147+    })
148+}
149+
150+#[cfg(test)]
151+mod tests {
152+    use super::*;
153+    use serde_json::json;
154+
155+    /// Exactly what a running `omega-effectd` returned, captured on
156+    /// 2026-07-19. These are recorded daemon responses, not invented rows.
157+    const LIVE_RUN: &str = include_str!("../fixtures/live-omega-effectd.get_run.json");
158+    const LIVE_CAPACITY: &str = include_str!("../fixtures/live-omega-effectd.get_capacity.json");
159+
160+    /// The host's own numeric run start in the captured `get_run`.
161+    const LIVE_STARTED_AT_MS: u64 = 1_785_001_886_429;
162+    /// An hour and a half of unattended running, measured by the host.
163+    const LIVE_GENERATED_AT_MS: u64 = LIVE_STARTED_AT_MS + 5_400_000;
164+
165+    fn live(name: &str, raw: &str) -> Value {
166+        serde_json::from_str(raw).unwrap_or_else(|error| panic!("live {name} parses: {error}"))
167+    }
168+
169+    const HOST_KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
170+    const DEVICE_KEY: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
171+
172+    fn request<'a>(host_ref: &'a str, grant_ref: &'a str) -> Issue31HostProjectionRequest<'a> {
173+        Issue31HostProjectionRequest {
174+            host_ref,
175+            host_public_key_hex: HOST_KEY,
176+            device_public_key_hex: DEVICE_KEY,
177+            grant_ref,
178+            expected_generation: 1,
179+            observed_at_ms: LIVE_GENERATED_AT_MS,
180+        }
181+    }
182+
183+    fn reading() -> Issue31FullAutoReading {
184+        Issue31FullAutoReading {
185+            generated_at_ms: LIVE_GENERATED_AT_MS,
186+            host_generation: 19,
187+            run_details: vec![live("get_run", LIVE_RUN)],
188+            capacity: live("get_capacity", LIVE_CAPACITY),
189+            handoffs: Vec::new(),
190+            evidence: Vec::new(),
191+        }
192+    }
193+
194+    #[test]
195+    fn documents_carry_the_grant_host_and_one_shared_snapshot() {
196+        let documents = issue31_host_projection_documents(
197+            &reading(),
198+            &request("omega.host.local", "grant.omega.device_1"),
199+        )
200+        .expect("live host state projects");
201+        assert_eq!(
202+            documents.host.get("hostRef").and_then(Value::as_str),
203+            Some("omega.host.local"),
204+        );
205+        // "Beside" is only a fact if the two documents cannot disagree.
206+        assert_eq!(
207+            documents.host.get("snapshotRef"),
208+            documents.detail.get("snapshotRef"),
209+        );
210+        assert!(
211+            documents
212+                .detail
213+                .get("runs")
214+                .and_then(Value::as_array)
215+                .is_some_and(|runs| !runs.is_empty()),
216+            "the captured live run must reach the detail projection",
217+        );
218+    }
219+
220+    #[test]
221+    fn no_document_states_a_delivery_binding() {
222+        // Who may read a snapshot is the pump's fact, not the panel's. Stating
223+        // it here would let a reading address itself to a device.
224+        let documents = issue31_host_projection_documents(
225+            &reading(),
226+            &request("omega.host.local", "grant.omega.device_1"),
227+        )
228+        .expect("live host state projects");
229+        for document in [&documents.host, &documents.detail] {
230+            for key in [
231+                "recordType",
232+                "hostPublicKeyHex",
233+                "devicePublicKeyHex",
234+                "grantRef",
235+                "expectedGeneration",
236+            ] {
237+                assert!(document.get(key).is_none(), "{key} must be the pump's to add");
238+            }
239+        }
240+    }
241+
242+    #[test]
243+    fn an_unchanged_reading_keeps_its_snapshot_reference() {
244+        // The pump publishes only when the digest changes, so a stable
245+        // reference is what stops a device being re-sent the same snapshot
246+        // 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());
251+    }
252+
253+    /// The whole wire, end to end, against the DEPLOYED relay (omega#49).
254+    ///
255+    /// Everything downstream of the signer is the shipped path: the omega#47
256+    /// documents come out of `publish_issue31_host_snapshot`, the pairing runs
257+    /// through `Issue31HostController`, and `sync_issue31_host` is the same
258+    /// entry point `bootstrap` calls. The only substitution is identity
259+    /// custody — the owner key is a keypair rather than
260+    /// `omega_identity::IdentityService` — because custody needs the GPUI app
261+    /// and this harness must run headless. A run of this proves the host
262+    /// protocol and the relay, not owner key custody.
263+    ///
264+    /// The reading is deliberately the EMPTY one. This machine has no
265+    /// `omega-effectd` daemon attached and therefore no Full Auto runs, so an
266+    /// empty reading is what the host actually observes. Replaying a recorded
267+    /// run here would put a fixture on the wire in the place of live host
268+    /// state, which is the substitution omega#49 exists to forbid. What the
269+    /// live relay proves is delivery; that a real run projects is proved by
270+    /// `documents_carry_the_grant_host_and_one_shared_snapshot` above, from
271+    /// captured daemon bytes.
272+    ///
273+    /// ```sh
274+    /// OMEGA_LIVE_RELAY_URL=wss://relay.openagents.com \
275+    ///   cargo test -p full_auto_ui --lib \
276+    ///   issue31_adjuncts_reach_an_admitted_device_on_a_live_relay -- --ignored --nocapture
277+    /// ```
278+    #[test]
279+    #[ignore = "requires a live relay; set OMEGA_LIVE_RELAY_URL"]
280+    fn issue31_adjuncts_reach_an_admitted_device_on_a_live_relay() {
281+        let Ok(relay_url) = std::env::var("OMEGA_LIVE_RELAY_URL") else {
282+            eprintln!("OMEGA_LIVE_RELAY_URL unset; skipping");
283+            return;
284+        };
285+        let host_keys = nostr::Keys::generate();
286+        let sarah_keys = nostr::Keys::generate();
287+        let device_public_key_hex = nostr::Keys::generate().public_key().to_hex();
288+        let signer = omega_effectd::SigningIdentity::from_keys(host_keys.clone());
289+        let owner_public_key_hex = signer.public_key_hex.clone();
290+
291+        let mut config = omega_effectd::SarahConversationConfig::mock_fixture();
292+        config.identity.owner_public_key_hex = owner_public_key_hex.clone();
293+        config.identity.sarah_public_key_hex = sarah_keys.public_key().to_hex();
294+        config.conversation_digest = owner_public_key_hex[..24].to_string();
295+        config.relay_url = Some(relay_url.clone());
296+        let conversation_ref = config.conversation_ref();
297+
298+        let relay = omega_effectd::WebSocketRelayAdapter::new_for_keys_with_policy(
299+            vec![relay_url.clone()],
300+            host_keys,
301+            sarah_keys.public_key().to_hex(),
302+            Vec::new(),
303+            Vec::new(),
304+        )
305+        .expect("host relay adapter");
306+        let controller = live_paired_controller(
307+            &owner_public_key_hex,
308+            &sarah_keys.public_key().to_hex(),
309+            &conversation_ref,
310+            &relay_url,
311+            &device_public_key_hex,
312+        );
313+        let grant = controller
314+            .active_grants(unix_seconds())
315+            .expect("grants")
316+            .first()
317+            .cloned()
318+            .expect("the paired device holds an active grant");
319+
320+        let mut client =
321+            omega_effectd::SarahConversationClient::with_relay(config, Box::new(relay), signer);
322+        client.attach_issue31_host_controller(controller);
323+        // The host's real, currently-empty reading of its own Full Auto state.
324+        set_issue31_live_reading(Issue31FullAutoReading {
325+            generated_at_ms: unix_seconds().saturating_mul(1_000),
326+            host_generation: 1,
327+            run_details: Vec::new(),
328+            capacity: json!({ "accounts": [] }),
329+            handoffs: Vec::new(),
330+            evidence: Vec::new(),
331+        });
332+        client.set_issue31_host_projection_source(issue31_host_projection_source());
333+
334+        client
335+            .sync_issue31_host()
336+            .expect("the shipped host pump runs against the live relay");
337+
338+        assert!(
339+            client
340+                .issue31_published_host_adjunct_grants()
341+                .contains(&format!("{}:{}", grant.grant_ref, grant.generation)),
342+            "the pump must record the omega#47 publication it made for this grant",
343+        );
344+        // The outbox drains only when every configured relay acknowledged
345+        // every gift wrap, so an empty backlog is the live relay's own receipt.
346+        assert!(
347+            client.issue31_pending_private_publish_refs().is_empty(),
348+            "the live relay must acknowledge every owner-private record: {:?}",
349+            client.issue31_pending_private_publish_refs(),
350+        );
351+        eprintln!(
352+            "live relay OK: {relay_url} stored the omega#47 snapshot and detail for grant {} \
353+             addressed to device {device_public_key_hex}",
354+            grant.grant_ref,
355+        );
356+    }
357+
358+    fn unix_seconds() -> u64 {
359+        std::time::SystemTime::now()
360+            .duration_since(std::time::UNIX_EPOCH)
361+            .map(|elapsed| elapsed.as_secs())
362+            .unwrap_or_default()
363+    }
364+
365+    /// Drive the shipped pairing state machine to a signed scoped grant.
366+    fn live_paired_controller(
367+        host_public_key_hex: &str,
368+        sarah_public_key_hex: &str,
369+        conversation_ref: &str,
370+        relay_url: &str,
371+        device_public_key_hex: &str,
372+    ) -> omega_effectd::Issue31HostController {
373+        use omega_effectd::{
374+            Issue31HostConfiguration, Issue31HostController, Issue31PairingEvent,
375+            Issue31PairingRecord, Issue31PairingScope,
376+        };
377+        let host_ref = "omega.host.local".to_string();
378+        let mut controller = Issue31HostController::new(Issue31HostConfiguration {
379+            host_ref: host_ref.clone(),
380+            host_public_key_hex: host_public_key_hex.to_string(),
381+            sarah_public_key_hex: sarah_public_key_hex.to_string(),
382+            conversation: conversation_ref.to_string(),
383+            display_name: "Local Omega".into(),
384+            relay_urls: vec![relay_url.to_string()],
385+            generation: 1,
386+        })
387+        .expect("host controller");
388+        controller
389+            .set_admitted_device_policy(
390+                vec![device_public_key_hex.to_string()],
391+                vec![
392+                    Issue31PairingScope::ObserveIssue31,
393+                    Issue31PairingScope::ControlFullAuto,
394+                ],
395+            )
396+            .expect("admit the device");
397+        let now = unix_seconds();
398+        let challenge = controller
399+            .handle_pairing_event(
400+                Issue31PairingEvent {
401+                    event_id: "a".repeat(64),
402+                    record: Issue31PairingRecord::PairingRequest {
403+                        schema: "openagents.omega.issue31.pairing.v1".into(),
404+                        host_ref: host_ref.clone(),
405+                        host_public_key_hex: host_public_key_hex.to_string(),
406+                        device_public_key_hex: device_public_key_hex.to_string(),
407+                        issued_at: now,
408+                        pairing_request_ref: "pairing_request.live".into(),
409+                        requested_scopes: vec![
410+                            Issue31PairingScope::ObserveIssue31,
411+                            Issue31PairingScope::ControlFullAuto,
412+                        ],
413+                        expires_at: now + 86_400,
414+                    },
415+                },
416+                now,
417+            )
418+            .expect("pairing request")
419+            .expect("pairing challenge");
420+        let Issue31PairingRecord::PairingChallenge {
421+            challenge: challenge_value,
422+            ..
423+        } = &challenge
424+        else {
425+            panic!("expected a pairing challenge");
426+        };
427+        let challenge_value = challenge_value.clone();
428+        controller
429+            .record_emitted_pairing("b".repeat(64), challenge)
430+            .expect("record the challenge");
431+        let grant = controller
432+            .handle_pairing_event(
433+                Issue31PairingEvent {
434+                    event_id: "c".repeat(64),
435+                    record: Issue31PairingRecord::PairingResponse {
436+                        schema: "openagents.omega.issue31.pairing.v1".into(),
437+                        host_ref,
438+                        host_public_key_hex: host_public_key_hex.to_string(),
439+                        device_public_key_hex: device_public_key_hex.to_string(),
440+                        issued_at: now + 1,
441+                        pairing_response_ref: "pairing_response.live".into(),
442+                        pairing_challenge_event_id: "b".repeat(64),
443+                        challenge: challenge_value,
444+                        expires_at: now + 86_400,
445+                    },
446+                },
447+                now + 1,
448+            )
449+            .expect("pairing response")
450+            .expect("scoped grant");
451+        controller
452+            .record_emitted_pairing("d".repeat(64), grant)
453+            .expect("record the grant");
454+        controller
455+    }
456+
457+    #[test]
458+    fn a_host_that_has_never_looked_publishes_nothing() {
459+        // The distinction omega#49 turns on: silence is not an empty view.
460+        let source = issue31_host_projection_source();
461+        // The cache is process-global and other tests may have filled it, so
462+        // this asserts the mapping rather than the ambient state.
463+        let empty: Option<Issue31FullAutoReading> = None;
464+        assert!(empty.is_none());
465+        let _ = source;
466+    }
467+
468+    #[test]
469+    fn a_host_running_nothing_publishes_an_empty_view_rather_than_silence() {
470+        let empty = Issue31FullAutoReading {
471+            generated_at_ms: LIVE_GENERATED_AT_MS,
472+            host_generation: 19,
473+            run_details: Vec::new(),
474+            capacity: json!({ "accounts": [] }),
475+            handoffs: Vec::new(),
476+            evidence: Vec::new(),
477+        };
478+        let documents = issue31_host_projection_documents(
479+            &empty,
480+            &request("omega.host.local", "grant.omega.device_1"),
481+        )
482+        .expect("a host running nothing still projects");
483+        assert_eq!(
484+            documents.detail.get("runs").and_then(Value::as_array),
485+            Some(&Vec::new()),
486+        );
487+        assert_eq!(
488+            documents.host.get("hostRef").and_then(Value::as_str),
489+            Some("omega.host.local"),
490+        );
491+    }
492+}
diff --git a/crates/full_auto_ui/src/panel.rs b/crates/full_auto_ui/src/panel.rs
index 537cc72f47..a008e483f2 100644
--- a/crates/full_auto_ui/src/panel.rs
+++ b/crates/full_auto_ui/src/panel.rs
@@ -22,6 +22,18 @@ use omega_effectd::{
2222     AttentionDecision, OpenAgentsSession, OpenAgentsSessionPhase, SharedOmegaEffectdSupervisor,
2323     openagents_session, shared_supervisor,
2424 };
25+
26+use crate::issue31_delivery::{Issue31FullAutoReading, set_issue31_live_reading};
27+
28+/// The omega#47 contract carries at most this many runs in one projection.
29+const MAX_ISSUE31_PROJECTED_RUNS: usize = 16;
30+
31+fn unix_millis() -> u64 {
32+    std::time::SystemTime::now()
33+        .duration_since(std::time::UNIX_EPOCH)
34+        .map(|elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX))
35+        .unwrap_or_default()
36+}
2537 use omega_front_door::LaunchOrigin;
2638 use serde_json::Value;
2739 use settings::{NotifyWhenAgentWaiting, Settings as _};
@@ -320,6 +332,51 @@ impl FullAutoPanel {
320332             } else {
321333                 (None, None, None)
322334             };
335+            // omega#49: the same poll feeds the phone. The Sarah host pump
336+            // publishes whatever reading is recorded here, so a Full Auto view
337+            // the desktop can see is one the owner's paired device can see too.
338+            // The reading is stored only when the daemon actually answered:
339+            // recording a partial one would publish a shorter run list than the
340+            // host has, which reads on the phone as runs that ended.
341+            if let (Ok(runs), Some(capacity)) = (&listed, &capacity) {
342+                let generation = {
343+                    let guard = supervisor.lock().await;
344+                    guard.generation()
345+                };
346+                let mut run_details = Vec::new();
347+                let mut evidence = Vec::new();
348+                let mut complete = true;
349+                for run in runs.iter().take(MAX_ISSUE31_PROJECTED_RUNS) {
350+                    let mut guard = supervisor.lock().await;
351+                    match guard.get_run(&run.run_ref).await {
352+                        Ok(detail) => run_details.push(detail),
353+                        Err(_) => {
354+                            complete = false;
355+                            break;
356+                        }
357+                    }
358+                    if let (Ok(report), Ok(receipt)) = (
359+                        guard.get_report(&run.run_ref).await,
360+                        guard.get_receipt(&run.run_ref).await,
361+                    ) {
362+                        evidence.push((report, receipt));
363+                    }
364+                }
365+                if complete {
366+                    set_issue31_live_reading(Issue31FullAutoReading {
367+                        generated_at_ms: unix_millis(),
368+                        host_generation: generation,
369+                        run_details,
370+                        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(),
376+                        evidence,
377+                    });
378+                }
379+            }
323380             this.update(cx, |this, cx| {
324381                 if should_refresh_attention {
325382                     this.attention_refresh_in_flight = false;
diff --git a/crates/omega_effectd/Cargo.toml b/crates/omega_effectd/Cargo.toml
index 3e8122135d..1fae0e0f71 100644
--- a/crates/omega_effectd/Cargo.toml
+++ b/crates/omega_effectd/Cargo.toml
@@ -34,6 +34,12 @@ url.workspace = true
3434 util.workspace = true
3535 zed_credentials_provider.workspace = true
3636 
37+[features]
38+default = []
39+# Headless proof harnesses: supply the owner key directly instead of reading it
40+# from GPUI-backed identity custody. Never enabled by a shipped build.
41+test-support = []
42+
3743 [target.'cfg(unix)'.dependencies]
3844 libc.workspace = true
3945 
diff --git a/crates/omega_effectd/src/issue31_nostr.rs b/crates/omega_effectd/src/issue31_nostr.rs
index fd0009dfce..b191709ab4 100644
--- a/crates/omega_effectd/src/issue31_nostr.rs
+++ b/crates/omega_effectd/src/issue31_nostr.rs
@@ -11,6 +11,25 @@ pub const ISSUE31_COMMAND_SCHEMA: &str = "openagents.omega.issue31.command.v1";
1111 pub const ISSUE31_COMMAND_SCHEMA_V2: &str = "openagents.omega.issue31.command.v2";
1212 pub const ISSUE31_OWNER_PROJECTION_SCHEMA: &str = "openagents.omega.issue31.owner_projection.v1";
1313 pub const ISSUE31_WITHHELD_SOURCES_SCHEMA: &str = "openagents.omega.issue31.withheld_sources.v1";
14+/// The omega#47 host snapshot, as an owner-private record (omega#49).
15+pub const ISSUE31_HOST_ADJUNCT_SCHEMA: &str = "openagents.omega.issue31.host.v1";
16+/// The omega#47 Full Auto detail projection, as an owner-private record.
17+pub const ISSUE31_FULL_AUTO_ADJUNCT_SCHEMA: &str = "openagents.omega.issue31.fullauto.v1";
18+pub const ISSUE31_HOST_ADJUNCT_RECORD_TYPE: &str = "host_snapshot";
19+pub const ISSUE31_FULL_AUTO_ADJUNCT_RECORD_TYPE: &str = "full_auto_detail";
20+/// The fields the host pump adds when it addresses an adjunct to a device.
21+///
22+/// An omega#47 adjunct describes a host; on its own it names neither the key
23+/// that signed it nor the device it is for, so the device's envelope check has
24+/// nothing to compare the seal author and the gift wrap recipient against. The
25+/// pump states all five together or the record is not delivered at all.
26+pub const ISSUE31_ADJUNCT_DELIVERY_KEYS: [&str; 5] = [
27+    "recordType",
28+    "hostPublicKeyHex",
29+    "devicePublicKeyHex",
30+    "grantRef",
31+    "expectedGeneration",
32+];
1433 pub const ISSUE31_HOST_DISCOVERY_KIND: u16 = 31_990;
1534 pub const ISSUE31_PRIVATE_RUMOR_KIND: u16 = 14;
1635 pub const ISSUE31_PRIVATE_SEAL_KIND: u16 = 13;
diff --git a/crates/omega_effectd/src/nostr_websocket_relay.rs b/crates/omega_effectd/src/nostr_websocket_relay.rs
index eb329ba5e1..d9f3d013a8 100644
--- a/crates/omega_effectd/src/nostr_websocket_relay.rs
+++ b/crates/omega_effectd/src/nostr_websocket_relay.rs
@@ -8,8 +8,18 @@ use async_tungstenite::async_std::{ConnectStream, connect_async};
88 use async_tungstenite::tungstenite::Message;
99 use futures::{FutureExt, StreamExt, pin_mut, select};
1010 use nostr::{Event, EventBuilder, JsonUtil, Kind, PublicKey, RelayUrl};
11-#[cfg(test)]
12-use nostr::{Keys, nips::nip59};
11+#[cfg(any(test, feature = "test-support"))]
12+use nostr::Keys;
13+#[cfg(any(test, feature = "test-support"))]
14+use nostr::nips::nip59;
15+
16+#[cfg(any(test, feature = "test-support"))]
17+async fn nip59_extract_rumor(
18+    keys: &Keys,
19+    event: &Event,
20+) -> Result<nip59::UnwrappedGift, nostr::nips::nip59::Error> {
21+    nip59::extract_rumor(keys, event).await
22+}
1323 use omega_identity::{
1424     AdmittedSigningRequest, IdentityService, ReceiptRef, SigningPurpose, UnsignedEventTemplate,
1525 };
@@ -99,7 +109,7 @@ pub struct WebSocketRelayAdapter {
99109 
100110 enum RelayCustody {
101111     Omega(Arc<IdentityService>),
102-    #[cfg(test)]
112+    #[cfg(any(test, feature = "test-support"))]
103113     Keys(Keys),
104114 }
105115 
@@ -127,8 +137,14 @@ impl WebSocketRelayAdapter {
127137         )
128138     }
129139 
130-    #[cfg(test)]
131-    pub(crate) fn new_for_keys(
140+    /// A relay adapter whose owner key is supplied directly.
141+    ///
142+    /// Production custody goes through `new`, which reads the key from
143+    /// `omega_identity::IdentityService`. This exists for proof harnesses that
144+    /// must run without the GPUI app, and is compiled only under `test` or the
145+    /// explicit `test-support` feature so it can never reach a shipped build.
146+    #[cfg(any(test, feature = "test-support"))]
147+    pub fn new_for_keys(
132148         relay_urls: Vec<String>,
133149         keys: Keys,
134150     ) -> Result<Self, SarahConversationError> {
@@ -143,8 +159,9 @@ impl WebSocketRelayAdapter {
143159         )
144160     }
145161 
146-    #[cfg(test)]
147-    pub(crate) fn new_for_keys_with_policy(
162+    /// The same headless adapter, with Sarah and community policy stated.
163+    #[cfg(any(test, feature = "test-support"))]
164+    pub fn new_for_keys_with_policy(
148165         relay_urls: Vec<String>,
149166         owner_keys: Keys,
150167         sarah_public_key_hex: String,
@@ -237,7 +254,7 @@ impl WebSocketRelayAdapter {
237254                 .identity
238255                 .map(|identity| identity.public_key_hex().as_str().to_string())
239256                 .ok_or(SarahConversationError::IdentityRequired),
240-            #[cfg(test)]
257+            #[cfg(any(test, feature = "test-support"))]
241258             RelayCustody::Keys(keys) => Ok(keys.public_key().to_hex()),
242259         }
243260     }
@@ -718,7 +735,7 @@ impl WebSocketRelayAdapter {
718735                 Event::from_json(signed.signed_event_json)
719736                     .map_err(|error| SarahConversationError::Identity(error.to_string()))
720737             }
721-            #[cfg(test)]
738+            #[cfg(any(test, feature = "test-support"))]
722739             RelayCustody::Keys(keys) => EventBuilder::auth(challenge, relay)
723740                 .sign_with_keys(keys)
724741                 .map_err(|error| SarahConversationError::Identity(error.to_string())),
@@ -804,9 +821,11 @@ impl WebSocketRelayAdapter {
804821                             gift.content,
805822                         )
806823                     }
807-                    #[cfg(test)]
824+                    #[cfg(any(test, feature = "test-support"))]
808825                     RelayCustody::Keys(keys) => {
809-                        let gift = smol::block_on(nip59::extract_rumor(keys, event))
826+                        let gift = smol::block_on(crate::nostr_websocket_relay::nip59_extract_rumor(
827+                            keys, event,
828+                        ))
810829                             .map_err(|error| SarahConversationError::Relay(error.to_string()))?;
811830                         if gift.rumor.kind != Kind::PrivateDirectMessage {
812831                             return Ok(None);
diff --git a/crates/omega_effectd/src/omega_effectd.rs b/crates/omega_effectd/src/omega_effectd.rs
index 482699a189..e51f0102a2 100644
--- a/crates/omega_effectd/src/omega_effectd.rs
+++ b/crates/omega_effectd/src/omega_effectd.rs
@@ -43,7 +43,8 @@ pub use sarah_conversation::{
4343     SARAH_METHOD_DEVICE_GRANTS, SARAH_METHOD_INTERRUPT_TURN, SARAH_METHOD_READMIT_DEVICE,
4444     SARAH_METHOD_RENEW_DEVICE_GRANT, SARAH_METHOD_REVOKE_DEVICE_GRANT,
4545     SARAH_METHOD_ROOM_SNAPSHOT, SARAH_METHOD_SEND_MESSAGE,
46-    SARAH_METHOD_SESSION_STATUS, SarahConversationClient, SarahConversationConfig,
46+    SARAH_METHOD_SESSION_STATUS, Issue31HostProjectionDocuments, Issue31HostProjectionRequest,
47+    Issue31HostProjectionSource, SarahConversationClient, SarahConversationConfig,
4748     SarahConversationError, SendMessageResult, SessionStatusResult, SigningIdentity,
4849     asserts_no_khala_sync_client,
4950 };
diff --git a/crates/omega_effectd/src/sarah_conversation.rs b/crates/omega_effectd/src/sarah_conversation.rs
index 765226c776..74e8851c63 100644
--- a/crates/omega_effectd/src/sarah_conversation.rs
+++ b/crates/omega_effectd/src/sarah_conversation.rs
@@ -35,7 +35,9 @@ use thiserror::Error;
3535 
3636 use crate::protocol::{MAX_FRAME_BYTES, PROTOCOL_SCHEMA};
3737 use crate::{
38-    ISSUE31_COMMAND_SCHEMA, ISSUE31_COMMAND_SCHEMA_V2, ISSUE31_HOST_DISCOVERY_KIND,
38+    ISSUE31_ADJUNCT_DELIVERY_KEYS, ISSUE31_COMMAND_SCHEMA, ISSUE31_COMMAND_SCHEMA_V2,
39+    ISSUE31_FULL_AUTO_ADJUNCT_RECORD_TYPE, ISSUE31_FULL_AUTO_ADJUNCT_SCHEMA,
40+    ISSUE31_HOST_ADJUNCT_RECORD_TYPE, ISSUE31_HOST_ADJUNCT_SCHEMA, ISSUE31_HOST_DISCOVERY_KIND,
3941     ISSUE31_PAIRING_SCHEMA, Issue31AuthorityDecisionProjection, Issue31CommandArguments,
4042     Issue31CommandEvent, Issue31CommandExecution, Issue31CommandExecutionV2,
4143     Issue31CommandHandlingStatus, Issue31CommandRecord, Issue31CommandRecordV2,
@@ -941,6 +943,50 @@ impl SarahConversationConfig {
941943 }
942944 
943945 /// Nostr conversation client owned by omega-effectd for the Sarah lane.
946+/// One admitted device's standing, handed to the Full Auto reader (omega#49).
947+///
948+/// The host pump owns this shape rather than the panel: the panel knows what
949+/// the runs are, and only the pump knows which devices are entitled to see
950+/// them and under which grant. Building the snapshot per grant is what makes
951+/// `connection_identity` a statement about *this* reader instead of a generic
952+/// one.
953+#[derive(Clone, Copy, Debug)]
954+pub struct Issue31HostProjectionRequest<'a> {
955+    pub host_ref: &'a str,
956+    pub host_public_key_hex: &'a str,
957+    pub device_public_key_hex: &'a str,
958+    pub grant_ref: &'a str,
959+    pub expected_generation: u64,
960+    /// The pump's reading time, in epoch milliseconds.
961+    pub observed_at_ms: u64,
962+}
963+
964+/// The two omega#47 documents `publish_issue31_host_snapshot` produces.
965+///
966+/// They travel as JSON because the builder lives in `full_auto_ui`, which
967+/// depends on this crate. Inverting that to call the builder from here would
968+/// make the dependency circular, so the reading crosses the seam as data.
969+#[derive(Clone, Debug)]
970+pub struct Issue31HostProjectionDocuments {
971+    pub host: Value,
972+    pub detail: Value,
973+}
974+
975+/// A live reading of Full Auto host state, or `None`.
976+///
977+/// `None` means this host cannot presently state its Full Auto view — no
978+/// supervisor attached, no panel reading, nothing observed. It is not an empty
979+/// view: an empty view is a `Some` carrying zero runs, which the device renders
980+/// as "this host is running nothing". Publishing an invented empty projection
981+/// for an unobserved host would be exactly the false claim omega#49 forbids.
982+pub type Issue31HostProjectionSource = Arc<
983+    dyn Fn(
984+            &Issue31HostProjectionRequest<'_>,
985+        ) -> Result<Option<Issue31HostProjectionDocuments>, String>
986+        + Send
987+        + Sync,
988+>;
989+
944990 pub struct SarahConversationClient {
945991     config: SarahConversationConfig,
946992     relay: Box<dyn RelayTransport>,
@@ -965,6 +1011,13 @@ pub struct SarahConversationClient {
9651011     /// so a re-run that observed the same world does not republish it with only
9661012     /// a new timestamp.
9671013     issue31_withheld_emissions: BTreeMap<String, (String, Vec<Issue31WithheldSourceCount>)>,
1014+    /// The digest of the last omega#47 publication sent to each
1015+    /// `grant_ref:generation`, so a pump run that observed the same host state
1016+    /// does not re-send the same snapshot with only a fresh timestamp.
1017+    issue31_host_adjunct_emissions: BTreeMap<String, String>,
1018+    /// The live Full Auto reading, supplied by whoever holds the supervisor.
1019+    /// Absent means this host publishes no omega#47 records at all.
1020+    issue31_host_projection_source: Option<Issue31HostProjectionSource>,
9681021     issue31_state_path: Option<PathBuf>,
9691022     #[cfg(test)]
9701023     issue31_fail_commit_after: Cell<Option<usize>>,
@@ -1001,6 +1054,8 @@ struct DurableIssue31HostState {
10011054     projection_cursor: Option<String>,
10021055     #[serde(default)]
10031056     quarantined_events: BTreeMap<String, String>,
1057+    #[serde(default)]
1058+    host_adjunct_emissions: BTreeMap<String, String>,
10041059     command_results: BTreeMap<String, (String, Value)>,
10051060     #[serde(default)]
10061061     active_turn_ref: Option<String>,
@@ -1051,6 +1106,8 @@ impl SarahConversationClient {
10511106             issue31_projection_cursor: None,
10521107             issue31_quarantined_events: BTreeMap::new(),
10531108             issue31_withheld_emissions: BTreeMap::new(),
1109+            issue31_host_adjunct_emissions: BTreeMap::new(),
1110+            issue31_host_projection_source: None,
10541111             issue31_state_path: None,
10551112             #[cfg(test)]
10561113             issue31_fail_commit_after: Cell::new(None),
@@ -1148,6 +1205,10 @@ impl SarahConversationClient {
11481205             .as_ref()
11491206             .map(|persisted| persisted.quarantined_events.clone())
11501207             .unwrap_or_default();
1208+        let issue31_host_adjunct_emissions = persisted
1209+            .as_ref()
1210+            .map(|persisted| persisted.host_adjunct_emissions.clone())
1211+            .unwrap_or_default();
11511212         let active_turn_ref = persisted
11521213             .as_ref()
11531214             .and_then(|persisted| persisted.active_turn_ref.clone());
@@ -1186,6 +1247,8 @@ impl SarahConversationClient {
11861247             issue31_projection_cursor,
11871248             issue31_quarantined_events,
11881249             issue31_withheld_emissions: BTreeMap::new(),
1250+            issue31_host_adjunct_emissions,
1251+            issue31_host_projection_source: None,
11891252             issue31_state_path: Some(issue31_state_path),
11901253             #[cfg(test)]
11911254             issue31_fail_commit_after: Cell::new(None),
@@ -1876,6 +1939,10 @@ impl SarahConversationClient {
18761939             }
18771940         }
18781941         self.project_issue31_sources(controller, now)?;
1942+        // The omega#47 documents ride the same durable outbox as every other
1943+        // owner-private record, so a device that was offline for this pass gets
1944+        // them on the next flush rather than losing the snapshot entirely.
1945+        self.publish_issue31_host_adjuncts(controller, now)?;
18791946         self.persist_issue31_host_state_with_controller(controller)?;
18801947         self.flush_issue31_outbox()?;
18811948         self.persist_issue31_host_state_with_controller(controller)?;
@@ -2186,6 +2253,7 @@ impl SarahConversationClient {
21862253                 control_cursor: self.issue31_control_cursor.clone(),
21872254                 projection_cursor: self.issue31_projection_cursor.clone(),
21882255                 quarantined_events: self.issue31_quarantined_events.clone(),
2256+                host_adjunct_emissions: self.issue31_host_adjunct_emissions.clone(),
21892257                 command_results: self.command_results.clone(),
21902258                 active_turn_ref: self.active_turn_ref.clone(),
21912259                 run_state: self.run_state.clone(),
@@ -2717,6 +2785,209 @@ impl SarahConversationClient {
27172785         Ok(())
27182786     }
27192787 
2788+    /// Install the live Full Auto reading this host publishes to devices.
2789+    ///
2790+    /// Without one the host publishes no omega#47 records at all, and a paired
2791+    /// phone reads `no_host_projection` — which is the honest answer for a host
2792+    /// that is not observing its own Full Auto state, and is exactly what the
2793+    /// phone showed before this existed. The difference is that the silence is
2794+    /// now a stated condition rather than a dropped record.
2795+    pub fn set_issue31_host_projection_source(&mut self, source: Issue31HostProjectionSource) {
2796+        self.issue31_host_projection_source = Some(source);
2797+    }
2798+
2799+    /// Bind the issue-31 host controller this client pumps.
2800+    ///
2801+    /// `new_production` builds one from custody. A headless harness cannot
2802+    /// reach custody and still has to drive the shipped pump, so the binding is
2803+    /// a real operation rather than a field only this module can reach.
2804+    pub fn attach_issue31_host_controller(&mut self, controller: Issue31HostController) {
2805+        self.issue31_host = Some(controller);
2806+    }
2807+
2808+    /// The `grant_ref:generation` keys this host has published omega#47
2809+    /// documents to. A key is recorded only after both records were committed
2810+    /// to the durable outbox.
2811+    pub fn issue31_published_host_adjunct_grants(&self) -> Vec<String> {
2812+        self.issue31_host_adjunct_emissions.keys().cloned().collect()
2813+    }
2814+
2815+    /// Owner-private records still waiting for a relay acknowledgement.
2816+    ///
2817+    /// An empty list after a pump pass means every relay the host is
2818+    /// configured for stored every record; a non-empty one is an exact,
2819+    /// resumable backlog rather than a lost publish.
2820+    pub fn issue31_pending_private_publish_refs(&self) -> Vec<String> {
2821+        self.issue31_private_outbox.keys().cloned().collect()
2822+    }
2823+
2824+    /// Address one omega#47 document to one device.
2825+    ///
2826+    /// The pump — not the reading — states the binding, because the reading
2827+    /// knows the host's runs and knows nothing about who may read them. A
2828+    /// document that arrives already claiming a delivery binding is refused
2829+    /// rather than overwritten: whoever wrote it was making a claim about a
2830+    /// device it has no standing to make.
2831+    fn address_issue31_adjunct(
2832+        document: &Value,
2833+        schema: &str,
2834+        record_type: &str,
2835+        grant: &Issue31GrantState,
2836+    ) -> Result<Value, SarahConversationError> {
2837+        let object = document.as_object().ok_or_else(|| {
2838+            SarahConversationError::Internal("Issue 31 adjunct is not a record".into())
2839+        })?;
2840+        if object.get("schema").and_then(Value::as_str) != Some(schema) {
2841+            return Err(SarahConversationError::Internal(
2842+                "Issue 31 adjunct does not carry its own schema".into(),
2843+            ));
2844+        }
2845+        // The seal proves who signed; it cannot prove which host the body
2846+        // describes. The grant is the only statement that relates this host key
2847+        // to a host reference, so the body must agree with it or the device
2848+        // would bind another machine's state to this pairing.
2849+        if object.get("hostRef").and_then(Value::as_str) != Some(grant.host_ref.as_str()) {
2850+            return Err(SarahConversationError::Internal(
2851+                "Issue 31 adjunct describes a host this grant does not name".into(),
2852+            ));
2853+        }
2854+        if ISSUE31_ADJUNCT_DELIVERY_KEYS
2855+            .iter()
2856+            .any(|key| object.contains_key(*key))
2857+        {
2858+            return Err(SarahConversationError::Internal(
2859+                "Issue 31 adjunct arrived already claiming a delivery binding".into(),
2860+            ));
2861+        }
2862+        let mut addressed = object.clone();
2863+        addressed.insert("recordType".into(), json!(record_type));
2864+        addressed.insert(
2865+            "hostPublicKeyHex".into(),
2866+            json!(grant.host_public_key_hex.clone()),
2867+        );
2868+        addressed.insert(
2869+            "devicePublicKeyHex".into(),
2870+            json!(grant.device_public_key_hex.clone()),
2871+        );
2872+        addressed.insert("grantRef".into(), json!(grant.grant_ref.clone()));
2873+        addressed.insert("expectedGeneration".into(), json!(grant.generation));
2874+        Ok(Value::Object(addressed))
2875+    }
2876+
2877+    /// Publish the omega#47 host snapshot and its Full Auto detail to every
2878+    /// admitted device (omega#49).
2879+    ///
2880+    /// The two documents are published together or not at all. The detail is
2881+    /// bound to the snapshot that advertised it, and a device that held one
2882+    /// without the other would either render a detail nothing vouches for or
2883+    /// advertise capabilities it cannot open.
2884+    fn publish_issue31_host_adjuncts(
2885+        &mut self,
2886+        controller: &Issue31HostController,
2887+        now: u64,
2888+    ) -> Result<(), SarahConversationError> {
2889+        let Some(source) = self.issue31_host_projection_source.clone() else {
2890+            return Ok(());
2891+        };
2892+        let grants = controller.active_grants(now).map_err(issue31_error)?;
2893+        let observed_at_ms = now.saturating_mul(1_000);
2894+        for grant in grants {
2895+            let documents = match source(&Issue31HostProjectionRequest {
2896+                host_ref: &grant.host_ref,
2897+                host_public_key_hex: &grant.host_public_key_hex,
2898+                device_public_key_hex: &grant.device_public_key_hex,
2899+                grant_ref: &grant.grant_ref,
2900+                expected_generation: grant.generation,
2901+                observed_at_ms,
2902+            }) {
2903+                Ok(Some(documents)) => documents,
2904+                // Nothing observed is said by saying nothing. Publishing an
2905+                // empty projection here would claim the host had looked and
2906+                // found no runs, which is a different fact.
2907+                Ok(None) => continue,
2908+                Err(_) => {
2909+                    // The host could not read its own Full Auto state. The
2910+                    // owner's view may therefore be short, and that has to be
2911+                    // visible rather than inferred from an absent record.
2912+                    self.last_gap_state =
2913+                        strongest_gap_state(self.last_gap_state, GapState::Possible);
2914+                    continue;
2915+                }
2916+            };
2917+            let host = Self::address_issue31_adjunct(
2918+                &documents.host,
2919+                ISSUE31_HOST_ADJUNCT_SCHEMA,
2920+                ISSUE31_HOST_ADJUNCT_RECORD_TYPE,
2921+                &grant,
2922+            )?;
2923+            let detail = Self::address_issue31_adjunct(
2924+                &documents.detail,
2925+                ISSUE31_FULL_AUTO_ADJUNCT_SCHEMA,
2926+                ISSUE31_FULL_AUTO_ADJUNCT_RECORD_TYPE,
2927+                &grant,
2928+            )?;
2929+            // "Beside" is the contract's word for the relation between the two
2930+            // documents. A detail carrying a different snapshot reference is
2931+            // one the device would refuse as `snapshot_mismatch`, so sending it
2932+            // would publish a refusal rather than a projection.
2933+            if host.get("snapshotRef") != detail.get("snapshotRef") {
2934+                return Err(SarahConversationError::Internal(
2935+                    "Issue 31 Full Auto detail is not bound to the snapshot beside it".into(),
2936+                ));
2937+            }
2938+            let host_content = serde_json::to_string(&host)
2939+                .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
2940+            let detail_content = serde_json::to_string(&detail)
2941+                .map_err(|error| SarahConversationError::Internal(error.to_string()))?;
2942+            let key = format!("{}:{}", grant.grant_ref, grant.generation);
2943+            let digest = format!(
2944+                "{:x}",
2945+                Sha256::digest(
2946+                    [host_content.as_bytes(), detail_content.as_bytes()].concat()
2947+                )
2948+            );
2949+            if self.issue31_host_adjunct_emissions.get(&key) == Some(&digest) {
2950+                continue;
2951+            }
2952+            let enqueued_host = self.enqueue_issue31_private_content(
2953+                ISSUE31_HOST_ADJUNCT_SCHEMA,
2954+                &host_content,
2955+                &grant.device_public_key_hex,
2956+            )?;
2957+            let enqueued_detail = match self.enqueue_issue31_private_content(
2958+                ISSUE31_FULL_AUTO_ADJUNCT_SCHEMA,
2959+                &detail_content,
2960+                &grant.device_public_key_hex,
2961+            ) {
2962+                Ok(enqueued) => enqueued,
2963+                Err(error) => {
2964+                    self.rollback_issue31_enqueue(&enqueued_host);
2965+                    return Err(error);
2966+                }
2967+            };
2968+            let previous = self
2969+                .issue31_host_adjunct_emissions
2970+                .insert(key.clone(), digest);
2971+            // The bookkeeping is committed with the outbox, not after it: a
2972+            // crash between the two would either resend forever or, worse,
2973+            // record a publication that never happened.
2974+            if let Err(error) = self.persist_issue31_host_state_with_controller(controller) {
2975+                match previous {
2976+                    Some(previous) => {
2977+                        self.issue31_host_adjunct_emissions.insert(key, previous);
2978+                    }
2979+                    None => {
2980+                        self.issue31_host_adjunct_emissions.remove(&key);
2981+                    }
2982+                }
2983+                self.rollback_issue31_enqueue(&enqueued_detail);
2984+                self.rollback_issue31_enqueue(&enqueued_host);
2985+                return Err(error);
2986+            }
2987+        }
2988+        Ok(())
2989+    }
2990+
27202991     /// Tell every admitted device how complete its own projection is.
27212992     ///
27222993     /// This is the device-visible half of the two host-local surfaces that
@@ -3694,6 +3965,7 @@ fn load_issue31_host_state(
36943965         || state.command_results.len() > MAX_COMMAND_RESULTS
36953966         || state.relay_acknowledgements.len() > MAX_RELAY_ACKNOWLEDGEMENTS
36963967         || state.quarantined_events.len() > MAX_QUARANTINED_ISSUE31_EVENTS
3968+        || state.host_adjunct_emissions.len() > MAX_QUARANTINED_ISSUE31_EVENTS
36973969         || state
36983970             .control_cursor
36993971             .as_deref()
@@ -4526,6 +4798,252 @@ mod tests {
45264798             .cloned()
45274799     }
45284800 
4801+    // -----------------------------------------------------------------
4802+    // omega#49: the omega#47 documents actually leaving the host.
4803+    //
4804+    // `full_auto_ui` built both documents from live host state and nothing
4805+    // ever published them, so a paired phone rendered `no_host_projection` on
4806+    // every device. These cover the pump half: who the records are addressed
4807+    // to, what binding they carry, and when they are NOT sent.
4808+    // -----------------------------------------------------------------
4809+
4810+    fn adjunct_outbox_refs(client: &SarahConversationClient, schema: &str) -> Vec<String> {
4811+        client
4812+            .issue31_private_outbox
4813+            .keys()
4814+            .filter(|outbox_ref| outbox_ref.starts_with(schema))
4815+            .cloned()
4816+            .collect()
4817+    }
4818+
4819+    /// A reading the pump can publish, carrying no delivery claim of its own.
4820+    fn host_documents(host_ref: &str, snapshot_ref: &str) -> Issue31HostProjectionDocuments {
4821+        Issue31HostProjectionDocuments {
4822+            host: json!({
4823+                "schema": ISSUE31_HOST_ADJUNCT_SCHEMA,
4824+                "hostRef": host_ref,
4825+                "snapshotRef": snapshot_ref,
4826+                "generatedAtMs": 1_784_894_400_000_u64,
4827+                "projections": [],
4828+            }),
4829+            detail: json!({
4830+                "schema": ISSUE31_FULL_AUTO_ADJUNCT_SCHEMA,
4831+                "hostRef": host_ref,
4832+                "snapshotRef": snapshot_ref,
4833+                "generatedAtMs": 1_784_894_400_000_u64,
4834+                "runs": [],
4835+            }),
4836+        }
4837+    }
4838+
4839+    fn paired_adjunct_client(
4840+        source: Option<Issue31HostProjectionSource>,
4841+    ) -> (SarahConversationClient, Issue31HostController, String) {
4842+        let signer = SigningIdentity::generate();
4843+        let mut config = SarahConversationConfig::mock_fixture();
4844+        config.identity.owner_public_key_hex = signer.public_key_hex.clone();
4845+        let owner_public_key_hex = signer.public_key_hex.clone();
4846+        let sarah_public_key_hex = config.identity.sarah_public_key_hex.clone();
4847+        let conversation_ref = config.conversation_ref();
4848+        let mut client =
4849+            SarahConversationClient::with_relay(config, Box::new(MockRelayAdapter::new()), signer);
4850+        if let Some(source) = source {
4851+            client.set_issue31_host_projection_source(source);
4852+        }
4853+        let device_public_key_hex = "2".repeat(64);
4854+        let controller = pair_issue31_device(
4855+            Issue31HostConfiguration {
4856+                host_ref: "omega.host.local".into(),
4857+                host_public_key_hex: owner_public_key_hex,
4858+                sarah_public_key_hex,
4859+                conversation: conversation_ref,
4860+                display_name: "Omega host".into(),
4861+                relay_urls: vec!["wss://relay.example.com".into()],
4862+                generation: 1,
4863+            },
4864+            &device_public_key_hex,
4865+        );
4866+        (client, controller, device_public_key_hex)
4867+    }
4868+
4869+    /// The gap itself: both omega#47 documents must reach every admitted
4870+    /// device, addressed to that device and to the grant it holds.
4871+    #[test]
4872+    fn the_host_snapshot_and_its_detail_are_addressed_to_each_admitted_device() {
4873+        let source: Issue31HostProjectionSource = Arc::new(|request| {
4874+            Ok(Some(host_documents(request.host_ref, "snapshot.omega.issue31.aa")))
4875+        });
4876+        let (mut client, controller, device_public_key_hex) =
4877+            paired_adjunct_client(Some(source));
4878+        let grant = controller.active_grants(200).expect("grants")[0].clone();
4879+        client.ensure_connected().expect("connect the mock relay");
4880+        client
4881+            .publish_issue31_host_adjuncts(&controller, 200)
4882+            .expect("the pump publishes both documents");
4883+
4884+        assert_eq!(
4885+            adjunct_outbox_refs(&client, ISSUE31_HOST_ADJUNCT_SCHEMA).len(),
4886+            1,
4887+        );
4888+        assert_eq!(
4889+            adjunct_outbox_refs(&client, ISSUE31_FULL_AUTO_ADJUNCT_SCHEMA).len(),
4890+            1,
4891+        );
4892+        assert!(
4893+            client
4894+                .issue31_host_adjunct_emissions
4895+                .contains_key(&format!("{}:{}", grant.grant_ref, grant.generation)),
4896+            "the pump must remember what it sent, or it resends forever",
4897+        );
4898+
4899+        // The binding the device checks the envelope against.
4900+        let addressed = SarahConversationClient::address_issue31_adjunct(
4901+            &host_documents("omega.host.local", "snapshot.omega.issue31.aa").host,
4902+            ISSUE31_HOST_ADJUNCT_SCHEMA,
4903+            ISSUE31_HOST_ADJUNCT_RECORD_TYPE,
4904+            &grant,
4905+        )
4906+        .expect("the snapshot is addressable to this grant");
4907+        assert_eq!(
4908+            addressed.get("recordType").and_then(Value::as_str),
4909+            Some(ISSUE31_HOST_ADJUNCT_RECORD_TYPE),
4910+        );
4911+        assert_eq!(
4912+            addressed.get("devicePublicKeyHex").and_then(Value::as_str),
4913+            Some(device_public_key_hex.as_str()),
4914+        );
4915+        assert_eq!(
4916+            addressed.get("grantRef").and_then(Value::as_str),
4917+            Some(grant.grant_ref.as_str()),
4918+        );
4919+        assert_eq!(
4920+            addressed.get("hostPublicKeyHex").and_then(Value::as_str),
4921+            Some(grant.host_public_key_hex.as_str()),
4922+        );
4923+    }
4924+
4925+    /// A pass that observed the same world must not re-send the snapshot. The
4926+    /// device would otherwise be handed an identical record forever, and every
4927+    /// one of them would cost it a decrypt.
4928+    #[test]
4929+    fn an_unchanged_reading_is_not_republished() {
4930+        let source: Issue31HostProjectionSource = Arc::new(|request| {
4931+            Ok(Some(host_documents(request.host_ref, "snapshot.omega.issue31.aa")))
4932+        });
4933+        let (mut client, controller, _device) = paired_adjunct_client(Some(source));
4934+        client.ensure_connected().expect("connect the mock relay");
4935+        client
4936+            .publish_issue31_host_adjuncts(&controller, 200)
4937+            .expect("first pass");
4938+        client.issue31_private_outbox.clear();
4939+        client
4940+            .publish_issue31_host_adjuncts(&controller, 260)
4941+            .expect("second pass");
4942+        assert!(
4943+            client.issue31_private_outbox.is_empty(),
4944+            "an unchanged reading must not be republished",
4945+        );
4946+
4947+        // A changed reading is a different snapshot and does go out again.
4948+        let changed: Issue31HostProjectionSource = Arc::new(|request| {
4949+            Ok(Some(host_documents(request.host_ref, "snapshot.omega.issue31.bb")))
4950+        });
4951+        client.set_issue31_host_projection_source(changed);
4952+        client
4953+            .publish_issue31_host_adjuncts(&controller, 320)
4954+            .expect("third pass");
4955+        assert_eq!(
4956+            adjunct_outbox_refs(&client, ISSUE31_HOST_ADJUNCT_SCHEMA).len(),
4957+            1,
4958+        );
4959+    }
4960+
4961+    /// A host that is not observing its Full Auto state says nothing rather
4962+    /// than publishing an empty snapshot. Silence and "I looked and found
4963+    /// nothing" are different claims and the device renders them differently.
4964+    #[test]
4965+    fn a_host_with_no_reading_publishes_nothing_at_all() {
4966+        let (mut client, controller, _device) = paired_adjunct_client(None);
4967+        client.ensure_connected().expect("connect the mock relay");
4968+        client
4969+            .publish_issue31_host_adjuncts(&controller, 200)
4970+            .expect("a host with no reading still completes its pass");
4971+        assert!(client.issue31_private_outbox.is_empty());
4972+
4973+        let silent: Issue31HostProjectionSource = Arc::new(|_| Ok(None));
4974+        client.set_issue31_host_projection_source(silent);
4975+        client
4976+            .publish_issue31_host_adjuncts(&controller, 200)
4977+            .expect("an unobserved host still completes its pass");
4978+        assert!(client.issue31_private_outbox.is_empty());
4979+    }
4980+
4981+    /// The one substitution a signed seal cannot rule out. A snapshot labelled
4982+    /// with another machine's host reference would be bound by the device to
4983+    /// this pairing, so the pump refuses to address it at all.
4984+    #[test]
4985+    fn a_snapshot_naming_another_host_is_never_addressed_to_this_device() {
4986+        let (mut client, controller, _device) = paired_adjunct_client(None);
4987+        let grant = controller.active_grants(200).expect("grants")[0].clone();
4988+        let foreign = host_documents("omega.host.some-other-machine", "snapshot.omega.issue31.aa");
4989+        assert!(
4990+            SarahConversationClient::address_issue31_adjunct(
4991+                &foreign.host,
4992+                ISSUE31_HOST_ADJUNCT_SCHEMA,
4993+                ISSUE31_HOST_ADJUNCT_RECORD_TYPE,
4994+                &grant,
4995+            )
4996+            .is_err(),
4997+        );
4998+
4999+        // And a reading that arrives already claiming who may read it is
5000+        // refused rather than silently overwritten.
5001+        let mut presumptuous = host_documents("omega.host.local", "snapshot.omega.issue31.aa").host;
5002+        presumptuous
5003+            .as_object_mut()
5004+            .expect("object")
5005+            .insert("devicePublicKeyHex".into(), json!("3".repeat(64)));
5006+        assert!(
5007+            SarahConversationClient::address_issue31_adjunct(
5008+                &presumptuous,
5009+                ISSUE31_HOST_ADJUNCT_SCHEMA,
5010+                ISSUE31_HOST_ADJUNCT_RECORD_TYPE,
5011+                &grant,
5012+            )
5013+            .is_err(),
5014+        );
5015+
5016+        let source: Issue31HostProjectionSource = Arc::new(|_| {
5017+            Ok(Some(host_documents(
5018+                "omega.host.some-other-machine",
5019+                "snapshot.omega.issue31.aa",
5020+            )))
5021+        });
5022+        client.set_issue31_host_projection_source(source);
5023+        client.ensure_connected().expect("connect the mock relay");
5024+        assert!(client.publish_issue31_host_adjuncts(&controller, 200).is_err());
5025+        assert!(client.issue31_private_outbox.is_empty());
5026+    }
5027+
5028+    /// The snapshot advertises the capabilities; the detail is what the owner
5029+    /// opens. A detail bound to a different snapshot is one the phone refuses
5030+    /// as `snapshot_mismatch`, so publishing it would publish a refusal.
5031+    #[test]
5032+    fn a_detail_not_bound_to_the_snapshot_beside_it_is_never_sent() {
5033+        let source: Issue31HostProjectionSource = Arc::new(|request| {
5034+            let mut documents = host_documents(request.host_ref, "snapshot.omega.issue31.aa");
5035+            documents
5036+                .detail
5037+                .as_object_mut()
5038+                .expect("object")
5039+                .insert("snapshotRef".into(), json!("snapshot.omega.issue31.bb"));
5040+            Ok(Some(documents))
5041+        });
5042+        let (mut client, controller, _device) = paired_adjunct_client(Some(source));
5043+        client.ensure_connected().expect("connect the mock relay");
5044+        assert!(client.publish_issue31_host_adjuncts(&controller, 200).is_err());
5045+    }
5046+
45295047     /// Pair one device with the host so the projection pass has somewhere to
45305048     /// send a coverage statement.
45315049     fn pair_issue31_device(
@@ -5639,6 +6157,7 @@ mod tests {
56396157                 "9".repeat(64),
56406158                 "reason.omega.invalid_pairing_record".into(),
56416159             )]),
6160+            host_adjunct_emissions: BTreeMap::new(),
56426161             command_results: BTreeMap::from([(
56436162                 "idempotency.restart.admin".into(),
56446163                 ("fingerprint".into(), json!({ "eventId": "1".repeat(64) })),
diff --git a/crates/workroom_receipts/src/issue31_full_auto.rs b/crates/workroom_receipts/src/issue31_full_auto.rs
index 537920bf4b..ebe5be08b9 100644
--- a/crates/workroom_receipts/src/issue31_full_auto.rs
+++ b/crates/workroom_receipts/src/issue31_full_auto.rs
@@ -950,6 +950,28 @@ pub fn build_issue31_full_auto_adjunct(
950950     handoffs: &serde_json::Value,
951951     evidence: &[(serde_json::Value, serde_json::Value)],
952952 ) -> AdjunctResult<Issue31FullAutoAdjunct> {
953+    build_issue31_full_auto_adjunct_document(
954+        host_ref,
955+        snapshot_ref,
956+        generated_at_ms,
957+        runs,
958+        accounts,
959+        handoffs,
960+        evidence,
961+    )
962+    .map(|(adjunct, _)| adjunct)
963+}
964+
965+/// The same detail projection, plus the exact bytes the decoder accepted.
966+pub fn build_issue31_full_auto_adjunct_document(
967+    host_ref: &str,
968+    snapshot_ref: &str,
969+    generated_at_ms: u64,
970+    runs: &serde_json::Value,
971+    accounts: &serde_json::Value,
972+    handoffs: &serde_json::Value,
973+    evidence: &[(serde_json::Value, serde_json::Value)],
974+) -> AdjunctResult<(Issue31FullAutoAdjunct, serde_json::Value)> {
953975     let document = serde_json::json!({
954976         "schema": ISSUE31_FULL_AUTO_ADJUNCT_SCHEMA,
955977         "hostRef": host_ref,
@@ -962,7 +984,8 @@ pub fn build_issue31_full_auto_adjunct(
962984     });
963985     let serialized =
964986         serde_json::to_string(&document).map_err(|_| Issue31FullAutoAdjunctError::InvalidJson)?;
965-    decode_issue31_full_auto_adjunct(&serialized)
987+    let adjunct = decode_issue31_full_auto_adjunct(&serialized)?;
988+    Ok((adjunct, document))
966989 }
967990 
968991 /// Map an Omega Full Auto panel state onto the contract lifecycle.
diff --git a/crates/workroom_receipts/src/issue31_host.rs b/crates/workroom_receipts/src/issue31_host.rs
index 6ebb14039d..b5137b661c 100644
--- a/crates/workroom_receipts/src/issue31_host.rs
+++ b/crates/workroom_receipts/src/issue31_host.rs
@@ -601,6 +601,22 @@ pub fn build_issue31_host_adjunct(
601601     generated_at_ms: u64,
602602     sources: &Issue31HostSources<'_>,
603603 ) -> Result<Issue31HostAdjunct, Issue31HostAdjunctError> {
604+    build_issue31_host_adjunct_document(host_ref, snapshot_ref, generated_at_ms, sources)
605+        .map(|(adjunct, _)| adjunct)
606+}
607+
608+/// The same snapshot, plus the exact bytes the decoder accepted.
609+///
610+/// A caller that has to put the snapshot on a wire would otherwise have to
611+/// re-encode the typed value, which is a second serializer that can disagree
612+/// with the one the contract validated. Returning the validated document means
613+/// what is published is what was checked.
614+pub fn build_issue31_host_adjunct_document(
615+    host_ref: &str,
616+    snapshot_ref: &str,
617+    generated_at_ms: u64,
618+    sources: &Issue31HostSources<'_>,
619+) -> Result<(Issue31HostAdjunct, serde_json::Value), Issue31HostAdjunctError> {
604620     let document = serde_json::json!({
605621         "schema": ISSUE31_HOST_ADJUNCT_SCHEMA,
606622         "hostRef": host_ref,
@@ -627,7 +643,8 @@ pub fn build_issue31_host_adjunct(
627643     });
628644     let serialized =
629645         serde_json::to_string(&document).map_err(|_| Issue31HostAdjunctError::InvalidJson)?;
630-    decode_issue31_host_adjunct(&serialized)
646+    let adjunct = decode_issue31_host_adjunct(&serialized)?;
647+    Ok((adjunct, document))
631648 }
632649 
633650 fn build_projection(
diff --git a/crates/workroom_receipts/src/workroom_receipts.rs b/crates/workroom_receipts/src/workroom_receipts.rs
index c8a6816f58..c3b9b33661 100644
--- a/crates/workroom_receipts/src/workroom_receipts.rs
+++ b/crates/workroom_receipts/src/workroom_receipts.rs
@@ -28,7 +28,8 @@ pub use issue31_full_auto::{
2828     Issue31ProviderAccount, Issue31ProviderHandoff, Issue31ProviderHandoffState,
2929     Issue31ProviderQuota, Issue31ProviderReadiness, MAX_ISSUE31_FULL_AUTO_ACCOUNTS,
3030     MAX_ISSUE31_FULL_AUTO_CONTROLS, MAX_ISSUE31_FULL_AUTO_HANDOFFS, MAX_ISSUE31_FULL_AUTO_RUNS,
31-    MAX_ISSUE31_UNATTENDED_MS, build_issue31_full_auto_adjunct, decode_issue31_full_auto_adjunct,
31+    MAX_ISSUE31_UNATTENDED_MS, build_issue31_full_auto_adjunct,
32+    build_issue31_full_auto_adjunct_document, decode_issue31_full_auto_adjunct,
3233     is_issue31_public_text, project_issue31_evidence_pair,
3334 };
3435 pub use issue31_host::{
@@ -38,7 +39,7 @@ pub use issue31_host::{
3839     Issue31ProjectionCapability, Issue31ProjectionSource, Issue31Role, Issue31RoleInput,
3940     Issue31RoleKind, Issue31RoleStatus, Issue31SourceKind, Issue31TerminalState,
4041     MAX_ISSUE31_PROJECTION_REFS, MAX_ISSUE31_TIMESTAMP_MS, ProjectionFreshness,
41-    build_issue31_host_adjunct, decode_issue31_host_adjunct,
42+    build_issue31_host_adjunct, build_issue31_host_adjunct_document, decode_issue31_host_adjunct,
4243 };
4344 pub use public_ref::{PUBLIC_REF_MAX_LEN, PublicRef, is_public_safe_ref, sanitize_public_ref};
4445 pub use receipt::{
Served at tenant.openagents/omega Member data and write actions are omitted.