Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:03:55.045Z 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_host.rs

1206 lines · 44.9 KB · rust
1use std::collections::HashSet;
2
3use serde::{Deserialize, Serialize};
4
5use crate::{PublicRef, sanitize_public_ref};
6
7pub const ISSUE31_HOST_ADJUNCT_SCHEMA: &str = "openagents.omega.issue31.host.v1";
8pub const MAX_ISSUE31_PROJECTION_REFS: usize = 16;
9pub const MAX_ISSUE31_TIMESTAMP_MS: u64 = 8_640_000_000_000_000;
10const ISSUE31_PROJECTION_COUNT: usize = 4;
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum Issue31ProjectionCapability {
15    ConnectionIdentity,
16    FullAutoRuns,
17    ProviderAccounts,
18    EvidenceChain,
19}
20
21impl Issue31ProjectionCapability {
22    const ALL: [Self; ISSUE31_PROJECTION_COUNT] = [
23        Self::ConnectionIdentity,
24        Self::FullAutoRuns,
25        Self::ProviderAccounts,
26        Self::EvidenceChain,
27    ];
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum Issue31SourceKind {
33    OmegaHost,
34}
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum ProjectionFreshness {
39    Current,
40    Stale,
41    Unknown,
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum Issue31Gap {
47    Complete,
48    Partial,
49    Missing,
50    Unavailable,
51}
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum Issue31RoleKind {
56    Owner,
57    Member,
58    Verifier,
59    Observer,
60}
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum Issue31RoleStatus {
65    Active,
66    Revoked,
67    Unknown,
68}
69
70#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum Issue31TerminalState {
73    Succeeded,
74    Failed,
75    Stopped,
76    Unavailable,
77}
78
79#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
80#[serde(rename_all = "camelCase")]
81pub struct Issue31ProjectionSource {
82    pub kind: Issue31SourceKind,
83    pub source_ref: PublicRef,
84    pub observed_at_ms: u64,
85}
86
87#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
88#[serde(rename_all = "camelCase")]
89pub struct Issue31Role {
90    pub kind: Issue31RoleKind,
91    pub status: Issue31RoleStatus,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub grant_ref: Option<PublicRef>,
94}
95
96#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
97#[serde(tag = "kind", rename_all = "snake_case")]
98pub enum Issue31CommandState {
99    Idle,
100    Pending {
101        #[serde(rename = "intentRef")]
102        intent_ref: PublicRef,
103        #[serde(rename = "actionRef")]
104        action_ref: PublicRef,
105    },
106    Refused {
107        #[serde(rename = "intentRef")]
108        intent_ref: PublicRef,
109        #[serde(rename = "actionRef")]
110        action_ref: PublicRef,
111        #[serde(rename = "reasonClass")]
112        reason_class: PublicRef,
113        #[serde(rename = "decisionRef")]
114        decision_ref: PublicRef,
115        #[serde(rename = "receiptRef", skip_serializing_if = "Option::is_none")]
116        receipt_ref: Option<PublicRef>,
117    },
118    Terminal {
119        #[serde(rename = "intentRef")]
120        intent_ref: PublicRef,
121        #[serde(rename = "actionRef")]
122        action_ref: PublicRef,
123        state: Issue31TerminalState,
124        #[serde(rename = "outcomeRef")]
125        outcome_ref: PublicRef,
126        #[serde(rename = "reasonRef", skip_serializing_if = "Option::is_none")]
127        reason_ref: Option<PublicRef>,
128        #[serde(rename = "receiptRef", skip_serializing_if = "Option::is_none")]
129        receipt_ref: Option<PublicRef>,
130    },
131}
132
133#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
134#[serde(rename_all = "camelCase")]
135pub struct Issue31HostProjection {
136    pub capability: Issue31ProjectionCapability,
137    pub source: Issue31ProjectionSource,
138    pub freshness: ProjectionFreshness,
139    pub gap: Issue31Gap,
140    pub role: Issue31Role,
141    pub record_refs: Vec<PublicRef>,
142    pub permitted_action_refs: Vec<PublicRef>,
143    pub command_state: Issue31CommandState,
144}
145
146#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
147#[serde(rename_all = "camelCase")]
148pub struct Issue31HostAdjunct {
149    pub schema: &'static str,
150    pub host_ref: PublicRef,
151    pub snapshot_ref: PublicRef,
152    pub generated_at_ms: u64,
153    pub projections: Vec<Issue31HostProjection>,
154}
155
156#[derive(Clone, Copy, Debug, PartialEq, Eq)]
157pub enum Issue31HostAdjunctError {
158    InvalidJson,
159    InvalidSchema,
160    UnsafeReference,
161    ReferenceBoundExceeded,
162    DuplicateReference,
163    MissingCapability,
164    DuplicateCapability,
165    InvalidTimestamp,
166    InvalidProjectionState,
167    InvalidRoleState,
168    InvalidCommandState,
169}
170
171impl std::fmt::Display for Issue31HostAdjunctError {
172    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        let message = match self {
174            Self::InvalidJson => "issue 31 host adjunct is not valid contract JSON",
175            Self::InvalidSchema => "issue 31 host adjunct schema is not supported",
176            Self::UnsafeReference => "issue 31 host adjunct contains an unsafe reference",
177            Self::ReferenceBoundExceeded => "issue 31 host adjunct reference bound was exceeded",
178            Self::DuplicateReference => "issue 31 host adjunct contains a duplicate reference",
179            Self::MissingCapability => "issue 31 host adjunct is missing a required capability",
180            Self::DuplicateCapability => "issue 31 host adjunct repeats a capability",
181            Self::InvalidTimestamp => "issue 31 host adjunct timestamp order is invalid",
182            Self::InvalidProjectionState => "issue 31 host adjunct projection state is invalid",
183            Self::InvalidRoleState => "issue 31 host adjunct role state is invalid",
184            Self::InvalidCommandState => "issue 31 host adjunct command state is invalid",
185        };
186        formatter.write_str(message)
187    }
188}
189
190impl std::error::Error for Issue31HostAdjunctError {}
191
192#[derive(Deserialize)]
193#[serde(rename_all = "camelCase", deny_unknown_fields)]
194struct RawIssue31HostAdjunct {
195    schema: String,
196    host_ref: String,
197    snapshot_ref: String,
198    generated_at_ms: u64,
199    projections: Vec<RawIssue31HostProjection>,
200}
201
202#[derive(Deserialize)]
203#[serde(rename_all = "camelCase", deny_unknown_fields)]
204struct RawIssue31HostProjection {
205    capability: Issue31ProjectionCapability,
206    source: RawIssue31ProjectionSource,
207    freshness: ProjectionFreshness,
208    gap: Issue31Gap,
209    role: RawIssue31Role,
210    record_refs: Vec<String>,
211    permitted_action_refs: Vec<String>,
212    command_state: RawIssue31CommandState,
213}
214
215#[derive(Deserialize)]
216#[serde(rename_all = "camelCase", deny_unknown_fields)]
217struct RawIssue31ProjectionSource {
218    kind: Issue31SourceKind,
219    source_ref: String,
220    observed_at_ms: u64,
221}
222
223#[derive(Deserialize)]
224#[serde(rename_all = "camelCase", deny_unknown_fields)]
225struct RawIssue31Role {
226    kind: Issue31RoleKind,
227    status: Issue31RoleStatus,
228    grant_ref: Option<String>,
229}
230
231#[derive(Deserialize)]
232#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
233enum RawIssue31CommandState {
234    Idle,
235    Pending {
236        #[serde(rename = "intentRef")]
237        intent_ref: String,
238        #[serde(rename = "actionRef")]
239        action_ref: String,
240    },
241    Refused {
242        #[serde(rename = "intentRef")]
243        intent_ref: String,
244        #[serde(rename = "actionRef")]
245        action_ref: String,
246        #[serde(rename = "reasonClass")]
247        reason_class: String,
248        #[serde(rename = "decisionRef")]
249        decision_ref: String,
250        #[serde(rename = "receiptRef")]
251        receipt_ref: Option<String>,
252    },
253    Terminal {
254        #[serde(rename = "intentRef")]
255        intent_ref: String,
256        #[serde(rename = "actionRef")]
257        action_ref: String,
258        state: Issue31TerminalState,
259        #[serde(rename = "outcomeRef")]
260        outcome_ref: String,
261        #[serde(rename = "reasonRef")]
262        reason_ref: Option<String>,
263        #[serde(rename = "receiptRef")]
264        receipt_ref: Option<String>,
265    },
266}
267
268pub fn decode_issue31_host_adjunct(
269    input: &str,
270) -> Result<Issue31HostAdjunct, Issue31HostAdjunctError> {
271    let raw: RawIssue31HostAdjunct =
272        serde_json::from_str(input).map_err(|_| Issue31HostAdjunctError::InvalidJson)?;
273    if raw.schema != ISSUE31_HOST_ADJUNCT_SCHEMA {
274        return Err(Issue31HostAdjunctError::InvalidSchema);
275    }
276    let host_ref = public_ref(raw.host_ref)?;
277    let snapshot_ref = public_ref(raw.snapshot_ref)?;
278    if raw.generated_at_ms > MAX_ISSUE31_TIMESTAMP_MS {
279        return Err(Issue31HostAdjunctError::InvalidTimestamp);
280    }
281    if raw.projections.len() != ISSUE31_PROJECTION_COUNT {
282        return Err(Issue31HostAdjunctError::MissingCapability);
283    }
284
285    let mut capabilities = HashSet::with_capacity(ISSUE31_PROJECTION_COUNT);
286    let mut projections = Vec::with_capacity(ISSUE31_PROJECTION_COUNT);
287    for projection in raw.projections {
288        if !capabilities.insert(projection.capability) {
289            return Err(Issue31HostAdjunctError::DuplicateCapability);
290        }
291        projections.push(project_projection(projection, raw.generated_at_ms)?);
292    }
293    if Issue31ProjectionCapability::ALL
294        .iter()
295        .any(|capability| !capabilities.contains(capability))
296    {
297        return Err(Issue31HostAdjunctError::MissingCapability);
298    }
299
300    Ok(Issue31HostAdjunct {
301        schema: ISSUE31_HOST_ADJUNCT_SCHEMA,
302        host_ref,
303        snapshot_ref,
304        generated_at_ms: raw.generated_at_ms,
305        projections,
306    })
307}
308
309fn project_projection(
310    raw: RawIssue31HostProjection,
311    generated_at_ms: u64,
312) -> Result<Issue31HostProjection, Issue31HostAdjunctError> {
313    if raw.source.observed_at_ms > generated_at_ms
314        || raw.source.observed_at_ms > MAX_ISSUE31_TIMESTAMP_MS
315    {
316        return Err(Issue31HostAdjunctError::InvalidTimestamp);
317    }
318
319    let source = Issue31ProjectionSource {
320        kind: raw.source.kind,
321        source_ref: public_ref(raw.source.source_ref)?,
322        observed_at_ms: raw.source.observed_at_ms,
323    };
324    let role = Issue31Role {
325        kind: raw.role.kind,
326        status: raw.role.status,
327        grant_ref: raw.role.grant_ref.map(public_ref).transpose()?,
328    };
329    let record_refs = project_ref_list(raw.record_refs)?;
330    let permitted_action_refs = project_ref_list(raw.permitted_action_refs)?;
331    let command_state = project_command_state(raw.command_state)?;
332
333    validate_source_state(
334        raw.freshness,
335        raw.gap,
336        &record_refs,
337        &permitted_action_refs,
338        &command_state,
339    )?;
340    validate_role_state(&role, &permitted_action_refs, &command_state)?;
341    validate_command_state(&permitted_action_refs, &command_state)?;
342
343    Ok(Issue31HostProjection {
344        capability: raw.capability,
345        source,
346        freshness: raw.freshness,
347        gap: raw.gap,
348        role,
349        record_refs,
350        permitted_action_refs,
351        command_state,
352    })
353}
354
355fn project_command_state(
356    raw: RawIssue31CommandState,
357) -> Result<Issue31CommandState, Issue31HostAdjunctError> {
358    match raw {
359        RawIssue31CommandState::Idle => Ok(Issue31CommandState::Idle),
360        RawIssue31CommandState::Pending {
361            intent_ref,
362            action_ref,
363        } => Ok(Issue31CommandState::Pending {
364            intent_ref: public_ref(intent_ref)?,
365            action_ref: public_ref(action_ref)?,
366        }),
367        RawIssue31CommandState::Refused {
368            intent_ref,
369            action_ref,
370            reason_class,
371            decision_ref,
372            receipt_ref,
373        } => Ok(Issue31CommandState::Refused {
374            intent_ref: public_ref(intent_ref)?,
375            action_ref: public_ref(action_ref)?,
376            reason_class: public_ref(reason_class)?,
377            decision_ref: public_ref(decision_ref)?,
378            receipt_ref: receipt_ref.map(public_ref).transpose()?,
379        }),
380        RawIssue31CommandState::Terminal {
381            intent_ref,
382            action_ref,
383            state,
384            outcome_ref,
385            reason_ref,
386            receipt_ref,
387        } => Ok(Issue31CommandState::Terminal {
388            intent_ref: public_ref(intent_ref)?,
389            action_ref: public_ref(action_ref)?,
390            state,
391            outcome_ref: public_ref(outcome_ref)?,
392            reason_ref: reason_ref.map(public_ref).transpose()?,
393            receipt_ref: receipt_ref.map(public_ref).transpose()?,
394        }),
395    }
396}
397
398fn validate_source_state(
399    freshness: ProjectionFreshness,
400    gap: Issue31Gap,
401    record_refs: &[PublicRef],
402    permitted_action_refs: &[PublicRef],
403    command_state: &Issue31CommandState,
404) -> Result<(), Issue31HostAdjunctError> {
405    match gap {
406        Issue31Gap::Complete | Issue31Gap::Partial => {
407            if freshness == ProjectionFreshness::Unknown {
408                return Err(Issue31HostAdjunctError::InvalidProjectionState);
409            }
410        }
411        Issue31Gap::Missing | Issue31Gap::Unavailable => {
412            if freshness != ProjectionFreshness::Unknown
413                || !record_refs.is_empty()
414                || !permitted_action_refs.is_empty()
415                || !matches!(command_state, Issue31CommandState::Idle)
416            {
417                return Err(Issue31HostAdjunctError::InvalidProjectionState);
418            }
419        }
420    }
421    Ok(())
422}
423
424fn validate_role_state(
425    role: &Issue31Role,
426    permitted_action_refs: &[PublicRef],
427    command_state: &Issue31CommandState,
428) -> Result<(), Issue31HostAdjunctError> {
429    if role.status == Issue31RoleStatus::Active && role.grant_ref.is_none() {
430        return Err(Issue31HostAdjunctError::InvalidRoleState);
431    }
432    if role.status == Issue31RoleStatus::Unknown && role.grant_ref.is_some() {
433        return Err(Issue31HostAdjunctError::InvalidRoleState);
434    }
435    if role.status != Issue31RoleStatus::Active
436        && (!permitted_action_refs.is_empty()
437            || matches!(command_state, Issue31CommandState::Pending { .. }))
438    {
439        return Err(Issue31HostAdjunctError::InvalidRoleState);
440    }
441    Ok(())
442}
443
444fn validate_command_state(
445    permitted_action_refs: &[PublicRef],
446    command_state: &Issue31CommandState,
447) -> Result<(), Issue31HostAdjunctError> {
448    if let Issue31CommandState::Pending { action_ref, .. } = command_state {
449        if !permitted_action_refs.contains(action_ref) {
450            return Err(Issue31HostAdjunctError::InvalidCommandState);
451        }
452    }
453    Ok(())
454}
455
456fn project_ref_list(values: Vec<String>) -> Result<Vec<PublicRef>, Issue31HostAdjunctError> {
457    if values.len() > MAX_ISSUE31_PROJECTION_REFS {
458        return Err(Issue31HostAdjunctError::ReferenceBoundExceeded);
459    }
460    let mut unique = HashSet::with_capacity(values.len());
461    let mut projected = Vec::with_capacity(values.len());
462    for value in values {
463        let value = public_ref(value)?;
464        if !unique.insert(value.clone()) {
465            return Err(Issue31HostAdjunctError::DuplicateReference);
466        }
467        projected.push(value);
468    }
469    Ok(projected)
470}
471
472fn public_ref(raw: String) -> Result<PublicRef, Issue31HostAdjunctError> {
473    if raw != raw.trim() {
474        return Err(Issue31HostAdjunctError::UnsafeReference);
475    }
476    sanitize_public_ref(&raw).ok_or(Issue31HostAdjunctError::UnsafeReference)
477}
478
479// ---------------------------------------------------------------------------
480// Emitter
481// ---------------------------------------------------------------------------
482
483/// The role this host grants the reader for one capability.
484///
485/// An active role must name the grant that made it active. That is enforced at
486/// decode, and this type keeps the emitter from constructing the violation by
487/// accident: `Active` carries its grant, and the other two cannot carry one.
488#[derive(Clone, Copy, Debug, PartialEq, Eq)]
489pub enum Issue31RoleInput<'a> {
490    Active {
491        kind: Issue31RoleKind,
492        grant_ref: &'a str,
493    },
494    Revoked {
495        kind: Issue31RoleKind,
496        grant_ref: &'a str,
497    },
498    /// No grant is known. A role nobody granted cannot cite one.
499    Unknown {
500        kind: Issue31RoleKind,
501    },
502}
503
504/// The state of the reader's most recent command against one capability.
505///
506/// `Terminal` requires the outcome reference that settled it, for the same
507/// reason `completed` does in the Full Auto contract: a caller cannot say
508/// finished without naming what finished it.
509#[derive(Clone, Copy, Debug, PartialEq, Eq)]
510pub enum Issue31CommandStateInput<'a> {
511    Idle,
512    Pending {
513        intent_ref: &'a str,
514        action_ref: &'a str,
515    },
516    Refused {
517        intent_ref: &'a str,
518        action_ref: &'a str,
519        reason_class: &'a str,
520        decision_ref: &'a str,
521        receipt_ref: Option<&'a str>,
522    },
523    Terminal {
524        intent_ref: &'a str,
525        action_ref: &'a str,
526        state: Issue31TerminalState,
527        outcome_ref: &'a str,
528        reason_ref: Option<&'a str>,
529        receipt_ref: Option<&'a str>,
530    },
531}
532
533/// How completely the host observed one capability, when it did observe it.
534#[derive(Clone, Copy, Debug, PartialEq, Eq)]
535pub enum Issue31ObservedGap {
536    Complete,
537    Partial,
538}
539
540/// Why the host could not observe one capability at all.
541#[derive(Clone, Copy, Debug, PartialEq, Eq)]
542pub enum Issue31AbsentGap {
543    /// The capability exists on this host but produced no record.
544    Missing,
545    /// The capability could not be reached.
546    Unavailable,
547}
548
549/// One capability's live host state.
550///
551/// The two variants are the whole point of this type. An `Absent` projection
552/// structurally cannot carry record references, permitted actions, or a command
553/// state, so the emitter cannot produce the incoherent shape the decoder
554/// rejects as `InvalidProjectionState` — a capability the host could not read,
555/// yet which somehow offers the reader a pending action. The decoder still
556/// guards it; this makes writing it impossible one layer earlier.
557pub enum Issue31HostProjectionInput<'a> {
558    Observed {
559        source_ref: &'a str,
560        observed_at_ms: u64,
561        freshness: ProjectionFreshness,
562        gap: Issue31ObservedGap,
563        role: Issue31RoleInput<'a>,
564        record_refs: &'a [&'a str],
565        permitted_action_refs: &'a [&'a str],
566        command_state: Issue31CommandStateInput<'a>,
567    },
568    Absent {
569        source_ref: &'a str,
570        observed_at_ms: u64,
571        gap: Issue31AbsentGap,
572        role: Issue31RoleInput<'a>,
573    },
574}
575
576/// The four capabilities this snapshot projects, named rather than listed.
577///
578/// A `Vec` would let a caller omit one or repeat one, which the decoder refuses
579/// as `MissingCapability` / `DuplicateCapability`. Naming each field makes both
580/// unrepresentable: exactly four projections exist and each carries its own
581/// capability tag, assigned here rather than by the caller.
582pub struct Issue31HostSources<'a> {
583    pub connection_identity: Issue31HostProjectionInput<'a>,
584    pub full_auto_runs: Issue31HostProjectionInput<'a>,
585    pub provider_accounts: Issue31HostProjectionInput<'a>,
586    pub evidence_chain: Issue31HostProjectionInput<'a>,
587}
588
589/// Build the `host.v1` snapshot the omega#47 detail projection is published
590/// beside.
591///
592/// Like `build_issue31_full_auto_adjunct`, this deliberately builds a JSON
593/// document and hands it to `decode_issue31_host_adjunct` rather than
594/// constructing the typed value directly. There is then exactly one place where
595/// the contract's boundaries live, and the emitter is structurally incapable of
596/// producing something the reader would refuse: every refusal a phone could
597/// raise against this snapshot is raised here first, against the same bytes.
598pub fn build_issue31_host_adjunct(
599    host_ref: &str,
600    snapshot_ref: &str,
601    generated_at_ms: u64,
602    sources: &Issue31HostSources<'_>,
603) -> Result<Issue31HostAdjunct, Issue31HostAdjunctError> {
604    build_issue31_host_adjunct_document(host_ref, snapshot_ref, generated_at_ms, sources)
605        .map(|(adjunct, _)| adjunct)
606}
607
608/// The same snapshot, plus the exact bytes the decoder accepted.
609///
610/// A caller that has to put the snapshot on a wire would otherwise have to
611/// re-encode the typed value, which is a second serializer that can disagree
612/// with the one the contract validated. Returning the validated document means
613/// what is published is what was checked.
614pub fn build_issue31_host_adjunct_document(
615    host_ref: &str,
616    snapshot_ref: &str,
617    generated_at_ms: u64,
618    sources: &Issue31HostSources<'_>,
619) -> Result<(Issue31HostAdjunct, serde_json::Value), Issue31HostAdjunctError> {
620    let document = serde_json::json!({
621        "schema": ISSUE31_HOST_ADJUNCT_SCHEMA,
622        "hostRef": host_ref,
623        "snapshotRef": snapshot_ref,
624        "generatedAtMs": generated_at_ms,
625        "projections": [
626            build_projection(
627                Issue31ProjectionCapability::ConnectionIdentity,
628                &sources.connection_identity,
629            ),
630            build_projection(
631                Issue31ProjectionCapability::FullAutoRuns,
632                &sources.full_auto_runs,
633            ),
634            build_projection(
635                Issue31ProjectionCapability::ProviderAccounts,
636                &sources.provider_accounts,
637            ),
638            build_projection(
639                Issue31ProjectionCapability::EvidenceChain,
640                &sources.evidence_chain,
641            ),
642        ],
643    });
644    let serialized =
645        serde_json::to_string(&document).map_err(|_| Issue31HostAdjunctError::InvalidJson)?;
646    let adjunct = decode_issue31_host_adjunct(&serialized)?;
647    Ok((adjunct, document))
648}
649
650fn build_projection(
651    capability: Issue31ProjectionCapability,
652    input: &Issue31HostProjectionInput<'_>,
653) -> serde_json::Value {
654    match input {
655        Issue31HostProjectionInput::Observed {
656            source_ref,
657            observed_at_ms,
658            freshness,
659            gap,
660            role,
661            record_refs,
662            permitted_action_refs,
663            command_state,
664        } => serde_json::json!({
665            "capability": capability,
666            "source": {
667                "kind": Issue31SourceKind::OmegaHost,
668                "sourceRef": source_ref,
669                "observedAtMs": observed_at_ms,
670            },
671            "freshness": freshness,
672            "gap": match gap {
673                Issue31ObservedGap::Complete => Issue31Gap::Complete,
674                Issue31ObservedGap::Partial => Issue31Gap::Partial,
675            },
676            "role": build_role(role),
677            "recordRefs": record_refs,
678            "permittedActionRefs": permitted_action_refs,
679            "commandState": build_command_state(command_state),
680        }),
681        // A capability the host could not read offers nothing and claims
682        // nothing: unknown freshness, no records, no actions, an idle command
683        // state. These are not defaults the caller may override — there is no
684        // field to override them with.
685        Issue31HostProjectionInput::Absent {
686            source_ref,
687            observed_at_ms,
688            gap,
689            role,
690        } => serde_json::json!({
691            "capability": capability,
692            "source": {
693                "kind": Issue31SourceKind::OmegaHost,
694                "sourceRef": source_ref,
695                "observedAtMs": observed_at_ms,
696            },
697            "freshness": ProjectionFreshness::Unknown,
698            "gap": match gap {
699                Issue31AbsentGap::Missing => Issue31Gap::Missing,
700                Issue31AbsentGap::Unavailable => Issue31Gap::Unavailable,
701            },
702            "role": build_role(role),
703            "recordRefs": Vec::<&str>::new(),
704            "permittedActionRefs": Vec::<&str>::new(),
705            "commandState": { "kind": "idle" },
706        }),
707    }
708}
709
710fn build_role(role: &Issue31RoleInput<'_>) -> serde_json::Value {
711    match role {
712        Issue31RoleInput::Active { kind, grant_ref } => serde_json::json!({
713            "kind": kind,
714            "status": Issue31RoleStatus::Active,
715            "grantRef": grant_ref,
716        }),
717        Issue31RoleInput::Revoked { kind, grant_ref } => serde_json::json!({
718            "kind": kind,
719            "status": Issue31RoleStatus::Revoked,
720            "grantRef": grant_ref,
721        }),
722        Issue31RoleInput::Unknown { kind } => serde_json::json!({
723            "kind": kind,
724            "status": Issue31RoleStatus::Unknown,
725            "grantRef": serde_json::Value::Null,
726        }),
727    }
728}
729
730fn build_command_state(state: &Issue31CommandStateInput<'_>) -> serde_json::Value {
731    match state {
732        Issue31CommandStateInput::Idle => serde_json::json!({ "kind": "idle" }),
733        Issue31CommandStateInput::Pending {
734            intent_ref,
735            action_ref,
736        } => serde_json::json!({
737            "kind": "pending",
738            "intentRef": intent_ref,
739            "actionRef": action_ref,
740        }),
741        Issue31CommandStateInput::Refused {
742            intent_ref,
743            action_ref,
744            reason_class,
745            decision_ref,
746            receipt_ref,
747        } => serde_json::json!({
748            "kind": "refused",
749            "intentRef": intent_ref,
750            "actionRef": action_ref,
751            "reasonClass": reason_class,
752            "decisionRef": decision_ref,
753            "receiptRef": receipt_ref,
754        }),
755        Issue31CommandStateInput::Terminal {
756            intent_ref,
757            action_ref,
758            state,
759            outcome_ref,
760            reason_ref,
761            receipt_ref,
762        } => serde_json::json!({
763            "kind": "terminal",
764            "intentRef": intent_ref,
765            "actionRef": action_ref,
766            "state": state,
767            "outcomeRef": outcome_ref,
768            "reasonRef": reason_ref,
769            "receiptRef": receipt_ref,
770        }),
771    }
772}
773
774#[cfg(test)]
775mod tests {
776    use super::*;
777
778    const CANONICAL: &str =
779        include_str!("../fixtures/openagents.omega.issue31.host.v1.canonical.json");
780    const NEGATIVE_PRIVATE_FIELD: &str =
781        include_str!("../fixtures/openagents.omega.issue31.host.v1.negative-private-field.json");
782    const NEGATIVE_UNSAFE_REF: &str =
783        include_str!("../fixtures/openagents.omega.issue31.host.v1.negative-unsafe-ref.json");
784    const NEGATIVE_INVALID_STATE: &str =
785        include_str!("../fixtures/openagents.omega.issue31.host.v1.negative-invalid-state.json");
786
787    #[test]
788    fn decodes_canonical_issue31_host_adjunct() {
789        let adjunct = decode_issue31_host_adjunct(CANONICAL).expect("canonical fixture decodes");
790        assert_eq!(adjunct.schema, ISSUE31_HOST_ADJUNCT_SCHEMA);
791        assert_eq!(adjunct.projections.len(), ISSUE31_PROJECTION_COUNT);
792        assert!(adjunct.projections.iter().any(|projection| matches!(
793            projection.command_state,
794            Issue31CommandState::Pending { .. }
795        )));
796        assert!(adjunct.projections.iter().any(|projection| matches!(
797            projection.command_state,
798            Issue31CommandState::Refused { .. }
799        )));
800        assert!(adjunct.projections.iter().any(|projection| matches!(
801            projection.command_state,
802            Issue31CommandState::Terminal { .. }
803        )));
804    }
805
806    #[test]
807    fn serialized_projection_contains_only_admitted_contract_fields() {
808        let adjunct = decode_issue31_host_adjunct(CANONICAL).expect("canonical fixture decodes");
809        let value = serde_json::to_value(adjunct).expect("safe adjunct serializes");
810        let encoded = serde_json::to_string(&value).expect("safe adjunct encodes");
811        assert!(!encoded.contains("prompt"));
812        assert!(!encoded.contains("credential"));
813        assert!(!encoded.contains("/Users/"));
814        assert!(value.get("hostRef").is_some());
815        assert!(value.get("generatedAtMs").is_some());
816    }
817
818    #[test]
819    fn rejects_private_payload_field_without_echoing_it() {
820        let error = decode_issue31_host_adjunct(NEGATIVE_PRIVATE_FIELD)
821            .expect_err("private payload field must fail closed");
822        assert_eq!(error, Issue31HostAdjunctError::InvalidJson);
823        assert!(!error.to_string().contains("owner-private prompt"));
824    }
825
826    #[test]
827    fn rejects_unsafe_ref_without_echoing_it() {
828        let error = decode_issue31_host_adjunct(NEGATIVE_UNSAFE_REF)
829            .expect_err("private path must fail closed");
830        assert_eq!(error, Issue31HostAdjunctError::UnsafeReference);
831        assert!(!error.to_string().contains("/Users/"));
832
833        let mut value: serde_json::Value =
834            serde_json::from_str(CANONICAL).expect("canonical JSON value");
835        value["hostRef"] = serde_json::Value::String(" host.omega.device-alpha".into());
836        assert_eq!(
837            decode_issue31_host_adjunct(&value.to_string()),
838            Err(Issue31HostAdjunctError::UnsafeReference)
839        );
840    }
841
842    #[test]
843    fn rejects_incoherent_unavailable_projection() {
844        let error = decode_issue31_host_adjunct(NEGATIVE_INVALID_STATE)
845            .expect_err("unavailable source cannot expose a pending action");
846        assert_eq!(error, Issue31HostAdjunctError::InvalidProjectionState);
847    }
848
849    #[test]
850    fn rejects_missing_or_duplicate_capabilities() {
851        let mut value: serde_json::Value =
852            serde_json::from_str(CANONICAL).expect("canonical JSON value");
853        let projections = value
854            .get_mut("projections")
855            .and_then(serde_json::Value::as_array_mut)
856            .expect("projection array");
857        projections.pop();
858        assert_eq!(
859            decode_issue31_host_adjunct(&value.to_string()),
860            Err(Issue31HostAdjunctError::MissingCapability)
861        );
862
863        let mut value: serde_json::Value =
864            serde_json::from_str(CANONICAL).expect("canonical JSON value");
865        let projections = value
866            .get_mut("projections")
867            .and_then(serde_json::Value::as_array_mut)
868            .expect("projection array");
869        projections[1]["capability"] = serde_json::Value::String("connection_identity".into());
870        assert_eq!(
871            decode_issue31_host_adjunct(&value.to_string()),
872            Err(Issue31HostAdjunctError::DuplicateCapability)
873        );
874    }
875
876    #[test]
877    fn enforces_reference_and_role_bounds() {
878        let mut value: serde_json::Value =
879            serde_json::from_str(CANONICAL).expect("canonical JSON value");
880        let refs = value["projections"][0]["recordRefs"]
881            .as_array_mut()
882            .expect("record refs");
883        for index in 0..=MAX_ISSUE31_PROJECTION_REFS {
884            refs.push(serde_json::Value::String(format!("record.extra.{index}")));
885        }
886        assert_eq!(
887            decode_issue31_host_adjunct(&value.to_string()),
888            Err(Issue31HostAdjunctError::ReferenceBoundExceeded)
889        );
890
891        let mut value: serde_json::Value =
892            serde_json::from_str(CANONICAL).expect("canonical JSON value");
893        value["projections"][1]["role"]["status"] = serde_json::Value::String("revoked".into());
894        assert_eq!(
895            decode_issue31_host_adjunct(&value.to_string()),
896            Err(Issue31HostAdjunctError::InvalidRoleState)
897        );
898    }
899
900    // -----------------------------------------------------------------
901    // Emitter (omega#47): the `host.v1` producer the detail projection is
902    // published beside.
903    // -----------------------------------------------------------------
904
905    const HOST: &str = "host.omega.device-alpha";
906    const SNAPSHOT: &str = "snapshot.omega.issue31.000042";
907    const GENERATED_AT: u64 = 1_784_894_400_000;
908    const OWNER_GRANT: &str = "grant.omega.mobile.owner-01";
909
910    fn owner() -> Issue31RoleInput<'static> {
911        Issue31RoleInput::Active {
912            kind: Issue31RoleKind::Owner,
913            grant_ref: OWNER_GRANT,
914        }
915    }
916
917    /// The exact host state the byte-shared canonical fixture describes.
918    fn canonical_sources() -> Issue31HostSources<'static> {
919        Issue31HostSources {
920            connection_identity: Issue31HostProjectionInput::Observed {
921                source_ref: "source.omega.identity-binding",
922                observed_at_ms: 1_784_894_399_000,
923                freshness: ProjectionFreshness::Current,
924                gap: Issue31ObservedGap::Complete,
925                role: owner(),
926                record_refs: &[
927                    "record.omega.host-announcement.01",
928                    "record.omega.owner-binding.01",
929                ],
930                permitted_action_refs: &[
931                    "action.omega.device.pair",
932                    "action.omega.device.renew",
933                    "action.omega.device.revoke",
934                ],
935                command_state: Issue31CommandStateInput::Idle,
936            },
937            full_auto_runs: Issue31HostProjectionInput::Observed {
938                source_ref: "source.omega.full-auto-registry",
939                observed_at_ms: 1_784_894_340_000,
940                freshness: ProjectionFreshness::Stale,
941                gap: Issue31ObservedGap::Partial,
942                role: owner(),
943                record_refs: &["record.full-auto.run.run-01"],
944                permitted_action_refs: &[
945                    "action.full-auto.pause",
946                    "action.full-auto.resume",
947                    "action.full-auto.stop",
948                    "action.full-auto.ask-sarah",
949                ],
950                command_state: Issue31CommandStateInput::Pending {
951                    intent_ref: "intent.full-auto.pause.01",
952                    action_ref: "action.full-auto.pause",
953                },
954            },
955            provider_accounts: Issue31HostProjectionInput::Observed {
956                source_ref: "source.omega.provider-roster",
957                observed_at_ms: 1_784_894_398_000,
958                freshness: ProjectionFreshness::Current,
959                gap: Issue31ObservedGap::Complete,
960                role: owner(),
961                record_refs: &["record.provider.roster.01"],
962                permitted_action_refs: &["action.provider.request-connect-handoff"],
963                command_state: Issue31CommandStateInput::Refused {
964                    intent_ref: "intent.provider.connect.01",
965                    action_ref: "action.provider.request-connect-handoff",
966                    reason_class: "provider_login_requires_host",
967                    decision_ref: "decision.provider.connect.01",
968                    receipt_ref: Some("receipt.provider.connect.01"),
969                },
970            },
971            evidence_chain: Issue31HostProjectionInput::Observed {
972                source_ref: "source.omega.evidence-inspector",
973                observed_at_ms: 1_784_894_397_000,
974                freshness: ProjectionFreshness::Current,
975                gap: Issue31ObservedGap::Complete,
976                role: Issue31RoleInput::Active {
977                    kind: Issue31RoleKind::Verifier,
978                    grant_ref: "grant.omega.mobile.verifier-01",
979                },
980                record_refs: &["record.evidence.chain.run-01"],
981                permitted_action_refs: &[],
982                command_state: Issue31CommandStateInput::Terminal {
983                    intent_ref: "intent.evidence.verify.01",
984                    action_ref: "action.evidence.verify",
985                    state: Issue31TerminalState::Succeeded,
986                    outcome_ref: "outcome.evidence.verify.01",
987                    reason_ref: Some("reason.evidence.chain-valid"),
988                    receipt_ref: Some("receipt.evidence.verify.01"),
989                },
990            },
991        }
992    }
993
994    fn build(sources: &Issue31HostSources<'_>) -> Result<Issue31HostAdjunct, Issue31HostAdjunctError>
995    {
996        build_issue31_host_adjunct(HOST, SNAPSHOT, GENERATED_AT, sources)
997    }
998
999    /// The strongest cross-check available: what this host emits must be the
1000    /// same value the shared fixture decodes to, and that fixture is
1001    /// byte-identical to its `packages/sarah` peer. The producer and the phone
1002    /// therefore cannot drift without a test going red on one side.
1003    #[test]
1004    fn the_producer_emits_exactly_what_the_shared_fixture_decodes_to() {
1005        let emitted = build(&canonical_sources()).expect("canonical host state emits");
1006        let fixture = decode_issue31_host_adjunct(CANONICAL).expect("canonical fixture decodes");
1007        assert_eq!(emitted, fixture);
1008    }
1009
1010    #[test]
1011    fn every_emitted_snapshot_carries_all_four_capabilities_exactly_once() {
1012        let emitted = build(&canonical_sources()).expect("emits");
1013        let mut seen: Vec<_> = emitted
1014            .projections
1015            .iter()
1016            .map(|projection| projection.capability)
1017            .collect();
1018        assert_eq!(seen.len(), ISSUE31_PROJECTION_COUNT);
1019        for capability in Issue31ProjectionCapability::ALL {
1020            let before = seen.len();
1021            seen.retain(|value| *value != capability);
1022            assert_eq!(before - seen.len(), 1, "{capability:?} appears exactly once");
1023        }
1024        assert!(seen.is_empty());
1025    }
1026
1027    /// A capability the host could not read has no way to offer the reader an
1028    /// action. `Absent` has no field for one.
1029    #[test]
1030    fn an_unreadable_capability_claims_nothing_and_offers_nothing() {
1031        let mut sources = canonical_sources();
1032        sources.provider_accounts = Issue31HostProjectionInput::Absent {
1033            source_ref: "source.omega.provider-roster",
1034            observed_at_ms: 1_784_894_398_000,
1035            gap: Issue31AbsentGap::Unavailable,
1036            role: owner(),
1037        };
1038        let emitted = build(&sources).expect("an unreadable capability is still a valid snapshot");
1039        let projection = emitted
1040            .projections
1041            .iter()
1042            .find(|projection| {
1043                projection.capability == Issue31ProjectionCapability::ProviderAccounts
1044            })
1045            .expect("provider accounts projection");
1046        assert_eq!(projection.gap, Issue31Gap::Unavailable);
1047        assert_eq!(projection.freshness, ProjectionFreshness::Unknown);
1048        assert!(projection.record_refs.is_empty());
1049        assert!(projection.permitted_action_refs.is_empty());
1050        assert_eq!(projection.command_state, Issue31CommandState::Idle);
1051    }
1052
1053    #[test]
1054    fn a_private_path_cannot_be_emitted_and_the_error_does_not_echo_it() {
1055        let mut sources = canonical_sources();
1056        sources.connection_identity = Issue31HostProjectionInput::Observed {
1057            source_ref: "/Users/owner/.codex/auth.json",
1058            observed_at_ms: 1_784_894_399_000,
1059            freshness: ProjectionFreshness::Current,
1060            gap: Issue31ObservedGap::Complete,
1061            role: owner(),
1062            record_refs: &["record.omega.host-announcement.01"],
1063            permitted_action_refs: &[],
1064            command_state: Issue31CommandStateInput::Idle,
1065        };
1066        let error = build(&sources).expect_err("a private path must fail closed");
1067        assert_eq!(error, Issue31HostAdjunctError::UnsafeReference);
1068        assert!(!error.to_string().contains("/Users/"));
1069        assert!(!error.to_string().contains("auth.json"));
1070    }
1071
1072    #[test]
1073    fn a_pending_command_the_host_does_not_permit_is_refused() {
1074        let mut sources = canonical_sources();
1075        sources.full_auto_runs = Issue31HostProjectionInput::Observed {
1076            source_ref: "source.omega.full-auto-registry",
1077            observed_at_ms: 1_784_894_340_000,
1078            freshness: ProjectionFreshness::Stale,
1079            gap: Issue31ObservedGap::Partial,
1080            role: owner(),
1081            record_refs: &["record.full-auto.run.run-01"],
1082            permitted_action_refs: &["action.full-auto.pause"],
1083            command_state: Issue31CommandStateInput::Pending {
1084                intent_ref: "intent.full-auto.stop.01",
1085                action_ref: "action.full-auto.stop",
1086            },
1087        };
1088        assert_eq!(
1089            build(&sources),
1090            Err(Issue31HostAdjunctError::InvalidCommandState)
1091        );
1092    }
1093
1094    #[test]
1095    fn a_revoked_role_cannot_be_emitted_with_permitted_actions() {
1096        let mut sources = canonical_sources();
1097        sources.provider_accounts = Issue31HostProjectionInput::Observed {
1098            source_ref: "source.omega.provider-roster",
1099            observed_at_ms: 1_784_894_398_000,
1100            freshness: ProjectionFreshness::Current,
1101            gap: Issue31ObservedGap::Complete,
1102            role: Issue31RoleInput::Revoked {
1103                kind: Issue31RoleKind::Owner,
1104                grant_ref: OWNER_GRANT,
1105            },
1106            record_refs: &["record.provider.roster.01"],
1107            permitted_action_refs: &["action.provider.request-connect-handoff"],
1108            command_state: Issue31CommandStateInput::Idle,
1109        };
1110        assert_eq!(build(&sources), Err(Issue31HostAdjunctError::InvalidRoleState));
1111    }
1112
1113    #[test]
1114    fn a_projection_observed_after_the_snapshot_was_generated_is_refused() {
1115        let mut sources = canonical_sources();
1116        sources.evidence_chain = Issue31HostProjectionInput::Observed {
1117            source_ref: "source.omega.evidence-inspector",
1118            observed_at_ms: GENERATED_AT + 1,
1119            freshness: ProjectionFreshness::Current,
1120            gap: Issue31ObservedGap::Complete,
1121            role: owner(),
1122            record_refs: &["record.evidence.chain.run-01"],
1123            permitted_action_refs: &[],
1124            command_state: Issue31CommandStateInput::Idle,
1125        };
1126        assert_eq!(build(&sources), Err(Issue31HostAdjunctError::InvalidTimestamp));
1127    }
1128
1129    #[test]
1130    fn a_duplicate_record_reference_is_refused_rather_than_deduplicated() {
1131        let mut sources = canonical_sources();
1132        sources.connection_identity = Issue31HostProjectionInput::Observed {
1133            source_ref: "source.omega.identity-binding",
1134            observed_at_ms: 1_784_894_399_000,
1135            freshness: ProjectionFreshness::Current,
1136            gap: Issue31ObservedGap::Complete,
1137            role: owner(),
1138            record_refs: &[
1139                "record.omega.host-announcement.01",
1140                "record.omega.host-announcement.01",
1141            ],
1142            permitted_action_refs: &[],
1143            command_state: Issue31CommandStateInput::Idle,
1144        };
1145        assert_eq!(
1146            build(&sources),
1147            Err(Issue31HostAdjunctError::DuplicateReference)
1148        );
1149    }
1150
1151    #[test]
1152    fn the_reference_bound_holds_at_the_producer_too() {
1153        let refs: Vec<String> = (0..=MAX_ISSUE31_PROJECTION_REFS)
1154            .map(|index| format!("record.omega.extra.{index}"))
1155            .collect();
1156        let borrowed: Vec<&str> = refs.iter().map(String::as_str).collect();
1157        let mut sources = canonical_sources();
1158        sources.connection_identity = Issue31HostProjectionInput::Observed {
1159            source_ref: "source.omega.identity-binding",
1160            observed_at_ms: 1_784_894_399_000,
1161            freshness: ProjectionFreshness::Current,
1162            gap: Issue31ObservedGap::Complete,
1163            role: owner(),
1164            record_refs: &borrowed,
1165            permitted_action_refs: &[],
1166            command_state: Issue31CommandStateInput::Idle,
1167        };
1168        assert_eq!(
1169            build(&sources),
1170            Err(Issue31HostAdjunctError::ReferenceBoundExceeded)
1171        );
1172    }
1173
1174    /// Nothing the producer emits may carry owner-private material, whatever
1175    /// the host record around it looked like.
1176    #[test]
1177    fn an_emitted_snapshot_serializes_to_admitted_fields_only() {
1178        let emitted = build(&canonical_sources()).expect("emits");
1179        let encoded = serde_json::to_string(&emitted).expect("emitted snapshot encodes");
1180        assert!(!encoded.contains("/Users/"));
1181        assert!(!encoded.contains("credential"));
1182        assert!(!encoded.contains("Bearer "));
1183        assert!(!encoded.contains("prompt"));
1184    }
1185
1186    #[test]
1187    fn rejects_timestamp_outside_the_shared_javascript_date_bound() {
1188        let mut value: serde_json::Value =
1189            serde_json::from_str(CANONICAL).expect("canonical JSON value");
1190        value["generatedAtMs"] = serde_json::Value::from(MAX_ISSUE31_TIMESTAMP_MS + 1);
1191        assert_eq!(
1192            decode_issue31_host_adjunct(&value.to_string()),
1193            Err(Issue31HostAdjunctError::InvalidTimestamp)
1194        );
1195
1196        let mut value: serde_json::Value =
1197            serde_json::from_str(CANONICAL).expect("canonical JSON value");
1198        value["projections"][0]["source"]["observedAtMs"] =
1199            serde_json::Value::from(MAX_ISSUE31_TIMESTAMP_MS + 1);
1200        assert_eq!(
1201            decode_issue31_host_adjunct(&value.to_string()),
1202            Err(Issue31HostAdjunctError::InvalidTimestamp)
1203        );
1204    }
1205}
1206
Served at tenant.openagents/omega Member data and write actions are omitted.