Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:08:38.846Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

issue31_adjunct.rs

1398 lines · 58.5 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.