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 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitRevision diff
460e04e5 → 1fe77eefdiff --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::{