From b4ccf50da907700ab98e17cb1b32f2b536c1e2b3 Mon Sep 17 00:00:00 2001 From: dp-web4 Date: Thu, 29 Oct 2026 17:03:55 -0700 Subject: [PATCH 1/3] hub: commitments follow a contact into the member it became (K5b2) When ContactBound folds, every commitment naming the contact is rewritten to name the member: owner and backup, acknowledgements, deliveries, handoffs and the resolution. The projection reads every later commitment event through Invites::canonical_event, and the hub writes events through it too, so a live hub and a replay agree. A link mailed to the contact, an operator relaying in the contact's name, and a carrier that listed a nudge before the join all act for the member. The operator sees the member by the contact's name, from the book and not the ledger (C3). The projection does not fold a binding that would make one person the owner and the backup of an open job (bind_conflict). The hub never writes one: since Legion's #961 Q2, a member binds no invite their own join did not carry. Also from the #960 review: a corrupt legacy key in localStorage now throws LEGACY_UNREADABLE, is left in place, and both key pages say so in plain words. Seat: hub-claude Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3DBMCeF8zJ2RMvPCr3Unc --- hub/docs/PRD_COMMITMENTS.md | 10 ++ hub/hub-daemon/js-tests/client_keystore.js | 12 ++ hub/hub-daemon/src/rest.rs | 12 +- hub/hub-daemon/src/rest/commitments.rs | 36 ++++- .../src/rest/commitments/invites.rs | 89 ++++++++++ hub/hub-lib/src/commitments.rs | 152 ++++++++++++++++++ hub/hub-lib/src/state.rs | 15 +- 7 files changed, 313 insertions(+), 13 deletions(-) diff --git a/hub/docs/PRD_COMMITMENTS.md b/hub/docs/PRD_COMMITMENTS.md index 41809cba..62f20e13 100644 --- a/hub/docs/PRD_COMMITMENTS.md +++ b/hub/docs/PRD_COMMITMENTS.md @@ -329,6 +329,16 @@ K5 adds four things. - Opening an invite again from the browser it bound writes nothing. - **Open:** the admission notice is a listing the operator reads, not a message the carrier sends. Pushing it to the operator needs an operator address, which the hub doesn't hold yet. +**K5b2 as built.** +- **One id per person.** When `ContactBound` folds, every commitment that names the contact is rewritten to name the member: owner and backup, acknowledgements, deliveries, handoffs, the resolution. `Invites::canonical_event` reads every later commitment event the same way, and the hub writes events through it, so the ledger names the member and a replay agrees with the live hub. +- **Nothing the contact already holds stops working.** These all act for the member: + - a K4 link mailed to the contact before the join; + - an operator relaying in the contact's name; + - a carrier that listed a nudge before the join and reports it after. +- **The operator still sees who.** A member who joined from an invite sent no name, so the operator's views show the contact's name from the book. Erasing the contact erases the name (C3). +- **One person is never owner and backup.** A member never binds an invite their own join didn't carry (K5b, Legion #961), so the hub never writes a binding that merges an open job's owner and backup. If such a binding reaches the ledger by another path, the projection doesn't fold it (`bind_conflict`). +- **Replay and handoff nudges (supervisor, #958).** Replaying a ledger derives the K4b handoff nudges for every open commitment whose latest entry is a handoff. A hub that first runs K4b over a ledger that already has handoffs nudges those parties once, about a move that may be old. The live hub predates K1, so it has none; a hub upgraded mid-pilot should expect this. + **Questions asked, and answered (Legion, #959, 10-29 20:17Z):** - (a) **Does an invite admit on its own?** Yes, because an invite is no more trust than the K4 link the contact already holds. It holds only with the five conditions in (1): atomic once, MAC verified, 7-day life, revocation, and the operator told. - (b) **Are browsers without WebCrypto Ed25519 refused?** Yes, with "ask your organiser" and no JS fallback library: a bundled library would put an extractable key in page memory. The keys that actually get lost are lost to webviews and eviction, covered in (3). diff --git a/hub/hub-daemon/js-tests/client_keystore.js b/hub/hub-daemon/js-tests/client_keystore.js index 1c363862..0d79485f 100644 --- a/hub/hub-daemon/js-tests/client_keystore.js +++ b/hub/hub-daemon/js-tests/client_keystore.js @@ -108,4 +108,16 @@ const run = (ctx, code) => vm.runInContext(code, ctx); // 5. esc escapes quotes, so a topic id cannot break out of a data attribute. assert.strictEqual(run(g.ctx, 'esc(`a"b\'c`)'), 'a"b'c<d>'); console.log('ok 5 esc covers quotes'); + + // 6. A corrupt old key is named as such and left in place, every time it is loaded. + for (const bad of ['{not json', JSON.stringify({ lct_id: 'lct-1', pubHex, privJwk: { kty: 'OKP' } })]) { + const c = makeEnv(false); + c.ls.set('hestia_hub_id', bad); + for (let i = 0; i < 2; i++) { + await assert.rejects(run(c.ctx, 'loadID()'), { message: 'LEGACY_UNREADABLE' }); + } + assert.strictEqual(c.ls.get('hestia_hub_id'), bad, 'the unreadable key is kept'); + assert.strictEqual(await run(c.ctx, 'idbGet()'), undefined, 'and nothing half-moved'); + } + console.log('ok 6 a corrupt old key is named and kept'); })().catch(e => { console.error('FAIL', e); process.exit(1); }); diff --git a/hub/hub-daemon/src/rest.rs b/hub/hub-daemon/src/rest.rs index 3e2466a0..112b19e5 100644 --- a/hub/hub-daemon/src/rest.rs +++ b/hub/hub-daemon/src/rest.rs @@ -8500,8 +8500,12 @@ async function loadID(){ // One move from the old store, an extractable JWK in localStorage. Same key, same LCT; // the JWK goes only once the non-extractable copy is stored. const raw=localStorage.getItem(LEGACY); if(!raw)return null; - const o=JSON.parse(raw); - const priv=await crypto.subtle.importKey('jwk',o.privJwk,{name:'Ed25519'},false,['sign']); + // An old key that can't be read is left where it is: it may be the person's only copy. + let o,priv; + try{ + o=JSON.parse(raw); + priv=await crypto.subtle.importKey('jwk',o.privJwk,{name:'Ed25519'},false,['sign']); + }catch(e){ throw new Error('LEGACY_UNREADABLE'); } const moved={lct_id:o.lct_id,pubHex:o.pubHex,priv}; await idbPut(moved); localStorage.removeItem(LEGACY); persist(); return moved; @@ -8632,7 +8636,9 @@ document.addEventListener('click',ev=>{ ' ยท '+(w.law_present?'governed by law':'no law'); $('#lawlink').href='/v1/hubs/'+HUBID+'/law'; }catch(e){ $('#hubinfo').innerHTML='could not reach hub: '+esc(e.message)+''; return; } - try{ ID=await loadID(); }catch(e){ $('#idbody').innerHTML='could not open this browser\'s key store: '+esc(e.message)+''; } + try{ ID=await loadID(); }catch(e){ $('#idbody').innerHTML=''+(e.message==='LEGACY_UNREADABLE' + ?'this browser holds an older key that can\'t be read; it has been left as it was. Ask your organiser.' + :'could not open this browser\'s key store: '+esc(e.message))+''; } if(ID||!$('#idbody').textContent)renderID(); loadTopics(); })(); diff --git a/hub/hub-daemon/src/rest/commitments.rs b/hub/hub-daemon/src/rest/commitments.rs index 065e3f81..a650dcd5 100644 --- a/hub/hub-daemon/src/rest/commitments.rs +++ b/hub/hub-daemon/src/rest/commitments.rs @@ -75,6 +75,8 @@ fn check_leads_role(state: &HubState, role: &str) -> Result { /// person's name. Recorded under the operator's own id it would read as a lead's and send the /// queue to leads (Legion, #947). fn check_actor(s: &RestState, state: &HubState, c: &Commitment, by: &str, operator_may: bool) -> Result<(), ApiError> { + let by = state.invites.canonical_id(by); + let by = by.as_str(); if by == c.owner.party_id || by == c.backup.party_id { return Ok(()); } @@ -107,11 +109,16 @@ pub(crate) fn leads_of(state: &HubState, c: &Commitment) -> Vec { } /// Run the projection checks, then witness, both under [`COMMITMENT_LOCK`] (the caller holds it). +/// A party a contact's id still names is written as the member they became (K5b2), so the ledger +/// says who acted as the projection reads it. async fn checked_witness(s: &RestState, event: HubEvent) -> Result { - { + let event = { let ledger = s.ledger.lock().await; - commitments::check(&s.projected(&ledger).commitments, &event).map_err(refused)?; - } + let state = s.projected(&ledger); + let event = state.invites.canonical_event(&event); + commitments::check(&state.commitments, &event).map_err(refused)?; + event + }; witness_event(s, event).await } @@ -358,12 +365,15 @@ async fn witness_resolution( } /// A party for the operator's eyes: the contact book's name, or the member's roster name. An -/// erased contact reads as erased; the ledger keeps only its id. +/// erased contact reads as erased; the ledger keeps only its id. A member who joined from an +/// invite sent no name (C3), so they go by their contact's name while the book holds it. pub(crate) fn party_view(state: &HubState, book: &ContactBook, p: &PartyRef) -> serde_json::Value { + let contact_name = |id: &str| book.contacts.get(id).map(|c| c.name.clone()); let name = match p.kind { - PartyKind::Contact => book.contacts.get(&p.party_id).map(|c| c.name.clone()), + PartyKind::Contact => contact_name(&p.party_id), PartyKind::Member => Uuid::parse_str(&p.party_id).ok() - .and_then(|u| state.members.get(&u)).and_then(|m| m.name.clone()), + .and_then(|u| state.members.get(&u)).and_then(|m| m.name.clone()) + .or_else(|| state.invites.contact_of(&p.party_id).and_then(contact_name)), }; serde_json::json!({ "party_id": p.party_id, "kind": p.kind, "name": name, "erased": p.kind == PartyKind::Contact && !book.contacts.contains_key(&p.party_id) }) @@ -675,6 +685,8 @@ pub(super) async fn admin_nudge_delivered( async fn record_delivery(s: &RestState, body: DeliveredBody) -> Result { let _serial = COMMITMENT_LOCK.lock().await; let state = { let ledger = s.ledger.lock().await; s.projected(&ledger) }; + // A carrier that listed the nudge before the contact joined reports the contact's id (K5b2). + let body = DeliveredBody { party_id: state.invites.canonical_id(&body.party_id), ..body }; let c = state.commitments.get(&body.commitment_id) .ok_or_else(|| refused(Refusal::Unknown(body.commitment_id.clone())))?; // The party must be one this commitment could have nudged. Not "is owed now": the queue may @@ -815,13 +827,19 @@ pub(crate) fn mint_link(key: &[u8; 32], commitment_id: &str, party_id: &str, exp } /// What a verified link says, and the digest the ledger records in place of the token. -#[derive(Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct LinkClaim { pub commitment_id: String, pub party_id: String, pub digest: String, } +/// The claim with its party read as who they are now (K5b2): a link mailed to a contact keeps +/// working after the contact joins, as the member they became. +fn claim_now(state: &HubState, claim: &LinkClaim) -> LinkClaim { + LinkClaim { party_id: state.invites.canonical_id(&claim.party_id), ..claim.clone() } +} + #[derive(Debug, PartialEq, Eq)] pub(crate) enum LinkError { /// Not a token this hub made. Indistinguishable on purpose: no oracle for a forger. @@ -939,6 +957,7 @@ fn act_label(act: LinkAct, role: LinkRole, backup: &str) -> String { async fn act_by_link(s: &RestState, claim: &LinkClaim, act: LinkAct) -> Result { let _serial = COMMITMENT_LOCK.lock().await; let state = { let ledger = s.ledger.lock().await; s.projected(&ledger) }; + let claim = &claim_now(&state, claim); let c = projected_commitment(&state, &claim.commitment_id)?; let role = role_of(&state, &c, &claim.party_id).ok_or_else(|| ApiError { status: StatusCode::FORBIDDEN, @@ -1049,6 +1068,7 @@ pub(super) async fn link_page( Err(page) => return page, }; let state = { let ledger = s.ledger.lock().await; s.projected(&ledger) }; + let claim = claim_now(&state, &claim); let book = match load_book(&s) { Ok(book) => book, Err(e) => { @@ -2051,6 +2071,6 @@ mod tests { }; let said = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); assert!(out.status.success(), "{said}"); - assert_eq!(said.matches("\nok ").count() + usize::from(said.starts_with("ok ")), 5, "{said}"); + assert_eq!(said.matches("\nok ").count() + usize::from(said.starts_with("ok ")), 6, "{said}"); } } diff --git a/hub/hub-daemon/src/rest/commitments/invites.rs b/hub/hub-daemon/src/rest/commitments/invites.rs index f50b5c7e..8a3e995f 100644 --- a/hub/hub-daemon/src/rest/commitments/invites.rs +++ b/hub/hub-daemon/src/rest/commitments/invites.rs @@ -352,6 +352,7 @@ async function join(){ else { say('err',out.error||('Something went wrong ('+r.status+').')); b.disabled=false; } }catch(e){ say('err',e.message==='NO_ED25519'?'This browser cannot hold a signing key. Ask your organiser.' + :e.message==='LEGACY_UNREADABLE'?'This browser holds an older key that can\'t be read. Ask your organiser.' :'Something went wrong: '+e.message); b.disabled=false; } @@ -557,6 +558,94 @@ admission: assert_eq!(join(&s, &KeyPair::generate(), Uuid::new_v4(), Some(&token)).await.0, StatusCode::OK); } + async fn leads_role(s: &RestState) -> Uuid { + let role = Uuid::new_v4(); + crate::rest::channel_e2e_tests::witness_for_test(s, HubEvent::RoleCreated { + role_lct_id: role, + role: web4_core::role::SocietyRole::Custom("leads".into()), + role_kind: hub_lib::events::RoleKind::Capacity, + charter: None, + parent_role_lct_id: None, + initial_occupant: None, + created_by: s.sovereign_lct_id, + }).await; + role + } + + async fn open_for(s: &RestState, owner: PartyRef, backup: PartyRef, role: Uuid) -> String { + let v = open_commitment(s, OpenBody { title: "Venue AV".into(), owner, backup, + leads_role: role.to_string(), ladder: vec![48, 24], due_at: None }).await.unwrap(); + v["commitment_id"].as_str().unwrap().to_string() + } + + async fn last_event(s: &RestState) -> HubEvent { + s.ledger.lock().await.entries().last().unwrap().event.clone() + } + + #[tokio::test] + async fn a_contacts_commitments_follow_them_into_membership() { + let (_tmp, s, robin) = hub(PILOT_LAW).await; + let (sam_kp, role) = (KeyPair::generate(), leads_role(&s).await); + let sam = existing_member(&s, &sam_kp).await; + let contact = PartyRef { party_id: robin.clone(), kind: PartyKind::Contact }; + let as_member = |id: Uuid| PartyRef { party_id: id.to_string(), kind: PartyKind::Member }; + let id = open_for(&s, contact.clone(), as_member(sam), role).await; + // Mailed to Robin as a contact, before Robin joins. + let link = { + let _serial = COMMITMENT_LOCK.lock().await; + let key = link_key_or_create(&s.paths.root).unwrap(); + mint_link(&key, &id, &robin, Utc::now() + chrono::Duration::days(LINK_TTL_DAYS)) + }; + let token = invite(&s, &robin).await; + let lct = Uuid::new_v4(); + assert_eq!(join(&s, &KeyPair::generate(), lct, Some(&token)).await.0, StatusCode::OK); + let m = lct.to_string(); + + let state = { let l = s.ledger.lock().await; s.projected(&l) }; + let book = load_book(&s).unwrap(); + let c = &state.commitments[&id]; + assert_eq!(c.owner, as_member(lct), "the job is the member's now"); + assert_eq!(party_view(&state, &book, &c.owner)["name"], "Robin", "and the operator still sees who"); + assert_eq!(book.address_of(&c.owner), Some("robin@example.org"), "nudges still reach Robin"); + + // The contact's link still answers for Robin, and the ledger names the member. + let (status, _, html) = page(&s, &format!("/c/{link}")).await; + assert_eq!(status, StatusCode::OK); + assert!(html.contains("You're the owner. Your backup is"), "{html}"); + let (status, _, out) = page_post(&s, &format!("/c/{link}/acknowledge")).await; + assert_eq!(status, StatusCode::OK, "{out}"); + assert!(matches!(last_event(&s).await, HubEvent::CommitmentAcknowledged { party_id, via: AckVia::Link, .. } + if party_id == m)); + + // The operator relaying in the contact's name, and a carrier that listed before the join. + move_commitment(&s, &id, MoveBody { by: robin.clone(), move_kind: MoveKind::Update, to: None, + note_ref: None }).await.unwrap(); + assert!(matches!(last_event(&s).await, HubEvent::CommitmentMoved { by, .. } if by == m)); + record_delivery(&s, DeliveredBody { commitment_id: id.clone(), about_index: c.opened_index, party_id: robin.clone(), + channel: "email".into(), ok: true }).await.unwrap(); + assert!(matches!(last_event(&s).await, HubEvent::NudgeDelivered { party_id, .. } if party_id == m)); + + // A job opened for the contact now is the member's, and Robin can't back Robin up. + let later = open_for(&s, contact.clone(), as_member(sam), role).await; + assert_eq!(current_owner(&s, &later).await, as_member(lct)); + assert!(open_commitment(&s, OpenBody { title: "x".into(), owner: contact, backup: as_member(lct), + leads_role: role.to_string(), ladder: vec![48], due_at: None }).await.is_err()); + } + + async fn current_owner(s: &RestState, id: &str) -> PartyRef { + let l = s.ledger.lock().await; + s.projected(&l).commitments[id].owner.clone() + } + + async fn page_post(s: &RestState, path: &str) -> (StatusCode, axum::http::HeaderMap, String) { + use tower::ServiceExt; + let req = axum::http::Request::builder().method("POST").uri(path).body(axum::body::Body::empty()).unwrap(); + let resp = crate::rest::router(s.clone()).oneshot(req).await.unwrap(); + let (status, headers) = (resp.status(), resp.headers().clone()); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + (status, headers, String::from_utf8_lossy(&bytes).into_owned()) + } + #[tokio::test] async fn an_invite_is_evidence_for_law_not_a_way_around_it() { let (_tmp, s, robin) = hub(QUEUE_LAW).await; diff --git a/hub/hub-lib/src/commitments.rs b/hub/hub-lib/src/commitments.rs index 17a33a50..d0842092 100644 --- a/hub/hub-lib/src/commitments.rs +++ b/hub/hub-lib/src/commitments.rs @@ -470,6 +470,87 @@ impl Invites { None } } + + /// The id a party goes by now (K5b2): a contact who joined is the member they became, and + /// any other id is itself. + pub fn canonical_id(&self, party_id: &str) -> String { + self.bound.get(party_id).map_or_else(|| party_id.to_string(), Uuid::to_string) + } + + pub fn canonical(&self, p: &PartyRef) -> PartyRef { + match (p.kind, self.bound.get(&p.party_id)) { + (PartyKind::Contact, Some(m)) => PartyRef { party_id: m.to_string(), kind: PartyKind::Member }, + _ => p.clone(), + } + } + + /// The contact a member joined as, if they joined from an invite. + pub fn contact_of(&self, member: &str) -> Option<&str> { + self.bound.iter().find(|(_, m)| m.to_string() == member).map(|(p, _)| p.as_str()) + } + + /// A commitment event with every party it names read as the party goes by now. The hub + /// writes events this way, and the projection reads old ones this way, so an operator, a link + /// or a carrier still holding the contact's id acts for the member it became. + pub fn canonical_event(&self, event: &HubEvent) -> HubEvent { + let id = |s: &String| self.canonical_id(s); + let mut e = event.clone(); + match &mut e { + HubEvent::CommitmentOpened { owner, backup, .. } => { + *owner = self.canonical(owner); + *backup = self.canonical(backup); + } + HubEvent::CommitmentAcknowledged { party_id, .. } => *party_id = id(party_id), + HubEvent::CommitmentMoved { by, to, .. } => { + *by = id(by); + if let Some(t) = to { + *t = self.canonical(t); + } + } + HubEvent::CommitmentResolved { by, .. } => *by = id(by), + HubEvent::NudgeDelivered { party_id, .. } => *party_id = id(party_id), + _ => {} + } + e + } +} + +/// Why binding `party_id` to `member` would be wrong for the commitments: an open one that names +/// them both would have one person as its owner and its backup (K5b2). +pub fn bind_conflict(commitments: &BTreeMap, party_id: &str, member: &str) -> Option { + commitments.values().filter(|c| c.is_open()).find(|c| { + let names = [c.owner.party_id.as_str(), c.backup.party_id.as_str()]; + names.contains(&party_id) && names.contains(&member) + }).map(|c| format!("{} names this contact and this member as its owner and backup", c.commitment_id)) +} + +/// Read every commitment that names a contact as naming the member it became (K5b2). Applied when +/// `ContactBound` folds, so the projection and every check after it know the person by one id. +pub fn rebind(commitments: &mut BTreeMap, party_id: &str, member: &str) { + let member_ref = || PartyRef { party_id: member.to_string(), kind: PartyKind::Member }; + let swap = |s: &mut String| if s == party_id { *s = member.to_string() }; + for c in commitments.values_mut() { + for p in [&mut c.owner, &mut c.backup] { + if p.party_id == party_id { + *p = member_ref(); + } + } + if let Some(via) = c.acknowledged.remove(party_id) { + c.acknowledged.insert(member.to_string(), via); + } + for d in &mut c.deliveries { + swap(&mut d.party_id); + } + for h in &mut c.handoffs { + if h.previous_owner.party_id == party_id { + h.previous_owner = member_ref(); + } + swap(&mut h.by); + } + if let Some(r) = &mut c.resolved { + swap(&mut r.by); + } + } } /// Fold one commitment event into the map. Called from `HubState::apply`. Events the routes would @@ -1263,6 +1344,77 @@ mod tests { assert!(matches!(check(&m, &delivered(51, "sam", "email", true)), Err(Refusal::Invalid(_)))); } + #[test] + fn commitments_follow_a_contact_into_the_member_it_became() { + let (op, robin_m, kim_m) = (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4()); + let robin_s = robin_m.to_string(); + let contact = |id: &str| PartyRef { party_id: id.into(), kind: PartyKind::Contact }; + let open = |id: &str, owner: PartyRef, backup: PartyRef| match opened(id) { + HubEvent::CommitmentOpened { commitment_id, title, leads_role, ladder, due_at, recorded_by, basis, .. } => + HubEvent::CommitmentOpened { commitment_id, title, owner, backup, leads_role, ladder, due_at, + recorded_by, basis }, + _ => unreachable!(), + }; + let bound = |party: &str, member: Uuid, c: char| HubEvent::ContactBound { + party_id: party.into(), member_lct_id: member, invite_digest: c.to_string().repeat(LINK_DIGEST_LEN), + bound_by: op }; + let ack = |id: &str, party: &str, via| HubEvent::CommitmentAcknowledged { + commitment_id: id.into(), party_id: party.into(), via, link_digest: (via == AckVia::Link).then(|| dg()).flatten() }; + let mut st = crate::state::HubState::default(); + let mut n = 0; + let mut apply = |st: &mut crate::state::HubState, e: HubEvent| { + n += 1; + st.apply(&e, at(n), n as u64); + }; + + apply(&mut st, open("c", contact("robin"), member("sam"))); + apply(&mut st, ack("c", "robin", AckVia::Operator)); + apply(&mut st, HubEvent::NudgeDelivered { commitment_id: "c".into(), about_index: 1, + party_id: "robin".into(), channel: "email".into(), ok: true }); + // Kim, still a contact, is the backup beside the member Kim's key already is. + apply(&mut st, open("k", member(&kim_m.to_string()), contact("kim"))); + + apply(&mut st, bound("robin", robin_m, 'a')); + let c = &st.commitments["c"]; + assert_eq!(c.owner, member(&robin_s)); + assert_eq!(c.acknowledged.get(&robin_s), Some(&AckVia::Operator)); + assert!(!c.acknowledged.contains_key("robin")); + assert_eq!(nudge_attempts_since(&st.commitments, &robin_s, at(0)), 1); + assert!(!c.owner_acknowledged()); + + // A link mailed to the contact still speaks for them: read as the member, it is the owner's word. + let late = ack("c", "robin", AckVia::Link); + let read = st.invites.canonical_event(&late); + assert!(matches!(&read, HubEvent::CommitmentAcknowledged { party_id, .. } if *party_id == robin_s)); + check(&st.commitments, &read).expect("the member is the owner"); + assert!(check(&st.commitments, &late).is_err(), "the raw contact id names nobody on the job now"); + apply(&mut st, late); + assert!(st.commitments["c"].owner_acknowledged()); + apply(&mut st, HubEvent::NudgeDelivered { commitment_id: "c".into(), about_index: 1, + party_id: "robin".into(), channel: "email".into(), ok: true }); + assert_eq!(nudge_attempts_since(&st.commitments, &robin_s, at(0)), 2, "a late report counts for the member"); + + // A job opened later for the contact is the member's. + apply(&mut st, open("d", contact("robin"), member("sam"))); + assert_eq!(st.commitments["d"].owner, member(&robin_s)); + let both = st.invites.canonical_event(&open("e", contact("robin"), member(&robin_s))); + assert!(check(&st.commitments, &both).is_err(), "one person is not their own backup"); + + // A binding that would make Kim owner and backup of "k" is not folded. + assert!(bind_conflict(&st.commitments, "kim", &kim_m.to_string()).is_some()); + let before = (st.invites.clone(), st.commitments["k"].backup.clone()); + apply(&mut st, bound("kim", kim_m, 'b')); + assert_eq!((st.invites.clone(), st.commitments["k"].backup.clone()), before); + // Once that job is closed, nothing stands in the way. + apply(&mut st, HubEvent::CommitmentResolved { commitment_id: "k".into(), outcome: Outcome::Withdrawn, + by: op.to_string(), resolved_at: at(0), owner_acknowledged: false, link_digest: None }); + assert!(bind_conflict(&st.commitments, "kim", &kim_m.to_string()).is_none()); + assert_eq!(st.invites.contact_of(&robin_s), Some("robin")); + apply(&mut st, HubEvent::CommitmentResolved { commitment_id: "c".into(), outcome: Outcome::Done, + by: "robin".into(), resolved_at: at(0), owner_acknowledged: true, link_digest: None }); + assert_eq!(st.commitments["c"].resolved.as_ref().map(|r| r.by.as_str()), Some(robin_s.as_str())); + } + #[test] fn an_invite_binds_once_a_contact_once_and_a_withdrawn_one_never() { let (op, m1, m2) = (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4()); diff --git a/hub/hub-lib/src/state.rs b/hub/hub-lib/src/state.rs index de02e35d..14649b19 100644 --- a/hub/hub-lib/src/state.rs +++ b/hub/hub-lib/src/state.rs @@ -1634,9 +1634,20 @@ impl HubState { | HubEvent::CommitmentEscalated { .. } | HubEvent::CommitmentResolved { .. } | HubEvent::NudgeDelivered { .. } => { - crate::commitments::fold(&mut self.commitments, event, ts, index); + // A contact who joined is folded as the member they became (K5b2). + let event = self.invites.canonical_event(event); + crate::commitments::fold(&mut self.commitments, &event, ts, index); } - HubEvent::ContactBound { .. } | HubEvent::InviteRevoked { .. } => self.invites.fold(event), + HubEvent::ContactBound { party_id, member_lct_id, .. } => { + let member = member_lct_id.to_string(); + if self.invites.check(event).is_ok() + && crate::commitments::bind_conflict(&self.commitments, party_id, &member).is_none() + { + self.invites.fold(event); + crate::commitments::rebind(&mut self.commitments, party_id, &member); + } + } + HubEvent::InviteRevoked { .. } => self.invites.fold(event), // Member-of routing (PRD_MEMBER_OF_ROUTING). Ids arrive as strings, already validated // at intake; one that is not canonical is not routable and is not projected (same // stance as the LctPublished arm below). From 385404cd060a6df1d91ac9108e92e8684d0cee26 Mon Sep 17 00:00:00 2001 From: dp-web4 Date: Thu, 29 Oct 2026 21:56:13 -0700 Subject: [PATCH 2/3] hub: an invite binds only what the projection folds, once per waiting join (Legion 961/962 review) Legion's review of 962, with the 961 notes it raised in priority: - bind_contact checks bind_conflict before it witnesses, so the hub never answers 200 for a binding the projection drops. - One waiting join per invite: a forwarded copy cannot queue (or auto-admit) while another key's join carries the same invite. 409, nothing written; denying the wrong request frees it. - approve_join re-reads the request under COMMITMENT_LOCK (in bind_queued): still pending, and its key not a member meanwhile, or 409. - submit_join refuses a member id that is a contact's party id in the book or a bound party id: commitments read both by the same string. PRD: expiry is checked when the join is made (a queued join keeps its invite); a binding has no undo yet (operator unbind when a chapter needs it). Tests: 4 new; 9/9 mutants caught (k5b3_mutants.sh). Seat: hub-claude Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3DBMCeF8zJ2RMvPCr3Unc --- hub/docs/PRD_COMMITMENTS.md | 7 +- hub/hub-daemon/src/rest.rs | 6 + .../src/rest/commitments/invites.rs | 125 +++++++++++++++++- 3 files changed, 135 insertions(+), 3 deletions(-) diff --git a/hub/docs/PRD_COMMITMENTS.md b/hub/docs/PRD_COMMITMENTS.md index 62f20e13..895aa9e2 100644 --- a/hub/docs/PRD_COMMITMENTS.md +++ b/hub/docs/PRD_COMMITMENTS.md @@ -325,6 +325,9 @@ K5 adds four things. - If the join stops in between, the invite is spent on a key that only its holder can finish with. That key's page still shows the button and its retry completes the admission. Any other key gets 410. - A contact book that can't be written doesn't undo the join: the binding is on the ledger and the log names the missing address. - **If law queues the join,** `MemberJoinRequested` carries the invite (`invite_party_id`, `invite_digest`), and the operator's admission binds it under the same lock. If the invite stopped being good while the join waited (withdrawn, or its contact erased or bound by someone else), the admission stands and binds nothing. + - **Expiry is checked when the join is made, not at admission.** A join made with a good invite keeps it while it waits. The operator's approval is the gate, and withdrawing the invite is how the operator stops it. + - **One waiting join per invite (Legion, #961).** A forwarded copy can't queue behind the real invitee, because whoever the operator admitted first would take the contact. It gets 409 and writes nothing, and so does an auto-admitting join while that join waits. Denying the wrong request frees the invite. + - **Under the lock, the admission re-reads the request.** It must still be waiting, and its key must not have become a member meanwhile. Otherwise the approval is refused with 409 and writes nothing: a key admitted another way would take the invite by the member path that the join route refuses. - **A member who opens an invite** their own join didn't carry is refused with 409, and nothing is written. A forwarded invite must not hand a contact's jobs and address to whichever member opened it. The rare genuine case, a contact who is already a member under another key, goes through the operator. - Opening an invite again from the browser it bound writes nothing. - **Open:** the admission notice is a listing the operator reads, not a message the carrier sends. Pushing it to the operator needs an operator address, which the hub doesn't hold yet. @@ -336,7 +339,9 @@ K5 adds four things. - an operator relaying in the contact's name; - a carrier that listed a nudge before the join and reports it after. - **The operator still sees who.** A member who joined from an invite sent no name, so the operator's views show the contact's name from the book. Erasing the contact erases the name (C3). -- **One person is never owner and backup.** A member never binds an invite their own join didn't carry (K5b, Legion #961), so the hub never writes a binding that merges an open job's owner and backup. If such a binding reaches the ledger by another path, the projection doesn't fold it (`bind_conflict`). +- **One person is never owner and backup.** A member never binds an invite their own join didn't carry (K5b, Legion #961). The projection doesn't fold a binding that would merge an open job's owner and backup (`bind_conflict`). The hub checks the same thing before it writes one and refuses with 409 (Legion, #962), so a join is never told it bound a contact that the projection then ignores. +- **A joiner can't take a contact's id.** Commitments read a contact and a member by the same string, so the join route refuses, with 409, a member id that is a contact's party id in the book or a bound party id (Legion, #962). The hub mints contact ids, so a real joiner never collides. +- **Open: a binding has no undo.** A wrong binding now moves the contact's jobs, acknowledgements, deliveries and old links, not just an address. An invite bound to a key that law then refuses stays bound to a non-member, and the contact's jobs then name that key. It fails closed: no one else can use the invite, and the key can't act as a member. An operator unbind (or re-mint) is the remedy, built when a chapter needs it. - **Replay and handoff nudges (supervisor, #958).** Replaying a ledger derives the K4b handoff nudges for every open commitment whose latest entry is a handoff. A hub that first runs K4b over a ledger that already has handoffs nudges those parties once, about a move that may be old. The live hub predates K1, so it has none; a hub upgraded mid-pilot should expect this. **Questions asked, and answered (Legion, #959, 10-29 20:17Z):** diff --git a/hub/hub-daemon/src/rest.rs b/hub/hub-daemon/src/rest.rs index 112b19e5..4cec0445 100644 --- a/hub/hub-daemon/src/rest.rs +++ b/hub/hub-daemon/src/rest.rs @@ -7942,6 +7942,12 @@ async fn submit_join( let mut adhoc = MapResolver::new(); adhoc.insert(applicant_lct.clone()); let _redeemed = verify_envelope(&envelope, &s.nonces, &adhoc, Utc::now())?; + { + let state = { let l = s.ledger.lock().await; s.projected(&l) }; + if commitments::names_a_contact(&s, &state, payload.member_lct_id)? { + return Err(ApiError::conflict("that id is already in use here. Start again to make a new key")); + } + } // 2a. Commitments K5b: an invite is evidence that the operator chose this person, verified by // its MAC and date before anything is decided. From here to the binding the commitments lock diff --git a/hub/hub-daemon/src/rest/commitments/invites.rs b/hub/hub-daemon/src/rest/commitments/invites.rs index 8a3e995f..3172378c 100644 --- a/hub/hub-daemon/src/rest/commitments/invites.rs +++ b/hub/hub-daemon/src/rest/commitments/invites.rs @@ -81,6 +81,14 @@ pub(crate) fn invite_standing(s: &RestState, state: &HubState, claim: &InviteCla if state.invites.bound.contains_key(&claim.party_id) { return Err(ApiError::conflict("this contact is already a member")); } + // A forwarded copy must not queue behind the real invitee: whoever the operator admitted first + // would take the contact (Legion, #961). The operator denies the wrong one to free it. + if state.pending_joins.values().any(|j| j.status == hub_lib::state::JoinStatus::Pending + && j.invite_digest.as_deref() == Some(claim.digest.as_str()) && j.member_lct_id != member) + { + return Err(ApiError::conflict( + "this invite is already waiting for your organiser with someone else's join. Ask your organiser")); + } // An erased contact's invite dies with the details it was for (C3). if !load_book(s)?.contacts.contains_key(&claim.party_id) { return Err(gone("this invite's contact is no longer in the hub's book")); @@ -88,9 +96,17 @@ pub(crate) fn invite_standing(s: &RestState, state: &HubState, claim: &InviteCla Ok(InviteStanding::Open) } +/// Does `lct` spell a contact's party id? Commitments read a contact and a member by the same +/// string, so a joiner who picked a contact's id as their own would be taken for that contact +/// (Legion, #962). Contact ids are minted by the hub, so a real joiner never collides. +pub(crate) fn names_a_contact(s: &RestState, state: &HubState, lct: Uuid) -> Result { + let id = lct.to_string(); + Ok(state.invites.bound.contains_key(&id) || load_book(s)?.contacts.contains_key(&id)) +} + /// Witness `ContactBound` and copy the contact's address to the member's entry, so nudges keep /// reaching the same person once they are named as the member. The caller holds `COMMITMENT_LOCK` -/// and has just admitted `member` (or found them already a member). +/// and admits `member` next (Legion, #961). pub(crate) async fn bind_contact(s: &RestState, claim: &InviteClaim, member: Uuid) -> Result { let event = HubEvent::ContactBound { party_id: claim.party_id.clone(), @@ -100,7 +116,14 @@ pub(crate) async fn bind_contact(s: &RestState, claim: &InviteClaim, member: Uui }; { let ledger = s.ledger.lock().await; - s.projected(&ledger).invites.check(&event).map_err(refused)?; + let state = s.projected(&ledger); + state.invites.check(&event).map_err(refused)?; + // The fold drops a binding that would make one person both owner and backup of an open + // job, so the writer refuses it first: a 200 for a binding the projection ignores would be + // false (Legion, #962). + if let Some(why) = hub_lib::commitments::bind_conflict(&state.commitments, &claim.party_id, &member.to_string()) { + return Err(ApiError::conflict(why)); + } } let index = witness_event(s, event).await?; // The binding is on the ledger now, so a book that can't be written does not undo it or fail @@ -132,6 +155,16 @@ pub(crate) async fn bind_queued(s: &RestState, jr: &hub_lib::state::JoinRequest) let (Some(party_id), Some(digest)) = (&jr.invite_party_id, &jr.invite_digest) else { return Ok(None) }; let claim = InviteClaim { party_id: party_id.clone(), digest: digest.clone() }; let state = { let l = s.ledger.lock().await; s.projected(&l) }; + // The request was read before the lock. Under it, the join must still be waiting and its key + // still not a member: a key admitted meanwhile would take the invite by the member path that + // submit_join refuses (Legion, #961). + if state.pending_joins.get(&jr.request_id).map(|j| &j.status) != Some(&hub_lib::state::JoinStatus::Pending) { + return Err(ApiError::conflict(format!("join request {} is no longer waiting", jr.request_id))); + } + if state.members.contains_key(&jr.member_lct_id) { + return Err(ApiError::conflict( + "this key became a member while its join waited, so the invite it carried isn't its to use. Deny the request")); + } match invite_standing(s, &state, &claim, jr.member_lct_id) { Ok(InviteStanding::Open) => bind_contact(s, &claim, jr.member_lct_id).await.map(Some), Ok(_) => Ok(None), @@ -704,6 +737,94 @@ admission: assert_eq!(join(&s, &KeyPair::generate(), Uuid::new_v4(), Some(&token)).await.0, StatusCode::OK); } + #[tokio::test] + async fn a_forwarded_copy_cannot_queue_behind_the_invitee() { + let (_tmp, s, robin) = hub(QUEUE_LAW).await; + let token = invite(&s, &robin).await; + let (alice_kp, alice) = (KeyPair::generate(), Uuid::new_v4()); + let (status, out) = join(&s, &alice_kp, alice, Some(&token)).await; + assert_eq!(status, StatusCode::ACCEPTED, "{out}"); + let first: Uuid = out["request_id"].as_str().unwrap().parse().unwrap(); + // Bob holds a forwarded copy: refused, and nothing queued. + let before = len(&s).await; + let (status, out) = join(&s, &KeyPair::generate(), Uuid::new_v4(), Some(&token)).await; + assert_eq!(status, StatusCode::CONFLICT, "{out}"); + assert!(out["error"].as_str().unwrap().contains("already waiting"), "{out}"); + assert_eq!(len(&s).await, before); + // Alice asking again is her own request, not a second one. + assert_eq!(join(&s, &alice_kp, alice, Some(&token)).await.1["request_id"], serde_json::json!(first)); + // The operator denies the wrong one, and the invite is free again. + crate::rest::deny_join(&s, first, s.sovereign_lct_id, None).await.unwrap(); + assert_eq!(join(&s, &KeyPair::generate(), Uuid::new_v4(), Some(&token)).await.0, StatusCode::ACCEPTED); + } + + #[tokio::test] + async fn an_invited_join_rechecks_its_request_under_the_lock() { + let (_tmp, s, robin) = hub(QUEUE_LAW).await; + let token = invite(&s, &robin).await; + let (kp, lct) = (KeyPair::generate(), Uuid::new_v4()); + let (status, out) = join(&s, &kp, lct, Some(&token)).await; + assert_eq!(status, StatusCode::ACCEPTED, "{out}"); + let request: Uuid = out["request_id"].as_str().unwrap().parse().unwrap(); + let read_early = { let l = s.ledger.lock().await; s.projected(&l) }.pending_joins[&request].clone(); + + // Resolved after approve_join read it: the binding is refused, not written. + crate::rest::deny_join(&s, request, s.sovereign_lct_id, None).await.unwrap(); + let before = len(&s).await; + let refused = { let _serial = COMMITMENT_LOCK.lock().await; bind_queued(&s, &read_early).await.unwrap_err() }; + assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message); + assert_eq!(len(&s).await, before); + + // Queued again, then admitted another way while it waits: the member path that submit_join + // refuses is refused here too, and the operator's approval writes nothing. + let (kp, lct) = (KeyPair::generate(), Uuid::new_v4()); + let (status, out) = join(&s, &kp, lct, Some(&token)).await; + assert_eq!(status, StatusCode::ACCEPTED, "{out}"); + let request = out["request_id"].as_str().unwrap().parse().unwrap(); + let anchor = { let g = s.law.read().await; hub_lib::law::resolve_anchor_verdict(g.as_ref(), None, None) }; + crate::rest::admit_member(&s, lct, &kp.verifying_key().to_hex(), None, &anchor).await.unwrap(); + let before = len(&s).await; + let refused = crate::rest::approve_join(&s, request, s.sovereign_lct_id).await.unwrap_err(); + assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message); + assert_eq!(len(&s).await, before); + assert!(bindings(&s).await.is_empty()); + } + + #[tokio::test] + async fn a_joiner_cannot_take_a_contacts_id() { + let (_tmp, s, robin) = hub(PILOT_LAW).await; + let before = len(&s).await; + let (status, out) = join(&s, &KeyPair::generate(), robin.parse().unwrap(), None).await; + assert_eq!(status, StatusCode::CONFLICT, "{out}"); + assert_eq!(len(&s).await, before); + // Once bound and erased from the book, the id is still the member's alias. + let token = invite(&s, &robin).await; + assert_eq!(join(&s, &KeyPair::generate(), Uuid::new_v4(), Some(&token)).await.0, StatusCode::OK); + erase_contact(&s, &robin).await.unwrap(); + let before = len(&s).await; + let (status, out) = join(&s, &KeyPair::generate(), robin.parse().unwrap(), None).await; + assert_eq!(status, StatusCode::CONFLICT, "{out}"); + assert_eq!(len(&s).await, before); + } + + #[tokio::test] + async fn the_hub_refuses_a_binding_its_projection_would_drop() { + let (_tmp, s, robin) = hub(PILOT_LAW).await; + let (sam_kp, role) = (KeyPair::generate(), leads_role(&s).await); + let sam = existing_member(&s, &sam_kp).await; + open_for(&s, PartyRef { party_id: robin.clone(), kind: PartyKind::Contact }, + PartyRef { party_id: sam.to_string(), kind: PartyKind::Member }, role).await; + let token = invite(&s, &robin).await; + let before = len(&s).await; + let refused = { + let _serial = COMMITMENT_LOCK.lock().await; + bind_contact(&s, &verify_invite(&s, &token).unwrap(), sam).await.unwrap_err() + }; + assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message); + assert!(refused.message.contains("owner and backup"), "{}", refused.message); + assert_eq!(len(&s).await, before); + } + #[tokio::test] async fn a_join_stopped_between_binding_and_admission_fails_closed() { let (_tmp, s, robin) = hub(PILOT_LAW).await; From 11371eefa2c1ee97bad3b192cd5cee21901c6f85 Mon Sep 17 00:00:00 2001 From: dp-web4 Date: Thu, 29 Oct 2026 22:50:24 -0700 Subject: [PATCH 3/3] hub: test the binding conflict through the join route (supervisor 962 hold) A member who backs up a contact's open job, is removed, and joins again holding that contact's invite: binding it would make one person owner and backup, which the fold drops. The join route answers 409 with the conflict, writes neither the binding nor the admission, and the contact's own key still joins afterwards. Seat: hub-claude Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3DBMCeF8zJ2RMvPCr3Unc --- .../src/rest/commitments/invites.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/hub/hub-daemon/src/rest/commitments/invites.rs b/hub/hub-daemon/src/rest/commitments/invites.rs index 3172378c..ae73b355 100644 --- a/hub/hub-daemon/src/rest/commitments/invites.rs +++ b/hub/hub-daemon/src/rest/commitments/invites.rs @@ -825,6 +825,29 @@ admission: assert_eq!(len(&s).await, before); } + #[tokio::test] + async fn a_removed_backup_rejoining_on_the_owners_invite_is_refused_at_the_join() { + // The path through the join route (supervisor, #962): Sam backs up Robin's job, is removed + // while it is open, and joins again holding Robin's invite. Binding it would make Sam both + // owner and backup; the fold would drop it, so the join is refused and writes nothing. + let (_tmp, s, robin) = hub(PILOT_LAW).await; + let (sam_kp, role) = (KeyPair::generate(), leads_role(&s).await); + let sam = existing_member(&s, &sam_kp).await; + open_for(&s, PartyRef { party_id: robin.clone(), kind: PartyKind::Contact }, + PartyRef { party_id: sam.to_string(), kind: PartyKind::Member }, role).await; + crate::rest::channel_e2e_tests::witness_for_test(&s, HubEvent::MemberRemoved { + member_lct_id: sam, removed_by: s.sovereign_lct_id, reason: None }).await; + let token = invite(&s, &robin).await; + let before = len(&s).await; + let (status, out) = join(&s, &sam_kp, sam, Some(&token)).await; + assert_eq!(status, StatusCode::CONFLICT, "{out}"); + assert!(out["error"].as_str().unwrap().contains("owner and backup"), "{out}"); + assert_eq!(len(&s).await, before, "no binding and no admission"); + assert!(bindings(&s).await.is_empty()); + // Robin, with Robin's own key, still can. + assert_eq!(join(&s, &KeyPair::generate(), Uuid::new_v4(), Some(&token)).await.0, StatusCode::OK); + } + #[tokio::test] async fn a_join_stopped_between_binding_and_admission_fails_closed() { let (_tmp, s, robin) = hub(PILOT_LAW).await;