open-standards/smart-byte/smart-byte-rs/crates/smart-byte-activitypub/src/outbox.rs
Transaction Science fdc9cf6ab0 ingest wave C: 3 crates · op-statements + smart-byte-activitypub + eoc-observability
OpenPay (1 crate, ~2940 LOC, 40 tests)
  op-statements         Statement generation + reconciliation + fee accounting.
                        Render targets: PDF (template), CSV, JSON, 94-char
                        NACHA fixed-width. ISO 20022 camt.053 with full
                        BkToCstmrStmt envelope (GrpHdr/Stmt/Bal/Ntry). BAI2
                        with 01/02/03/16/49/98/99 records. MT940/MT942
                        SWIFT with :20:/:25:/:28C:/:60F:/:61:/:86:/:62F:
                        + :90C:/:90D: counters. FeeBucket (interchange/scheme/
                        acquirer/fx/settlement-network/bnpl) + FeeSchedule
                        with bps + flat rules. Cadence (daily/weekly/monthly/
                        custom). Two-tier Reconciler (external_id → window
                        heuristic).

Smart Byte (1 crate, 3032 LOC, 39 tests)
  smart-byte-activitypub ActivityPub W3C Rec (2018). AS2 vocab + Public
                         addressing; Actor (Person/Application/Group/
                         Organization/Service) with publicKey + shared-inbox;
                         Object (Note/Article/Image/Video/Tombstone) with
                         Mastodon extensions (sensitive, CW summary,
                         conversation, blurhash); 12 Activity types
                         (Create/Update/Delete/Follow/Accept/Reject/Announce/
                         Like/Undo/Move/Block/Flag) with side-effect rules;
                         OrderedCollection + Page; Webfinger (RFC 7033);
                         HTTP Signatures (cavage draft-12 + RFC 9421);
                         SHA-256 Digest; Ed25519 sign/verify.

EOC (1 crate, 2759 LOC, 20 tests)
  eoc-observability     OpenTelemetry-compatible tracing + metrics + logs.
                        W3C tracecontext + baggage. Span / SpanKind / Status.
                        Samplers: AlwaysOn/Off/RatioBased/ParentBased. Sync
                        BatchSpanProcessor + Exporter trait. Counter / Gauge /
                        Histogram. Prometheus text exposition (HELP/TYPE/
                        _bucket/_sum/_count/+Inf). StatsD / DogStatsD lines.
                        GenAI semconv (gen_ai.{operation,system,request.*,
                        response.*,usage.*}) + EOC extensions (eoc.joules.*,
                        eoc.co2e_grams, eoc.cost.usd). USD + gCO2e attribution
                        with PUE. LangSmith + LangFuse export adapters.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 13:51:09 -04:00

165 lines
5.5 KiB
Rust

//! Outbox — local publication + delivery queue.
//!
//! When a local actor publishes a Create / Follow / Announce / Like /
//! etc, the activity is appended to the outbox and queued for delivery
//! to the audience computed from [`crate::addressing`].
//!
//! Delivery is intentionally in-memory and synchronous here. A
//! production server replaces [`DeliveryQueue::flush`] with HTTP POSTs
//! signed via [`crate::http_sig`]; the planning surface is identical.
use crate::activity::Activity;
use crate::addressing::{resolve_audience, strip_bcc, CollectionResolver, ResolvedAudience};
use crate::error::Result;
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
/// A single queued delivery.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PendingDelivery {
/// The fully-prepared Activity body (bcc stripped, signing pending).
pub body: Activity,
/// Inbox IRIs that still need a successful POST.
pub inboxes: Vec<String>,
}
/// In-memory delivery queue + outbox log.
#[derive(Debug, Default, Clone)]
pub struct Outbox {
/// Append-only log of activities the local actor has published.
pub log: Vec<Activity>,
/// Queue of pending deliveries.
pub queue: VecDeque<PendingDelivery>,
}
impl Outbox {
/// Fresh empty outbox.
pub fn new() -> Self {
Self::default()
}
/// Publish an Activity locally — append to the log and enqueue
/// deliveries for every resolved inbox.
pub fn publish<R: CollectionResolver>(
&mut self,
activity: Activity,
resolver: &R,
actor_inboxes: &dyn Fn(&str) -> Option<String>,
) -> Result<ResolvedAudience> {
self.log.push(activity.clone());
let audience = resolve_audience(&activity, resolver);
let body = strip_bcc(&activity);
let mut inboxes: Vec<String> = audience
.actors
.iter()
.filter_map(|a| actor_inboxes(a))
.collect();
inboxes.sort();
inboxes.dedup();
if !inboxes.is_empty() {
self.queue.push_back(PendingDelivery { body, inboxes });
}
Ok(audience)
}
/// Pop the next pending delivery, or `None` if the queue is empty.
pub fn next(&mut self) -> Option<PendingDelivery> {
self.queue.pop_front()
}
/// Drain the queue by invoking the supplied closure on each
/// delivery. The closure returns the set of inboxes that *failed*;
/// those are re-enqueued for retry.
pub fn flush<F>(&mut self, mut deliver: F)
where
F: FnMut(&Activity, &str) -> bool,
{
let mut retries = VecDeque::new();
while let Some(d) = self.queue.pop_front() {
let mut failed = Vec::new();
for inbox in &d.inboxes {
if !deliver(&d.body, inbox) {
failed.push(inbox.clone());
}
}
if !failed.is_empty() {
retries.push_back(PendingDelivery {
body: d.body,
inboxes: failed,
});
}
}
self.queue = retries;
}
/// Number of activities the local actor has published.
pub fn total_items(&self) -> u64 {
self.log.len() as u64
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::addressing::StaticResolver;
use crate::object::Object;
#[test]
fn publish_enqueues_for_each_inbox() -> Result<()> {
let mut ob = Outbox::new();
let mut resolver = StaticResolver::new();
resolver.insert(
"https://a.test/users/alice/followers",
vec![
"https://b.test/users/bob".into(),
"https://c.test/users/carol".into(),
],
);
let note = Object::note(
"https://a.test/notes/1",
"https://a.test/users/alice",
"hi",
)
.cc("https://a.test/users/alice/followers");
let create = Activity::create(
"https://a.test/activities/c",
"https://a.test/users/alice",
note,
);
ob.publish(create, &resolver, &|actor| match actor {
"https://b.test/users/bob" => Some("https://b.test/users/bob/inbox".into()),
"https://c.test/users/carol" => Some("https://c.test/users/carol/inbox".into()),
_ => None,
})?;
assert_eq!(ob.queue.len(), 1);
let d = ob.next().expect("one delivery");
assert_eq!(d.inboxes.len(), 2);
Ok(())
}
#[test]
fn flush_retries_failed_inboxes() -> Result<()> {
let mut ob = Outbox::new();
let resolver = StaticResolver::new();
let note = Object::note(
"https://a.test/notes/1",
"https://a.test/users/alice",
"hi",
);
let mut create = Activity::create(
"https://a.test/activities/c",
"https://a.test/users/alice",
note,
);
create.to.push("https://b.test/users/bob".into());
create.to.push("https://c.test/users/carol".into());
ob.publish(create, &resolver, &|actor| match actor {
"https://b.test/users/bob" => Some("https://b.test/users/bob/inbox".into()),
"https://c.test/users/carol" => Some("https://c.test/users/carol/inbox".into()),
_ => None,
})?;
ob.flush(|_a, inbox| inbox.contains("bob")); // only bob succeeds
assert_eq!(ob.queue.len(), 1, "carol should be queued for retry");
Ok(())
}
}