From 08e1acb197ed61ee9aa6a1dcf0fb58c96d66ee38 Mon Sep 17 00:00:00 2001 From: Lewis Date: Sat, 5 Sep 2026 10:50:54 +0300 Subject: [PATCH] knot2/knot-cobs: store contrib policy on registration Lewis: May this revision serve well! --- knot2/crates/knot-cobs/Cargo.toml | 1 + knot2/crates/knot-cobs/src/blocklist.rs | 4 +- knot2/crates/knot-cobs/src/collaborators.rs | 4 +- knot2/crates/knot-cobs/src/grant.rs | 89 ++++----- knot2/crates/knot-cobs/src/lib.rs | 15 +- knot2/crates/knot-cobs/src/members.rs | 4 +- knot2/crates/knot-cobs/src/registry.rs | 196 ++++++++++++++++++-- knot2/crates/knot-cobs/tests/cobs.rs | 47 +++-- knot2/crates/knot-cobs/tests/common/mod.rs | 1 + knot2/crates/knot-cobs/tests/invariants.rs | 32 ++-- knot2/crates/knot-migrate/src/emit.rs | 30 ++- 11 files changed, 317 insertions(+), 106 deletions(-) diff --git a/knot2/crates/knot-cobs/Cargo.toml b/knot2/crates/knot-cobs/Cargo.toml index 9a20849d8..e380f0803 100644 --- a/knot2/crates/knot-cobs/Cargo.toml +++ b/knot2/crates/knot-cobs/Cargo.toml @@ -10,6 +10,7 @@ knot-types = { workspace = true } knot-cob = { workspace = true } knot-runtime = { workspace = true } serde = { workspace = true } +serde_ipld_dagcbor = { workspace = true } thiserror = { workspace = true } [dev-dependencies] diff --git a/knot2/crates/knot-cobs/src/blocklist.rs b/knot2/crates/knot-cobs/src/blocklist.rs index 61190c045..56a4effba 100644 --- a/knot2/crates/knot-cobs/src/blocklist.rs +++ b/knot2/crates/knot-cobs/src/blocklist.rs @@ -1,6 +1,6 @@ -use crate::grant::roster_cob; +use crate::grant::roll_cob; -roster_cob! { +roll_cob! { change = BlocklistChange, ops = { Add(Grant), Remove(Removal) }, cob = BlocklistCob, diff --git a/knot2/crates/knot-cobs/src/collaborators.rs b/knot2/crates/knot-cobs/src/collaborators.rs index fc7ebbdbd..6af709c71 100644 --- a/knot2/crates/knot-cobs/src/collaborators.rs +++ b/knot2/crates/knot-cobs/src/collaborators.rs @@ -1,6 +1,6 @@ -use crate::grant::roster_cob; +use crate::grant::roll_cob; -roster_cob! { +roll_cob! { change = CollaboratorsChange, ops = { Add(Grant), Invite(Invite), Accept(Accept), Remove(Removal) }, cob = CollaboratorsCob, diff --git a/knot2/crates/knot-cobs/src/grant.rs b/knot2/crates/knot-cobs/src/grant.rs index 9adb19c0a..c49d4c441 100644 --- a/knot2/crates/knot-cobs/src/grant.rs +++ b/knot2/crates/knot-cobs/src/grant.rs @@ -49,8 +49,8 @@ pub struct Entry { } impl Entry { - pub fn standing(&self) -> Standing { - Standing::of(self.offer, self.created_at, self.verified_at) + pub fn state(&self) -> EntryState { + EntryState::of(self.offer, self.created_at, self.verified_at) } pub fn effective_since(&self) -> Option { @@ -71,11 +71,11 @@ impl EffectiveSince { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Roster { +pub struct Roll { entries: BTreeMap, } -impl Roster { +impl Roll { pub fn empty() -> Self { Self { entries: BTreeMap::new(), @@ -117,8 +117,8 @@ impl Roster { } } - pub fn standing(&self, subject: &AccountDid) -> Option { - self.entries.get(subject).map(Entry::standing) + pub fn state(&self, subject: &AccountDid) -> Option { + self.entries.get(subject).map(Entry::state) } pub fn get(&self, subject: &AccountDid) -> Option<&Entry> { @@ -139,13 +139,13 @@ impl Roster { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Standing { +pub enum EntryState { Invited, Accepted { verified_at: UnixSeconds }, Grandfathered { granted_at: UnixSeconds }, } -impl Standing { +impl EntryState { pub fn of(offer: Offer, created_at: UnixSeconds, verified_at: Option) -> Self { match (verified_at, offer) { (Some(verified_at), _) => Self::Accepted { verified_at }, @@ -201,7 +201,7 @@ grant_change! { Removal(removal) => Effect::Revoke(&removal.subject), } -macro_rules! roster_cob { +macro_rules! roll_cob { ( change = $change:ident, ops = { $($variant:ident($payload:ident)),+ $(,)? }, @@ -227,7 +227,9 @@ macro_rules! roster_cob { const TYPE: &'static str = $type_name; } - pub type $state = $crate::grant::Roster; + impl ::knot_cob::KnotAuthored for $change {} + + pub type $state = $crate::grant::Roll; pub struct $cob; @@ -238,7 +240,7 @@ macro_rules! roster_cob { const HISTORY: ::knot_cob::HistoryModel = ::knot_cob::HistoryModel::Linear; fn initial() -> Self::State { - $crate::grant::Roster::empty() + $crate::grant::Roll::empty() } fn apply( @@ -274,18 +276,18 @@ macro_rules! roster_cob { assert_eq!( $change::decode(&change.encode().unwrap()).unwrap(), change, - "a change must decode from dag-cbor as itself, or replay folds a different roster" + "Change must decode from dag-cbor as itself, or its replay folds another roll" ); assert_eq!( $cob::apply($cob::initial(), change, &actor).len(), 1, - "the cob's fold has to write the change's subject into the roster" + "Cob's fold has to write change's subject into its roll" ); } }; } -pub(crate) use roster_cob; +pub(crate) use roll_cob; #[cfg(test)] mod tests { @@ -320,8 +322,8 @@ mod tests { } } - fn fold(changes: Vec) -> Roster { - changes.iter().fold(Roster::empty(), Roster::apply_change) + fn fold(changes: Vec) -> Roll { + changes.iter().fold(Roll::empty(), Roll::apply_change) } fn entry(offer: Offer, added_by: &str, at: i64, verified: Option) -> Option { @@ -333,19 +335,19 @@ mod tests { }) } - fn grandfathered(at: i64) -> Standing { - Standing::Grandfathered { + fn grandfathered(at: i64) -> EntryState { + EntryState::Grandfathered { granted_at: UnixSeconds::new(at), } } - fn accepted(at: i64) -> Standing { - Standing::Accepted { + fn accepted(at: i64) -> EntryState { + EntryState::Accepted { verified_at: UnixSeconds::new(at), } } - fn history() -> Roster { + fn history() -> Roll { fold(vec![ change(b'g', "granted", "olaren", 1), change(b'i', "invited", "olaren", 1), @@ -383,7 +385,7 @@ mod tests { #[test] fn a_fold_keeps_the_first_offer_and_the_first_acceptance_until_a_removal_clears_them() { - let roster = history(); + let roll = history(); [ ("nobody", None), ("granted", entry(Granted, "olaren", 1, None)), @@ -404,48 +406,48 @@ mod tests { .into_iter() .for_each(|(subject, expected)| { assert_eq!( - roster.get(&did(subject)).cloned(), + roll.get(&did(subject)).cloned(), expected, "the first offer sets an entry's provenance, and only a removal clears it: \ - {subject}" + {subject}" ); }); } #[test] - fn a_standing_is_effective_exactly_when_it_has_a_since_key_to_page_on() { - let roster = history(); + fn an_entry_is_effective_exactly_when_it_has_a_since_key_to_page_on() { + let roll = history(); [ ("granted", grandfathered(1), Some(1)), - ("invited", Standing::Invited, None), + ("invited", EntryState::Invited, None), ("grant_then_accept", accepted(7), Some(1)), ("invite_then_accept", accepted(7), Some(7)), ] .into_iter() - .for_each(|(subject, standing, since)| { - let entry = roster.get(&did(subject)).unwrap(); + .for_each(|(subject, state, since)| { + let entry = roll.get(&did(subject)).unwrap(); assert_eq!( ( - entry.standing(), + entry.state(), entry.effective_since().map(EffectiveSince::seconds) ), - (standing, since.map(UnixSeconds::new)), + (state, since.map(UnixSeconds::new)), "effective_since comes from the offer, so accepting mustn't shift {subject} in \ - a page a reader is already walking" + a page a reader is already walking" ); }); - roster.entries().for_each(|(subject, entry)| { + roll.entries().for_each(|(subject, entry)| { assert_eq!( entry.effective_since().is_some(), - entry.standing().is_effective(), + entry.state().is_effective(), "a listing that pages on the since-key must serve exactly the rows the acl \ - grants on, so the two readings of {subject} have to agree" + grants on, so the two readings of {subject} have to agree" ); }); assert_eq!( - fold(vec![]).standing(&did("nel")), + fold(vec![]).state(&did("nel")), None, - "a standing for nel out of an empty change log" + "Entry for nel out of empty change log" ); } @@ -469,17 +471,16 @@ mod tests { ); let decoded: Entry = serde_ipld_dagcbor::from_slice(&before).unwrap(); assert_eq!( - decoded.standing(), + decoded.state(), grandfathered(7), "an entry from before invites existed must decode as grandfathered, not an acceptance" ); let mixed = history(); - let reread: Roster = + let reread: Roll = serde_ipld_dagcbor::from_slice(&serde_ipld_dagcbor::to_vec(&mixed).unwrap()).unwrap(); assert_eq!( reread, mixed, - "a checkpoint is the roster's own encoding, so an outstanding invite has to come \ - back out of one or the knot reads it as the grant it never was" + "the checkpoint is the roll's own encoding, outstanding invites have to come back out of one, or knot mistakes them for grants" ); } @@ -491,7 +492,7 @@ mod tests { ops in prop::collection::vec((0u8..4, 0u8..3), 0..12), (op, who) in (0u8..4, 0u8..3), ) { - let roster = fold( + let roll = fold( ops.iter() .enumerate() .map(|(index, (op, who))| { @@ -502,8 +503,8 @@ mod tests { ); let next = change(ALPHABET[op as usize], &format!("s{who}"), "olaren", 99); prop_assert_eq!( - roster.would_change(&next), - roster.clone().apply_change(&next) != roster, + roll.would_change(&next), + roll.clone().apply_change(&next) != roll, "the append-time predicate and the fold disagree on {:?}", next ); diff --git a/knot2/crates/knot-cobs/src/lib.rs b/knot2/crates/knot-cobs/src/lib.rs index 97f013a57..5e7b170d8 100644 --- a/knot2/crates/knot-cobs/src/lib.rs +++ b/knot2/crates/knot-cobs/src/lib.rs @@ -4,18 +4,24 @@ mod grant; mod import; mod members; mod registry; +mod social; pub use blocklist::{Blocklist, BlocklistChange, BlocklistCob}; pub use collaborators::{Collaborators, CollaboratorsChange, CollaboratorsCob}; pub use grant::{ - Accept, Effect, EffectiveSince, Entry, Grant, GrantChange, Invite, Offer, Removal, Roster, - Standing, + Accept, Effect, EffectiveSince, Entry, EntryState, Grant, GrantChange, Invite, Offer, Removal, + Roll, }; pub use import::{ImportError, verify_cob_ref}; pub use members::{Members, MembersChange, MembersCob}; pub use registry::{ - Registration, Registry, RegistryChange, RegistryError, Rename, RepoRecord, RepoRef, - RepoRegistryCob, deregister_repo, register_repo, rename_repo, + Policy, Registration, RegistrationField, Registry, RegistryChange, RegistryError, Rename, + RepoRecord, RepoRef, RepoRegistryCob, deregister_repo, register_repo, rename_repo, + set_repo_policy, +}; +pub use social::{ + AnyBody, Creation, Editing, Emission, Erasure, Social, SocialBody, SocialChange, SocialCob, + SocialObject, SocialState, SocialWire, decode_social, }; #[doc(hidden)] @@ -29,6 +35,7 @@ pub mod fuzz { let _ = MembersChange::decode(data); let _ = BlocklistChange::decode(data); let _ = CollaboratorsChange::decode(data); + let _ = crate::decode_social(data); } pub fn ref_parse(data: &[u8]) { diff --git a/knot2/crates/knot-cobs/src/members.rs b/knot2/crates/knot-cobs/src/members.rs index cb7b06a32..ba00f5f4a 100644 --- a/knot2/crates/knot-cobs/src/members.rs +++ b/knot2/crates/knot-cobs/src/members.rs @@ -1,6 +1,6 @@ -use crate::grant::roster_cob; +use crate::grant::roll_cob; -roster_cob! { +roll_cob! { change = MembersChange, ops = { Add(Grant), Invite(Invite), Accept(Accept), Remove(Removal) }, cob = MembersCob, diff --git a/knot2/crates/knot-cobs/src/registry.rs b/knot2/crates/knot-cobs/src/registry.rs index 76487c0a1..d96d812da 100644 --- a/knot2/crates/knot-cobs/src/registry.rs +++ b/knot2/crates/knot-cobs/src/registry.rs @@ -1,11 +1,12 @@ use std::collections::BTreeMap; +use std::fmt; use knot_cob::{ ChangeId, ChangePayload, Checkpoint, CobError, CobHome, CobId, CobStore, Evaluate, - HistoryModel, SnapshotStride, StateSize, + HistoryModel, KnotAuthored, SnapshotStride, StateSize, }; use knot_runtime::Signer; -use knot_types::{ActorId, OwnerDid, RepoDid, RepoName, RepoRkey, UnixSeconds}; +use knot_types::{ActorId, ContributionPolicy, OwnerDid, RepoDid, RepoName, RepoRkey, UnixSeconds}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -15,6 +16,53 @@ pub struct Registration { pub name: RepoName, pub repo: RepoDid, pub created_at: UnixSeconds, + #[serde(default)] + pub policy: ContributionPolicy, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RegistrationField { + Owner, + Rkey, + Name, + CreatedAt, + Policy, +} + +impl fmt::Display for RegistrationField { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.pad(match self { + Self::Owner => "owner", + Self::Rkey => "rkey", + Self::Name => "name", + Self::CreatedAt => "createdAt", + Self::Policy => "contributionPolicy", + }) + } +} + +impl Registration { + fn diverges_from(&self, record: &RepoRecord) -> Option { + [ + (record.owner != self.owner, RegistrationField::Owner), + (record.rkey != self.rkey, RegistrationField::Rkey), + (record.name != self.name, RegistrationField::Name), + ( + record.created_at != self.created_at, + RegistrationField::CreatedAt, + ), + (record.policy != self.policy, RegistrationField::Policy), + ] + .into_iter() + .find_map(|(moved, field)| moved.then_some(field)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Policy { + pub owner: OwnerDid, + pub repo: RepoDid, + pub policy: ContributionPolicy, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -37,18 +85,23 @@ pub enum RegistryChange { Register(Registration), Rename(Rename), Deregister(RepoRef), + SetPolicy(Policy), } impl ChangePayload for RegistryChange { const TYPE: &'static str = "sh.tangled.knot.repoRegistry"; } +impl KnotAuthored for RegistryChange {} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RepoRecord { pub owner: OwnerDid, pub rkey: RepoRkey, pub name: RepoName, pub created_at: UnixSeconds, + #[serde(default)] + pub policy: ContributionPolicy, } #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] @@ -110,6 +163,7 @@ impl Registry { rkey: registration.rkey, name: registration.name, created_at: registration.created_at, + policy: registration.policy, }, ); self @@ -139,6 +193,17 @@ impl Registry { } } + fn set_policy(mut self, change: Policy) -> Self { + if let Some(record) = self + .records + .get_mut(&change.repo) + .filter(|record| record.owner == change.owner) + { + record.policy = change.policy; + } + self + } + fn steal_alias(mut self, owner: &OwnerDid, rkey: &RepoRkey, target: &RepoDid) -> Self { match self.resolve(owner, rkey).cloned() { Some(holder) if holder != *target => { @@ -190,6 +255,7 @@ impl Evaluate for RepoRegistryCob { RegistryChange::Register(registration) => state.register(registration), RegistryChange::Rename(rename) => state.rename(rename), RegistryChange::Deregister(target) => state.deregister(target), + RegistryChange::SetPolicy(policy) => state.set_policy(policy), } } } @@ -214,11 +280,12 @@ pub enum RegistryError { expected: RepoDid, found: RepoDid, }, - #[error("repo {repo} is already registered as {owner}/{rkey}")] + #[error("repo {repo} is already registered as {owner}/{rkey} with another {field}")] AlreadyRegistered { repo: RepoDid, owner: OwnerDid, rkey: RepoRkey, + field: RegistrationField, }, #[error("record key {owner}/{rkey} is canonical key of {existing}")] RkeyTaken { @@ -255,17 +322,17 @@ pub fn register_repo( existing: holder.clone(), }); } - match registry.record_of(®istration.repo) { - Some(record) - if record.owner != registration.owner || record.rkey != registration.rkey => - { - Err(RegistryError::AlreadyRegistered { - repo: registration.repo.clone(), - owner: record.owner.clone(), - rkey: record.rkey.clone(), - }) - } - Some(_) => Ok(None), + match registry + .record_of(®istration.repo) + .map(|record| (record, registration.diverges_from(record))) + { + Some((record, Some(field))) => Err(RegistryError::AlreadyRegistered { + repo: registration.repo.clone(), + owner: record.owner.clone(), + rkey: record.rkey.clone(), + field, + }), + Some((_, None)) => Ok(None), None => Ok(Some(RegistryChange::Register(registration.clone()))), } }, @@ -345,6 +412,37 @@ pub fn deregister_repo( ) } +pub fn set_repo_policy( + store: &CobStore, + home: &CobHome, + object: CobId, + change: Policy, + signer: &dyn Signer, + timestamp: UnixSeconds, +) -> Result, RegistryError> { + store.update_maybe_checkpointed::( + home, + object, + signer, + timestamp, + |registry| { + let record = + registry + .record_of(&change.repo) + .ok_or_else(|| RegistryError::NotHosted { + repo: change.repo.clone(), + })?; + if record.owner != change.owner { + return Err(RegistryError::OwnerMoved { + repo: change.repo.clone(), + expected: change.owner.clone(), + }); + } + Ok((record.policy != change.policy).then(|| RegistryChange::SetPolicy(change.clone()))) + }, + ) +} + #[cfg(test)] mod tests { use super::*; @@ -372,6 +470,7 @@ mod tests { name: name(key), repo: repo(repo_id), created_at: UnixSeconds::new(at), + policy: ContributionPolicy::default(), }) } @@ -542,6 +641,11 @@ mod tests { register("nel", "anemone", "squid", 5), rename("nel", "barnacle", "squid"), deregister("nel", "anemone"), + RegistryChange::SetPolicy(Policy { + owner: owner("nel"), + repo: repo("squid"), + policy: ContributionPolicy::Anyone, + }), ] .into_iter() .for_each(|change| { @@ -549,4 +653,68 @@ mod tests { assert_eq!(RegistryChange::decode(&bytes).unwrap(), change); }); } + + #[test] + fn a_registration_written_before_the_policy_field_still_folds() { + #[derive(Serialize)] + struct Before<'a> { + owner: &'a str, + rkey: &'a str, + name: &'a str, + repo: &'a str, + created_at: i64, + } + + #[derive(Serialize)] + #[serde(tag = "op", content = "data", rename_all = "snake_case")] + enum BeforeChange<'a> { + Register(Before<'a>), + } + + let bytes = serde_ipld_dagcbor::to_vec(&BeforeChange::Register(Before { + owner: "did:plc:nel", + rkey: "anemone", + name: "anemone", + repo: "did:plc:squid", + created_at: 5, + })) + .unwrap(); + let decoded = RegistryChange::decode(&bytes) + .expect("Change from before this field existed still folds"); + let folded = fold(vec![decoded]); + assert_eq!( + folded.record_of(&repo("squid")).unwrap().policy, + ContributionPolicy::Collaborators, + "Repository registered before this field existed takes the safe policy, not the open one" + ); + } + + #[test] + fn only_the_owner_moves_the_policy_and_a_repeat_doesnt_write_a_change() { + let opened = fold(vec![ + register("nel", "anemone", "squid", 5), + RegistryChange::SetPolicy(Policy { + owner: owner("nel"), + repo: repo("squid"), + policy: ContributionPolicy::Anyone, + }), + ]); + assert_eq!( + opened.record_of(&repo("squid")).unwrap().policy, + ContributionPolicy::Anyone + ); + let stranger = fold(vec![ + register("nel", "anemone", "squid", 5), + RegistryChange::SetPolicy(Policy { + owner: owner("olaren"), + repo: repo("squid"), + policy: ContributionPolicy::Anyone, + }), + ]); + assert_eq!( + stranger.record_of(&repo("squid")).unwrap().policy, + ContributionPolicy::Collaborators, + "Fold answers to the owner the registry has on file, not to what the change claims" + ); + } } diff --git a/knot2/crates/knot-cobs/tests/cobs.rs b/knot2/crates/knot-cobs/tests/cobs.rs index 523d9cc7d..f542330fb 100644 --- a/knot2/crates/knot-cobs/tests/cobs.rs +++ b/knot2/crates/knot-cobs/tests/cobs.rs @@ -3,8 +3,8 @@ mod common; use common::{account, at, did, fixture, grant, home, registration, rename, reopen, rkey, signer}; use knot_cob::CobStore; use knot_cobs::{ - CollaboratorsChange, MembersChange, MembersCob, RegistryChange, RegistryError, Removal, - RepoRegistryCob, register_repo, rename_repo, + CollaboratorsChange, MembersChange, MembersCob, RegistrationField, RegistryChange, + RegistryError, Removal, RepoRegistryCob, register_repo, rename_repo, }; use knot_types::{AccountDid, OwnerDid, RepoDid}; @@ -53,23 +53,23 @@ fn members_roundtrip_and_reload_is_identical() { .unwrap(); let object = store.get::(created.object).unwrap(); - let state = object.state(); - assert!(state.standing(&account("nel")).is_some()); + let roll = object.state(); + assert!(roll.state(&account("nel")).is_some()); assert_eq!( - state.get(&account("olaren")).unwrap().added_by, + roll.get(&account("olaren")).unwrap().added_by, account("nel") ); - assert!(state.standing(&account("teq")).is_none()); - assert_eq!(state.len(), 2); + assert!(roll.state(&account("teq")).is_none()); + assert_eq!(roll.len(), 2); - let listed: Vec<&AccountDid> = state.entries().map(|(subject, _)| subject).collect(); + let listed: Vec<&AccountDid> = roll.entries().map(|(subject, _)| subject).collect(); assert_eq!(listed, vec![&account("nel"), &account("olaren")]); let reopened = reopen(repo); let reloaded = CobStore::new(&reopened) .get::(created.object) .unwrap(); - assert_eq!(state, reloaded.state()); + assert_eq!(roll, reloaded.state()); let (_dup_dir, dup_repo) = fixture(); let dup_store = CobStore::new(&dup_repo); @@ -183,20 +183,39 @@ fn registry_handler_semantics() { ); assert!( matches!(clash, Err(RegistryError::RkeyTaken { .. })), - "register cannot claim the canonical rkey of a live repo under the same owner" + "register can't claim the canonical rkey, of live repo, under the same owner" ); assert_eq!( register_repo( &store, &home(), object, - registration("nel", "anemone", "squid", 2), + registration("nel", "anemone", "squid", 1), &key, at(2), ) .unwrap(), None, - "re-registering identical owner, rkey, and repo appends nothing" + "re-registering record registry already has doesn't append change" + ); + let moved = register_repo( + &store, + &home(), + object, + registration("nel", "anemone", "squid", 2), + &key, + at(2), + ); + assert!( + matches!( + moved, + Err(RegistryError::AlreadyRegistered { + field: RegistrationField::CreatedAt, + .. + }) + ), + "Register that would move stored field answers with field's name instead of \ + dropping change, got {moved:?}" ); let renamed = rename_repo( @@ -228,14 +247,14 @@ fn registry_handler_semantics() { &store, &home(), object, - registration("nel", "barnacle", "squid", 3), + registration("nel", "barnacle", "squid", 1), &key, at(3), ) .unwrap(); assert_eq!( redundant, None, - "re-register matching canonical owner and rkey appends nothing" + "re-register matching whole stored record doesn't append change" ); assert_eq!( store diff --git a/knot2/crates/knot-cobs/tests/common/mod.rs b/knot2/crates/knot-cobs/tests/common/mod.rs index 003b077c0..c0e37c443 100644 --- a/knot2/crates/knot-cobs/tests/common/mod.rs +++ b/knot2/crates/knot-cobs/tests/common/mod.rs @@ -63,6 +63,7 @@ pub fn registration(owner: &str, key: &str, repo_id: &str, ts: i64) -> Registrat name: RepoName::new(key).unwrap(), repo: RepoDid::new(format!("did:plc:{repo_id}")).unwrap(), created_at: at(ts), + policy: knot_types::ContributionPolicy::default(), } } diff --git a/knot2/crates/knot-cobs/tests/invariants.rs b/knot2/crates/knot-cobs/tests/invariants.rs index d52766825..8b4e57202 100644 --- a/knot2/crates/knot-cobs/tests/invariants.rs +++ b/knot2/crates/knot-cobs/tests/invariants.rs @@ -6,9 +6,9 @@ use common::{ }; use knot_cob::{ChangePayload, CobError, CobHome, CobId, CobStore, Evaluate}; use knot_cobs::{ - Accept, CollaboratorsChange, ImportError, Invite, MembersChange, MembersCob, RegistryChange, - RegistryError, Removal, RepoRef, RepoRegistryCob, Standing, deregister_repo, register_repo, - verify_cob_ref, + Accept, CollaboratorsChange, EntryState, ImportError, Invite, MembersChange, MembersCob, + RegistryChange, RegistryError, Removal, RepoRef, RepoRegistryCob, deregister_repo, + register_repo, verify_cob_ref, }; use knot_git::RefUpdate; use knot_runtime::Signer; @@ -44,7 +44,7 @@ fn accepted(subject: &str, seconds: i64) -> MembersChange { } #[test] -fn only_invited_accounts_gain_standing_and_first_verification_wins() { +fn only_invited_accounts_gain_an_entry_and_first_verification_wins() { let uninvited = build_members( 3, &[ @@ -53,8 +53,8 @@ fn only_invited_accounts_gain_standing_and_first_verification_wins() { ], ); assert!( - uninvited.standing(&account("nel")).is_none(), - "nel has a standing from an acceptance the knot never offered" + uninvited.state(&account("nel")).is_none(), + "nel has an entry from an acceptance the knot never offered" ); let twice = build_members( @@ -88,7 +88,7 @@ fn linear_member_semantics() { ], ); assert!( - readd.standing(&account("nel")).is_some(), + readd.state(&account("nel")).is_some(), "linear re-add after a remove is a legitimate decision and takes effect" ); @@ -105,7 +105,7 @@ fn linear_member_semantics() { ], ); assert!( - stale_remove.standing(&account("nel")).is_none(), + stale_remove.state(&account("nel")).is_none(), "in a linear chain Remove is Add's child, so it applies last even with an older timestamp" ); @@ -368,7 +368,7 @@ fn registry_handler_guards() { } #[test] -fn add_member_handler_lands_a_grant() { +fn add_member_handler_stores_a_grant() { let (_dir, repo) = fixture(); let key = signer(81); let store = CobStore::new(&repo); @@ -395,7 +395,7 @@ fn add_member_handler_lands_a_grant() { .get::(created.object) .unwrap() .into_state(); - assert!(members.standing(&account("olaren")).is_some()); + assert!(members.state(&account("olaren")).is_some()); } #[test] @@ -523,11 +523,11 @@ proptest! { MembersCob::apply(state, change, &author) }); - let unearned: Vec<(u8, Option)> = (0u8..3) - .map(|who| (who, folded.standing(&account(&format!("s{who}"))))) - .filter(|(who, standing)| match standing { - Some(Standing::Accepted { .. }) => true, - Some(Standing::Grandfathered { .. }) => { + let unearned: Vec<(u8, Option)> = (0u8..3) + .map(|who| (who, folded.state(&account(&format!("s{who}"))))) + .filter(|(who, state)| match state { + Some(EntryState::Accepted { .. }) => true, + Some(EntryState::Grandfathered { .. }) => { !ops.iter().any(|(op, subject)| *op == 2 && subject == who) } _ => false, @@ -535,7 +535,7 @@ proptest! { .collect(); prop_assert!( unearned.is_empty(), - "standings the knot signed for on its own: {unearned:?}" + "entry states the knot didn't earn: {unearned:?}" ); } } diff --git a/knot2/crates/knot-migrate/src/emit.rs b/knot2/crates/knot-migrate/src/emit.rs index 9efbe55d7..0be4f4f53 100644 --- a/knot2/crates/knot-migrate/src/emit.rs +++ b/knot2/crates/knot-migrate/src/emit.rs @@ -1,9 +1,9 @@ use std::path::{Path, PathBuf}; -use knot_cob::{Checkpoint, CobError, CobHome, CobStore, Evaluate}; +use knot_cob::{Checkpoint, CobError, CobHome, CobStore, Evaluate, KnotAuthored}; use knot_cobs::{ CollaboratorsChange, CollaboratorsCob, Grant, MembersChange, MembersCob, Registration, - RegistryChange, RegistryError, RepoRegistryCob, + RegistrationField, RegistryChange, RegistryError, RepoRegistryCob, }; use knot_git::{GitError, Layout}; use knot_runtime::Signer; @@ -149,8 +149,9 @@ fn write_grant_set( make: impl Fn(Grant) -> E::Change, ) -> Result where - E: Checkpoint + Evaluate, + E: Checkpoint + Evaluate, E::State: Serialize + DeserializeOwned, + E::Change: KnotAuthored + Clone, { write_batch::( store, @@ -160,7 +161,7 @@ where signer, |grant| make(to_grant(grant)), |grant| grant.created_at, - |roster, grant| roster.standing(&grant.subject).is_some(), + |roll, grant| roll.state(&grant.subject).is_some(), |_| Ok(()), ) } @@ -190,11 +191,20 @@ fn write_registry( registry.record_of(&repo.did), registry.resolve(&repo.owner, &repo.rkey), ) { - (Some(record), _) if record.owner != repo.owner || record.rkey != repo.rkey => { + (Some(record), _) if record.owner != repo.owner => { Err(EmitError::Registry(RegistryError::AlreadyRegistered { repo: repo.did.clone(), owner: record.owner.clone(), rkey: record.rkey.clone(), + field: RegistrationField::Owner, + })) + } + (Some(record), _) if record.rkey != repo.rkey => { + Err(EmitError::Registry(RegistryError::AlreadyRegistered { + repo: repo.did.clone(), + owner: record.owner.clone(), + rkey: record.rkey.clone(), + field: RegistrationField::Rkey, })) } (Some(record), _) if record.name != repo.name => { @@ -233,21 +243,25 @@ fn write_batch( where E: Checkpoint, E::State: Serialize + DeserializeOwned, + E::Change: KnotAuthored + Clone, { let fail = |source: CobError| EmitError::Cob { cob, source }; let objects = store.list::().map_err(fail)?; let (object, state, created) = match (objects.as_slice(), items) { (_, []) => return Ok(GrantSetOutcome::default()), ([], [first, ..]) => { + let author = ActorId::from_secp256k1(signer.public_key().as_bytes()); let change = make(first); + let state = E::apply(E::initial(), change.clone(), &author); + precheck(&state)?; let created = store .create(home, &change, signer, stamp(first)) .map_err(fail)?; - let author = ActorId::from_secp256k1(signer.public_key().as_bytes()); - (created.object, E::apply(E::initial(), change, &author), 1) + (created.object, state, 1) } ([object], _) => { let (state, _) = store.materialize::(*object).map_err(fail)?; + precheck(&state)?; (*object, state, 0) } (many, _) => { @@ -257,7 +271,6 @@ where }); } }; - precheck(&state)?; let missing: Vec<&T> = items.iter().filter(|item| !present(&state, item)).collect(); missing.split_last().map_or(Ok(()), |(last, head)| { @@ -301,6 +314,7 @@ fn registration(repo: &AdoptRepo) -> Registration { name: repo.name.clone(), repo: repo.did.clone(), created_at: repo.created_at, + policy: knot_types::ContributionPolicy::default(), } } -- 2.51.2