Skip to repository content1920 lines · 71.1 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:06:56.681Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
issue31_full_auto.rs
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