the proposal board: a Space's pinned playlist takes signed proposals, and an accept republishes it

This commit is contained in:
KeelrLeek committed 2026-09-02 16:33:38 -05:00
1 parent 8b511b20e7
commit bf81792c49
15 files changed
+3207 -10

No files matched your search

+1
View File
@@ -20,6 +20,7 @@ pub mod friend;
pub mod playlist;
pub mod inbox;
pub mod space;
pub mod proposals;
pub const SCHEMA_VERSION: u32 = 1;
pub const SIGN_DOMAIN: &str = "soulstream.profile/v1";
+613
View File
@@ -0,0 +1,613 @@
//! 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<u8> {
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<u8>, // owner
}
#[derive(Serialize)]
struct CheckpointSigBody<'a> {
epoch: u64,
contributors: &'a [[u8; 32]],
}
pub fn checkpoint_sig_body(c: &AuthorityCheckpoint) -> Vec<u8> {
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<ProposalOp>,
pub note: String,
pub created_at: u64,
pub sig: Vec<u8>,
}
#[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<u8> {
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<u8>, // 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<u8> {
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<AuthorityCheckpoint>,
pub proposals: Vec<Proposal>,
pub decisions: Vec<Decision>,
}
pub fn make_params(owner: &[u8; 32], uuid: &[u8; 16]) -> Vec<u8> {
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 || &params[..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<BoardState> = Vec::new();
for bytes in candidates {
if bytes.is_empty() || bytes.len() > MAX_STATE_BYTES {
continue;
}
let Ok(st) = bincode::deserialize::<BoardState>(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<Decision> = 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<Proposal> = 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<Proposal>> = BTreeMap::new();
for p in all {
per_author.entry(p.author).or_default().push(p);
}
let mut proposals: Vec<Proposal> = 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<Vec<f3::EntryV1>, 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<u8> {
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<AuthorityCheckpoint>, proposals: Vec<Proposal>, decisions: Vec<Decision>) -> Vec<u8> {
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<usize>, rest: &mut Vec<usize>, 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<Proposal> = (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(&current, &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(&current, &[8u8; 32], &p), Err(ApplyError::StaleBase { .. })));
p.ops = vec![ProposalOp::Remove { entry_id: [0xAB; 16] }];
assert!(matches!(apply_proposal(&current, &hash, &p), Err(ApplyError::RemoveMissing(_))));
}
}