Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:02:20.395Z 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_full_auto.rs

1920 lines · 71.1 KB · rust
1//! OMEGA-MOB-31-03 headless Full Auto contract (omega#47).
2//!
3//! `issue31_host` says *which* capabilities this host projects and how fresh
4//! each one is, but its record references are opaque. The content behind them
5//! lives in three unrelated shapes: `full_auto_ui::panel` run rows (omega#41),
6//! `full_auto_ui::provider_roster` (omega#42), and
7//! `full_auto_ui::evidence_chain` (omega#43). None is consumable by the phone.
8//!
9//! This is the one headless contract those three collapse into. It is the
10//! byte-shared peer of `packages/sarah/src/issue31-workroom/full-auto-adjunct.ts`
11//! in the OpenAgents repository, and it enforces the same laws so a projection
12//! the TypeScript reader would refuse cannot be produced here either.
13
14use std::collections::HashSet;
15
16use serde::{Deserialize, Serialize};
17
18use crate::{PublicRef, sanitize_public_ref};
19
20pub const ISSUE31_FULL_AUTO_ADJUNCT_SCHEMA: &str = "openagents.omega.issue31.fullauto.v1";
21pub const MAX_ISSUE31_FULL_AUTO_RUNS: usize = 16;
22pub const MAX_ISSUE31_FULL_AUTO_ACCOUNTS: usize = 32;
23pub const MAX_ISSUE31_FULL_AUTO_HANDOFFS: usize = 16;
24pub const MAX_ISSUE31_FULL_AUTO_CONTROLS: usize = 8;
25pub const MAX_ISSUE31_TIMESTAMP_MS: u64 = 8_640_000_000_000_000;
26/// One year. An unattended duration longer than this is a projection defect.
27pub const MAX_ISSUE31_UNATTENDED_MS: u64 = 31_536_000_000;
28
29const MAX_PUBLIC_TEXT: usize = 512;
30const MAX_PUBLIC_LABEL: usize = 96;
31const MAX_PUBLIC_COMMAND: usize = 256;
32
33/// The ordered omega#43 chain. The order is normative: a viewer follows one
34/// finished unit from objective through authority receipt.
35pub const ISSUE31_EVIDENCE_HOPS: [Issue31EvidenceHopKind; 9] = [
36    Issue31EvidenceHopKind::Objective,
37    Issue31EvidenceHopKind::Turn,
38    Issue31EvidenceHopKind::Change,
39    Issue31EvidenceHopKind::ProjectGeneration,
40    Issue31EvidenceHopKind::Test,
41    Issue31EvidenceHopKind::TypedOutcome,
42    Issue31EvidenceHopKind::HostVerification,
43    Issue31EvidenceHopKind::AuthorityDecision,
44    Issue31EvidenceHopKind::Receipt,
45];
46
47const FORBIDDEN_TEXT_FRAGMENTS: [&str; 14] = [
48    "bearer ",
49    "authorization:",
50    "api_key",
51    "apikey",
52    "access_token",
53    "refresh_token",
54    "client_secret",
55    "private_key",
56    "auth.json",
57    "id_rsa",
58    "begin rsa",
59    "begin openssh",
60    "begin private key",
61    "openagents_agent_token",
62];
63
64const FORBIDDEN_TEXT_PREFIXES: [&str; 8] = [
65    "sk-",
66    "sk_",
67    "ghp_",
68    "gho_",
69    "github_pat_",
70    "xox",
71    "nsec1",
72    "ncryptsec1",
73];
74
75const FORBIDDEN_PATH_FRAGMENTS: [&str; 5] =
76    ["/users/", "/home/", "/var/folders/", "/private/tmp/", "~/"];
77
78/// True when bounded owner-facing text carries no credential or private-path
79/// shape. The provider boundary in omega#47 is absolute: the phone never sees a
80/// token, an authorization response, a private path, or raw credential state,
81/// so this rejects rather than redacts.
82pub fn is_issue31_public_text(value: &str, maximum_length: usize) -> bool {
83    if value.is_empty() || value.trim() != value || value.chars().count() > maximum_length {
84        return false;
85    }
86    // Control characters can forge line structure in the owner transcript.
87    if value.chars().any(|character| {
88        character.is_control() && character != '\u{0009}' || character == '\u{007f}'
89    }) {
90        return false;
91    }
92    let lower = value.to_ascii_lowercase();
93    if FORBIDDEN_TEXT_FRAGMENTS
94        .iter()
95        .any(|fragment| lower.contains(fragment))
96    {
97        return false;
98    }
99    if FORBIDDEN_TEXT_PREFIXES
100        .iter()
101        .any(|prefix| lower.starts_with(prefix))
102    {
103        return false;
104    }
105    !FORBIDDEN_PATH_FRAGMENTS
106        .iter()
107        .any(|fragment| lower.contains(fragment))
108}
109
110#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum Issue31FullAutoLifecycle {
113    Queued,
114    Running,
115    Pausing,
116    Paused,
117    Stopping,
118    /// Omega's Full Auto panel distinguishes these from a healthy run.
119    /// Collapsing them into `Running` would show a stalled run as progressing.
120    Retrying,
121    Stalled,
122    Succeeded,
123    Failed,
124    Stopped,
125    Expired,
126}
127
128impl Issue31FullAutoLifecycle {
129    #[must_use]
130    pub fn is_terminal(self) -> bool {
131        matches!(
132            self,
133            Self::Succeeded | Self::Failed | Self::Stopped | Self::Expired
134        )
135    }
136}
137
138#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
139#[serde(rename_all = "snake_case")]
140pub enum Issue31FullAutoControlKind {
141    Pause,
142    Resume,
143    Stop,
144}
145
146#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
147#[serde(rename_all = "snake_case")]
148pub enum Issue31ProviderReadiness {
149    Ready,
150    Busy,
151    Exhausted,
152    RateLimited,
153    Revoked,
154    Unknown,
155}
156
157#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case")]
159pub enum Issue31ProviderQuota {
160    Available,
161    Cooling,
162    Depleted,
163    Unknown,
164}
165
166#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub enum Issue31ProviderHandoffState {
169    Requested,
170    Active,
171    Completed,
172    Refused,
173    Failed,
174    Expired,
175}
176
177impl Issue31ProviderHandoffState {
178    #[must_use]
179    pub fn is_terminal(self) -> bool {
180        matches!(
181            self,
182            Self::Completed | Self::Refused | Self::Failed | Self::Expired
183        )
184    }
185}
186
187#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189pub enum Issue31EvidenceHopKind {
190    Objective,
191    Turn,
192    Change,
193    ProjectGeneration,
194    Test,
195    TypedOutcome,
196    HostVerification,
197    AuthorityDecision,
198    Receipt,
199}
200
201impl Issue31EvidenceHopKind {
202    /// The wire token for this hop.
203    ///
204    /// The same string the contract serialises, exposed so a renderer can
205    /// label a hop without re-deriving a name of its own.
206    /// `hop_tokens_are_the_wire_tokens` pins the two together, so a rename in
207    /// the contract cannot leave a surface labelling hops by an older name.
208    #[must_use]
209    pub const fn token(self) -> &'static str {
210        match self {
211            Self::Objective => "objective",
212            Self::Turn => "turn",
213            Self::Change => "change",
214            Self::ProjectGeneration => "project_generation",
215            Self::Test => "test",
216            Self::TypedOutcome => "typed_outcome",
217            Self::HostVerification => "host_verification",
218            Self::AuthorityDecision => "authority_decision",
219            Self::Receipt => "receipt",
220        }
221    }
222}
223
224#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
225#[serde(rename_all = "snake_case")]
226pub enum Issue31EvidenceUnavailableReason {
227    HopMissing,
228    HopMismatched,
229    HopPrivate,
230    SelfReported,
231    HostUnavailable,
232}
233
234impl Issue31EvidenceUnavailableReason {
235    /// The wire token for this refusal.
236    ///
237    /// A reader must be told *why* a chain is unavailable, and the four
238    /// record-level reasons are not interchangeable: `hop_missing` says the
239    /// host never produced the step, `hop_mismatched` says two records tell
240    /// two stories about one run, `hop_private` says the host produced it and
241    /// this surface may not carry it, and `self_reported` says the run
242    /// vouched for itself. Collapsing them into one "unavailable" word is how
243    /// a contradicted chain gets read as merely incomplete.
244    #[must_use]
245    pub const fn token(self) -> &'static str {
246        match self {
247            Self::HopMissing => "hop_missing",
248            Self::HopMismatched => "hop_mismatched",
249            Self::HopPrivate => "hop_private",
250            Self::SelfReported => "self_reported",
251            Self::HostUnavailable => "host_unavailable",
252        }
253    }
254
255    /// Every admitted reason, in declaration order.
256    #[must_use]
257    pub const fn all() -> &'static [Self] {
258        &[
259            Self::HopMissing,
260            Self::HopMismatched,
261            Self::HopPrivate,
262            Self::SelfReported,
263            Self::HostUnavailable,
264        ]
265    }
266}
267
268#[derive(Clone, Debug, PartialEq, Eq)]
269pub struct Issue31FullAutoControl {
270    pub action_ref: PublicRef,
271    pub kind: Issue31FullAutoControlKind,
272    pub run_generation: u64,
273    pub idempotency_ref: PublicRef,
274}
275
276#[derive(Clone, Debug, PartialEq, Eq)]
277pub struct Issue31FullAutoRun {
278    pub run_ref: PublicRef,
279    pub objective: String,
280    pub lane_ref: PublicRef,
281    pub lifecycle: Issue31FullAutoLifecycle,
282    pub generation: u64,
283    pub unattended_ms: u64,
284    pub live_work_ref: Option<PublicRef>,
285    pub terminal_reason_ref: Option<PublicRef>,
286    pub controls: Vec<Issue31FullAutoControl>,
287}
288
289#[derive(Clone, Debug, PartialEq, Eq)]
290pub struct Issue31ProviderAccount {
291    pub account_ref: PublicRef,
292    pub provider: PublicRef,
293    pub label: String,
294    pub readiness: Issue31ProviderReadiness,
295    pub quota: Issue31ProviderQuota,
296    pub lane_ref: PublicRef,
297}
298
299#[derive(Clone, Debug, PartialEq, Eq)]
300pub struct Issue31ProviderHandoff {
301    pub handoff_ref: PublicRef,
302    pub provider: PublicRef,
303    pub state: Issue31ProviderHandoffState,
304    pub requested_at_ms: u64,
305    pub account_ref: Option<PublicRef>,
306    pub reason_class: Option<PublicRef>,
307    pub outcome_ref: Option<PublicRef>,
308    pub receipt_ref: Option<PublicRef>,
309}
310
311#[derive(Clone, Debug, PartialEq, Eq)]
312pub struct Issue31EvidenceHop {
313    pub kind: Issue31EvidenceHopKind,
314    pub reference: PublicRef,
315    pub detail: Option<String>,
316}
317
318#[derive(Clone, Debug, PartialEq, Eq)]
319pub enum Issue31EvidenceChain {
320    Complete {
321        run_ref: PublicRef,
322        authority_allowed: bool,
323        hops: Vec<Issue31EvidenceHop>,
324    },
325    Unavailable {
326        run_ref: PublicRef,
327        reason: Issue31EvidenceUnavailableReason,
328        broken_at: Option<Issue31EvidenceHopKind>,
329    },
330}
331
332impl Issue31EvidenceChain {
333    #[must_use]
334    pub fn run_ref(&self) -> &PublicRef {
335        match self {
336            Self::Complete { run_ref, .. } | Self::Unavailable { run_ref, .. } => run_ref,
337        }
338    }
339}
340
341#[derive(Clone, Debug, PartialEq, Eq)]
342pub struct Issue31FullAutoAdjunct {
343    pub schema: &'static str,
344    pub host_ref: PublicRef,
345    pub snapshot_ref: PublicRef,
346    pub generated_at_ms: u64,
347    pub runs: Vec<Issue31FullAutoRun>,
348    pub accounts: Vec<Issue31ProviderAccount>,
349    pub handoffs: Vec<Issue31ProviderHandoff>,
350    pub evidence: Vec<Issue31EvidenceChain>,
351}
352
353impl Issue31FullAutoAdjunct {
354    /// True when this detail projection belongs to the exact `host.v1` snapshot
355    /// that advertised it. A detail payload from a different snapshot is stale
356    /// content wearing a current label.
357    #[must_use]
358    pub fn is_bound_to(&self, host_ref: &PublicRef, snapshot_ref: &PublicRef) -> bool {
359        &self.host_ref == host_ref && &self.snapshot_ref == snapshot_ref
360    }
361}
362
363#[derive(Clone, Copy, Debug, PartialEq, Eq)]
364pub enum Issue31FullAutoAdjunctError {
365    InvalidJson,
366    InvalidSchema,
367    UnsafeReference,
368    UnsafeText,
369    BoundExceeded,
370    DuplicateReference,
371    InvalidTimestamp,
372    InvalidRunState,
373    InvalidControlBinding,
374    InvalidAccountState,
375    InvalidHandoffState,
376    InvalidEvidenceChain,
377    UnknownReference,
378}
379
380impl std::fmt::Display for Issue31FullAutoAdjunctError {
381    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382        let message = match self {
383            Self::InvalidJson => "issue 31 full auto adjunct is not valid contract JSON",
384            Self::InvalidSchema => "issue 31 full auto adjunct schema is not supported",
385            Self::UnsafeReference => "issue 31 full auto adjunct contains an unsafe reference",
386            Self::UnsafeText => "issue 31 full auto adjunct contains unsafe text",
387            Self::BoundExceeded => "issue 31 full auto adjunct bound was exceeded",
388            Self::DuplicateReference => "issue 31 full auto adjunct contains a duplicate reference",
389            Self::InvalidTimestamp => "issue 31 full auto adjunct timestamp order is invalid",
390            Self::InvalidRunState => "issue 31 full auto adjunct run state is invalid",
391            Self::InvalidControlBinding => {
392                "issue 31 full auto adjunct binds a control to a stale run generation"
393            }
394            Self::InvalidAccountState => {
395                "issue 31 full auto adjunct confuses a lane with an account"
396            }
397            Self::InvalidHandoffState => "issue 31 full auto adjunct handoff state is invalid",
398            Self::InvalidEvidenceChain => "issue 31 full auto adjunct evidence chain is invalid",
399            Self::UnknownReference => "issue 31 full auto adjunct points at an unknown record",
400        };
401        formatter.write_str(message)
402    }
403}
404
405impl std::error::Error for Issue31FullAutoAdjunctError {}
406
407type AdjunctResult<T> = Result<T, Issue31FullAutoAdjunctError>;
408
409#[derive(Deserialize)]
410#[serde(rename_all = "camelCase", deny_unknown_fields)]
411struct RawAdjunct {
412    schema: String,
413    host_ref: String,
414    snapshot_ref: String,
415    generated_at_ms: u64,
416    runs: Vec<RawRun>,
417    accounts: Vec<RawAccount>,
418    handoffs: Vec<RawHandoff>,
419    evidence: Vec<RawEvidence>,
420}
421
422#[derive(Deserialize)]
423#[serde(rename_all = "camelCase", deny_unknown_fields)]
424struct RawRun {
425    run_ref: String,
426    objective: String,
427    lane_ref: String,
428    lifecycle: Issue31FullAutoLifecycle,
429    generation: u64,
430    unattended_ms: u64,
431    live_work_ref: Option<String>,
432    terminal_reason_ref: Option<String>,
433    controls: Vec<RawControl>,
434}
435
436#[derive(Deserialize)]
437#[serde(rename_all = "camelCase", deny_unknown_fields)]
438struct RawControl {
439    action_ref: String,
440    kind: Issue31FullAutoControlKind,
441    run_generation: u64,
442    idempotency_ref: String,
443}
444
445#[derive(Deserialize)]
446#[serde(rename_all = "camelCase", deny_unknown_fields)]
447struct RawAccount {
448    account_ref: String,
449    provider: String,
450    label: String,
451    readiness: Issue31ProviderReadiness,
452    quota: Issue31ProviderQuota,
453    lane_ref: String,
454}
455
456#[derive(Deserialize)]
457#[serde(rename_all = "camelCase", deny_unknown_fields)]
458struct RawHandoff {
459    handoff_ref: String,
460    provider: String,
461    state: Issue31ProviderHandoffState,
462    requested_at_ms: u64,
463    account_ref: Option<String>,
464    reason_class: Option<String>,
465    outcome_ref: Option<String>,
466    receipt_ref: Option<String>,
467}
468
469#[derive(Deserialize)]
470#[serde(tag = "completeness", rename_all = "snake_case", deny_unknown_fields)]
471enum RawEvidence {
472    Complete {
473        #[serde(rename = "runRef")]
474        run_ref: String,
475        #[serde(rename = "hostExecuted")]
476        host_executed: bool,
477        #[serde(rename = "authorityAllowed")]
478        authority_allowed: bool,
479        hops: Vec<RawHop>,
480    },
481    Unavailable {
482        #[serde(rename = "runRef")]
483        run_ref: String,
484        #[serde(rename = "reasonClass")]
485        reason_class: Issue31EvidenceUnavailableReason,
486        #[serde(rename = "brokenAt")]
487        broken_at: Option<Issue31EvidenceHopKind>,
488    },
489}
490
491#[derive(Deserialize)]
492#[serde(rename_all = "camelCase", deny_unknown_fields)]
493struct RawHop {
494    kind: Issue31EvidenceHopKind,
495    #[serde(rename = "ref")]
496    reference: String,
497    detail: Option<String>,
498}
499
500pub fn decode_issue31_full_auto_adjunct(input: &str) -> AdjunctResult<Issue31FullAutoAdjunct> {
501    let raw: RawAdjunct =
502        serde_json::from_str(input).map_err(|_| Issue31FullAutoAdjunctError::InvalidJson)?;
503    if raw.schema != ISSUE31_FULL_AUTO_ADJUNCT_SCHEMA {
504        return Err(Issue31FullAutoAdjunctError::InvalidSchema);
505    }
506    if raw.generated_at_ms > MAX_ISSUE31_TIMESTAMP_MS {
507        return Err(Issue31FullAutoAdjunctError::InvalidTimestamp);
508    }
509    if raw.runs.len() > MAX_ISSUE31_FULL_AUTO_RUNS
510        || raw.accounts.len() > MAX_ISSUE31_FULL_AUTO_ACCOUNTS
511        || raw.handoffs.len() > MAX_ISSUE31_FULL_AUTO_HANDOFFS
512        || raw.evidence.len() > MAX_ISSUE31_FULL_AUTO_RUNS
513    {
514        return Err(Issue31FullAutoAdjunctError::BoundExceeded);
515    }
516
517    let host_ref = public_ref(raw.host_ref)?;
518    let snapshot_ref = public_ref(raw.snapshot_ref)?;
519
520    let runs = raw
521        .runs
522        .into_iter()
523        .map(project_run)
524        .collect::<AdjunctResult<Vec<_>>>()?;
525    let accounts = raw
526        .accounts
527        .into_iter()
528        .map(project_account)
529        .collect::<AdjunctResult<Vec<_>>>()?;
530    let handoffs = raw
531        .handoffs
532        .into_iter()
533        .map(|handoff| project_handoff(handoff, raw.generated_at_ms))
534        .collect::<AdjunctResult<Vec<_>>>()?;
535    let evidence = raw
536        .evidence
537        .into_iter()
538        .map(project_evidence)
539        .collect::<AdjunctResult<Vec<_>>>()?;
540
541    assert_unique(runs.iter().map(|run| run.run_ref.as_str()))?;
542    assert_unique(accounts.iter().map(|account| account.account_ref.as_str()))?;
543    assert_unique(handoffs.iter().map(|handoff| handoff.handoff_ref.as_str()))?;
544    assert_unique(evidence.iter().map(|chain| chain.run_ref().as_str()))?;
545
546    // Evidence and handoffs must point at things this snapshot actually
547    // carries, otherwise the phone renders a chain for a run it cannot show.
548    let known_runs: HashSet<&str> = runs.iter().map(|run| run.run_ref.as_str()).collect();
549    if evidence
550        .iter()
551        .any(|chain| !known_runs.contains(chain.run_ref().as_str()))
552    {
553        return Err(Issue31FullAutoAdjunctError::UnknownReference);
554    }
555    let known_accounts: HashSet<&str> = accounts
556        .iter()
557        .map(|account| account.account_ref.as_str())
558        .collect();
559    if handoffs.iter().any(|handoff| {
560        handoff
561            .account_ref
562            .as_ref()
563            .is_some_and(|account| !known_accounts.contains(account.as_str()))
564    }) {
565        return Err(Issue31FullAutoAdjunctError::UnknownReference);
566    }
567
568    Ok(Issue31FullAutoAdjunct {
569        schema: ISSUE31_FULL_AUTO_ADJUNCT_SCHEMA,
570        host_ref,
571        snapshot_ref,
572        generated_at_ms: raw.generated_at_ms,
573        runs,
574        accounts,
575        handoffs,
576        evidence,
577    })
578}
579
580fn project_run(raw: RawRun) -> AdjunctResult<Issue31FullAutoRun> {
581    if raw.controls.len() > MAX_ISSUE31_FULL_AUTO_CONTROLS {
582        return Err(Issue31FullAutoAdjunctError::BoundExceeded);
583    }
584    if raw.unattended_ms > MAX_ISSUE31_UNATTENDED_MS {
585        return Err(Issue31FullAutoAdjunctError::InvalidRunState);
586    }
587    if !is_issue31_public_text(&raw.objective, MAX_PUBLIC_TEXT) {
588        return Err(Issue31FullAutoAdjunctError::UnsafeText);
589    }
590
591    let run_ref = public_ref(raw.run_ref)?;
592    let lane_ref = public_ref(raw.lane_ref)?;
593    let live_work_ref = raw.live_work_ref.map(public_ref).transpose()?;
594    let terminal_reason_ref = raw.terminal_reason_ref.map(public_ref).transpose()?;
595
596    if raw.lifecycle.is_terminal() {
597        // A finished run offers no controls. Otherwise the phone can present a
598        // button whose completion can never arrive.
599        if live_work_ref.is_some() || terminal_reason_ref.is_none() || !raw.controls.is_empty() {
600            return Err(Issue31FullAutoAdjunctError::InvalidRunState);
601        }
602    } else if terminal_reason_ref.is_some() {
603        return Err(Issue31FullAutoAdjunctError::InvalidRunState);
604    }
605
606    let mut controls = Vec::with_capacity(raw.controls.len());
607    for control in raw.controls {
608        if control.run_generation != raw.generation {
609            return Err(Issue31FullAutoAdjunctError::InvalidControlBinding);
610        }
611        controls.push(Issue31FullAutoControl {
612            action_ref: public_ref(control.action_ref)?,
613            kind: control.kind,
614            run_generation: control.run_generation,
615            idempotency_ref: public_ref(control.idempotency_ref)?,
616        });
617    }
618    assert_unique(controls.iter().map(|control| control.action_ref.as_str()))?;
619    let kinds: HashSet<Issue31FullAutoControlKind> =
620        controls.iter().map(|control| control.kind).collect();
621    if kinds.len() != controls.len() {
622        return Err(Issue31FullAutoAdjunctError::DuplicateReference);
623    }
624
625    Ok(Issue31FullAutoRun {
626        run_ref,
627        objective: raw.objective,
628        lane_ref,
629        lifecycle: raw.lifecycle,
630        generation: raw.generation,
631        unattended_ms: raw.unattended_ms,
632        live_work_ref,
633        terminal_reason_ref,
634        controls,
635    })
636}
637
638fn project_account(raw: RawAccount) -> AdjunctResult<Issue31ProviderAccount> {
639    if !is_issue31_public_text(&raw.label, MAX_PUBLIC_LABEL) {
640        return Err(Issue31FullAutoAdjunctError::UnsafeText);
641    }
642    let account_ref = public_ref(raw.account_ref)?;
643    let lane_ref = public_ref(raw.lane_ref)?;
644    // A lane reference that is literally the account reference collapses the
645    // two concepts omega#47 insists on keeping distinct.
646    if account_ref == lane_ref {
647        return Err(Issue31FullAutoAdjunctError::InvalidAccountState);
648    }
649    Ok(Issue31ProviderAccount {
650        account_ref,
651        provider: public_ref(raw.provider)?,
652        label: raw.label,
653        readiness: raw.readiness,
654        quota: raw.quota,
655        lane_ref,
656    })
657}
658
659fn project_handoff(raw: RawHandoff, generated_at_ms: u64) -> AdjunctResult<Issue31ProviderHandoff> {
660    if raw.requested_at_ms > generated_at_ms || raw.requested_at_ms > MAX_ISSUE31_TIMESTAMP_MS {
661        return Err(Issue31FullAutoAdjunctError::InvalidTimestamp);
662    }
663    let account_ref = raw.account_ref.map(public_ref).transpose()?;
664    let reason_class = raw.reason_class.map(public_ref).transpose()?;
665    let outcome_ref = raw.outcome_ref.map(public_ref).transpose()?;
666
667    if raw.state.is_terminal() {
668        // The exit is "a provider connection handoff reports its exact
669        // host-owned outcome". A terminal state with no host outcome reference
670        // is a claim the host never made.
671        if outcome_ref.is_none() {
672            return Err(Issue31FullAutoAdjunctError::InvalidHandoffState);
673        }
674        if raw.state == Issue31ProviderHandoffState::Completed {
675            if account_ref.is_none() {
676                return Err(Issue31FullAutoAdjunctError::InvalidHandoffState);
677            }
678        } else if reason_class.is_none() {
679            return Err(Issue31FullAutoAdjunctError::InvalidHandoffState);
680        }
681    } else if outcome_ref.is_some() {
682        return Err(Issue31FullAutoAdjunctError::InvalidHandoffState);
683    }
684
685    Ok(Issue31ProviderHandoff {
686        handoff_ref: public_ref(raw.handoff_ref)?,
687        provider: public_ref(raw.provider)?,
688        state: raw.state,
689        requested_at_ms: raw.requested_at_ms,
690        account_ref,
691        reason_class,
692        outcome_ref,
693        receipt_ref: raw.receipt_ref.map(public_ref).transpose()?,
694    })
695}
696
697/// Decode exactly one provider connection handoff row.
698///
699/// This is the same `project_handoff` the whole-document decoder applies, with
700/// no second copy of the law: a producer that routes a row through this cannot
701/// write a handoff the reader of the assembled adjunct would then refuse.
702///
703/// It deliberately does not check the one law that is not local to a row — a
704/// bound `accountRef` must name an account the same snapshot carries. That
705/// relation belongs to the document, and a producer holding only a row cannot
706/// state it. `decode_issue31_full_auto_adjunct` still enforces it.
707pub fn decode_issue31_provider_handoff(
708    value: &serde_json::Value,
709    generated_at_ms: u64,
710) -> AdjunctResult<Issue31ProviderHandoff> {
711    let raw: RawHandoff = serde_json::from_value(value.clone())
712        .map_err(|_| Issue31FullAutoAdjunctError::InvalidJson)?;
713    project_handoff(raw, generated_at_ms)
714}
715
716fn project_evidence(raw: RawEvidence) -> AdjunctResult<Issue31EvidenceChain> {
717    match raw {
718        RawEvidence::Unavailable {
719            run_ref,
720            reason_class,
721            broken_at,
722        } => Ok(Issue31EvidenceChain::Unavailable {
723            run_ref: public_ref(run_ref)?,
724            reason: reason_class,
725            broken_at,
726        }),
727        RawEvidence::Complete {
728            run_ref,
729            host_executed,
730            authority_allowed,
731            hops,
732        } => {
733            // A run that reports its own success is self-reported, not
734            // verified, so a complete chain cannot say otherwise.
735            if !host_executed {
736                return Err(Issue31FullAutoAdjunctError::InvalidEvidenceChain);
737            }
738            if hops.len() != ISSUE31_EVIDENCE_HOPS.len() {
739                return Err(Issue31FullAutoAdjunctError::InvalidEvidenceChain);
740            }
741            let mut projected = Vec::with_capacity(hops.len());
742            for (index, hop) in hops.into_iter().enumerate() {
743                if hop.kind != ISSUE31_EVIDENCE_HOPS[index] {
744                    return Err(Issue31FullAutoAdjunctError::InvalidEvidenceChain);
745                }
746                if let Some(detail) = hop.detail.as_deref()
747                    && !is_issue31_public_text(detail, MAX_PUBLIC_COMMAND)
748                {
749                    return Err(Issue31FullAutoAdjunctError::UnsafeText);
750                }
751                projected.push(Issue31EvidenceHop {
752                    kind: hop.kind,
753                    reference: public_ref(hop.reference)?,
754                    detail: hop.detail,
755                });
756            }
757            Ok(Issue31EvidenceChain::Complete {
758                run_ref: public_ref(run_ref)?,
759                authority_allowed,
760                hops: projected,
761            })
762        }
763    }
764}
765
766fn assert_unique<'a>(values: impl Iterator<Item = &'a str>) -> AdjunctResult<()> {
767    let mut seen = HashSet::new();
768    for value in values {
769        if !seen.insert(value) {
770            return Err(Issue31FullAutoAdjunctError::DuplicateReference);
771        }
772    }
773    Ok(())
774}
775
776fn public_ref(raw: String) -> AdjunctResult<PublicRef> {
777    sanitize_public_ref(&raw).ok_or(Issue31FullAutoAdjunctError::UnsafeReference)
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783
784    const CANONICAL: &str =
785        include_str!("../fixtures/openagents.omega.issue31.fullauto.v1.canonical.json");
786
787    fn negative(name: &str) -> String {
788        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
789            .join("fixtures")
790            .join(format!(
791                "openagents.omega.issue31.fullauto.v1.negative-{name}.json"
792            ));
793        std::fs::read_to_string(path).expect("negative fixture is readable")
794    }
795
796    #[test]
797    fn decodes_the_byte_shared_canonical_fixture() {
798        let adjunct = decode_issue31_full_auto_adjunct(CANONICAL).expect("canonical decodes");
799        assert_eq!(adjunct.schema, ISSUE31_FULL_AUTO_ADJUNCT_SCHEMA);
800        assert_eq!(adjunct.runs.len(), 2);
801        assert_eq!(adjunct.accounts.len(), 3);
802        assert_eq!(adjunct.handoffs.len(), 3);
803        assert_eq!(adjunct.evidence.len(), 2);
804    }
805
806    /// The bytes `omega_effectd`'s handoff ledger actually emits (omega#91),
807    /// byte-shared with `packages/sarah`.
808    const HOST_PRODUCED_HANDOFFS: &str = include_str!(
809        "../fixtures/openagents.omega.issue31.fullauto.v1.host-produced-handoffs.json"
810    );
811
812    #[test]
813    fn decodes_every_host_produced_handoff_lifecycle() {
814        let adjunct =
815            decode_issue31_full_auto_adjunct(HOST_PRODUCED_HANDOFFS).expect("host-produced decodes");
816        let states: Vec<Issue31ProviderHandoffState> = adjunct
817            .handoffs
818            .iter()
819            .map(|handoff| handoff.state)
820            .collect();
821        for state in [
822            Issue31ProviderHandoffState::Requested,
823            Issue31ProviderHandoffState::Active,
824            Issue31ProviderHandoffState::Completed,
825            Issue31ProviderHandoffState::Refused,
826            Issue31ProviderHandoffState::Failed,
827            Issue31ProviderHandoffState::Expired,
828        ] {
829            assert!(states.contains(&state), "{state:?} is unrepresented");
830        }
831        // A bound handoff and the account it chose are readable together, so a
832        // viewer can follow the lane the host picked rather than infer it.
833        let accounts: HashSet<&str> = adjunct
834            .accounts
835            .iter()
836            .map(|account| account.account_ref.as_str())
837            .collect();
838        for handoff in &adjunct.handoffs {
839            if let Some(account_ref) = &handoff.account_ref {
840                assert!(accounts.contains(account_ref.as_str()));
841            }
842        }
843    }
844
845    #[test]
846    fn projects_the_explicit_account_to_lane_relation() {
847        let adjunct = decode_issue31_full_auto_adjunct(CANONICAL).expect("canonical decodes");
848        let pairs: Vec<(&str, &str)> = adjunct
849            .accounts
850            .iter()
851            .map(|account| (account.account_ref.as_str(), account.lane_ref.as_str()))
852            .collect();
853        assert_eq!(
854            pairs,
855            vec![
856                ("account.codex.1", "lane.codex-local"),
857                ("account.codex.2", "lane.codex-local-2"),
858                ("account.claude.1", "lane.claude-local"),
859            ]
860        );
861    }
862
863    #[test]
864    fn keeps_every_control_bound_to_the_exact_run_generation() {
865        let adjunct = decode_issue31_full_auto_adjunct(CANONICAL).expect("canonical decodes");
866        for run in &adjunct.runs {
867            for control in &run.controls {
868                assert_eq!(control.run_generation, run.generation);
869                assert!(!control.idempotency_ref.as_str().is_empty());
870            }
871        }
872    }
873
874    #[test]
875    fn orders_a_complete_chain_from_objective_through_receipt() {
876        let adjunct = decode_issue31_full_auto_adjunct(CANONICAL).expect("canonical decodes");
877        let complete = adjunct
878            .evidence
879            .iter()
880            .find_map(|chain| match chain {
881                Issue31EvidenceChain::Complete { hops, .. } => Some(hops),
882                Issue31EvidenceChain::Unavailable { .. } => None,
883            })
884            .expect("canonical carries a complete chain");
885        let kinds: Vec<Issue31EvidenceHopKind> = complete.iter().map(|hop| hop.kind).collect();
886        assert_eq!(kinds, ISSUE31_EVIDENCE_HOPS.to_vec());
887    }
888
889    #[test]
890    fn a_broken_chain_is_unavailable_and_carries_no_partial_hops() {
891        let adjunct = decode_issue31_full_auto_adjunct(CANONICAL).expect("canonical decodes");
892        let broken = adjunct
893            .evidence
894            .iter()
895            .find(|chain| matches!(chain, Issue31EvidenceChain::Unavailable { .. }))
896            .expect("canonical carries an unavailable chain");
897        match broken {
898            Issue31EvidenceChain::Unavailable {
899                reason, broken_at, ..
900            } => {
901                assert_eq!(*reason, Issue31EvidenceUnavailableReason::HopMissing);
902                assert_eq!(*broken_at, Some(Issue31EvidenceHopKind::HostVerification));
903            }
904            Issue31EvidenceChain::Complete { .. } => unreachable!(),
905        }
906    }
907
908    #[test]
909    fn refuses_every_boundary_violation_without_echoing_the_offending_value() {
910        for (name, expected) in [
911            ("credential-label", Issue31FullAutoAdjunctError::UnsafeText),
912            ("private-path", Issue31FullAutoAdjunctError::UnsafeText),
913            (
914                "stale-generation",
915                Issue31FullAutoAdjunctError::InvalidControlBinding,
916            ),
917            (
918                "lane-as-account",
919                Issue31FullAutoAdjunctError::InvalidAccountState,
920            ),
921            (
922                "partial-chain",
923                Issue31FullAutoAdjunctError::InvalidEvidenceChain,
924            ),
925            (
926                "self-reported",
927                Issue31FullAutoAdjunctError::InvalidEvidenceChain,
928            ),
929            (
930                "terminal-control",
931                Issue31FullAutoAdjunctError::InvalidRunState,
932            ),
933            (
934                "handoff-no-outcome",
935                Issue31FullAutoAdjunctError::InvalidHandoffState,
936            ),
937        ] {
938            let error = decode_issue31_full_auto_adjunct(&negative(name))
939                .expect_err("negative fixture must be refused");
940            assert_eq!(error, expected, "fixture {name}");
941            let rendered = error.to_string();
942            assert!(
943                !rendered.contains("Bearer"),
944                "fixture {name} echoed a token"
945            );
946            assert!(
947                !rendered.contains("/Users/"),
948                "fixture {name} echoed a path"
949            );
950        }
951    }
952
953    #[test]
954    fn binds_only_to_the_snapshot_that_advertised_the_capabilities() {
955        let adjunct = decode_issue31_full_auto_adjunct(CANONICAL).expect("canonical decodes");
956        let host = sanitize_public_ref("host.omega.device-alpha").expect("safe ref");
957        let snapshot = sanitize_public_ref("snapshot.omega.issue31.000042").expect("safe ref");
958        let other = sanitize_public_ref("snapshot.omega.issue31.000043").expect("safe ref");
959        assert!(adjunct.is_bound_to(&host, &snapshot));
960        assert!(!adjunct.is_bound_to(&host, &other));
961    }
962
963    #[test]
964    fn rejects_credential_and_private_path_text_directly() {
965        for value in [
966            "Bearer abc123",
967            "sk-live-0000",
968            "ghp_0000000000",
969            "Authorization: token",
970            "/Users/owner/.codex/auth.json",
971            "~/.codex/auth.json",
972            "-----BEGIN PRIVATE KEY-----",
973            " leading space",
974            "",
975        ] {
976            assert!(
977                !is_issue31_public_text(value, MAX_PUBLIC_TEXT),
978                "expected {value:?} to be refused"
979            );
980        }
981        assert!(is_issue31_public_text(
982            "3 files changed, 214 insertions, 6 deletions",
983            MAX_PUBLIC_TEXT
984        ));
985    }
986}
987
988// ---------------------------------------------------------------------------
989// Emitter
990// ---------------------------------------------------------------------------
991
992/// Build the adjunct from the same host records the Full Auto panels read.
993///
994/// The panels in `full_auto_ui` parse these values into display rows. This
995/// produces the headless contract from the identical source, so the phone and
996/// the panel cannot disagree about what the host said.
997///
998/// It deliberately builds a JSON document and hands it to
999/// `decode_issue31_full_auto_adjunct` rather than constructing the typed value
1000/// directly. There is then exactly one place where the issue's boundaries are
1001/// enforced, and the emitter cannot produce anything the reader would refuse.
1002pub fn build_issue31_full_auto_adjunct(
1003    host_ref: &str,
1004    snapshot_ref: &str,
1005    generated_at_ms: u64,
1006    runs: &serde_json::Value,
1007    accounts: &serde_json::Value,
1008    handoffs: &serde_json::Value,
1009    evidence: &[(serde_json::Value, serde_json::Value)],
1010) -> AdjunctResult<Issue31FullAutoAdjunct> {
1011    build_issue31_full_auto_adjunct_document(
1012        host_ref,
1013        snapshot_ref,
1014        generated_at_ms,
1015        runs,
1016        accounts,
1017        handoffs,
1018        evidence,
1019    )
1020    .map(|(adjunct, _)| adjunct)
1021}
1022
1023/// The same detail projection, plus the exact bytes the decoder accepted.
1024pub fn build_issue31_full_auto_adjunct_document(
1025    host_ref: &str,
1026    snapshot_ref: &str,
1027    generated_at_ms: u64,
1028    runs: &serde_json::Value,
1029    accounts: &serde_json::Value,
1030    handoffs: &serde_json::Value,
1031    evidence: &[(serde_json::Value, serde_json::Value)],
1032) -> AdjunctResult<(Issue31FullAutoAdjunct, serde_json::Value)> {
1033    let document = serde_json::json!({
1034        "schema": ISSUE31_FULL_AUTO_ADJUNCT_SCHEMA,
1035        "hostRef": host_ref,
1036        "snapshotRef": snapshot_ref,
1037        "generatedAtMs": generated_at_ms,
1038        "runs": build_runs(runs, generated_at_ms)?,
1039        "accounts": build_accounts(accounts)?,
1040        "handoffs": build_handoffs(handoffs)?,
1041        "evidence": build_evidence(evidence),
1042    });
1043    let serialized =
1044        serde_json::to_string(&document).map_err(|_| Issue31FullAutoAdjunctError::InvalidJson)?;
1045    let adjunct = decode_issue31_full_auto_adjunct(&serialized)?;
1046    Ok((adjunct, document))
1047}
1048
1049/// Map an Omega Full Auto panel state onto the contract lifecycle.
1050///
1051/// Unknown states are refused rather than folded into `running`. A state this
1052/// build does not understand is not evidence that a run is healthy.
1053fn lifecycle_from_state(state: &str) -> Option<&'static str> {
1054    Some(match state {
1055        "queued" => "queued",
1056        "running" => "running",
1057        "pausing" => "pausing",
1058        "paused" => "paused",
1059        "stopping" => "stopping",
1060        "retrying" => "retrying",
1061        "stalled" => "stalled",
1062        "succeeded" | "completed" => "succeeded",
1063        "failed" => "failed",
1064        "stopped" | "cancelled" => "stopped",
1065        "expired" => "expired",
1066        _ => return None,
1067    })
1068}
1069
1070fn is_terminal_state(lifecycle: &str) -> bool {
1071    matches!(lifecycle, "succeeded" | "failed" | "stopped" | "expired")
1072}
1073
1074fn build_runs(
1075    runs: &serde_json::Value,
1076    generated_at_ms: u64,
1077) -> AdjunctResult<Vec<serde_json::Value>> {
1078    let mut built = Vec::new();
1079    for run in runs
1080        .get("runs")
1081        .and_then(serde_json::Value::as_array)
1082        .into_iter()
1083        .flatten()
1084    {
1085        let state = run
1086            .get("state")
1087            .and_then(serde_json::Value::as_str)
1088            .ok_or(Issue31FullAutoAdjunctError::InvalidRunState)?;
1089        let lifecycle =
1090            lifecycle_from_state(state).ok_or(Issue31FullAutoAdjunctError::InvalidRunState)?;
1091        let terminal = is_terminal_state(lifecycle);
1092        let generation = run
1093            .get("generation")
1094            .and_then(serde_json::Value::as_u64)
1095            .ok_or(Issue31FullAutoAdjunctError::InvalidRunState)?;
1096        let run_ref = run
1097            .get("runRef")
1098            .and_then(serde_json::Value::as_str)
1099            .ok_or(Issue31FullAutoAdjunctError::UnsafeReference)?;
1100
1101        // Measured from the host's own start time, never re-derived downstream.
1102        // A run whose start the host never recorded has no known unattended
1103        // duration, and `0` would read on the phone as "just started" — a claim
1104        // nothing supports. The exact duration is part of this issue's outcome,
1105        // so its absence is a refusal rather than a zero.
1106        let started_at_ms = run
1107            .get("startedAtMs")
1108            .and_then(serde_json::Value::as_u64)
1109            .ok_or(Issue31FullAutoAdjunctError::InvalidRunState)?;
1110        if started_at_ms > generated_at_ms {
1111            return Err(Issue31FullAutoAdjunctError::InvalidTimestamp);
1112        }
1113        let unattended_ms = generated_at_ms - started_at_ms;
1114
1115        let mut entry = serde_json::json!({
1116            "runRef": run_ref,
1117            "objective": run.get("objective").cloned().unwrap_or(serde_json::Value::Null),
1118            "laneRef": run.get("laneRef").or_else(|| run.get("lane")).cloned().unwrap_or(serde_json::Value::Null),
1119            "lifecycle": lifecycle,
1120            "generation": generation,
1121            "unattendedMs": unattended_ms,
1122            "controls": build_controls(run, generation, terminal),
1123        });
1124        let object = entry
1125            .as_object_mut()
1126            .ok_or(Issue31FullAutoAdjunctError::InvalidJson)?;
1127        if terminal {
1128            // The contract requires a reason on a finished run, and a run that
1129            // ended without the host recording why is a gap the owner should
1130            // see rather than a blank.
1131            let reason = run
1132                .get("terminalReasonRef")
1133                .cloned()
1134                .unwrap_or_else(|| serde_json::json!("reason.full-auto.unrecorded"));
1135            object.insert("terminalReasonRef".into(), reason);
1136        } else if let Some(live) = run.get("liveWorkRef").cloned() {
1137            object.insert("liveWorkRef".into(), live);
1138        }
1139        built.push(entry);
1140    }
1141    Ok(built)
1142}
1143
1144/// A control the host is willing to accept, bound to this exact generation.
1145fn build_controls(
1146    run: &serde_json::Value,
1147    generation: u64,
1148    terminal: bool,
1149) -> Vec<serde_json::Value> {
1150    if terminal {
1151        return Vec::new();
1152    }
1153    let run_ref = run
1154        .get("runRef")
1155        .and_then(serde_json::Value::as_str)
1156        .unwrap_or("run");
1157    run.get("permittedControls")
1158        .and_then(serde_json::Value::as_array)
1159        .into_iter()
1160        .flatten()
1161        .filter_map(serde_json::Value::as_str)
1162        .filter(|kind| matches!(*kind, "pause" | "resume" | "stop"))
1163        .map(|kind| {
1164            serde_json::json!({
1165                "actionRef": format!("action.full-auto.{kind}"),
1166                "kind": kind,
1167                "runGeneration": generation,
1168                "idempotencyRef": format!("idem.{run_ref}.{kind}.{generation}"),
1169            })
1170        })
1171        .collect()
1172}
1173
1174fn build_accounts(accounts: &serde_json::Value) -> AdjunctResult<Vec<serde_json::Value>> {
1175    let mut built = Vec::new();
1176    for account in accounts
1177        .get("accounts")
1178        .and_then(serde_json::Value::as_array)
1179        .into_iter()
1180        .flatten()
1181    {
1182        built.push(serde_json::json!({
1183            "accountRef": account.get("accountRef").cloned().unwrap_or(serde_json::Value::Null),
1184            "provider": account.get("provider").cloned().unwrap_or(serde_json::Value::Null),
1185            "label": account.get("label").cloned().unwrap_or(serde_json::Value::Null),
1186            "readiness": normalize_readiness(account.get("state").and_then(serde_json::Value::as_str)),
1187            "quota": normalize_quota(account.get("quotaState").and_then(serde_json::Value::as_str)),
1188            // The panel calls this `lane`. The contract insists an account
1189            // states its lane, so a missing one becomes an explicit decode
1190            // failure rather than a silently unmapped account row.
1191            "laneRef": account.get("laneRef").or_else(|| account.get("lane")).cloned().unwrap_or(serde_json::Value::Null),
1192        }));
1193    }
1194    Ok(built)
1195}
1196
1197/// Project each host-owned provider connection handoff onto the contract.
1198///
1199/// The handoff record on the host side carries the isolated provider home, the
1200/// browser or device login it drove, and whatever the provider returned. Only
1201/// the fields named here are copied, so a private path or an authorization
1202/// response present on the host record cannot reach the phone even by accident,
1203/// and cannot make the whole projection unreadable through an unknown field
1204/// either.
1205fn build_handoffs(handoffs: &serde_json::Value) -> AdjunctResult<Vec<serde_json::Value>> {
1206    let mut built = Vec::new();
1207    for handoff in handoffs
1208        .get("handoffs")
1209        .and_then(serde_json::Value::as_array)
1210        .into_iter()
1211        .flatten()
1212    {
1213        let mut entry = serde_json::json!({
1214            "handoffRef": handoff.get("handoffRef").cloned().unwrap_or(serde_json::Value::Null),
1215            "provider": handoff.get("provider").cloned().unwrap_or(serde_json::Value::Null),
1216            "state": handoff.get("state").cloned().unwrap_or(serde_json::Value::Null),
1217            "requestedAtMs": handoff.get("requestedAtMs").cloned().unwrap_or(serde_json::Value::Null),
1218        });
1219        let object = entry
1220            .as_object_mut()
1221            .ok_or(Issue31FullAutoAdjunctError::InvalidJson)?;
1222        for field in ["accountRef", "reasonClass", "outcomeRef", "receiptRef"] {
1223            if let Some(value) = handoff.get(field)
1224                && !value.is_null()
1225            {
1226                object.insert(field.into(), value.clone());
1227            }
1228        }
1229        built.push(entry);
1230    }
1231    Ok(built)
1232}
1233
1234fn normalize_readiness(state: Option<&str>) -> &'static str {
1235    match state {
1236        Some("ready") => "ready",
1237        Some("busy") => "busy",
1238        Some("exhausted") => "exhausted",
1239        Some("rate_limited") => "rate_limited",
1240        Some("revoked") => "revoked",
1241        _ => "unknown",
1242    }
1243}
1244
1245fn normalize_quota(state: Option<&str>) -> &'static str {
1246    match state {
1247        Some("available") => "available",
1248        Some("cooling") => "cooling",
1249        Some("depleted") => "depleted",
1250        _ => "unknown",
1251    }
1252}
1253
1254/// Project each (report, receipt) pair through the same rules the omega#43
1255/// inspector uses, and fail closed to `unavailable` when the pair does not
1256/// form one complete chain.
1257fn build_evidence(pairs: &[(serde_json::Value, serde_json::Value)]) -> Vec<serde_json::Value> {
1258    pairs
1259        .iter()
1260        .map(|(report, receipt)| {
1261            let run_ref = report
1262                .get("runRef")
1263                .and_then(serde_json::Value::as_str)
1264                .unwrap_or("run.unknown");
1265            match complete_chain(report, receipt) {
1266                Some(chain) => chain,
1267                None => serde_json::json!({
1268                    "completeness": "unavailable",
1269                    "runRef": run_ref,
1270                    "reasonClass": unavailable_reason(report, receipt),
1271                }),
1272            }
1273        })
1274        .collect()
1275}
1276
1277/// Project one `(get_report, get_receipt)` pair into the chain it forms.
1278///
1279/// The single-pair entry point to the producer `build_evidence` already runs,
1280/// exposed so a surface that shows one run's chain — the desktop thread link
1281/// in omega#80 — reads the same chain the phone reads instead of assembling a
1282/// second opinion. There is deliberately no second implementation here: the
1283/// body emits through `build_evidence` and reads the result back through
1284/// `project_evidence`, which is the decoder the adjunct itself goes through.
1285/// That is what makes the public-safety bound non-negotiable at this entry
1286/// point too — a hop the decoder refuses cannot be returned as a chain.
1287///
1288/// `run_ref_override` is the reference the *caller already proved* public-safe,
1289/// used when the pair is unavailable. `build_evidence` would otherwise echo the
1290/// report's own `runRef`, and a report whose reference fails the bound would
1291/// turn a reportable refusal into an undecodable one.
1292///
1293/// # Errors
1294///
1295/// Returns the decoder's error when the host produced a chain this surface is
1296/// not allowed to carry — an unbounded detail, or a hop reference outside the
1297/// public-reference character class. The caller's honest rendering of that is
1298/// [`Issue31EvidenceUnavailableReason::HopPrivate`]: the host has the step and
1299/// this surface may not show it.
1300pub fn project_issue31_evidence_pair(
1301    report: &serde_json::Value,
1302    receipt: &serde_json::Value,
1303    run_ref_override: Option<&str>,
1304) -> AdjunctResult<Issue31EvidenceChain> {
1305    let pair = [(report.clone(), receipt.clone())];
1306    let mut emitted = build_evidence(&pair)
1307        .pop()
1308        .ok_or(Issue31FullAutoAdjunctError::InvalidEvidenceChain)?;
1309    if let Some(run_ref) = run_ref_override
1310        && emitted
1311            .get("completeness")
1312            .and_then(serde_json::Value::as_str)
1313            == Some("unavailable")
1314        && let Some(object) = emitted.as_object_mut()
1315    {
1316        object.insert("runRef".into(), serde_json::json!(run_ref));
1317    }
1318    let raw: RawEvidence = serde_json::from_value(emitted)
1319        .map_err(|_| Issue31FullAutoAdjunctError::InvalidEvidenceChain)?;
1320    project_evidence(raw)
1321}
1322
1323/// The hops the omega#43 report and the authority receipt both carry. They must
1324/// agree on all of them for the pair to be one chain.
1325const SHARED_EVIDENCE_HOP_FIELDS: [&str; 4] =
1326    ["objectiveRef", "turnRef", "changeRef", "verificationRef"];
1327
1328fn unavailable_reason(report: &serde_json::Value, receipt: &serde_json::Value) -> &'static str {
1329    let Some(evidence) = report.get("evidence") else {
1330        return "hop_missing";
1331    };
1332    // Disagreement on any hop the two records share is two stories about one
1333    // run, not a hop the host failed to produce. Checking only `runRef` here
1334    // would report a contradicted chain as merely incomplete.
1335    if report.get("runRef") != receipt.get("runRef")
1336        || SHARED_EVIDENCE_HOP_FIELDS.iter().any(|field| {
1337            let reported = evidence.get(field);
1338            let received = receipt.get(field);
1339            reported.is_some() && received.is_some() && reported != received
1340        })
1341    {
1342        return "hop_mismatched";
1343    }
1344    if evidence
1345        .get("hostExecuted")
1346        .and_then(serde_json::Value::as_bool)
1347        != Some(true)
1348    {
1349        return "self_reported";
1350    }
1351    // A hop the host recorded but cannot show publicly is not a missing hop.
1352    // Naming it `hop_private` keeps the owner's two situations distinct: the
1353    // host never produced this step, versus the host produced it and this
1354    // surface is not allowed to carry it.
1355    if carries_private_hop(evidence) || carries_private_hop(receipt) {
1356        return "hop_private";
1357    }
1358    "hop_missing"
1359}
1360
1361/// True when any owner-facing hop value on this record would be refused by the
1362/// contract's public-text rule.
1363fn carries_private_hop(record: &serde_json::Value) -> bool {
1364    record
1365        .as_object()
1366        .into_iter()
1367        .flatten()
1368        .filter_map(|(_, value)| value.as_str())
1369        .any(|value| !is_issue31_public_text(value, MAX_PUBLIC_TEXT))
1370}
1371
1372fn complete_chain(
1373    report: &serde_json::Value,
1374    receipt: &serde_json::Value,
1375) -> Option<serde_json::Value> {
1376    let run_ref = report.get("runRef")?.as_str()?;
1377    if receipt.get("runRef")?.as_str()? != run_ref {
1378        return None;
1379    }
1380    let evidence = report.get("evidence")?;
1381    if evidence
1382        .get("hostExecuted")
1383        .and_then(serde_json::Value::as_bool)
1384        != Some(true)
1385    {
1386        return None;
1387    }
1388    // A private hop makes this one chain unavailable. Letting it through to the
1389    // decoder instead would abort the whole projection, so a single unshowable
1390    // detail would hide every other run the owner is entitled to see.
1391    if carries_private_hop(evidence) || carries_private_hop(receipt) {
1392        return None;
1393    }
1394    // The receipt must agree with the report on every shared hop, or the chain
1395    // is two stories rather than one.
1396    for field in SHARED_EVIDENCE_HOP_FIELDS {
1397        if receipt.get(field)? != evidence.get(field)? {
1398            return None;
1399        }
1400    }
1401    let hop = |kind: &str, value: Option<&serde_json::Value>| -> Option<serde_json::Value> {
1402        Some(serde_json::json!({ "kind": kind, "ref": value?.as_str()? }))
1403    };
1404    let detailed =
1405        |kind: &str, value: Option<&serde_json::Value>, detail: Option<&serde_json::Value>| {
1406            let mut entry = serde_json::json!({ "kind": kind, "ref": value?.as_str()? });
1407            if let Some(detail) = detail.and_then(serde_json::Value::as_str) {
1408                entry
1409                    .as_object_mut()?
1410                    .insert("detail".into(), serde_json::json!(detail));
1411            }
1412            Some(entry)
1413        };
1414
1415    let hops = vec![
1416        hop("objective", evidence.get("objectiveRef"))?,
1417        hop("turn", evidence.get("turnRef"))?,
1418        detailed(
1419            "change",
1420            evidence.get("changeRef"),
1421            evidence.get("diffSummary"),
1422        )?,
1423        hop("project_generation", evidence.get("projectGeneration"))?,
1424        detailed(
1425            "test",
1426            evidence
1427                .get("testRef")
1428                .or_else(|| evidence.get("verificationRef")),
1429            evidence.get("testCommand"),
1430        )?,
1431        hop("typed_outcome", evidence.get("testOutcome"))?,
1432        hop("host_verification", evidence.get("verificationRef"))?,
1433        hop("authority_decision", receipt.get("decisionRef"))?,
1434        hop("receipt", receipt.get("authorityReceiptRef"))?,
1435    ];
1436
1437    Some(serde_json::json!({
1438        "completeness": "complete",
1439        "runRef": run_ref,
1440        "hostExecuted": true,
1441        "authorityAllowed": receipt.get("allowed").and_then(serde_json::Value::as_bool)?,
1442        "hops": hops,
1443    }))
1444}
1445
1446#[cfg(test)]
1447mod emitter_tests {
1448    use super::*;
1449    use serde_json::json;
1450
1451    const NOW: u64 = 1_784_894_400_000;
1452
1453    fn runs() -> serde_json::Value {
1454        json!({"runs": [
1455            {
1456                "runRef": "run.full-auto.run-01",
1457                "objective": "Finish the issue 31 mobile workroom.",
1458                "lane": "lane.codex-local",
1459                "state": "running",
1460                "generation": 7,
1461                "startedAtMs": NOW - 5_400_000,
1462                "liveWorkRef": "work.run-01.unit-14",
1463                "permittedControls": ["pause", "stop"]
1464            }
1465        ]})
1466    }
1467
1468    fn accounts() -> serde_json::Value {
1469        json!({"accounts": [
1470            {"accountRef":"account.codex.1","provider":"openai","label":"ChatGPT Personal","state":"busy","quotaState":"available","lane":"lane.codex-local"}
1471        ]})
1472    }
1473
1474    fn build(
1475        runs: &serde_json::Value,
1476        accounts: &serde_json::Value,
1477        evidence: &[(serde_json::Value, serde_json::Value)],
1478    ) -> AdjunctResult<Issue31FullAutoAdjunct> {
1479        build_issue31_full_auto_adjunct(
1480            "host.omega.device-alpha",
1481            "snapshot.omega.issue31.000042",
1482            NOW,
1483            runs,
1484            accounts,
1485            &json!({}),
1486            evidence,
1487        )
1488    }
1489
1490    #[test]
1491    fn builds_from_the_same_records_the_panels_read() {
1492        let adjunct = build(&runs(), &accounts(), &[]).expect("emits a valid adjunct");
1493        assert_eq!(adjunct.runs.len(), 1);
1494        let run = &adjunct.runs[0];
1495        assert_eq!(run.lifecycle, Issue31FullAutoLifecycle::Running);
1496        assert_eq!(run.unattended_ms, 5_400_000);
1497        assert_eq!(run.controls.len(), 2);
1498        assert!(
1499            run.controls
1500                .iter()
1501                .all(|control| control.run_generation == 7)
1502        );
1503        assert_eq!(adjunct.accounts[0].lane_ref.as_str(), "lane.codex-local");
1504    }
1505
1506    #[test]
1507    fn refuses_a_state_this_build_does_not_understand() {
1508        let mut value = runs();
1509        value["runs"][0]["state"] = json!("vibing");
1510        // Folding an unknown state into `running` would report a run as healthy
1511        // on the strength of a string nobody has defined.
1512        assert_eq!(
1513            build(&value, &accounts(), &[]).expect_err("must refuse"),
1514            Issue31FullAutoAdjunctError::InvalidRunState
1515        );
1516    }
1517
1518    #[test]
1519    fn a_stalled_run_is_not_reported_as_running() {
1520        let mut value = runs();
1521        value["runs"][0]["state"] = json!("stalled");
1522        let adjunct = build(&value, &accounts(), &[]).expect("stalled is a known state");
1523        assert_eq!(adjunct.runs[0].lifecycle, Issue31FullAutoLifecycle::Stalled);
1524        assert!(!adjunct.runs[0].lifecycle.is_terminal());
1525    }
1526
1527    #[test]
1528    fn a_finished_run_loses_its_controls_and_gains_a_reason() {
1529        let mut value = runs();
1530        value["runs"][0]["state"] = json!("succeeded");
1531        let adjunct = build(&value, &accounts(), &[]).expect("emits");
1532        let run = &adjunct.runs[0];
1533        assert!(run.controls.is_empty());
1534        assert!(run.live_work_ref.is_none());
1535        assert_eq!(
1536            run.terminal_reason_ref.as_ref().map(PublicRef::as_str),
1537            Some("reason.full-auto.unrecorded")
1538        );
1539    }
1540
1541    #[test]
1542    fn an_account_without_a_lane_is_refused_rather_than_shown_unmapped() {
1543        let mut value = accounts();
1544        value["accounts"][0]
1545            .as_object_mut()
1546            .expect("object")
1547            .remove("lane");
1548        assert!(build(&runs(), &value, &[]).is_err());
1549    }
1550
1551    #[test]
1552    fn a_credential_shaped_label_cannot_be_emitted() {
1553        let mut value = accounts();
1554        value["accounts"][0]["label"] = json!("Bearer sk-live-abc");
1555        assert_eq!(
1556            build(&runs(), &value, &[]).expect_err("must refuse"),
1557            Issue31FullAutoAdjunctError::UnsafeText
1558        );
1559    }
1560
1561    fn evidence_pair() -> (serde_json::Value, serde_json::Value) {
1562        (
1563            json!({
1564                "runRef": "run.full-auto.run-01",
1565                "evidence": {
1566                    "objectiveRef": "objective.run-01",
1567                    "turnRef": "turn.run-01.11",
1568                    "changeRef": "change.run-01.11",
1569                    "projectGeneration": "generation.project.00219",
1570                    "verificationRef": "verification.run-01.11",
1571                    "testOutcome": "outcome.test.passed",
1572                    "testCommand": "cargo test -p workroom_receipts",
1573                    "diffSummary": "3 files changed",
1574                    "hostExecuted": true
1575                }
1576            }),
1577            json!({
1578                "runRef": "run.full-auto.run-01",
1579                "objectiveRef": "objective.run-01",
1580                "turnRef": "turn.run-01.11",
1581                "changeRef": "change.run-01.11",
1582                "verificationRef": "verification.run-01.11",
1583                "decisionRef": "decision.run-01.11",
1584                "authorityReceiptRef": "receipt.run-01.11",
1585                "allowed": true
1586            }),
1587        )
1588    }
1589
1590    #[test]
1591    fn projects_one_complete_chain_in_the_normative_order() {
1592        let adjunct = build(&runs(), &accounts(), &[evidence_pair()]).expect("emits");
1593        match &adjunct.evidence[0] {
1594            Issue31EvidenceChain::Complete { hops, .. } => {
1595                let kinds: Vec<Issue31EvidenceHopKind> = hops.iter().map(|hop| hop.kind).collect();
1596                assert_eq!(kinds, ISSUE31_EVIDENCE_HOPS.to_vec());
1597            }
1598            Issue31EvidenceChain::Unavailable { .. } => panic!("expected a complete chain"),
1599        }
1600    }
1601
1602    #[test]
1603    fn a_self_reported_chain_fails_closed_to_unavailable() {
1604        let (mut report, receipt) = evidence_pair();
1605        report["evidence"]["hostExecuted"] = json!(false);
1606        let adjunct = build(&runs(), &accounts(), &[(report, receipt)]).expect("emits");
1607        match &adjunct.evidence[0] {
1608            Issue31EvidenceChain::Unavailable { reason, .. } => {
1609                assert_eq!(*reason, Issue31EvidenceUnavailableReason::SelfReported);
1610            }
1611            Issue31EvidenceChain::Complete { .. } => {
1612                panic!("a run reporting its own success is not verified")
1613            }
1614        }
1615    }
1616
1617    #[test]
1618    fn a_receipt_that_disagrees_with_the_report_fails_closed() {
1619        let (report, mut receipt) = evidence_pair();
1620        receipt["changeRef"] = json!("change.someone-elses-work");
1621        let adjunct = build(&runs(), &accounts(), &[(report, receipt)]).expect("emits");
1622        // Two stories about one run is not a chain.
1623        assert!(matches!(
1624            adjunct.evidence[0],
1625            Issue31EvidenceChain::Unavailable { .. }
1626        ));
1627    }
1628
1629    fn unavailable_reason_of(adjunct: &Issue31FullAutoAdjunct) -> Issue31EvidenceUnavailableReason {
1630        match &adjunct.evidence[0] {
1631            Issue31EvidenceChain::Unavailable { reason, .. } => *reason,
1632            Issue31EvidenceChain::Complete { .. } => {
1633                panic!("a broken chain must never render as complete")
1634            }
1635        }
1636    }
1637
1638    /// Falsification of every way omega#47 says a chain can be a story rather
1639    /// than a proof. Each is watched refusing: a fail-closed path nobody saw
1640    /// refuse is not proven to fail closed.
1641    #[test]
1642    fn each_broken_evidence_hop_is_refused_with_its_own_reason() {
1643        let (mut report, receipt) = evidence_pair();
1644        report["evidence"]
1645            .as_object_mut()
1646            .expect("evidence object")
1647            .remove("turnRef");
1648        let missing = build(&runs(), &accounts(), &[(report, receipt)]).expect("emits");
1649        assert_eq!(
1650            unavailable_reason_of(&missing),
1651            Issue31EvidenceUnavailableReason::HopMissing
1652        );
1653
1654        let (report, mut receipt) = evidence_pair();
1655        receipt["turnRef"] = json!("turn.some-other-run.3");
1656        let mismatched = build(&runs(), &accounts(), &[(report, receipt)]).expect("emits");
1657        assert_eq!(
1658            unavailable_reason_of(&mismatched),
1659            Issue31EvidenceUnavailableReason::HopMismatched
1660        );
1661
1662        let (mut report, receipt) = evidence_pair();
1663        report["evidence"]["testCommand"] = json!("cat /Users/owner/.codex/auth.json");
1664        let private = build(&runs(), &accounts(), &[(report, receipt)]).expect("emits");
1665        assert_eq!(
1666            unavailable_reason_of(&private),
1667            Issue31EvidenceUnavailableReason::HopPrivate
1668        );
1669
1670        let (mut report, receipt) = evidence_pair();
1671        report["evidence"]["hostExecuted"] = json!(false);
1672        let self_reported = build(&runs(), &accounts(), &[(report, receipt)]).expect("emits");
1673        assert_eq!(
1674            unavailable_reason_of(&self_reported),
1675            Issue31EvidenceUnavailableReason::SelfReported
1676        );
1677    }
1678
1679    #[test]
1680    fn one_unshowable_hop_never_hides_the_runs_beside_it() {
1681        let (mut report, receipt) = evidence_pair();
1682        report["evidence"]["diffSummary"] = json!("wrote /Users/owner/.codex/auth.json");
1683        let adjunct = build(&runs(), &accounts(), &[(report, receipt)]).expect(
1684            "a private hop makes one chain unavailable, it does not abort the whole projection",
1685        );
1686        assert_eq!(adjunct.runs.len(), 1);
1687        assert_eq!(
1688            unavailable_reason_of(&adjunct),
1689            Issue31EvidenceUnavailableReason::HopPrivate
1690        );
1691    }
1692
1693    #[test]
1694    fn a_run_whose_start_the_host_never_recorded_is_refused_not_shown_as_zero() {
1695        let mut value = runs();
1696        value["runs"][0]
1697            .as_object_mut()
1698            .expect("run object")
1699            .remove("startedAtMs");
1700        // The exact unattended duration is part of this issue's outcome. Zero
1701        // would read on the phone as "just started".
1702        assert_eq!(
1703            build(&value, &accounts(), &[]).expect_err("must refuse"),
1704            Issue31FullAutoAdjunctError::InvalidRunState
1705        );
1706    }
1707
1708    #[test]
1709    fn a_terminal_handoff_without_a_host_outcome_cannot_be_emitted() {
1710        let handoffs = json!({"handoffs": [{
1711            "handoffRef": "handoff.codex.1",
1712            "provider": "openai",
1713            "state": "completed",
1714            "requestedAtMs": NOW - 60_000,
1715            "accountRef": "account.codex.1"
1716        }]});
1717        let error = build_issue31_full_auto_adjunct(
1718            "host.omega.device-alpha",
1719            "snapshot.omega.issue31.000042",
1720            NOW,
1721            &runs(),
1722            &accounts(),
1723            &handoffs,
1724            &[],
1725        )
1726        .expect_err("a completed handoff with no host outcome is a claim the host never made");
1727        assert_eq!(error, Issue31FullAutoAdjunctError::InvalidHandoffState);
1728    }
1729
1730    #[test]
1731    fn a_private_field_on_a_host_handoff_record_never_reaches_the_contract() {
1732        let handoffs = json!({"handoffs": [{
1733            "handoffRef": "handoff.codex.1",
1734            "provider": "openai",
1735            "state": "completed",
1736            "requestedAtMs": NOW - 60_000,
1737            "accountRef": "account.codex.1",
1738            "outcomeRef": "outcome.handoff.linked",
1739            // The host record legitimately holds these. The phone never may.
1740            "providerHome": "/Users/owner/.pylon/accounts/codex/codex-2",
1741            "authorizationResponse": "Bearer sk-live-0000"
1742        }]});
1743        let adjunct = build_issue31_full_auto_adjunct(
1744            "host.omega.device-alpha",
1745            "snapshot.omega.issue31.000042",
1746            NOW,
1747            &runs(),
1748            &accounts(),
1749            &handoffs,
1750            &[],
1751        )
1752        .expect("admitted fields are projected, private ones are not carried");
1753        assert_eq!(adjunct.handoffs.len(), 1);
1754        let encoded = serde_json::to_string(&serde_json::json!({
1755            "handoffRef": adjunct.handoffs[0].handoff_ref.as_str(),
1756            "outcomeRef": adjunct.handoffs[0].outcome_ref.as_ref().map(PublicRef::as_str),
1757        }))
1758        .expect("encodes");
1759        assert!(!encoded.contains("/Users/"));
1760        assert!(!encoded.contains("Bearer"));
1761    }
1762
1763    /// The tokens a renderer labels hops with are the tokens the contract
1764    /// serialises.
1765    ///
1766    /// A second name for the same hop is how a desktop surface and a phone
1767    /// end up describing one chain differently, so the two are compared
1768    /// rather than trusted to have been written consistently.
1769    #[test]
1770    fn hop_tokens_are_the_wire_tokens() {
1771        for kind in ISSUE31_EVIDENCE_HOPS {
1772            let wire = serde_json::to_value(kind).expect("hop kind serialises");
1773            assert_eq!(wire.as_str(), Some(kind.token()), "{kind:?}");
1774        }
1775        for reason in Issue31EvidenceUnavailableReason::all() {
1776            let wire = serde_json::to_value(reason).expect("reason serialises");
1777            assert_eq!(wire.as_str(), Some(reason.token()), "{reason:?}");
1778        }
1779        assert_eq!(
1780            Issue31EvidenceUnavailableReason::all().len(),
1781            5,
1782            "the reason vocabulary is byte-shared with packages/sarah. Adding \
1783             a sixth reason is a contract change, not a rendering convenience."
1784        );
1785    }
1786
1787    /// One pair projects to the same chain through the single-pair entry point
1788    /// as through the whole adjunct.
1789    ///
1790    /// The point of the entry point is that omega#80's thread link reads the
1791    /// chain the phone reads. A second implementation would satisfy every
1792    /// other test in this file and still disagree here.
1793    #[test]
1794    fn the_single_pair_entry_point_agrees_with_the_adjunct() {
1795        let (report, receipt) = evidence_pair();
1796        let through_adjunct = build(&runs(), &accounts(), &[(report.clone(), receipt.clone())])
1797            .expect("emits")
1798            .evidence
1799            .remove(0);
1800        let direct = project_issue31_evidence_pair(&report, &receipt, None).expect("projects");
1801        assert_eq!(direct, through_adjunct);
1802        assert!(matches!(direct, Issue31EvidenceChain::Complete { .. }));
1803    }
1804
1805    /// Every refusal reason survives the single-pair entry point, named.
1806    ///
1807    /// Falsified one at a time: a surface that reports "unavailable" without
1808    /// the reason has told the reader nothing they can act on, and a
1809    /// contradicted chain reported as merely incomplete is the specific bug
1810    /// this vocabulary exists to prevent.
1811    #[test]
1812    fn the_single_pair_entry_point_names_each_refusal() {
1813        let reason_of = |report: &serde_json::Value, receipt: &serde_json::Value| {
1814            match project_issue31_evidence_pair(report, receipt, None).expect("projects") {
1815                Issue31EvidenceChain::Unavailable { reason, .. } => reason,
1816                Issue31EvidenceChain::Complete { .. } => {
1817                    panic!("a broken chain must never render as complete")
1818                }
1819            }
1820        };
1821
1822        let (mut report, receipt) = evidence_pair();
1823        report["evidence"]
1824            .as_object_mut()
1825            .expect("evidence object")
1826            .remove("changeRef");
1827        assert_eq!(
1828            reason_of(&report, &receipt),
1829            Issue31EvidenceUnavailableReason::HopMissing
1830        );
1831
1832        let (report, mut receipt) = evidence_pair();
1833        receipt["changeRef"] = json!("change.someone-elses-work");
1834        assert_eq!(
1835            reason_of(&report, &receipt),
1836            Issue31EvidenceUnavailableReason::HopMismatched
1837        );
1838
1839        let (mut report, receipt) = evidence_pair();
1840        report["evidence"]["testCommand"] = json!("cat /Users/owner/.codex/auth.json");
1841        assert_eq!(
1842            reason_of(&report, &receipt),
1843            Issue31EvidenceUnavailableReason::HopPrivate
1844        );
1845
1846        let (mut report, receipt) = evidence_pair();
1847        report["evidence"]["hostExecuted"] = json!(false);
1848        assert_eq!(
1849            reason_of(&report, &receipt),
1850            Issue31EvidenceUnavailableReason::SelfReported
1851        );
1852    }
1853
1854    /// A report whose own `runRef` is not public-safe still produces a
1855    /// reportable refusal, because the caller supplies the reference it
1856    /// already proved.
1857    ///
1858    /// Without the override this pair decodes to nothing at all, and a surface
1859    /// with nothing to render shows the reader an empty space where a refusal
1860    /// belongs.
1861    #[test]
1862    fn an_unsafe_report_reference_still_yields_a_named_refusal() {
1863        let (mut report, mut receipt) = evidence_pair();
1864        // Both records agree on the reference, so this is not a mismatch. The
1865        // host simply names the run in a way this surface may not repeat.
1866        report["runRef"] = json!("/Users/owner/runs/17");
1867        receipt["runRef"] = json!("/Users/owner/runs/17");
1868        report["evidence"]["hostExecuted"] = json!(false);
1869
1870        assert_eq!(
1871            project_issue31_evidence_pair(&report, &receipt, None),
1872            Err(Issue31FullAutoAdjunctError::UnsafeReference),
1873            "without a proven reference there is nothing safe to name the \
1874             refusal after"
1875        );
1876
1877        let named = project_issue31_evidence_pair(&report, &receipt, Some("run.full-auto.run-01"))
1878            .expect("the caller's proven reference names the refusal");
1879        match named {
1880            Issue31EvidenceChain::Unavailable {
1881                run_ref, reason, ..
1882            } => {
1883                assert_eq!(run_ref.as_str(), "run.full-auto.run-01");
1884                assert_eq!(reason, Issue31EvidenceUnavailableReason::SelfReported);
1885            }
1886            Issue31EvidenceChain::Complete { .. } => panic!("self-reported is not complete"),
1887        }
1888    }
1889
1890    /// The override never rewrites a complete chain's identity.
1891    ///
1892    /// If it did, a chain proving one run could be relabelled as another
1893    /// run's proof by the surface displaying it, which is the forgery the
1894    /// whole hop-mismatch rule exists to catch.
1895    #[test]
1896    fn the_override_cannot_relabel_a_complete_chain() {
1897        let (report, receipt) = evidence_pair();
1898        let chain = project_issue31_evidence_pair(&report, &receipt, Some("run.some.other.run"))
1899            .expect("projects");
1900        assert_eq!(chain.run_ref().as_str(), "run.full-auto.run-01");
1901    }
1902
1903    /// A chain the host produced but this surface may not carry is an error,
1904    /// not a complete chain and not a silent drop.
1905    ///
1906    /// The detail bound at decode (256) is tighter than the record scan
1907    /// (512), so a long-but-clean test command passes `carries_private_hop`
1908    /// and is then refused by the decoder. The caller's honest rendering of
1909    /// that error is `hop_private`.
1910    #[test]
1911    fn a_detail_past_the_carried_bound_is_refused_rather_than_shown() {
1912        let (mut report, receipt) = evidence_pair();
1913        report["evidence"]["testCommand"] = json!(format!("cargo test -p {}", "x".repeat(300)));
1914        assert_eq!(
1915            project_issue31_evidence_pair(&report, &receipt, Some("run.full-auto.run-01")),
1916            Err(Issue31FullAutoAdjunctError::UnsafeText)
1917        );
1918    }
1919}
1920
Served at tenant.openagents/omega Member data and write actions are omitted.