//! The collaborative-playlist proposal board (F7 → the app). One board per //! shared playlist, keyed by (owner, playlist uuid); a MULTI-WRITER contract //! in the inbox mold — anyone may offer, `merge_board` is the bouncer. //! //! Three record families: //! - **AuthorityCheckpoint** — the owner's signed contributor roster with a //! monotonic epoch, co-located with the board. Highest (epoch, hash) wins; //! an epoch that drops an author IS revocation, and that author's pending //! proposals leave the board deterministically. //! - **Proposal** — immutable, contributor-signed, bound to an EXACT base //! revision (the F3 content hash) and valid only while its author is on //! the CURRENT roster. Bounded ops, bounded size, per-author and total //! caps (newest per author kept). //! - **Decision** — owner-signed accept/reject with a monotonic sequence: //! the audit trail. A decided proposal leaves the pending set and no stale //! replica can bring it back. The canonical playlist never lives here — an //! accepted proposal becomes an ordinary owner-signed revision + pointer //! bump outside. //! //! BYTE-COMPATIBILITY IS THE CONTRACT: these shapes are bincode-identical to //! the research crate the bundled `proposal_contract.wasm` was proven with //! (research/freenet-f7); the entry types are `playlist::EntryV1` itself. use serde::{Deserialize, Serialize}; use crate::playlist as f3; pub const SCHEMA_VERSION: u32 = 1; pub const PARAMS_FAMILY: &[u8] = b"soulstream.playlist-props.v1"; /// Proposals and decisions sign under the playlist domain — the delegate /// allowlist has no separate board entry, and a board IS playlist business. pub const SIGN_DOMAIN: &str = f3::SIGN_DOMAIN; pub const MAX_STATE_BYTES: usize = 1024 * 1024; pub const MAX_CONTRIBUTORS: usize = 64; pub const MAX_OPS_PER_PROPOSAL: usize = 32; pub const MAX_PROPOSAL_BYTES: usize = 64 * 1024; pub const MAX_PROPOSALS: usize = 128; pub const PER_AUTHOR_PENDING_CAP: usize = 8; pub const MAX_DECISIONS: usize = 256; pub const MAX_REASON_BYTES: usize = 128; fn verify(public: &[u8; 32], body: &[u8], sig: &[u8]) -> bool { crate::verify_sig_in(SIGN_DOMAIN, public, &sign_payload(body), sig) } /// What the delegate is handed for any board record: blake3 of its sig body. pub fn sign_payload(body: &[u8]) -> Vec { blake3::hash(body).as_bytes().to_vec() } // ---------------------------------------------------------------- records #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthorityCheckpoint { pub epoch: u64, pub contributors: Vec<[u8; 32]>, pub sig: Vec, // owner } #[derive(Serialize)] struct CheckpointSigBody<'a> { epoch: u64, contributors: &'a [[u8; 32]], } pub fn checkpoint_sig_body(c: &AuthorityCheckpoint) -> Vec { bincode::serialize(&CheckpointSigBody { epoch: c.epoch, contributors: &c.contributors }).expect("serializes") } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ProposalOp { Add { position: u32, entry: f3::EntryV1 }, Remove { entry_id: [u8; 16] }, Move { entry_id: [u8; 16], to_position: u32 }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Proposal { pub proposal_id: [u8; 16], pub author: [u8; 32], pub authority_epoch: u64, pub base_revision_hash: [u8; 32], pub ops: Vec, pub note: String, pub created_at: u64, pub sig: Vec, } #[derive(Serialize)] struct ProposalSigBody<'a> { proposal_id: &'a [u8; 16], author: &'a [u8; 32], authority_epoch: u64, base_revision_hash: &'a [u8; 32], ops: &'a [ProposalOp], note: &'a str, created_at: u64, } pub fn proposal_sig_body(p: &Proposal) -> Vec { bincode::serialize(&ProposalSigBody { proposal_id: &p.proposal_id, author: &p.author, authority_epoch: p.authority_epoch, base_revision_hash: &p.base_revision_hash, ops: &p.ops, note: &p.note, created_at: p.created_at, }) .expect("serializes") } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Decision { pub proposal_id: [u8; 16], pub accept: bool, pub seq: u64, pub reason: String, /// When accepted: the revision the owner published applying it. pub result_revision_hash: Option<[u8; 32]>, pub sig: Vec, // owner } #[derive(Serialize)] struct DecisionSigBody<'a> { proposal_id: &'a [u8; 16], accept: bool, seq: u64, reason: &'a str, result_revision_hash: &'a Option<[u8; 32]>, } pub fn decision_sig_body(d: &Decision) -> Vec { bincode::serialize(&DecisionSigBody { proposal_id: &d.proposal_id, accept: d.accept, seq: d.seq, reason: &d.reason, result_revision_hash: &d.result_revision_hash, }) .expect("serializes") } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct BoardState { pub schema_version: u32, pub owner: [u8; 32], pub playlist_uuid: [u8; 16], pub authority: Option, pub proposals: Vec, pub decisions: Vec, } pub fn make_params(owner: &[u8; 32], uuid: &[u8; 16]) -> Vec { let mut p = Vec::with_capacity(PARAMS_FAMILY.len() + 48); p.extend_from_slice(PARAMS_FAMILY); p.extend_from_slice(owner); p.extend_from_slice(uuid); p } pub fn params_parts(params: &[u8]) -> Option<([u8; 32], [u8; 16])> { let t = PARAMS_FAMILY.len(); if params.len() != t + 48 || ¶ms[..t] != PARAMS_FAMILY { return None; } Some((params[t..t + 32].try_into().unwrap(), params[t + 32..t + 48].try_into().unwrap())) } // ---------------------------------------------------------------- merge pub fn checkpoint_valid(owner: &[u8; 32], c: &AuthorityCheckpoint) -> bool { c.contributors.len() <= MAX_CONTRIBUTORS && verify(owner, &checkpoint_sig_body(c), &c.sig) } pub fn proposal_valid(p: &Proposal) -> bool { if p.ops.is_empty() || p.ops.len() > MAX_OPS_PER_PROPOSAL { return false; } if p.note.len() > f3::MAX_DESC_BYTES { return false; } if bincode::serialized_size(p).map(|n| n as usize > MAX_PROPOSAL_BYTES).unwrap_or(true) { return false; } for op in &p.ops { if let ProposalOp::Add { entry, .. } = op { for s in [&entry.recording.title, &entry.recording.album] { if s.len() > f3::MAX_FIELD_BYTES { return false; } } if entry.note.as_deref().map(str::len).unwrap_or(0) > f3::MAX_NOTE_BYTES { return false; } if entry.slsk_hint.as_deref().map(str::len).unwrap_or(0) > f3::MAX_FIELD_BYTES { return false; } } } verify(&p.author, &proposal_sig_body(p), &p.sig) } pub fn decision_valid(owner: &[u8; 32], d: &Decision) -> bool { d.reason.len() <= MAX_REASON_BYTES && verify(owner, &decision_sig_body(d), &d.sig) } /// Deterministic multi-writer merge — the bouncer. Every delivery order of /// the same candidates yields the same bytes. pub fn merge_board(owner: &[u8; 32], uuid: &[u8; 16], candidates: &[&[u8]]) -> BoardState { use std::collections::{BTreeMap, BTreeSet}; let mut parsed: Vec = Vec::new(); for bytes in candidates { if bytes.is_empty() || bytes.len() > MAX_STATE_BYTES { continue; } let Ok(st) = bincode::deserialize::(bytes) else { continue }; if st.schema_version != SCHEMA_VERSION || &st.owner != owner || &st.playlist_uuid != uuid { continue; } parsed.push(st); } // 1. The winning authority checkpoint: highest (epoch, blake3) valid one. let mut best_auth: Option<(u64, [u8; 32], AuthorityCheckpoint)> = None; for st in &parsed { if let Some(c) = &st.authority { if checkpoint_valid(owner, c) { let rank = (c.epoch, *blake3::hash(&checkpoint_sig_body(c)).as_bytes()); let better = match &best_auth { None => true, Some((e, h, _)) => rank > (*e, *h), }; if better { best_auth = Some((rank.0, rank.1, c.clone())); } } } } let authority = best_auth.map(|(_, _, c)| c); // 2. Decisions: union by proposal id, highest (seq, hash) per proposal; // the oldest age out past the cap — the window is honest. let mut dec_by_id: BTreeMap<[u8; 16], (u64, [u8; 32], Decision)> = BTreeMap::new(); for st in &parsed { for d in &st.decisions { if !decision_valid(owner, d) { continue; } let rank = (d.seq, *blake3::hash(&decision_sig_body(d)).as_bytes()); match dec_by_id.get(&d.proposal_id) { Some((s, h, _)) if (*s, *h) >= rank => {} _ => { dec_by_id.insert(d.proposal_id, (rank.0, rank.1, d.clone())); } } } } let mut decisions: Vec = dec_by_id.into_values().map(|(_, _, d)| d).collect(); decisions.sort_by_key(|d| (d.seq, d.proposal_id)); if decisions.len() > MAX_DECISIONS { let cut = decisions.len() - MAX_DECISIONS; decisions.drain(0..cut); } // 3. Proposals: union by id (a collision keeps the smaller hash), gated // on the CURRENT roster, undecided only, then the caps (newest per // author). let decided: BTreeSet<[u8; 16]> = decisions.iter().map(|d| d.proposal_id).collect(); let roster: Vec<[u8; 32]> = authority.as_ref().map(|a| a.contributors.clone()).unwrap_or_default(); let mut prop_by_id: BTreeMap<[u8; 16], ([u8; 32], Proposal)> = BTreeMap::new(); for st in &parsed { for p in &st.proposals { if decided.contains(&p.proposal_id) { continue; } if !roster.contains(&p.author) { continue; } if !proposal_valid(p) { continue; } let h = *blake3::hash(&bincode::serialize(p).unwrap()).as_bytes(); match prop_by_id.get(&p.proposal_id) { Some((eh, _)) if eh <= &h => {} _ => { prop_by_id.insert(p.proposal_id, (h, p.clone())); } } } } let mut all: Vec = prop_by_id.into_values().map(|(_, p)| p).collect(); all.sort_by_key(|p| (p.created_at, p.proposal_id)); let mut per_author: BTreeMap<[u8; 32], Vec> = BTreeMap::new(); for p in all { per_author.entry(p.author).or_default().push(p); } let mut proposals: Vec = Vec::new(); for (_, mut list) in per_author { let keep_from = list.len().saturating_sub(PER_AUTHOR_PENDING_CAP); proposals.extend(list.drain(keep_from..)); } proposals.sort_by_key(|p| (p.created_at, p.proposal_id)); proposals.truncate(MAX_PROPOSALS); BoardState { schema_version: SCHEMA_VERSION, owner: *owner, playlist_uuid: *uuid, authority, proposals, decisions } } // ---------------------------------------------------------------- apply #[derive(Debug, PartialEq, Eq)] pub enum ApplyError { StaleBase { expected: [u8; 32], got: [u8; 32] }, RemoveMissing([u8; 16]), MoveMissing([u8; 16]), } impl core::fmt::Display for ApplyError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { ApplyError::StaleBase { .. } => write!( f, "the proposal targets a revision that is no longer current; ask for a re-submit against the current one" ), ApplyError::RemoveMissing(_) => write!(f, "a remove targets an entry not in the base revision"), ApplyError::MoveMissing(_) => write!(f, "a move targets an entry not in the base revision"), } } } /// Pure application of a proposal's ops to the CURRENT canonical manifest. /// A stale base is refused outright: exact-base binding is the whole /// concurrency model — the owner rebases by asking for a re-submit, never /// by guessing intent. pub fn apply_proposal( current: &f3::ManifestV1, current_bytes_hash: &[u8; 32], p: &Proposal, ) -> Result, ApplyError> { if &p.base_revision_hash != current_bytes_hash { return Err(ApplyError::StaleBase { expected: *current_bytes_hash, got: p.base_revision_hash }); } let mut entries = current.entries.clone(); for op in &p.ops { match op { ProposalOp::Add { position, entry } => { let at = (*position as usize).min(entries.len()); entries.insert(at, entry.clone()); } ProposalOp::Remove { entry_id } => { let Some(i) = entries.iter().position(|e| &e.entry_id == entry_id) else { return Err(ApplyError::RemoveMissing(*entry_id)); }; entries.remove(i); } ProposalOp::Move { entry_id, to_position } => { let Some(i) = entries.iter().position(|e| &e.entry_id == entry_id) else { return Err(ApplyError::MoveMissing(*entry_id)); }; let e = entries.remove(i); let at = (*to_position as usize).min(entries.len()); entries.insert(at, e); } } } for (i, e) in entries.iter_mut().enumerate() { e.position = i as u32; } Ok(entries) } #[cfg(test)] mod tests { use super::*; use ed25519_dalek::{Signer, SigningKey}; fn kp(seed: u8) -> SigningKey { SigningKey::from_bytes(&[seed; 32]) } fn sign(key: &SigningKey, body: &[u8]) -> Vec { key.sign(&crate::signable(SIGN_DOMAIN, &sign_payload(body))).to_bytes().to_vec() } fn entry(i: u32, title: &str) -> f3::EntryV1 { let mut id = [0u8; 16]; id[..4].copy_from_slice(&i.to_le_bytes()); id[15] = 0xEE; f3::EntryV1 { entry_id: id, position: i, recording: f3::RecordingRef { musicbrainz_recording_id: None, isrcs: vec![], deezer_id: Some(format!("dz{i}")), apple_music_id: None, title: title.into(), artists: vec!["Artist".into()], album: "Album".into(), duration_ms: Some(180_000), release_year: Some(2000), }, note: None, slsk_hint: None, } } fn checkpoint(owner: &SigningKey, epoch: u64, contributors: &[&SigningKey]) -> AuthorityCheckpoint { let mut c = AuthorityCheckpoint { epoch, contributors: contributors.iter().map(|k| k.verifying_key().to_bytes()).collect(), sig: vec![], }; c.sig = sign(owner, &checkpoint_sig_body(&c)); c } fn proposal(author: &SigningKey, id: u8, epoch: u64, base: [u8; 32], created: u64) -> Proposal { let mut p = Proposal { proposal_id: [id; 16], author: author.verifying_key().to_bytes(), authority_epoch: epoch, base_revision_hash: base, ops: vec![ProposalOp::Add { position: 0, entry: entry(90 + id as u32, "Proposed") }], note: "please add".into(), created_at: created, sig: vec![], }; p.sig = sign(author, &proposal_sig_body(&p)); p } fn decision(owner: &SigningKey, pid: [u8; 16], accept: bool, seq: u64) -> Decision { let mut d = Decision { proposal_id: pid, accept, seq, reason: if accept { "applied".into() } else { "no".into() }, result_revision_hash: accept.then_some([7u8; 32]), sig: vec![], }; d.sig = sign(owner, &decision_sig_body(&d)); d } fn state(owner: &SigningKey, auth: Option, proposals: Vec, decisions: Vec) -> Vec { bincode::serialize(&BoardState { schema_version: SCHEMA_VERSION, owner: owner.verifying_key().to_bytes(), playlist_uuid: [7u8; 16], authority: auth, proposals, decisions, }) .unwrap() } fn permute(items: &[usize], f: &mut dyn FnMut(&[usize])) { fn go(cur: &mut Vec, rest: &mut Vec, f: &mut dyn FnMut(&[usize])) { if rest.is_empty() { f(cur); return; } for i in 0..rest.len() { let x = rest.remove(i); cur.push(x); go(cur, rest, f); cur.pop(); rest.insert(i, x); } } go(&mut Vec::new(), &mut items.to_vec(), f); } #[test] fn merge_converges_over_permutations_and_is_idempotent() { let owner = kp(1); let opub = owner.verifying_key().to_bytes(); let b = kp(2); let c = kp(3); let base = [5u8; 32]; let cp1 = checkpoint(&owner, 1, &[&b, &c]); let cp2 = checkpoint(&owner, 2, &[&b]); let p_b = proposal(&b, 10, 1, base, 100); let p_c = proposal(&c, 11, 1, base, 101); let d_b = decision(&owner, [10u8; 16], true, 1); let cands = vec![ state(&owner, Some(cp1.clone()), vec![p_b.clone(), p_c.clone()], vec![]), state(&owner, Some(cp2.clone()), vec![], vec![]), state(&owner, Some(cp1.clone()), vec![p_c.clone()], vec![d_b.clone()]), state(&owner, None, vec![p_b.clone()], vec![d_b.clone()]), ]; let uuid = [7u8; 16]; let reference = { let refs: Vec<&[u8]> = cands.iter().map(|v| v.as_slice()).collect(); merge_board(&opub, &uuid, &refs) }; permute(&[0, 1, 2, 3], &mut |order| { let refs: Vec<&[u8]> = order.iter().map(|&i| cands[i].as_slice()).collect(); assert_eq!(merge_board(&opub, &uuid, &refs), reference, "diverged for {order:?}"); }); assert_eq!(reference.authority.as_ref().unwrap().epoch, 2); assert!(reference.proposals.is_empty()); assert_eq!(reference.decisions.len(), 1); let rb = bincode::serialize(&reference).unwrap(); assert_eq!(merge_board(&opub, &uuid, &[&rb, &rb]), reference); } #[test] fn revoked_author_drops_and_never_re_enters() { let owner = kp(1); let opub = owner.verifying_key().to_bytes(); let b = kp(2); let c = kp(3); let uuid = [7u8; 16]; let cp1 = checkpoint(&owner, 1, &[&b, &c]); let cp2 = checkpoint(&owner, 2, &[&b]); let p_c_old = proposal(&c, 20, 1, [5u8; 32], 100); let with_c = state(&owner, Some(cp1), vec![p_c_old.clone()], vec![]); let revoked = state(&owner, Some(cp2), vec![], vec![]); let m = merge_board(&opub, &uuid, &[&with_c, &revoked]); assert!(m.proposals.is_empty()); let mb = bincode::serialize(&m).unwrap(); let offer = state(&owner, None, vec![proposal(&c, 21, 2, [5u8; 32], 200)], vec![]); assert!(merge_board(&opub, &uuid, &[&mb, &offer]).proposals.is_empty()); let replay = state(&owner, Some(checkpoint(&owner, 1, &[&b, &c])), vec![p_c_old], vec![]); let m3 = merge_board(&opub, &uuid, &[&mb, &replay]); assert_eq!(m3.authority.as_ref().unwrap().epoch, 2); assert!(m3.proposals.is_empty()); } #[test] fn forged_oversized_and_non_owner_decisions_never_enter() { let owner = kp(1); let opub = owner.verifying_key().to_bytes(); let b = kp(2); let mallory = kp(66); let uuid = [7u8; 16]; let cp = checkpoint(&owner, 1, &[&b]); let mut forged = proposal(&mallory, 30, 1, [5u8; 32], 100); forged.author = b.verifying_key().to_bytes(); let mut big = Proposal { proposal_id: [31u8; 16], author: b.verifying_key().to_bytes(), authority_epoch: 1, base_revision_hash: [5u8; 32], ops: (0..(MAX_OPS_PER_PROPOSAL as u32 + 1)).map(|i| ProposalOp::Add { position: i, entry: entry(i, "x") }).collect(), note: String::new(), created_at: 101, sig: vec![], }; big.sig = sign(&b, &proposal_sig_body(&big)); let good = proposal(&b, 32, 1, [5u8; 32], 102); let fake_dec = decision(&mallory, [32u8; 16], true, 1); let st = state(&owner, Some(cp), vec![forged, big, good], vec![fake_dec]); let m = merge_board(&opub, &uuid, &[&st]); assert_eq!(m.proposals.len(), 1); assert_eq!(m.proposals[0].proposal_id, [32u8; 16]); assert!(m.decisions.is_empty()); } #[test] fn flood_and_sybil_bounded_newest_kept() { let owner = kp(1); let opub = owner.verifying_key().to_bytes(); let b = kp(2); let uuid = [7u8; 16]; let cp = checkpoint(&owner, 1, &[&b]); let mut props: Vec = (0..20).map(|i| proposal(&b, 100 + i, 1, [5u8; 32], 1000 + i as u64)).collect(); for s in 0..50u8 { props.push(proposal(&kp(150 + s), 200 + s, 1, [5u8; 32], 5000)); } let st = state(&owner, Some(cp), props, vec![]); let m = merge_board(&opub, &uuid, &[&st]); assert_eq!(m.proposals.len(), PER_AUTHOR_PENDING_CAP); assert!(m.proposals.iter().all(|p| p.created_at >= 1000 + 12)); } #[test] fn apply_composes_and_refuses_stale_or_missing() { let b = kp(2); let current = f3::ManifestV1 { schema_version: f3::SCHEMA_VERSION, owner: [1u8; 32], playlist_uuid: [7u8; 16], revision_seq: 1, prev_revision_hash: None, name: "n".into(), description: String::new(), artwork: None, entries: vec![entry(0, "First"), entry(1, "Middle"), entry(2, "Last")], sig: vec![], }; let hash = [9u8; 32]; let mut p = Proposal { proposal_id: [1u8; 16], author: b.verifying_key().to_bytes(), authority_epoch: 1, base_revision_hash: hash, ops: vec![ ProposalOp::Add { position: 99, entry: entry(50, "Appended") }, ProposalOp::Move { entry_id: entry(1, "").entry_id, to_position: 0 }, ProposalOp::Remove { entry_id: entry(0, "").entry_id }, ], note: String::new(), created_at: 1, sig: vec![], }; p.sig = sign(&b, &proposal_sig_body(&p)); let out = apply_proposal(¤t, &hash, &p).unwrap(); let titles: Vec<&str> = out.iter().map(|e| e.recording.title.as_str()).collect(); assert_eq!(titles, ["Middle", "Last", "Appended"]); assert!(out.iter().enumerate().all(|(i, e)| e.position == i as u32)); assert!(matches!(apply_proposal(¤t, &[8u8; 32], &p), Err(ApplyError::StaleBase { .. }))); p.ops = vec![ProposalOp::Remove { entry_id: [0xAB; 16] }]; assert!(matches!(apply_proposal(¤t, &hash, &p), Err(ApplyError::RemoveMissing(_)))); } }