Skip to repository content1398 lines · 58.5 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:58:09.135Z 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.gitBrowse files
issue31_adjunct.rs
1//! Project the live Full Auto panel sources into the OMEGA-MOB-31-03 headless
2//! contract (omega#47).
3//!
4//! `workroom_receipts::build_issue31_full_auto_adjunct` owns the boundaries.
5//! This module owns the adaptation: it takes the exact `omega_effectd`
6//! responses the panel already holds — the run details behind omega#41, the
7//! capacity record behind omega#42, and the report/receipt pairs behind
8//! omega#43 — and puts them into the emitter's input shape.
9//!
10//! The three panels and the phone therefore read one set of host records. A
11//! disagreement between what Omega shows on the desktop and what the owner sees
12//! on the phone would have to be a bug in this one adapter rather than a second
13//! opinion about the host's state.
14//!
15//! Nothing here decides what is safe to project. Every value goes through the
16//! emitter, which routes it through the decoder, so this file cannot widen the
17//! boundary even by mistake.
18
19use serde_json::{Value, json};
20use workroom_receipts::{
21 Issue31CommandStateInput, Issue31FullAutoAdjunct, Issue31FullAutoAdjunctError,
22 Issue31HostAdjunct, Issue31HostAdjunctError, Issue31HostProjectionInput, Issue31HostSources,
23 Issue31ObservedGap, Issue31RoleInput, MAX_ISSUE31_PROJECTION_REFS, ProjectionFreshness,
24 build_issue31_full_auto_adjunct_document, build_issue31_host_adjunct_document,
25};
26
27use crate::provider_roster::parse_provider_accounts;
28
29/// Why a live host state could not be projected at all.
30///
31/// There is deliberately no partial success. A projection that dropped the one
32/// run the owner is watching, while showing the others, would be the most
33/// misleading result available.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum Issue31FullAutoProjectionError {
36 /// The host reported a run with no objective, lane, or reference.
37 IncompleteRunRecord,
38 /// The host never recorded when this run began, so its exact unattended
39 /// duration is unknown. Showing zero would be a claim nothing supports.
40 UnattendedDurationUnknown,
41 /// The host reported a lifecycle state this contract does not model. The
42 /// safe answer is to refuse: guessing which contract state an unrecognised
43 /// host state resembles is how a stalled run gets shown as running.
44 UnknownLifecycle,
45 /// The assembled projection was refused by the contract.
46 Contract(Issue31FullAutoAdjunctError),
47}
48
49impl std::fmt::Display for Issue31FullAutoProjectionError {
50 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 match self {
52 Self::IncompleteRunRecord => {
53 formatter.write_str("full auto run record is missing a required public field")
54 }
55 Self::UnattendedDurationUnknown => {
56 formatter.write_str("full auto run has no host-recorded start time")
57 }
58 Self::UnknownLifecycle => formatter
59 .write_str("full auto run reported a lifecycle state this contract does not model"),
60 Self::Contract(error) => write!(formatter, "{error}"),
61 }
62 }
63}
64
65impl std::error::Error for Issue31FullAutoProjectionError {}
66
67/// The contract lifecycle for a host lifecycle state.
68///
69/// `omega_effectd` and the mobile contract name the same lifecycle with two
70/// vocabularies, so exactly one place has to translate. Doing it here, as a
71/// total match over the host's typed states, means an unrecognised state is a
72/// refusal rather than a silent pass-through that the emitter would reject with
73/// a state error the reader cannot act on.
74///
75/// `draft` maps to `queued` because both mean the same thing to a viewer: this
76/// run exists and no work has begun. Every other pair is a rename.
77pub(crate) fn lifecycle_for_host_state(state: &str) -> Option<&'static str> {
78 Some(match state {
79 "draft" | "queued" => "queued",
80 "running" => "running",
81 "pausing" => "pausing",
82 "paused" => "paused",
83 "stopping" => "stopping",
84 "retrying" => "retrying",
85 "stalled" => "stalled",
86 "completed" => "succeeded",
87 "failed" => "failed",
88 "stopped" => "stopped",
89 "cap_reached" => "expired",
90 _ => return None,
91 })
92}
93
94fn controls_for_state(state: &str) -> &'static [&'static str] {
95 match state {
96 "running" | "retrying" | "stalled" => &["pause", "stop"],
97 "paused" => &["resume", "stop"],
98 "queued" => &["stop"],
99 // `pausing` and `stopping` already have a mutation in flight, and every
100 // remaining state is terminal.
101 _ => &[],
102 }
103}
104
105/// The host's own start time for a run, in milliseconds.
106///
107/// Only a numeric field the host recorded is accepted. Re-deriving a start from
108/// a formatted display string would make the duration a parse of Omega's UI
109/// rather than a measurement of the run.
110fn started_at_ms(run: &Value) -> Option<u64> {
111 for field in ["startedAtMs", "startedAtEpochMs", "createdAtMs"] {
112 if let Some(value) = run.get(field).and_then(Value::as_u64) {
113 return Some(value);
114 }
115 }
116 None
117}
118
119fn project_run(run: &Value, host_generation: u64) -> Result<Value, Issue31FullAutoProjectionError> {
120 let run_ref = run
121 .get("runRef")
122 .and_then(Value::as_str)
123 .ok_or(Issue31FullAutoProjectionError::IncompleteRunRecord)?;
124 let objective = run
125 .get("objective")
126 .and_then(Value::as_str)
127 .filter(|objective| !objective.trim().is_empty())
128 .ok_or(Issue31FullAutoProjectionError::IncompleteRunRecord)?;
129 // A run with no lane cannot be related to any provider account, and
130 // omega#47 requires the account-to-lane relation to be explicit.
131 let lane_ref = run
132 .get("laneRef")
133 .or_else(|| run.get("lane"))
134 .and_then(Value::as_str)
135 .ok_or(Issue31FullAutoProjectionError::IncompleteRunRecord)?;
136 let state = run
137 .get("state")
138 .and_then(Value::as_str)
139 .ok_or(Issue31FullAutoProjectionError::IncompleteRunRecord)?;
140 let started_at_ms =
141 started_at_ms(run).ok_or(Issue31FullAutoProjectionError::UnattendedDurationUnknown)?;
142 let lifecycle =
143 lifecycle_for_host_state(state).ok_or(Issue31FullAutoProjectionError::UnknownLifecycle)?;
144
145 let permitted: Vec<&str> = match run.get("permittedControls").and_then(Value::as_array) {
146 Some(declared) => declared.iter().filter_map(Value::as_str).collect(),
147 None => controls_for_state(state).to_vec(),
148 };
149
150 let mut projected = json!({
151 "runRef": run_ref,
152 "objective": objective,
153 "laneRef": lane_ref,
154 "state": lifecycle,
155 "generation": host_generation,
156 "startedAtMs": started_at_ms,
157 "permittedControls": permitted,
158 });
159 let object = projected
160 .as_object_mut()
161 .ok_or(Issue31FullAutoProjectionError::IncompleteRunRecord)?;
162 for field in ["liveWorkRef", "terminalReasonRef"] {
163 if let Some(value) = run.get(field)
164 && !value.is_null()
165 {
166 object.insert(field.into(), value.clone());
167 }
168 }
169 // The host's free-text `terminalReason` is deliberately NOT promoted into
170 // `terminalReasonRef`. A sentence written for a human is not a typed
171 // classification, and reading one as the other is how a run's ending
172 // acquires a meaning nobody recorded. A finished run whose host stated no
173 // typed reason reaches the emitter without one, which marks it
174 // `reason.full-auto.unrecorded` -- a gap the owner can see.
175 Ok(projected)
176}
177
178/// The live host state the three Full Auto panels read.
179pub struct Issue31FullAutoLiveSources<'a> {
180 /// The `host.v1` snapshot this detail projection is bound to. A phone that
181 /// holds a different snapshot renders `snapshot_mismatch` rather than runs.
182 pub host_ref: &'a str,
183 pub snapshot_ref: &'a str,
184 pub generated_at_ms: u64,
185 /// The supervised `omega_effectd` generation. Every control is bound to it,
186 /// so a control minted before a host restart is refused afterwards rather
187 /// than replayed against a run the owner can no longer see.
188 pub host_generation: u64,
189 /// One `get_run` record per projected run.
190 pub run_details: &'a [Value],
191 /// The `get_capacity` record the provider roster (omega#42) parses.
192 pub capacity: &'a Value,
193 /// Host-owned provider connection handoff records.
194 pub handoffs: &'a [Value],
195 /// One `(get_report, get_receipt)` pair per run with evidence.
196 pub evidence: &'a [(Value, Value)],
197}
198
199/// Build the headless contract from live host state.
200pub fn project_issue31_full_auto_adjunct(
201 sources: &Issue31FullAutoLiveSources<'_>,
202) -> 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.
207pub fn project_issue31_full_auto_adjunct_document(
208 sources: &Issue31FullAutoLiveSources<'_>,
209) -> Result<(Issue31FullAutoAdjunct, Value), Issue31FullAutoProjectionError> {
210 let runs = sources
211 .run_details
212 .iter()
213 .map(|run| project_run(run, sources.host_generation))
214 .collect::<Result<Vec<_>, _>>()?;
215
216 // Routed through the same parser the desktop roster renders, so the panel
217 // and the phone cannot disagree about which accounts exist or which lane
218 // each one serves.
219 let accounts: Vec<Value> = parse_provider_accounts(sources.capacity)
220 .into_iter()
221 .map(|account| {
222 json!({
223 "accountRef": account.account_ref,
224 "provider": account.provider,
225 "label": account.label,
226 "state": account.readiness,
227 "quotaState": account.quota,
228 "lane": account.lane,
229 })
230 })
231 .collect();
232
233 build_issue31_full_auto_adjunct_document(
234 sources.host_ref,
235 sources.snapshot_ref,
236 sources.generated_at_ms,
237 &json!({ "runs": runs }),
238 &json!({ "accounts": accounts }),
239 &json!({ "handoffs": sources.handoffs }),
240 sources.evidence,
241 )
242 .map_err(Issue31FullAutoProjectionError::Contract)
243}
244
245// ---------------------------------------------------------------------------
246// The `host.v1` snapshot the detail projection is published beside
247// ---------------------------------------------------------------------------
248
249/// The one capability the three Full Auto panels do not hold.
250///
251/// Connection identity is the host announcement and the owner device grant.
252/// Its records live with the pairing/grant surface, so the caller supplies
253/// them; everything else in the snapshot is derived from the exact same live
254/// values the detail projection uses.
255pub struct Issue31HostIdentitySource<'a> {
256 pub source_ref: &'a str,
257 pub observed_at_ms: u64,
258 /// The grant that makes the reader's owner role active. Absent means this
259 /// host cannot presently state the reader's role, which is projected as an
260 /// unknown role with no permitted actions rather than as a working one.
261 pub owner_grant_ref: Option<&'a str>,
262 pub record_refs: &'a [&'a str],
263 pub permitted_action_refs: &'a [&'a str],
264}
265
266/// Both omega#47 documents, produced from one reading of the host.
267///
268/// The contract says the detail projection is published "beside the `host.v1`
269/// snapshot". Returning them together, built from a single
270/// `Issue31FullAutoLiveSources`, is what makes "beside" a fact rather than a
271/// convention: they carry the same `hostRef`, the same `snapshotRef`, and the
272/// same `generatedAtMs` because there is no code path that could give them
273/// different ones.
274#[derive(Debug)]
275pub struct Issue31HostPublication {
276 pub host: Issue31HostAdjunct,
277 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,
285}
286
287/// Why a live host state could not be published as a `host.v1` snapshot.
288#[derive(Clone, Copy, Debug, PartialEq, Eq)]
289pub enum Issue31HostProjectionError {
290 /// The snapshot itself was refused by the contract.
291 Host(Issue31HostAdjunctError),
292 /// The detail projection beside it was refused. Neither is published: a
293 /// snapshot advertising Full Auto records the owner cannot then open would
294 /// claim more than the host can show.
295 Detail(Issue31FullAutoProjectionError),
296}
297
298impl std::fmt::Display for Issue31HostProjectionError {
299 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300 match self {
301 Self::Host(error) => write!(formatter, "{error}"),
302 Self::Detail(error) => write!(formatter, "{error}"),
303 }
304 }
305}
306
307impl std::error::Error for Issue31HostProjectionError {}
308
309/// Truncate a record-reference list to the contract bound, reporting whether
310/// anything was dropped.
311///
312/// A host with more records than one snapshot can cite is a real situation, and
313/// the honest answer is `partial` — "this is not all of them" — rather than a
314/// refusal that would hide every record, or a silent truncation that would
315/// claim completeness the snapshot does not have.
316fn bounded_refs(refs: Vec<String>) -> (Vec<String>, Issue31ObservedGap) {
317 if refs.len() > MAX_ISSUE31_PROJECTION_REFS {
318 (
319 refs.into_iter().take(MAX_ISSUE31_PROJECTION_REFS).collect(),
320 Issue31ObservedGap::Partial,
321 )
322 } else {
323 (refs, Issue31ObservedGap::Complete)
324 }
325}
326
327/// Publish the `host.v1` snapshot and the detail projection that sits beside
328/// it, from one reading of live host state.
329pub fn publish_issue31_host_snapshot(
330 sources: &Issue31FullAutoLiveSources<'_>,
331 identity: &Issue31HostIdentitySource<'_>,
332) -> Result<Issue31HostPublication, Issue31HostProjectionError> {
333 // Built first: a snapshot must never advertise records whose detail the
334 // host would refuse to project.
335 let (detail, detail_document) = project_issue31_full_auto_adjunct_document(sources)
336 .map_err(Issue31HostProjectionError::Detail)?;
337
338 let run_refs: Vec<String> = detail
339 .runs
340 .iter()
341 .map(|run| format!("record.full-auto.run.{}", run.run_ref.as_str()))
342 .collect();
343 let account_refs: Vec<String> = detail
344 .accounts
345 .iter()
346 .map(|account| format!("record.provider.account.{}", account.account_ref.as_str()))
347 .collect();
348 let evidence_refs: Vec<String> = detail
349 .runs
350 .iter()
351 .take(detail.evidence.len())
352 .map(|run| format!("record.evidence.chain.{}", run.run_ref.as_str()))
353 .collect();
354
355 let (run_refs, run_gap) = bounded_refs(run_refs);
356 let (account_refs, account_gap) = bounded_refs(account_refs);
357 let (evidence_refs, evidence_gap) = bounded_refs(evidence_refs);
358 let run_refs: Vec<&str> = run_refs.iter().map(String::as_str).collect();
359 let account_refs: Vec<&str> = account_refs.iter().map(String::as_str).collect();
360 let evidence_refs: Vec<&str> = evidence_refs.iter().map(String::as_str).collect();
361
362 // A run this host is willing to control is one whose controls it already
363 // minted. Advertising an action for a snapshot with no controllable run
364 // would offer the owner a button the host would not honour.
365 let run_actions: Vec<&str> = if detail.runs.iter().any(|run| !run.controls.is_empty()) {
366 vec![
367 "action.full-auto.pause",
368 "action.full-auto.resume",
369 "action.full-auto.stop",
370 ]
371 } else {
372 Vec::new()
373 };
374
375 let owner = match identity.owner_grant_ref {
376 Some(grant_ref) => Issue31RoleInput::Active {
377 kind: workroom_receipts::Issue31RoleKind::Owner,
378 grant_ref,
379 },
380 None => Issue31RoleInput::Unknown {
381 kind: workroom_receipts::Issue31RoleKind::Owner,
382 },
383 };
384 // An unknown role carries no permitted actions at all — that is enforced at
385 // decode, and honouring it here keeps the emitter from building the
386 // violation and then discovering it.
387 let granted = identity.owner_grant_ref.is_some();
388 let empty: &[&str] = &[];
389
390 let (host_snapshot, host_document) = build_issue31_host_adjunct_document(
391 sources.host_ref,
392 sources.snapshot_ref,
393 sources.generated_at_ms,
394 &Issue31HostSources {
395 connection_identity: Issue31HostProjectionInput::Observed {
396 source_ref: identity.source_ref,
397 observed_at_ms: identity.observed_at_ms,
398 freshness: ProjectionFreshness::Current,
399 gap: if identity.record_refs.is_empty() {
400 Issue31ObservedGap::Partial
401 } else {
402 Issue31ObservedGap::Complete
403 },
404 role: owner,
405 record_refs: identity.record_refs,
406 permitted_action_refs: if granted {
407 identity.permitted_action_refs
408 } else {
409 empty
410 },
411 command_state: Issue31CommandStateInput::Idle,
412 },
413 full_auto_runs: Issue31HostProjectionInput::Observed {
414 source_ref: "source.omega.full-auto-registry",
415 observed_at_ms: sources.generated_at_ms,
416 freshness: ProjectionFreshness::Current,
417 gap: run_gap,
418 role: owner,
419 record_refs: &run_refs,
420 permitted_action_refs: if granted { &run_actions } else { empty },
421 command_state: Issue31CommandStateInput::Idle,
422 },
423 provider_accounts: Issue31HostProjectionInput::Observed {
424 source_ref: "source.omega.provider-roster",
425 observed_at_ms: sources.generated_at_ms,
426 freshness: ProjectionFreshness::Current,
427 gap: account_gap,
428 role: owner,
429 record_refs: &account_refs,
430 // Provider login is host-owned. The phone may only ASK for a
431 // handoff; it can never carry the login itself.
432 permitted_action_refs: if granted {
433 &["action.provider.request-connect-handoff"]
434 } else {
435 empty
436 },
437 command_state: Issue31CommandStateInput::Idle,
438 },
439 evidence_chain: Issue31HostProjectionInput::Observed {
440 source_ref: "source.omega.evidence-inspector",
441 observed_at_ms: sources.generated_at_ms,
442 freshness: ProjectionFreshness::Current,
443 gap: evidence_gap,
444 role: owner,
445 record_refs: &evidence_refs,
446 // Evidence is read, never commanded from the phone.
447 permitted_action_refs: empty,
448 command_state: Issue31CommandStateInput::Idle,
449 },
450 },
451 )
452 .map_err(Issue31HostProjectionError::Host)?;
453
454 Ok(Issue31HostPublication {
455 host: host_snapshot,
456 detail,
457 host_document,
458 detail_document,
459 })
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use workroom_receipts::{
466 ISSUE31_EVIDENCE_HOPS, Issue31EvidenceChain, Issue31EvidenceUnavailableReason,
467 Issue31FullAutoLifecycle, PublicRef,
468 };
469
470 fn unavailable_reason_of(adjunct: &Issue31FullAutoAdjunct) -> Issue31EvidenceUnavailableReason {
471 match &adjunct.evidence[0] {
472 Issue31EvidenceChain::Unavailable { reason, .. } => *reason,
473 Issue31EvidenceChain::Complete { .. } => {
474 panic!("expected the chain to be refused")
475 }
476 }
477 }
478
479 const NOW: u64 = 1_784_894_400_000;
480 const HOST_GENERATION: u64 = 19;
481
482 fn run_detail() -> Value {
483 json!({
484 "runRef": "run.full-auto.run-01",
485 "title": "Mobile workroom",
486 "objective": "Finish the issue 31 mobile workroom.",
487 "doneCondition": "Every exit holds with evidence.",
488 "lane": "codex-local",
489 "state": "running",
490 "startedAtMs": NOW - 5_400_000,
491 "liveWorkRef": "work.run-01.unit-14"
492 })
493 }
494
495 fn capacity() -> Value {
496 json!({
497 "lanes": [{"lane": "codex-local", "state": "available", "activeRuns": 1}],
498 "accounts": [
499 {"accountRef":"account.codex.1","provider":"openai","label":"ChatGPT Personal","state":"busy","quotaState":"available","lane":"codex-local"},
500 {"accountRef":"account.claude.1","provider":"anthropic","label":"Claude","state":"ready","quotaState":"available","lane":"claude-local"}
501 ]
502 })
503 }
504
505 fn project(
506 run_details: &[Value],
507 capacity: &Value,
508 handoffs: &[Value],
509 evidence: &[(Value, Value)],
510 ) -> Result<Issue31FullAutoAdjunct, Issue31FullAutoProjectionError> {
511 project_issue31_full_auto_adjunct(&Issue31FullAutoLiveSources {
512 host_ref: "host.omega.device-alpha",
513 snapshot_ref: "snapshot.omega.issue31.000042",
514 generated_at_ms: NOW,
515 host_generation: HOST_GENERATION,
516 run_details,
517 capacity,
518 handoffs,
519 evidence,
520 })
521 }
522
523 #[test]
524 fn projects_a_live_run_with_its_exact_host_measured_duration() {
525 let adjunct = project(&[run_detail()], &capacity(), &[], &[]).expect("projects");
526 assert_eq!(adjunct.runs.len(), 1);
527 let run = &adjunct.runs[0];
528 assert_eq!(run.lifecycle, Issue31FullAutoLifecycle::Running);
529 assert_eq!(run.unattended_ms, 5_400_000);
530 assert_eq!(run.lane_ref.as_str(), "codex-local");
531 }
532
533 #[test]
534 fn binds_every_control_to_the_live_host_generation() {
535 let adjunct = project(&[run_detail()], &capacity(), &[], &[]).expect("projects");
536 let run = &adjunct.runs[0];
537 assert_eq!(run.controls.len(), 2);
538 for control in &run.controls {
539 assert_eq!(control.run_generation, HOST_GENERATION);
540 assert_eq!(control.run_generation, run.generation);
541 assert!(control.idempotency_ref.as_str().contains("run.full-auto"));
542 }
543 }
544
545 #[test]
546 fn a_paused_run_offers_resume_and_a_finished_run_offers_nothing() {
547 let mut paused = run_detail();
548 paused["state"] = json!("paused");
549 let adjunct = project(&[paused], &capacity(), &[], &[]).expect("projects");
550 let kinds: Vec<_> = adjunct.runs[0]
551 .controls
552 .iter()
553 .map(|control| control.kind)
554 .collect();
555 assert_eq!(kinds.len(), 2);
556
557 // The host's own word for a finished run is `completed`; the contract
558 // calls the same state `succeeded`. The live capture is what settled
559 // that -- these inputs are host records, so they use the host's
560 // vocabulary and the adapter translates.
561 let mut finished = run_detail();
562 finished["state"] = json!("completed");
563 finished["terminalReasonRef"] = json!("terminal.full_auto.completed.control_api");
564 finished
565 .as_object_mut()
566 .expect("object")
567 .remove("liveWorkRef");
568 let adjunct = project(&[finished], &capacity(), &[], &[]).expect("projects");
569 assert!(adjunct.runs[0].controls.is_empty());
570 assert!(adjunct.runs[0].terminal_reason_ref.is_some());
571 }
572
573 #[test]
574 fn the_phone_and_the_roster_read_the_same_accounts() {
575 let adjunct = project(&[run_detail()], &capacity(), &[], &[]).expect("projects");
576 let projected: Vec<(&str, &str)> = adjunct
577 .accounts
578 .iter()
579 .map(|account| (account.account_ref.as_str(), account.lane_ref.as_str()))
580 .collect();
581 let roster: Vec<(String, String)> = parse_provider_accounts(&capacity())
582 .into_iter()
583 .map(|row| (row.account_ref, row.lane))
584 .collect();
585 assert_eq!(projected.len(), roster.len());
586 for (index, (account_ref, lane_ref)) in projected.iter().enumerate() {
587 assert_eq!(*account_ref, roster[index].0);
588 assert_eq!(*lane_ref, roster[index].1);
589 }
590 }
591
592 #[test]
593 fn a_run_with_no_host_recorded_start_is_refused_rather_than_shown_as_new() {
594 let mut run = run_detail();
595 run.as_object_mut().expect("object").remove("startedAtMs");
596 assert_eq!(
597 project(&[run], &capacity(), &[], &[]).expect_err("must refuse"),
598 Issue31FullAutoProjectionError::UnattendedDurationUnknown
599 );
600 }
601
602 #[test]
603 fn a_run_with_no_lane_cannot_be_projected_against_provider_accounts() {
604 let mut run = run_detail();
605 run.as_object_mut().expect("object").remove("lane");
606 assert_eq!(
607 project(&[run], &capacity(), &[], &[]).expect_err("must refuse"),
608 Issue31FullAutoProjectionError::IncompleteRunRecord
609 );
610 }
611
612 #[test]
613 fn a_credential_shaped_objective_cannot_leave_the_host() {
614 let mut run = run_detail();
615 run["objective"] = json!("Rotate the key in ~/.codex/auth.json");
616 let error = project(&[run], &capacity(), &[], &[]).expect_err("must refuse");
617 assert_eq!(
618 error,
619 Issue31FullAutoProjectionError::Contract(Issue31FullAutoAdjunctError::UnsafeText)
620 );
621 let rendered = error.to_string();
622 assert!(!rendered.contains("auth.json"));
623 }
624
625 #[test]
626 fn a_phone_initiated_handoff_reports_only_its_host_owned_outcome() {
627 let handoffs = [json!({
628 "handoffRef": "handoff.codex.2",
629 "provider": "openai",
630 "state": "refused",
631 "requestedAtMs": NOW - 120_000,
632 "reasonClass": "reason.handoff.owner_declined",
633 "outcomeRef": "outcome.handoff.declined",
634 // Host-side truth that must never cross to the phone.
635 "isolatedHome": "/Users/owner/.pylon/accounts/codex/codex-2",
636 "authorizationResponse": "Bearer sk-live-0000"
637 })];
638 let adjunct = project(&[run_detail()], &capacity(), &handoffs, &[]).expect("projects");
639 assert_eq!(adjunct.handoffs.len(), 1);
640 let handoff = &adjunct.handoffs[0];
641 assert_eq!(
642 handoff.outcome_ref.as_ref().map(|value| value.as_str()),
643 Some("outcome.handoff.declined")
644 );
645 // A refused handoff stays unattributed rather than appearing against a
646 // working account of the same provider.
647 assert!(handoff.account_ref.is_none());
648 }
649
650 // -----------------------------------------------------------------
651 // The `host.v1` snapshot the detail projection is published beside.
652 // -----------------------------------------------------------------
653
654 fn identity() -> Issue31HostIdentitySource<'static> {
655 Issue31HostIdentitySource {
656 source_ref: "source.omega.identity-binding",
657 observed_at_ms: NOW - 1_000,
658 owner_grant_ref: Some("grant.omega.mobile.owner-01"),
659 record_refs: &[
660 "record.omega.host-announcement.01",
661 "record.omega.owner-binding.01",
662 ],
663 permitted_action_refs: &[
664 "action.omega.device.pair",
665 "action.omega.device.renew",
666 "action.omega.device.revoke",
667 ],
668 }
669 }
670
671 fn publish(
672 run_details: &[Value],
673 capacity: &Value,
674 identity: &Issue31HostIdentitySource<'_>,
675 ) -> Result<Issue31HostPublication, Issue31HostProjectionError> {
676 publish_issue31_host_snapshot(
677 &Issue31FullAutoLiveSources {
678 host_ref: "host.omega.device-alpha",
679 snapshot_ref: "snapshot.omega.issue31.000042",
680 generated_at_ms: NOW,
681 host_generation: HOST_GENERATION,
682 run_details,
683 capacity,
684 handoffs: &[],
685 evidence: &[],
686 },
687 identity,
688 )
689 }
690
691 #[test]
692 fn the_snapshot_and_the_detail_beside_it_cannot_describe_different_hosts() {
693 let published = publish(&[run_detail()], &capacity(), &identity()).expect("publishes");
694 assert_eq!(
695 published.host.host_ref.as_str(),
696 published.detail.host_ref.as_str()
697 );
698 assert_eq!(
699 published.host.snapshot_ref.as_str(),
700 published.detail.snapshot_ref.as_str()
701 );
702 assert_eq!(
703 published.host.generated_at_ms,
704 published.detail.generated_at_ms
705 );
706 }
707
708 #[test]
709 fn the_snapshot_cites_exactly_the_runs_and_accounts_the_detail_carries() {
710 let published = publish(&[run_detail()], &capacity(), &identity()).expect("publishes");
711 let runs = published
712 .host
713 .projections
714 .iter()
715 .find(|projection| {
716 projection.capability == workroom_receipts::Issue31ProjectionCapability::FullAutoRuns
717 })
718 .expect("full auto runs projection");
719 assert_eq!(runs.record_refs.len(), published.detail.runs.len());
720 assert!(
721 runs.record_refs[0]
722 .as_str()
723 .ends_with(published.detail.runs[0].run_ref.as_str())
724 );
725
726 let accounts = published
727 .host
728 .projections
729 .iter()
730 .find(|projection| {
731 projection.capability
732 == workroom_receipts::Issue31ProjectionCapability::ProviderAccounts
733 })
734 .expect("provider accounts projection");
735 assert_eq!(accounts.record_refs.len(), published.detail.accounts.len());
736 }
737
738 /// A snapshot must never advertise Full Auto records the host would then
739 /// refuse to detail. Publishing neither is the honest outcome.
740 #[test]
741 fn a_run_the_detail_would_refuse_blocks_the_whole_publication() {
742 let mut run = run_detail();
743 run.as_object_mut().expect("object").remove("startedAtMs");
744 let error = publish(&[run], &capacity(), &identity()).expect_err("must refuse");
745 assert_eq!(
746 error,
747 Issue31HostProjectionError::Detail(
748 Issue31FullAutoProjectionError::UnattendedDurationUnknown
749 )
750 );
751 }
752
753 /// An ungranted reader is not a reader with fewer buttons; it is a reader
754 /// whose role this host cannot state, and it gets none.
755 #[test]
756 fn a_host_that_cannot_state_the_readers_role_offers_no_actions_at_all() {
757 let mut ungranted = identity();
758 ungranted.owner_grant_ref = None;
759 let published = publish(&[run_detail()], &capacity(), &ungranted).expect("publishes");
760 for projection in &published.host.projections {
761 assert!(
762 projection.permitted_action_refs.is_empty(),
763 "{:?} offered an action without a grant",
764 projection.capability
765 );
766 assert_eq!(
767 projection.command_state,
768 workroom_receipts::Issue31CommandState::Idle
769 );
770 }
771 }
772
773 /// A finished run has no controls, so the snapshot must not advertise a
774 /// Full Auto action the host would refuse to honour.
775 #[test]
776 fn a_snapshot_of_only_finished_runs_advertises_no_run_controls() {
777 // The host's own word for a finished run is `completed`; the contract
778 // calls the same state `succeeded`. The live capture is what settled
779 // that -- these inputs are host records, so they use the host's
780 // vocabulary and the adapter translates.
781 let mut finished = run_detail();
782 finished["state"] = json!("completed");
783 finished["terminalReasonRef"] = json!("terminal.full_auto.completed.control_api");
784 finished
785 .as_object_mut()
786 .expect("object")
787 .remove("liveWorkRef");
788 let published = publish(&[finished], &capacity(), &identity()).expect("publishes");
789 let runs = published
790 .host
791 .projections
792 .iter()
793 .find(|projection| {
794 projection.capability == workroom_receipts::Issue31ProjectionCapability::FullAutoRuns
795 })
796 .expect("full auto runs projection");
797 assert!(runs.permitted_action_refs.is_empty());
798 assert!(published.detail.runs[0].controls.is_empty());
799 }
800
801 /// More records than one snapshot can cite is `partial` — "this is not all
802 /// of them" — not a refusal that would hide every record.
803 #[test]
804 fn more_accounts_than_the_reference_bound_is_partial_rather_than_a_refusal() {
805 let accounts: Vec<Value> = (0..MAX_ISSUE31_PROJECTION_REFS + 4)
806 .map(|index| {
807 json!({
808 "accountRef": format!("account.codex.{index}"),
809 "provider": "openai",
810 "label": format!("ChatGPT {index}"),
811 "state": "ready",
812 "quotaState": "available",
813 "lane": "codex-local"
814 })
815 })
816 .collect();
817 let capacity = json!({
818 "lanes": [{"lane": "codex-local", "state": "available", "activeRuns": 1}],
819 "accounts": accounts
820 });
821 let published = publish(&[run_detail()], &capacity, &identity()).expect("publishes");
822 let projection = published
823 .host
824 .projections
825 .iter()
826 .find(|projection| {
827 projection.capability
828 == workroom_receipts::Issue31ProjectionCapability::ProviderAccounts
829 })
830 .expect("provider accounts projection");
831 assert_eq!(
832 projection.record_refs.len(),
833 MAX_ISSUE31_PROJECTION_REFS,
834 "cites as many as the contract admits"
835 );
836 assert_eq!(projection.gap, workroom_receipts::Issue31Gap::Partial);
837 }
838
839 /// The provider boundary reaches the snapshot too.
840 #[test]
841 fn a_private_identity_record_cannot_be_published_and_is_not_echoed() {
842 let mut leaking = identity();
843 leaking.record_refs = &["/Users/owner/.codex/auth.json"];
844 let error = publish(&[run_detail()], &capacity(), &leaking).expect_err("must refuse");
845 assert_eq!(
846 error,
847 Issue31HostProjectionError::Host(Issue31HostAdjunctError::UnsafeReference)
848 );
849 assert!(!error.to_string().contains("/Users/"));
850 assert!(!error.to_string().contains("auth.json"));
851 }
852
853 // -----------------------------------------------------------------
854 // The live-engine walk.
855 //
856 // These four fixtures are not hand-written. They are the EXACT bytes a
857 // running `omega-effectd` returned for `get_run`, `get_capacity`,
858 // `get_report`, and `get_receipt` after a real `start`, captured on
859 // 2026-07-25 against the engine at `omega-effectd-v0.1.0-rc.8` plus the
860 // host-recorded numeric run start (openagents `startedAtMs`). Projecting
861 // them here is what stops this adapter from agreeing only with fixtures
862 // that were written to make it agree.
863 // -----------------------------------------------------------------
864
865 const LIVE_RUN: &str = include_str!("../fixtures/live-omega-effectd.get_run.json");
866 const LIVE_CAPACITY: &str = include_str!("../fixtures/live-omega-effectd.get_capacity.json");
867 const LIVE_REPORT: &str = include_str!("../fixtures/live-omega-effectd.get_report.json");
868 const LIVE_RECEIPT: &str = include_str!("../fixtures/live-omega-effectd.get_receipt.json");
869 /// The host's own start in the captured `get_run`.
870 const LIVE_STARTED_AT_MS: u64 = 1_785_001_886_429;
871
872 fn live(name: &str, raw: &str) -> Value {
873 serde_json::from_str(raw).unwrap_or_else(|error| panic!("live {name} parses: {error}"))
874 }
875
876 /// The gap this issue stayed open on. A live host now records a numeric
877 /// run start, so its exact unattended duration is a measurement rather
878 /// than a refusal.
879 #[test]
880 fn a_live_host_run_projects_its_exact_unattended_duration() {
881 let run = live("get_run", LIVE_RUN);
882 assert_eq!(
883 run.get("startedAtMs").and_then(Value::as_u64),
884 Some(LIVE_STARTED_AT_MS),
885 "the live engine records a numeric run start"
886 );
887
888 let generated_at_ms = LIVE_STARTED_AT_MS + 5_400_000;
889 let adjunct = project_issue31_full_auto_adjunct(&Issue31FullAutoLiveSources {
890 host_ref: "host.omega.device-alpha",
891 snapshot_ref: "snapshot.omega.issue31.live-walk",
892 generated_at_ms,
893 host_generation: HOST_GENERATION,
894 run_details: &[run],
895 capacity: &live("get_capacity", LIVE_CAPACITY),
896 handoffs: &[],
897 evidence: &[],
898 })
899 .expect("a live host run projects");
900
901 assert_eq!(adjunct.runs.len(), 1);
902 assert_eq!(adjunct.runs[0].unattended_ms, 5_400_000);
903 assert_eq!(adjunct.runs[0].lifecycle, Issue31FullAutoLifecycle::Running);
904 assert_eq!(adjunct.runs[0].lane_ref.as_str(), "codex-local");
905 // The panel's roster and the phone read the same live capacity record.
906 assert_eq!(
907 adjunct.accounts.len(),
908 parse_provider_accounts(&live("get_capacity", LIVE_CAPACITY)).len()
909 );
910 }
911
912 /// The formatted `updatedAt` a live run carries is still exactly that, and
913 /// the duration above owes nothing to it. If a future change ever derived
914 /// the unattended duration from this string, this test is where it shows.
915 #[test]
916 fn the_live_duration_owes_nothing_to_the_formatted_timestamp() {
917 let mut run = live("get_run", LIVE_RUN);
918 assert!(run.get("updatedAt").and_then(Value::as_str).is_some());
919 run["updatedAt"] = json!("not a timestamp at all");
920 let generated_at_ms = LIVE_STARTED_AT_MS + 90_000;
921 let adjunct = project_issue31_full_auto_adjunct(&Issue31FullAutoLiveSources {
922 host_ref: "host.omega.device-alpha",
923 snapshot_ref: "snapshot.omega.issue31.live-walk",
924 generated_at_ms,
925 host_generation: HOST_GENERATION,
926 run_details: &[run],
927 capacity: &live("get_capacity", LIVE_CAPACITY),
928 handoffs: &[],
929 evidence: &[],
930 })
931 .expect("projects");
932 assert_eq!(adjunct.runs[0].unattended_ms, 90_000);
933 }
934
935 /// A live run that has NOT finished still has no chain to show.
936 ///
937 /// The earlier capture is a run in flight: the host has not verified any
938 /// done condition, so the report carries no `evidence` block and the receipt
939 /// carries no `decisionRef` or `authorityReceiptRef`. The contract's answer
940 /// is `unavailable`, and that is the correct one — a run that has not
941 /// finished has produced no finished unit to prove. The run beside it still
942 /// renders: one unavailable chain never hides the work the owner is
943 /// entitled to see.
944 #[test]
945 fn a_live_unfinished_run_projects_no_authority_chain() {
946 let report = live("get_report", LIVE_REPORT);
947 let receipt = live("get_receipt", LIVE_RECEIPT);
948 assert_eq!(report.get("state").and_then(Value::as_str), Some("running"));
949 assert!(report.get("evidence").is_none());
950 assert!(receipt.get("authorityReceiptRef").is_none());
951 assert!(receipt.get("decisionRef").is_none());
952
953 let adjunct = project_issue31_full_auto_adjunct(&Issue31FullAutoLiveSources {
954 host_ref: "host.omega.device-alpha",
955 snapshot_ref: "snapshot.omega.issue31.live-walk",
956 generated_at_ms: LIVE_STARTED_AT_MS + 60_000,
957 host_generation: HOST_GENERATION,
958 run_details: &[live("get_run", LIVE_RUN)],
959 capacity: &live("get_capacity", LIVE_CAPACITY),
960 handoffs: &[],
961 evidence: &[(report, receipt)],
962 })
963 .expect("the run still projects beside an unavailable chain");
964
965 match &adjunct.evidence[0] {
966 Issue31EvidenceChain::Unavailable { reason, .. } => {
967 assert_eq!(*reason, Issue31EvidenceUnavailableReason::HopMissing);
968 }
969 Issue31EvidenceChain::Complete { .. } => {
970 panic!("an unfinished run has no finished unit to prove")
971 }
972 }
973 assert_eq!(adjunct.runs.len(), 1);
974 }
975
976 // -----------------------------------------------------------------
977 // The finished-unit walk.
978 //
979 // These four fixtures are the EXACT bytes a running `omega-effectd`
980 // returned for one COMPLETED Full Auto run, captured on 2026-07-25 against
981 // the engine that stamps the omega#43 chain. The run was started through
982 // the framed protocol with autonomy on, a provider turn edited and
983 // committed real files in a real Git worktree and self-reported
984 // FULL-AUTO-COMPLETE, and the host then ran the run's own `verify:` command
985 // as a child process, read its exit code, and admitted the completion.
986 //
987 // The start request DELIBERATELY carried a forged `evidence` block, a
988 // forged `decisionRef`, and a forged `authorityReceiptRef`. Nothing the
989 // client sent appears anywhere in what the host published; every assertion
990 // below reads a value the host measured for itself.
991 // -----------------------------------------------------------------
992
993 const DONE_RUN: &str = include_str!("../fixtures/live-omega-effectd.completed.get_run.json");
994 const DONE_CAPACITY: &str =
995 include_str!("../fixtures/live-omega-effectd.completed.get_capacity.json");
996 const DONE_REPORT: &str =
997 include_str!("../fixtures/live-omega-effectd.completed.get_report.json");
998 const DONE_RECEIPT: &str =
999 include_str!("../fixtures/live-omega-effectd.completed.get_receipt.json");
1000 const DONE_STARTED_AT_MS: u64 = 1_785_006_309_447;
1001
1002 fn finished_sources(
1003 generated_at_ms: u64,
1004 evidence: &[(Value, Value)],
1005 ) -> Result<Issue31FullAutoAdjunct, Issue31FullAutoProjectionError> {
1006 project_issue31_full_auto_adjunct(&Issue31FullAutoLiveSources {
1007 host_ref: "host.omega.device-alpha",
1008 snapshot_ref: "snapshot.omega.issue31.finished-unit",
1009 generated_at_ms,
1010 host_generation: HOST_GENERATION,
1011 run_details: &[live("get_run", DONE_RUN)],
1012 capacity: &live("get_capacity", DONE_CAPACITY),
1013 handoffs: &[],
1014 evidence,
1015 })
1016 }
1017
1018 /// omega#47's exit line, against a live host: a viewer follows ONE finished
1019 /// unit from objective through authority receipt.
1020 #[test]
1021 fn a_live_finished_unit_walks_from_objective_through_authority_receipt() {
1022 let report = live("get_report", DONE_REPORT);
1023 let receipt = live("get_receipt", DONE_RECEIPT);
1024 let adjunct = finished_sources(DONE_STARTED_AT_MS + 120_000, &[(report, receipt)])
1025 .expect("a finished live run projects");
1026
1027 let hops = match &adjunct.evidence[0] {
1028 Issue31EvidenceChain::Complete {
1029 hops,
1030 authority_allowed,
1031 ..
1032 } => {
1033 assert!(*authority_allowed, "the host's authority admitted this run");
1034 hops
1035 }
1036 Issue31EvidenceChain::Unavailable { reason, .. } => {
1037 panic!("a live finished unit must walk end to end (reason: {reason:?})")
1038 }
1039 };
1040 assert_eq!(
1041 hops.iter().map(|hop| hop.kind).collect::<Vec<_>>(),
1042 ISSUE31_EVIDENCE_HOPS,
1043 "the walk is the contract's ordered chain, objective first and receipt last"
1044 );
1045 // The run beside the chain is the finished one, with no control the
1046 // host would refuse.
1047 assert_eq!(
1048 adjunct.runs[0].lifecycle,
1049 Issue31FullAutoLifecycle::Succeeded
1050 );
1051 assert!(adjunct.runs[0].controls.is_empty());
1052 assert!(adjunct.runs[0].terminal_reason_ref.is_some());
1053 }
1054
1055 /// Every hop is something the host MEASURED, not something the run said.
1056 ///
1057 /// The client that started this run sent a complete forged chain. If any
1058 /// forged value had reached the projection, it would appear here.
1059 #[test]
1060 fn every_live_hop_is_a_host_measurement_rather_than_a_client_claim() {
1061 let report = live("get_report", DONE_REPORT);
1062 let receipt = live("get_receipt", DONE_RECEIPT);
1063 let evidence = report
1064 .get("evidence")
1065 .expect("the live report carries hops");
1066
1067 // The objective hop is the digest of the objective the host itself
1068 // holds: it equals the report's own `objectiveDigest`, so a reader can
1069 // check the chain names the mission the report is about.
1070 assert_eq!(
1071 evidence.get("objectiveRef").and_then(Value::as_str),
1072 Some(
1073 format!(
1074 "objective.{}",
1075 report
1076 .get("objectiveDigest")
1077 .and_then(Value::as_str)
1078 .expect("digest")
1079 )
1080 .as_str()
1081 )
1082 );
1083 // The turn hop is a real row in the host's own journal.
1084 let turn_ref = evidence
1085 .get("turnRef")
1086 .and_then(Value::as_str)
1087 .expect("turn");
1088 assert!(
1089 live("get_run", DONE_RUN)
1090 .get("turns")
1091 .and_then(Value::as_array)
1092 .expect("turns")
1093 .iter()
1094 .any(|turn| turn.get("turnRef").and_then(Value::as_str) == Some(turn_ref)),
1095 "the verified turn is one the host recorded"
1096 );
1097 // The change and generation hops are one Git reading of the bound
1098 // worktree, and the diff counts name the baseline they were measured
1099 // against.
1100 let change = evidence
1101 .get("changeRef")
1102 .and_then(Value::as_str)
1103 .expect("change");
1104 assert!(change.starts_with("change."));
1105 assert_eq!(change.len(), "change.".len() + 40);
1106 assert!(
1107 evidence
1108 .get("projectGeneration")
1109 .and_then(Value::as_str)
1110 .is_some_and(|value| value.starts_with("generation.project."))
1111 );
1112 assert!(
1113 evidence
1114 .get("diffSummary")
1115 .and_then(Value::as_str)
1116 .is_some_and(|value| value.contains("files changed")),
1117 "the change hop carries the host's own shortstat"
1118 );
1119 // The verification hop is the host's own executed command.
1120 assert_eq!(
1121 evidence.get("hostExecuted").and_then(Value::as_bool),
1122 Some(true)
1123 );
1124 assert_eq!(
1125 evidence.get("testOutcome").and_then(Value::as_str),
1126 Some("outcome.test.passed")
1127 );
1128
1129 // Nothing the client sent survived anywhere.
1130 for record in [&report, &receipt] {
1131 let serialized = record.to_string();
1132 for forged in [
1133 "objective.client.forged",
1134 "turn.client.forged",
1135 "change.client.forged",
1136 "verification.client.forged",
1137 "decision.client.forged",
1138 "receipt.client.forged",
1139 "generation.project.99999",
1140 "999 files changed",
1141 ] {
1142 assert!(
1143 !serialized.contains(forged),
1144 "a client-supplied {forged} reached a host record"
1145 );
1146 }
1147 }
1148 }
1149
1150 /// The report and the receipt tell ONE story, because the host projects
1151 /// both from one stored record.
1152 #[test]
1153 fn the_live_report_and_receipt_agree_on_every_shared_hop() {
1154 let report = live("get_report", DONE_REPORT);
1155 let receipt = live("get_receipt", DONE_RECEIPT);
1156 let evidence = report.get("evidence").expect("hops");
1157 for field in ["objectiveRef", "turnRef", "changeRef", "verificationRef"] {
1158 assert_eq!(
1159 evidence.get(field),
1160 receipt.get(field),
1161 "{field} must not tell two stories about one run"
1162 );
1163 }
1164 assert!(receipt.get("decisionRef").is_some());
1165 assert!(receipt.get("authorityReceiptRef").is_some());
1166 assert_eq!(
1167 receipt.get("authorityRef").and_then(Value::as_str),
1168 Some("authority.omega.host.full_auto_completion"),
1169 "the receipt names WHICH authority allowed the completion"
1170 );
1171 }
1172
1173 /// The same live records drive the desktop panel's inspector, so the phone
1174 /// and Omega cannot show two different chains for one run.
1175 #[test]
1176 fn the_live_records_also_render_in_the_desktop_inspector() {
1177 let view = crate::evidence_chain::FullAutoEvidenceView::from_records(
1178 &live("get_report", DONE_REPORT),
1179 &live("get_receipt", DONE_RECEIPT),
1180 )
1181 .expect("the panel renders the same live chain");
1182 let labels: Vec<_> = view.fields.iter().map(|field| field.label).collect();
1183 assert!(labels.contains(&"objective_ref"));
1184 assert!(labels.contains(&"authority_receipt_ref"));
1185 assert!(labels.contains(&"decision_ref"));
1186 }
1187
1188 /// Falsification: break each hop of the live chain in turn and watch the
1189 /// projection refuse. A refusal nobody watched is not proven.
1190 #[test]
1191 fn breaking_any_live_hop_refuses_the_chain() {
1192 let generated = DONE_STARTED_AT_MS + 120_000;
1193
1194 // A self-reported verification.
1195 let mut report = live("get_report", DONE_REPORT);
1196 report["evidence"]["hostExecuted"] = json!(false);
1197 assert_eq!(
1198 unavailable_reason_of(
1199 &finished_sources(generated, &[(report, live("get_receipt", DONE_RECEIPT))])
1200 .expect("projects")
1201 ),
1202 Issue31EvidenceUnavailableReason::SelfReported
1203 );
1204
1205 // A receipt that disagrees with the report about the change.
1206 let mut receipt = live("get_receipt", DONE_RECEIPT);
1207 receipt["changeRef"] = json!("change.0000000000000000000000000000000000000000");
1208 assert_eq!(
1209 unavailable_reason_of(
1210 &finished_sources(generated, &[(live("get_report", DONE_REPORT), receipt)])
1211 .expect("projects")
1212 ),
1213 Issue31EvidenceUnavailableReason::HopMismatched
1214 );
1215
1216 // A private path smuggled into the test command.
1217 let mut report = live("get_report", DONE_REPORT);
1218 report["evidence"]["testCommand"] = json!("cat /Users/owner/.codex/auth.json");
1219 assert_eq!(
1220 unavailable_reason_of(
1221 &finished_sources(generated, &[(report, live("get_receipt", DONE_RECEIPT))])
1222 .expect("projects")
1223 ),
1224 Issue31EvidenceUnavailableReason::HopPrivate
1225 );
1226
1227 // Each hop of the chain, removed one at a time.
1228 for hop in [
1229 "objectiveRef",
1230 "turnRef",
1231 "changeRef",
1232 "projectGeneration",
1233 "verificationRef",
1234 "testOutcome",
1235 ] {
1236 let mut report = live("get_report", DONE_REPORT);
1237 report["evidence"]
1238 .as_object_mut()
1239 .expect("evidence object")
1240 .remove(hop);
1241 let adjunct =
1242 finished_sources(generated, &[(report, live("get_receipt", DONE_RECEIPT))])
1243 .expect("projects");
1244 assert!(
1245 matches!(
1246 adjunct.evidence[0],
1247 Issue31EvidenceChain::Unavailable { .. }
1248 ),
1249 "removing {hop} must break the chain"
1250 );
1251 }
1252 // And the two hops only the receipt carries.
1253 for hop in ["decisionRef", "authorityReceiptRef", "allowed"] {
1254 let mut receipt = live("get_receipt", DONE_RECEIPT);
1255 receipt.as_object_mut().expect("receipt object").remove(hop);
1256 let adjunct =
1257 finished_sources(generated, &[(live("get_report", DONE_REPORT), receipt)])
1258 .expect("projects");
1259 assert!(
1260 matches!(
1261 adjunct.evidence[0],
1262 Issue31EvidenceChain::Unavailable { .. }
1263 ),
1264 "removing {hop} must break the chain"
1265 );
1266 }
1267 }
1268
1269 /// Falsification: the two hop DETAILS -- the diff counts and the exact
1270 /// command -- are optional to the phone contract but required by the
1271 /// desktop inspector, which shows them as their own fields. Removing either
1272 /// therefore leaves the phone chain complete and refuses the panel view,
1273 /// and this pins that difference rather than leaving it to be discovered.
1274 #[test]
1275 fn removing_a_hop_detail_refuses_the_panel_view() {
1276 for detail in ["testCommand", "diffSummary"] {
1277 let mut report = live("get_report", DONE_REPORT);
1278 report["evidence"]
1279 .as_object_mut()
1280 .expect("evidence object")
1281 .remove(detail);
1282 assert!(
1283 crate::evidence_chain::FullAutoEvidenceView::from_records(
1284 &report,
1285 &live("get_receipt", DONE_RECEIPT)
1286 )
1287 .is_none(),
1288 "the inspector must refuse a chain missing {detail}"
1289 );
1290 }
1291 }
1292
1293 /// The typed terminal reason is the host's, and the free-text explanation
1294 /// beside it is never read as one.
1295 ///
1296 /// A live finished run states `terminal.full_auto.completed.control_api`,
1297 /// built from its own typed state and the actor that ended it. Strip that
1298 /// and the projection reports `reason.full-auto.unrecorded` -- an honest
1299 /// gap -- rather than mining the sentence "host verified the done
1300 /// condition" for a classification nobody recorded.
1301 #[test]
1302 fn the_terminal_reason_is_typed_and_never_read_out_of_the_prose() {
1303 let adjunct = finished_sources(DONE_STARTED_AT_MS + 120_000, &[]).expect("projects");
1304 assert_eq!(
1305 adjunct.runs[0]
1306 .terminal_reason_ref
1307 .as_ref()
1308 .map(PublicRef::as_str),
1309 Some("terminal.full_auto.completed.control_api")
1310 );
1311
1312 let mut run = live("get_run", DONE_RUN);
1313 assert!(
1314 run.get("terminalReason")
1315 .and_then(Value::as_str)
1316 .is_some_and(|reason| reason.contains("verified")),
1317 "the free-text explanation is still there to be misread"
1318 );
1319 run["terminalReasonRef"] = json!(null);
1320 let adjunct = project_issue31_full_auto_adjunct(&Issue31FullAutoLiveSources {
1321 host_ref: "host.omega.device-alpha",
1322 snapshot_ref: "snapshot.omega.issue31.finished-unit",
1323 generated_at_ms: DONE_STARTED_AT_MS + 120_000,
1324 host_generation: HOST_GENERATION,
1325 run_details: &[run],
1326 capacity: &live("get_capacity", DONE_CAPACITY),
1327 handoffs: &[],
1328 evidence: &[],
1329 })
1330 .expect("projects with an explicit gap");
1331 assert_eq!(
1332 adjunct.runs[0]
1333 .terminal_reason_ref
1334 .as_ref()
1335 .map(PublicRef::as_str),
1336 Some("reason.full-auto.unrecorded")
1337 );
1338 }
1339
1340 /// Falsification: a lifecycle state this contract does not model is a
1341 /// refusal, never a guess at the nearest one.
1342 #[test]
1343 fn an_unmodelled_lifecycle_state_is_refused() {
1344 let mut run = live("get_run", DONE_RUN);
1345 run["state"] = json!("hibernating");
1346 let error = project_issue31_full_auto_adjunct(&Issue31FullAutoLiveSources {
1347 host_ref: "host.omega.device-alpha",
1348 snapshot_ref: "snapshot.omega.issue31.finished-unit",
1349 generated_at_ms: DONE_STARTED_AT_MS + 120_000,
1350 host_generation: HOST_GENERATION,
1351 run_details: &[run],
1352 capacity: &live("get_capacity", DONE_CAPACITY),
1353 handoffs: &[],
1354 evidence: &[],
1355 })
1356 .expect_err("must refuse");
1357 assert_eq!(error, Issue31FullAutoProjectionError::UnknownLifecycle);
1358 }
1359
1360 #[test]
1361 fn a_self_reported_run_projects_as_unavailable_beside_a_live_run() {
1362 let report = json!({
1363 "runRef": "run.full-auto.run-01",
1364 "evidence": {
1365 "objectiveRef": "objective.run-01",
1366 "turnRef": "turn.run-01.11",
1367 "changeRef": "change.run-01.11",
1368 "projectGeneration": "generation.project.00219",
1369 "verificationRef": "verification.run-01.11",
1370 "testOutcome": "outcome.test.passed",
1371 "testCommand": "cargo test -p workroom_receipts",
1372 "diffSummary": "3 files changed",
1373 "hostExecuted": false
1374 }
1375 });
1376 let receipt = json!({
1377 "runRef": "run.full-auto.run-01",
1378 "objectiveRef": "objective.run-01",
1379 "turnRef": "turn.run-01.11",
1380 "changeRef": "change.run-01.11",
1381 "verificationRef": "verification.run-01.11",
1382 "decisionRef": "decision.run-01.11",
1383 "authorityReceiptRef": "receipt.run-01.11",
1384 "allowed": true
1385 });
1386 let adjunct =
1387 project(&[run_detail()], &capacity(), &[], &[(report, receipt)]).expect("projects");
1388 match &adjunct.evidence[0] {
1389 Issue31EvidenceChain::Unavailable { reason, .. } => {
1390 assert_eq!(*reason, Issue31EvidenceUnavailableReason::SelfReported);
1391 }
1392 Issue31EvidenceChain::Complete { .. } => {
1393 panic!("a run reporting its own success is not verified")
1394 }
1395 }
1396 }
1397}
1398