From ec7add76962af5c8178227b6fd072532e947997c Mon Sep 17 00:00:00 2001 From: James Kane Date: Sun, 2 Aug 2026 11:47:54 -0500 Subject: [PATCH 1/3] Give federated IBD matching a front door, and a memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IBD engine has been complete for months — detection, the X3DH/AES-GCM exchange channel, signed attestations, the chromosome browser — but a matching conversation had nowhere to live. Outbound requests were held in a UI HashMap, so a restart forgot that we had asked anyone anything; only *completed* exchanges persisted. Discovery, consent, and results were three disjoint cards buried in one subject's tab, even though a conversation is keyed by our DID and the broker's request URI and belongs to no subject in particular. Adds the ledger the flow was missing (`ibd_request`, migration 0041) and `App::refresh_matching` to reconcile it against the broker in one pass. The reconciler adopts what `/exchange/incoming` reports with `insert_if_absent`, so re-polling can never walk back a decision made locally, and a failing pass degrades the view instead of emptying it. Two AppView endpoints that existed but were never called are now wired. `/ibd/dismiss` gives dismissal an actual button. `/ibd/attest` is the one that mattered: without it a completed comparison stayed private forever and the discovery graph could not grow from its own results. Attest is gated on both parties agreeing on the summary — filing a one-sided figure from a comparison our own run disputes would put a claim on the graph we do not believe. Status is deliberately honest about what this edge can know. The broker is symmetric-blind, so a partner's refusal is indistinguishable from silence; both stay "waiting on them", and DECLINED means *we* declined. Consent is a modal, not a row button, because accepting reveals our DID and puts our IBD-panel dosages on the wire. It says so, in three headings: what you send, what they learn, what never leaves the device. The subject tab keeps its results card and gains a link across. The subject picker is a filter plus a virtualized list rather than a ComboBox — a workspace can hold 10k subjects, and a ComboBox builds a widget per entry per frame. Companion AppView change (decodingus): `/ibd/suggestions` now returns the caller's own `target_sample_guid`. Without it `owns_sample` could never be satisfied from the edge and attest was unreachable. Co-Authored-By: Claude Opus 5 (1M context) --- crates/navigator-app/src/ibd_exchange.rs | 6 + crates/navigator-app/src/lib.rs | 217 ++++++++ crates/navigator-app/src/matching.rs | 488 ++++++++++++++++++ crates/navigator-app/src/sync.rs | 11 +- crates/navigator-domain/locales/en.txt | 60 ++- crates/navigator-domain/locales/es.txt | 60 ++- .../migrations/0041_ibd_request.down.sql | 3 + .../migrations/0041_ibd_request.up.sql | 38 ++ crates/navigator-store/src/ibd_request.rs | 233 +++++++++ crates/navigator-store/src/lib.rs | 1 + crates/navigator-ui/src/ui/central.rs | 4 +- crates/navigator-ui/src/ui/chrome.rs | 7 +- crates/navigator-ui/src/ui/events.rs | 39 +- crates/navigator-ui/src/ui/ibd.rs | 182 +------ crates/navigator-ui/src/ui/matching.rs | 438 ++++++++++++++++ crates/navigator-ui/src/ui/mod.rs | 65 ++- crates/navigator-ui/src/ui/modals.rs | 62 +++ crates/navigator-ui/src/ui/simple.rs | 14 +- crates/navigator-ui/src/worker.rs | 108 ++-- documents/BACKLOG.md | 15 +- documents/IBD_Matching_Implementation_Plan.md | 8 + 21 files changed, 1792 insertions(+), 267 deletions(-) create mode 100644 crates/navigator-app/src/matching.rs create mode 100644 crates/navigator-store/migrations/0041_ibd_request.down.sql create mode 100644 crates/navigator-store/migrations/0041_ibd_request.up.sql create mode 100644 crates/navigator-store/src/ibd_request.rs create mode 100644 crates/navigator-ui/src/ui/matching.rs diff --git a/crates/navigator-app/src/ibd_exchange.rs b/crates/navigator-app/src/ibd_exchange.rs index ec7b681e..8e7a6ffa 100644 --- a/crates/navigator-app/src/ibd_exchange.rs +++ b/crates/navigator-app/src/ibd_exchange.rs @@ -513,8 +513,14 @@ impl App { ) .await?; self.record_ibd_exchange(guid, session, request_uri, &result).await?; + // Advance the ledger before either publish: the comparison is done and persisted, so the + // conversation is complete whether or not the network steps below succeed. + self.mark_matching_exchanged(guid, session, request_uri).await?; // Best-effort: publish our attestation to the PDS (skipped for did:key; never fails the exchange). let _ = self.publish_ibd_attestation(&result.my_attestation).await; + // Best-effort: report the outcome to the AppView so the match feeds discovery. A no-op + // unless both sides agreed and we know both AppView sample handles. + let _ = self.attest_exchange_if_possible(request_uri).await; Ok(result) } diff --git a/crates/navigator-app/src/lib.rs b/crates/navigator-app/src/lib.rs index e06ab16a..e06d3039 100644 --- a/crates/navigator-app/src/lib.rs +++ b/crates/navigator-app/src/lib.rs @@ -436,8 +436,14 @@ pub struct AssetStatus { /// `suggested_sample_guid` is the AppView's opaque handle for the counterpart (not a DID, /// not PII) — used to request an introduction. `signals` names the sources that contributed /// (e.g. `POPULATION_OVERLAP`, `HAPLOGROUP`, `SHARED_MATCH`) behind the composite `score`. +/// +/// `target_sample_guid` is the AppView's handle for **our own** sample the candidate was ranked +/// against. We already own it, so it discloses nothing — but a self-publishing client has no other +/// way to learn its server-side sample handle, and [`App::ibd_attest`] cannot report a completed +/// comparison without it. `None` when talking to an AppView that predates that field. #[derive(Debug, Clone, PartialEq)] pub struct IbdSuggestion { + pub target_sample_guid: Option, pub suggested_sample_guid: String, pub suggestion_type: String, pub score: f64, @@ -481,10 +487,15 @@ impl IbdSuggestion { /// Result of requesting an introduction to a candidate: the AppView's request URI and its /// status (initially `PENDING`, awaiting the consent round-trip). +/// +/// `purpose` is chosen server-side from the suggestion's dominant signal (`IBD_AUTOSOMAL` / `IBD_Y` +/// / `IBD_MT`) — it decides which genomic region a later attestation is filed under, so it is worth +/// recording at introduction rather than waiting for the session to reveal it. #[derive(Debug, Clone, PartialEq)] pub struct IbdIntroResult { pub request_uri: String, pub status: String, + pub purpose: String, } /// An inbound, **symmetric-blind** exchange request awaiting this account's consent (the initiator @@ -515,6 +526,107 @@ pub struct ConsentOutcome { pub session_id: Option, } +/// Who opened a matching conversation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MatchingDirection { + /// We asked to be introduced. + Outbound, + /// Someone asked to be introduced to us. + Inbound, +} + +impl MatchingDirection { + /// Stable ledger token (independent of display strings). + pub fn as_str(self) -> &'static str { + match self { + MatchingDirection::Outbound => "OUTBOUND", + MatchingDirection::Inbound => "INBOUND", + } + } + fn parse(s: &str) -> Self { + match s { + "INBOUND" => MatchingDirection::Inbound, + _ => MatchingDirection::Outbound, + } + } +} + +/// Where a matching conversation stands. Deliberately records only what this edge can *know*: +/// the broker is symmetric-blind, so a partner declining is indistinguishable from a partner who +/// has not answered yet — both stay [`MatchingStatus::Requested`], and [`MatchingStatus::Declined`] +/// means **we** declined. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MatchingStatus { + /// We asked; the counterpart has not consented (or has not answered). + Requested, + /// Inbound and awaiting our decision. + AwaitingConsent, + /// We declined. + Declined, + /// Both consented — a session is open and the encrypted exchange can run. + Ready, + /// The exchange ran and a result is stored. + Exchanged, + /// The exchange was attempted and failed; `last_error` says why. + Failed, +} + +impl MatchingStatus { + /// Stable ledger token (independent of display strings). + pub fn as_str(self) -> &'static str { + match self { + MatchingStatus::Requested => "REQUESTED", + MatchingStatus::AwaitingConsent => "AWAITING_CONSENT", + MatchingStatus::Declined => "DECLINED", + MatchingStatus::Ready => "READY", + MatchingStatus::Exchanged => "EXCHANGED", + MatchingStatus::Failed => "FAILED", + } + } + fn parse(s: &str) -> Self { + match s { + "AWAITING_CONSENT" => MatchingStatus::AwaitingConsent, + "DECLINED" => MatchingStatus::Declined, + "READY" => MatchingStatus::Ready, + "EXCHANGED" => MatchingStatus::Exchanged, + "FAILED" => MatchingStatus::Failed, + _ => MatchingStatus::Requested, + } + } + /// True once the conversation has nothing further to do — it either produced a result or we + /// turned it down. The UI files these away from the actionable list. + pub fn is_terminal(self) -> bool { + matches!(self, MatchingStatus::Exchanged | MatchingStatus::Declined) + } +} + +/// One matching conversation, as the UI reads it: the durable ledger row plus the exchange result +/// once there is one. Assembled by [`App::matching_entries`]. +#[derive(Debug, Clone)] +pub struct MatchingEntry { + pub request_uri: String, + pub direction: MatchingDirection, + pub purpose: String, + pub status: MatchingStatus, + /// Revealed only after mutual consent — `None` while the request is still blind. + pub partner_did: Option, + pub session_id: Option, + /// The local subject whose dosages this conversation exchanges. + pub biosample_guid: Option, + /// AppView sample handles (ours / theirs) — what an attestation is filed under. + pub my_sample_ref: Option, + pub partner_sample_ref: Option, + /// Our own consent decision; `None` until we make one. + pub consent_given: Option, + /// True once the AppView accepted our attestation for this comparison. + pub attested: bool, + pub last_error: Option, + pub created_at: String, + pub updated_at: String, + /// The stored exchange result, present once `status` is [`MatchingStatus::Exchanged`]. + pub result: Option, +} + /// A pulled relay envelope: the opaque ciphertext `blob` plus its routing (`from_did`/`seq`) and the /// broker `id` to ack. From `GET /api/v1/exchange/relay/pull`. #[derive(Debug, Clone, PartialEq)] @@ -602,6 +714,13 @@ fn parse_ibd_suggestions(body: &serde_json::Value) -> Vec { .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(); + // Optional: older AppViews omit it, and the row is still usable for everything but + // attesting, so a missing value must not drop the candidate. + let target_sample_guid = it + .get("targetSampleGuid") + .or_else(|| it.get("target_sample_guid")) + .and_then(|v| v.as_str()) + .map(str::to_string); let score = it.get("score").and_then(|v| v.as_f64()).unwrap_or(0.0); let signals = it .get("metadata") @@ -610,6 +729,7 @@ fn parse_ibd_suggestions(body: &serde_json::Value) -> Vec { .map(parse_ibd_signals) .unwrap_or_default(); Some(IbdSuggestion { + target_sample_guid, suggested_sample_guid, suggestion_type, score, @@ -680,6 +800,7 @@ pub use navigator_domain::yprofile::{YProfileSummary, YProfileVariant, YSourceOb use navigator_domain::ysnp_dict::{self, YsnpDictionary}; pub use navigator_store::dm::{DmConversationSummary, DmMessage}; pub use navigator_store::ibd_exchange::StoredIbdExchange; +pub use navigator_store::ibd_request::StoredIbdRequest; pub use navigator_store::source_file::SourceFile; use navigator_store::{ alignment, ancestry_result, artifact, biosample, biosample_project, chip_profile, consensus_painting, @@ -2617,6 +2738,7 @@ mod import_unified; pub mod llm; pub use llm::{ChatTurn, NarratedBrief}; pub use navigator_domain::results_context::SignalKind; +mod matching; mod publish; mod queries; mod recruitment; @@ -4066,6 +4188,100 @@ mod ibd_attest_tests { assert_eq!(rows[0].total_shared_cm, 75.0); assert!(rows[0].agreed); assert_eq!(rows[0].partner_did, "did:key:zB"); + + // The ledger adopts a conversation it never saw opened, so the completed exchange still + // reads as one entry with its result attached rather than an orphan row. + app.mark_matching_exchanged(b.guid, &session, "exchange:r").await.unwrap(); + let entries = app.matching_entries().await.unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].status, MatchingStatus::Exchanged); + assert_eq!(entries[0].partner_did.as_deref(), Some("did:key:zB")); + assert_eq!(entries[0].biosample_guid, Some(b.guid)); + assert_eq!(entries[0].result.as_ref().map(|r| r.total_shared_cm), Some(75.0)); + } + + /// A failed exchange must read as failed with its reason, not sit at READY forever; and + /// forgetting a conversation drops it locally. + #[tokio::test] + async fn matching_failure_and_forget() { + use navigator_store::Store; + let app = App::new(Store::open_in_memory().await.unwrap()); + let b = app.add_biosample(None, "S1", None, None).await.unwrap(); + let session = EstablishedSession { + session_id: "sess-y".into(), + partner_did: "did:key:zC".into(), + key: [0u8; 32], + }; + app.mark_matching_exchanged(b.guid, &session, "exchange:f").await.unwrap(); + + app.record_matching_failure("exchange:f", "relay timeout").await.unwrap(); + let e = app.matching_entry("exchange:f").await.unwrap(); + assert_eq!(e.status, MatchingStatus::Failed); + assert_eq!(e.last_error.as_deref(), Some("relay timeout")); + assert!(!e.status.is_terminal(), "a failure stays retryable"); + + // Recording against an unknown request is a no-op, not an error. + app.record_matching_failure("exchange:nope", "x").await.unwrap(); + assert!(app.matching_entry("exchange:nope").await.is_err()); + + app.forget_matching_request("exchange:f").await.unwrap(); + assert!(app.matching_entries().await.unwrap().is_empty()); + } + + /// Attestation is gated: without the AppView sample handles there is nothing to file, and a + /// disputed summary must not be reported as a match. Neither case is an error. + #[tokio::test] + async fn attest_is_skipped_without_handles_or_agreement() { + use navigator_store::Store; + let app = App::new(Store::open_in_memory().await.unwrap()); + let b = app.add_biosample(None, "S1", None, None).await.unwrap(); + let session = EstablishedSession { + session_id: "sess-z".into(), + partner_did: "did:key:zD".into(), + key: [0u8; 32], + }; + let mut result = IbdExchangeResult { + summary: summary(75.0), + segments: vec![], + overlapping_sites: 100, + my_attestation: IbdAttestation::unsigned( + "exchange:a", + "sess-z", + "did:key:zA", + Some(b.guid.to_string()), + None, + &summary(75.0), + "t", + ), + partner_attestation: IbdAttestation::unsigned( + "exchange:a", + "sess-z", + "did:key:zD", + None, + Some(b.guid.to_string()), + &summary(75.0), + "t", + ), + agreed: true, + }; + app.record_ibd_exchange(b.guid, &session, "exchange:a", &result) + .await + .unwrap(); + app.mark_matching_exchanged(b.guid, &session, "exchange:a").await.unwrap(); + + // No sample handles (a direct request never carries them) → nothing to attest, no network. + assert!(!app.attest_exchange_if_possible("exchange:a").await.unwrap()); + + // With handles but a disputed summary, we still file nothing. + app.set_matching_sample_refs("exchange:a", Some("s-mine"), Some("s-theirs")) + .await + .unwrap(); + result.agreed = false; + app.record_ibd_exchange(b.guid, &session, "exchange:a", &result) + .await + .unwrap(); + assert!(!app.attest_exchange_if_possible("exchange:a").await.unwrap()); + assert!(!app.matching_entry("exchange:a").await.unwrap().attested); } } @@ -4155,6 +4371,7 @@ mod ibd_federated_tests { fn match_strength_tiers_are_conservative() { let at = |score: f64| { IbdSuggestion { + target_sample_guid: None, suggested_sample_guid: "h".into(), suggestion_type: "SHARED_MATCH".into(), score, diff --git a/crates/navigator-app/src/matching.rs b/crates/navigator-app/src/matching.rs new file mode 100644 index 00000000..245e3305 --- /dev/null +++ b/crates/navigator-app/src/matching.rs @@ -0,0 +1,488 @@ +//! `impl App` methods for the **matching ledger** — the durable state behind federated-IBD +//! discovery and consent. +//! +//! The pieces this coordinates already existed: the AppView's candidate engine +//! ([`App::ibd_suggestions`]), the blind introduction broker ([`App::ibd_introduce`]), the +//! consent round-trip and encrypted channel (`ibd_exchange.rs`), and the stored result +//! (`navigator_store::ibd_exchange`). What was missing is the thread between them — every +//! in-flight request lived in UI memory, so a restart forgot that we had asked anyone anything. +//! +//! [`App::refresh_matching`] is the single reconcile: it adopts what the broker reports, advances +//! rows the broker has moved on, and never overwrites a decision we made locally. + +use super::*; + +/// Region tag an attestation is filed under, derived from the exchange purpose +/// (`ibd.ibd_discovery_index.match_region_type` is `AUTOSOMAL`/`X`/`Y`/`MT`). +fn region_type_for(purpose: &str) -> &str { + match purpose { + "IBD_Y" => "Y", + "IBD_MT" => "MT", + "IBD_X" => "X", + _ => "AUTOSOMAL", + } +} + +/// The ledger row a fresh conversation starts as. +fn new_row(request_uri: &str, direction: MatchingDirection, purpose: &str, status: MatchingStatus) -> StoredIbdRequest { + let now = Utc::now().to_rfc3339(); + StoredIbdRequest { + request_uri: request_uri.to_string(), + direction: direction.as_str().to_string(), + purpose: purpose.to_string(), + status: status.as_str().to_string(), + partner_did: None, + session_id: None, + biosample_guid: None, + my_sample_ref: None, + partner_sample_ref: None, + consent_given: None, + consent_at: None, + attested_at: None, + last_error: None, + created_at: now.clone(), + updated_at: now, + } +} + +impl App { + /// Every matching conversation, newest first, with its exchange result attached when it has one. + pub async fn matching_entries(&self) -> Result, AppError> { + let rows = navigator_store::ibd_request::list(self.store.pool()).await?; + let results = navigator_store::ibd_exchange::list(self.store.pool()).await?; + Ok(rows + .into_iter() + .map(|r| { + let result = results.iter().find(|x| x.request_uri == r.request_uri).cloned(); + MatchingEntry { + direction: MatchingDirection::parse(&r.direction), + status: MatchingStatus::parse(&r.status), + biosample_guid: r.biosample_guid.as_deref().and_then(parse_sample_guid), + request_uri: r.request_uri, + purpose: r.purpose, + partner_did: r.partner_did, + session_id: r.session_id, + my_sample_ref: r.my_sample_ref, + partner_sample_ref: r.partner_sample_ref, + consent_given: r.consent_given, + attested: r.attested_at.is_some(), + last_error: r.last_error, + created_at: r.created_at, + updated_at: r.updated_at, + result, + } + }) + .collect()) + } + + /// Reconcile the ledger with the broker, then return the full list. + /// + /// Three passes, in order of increasing knowledge: + /// 1. `/exchange/incoming` — inbound requests we have not seen. Adopted with + /// `insert_if_absent`, so a request we already declined stays declined even though the + /// broker keeps listing it. + /// 2. `/exchange/pending` — mutual consent happened: the partner DID and session id are now + /// known. Advances anything not already terminal (a completed exchange stays completed). + /// 3. Stored results — a completed exchange marks its request `EXCHANGED`. + /// + /// A pass that fails does not abort the others: a broker hiccup should degrade the view, not + /// empty it. The first error is returned once the local state is consistent. + pub async fn refresh_matching(&self) -> Result, AppError> { + let mut first_err: Option = None; + + match self.exchange_incoming().await { + Ok(incoming) => { + for r in incoming { + let mut row = new_row( + &r.request_uri, + MatchingDirection::Inbound, + &r.purpose, + MatchingStatus::AwaitingConsent, + ); + if !r.created_at.is_empty() { + row.created_at = r.created_at.clone(); + } + navigator_store::ibd_request::insert_if_absent(self.store.pool(), &row).await?; + } + } + Err(e) => first_err = Some(e), + } + + match self.exchange_pending().await { + Ok(pending) => { + for info in pending { + // An unknown session means the request was opened on another device (or the + // ledger predates it) — adopt it so the session is still runnable here. + let existing = navigator_store::ibd_request::get(self.store.pool(), &info.request_uri).await?; + let mut row = existing.unwrap_or_else(|| { + new_row( + &info.request_uri, + MatchingDirection::Inbound, + &info.purpose, + MatchingStatus::Ready, + ) + }); + if !MatchingStatus::parse(&row.status).is_terminal() { + row.status = MatchingStatus::Ready.as_str().to_string(); + } + row.partner_did = Some(info.partner_did.clone()); + row.session_id = Some(info.session_id.clone()); + if row.purpose.is_empty() { + row.purpose = info.purpose.clone(); + } + row.updated_at = Utc::now().to_rfc3339(); + navigator_store::ibd_request::upsert(self.store.pool(), &row).await?; + } + } + Err(e) => first_err = first_err.or(Some(e)), + } + + for done in navigator_store::ibd_exchange::list(self.store.pool()).await? { + let Some(mut row) = navigator_store::ibd_request::get(self.store.pool(), &done.request_uri).await? else { + continue; + }; + if MatchingStatus::parse(&row.status) == MatchingStatus::Exchanged { + continue; + } + row.status = MatchingStatus::Exchanged.as_str().to_string(); + row.session_id = Some(done.session_id.clone()); + row.partner_did = Some(done.partner_did.clone()); + row.updated_at = Utc::now().to_rfc3339(); + navigator_store::ibd_request::upsert(self.store.pool(), &row).await?; + } + + let entries = self.matching_entries().await?; + match first_err { + Some(e) => Err(e), + None => Ok(entries), + } + } + + /// Ask to be introduced to a candidate and record the conversation. + /// + /// Carries both AppView sample handles into the ledger: `target_sample_guid` (ours) and + /// `suggested_sample_guid` (theirs) are the only two identifiers an attestation can be filed + /// under, and the suggestion is the one place we ever see them. + pub async fn request_introduction( + &self, + suggestion: &IbdSuggestion, + biosample_guid: Option, + ) -> Result { + let intro = self.ibd_introduce(&suggestion.suggested_sample_guid).await?; + let mut row = new_row( + &intro.request_uri, + MatchingDirection::Outbound, + &intro.purpose, + MatchingStatus::Requested, + ); + row.my_sample_ref = suggestion.target_sample_guid.clone(); + row.partner_sample_ref = Some(suggestion.suggested_sample_guid.clone()); + row.biosample_guid = biosample_guid.map(|g| g.to_string()); + navigator_store::ibd_request::upsert(self.store.pool(), &row).await?; + self.matching_entry(&intro.request_uri).await + } + + /// Consent to (or decline) an inbound request, recording our decision durably. The decision is + /// written whatever the broker says next — re-polling must never resurrect a request we + /// turned down. + pub async fn matching_consent( + &self, + request_uri: &str, + given: bool, + biosample_guid: Option, + ) -> Result { + let outcome = self.exchange_consent(request_uri, given).await?; + let mut row = navigator_store::ibd_request::get(self.store.pool(), request_uri) + .await? + .unwrap_or_else(|| { + new_row( + request_uri, + MatchingDirection::Inbound, + "", + MatchingStatus::AwaitingConsent, + ) + }); + row.consent_given = Some(given); + row.consent_at = Some(Utc::now().to_rfc3339()); + row.status = if !given { + MatchingStatus::Declined + } else if outcome.session_id.is_some() { + MatchingStatus::Ready + } else { + // Recorded, but the counterpart has not consented yet. + MatchingStatus::Requested + } + .as_str() + .to_string(); + if let Some(sid) = outcome.session_id { + row.session_id = Some(sid); + } + if biosample_guid.is_some() { + row.biosample_guid = biosample_guid.map(|g| g.to_string()); + } + row.updated_at = Utc::now().to_rfc3339(); + navigator_store::ibd_request::upsert(self.store.pool(), &row).await?; + self.matching_entry(request_uri).await + } + + /// Bind a conversation to the local subject whose dosages it will exchange. + pub async fn set_matching_subject(&self, request_uri: &str, guid: SampleGuid) -> Result<(), AppError> { + let Some(mut row) = navigator_store::ibd_request::get(self.store.pool(), request_uri).await? else { + return Ok(()); + }; + row.biosample_guid = Some(guid.to_string()); + row.updated_at = Utc::now().to_rfc3339(); + navigator_store::ibd_request::upsert(self.store.pool(), &row).await?; + Ok(()) + } + + /// Record the AppView sample handles for a conversation that did not come from a suggestion + /// (or whose suggestion predated the AppView returning our own handle). Without both, a + /// completed comparison cannot be attested. + pub async fn set_matching_sample_refs( + &self, + request_uri: &str, + mine: Option<&str>, + theirs: Option<&str>, + ) -> Result<(), AppError> { + let Some(mut row) = navigator_store::ibd_request::get(self.store.pool(), request_uri).await? else { + return Ok(()); + }; + if mine.is_some() { + row.my_sample_ref = mine.map(str::to_string); + } + if theirs.is_some() { + row.partner_sample_ref = theirs.map(str::to_string); + } + row.updated_at = Utc::now().to_rfc3339(); + navigator_store::ibd_request::upsert(self.store.pool(), &row).await?; + Ok(()) + } + + /// Record that an exchange attempt failed, so the row reads as `FAILED` with the reason rather + /// than sitting at `READY` forever. + pub async fn record_matching_failure(&self, request_uri: &str, err: &str) -> Result<(), AppError> { + let Some(mut row) = navigator_store::ibd_request::get(self.store.pool(), request_uri).await? else { + return Ok(()); + }; + row.status = MatchingStatus::Failed.as_str().to_string(); + row.last_error = Some(err.to_string()); + row.updated_at = Utc::now().to_rfc3339(); + navigator_store::ibd_request::upsert(self.store.pool(), &row).await?; + Ok(()) + } + + /// Drop a conversation from the local ledger. The broker keeps its own record, so a still-live + /// request can reappear on the next refresh — this forgets, it does not cancel. + pub async fn forget_matching_request(&self, request_uri: &str) -> Result<(), AppError> { + navigator_store::ibd_request::delete(self.store.pool(), request_uri).await?; + Ok(()) + } + + /// Mark a conversation complete once its exchange result is stored, adopting the request if the + /// ledger has never seen it (a session opened on another device, or one predating the ledger). + pub(crate) async fn mark_matching_exchanged( + &self, + guid: SampleGuid, + session: &EstablishedSession, + request_uri: &str, + ) -> Result<(), AppError> { + let mut row = navigator_store::ibd_request::get(self.store.pool(), request_uri) + .await? + .unwrap_or_else(|| new_row(request_uri, MatchingDirection::Inbound, "", MatchingStatus::Ready)); + row.status = MatchingStatus::Exchanged.as_str().to_string(); + row.session_id = Some(session.session_id.clone()); + row.partner_did = Some(session.partner_did.clone()); + row.biosample_guid = Some(guid.to_string()); + row.last_error = None; + row.updated_at = Utc::now().to_rfc3339(); + navigator_store::ibd_request::upsert(self.store.pool(), &row).await?; + Ok(()) + } + + /// One conversation by request URI. + pub async fn matching_entry(&self, request_uri: &str) -> Result { + self.matching_entries() + .await? + .into_iter() + .find(|e| e.request_uri == request_uri) + .ok_or_else(|| AppError::AppView(format!("no matching request {request_uri}"))) + } + + /// Tell the AppView to stop suggesting a candidate (`POST /api/v1/ibd/dismiss`). The dismissal + /// is kept server-side across recomputes, so it survives without any local mirror. + pub async fn ibd_dismiss(&self, suggested_sample_guid: &str) -> Result<(), AppError> { + let did = self.current_account().ok_or(AppError::NotAuthenticated)?; + let key = self.ensure_device_key().await?; + let ts = Utc::now().timestamp(); + let sig = key.sign_fresh(ts, &format!("ibd-dismiss\n{did}\n{suggested_sample_guid}")); + let body = serde_json::json!({ + "did": did, + "suggested_sample_guid": suggested_sample_guid, + "ts": ts, + "signature": sig, + }); + let url = format!("{}/api/v1/ibd/dismiss", decodingus_appview_url()); + let resp = self + .auth + .http + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| AppError::Sync(navigator_sync::SyncError::from(e)))?; + if !resp.status().is_success() { + return Err(appview_status_error("ibd/dismiss", resp).await); + } + Ok(()) + } + + /// Report a completed comparison to the AppView (`POST /api/v1/ibd/attest`) — the step that + /// turns a private, edge-computed match into a discovery signal. Only coarse totals travel: + /// two opaque sample handles, a region tag, cM and segment count. Never coordinates, never + /// genotypes. + /// + /// The signed `cm` is formatted `{:.1}` to match the AppView's own canonical string byte for + /// byte; a mismatch there fails signature verification, not parsing. + pub async fn ibd_attest( + &self, + request_uri: &str, + claimed_sample: &str, + counterpart_sample: &str, + region_type: &str, + total_shared_cm: f64, + num_segments: i32, + ) -> Result<(), AppError> { + let did = self.current_account().ok_or(AppError::NotAuthenticated)?; + let key = self.ensure_device_key().await?; + let cm = format!("{total_shared_cm:.1}"); + let ts = Utc::now().timestamp(); + let sig = key.sign_fresh( + ts, + &format!("ibd-attest\n{did}\n{request_uri}\n{claimed_sample}\n{counterpart_sample}\n{region_type}\n{cm}"), + ); + let body = serde_json::json!({ + "did": did, + "request_uri": request_uri, + "claimed_sample": claimed_sample, + "counterpart_sample": counterpart_sample, + "region_type": region_type, + "total_shared_cm": total_shared_cm, + "num_segments": num_segments, + "ts": ts, + "signature": sig, + }); + let url = format!("{}/api/v1/ibd/attest", decodingus_appview_url()); + let resp = self + .auth + .http + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| AppError::Sync(navigator_sync::SyncError::from(e)))?; + if !resp.status().is_success() { + return Err(appview_status_error("ibd/attest", resp).await); + } + Ok(()) + } + + /// Attest a completed exchange if — and only if — it is attestable: both parties agreed on the + /// summary, and we know both AppView sample handles. + /// + /// Agreement is the gate because the AppView confirms an edge only when both parties report a + /// compatible total; filing a one-sided figure from a comparison our partner disputes would put + /// a claim on the discovery graph that our own run says is wrong. Handles are missing whenever + /// the conversation did not come from a suggestion (a direct request never names them), so this + /// is a no-op rather than an error. + pub(crate) async fn attest_exchange_if_possible(&self, request_uri: &str) -> Result { + let entry = self.matching_entry(request_uri).await?; + let (Some(mine), Some(theirs)) = (entry.my_sample_ref.clone(), entry.partner_sample_ref.clone()) else { + return Ok(false); + }; + let Some(result) = entry.result.as_ref().filter(|r| r.agreed) else { + return Ok(false); + }; + self.ibd_attest( + request_uri, + &mine, + &theirs, + region_type_for(&entry.purpose), + result.total_shared_cm, + result.segment_count as i32, + ) + .await?; + if let Some(mut row) = navigator_store::ibd_request::get(self.store.pool(), request_uri).await? { + row.attested_at = Some(Utc::now().to_rfc3339()); + row.updated_at = Utc::now().to_rfc3339(); + navigator_store::ibd_request::upsert(self.store.pool(), &row).await?; + } + Ok(true) + } +} + +/// A stored guid string back into a `SampleGuid` (a malformed one just reads as unbound). +fn parse_sample_guid(s: &str) -> Option { + Uuid::parse_str(s).ok().map(SampleGuid) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn region_type_follows_the_exchange_purpose() { + assert_eq!(region_type_for("IBD_AUTOSOMAL"), "AUTOSOMAL"); + assert_eq!(region_type_for("IBD_Y"), "Y"); + assert_eq!(region_type_for("IBD_MT"), "MT"); + assert_eq!(region_type_for("IBD_X"), "X"); + // An unknown/empty purpose must not invent a uniparental claim. + assert_eq!(region_type_for(""), "AUTOSOMAL"); + assert_eq!(region_type_for("GENEALOGY_PII"), "AUTOSOMAL"); + } + + /// Cross-repo contract: these strings are signed, and the AppView rebuilds them byte for byte + /// (`du_db::ibd::messages`). A drift here fails as a signature rejection, not a parse error. + #[test] + fn canonical_dismiss_and_attest_messages() { + let did = "did:plc:abc123"; + assert_eq!( + format!("ibd-dismiss\n{did}\n{}", "sample-xyz"), + "ibd-dismiss\ndid:plc:abc123\nsample-xyz" + ); + let cm = format!("{:.1}", 75.04_f64); + assert_eq!(cm, "75.0", "cM is signed at one decimal place"); + assert_eq!( + format!( + "ibd-attest\n{did}\n{}\n{}\n{}\n{}\n{}", + "urn:ibd:r", "s-mine", "s-theirs", "AUTOSOMAL", cm + ), + "ibd-attest\ndid:plc:abc123\nurn:ibd:r\ns-mine\ns-theirs\nAUTOSOMAL\n75.0" + ); + } + + #[test] + fn status_round_trips_and_marks_terminal() { + for s in [ + MatchingStatus::Requested, + MatchingStatus::AwaitingConsent, + MatchingStatus::Declined, + MatchingStatus::Ready, + MatchingStatus::Exchanged, + MatchingStatus::Failed, + ] { + assert_eq!(MatchingStatus::parse(s.as_str()), s); + } + // An unknown token degrades to the least-committal state, never to a terminal one. + assert_eq!(MatchingStatus::parse("WAT"), MatchingStatus::Requested); + assert!(MatchingStatus::Exchanged.is_terminal()); + assert!(MatchingStatus::Declined.is_terminal()); + assert!(!MatchingStatus::Ready.is_terminal()); + assert!( + !MatchingStatus::Failed.is_terminal(), + "a failure is retryable, not done" + ); + assert_eq!(MatchingDirection::parse("INBOUND"), MatchingDirection::Inbound); + assert_eq!(MatchingDirection::parse("OUTBOUND"), MatchingDirection::Outbound); + } +} diff --git a/crates/navigator-app/src/sync.rs b/crates/navigator-app/src/sync.rs index dd7abf1a..5729d69e 100644 --- a/crates/navigator-app/src/sync.rs +++ b/crates/navigator-app/src/sync.rs @@ -467,6 +467,15 @@ impl App { .and_then(|x| x.as_str()) .unwrap_or("PENDING") .to_string(); - Ok(IbdIntroResult { request_uri, status }) + let purpose = v + .get("purpose") + .and_then(|x| x.as_str()) + .unwrap_or_default() + .to_string(); + Ok(IbdIntroResult { + request_uri, + status, + purpose, + }) } } diff --git a/crates/navigator-domain/locales/en.txt b/crates/navigator-domain/locales/en.txt index 47effd7f..e50205d6 100644 --- a/crates/navigator-domain/locales/en.txt +++ b/crates/navigator-domain/locales/en.txt @@ -374,12 +374,11 @@ form.chooseBed=Choose BED… ibd.identity=Identity: ibd.vs=IBD vs: ibd.chipCompatible=Chip-compatible IBD: -card.networkSuggestions=Network match suggestions network.signInRequired=Sign in to a PDS account to see network match suggestions. network.find=Find suggestions network.finding=Finding network match suggestions… network.introducing=Requesting introduction… -network.note=Pseudonymous candidates the network suggests from your published records. Requesting an introduction opens a request — the consent exchange isn't closeable yet. +network.note=Pseudonymous candidates the network suggests from your published records. Requesting an introduction opens a request; it moves to Requests, and nothing is shared until they consent too. network.empty=No suggestions yet. network.introduce=Request introduction network.col.candidate=Candidate @@ -388,11 +387,8 @@ network.col.score=Score network.col.signals=Signals card.encryptedExchange=Encrypted exchange (federated IBD) hint.encryptedExchange=Exchange IBD with a consented partner over an end-to-end encrypted channel. Only IBD-panel dosages cross the wire (never to the broker); both sides compute + sign the result. -exchange.refresh=Refresh inbox -exchange.incoming=Incoming requests exchange.accept=Accept exchange.decline=Decline -exchange.ready=Ready to exchange exchange.run=Run IBD exchange exchange.running=Running IBD exchange (handshake needs the peer online)… exchange.results=Results @@ -808,3 +804,57 @@ simple.relatives.estimate=Estimated from shared signals — connect to measure h archaicSegments.withheld=Not reported: we could not show you where your archaic DNA sits accurately enough to be worth reporting. archaicSegments.withheldWhy=Checked against an independent published callset for the same 20 people, our tract locations matched no better than chance, and the total did not track the individual. The overall amount for a population came out right, which is not the same as being right about you. We would rather report nothing than a map that looks precise and is not. archaicSegments.withinPopulation=Compare this only with people of similar ancestry. The amount is measured against four sequenced archaic genomes, and they match some ancestries better than others — so this figure is not a like-for-like number between, say, a European and an East Asian. + +# Matching (federated IBD discovery + consent) +nav.matching=Matching +matching.intro=People the network suggests you may share DNA with, and the requests you have open with them. Nothing is shared until both sides consent. +matching.signedout.title=Sign in to match +matching.signedout.hint=Matching runs against your DecodingUs account. Sign in to see suggestions and requests. +matching.tab.suggestions=Suggestions +matching.tab.requests=Requests +matching.tab.results=Results +matching.exchangeAs=Exchange as +matching.noSubject=No subject selected +matching.openTab=Open Matching +matching.dismiss=Dismiss +matching.dismissHint=Stop suggesting this candidate. Nothing is sent to them. +matching.dismissed=Candidate dismissed. +matching.review=Review request… +matching.retry=Retry exchange +matching.forget=Forget +matching.forgetHint=Remove this from your list. It does not cancel the request, and a live one can reappear. +matching.notReady=This request is not ready to exchange yet. +matching.noRequests=No open requests. +matching.blindHint=Who this is stays hidden until both sides consent — that is how the broker works, not a missing value. +matching.col.who=Who +matching.col.direction=Direction +matching.col.purpose=Purpose +matching.col.status=Status +matching.col.reported=Reported +matching.dir.outbound=You asked +matching.dir.inbound=They asked +matching.status.requested=Waiting on them +matching.status.awaiting=Needs your answer +matching.status.declined=Declined +matching.status.ready=Ready +matching.status.exchanged=Compared +matching.status.failed=Failed +matching.hint.requested=They have not answered yet. A refusal looks the same as silence — the broker never reports one. +matching.hint.awaiting=They asked to compare DNA with you. Review what would be shared before deciding. +matching.hint.declined=You declined this request. +matching.hint.ready=Both sides consented. Run the exchange while they are online. +matching.hint.exchanged=The comparison ran and the result is saved. +matching.hint.failed=The last attempt failed. Both sides must be online at the same time. +matching.reportedYes=✓ reported +matching.reportedNo=private +matching.reportedHint=Filed with DecodingUs so this match can surface for both of you. Only the totals — never segments or genotypes. +matching.notReportedHint=Kept on this device. A match is only reported when both sides agree on the result and both sample handles are known. +matching.consent.title=Compare DNA with this person? +matching.consent.body=Someone asked to compare DNA with you. They cannot see anything until you accept. +matching.consent.request=Request +matching.consent.sendTitle=What you send +matching.consent.sendBody=Your genotypes at the shared IBD panel positions, encrypted end to end. Both sides compute the same result independently and sign it. +matching.consent.learnTitle=What they learn +matching.consent.learnBody=Your account identifier, and how much DNA you share — total centimorgans, the segments, and the relationship that implies. +matching.consent.neverTitle=What never leaves this device +matching.consent.neverBody=Your name, your files, your health information, and any position outside the IBD panel. DecodingUs relays only ciphertext and cannot read any of it. diff --git a/crates/navigator-domain/locales/es.txt b/crates/navigator-domain/locales/es.txt index a9500bc6..af495510 100644 --- a/crates/navigator-domain/locales/es.txt +++ b/crates/navigator-domain/locales/es.txt @@ -361,12 +361,11 @@ form.chooseBed=Elegir BED… ibd.identity=Identidad: ibd.vs=IBD vs: ibd.chipCompatible=IBD compatible con chip: -card.networkSuggestions=Sugerencias de coincidencias de la red network.signInRequired=Inicia sesión en una cuenta PDS para ver sugerencias de coincidencias de la red. network.find=Buscar sugerencias network.finding=Buscando sugerencias de coincidencias de la red… network.introducing=Solicitando presentación… -network.note=Candidatos seudónimos que la red sugiere a partir de tus registros publicados. Solicitar una presentación abre una solicitud — el intercambio de consentimiento aún no se puede completar. +network.note=Candidatos seudónimos que la red sugiere a partir de tus registros publicados. Solicitar una presentación abre una solicitud; pasa a Solicitudes, y no se comparte nada hasta que ellos también consientan. network.empty=Aún no hay sugerencias. network.introduce=Solicitar presentación network.col.candidate=Candidato @@ -375,11 +374,8 @@ network.col.score=Puntuación network.col.signals=Señales card.encryptedExchange=Intercambio cifrado (IBD federado) hint.encryptedExchange=Intercambia IBD con un socio que ha dado su consentimiento por un canal cifrado de extremo a extremo. Solo las dosis del panel IBD cruzan la red (nunca al intermediario); ambos lados calculan y firman el resultado. -exchange.refresh=Actualizar bandeja -exchange.incoming=Solicitudes entrantes exchange.accept=Aceptar exchange.decline=Rechazar -exchange.ready=Listo para intercambiar exchange.run=Ejecutar intercambio IBD exchange.running=Ejecutando intercambio IBD (el handshake necesita al par en línea)… exchange.results=Resultados @@ -793,3 +789,57 @@ simple.relatives.estimate=Estimado a partir de señales compartidas: conecta par archaicSegments.withheld=No se informa: no hemos podido mostrarte dónde se sitúa tu ADN arcaico con la precisión necesaria para publicarlo. archaicSegments.withheldWhy=Al contrastarlo con un conjunto de datos publicado e independiente para las mismas 20 personas, la ubicación de nuestros tramos no acertó más que el azar, y el total no seguía a cada individuo. La cantidad global de una población sí salía bien, lo cual no equivale a acertar contigo. Preferimos no informar nada antes que ofrecer un mapa que parece preciso y no lo es. archaicSegments.withinPopulation=Compara esta cifra solo con personas de ascendencia similar. La cantidad se mide frente a cuatro genomas arcaicos secuenciados, que se ajustan mejor a unas ascendencias que a otras: no es una cifra equiparable entre, por ejemplo, una persona europea y una del este de Asia. + +# Matching (federated IBD discovery + consent) +nav.matching=Coincidencias +matching.intro=Personas con las que la red sugiere que podría compartir ADN, y las solicitudes que tiene abiertas con ellas. No se comparte nada hasta que ambas partes consientan. +matching.signedout.title=Inicie sesión para buscar coincidencias +matching.signedout.hint=Las coincidencias se calculan con su cuenta de DecodingUs. Inicie sesión para ver sugerencias y solicitudes. +matching.tab.suggestions=Sugerencias +matching.tab.requests=Solicitudes +matching.tab.results=Resultados +matching.exchangeAs=Intercambiar como +matching.noSubject=Ningún sujeto seleccionado +matching.openTab=Abrir Coincidencias +matching.dismiss=Descartar +matching.dismissHint=Dejar de sugerir este candidato. No se le envía nada. +matching.dismissed=Candidato descartado. +matching.review=Revisar solicitud… +matching.retry=Reintentar intercambio +matching.forget=Olvidar +matching.forgetHint=Quitarlo de su lista. No cancela la solicitud, y una activa puede reaparecer. +matching.notReady=Esta solicitud aún no está lista para intercambiar. +matching.noRequests=No hay solicitudes abiertas. +matching.blindHint=Quién es esta persona permanece oculto hasta que ambas partes consientan — así funciona el intermediario, no es un dato que falte. +matching.col.who=Quién +matching.col.direction=Dirección +matching.col.purpose=Propósito +matching.col.status=Estado +matching.col.reported=Informado +matching.dir.outbound=Usted lo pidió +matching.dir.inbound=Se lo pidieron +matching.status.requested=Esperando su respuesta +matching.status.awaiting=Necesita su respuesta +matching.status.declined=Rechazada +matching.status.ready=Lista +matching.status.exchanged=Comparada +matching.status.failed=Falló +matching.hint.requested=Aún no han respondido. Un rechazo se ve igual que el silencio — el intermediario nunca lo informa. +matching.hint.awaiting=Pidieron comparar su ADN con el suyo. Revise qué se compartiría antes de decidir. +matching.hint.declined=Usted rechazó esta solicitud. +matching.hint.ready=Ambas partes consintieron. Ejecute el intercambio mientras estén en línea. +matching.hint.exchanged=La comparación se ejecutó y el resultado está guardado. +matching.hint.failed=El último intento falló. Ambas partes deben estar en línea a la vez. +matching.reportedYes=✓ informado +matching.reportedNo=privado +matching.reportedHint=Registrado en DecodingUs para que esta coincidencia aparezca para ambos. Solo los totales — nunca segmentos ni genotipos. +matching.notReportedHint=Se queda en este dispositivo. Una coincidencia solo se informa cuando ambas partes coinciden en el resultado y se conocen ambos identificadores de muestra. +matching.consent.title=¿Comparar ADN con esta persona? +matching.consent.body=Alguien pidió comparar su ADN con el suyo. No puede ver nada hasta que usted acepte. +matching.consent.request=Solicitud +matching.consent.sendTitle=Qué envía usted +matching.consent.sendBody=Sus genotipos en las posiciones del panel IBD compartido, cifrados de extremo a extremo. Ambas partes calculan el mismo resultado por separado y lo firman. +matching.consent.learnTitle=Qué aprenden ellos +matching.consent.learnBody=Su identificador de cuenta, y cuánto ADN comparten — centimorgans totales, los segmentos y el parentesco que implican. +matching.consent.neverTitle=Qué nunca sale de este dispositivo +matching.consent.neverBody=Su nombre, sus archivos, su información de salud y cualquier posición fuera del panel IBD. DecodingUs solo retransmite texto cifrado y no puede leer nada de ello. diff --git a/crates/navigator-store/migrations/0041_ibd_request.down.sql b/crates/navigator-store/migrations/0041_ibd_request.down.sql new file mode 100644 index 00000000..1e4ad883 --- /dev/null +++ b/crates/navigator-store/migrations/0041_ibd_request.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS ix_ibd_request_biosample; +DROP INDEX IF EXISTS ix_ibd_request_status; +DROP TABLE IF EXISTS ibd_request; diff --git a/crates/navigator-store/migrations/0041_ibd_request.up.sql b/crates/navigator-store/migrations/0041_ibd_request.up.sql new file mode 100644 index 00000000..6430a4b1 --- /dev/null +++ b/crates/navigator-store/migrations/0041_ibd_request.up.sql @@ -0,0 +1,38 @@ +-- The federated-IBD request ledger: one durable row per matching conversation, from the moment we +-- ask for (or receive) an introduction until the encrypted exchange produces a result. Before this +-- table the only persisted state was the *completed* exchange (`ibd_exchange_result`), so a restart +-- lost every in-flight request — "I asked X and I'm waiting on their consent" existed only in UI +-- memory. +-- +-- Keyed by the broker's `request_uri` (`urn:ibd:` for a suggestion-mediated introduction, +-- `exchange:` for a direct one), which is stable and idempotent per (caller, candidate). +-- +-- `my_sample_ref` / `partner_sample_ref` are the **AppView** `core.biosample` guids (from the +-- suggestion's `target_sample_guid` / `suggested_sample_guid`) — not local subject guids. Both are +-- needed to attest a completed comparison, which the AppView gates on sample ownership. +-- `biosample_guid` is the *local* subject whose dosages we exchange. +-- +-- `consent_given` records only OUR OWN decision (NULL = undecided). The broker is symmetric-blind: +-- a partner's refusal is never reported, it simply never becomes READY. +-- +-- PII-free: DIDs, opaque sample handles, and a lifecycle status. +CREATE TABLE ibd_request ( + request_uri TEXT PRIMARY KEY, + direction TEXT NOT NULL, + purpose TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL, + partner_did TEXT, + session_id TEXT, + biosample_guid TEXT REFERENCES biosample(guid), + my_sample_ref TEXT, + partner_sample_ref TEXT, + consent_given INTEGER, + consent_at TEXT, + attested_at TEXT, + last_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX ix_ibd_request_status ON ibd_request (status); +CREATE INDEX ix_ibd_request_biosample ON ibd_request (biosample_guid); diff --git a/crates/navigator-store/src/ibd_request.rs b/crates/navigator-store/src/ibd_request.rs new file mode 100644 index 00000000..52cb6fc5 --- /dev/null +++ b/crates/navigator-store/src/ibd_request.rs @@ -0,0 +1,233 @@ +//! The federated-IBD **request ledger** — durable state for a matching conversation from the +//! introduction request through consent to a completed exchange. Its completed counterpart is +//! [`crate::ibd_exchange`], which stores the *result*; this table is what makes the in-flight +//! middle of that story survive a restart. +//! +//! Keyed by the broker's `request_uri`. Rows are plain data: the lifecycle `status` and +//! `direction` are stored as TEXT and given meaning by `navigator-app` (the same convention as +//! [`crate::ibd_exchange::StoredIbdExchange::relationship`]). + +use du_domain::ids::SampleGuid; +use sqlx::SqlitePool; + +use crate::StoreError; + +/// One matching conversation. See `migrations/0041_ibd_request.up.sql` for the field semantics — +/// in particular that `my_sample_ref` / `partner_sample_ref` are **AppView** sample handles while +/// `biosample_guid` is the local subject, and that `consent_given` records only our own decision. +#[derive(Debug, Clone, PartialEq, sqlx::FromRow)] +pub struct StoredIbdRequest { + pub request_uri: String, + pub direction: String, + pub purpose: String, + pub status: String, + pub partner_did: Option, + pub session_id: Option, + pub biosample_guid: Option, + pub my_sample_ref: Option, + pub partner_sample_ref: Option, + pub consent_given: Option, + pub consent_at: Option, + pub attested_at: Option, + pub last_error: Option, + pub created_at: String, + pub updated_at: String, +} + +/// Insert or replace a request. `created_at` is preserved from the existing row (an update never +/// rewrites when the conversation began). +pub async fn upsert(pool: &SqlitePool, r: &StoredIbdRequest) -> Result<(), StoreError> { + sqlx::query( + "INSERT INTO ibd_request (request_uri, direction, purpose, status, partner_did, session_id, \ + biosample_guid, my_sample_ref, partner_sample_ref, consent_given, consent_at, attested_at, \ + last_error, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ + ON CONFLICT(request_uri) DO UPDATE SET \ + direction = excluded.direction, purpose = excluded.purpose, status = excluded.status, \ + partner_did = excluded.partner_did, session_id = excluded.session_id, \ + biosample_guid = excluded.biosample_guid, my_sample_ref = excluded.my_sample_ref, \ + partner_sample_ref = excluded.partner_sample_ref, consent_given = excluded.consent_given, \ + consent_at = excluded.consent_at, attested_at = excluded.attested_at, \ + last_error = excluded.last_error, updated_at = excluded.updated_at", + ) + .bind(&r.request_uri) + .bind(&r.direction) + .bind(&r.purpose) + .bind(&r.status) + .bind(&r.partner_did) + .bind(&r.session_id) + .bind(&r.biosample_guid) + .bind(&r.my_sample_ref) + .bind(&r.partner_sample_ref) + .bind(r.consent_given) + .bind(&r.consent_at) + .bind(&r.attested_at) + .bind(&r.last_error) + .bind(&r.created_at) + .bind(&r.updated_at) + .execute(pool) + .await?; + Ok(()) +} + +/// Insert only if the request is unknown — the reconciler's primitive for adopting a request the +/// broker reports. An existing row keeps every local field (notably our consent decision and the +/// subject we chose), so re-polling can never walk them back. +pub async fn insert_if_absent(pool: &SqlitePool, r: &StoredIbdRequest) -> Result { + let res = sqlx::query( + "INSERT INTO ibd_request (request_uri, direction, purpose, status, partner_did, session_id, \ + biosample_guid, my_sample_ref, partner_sample_ref, consent_given, consent_at, attested_at, \ + last_error, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ + ON CONFLICT(request_uri) DO NOTHING", + ) + .bind(&r.request_uri) + .bind(&r.direction) + .bind(&r.purpose) + .bind(&r.status) + .bind(&r.partner_did) + .bind(&r.session_id) + .bind(&r.biosample_guid) + .bind(&r.my_sample_ref) + .bind(&r.partner_sample_ref) + .bind(r.consent_given) + .bind(&r.consent_at) + .bind(&r.attested_at) + .bind(&r.last_error) + .bind(&r.created_at) + .bind(&r.updated_at) + .execute(pool) + .await?; + Ok(res.rows_affected() > 0) +} + +/// One request by its broker URI. +pub async fn get(pool: &SqlitePool, request_uri: &str) -> Result, StoreError> { + let row = sqlx::query_as("SELECT * FROM ibd_request WHERE request_uri = ?") + .bind(request_uri) + .fetch_optional(pool) + .await?; + Ok(row) +} + +/// All requests, newest first. +pub async fn list(pool: &SqlitePool) -> Result, StoreError> { + let rows = sqlx::query_as("SELECT * FROM ibd_request ORDER BY created_at DESC") + .fetch_all(pool) + .await?; + Ok(rows) +} + +/// Requests bound to one local subject, newest first. +pub async fn list_for_biosample(pool: &SqlitePool, guid: SampleGuid) -> Result, StoreError> { + let rows = sqlx::query_as("SELECT * FROM ibd_request WHERE biosample_guid = ? ORDER BY created_at DESC") + .bind(guid.0.to_string()) + .fetch_all(pool) + .await?; + Ok(rows) +} + +/// Forget a request (the user dismissed it locally). The broker keeps its own record. +pub async fn delete(pool: &SqlitePool, request_uri: &str) -> Result<(), StoreError> { + sqlx::query("DELETE FROM ibd_request WHERE request_uri = ?") + .bind(request_uri) + .execute(pool) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + fn row(uri: &str, status: &str) -> StoredIbdRequest { + StoredIbdRequest { + request_uri: uri.into(), + direction: "OUTBOUND".into(), + purpose: "IBD_AUTOSOMAL".into(), + status: status.into(), + partner_did: None, + session_id: None, + biosample_guid: None, + my_sample_ref: Some("sample-mine".into()), + partner_sample_ref: Some("sample-theirs".into()), + consent_given: None, + consent_at: None, + attested_at: None, + last_error: None, + created_at: "2026-08-01T00:00:00Z".into(), + updated_at: "2026-08-01T00:00:00Z".into(), + } + } + + async fn store_with_subject() -> (crate::Store, SampleGuid) { + let store = crate::Store::open_in_memory().await.unwrap(); + let g = SampleGuid(Uuid::new_v4()); + let bio = navigator_domain::workspace::Biosample { + guid: g, + sample_accession: None, + donor_identifier: "S1".into(), + description: None, + center_name: None, + sex: None, + project_id: None, + }; + crate::biosample::create(store.pool(), &bio).await.unwrap(); + (store, g) + } + + #[tokio::test] + async fn upsert_advances_status_but_keeps_created_at() { + let (store, g) = store_with_subject().await; + let mut r = row("urn:ibd:abc", "REQUESTED"); + r.biosample_guid = Some(g.0.to_string()); + upsert(store.pool(), &r).await.unwrap(); + + r.status = "READY".into(); + r.partner_did = Some("did:key:zB".into()); + r.session_id = Some("sess-1".into()); + r.updated_at = "2026-08-02T00:00:00Z".into(); + upsert(store.pool(), &r).await.unwrap(); + + let got = get(store.pool(), "urn:ibd:abc").await.unwrap().unwrap(); + assert_eq!(got.status, "READY"); + assert_eq!(got.partner_did.as_deref(), Some("did:key:zB")); + assert_eq!(got.created_at, "2026-08-01T00:00:00Z", "created_at is never rewritten"); + assert_eq!(got.updated_at, "2026-08-02T00:00:00Z"); + assert_eq!(list_for_biosample(store.pool(), g).await.unwrap().len(), 1); + } + + /// Re-polling the broker must not walk back a decision we already made locally. + #[tokio::test] + async fn insert_if_absent_preserves_local_consent() { + let store = crate::Store::open_in_memory().await.unwrap(); + let mut mine = row("urn:ibd:xyz", "DECLINED"); + mine.direction = "INBOUND".into(); + mine.consent_given = Some(false); + mine.consent_at = Some("2026-08-01T12:00:00Z".into()); + assert!(insert_if_absent(store.pool(), &mine).await.unwrap()); + + // The broker still lists it as awaiting consent; adopting it again is a no-op. + let fresh = row("urn:ibd:xyz", "AWAITING_CONSENT"); + assert!(!insert_if_absent(store.pool(), &fresh).await.unwrap()); + + let got = get(store.pool(), "urn:ibd:xyz").await.unwrap().unwrap(); + assert_eq!(got.status, "DECLINED"); + assert_eq!(got.consent_given, Some(false)); + assert_eq!(got.direction, "INBOUND"); + } + + #[tokio::test] + async fn list_and_delete() { + let store = crate::Store::open_in_memory().await.unwrap(); + upsert(store.pool(), &row("urn:ibd:a", "REQUESTED")).await.unwrap(); + upsert(store.pool(), &row("urn:ibd:b", "EXCHANGED")).await.unwrap(); + assert_eq!(list(store.pool()).await.unwrap().len(), 2); + delete(store.pool(), "urn:ibd:a").await.unwrap(); + let rest = list(store.pool()).await.unwrap(); + assert_eq!(rest.len(), 1); + assert_eq!(rest[0].request_uri, "urn:ibd:b"); + assert!(get(store.pool(), "urn:ibd:a").await.unwrap().is_none()); + } +} diff --git a/crates/navigator-store/src/lib.rs b/crates/navigator-store/src/lib.rs index a715bf59..ec97b088 100644 --- a/crates/navigator-store/src/lib.rs +++ b/crates/navigator-store/src/lib.rs @@ -26,6 +26,7 @@ pub mod external_panel_dosage; pub mod ftdna_member; pub mod haplogroup_call; pub mod ibd_exchange; +pub mod ibd_request; pub mod mdka; pub mod mtdna; pub mod project; diff --git a/crates/navigator-ui/src/ui/central.rs b/crates/navigator-ui/src/ui/central.rs index f515d98a..5ed48650 100644 --- a/crates/navigator-ui/src/ui/central.rs +++ b/crates/navigator-ui/src/ui/central.rs @@ -690,8 +690,8 @@ impl NavigatorApp { // Subject-level IBD over the pooled consensus is the primary path. card(ui, self.tr("card.consensusIbd"), |ui| self.consensus_ibd_section(ui, guid)); ui.add_space(10.0); - card(ui, self.tr("card.networkSuggestions"), |ui| self.network_suggestions_section(ui)); - ui.add_space(10.0); + // Discovery + consent live in the top-level Matching tab (they are + // account-scoped); what belongs to *this* subject is the results it produced. card(ui, self.tr("card.encryptedExchange"), |ui| self.exchange_section(ui, guid)); // Per-source compare + within-subject identity (the QC gate) — advanced. if self.selected_alignment.is_some() { diff --git a/crates/navigator-ui/src/ui/chrome.rs b/crates/navigator-ui/src/ui/chrome.rs index bcd230dc..dd7e59d7 100644 --- a/crates/navigator-ui/src/ui/chrome.rs +++ b/crates/navigator-ui/src/ui/chrome.rs @@ -100,7 +100,7 @@ impl NavigatorApp { /// Keep nav consistent with the mode: Simple hides Projects/Community, so snap off them. pub(crate) fn normalize_for_mode(&mut self) { - if self.ui_mode == UiMode::Simple && matches!(self.nav, Nav::Projects | Nav::Community) { + if self.ui_mode == UiMode::Simple && matches!(self.nav, Nav::Projects | Nav::Matching | Nav::Community) { self.nav = Nav::Subjects; } } @@ -183,6 +183,7 @@ impl NavigatorApp { (Nav::Dashboard, "📊", "nav.dashboard"), (Nav::Subjects, "👥", "nav.subjects"), (Nav::Projects, "📁", "nav.projects"), + (Nav::Matching, "🔗", "nav.matching"), (Nav::Community, "💬", "nav.community"), ], }; @@ -266,8 +267,8 @@ impl NavigatorApp { /// which needs a quarter of that and was taking half the window. pub(crate) fn left_panel(&mut self, ctx: &egui::Context) { match self.nav { - // Dashboard + Community are full-width (no side panel). - Nav::Dashboard | Nav::Community => {} + // Dashboard, Matching and Community are full-width (no side panel). + Nav::Dashboard | Nav::Matching | Nav::Community => {} Nav::Projects if self.projects_collapsed => { // Collapsed: a thin strip with just an expand button, handing the detail panel // the full width for the wide Y-STR chart. diff --git a/crates/navigator-ui/src/ui/events.rs b/crates/navigator-ui/src/ui/events.rs index f70f7957..0e2585ca 100644 --- a/crates/navigator-ui/src/ui/events.rs +++ b/crates/navigator-ui/src/ui/events.rs @@ -1004,33 +1004,20 @@ impl NavigatorApp { self.ibd_suggestions = items; self.loading_ibd_suggestions = false; } - Event::IbdIntroduced { - suggested_sample_guid, - request_uri, - status, - } => { - self.status = format!("Introduction requested: {status} ({request_uri})"); - let label = if request_uri.is_empty() { - status - } else { - format!("{status} · {request_uri}") - }; - self.ibd_intros.insert(suggested_sample_guid, label); - } - Event::ExchangeInbox { incoming, ready } => { + Event::Matching(entries) => { self.exchange_busy = false; - self.status = format!( - "Exchange inbox: {} request(s), {} ready session(s)", - incoming.len(), - ready.len() - ); - self.exchange_incoming = incoming; - self.exchange_ready = ready; - } - Event::ExchangeConsented => { + self.consent_prompt = None; + // A candidate that became a request is no longer a candidate. + let requested: std::collections::HashSet<&str> = + entries.iter().filter_map(|e| e.partner_sample_ref.as_deref()).collect(); + self.ibd_suggestions.retain(|s| !requested.contains(s.suggested_sample_guid.as_str())); + self.matching = entries; + } + Event::CandidateDismissed { suggested_sample_guid } => { self.exchange_busy = false; - self.status = "Consent recorded".into(); - let _ = self.tx.send(Command::ExchangeInbox); // refresh + self.status = self.tr("matching.dismissed").to_string(); + self.ibd_suggestions.retain(|s| s.suggested_sample_guid != suggested_sample_guid); + self.dismissed_candidates.insert(suggested_sample_guid); } Event::IbdExchangeDone { biosample_guid, @@ -1045,6 +1032,8 @@ impl NavigatorApp { if agreed { " · agreed" } else { " · NOT agreed" } ); let _ = self.tx.send(Command::LoadIbdExchanges { biosample_guid }); + // The conversation is now complete — pick the result up in the ledger too. + let _ = self.tx.send(Command::RefreshMatching); } Event::IbdExchanges { biosample_guid, rows } => { if self.selected_sample == Some(biosample_guid) { diff --git a/crates/navigator-ui/src/ui/ibd.rs b/crates/navigator-ui/src/ui/ibd.rs index c972e5b7..9d06a801 100644 --- a/crates/navigator-ui/src/ui/ibd.rs +++ b/crates/navigator-ui/src/ui/ibd.rs @@ -210,187 +210,25 @@ impl NavigatorApp { self.render_ibd_result(ui); } - /// Federated IBD: the AppView's pseudonymous "people who may share DNA with you" list, - /// mined from the records we've published. Distinct from the local 1:1 compare above — - /// these are network candidates we haven't exchanged any genotypes with. Requesting an - /// introduction opens a PENDING request; the consent round-trip and encrypted segment exchange - /// then run over the edge channel (see the exchange section and `App::exchange_*`). - pub(crate) fn network_suggestions_section(&mut self, ui: &mut egui::Ui) { - if self.account.is_none() { - ui.label(self.tr("network.signInRequired")); - return; - } - ui.horizontal(|ui| { - if ui - .add_enabled( - !self.loading_ibd_suggestions, - egui::Button::new(self.tr("network.find")), - ) - .clicked() - { - self.loading_ibd_suggestions = true; - self.status = self.tr("network.finding").to_string(); - let _ = self.tx.send(Command::LoadIbdSuggestions); - } - if self.loading_ibd_suggestions { - ui.spinner(); - } - }); - ui.label(self.tr("network.note")); - - if self.ibd_suggestions.is_empty() { - if !self.loading_ibd_suggestions { - ui.add_space(4.0); - ui.weak(self.tr("network.empty")); - } - return; - } - - ui.add_space(6.0); - // Collect the rows first so the table closure doesn't borrow `self` immutably while we - // also need `self.tx` / `self.ibd_intros` (and to send commands without a borrow clash). - let rows: Vec<(String, String, f64, String, Option)> = self - .ibd_suggestions - .iter() - .map(|s| { - let signals = s.signals.join(", "); - ( - s.suggested_sample_guid.clone(), - s.suggestion_type.clone(), - s.score, - signals, - self.ibd_intros.get(&s.suggested_sample_guid).cloned(), - ) - }) - .collect(); - - let mut introduce: Option = None; - egui::Grid::new("ibd_suggestions") - .striped(true) - .num_columns(5) - .show(ui, |ui| { - ui.strong(self.tr("network.col.candidate")); - ui.strong(self.tr("network.col.type")); - ui.strong(self.tr("network.col.score")); - ui.strong(self.tr("network.col.signals")); - ui.strong(""); - ui.end_row(); - for (guid, ty, score, signals, intro) in &rows { - // Pseudonymous guid, shown truncated (it's an opaque AppView handle, not PII). - let short: String = guid.chars().take(12).collect(); - ui.label(short).on_hover_text(guid); - ui.label(ty); - ui.label(format!("{score:.2}")); - ui.label(signals); - if let Some(status) = intro { - ui.label(status); - } else if ui.button(self.tr("network.introduce")).clicked() { - introduce = Some(guid.clone()); - } - ui.end_row(); - } - }); - if let Some(guid) = introduce { - self.status = self.tr("network.introducing").to_string(); - let _ = self.tx.send(Command::IbdIntroduce { - suggested_sample_guid: guid, - }); - } - } - - /// The encrypted edge-to-edge exchange (gap §4): inbound requests awaiting consent, consent-ready - /// sessions to run an IBD exchange over, and this subject's saved results. Requires an active - /// account (real PDS or did:key). Flows into the page scroll (no nested ScrollArea). + /// This subject's completed federated exchanges. Discovery and consent are **not** here — they + /// are account-scoped and live in the top-level Matching tab; what this card answers is "what + /// did the network find for *this person*". Flows into the page scroll (no nested ScrollArea). pub(crate) fn exchange_section(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { if self.account.is_none() { ui.label(self.tr("network.signInRequired")); return; } ui.horizontal(|ui| { - if ui - .add_enabled(!self.exchange_busy, egui::Button::new(self.tr("exchange.refresh"))) - .clicked() - { - self.exchange_busy = true; - let _ = self.tx.send(Command::ExchangeInbox); - } - if self.exchange_busy { - ui.spinner(); - } ui.label(egui::RichText::new(self.tr("hint.encryptedExchange")).weak().small()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(self.tr("matching.openTab")).clicked() { + self.nav = Nav::Matching; + self.matching_subject = Some(guid); + let _ = self.tx.send(Command::RefreshMatching); + } + }); }); - // Inbound requests awaiting our consent (symmetric-blind: no initiator DID until consent). - let incoming: Vec<(String, String, String)> = self - .exchange_incoming - .iter() - .map(|r| (r.request_uri.clone(), r.purpose.clone(), r.created_at.clone())) - .collect(); - if !incoming.is_empty() { - ui.add_space(6.0); - ui.label(egui::RichText::new(self.tr("exchange.incoming")).strong()); - let mut consent: Option<(String, bool)> = None; - egui::Grid::new(("exchange_incoming", guid)) - .striped(true) - .num_columns(4) - .spacing([12.0, 2.0]) - .show(ui, |ui| { - for (req, purpose, created) in &incoming { - let short: String = req.chars().take(16).collect(); - ui.label(short).on_hover_text(req); - ui.label(purpose); - ui.label(egui::RichText::new(created).weak().small()); - ui.horizontal(|ui| { - if ui.button(self.tr("exchange.accept")).clicked() { - consent = Some((req.clone(), true)); - } - if ui.button(self.tr("exchange.decline")).clicked() { - consent = Some((req.clone(), false)); - } - }); - ui.end_row(); - } - }); - if let Some((request_uri, given)) = consent { - self.exchange_busy = true; - let _ = self.tx.send(Command::ExchangeConsent { request_uri, given }); - } - } - - // Consent-ready sessions → run the IBD exchange (handshake + dosage exchange + attestation). - let ready: Vec = self.exchange_ready.clone(); - if !ready.is_empty() { - ui.add_space(6.0); - ui.label(egui::RichText::new(self.tr("exchange.ready")).strong()); - let mut run: Option = None; - egui::Grid::new(("exchange_ready", guid)) - .striped(true) - .num_columns(3) - .spacing([12.0, 2.0]) - .show(ui, |ui| { - for info in &ready { - let short: String = info.partner_did.chars().take(20).collect(); - ui.label(short).on_hover_text(&info.partner_did); - ui.label(&info.purpose); - if ui - .add_enabled(!self.exchange_busy, egui::Button::new(self.tr("exchange.run"))) - .clicked() - { - run = Some(info.clone()); - } - ui.end_row(); - } - }); - if let Some(info) = run { - self.exchange_busy = true; - self.status = self.tr("exchange.running").to_string(); - let _ = self.tx.send(Command::RunIbdExchange { - info, - biosample_guid: guid, - }); - } - } - // This subject's saved results. ui.add_space(6.0); if self.exchange_results.is_empty() { diff --git a/crates/navigator-ui/src/ui/matching.rs b/crates/navigator-ui/src/ui/matching.rs new file mode 100644 index 00000000..10f82afb --- /dev/null +++ b/crates/navigator-ui/src/ui/matching.rs @@ -0,0 +1,438 @@ +//! `impl NavigatorApp` — the **Matching** tab: federated-IBD discovery and consent. +//! +//! This is the front door for machinery that was already complete but had no coherent surface. It +//! is account-scoped, not subject-scoped: a conversation is keyed by our DID and the broker's +//! request URI, and a local subject is chosen only when it is time to exchange dosages. The +//! subject's own IBD tab keeps the *results* for that person; this tab owns the conversation. +//! +//! Three sub-tabs follow one conversation's life — a ranked candidate (Suggestions) becomes a +//! request awaiting consent (Requests) and then a result (Results). +use super::*; + +impl NavigatorApp { + /// The Matching work area. Gated on sign-in: every call here is device-key-signed. + pub(crate) fn matching_central(&mut self, ui: &mut egui::Ui) { + if self.account.is_none() { + empty_state( + ui, + self.tr("matching.signedout.title"), + self.tr("matching.signedout.hint"), + ); + return; + } + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.heading(self.tr("nav.matching")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add_enabled(!self.exchange_busy, egui::Button::new(self.tr("common.refresh"))) + .clicked() + { + self.exchange_busy = true; + let _ = self.tx.send(Command::RefreshMatching); + } + if self.exchange_busy { + ui.spinner(); + } + }); + }); + ui.label(egui::RichText::new(self.tr("matching.intro")).weak().small()); + ui.separator(); + ui.add_space(4.0); + self.matching_subject_picker(ui); + ui.add_space(4.0); + self.matching_tab = self.sub_bar(ui, self.matching_tab, &MatchingTab::ALL); + egui::ScrollArea::vertical().show(ui, |ui| { + ui.add_space(4.0); + match self.matching_tab { + MatchingTab::Suggestions => self.matching_suggestions(ui), + MatchingTab::Requests => self.matching_requests(ui), + MatchingTab::Results => self.matching_results(ui), + } + }); + } + + /// Which local subject an exchange speaks for. Explicit here rather than implied by whichever + /// subject tab happened to be open — the same account can hold several people's data, and + /// sending the wrong one's genotypes is not a recoverable mistake. + /// + /// Shown as the current choice plus a *Change* toggle rather than a dropdown: a workspace can + /// hold tens of thousands of subjects, and a `ComboBox` builds a widget per entry every frame + /// its popup is open. The reveal is the same filter-then-virtualized-list the subjects rail + /// uses, so the cost is the number of rows on screen, not the number in the workspace. + fn matching_subject_picker(&mut self, ui: &mut egui::Ui) { + if self.matching_subject.is_none() { + self.matching_subject = self.selected_sample.or(self.all_biosamples.first().map(|b| b.guid)); + } + let label = self + .matching_subject + .and_then(|g| self.find_subject(g).map(|b| b.donor_identifier.clone())) + .unwrap_or_else(|| self.tr("matching.noSubject").to_string()); + ui.horizontal(|ui| { + ui.label(self.tr("matching.exchangeAs")); + ui.label(egui::RichText::new(label).strong()); + let toggle = if self.matching_subject_picking { + self.tr("common.cancel") + } else { + self.tr("common.change") + }; + if ui.button(toggle).clicked() { + self.matching_subject_picking = !self.matching_subject_picking; + self.matching_subject_filter.clear(); + } + }); + if !self.matching_subject_picking { + return; + } + + ui.add_space(4.0); + let hint = self.tr("subjects.filter"); + ui.add( + egui::TextEdit::singleline(&mut self.matching_subject_filter) + .hint_text(hint) + .desired_width(280.0), + ); + // Build the filtered view from immutable reads first, so the scroll closure borrows locals. + let needle = self.matching_subject_filter.trim().to_lowercase(); + let rows: Vec<(SampleGuid, String)> = self + .all_biosamples + .iter() + .filter(|b| needle.is_empty() || b.donor_identifier.to_lowercase().contains(&needle)) + .map(|b| (b.guid, b.donor_identifier.clone())) + .collect(); + ui.label(egui::RichText::new(format!("{}", rows.len())).weak().small()); + if rows.is_empty() { + ui.label(egui::RichText::new(self.tr("subjects.noMatch")).weak()); + return; + } + + let selected = self.matching_subject; + let mut pick = None; + let row_h = ui.spacing().interact_size.y; + egui::ScrollArea::vertical() + .id_salt("matching_subject_list") + .max_height(180.0) + .auto_shrink([false, false]) + .show_rows(ui, row_h, rows.len(), |ui, range| { + for i in range { + let (guid, name) = &rows[i]; + if ui.selectable_label(selected == Some(*guid), name).clicked() { + pick = Some(*guid); + } + } + }); + if let Some(guid) = pick { + self.matching_subject = Some(guid); + self.matching_subject_picking = false; + self.matching_subject_filter.clear(); + } + } + + /// Ranked candidates from the AppView's engine. Pseudonymous: a candidate is an opaque sample + /// handle plus the signals behind its score — never a DID, never a name. + fn matching_suggestions(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + if ui + .add_enabled( + !self.loading_ibd_suggestions, + egui::Button::new(self.tr("network.find")), + ) + .clicked() + { + self.loading_ibd_suggestions = true; + self.status = self.tr("network.finding").to_string(); + let _ = self.tx.send(Command::LoadIbdSuggestions); + } + if self.loading_ibd_suggestions { + ui.spinner(); + } + }); + ui.label(egui::RichText::new(self.tr("network.note")).weak().small()); + + // Requesting an introduction and dismissing both remove a row, so filter against what the + // ledger already knows rather than trusting the fetched list to be current. + let requested: std::collections::HashSet = self + .matching + .iter() + .filter_map(|e| e.partner_sample_ref.clone()) + .collect(); + let rows: Vec = self + .ibd_suggestions + .iter() + .filter(|s| { + !requested.contains(&s.suggested_sample_guid) + && !self.dismissed_candidates.contains(&s.suggested_sample_guid) + }) + .cloned() + .collect(); + if rows.is_empty() { + if !self.loading_ibd_suggestions { + ui.add_space(6.0); + ui.weak(self.tr("network.empty")); + } + return; + } + + ui.add_space(6.0); + let mut introduce: Option = None; + let mut dismiss: Option = None; + egui::Grid::new("matching_suggestions") + .striped(true) + .num_columns(5) + .spacing([14.0, 4.0]) + .show(ui, |ui| { + ui.strong(self.tr("network.col.candidate")); + ui.strong(self.tr("network.col.type")); + ui.strong(self.tr("network.col.score")); + ui.strong(self.tr("network.col.signals")); + ui.strong(""); + ui.end_row(); + for s in &rows { + let short: String = s.suggested_sample_guid.chars().take(12).collect(); + ui.label(short).on_hover_text(&s.suggested_sample_guid); + ui.label(&s.suggestion_type); + ui.label(format!("{:.2}", s.score)); + ui.label(s.signals.join(", ")); + ui.horizontal(|ui| { + if ui.button(self.tr("network.introduce")).clicked() { + introduce = Some(s.clone()); + } + if ui + .button(self.tr("matching.dismiss")) + .on_hover_text(self.tr("matching.dismissHint")) + .clicked() + { + dismiss = Some(s.suggested_sample_guid.clone()); + } + }); + ui.end_row(); + } + }); + if let Some(suggestion) = introduce { + self.status = self.tr("network.introducing").to_string(); + let _ = self.tx.send(Command::RequestIntroduction { + suggestion, + biosample_guid: self.matching_subject, + }); + } + if let Some(suggested_sample_guid) = dismiss { + self.exchange_busy = true; + let _ = self.tx.send(Command::DismissCandidate { suggested_sample_guid }); + } + } + + /// Every conversation that has not produced a result yet, with the action it is waiting on. + fn matching_requests(&mut self, ui: &mut egui::Ui) { + let rows: Vec = self + .matching + .iter() + .filter(|e| e.status != navigator_app::MatchingStatus::Exchanged) + .cloned() + .collect(); + if rows.is_empty() { + ui.add_space(6.0); + ui.weak(self.tr("matching.noRequests")); + return; + } + let mut consent_for: Option = None; + let mut run: Option = None; + let mut forget: Option = None; + egui::Grid::new("matching_requests") + .striped(true) + .num_columns(5) + .spacing([14.0, 4.0]) + .show(ui, |ui| { + ui.strong(self.tr("matching.col.who")); + ui.strong(self.tr("matching.col.direction")); + ui.strong(self.tr("matching.col.purpose")); + ui.strong(self.tr("matching.col.status")); + ui.strong(""); + ui.end_row(); + for e in &rows { + // Before mutual consent there is no partner identity to show — the broker is + // symmetric-blind by design, so the request URI is all either side has. + match &e.partner_did { + Some(did) => { + let short: String = did.chars().take(20).collect(); + ui.label(short).on_hover_text(did); + } + None => { + let short: String = e.request_uri.chars().take(16).collect(); + ui.label(egui::RichText::new(short).italics()) + .on_hover_text(self.tr("matching.blindHint")); + } + } + ui.label(self.tr(direction_key(e.direction))); + ui.label(if e.purpose.is_empty() { "—" } else { &e.purpose }); + let (text, color) = self.status_chip(e.status); + ui.colored_label(color, text).on_hover_text(match &e.last_error { + Some(err) => err.clone(), + None => self.tr(status_hint_key(e.status)).to_string(), + }); + ui.horizontal(|ui| { + if e.status == navigator_app::MatchingStatus::AwaitingConsent { + if ui.button(self.tr("matching.review")).clicked() { + consent_for = Some(e.clone()); + } + } else if matches!( + e.status, + navigator_app::MatchingStatus::Ready | navigator_app::MatchingStatus::Failed + ) && e.session_id.is_some() + { + let label = if e.status == navigator_app::MatchingStatus::Failed { + self.tr("matching.retry") + } else { + self.tr("exchange.run") + }; + if ui.add_enabled(!self.exchange_busy, egui::Button::new(label)).clicked() { + run = Some(e.clone()); + } + } + if ui + .button(self.tr("matching.forget")) + .on_hover_text(self.tr("matching.forgetHint")) + .clicked() + { + forget = Some(e.request_uri.clone()); + } + }); + ui.end_row(); + } + }); + if let Some(e) = consent_for { + self.consent_prompt = Some(e); + } + if let Some(e) = run { + self.run_matching_exchange(&e); + } + if let Some(request_uri) = forget { + let _ = self.tx.send(Command::ForgetMatchingRequest { request_uri }); + } + } + + /// Start the encrypted exchange for a consent-ready conversation, using the chosen subject. + fn run_matching_exchange(&mut self, e: &navigator_app::MatchingEntry) { + let (Some(session_id), Some(partner_did)) = (e.session_id.clone(), e.partner_did.clone()) else { + self.status = self.tr("matching.notReady").to_string(); + return; + }; + // Prefer the subject already bound to this conversation; the picker only supplies one when + // the conversation has never chosen. + let Some(guid) = e.biosample_guid.or(self.matching_subject) else { + self.status = self.tr("matching.noSubject").to_string(); + return; + }; + self.exchange_busy = true; + self.status = self.tr("exchange.running").to_string(); + let _ = self.tx.send(Command::RunIbdExchange { + info: navigator_app::ExchangeSessionInfo { + session_id, + request_uri: e.request_uri.clone(), + purpose: e.purpose.clone(), + partner_did, + partner_key_uri: None, + }, + biosample_guid: guid, + }); + } + + /// Completed comparisons, across every subject in the workspace. + fn matching_results(&mut self, ui: &mut egui::Ui) { + let rows: Vec = + self.matching.iter().filter(|e| e.result.is_some()).cloned().collect(); + if rows.is_empty() { + ui.add_space(6.0); + ui.weak(self.tr("exchange.noResults")); + return; + } + let mut message_partner: Option = None; + egui::Grid::new("matching_results") + .striped(true) + .num_columns(6) + .spacing([14.0, 4.0]) + .show(ui, |ui| { + ui.strong(self.tr("exchange.col.partner")); + ui.strong(self.tr("exchange.col.shared")); + ui.strong(self.tr("exchange.col.relationship")); + ui.strong(self.tr("exchange.col.agreed")); + ui.strong(self.tr("matching.col.reported")); + ui.strong(""); + ui.end_row(); + for e in &rows { + let Some(r) = e.result.as_ref() else { continue }; + let short: String = r.partner_did.chars().take(20).collect(); + ui.label(short).on_hover_text(&r.partner_did); + ui.label(format!("{:.1} cM · {} seg", r.total_shared_cm, r.segment_count)); + ui.label(&r.relationship); + if r.agreed { + ui.colored_label(OK_GREEN, self.tr("exchange.agreedYes")); + } else { + ui.colored_label(WARN_RED, self.tr("exchange.agreedNo")); + } + // Whether the AppView has this match on the discovery graph. Not every result + // can be reported: a disputed summary, or a conversation with no AppView sample + // handles, is deliberately kept private. + if e.attested { + ui.colored_label(OK_GREEN, self.tr("matching.reportedYes")) + .on_hover_text(self.tr("matching.reportedHint")); + } else { + ui.weak(self.tr("matching.reportedNo")) + .on_hover_text(self.tr("matching.notReportedHint")); + } + if ui.button(self.tr("dm.message")).clicked() { + message_partner = Some(r.partner_did.clone()); + } + ui.end_row(); + } + }); + if let Some(partner_did) = message_partner { + let _ = self.tx.send(Command::DmInitiate { partner_did }); + self.nav = Nav::Community; + self.community_tab = CommunityTab::Messages; + self.dm_loaded = false; + } + } + + /// Colour + label for a lifecycle status. + fn status_chip(&self, s: navigator_app::MatchingStatus) -> (&'static str, egui::Color32) { + use navigator_app::MatchingStatus as S; + let neutral = egui::Color32::from_rgb(170, 150, 40); + match s { + S::Requested => (self.tr("matching.status.requested"), neutral), + S::AwaitingConsent => ( + self.tr("matching.status.awaiting"), + egui::Color32::from_rgb(90, 140, 200), + ), + S::Declined => (self.tr("matching.status.declined"), egui::Color32::GRAY), + S::Ready => (self.tr("matching.status.ready"), OK_GREEN), + S::Exchanged => (self.tr("matching.status.exchanged"), OK_GREEN), + S::Failed => (self.tr("matching.status.failed"), WARN_RED), + } + } +} + +/// Agreement / success green, matching the exchange card's existing verdict colour. +const OK_GREEN: egui::Color32 = egui::Color32::from_rgb(60, 160, 60); +/// Disagreement / failure red (softer than [`DANGER`], which is reserved for destructive buttons). +const WARN_RED: egui::Color32 = egui::Color32::from_rgb(200, 90, 90); + +/// i18n key for a direction. +fn direction_key(d: navigator_app::MatchingDirection) -> &'static str { + match d { + navigator_app::MatchingDirection::Outbound => "matching.dir.outbound", + navigator_app::MatchingDirection::Inbound => "matching.dir.inbound", + } +} + +/// i18n key for the tooltip explaining what a status is waiting on. +fn status_hint_key(s: navigator_app::MatchingStatus) -> &'static str { + use navigator_app::MatchingStatus as S; + match s { + S::Requested => "matching.hint.requested", + S::AwaitingConsent => "matching.hint.awaiting", + S::Declined => "matching.hint.declined", + S::Ready => "matching.hint.ready", + S::Exchanged => "matching.hint.exchanged", + S::Failed => "matching.hint.failed", + } +} diff --git a/crates/navigator-ui/src/ui/mod.rs b/crates/navigator-ui/src/ui/mod.rs index 796d49d6..11cd44a2 100644 --- a/crates/navigator-ui/src/ui/mod.rs +++ b/crates/navigator-ui/src/ui/mod.rs @@ -73,6 +73,10 @@ enum Nav { Dashboard, Subjects, Projects, + /// Federated IBD discovery + consent. Top-level rather than a subject tab because a matching + /// conversation belongs to the *account* (it is keyed by our DID and the broker's request URI), + /// not to any one biosample — the subject is only chosen when it is time to exchange dosages. + Matching, Community, } @@ -83,6 +87,7 @@ impl Nav { Nav::Dashboard => "dashboard", Nav::Subjects => "subjects", Nav::Projects => "projects", + Nav::Matching => "matching", Nav::Community => "community", } } @@ -91,12 +96,30 @@ impl Nav { "dashboard" => Some(Nav::Dashboard), "subjects" => Some(Nav::Subjects), "projects" => Some(Nav::Projects), + "matching" => Some(Nav::Matching), "community" => Some(Nav::Community), _ => None, } } } +/// Sub-tabs of the Matching panel, following one conversation's life: a ranked candidate becomes a +/// request, a request becomes a result. +#[derive(Clone, Copy, PartialEq, Eq, Default)] +enum MatchingTab { + #[default] + Suggestions, + Requests, + Results, +} +impl MatchingTab { + const ALL: [(MatchingTab, &'static str); 3] = [ + (MatchingTab::Suggestions, "matching.tab.suggestions"), + (MatchingTab::Requests, "matching.tab.requests"), + (MatchingTab::Results, "matching.tab.results"), + ]; +} + /// Sub-tabs of the Community panel (the signed-in account's social surface). #[derive(Clone, Copy, PartialEq, Eq, Default)] enum CommunityTab { @@ -933,13 +956,27 @@ pub struct NavigatorApp { loading_ibd_suggestions: bool, /// Per-candidate introduction status, keyed by `suggested_sample_guid` (e.g. "PENDING"). ibd_intros: std::collections::HashMap, - /// Encrypted-exchange inbox: inbound requests awaiting consent + consent-ready sessions. - exchange_incoming: Vec, - exchange_ready: Vec, /// The selected subject's persisted IBD exchange results. exchange_results: Vec, /// True while an inbox refresh / consent / exchange run is in flight. exchange_busy: bool, + /// The matching ledger: every conversation, whatever its stage. Replaces the per-card view of + /// the same data, and unlike `ibd_intros` it survives a restart because the app persists it. + matching: Vec, + /// Which stage of the Matching panel is showing. + matching_tab: MatchingTab, + /// Candidates dismissed this session, hidden immediately rather than waiting for a refetch + /// (the AppView keeps the authoritative dismissal). + dismissed_candidates: std::collections::HashSet, + /// The local subject whose dosages an exchange will use. Defaults to the selected subject. + matching_subject: Option, + /// Whether the subject picker's filter + list is revealed (it is a reveal, not a dropdown, so a + /// 10k-subject workspace costs only the rows on screen). + matching_subject_picking: bool, + /// Filter text for that picker. + matching_subject_filter: String, + /// Request URI whose consent decision is being confirmed, with what we know of the request. + consent_prompt: Option, /// Signed-in account DID, or `None`. Gates the "Publish" actions. account: Option, /// Whether the last PDS write reached the server (offline indicator). @@ -1131,6 +1168,7 @@ mod descent; mod detail; mod events; mod ibd; +mod matching; mod modals; mod rowcache; mod simple; @@ -1365,10 +1403,15 @@ impl NavigatorApp { ibd_suggestions: Vec::new(), loading_ibd_suggestions: false, ibd_intros: std::collections::HashMap::new(), - exchange_incoming: Vec::new(), - exchange_ready: Vec::new(), exchange_results: Vec::new(), exchange_busy: false, + matching: Vec::new(), + matching_tab: MatchingTab::default(), + dismissed_candidates: std::collections::HashSet::new(), + matching_subject: None, + matching_subject_picking: false, + matching_subject_filter: String::new(), + consent_prompt: None, account: None, online: true, sync_pending: 0, @@ -1606,6 +1649,7 @@ impl eframe::App for NavigatorApp { Nav::Dashboard => self.dashboard_central(ui), Nav::Subjects => self.subjects_central(ui), Nav::Projects => self.projects_central(ui), + Nav::Matching => self.matching_central(ui), Nav::Community => self.community_central(ui), }); self.analysis_modal(ctx); @@ -1616,6 +1660,7 @@ impl eframe::App for NavigatorApp { self.edit_mdka_modal(ctx); self.delete_subject_modal(ctx); self.clear_subject_modal(ctx); + self.consent_modal(ctx); self.reset_haplo_modal(ctx); self.data_delete_modal(ctx); self.assign_project_modal(ctx); @@ -2184,7 +2229,13 @@ mod nav_persistence_tests { #[test] fn nav_keys_round_trip() { - for nav in [Nav::Dashboard, Nav::Subjects, Nav::Projects, Nav::Community] { + for nav in [ + Nav::Dashboard, + Nav::Subjects, + Nav::Projects, + Nav::Matching, + Nav::Community, + ] { assert_eq!(Nav::from_key(nav.as_key()), Some(nav)); } assert_eq!(Nav::from_key("bogus"), None); @@ -2256,7 +2307,7 @@ mod icon_glyph_tests { } } // The nav strip's icons, which live inline in `chrome::nav_bar`. - for icon in ['📊', '👤', '👥', '📁', '💬'] { + for icon in ['📊', '👤', '👥', '📁', '🔗', '💬'] { assert!( renderable(icon), "nav icon {icon:?} (U+{:04X}) has no glyph — it renders as a tofu box", diff --git a/crates/navigator-ui/src/ui/modals.rs b/crates/navigator-ui/src/ui/modals.rs index 9bfbebd3..aa3facaf 100644 --- a/crates/navigator-ui/src/ui/modals.rs +++ b/crates/navigator-ui/src/ui/modals.rs @@ -1808,6 +1808,68 @@ impl NavigatorApp { } } +impl NavigatorApp { + /// The consent decision for an inbound matching request. + /// + /// A modal rather than an Accept button in a table row, because consenting does two things the + /// row cannot say: it reveals our DID to the counterpart, and it puts our IBD-panel dosages on + /// the encrypted channel. Neither is undoable. The three headings below are the whole point of + /// the dialog — what we send, what they learn, and what never leaves the device. + pub(crate) fn consent_modal(&mut self, ctx: &egui::Context) { + let Some(entry) = self.consent_prompt.clone() else { return }; + let mut decision: Option = None; + let mut close = false; + modal_frame(ctx, "matching_consent_modal", 480.0, |ui| { + ui.label(egui::RichText::new(self.tr("matching.consent.title")).strong().size(16.0)); + ui.separator(); + ui.add_space(8.0); + ui.label(self.tr("matching.consent.body")); + ui.add_space(8.0); + egui::Grid::new("consent_facts").num_columns(2).spacing([12.0, 4.0]).show(ui, |ui| { + ui.strong(self.tr("matching.col.purpose")); + ui.label(if entry.purpose.is_empty() { "—" } else { &entry.purpose }); + ui.end_row(); + ui.strong(self.tr("matching.consent.request")); + ui.label(egui::RichText::new(&entry.request_uri).small()); + ui.end_row(); + }); + ui.add_space(10.0); + for (title, body) in [ + ("matching.consent.sendTitle", "matching.consent.sendBody"), + ("matching.consent.learnTitle", "matching.consent.learnBody"), + ("matching.consent.neverTitle", "matching.consent.neverBody"), + ] { + ui.label(egui::RichText::new(self.tr(title)).strong()); + ui.label(egui::RichText::new(self.tr(body)).small()); + ui.add_space(6.0); + } + ui.add_space(6.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(self.tr("exchange.accept")).clicked() { + decision = Some(true); + } + if ui.button(self.tr("exchange.decline")).clicked() { + decision = Some(false); + } + if ui.button(self.tr("common.cancel")).clicked() { + close = true; + } + }); + }); + if let Some(given) = decision { + self.exchange_busy = true; + let _ = self.tx.send(Command::MatchingConsent { + request_uri: entry.request_uri, + given, + biosample_guid: self.matching_subject, + }); + self.consent_prompt = None; + } else if close { + self.consent_prompt = None; + } + } +} + /// Shared modal scaffold: a dimmed full-screen backdrop + a centered `Frame::window` of `width`. /// `id` namespaces the dim layer + area; `add_contents` draws the modal body. fn modal_frame(ctx: &egui::Context, id: &str, width: f32, add_contents: impl FnOnce(&mut egui::Ui)) { diff --git a/crates/navigator-ui/src/ui/simple.rs b/crates/navigator-ui/src/ui/simple.rs index 73df6903..6bde04cd 100644 --- a/crates/navigator-ui/src/ui/simple.rs +++ b/crates/navigator-ui/src/ui/simple.rs @@ -596,10 +596,18 @@ impl NavigatorApp { } ui.add_space(12.0); } - if let Some(guid) = introduce { + // Resolve back to the full suggestion: the introduction records both AppView sample + // handles in the matching ledger, and only the suggestion carries them. + if let Some(suggestion) = introduce.and_then(|g| { + self.ibd_suggestions + .iter() + .find(|s| s.suggested_sample_guid == g) + .cloned() + }) { self.status = self.tr("network.introducing").to_string(); - let _ = self.tx.send(Command::IbdIntroduce { - suggested_sample_guid: guid, + let _ = self.tx.send(Command::RequestIntroduction { + suggestion, + biosample_guid: Some(guid), }); } ui.add_space(4.0); diff --git a/crates/navigator-ui/src/worker.rs b/crates/navigator-ui/src/worker.rs index 2f378e89..6996900e 100644 --- a/crates/navigator-ui/src/worker.rs +++ b/crates/navigator-ui/src/worker.rs @@ -20,6 +20,7 @@ use navigator_app::{ ExchangeSessionInfo, FtdnaGenealogy, FtdnaImportOptions, FtdnaImportPlan, FtdnaImportSummary, FtdnaResolution, HaploAssignment, HeteroplasmySite, IbdComparison, IbdDetectorConfig, IbdSuggestion, IdentityVerification, IncomingRequest, + MatchingEntry, NarratedBrief, PaintingResult, PrivateBucket, ProjectImportSummary, ProjectOverview, ProjectSampleReport, ArchaicMarkerResult, ArchaicSegmentResult, ProjectStrChart, ReadMetrics, RecruitmentInvitation, RefBuildStatus, RohResult, SexInferenceResult, SignalKind, @@ -369,18 +370,29 @@ pub enum Command { /// Federated IBD step 1: fetch the AppView's pseudonymous match suggestions for the /// signed-in account (registers the device key on first use). LoadIbdSuggestions, - /// Federated IBD step 2: request an introduction to a suggested candidate. - IbdIntroduce { + /// Ask to be introduced to a candidate, recording the conversation in the matching ledger. + RequestIntroduction { + suggestion: IbdSuggestion, + biosample_guid: Option, + }, + /// Tell the AppView to stop suggesting a candidate. + DismissCandidate { suggested_sample_guid: String, }, /// Adopt a local self-certifying did:key identity (desktop bootstrap — no PDS/OAuth). UseLocalIdentity, - /// Poll the AppView for inbound exchange requests + consent-ready sessions (the exchange inbox). - ExchangeInbox, - /// Consent to (or decline) an inbound exchange request. - ExchangeConsent { + /// Reconcile the matching ledger against the broker (inbound requests + consent-ready sessions) + /// and return every conversation. + RefreshMatching, + /// Consent to (or decline) an inbound exchange request, recording the decision durably. + MatchingConsent { request_uri: String, given: bool, + biosample_guid: Option, + }, + /// Drop a conversation from the local ledger (forget, not cancel). + ForgetMatchingRequest { + request_uri: String, }, /// Run a full IBD exchange for a subject over a consent-ready session (handshake → dosage /// exchange → signed attestations → persist). Long-running; needs the peer online. @@ -1064,19 +1076,13 @@ pub enum Event { Ibd(IbdComparison), /// Federated IBD match suggestions from the AppView (may be empty in a single-user dev AppView). IbdSuggestions(Vec), - /// An introduction request was opened for a candidate (status initially `PENDING`). - IbdIntroduced { + /// The matching ledger — every conversation with its result attached. Emitted by the refresh + /// and by every mutation, so the panel never has to re-poll the broker to see its own action. + Matching(Vec), + /// A candidate was dismissed; the UI drops its row. + CandidateDismissed { suggested_sample_guid: String, - request_uri: String, - status: String, }, - /// The exchange inbox: inbound requests awaiting our consent + consent-ready sessions. - ExchangeInbox { - incoming: Vec, - ready: Vec, - }, - /// A consent was recorded (the UI refreshes the inbox). - ExchangeConsented, /// A DM request was opened to a partner DID (the UI refreshes the inbox). DmInitiated, /// The DM inbox: inbound DM requests + consent-ready sessions to connect. @@ -2034,37 +2040,55 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { } Command::VerifyIdentityConsensus { a, b } => ev(app.verify_identity_consensus(a, b).await, Event::Identity), Command::LoadIbdSuggestions => ev(app.ibd_suggestions().await, Event::IbdSuggestions), - Command::IbdIntroduce { suggested_sample_guid } => ev( - app.ibd_introduce(&suggested_sample_guid).await, - |r| Event::IbdIntroduced { - suggested_sample_guid, - request_uri: r.request_uri, - status: r.status, - }, + Command::RequestIntroduction { + suggestion, + biosample_guid, + } => match app.request_introduction(&suggestion, biosample_guid).await { + Ok(_) => ev(app.matching_entries().await, Event::Matching), + Err(e) => Event::Error(e.to_string()), + }, + Command::DismissCandidate { suggested_sample_guid } => ev( + app.ibd_dismiss(&suggested_sample_guid).await, + |_| Event::CandidateDismissed { suggested_sample_guid }, ), Command::UseLocalIdentity => ev(app.use_local_identity(), |did| Event::Authenticated(Some(did))), - Command::ExchangeInbox => match (app.exchange_incoming().await, app.exchange_pending().await) { - (Ok(incoming), Ok(ready)) => Event::ExchangeInbox { incoming, ready }, - (Err(e), _) | (_, Err(e)) => Event::Error(e.to_string()), + Command::RefreshMatching => ev(app.refresh_matching().await, Event::Matching), + Command::MatchingConsent { + request_uri, + given, + biosample_guid, + } => match app.matching_consent(&request_uri, given, biosample_guid).await { + Ok(_) => ev(app.matching_entries().await, Event::Matching), + Err(e) => Event::Error(e.to_string()), + }, + Command::ForgetMatchingRequest { request_uri } => match app.forget_matching_request(&request_uri).await { + Ok(()) => ev(app.matching_entries().await, Event::Matching), + Err(e) => Event::Error(e.to_string()), }, - Command::ExchangeConsent { request_uri, given } => { - ev(app.exchange_consent(&request_uri, given).await, |_| Event::ExchangeConsented) - } Command::RunIbdExchange { info, biosample_guid } => { let cfg = IbdDetectorConfig::default(); - match app.open_exchange_session(&info).await { - Ok(session) => ev( + // A failure is recorded on the conversation rather than only surfaced as a transient + // toast — otherwise the request sits at READY and the user cannot tell it was tried. + let outcome = match app.open_exchange_session(&info).await { + Ok(session) => { app.exchange_ibd_for_subject(&session, biosample_guid, &info.request_uri, None, cfg) - .await, - |r| Event::IbdExchangeDone { - biosample_guid, - total_shared_cm: r.summary.total_shared_cm, - segment_count: r.summary.segment_count, - relationship: format!("{:?}", r.summary.relationship), - agreed: r.agreed, - }, - ), - Err(e) => Event::Error(e.to_string()), + .await + } + Err(e) => Err(e), + }; + match outcome { + Ok(r) => Event::IbdExchangeDone { + biosample_guid, + total_shared_cm: r.summary.total_shared_cm, + segment_count: r.summary.segment_count, + relationship: format!("{:?}", r.summary.relationship), + agreed: r.agreed, + }, + Err(e) => { + let msg = e.to_string(); + let _ = app.record_matching_failure(&info.request_uri, &msg).await; + Event::Error(msg) + } } } Command::LoadIbdExchanges { biosample_guid } => { diff --git a/documents/BACKLOG.md b/documents/BACKLOG.md index 6abbd0d4..ab130572 100644 --- a/documents/BACKLOG.md +++ b/documents/BACKLOG.md @@ -72,8 +72,19 @@ Code exists or the design is settled; these are the near-term threads. [`IBD_Matching_Implementation_Plan.md`](IBD_Matching_Implementation_Plan.md) - **Status:** Detection, identity math, the encrypted exchange channel (X3DH/AES-GCM), signed attestations, and the pairwise consensus-IBD chromosome browser are **all built and live-validated**. -- **Scope:** the remaining surface is user-facing consent + discovery — deciding who to match - against, managing consent, and turning `network_suggestions_section` into a full discovery flow. + The consent/discovery surface landed 2026-08-02 (branch `feat/ibd-matching-ux`): a durable + `ibd_request` ledger (mig 0041) + `App::refresh_matching`, a top-level **Matching** tab + (Suggestions / Requests / Results) replacing the per-subject discovery cards, an informed-consent + modal, and the two previously unwired AppView endpoints (`/ibd/dismiss`, `/ibd/attest`). + Attest needed a companion AppView change — `/ibd/suggestions` now returns the caller's own + `target_sample_guid`, without which `owns_sample` could never be satisfied from the edge. +- **Scope remaining:** background polling + an unread badge for inbound consent requests (the + Community 🔔 pattern); a Settings discoverability opt-in; a UI path for the *direct* + `exchange_request(partner_did, …)` initiator (still test-only); the segment ideogram for persisted + exchange results; and **live two-peer validation** of the whole flow against a running AppView. +- **Known rough edge:** `consensus_ibd_section` (`ui/ibd.rs`) still picks the comparison subject with + a flat `ComboBox` over every biosample — unusable at 10k subjects. The Matching picker was rebuilt + on the filter + `show_rows` pattern; this one has not been. ### 1.7 Packaging & release — open items - **Design:** [`design/packaging-and-release.md`](design/packaging-and-release.md) diff --git a/documents/IBD_Matching_Implementation_Plan.md b/documents/IBD_Matching_Implementation_Plan.md index e692f46f..a785b14e 100644 --- a/documents/IBD_Matching_Implementation_Plan.md +++ b/documents/IBD_Matching_Implementation_Plan.md @@ -2,6 +2,14 @@ Last updated: 2026-03-07 +> **Status header added 2026-08-02: this is a SCALA-ERA document and its library section is +> obsolete.** The dual-computation mutual-attestation architecture below was built and still holds, +> but it was built in Rust: crypto is `ed25519-dalek` / `x25519-dalek` / `aes-gcm` (not JDK 17), +> transport is a signed HTTP relay via `reqwest` (not STTP WebSocket), and the detector reads +> noodles/IBD-panel dosages (not HTSJDK VCFs). The consent/discovery surface shipped on +> `feat/ibd-matching-ux` — see `documents/BACKLOG.md` §1.6 and agent memory +> `ibd-matching-ledger` for the current shape. Read this doc for the *model*, not the *stack*. + ## Overview Implements backlog item 2.1 (IBD Matching System) in Navigator, coordinating with the From c2a601d26341595966e68ec871acdf8d77a37bc6 Mon Sep 17 00:00:00 2001 From: James Kane Date: Sun, 2 Aug 2026 12:04:38 -0500 Subject: [PATCH 2/3] Stop the consensus-compare picker rebuilding the roster every frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining flat `ComboBox` over every other subject in the workspace, left behind when the Matching tab's picker was rebuilt. Two costs, both proportional to workspace size and both paid per frame: the `others` vec cloned every subject's `donor_identifier` whether or not the popup was open, and the popup itself built one widget per entry. At 10k subjects that is a stall on a card that is merely *visible*. Same shape as the Matching picker now — current choice plus a Change reveal, then a filter over a virtualized `show_rows` list, so the cost is the rows on screen. The selected label is a lookup rather than a scan of a copied roster, and the "no other subjects" check is a short-circuiting `any` instead of building the vec to ask whether it is empty. Co-Authored-By: Claude Opus 5 (1M context) --- crates/navigator-ui/src/ui/ibd.rs | 88 +++++++++++++++++++++++++------ crates/navigator-ui/src/ui/mod.rs | 6 +++ 2 files changed, 79 insertions(+), 15 deletions(-) diff --git a/crates/navigator-ui/src/ui/ibd.rs b/crates/navigator-ui/src/ui/ibd.rs index 9d06a801..623c8d31 100644 --- a/crates/navigator-ui/src/ui/ibd.rs +++ b/crates/navigator-ui/src/ui/ibd.rs @@ -152,30 +152,32 @@ impl NavigatorApp { /// Subject-level IBD: compare this subject's autosomal consensus against another subject's — the /// pooled-genotype path (no per-source genotyping). A near-complete match is the dedup/identity /// signal (read off the relationship). + /// The comparison target is picked with a *Change* reveal — current choice, then a filter over a + /// virtualized list — rather than a dropdown. A `ComboBox` builds a widget per entry every frame + /// its popup is open, and this list is every other subject in the workspace; at 10k that is a + /// stall on each frame. The same reason the Matching tab's subject picker is shaped this way. pub(crate) fn consensus_ibd_section(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { - let others: Vec<(SampleGuid, String)> = self - .all_biosamples - .iter() - .filter(|b| b.guid != guid) - .map(|b| (b.guid, b.donor_identifier.clone())) - .collect(); - if others.is_empty() { + if !self.all_biosamples.iter().any(|b| b.guid != guid) { ui.label(egui::RichText::new(self.tr("hint.ibdNoOtherSubjects")).weak()); return; } + // A lookup, not a copy of every subject — the old build allocated the whole roster per frame. let sel = self .ibd_other_subject - .and_then(|g| others.iter().find(|(og, _)| *og == g).map(|(_, l)| l.clone())) + .and_then(|g| self.find_subject(g).map(|b| b.donor_identifier.clone())) .unwrap_or_else(|| "—".to_string()); + let mut toggle_picker = false; ui.horizontal(|ui| { ui.label(self.tr("ibd.otherSubject")); - egui::ComboBox::from_id_salt("ibd_subject") - .selected_text(sel) - .show_ui(ui, |ui| { - for (og, l) in &others { - ui.selectable_value(&mut self.ibd_other_subject, Some(*og), l); - } - }); + ui.label(egui::RichText::new(sel).strong()); + let label = if self.ibd_other_picking { + self.tr("common.cancel") + } else { + self.tr("common.change") + }; + if ui.button(label).clicked() { + toggle_picker = true; + } let ready = self.ibd_other_subject.is_some() && !self.running_ibd; if ui .add_enabled(ready, egui::Button::new(self.tr("ibd.compare"))) @@ -205,11 +207,67 @@ impl NavigatorApp { ui.spinner(); } }); + if toggle_picker { + self.ibd_other_picking = !self.ibd_other_picking; + self.ibd_other_filter.clear(); + } + self.ibd_other_picker(ui, guid); ui.label(egui::RichText::new(self.tr("hint.ibdConsensus")).weak().small()); self.render_identity(ui); self.render_ibd_result(ui); } + /// The revealed filter + virtualized subject list behind [`Self::consensus_ibd_section`]'s + /// *Change* button. Only the visible rows are built, so the cost is independent of workspace + /// size; the filtered `Vec` is assembled from immutable reads first so the scroll closure + /// borrows only locals. + fn ibd_other_picker(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { + if !self.ibd_other_picking { + return; + } + ui.add_space(4.0); + let hint = self.tr("subjects.filter"); + ui.add( + egui::TextEdit::singleline(&mut self.ibd_other_filter) + .hint_text(hint) + .desired_width(280.0), + ); + let needle = self.ibd_other_filter.trim().to_lowercase(); + let rows: Vec<(SampleGuid, String)> = self + .all_biosamples + .iter() + .filter(|b| b.guid != guid) + .filter(|b| needle.is_empty() || b.donor_identifier.to_lowercase().contains(&needle)) + .map(|b| (b.guid, b.donor_identifier.clone())) + .collect(); + ui.label(egui::RichText::new(format!("{}", rows.len())).weak().small()); + if rows.is_empty() { + ui.label(egui::RichText::new(self.tr("subjects.noMatch")).weak()); + return; + } + + let selected = self.ibd_other_subject; + let mut pick = None; + let row_h = ui.spacing().interact_size.y; + egui::ScrollArea::vertical() + .id_salt("ibd_other_list") + .max_height(180.0) + .auto_shrink([false, false]) + .show_rows(ui, row_h, rows.len(), |ui, range| { + for i in range { + let (g, name) = &rows[i]; + if ui.selectable_label(selected == Some(*g), name).clicked() { + pick = Some(*g); + } + } + }); + if let Some(g) = pick { + self.ibd_other_subject = Some(g); + self.ibd_other_picking = false; + self.ibd_other_filter.clear(); + } + } + /// This subject's completed federated exchanges. Discovery and consent are **not** here — they /// are account-scoped and live in the top-level Matching tab; what this card answers is "what /// did the network find for *this person*". Flows into the page scroll (no nested ScrollArea). diff --git a/crates/navigator-ui/src/ui/mod.rs b/crates/navigator-ui/src/ui/mod.rs index 11cd44a2..1f88c41c 100644 --- a/crates/navigator-ui/src/ui/mod.rs +++ b/crates/navigator-ui/src/ui/mod.rs @@ -946,6 +946,10 @@ pub struct NavigatorApp { ibd_src_b: Option, /// Subject-level (consensus) IBD compare: the other subject picked for comparison. ibd_other_subject: Option, + /// Whether the consensus-compare subject picker's filter + list is revealed. + ibd_other_picking: bool, + /// Filter text for that picker. + ibd_other_filter: String, ibd_result: Option, running_ibd: bool, /// Identity-verification result for the current IBD pair. @@ -1397,6 +1401,8 @@ impl NavigatorApp { ibd_src_a: None, ibd_src_b: None, ibd_other_subject: None, + ibd_other_picking: false, + ibd_other_filter: String::new(), ibd_result: None, running_ibd: false, identity: None, From 1b20dea764479fbbf45703c09373550166c2f275 Mon Sep 17 00:00:00 2001 From: James Kane Date: Sun, 2 Aug 2026 12:05:34 -0500 Subject: [PATCH 3/3] Backlog: both IBD subject pickers are virtualized now Co-Authored-By: Claude Opus 5 (1M context) --- documents/BACKLOG.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/documents/BACKLOG.md b/documents/BACKLOG.md index ab130572..51a236a1 100644 --- a/documents/BACKLOG.md +++ b/documents/BACKLOG.md @@ -82,9 +82,10 @@ Code exists or the design is settled; these are the near-term threads. Community 🔔 pattern); a Settings discoverability opt-in; a UI path for the *direct* `exchange_request(partner_did, …)` initiator (still test-only); the segment ideogram for persisted exchange results; and **live two-peer validation** of the whole flow against a running AppView. -- **Known rough edge:** `consensus_ibd_section` (`ui/ibd.rs`) still picks the comparison subject with - a flat `ComboBox` over every biosample — unusable at 10k subjects. The Matching picker was rebuilt - on the filter + `show_rows` pattern; this one has not been. +- **Note:** both subject pickers in this area (Matching, and `consensus_ibd_section` in `ui/ibd.rs`) + are now filter + virtualized `show_rows` rather than `ComboBox` — a workspace can hold 10k + subjects, and a `ComboBox` builds a widget per entry per frame. Follow that pattern for any new + roster-wide picker. ### 1.7 Packaging & release — open items - **Design:** [`design/packaging-and-release.md`](design/packaging-and-release.md)