Skip to repository content1206 lines · 44.9 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:03:55.045Z 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_host.rs
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