Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T06:25:58.834Z 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_provider_handoff.rs

1181 lines · 48.7 KB · rust
1//! Host-owned provider connection handoff records (omega#91).
2//!
3//! The phone can already ask: `issue31_nostr` maps `request_provider_handoff`
4//! onto a pairing scope and `action.omega.provider_handoff` onto that scope, so
5//! a paired device can hold the grant and send the intent. Until this module
6//! existed nothing answered — the `fullauto.v1` delivery carried an empty
7//! handoff vector forever, and the phone rendered a capability the host had
8//! nothing to say about.
9//!
10//! This is the thing that has something to say. It is a ledger of host-owned
11//! records with one lifecycle:
12//!
13//! ```text
14//! requested ── bound to an account ──▶ active ──▶ completed
15//!     │                                  │
16//!     └──────────────────────────────────┴──▶ refused | failed | expired
17//! ```
18//!
19//! Three properties are load-bearing and are why this is a ledger rather than a
20//! field on a request:
21//!
22//! - **Every host-owned field is a measurement, not a claim.** `requestedAtMs`
23//!   comes from one reading of `now` taken when the host admitted the request;
24//!   the device supplies no timestamp and there is no input path for one. The
25//!   account binding comes from the host's own roster; the device names a
26//!   provider and nothing else. The terminal outcome and the reason for a
27//!   non-successful end are decided here, from what the host observed.
28//! - **A row is written only if it can be read.** Every projected row goes back
29//!   through `workroom_receipts::decode_issue31_provider_handoff`, the exact
30//!   function the whole-document decoder uses, so this cannot emit a handoff
31//!   the phone would refuse.
32//! - **Nothing is backfilled.** A persisted row whose `requestedAtMs` predates
33//!   this field decodes, is reported unavailable, and is refused. It is never
34//!   shown with a stamp taken at load time, which would give one field two
35//!   provenances that nothing on the wire distinguishes.
36//!
37//! The record carries **the fact of a connection, never the connection
38//! secret**. Nothing here reads, writes, moves, or names a provider credential,
39//! an isolated provider home, or a filesystem path; the only strings that enter
40//! are a provider token and references the host itself minted.
41
42use std::collections::BTreeMap;
43
44use serde::{Deserialize, Serialize};
45use serde_json::{Value, json};
46use sha2::{Digest, Sha256};
47use workroom_receipts::{
48    Issue31FullAutoAdjunctError, Issue31ProviderHandoffState, decode_issue31_provider_handoff,
49    is_public_safe_ref,
50};
51
52/// The action a device sends to open one.
53pub const ISSUE31_ACTION_REQUEST_PROVIDER_HANDOFF: &str = "action.omega.provider_handoff";
54
55/// The only shape of `argumentsRef` this action accepts.
56///
57/// The device names a provider and nothing else. Everything after this prefix
58/// is a bounded provider token, so there is no field on the wire through which
59/// a device could state a time, an account, a lane, or an outcome — the four
60/// things that have to be the host's own measurements.
61pub const ISSUE31_PROVIDER_HANDOFF_ARGUMENTS_PREFIX: &str = "arguments.omega.provider_handoff.";
62
63/// Matches `workroom_receipts::MAX_ISSUE31_FULL_AUTO_HANDOFFS`. Exceeding the
64/// contract bound in the ledger would produce a projection the phone refuses
65/// wholesale, so the ledger refuses to open the seventeenth instead.
66pub const MAX_ISSUE31_PROVIDER_HANDOFFS: usize = 16;
67
68/// How long the host will hold a handoff open before calling it expired.
69///
70/// The work behind a handoff is an owner completing a provider login at the
71/// host, in the host's own isolated provider home. Fifteen minutes is long
72/// enough for that and short enough that the phone is never left holding a
73/// request that neither resolves nor fails — the state this issue exists to
74/// eliminate.
75pub const ISSUE31_PROVIDER_HANDOFF_DEADLINE_MS: u64 = 15 * 60 * 1_000;
76
77pub const ISSUE31_HANDOFF_OUTCOME_CONNECTED: &str = "outcome.omega.handoff_connected";
78pub const ISSUE31_HANDOFF_OUTCOME_REFUSED: &str = "outcome.omega.handoff_refused";
79pub const ISSUE31_HANDOFF_OUTCOME_INTERRUPTED: &str = "outcome.omega.handoff_interrupted";
80pub const ISSUE31_HANDOFF_OUTCOME_EXPIRED: &str = "outcome.omega.handoff_expired";
81pub const ISSUE31_HANDOFF_OUTCOME_FAILED: &str = "outcome.omega.handoff_failed";
82
83pub const ISSUE31_HANDOFF_REASON_ACCOUNT_REVOKED: &str = "reason.omega.handoff_account_revoked";
84pub const ISSUE31_HANDOFF_REASON_ACCOUNT_WITHDRAWN: &str =
85    "reason.omega.handoff_account_withdrawn";
86pub const ISSUE31_HANDOFF_REASON_LANE_CONFLICT: &str = "reason.omega.handoff_account_lane_conflict";
87pub const ISSUE31_HANDOFF_REASON_HOST_RESTARTED: &str = "reason.omega.handoff_host_restarted";
88pub const ISSUE31_HANDOFF_REASON_DEADLINE_PASSED: &str = "reason.omega.handoff_deadline_passed";
89
90/// Why the host would not open a handoff at all.
91///
92/// Each of these leaves **no record**, and that is the point: a request the
93/// host never admitted is not a handoff that failed. The phone can tell them
94/// apart because one produces a row it can watch and the other does not.
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub enum Issue31ProviderHandoffError {
97    /// The `argumentsRef` was not `arguments.omega.provider_handoff.<provider>`.
98    ArgumentsInvalid,
99    /// The provider token was empty, oversized, or not public-safe.
100    ProviderInvalid,
101    /// The ledger already holds as many handoffs as the contract can carry.
102    BoundExhausted,
103    /// The host tried to write a row its own reader would refuse. This is a
104    /// host defect, never a device one, and it is surfaced rather than
105    /// swallowed so the defect cannot hide as an empty handoff list.
106    Unprojectable(Issue31FullAutoAdjunctError),
107}
108
109impl std::fmt::Display for Issue31ProviderHandoffError {
110    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        match self {
112            Self::ArgumentsInvalid => {
113                formatter.write_str("provider handoff arguments do not name a provider")
114            }
115            Self::ProviderInvalid => {
116                formatter.write_str("provider handoff names an unsafe provider token")
117            }
118            Self::BoundExhausted => formatter.write_str("provider handoff ledger bound is full"),
119            Self::Unprojectable(error) => {
120                write!(formatter, "provider handoff would not decode: {error}")
121            }
122        }
123    }
124}
125
126impl std::error::Error for Issue31ProviderHandoffError {}
127
128impl Issue31ProviderHandoffError {
129    /// The public-safe reason reference a command result carries.
130    #[must_use]
131    pub const fn reason_ref(self) -> &'static str {
132        match self {
133            Self::ArgumentsInvalid => "reason.omega.handoff_arguments_invalid",
134            Self::ProviderInvalid => "reason.omega.handoff_provider_invalid",
135            Self::BoundExhausted => "reason.omega.handoff_bound_exhausted",
136            Self::Unprojectable(_) => "reason.omega.handoff_unprojectable",
137        }
138    }
139}
140
141/// One provider account as the host's own roster reports it.
142///
143/// This is the host observing itself. The lane is carried because omega#42
144/// asked for the account-to-lane relation and omega#91 needs it for a specific
145/// reason: it is *why a handoff chose what it chose*. A handoff binds to an
146/// account, and the account states the lane it serves, so a viewer can follow
147/// the choice rather than infer it.
148#[derive(Clone, Debug, PartialEq, Eq)]
149pub struct Issue31ProviderRosterAccount {
150    pub account_ref: String,
151    pub provider: String,
152    pub lane_ref: String,
153    /// The roster's own readiness token: `ready`, `busy`, `revoked`, and so on.
154    pub readiness: String,
155}
156
157/// One host-owned handoff.
158///
159/// Every optional field is optional for one reason only: the host has not
160/// measured it yet. None of them has a device-supplied alternative.
161#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "camelCase", deny_unknown_fields)]
163pub struct Issue31ProviderHandoffRecord {
164    pub handoff_ref: String,
165    pub provider: String,
166    pub state: Issue31ProviderHandoffState,
167    /// One reading of `now`, taken when the host admitted the request, stamped
168    /// once and never restamped.
169    ///
170    /// `None` means this row was persisted by a build that did not measure it.
171    /// It is refused at projection rather than filled in at load: a value
172    /// invented now and a value measured then are indistinguishable on the
173    /// wire, and a viewer would have no way to know which it was reading.
174    #[serde(default)]
175    pub requested_at_ms: Option<u64>,
176    /// The host's own bound on how long it will hold this open. Host-private:
177    /// the contract carries no deadline, so this never reaches the phone.
178    #[serde(default)]
179    pub deadline_at_ms: Option<u64>,
180    /// Set when the host bound this handoff to a concrete account of its own.
181    #[serde(default)]
182    pub account_ref: Option<String>,
183    /// The lane that account served at the moment of binding. Host-private and
184    /// checked on every later pass: the projected relation is
185    /// `handoff.accountRef` → `account.laneRef`, and this is what makes a lane
186    /// that moves underneath a bound handoff a detected conflict instead of a
187    /// silent re-mapping.
188    #[serde(default)]
189    pub lane_ref: Option<String>,
190    #[serde(default)]
191    pub reason_class: Option<String>,
192    #[serde(default)]
193    pub outcome_ref: Option<String>,
194}
195
196impl Issue31ProviderHandoffRecord {
197    #[must_use]
198    pub fn is_terminal(&self) -> bool {
199        self.state.is_terminal()
200    }
201
202    /// The contract row for this record, or `None` when the host cannot state
203    /// one. Never a partially-invented row.
204    fn contract_row(&self) -> Option<Value> {
205        let requested_at_ms = self.requested_at_ms?;
206        let mut row = json!({
207            "handoffRef": self.handoff_ref,
208            "provider": self.provider,
209            "state": self.state,
210            "requestedAtMs": requested_at_ms,
211        });
212        let object = row.as_object_mut()?;
213        for (field, value) in [
214            ("accountRef", self.account_ref.as_ref()),
215            ("reasonClass", self.reason_class.as_ref()),
216            ("outcomeRef", self.outcome_ref.as_ref()),
217        ] {
218            if let Some(value) = value {
219                object.insert(field.into(), json!(value));
220            }
221        }
222        Some(row)
223    }
224}
225
226/// What the host can presently say about its handoffs.
227#[derive(Clone, Debug, Default, PartialEq, Eq)]
228pub struct Issue31ProviderHandoffProjection {
229    /// Contract rows, each already accepted by the reader's own decoder.
230    pub rows: Vec<Value>,
231    /// Handoff references the host holds and cannot state.
232    ///
233    /// A non-empty list is a gap the owner must see. It is deliberately not
234    /// merged into `rows` with substituted values, and deliberately not
235    /// silently dropped either: the count is what turns "the host has nothing
236    /// to say" into "the host has something it cannot say".
237    pub unavailable: Vec<String>,
238}
239
240/// The durable ledger of host-owned provider connection handoffs.
241#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
242#[serde(rename_all = "camelCase", deny_unknown_fields)]
243pub struct Issue31ProviderHandoffLedger {
244    #[serde(default)]
245    entries: BTreeMap<String, Issue31ProviderHandoffRecord>,
246}
247
248impl Issue31ProviderHandoffLedger {
249    #[must_use]
250    pub fn len(&self) -> usize {
251        self.entries.len()
252    }
253
254    #[must_use]
255    pub fn is_empty(&self) -> bool {
256        self.entries.is_empty()
257    }
258
259    #[must_use]
260    pub fn get(&self, handoff_ref: &str) -> Option<&Issue31ProviderHandoffRecord> {
261        self.entries.get(handoff_ref)
262    }
263
264    #[must_use]
265    pub fn records(&self) -> impl Iterator<Item = &Issue31ProviderHandoffRecord> {
266        self.entries.values()
267    }
268
269    /// The reference the host mints for a command, without opening anything.
270    ///
271    /// Derived from the device's idempotency reference so that a command
272    /// replayed after a failed durable commit reopens the same handoff rather
273    /// than a second one. The device does not choose the reference: it chooses
274    /// an idempotency label, and the host decides what record that maps to.
275    #[must_use]
276    pub fn handoff_ref_for(idempotency_ref: &str) -> String {
277        let digest = format!("{:x}", Sha256::digest(idempotency_ref.as_bytes()));
278        format!("handoff.omega.{}", &digest[..24])
279    }
280
281    /// The provider a `provider_handoff` command names, if it names one.
282    ///
283    /// This is the whole input surface of the action.
284    pub fn provider_from_arguments_ref(
285        arguments_ref: &str,
286    ) -> Result<String, Issue31ProviderHandoffError> {
287        let provider = arguments_ref
288            .strip_prefix(ISSUE31_PROVIDER_HANDOFF_ARGUMENTS_PREFIX)
289            .ok_or(Issue31ProviderHandoffError::ArgumentsInvalid)?;
290        if provider.is_empty()
291            || provider.len() > 32
292            || !provider
293                .bytes()
294                .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
295            || !is_public_safe_ref(provider)
296        {
297            return Err(Issue31ProviderHandoffError::ProviderInvalid);
298        }
299        Ok(provider.to_string())
300    }
301
302    /// Open a handoff for a command the host has already admitted.
303    ///
304    /// `now_ms` is the caller's single reading of the clock for this command;
305    /// both the stamp and the deadline come from it, so a handoff cannot be
306    /// requested at one instant and bounded from another.
307    ///
308    /// Opening is idempotent by handoff reference. A replay after a durable
309    /// commit failed finds the record it already made instead of minting a
310    /// second one with a later stamp.
311    pub fn open(
312        &mut self,
313        arguments_ref: &str,
314        idempotency_ref: &str,
315        now_ms: u64,
316    ) -> Result<Issue31ProviderHandoffRecord, Issue31ProviderHandoffError> {
317        let provider = Self::provider_from_arguments_ref(arguments_ref)?;
318        let handoff_ref = Self::handoff_ref_for(idempotency_ref);
319        if let Some(existing) = self.entries.get(&handoff_ref) {
320            return Ok(existing.clone());
321        }
322        if self.entries.len() >= MAX_ISSUE31_PROVIDER_HANDOFFS {
323            return Err(Issue31ProviderHandoffError::BoundExhausted);
324        }
325        let record = Issue31ProviderHandoffRecord {
326            handoff_ref: handoff_ref.clone(),
327            provider,
328            state: Issue31ProviderHandoffState::Requested,
329            requested_at_ms: Some(now_ms),
330            deadline_at_ms: Some(now_ms.saturating_add(ISSUE31_PROVIDER_HANDOFF_DEADLINE_MS)),
331            account_ref: None,
332            lane_ref: None,
333            reason_class: None,
334            outcome_ref: None,
335        };
336        // Unreadable states are unwritable. The row is checked against the
337        // reader's own decoder before it enters the ledger, so a handoff the
338        // phone would refuse never becomes durable host state in the first
339        // place.
340        let row = record
341            .contract_row()
342            .ok_or(Issue31ProviderHandoffError::Unprojectable(
343                Issue31FullAutoAdjunctError::InvalidHandoffState,
344            ))?;
345        decode_issue31_provider_handoff(&row, now_ms)
346            .map_err(Issue31ProviderHandoffError::Unprojectable)?;
347        self.entries.insert(handoff_ref, record.clone());
348        Ok(record)
349    }
350
351    /// Move every open handoff on by at most one step, from what the host sees.
352    ///
353    /// `roster` is the host's own reading of its provider accounts. `None`
354    /// means the host has not looked — nothing advances, because a decision
355    /// taken against an unread roster would be a guess wearing a measurement's
356    /// clothes. `Some(&[])` means the host looked and holds no accounts, which
357    /// is a real observation and does move the deadline clock.
358    ///
359    /// **At most one transition per handoff per pass**, on purpose. Binding and
360    /// completing are two separate observations of the roster, and collapsing
361    /// them would mean `active` is a state the host passes through without ever
362    /// publishing — the phone would see a handoff appear and complete, and
363    /// never see it bind.
364    ///
365    /// Returns how many handoffs moved.
366    pub fn advance(
367        &mut self,
368        roster: Option<&[Issue31ProviderRosterAccount]>,
369        now_ms: u64,
370    ) -> usize {
371        let mut moved = 0;
372        for record in self.entries.values_mut() {
373            if record.is_terminal() {
374                continue;
375            }
376            // A row the host cannot state is not a row the host may decide
377            // about. It is reported unavailable and left exactly as found.
378            let Some(_) = record.requested_at_ms else {
379                continue;
380            };
381            if record
382                .deadline_at_ms
383                .is_some_and(|deadline| now_ms >= deadline)
384            {
385                record.state = Issue31ProviderHandoffState::Expired;
386                record.reason_class = Some(ISSUE31_HANDOFF_REASON_DEADLINE_PASSED.into());
387                record.outcome_ref = Some(ISSUE31_HANDOFF_OUTCOME_EXPIRED.into());
388                moved += 1;
389                continue;
390            }
391            let Some(roster) = roster else {
392                continue;
393            };
394            if advance_against_roster(record, roster) {
395                moved += 1;
396            }
397        }
398        moved
399    }
400
401    /// Settle every handoff that was in flight when the host process ended.
402    ///
403    /// The work behind an open handoff lived in the process that is gone: the
404    /// isolated provider home it drove, and the login the owner was completing
405    /// there. Nothing survives it, so the honest terminal answer is that the
406    /// handoff was interrupted. This only ever under-claims — it cannot report
407    /// a connection the host did not make — and it is what stops a restart
408    /// leaving the phone with a request that neither resolves nor fails.
409    ///
410    /// Returns how many handoffs were settled.
411    pub fn adopt_after_restart(&mut self) -> usize {
412        let mut settled = 0;
413        for record in self.entries.values_mut() {
414            if record.is_terminal() || record.requested_at_ms.is_none() {
415                continue;
416            }
417            record.state = Issue31ProviderHandoffState::Failed;
418            record.reason_class = Some(ISSUE31_HANDOFF_REASON_HOST_RESTARTED.into());
419            record.outcome_ref = Some(ISSUE31_HANDOFF_OUTCOME_INTERRUPTED.into());
420            settled += 1;
421        }
422        settled
423    }
424
425    /// Handoffs this host holds and can never state.
426    ///
427    /// Only the permanently unstateable ones: a row whose request time was
428    /// never measured. A row that is merely newer than the snapshot being
429    /// built is not listed — it is not missing, it is not yet part of that
430    /// reading, and it appears in the next one.
431    #[must_use]
432    pub fn unstateable_refs(&self) -> Vec<String> {
433        self.entries
434            .values()
435            .filter(|record| record.requested_at_ms.is_none())
436            .map(|record| record.handoff_ref.clone())
437            .collect()
438    }
439
440    /// Fold another ledger's rows in. Test support for building a fixture that
441    /// shows every lifecycle at once; the production path never merges.
442    #[cfg(test)]
443    pub(crate) fn merge_for_fixture(&mut self, other: Self) {
444        for (handoff_ref, record) in other.entries {
445            self.entries.insert(handoff_ref, record);
446        }
447    }
448
449    /// The contract rows for this ledger, plus what it could not state.
450    ///
451    /// Every row is routed back through the reader's own decoder against the
452    /// exact `generatedAtMs` the projection will carry, so a row stamped after
453    /// the reading it would ride in is refused here rather than accepted and
454    /// then rejected on the phone.
455    #[must_use]
456    pub fn projected(&self, generated_at_ms: u64) -> Issue31ProviderHandoffProjection {
457        let mut projection = Issue31ProviderHandoffProjection::default();
458        for record in self.entries.values() {
459            if projection.rows.len() >= MAX_ISSUE31_PROVIDER_HANDOFFS {
460                projection.unavailable.push(record.handoff_ref.clone());
461                continue;
462            }
463            let stated = record
464                .contract_row()
465                .filter(|row| decode_issue31_provider_handoff(row, generated_at_ms).is_ok());
466            match stated {
467                Some(row) => projection.rows.push(row),
468                None => projection.unavailable.push(record.handoff_ref.clone()),
469            }
470        }
471        projection
472    }
473}
474
475/// One roster-driven step for a single open handoff.
476///
477/// Returns true when the record moved.
478fn advance_against_roster(
479    record: &mut Issue31ProviderHandoffRecord,
480    roster: &[Issue31ProviderRosterAccount],
481) -> bool {
482    match record.account_ref.clone() {
483        Some(account_ref) => {
484            let Some(account) = roster
485                .iter()
486                .find(|account| account.account_ref == account_ref)
487            else {
488                // The account this handoff was bound to is gone from the host's
489                // own roster. Reporting the handoff as still active would point
490                // the phone at a record the host no longer has.
491                record.state = Issue31ProviderHandoffState::Failed;
492                record.reason_class = Some(ISSUE31_HANDOFF_REASON_ACCOUNT_WITHDRAWN.into());
493                record.outcome_ref = Some(ISSUE31_HANDOFF_OUTCOME_FAILED.into());
494                return true;
495            };
496            if record.lane_ref.as_deref() != Some(account.lane_ref.as_str()) {
497                // The account now serves a different lane than the one this
498                // handoff chose. Quietly adopting the new lane would rewrite
499                // the reason the handoff picked this account at all.
500                record.state = Issue31ProviderHandoffState::Failed;
501                record.reason_class = Some(ISSUE31_HANDOFF_REASON_LANE_CONFLICT.into());
502                record.outcome_ref = Some(ISSUE31_HANDOFF_OUTCOME_FAILED.into());
503                return true;
504            }
505            match account.readiness.as_str() {
506                "revoked" => {
507                    record.state = Issue31ProviderHandoffState::Refused;
508                    record.reason_class = Some(ISSUE31_HANDOFF_REASON_ACCOUNT_REVOKED.into());
509                    record.outcome_ref = Some(ISSUE31_HANDOFF_OUTCOME_REFUSED.into());
510                    true
511                }
512                "ready" => {
513                    record.state = Issue31ProviderHandoffState::Completed;
514                    record.outcome_ref = Some(ISSUE31_HANDOFF_OUTCOME_CONNECTED.into());
515                    true
516                }
517                // Connected but not presently usable. The handoff stays bound
518                // and open; the deadline is what eventually ends it.
519                _ => false,
520            }
521        }
522        None => {
523            let mut candidates: Vec<&Issue31ProviderRosterAccount> = roster
524                .iter()
525                .filter(|account| account.provider == record.provider)
526                .collect();
527            if candidates.is_empty() {
528                // The host holds no account for this provider yet. That is not
529                // a refusal: the owner may still be completing the login at the
530                // host. The handoff stays `requested` until the host's own
531                // deadline decides.
532                return false;
533            }
534            // Deterministic: a ready account first, then by reference, so two
535            // hosts reading the same roster bind the same way and a viewer can
536            // reproduce the choice.
537            candidates.sort_by(|left, right| {
538                let left_ready = u8::from(left.readiness != "ready");
539                let right_ready = u8::from(right.readiness != "ready");
540                left_ready
541                    .cmp(&right_ready)
542                    .then_with(|| left.account_ref.cmp(&right.account_ref))
543            });
544            let Some(account) = candidates.first() else {
545                return false;
546            };
547            record.state = Issue31ProviderHandoffState::Active;
548            record.account_ref = Some(account.account_ref.clone());
549            record.lane_ref = Some(account.lane_ref.clone());
550            true
551        }
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    const NOW_MS: u64 = 1_785_000_000_000;
560
561    fn account(
562        account_ref: &str,
563        provider: &str,
564        lane_ref: &str,
565        readiness: &str,
566    ) -> Issue31ProviderRosterAccount {
567        Issue31ProviderRosterAccount {
568            account_ref: account_ref.into(),
569            provider: provider.into(),
570            lane_ref: lane_ref.into(),
571            readiness: readiness.into(),
572        }
573    }
574
575    fn opened() -> (Issue31ProviderHandoffLedger, String) {
576        let mut ledger = Issue31ProviderHandoffLedger::default();
577        let record = ledger
578            .open(
579                "arguments.omega.provider_handoff.anthropic",
580                "idempotency.issue31.handoff:one",
581                NOW_MS,
582            )
583            .expect("the host opens a handoff for an admitted request");
584        (ledger, record.handoff_ref)
585    }
586
587    #[test]
588    fn a_handoff_appears_binds_and_completes_as_three_separate_observations() {
589        let (mut ledger, handoff_ref) = opened();
590        assert_eq!(
591            ledger.get(&handoff_ref).expect("record").state,
592            Issue31ProviderHandoffState::Requested,
593        );
594        assert!(ledger.get(&handoff_ref).expect("record").account_ref.is_none());
595
596        let roster = [account(
597            "account.claude.1",
598            "anthropic",
599            "lane.claude-local",
600            "ready",
601        )];
602        assert_eq!(ledger.advance(Some(&roster), NOW_MS + 1_000), 1);
603        let bound = ledger.get(&handoff_ref).expect("record").clone();
604        assert_eq!(bound.state, Issue31ProviderHandoffState::Active);
605        assert_eq!(bound.account_ref.as_deref(), Some("account.claude.1"));
606        // The account-to-lane relation is why this handoff chose this account.
607        assert_eq!(bound.lane_ref.as_deref(), Some("lane.claude-local"));
608        assert!(bound.outcome_ref.is_none(), "an open handoff has no outcome");
609
610        assert_eq!(ledger.advance(Some(&roster), NOW_MS + 2_000), 1);
611        let done = ledger.get(&handoff_ref).expect("record").clone();
612        assert_eq!(done.state, Issue31ProviderHandoffState::Completed);
613        assert_eq!(
614            done.outcome_ref.as_deref(),
615            Some(ISSUE31_HANDOFF_OUTCOME_CONNECTED)
616        );
617        assert!(done.is_terminal());
618        assert_eq!(ledger.advance(Some(&roster), NOW_MS + 3_000), 0);
619    }
620
621    #[test]
622    fn a_failure_is_a_row_and_a_request_that_never_started_is_no_row_at_all() {
623        // The distinction the exit turns on. A handoff the host admitted and
624        // then could not complete leaves a record the phone can read and act
625        // on. A request the host never admitted leaves nothing, and the phone
626        // must not be able to confuse the two.
627        let mut ledger = Issue31ProviderHandoffLedger::default();
628        let refused = ledger.open(
629            "arguments.omega.provider_handoff",
630            "idempotency.issue31.handoff:never",
631            NOW_MS,
632        );
633        assert_eq!(refused, Err(Issue31ProviderHandoffError::ArgumentsInvalid));
634        assert!(ledger.is_empty(), "an unadmitted request leaves no record");
635        assert!(ledger.projected(NOW_MS).rows.is_empty());
636
637        let (mut ledger, handoff_ref) = opened();
638        let roster = [account(
639            "account.claude.1",
640            "anthropic",
641            "lane.claude-local",
642            "revoked",
643        )];
644        ledger.advance(Some(&roster), NOW_MS + 1_000);
645        ledger.advance(Some(&roster), NOW_MS + 2_000);
646        let failed = ledger.get(&handoff_ref).expect("record").clone();
647        assert_eq!(failed.state, Issue31ProviderHandoffState::Refused);
648        assert_eq!(
649            failed.reason_class.as_deref(),
650            Some(ISSUE31_HANDOFF_REASON_ACCOUNT_REVOKED)
651        );
652        assert_eq!(
653            failed.outcome_ref.as_deref(),
654            Some(ISSUE31_HANDOFF_OUTCOME_REFUSED)
655        );
656        let projection = ledger.projected(NOW_MS + 3_000);
657        assert_eq!(projection.rows.len(), 1, "a failure is visible on the wire");
658        assert!(projection.unavailable.is_empty());
659    }
660
661    #[test]
662    fn an_unread_roster_decides_nothing() {
663        let (mut ledger, handoff_ref) = opened();
664        assert_eq!(ledger.advance(None, NOW_MS + 1_000), 0);
665        assert_eq!(
666            ledger.get(&handoff_ref).expect("record").state,
667            Issue31ProviderHandoffState::Requested,
668        );
669    }
670
671    #[test]
672    fn a_host_with_no_account_for_the_provider_waits_and_then_expires() {
673        let (mut ledger, handoff_ref) = opened();
674        let roster = [account("account.codex.1", "openai", "lane.codex-local", "ready")];
675        assert_eq!(ledger.advance(Some(&roster), NOW_MS + 1_000), 0);
676        assert_eq!(
677            ledger.get(&handoff_ref).expect("record").state,
678            Issue31ProviderHandoffState::Requested,
679            "the owner may still be completing the login at the host",
680        );
681        assert_eq!(
682            ledger.advance(
683                Some(&roster),
684                NOW_MS + ISSUE31_PROVIDER_HANDOFF_DEADLINE_MS
685            ),
686            1,
687        );
688        let expired = ledger.get(&handoff_ref).expect("record").clone();
689        assert_eq!(expired.state, Issue31ProviderHandoffState::Expired);
690        assert_eq!(
691            expired.reason_class.as_deref(),
692            Some(ISSUE31_HANDOFF_REASON_DEADLINE_PASSED)
693        );
694        assert_eq!(
695            expired.outcome_ref.as_deref(),
696            Some(ISSUE31_HANDOFF_OUTCOME_EXPIRED)
697        );
698    }
699
700    #[test]
701    fn a_restart_settles_what_was_in_flight_rather_than_losing_it() {
702        let (mut ledger, handoff_ref) = opened();
703        let roster = [account(
704            "account.claude.1",
705            "anthropic",
706            "lane.claude-local",
707            "busy",
708        )];
709        ledger.advance(Some(&roster), NOW_MS + 1_000);
710        assert_eq!(
711            ledger.get(&handoff_ref).expect("record").state,
712            Issue31ProviderHandoffState::Active,
713        );
714
715        // The exact bytes that cross a restart.
716        let serialized = serde_json::to_string(&ledger).expect("the ledger serializes");
717        let mut reloaded: Issue31ProviderHandoffLedger =
718            serde_json::from_str(&serialized).expect("the ledger survives a restart");
719        assert_eq!(reloaded, ledger, "a restart loses nothing");
720        assert_eq!(reloaded.adopt_after_restart(), 1);
721        let settled = reloaded.get(&handoff_ref).expect("record").clone();
722        assert_eq!(settled.state, Issue31ProviderHandoffState::Failed);
723        assert_eq!(
724            settled.reason_class.as_deref(),
725            Some(ISSUE31_HANDOFF_REASON_HOST_RESTARTED)
726        );
727        assert_eq!(
728            settled.outcome_ref.as_deref(),
729            Some(ISSUE31_HANDOFF_OUTCOME_INTERRUPTED)
730        );
731        assert_eq!(
732            reloaded.adopt_after_restart(),
733            0,
734            "a second restart does not re-settle a terminal handoff",
735        );
736    }
737
738    #[test]
739    fn a_restart_never_reports_a_connection_the_host_did_not_make() {
740        let (mut ledger, handoff_ref) = opened();
741        ledger.adopt_after_restart();
742        let settled = ledger.get(&handoff_ref).expect("record").clone();
743        assert_ne!(settled.state, Issue31ProviderHandoffState::Completed);
744        assert!(settled.account_ref.is_none());
745    }
746
747    #[test]
748    fn a_row_the_host_never_measured_is_reported_unavailable_rather_than_stamped() {
749        // Exactly the shape a build older than `requestedAtMs` persisted.
750        let ledger: Issue31ProviderHandoffLedger = serde_json::from_str(
751            r#"{"entries":{"handoff.omega.legacy":{"handoffRef":"handoff.omega.legacy",
752                "provider":"anthropic","state":"requested"}}}"#,
753        )
754        .expect("a pre-existing row decodes");
755        let projection = ledger.projected(NOW_MS);
756        assert!(
757            projection.rows.is_empty(),
758            "a row with no measured request time is never shown"
759        );
760        assert_eq!(projection.unavailable, vec!["handoff.omega.legacy"]);
761    }
762
763    #[test]
764    fn a_row_the_host_never_measured_is_never_advanced_either() {
765        let mut ledger: Issue31ProviderHandoffLedger = serde_json::from_str(
766            r#"{"entries":{"handoff.omega.legacy":{"handoffRef":"handoff.omega.legacy",
767                "provider":"anthropic","state":"requested"}}}"#,
768        )
769        .expect("a pre-existing row decodes");
770        let roster = [account(
771            "account.claude.1",
772            "anthropic",
773            "lane.claude-local",
774            "ready",
775        )];
776        assert_eq!(ledger.advance(Some(&roster), NOW_MS), 0);
777        assert_eq!(ledger.adopt_after_restart(), 0);
778        assert_eq!(
779            ledger.get("handoff.omega.legacy").expect("record").state,
780            Issue31ProviderHandoffState::Requested,
781        );
782    }
783
784    #[test]
785    fn a_lane_that_moves_under_a_bound_handoff_is_a_conflict_not_a_re_mapping() {
786        let (mut ledger, handoff_ref) = opened();
787        let before = [account(
788            "account.claude.1",
789            "anthropic",
790            "lane.claude-local",
791            "busy",
792        )];
793        ledger.advance(Some(&before), NOW_MS + 1_000);
794        let after = [account(
795            "account.claude.1",
796            "anthropic",
797            "lane.claude-local-2",
798            "ready",
799        )];
800        assert_eq!(ledger.advance(Some(&after), NOW_MS + 2_000), 1);
801        let conflicted = ledger.get(&handoff_ref).expect("record").clone();
802        assert_eq!(conflicted.state, Issue31ProviderHandoffState::Failed);
803        assert_eq!(
804            conflicted.reason_class.as_deref(),
805            Some(ISSUE31_HANDOFF_REASON_LANE_CONFLICT)
806        );
807    }
808
809    #[test]
810    fn a_bound_account_that_leaves_the_roster_fails_rather_than_staying_active() {
811        let (mut ledger, handoff_ref) = opened();
812        let before = [account(
813            "account.claude.1",
814            "anthropic",
815            "lane.claude-local",
816            "busy",
817        )];
818        ledger.advance(Some(&before), NOW_MS + 1_000);
819        assert_eq!(ledger.advance(Some(&[]), NOW_MS + 2_000), 1);
820        assert_eq!(
821            ledger
822                .get(&handoff_ref)
823                .expect("record")
824                .reason_class
825                .as_deref(),
826            Some(ISSUE31_HANDOFF_REASON_ACCOUNT_WITHDRAWN),
827        );
828    }
829
830    #[test]
831    fn the_device_supplies_no_timestamp_account_lane_or_outcome() {
832        // The action's entire input surface is a provider token, so there is no
833        // field through which a device could state a host-owned fact.
834        assert_eq!(
835            Issue31ProviderHandoffLedger::provider_from_arguments_ref(
836                "arguments.omega.provider_handoff.anthropic"
837            ),
838            Ok("anthropic".to_string()),
839        );
840        for rejected in [
841            "arguments.omega.none",
842            "arguments.omega.provider_handoff.",
843            "arguments.omega.provider_handoff.Anthropic",
844            "arguments.omega.provider_handoff.an thropic",
845            "arguments.omega.provider_handoff.../../etc",
846        ] {
847            assert!(
848                Issue31ProviderHandoffLedger::provider_from_arguments_ref(rejected).is_err(),
849                "expected {rejected:?} to be refused"
850            );
851        }
852    }
853
854    #[test]
855    fn a_replayed_command_reopens_the_same_handoff_with_its_first_stamp() {
856        let mut ledger = Issue31ProviderHandoffLedger::default();
857        let first = ledger
858            .open(
859                "arguments.omega.provider_handoff.anthropic",
860                "idempotency.issue31.handoff:one",
861                NOW_MS,
862            )
863            .expect("open");
864        let replay = ledger
865            .open(
866                "arguments.omega.provider_handoff.anthropic",
867                "idempotency.issue31.handoff:one",
868                NOW_MS + 60_000,
869            )
870            .expect("replay");
871        assert_eq!(first, replay, "a replay is not a second measurement");
872        assert_eq!(ledger.len(), 1);
873    }
874
875    #[test]
876    fn a_second_request_after_a_terminal_one_opens_a_new_handoff() {
877        let (mut ledger, first_ref) = opened();
878        ledger.adopt_after_restart();
879        let second = ledger
880            .open(
881                "arguments.omega.provider_handoff.anthropic",
882                "idempotency.issue31.handoff:two",
883                NOW_MS + 1_000,
884            )
885            .expect("a fresh ask is a fresh handoff");
886        assert_ne!(second.handoff_ref, first_ref);
887        assert_eq!(second.state, Issue31ProviderHandoffState::Requested);
888        assert_eq!(ledger.len(), 2);
889    }
890
891    #[test]
892    fn the_ledger_refuses_the_row_after_the_contract_bound() {
893        let mut ledger = Issue31ProviderHandoffLedger::default();
894        for index in 0..MAX_ISSUE31_PROVIDER_HANDOFFS {
895            ledger
896                .open(
897                    "arguments.omega.provider_handoff.anthropic",
898                    &format!("idempotency.issue31.handoff:{index}"),
899                    NOW_MS,
900                )
901                .expect("within the bound");
902        }
903        assert_eq!(
904            ledger.open(
905                "arguments.omega.provider_handoff.anthropic",
906                "idempotency.issue31.handoff:overflow",
907                NOW_MS,
908            ),
909            Err(Issue31ProviderHandoffError::BoundExhausted),
910        );
911        assert_eq!(ledger.projected(NOW_MS).rows.len(), MAX_ISSUE31_PROVIDER_HANDOFFS);
912    }
913
914    #[test]
915    fn every_projected_row_is_one_the_reader_accepts() {
916        let (mut ledger, _) = opened();
917        let roster = [account(
918            "account.claude.1",
919            "anthropic",
920            "lane.claude-local",
921            "ready",
922        )];
923        ledger.advance(Some(&roster), NOW_MS + 1_000);
924        ledger.advance(Some(&roster), NOW_MS + 2_000);
925        let generated_at_ms = NOW_MS + 3_000;
926        for row in ledger.projected(generated_at_ms).rows {
927            decode_issue31_provider_handoff(&row, generated_at_ms)
928                .expect("the emitter cannot write what the reader refuses");
929        }
930    }
931
932    #[test]
933    fn a_row_stamped_after_the_reading_it_would_ride_in_is_refused() {
934        let (ledger, handoff_ref) = opened();
935        let projection = ledger.projected(NOW_MS - 1);
936        assert!(projection.rows.is_empty());
937        assert_eq!(projection.unavailable, vec![handoff_ref]);
938    }
939
940    #[test]
941    fn a_projected_row_carries_no_host_private_field() {
942        let (mut ledger, _) = opened();
943        let roster = [account(
944            "account.claude.1",
945            "anthropic",
946            "lane.claude-local",
947            "busy",
948        )];
949        ledger.advance(Some(&roster), NOW_MS + 1_000);
950        let row = ledger.projected(NOW_MS + 2_000).rows.remove(0);
951        let object = row.as_object().expect("row");
952        // The deadline and the recorded lane are the host's own bookkeeping.
953        // The contract has no field for either, and inventing one here would
954        // widen the boundary the phone reads.
955        assert!(object.get("deadlineAtMs").is_none());
956        assert!(object.get("laneRef").is_none());
957        assert!(object.get("lane").is_none());
958        let mut keys: Vec<&str> = object.keys().map(String::as_str).collect();
959        keys.sort_unstable();
960        assert_eq!(
961            keys,
962            vec![
963                "accountRef",
964                "handoffRef",
965                "provider",
966                "requestedAtMs",
967                "state"
968            ]
969        );
970    }
971}
972
973/// The byte-shared lifecycle fixture (omega#91).
974///
975/// `crates/workroom_receipts/fixtures/openagents.omega.issue31.fullauto.v1.host-produced-handoffs.json`
976/// and its byte-identical peer under `packages/sarah/fixtures/issue31-workroom/`
977/// are not written by hand. They are what this ledger emits when it is driven
978/// through every lifecycle, so a change to the lifecycle that the phone would
979/// read differently fails here rather than being discovered on a device.
980#[cfg(test)]
981mod shared_fixture {
982    use super::*;
983
984    pub const FIXTURE_GENERATED_AT_MS: u64 = 1_785_000_600_000;
985    pub const FIXTURE_OPENED_AT_MS: u64 = 1_785_000_000_000;
986
987    pub fn fixture_roster() -> Vec<Issue31ProviderRosterAccount> {
988        vec![
989            Issue31ProviderRosterAccount {
990                account_ref: "account.claude.1".into(),
991                provider: "anthropic".into(),
992                lane_ref: "lane.claude-local".into(),
993                readiness: "busy".into(),
994            },
995            Issue31ProviderRosterAccount {
996                account_ref: "account.codex.1".into(),
997                provider: "openai".into(),
998                lane_ref: "lane.codex-local".into(),
999                readiness: "ready".into(),
1000            },
1001            Issue31ProviderRosterAccount {
1002                account_ref: "account.grok.1".into(),
1003                provider: "xai".into(),
1004                lane_ref: "lane.grok-local".into(),
1005                readiness: "revoked".into(),
1006            },
1007        ]
1008    }
1009
1010    /// Six handoffs, every one produced by the real lifecycle.
1011    pub fn fixture_ledger() -> Issue31ProviderHandoffLedger {
1012        let roster = fixture_roster();
1013        let mut ledger = Issue31ProviderHandoffLedger::default();
1014        for (provider, idempotency) in [
1015            ("cohere", "idempotency.issue31.fixture-requested"),
1016            ("anthropic", "idempotency.issue31.fixture-active"),
1017            ("openai", "idempotency.issue31.fixture-completed"),
1018            ("xai", "idempotency.issue31.fixture-refused"),
1019            ("mistral", "idempotency.issue31.fixture-failed"),
1020            ("google", "idempotency.issue31.fixture-expired"),
1021        ] {
1022            ledger
1023                .open(
1024                    &format!("{ISSUE31_PROVIDER_HANDOFF_ARGUMENTS_PREFIX}{provider}"),
1025                    idempotency,
1026                    FIXTURE_OPENED_AT_MS,
1027                )
1028                .expect("open");
1029        }
1030        // One roster observation binds anthropic, openai and xai.
1031        ledger.advance(Some(&roster), FIXTURE_OPENED_AT_MS + 60_000);
1032        // A second settles openai (ready) and xai (revoked); anthropic is busy
1033        // and stays bound and open.
1034        ledger.advance(Some(&roster), FIXTURE_OPENED_AT_MS + 120_000);
1035        // The host process ends. `mistral` was still `requested`, so it is
1036        // settled as interrupted rather than lost -- but so would the others
1037        // be, which is why the restart sweep runs on a ledger holding only the
1038        // one still in flight in the real path. Here it is applied to a clone
1039        // and merged, so each row shows exactly one lifecycle.
1040        let mut interrupted = Issue31ProviderHandoffLedger::default();
1041        interrupted
1042            .open(
1043                &format!("{ISSUE31_PROVIDER_HANDOFF_ARGUMENTS_PREFIX}mistral"),
1044                "idempotency.issue31.fixture-failed",
1045                FIXTURE_OPENED_AT_MS,
1046            )
1047            .expect("open");
1048        interrupted.adopt_after_restart();
1049        let mut expired = Issue31ProviderHandoffLedger::default();
1050        expired
1051            .open(
1052                &format!("{ISSUE31_PROVIDER_HANDOFF_ARGUMENTS_PREFIX}google"),
1053                "idempotency.issue31.fixture-expired",
1054                FIXTURE_OPENED_AT_MS,
1055            )
1056            .expect("open");
1057        expired.advance(
1058            Some(&roster),
1059            FIXTURE_OPENED_AT_MS + ISSUE31_PROVIDER_HANDOFF_DEADLINE_MS,
1060        );
1061        ledger.merge_for_fixture(interrupted);
1062        ledger.merge_for_fixture(expired);
1063        ledger
1064    }
1065
1066    pub fn build_fixture_document() -> serde_json::Value {
1067        let accounts: Vec<serde_json::Value> = fixture_roster()
1068            .into_iter()
1069            .map(|account| {
1070                json!({
1071                    "accountRef": account.account_ref,
1072                    "provider": account.provider,
1073                    "label": match account.provider.as_str() {
1074                        "anthropic" => "Claude",
1075                        "openai" => "ChatGPT Personal",
1076                        _ => "Grok",
1077                    },
1078                    "state": account.readiness,
1079                    "quotaState": if account.readiness == "ready" { "available" } else { "cooling" },
1080                    "lane": account.lane_ref,
1081                })
1082            })
1083            .collect();
1084        let handoffs = fixture_ledger().projected(FIXTURE_GENERATED_AT_MS).rows;
1085        let (_, document) = workroom_receipts::build_issue31_full_auto_adjunct_document(
1086            "omega.host.local",
1087            "snapshot.omega.issue31.handoff-lifecycle",
1088            FIXTURE_GENERATED_AT_MS,
1089            &json!({ "runs": [] }),
1090            &json!({ "accounts": accounts }),
1091            &json!({ "handoffs": handoffs }),
1092            &[],
1093        )
1094        .expect("the ledger's own rows build a readable adjunct");
1095        document
1096    }
1097
1098    const HOST_PRODUCED: &str = include_str!(
1099        "../../workroom_receipts/fixtures/openagents.omega.issue31.fullauto.v1.host-produced-handoffs.json"
1100    );
1101
1102    #[test]
1103    fn the_shared_fixture_is_exactly_what_the_ledger_emits() {
1104        let expected: serde_json::Value =
1105            serde_json::from_str(HOST_PRODUCED).expect("the shared fixture parses");
1106        assert_eq!(
1107            build_fixture_document(),
1108            expected,
1109            "the lifecycle changed. Re-share the fixture bytes with \
1110             packages/sarah rather than re-pinning one side.",
1111        );
1112    }
1113
1114    #[test]
1115    fn the_shared_fixture_shows_every_lifecycle_the_host_can_reach() {
1116        let document = build_fixture_document();
1117        let states: Vec<&str> = document
1118            .get("handoffs")
1119            .and_then(serde_json::Value::as_array)
1120            .expect("handoffs")
1121            .iter()
1122            .filter_map(|handoff| handoff.get("state").and_then(serde_json::Value::as_str))
1123            .collect();
1124        for state in [
1125            "requested", "active", "completed", "refused", "failed", "expired",
1126        ] {
1127            assert!(states.contains(&state), "{state} is unrepresented");
1128        }
1129    }
1130
1131    #[test]
1132    fn every_terminal_row_in_the_shared_fixture_states_a_host_owned_outcome() {
1133        let document = build_fixture_document();
1134        for handoff in document
1135            .get("handoffs")
1136            .and_then(serde_json::Value::as_array)
1137            .expect("handoffs")
1138        {
1139            let state = handoff
1140                .get("state")
1141                .and_then(serde_json::Value::as_str)
1142                .expect("state");
1143            let terminal = matches!(state, "completed" | "refused" | "failed" | "expired");
1144            assert_eq!(
1145                handoff.get("outcomeRef").is_some(),
1146                terminal,
1147                "an outcome is present exactly when the handoff is terminal: {handoff}",
1148            );
1149            if terminal && state != "completed" {
1150                assert!(
1151                    handoff.get("reasonClass").is_some(),
1152                    "a non-successful end must say why: {handoff}",
1153                );
1154            }
1155        }
1156    }
1157
1158    #[test]
1159    fn the_shared_fixture_carries_no_credential_home_or_private_path() {
1160        // The record carries the fact of a connection, never the connection
1161        // secret. Asserted on the bytes that actually ship.
1162        let lowered = HOST_PRODUCED.to_ascii_lowercase();
1163        for forbidden in [
1164            "auth.json",
1165            "bearer ",
1166            "codex_home",
1167            "/users/",
1168            "/home/",
1169            "~/",
1170            "sk-",
1171            "access_token",
1172            "api_key",
1173        ] {
1174            assert!(
1175                !lowered.contains(forbidden),
1176                "the shared fixture leaked {forbidden}",
1177            );
1178        }
1179    }
1180}
1181
Served at tenant.openagents/omega Member data and write actions are omitted.