From 069dd9af087cedc64bf5aacbab8a4e9488a542a7 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 2 Jun 2026 18:52:17 +0900 Subject: [PATCH 01/17] Add minimal ActivityPub vocab types --- Cargo.lock | 100 +++++++ Cargo.toml | 4 + crates/feder-vocab/Cargo.toml | 4 + crates/feder-vocab/src/lib.rs | 324 +++++++++++++++++++++- crates/feder-vocab/tests/phase1_shapes.rs | 142 ++++++++++ 5 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 crates/feder-vocab/tests/phase1_shapes.rs diff --git a/Cargo.lock b/Cargo.lock index 8e693f7..1fceb53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,3 +12,103 @@ dependencies = [ [[package]] name = "feder-vocab" version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 8407033..ac5a686 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,10 @@ version = "0.1.0" edition = "2024" license = "AGPL-3.0-only" +[workspace.dependencies] +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.140" + [workspace.lints.rust] warnings = "deny" diff --git a/crates/feder-vocab/Cargo.toml b/crates/feder-vocab/Cargo.toml index d0d075e..4c9957a 100644 --- a/crates/feder-vocab/Cargo.toml +++ b/crates/feder-vocab/Cargo.toml @@ -5,6 +5,10 @@ edition.workspace = true license.workspace = true [dependencies] +serde.workspace = true + +[dev-dependencies] +serde_json.workspace = true [lints] workspace = true diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index 65dbcee..c10410d 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -1 +1,323 @@ -//! Activity Vocabulary types for Feder. +//! Minimal Activity Vocabulary types for Feder. +//! +//! This crate models ActivityPub/ActivityStreams protocol data only. It does +//! not fetch remote objects, read or write storage, deliver activities, or own +//! core decision logic. + +use serde::{Deserialize, Serialize}; + +/// The canonical Activity Streams JSON-LD context URL. +pub const ACTIVITYSTREAMS_CONTEXT: &str = "https://www.w3.org/ns/activitystreams"; + +/// An absolute ActivityPub/ActivityStreams identifier. +pub type Iri = String; + +/// A non-scalar ActivityStreams property value. +/// +/// ActivityStreams object slots can contain either an embedded object or the +/// object's IRI. Phase 1 keeps both forms explicit and avoids dereferencing. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(untagged)] +pub enum Reference { + Id(Iri), + Object(Box), +} + +impl Reference { + #[must_use] + pub fn id(id: impl Into) -> Self { + Self::Id(id.into()) + } + + #[must_use] + pub fn object(object: T) -> Self { + Self::Object(Box::new(object)) + } +} + +/// A property value that can appear either once or multiple times. +/// +/// ActivityStreams commonly allows fields to be absent, scalar, or arrays. +/// Absence is represented by `Option>` on the containing type. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(untagged)] +pub enum OneOrMany { + One(T), + Many(Vec), +} + +impl OneOrMany { + #[must_use] + pub fn one(value: T) -> Self { + Self::One(value) + } + + #[must_use] + pub fn many(values: impl Into>) -> Self { + Self::Many(values.into()) + } +} + +macro_rules! activitystreams_type { + ($name:ident, $variant:ident) => { + #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] + pub enum $name { + #[default] + $variant, + } + }; +} + +activitystreams_type!(PersonType, Person); +activitystreams_type!(NoteType, Note); +activitystreams_type!(FollowType, Follow); +activitystreams_type!(AcceptType, Accept); +activitystreams_type!(CreateType, Create); + +/// A minimal ActivityPub actor for Phase 1 core tests. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Actor { + #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(rename = "type")] + pub kind: PersonType, + pub id: Iri, + pub inbox: Iri, + pub outbox: Iri, + #[serde(rename = "preferredUsername", skip_serializing_if = "Option::is_none")] + pub preferred_username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +impl Actor { + #[must_use] + pub fn person(id: impl Into, inbox: impl Into, outbox: impl Into) -> Self { + Self { + context: Some(ACTIVITYSTREAMS_CONTEXT.to_string()), + kind: PersonType::default(), + id: id.into(), + inbox: inbox.into(), + outbox: outbox.into(), + preferred_username: None, + name: None, + } + } +} + +/// A minimal ActivityStreams Note object. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Note { + #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(rename = "type")] + pub kind: NoteType, + pub id: Iri, + #[serde(rename = "attributedTo", skip_serializing_if = "Option::is_none")] + pub attributed_to: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub published: Option, +} + +impl Note { + #[must_use] + pub fn new(id: impl Into) -> Self { + Self { + context: Some(ACTIVITYSTREAMS_CONTEXT.to_string()), + kind: NoteType::default(), + id: id.into(), + attributed_to: None, + content: None, + published: None, + } + } +} + +/// A minimal Follow activity. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Follow { + #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(rename = "type")] + pub kind: FollowType, + pub id: Iri, + pub actor: Reference, + pub object: Reference, +} + +impl Follow { + #[must_use] + pub fn new(id: impl Into, actor: Reference, object: Reference) -> Self { + Self { + context: Some(ACTIVITYSTREAMS_CONTEXT.to_string()), + kind: FollowType::default(), + id: id.into(), + actor, + object, + } + } +} + +/// A minimal Accept activity. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Accept { + #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(rename = "type")] + pub kind: AcceptType, + pub id: Iri, + pub actor: Reference, + pub object: Reference, +} + +impl Accept { + #[must_use] + pub fn new(id: impl Into, actor: Reference, object: Reference) -> Self { + Self { + context: Some(ACTIVITYSTREAMS_CONTEXT.to_string()), + kind: AcceptType::default(), + id: id.into(), + actor, + object, + } + } +} + +/// A minimal Create activity for a concrete object type. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Create { + #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(rename = "type")] + pub kind: CreateType, + pub id: Iri, + pub actor: Reference, + pub object: Reference, +} + +impl Create { + #[must_use] + pub fn new(id: impl Into, actor: Reference, object: Reference) -> Self { + Self { + context: Some(ACTIVITYSTREAMS_CONTEXT.to_string()), + kind: CreateType::default(), + id: id.into(), + actor, + object, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::de::DeserializeOwned; + use serde_json::json; + + fn roundtrip(value: &T) -> T + where + T: DeserializeOwned + Serialize, + { + let json = serde_json::to_string(value).expect("serialize activitystreams value"); + serde_json::from_str(&json).expect("deserialize activitystreams value") + } + + #[test] + fn actor_roundtrips_json() { + let mut actor = Actor::person( + "https://example.com/users/alice", + "https://example.com/users/alice/inbox", + "https://example.com/users/alice/outbox", + ); + actor.preferred_username = Some("alice".to_string()); + actor.name = Some("Alice".to_string()); + + assert_eq!(roundtrip(&actor), actor); + } + + #[test] + fn actor_deserializes_basic_activitypub_json() { + let actor: Actor = serde_json::from_value(json!({ + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Person", + "id": "https://example.com/users/alice", + "inbox": "https://example.com/users/alice/inbox", + "outbox": "https://example.com/users/alice/outbox", + "preferredUsername": "alice", + "name": "Alice" + })) + .expect("deserialize actor from json"); + + assert_eq!(actor.id, "https://example.com/users/alice"); + assert_eq!(actor.preferred_username, Some("alice".to_string())); + } + + #[test] + fn follow_and_accept_roundtrip_json() { + let follow = Follow::new( + "https://remote.example/activities/follow/1", + Reference::id("https://remote.example/users/bob"), + Reference::id("https://example.com/users/alice"), + ); + let accept = Accept::new( + "https://example.com/activities/accept/1", + Reference::id("https://example.com/users/alice"), + Reference::object(follow), + ); + + assert_eq!(roundtrip(&accept), accept); + } + + #[test] + fn create_note_roundtrips_json() { + let mut note = Note::new("https://example.com/notes/1"); + note.attributed_to = Some(Reference::id("https://example.com/users/alice")); + note.content = Some("Hello, fediverse.".to_string()); + note.published = Some("2026-05-29T06:30:00Z".to_string()); + + let create = Create::new( + "https://example.com/activities/create/1", + Reference::id("https://example.com/users/alice"), + Reference::object(note), + ); + + assert_eq!(roundtrip(&create), create); + } + + #[test] + fn concrete_types_reject_wrong_activitystreams_type() { + let result = serde_json::from_value::(json!({ + "type": "Accept", + "id": "https://remote.example/activities/follow/1", + "actor": "https://remote.example/users/bob", + "object": "https://example.com/users/alice" + })); + + assert!(result.is_err()); + } + + #[test] + fn one_or_many_deserializes_scalar_and_array() { + let one: OneOrMany = serde_json::from_value(json!("https://example.com/users/alice")) + .expect("deserialize scalar one-or-many value"); + let many: OneOrMany = serde_json::from_value(json!([ + "https://example.com/users/alice", + "https://example.com/users/bob" + ])) + .expect("deserialize array one-or-many value"); + + assert_eq!( + one, + OneOrMany::one("https://example.com/users/alice".to_string()) + ); + assert_eq!( + many, + OneOrMany::many([ + "https://example.com/users/alice".to_string(), + "https://example.com/users/bob".to_string() + ]) + ); + } +} diff --git a/crates/feder-vocab/tests/phase1_shapes.rs b/crates/feder-vocab/tests/phase1_shapes.rs new file mode 100644 index 0000000..ff3e51d --- /dev/null +++ b/crates/feder-vocab/tests/phase1_shapes.rs @@ -0,0 +1,142 @@ +use feder_vocab::{ACTIVITYSTREAMS_CONTEXT, Accept, Create, Follow, Iri, Note, Reference}; +use serde_json::{Value, json}; + +fn serialize(value: impl serde::Serialize) -> Value { + serde_json::to_value(value).expect("serialize vocab value") +} + +fn incoming_follow_json() -> serde_json::Value { + json!({ + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Follow", + "id": "https://remote.example/activities/follow/1", + "actor": "https://remote.example/users/bob", + "object": { + "type": "Person", + "id": "https://example.com/users/alice", + "inbox": "https://example.com/users/alice/inbox", + "outbox": "https://example.com/users/alice/outbox", + "preferredUsername": "alice" + } + }) +} + +#[test] +fn follow_activity_accepts_id_or_embedded_actor_references() { + let follow: Follow = + serde_json::from_value(incoming_follow_json()).expect("deserialize incoming follow"); + + assert_eq!(follow.id, "https://remote.example/activities/follow/1"); + assert!(matches!(follow.actor, Reference::Id(id) if id == "https://remote.example/users/bob")); + assert!( + matches!(follow.object, Reference::Object(actor) if actor.id == "https://example.com/users/alice") + ); +} + +#[test] +fn accept_activity_can_embed_follow_activity() { + let follow: Follow = + serde_json::from_value(incoming_follow_json()).expect("deserialize incoming follow"); + + let outgoing_accept = Accept::new( + "https://example.com/activities/accept/1", + Reference::id("https://example.com/users/alice"), + Reference::object(follow), + ); + + assert_eq!( + serialize(outgoing_accept), + json!({ + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Accept", + "id": "https://example.com/activities/accept/1", + "actor": "https://example.com/users/alice", + "object": { + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Follow", + "id": "https://remote.example/activities/follow/1", + "actor": "https://remote.example/users/bob", + "object": { + "type": "Person", + "id": "https://example.com/users/alice", + "inbox": "https://example.com/users/alice/inbox", + "outbox": "https://example.com/users/alice/outbox", + "preferredUsername": "alice" + } + } + }) + ); +} + +#[test] +fn local_note_can_shape_create_note_activity() { + let mut note = Note::new("https://example.com/notes/1"); + note.attributed_to = Some(Reference::id("https://example.com/users/alice")); + note.content = Some("Hello from Feder.".to_string()); + note.published = Some("2026-06-02T00:00:00Z".to_string()); + + let create = Create::new( + "https://example.com/activities/create/1", + Reference::id("https://example.com/users/alice"), + Reference::object(note), + ); + + assert_eq!( + serialize(create), + json!({ + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Create", + "id": "https://example.com/activities/create/1", + "actor": "https://example.com/users/alice", + "object": { + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Note", + "id": "https://example.com/notes/1", + "attributedTo": "https://example.com/users/alice", + "content": "Hello from Feder.", + "published": "2026-06-02T00:00:00Z" + } + }) + ); +} + +#[test] +fn reference_keeps_id_and_embedded_object_shapes_distinct() { + let id_reference: Reference = + serde_json::from_value(json!("https://example.com/notes/1")) + .expect("deserialize id reference"); + let object_reference: Reference = serde_json::from_value(json!({ + "type": "Note", + "id": "https://example.com/notes/1" + })) + .expect("deserialize embedded object reference"); + + assert!(matches!(id_reference, Reference::Id(id) if id == "https://example.com/notes/1")); + assert!( + matches!(object_reference, Reference::Object(note) if note.id == "https://example.com/notes/1") + ); +} + +#[test] +fn one_or_many_can_represent_common_recipient_shapes() { + let single: feder_vocab::OneOrMany = + serde_json::from_value(json!("https://www.w3.org/ns/activitystreams#Public")) + .expect("deserialize single recipient"); + let multiple: feder_vocab::OneOrMany = serde_json::from_value(json!([ + "https://www.w3.org/ns/activitystreams#Public", + "https://example.com/users/alice/followers" + ])) + .expect("deserialize multiple recipients"); + + assert_eq!( + single, + feder_vocab::OneOrMany::one("https://www.w3.org/ns/activitystreams#Public".to_string()) + ); + assert_eq!( + multiple, + feder_vocab::OneOrMany::many([ + "https://www.w3.org/ns/activitystreams#Public".to_string(), + "https://example.com/users/alice/followers".to_string() + ]) + ); +} From 9e177eb75120145dd4de176693f711c9ded9fee5 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Thu, 4 Jun 2026 15:43:25 +0900 Subject: [PATCH 02/17] Address vocab review feedback --- Cargo.lock | 10 ++ Cargo.toml | 3 +- crates/feder-vocab/Cargo.toml | 1 + crates/feder-vocab/src/lib.rs | 141 +++++++++++++++------- crates/feder-vocab/tests/phase1_shapes.rs | 34 +++--- 5 files changed, 132 insertions(+), 57 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1fceb53..7805f2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,10 +13,20 @@ dependencies = [ name = "feder-vocab" version = "0.1.0" dependencies = [ + "iri-string", "serde", "serde_json", ] +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "serde", +] + [[package]] name = "itoa" version = "1.0.18" diff --git a/Cargo.toml b/Cargo.toml index ac5a686..c56d565 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,8 @@ edition = "2024" license = "AGPL-3.0-only" [workspace.dependencies] -serde = { version = "1.0.219", features = ["derive"] } +iri-string = { version = "0.7.12", default-features = false, features = ["alloc", "serde"] } +serde = { version = "1.0.219", default-features = false, features = ["alloc", "derive"] } serde_json = "1.0.140" [workspace.lints.rust] diff --git a/crates/feder-vocab/Cargo.toml b/crates/feder-vocab/Cargo.toml index 4c9957a..4b6a648 100644 --- a/crates/feder-vocab/Cargo.toml +++ b/crates/feder-vocab/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true license.workspace = true [dependencies] +iri-string.workspace = true serde.workspace = true [dev-dependencies] diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index c10410d..64915cf 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -1,16 +1,21 @@ //! Minimal Activity Vocabulary types for Feder. +#![no_std] //! //! This crate models ActivityPub/ActivityStreams protocol data only. It does //! not fetch remote objects, read or write storage, deliver activities, or own //! core decision logic. +extern crate alloc; + +use alloc::{boxed::Box, string::String, vec::Vec}; +use iri_string::types::IriString; use serde::{Deserialize, Serialize}; /// The canonical Activity Streams JSON-LD context URL. pub const ACTIVITYSTREAMS_CONTEXT: &str = "https://www.w3.org/ns/activitystreams"; /// An absolute ActivityPub/ActivityStreams identifier. -pub type Iri = String; +pub type Iri = IriString; /// A non-scalar ActivityStreams property value. /// @@ -25,8 +30,8 @@ pub enum Reference { impl Reference { #[must_use] - pub fn id(id: impl Into) -> Self { - Self::Id(id.into()) + pub fn id(id: Iri) -> Self { + Self::Id(id) } #[must_use] @@ -68,19 +73,28 @@ macro_rules! activitystreams_type { }; } -activitystreams_type!(PersonType, Person); activitystreams_type!(NoteType, Note); activitystreams_type!(FollowType, Follow); activitystreams_type!(AcceptType, Accept); activitystreams_type!(CreateType, Create); +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub enum ActorType { + Application, + Group, + Organization, + #[default] + Person, + Service, +} + /// A minimal ActivityPub actor for Phase 1 core tests. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct Actor { #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] pub context: Option, #[serde(rename = "type")] - pub kind: PersonType, + pub kind: ActorType, pub id: Iri, pub inbox: Iri, pub outbox: Iri, @@ -92,13 +106,22 @@ pub struct Actor { impl Actor { #[must_use] - pub fn person(id: impl Into, inbox: impl Into, outbox: impl Into) -> Self { + pub fn person(id: Iri, inbox: Iri, outbox: Iri) -> Self { + Self::new(ActorType::Person, id, inbox, outbox) + } + + #[must_use] + pub fn new(kind: ActorType, id: Iri, inbox: Iri, outbox: Iri) -> Self { Self { - context: Some(ACTIVITYSTREAMS_CONTEXT.to_string()), - kind: PersonType::default(), - id: id.into(), - inbox: inbox.into(), - outbox: outbox.into(), + context: Some( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ), + kind, + id, + inbox, + outbox, preferred_username: None, name: None, } @@ -123,11 +146,15 @@ pub struct Note { impl Note { #[must_use] - pub fn new(id: impl Into) -> Self { + pub fn new(id: Iri) -> Self { Self { - context: Some(ACTIVITYSTREAMS_CONTEXT.to_string()), + context: Some( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ), kind: NoteType::default(), - id: id.into(), + id, attributed_to: None, content: None, published: None, @@ -149,11 +176,15 @@ pub struct Follow { impl Follow { #[must_use] - pub fn new(id: impl Into, actor: Reference, object: Reference) -> Self { + pub fn new(id: Iri, actor: Reference, object: Reference) -> Self { Self { - context: Some(ACTIVITYSTREAMS_CONTEXT.to_string()), + context: Some( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ), kind: FollowType::default(), - id: id.into(), + id, actor, object, } @@ -174,11 +205,15 @@ pub struct Accept { impl Accept { #[must_use] - pub fn new(id: impl Into, actor: Reference, object: Reference) -> Self { + pub fn new(id: Iri, actor: Reference, object: Reference) -> Self { Self { - context: Some(ACTIVITYSTREAMS_CONTEXT.to_string()), + context: Some( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ), kind: AcceptType::default(), - id: id.into(), + id, actor, object, } @@ -199,11 +234,15 @@ pub struct Create { impl Create { #[must_use] - pub fn new(id: impl Into, actor: Reference, object: Reference) -> Self { + pub fn new(id: Iri, actor: Reference, object: Reference) -> Self { Self { - context: Some(ACTIVITYSTREAMS_CONTEXT.to_string()), + context: Some( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ), kind: CreateType::default(), - id: id.into(), + id, actor, object, } @@ -213,6 +252,7 @@ impl Create { #[cfg(test)] mod tests { use super::*; + use alloc::string::ToString; use serde::de::DeserializeOwned; use serde_json::json; @@ -224,12 +264,16 @@ mod tests { serde_json::from_str(&json).expect("deserialize activitystreams value") } + fn iri(value: &str) -> Iri { + value.parse().expect("valid test IRI") + } + #[test] fn actor_roundtrips_json() { let mut actor = Actor::person( - "https://example.com/users/alice", - "https://example.com/users/alice/inbox", - "https://example.com/users/alice/outbox", + iri("https://example.com/users/alice"), + iri("https://example.com/users/alice/inbox"), + iri("https://example.com/users/alice/outbox"), ); actor.preferred_username = Some("alice".to_string()); actor.name = Some("Alice".to_string()); @@ -250,20 +294,36 @@ mod tests { })) .expect("deserialize actor from json"); - assert_eq!(actor.id, "https://example.com/users/alice"); + assert_eq!(actor.id, iri("https://example.com/users/alice")); assert_eq!(actor.preferred_username, Some("alice".to_string())); } + #[test] + fn actor_deserializes_non_person_activitypub_json() { + let actor: Actor = serde_json::from_value(json!({ + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Service", + "id": "https://example.com/actors/service", + "inbox": "https://example.com/actors/service/inbox", + "outbox": "https://example.com/actors/service/outbox", + "name": "Feder Service" + })) + .expect("deserialize service actor from json"); + + assert_eq!(actor.kind, ActorType::Service); + assert_eq!(actor.id, iri("https://example.com/actors/service")); + } + #[test] fn follow_and_accept_roundtrip_json() { let follow = Follow::new( - "https://remote.example/activities/follow/1", - Reference::id("https://remote.example/users/bob"), - Reference::id("https://example.com/users/alice"), + iri("https://remote.example/activities/follow/1"), + Reference::id(iri("https://remote.example/users/bob")), + Reference::id(iri("https://example.com/users/alice")), ); let accept = Accept::new( - "https://example.com/activities/accept/1", - Reference::id("https://example.com/users/alice"), + iri("https://example.com/activities/accept/1"), + Reference::id(iri("https://example.com/users/alice")), Reference::object(follow), ); @@ -272,14 +332,14 @@ mod tests { #[test] fn create_note_roundtrips_json() { - let mut note = Note::new("https://example.com/notes/1"); - note.attributed_to = Some(Reference::id("https://example.com/users/alice")); + let mut note = Note::new(iri("https://example.com/notes/1")); + note.attributed_to = Some(Reference::id(iri("https://example.com/users/alice"))); note.content = Some("Hello, fediverse.".to_string()); note.published = Some("2026-05-29T06:30:00Z".to_string()); let create = Create::new( - "https://example.com/activities/create/1", - Reference::id("https://example.com/users/alice"), + iri("https://example.com/activities/create/1"), + Reference::id(iri("https://example.com/users/alice")), Reference::object(note), ); @@ -308,15 +368,12 @@ mod tests { ])) .expect("deserialize array one-or-many value"); - assert_eq!( - one, - OneOrMany::one("https://example.com/users/alice".to_string()) - ); + assert_eq!(one, OneOrMany::one(iri("https://example.com/users/alice"))); assert_eq!( many, OneOrMany::many([ - "https://example.com/users/alice".to_string(), - "https://example.com/users/bob".to_string() + iri("https://example.com/users/alice"), + iri("https://example.com/users/bob") ]) ); } diff --git a/crates/feder-vocab/tests/phase1_shapes.rs b/crates/feder-vocab/tests/phase1_shapes.rs index ff3e51d..2d572d9 100644 --- a/crates/feder-vocab/tests/phase1_shapes.rs +++ b/crates/feder-vocab/tests/phase1_shapes.rs @@ -5,6 +5,10 @@ fn serialize(value: impl serde::Serialize) -> Value { serde_json::to_value(value).expect("serialize vocab value") } +fn iri(value: &str) -> Iri { + value.parse().expect("valid test IRI") +} + fn incoming_follow_json() -> serde_json::Value { json!({ "@context": ACTIVITYSTREAMS_CONTEXT, @@ -26,10 +30,12 @@ fn follow_activity_accepts_id_or_embedded_actor_references() { let follow: Follow = serde_json::from_value(incoming_follow_json()).expect("deserialize incoming follow"); - assert_eq!(follow.id, "https://remote.example/activities/follow/1"); - assert!(matches!(follow.actor, Reference::Id(id) if id == "https://remote.example/users/bob")); + assert_eq!(follow.id, iri("https://remote.example/activities/follow/1")); + assert!( + matches!(follow.actor, Reference::Id(id) if id == iri("https://remote.example/users/bob")) + ); assert!( - matches!(follow.object, Reference::Object(actor) if actor.id == "https://example.com/users/alice") + matches!(follow.object, Reference::Object(actor) if actor.id == iri("https://example.com/users/alice")) ); } @@ -39,8 +45,8 @@ fn accept_activity_can_embed_follow_activity() { serde_json::from_value(incoming_follow_json()).expect("deserialize incoming follow"); let outgoing_accept = Accept::new( - "https://example.com/activities/accept/1", - Reference::id("https://example.com/users/alice"), + iri("https://example.com/activities/accept/1"), + Reference::id(iri("https://example.com/users/alice")), Reference::object(follow), ); @@ -70,14 +76,14 @@ fn accept_activity_can_embed_follow_activity() { #[test] fn local_note_can_shape_create_note_activity() { - let mut note = Note::new("https://example.com/notes/1"); - note.attributed_to = Some(Reference::id("https://example.com/users/alice")); + let mut note = Note::new(iri("https://example.com/notes/1")); + note.attributed_to = Some(Reference::id(iri("https://example.com/users/alice"))); note.content = Some("Hello from Feder.".to_string()); note.published = Some("2026-06-02T00:00:00Z".to_string()); let create = Create::new( - "https://example.com/activities/create/1", - Reference::id("https://example.com/users/alice"), + iri("https://example.com/activities/create/1"), + Reference::id(iri("https://example.com/users/alice")), Reference::object(note), ); @@ -111,9 +117,9 @@ fn reference_keeps_id_and_embedded_object_shapes_distinct() { })) .expect("deserialize embedded object reference"); - assert!(matches!(id_reference, Reference::Id(id) if id == "https://example.com/notes/1")); + assert!(matches!(id_reference, Reference::Id(id) if id == iri("https://example.com/notes/1"))); assert!( - matches!(object_reference, Reference::Object(note) if note.id == "https://example.com/notes/1") + matches!(object_reference, Reference::Object(note) if note.id == iri("https://example.com/notes/1")) ); } @@ -130,13 +136,13 @@ fn one_or_many_can_represent_common_recipient_shapes() { assert_eq!( single, - feder_vocab::OneOrMany::one("https://www.w3.org/ns/activitystreams#Public".to_string()) + feder_vocab::OneOrMany::one(iri("https://www.w3.org/ns/activitystreams#Public")) ); assert_eq!( multiple, feder_vocab::OneOrMany::many([ - "https://www.w3.org/ns/activitystreams#Public".to_string(), - "https://example.com/users/alice/followers".to_string() + iri("https://www.w3.org/ns/activitystreams#Public"), + iri("https://example.com/users/alice/followers") ]) ); } From a6e68cb0bae5c766ae8c2ed0e4e9e8937f3f4ddd Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 10 Jun 2026 14:46:07 +0900 Subject: [PATCH 03/17] Refine multi-value vocab references --- crates/feder-vocab/src/lib.rs | 141 +++++++++++++++++++--- crates/feder-vocab/tests/phase1_shapes.rs | 40 +++++- 2 files changed, 156 insertions(+), 25 deletions(-) diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index 64915cf..0d7a516 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -9,7 +9,7 @@ extern crate alloc; use alloc::{boxed::Box, string::String, vec::Vec}; use iri_string::types::IriString; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, ser::SerializeSeq}; /// The canonical Activity Streams JSON-LD context URL. pub const ACTIVITYSTREAMS_CONTEXT: &str = "https://www.w3.org/ns/activitystreams"; @@ -40,26 +40,105 @@ impl Reference { } } -/// A property value that can appear either once or multiple times. +/// Zero or more ActivityStreams property values. /// -/// ActivityStreams commonly allows fields to be absent, scalar, or arrays. -/// Absence is represented by `Option>` on the containing type. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(untagged)] -pub enum OneOrMany { - One(T), - Many(Vec), +/// Use this with `#[serde(default, skip_serializing_if = "References::is_empty")]` +/// on containing fields. Empty values then serialize as absent, one value +/// serializes as a scalar, and multiple values serialize as an array. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct References { + values: Vec, +} + +impl Default for References { + fn default() -> Self { + Self::new() + } } -impl OneOrMany { +impl References { + #[must_use] + pub fn new() -> Self { + Self { values: Vec::new() } + } + #[must_use] pub fn one(value: T) -> Self { - Self::One(value) + Self { + values: Vec::from([value]), + } } #[must_use] pub fn many(values: impl Into>) -> Self { - Self::Many(values.into()) + Self { + values: values.into(), + } + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } + + #[must_use] + pub fn len(&self) -> usize { + self.values.len() + } + + pub fn iter(&self) -> core::slice::Iter<'_, T> { + self.values.iter() + } + + pub fn into_vec(self) -> Vec { + self.values + } +} + +impl From> for References { + fn from(values: Vec) -> Self { + Self::many(values) + } +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum OneOrMany { + One(T), + Many(Vec), +} + +impl Serialize for References +where + T: Serialize, +{ + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self.values.as_slice() { + [] => { + let sequence = serializer.serialize_seq(Some(0))?; + sequence.end() + } + [value] => value.serialize(serializer), + values => values.serialize(serializer), + } + } +} + +impl<'de, T> Deserialize<'de> for References +where + T: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + match OneOrMany::deserialize(deserializer)? { + OneOrMany::One(value) => Ok(References::one(value)), + OneOrMany::Many(values) => Ok(References::many(values)), + } } } @@ -359,21 +438,45 @@ mod tests { } #[test] - fn one_or_many_deserializes_scalar_and_array() { - let one: OneOrMany = serde_json::from_value(json!("https://example.com/users/alice")) - .expect("deserialize scalar one-or-many value"); - let many: OneOrMany = serde_json::from_value(json!([ + fn references_deserializes_scalar_and_array() { + let one: References = serde_json::from_value(json!("https://example.com/users/alice")) + .expect("deserialize scalar references value"); + let many: References = serde_json::from_value(json!([ "https://example.com/users/alice", "https://example.com/users/bob" ])) - .expect("deserialize array one-or-many value"); + .expect("deserialize array references value"); - assert_eq!(one, OneOrMany::one(iri("https://example.com/users/alice"))); + assert_eq!(one, References::one(iri("https://example.com/users/alice"))); assert_eq!( many, - OneOrMany::many([ + References::many([ + iri("https://example.com/users/alice"), + iri("https://example.com/users/bob") + ]) + ); + } + + #[test] + fn references_serializes_empty_one_and_many() { + assert_eq!( + serde_json::to_value(References::::new()).expect("serialize empty references"), + json!([]) + ); + assert_eq!( + serde_json::to_value(References::one(iri("https://example.com/users/alice"))) + .expect("serialize one reference"), + json!("https://example.com/users/alice") + ); + assert_eq!( + serde_json::to_value(References::many([ iri("https://example.com/users/alice"), iri("https://example.com/users/bob") + ])) + .expect("serialize many references"), + json!([ + "https://example.com/users/alice", + "https://example.com/users/bob" ]) ); } diff --git a/crates/feder-vocab/tests/phase1_shapes.rs b/crates/feder-vocab/tests/phase1_shapes.rs index 2d572d9..371e682 100644 --- a/crates/feder-vocab/tests/phase1_shapes.rs +++ b/crates/feder-vocab/tests/phase1_shapes.rs @@ -1,4 +1,6 @@ -use feder_vocab::{ACTIVITYSTREAMS_CONTEXT, Accept, Create, Follow, Iri, Note, Reference}; +use feder_vocab::{ + ACTIVITYSTREAMS_CONTEXT, Accept, Create, Follow, Iri, Note, Reference, References, +}; use serde_json::{Value, json}; fn serialize(value: impl serde::Serialize) -> Value { @@ -124,11 +126,11 @@ fn reference_keeps_id_and_embedded_object_shapes_distinct() { } #[test] -fn one_or_many_can_represent_common_recipient_shapes() { - let single: feder_vocab::OneOrMany = +fn references_can_represent_common_recipient_shapes() { + let single: References = serde_json::from_value(json!("https://www.w3.org/ns/activitystreams#Public")) .expect("deserialize single recipient"); - let multiple: feder_vocab::OneOrMany = serde_json::from_value(json!([ + let multiple: References = serde_json::from_value(json!([ "https://www.w3.org/ns/activitystreams#Public", "https://example.com/users/alice/followers" ])) @@ -136,13 +138,39 @@ fn one_or_many_can_represent_common_recipient_shapes() { assert_eq!( single, - feder_vocab::OneOrMany::one(iri("https://www.w3.org/ns/activitystreams#Public")) + References::one(iri("https://www.w3.org/ns/activitystreams#Public")) ); assert_eq!( multiple, - feder_vocab::OneOrMany::many([ + References::many([ iri("https://www.w3.org/ns/activitystreams#Public"), iri("https://example.com/users/alice/followers") ]) ); } + +#[test] +fn references_treat_absent_and_empty_array_as_empty() { + #[derive(Debug, PartialEq, serde::Deserialize, serde::Serialize)] + struct Recipients { + #[serde(default, skip_serializing_if = "References::is_empty")] + to: References, + } + + let absent: Recipients = serde_json::from_value(json!({})).expect("deserialize absent field"); + let empty: Recipients = + serde_json::from_value(json!({ "to": [] })).expect("deserialize empty field"); + let one = Recipients { + to: References::one(iri("https://www.w3.org/ns/activitystreams#Public")), + }; + + assert_eq!(absent, empty); + assert_eq!( + serde_json::to_value(absent).expect("serialize absent"), + json!({}) + ); + assert_eq!( + serde_json::to_value(one).expect("serialize one recipient"), + json!({ "to": "https://www.w3.org/ns/activitystreams#Public" }) + ); +} From de60a5dc29d69d126f72dce8308a275b997b8fc6 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 10 Jun 2026 23:33:02 +0900 Subject: [PATCH 04/17] Add core input action boundary Assisted-by: Codex:gpt-5.5 --- crates/feder-core/src/lib.rs | 157 +++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index b82752b..5a13a69 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -1,3 +1,160 @@ //! Portable ActivityPub core logic for Feder. +#![no_std] + +extern crate alloc; + +use alloc::{string::String, vec::Vec}; pub use feder_vocab as vocab; + +/// Portable core state and decision logic. +#[derive(Debug, Default)] +pub struct FederCore; + +impl FederCore { + #[must_use] + pub fn new() -> Self { + Self + } + + /// Handle one core input and return runtime actions to perform later. + /// + /// This method intentionally performs no I/O. Follow acceptance, object + /// storage, and delivery behavior are added by later Phase 1 issues. + #[must_use] + pub fn handle(&mut self, input: Input) -> HandleResult { + match input { + Input::ReceivedFollow(_) | Input::UserCreateNote(_) => HandleResult::default(), + } + } +} + +/// Something entering the portable core from a runtime. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Input { + ReceivedFollow(vocab::Follow), + UserCreateNote(UserCreateNote), +} + +/// Runtime-provided data for creating a local note. +/// +/// IDs and timestamps are inputs so the core does not depend on clocks, +/// randomness, or platform-specific ID generation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserCreateNote { + pub note_id: vocab::Iri, + pub create_id: vocab::Iri, + pub actor: vocab::Reference, + pub content: String, + pub published: Option, +} + +/// Something the runtime should perform after core handling. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Action { + StoreFollower(StoreFollower), + StoreObject(StoreObject), + SendActivity(SendActivity), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StoreFollower { + pub actor: vocab::Reference, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StoreObject { + pub object: Object, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SendActivity { + pub activity: Activity, + pub target: vocab::Iri, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Activity { + Accept(vocab::Accept), + CreateNote(vocab::Create), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Object { + Note(vocab::Note), +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct HandleResult { + pub actions: Vec, +} + +impl HandleResult { + #[must_use] + pub fn new(actions: Vec) -> Self { + Self { actions } + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.actions.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::format; + use alloc::string::ToString; + + fn iri(value: &str) -> vocab::Iri { + value.parse().expect("valid test IRI") + } + + fn actor(id: &str) -> vocab::Actor { + vocab::Actor::person( + iri(id), + iri(&format!("{id}/inbox")), + iri(&format!("{id}/outbox")), + ) + } + + #[test] + fn received_follow_enters_core_without_runtime_io() { + let mut core = FederCore::new(); + let follow = vocab::Follow::new( + iri("https://remote.example/activities/follow/1"), + vocab::Reference::id(iri("https://remote.example/users/bob")), + vocab::Reference::object(actor("https://example.com/users/alice")), + ); + + let result = core.handle(Input::ReceivedFollow(follow)); + + assert!(result.is_empty()); + } + + #[test] + fn user_create_note_input_carries_nondeterministic_values() { + let input = UserCreateNote { + note_id: iri("https://example.com/notes/1"), + create_id: iri("https://example.com/activities/create/1"), + actor: vocab::Reference::id(iri("https://example.com/users/alice")), + content: "Hello from Feder.".to_string(), + published: Some("2026-06-10T00:00:00Z".to_string()), + }; + + let mut core = FederCore::new(); + let result = core.handle(Input::UserCreateNote(input)); + + assert!(result.is_empty()); + } + + #[test] + fn handle_result_wraps_action_lists() { + let result = HandleResult::new(Vec::from([Action::StoreFollower(StoreFollower { + actor: vocab::Reference::id(iri("https://remote.example/users/bob")), + })])); + + assert_eq!(result.actions.len(), 1); + } +} From 7af157ea18975e9a17100467e5000d7df1405abd Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Thu, 11 Jun 2026 14:54:19 +0900 Subject: [PATCH 05/17] Clarify core action payloads Assisted-by: Codex:gpt-5.5 --- crates/feder-core/src/lib.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 5a13a69..2c82259 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -51,6 +51,7 @@ pub struct UserCreateNote { /// Something the runtime should perform after core handling. #[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] pub enum Action { StoreFollower(StoreFollower), StoreObject(StoreObject), @@ -59,7 +60,8 @@ pub enum Action { #[derive(Clone, Debug, Eq, PartialEq)] pub struct StoreFollower { - pub actor: vocab::Reference, + pub follower: vocab::Reference, + pub following: vocab::Reference, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -70,16 +72,18 @@ pub struct StoreObject { #[derive(Clone, Debug, Eq, PartialEq)] pub struct SendActivity { pub activity: Activity, - pub target: vocab::Iri, + pub inbox: vocab::Iri, } #[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] pub enum Activity { Accept(vocab::Accept), CreateNote(vocab::Create), } #[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] pub enum Object { Note(vocab::Note), } @@ -152,7 +156,8 @@ mod tests { #[test] fn handle_result_wraps_action_lists() { let result = HandleResult::new(Vec::from([Action::StoreFollower(StoreFollower { - actor: vocab::Reference::id(iri("https://remote.example/users/bob")), + follower: vocab::Reference::id(iri("https://remote.example/users/bob")), + following: vocab::Reference::id(iri("https://example.com/users/alice")), })])); assert_eq!(result.actions.len(), 1); From 50baf2e5aa286bb9dbada0b47016b4bb3efb82d2 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Fri, 12 Jun 2026 12:12:57 +0900 Subject: [PATCH 06/17] Mark core inputs non-exhaustive Assisted-by: Codex:gpt-5.5 --- crates/feder-core/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 2c82259..7d4bc37 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -31,6 +31,7 @@ impl FederCore { /// Something entering the portable core from a runtime. #[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] pub enum Input { ReceivedFollow(vocab::Follow), UserCreateNote(UserCreateNote), From 65404c07c98f5630493a22a2ee944133fc747388 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Fri, 12 Jun 2026 16:29:47 +0900 Subject: [PATCH 07/17] Add minimal core state Assisted-by: Codex:gpt-5.5 --- crates/feder-core/src/lib.rs | 293 +++++++++++++++++++++++++++++++++-- 1 file changed, 281 insertions(+), 12 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 7d4bc37..6c0619d 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -8,24 +8,179 @@ use alloc::{string::String, vec::Vec}; pub use feder_vocab as vocab; /// Portable core state and decision logic. -#[derive(Debug, Default)] -pub struct FederCore; +#[derive(Debug)] +pub struct FederCore { + state: FederState, +} impl FederCore { #[must_use] - pub fn new() -> Self { - Self + pub fn new(config: FederConfig) -> Self { + Self { + state: FederState::new(config), + } + } + + #[must_use] + pub fn state(&self) -> &FederState { + &self.state } /// Handle one core input and return runtime actions to perform later. /// - /// This method intentionally performs no I/O. Follow acceptance, object - /// storage, and delivery behavior are added by later Phase 1 issues. + /// This method intentionally performs no I/O. Follow acceptance and delivery + /// behavior are added by later Phase 1 issues. #[must_use] pub fn handle(&mut self, input: Input) -> HandleResult { match input { - Input::ReceivedFollow(_) | Input::UserCreateNote(_) => HandleResult::default(), + Input::ReceivedFollow(follow) => { + self.state.record_follow(follow); + HandleResult::default() + } + Input::UserCreateNote(input) => { + self.state.record_created_note(input); + HandleResult::default() + } + } + } +} + +/// Runtime-provided configuration for portable core state. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FederConfig { + pub local_actor: vocab::Actor, +} + +impl FederConfig { + #[must_use] + pub fn new(local_actor: vocab::Actor) -> Self { + Self { local_actor } + } +} + +/// In-memory state used by Phase 1 core flows. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FederState { + local_actor: vocab::Actor, + followers: Vec, + delivery_targets: Vec, + objects: Vec, + activities: Vec, +} + +impl FederState { + #[must_use] + pub fn new(config: FederConfig) -> Self { + Self { + local_actor: config.local_actor, + followers: Vec::new(), + delivery_targets: Vec::new(), + objects: Vec::new(), + activities: Vec::new(), + } + } + + #[must_use] + pub fn local_actor(&self) -> &vocab::Actor { + &self.local_actor + } + + #[must_use] + pub fn followers(&self) -> &[Follower] { + &self.followers + } + + #[must_use] + pub fn delivery_targets(&self) -> &[DeliveryTarget] { + &self.delivery_targets + } + + #[must_use] + pub fn objects(&self) -> &[Object] { + &self.objects + } + + #[must_use] + pub fn activities(&self) -> &[Activity] { + &self.activities + } + + fn record_follow(&mut self, follow: vocab::Follow) { + let Some(following) = reference_id(&follow.object).cloned() else { + return; + }; + + if following != self.local_actor.id { + return; } + + let Some(follower) = reference_id(&follow.actor).cloned() else { + return; + }; + + let relation = Follower { + follower: follower.clone(), + following, + }; + + if !self.followers.contains(&relation) { + self.followers.push(relation); + } + + if let vocab::Reference::Object(actor) = follow.actor { + let target = DeliveryTarget { + actor: follower, + inbox: actor.inbox, + }; + + if !self.delivery_targets.contains(&target) { + self.delivery_targets.push(target); + } + } + } + + fn record_created_note(&mut self, input: UserCreateNote) { + let Some(actor) = reference_id(&input.actor) else { + return; + }; + + if actor != &self.local_actor.id { + return; + } + + let mut note = vocab::Note::new(input.note_id); + note.attributed_to = Some(input.actor.clone()); + note.content = Some(input.content); + note.published = input.published; + + let create = vocab::Create::new( + input.create_id, + input.actor, + vocab::Reference::object(note.clone()), + ); + + self.objects.push(Object::Note(note)); + self.activities.push(Activity::CreateNote(create)); + } +} + +fn reference_id(reference: &vocab::Reference) -> Option<&vocab::Iri> +where + T: HasId, +{ + match reference { + vocab::Reference::Id(id) => Some(id), + vocab::Reference::Object(object) => Some(object.id()), + } +} + +trait HasId { + fn id(&self) -> &vocab::Iri; +} + +impl HasId for vocab::Actor { + fn id(&self) -> &vocab::Iri { + &self.id } } @@ -50,6 +205,18 @@ pub struct UserCreateNote { pub published: Option, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Follower { + pub follower: vocab::Iri, + pub following: vocab::Iri, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeliveryTarget { + pub actor: vocab::Iri, + pub inbox: vocab::Iri, +} + /// Something the runtime should perform after core handling. #[derive(Clone, Debug, Eq, PartialEq)] #[non_exhaustive] @@ -124,22 +291,92 @@ mod tests { ) } + fn core() -> FederCore { + FederCore::new(FederConfig::new(actor("https://example.com/users/alice"))) + } + + #[test] + fn core_is_created_with_local_actor_state() { + let core = core(); + + assert_eq!( + core.state().local_actor().id, + iri("https://example.com/users/alice") + ); + assert!(core.state().followers().is_empty()); + assert!(core.state().delivery_targets().is_empty()); + assert!(core.state().objects().is_empty()); + assert!(core.state().activities().is_empty()); + } + + #[test] + fn received_follow_updates_followers_and_delivery_targets() { + let mut core = core(); + let follow = vocab::Follow::new( + iri("https://remote.example/activities/follow/1"), + vocab::Reference::object(actor("https://remote.example/users/bob")), + vocab::Reference::id(iri("https://example.com/users/alice")), + ); + + let result = core.handle(Input::ReceivedFollow(follow)); + + assert!(result.is_empty()); + assert_eq!( + core.state().followers(), + &[Follower { + follower: iri("https://remote.example/users/bob"), + following: iri("https://example.com/users/alice"), + }] + ); + assert_eq!( + core.state().delivery_targets(), + &[DeliveryTarget { + actor: iri("https://remote.example/users/bob"), + inbox: iri("https://remote.example/users/bob/inbox"), + }] + ); + } + #[test] - fn received_follow_enters_core_without_runtime_io() { - let mut core = FederCore::new(); + fn received_follow_with_actor_id_records_follower_without_delivery_target() { + let mut core = core(); let follow = vocab::Follow::new( iri("https://remote.example/activities/follow/1"), vocab::Reference::id(iri("https://remote.example/users/bob")), - vocab::Reference::object(actor("https://example.com/users/alice")), + vocab::Reference::id(iri("https://example.com/users/alice")), ); let result = core.handle(Input::ReceivedFollow(follow)); assert!(result.is_empty()); + assert_eq!( + core.state().followers(), + &[Follower { + follower: iri("https://remote.example/users/bob"), + following: iri("https://example.com/users/alice"), + }] + ); + assert!(core.state().delivery_targets().is_empty()); } #[test] - fn user_create_note_input_carries_nondeterministic_values() { + fn received_follow_for_other_actor_is_ignored() { + let mut core = core(); + let follow = vocab::Follow::new( + iri("https://remote.example/activities/follow/1"), + vocab::Reference::object(actor("https://remote.example/users/bob")), + vocab::Reference::id(iri("https://example.com/users/other")), + ); + + let result = core.handle(Input::ReceivedFollow(follow)); + + assert!(result.is_empty()); + assert!(core.state().followers().is_empty()); + assert!(core.state().delivery_targets().is_empty()); + } + + #[test] + fn user_create_note_records_created_object_and_activity() { let input = UserCreateNote { note_id: iri("https://example.com/notes/1"), create_id: iri("https://example.com/activities/create/1"), @@ -148,10 +385,42 @@ mod tests { published: Some("2026-06-10T00:00:00Z".to_string()), }; - let mut core = FederCore::new(); + let mut core = core(); + let result = core.handle(Input::UserCreateNote(input)); + + assert!(result.is_empty()); + assert_eq!(core.state().objects().len(), 1); + assert_eq!(core.state().activities().len(), 1); + + let Object::Note(note) = &core.state().objects()[0]; + assert_eq!(note.id, iri("https://example.com/notes/1")); + assert_eq!(note.content, Some("Hello from Feder.".to_string())); + assert_eq!(note.published, Some("2026-06-10T00:00:00Z".to_string())); + + match &core.state().activities()[0] { + Activity::CreateNote(create) => { + assert_eq!(create.id, iri("https://example.com/activities/create/1")); + } + Activity::Accept(_) => panic!("expected Create activity"), + } + } + + #[test] + fn user_create_note_for_non_local_actor_is_ignored() { + let input = UserCreateNote { + note_id: iri("https://remote.example/notes/1"), + create_id: iri("https://remote.example/activities/create/1"), + actor: vocab::Reference::id(iri("https://remote.example/users/bob")), + content: "Hello from elsewhere.".to_string(), + published: Some("2026-06-10T00:00:00Z".to_string()), + }; + + let mut core = core(); let result = core.handle(Input::UserCreateNote(input)); assert!(result.is_empty()); + assert!(core.state().objects().is_empty()); + assert!(core.state().activities().is_empty()); } #[test] From 349e12f486890491242739585f5c40111c43163b Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 16 Jun 2026 11:17:53 +0900 Subject: [PATCH 08/17] Normalize local note actor references Assisted-by: Codex:gpt-5.5 --- crates/feder-core/src/lib.rs | 47 ++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 6c0619d..4a1098c 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -148,14 +148,16 @@ impl FederState { return; } + let actor = vocab::Reference::id(self.local_actor.id.clone()); + let mut note = vocab::Note::new(input.note_id); - note.attributed_to = Some(input.actor.clone()); + note.attributed_to = Some(actor.clone()); note.content = Some(input.content); note.published = input.published; let create = vocab::Create::new( input.create_id, - input.actor, + actor, vocab::Reference::object(note.clone()), ); @@ -394,17 +396,58 @@ mod tests { let Object::Note(note) = &core.state().objects()[0]; assert_eq!(note.id, iri("https://example.com/notes/1")); + assert_eq!( + note.attributed_to, + Some(vocab::Reference::id(iri("https://example.com/users/alice"))) + ); assert_eq!(note.content, Some("Hello from Feder.".to_string())); assert_eq!(note.published, Some("2026-06-10T00:00:00Z".to_string())); match &core.state().activities()[0] { Activity::CreateNote(create) => { assert_eq!(create.id, iri("https://example.com/activities/create/1")); + assert_eq!( + create.actor, + vocab::Reference::id(iri("https://example.com/users/alice")) + ); } Activity::Accept(_) => panic!("expected Create activity"), } } + #[test] + fn user_create_note_normalizes_embedded_local_actor_to_local_actor_id() { + let mut supplied_actor = actor("https://example.com/users/alice"); + supplied_actor.inbox = iri("https://untrusted.example/inbox"); + + let input = UserCreateNote { + note_id: iri("https://example.com/notes/1"), + create_id: iri("https://example.com/activities/create/1"), + actor: vocab::Reference::object(supplied_actor), + content: "Hello from Feder.".to_string(), + published: None, + }; + + let mut core = core(); + let result = core.handle(Input::UserCreateNote(input)); + + assert!(result.is_empty()); + + let Object::Note(note) = &core.state().objects()[0]; + assert_eq!( + note.attributed_to, + Some(vocab::Reference::id(iri("https://example.com/users/alice"))) + ); + + let Activity::CreateNote(create) = &core.state().activities()[0] else { + panic!("expected Create activity"); + }; + assert_eq!( + create.actor, + vocab::Reference::id(iri("https://example.com/users/alice")) + ); + } + #[test] fn user_create_note_for_non_local_actor_is_ignored() { let input = UserCreateNote { From f594690593935307b868f7c0f4b4f60b1ee68980 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 17 Jun 2026 00:02:35 +0900 Subject: [PATCH 09/17] Document known delivery targets Assisted-by: Codex:gpt-5.5 --- crates/feder-core/src/lib.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 4a1098c..3011192 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -91,6 +91,10 @@ impl FederState { } #[must_use] + /// Delivery targets known from embedded actor data. + /// + /// ID-only followers are tracked in `followers`, but they do not produce a + /// delivery target until a runtime or later core flow resolves actor data. pub fn delivery_targets(&self) -> &[DeliveryTarget] { &self.delivery_targets } @@ -213,6 +217,10 @@ pub struct Follower { pub following: vocab::Iri, } +/// A known actor inbox for future delivery. +/// +/// Phase 1 records this only when an incoming object embeds enough actor data +/// to expose an inbox. It does not imply every follower has been resolved. #[derive(Clone, Debug, Eq, PartialEq)] pub struct DeliveryTarget { pub actor: vocab::Iri, From a332df7d552b5cc3b6703694b11378613d6e77b0 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 17 Jun 2026 02:50:35 +0900 Subject: [PATCH 10/17] Update delivery targets by actor Assisted-by: Codex:gpt-5.5 --- crates/feder-core/src/lib.rs | 44 +++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 3011192..46a3d63 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -137,7 +137,13 @@ impl FederState { inbox: actor.inbox, }; - if !self.delivery_targets.contains(&target) { + if let Some(existing) = self + .delivery_targets + .iter_mut() + .find(|existing| existing.actor == target.actor) + { + existing.inbox = target.inbox; + } else { self.delivery_targets.push(target); } } @@ -347,6 +353,42 @@ mod tests { ); } + #[test] + fn received_follow_updates_existing_delivery_target_by_actor() { + let mut core = core(); + let first_follow = vocab::Follow::new( + iri("https://remote.example/activities/follow/1"), + vocab::Reference::object(actor("https://remote.example/users/bob")), + vocab::Reference::id(iri("https://example.com/users/alice")), + ); + + let mut updated_actor = actor("https://remote.example/users/bob"); + updated_actor.inbox = iri("https://remote.example/inboxes/bob"); + let second_follow = vocab::Follow::new( + iri("https://remote.example/activities/follow/2"), + vocab::Reference::object(updated_actor), + vocab::Reference::id(iri("https://example.com/users/alice")), + ); + + assert!(core.handle(Input::ReceivedFollow(first_follow)).is_empty()); + assert!(core.handle(Input::ReceivedFollow(second_follow)).is_empty()); + + assert_eq!( + core.state().followers(), + &[Follower { + follower: iri("https://remote.example/users/bob"), + following: iri("https://example.com/users/alice"), + }] + ); + assert_eq!( + core.state().delivery_targets(), + &[DeliveryTarget { + actor: iri("https://remote.example/users/bob"), + inbox: iri("https://remote.example/inboxes/bob"), + }] + ); + } + #[test] fn received_follow_with_actor_id_records_follower_without_delivery_target() { let mut core = core(); From 684a2f96844e9c72e7f3ffbc74285981ba63871b Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 17 Jun 2026 03:12:10 +0900 Subject: [PATCH 11/17] Emit accept actions for follows Assisted-by: Codex:gpt-5.5 --- crates/feder-core/src/lib.rs | 141 +++++++++++++++++++++++++++++------ 1 file changed, 120 insertions(+), 21 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 46a3d63..c38633d 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -28,14 +28,14 @@ impl FederCore { /// Handle one core input and return runtime actions to perform later. /// - /// This method intentionally performs no I/O. Follow acceptance and delivery - /// behavior are added by later Phase 1 issues. + /// This method intentionally performs no I/O. Returned actions describe + /// work for a runtime or test harness to perform later. #[must_use] pub fn handle(&mut self, input: Input) -> HandleResult { match input { - Input::ReceivedFollow(follow) => { - self.state.record_follow(follow); - HandleResult::default() + Input::ReceivedFollow(input) => { + let actions = self.state.record_follow(input); + HandleResult::new(actions) } Input::UserCreateNote(input) => { self.state.record_created_note(input); @@ -109,32 +109,45 @@ impl FederState { &self.activities } - fn record_follow(&mut self, follow: vocab::Follow) { + fn record_follow(&mut self, input: ReceivedFollow) -> Vec { + let follow = input.follow; let Some(following) = reference_id(&follow.object).cloned() else { - return; + return Vec::new(); }; if following != self.local_actor.id { - return; + return Vec::new(); } let Some(follower) = reference_id(&follow.actor).cloned() else { - return; + return Vec::new(); }; let relation = Follower { follower: follower.clone(), following, }; + let mut actions = Vec::new(); if !self.followers.contains(&relation) { - self.followers.push(relation); + self.followers.push(relation.clone()); } - if let vocab::Reference::Object(actor) = follow.actor { + actions.push(Action::StoreFollower(StoreFollower { + follower: follow.actor.clone(), + following: follow.object.clone(), + })); + + let mut inbox = self + .delivery_targets + .iter() + .find(|target| target.actor == follower) + .map(|target| target.inbox.clone()); + + if let vocab::Reference::Object(actor) = &follow.actor { let target = DeliveryTarget { actor: follower, - inbox: actor.inbox, + inbox: actor.inbox.clone(), }; if let Some(existing) = self @@ -146,7 +159,24 @@ impl FederState { } else { self.delivery_targets.push(target); } + + inbox = Some(actor.inbox.clone()); + } + + if let Some(inbox) = inbox { + let accept = vocab::Accept::new( + input.accept_id, + vocab::Reference::id(self.local_actor.id.clone()), + vocab::Reference::object(follow), + ); + + actions.push(Action::SendActivity(SendActivity { + activity: Activity::Accept(accept), + inbox, + })); } + + actions } fn record_created_note(&mut self, input: UserCreateNote) { @@ -200,10 +230,20 @@ impl HasId for vocab::Actor { #[derive(Clone, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum Input { - ReceivedFollow(vocab::Follow), + ReceivedFollow(ReceivedFollow), UserCreateNote(UserCreateNote), } +/// Runtime-provided data for handling a received Follow. +/// +/// The Accept activity ID is an input so the core does not depend on clocks, +/// randomness, or platform-specific ID generation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReceivedFollow { + pub follow: vocab::Follow, + pub accept_id: vocab::Iri, +} + /// Runtime-provided data for creating a local note. /// /// IDs and timestamps are inputs so the core does not depend on clocks, @@ -311,6 +351,13 @@ mod tests { FederCore::new(FederConfig::new(actor("https://example.com/users/alice"))) } + fn received_follow(follow: vocab::Follow, id: &str) -> Input { + Input::ReceivedFollow(ReceivedFollow { + follow, + accept_id: iri(id), + }) + } + #[test] fn core_is_created_with_local_actor_state() { let core = core(); @@ -326,7 +373,7 @@ mod tests { } #[test] - fn received_follow_updates_followers_and_delivery_targets() { + fn received_follow_records_follower_and_emits_accept_actions() { let mut core = core(); let follow = vocab::Follow::new( iri("https://remote.example/activities/follow/1"), @@ -334,9 +381,12 @@ mod tests { vocab::Reference::id(iri("https://example.com/users/alice")), ); - let result = core.handle(Input::ReceivedFollow(follow)); + let result = core.handle(received_follow( + follow, + "https://example.com/activities/accept/1", + )); - assert!(result.is_empty()); + assert_eq!(result.actions.len(), 2); assert_eq!( core.state().followers(), &[Follower { @@ -351,6 +401,34 @@ mod tests { inbox: iri("https://remote.example/users/bob/inbox"), }] ); + assert_eq!( + result.actions[0], + Action::StoreFollower(StoreFollower { + follower: vocab::Reference::object(actor("https://remote.example/users/bob")), + following: vocab::Reference::id(iri("https://example.com/users/alice")), + }) + ); + + let Action::SendActivity(send) = &result.actions[1] else { + panic!("expected SendActivity action"); + }; + assert_eq!(send.inbox, iri("https://remote.example/users/bob/inbox")); + + let Activity::Accept(accept) = &send.activity else { + panic!("expected Accept activity"); + }; + assert_eq!(accept.id, iri("https://example.com/activities/accept/1")); + assert_eq!( + accept.actor, + vocab::Reference::id(iri("https://example.com/users/alice")) + ); + let vocab::Reference::Object(accepted_follow) = &accept.object else { + panic!("expected embedded Follow object"); + }; + assert_eq!( + accepted_follow.id, + iri("https://remote.example/activities/follow/1") + ); } #[test] @@ -370,8 +448,17 @@ mod tests { vocab::Reference::id(iri("https://example.com/users/alice")), ); - assert!(core.handle(Input::ReceivedFollow(first_follow)).is_empty()); - assert!(core.handle(Input::ReceivedFollow(second_follow)).is_empty()); + let first_result = core.handle(received_follow( + first_follow, + "https://example.com/activities/accept/1", + )); + let second_result = core.handle(received_follow( + second_follow, + "https://example.com/activities/accept/2", + )); + + assert_eq!(first_result.actions.len(), 2); + assert_eq!(second_result.actions.len(), 2); assert_eq!( core.state().followers(), @@ -398,9 +485,18 @@ mod tests { vocab::Reference::id(iri("https://example.com/users/alice")), ); - let result = core.handle(Input::ReceivedFollow(follow)); + let result = core.handle(received_follow( + follow, + "https://example.com/activities/accept/1", + )); - assert!(result.is_empty()); + assert_eq!( + result.actions, + Vec::from([Action::StoreFollower(StoreFollower { + follower: vocab::Reference::id(iri("https://remote.example/users/bob")), + following: vocab::Reference::id(iri("https://example.com/users/alice")), + })]) + ); assert_eq!( core.state().followers(), &[Follower { @@ -420,7 +516,10 @@ mod tests { vocab::Reference::id(iri("https://example.com/users/other")), ); - let result = core.handle(Input::ReceivedFollow(follow)); + let result = core.handle(received_follow( + follow, + "https://example.com/activities/accept/1", + )); assert!(result.is_empty()); assert!(core.state().followers().is_empty()); From e35007658fd1218ba9867d6daa3f3b0f33a01e77 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 17 Jun 2026 14:08:05 +0900 Subject: [PATCH 12/17] Emit create note actions Assisted-by: Codex:gpt-5.5 --- crates/feder-core/src/lib.rs | 136 ++++++++++++++++++++++++++++++++--- 1 file changed, 126 insertions(+), 10 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index c38633d..3e09a9c 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -38,8 +38,8 @@ impl FederCore { HandleResult::new(actions) } Input::UserCreateNote(input) => { - self.state.record_created_note(input); - HandleResult::default() + let actions = self.state.record_created_note(input); + HandleResult::new(actions) } } } @@ -179,13 +179,13 @@ impl FederState { actions } - fn record_created_note(&mut self, input: UserCreateNote) { + fn record_created_note(&mut self, input: UserCreateNote) -> Vec { let Some(actor) = reference_id(&input.actor) else { - return; + return Vec::new(); }; if actor != &self.local_actor.id { - return; + return Vec::new(); } let actor = vocab::Reference::id(self.local_actor.id.clone()); @@ -201,8 +201,20 @@ impl FederState { vocab::Reference::object(note.clone()), ); - self.objects.push(Object::Note(note)); - self.activities.push(Activity::CreateNote(create)); + let object = Object::Note(note); + self.objects.push(object.clone()); + self.activities.push(Activity::CreateNote(create.clone())); + + let mut actions = Vec::from([Action::StoreObject(StoreObject { object })]); + + actions.extend(self.delivery_targets.iter().map(|target| { + Action::SendActivity(SendActivity { + activity: Activity::CreateNote(create.clone()), + inbox: target.inbox.clone(), + }) + })); + + actions } } @@ -527,7 +539,7 @@ mod tests { } #[test] - fn user_create_note_records_created_object_and_activity() { + fn user_create_note_records_created_object_and_emits_store_action() { let input = UserCreateNote { note_id: iri("https://example.com/notes/1"), create_id: iri("https://example.com/activities/create/1"), @@ -539,7 +551,7 @@ mod tests { let mut core = core(); let result = core.handle(Input::UserCreateNote(input)); - assert!(result.is_empty()); + assert_eq!(result.actions.len(), 1); assert_eq!(core.state().objects().len(), 1); assert_eq!(core.state().activities().len(), 1); @@ -562,6 +574,110 @@ mod tests { } Activity::Accept(_) => panic!("expected Create activity"), } + + assert_eq!( + result.actions[0], + Action::StoreObject(StoreObject { + object: Object::Note(note.clone()), + }) + ); + } + + #[test] + fn user_create_note_emits_create_activity_for_known_delivery_targets() { + let mut core = core(); + let follow = vocab::Follow::new( + iri("https://remote.example/activities/follow/1"), + vocab::Reference::object(actor("https://remote.example/users/bob")), + vocab::Reference::id(iri("https://example.com/users/alice")), + ); + let _ = core.handle(received_follow( + follow, + "https://example.com/activities/accept/1", + )); + + let input = UserCreateNote { + note_id: iri("https://example.com/notes/1"), + create_id: iri("https://example.com/activities/create/1"), + actor: vocab::Reference::id(iri("https://example.com/users/alice")), + content: "Hello from Feder.".to_string(), + published: Some("2026-06-10T00:00:00Z".to_string()), + }; + + let result = core.handle(Input::UserCreateNote(input)); + + assert_eq!(result.actions.len(), 2); + let Action::StoreObject(store) = &result.actions[0] else { + panic!("expected StoreObject action"); + }; + let Object::Note(note) = &store.object; + assert_eq!(note.id, iri("https://example.com/notes/1")); + + let Action::SendActivity(send) = &result.actions[1] else { + panic!("expected SendActivity action"); + }; + assert_eq!(send.inbox, iri("https://remote.example/users/bob/inbox")); + + let Activity::CreateNote(create) = &send.activity else { + panic!("expected Create activity"); + }; + assert_eq!(create.id, iri("https://example.com/activities/create/1")); + assert_eq!( + create.actor, + vocab::Reference::id(iri("https://example.com/users/alice")) + ); + let vocab::Reference::Object(created_note) = &create.object else { + panic!("expected embedded Note object"); + }; + assert_eq!(created_note.id, iri("https://example.com/notes/1")); + } + + #[test] + fn user_create_note_emits_create_activity_for_each_known_delivery_target() { + let mut core = core(); + for (index, follower) in [ + "https://remote.example/users/bob", + "https://another.example/users/carol", + ] + .into_iter() + .enumerate() + { + let follow = vocab::Follow::new( + iri(&format!("https://example.com/activities/follow/{index}")), + vocab::Reference::object(actor(follower)), + vocab::Reference::id(iri("https://example.com/users/alice")), + ); + let _ = core.handle(received_follow( + follow, + &format!("https://example.com/activities/accept/{index}"), + )); + } + + let input = UserCreateNote { + note_id: iri("https://example.com/notes/1"), + create_id: iri("https://example.com/activities/create/1"), + actor: vocab::Reference::id(iri("https://example.com/users/alice")), + content: "Hello from Feder.".to_string(), + published: None, + }; + + let result = core.handle(Input::UserCreateNote(input)); + + assert_eq!(result.actions.len(), 3); + assert!(matches!(result.actions[0], Action::StoreObject(_))); + + let expected_inboxes = [ + iri("https://remote.example/users/bob/inbox"), + iri("https://another.example/users/carol/inbox"), + ]; + + for (action, expected_inbox) in result.actions[1..].iter().zip(expected_inboxes) { + let Action::SendActivity(send) = action else { + panic!("expected SendActivity action"); + }; + assert_eq!(send.inbox, expected_inbox); + assert!(matches!(send.activity, Activity::CreateNote(_))); + } } #[test] @@ -580,7 +696,7 @@ mod tests { let mut core = core(); let result = core.handle(Input::UserCreateNote(input)); - assert!(result.is_empty()); + assert_eq!(result.actions.len(), 1); let Object::Note(note) = &core.state().objects()[0]; assert_eq!( From 1335ebfe91ae3348132fae7894a914d5c8a7f417 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 17 Jun 2026 17:44:14 +0900 Subject: [PATCH 13/17] Add mocked core flow test Assisted-by: Codex:gpt-5.5 --- crates/feder-core/src/lib.rs | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 3e09a9c..6231ea0 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -680,6 +680,56 @@ mod tests { } } + #[test] + fn mocked_core_flow_accepts_follow_then_delivers_created_note() { + let mut core = core(); + let follow = vocab::Follow::new( + iri("https://remote.example/activities/follow/1"), + vocab::Reference::object(actor("https://remote.example/users/bob")), + vocab::Reference::id(iri("https://example.com/users/alice")), + ); + + let follow_result = core.handle(received_follow( + follow, + "https://example.com/activities/accept/1", + )); + + assert_eq!(follow_result.actions.len(), 2); + assert!(matches!(follow_result.actions[0], Action::StoreFollower(_))); + let Action::SendActivity(accept_delivery) = &follow_result.actions[1] else { + panic!("expected Accept delivery action"); + }; + assert_eq!( + accept_delivery.inbox, + iri("https://remote.example/users/bob/inbox") + ); + assert!(matches!(accept_delivery.activity, Activity::Accept(_))); + + let create_result = core.handle(Input::UserCreateNote(UserCreateNote { + note_id: iri("https://example.com/notes/1"), + create_id: iri("https://example.com/activities/create/1"), + actor: vocab::Reference::id(iri("https://example.com/users/alice")), + content: "Hello from Feder.".to_string(), + published: Some("2026-06-10T00:00:00Z".to_string()), + })); + + assert_eq!(create_result.actions.len(), 2); + assert!(matches!(create_result.actions[0], Action::StoreObject(_))); + let Action::SendActivity(create_delivery) = &create_result.actions[1] else { + panic!("expected Create delivery action"); + }; + assert_eq!( + create_delivery.inbox, + iri("https://remote.example/users/bob/inbox") + ); + assert!(matches!(create_delivery.activity, Activity::CreateNote(_))); + + assert_eq!(core.state().followers().len(), 1); + assert_eq!(core.state().delivery_targets().len(), 1); + assert_eq!(core.state().objects().len(), 1); + assert_eq!(core.state().activities().len(), 1); + } + #[test] fn user_create_note_normalizes_embedded_local_actor_to_local_actor_id() { let mut supplied_actor = actor("https://example.com/users/alice"); From 15ab953e145b8fe7b285784cbc36ff06644c0756 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 17 Jun 2026 22:30:55 +0900 Subject: [PATCH 14/17] Emit store follower only for new follows Assisted-by: Codex:gpt-5.5 --- crates/feder-core/src/lib.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 6231ea0..9550884 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -131,12 +131,12 @@ impl FederState { if !self.followers.contains(&relation) { self.followers.push(relation.clone()); - } - actions.push(Action::StoreFollower(StoreFollower { - follower: follow.actor.clone(), - following: follow.object.clone(), - })); + actions.push(Action::StoreFollower(StoreFollower { + follower: follow.actor.clone(), + following: follow.object.clone(), + })); + } let mut inbox = self .delivery_targets @@ -470,7 +470,14 @@ mod tests { )); assert_eq!(first_result.actions.len(), 2); - assert_eq!(second_result.actions.len(), 2); + assert_eq!(second_result.actions.len(), 1); + assert!(matches!( + second_result.actions[0], + Action::SendActivity(SendActivity { + activity: Activity::Accept(_), + .. + }) + )); assert_eq!( core.state().followers(), From fa97e1f7a54d8357ddc854ccef59a44b94fd510a Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 17 Jun 2026 23:31:25 +0900 Subject: [PATCH 15/17] Avoid phase-specific doc wording Assisted-by: Codex:gpt-5.5 --- crates/feder-core/src/lib.rs | 6 +++--- crates/feder-vocab/src/lib.rs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 9550884..c520208 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -58,7 +58,7 @@ impl FederConfig { } } -/// In-memory state used by Phase 1 core flows. +/// In-memory state used by portable core flows. #[derive(Clone, Debug, Eq, PartialEq)] pub struct FederState { local_actor: vocab::Actor, @@ -277,8 +277,8 @@ pub struct Follower { /// A known actor inbox for future delivery. /// -/// Phase 1 records this only when an incoming object embeds enough actor data -/// to expose an inbox. It does not imply every follower has been resolved. +/// Core records this only when an incoming object embeds enough actor data to +/// expose an inbox. It does not imply every follower has been resolved. #[derive(Clone, Debug, Eq, PartialEq)] pub struct DeliveryTarget { pub actor: vocab::Iri, diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index 0d7a516..f806167 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -20,7 +20,7 @@ pub type Iri = IriString; /// A non-scalar ActivityStreams property value. /// /// ActivityStreams object slots can contain either an embedded object or the -/// object's IRI. Phase 1 keeps both forms explicit and avoids dereferencing. +/// object's IRI. Feder keeps both forms explicit and avoids dereferencing. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(untagged)] pub enum Reference { @@ -167,7 +167,7 @@ pub enum ActorType { Service, } -/// A minimal ActivityPub actor for Phase 1 core tests. +/// A minimal ActivityPub actor. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct Actor { #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] From 10761850470c8fb2fb6b505fb3eb293940122f25 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 17 Jun 2026 23:46:11 +0900 Subject: [PATCH 16/17] Expose delivery target storage actions Assisted-by: Codex:gpt-5.5 --- crates/feder-core/src/lib.rs | 68 +++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 13 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index c520208..6940679 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -149,15 +149,26 @@ impl FederState { actor: follower, inbox: actor.inbox.clone(), }; + let mut should_store_target = false; if let Some(existing) = self .delivery_targets .iter_mut() .find(|existing| existing.actor == target.actor) { - existing.inbox = target.inbox; + if existing.inbox != target.inbox { + existing.inbox = target.inbox.clone(); + should_store_target = true; + } } else { - self.delivery_targets.push(target); + self.delivery_targets.push(target.clone()); + should_store_target = true; + } + + if should_store_target { + actions.push(Action::StoreDeliveryTarget(StoreDeliveryTarget { + target: target.clone(), + })); } inbox = Some(actor.inbox.clone()); @@ -290,6 +301,7 @@ pub struct DeliveryTarget { #[non_exhaustive] pub enum Action { StoreFollower(StoreFollower), + StoreDeliveryTarget(StoreDeliveryTarget), StoreObject(StoreObject), SendActivity(SendActivity), } @@ -300,6 +312,11 @@ pub struct StoreFollower { pub following: vocab::Reference, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StoreDeliveryTarget { + pub target: DeliveryTarget, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct StoreObject { pub object: Object, @@ -398,7 +415,7 @@ mod tests { "https://example.com/activities/accept/1", )); - assert_eq!(result.actions.len(), 2); + assert_eq!(result.actions.len(), 3); assert_eq!( core.state().followers(), &[Follower { @@ -420,8 +437,17 @@ mod tests { following: vocab::Reference::id(iri("https://example.com/users/alice")), }) ); + assert_eq!( + result.actions[1], + Action::StoreDeliveryTarget(StoreDeliveryTarget { + target: DeliveryTarget { + actor: iri("https://remote.example/users/bob"), + inbox: iri("https://remote.example/users/bob/inbox"), + }, + }) + ); - let Action::SendActivity(send) = &result.actions[1] else { + let Action::SendActivity(send) = &result.actions[2] else { panic!("expected SendActivity action"); }; assert_eq!(send.inbox, iri("https://remote.example/users/bob/inbox")); @@ -469,15 +495,27 @@ mod tests { "https://example.com/activities/accept/2", )); - assert_eq!(first_result.actions.len(), 2); - assert_eq!(second_result.actions.len(), 1); - assert!(matches!( + assert_eq!(first_result.actions.len(), 3); + assert_eq!(second_result.actions.len(), 2); + assert_eq!( second_result.actions[0], - Action::SendActivity(SendActivity { - activity: Activity::Accept(_), - .. + Action::StoreDeliveryTarget(StoreDeliveryTarget { + target: DeliveryTarget { + actor: iri("https://remote.example/users/bob"), + inbox: iri("https://remote.example/inboxes/bob"), + }, }) - )); + ); + + let Action::SendActivity(send) = &second_result.actions[1] else { + panic!("expected SendActivity action"); + }; + assert_eq!(send.inbox, iri("https://remote.example/inboxes/bob")); + + let Activity::Accept(accept) = &send.activity else { + panic!("expected Accept activity"); + }; + assert_eq!(accept.id, iri("https://example.com/activities/accept/2")); assert_eq!( core.state().followers(), @@ -701,9 +739,13 @@ mod tests { "https://example.com/activities/accept/1", )); - assert_eq!(follow_result.actions.len(), 2); + assert_eq!(follow_result.actions.len(), 3); assert!(matches!(follow_result.actions[0], Action::StoreFollower(_))); - let Action::SendActivity(accept_delivery) = &follow_result.actions[1] else { + assert!(matches!( + follow_result.actions[1], + Action::StoreDeliveryTarget(_) + )); + let Action::SendActivity(accept_delivery) = &follow_result.actions[2] else { panic!("expected Accept delivery action"); }; assert_eq!( From 9962e5e3b152375951da60fcf691e36ffaa47f71 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 17 Jun 2026 23:50:28 +0900 Subject: [PATCH 17/17] Trim follow handling clones Assisted-by: Codex:gpt-5.5 --- crates/feder-core/src/lib.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 6940679..8254263 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -111,11 +111,11 @@ impl FederState { fn record_follow(&mut self, input: ReceivedFollow) -> Vec { let follow = input.follow; - let Some(following) = reference_id(&follow.object).cloned() else { + let Some(following) = reference_id(&follow.object) else { return Vec::new(); }; - if following != self.local_actor.id { + if following != &self.local_actor.id { return Vec::new(); } @@ -125,7 +125,7 @@ impl FederState { let relation = Follower { follower: follower.clone(), - following, + following: following.clone(), }; let mut actions = Vec::new(); @@ -166,9 +166,7 @@ impl FederState { } if should_store_target { - actions.push(Action::StoreDeliveryTarget(StoreDeliveryTarget { - target: target.clone(), - })); + actions.push(Action::StoreDeliveryTarget(StoreDeliveryTarget { target })); } inbox = Some(actor.inbox.clone());