Skip to repository content1181 lines · 48.7 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:20:11.339Z 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_provider_handoff.rs
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