Make an aggregate's own events a first-class mode

An aggregate with no default_sink already left events created in it alone, but
treated doing so as a failure: it reported a skip per event per cycle saying no
sink was configured, as though something had gone wrong. Nothing had. An
aggregate is also a calendar, and holding events of its own is a legitimate way
to use one.

Skipped::NoSink is replaced by a kept_local count, reported plainly. `@local`
joins the routing markers, so the mode also works per-event where a
default_sink is configured — which was not previously expressible. Unlike every
other marker it is deliberately not stripped: the others have done their job
once the event reaches its source, whereas this one never leaves, so it has to
stay legible for the next cycle to reach the same decision. `local` is
therefore a reserved endpoint id, and configuring one is refused.

This also fixes a real defect. `retarget` rebuilds the new target from the
recorded links, which cover derived events only, so an event belonging to the
aggregate itself did not follow the move — it stayed on the calendar being left
behind while everything around it moved on, quietly. It is now carried across,
since there is nothing to re-derive it from, and the new target's scheduling
rule is applied on the way: this is a write to an aggregate like any other, and
a guest list carried live onto a server that schedules would mail everyone on
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-09-10 14:30:25 +03:00
parent 7607078394
commit 47ad8b4c47
9 changed files with 298 additions and 26 deletions

View file

@ -30,6 +30,15 @@ const OWN_PROPERTIES: &[&str] = &[SOURCE_PROPERTY, ORIGIN_UID_PROPERTY, ATTENDEE
/// Live scheduling properties, whose presence is what makes a server send mail.
const SCHEDULING_PROPERTIES: &[&str] = &["ATTENDEE", "ORGANIZER"];
/// The marker that keeps an event in the aggregate rather than filing it under
/// a source. Reserved: an endpoint may not be given this id.
pub const LOCAL_SINK: &str = "local";
/// Whether a routing hint asks for the event to stay where it is.
pub fn is_local_sink(hint: &str) -> bool {
hint.eq_ignore_ascii_case(LOCAL_SINK)
}
/// Builds the aggregate copy of a source event.
pub fn to_aggregate(
source: &Calendar,
@ -52,6 +61,24 @@ pub fn to_aggregate(
mirrored
}
/// Applies a target's scheduling rule to an event that is not a mirror.
///
/// An event living only in the aggregate still has to obey the rule that writes
/// to an aggregate never emit scheduling mail — moving one between calendars is
/// as capable of mailing a guest list as mirroring is. It carries no provenance,
/// having none, so this is the demotion alone.
pub fn make_inert(
calendar: &Calendar,
owner: Option<&str>,
suppression: SchedulingSuppression,
) -> Calendar {
let mut inert = calendar.clone();
if suppression == SchedulingSuppression::None {
demote_attendees(&mut inert, owner);
}
inert
}
/// Removes the live guest list, keeping its information in inert form.
fn demote_attendees(calendar: &mut Calendar, owner: Option<&str>) {
let guests: Vec<String> = calendar